Rebuild DEEG as a fifth installation, add stepped forms and a plugin system, and clear the review backlog

Forms gained steps, conditions, image choices, number fields with units and a
summary step; the DEEG price finder uses all of it. Plugins are manifest plus
Twig: blocks, settings, containers, routes, presets and theme packs, switched
per installation. The review backlog is done: archive pages render through the
normal page pipeline, forms and post lists work inside areas and sections,
repeated sections get their own ids, breakpoints and colours come from the
palette, backup and update work on MySQL and lock each other out.

The task worker no longer shortens a process that has no time limit:
set_time_limit() sets the limit instead of extending it, which turned an
unlimited CLI run into eighty seconds and killed the full test suite far away
from the scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-16 07:32:35 +02:00
1 parent 66ff68abf8
commit c8a8fa7460
74 files changed
+5352 -243

No files matched your search

+45 -8
View File
@@ -3,10 +3,13 @@
namespace App\Content;
use App\Domain\InvalidDocument;
use App\Plugin\PluginRegistry;
final class DocumentValidator
{
public function __construct(private StyleCompiler $styles = new StyleCompiler(), private CustomCode $code = new CustomCode())
private bool $writing = false;
public function __construct(private StyleCompiler $styles = new StyleCompiler(), private CustomCode $code = new CustomCode(), private ?PluginRegistry $plugins = null)
{
}
@@ -35,16 +38,24 @@ final class DocumentValidator
public const V15_TYPES = ['icon', 'progress', 'slider'];
/** Schema v23: die sozialen Profile aus den Einstellungen, als Symbolreihe an jeder Stelle. */
public const V23_TYPES = ['social'];
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
public const SCHEMA_VERSION = 23;
/**
* @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.
* @param bool $writing Beim Schreiben werden die Eigenschaften von Plugin-Bausteinen
* gegen ihr Schema geprüft. Beim Lesen nicht: ein Plugin, das
* nach dem Speichern eingeschaltet oder geändert wurde, darf
* eine gespeicherte Seite nicht unlesbar machen - der Renderer
* lässt unpassende Eigenschaften ohnehin nicht durch.
*/
public function validate(array $document, bool $requireHeading = true): void
public function validate(array $document, bool $requireHeading = true, bool $writing = false): void
{
$this->writing = $writing;
$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, 23], true), 'Unsupported document schema.');
$this->require(is_int($schemaVersion) && $schemaVersion >= 1 && $schemaVersion <= self::SCHEMA_VERSION, '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.');
@@ -112,6 +123,21 @@ final class DocumentValidator
$props = $block['props'] ?? [];
$layout = $block['layout'] ?? [];
$this->require(is_array($props) && is_array($layout), 'Props and layout must be objects.');
// Ein aktives Plugin kennt das Schema seines Bausteins - dann wird es auch geprüft, so
// streng wie bei einem Kernbaustein. Ohne Plugin bleibt der Erhaltungsvertrag: aufbewahren,
// nicht prüfen, nicht rendern.
if ($plugin && $this->writing && null !== $this->plugins && null !== ($definition = $this->plugins->block($type))) {
$this->plugins->validateProps($type, $props);
// Ein Container nimmt Kinder auf, ein gewöhnlicher Baustein nicht; eine Liste
// erlaubter Kinder im Manifest gilt wie ALLOW bei Galerie oder Akkordeon.
$kinder = is_array($block['children'] ?? null) ? $block['children'] : [];
$this->require($definition['container'] || [] === $kinder, sprintf('„%s“ nimmt keine Bausteine auf.', $definition['label']));
if (null !== $definition['children']) {
foreach ($kinder as $kind) {
$this->require(in_array($kind['type'] ?? '', $definition['children'], true), sprintf('In „%s“ passt nur: %s.', $definition['label'], implode(', ', $definition['children'])));
}
}
}
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.');
@@ -240,7 +266,9 @@ final class DocumentValidator
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.');
// Leer ist erlaubt: der Baustein steht nach dem Einfuegen erst einmal ohne Angaben da,
// und ein Dokument mit einem leeren Kontaktblock darf sich speichern lassen. Oeffentlich
// rendert er dann nichts, im Editor einen Hinweis.
if ('' !== $props['phone']) {
$this->require(1 === preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $props['phone']), 'Invalid phone number.');
}
@@ -263,6 +291,10 @@ final class DocumentValidator
if ('tabitem' === $type) {
$this->text($props['title'] ?? null, 160);
}
if ('legal' === $type) {
// Die Beschriftung der Rechtszeile ist der Name der Landmarke fuer Vorlesesoftware.
$this->text($props['label'] ?? 'Rechtliches', 60, true);
}
if ('menu' === $type) {
// The entries live in the menu administration; the block only says which slot to show
// and how to lay it out.
@@ -283,7 +315,7 @@ final class DocumentValidator
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.');
$this->require(is_string($props['partial'] ?? null) && ('' === $props['partial'] || (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.');
@@ -338,7 +370,9 @@ final class DocumentValidator
$this->require(is_bool($props['dots']), 'Die Punkte sind ja oder nein.');
}
}
if ('embed' === $type) {
if ('embed' === $type && '' !== ($props['url'] ?? '')) {
// Ohne Adresse ist der Baustein ein leerer Rahmen, den der Editor benennt - erst mit
// Adresse gelten Titel, Anbieter und Hoehe.
// 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.');
@@ -358,7 +392,8 @@ final class DocumentValidator
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.');
// Leer heisst: noch kein Formular gewaehlt. Der Editor zeigt das, die Website nichts.
$this->require(is_string($props['form'] ?? null) && ('' === $props['form'] || (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.');
@@ -432,7 +467,9 @@ final class DocumentValidator
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.');
// Plugin-Bausteine dürfen jedes Token tragen: die Klassen dafür sind allgemein, und
// das Manifest kann nicht in dieser Datei stehen.
$this->require(isset($tokens[$key]) && (in_array($type, $tokens[$key]['types'], true) || str_contains($type, '.')), 'Design setting does not apply to this block.');
$this->require(is_string($value) && isset($tokens[$key]['values'][$value]), 'Invalid responsive design token.');
}
}
+232 -21
View File
@@ -9,6 +9,12 @@ use Doctrine\DBAL\Connection;
/**
* Form definitions and their submissions. A definition is a validated list of fields; a submission
* stores the answers, the moment consent was given and a keyed hash of the sender address.
*
* Ein Formular kann in Schritten fragen: ein Feld vom Typ „Schritt“ eröffnet einen Abschnitt, alle
* Felder bis zum nächsten Schritt gehören dazu. Und ein Feld oder ein Schritt kann von einer
* früheren Auswahl abhängen („nur zeigen, wenn Immobilienart = Wohnung“). Beides steht in der
* Definition, nicht im Skript: der Server entscheidet beim Absenden selbst, welche Felder galten,
* und verlangt nichts, was der Besucher gar nicht sehen sollte.
*/
final class FormRepository
{
@@ -16,11 +22,18 @@ final class FormRepository
'text' => 'Einzeiliger Text',
'email' => 'E-Mail-Adresse',
'phone' => 'Telefonnummer',
'number' => 'Zahl',
'textarea' => 'Mehrzeiliger Text',
'select' => 'Auswahl',
'select' => 'Auswahl (Liste)',
'radio' => 'Auswahl (Optionsfelder)',
'checkboxes' => 'Mehrfachauswahl',
'consent' => 'Einwilligung',
'step' => 'Neuer Schritt',
];
public const MAX_FIELDS = 25;
/** Die Feldtypen mit Optionen; nur von ihnen kann ein anderes Feld abhängen. */
public const CHOICE_TYPES = ['select', 'radio', 'checkboxes'];
/** Ein Preisfinder mit vier Objektarten braucht mehr als ein Kontaktformular. */
public const MAX_FIELDS = 40;
/** Die gestaltbaren Farben eines Formulars, mit ihrer Bezeichnung in der Verwaltung. */
public const COLORS = [
'fieldBorderColor' => 'den Rahmen der Felder',
@@ -56,10 +69,14 @@ final class FormRepository
private function present(array $row): array
{
// Ältere Definitionen kennen weder Grenzen noch Bedingungen; die Vorlage und die Prüfung
// verlassen sich darauf, dass jeder Schlüssel da ist.
$fields = array_map(static fn (array $field): array => $field + ['help' => '', 'options' => [], 'images' => [], 'min' => null, 'max' => null, 'unit' => '', 'showIf' => null], json_decode($row['fields'], true, 512, JSON_THROW_ON_ERROR));
return [
'id' => $row['id'], 'slug' => $row['slug'], 'name' => $row['name'],
'fields' => json_decode($row['fields'], true, 512, JSON_THROW_ON_ERROR),
'settings' => json_decode($row['settings'], true, 512, JSON_THROW_ON_ERROR),
'fields' => $fields,
'settings' => json_decode($row['settings'], true, 512, JSON_THROW_ON_ERROR) + ['summary' => false],
'version' => (int) $row['version'],
];
}
@@ -114,6 +131,8 @@ final class FormRepository
throw new InvalidForm(sprintf('Ein Formular fasst höchstens %d Felder.', self::MAX_FIELDS));
}
$names = [];
// Die Optionen der bisherigen Auswahlfelder: nur davon darf ein späteres Feld abhängen.
$choices = [];
$clean = [];
foreach ($fields as $field) {
$name = strtolower(trim((string) ($field['name'] ?? '')));
@@ -130,34 +149,149 @@ final class FormRepository
throw new InvalidForm('Jedes Feld braucht eine Beschriftung mit höchstens 120 Zeichen.');
}
$options = [];
if ('select' === $type) {
if (in_array($type, self::CHOICE_TYPES, true)) {
foreach ((array) ($field['options'] ?? []) as $option) {
$option = trim((string) $option);
if ('' !== $option && mb_strlen($option) <= 120) {
// Kein Komma in einer Option: die Verwaltung trennt Optionen daran, und eine
// Mehrfachauswahl wird mit Komma gespeichert.
if ('' !== $option && mb_strlen($option) <= 120 && !str_contains($option, ',') && !in_array($option, $options, true)) {
$options[] = $option;
}
}
if (count($options) < 2) {
throw new InvalidForm(sprintf('Die Auswahl „%s“ braucht mindestens zwei Optionen.', $label));
throw new InvalidForm(sprintf('Die Auswahl „%s“ braucht mindestens zwei Optionen ohne Komma.', $label));
}
}
$images = in_array($type, self::CHOICE_TYPES, true) ? $this->validateImages($field['images'] ?? [], $options, $label) : [];
$min = $max = null;
$unit = '';
if ('number' === $type) {
foreach (['min', 'max'] as $grenze) {
$wert = str_replace(',', '.', trim((string) ($field[$grenze] ?? '')));
if ('' === $wert) {
continue;
}
if (!is_numeric($wert)) {
throw new InvalidForm(sprintf('Die Grenze „%s“ von „%s“ ist keine Zahl.', $wert, $label));
}
$$grenze = (float) $wert;
}
if (null !== $min && null !== $max && $min >= $max) {
throw new InvalidForm(sprintf('Bei „%s“ muss die Untergrenze unter der Obergrenze liegen.', $label));
}
$unit = mb_substr(trim((string) ($field['unit'] ?? '')), 0, 12);
}
$showIf = $this->validateCondition($field, $label, $choices);
$clean[] = [
'name' => $name,
'type' => $type,
'label' => $label,
// A consent field is only meaningful when it has to be ticked.
'required' => 'consent' === $type ? true : (bool) ($field['required'] ?? false),
// A consent field is only meaningful when it has to be ticked; a step asks nothing.
'required' => 'consent' === $type ? true : ('step' !== $type && (bool) ($field['required'] ?? false)),
'help' => mb_substr(trim((string) ($field['help'] ?? '')), 0, 200),
'options' => $options,
'images' => $images,
'min' => $min,
'max' => $max,
'unit' => $unit,
'showIf' => $showIf,
];
if (in_array($type, self::CHOICE_TYPES, true)) {
$choices[$name] = $options;
}
}
if (count(array_filter($clean, static fn (array $f): bool => 'consent' === $f['type'])) > 1) {
throw new InvalidForm('Ein Formular hat höchstens ein Einwilligungsfeld.');
}
if ([] === array_filter($clean, static fn (array $f): bool => 'step' !== $f['type'])) {
throw new InvalidForm('Ein Formular braucht mindestens ein Feld, das etwas fragt – Schritte allein reichen nicht.');
}
return $clean;
}
/**
* Die Bedingung eines Feldes: es gilt nur, wenn ein früheres Auswahlfeld einen der genannten
* Werte hat. Angenommen wird die gespeicherte Form (showIf: {field, values}) und die aus der
* Verwaltung (showIfField, showIfValues als Kommaliste).
*
* @param array<string, list<string>> $choices Optionen der Auswahlfelder vor diesem Feld
*
* @return array{field: string, values: list<string>}|null
*/
private function validateCondition(array $field, string $label, array $choices): ?array
{
$ref = strtolower(trim((string) ($field['showIfField'] ?? ($field['showIf']['field'] ?? ''))));
if ('' === $ref) {
return null;
}
if (!isset($choices[$ref])) {
throw new InvalidForm(sprintf('„%s“ soll von „%s“ abhängen, aber davor steht kein Auswahlfeld mit diesem Namen.', $label, $ref));
}
$wanted = $field['showIfValues'] ?? ($field['showIf']['values'] ?? []);
if (is_string($wanted)) {
$wanted = explode(',', $wanted);
}
$values = [];
foreach ((array) $wanted as $wert) {
$wert = trim((string) $wert);
if ('' === $wert || in_array($wert, $values, true)) {
continue;
}
if (!in_array($wert, $choices[$ref], true)) {
throw new InvalidForm(sprintf('„%s“ ist keine Option von „%s“ – die Bedingung von „%s“ könnte nie zutreffen.', $wert, $ref, $label));
}
$values[] = $wert;
}
if ([] === $values) {
throw new InvalidForm(sprintf('Für die Bedingung von „%s“ fehlt der Wert, bei dem das Feld gilt.', $label));
}
return ['field' => $ref, 'values' => $values];
}
/**
* Die Bilder einer Auswahl: je Option höchstens ein Bild aus der Mediathek. Sobald eine Option
* eines hat, zeigt die Website die Auswahl als anklickbare Bilder statt als Kästchen.
* Angenommen wird die gespeicherte Form (Option => Kennung) und die aus der Verwaltung
* (eine Zeile je Option: „Option = Kennung“).
*
* @param list<string> $options
*
* @return array<string, string>
*/
private function validateImages(mixed $input, array $options, string $label): array
{
if (is_string($input)) {
$paare = [];
foreach (preg_split('/\\r?\\n/', $input) ?: [] as $zeile) {
if (!str_contains($zeile, '=')) {
continue;
}
[$option, $kennung] = array_map(trim(...), explode('=', $zeile, 2));
$paare[$option] = $kennung;
}
$input = $paare;
}
$images = [];
foreach ((array) $input as $option => $kennung) {
$option = trim((string) $option);
$kennung = strtolower(trim((string) $kennung));
if ('' === $kennung) {
continue;
}
if (!in_array($option, $options, true)) {
throw new InvalidForm(sprintf('„%s“ ist keine Option von „%s“ und kann kein Bild bekommen.', $option, $label));
}
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $kennung)) {
throw new InvalidForm(sprintf('Das Bild für „%s“ ist keine Kennung aus der Mediathek.', $option));
}
$images[$option] = $kennung;
}
return $images;
}
private function validateSettings(array $settings, array $fields): array
{
$recipient = trim((string) ($settings['recipient'] ?? ''));
@@ -184,6 +318,9 @@ final class FormRepository
'success' => mb_substr(trim((string) ($settings['success'] ?? '')), 0, 300) ?: 'Vielen Dank. Deine Nachricht ist angekommen.',
'replyToField' => $replyTo,
'buttonLabel' => mb_substr(trim((string) ($settings['buttonLabel'] ?? '')), 0, 40) ?: 'Absenden',
// Die Zusammenfassung vor dem Absenden - ein Schritt, den nur das Skript zeigt; ohne
// Skript steht das ganze Formular ohnehin auf einer Seite.
'summary' => (bool) ($settings['summary'] ?? false),
];
// Das Aussehen: leer heisst "wie die Website", sonst ein Palettenschluessel oder ein
// Hexwert - dieselbe Regel wie fuer jede Farbe im Editor.
@@ -198,6 +335,21 @@ final class FormRepository
return $clean;
}
/** Ob dieses Formular auf der Seite das Skript braucht: Schritte, Bedingungen oder die Zusammenfassung. */
public function needsScript(array $form): bool
{
if ($form['settings']['summary'] ?? false) {
return true;
}
foreach ($form['fields'] as $field) {
if ('step' === $field['type'] || null !== ($field['showIf'] ?? null)) {
return true;
}
}
return false;
}
/**
* Die Gestaltung eines Formulars als Regel fuer die Seite, die es zeigt.
*
@@ -217,7 +369,7 @@ final class FormRepository
}
$values[] = $variable.':'.$value;
if ('buttonColor' === $field) {
$values[] = '--kb-hover-fill:color-mix(in srgb,'.$value.' 82%,#000)';
$values[] = '--kb-hover-fill:color-mix(in srgb,'.$value.' 82%,var(--ink))';
$button = true;
}
if ('buttonInk' === $field) {
@@ -243,6 +395,10 @@ final class FormRepository
/**
* Checks one submission against the stored definition.
*
* Ein Feld hinter einer Bedingung zählt nur, wenn sie zutrifft; ein Schritt hinter einer
* Bedingung nimmt seine Felder mit. Was nicht galt, wird weder verlangt noch gespeichert -
* auch wenn ein Skript im Browser es mitgeschickt hat.
*
* @return array{values: array<string, string>, errors: array<string, string>, consent: bool}
*/
public function check(array $form, array $input): array
@@ -250,44 +406,99 @@ final class FormRepository
$values = [];
$errors = [];
$consent = false;
// Die gewählten Optionen der bisherigen Auswahlfelder, für die Bedingungen danach.
$chosen = [];
$stepActive = true;
// Kein Pfeil: der bekäme die leere Liste von oben und sähe keine spätere Wahl.
$holds = static function (?array $showIf) use (&$chosen): bool {
return null === $showIf || [] !== array_intersect($showIf['values'], $chosen[$showIf['field']] ?? []);
};
foreach ($form['fields'] as $field) {
$raw = $input[$field['name']] ?? '';
$name = $field['name'];
if ('step' === $field['type']) {
$stepActive = $holds($field['showIf']);
continue;
}
if (!$stepActive || !$holds($field['showIf'])) {
$values[$name] = '';
continue;
}
$raw = $input[$name] ?? '';
if ('checkboxes' === $field['type']) {
$picked = array_values(array_filter(array_map(static fn ($v): string => is_string($v) ? trim($v) : '', is_array($raw) ? $raw : [$raw]), static fn (string $v): bool => '' !== $v));
if ([] !== array_diff($picked, $field['options'])) {
$errors[$name] = 'Bitte wähle nur aus den angebotenen Optionen.';
$picked = array_values(array_intersect($picked, $field['options']));
} elseif ([] === $picked && $field['required']) {
$errors[$name] = 'Bitte wähle mindestens eine Option.';
}
$chosen[$name] = $picked;
$values[$name] = implode(', ', $picked);
continue;
}
$value = is_string($raw) ? trim($raw) : '';
$limit = 'textarea' === $field['type'] ? 5000 : 200;
if (mb_strlen($value) > $limit) {
$errors[$field['name']] = sprintf('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit);
$errors[$name] = sprintf('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit);
$value = mb_substr($value, 0, $limit);
}
if ('consent' === $field['type']) {
$consent = '' !== $value;
if (!$consent) {
$errors[$field['name']] = 'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.';
$errors[$name] = 'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.';
}
$values[$field['name']] = $consent ? 'ja' : '';
$values[$name] = $consent ? 'ja' : '';
continue;
}
if ('' === $value) {
if ($field['required']) {
$errors[$field['name']] = 'Dieses Feld wird benötigt.';
$errors[$name] = in_array($field['type'], self::CHOICE_TYPES, true) ? 'Bitte wähle eine der angebotenen Optionen.' : 'Dieses Feld wird benötigt.';
}
$values[$field['name']] = '';
$values[$name] = '';
continue;
}
if ('email' === $field['type'] && false === filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errors[$field['name']] = 'Bitte gib eine gültige E-Mail-Adresse an.';
$errors[$name] = 'Bitte gib eine gültige E-Mail-Adresse an.';
}
if ('phone' === $field['type'] && 1 !== preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $value)) {
$errors[$field['name']] = 'Bitte gib eine gültige Telefonnummer an.';
$errors[$name] = 'Bitte gib eine gültige Telefonnummer an.';
}
if ('select' === $field['type'] && !in_array($value, $field['options'], true)) {
$errors[$field['name']] = 'Bitte wähle eine der angebotenen Optionen.';
if ('number' === $field['type']) {
$zahl = str_replace(',', '.', $value);
if (!is_numeric($zahl)) {
$errors[$name] = 'Bitte gib eine Zahl an.';
} elseif ((null !== $field['min'] && (float) $zahl < $field['min']) || (null !== $field['max'] && (float) $zahl > $field['max'])) {
$errors[$name] = match (true) {
null === $field['min'] => sprintf('Bitte gib höchstens %s an.', self::zahl($field['max'])),
null === $field['max'] => sprintf('Bitte gib mindestens %s an.', self::zahl($field['min'])),
default => sprintf('Bitte gib einen Wert zwischen %s und %s an.', self::zahl($field['min']), self::zahl($field['max'])),
};
} else {
// Gespeichert wird die Zahl, wie sie ist - ohne führende Nullen, mit Punkt.
$value = self::zahl((float) $zahl);
}
}
$values[$field['name']] = $value;
if (in_array($field['type'], ['select', 'radio'], true)) {
if (!in_array($value, $field['options'], true)) {
$errors[$name] = 'Bitte wähle eine der angebotenen Optionen.';
} else {
$chosen[$name] = [$value];
}
}
$values[$name] = $value;
}
return ['values' => $values, 'errors' => $errors, 'consent' => $consent];
}
/** Eine Zahl ohne unnötige Nachkommastellen, für Meldungen und die gespeicherte Antwort. */
public static function zahl(float $wert): string
{
$text = number_format($wert, 3, '.', '');
return str_contains($text, '.') ? rtrim(rtrim($text, '0'), '.') : $text;
}
/** True when this sender has already sent the allowed number of messages within the hour. */
public function rateLimited(string $senderHash): bool
{
+2 -2
View File
@@ -32,7 +32,7 @@ final class PageRepository
throw new InvalidPage('Unbekannter Inhaltstyp.');
}
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document);
$this->validator->validate($document, writing: true);
return $this->db->transactional(function () use ($slug, $parentId, $type, $document, $locale): string {
$path = $slug;
@@ -334,7 +334,7 @@ final class PageRepository
// Sanitising happens before validating and before storing, so the database never holds
// markup the allowlist would reject and no renderer has to remember to filter.
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document);
$this->validator->validate($document, writing: true);
return $this->db->transactional(function () use ($pageId, $document, $expectedVersion): string {
$id = bin2hex(random_bytes(16));
+67 -2
View File
@@ -99,7 +99,7 @@ final class PartialRepository
$slug = 'default';
}
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document, false);
$this->validator->validate($document, false, true);
if (null !== $this->findByKind($kind, $slug, $locale)) {
throw new InvalidPage(sprintf('„%s“ ist als Adresse bereits vergeben.', $slug));
}
@@ -117,7 +117,7 @@ final class PartialRepository
public function save(string $id, array $document, int $expectedVersion): void
{
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document, false);
$this->validator->validate($document, false, true);
$this->db->transactional(function () use ($id, $document, $expectedVersion): void {
// A section must not end up containing itself, directly or through another section.
$this->guardAgainstCycles($id, $document);
@@ -179,6 +179,71 @@ final class PartialRepository
}
/** @return list<string> */
/**
* Dieselbe Sektion mehrfach auf einer Seite: die zweite und jede weitere Verknüpfung bekommt
* eine Kopie mit eigenen Kennungen (`id-2`, `id-3`) und ohne Sprungziele, damit die Seite keine
* doppelten IDs trägt - ein Anker, ein Stil und jede Skriptsuche träfen sonst nur die erste.
*
* Gezählt wird in Renderreihenfolge über alle Dokumente einer Seite hinweg (`$seen` teilen sich
* Kopf, Inhalt und Fuß); verschachtelte Verknüpfungen in einer Sektion zählen mit, bis zur
* Verschachtelungsgrenze. `$sections` bekommt die Kopien unter `slug~n`, der Verweis im
* zurückgegebenen Dokument zeigt dorthin.
*
* @param array<string, array> $sections die aufgelösten Sektionen, werden um Kopien ergänzt
* @param array<string, int> $seen Zähler je Slug
* @param array<string, array> $pristine die Sektionen, wie sie gespeichert sind
*/
public function expandRepeatedLinks(array $document, array &$sections, array &$seen, array $pristine, int $depth = 0): array
{
$walk = function (array $blocks) use (&$walk, &$sections, &$seen, $pristine, $depth): array {
foreach ($blocks as $i => $block) {
$slug = 'partialref' === ($block['type'] ?? null) && is_string($block['props']['partial'] ?? null) ? $block['props']['partial'] : null;
if (null !== $slug && isset($pristine[$slug])) {
$n = $seen[$slug] = ($seen[$slug] ?? 0) + 1;
if (1 === $n) {
if ($depth < self::MAX_NESTING) {
$sections[$slug] = $this->expandRepeatedLinks($pristine[$slug], $sections, $seen, $pristine, $depth + 1);
}
} else {
$key = $slug.'~'.$n;
$copy = $this->renamed($pristine[$slug], '-'.$n);
$sections[$key] = $depth < self::MAX_NESTING ? $this->expandRepeatedLinks($copy, $sections, $seen, $pristine, $depth + 1) : $copy;
$blocks[$i]['props']['partial'] = $key;
}
}
if (isset($block['children']) && is_array($block['children'])) {
$blocks[$i]['children'] = $walk($block['children']);
}
}
return $blocks;
};
$document['blocks'] = $walk($document['blocks'] ?? []);
return $document;
}
/** Ein Dokument mit angehängter Kennung an jedem Block, ohne Sprungziele. */
private function renamed(array $document, string $suffix): array
{
$walk = static function (array $blocks) use (&$walk, $suffix): array {
foreach ($blocks as $i => $block) {
if (isset($block['id'])) {
$blocks[$i]['id'] = substr((string) $block['id'], 0, 64 - strlen($suffix)).$suffix;
}
unset($blocks[$i]['anchor']);
if (isset($block['children']) && is_array($block['children'])) {
$blocks[$i]['children'] = $walk($block['children']);
}
}
return $blocks;
};
$document['blocks'] = $walk($document['blocks'] ?? []);
return $document;
}
public function referencedSlugs(array $document): array
{
$slugs = [];
+26
View File
@@ -311,6 +311,21 @@ final class Theme
return null;
}
/** Die relative Leuchtdichte einer Hexfarbe nach WCAG; 0 fuer Schwarz, 1 fuer Weiss, 0.5 wenn unlesbar. */
public static function luminance(string $hex): float
{
if (1 !== preg_match('/^#([0-9a-f]{6})/i', $hex, $m)) {
return 0.5;
}
$kanal = static function (int $wert): float {
$c = $wert / 255;
return $c <= 0.03928 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * $kanal(hexdec(substr($m[1], 0, 2))) + 0.7152 * $kanal(hexdec(substr($m[1], 2, 2))) + 0.0722 * $kanal(hexdec(substr($m[1], 4, 2)));
}
public function css(array $theme): string
{
$variables = [];
@@ -344,6 +359,17 @@ final class Theme
$variables[] = sprintf('--muted:var(--k-muted,color-mix(in srgb,%s 80%%,%s))', $ink, $paper);
$variables[] = sprintf('--line:var(--k-line,color-mix(in srgb,%s 20%%,%s))', $ink, $paper);
$variables[] = sprintf('--mint:var(--k-mint,color-mix(in srgb,%s 14%%,%s))', $colour($base['linkColor']), $paper);
// Dunkle Palette: Formularfelder, Rollbalken und Fehlermeldungen folgen ihr. Das Rot fuer
// Fehler war fest auf Weiss gedacht; auf dunklem Grund ist es unlesbar. Also je nach
// Helligkeit des Papiers ein Rot, das dort steht - und der Hintergrund der Meldung aus
// Rot und Papier gemischt statt aus einem festen Hellrosa.
// Fuer die Leuchtdichte braucht es den Hexwert, nicht den Verweis var(--k-...).
$hexwert = static fn (string $wert): string => str_starts_with($wert, '@') ? (string) (array_column($theme['colors'], 'value', 'key')[substr($wert, 1)] ?? '#ffffff') : $wert;
$dunkel = self::luminance($hexwert($base['backgroundColor'])) < 0.4;
$variables[] = '--k-scheme:'.($dunkel ? 'dark' : 'light');
$variables[] = '--k-error:'.($dunkel ? '#ff9b8a' : '#8b2f22');
$variables[] = sprintf('--k-error-bg:color-mix(in srgb,%s 12%%,%s)', $dunkel ? '#ff9b8a' : '#8b2f22', $hexwert($base['backgroundColor']));
$variables[] = sprintf('--k-error-line:color-mix(in srgb,%s 45%%,%s)', $dunkel ? '#ff9b8a' : '#8b2f22', $hexwert($base['backgroundColor']));
$scale = (float) $base['headingScale'];
$headings = '';