A site can have further languages (Settings → Languages). A page is built once; each language adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111, document schema v27, migration 20261009000100 for page_translation). - Editor: DE | EN switch, a list of all texts with original, state (missing, translated, original changed) and filter; structure stays with the main language. Undo, drafts, publishing and revisions cover all languages at once. - Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale, sitemap; links to translated pages follow the language; a language switch block. - Header, footer and linked sections translate the same way; menu entries carry labels per language; fixed template texts come from a small dictionary. - Forms: labels, help, options, button and success message per language; messages and the step script speak the language, the submission and the email stay in the main language. - Posts, archives, category names, consent text and the 404 page follow the language. - Single-language sites see none of it and never touch the new table. The forms administration becomes an overview with one row per form and its own edit page; fields are one line each with details collapsed. 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'];
|
|
/** 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);
|
|
}
|
|
}
|
|
}
|