437 lines
29 KiB
PHP
437 lines
29 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'];
|
|
/** 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'];
|
|
|
|
/**
|
|
* @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, 19, 20, 21, 22], 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);
|
|
}
|
|
$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.',
|
|
);
|
|
}
|
|
|
|
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)) || $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 ('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'], 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->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 (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 (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 ('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->url($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) {
|
|
$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);
|
|
}
|
|
}
|
|
}
|