Files
KantSites/src/Content/Theme.php
T
kantarciandClaude Fable 5.2 ead47cc924 Keep old pages readable, detect stale settings writes, make backups honest
- Own CSS is parsed on write only. A page whose stored CSS no longer
  passes a stricter rule answers 200 and opens in the editor; that one
  block loses its CSS. A document read several times in one request is
  validated once.
- Settings and theme refuse a write from an outdated form.
- A backup fails when a media file does not arrive completely and
  leaves no half directory behind; rotation counts intact backups only.
- The archive update takes its backup behind the maintenance page.
- A package import puts all media in place before removing the old
  ones, reports honestly when that fails and keeps a maintenance state
  that was already on.
- Restoring replaces the SQLite file in one move and points at pending
  migrations.
- Six irreversible actions ask first, in a dialog of the admin's own.
- Ctrl+S in the editor takes the field being typed in along; Delete
  no longer removes a block while a dialog is open.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:41:05 +01:00

420 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Content;
use App\Domain\InvalidPage;
use Doctrine\DBAL\Connection;
/**
* The site wide design system: a free colour palette, font families and the base typography every
* page inherits. This is the "Haupt-Typografie" – set once here, valid everywhere, overridable on
* any block. Stored in the website row that has carried a theme column since the first migration.
*/
final class Theme
{
public const MAX_COLORS = 40;
public const MAX_FONTS = 8;
public const MAX_PRESETS = 40;
/** Weights Google serves for most families; also the range a designer realistically needs. */
public const WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
public const FALLBACKS = [
'sans-serif' => 'Serifenlos',
'serif' => 'Serifen',
'monospace' => 'Feste Breite',
'system-ui' => 'Systemschrift',
];
/**
* Fertige Paletten zum Übernehmen. Dieselben fünf Schlüssel wie die Voreinstellung, damit
* Grundfarben und Blöcke, die auf die Schlüssel ink oder link zeigen, stimmen nach dem Wechsel weiter.
* Die Linkfarbe erreicht auf dem Papierton jeweils mindestens 4,5:1 - eine Palette, die
* ihre eigenen Links unlesbar macht, wäre keine Hilfe.
*/
public const PALETTES = [
['name' => 'Wald', 'colors' => ['paper' => '#f6f7f4', 'ink' => '#203b34', 'mint' => '#e4efb9', 'accent' => '#b55b12', 'link' => '#a4510f']],
['name' => 'Ozean', 'colors' => ['paper' => '#f5f8fa', 'ink' => '#12283a', 'mint' => '#d6ecf3', 'accent' => '#0e7490', 'link' => '#0b5f78']],
['name' => 'Sand', 'colors' => ['paper' => '#faf6ef', 'ink' => '#3b2f24', 'mint' => '#f1e4cf', 'accent' => '#b8672a', 'link' => '#9a5320']],
['name' => 'Koralle', 'colors' => ['paper' => '#fffaf8', 'ink' => '#2b2320', 'mint' => '#ffe4dc', 'accent' => '#d9483b', 'link' => '#b83a2f']],
['name' => 'Schiefer', 'colors' => ['paper' => '#f4f5f7', 'ink' => '#1f2937', 'mint' => '#e2e8f0', 'accent' => '#4f46e5', 'link' => '#4338ca']],
['name' => 'Olive', 'colors' => ['paper' => '#f7f7f2', 'ink' => '#2a2f23', 'mint' => '#e6ead3', 'accent' => '#6b7a2e', 'link' => '#55621f']],
['name' => 'Nacht', 'colors' => ['paper' => '#14181d', 'ink' => '#eef1f4', 'mint' => '#262e38', 'accent' => '#7cc4ff', 'link' => '#9ad2ff']],
];
/** Die Namen der Palettenfarben, wie sie in einer neuen Palette heißen. */
public const COLOR_NAMES = ['paper' => 'Papier', 'ink' => 'Tinte', 'mint' => 'Mint', 'accent' => 'Akzent', 'link' => 'Links'];
public const DEFAULTS = [
'colors' => [
['key' => 'paper', 'name' => 'Papier', 'value' => '#f6f7f4'],
['key' => 'ink', 'name' => 'Tinte', 'value' => '#203b34'],
['key' => 'mint', 'name' => 'Mint', 'value' => '#e4efb9'],
['key' => 'accent', 'name' => 'Akzent', 'value' => '#b55b12'],
// Darker sibling of the accent: #b55b12 reaches only 4.37:1 on the light tones and
// would fail AA for normal text. This one passes on paper, mint and white.
['key' => 'link', 'name' => 'Links', 'value' => '#a4510f'],
],
'fonts' => [
['key' => 'body', 'name' => 'Fließtext', 'family' => '', 'source' => 'system', 'weights' => [400, 600], 'fallback' => 'system-ui'],
['key' => 'heading', 'name' => 'Überschriften', 'family' => '', 'source' => 'system', 'weights' => [500, 700], 'fallback' => 'system-ui'],
],
'base' => [
'fontFamily' => 'body',
'headingFontFamily' => 'heading',
'fontSize' => '18px',
'lineHeight' => 1.6,
'color' => '@ink',
'backgroundColor' => '@paper',
'linkColor' => '@link',
'containerWidth' => '1240px',
'headingScale' => 1.25,
// Die Ecken jedes Bildes auf der Website. Stand bis 1.20.2 fest im Stylesheet -
// sichtbar gerundet, aber in keinem Feld zu finden. 12px bleibt die Vorgabe,
// damit bestehende Websites gleich aussehen; 0px macht Bilder eckig.
'imageRadius' => '12px',
// Unterstrichene Links im Fließtext. An, weil ein Link, den nur die Farbe verrät,
// für Menschen mit Farbsehschwäche kein Link ist. Wer eine ruhigere Seite will,
// schaltet es hier ab - dann bleibt der Unterstrich beim Überfahren.
'linkUnderline' => true,
],
];
public function __construct(private Connection $db)
{
}
/** Remembered for one request, like the settings; dropped on every write. */
private ?array $loaded = null;
private bool $buttonsConfigured = false;
public function load(): array
{
if (null !== $this->loaded) {
return $this->loaded;
}
$raw = $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']);
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
$this->buttonsConfigured = is_array($stored['buttons'] ?? null);
if (!is_array($stored) || !isset($stored['colors'], $stored['fonts'], $stored['base'])) {
return $this->loaded = [...self::DEFAULTS, 'presets' => [], 'buttons' => ButtonDesign::defaults(self::DEFAULTS['base'])];
}
return $this->loaded = [
'colors' => $stored['colors'],
'fonts' => $stored['fonts'],
'base' => [...self::DEFAULTS['base'], ...$stored['base']],
'presets' => is_array($stored['presets'] ?? null) ? $stored['presets'] : [],
'buttons' => [...ButtonDesign::defaults([...self::DEFAULTS['base'], ...$stored['base']]), ...($stored['buttons'] ?? [])],
];
}
/** Kennung des gespeicherten Stands, wie SiteSettings::stamp(). */
public function stamp(): string
{
return hash('xxh128', (string) $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']));
}
/** @param string|null $expected die Kennung aus stamp(), als das Formular geladen wurde; null prüft nicht */
public function save(array $colors, array $fonts, array $base, ?array $buttons = null, ?string $expected = null): void
{
$theme = [
'colors' => $this->colors($colors),
'fonts' => $this->fonts($fonts),
'base' => $this->base($base),
// Saving the palette must not drop the presets an editor has collected.
'presets' => $this->load()['presets'],
];
if (null !== $buttons || $this->buttonsConfigured) {
$theme['buttons'] = ButtonDesign::validate($buttons ?? $this->load()['buttons'], $theme['base'], $theme['colors'], $theme['fonts']);
}
$keys = array_column($theme['colors'], 'key');
foreach (['color', 'backgroundColor', 'linkColor'] as $field) {
$value = $theme['base'][$field];
if (str_starts_with($value, '@') && !in_array(substr($value, 1), $keys, true)) {
throw new InvalidPage(sprintf('Die Grundfarbe verweist auf „%s“, das es in der Palette nicht gibt.', substr($value, 1)));
}
}
foreach (['fontFamily', 'headingFontFamily'] as $field) {
if (!in_array($theme['base'][$field], array_column($theme['fonts'], 'key'), true)) {
throw new InvalidPage('Die Grundschrift verweist auf eine Schrift, die es nicht gibt.');
}
}
$this->db->transactional(function () use ($theme, $expected): void {
if (null !== $expected) {
$lock = $this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SQLitePlatform ? '' : ' FOR UPDATE';
if (!hash_equals(hash('xxh128', (string) $this->db->fetchOne('SELECT theme FROM website WHERE id = ?'.$lock, ['default'])), $expected)) {
throw new \App\Domain\StaleWrite('Das Design wurde inzwischen an anderer Stelle geändert – etwa durch eine im Editor gespeicherte Stilvorlage. Die Seite zeigt jetzt den aktuellen Stand – bitte trage deine Änderung dort noch einmal ein.');
}
}
$this->db->executeStatement('UPDATE website SET theme = ? WHERE id = ?', [
json_encode($theme, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
});
// After the write, never before: saving reads the theme again, and an early reset
// would only refill the memo with the state that is about to be replaced.
$this->loaded = null;
}
private function colors(array $input): array
{
$colors = [];
$seen = [];
foreach ($input as $row) {
$key = strtolower(trim((string) ($row['key'] ?? '')));
$value = trim((string) ($row['value'] ?? ''));
if ('' === $key) {
continue;
}
if (1 !== preg_match('/^[a-z][a-z0-9-]{0,31}$/D', $key) || isset($seen[$key])) {
throw new InvalidPage('Jede Farbe braucht einen eindeutigen Schlüssel aus Kleinbuchstaben, Ziffern und Bindestrich.');
}
if (1 !== preg_match('/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/D', $value)) {
throw new InvalidPage(sprintf('„%s“ ist kein Farbwert wie #1a2b3c.', $value));
}
$seen[$key] = true;
$colors[] = ['key' => $key, 'name' => mb_substr(trim((string) ($row['name'] ?? $key)), 0, 60) ?: $key, 'value' => strtolower($value)];
}
if ([] === $colors) {
throw new InvalidPage('Die Palette braucht mindestens eine Farbe.');
}
if (count($colors) > self::MAX_COLORS) {
throw new InvalidPage(sprintf('Mehr als %d Farben sind nicht vorgesehen.', self::MAX_COLORS));
}
return $colors;
}
private function fonts(array $input): array
{
$fonts = [];
$seen = [];
foreach ($input as $row) {
$key = strtolower(trim((string) ($row['key'] ?? '')));
if ('' === $key) {
continue;
}
if (1 !== preg_match('/^[a-z][a-z0-9-]{0,39}$/D', $key) || isset($seen[$key])) {
throw new InvalidPage('Jede Schrift braucht einen eindeutigen Schlüssel aus Kleinbuchstaben, Ziffern und Bindestrich.');
}
$family = trim((string) ($row['family'] ?? ''));
// Only a family name, never a font stack: the fallback is chosen separately, so no
// quoting or comma can travel into the generated CSS.
if ('' !== $family && 1 !== preg_match('/^[A-Za-z0-9][A-Za-z0-9 ]{0,48}$/D', $family)) {
throw new InvalidPage(sprintf('„%s“ ist kein einfacher Schriftname.', $family));
}
$source = (string) ($row['source'] ?? 'system');
if (!in_array($source, ['system', 'google'], true)) {
throw new InvalidPage('Als Quelle sind Systemschrift und Google Fonts vorgesehen.');
}
if ('google' === $source && '' === $family) {
throw new InvalidPage('Für eine Google-Schrift wird ein Name benötigt, zum Beispiel Inter.');
}
$fallback = (string) ($row['fallback'] ?? 'system-ui');
if (!isset(self::FALLBACKS[$fallback])) {
throw new InvalidPage('Unbekannte Ersatzschrift.');
}
$weights = array_values(array_intersect(self::WEIGHTS, array_map('intval', (array) ($row['weights'] ?? [400]))));
$seen[$key] = true;
$fonts[] = [
'key' => $key,
'name' => mb_substr(trim((string) ($row['name'] ?? $key)), 0, 60) ?: $key,
'family' => $family,
'source' => $source,
'weights' => [] === $weights ? [400] : $weights,
'fallback' => $fallback,
];
}
if ([] === $fonts) {
throw new InvalidPage('Es wird mindestens eine Schrift benötigt.');
}
if (count($fonts) > self::MAX_FONTS) {
throw new InvalidPage(sprintf('Mehr als %d Schriften sind nicht vorgesehen.', self::MAX_FONTS));
}
return $fonts;
}
private function base(array $input): array
{
$base = [...self::DEFAULTS['base']];
foreach (['fontFamily', 'headingFontFamily'] as $field) {
$base[$field] = strtolower(trim((string) ($input[$field] ?? $base[$field])));
}
foreach (['color', 'backgroundColor', 'linkColor'] as $field) {
$value = trim((string) ($input[$field] ?? $base[$field]));
if (1 !== preg_match(StyleSchema::COLOR, $value)) {
throw new InvalidPage(sprintf('„%s“ ist kein Farbwert.', $value));
}
$base[$field] = $value;
}
foreach (['fontSize', 'containerWidth', 'imageRadius'] as $field) {
$value = trim((string) ($input[$field] ?? $base[$field]));
if (1 !== preg_match(StyleSchema::LENGTH, $value)) {
throw new InvalidPage(sprintf('„%s“ ist keine Größenangabe wie 18px oder 70rem.', $value));
}
$base[$field] = $value;
}
$base['linkUnderline'] = (bool) ($input['linkUnderline'] ?? false);
foreach (['lineHeight' => [0.8, 3.0], 'headingScale' => [1.0, 2.0]] as $field => [$min, $max]) {
$value = (float) ($input[$field] ?? $base[$field]);
if ($value < $min || $value > $max) {
throw new InvalidPage(sprintf('„%s“ liegt außerhalb des zulässigen Bereichs.', $field));
}
$base[$field] = $value;
}
return $base;
}
/**
* Stores a reusable style under a name, or removes it when the style is null. The style is
* validated with the same compiler the documents use, so a preset can never carry a value a
* block would be refused for.
*/
public function savePreset(string $name, ?array $style, StyleCompiler $compiler): void
{
$name = trim($name);
if ('' === $name || mb_strlen($name) > 60) {
throw new InvalidPage('Eine Vorlage braucht einen Namen mit höchstens 60 Zeichen.');
}
$key = trim(preg_replace('/[^a-z0-9]+/', '-', mb_strtolower($name, 'UTF-8')) ?? '', '-');
if ('' === $key) {
throw new InvalidPage('Aus diesem Namen lässt sich kein Schlüssel bilden.');
}
if (null !== $style) {
$compiler->validate($style);
}
$theme = $this->load();
if (!$this->buttonsConfigured) {
unset($theme['buttons']);
}
$presets = array_values(array_filter($theme['presets'], static fn (array $p): bool => $p['key'] !== $key));
if (null !== $style) {
if (count($presets) >= self::MAX_PRESETS) {
throw new InvalidPage(sprintf('Mehr als %d Vorlagen sind nicht vorgesehen.', self::MAX_PRESETS));
}
$presets[] = ['key' => $key, 'name' => $name, 'style' => $style];
}
usort($presets, static fn (array $a, array $b): int => strcmp($a['name'], $b['name']));
$this->db->executeStatement('UPDATE website SET theme = ? WHERE id = ?', [
json_encode([...$theme, 'presets' => $presets], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
// After the write, never before: saving reads the theme again, and an early reset
// would only refill the memo with the state that is about to be replaced.
$this->loaded = null;
}
/** The stylesheet every page carries: palette variables, font variables and base typography. */
/**
* Eine Farbe aus den Einstellungen als CSS-Wert: `@schlüssel` wird zur Palettenvariablen,
* ein Hexwert bleibt, und leer heißt "nicht gesetzt" - dann gilt der Standard des Stylesheets.
* Ein Schlüssel, den die Palette nicht kennt, wird ebenfalls zu null: lieber der Standard als
* eine Variable, die auf nichts zeigt.
*/
public static function cssColor(string $value, array $theme): ?string
{
if ('' === $value) {
return null;
}
if (!str_starts_with($value, '@')) {
return $value;
}
foreach ($theme['colors'] as $color) {
if ($color['key'] === substr($value, 1)) {
return sprintf('var(--k-%s)', $color['key']);
}
}
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 = [];
foreach ($theme['colors'] as $color) {
$variables[] = sprintf('--k-%s:%s', $color['key'], $color['value']);
}
foreach ($theme['fonts'] as $font) {
$stack = '' === $font['family'] ? $font['fallback'] : sprintf('"%s",%s', $font['family'], $font['fallback']);
$variables[] = sprintf('--kf-%s:%s', $font['key'], $stack);
}
$base = $theme['base'];
$variables[] = sprintf('--k-container:%s', $base['containerWidth']);
$variables[] = sprintf('--k-image-radius:%s', $base['imageRadius']);
$colour = static fn (string $v): string => str_starts_with($v, '@') ? sprintf('var(--k-%s)', substr($v, 1)) : $v;
// Die Bausteine selbst - Knopf, Symbol, Rahmen, ruhiger Text - wurden aus festen Werten in
// site.css gefaerbt. Eine Website mit eigener Palette behielt damit das Gruen der Vorlage:
// die Farben liessen sich einstellen und taten nichts. Hier bekommen diese Token ihre
// Werte aus dem Theme. Als Akzent dient die Linkfarbe und nicht ein Palettenname, weil
// dieses Token auch kleine Beschriftungen faerbt - eine helle Akzentfarbe waere dort
// nicht mehr lesbar.
$ink = $colour($base['color']);
$paper = $colour($base['backgroundColor']);
$variables[] = sprintf('--ink:%s', $ink);
$variables[] = sprintf('--paper:%s', $paper);
$variables[] = sprintf('--accent:%s', $colour($base['linkColor']));
// Abgeleitet statt erfunden: ruhiger Text und Linien sind der Textton, in den Hintergrund
// gemischt. Ein Palettenwert gleichen Namens hat Vorrang, damit sich beides setzen laesst.
// 80 Prozent, nicht weniger: bei 72 kam fuer eine Palette mit hellem Textton ein Grau
// heraus, das die 4,5:1 knapp verfehlte - gefunden beim Nachbau von kant-it.de.
$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 = '';
// A modular scale from the base size, so one number changes the whole hierarchy.
foreach ([1 => 4, 2 => 3, 3 => 2, 4 => 1, 5 => 0, 6 => -1] as $level => $step) {
$headings .= sprintf('.kant h%d{font-size:calc(var(--k-size) * %.4f)}', $level, $scale ** $step);
}
return sprintf(
':root{%s;--k-size:%s}.kant{font-family:var(--kf-%s);font-size:var(--k-size);line-height:%s;color:%s;background:%s}'
.'.kant h1,.kant h2,.kant h3,.kant h4,.kant h5,.kant h6{font-family:var(--kf-%s)}'
.'.kant .block-text a,.kant .block-list a,.kant .post-excerpt a{color:%s;text-decoration:%s}'
.'.kant .block-text a:hover,.kant .block-list a:hover,.kant .post-excerpt a:hover{text-decoration:underline}'
.'.kant .shell{max-width:var(--k-container)}%s',
implode(';', $variables),
$base['fontSize'],
$base['fontFamily'],
$base['lineHeight'],
$colour($base['color']),
$colour($base['backgroundColor']),
$base['headingFontFamily'],
$colour($base['linkColor']),
($base['linkUnderline'] ?? true) ? 'underline' : 'none',
$headings,
).ButtonDesign::css($theme['buttons'] ?? ButtonDesign::defaults($base));
}
}