require(in_array($schemaVersion, [1, 2, 3], 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); } $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 { $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; $this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || (3 === $schemaVersion && 'gallery' === $type), 'Unknown block type. Preserve source until its plugin is available.'); $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 && 3 === $schemaVersion) { $this->require(is_array($value), 'Effects must be an object.'); $effects = ['shadow' => ['none', 'soft', 'strong'], 'hover' => ['none', 'lift', 'zoom'], 'entrance' => ['none', 'fade', 'rise']]; 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'], true), 'Invalid design token.'); } if (in_array($type, ['heading', 'text', 'button'], true)) { $this->text($props['text'] ?? null, 'text' === $type ? 10000 : 300); } 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 ('button' === $type) { $this->url($props['href'] ?? null); } if ('image' === $type) { $this->url($props['src'] ?? null); $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(3 === $schemaVersion, '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'])) { $this->require(is_array($props['sources']) && array_is_list($props['sources']) && count($props['sources']) <= 3, '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.'); $container = in_array($type, ['section', 'row', 'column', 'gallery'], true); $this->require($container || [] === $children, 'Leaf blocks cannot contain children.'); foreach ($children as $child) { if ('gallery' === $type) { $this->require(is_array($child) && 'image' === ($child['type'] ?? null), 'Galleries only contain images.'); } $this->block($child, $ids, $headings, $depth + 1, $schemaVersion); } } 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); } } }