Files
KantSites/src/Content/DocumentValidator.php
T
kantarciandClaude Opus 5 82b07820bc Kant CMS: Grundgerüst, visueller Editor, Mediathek
Symfony 7.4 / Twig / Doctrine DBAL auf herkömmlichem PHP-Webhosting.
Versionierte Blockdokumente (v1-v3) mit unveränderlichen Revisionen,
getrennten Entwürfen/Veröffentlichungen und optimistischer
Konflikterkennung. Visueller Editor mit Strukturbaum, Live-Vorschau,
responsiven Gerätewerten, Abschnittsvorlagen, Bildmediathek,
Galerien und Effekten. Öffentliche Ausgabe ohne Editor-JavaScript.

Geprüft: 41 PHP-Tests / 155 Assertions, 10 JavaScript-Modelltests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:19:05 +02:00

139 lines
7.5 KiB
PHP

<?php
namespace App\Content;
use App\Domain\InvalidDocument;
final class DocumentValidator
{
public function validate(array $document): void
{
$schemaVersion = $document['schemaVersion'] ?? null;
$this->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);
}
}
}