require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22], true), 'Unsupported document schema.'); $this->text($document['title'] ?? null, 160); $this->text($document['description'] ?? null, 320); $this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.'); $this->require(strlen(json_encode($document, JSON_THROW_ON_ERROR)) <= 262144, 'Document exceeds 256 KiB.'); $ids = []; $headings = 0; foreach ($document['blocks'] as $block) { $this->block($block, $ids, $headings, 0, $schemaVersion, null); } $anchors = function (array $blocks) use (&$anchors, &$ids): void { foreach ($blocks as $block) { if (isset($block['anchor'])) { $this->require(!isset($ids[$block['anchor']]) && !in_array($block['anchor'], ['main', 'top', 'admin-main'], true), 'Dieses Sprungziel ist bereits belegt. Bitte einen eindeutigen Namen wählen.'); $ids[$block['anchor']] = true; } $anchors($block['children'] ?? []); } }; $anchors($document['blocks']); $this->require( $requireHeading ? 1 === $headings : 0 === $headings, $requireHeading ? 'A page requires exactly one main heading.' : 'A shared area must not contain a main heading.', ); } private function block(mixed $block, array &$ids, int &$headings, int $depth, int $schemaVersion, ?string $parentType): void { $this->require(is_array($block) && $depth <= 10 && count($ids) < 300, 'Invalid block or document complexity limit exceeded.'); $id = $block['id'] ?? null; $this->require(is_string($id) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $id) && !isset($ids[$id]), 'Block IDs must be stable and unique.'); $ids[$id] = true; $type = $block['type'] ?? null; if (isset($block['anchor'])) { $this->require($schemaVersion >= 21 && in_array($type, ['section', 'row', 'column'], true) && is_string($block['anchor']) && 1 === preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/D', $block['anchor']), 'Ein Sprungziel braucht einen Bereich und einen Namen wie kontakt (ohne #), Schema v21.'); } // From schema v4 on, a namespaced type belongs to a plugin. The core does not know its // property schema, so it keeps the block untouched instead of rejecting or dropping the // page: a deactivated plugin must never make stored content unreadable. Nothing from // props is rendered for such a block, so unvalidated values never reach the output. $plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type); $this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.'); // An accordion entry only ever renders inside an accordion; at the top level or anywhere // else it would produce a details element without its group. $this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.'); $this->require('tabitem' !== $type || 'tabs' === $parentType, 'A tab belongs into a tab group.'); foreach (['css', 'script'] as $own) { if (!isset($block[$own])) { continue; } // Own code is checked when it is stored, so a bad value fails with a message instead // of disappearing silently at render time. Who may set a script is a question of role // and is answered in the controller, not here. $this->require(CustomCode::supports($schemaVersion), 'Own code requires schema v13.'); $this->require(is_string($block[$own]), sprintf('"%s" must be text.', $own)); 'css' === $own ? $this->code->checkCss($block[$own]) : $this->code->checkScript($block[$own]); } if (isset($block['style'])) { // Free styling: any property of StyleSchema, per device, with an optional hover value. // The old token based layout.design keeps working unchanged next to it. $this->require($schemaVersion >= 10, 'Free block styles require schema v10.'); $this->styles->validate($block['style']); } $props = $block['props'] ?? []; $layout = $block['layout'] ?? []; $this->require(is_array($props) && is_array($layout), 'Props and layout must be objects.'); foreach ($layout as $key => $value) { if ('visibility' === $key) { $this->require($schemaVersion >= 22 && in_array($value, ['all', 'desktop', 'tablet', 'mobile', 'hide-desktop', 'hide-tablet', 'hide-mobile', 'hidden'], true), 'Unbekannte Sichtbarkeit oder Dokumentschema vor v22.'); continue; } if ('button' === $key) { $this->require($schemaVersion >= 21 && 'button' === $type && is_array($value), 'Button-Varianten brauchen einen Button und Schema v21.'); foreach ($value as $device => $settings) { $this->require(isset(StyleSchema::DEVICES[$device]) && is_array($settings), 'Unbekanntes Button-Gerät.'); foreach ($settings as $setting => $token) { $this->require(is_string($token) && isset(ButtonDesign::OPTIONS[$setting][$token]), 'Unbekannte Button-Variante.'); } } continue; } if ('effects' === $key && $schemaVersion >= 3) { $this->require(is_array($value), 'Effects must be an object.'); $effects = [ 'shadow' => ['none', 'soft', 'strong'], 'hover' => ['none', 'lift', 'zoom', 'sink', 'glow', 'tilt', 'grow-border'], // Entrance animations run when the page loads, not when the element scrolls // into view: that would need a script, and a public page runs none. 'entrance' => ['none', 'fade', 'rise', 'sink', 'left', 'right', 'zoom', 'flip', 'blur'], 'delay' => ['none', 'short', 'medium', 'long'], ]; foreach ($value as $effect => $token) { $this->require(isset($effects[$effect]) && in_array($token, $effects[$effect], true), 'Unknown effect.'); } continue; } if ('design' === $key && $schemaVersion >= 2) { $this->design($value, $type); continue; } $this->require(in_array($key, ['tone', 'width'], true), 'Unknown layout setting.'); $this->require(in_array($value, 'tone' === $key ? ['paper', 'ink', 'mint'] : ['normal', 'narrow', 'wide', 'full'], true), 'Invalid design token.'); } if (in_array($type, ['heading', 'text', 'button'], true)) { // From v12 a text block may carry the markup RichText allows, which needs more room. // From v17 a heading may too - one highlighted word is a design need, not a luxury. $limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : ('heading' === $type && RichText::headings($schemaVersion) ? 2000 : 300); $this->text($props['text'] ?? null, $limit); } if (!$plugin && array_key_exists('newTab', $props)) { $this->require($schemaVersion >= 20 && in_array($type, ['button', 'image', 'iconcard', 'cta', 'icon'], true), 'Neue Link-Tabs brauchen Schema v20 und einen verlinkbaren Block.'); $this->require(is_bool($props['newTab']), 'In neuem Tab öffnen ist an oder aus.'); } foreach (($block['style'] ?? []) as $values) { if ('none' === ($values['backgroundImage'] ?? null)) { $this->require($schemaVersion >= 20, 'Ein entferntes Hintergrundbild braucht Schema v20.'); } } if ('heading' === $type) { $this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.'); $headings += 1 === $props['level'] ? 1 : 0; } if ('postnav' === $type) { // Nur die Beschriftungen sind Inhalt. Welche Beiträge es sind, entscheidet der // Renderer beim Ausliefern - gespeicherter Inhalt kann keinen Beitrag behaupten. $this->text($props['previousLabel'] ?? null, 60); $this->text($props['nextLabel'] ?? null, 60); } if ('postlist' === $type) { // The list is filled by the renderer, never by stored content: the block only says // how many posts to show and which term to limit them to. $this->require(is_int($props['count'] ?? null) && $props['count'] >= 1 && $props['count'] <= 24, 'Die Anzahl der Beiträge liegt zwischen 1 und 24.'); foreach (['category', 'tag'] as $kind) { if (isset($props[$kind]) && '' !== $props[$kind]) { $this->require(is_string($props[$kind]) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props[$kind]) && strlen($props[$kind]) <= 80, 'Ungültiger Filter für die Beitragsliste.'); } } // Was eine Karte zeigt. Ein Projektraster besteht aus Bild und Titel; Datum, Auszug // und Kategorie gehören in ein Blogarchiv und lassen eine Projektseite falsch // aussehen. Voreinstellung ist der alte Zustand, damit bestehende Seiten sich nicht // ändern, wenn niemand etwas einstellt. if (isset($props['layout'])) { // Drei Darstellungen für dieselben Daten: Raster aus Karten, Liste mit Bild // daneben, und eine reine Titelliste. Welche passt, hängt vom Ort ab - ein // Projektraster auf der Startseite, eine Liste im Archiv, eine Titelliste in // einer Randspalte. $this->require($schemaVersion >= 18, 'Die Darstellung der Beitragsliste braucht Schema v18.'); $this->require(in_array($props['layout'], ['karten', 'liste', 'kompakt', 'wechsel'], true), 'Unbekannte Darstellung der Beitragsliste.'); } if (isset($props['paginate'])) { // Eine Auswahl zeigt die ersten n und hört auf. Eine Übersicht blättert - sonst // verschweigt sie, dass es mehr gibt. Das ist die eigene Regel aus Arbeitspaket M. $this->require($schemaVersion >= 17, 'Das Blättern einer Beitragsliste braucht Schema v17.'); $this->require(is_bool($props['paginate']), 'Das Blättern ist ja oder nein.'); } foreach (['showMeta', 'showExcerpt', 'showTerms'] as $switch) { if (isset($props[$switch])) { $this->require($schemaVersion >= 17, 'Die Anzeigeoptionen der Beitragsliste brauchen Schema v17.'); $this->require(is_bool($props[$switch]), sprintf('"%s" ist ja oder nein.', $switch)); } } } if ('button' === $type) { $this->url($props['href'] ?? null); } if ('iconcard' === $type) { $this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.'); $this->text($props['title'] ?? null, 120); $this->text($props['text'] ?? null, 600, true); if ('' !== ($props['href'] ?? '')) { $this->url($props['href']); } } if ('stat' === $type) { $this->text($props['value'] ?? null, 16); $this->text($props['label'] ?? null, 80); } if ('quote' === $type) { $this->text($props['text'] ?? null, 1000); $this->text($props['author'] ?? null, 120); $this->text($props['role'] ?? null, 120, true); $rating = $props['rating'] ?? 0; $this->require(is_int($rating) && $rating >= 0 && $rating <= 5, 'A rating is a whole number between 0 and 5.'); } if ('cta' === $type) { $this->text($props['title'] ?? null, 160); $this->text($props['text'] ?? null, 600, true); $this->text($props['buttonText'] ?? null, 80); $this->url($props['href'] ?? null); } if ('contact' === $type) { foreach (['phone', 'email', 'address'] as $field) { $this->text($props[$field] ?? null, 'address' === $field ? 200 : 80, true); } $this->require('' !== trim($props['phone'].$props['email'].$props['address']), 'A contact block needs at least one detail.'); if ('' !== $props['phone']) { $this->require(1 === preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $props['phone']), 'Invalid phone number.'); } if ('' !== $props['email']) { $this->require(false !== filter_var($props['email'], FILTER_VALIDATE_EMAIL), 'Invalid email address.'); } } if ('spacer' === $type) { $this->require(in_array($props['size'] ?? null, self::SPACER_SIZES, true), 'Unknown spacer size.'); } if ('accordionitem' === $type) { $this->text($props['title'] ?? null, 160); $this->require(is_bool($props['open'] ?? false), 'The open state is a boolean.'); } if ('tabitem' === $type) { $this->text($props['title'] ?? null, 160); } if ('menu' === $type) { // The entries live in the menu administration; the block only says which slot to show // and how to lay it out. $this->require(isset(MenuRepository::SLOTS[$props['slot'] ?? '']), 'Unknown menu slot.'); if (array_key_exists('collapse', $props)) { $this->require($schemaVersion >= 22 && in_array($props['collapse'], ['tablet', 'mobile', 'never'], true), 'Unbekannte Hamburger-Umschaltung oder Dokumentschema vor v22.'); } if (isset($props['layout'])) { $this->require($schemaVersion >= 16, 'Die Menüart braucht Schema v16.'); $this->require(in_array($props['layout'], ['horizontal', 'vertical', 'burger'], true), 'Unbekannte Menüart.'); } } if ('partialref' === $type) { // Only the address of the section is content. Its blocks live in the partial, so one // change reaches every page that links it and a page cannot alter a shared section. $this->require(is_string($props['partial'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['partial']) && strlen($props['partial']) <= 80, 'Invalid section address.'); } if ('icon' === $type) { $this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.'); // A fixed list, because the size becomes a class: a value without a class would leave // an icon with no dimensions at all, and an SVG without dimensions is invisible. $this->require(in_array($props['size'] ?? null, self::ICON_SIZES, true), sprintf('Die Symbolgröße ist eine aus %s.', implode(', ', self::ICON_SIZES))); // A decorative icon is hidden from assistive technology; one that carries meaning // needs a label, or a screen reader announces nothing at all. $this->require(is_bool($props['decorative'] ?? null), 'Der Zweck des Symbols ist anzugeben.'); $this->text($props['label'] ?? null, 120, $props['decorative']); $this->require(!$props['decorative'] || '' === $props['label'], 'Ein dekoratives Symbol hat keine Beschriftung.'); if ('' !== ($props['href'] ?? '')) { $this->url($props['href']); } } if ('progress' === $type) { $this->text($props['label'] ?? null, 120); $this->require(is_int($props['value'] ?? null) && $props['value'] >= 0 && $props['value'] <= 100, 'Der Wert liegt zwischen 0 und 100.'); $this->require(is_bool($props['showValue'] ?? null), 'Die Anzeige des Werts ist anzugeben.'); } if ('slider' === $type) { // The slider is a scroll container: how many slides are visible per device is the // only thing it stores, the slides themselves are ordinary blocks. foreach (['desktop', 'tablet', 'mobile'] as $device) { $key = $device.'Visible'; $this->require(is_int($props[$key] ?? null) && $props[$key] >= 1 && $props[$key] <= 6, sprintf('"%s" liegt zwischen 1 und 6.', $key)); } $this->require(is_bool($props['snap'] ?? null), 'Das Einrasten ist anzugeben.'); if (isset($props['autoplay'])) { // Sekunden bis zur nächsten Folie. 0 heißt aus, und unter drei Sekunden wäre es // keine Hilfe mehr, sondern Hektik. $this->require($schemaVersion >= 17, 'Das automatische Weiterblättern braucht Schema v17.'); $this->require(is_int($props['autoplay']) && (0 === $props['autoplay'] || ($props['autoplay'] >= 3 && $props['autoplay'] <= 30)), 'Das Weiterblättern liegt bei 0 (aus) oder 3 bis 30 Sekunden.'); } if (isset($props['dots'])) { // Punkte und Pfeile zeichnet der Browser aus dem Scrollbereich selbst; das // geht erst ab v17, weil es vorher keine Eigenschaft dafür gab. $this->require($schemaVersion >= 17, 'Punkte am Slider brauchen Schema v17.'); $this->require(is_bool($props['dots']), 'Die Punkte sind ja oder nein.'); } } if ('embed' === $type) { // Everything about the embed is validated here, because the address ends up in an // iframe and in the content security policy of the page. $this->require(isset(Consent::GROUPS[$props['group'] ?? '']), 'Unbekannte Einwilligungsgruppe.'); // The title is what a screen reader announces for the frame, so it is not optional. $this->text($props['title'] ?? null, 160); $this->text($props['provider'] ?? null, 80); $this->text($props['hint'] ?? null, 300, true); $url = $props['url'] ?? null; $this->require(is_string($url) && strlen($url) <= 400 && str_starts_with($url, 'https://'), 'Ein eingebetteter Inhalt braucht eine HTTPS-Adresse.'); $host = parse_url($url, PHP_URL_HOST); // The host becomes part of frame-src, so it must be a plain name - no credentials, no // port, nothing that could smuggle a second source into the policy. $this->require(is_string($host) && 1 === preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/D', $host), 'Die Adresse des eingebetteten Inhalts braucht einen einfachen Hostnamen.'); $this->require(null === parse_url($url, PHP_URL_PORT) && null === parse_url($url, PHP_URL_USER), 'Die Adresse darf keinen Port und keine Zugangsdaten enthalten.'); $this->require(is_int($props['height'] ?? null) && $props['height'] >= 150 && $props['height'] <= 1200, 'Die Höhe liegt zwischen 150 und 1200 Pixeln.'); } if ('form' === $type) { // Only the address is content. Fields, recipient and consent text stay in the database, // so a stored document can never redirect a submission or change what is asked. $this->require(is_string($props['form'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['form']) && strlen($props['form']) <= 80, 'Invalid form address.'); } if ('gallery' === $type && isset($props['lightbox'])) { $this->require($schemaVersion >= 19, 'Der Bildbetrachter braucht Schema v19.'); $this->require(is_bool($props['lightbox']), 'Der Bildbetrachter ist an oder aus.'); } if ('image' === $type) { $this->url($props['src'] ?? null); // Ein Bild darf verlinken. Klingt selbstverstaendlich, fehlte aber: ein Logo im // Kopfbereich ohne Verweis auf die Startseite ist der erste Klick, den jeder sucht. if ('' !== ($props['href'] ?? '')) { $this->require($schemaVersion >= 17, 'Ein verlinktes Bild braucht Schema v17.'); $this->url($props['href']); } // Was ein Klick auf das Bild tut. Ohne Angabe: nichts - wie bisher. if (isset($props['click'])) { $this->require($schemaVersion >= 19, 'Die Klickwirkung eines Bildes braucht Schema v19.'); $this->require(in_array($props['click'], ['keine', 'vergroessern', 'link'], true), 'Für den Klick sind „keine", „vergroessern" und „link" vorgesehen.'); $this->require('link' !== $props['click'] || '' !== ($props['href'] ?? ''), 'Ein Bild, das auf eine Adresse zeigen soll, braucht eine.'); } $this->require(is_bool($props['decorative'] ?? null), 'Image purpose is required.'); $this->text($props['alt'] ?? null, 300, $props['decorative']); $this->require(!$props['decorative'] || '' === $props['alt'], 'Decorative images need empty alternative text.'); foreach (['width', 'height'] as $dimension) { $this->require(is_int($props[$dimension] ?? null) && $props[$dimension] > 0 && $props[$dimension] <= 8000, 'Image dimensions are required.'); } foreach (['caption', 'mediaId', 'sources'] as $extra) { if (isset($props[$extra])) { $this->require($schemaVersion >= 3, 'Media properties require schema v3.'); } } if (isset($props['caption'])) { $this->text($props['caption'], 500, true); } if (isset($props['mediaId'])) { $this->require(is_string($props['mediaId']) && 1 === preg_match('/^[a-f0-9]{32}$/D', $props['mediaId']), 'Invalid media ID.'); } if (isset($props['sources'])) { // As many entries as the media library produces widths, not one more: the list ends // up in a srcset, and the number has to move with the pipeline instead of standing // here as a number nobody connects to it. $this->require(is_array($props['sources']) && array_is_list($props['sources']) && count($props['sources']) <= count(MediaLibrary::WIDTHS), 'Invalid image variants.'); $widths = []; foreach ($props['sources'] as $source) { $this->require(is_array($source) && is_int($source['width'] ?? null) && $source['width'] > 0 && $source['width'] <= 8000 && !in_array($source['width'], $widths, true), 'Invalid image variant width.'); $this->url($source['src'] ?? null); $this->require(!str_contains($source['src'], ','), 'Invalid srcset URL.'); $widths[] = $source['width']; } } } $children = $block['children'] ?? []; $this->require(is_array($children) && array_is_list($children), 'Children must be a list.'); // A plugin block may nest content; keeping its children is part of preserving the page. $container = $plugin || in_array($type, ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'], true); $this->require($container || [] === $children, 'Leaf blocks cannot contain children.'); // Containers that only accept one kind of child, so the rendered markup stays valid. $only = ['gallery' => 'image', 'logos' => 'image', 'accordion' => 'accordionitem', 'tabs' => 'tabitem']; foreach ($children as $child) { if (isset($only[$type])) { $this->require(is_array($child) && $only[$type] === ($child['type'] ?? null), sprintf('A %s only contains %s blocks.', $type, $only[$type])); } $this->block($child, $ids, $headings, $depth + 1, $schemaVersion, $type); } } private function design(mixed $design, string $type): void { static $tokens = null; $tokens ??= json_decode(file_get_contents(__DIR__.'/../../public/assets/design-tokens.json'), true, 512, JSON_THROW_ON_ERROR); $this->require(is_array($design), 'Design must be an object.'); foreach ($design as $device => $settings) { $this->require(in_array($device, ['desktop', 'tablet', 'mobile'], true) && is_array($settings), 'Invalid responsive device.'); foreach ($settings as $key => $value) { $this->require(isset($tokens[$key]) && in_array($type, $tokens[$key]['types'], true), 'Design setting does not apply to this block.'); $this->require(is_string($value) && isset($tokens[$key]['values'][$value]), 'Invalid responsive design token.'); } } } private function text(mixed $value, int $max, bool $emptyAllowed = false): void { $this->require(is_string($value) && strlen($value) <= $max && ($emptyAllowed || '' !== trim($value)), 'Missing or oversized text.'); } private function url(mixed $url): void { $this->require(is_string($url) && strlen($url) <= 2048, 'Invalid URL.'); $this->require(!preg_match('/[\x00-\x20\\\\]/', $url), 'Unsafe URL characters.'); $local = str_starts_with($url, '/') && !str_starts_with($url, '//'); $external = str_starts_with($url, 'https://') && false !== filter_var($url, FILTER_VALIDATE_URL); $this->require($local || $external || 1 === preg_match('/^#[a-zA-Z][a-zA-Z0-9_-]*$/D', $url), 'Only local paths, fragments and HTTPS URLs are allowed.'); } private function require(bool $condition, string $message): void { if (!$condition) { throw new InvalidDocument($message); } } }