Files
KantSites/src/Content/DocumentValidator.php
T
kantarciandClaude Opus 5 70283db20a
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Dokumentschema v17: Projektraster, verlinktes Bild, klebender Kopfbereich
Alles aus dem Nachbau von kant-it.de, den Umut durchgesehen hat.

Eine Beitragsliste kann Datum, Autor, Auszug und Kategorie ausblenden und
sieht damit aus wie ein Projektraster statt wie ein Blogarchiv. Beinahe
lautlos danebengegangen: |default(true) macht in Twig aus einem
gespeicherten false ein true - die Einstellung haette sich speichern
lassen und nichts getan. Der Test prueft beide Richtungen.

Ein Bild darf verlinken, damit ein Logo auf die Startseite zeigt; die
Adresse geht durch dieselbe Pruefung wie jeder andere Link. Und der
Kopfbereich kann beim Scrollen oben bleiben - als Einstellung der
Website, weil nur das umschliessende Element kleben kann.

Der groesste Fund steckt im Editor: die Liste bekannter Schemafassungen
endete bei 12, waehrend Bausteine v13 bis v15 verlangen. In einem
aktuellen Dokument liess sich damit kein Baustein mehr einfuegen, und
Symbol, Fortschritt, Slider und externer Inhalt in gar keinem. Jetzt eine
Zahl an einer Stelle, mit einem Test ueber jeden Typ und jede Fassung.

Geprueft: 389 PHP-Tests unter 8.2 und 8.4, 18 JS-Tests, Editortest,
Browserpruefung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:02:23 +02:00

349 lines
23 KiB
PHP

<?php
namespace App\Content;
use App\Domain\InvalidDocument;
final class DocumentValidator
{
public function __construct(private StyleCompiler $styles = new StyleCompiler(), private CustomCode $code = new CustomCode())
{
}
/** 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 = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check'];
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 v15: a single icon, a progress bar and a slider that works without a script. */
public const V15_TYPES = ['icon', 'progress', 'slider'];
/**
* @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.
*/
public function validate(array $document, bool $requireHeading = true): void
{
$schemaVersion = $document['schemaVersion'] ?? null;
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17], 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);
}
$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;
// 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)) || $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 ('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.
$limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : 300;
$this->text($props['text'] ?? null, $limit);
}
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 ('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.
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 (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 ('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 ('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']);
}
$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);
}
}
}