Karten (Raster mit Bild oben), Liste (Bild links, Text rechts), Kompakt (nur Titel und Datum) und Wechselseitig (Bild mal links, mal rechts). Dieselben Daten, dasselbe Markup - nur eine Klasse wechselt. Die erste Fassung von "Wechselseitig" war ein umgedrehter Flexkasten und sah auch so aus. Jetzt sitzt das Bild in einer Karte mit Ecken, Schatten und festem Verhaeltnis, der Text hat eine Nummer aus einem CSS-Zaehler, eine begrenzte Zeilenlaenge und einen sichtbaren Verweis, und zwischen den Reihen liegt eine feine Linie. Zwei Details: die Karte ist das Listenelement, also zaehlt :nth-child auf ihr - die erste Fassung wechselte deshalb gar nicht. Und die Umkehrung laeuft ueber order statt row-reverse, damit auf dem Telefon sauber wieder Bild ueber Text steht. Geprueft: 399 Tests unter PHP 8.2 und 8.4, 18 JS-Tests, Editortest, Browserpruefung, axe ohne Verstoss auf allen vier Darstellungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
384 lines
25 KiB
PHP
384 lines
25 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 v18: die Navigation zwischen Beiträgen, vom Renderer gefüllt. */
|
|
public const V18_TYPES = ['postnav'];
|
|
/** 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, 18], 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)) || ($schemaVersion >= 18 && in_array($type, self::V18_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.
|
|
// 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 ('heading' === $type) {
|
|
$this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.');
|
|
$headings += 1 === $props['level'] ? 1 : 0;
|
|
}
|
|
if ('postnav' === $type) {
|
|
// Nur die Beschriftungen sind Inhalt. Welche Beiträge es sind, entscheidet der
|
|
// Renderer beim Ausliefern - gespeicherter Inhalt kann keinen Beitrag behaupten.
|
|
$this->text($props['previousLabel'] ?? null, 60);
|
|
$this->text($props['nextLabel'] ?? null, 60);
|
|
}
|
|
if ('postlist' === $type) {
|
|
// The list is filled by the renderer, never by stored content: the block only says
|
|
// how many posts to show and which term to limit them to.
|
|
$this->require(is_int($props['count'] ?? null) && $props['count'] >= 1 && $props['count'] <= 24, 'Die Anzahl der Beiträge liegt zwischen 1 und 24.');
|
|
foreach (['category', 'tag'] as $kind) {
|
|
if (isset($props[$kind]) && '' !== $props[$kind]) {
|
|
$this->require(is_string($props[$kind]) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props[$kind]) && strlen($props[$kind]) <= 80, 'Ungültiger Filter für die Beitragsliste.');
|
|
}
|
|
}
|
|
// Was eine Karte zeigt. Ein Projektraster besteht aus Bild und Titel; Datum, Auszug
|
|
// und Kategorie gehören in ein Blogarchiv und lassen eine Projektseite falsch
|
|
// aussehen. Voreinstellung ist der alte Zustand, damit bestehende Seiten sich nicht
|
|
// ändern, wenn niemand etwas einstellt.
|
|
if (isset($props['layout'])) {
|
|
// Drei Darstellungen für dieselben Daten: Raster aus Karten, Liste mit Bild
|
|
// daneben, und eine reine Titelliste. Welche passt, hängt vom Ort ab - ein
|
|
// Projektraster auf der Startseite, eine Liste im Archiv, eine Titelliste in
|
|
// einer Randspalte.
|
|
$this->require($schemaVersion >= 18, 'Die Darstellung der Beitragsliste braucht Schema v18.');
|
|
$this->require(in_array($props['layout'], ['karten', 'liste', 'kompakt', 'wechsel'], true), 'Unbekannte Darstellung der Beitragsliste.');
|
|
}
|
|
if (isset($props['paginate'])) {
|
|
// Eine Auswahl zeigt die ersten n und hört auf. Eine Übersicht blättert - sonst
|
|
// verschweigt sie, dass es mehr gibt. Das ist die eigene Regel aus Arbeitspaket M.
|
|
$this->require($schemaVersion >= 17, 'Das Blättern einer Beitragsliste braucht Schema v17.');
|
|
$this->require(is_bool($props['paginate']), 'Das Blättern ist ja oder nein.');
|
|
}
|
|
foreach (['showMeta', 'showExcerpt', 'showTerms'] as $switch) {
|
|
if (isset($props[$switch])) {
|
|
$this->require($schemaVersion >= 17, 'Die Anzeigeoptionen der Beitragsliste brauchen Schema v17.');
|
|
$this->require(is_bool($props[$switch]), sprintf('"%s" ist ja oder nein.', $switch));
|
|
}
|
|
}
|
|
}
|
|
if ('button' === $type) {
|
|
$this->url($props['href'] ?? null);
|
|
}
|
|
if ('iconcard' === $type) {
|
|
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
|
|
$this->text($props['title'] ?? null, 120);
|
|
$this->text($props['text'] ?? null, 600, true);
|
|
if ('' !== ($props['href'] ?? '')) {
|
|
$this->url($props['href']);
|
|
}
|
|
}
|
|
if ('stat' === $type) {
|
|
$this->text($props['value'] ?? null, 16);
|
|
$this->text($props['label'] ?? null, 80);
|
|
}
|
|
if ('quote' === $type) {
|
|
$this->text($props['text'] ?? null, 1000);
|
|
$this->text($props['author'] ?? null, 120);
|
|
$this->text($props['role'] ?? null, 120, true);
|
|
$rating = $props['rating'] ?? 0;
|
|
$this->require(is_int($rating) && $rating >= 0 && $rating <= 5, 'A rating is a whole number between 0 and 5.');
|
|
}
|
|
if ('cta' === $type) {
|
|
$this->text($props['title'] ?? null, 160);
|
|
$this->text($props['text'] ?? null, 600, true);
|
|
$this->text($props['buttonText'] ?? null, 80);
|
|
$this->url($props['href'] ?? null);
|
|
}
|
|
if ('contact' === $type) {
|
|
foreach (['phone', 'email', 'address'] as $field) {
|
|
$this->text($props[$field] ?? null, 'address' === $field ? 200 : 80, true);
|
|
}
|
|
$this->require('' !== trim($props['phone'].$props['email'].$props['address']), 'A contact block needs at least one detail.');
|
|
if ('' !== $props['phone']) {
|
|
$this->require(1 === preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $props['phone']), 'Invalid phone number.');
|
|
}
|
|
if ('' !== $props['email']) {
|
|
$this->require(false !== filter_var($props['email'], FILTER_VALIDATE_EMAIL), 'Invalid email address.');
|
|
}
|
|
}
|
|
if ('spacer' === $type) {
|
|
$this->require(in_array($props['size'] ?? null, self::SPACER_SIZES, true), 'Unknown spacer size.');
|
|
}
|
|
if ('accordionitem' === $type) {
|
|
$this->text($props['title'] ?? null, 160);
|
|
$this->require(is_bool($props['open'] ?? false), 'The open state is a boolean.');
|
|
}
|
|
if ('tabitem' === $type) {
|
|
$this->text($props['title'] ?? null, 160);
|
|
}
|
|
if ('menu' === $type) {
|
|
// The entries live in the menu administration; the block only says which slot to show
|
|
// and how to lay it out.
|
|
$this->require(isset(MenuRepository::SLOTS[$props['slot'] ?? '']), 'Unknown menu slot.');
|
|
if (isset($props['layout'])) {
|
|
$this->require($schemaVersion >= 16, 'Die Menüart braucht Schema v16.');
|
|
$this->require(in_array($props['layout'], ['horizontal', 'vertical', 'burger'], true), 'Unbekannte Menüart.');
|
|
}
|
|
}
|
|
if ('partialref' === $type) {
|
|
// Only the address of the section is content. Its blocks live in the partial, so one
|
|
// change reaches every page that links it and a page cannot alter a shared section.
|
|
$this->require(is_string($props['partial'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['partial']) && strlen($props['partial']) <= 80, 'Invalid section address.');
|
|
}
|
|
if ('icon' === $type) {
|
|
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
|
|
// A fixed list, because the size becomes a class: a value without a class would leave
|
|
// an icon with no dimensions at all, and an SVG without dimensions is invisible.
|
|
$this->require(in_array($props['size'] ?? null, self::ICON_SIZES, true), sprintf('Die Symbolgröße ist eine aus %s.', implode(', ', self::ICON_SIZES)));
|
|
// A decorative icon is hidden from assistive technology; one that carries meaning
|
|
// needs a label, or a screen reader announces nothing at all.
|
|
$this->require(is_bool($props['decorative'] ?? null), 'Der Zweck des Symbols ist anzugeben.');
|
|
$this->text($props['label'] ?? null, 120, $props['decorative']);
|
|
$this->require(!$props['decorative'] || '' === $props['label'], 'Ein dekoratives Symbol hat keine Beschriftung.');
|
|
if ('' !== ($props['href'] ?? '')) {
|
|
$this->url($props['href']);
|
|
}
|
|
}
|
|
if ('progress' === $type) {
|
|
$this->text($props['label'] ?? null, 120);
|
|
$this->require(is_int($props['value'] ?? null) && $props['value'] >= 0 && $props['value'] <= 100, 'Der Wert liegt zwischen 0 und 100.');
|
|
$this->require(is_bool($props['showValue'] ?? null), 'Die Anzeige des Werts ist anzugeben.');
|
|
}
|
|
if ('slider' === $type) {
|
|
// The slider is a scroll container: how many slides are visible per device is the
|
|
// only thing it stores, the slides themselves are ordinary blocks.
|
|
foreach (['desktop', 'tablet', 'mobile'] as $device) {
|
|
$key = $device.'Visible';
|
|
$this->require(is_int($props[$key] ?? null) && $props[$key] >= 1 && $props[$key] <= 6, sprintf('"%s" liegt zwischen 1 und 6.', $key));
|
|
}
|
|
$this->require(is_bool($props['snap'] ?? null), 'Das Einrasten ist anzugeben.');
|
|
if (isset($props['autoplay'])) {
|
|
// Sekunden bis zur nächsten Folie. 0 heißt aus, und unter drei Sekunden wäre es
|
|
// keine Hilfe mehr, sondern Hektik.
|
|
$this->require($schemaVersion >= 17, 'Das automatische Weiterblättern braucht Schema v17.');
|
|
$this->require(is_int($props['autoplay']) && (0 === $props['autoplay'] || ($props['autoplay'] >= 3 && $props['autoplay'] <= 30)), 'Das Weiterblättern liegt bei 0 (aus) oder 3 bis 30 Sekunden.');
|
|
}
|
|
if (isset($props['dots'])) {
|
|
// Punkte und Pfeile zeichnet der Browser aus dem Scrollbereich selbst; das
|
|
// geht erst ab v17, weil es vorher keine Eigenschaft dafür gab.
|
|
$this->require($schemaVersion >= 17, 'Punkte am Slider brauchen Schema v17.');
|
|
$this->require(is_bool($props['dots']), 'Die Punkte sind ja oder nein.');
|
|
}
|
|
}
|
|
if ('embed' === $type) {
|
|
// Everything about the embed is validated here, because the address ends up in an
|
|
// iframe and in the content security policy of the page.
|
|
$this->require(isset(Consent::GROUPS[$props['group'] ?? '']), 'Unbekannte Einwilligungsgruppe.');
|
|
// The title is what a screen reader announces for the frame, so it is not optional.
|
|
$this->text($props['title'] ?? null, 160);
|
|
$this->text($props['provider'] ?? null, 80);
|
|
$this->text($props['hint'] ?? null, 300, true);
|
|
$url = $props['url'] ?? null;
|
|
$this->require(is_string($url) && strlen($url) <= 400 && str_starts_with($url, 'https://'), 'Ein eingebetteter Inhalt braucht eine HTTPS-Adresse.');
|
|
$host = parse_url($url, PHP_URL_HOST);
|
|
// The host becomes part of frame-src, so it must be a plain name - no credentials, no
|
|
// port, nothing that could smuggle a second source into the policy.
|
|
$this->require(is_string($host) && 1 === preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/D', $host), 'Die Adresse des eingebetteten Inhalts braucht einen einfachen Hostnamen.');
|
|
$this->require(null === parse_url($url, PHP_URL_PORT) && null === parse_url($url, PHP_URL_USER), 'Die Adresse darf keinen Port und keine Zugangsdaten enthalten.');
|
|
$this->require(is_int($props['height'] ?? null) && $props['height'] >= 150 && $props['height'] <= 1200, 'Die Höhe liegt zwischen 150 und 1200 Pixeln.');
|
|
}
|
|
if ('form' === $type) {
|
|
// Only the address is content. Fields, recipient and consent text stay in the database,
|
|
// so a stored document can never redirect a submission or change what is asked.
|
|
$this->require(is_string($props['form'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['form']) && strlen($props['form']) <= 80, 'Invalid form address.');
|
|
}
|
|
if ('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);
|
|
}
|
|
}
|
|
}
|