Zehn Blöcke für die Abschnitte der Pilotseite (Arbeitspaket C)

Dokumentschema v6 ergänzt iconcard, stat, quote, logos, cta, contact,
divider, spacer, accordion und accordionitem - die Abschnittsarten,
aus denen die Startseite von kant-it.de besteht.

Symbole liegen als Inline-SVG aus einer festen Liste im Template:
kein Symbolschriftsatz, keine externe Anfrage. Kontaktdaten werden zu
tel:- und mailto:-Verweisen mit bereinigter Nummer. Das Akkordeon
nutzt details/summary und braucht damit kein öffentliches
JavaScript.

Tabs sind bewusst nicht umgesetzt: ohne JavaScript gibt es dafür
keine zugängliche Lösung, und eine CSS-Variante über Radiobuttons
wird als Auswahlfeld statt als Tabliste angekündigt. Begründung in
ADR 015.

Zwei Fehler von den Tests gefunden und behoben: die Regel "ein
Akkordeon-Eintrag gehört in ein Akkordeon" griff nicht auf oberster
Dokumentebene, und ein JavaScript-Test suchte im vorherigen
Dokumentstand, weil history.document vor history.change ausgewertet
wurde.

Geprüft: 107 PHP-Tests / 380 Assertions unter PHP 8.2.12 und 8.4.22,
13 JavaScript-Modelltests, Lint/Format/Container. Keine Browser- oder
Lighthouse-Läufe; für die neuen Blöcke liegt damit keine visuelle
Kontrolle und keine Barrierefreiheitsprüfung vor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 11:34:48 +02:00
1 parent ebd5883593
commit 8600384f1a
14 files changed
+442 -27

No files matched your search

+63 -8
View File
@@ -6,10 +6,16 @@ use App\Domain\InvalidDocument;
final class DocumentValidator
{
/** 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'];
public function validate(array $document): void
{
$schemaVersion = $document['schemaVersion'] ?? null;
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5], true), 'Unsupported document schema.');
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6], 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.');
@@ -17,12 +23,12 @@ final class DocumentValidator
$ids = [];
$headings = 0;
foreach ($document['blocks'] as $block) {
$this->block($block, $ids, $headings, 0, $schemaVersion);
$this->block($block, $ids, $headings, 0, $schemaVersion, null);
}
$this->require(1 === $headings, 'A page requires exactly one main heading.');
}
private function block(mixed $block, array &$ids, int &$headings, int $depth, int $schemaVersion): void
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;
@@ -34,7 +40,10 @@ final class DocumentValidator
// 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) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
$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)) || $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.');
$props = $block['props'] ?? [];
$layout = $block['layout'] ?? [];
$this->require(is_array($props) && is_array($layout), 'Props and layout must be objects.');
@@ -74,6 +83,50 @@ final class DocumentValidator
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 ('image' === $type) {
$this->url($props['src'] ?? null);
$this->require(is_bool($props['decorative'] ?? null), 'Image purpose is required.');
@@ -107,13 +160,15 @@ final class DocumentValidator
$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'], true);
$container = $plugin || in_array($type, ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem'], 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'];
foreach ($children as $child) {
if ('gallery' === $type) {
$this->require(is_array($child) && 'image' === ($child['type'] ?? null), 'Galleries only contain images.');
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);
$this->block($child, $ids, $headings, $depth + 1, $schemaVersion, $type);
}
}