The open burger menu now closes when someone clicks elsewhere, without a script: an invisible layer that belongs to the menu button covers the page while the menu is open. Facebook, Instagram and WhatsApp join the icon set, so menu buttons can carry them. The stroke attributes of every icon move into the stylesheet, which saves about 4 KB per page. Hüfner gets a right-aligned menu with the quote button, Facebook and Instagram as menu buttons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
618 lines
43 KiB
PHP
618 lines
43 KiB
PHP
<?php
|
|
|
|
namespace App\Content;
|
|
|
|
use App\Domain\InvalidDocument;
|
|
use App\Plugin\PluginRegistry;
|
|
|
|
final class DocumentValidator
|
|
{
|
|
private bool $writing = false;
|
|
|
|
public function __construct(private StyleCompiler $styles = new StyleCompiler(), private CustomCode $code = new CustomCode(), private ?PluginRegistry $plugins = null)
|
|
{
|
|
}
|
|
|
|
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */
|
|
public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'];
|
|
/** Icons ship as inline SVG from this fixed list; no icon font and no external request. */
|
|
public const ICONS = ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus', 'phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support', 'shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase', 'document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book', 'user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star', 'home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car', 'check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design', 'facebook', 'instagram', 'whatsapp'];
|
|
/** Das Symbol eines Buttons, wenn keins gewählt ist: der Pfeil, den jeder Button vor v25 hatte. */
|
|
public const BUTTON_DEFAULT_ICON = 'arrow-up-right';
|
|
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
|
|
/** Schema v7: tabs, the one pattern that needs a script to become a real widget. */
|
|
public const V7_TYPES = ['tabs', 'tabitem'];
|
|
/** Schema v8: a form block references a form by its address; the definition lives in the database. */
|
|
public const V8_TYPES = ['form'];
|
|
/** Schema v9: the breadcrumb trail, built from the page hierarchy rather than from content. */
|
|
public const V9_TYPES = ['breadcrumb'];
|
|
/** Schema v11: a link to a reusable section, resolved when the page is served. */
|
|
public const V11_TYPES = ['partialref', 'menu'];
|
|
/** Schema v14: content from a third party, which stays unloaded until consent is given. */
|
|
public const V14_TYPES = ['embed'];
|
|
/** The sizes an icon can have; each one has a class in site.css. */
|
|
public const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
|
|
/** Schema v18: die Navigation zwischen Beiträgen, vom Renderer gefüllt. */
|
|
public const V18_TYPES = ['postnav'];
|
|
/** Die Rechtszeile als Baustein: gestaltbar und dort, wo sie hingehoert. */
|
|
public const V19_TYPES = ['legal'];
|
|
/** Schema v15: a single icon, a progress bar and a slider that works without a script. */
|
|
public const V15_TYPES = ['icon', 'progress', 'slider'];
|
|
/** Schema v23: die sozialen Profile aus den Einstellungen, als Symbolreihe an jeder Stelle. */
|
|
public const V23_TYPES = ['social'];
|
|
public const V24_TYPES = ['html'];
|
|
/** Schema v27 (ADR 111): die Sprachwahl, gefüllt aus den Sprachen, in denen die Seite vorliegt. */
|
|
public const V27_TYPES = ['language'];
|
|
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
|
|
public const SCHEMA_VERSION = 27;
|
|
|
|
/**
|
|
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
|
|
* reusable section that appears on many pages must not bring one
|
|
* of its own, or every page carrying it would have two.
|
|
* @param bool $writing Beim Schreiben werden die Eigenschaften von Plugin-Bausteinen
|
|
* gegen ihr Schema geprüft. Beim Lesen nicht: ein Plugin, das
|
|
* nach dem Speichern eingeschaltet oder geändert wurde, darf
|
|
* eine gespeicherte Seite nicht unlesbar machen - der Renderer
|
|
* lässt unpassende Eigenschaften ohnehin nicht durch.
|
|
*/
|
|
public function validate(array $document, bool $requireHeading = true, bool $writing = false): void
|
|
{
|
|
$this->writing = $writing;
|
|
$schemaVersion = $document['schemaVersion'] ?? null;
|
|
$this->require(is_int($schemaVersion) && $schemaVersion >= 1 && $schemaVersion <= self::SCHEMA_VERSION, '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.');
|
|
// Die Grenze gilt für die Seite selbst; jede Übersetzung bringt ihre Texte noch einmal mit
|
|
// und bekommt deshalb insgesamt Platz für bis zu drei weitere Fassungen.
|
|
$this->require(strlen(json_encode(Translations::strip($document), JSON_THROW_ON_ERROR)) <= 262144, 'Document exceeds 256 KiB.');
|
|
$this->require(strlen(json_encode($document, JSON_THROW_ON_ERROR)) <= 1048576, 'Die Seite ist mit ihren Übersetzungen größer als 1 MiB.');
|
|
$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.',
|
|
);
|
|
if (array_key_exists('translations', $document)) {
|
|
$this->translations($document, $requireHeading);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Die Übersetzungen eines Dokuments (ADR 111). Geprüft wird zuerst die Form, dann jede
|
|
* Sprache so, wie sie ausgeliefert wird: als Dokument mit den übersetzten Texten, durch
|
|
* dieselbe Prüfung wie die Hauptsprache. Eine Übersetzung kann so keine Länge, keine
|
|
* Auszeichnung und keine Überschriftenregel umgehen.
|
|
*/
|
|
private function translations(array $document, bool $requireHeading): void
|
|
{
|
|
$translations = $document['translations'];
|
|
$this->require(Translations::supports($document['schemaVersion']), 'Übersetzungen brauchen Dokumentschema v27.');
|
|
$this->require(is_array($translations) && ([] === $translations || !array_is_list($translations)), 'Die Übersetzungen sind ein Objekt je Sprache.');
|
|
foreach ($translations as $locale => $translation) {
|
|
$this->require(is_string($locale) && isset(Languages::CATALOG[$locale]), 'Unbekannte Sprache in den Übersetzungen.');
|
|
$this->require(is_array($translation) && ([] === $translation || !array_is_list($translation)), 'Eine Übersetzung ist ein Objekt.');
|
|
foreach ($translation as $key => $value) {
|
|
$this->require(in_array($key, ['slug', 'blocks', 'source', ...array_keys(Translations::PAGE_FIELDS)], true), sprintf('Unbekanntes Feld „%s“ in einer Übersetzung.', $key));
|
|
}
|
|
foreach (Translations::PAGE_FIELDS as $field => $max) {
|
|
if (isset($translation[$field])) {
|
|
$this->text($translation[$field], $max, true);
|
|
}
|
|
}
|
|
if (isset($translation['slug'])) {
|
|
$this->require(is_string($translation['slug']) && strlen($translation['slug']) <= 80 && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $translation['slug']), 'Die übersetzte Adresse darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.');
|
|
}
|
|
$blocks = $translation['blocks'] ?? [];
|
|
$this->require(is_array($blocks) && count($blocks) <= Translations::MAX_BLOCKS && ([] === $blocks || !array_is_list($blocks)), 'Die übersetzten Texte sind ein Objekt je Block.');
|
|
foreach ($blocks as $id => $fields) {
|
|
$this->require(is_string($id) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $id) && is_array($fields), 'Übersetzte Texte gehören zu einem Block.');
|
|
foreach ($fields as $prop => $value) {
|
|
$this->require(is_string($prop) && 1 === preg_match('/^[a-zA-Z]{1,40}$/D', $prop) && is_string($value), 'Ein übersetzter Text ist Text.');
|
|
}
|
|
}
|
|
$source = $translation['source'] ?? [];
|
|
$this->require(is_array($source) && count($source) <= Translations::MAX_BLOCKS * 3 + 2, 'Ungültige Vermerke zur Übersetzung.');
|
|
foreach ($source as $key => $hash) {
|
|
$this->require(is_string($key) && strlen($key) <= 110 && is_string($hash) && 1 === preg_match('/^[a-f0-9]{8}$/D', $hash), 'Ungültiger Vermerk zur Übersetzung.');
|
|
}
|
|
try {
|
|
$this->validate(Translations::apply($document, $locale), $requireHeading, $this->writing);
|
|
} catch (InvalidDocument $error) {
|
|
throw new InvalidDocument(sprintf('%s (%s): %s', Languages::CATALOG[$locale]['name'], $locale, $error->getMessage()), previous: $error);
|
|
}
|
|
}
|
|
}
|
|
|
|
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)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || ($schemaVersion >= 24 && in_array($type, self::V24_TYPES, true)) || ($schemaVersion >= 27 && in_array($type, self::V27_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.');
|
|
// Ein aktives Plugin kennt das Schema seines Bausteins - dann wird es auch geprüft, so
|
|
// streng wie bei einem Kernbaustein. Ohne Plugin bleibt der Erhaltungsvertrag: aufbewahren,
|
|
// nicht prüfen, nicht rendern.
|
|
if ($plugin && $this->writing && null !== $this->plugins && null !== ($definition = $this->plugins->block($type))) {
|
|
$this->plugins->validateProps($type, $props);
|
|
// Ein Container nimmt Kinder auf, ein gewöhnlicher Baustein nicht; eine Liste
|
|
// erlaubter Kinder im Manifest gilt wie ALLOW bei Galerie oder Akkordeon.
|
|
$kinder = is_array($block['children'] ?? null) ? $block['children'] : [];
|
|
$this->require($definition['container'] || [] === $kinder, sprintf('„%s“ nimmt keine Bausteine auf.', $definition['label']));
|
|
if (null !== $definition['children']) {
|
|
foreach ($kinder as $kind) {
|
|
$this->require(in_array($kind['type'] ?? '', $definition['children'], true), sprintf('In „%s“ passt nur: %s.', $definition['label'], implode(', ', $definition['children'])));
|
|
}
|
|
}
|
|
}
|
|
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', 'social'], 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->link($props['href'] ?? null);
|
|
// Schema v25: das Symbol am Button ist wählbar. Fehlt es, bleibt es beim Pfeil wie
|
|
// bisher - ältere Dokumente sehen also unverändert aus. "none" heißt ohne Symbol.
|
|
if (array_key_exists('icon', $props)) {
|
|
$this->require($schemaVersion >= 25 && ('none' === $props['icon'] || in_array($props['icon'], self::ICONS, true)), 'Unbekanntes Symbol am Button oder Dokumentschema vor v25.');
|
|
}
|
|
if (array_key_exists('iconPosition', $props)) {
|
|
$this->require($schemaVersion >= 25 && in_array($props['iconPosition'], ['start', 'end'], true), 'Das Symbol steht vor oder nach dem Text und braucht Schema v25.');
|
|
}
|
|
// Schema v26 (ADR 109): was das Symbol beim Überfahren tut, und ein Button nur aus
|
|
// Symbol. Der Text bleibt dann für Vorlesesoftware da - ohne Symbol wäre der Button
|
|
// unsichtbar, deshalb braucht "nur Symbol" ein Symbol.
|
|
if (array_key_exists('iconMotion', $props)) {
|
|
$this->require($schemaVersion >= 26 && in_array($props['iconMotion'], ['none', 'slide', 'reveal'], true), 'Unbekannte Bewegung des Symbols oder Dokumentschema vor v26.');
|
|
}
|
|
if (array_key_exists('iconOnly', $props)) {
|
|
$this->require($schemaVersion >= 26 && is_bool($props['iconOnly']), 'Nur Symbol ist an oder aus und braucht Schema v26.');
|
|
$this->require(!$props['iconOnly'] || 'none' !== ($props['icon'] ?? self::BUTTON_DEFAULT_ICON), 'Ein Button nur aus Symbol braucht ein Symbol.');
|
|
}
|
|
}
|
|
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->link($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->link($props['href'] ?? null);
|
|
}
|
|
if ('contact' === $type) {
|
|
foreach (['phone', 'email', 'address'] as $field) {
|
|
$this->text($props[$field] ?? null, 'address' === $field ? 200 : 80, true);
|
|
}
|
|
// Leer ist erlaubt: der Baustein steht nach dem Einfuegen erst einmal ohne Angaben da,
|
|
// und ein Dokument mit einem leeren Kontaktblock darf sich speichern lassen. Oeffentlich
|
|
// rendert er dann nichts, im Editor einen Hinweis.
|
|
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 ('accordion' === $type && array_key_exists('faq', $props)) {
|
|
// Ein Akkordeon ist nicht automatisch eine FAQ; erst die Kennzeichnung macht aus den
|
|
// Einträgen Frage-Antwort-Paare in den strukturierten Daten.
|
|
$this->require($schemaVersion >= 23 && is_bool($props['faq']), 'Die FAQ-Kennzeichnung ist an oder aus und braucht Schema v23.');
|
|
}
|
|
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 ('legal' === $type) {
|
|
// Die Beschriftung der Rechtszeile ist der Name der Landmarke fuer Vorlesesoftware.
|
|
$this->text($props['label'] ?? 'Rechtliches', 60, true);
|
|
}
|
|
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 (array_key_exists('burgerLabel', $props)) {
|
|
// Wie der Knopf beschriftet ist. „Nur Symbol“ behält den Text für Vorlesesoftware:
|
|
// ein Knopf ohne Namen ist für sie ein Knopf ohne Zweck.
|
|
$this->require(in_array($props['burgerLabel'], ['both', 'text', 'icon'], true), 'Für den Menü-Knopf sind „both", „text" und „icon" vorgesehen.');
|
|
}
|
|
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 ('language' === $type) {
|
|
// Welche Sprachen es gibt, weiß nur die Website - der Block sagt nur, wie er sie nennt.
|
|
$this->require(in_array($props['display'] ?? null, ['code', 'name'], true), 'Die Sprachwahl zeigt Kürzel („code“) oder Namen („name“).');
|
|
}
|
|
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) && ('' === $props['partial'] || (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->link($props['href']);
|
|
}
|
|
}
|
|
if ('social' === $type) {
|
|
// Nur die Darstellung ist Inhalt des Blocks. Welche Profile es gibt, steht in den
|
|
// Einstellungen - ein Dokument kann kein Profil behaupten, das die Website nicht hat.
|
|
$this->require(in_array($props['size'] ?? null, self::ICON_SIZES, true), sprintf('Die Symbolgröße ist eine aus %s.', implode(', ', self::ICON_SIZES)));
|
|
$this->require(isset(SocialProfiles::SHAPES[$props['shape'] ?? '']), 'Unbekannte Form der Symbolfläche.');
|
|
$this->require(isset(SocialProfiles::LAYOUTS[$props['layout'] ?? '']), 'Unbekannte Anordnung der Symbole.');
|
|
$this->require(is_bool($props['labels'] ?? null), 'Die Beschriftung der Symbole ist an oder aus.');
|
|
$networks = $props['networks'] ?? [];
|
|
$this->require(is_array($networks) && array_is_list($networks), 'Die Auswahl der Netzwerke ist eine Liste.');
|
|
foreach ($networks as $network) {
|
|
$this->require(is_string($network) && isset(SocialProfiles::NETWORKS[$network]), 'Unbekanntes Netzwerk in der Auswahl.');
|
|
}
|
|
}
|
|
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 && '' !== ($props['url'] ?? '')) {
|
|
// Ohne Adresse ist der Baustein ein leerer Rahmen, den der Editor benennt - erst mit
|
|
// Adresse gelten Titel, Anbieter und Hoehe.
|
|
// 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 ('html' === $type) {
|
|
// Eigenes HTML geht unveraendert auf die Seite. Geprueft wird es hier, damit ein
|
|
// unbrauchbarer Wert beim Speichern eine Meldung bekommt statt beim Ausliefern
|
|
// stillschweigend zu verschwinden. Wer es setzen darf, ist eine Frage der Rolle und
|
|
// wird im Controller beantwortet - wie beim eigenen Skript.
|
|
$this->require(is_string($props['html'] ?? null), 'Eigenes HTML ist Text.');
|
|
$this->code->checkHtml($props['html']);
|
|
}
|
|
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.
|
|
// Leer heisst: noch kein Formular gewaehlt. Der Editor zeigt das, die Website nichts.
|
|
$this->require(is_string($props['form'] ?? null) && ('' === $props['form'] || (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->link($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) {
|
|
// Plugin-Bausteine dürfen jedes Token tragen: die Klassen dafür sind allgemein, und
|
|
// das Manifest kann nicht in dieser Datei stehen.
|
|
$this->require(isset($tokens[$key]) && (in_array($type, $tokens[$key]['types'], true) || str_contains($type, '.')), '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.');
|
|
}
|
|
|
|
/**
|
|
* Ein Linkziel: alles, was url() erlaubt, dazu tel: und mailto: - dieselben Ziele, die Menü
|
|
* (MenuRepository::url) und formatierter Text (RichText::safeHref) schon kennen. Ein Knopf
|
|
* „Jetzt anrufen“ war sonst nur als Textlink im Knopf-Aussehen zu bauen (Hüfner, 06.10.2026).
|
|
* Bildquellen und eingebettete Inhalte bleiben bei url(): dort ist tel: kein Ziel.
|
|
*/
|
|
private function link(mixed $url): void
|
|
{
|
|
if (is_string($url) && strlen($url) <= 2048 && !preg_match('/[\x00-\x20\\\\]/', $url)) {
|
|
if (1 === preg_match('/^tel:\+?[0-9()-]{3,32}$/D', $url)) {
|
|
return;
|
|
}
|
|
// Adresse vor dem ersten ?, danach höchstens Betreff und Text, prozentkodiert.
|
|
if (1 === preg_match('/^mailto:([^?]+)(\?[A-Za-z0-9%._~=&+-]{0,500})?$/D', $url, $teile)
|
|
&& false !== filter_var(rawurldecode($teile[1]), FILTER_VALIDATE_EMAIL)) {
|
|
return;
|
|
}
|
|
}
|
|
$this->require(true === $this->isUrl($url), 'Erlaubt sind lokale Pfade, Sprungmarken, HTTPS-Adressen sowie tel: und mailto:.');
|
|
}
|
|
|
|
private function isUrl(mixed $url): bool
|
|
{
|
|
try {
|
|
$this->url($url);
|
|
|
|
return true;
|
|
} catch (InvalidDocument) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private function require(bool $condition, string $message): void
|
|
{
|
|
if (!$condition) {
|
|
throw new InvalidDocument($message);
|
|
}
|
|
}
|
|
}
|