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:
kantarciandClaude Opus 5 committed 2026-09-12 13:04:54 +02:00
1 parent a40c2bb27e
commit abf0bd2c6b
24 files changed
+1591 -21

No files matched your search

+31 -4
View File
@@ -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;