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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user