Freie Gestaltung statt Tokenlisten (Dokumentschema v10)
Der bisherige Editor bot drei Farben, keine Schriftwahl und elf Auswahllisten mit 44 festen Werten. ADR 008 hatte das mit Sicherheit begründet - der Schluss war falsch: die Alternative zu beliebigem CSS-Text ist nicht eine Auswahlliste, sondern ein typisiertes Designmodell. StyleSchema erklärt jede Eigenschaft einmal mit Typ, CSS-Entsprechung und Grenzen. Validator, CSS-Erzeugung und Editorpanel lesen dieselbe Definition, also kann der Editor nichts anbieten, was der Server ablehnt. Rund fünfzig Eigenschaften in sieben Gruppen, je Block, je Gerät und für den Hover-Zustand, mit Vererbung und einzelnem Zurücksetzen. Werte sind kurze Zeichenketten mit verankertem Muster, nie CSS-Text; zusammengesetzte Deklarationen baut der Compiler selbst. Der Server erzeugt daraus pro Seite ein blockgenaues Stylesheet, das über seinen eigenen SHA-256-Hash erlaubt wird statt über unsafe-inline. Theme unter /admin/theme: freie Farbpalette als @schlüssel, Schriften aus Systemschrift oder Google Fonts, Grundtypografie mit modularer Überschriftenskala. Google Fonts auf ausdrückliche Nutzerentscheidung; die Richtlinie öffnet die Hosts nur, wenn wirklich eine eingestellt ist. Alte Tokenwerte aus v2 bis v9 bleiben gültig und im Editor unter "Klassische Werte dieser Seite" bearbeitbar. Geprüft: 154 PHP-Tests / 563 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, Lint/Format/Container. Im Browser wurde nur der Aufbau des Panels bestätigt, kein Gestaltungsvorgang von Hand ausgeführt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a40c2bb27e
commit
abf0bd2c6b
24 files changed
+1591
-21
No files matched your search
@@ -6,6 +6,10 @@ use App\Domain\InvalidDocument;
|
||||
|
||||
final class DocumentValidator
|
||||
{
|
||||
public function __construct(private StyleCompiler $styles = new StyleCompiler())
|
||||
{
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
@@ -21,7 +25,7 @@ final class DocumentValidator
|
||||
public function validate(array $document): void
|
||||
{
|
||||
$schemaVersion = $document['schemaVersion'] ?? null;
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9], true), 'Unsupported document schema.');
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 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.');
|
||||
@@ -51,6 +55,12 @@ final class DocumentValidator
|
||||
// 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.');
|
||||
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.');
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidDocument;
|
||||
|
||||
/**
|
||||
* Validates free block styles and compiles them into CSS.
|
||||
*
|
||||
* Both directions read StyleSchema, so the editor, the stored document and the rendered page
|
||||
* always agree. Nothing here ever concatenates user text into CSS: every value is matched against
|
||||
* an anchored pattern or a fixed list first, and only then placed into a declaration that this
|
||||
* class writes itself.
|
||||
*/
|
||||
final class StyleCompiler
|
||||
{
|
||||
private const BREAKPOINTS = ['tablet' => 1024, 'mobile' => 767];
|
||||
|
||||
public function __construct(private ?MediaLibrary $media = null)
|
||||
{
|
||||
}
|
||||
|
||||
/** @throws InvalidDocument */
|
||||
public function validate(mixed $style): void
|
||||
{
|
||||
$this->require(is_array($style), 'Style must be an object.');
|
||||
$properties = StyleSchema::properties();
|
||||
$hoverable = StyleSchema::hoverable();
|
||||
foreach ($style as $device => $values) {
|
||||
$this->require(isset(StyleSchema::DEVICES[$device]) && is_array($values), 'Unknown style device.');
|
||||
foreach ($values as $key => $value) {
|
||||
if ('hover' === $key) {
|
||||
$this->require(is_array($value), 'Hover styles must be an object.');
|
||||
foreach ($value as $hoverKey => $hoverValue) {
|
||||
$this->require(in_array($hoverKey, $hoverable, true), sprintf('The property "%s" has no hover value.', (string) $hoverKey));
|
||||
$this->value($properties[$hoverKey], $hoverValue, (string) $hoverKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$this->require(is_string($key) && isset($properties[$key]), sprintf('Unknown style property "%s".', (string) $key));
|
||||
$this->value($properties[$key], $value, $key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function value(array $property, mixed $value, string $key): void
|
||||
{
|
||||
if (($property['sides'] ?? false) || ($property['corners'] ?? false)) {
|
||||
$parts = ($property['corners'] ?? false) ? StyleSchema::corners() : StyleSchema::sides();
|
||||
$this->require(is_array($value), sprintf('"%s" is given per part.', $key));
|
||||
foreach ($value as $part => $single) {
|
||||
$this->require(isset($parts[$part]), sprintf('Unknown part for "%s".', $key));
|
||||
$this->scalar($property, $single, $key);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
$this->scalar($property, $value, $key);
|
||||
}
|
||||
|
||||
private function scalar(array $property, mixed $value, string $key): void
|
||||
{
|
||||
$this->require(is_string($value) || is_int($value) || is_float($value), sprintf('"%s" needs a simple value.', $key));
|
||||
$text = (string) $value;
|
||||
match ($property['type']) {
|
||||
'color' => $this->require(1 === preg_match(StyleSchema::COLOR, $text), sprintf('"%s" is not a colour.', $key)),
|
||||
'length' => $this->require(1 === preg_match(StyleSchema::LENGTH, $text), sprintf('"%s" is not a length.', $key)),
|
||||
'length-auto' => $this->require(1 === preg_match(StyleSchema::LENGTH_AUTO, $text), sprintf('"%s" is not a length.', $key)),
|
||||
'choice' => $this->require(isset($property['values'][$text]), sprintf('"%s" is not an allowed value for %s.', $text, $key)),
|
||||
'number' => $this->require(
|
||||
is_numeric($value) && (float) $value >= $property['min'] && (float) $value <= $property['max'],
|
||||
sprintf('"%s" is outside its range.', $key),
|
||||
),
|
||||
'font' => $this->require(1 === preg_match('/^[a-z][a-z0-9-]{0,39}$/D', $text), sprintf('"%s" is not a font key.', $key)),
|
||||
'media' => $this->require(1 === preg_match('/^[a-f0-9]{32}$/D', $text), sprintf('"%s" is not a media ID.', $key)),
|
||||
default => $this->require(false, 'Unknown style type.'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles every styled block of a document into one stylesheet.
|
||||
*
|
||||
* @return string CSS, already escaped by construction; empty when nothing is styled
|
||||
*/
|
||||
public function compileDocument(array $document): string
|
||||
{
|
||||
$css = '';
|
||||
$walk = function (array $blocks) use (&$walk, &$css): void {
|
||||
foreach ($blocks as $block) {
|
||||
if (is_array($block['style'] ?? null) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', (string) ($block['id'] ?? ''))) {
|
||||
$css .= $this->compileBlock((string) $block['id'], $block['style']);
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks'] ?? []);
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
private function compileBlock(string $id, array $style): string
|
||||
{
|
||||
$css = '';
|
||||
foreach (['desktop', 'tablet', 'mobile'] as $device) {
|
||||
$values = $style[$device] ?? null;
|
||||
if (!is_array($values)) {
|
||||
continue;
|
||||
}
|
||||
$rules = '';
|
||||
$base = $this->declarations($values);
|
||||
if ('' !== $base) {
|
||||
$rules .= sprintf('#%s{%s}', $id, $base);
|
||||
}
|
||||
$hover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover']) : '';
|
||||
if ('' !== $hover) {
|
||||
$rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover);
|
||||
}
|
||||
if ('' === $rules) {
|
||||
continue;
|
||||
}
|
||||
// Desktop first, narrower screens override it. Mobile last so it wins on small screens.
|
||||
$css .= isset(self::BREAKPOINTS[$device])
|
||||
? sprintf('@media (max-width:%dpx){%s}', self::BREAKPOINTS[$device], $rules)
|
||||
: $rules;
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
/** Turns one validated value set into CSS declarations. */
|
||||
private function declarations(array $values): string
|
||||
{
|
||||
$properties = StyleSchema::properties();
|
||||
$out = [];
|
||||
foreach ($values as $key => $value) {
|
||||
if ('hover' === $key || !isset($properties[$key])) {
|
||||
continue;
|
||||
}
|
||||
$property = $properties[$key];
|
||||
if (null === $property['css']) {
|
||||
continue; // Composed below out of several values.
|
||||
}
|
||||
if ($property['corners'] ?? false) {
|
||||
// border-radius becomes border-top-left-radius and friends.
|
||||
foreach ($value as $corner => $single) {
|
||||
$out[] = sprintf('border-%s-radius:%s', StyleSchema::cornerCss((string) $corner), $this->css($property, (string) $single));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($property['sides'] ?? false) {
|
||||
foreach ($value as $side => $single) {
|
||||
$out[] = sprintf('%s-%s:%s', $property['css'], $side, $this->css($property, (string) $single));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$out[] = sprintf('%s:%s', $property['css'], $this->css($property, (string) $value));
|
||||
}
|
||||
foreach ([$this->background($values), $this->shadow($values), $this->transform($values), $this->grid($values), $this->transition($values)] as $composed) {
|
||||
if ('' !== $composed) {
|
||||
$out[] = $composed;
|
||||
}
|
||||
}
|
||||
|
||||
return implode(';', $out);
|
||||
}
|
||||
|
||||
private function css(array $property, string $value): string
|
||||
{
|
||||
if ('color' === $property['type']) {
|
||||
return str_starts_with($value, '@') ? sprintf('var(--k-%s)', substr($value, 1)) : $value;
|
||||
}
|
||||
if ('font' === $property['type']) {
|
||||
return sprintf('var(--kf-%s)', $value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** Overlay, gradient and image share one background-image declaration, layered in that order. */
|
||||
private function background(array $values): string
|
||||
{
|
||||
$layers = [];
|
||||
$overlay = $values['overlayColor'] ?? null;
|
||||
if (null !== $overlay) {
|
||||
$colour = $this->css(['type' => 'color'], (string) $overlay);
|
||||
$alpha = isset($values['overlayOpacity']) ? (float) $values['overlayOpacity'] : 0.5;
|
||||
$layers[] = sprintf('linear-gradient(color-mix(in srgb,%s %d%%,transparent),color-mix(in srgb,%s %d%%,transparent))', $colour, (int) round($alpha * 100), $colour, (int) round($alpha * 100));
|
||||
}
|
||||
if (isset($values['gradientFrom'], $values['gradientTo'])) {
|
||||
$layers[] = sprintf(
|
||||
'linear-gradient(%ddeg,%s,%s)',
|
||||
(int) ($values['gradientAngle'] ?? 180),
|
||||
$this->css(['type' => 'color'], (string) $values['gradientFrom']),
|
||||
$this->css(['type' => 'color'], (string) $values['gradientTo']),
|
||||
);
|
||||
}
|
||||
if (isset($values['backgroundImage']) && null !== $this->media) {
|
||||
$item = $this->media->find((string) $values['backgroundImage']);
|
||||
if (null !== $item && [] !== $item['variants']) {
|
||||
$largest = end($item['variants']);
|
||||
// The URL comes from the media library, never from the document.
|
||||
$layers[] = sprintf('url("%s")', $largest['src']);
|
||||
}
|
||||
}
|
||||
|
||||
return [] === $layers ? '' : 'background-image:'.implode(',', $layers);
|
||||
}
|
||||
|
||||
private function shadow(array $values): string
|
||||
{
|
||||
$parts = array_intersect_key($values, array_flip(['shadowX', 'shadowY', 'shadowBlur', 'shadowSpread', 'shadowColor']));
|
||||
if ([] === $parts) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'box-shadow:%s %s %s %s %s',
|
||||
$values['shadowX'] ?? '0',
|
||||
$values['shadowY'] ?? '0',
|
||||
$values['shadowBlur'] ?? '0',
|
||||
$values['shadowSpread'] ?? '0',
|
||||
$this->css(['type' => 'color'], (string) ($values['shadowColor'] ?? '#00000026')),
|
||||
);
|
||||
}
|
||||
|
||||
private function transform(array $values): string
|
||||
{
|
||||
$parts = [];
|
||||
foreach (['translateX' => 'translateX(%s)', 'translateY' => 'translateY(%s)'] as $key => $format) {
|
||||
if (isset($values[$key])) {
|
||||
$parts[] = sprintf($format, $values[$key]);
|
||||
}
|
||||
}
|
||||
if (isset($values['scale'])) {
|
||||
$parts[] = sprintf('scale(%s)', (float) $values['scale']);
|
||||
}
|
||||
if (isset($values['rotate'])) {
|
||||
$parts[] = sprintf('rotate(%ddeg)', (int) $values['rotate']);
|
||||
}
|
||||
|
||||
return [] === $parts ? '' : 'transform:'.implode(' ', $parts);
|
||||
}
|
||||
|
||||
private function grid(array $values): string
|
||||
{
|
||||
return isset($values['gridColumns'])
|
||||
? sprintf('grid-template-columns:repeat(%d,minmax(0,1fr))', max(1, min(12, (int) $values['gridColumns'])))
|
||||
: '';
|
||||
}
|
||||
|
||||
private function transition(array $values): string
|
||||
{
|
||||
return isset($values['transition'])
|
||||
? sprintf('transition:all %dms ease', max(0, min(2000, (int) $values['transition'])))
|
||||
: '';
|
||||
}
|
||||
|
||||
private function require(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new InvalidDocument($message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
/**
|
||||
* The single source of truth for free block styling.
|
||||
*
|
||||
* Every property is declared once here with its type, its CSS counterpart and its limits. The
|
||||
* validator, the CSS generator and the editor panel all read this definition, so the three can
|
||||
* never drift apart: what the editor offers is exactly what the server accepts and renders.
|
||||
*
|
||||
* Values are stored as short strings ("18px", "#1a2b3c", "flex"), never as CSS text. Each type has
|
||||
* an anchored pattern or a fixed list, so no declaration can escape its property.
|
||||
*/
|
||||
final class StyleSchema
|
||||
{
|
||||
public const DEVICES = ['desktop' => 'Desktop', 'tablet' => 'Tablet', 'mobile' => 'Mobil'];
|
||||
/** A colour is a hex value or "@name", a reference into the site palette. */
|
||||
public const COLOR = '/^(#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?|@[a-z][a-z0-9-]{0,31})$/D';
|
||||
/** A length is a number with a unit, plus the two keywords that behave like one. */
|
||||
public const LENGTH = '/^(0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch))$/D';
|
||||
public const LENGTH_AUTO = '/^(auto|none|0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch))$/D';
|
||||
|
||||
/**
|
||||
* group – where the property appears in the editor
|
||||
* type – length | length-auto | color | number | choice | text
|
||||
* css – the CSS property it becomes; sides/corners expand to four
|
||||
* label – German label for the editor.
|
||||
*
|
||||
* @return array<string, array{group: string, type: string, css: string|array, label: string, values?: array<string, string>, min?: float, max?: float, step?: float, sides?: bool, hover?: bool}>
|
||||
*/
|
||||
public static function properties(): array
|
||||
{
|
||||
return [
|
||||
// ---- Typografie -------------------------------------------------------------
|
||||
'fontFamily' => ['group' => 'text', 'type' => 'font', 'css' => 'font-family', 'label' => 'Schriftart'],
|
||||
'fontSize' => ['group' => 'text', 'type' => 'length', 'css' => 'font-size', 'label' => 'Schriftgröße'],
|
||||
'fontWeight' => ['group' => 'text', 'type' => 'choice', 'css' => 'font-weight', 'label' => 'Schriftstärke', 'values' => [
|
||||
'100' => '100 · Thin', '200' => '200 · Extra Light', '300' => '300 · Light', '400' => '400 · Regular',
|
||||
'500' => '500 · Medium', '600' => '600 · Semibold', '700' => '700 · Bold', '800' => '800 · Extra Bold', '900' => '900 · Black',
|
||||
]],
|
||||
'fontStyle' => ['group' => 'text', 'type' => 'choice', 'css' => 'font-style', 'label' => 'Schriftlage', 'values' => ['normal' => 'Normal', 'italic' => 'Kursiv']],
|
||||
'lineHeight' => ['group' => 'text', 'type' => 'number', 'css' => 'line-height', 'label' => 'Zeilenhöhe', 'min' => 0.7, 'max' => 4, 'step' => 0.05],
|
||||
'letterSpacing' => ['group' => 'text', 'type' => 'length', 'css' => 'letter-spacing', 'label' => 'Buchstabenabstand'],
|
||||
'wordSpacing' => ['group' => 'text', 'type' => 'length', 'css' => 'word-spacing', 'label' => 'Wortabstand'],
|
||||
'textAlign' => ['group' => 'text', 'type' => 'choice', 'css' => 'text-align', 'label' => 'Ausrichtung', 'values' => [
|
||||
'left' => 'Links', 'center' => 'Mittig', 'right' => 'Rechts', 'justify' => 'Blocksatz',
|
||||
]],
|
||||
'textTransform' => ['group' => 'text', 'type' => 'choice', 'css' => 'text-transform', 'label' => 'Schreibweise', 'values' => [
|
||||
'none' => 'Wie eingegeben', 'uppercase' => 'GROSSBUCHSTABEN', 'lowercase' => 'kleinbuchstaben', 'capitalize' => 'Erster Buchstabe Groß',
|
||||
]],
|
||||
'textDecoration' => ['group' => 'text', 'type' => 'choice', 'css' => 'text-decoration-line', 'label' => 'Textlinie', 'values' => [
|
||||
'none' => 'Keine', 'underline' => 'Unterstrichen', 'line-through' => 'Durchgestrichen',
|
||||
]],
|
||||
'color' => ['group' => 'text', 'type' => 'color', 'css' => 'color', 'label' => 'Textfarbe', 'hover' => true],
|
||||
|
||||
// ---- Hintergrund ------------------------------------------------------------
|
||||
'backgroundColor' => ['group' => 'background', 'type' => 'color', 'css' => 'background-color', 'label' => 'Hintergrundfarbe', 'hover' => true],
|
||||
'gradientFrom' => ['group' => 'background', 'type' => 'color', 'css' => null, 'label' => 'Verlauf von'],
|
||||
'gradientTo' => ['group' => 'background', 'type' => 'color', 'css' => null, 'label' => 'Verlauf bis'],
|
||||
'gradientAngle' => ['group' => 'background', 'type' => 'number', 'css' => null, 'label' => 'Verlaufswinkel', 'min' => 0, 'max' => 360, 'step' => 1],
|
||||
'backgroundImage' => ['group' => 'background', 'type' => 'media', 'css' => null, 'label' => 'Hintergrundbild'],
|
||||
'backgroundSize' => ['group' => 'background', 'type' => 'choice', 'css' => 'background-size', 'label' => 'Bildgröße', 'values' => [
|
||||
'cover' => 'Füllend', 'contain' => 'Vollständig sichtbar', 'auto' => 'Originalgröße',
|
||||
]],
|
||||
'backgroundPosition' => ['group' => 'background', 'type' => 'choice', 'css' => 'background-position', 'label' => 'Bildausschnitt', 'values' => [
|
||||
'center' => 'Mittig', 'top' => 'Oben', 'bottom' => 'Unten', 'left' => 'Links', 'right' => 'Rechts',
|
||||
'top left' => 'Oben links', 'top right' => 'Oben rechts', 'bottom left' => 'Unten links', 'bottom right' => 'Unten rechts',
|
||||
]],
|
||||
'backgroundRepeat' => ['group' => 'background', 'type' => 'choice', 'css' => 'background-repeat', 'label' => 'Wiederholung', 'values' => [
|
||||
'no-repeat' => 'Nicht wiederholen', 'repeat' => 'Kacheln', 'repeat-x' => 'Waagerecht kacheln', 'repeat-y' => 'Senkrecht kacheln',
|
||||
]],
|
||||
'backgroundAttachment' => ['group' => 'background', 'type' => 'choice', 'css' => 'background-attachment', 'label' => 'Scrollverhalten', 'values' => [
|
||||
'scroll' => 'Mitscrollen', 'fixed' => 'Feststehend (Parallax)',
|
||||
]],
|
||||
'overlayColor' => ['group' => 'background', 'type' => 'color', 'css' => null, 'label' => 'Überlagerung'],
|
||||
'overlayOpacity' => ['group' => 'background', 'type' => 'number', 'css' => null, 'label' => 'Deckkraft der Überlagerung', 'min' => 0, 'max' => 1, 'step' => 0.05],
|
||||
|
||||
// ---- Abstand ----------------------------------------------------------------
|
||||
'padding' => ['group' => 'spacing', 'type' => 'length', 'css' => 'padding', 'label' => 'Innenabstand', 'sides' => true],
|
||||
'margin' => ['group' => 'spacing', 'type' => 'length-auto', 'css' => 'margin', 'label' => 'Außenabstand', 'sides' => true],
|
||||
|
||||
// ---- Größe ------------------------------------------------------------------
|
||||
'width' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'width', 'label' => 'Breite'],
|
||||
'maxWidth' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'max-width', 'label' => 'Maximale Breite'],
|
||||
'minWidth' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'min-width', 'label' => 'Mindestbreite'],
|
||||
'height' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'height', 'label' => 'Höhe'],
|
||||
'minHeight' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'min-height', 'label' => 'Mindesthöhe'],
|
||||
'maxHeight' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'max-height', 'label' => 'Maximale Höhe'],
|
||||
'overflow' => ['group' => 'size', 'type' => 'choice', 'css' => 'overflow', 'label' => 'Überlauf', 'values' => [
|
||||
'visible' => 'Sichtbar', 'hidden' => 'Abschneiden', 'auto' => 'Scrollen bei Bedarf',
|
||||
]],
|
||||
|
||||
// ---- Rahmen -----------------------------------------------------------------
|
||||
'borderWidth' => ['group' => 'border', 'type' => 'length', 'css' => 'border-width', 'label' => 'Rahmenstärke', 'sides' => true],
|
||||
'borderStyle' => ['group' => 'border', 'type' => 'choice', 'css' => 'border-style', 'label' => 'Rahmenart', 'values' => [
|
||||
'solid' => 'Durchgezogen', 'dashed' => 'Gestrichelt', 'dotted' => 'Gepunktet', 'double' => 'Doppelt', 'none' => 'Keiner',
|
||||
]],
|
||||
'borderColor' => ['group' => 'border', 'type' => 'color', 'css' => 'border-color', 'label' => 'Rahmenfarbe', 'hover' => true],
|
||||
'borderRadius' => ['group' => 'border', 'type' => 'length', 'css' => 'border-radius', 'label' => 'Ecken', 'corners' => true],
|
||||
|
||||
// ---- Layout -----------------------------------------------------------------
|
||||
'display' => ['group' => 'layout', 'type' => 'choice', 'css' => 'display', 'label' => 'Anzeige', 'values' => [
|
||||
'block' => 'Block', 'flex' => 'Flex', 'inline-flex' => 'Inline-Flex', 'grid' => 'Raster', 'inline-block' => 'Inline-Block', 'none' => 'Ausgeblendet',
|
||||
]],
|
||||
'flexDirection' => ['group' => 'layout', 'type' => 'choice', 'css' => 'flex-direction', 'label' => 'Flexrichtung', 'values' => [
|
||||
'row' => 'Nebeneinander', 'row-reverse' => 'Nebeneinander, umgekehrt', 'column' => 'Untereinander', 'column-reverse' => 'Untereinander, umgekehrt',
|
||||
]],
|
||||
'flexWrap' => ['group' => 'layout', 'type' => 'choice', 'css' => 'flex-wrap', 'label' => 'Umbruch', 'values' => ['wrap' => 'Umbrechen', 'nowrap' => 'Nicht umbrechen']],
|
||||
'justifyContent' => ['group' => 'layout', 'type' => 'choice', 'css' => 'justify-content', 'label' => 'Verteilung', 'values' => [
|
||||
'flex-start' => 'Anfang', 'center' => 'Mitte', 'flex-end' => 'Ende', 'space-between' => 'Außen verteilt', 'space-around' => 'Gleichmäßig', 'space-evenly' => 'Exakt gleich',
|
||||
]],
|
||||
'alignItems' => ['group' => 'layout', 'type' => 'choice', 'css' => 'align-items', 'label' => 'Ausrichtung quer', 'values' => [
|
||||
'stretch' => 'Dehnen', 'flex-start' => 'Anfang', 'center' => 'Mitte', 'flex-end' => 'Ende', 'baseline' => 'Grundlinie',
|
||||
]],
|
||||
'gridColumns' => ['group' => 'layout', 'type' => 'number', 'css' => null, 'label' => 'Spalten', 'min' => 1, 'max' => 12, 'step' => 1],
|
||||
'gap' => ['group' => 'layout', 'type' => 'length', 'css' => 'gap', 'label' => 'Abstand zwischen Elementen'],
|
||||
'order' => ['group' => 'layout', 'type' => 'number', 'css' => 'order', 'label' => 'Reihenfolge', 'min' => -20, 'max' => 20, 'step' => 1],
|
||||
|
||||
// ---- Wirkung ----------------------------------------------------------------
|
||||
'opacity' => ['group' => 'effect', 'type' => 'number', 'css' => 'opacity', 'label' => 'Deckkraft', 'min' => 0, 'max' => 1, 'step' => 0.05, 'hover' => true],
|
||||
'shadowX' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Schatten waagerecht'],
|
||||
'shadowY' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Schatten senkrecht'],
|
||||
'shadowBlur' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Schatten weich'],
|
||||
'shadowSpread' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Schatten größer'],
|
||||
'shadowColor' => ['group' => 'effect', 'type' => 'color', 'css' => null, 'label' => 'Schattenfarbe', 'hover' => true],
|
||||
'translateX' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Verschieben waagerecht', 'hover' => true],
|
||||
'translateY' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Verschieben senkrecht', 'hover' => true],
|
||||
'scale' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Skalierung', 'min' => 0.1, 'max' => 3, 'step' => 0.01, 'hover' => true],
|
||||
'rotate' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Drehung', 'min' => -360, 'max' => 360, 'step' => 1, 'hover' => true],
|
||||
'transition' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Übergangsdauer (ms)', 'min' => 0, 'max' => 2000, 'step' => 25],
|
||||
];
|
||||
}
|
||||
|
||||
/** Properties that may also be given a hover value. */
|
||||
public static function hoverable(): array
|
||||
{
|
||||
return array_keys(array_filter(self::properties(), static fn (array $p): bool => $p['hover'] ?? false));
|
||||
}
|
||||
|
||||
public static function sides(): array
|
||||
{
|
||||
return ['top' => 'Oben', 'right' => 'Rechts', 'bottom' => 'Unten', 'left' => 'Links'];
|
||||
}
|
||||
|
||||
/** Corners round a box; they are not sides, and their CSS property is spelled differently. */
|
||||
public static function corners(): array
|
||||
{
|
||||
return ['topLeft' => 'Oben links', 'topRight' => 'Oben rechts', 'bottomRight' => 'Unten rechts', 'bottomLeft' => 'Unten links'];
|
||||
}
|
||||
|
||||
/** "topLeft" becomes the CSS fragment "top-left". */
|
||||
public static function cornerCss(string $corner): string
|
||||
{
|
||||
return strtolower(preg_replace('/([A-Z])/', '-$1', $corner) ?? $corner);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
<?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;
|
||||
/** 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',
|
||||
];
|
||||
|
||||
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'],
|
||||
],
|
||||
'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' => '@accent',
|
||||
'containerWidth' => '1240px',
|
||||
'headingScale' => 1.25,
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(private Connection $db)
|
||||
{
|
||||
}
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
$raw = $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']);
|
||||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
if (!is_array($stored) || !isset($stored['colors'], $stored['fonts'], $stored['base'])) {
|
||||
return self::DEFAULTS;
|
||||
}
|
||||
|
||||
return ['colors' => $stored['colors'], 'fonts' => $stored['fonts'], 'base' => [...self::DEFAULTS['base'], ...$stored['base']]];
|
||||
}
|
||||
|
||||
public function save(array $colors, array $fonts, array $base): void
|
||||
{
|
||||
$theme = [
|
||||
'colors' => $this->colors($colors),
|
||||
'fonts' => $this->fonts($fonts),
|
||||
'base' => $this->base($base),
|
||||
];
|
||||
$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->executeStatement('UPDATE website SET theme = ? WHERE id = ?', [
|
||||
json_encode($theme, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
'default',
|
||||
]);
|
||||
}
|
||||
|
||||
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 && '' === $value) {
|
||||
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'] 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/** The stylesheet every page carries: palette variables, font variables and base typography. */
|
||||
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']);
|
||||
|
||||
$colour = static fn (string $v): string => str_starts_with($v, '@') ? sprintf('var(--k-%s)', substr($v, 1)) : $v;
|
||||
$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 a{color:%s}.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']),
|
||||
$headings,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Google Fonts stylesheet for every family that comes from there, or null when none does.
|
||||
* Chosen by the user; it means one request to a third party per page view.
|
||||
*/
|
||||
public function googleFontsUrl(array $theme): ?string
|
||||
{
|
||||
$families = [];
|
||||
foreach ($theme['fonts'] as $font) {
|
||||
if ('google' === $font['source'] && '' !== $font['family']) {
|
||||
$families[] = sprintf('family=%s:wght@%s', str_replace(' ', '+', $font['family']), implode(';', $font['weights']));
|
||||
}
|
||||
}
|
||||
|
||||
return [] === $families ? null : 'https://fonts.googleapis.com/css2?'.implode('&', array_unique($families)).'&display=swap';
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ use App\Content\DocumentValidator;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\StyleSchema;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
@@ -20,7 +23,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
#[Route('/admin')]
|
||||
final class EditorController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private CsrfTokenManagerInterface $csrf)
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private CsrfTokenManagerInterface $csrf)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -66,6 +69,23 @@ final class EditorController extends AbstractController
|
||||
return $this->redirectToRoute('admin_editor', ['id' => $id]);
|
||||
}
|
||||
|
||||
/** The editor reads the style vocabulary and the palette from here, so both stay in one place. */
|
||||
#[Route('/api/design', name: 'admin_design', methods: ['GET'])]
|
||||
public function design(): JsonResponse
|
||||
{
|
||||
$theme = $this->theme->load();
|
||||
|
||||
return $this->json([
|
||||
'properties' => StyleSchema::properties(),
|
||||
'devices' => StyleSchema::DEVICES,
|
||||
'sides' => StyleSchema::sides(),
|
||||
'corners' => StyleSchema::corners(),
|
||||
'hoverable' => StyleSchema::hoverable(),
|
||||
'colors' => $theme['colors'],
|
||||
'fonts' => $theme['fonts'],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/meta', name: 'admin_post_meta', methods: ['POST'])]
|
||||
public function metadata(Request $request, string $id): Response
|
||||
{
|
||||
@@ -180,7 +200,7 @@ final class EditorController extends AbstractController
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
|
||||
}
|
||||
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'needsTabs' => false]);
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'needsTabs' => false, 'pageStyles' => $this->theme->css($this->theme->load()).$this->styles->compileDocument($document), 'googleFonts' => $this->theme->googleFontsUrl($this->theme->load())]);
|
||||
}
|
||||
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) {
|
||||
throw new \InvalidArgumentException();
|
||||
|
||||
@@ -138,6 +138,8 @@ final class FormController extends AbstractController
|
||||
'postLists' => [],
|
||||
'postMeta' => null,
|
||||
'needsTabs' => false,
|
||||
'pageStyles' => '',
|
||||
'googleFonts' => null,
|
||||
'forms' => $states,
|
||||
], $response);
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\Content\PageRepository;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\Theme;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -15,7 +17,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PageController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private string $secret, private string $siteOrigin)
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private Theme $theme, private StyleCompiler $styles, private string $secret, private string $siteOrigin)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -32,6 +34,14 @@ final class PageController extends AbstractController
|
||||
return $this->redirectToRoute('home', status: 301);
|
||||
}
|
||||
$page = $this->pages->findPublished($path);
|
||||
$styles = '';
|
||||
$googleFonts = null;
|
||||
if (null !== $page) {
|
||||
$theme = $this->theme->load();
|
||||
// Theme first, block styles after, so a block always wins over the site defaults.
|
||||
$styles = $this->theme->css($theme).$this->styles->compileDocument($page['document']);
|
||||
$googleFonts = $this->theme->googleFontsUrl($theme);
|
||||
}
|
||||
if (null === $page) {
|
||||
// An address that moved or was deliberately removed answers before the 404 does.
|
||||
$redirect = $this->redirects->find($path);
|
||||
@@ -50,25 +60,42 @@ final class PageController extends AbstractController
|
||||
'forms' => $this->formStates($page, $request->query->getString('gesendet')),
|
||||
'breadcrumbs' => $this->breadcrumbs($page),
|
||||
'structuredData' => $this->structuredData($page),
|
||||
'pageStyles' => $styles,
|
||||
'googleFonts' => $googleFonts,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->shell($response, null !== $page && $this->hasType($page['document'], 'tabs'));
|
||||
return $this->shell($response, null !== $page && $this->hasType($page['document'], 'tabs'), $styles, null !== $googleFonts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every public response carries the same headers. Scripting stays forbidden unless the page
|
||||
* actually contains a tab group: only then may the browser load the one enhancement script,
|
||||
* and only from this origin. Inline script is never allowed.
|
||||
*
|
||||
* The generated stylesheet is inline for speed, so it is allowed by its own hash rather than
|
||||
* by 'unsafe-inline' – any other inline style, injected or not, stays blocked.
|
||||
*/
|
||||
private function shell(Response $response, bool $allowScript = false): Response
|
||||
private function shell(Response $response, bool $allowScript = false, string $styles = '', bool $googleFonts = false): Response
|
||||
{
|
||||
$styleSources = ["'self'"];
|
||||
if ('' !== $styles) {
|
||||
$styleSources[] = sprintf("'sha256-%s'", base64_encode(hash('sha256', $styles, true)));
|
||||
}
|
||||
$fontSources = ["'self'"];
|
||||
if ($googleFonts) {
|
||||
// Chosen deliberately: the stylesheet comes from Google and the files from its CDN.
|
||||
$styleSources[] = 'https://fonts.googleapis.com';
|
||||
$fontSources[] = 'https://fonts.gstatic.com';
|
||||
}
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
$response->headers->set('Content-Security-Policy', sprintf(
|
||||
"default-src 'self'; script-src %s; style-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||
"default-src 'self'; script-src %s; style-src %s; font-src %s; img-src 'self' https: data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||
$allowScript ? "'self'" : "'none'",
|
||||
implode(' ', $styleSources),
|
||||
implode(' ', $fontSources),
|
||||
));
|
||||
|
||||
return $response;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidPage;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class ThemeController extends AbstractController
|
||||
{
|
||||
public function __construct(private Theme $theme)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/theme', name: 'admin_theme', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('admin/theme.html.twig', [
|
||||
'theme' => $this->theme->load(),
|
||||
'fallbacks' => Theme::FALLBACKS,
|
||||
'weights' => Theme::WEIGHTS,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/theme/save', name: 'admin_theme_save', methods: ['POST'])]
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('theme', $request->request->getString('_csrf_token'))) {
|
||||
$this->addFlash('error', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
|
||||
return $this->redirectToRoute('admin_theme');
|
||||
}
|
||||
$fonts = [];
|
||||
foreach ($request->request->all('fonts') as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$row['weights'] = array_map('intval', (array) ($row['weights'] ?? []));
|
||||
$fonts[] = $row;
|
||||
}
|
||||
try {
|
||||
$this->theme->save($request->request->all('colors'), $fonts, $request->request->all('base'));
|
||||
} catch (InvalidPage $e) {
|
||||
$this->addFlash('error', $e->getMessage());
|
||||
|
||||
return $this->redirectToRoute('admin_theme');
|
||||
}
|
||||
$this->addFlash('success', 'Das Design wurde gespeichert. Es gilt sofort auf allen Seiten.');
|
||||
|
||||
return $this->redirectToRoute('admin_theme');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user