Files
KantSites/src/Content/StyleCompiler.php
T
kantarciandClaude Fable 5.1 7eb462ac7b Redirect the other www spelling to SITE_ORIGIN, hold roles to the role help and fix the audit findings
SITE_ORIGIN alone decides whether the site is www or not: CanonicalHostSubscriber
sends the other spelling there with 301, cms:site:origin writes the setting, the
system screen shows it. The roles now match the role help (editor: drafts,
publisher: live content, admin: design, settings, users, system). The installer no
longer reopens on an unreachable database, the session cookie follows ADMIN_PATH,
percent-encoded credentials no longer break the container, consent only accepts
same-site decisions, JSON-LD is HTML-safe, own CSS stays in its block, plus the
editor, updater, download and public-output findings from the full review
(ADR 099, ADR 100).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:15:20 +02:00

562 lines
25 KiB
PHP

<?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];
/**
* Dieselben Grenzen, als Medienabfragen fuer einen Verweis im Seitenkopf.
*
* Genau eine trifft zu, und sie trifft dieselbe Variante wie die Regel weiter unten - sonst
* holte ein Vorabverweis ein zweites Bild.
*/
public const PRELOAD_DEVICES = [
'desktop' => '(min-width:1025px)',
'tablet' => '(min-width:768px) and (max-width:1024px)',
'mobile' => '(max-width:767px)',
];
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;
}
if ('scrolled' === $key) {
$this->require(is_array($value), 'Scrolled styles must be an object.');
foreach ($value as $scrolledKey => $scrolledValue) {
$this->require(in_array($scrolledKey, StyleSchema::scrollable(), true), sprintf('The property "%s" has no scrolled value.', (string) $scrolledKey));
$this->value($properties[$scrolledKey], $scrolledValue, (string) $scrolledKey);
}
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)),
// A share of nothing is a column that cannot be seen, so zero is refused with the rest.
'fractions' => $this->require(
1 === preg_match(StyleSchema::FRACTIONS, $text) && !in_array(0.0, array_map(floatval(...), explode(' ', $text)), true),
sprintf('"%s" is not a list of column shares.', $key),
),
'media' => $this->require('none' === $text || 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, array $parent = []) 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']);
if ('image' === ($block['type'] ?? null)) {
$css .= $this->imageAlignment((string) $block['id'], $block['style'], $parent);
}
if ('row' === ($block['type'] ?? null)) {
$css .= $this->columnTracks((string) $block['id'], $block['style'], is_array($block['layout'] ?? null) ? $block['layout'] : [], count($block['children'] ?? []));
}
}
$walk($block['children'] ?? [], $block);
}
};
$walk($document['blocks'] ?? []);
return $css;
}
/**
* In a normal section/column, align-self cannot position a figure. Align its box
* and the possibly smaller image inside it, including linked images. A horizontal
* flex group must keep align-self's vertical axis without consuming its main-axis
* space with automatic margins. Resolve both styles at each breakpoint so a parent
* layout change or "auto" on a phone cannot leave desktop alignment behind.
*/
private function imageAlignment(string $id, array $style, array $parent): string
{
$css = $previous = '';
$values = $group = [];
// These block types render children in an unstyled inner wrapper.
$wrapped = in_array($parent['type'] ?? '', ['section', 'accordionitem', 'slider'], true);
foreach (['desktop', 'tablet', 'mobile'] as $device) {
$values = array_replace_recursive($values, $style[$device] ?? []);
$group = array_replace_recursive($group, $parent['style'][$device] ?? []);
if (!isset($values['alignSelf'])) {
continue;
}
$alignment = match ($values['alignSelf']) {
'center' => ['auto', 'auto', 'center'],
'flex-start' => ['0', 'auto', 'start'],
'flex-end' => ['auto', '0', 'end'],
default => null,
};
$horizontalFlex = !$wrapped
&& in_array($group['display'] ?? '', ['flex', 'inline-flex'], true)
&& in_array($group['flexDirection'] ?? 'row', ['row', 'row-reverse'], true);
[$left, $right, $justify] = $horizontalFlex || null === $alignment
? [$values['margin']['left'] ?? '0', $values['margin']['right'] ?? '0', 'auto']
: $alignment;
$rules = sprintf('#%s{margin-left:%s;margin-right:%s;justify-self:%s}#%s img{margin-left:%s;margin-right:%s}',
$id, $left, $right, $justify, $id, $alignment[0] ?? '0', $alignment[1] ?? '0');
if ($rules !== $previous) {
$css .= isset(self::BREAKPOINTS[$device])
? sprintf('@media (max-width:%dpx){%s}', self::BREAKPOINTS[$device], $rules)
: $rules;
$previous = $rules;
}
}
return $css;
}
private function compileBlock(string $id, array $style): string
{
$css = '';
$style = $this->scaleBackground($style);
foreach (['desktop', 'tablet', 'mobile'] as $device) {
$values = $style[$device] ?? null;
if (!is_array($values)) {
continue;
}
$rules = '';
$base = $this->declarations($values, 'self', $device);
if ('' !== $base) {
$rules .= sprintf('#%s{%s}', $id, $base);
}
// Some properties belong on the images inside the block, not on the block: object-fit
// on a figure does nothing, and a radius on the figure leaves the image square.
$images = $this->declarations($values, 'img', $device);
if ('' !== $images) {
$rules .= sprintf('#%s img{%s}', $id, $images);
}
// Slider colours: on the slider for the script-free markers, and on the control bar
// the script places right after it.
$controls = $this->declarations($values, 'controls', $device);
if ('' !== $controls) {
$rules .= sprintf('#%s,#%s + .slider-controls{%s}', $id, $id, $controls);
}
// Der Menü-Knopf: die Farben liegen am Block, gelten aber dem aufklappbaren Knopf.
$burger = $this->declarations($values, 'burger', $device);
if ('' !== $burger) {
$rules .= sprintf('#%s .menu-burger>summary{%s}', $id, $burger);
}
$hover = is_array($values['hover'] ?? null) ? $this->declarations($this->inheritShadow($values['hover'], $values), 'self', $device) : '';
if ('' !== $hover) {
$rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover);
}
// The scrolled state: scrolled.js puts `is-scrolled` on <html> once the page has moved.
// The change eases in, so a header does not snap from glass to solid.
$scrolled = is_array($values['scrolled'] ?? null) ? $this->declarations($this->inheritShadow($values['scrolled'], $values), 'self', $device) : '';
if ('' !== $scrolled) {
$rules .= sprintf('#%s{transition:background-color .3s,color .3s,border-color .3s,box-shadow .3s,opacity .3s}html.is-scrolled #%s{%s}', $id, $id, $scrolled);
}
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.
*
* @param string $target 'self' for the block, 'img' for the images inside it
*/
private function declarations(array $values, string $target = 'self', string $device = 'desktop'): string
{
$properties = StyleSchema::properties();
$out = [];
foreach ($values as $key => $value) {
if ('hover' === $key || !isset($properties[$key])) {
continue;
}
$property = $properties[$key];
if ($target !== ($property['target'] ?? 'self')) {
continue;
}
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) {
// The spelling comes from the schema: "border-top-width", not "border-width-top".
$pattern = $property['sideCss'] ?? $property['css'].'-%s';
foreach ($value as $side => $single) {
$out[] = sprintf('%s:%s', sprintf($pattern, $side), $this->css($property, (string) $single));
}
continue;
}
$out[] = sprintf('%s:%s', $property['css'], $this->css($property, (string) $value));
}
// The composed declarations describe the block itself; none of them has an image variant.
if ('self' === $target) {
foreach ([
$this->background($values, $device),
$this->shadow($values),
$this->textShadow($values),
$this->filter($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 $device = 'desktop'): 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']) && 'none' !== $values['backgroundImage'] && null !== $this->media) {
$item = $this->media->find((string) $values['backgroundImage']);
if (null !== $item && [] !== $item['variants']) {
// A phone should not download the 2400 pixel variant of a background. CSS has no
// srcset, but the rules are already written per device, so the device decides.
$largest = self::variantFor($item['variants'], $device);
// The URL comes from the media library, never from the document.
$layers[] = sprintf('url("%s")', $largest['src']);
}
}
return [] === $layers ? ('none' === ($values['backgroundImage'] ?? null) ? 'background-image:none' : '') : 'background-image:'.implode(',', $layers);
}
/**
* Ein Zustand, der nur die Schattenfarbe aendert, erbt Versatz, Weichzeichnung und Ausdehnung
* vom Grundzustand - sonst hiesse er `box-shadow:0 0 0 0 <farbe>` und waere unsichtbar.
*/
private function inheritShadow(array $state, array $base): array
{
foreach (['shadow' => ['shadowX', 'shadowY', 'shadowBlur', 'shadowSpread', 'shadowInset'], 'textShadow' => ['textShadowX', 'textShadowY', 'textShadowBlur']] as $colour => $parts) {
if (!isset($state[$colour.'Color']) && [] === array_intersect_key($state, array_flip($parts))) {
continue;
}
foreach ($parts as $part) {
if (!isset($state[$part]) && isset($base[$part])) {
$state[$part] = $base[$part];
}
}
}
return $state;
}
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 %s',
'innen' === ($values['shadowInset'] ?? 'aussen') ? 'inset ' : '',
$values['shadowX'] ?? '0',
$values['shadowY'] ?? '0',
$values['shadowBlur'] ?? '0',
$values['shadowSpread'] ?? '0',
$this->css(['type' => 'color'], (string) ($values['shadowColor'] ?? '#00000026')),
);
}
/** A text shadow, from the same kind of parts as the box shadow. */
private function textShadow(array $values): string
{
$parts = array_intersect_key($values, array_flip(['textShadowX', 'textShadowY', 'textShadowBlur', 'textShadowColor']));
if ([] === $parts) {
return '';
}
return sprintf(
'text-shadow:%s %s %s %s',
$values['textShadowX'] ?? '0',
$values['textShadowY'] ?? '0',
$values['textShadowBlur'] ?? '0',
$this->css(['type' => 'color'], (string) ($values['textShadowColor'] ?? '#00000040')),
);
}
/**
* The image filters, as one declaration in a fixed order.
*
* The order matters for the result, so it is written here rather than taken from the order in
* which someone happened to set the values.
*/
private function filter(array $values): string
{
$parts = [];
foreach ([
'blur' => 'blur(%spx)',
'brightness' => 'brightness(%s)',
'contrast' => 'contrast(%s)',
'saturate' => 'saturate(%s)',
'grayscale' => 'grayscale(%s)',
'sepia' => 'sepia(%s)',
'invert' => 'invert(%s)',
] as $key => $format) {
if (isset($values[$key])) {
$parts[] = sprintf($format, (float) $values[$key]);
}
}
if (isset($values['hueRotate'])) {
$parts[] = sprintf('hue-rotate(%ddeg)', (int) $values['hueRotate']);
}
return [] === $parts ? '' : 'filter:'.implode(' ', $parts);
}
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);
}
/**
* The variant a device should load for a background.
*
* Mobile takes the smallest that is at least 960 wide, tablet the one around 1600, desktop the
* largest. A picture that only has small variants simply gives what it has.
*/
/**
* Ein Hintergrundbild gilt auch dann in Gerätegrößen, wenn es nur einmal gesetzt wurde.
*
* Wer im Editor ein Hintergrundbild wählt, setzt es üblicherweise am Schreibtisch - also unter
* „Desktop“. Ohne eigene Regel für die kleineren Geräte erbt ein Telefon diese Regel und lädt
* die größte Variante: gemessen 189 KB statt 40 KB für ein Bild, das nie in dieser Auflösung
* zu sehen ist. CSS kennt kein srcset, aber die Regeln stehen ohnehin je Gerät da - also
* bekommt jedes Gerät dieselbe Bildwahl, die es bekäme, wenn jemand sie einzeln gesetzt hätte.
*
* Ein ausdrücklich gesetztes Bild wird nie ersetzt; ergänzt wird nur, wo nichts steht.
*/
private function scaleBackground(array $style): array
{
$inherited = $style['desktop'] ?? [];
foreach (['tablet', 'mobile'] as $device) {
if (isset($inherited['backgroundImage']) || isset($style[$device]['backgroundImage'])) {
$style[$device]['backgroundImage'] ??= $inherited['backgroundImage'];
// Verlauf und Überlagerung gehören zur selben Regel: ohne sie stünde auf dem
// Telefon plötzlich ein unabgedunkeltes Bild hinter hellem Text.
foreach (['gradientFrom', 'gradientTo', 'gradientAngle', 'overlayColor', 'overlayOpacity', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', 'backgroundAttachment'] as $begleiter) {
if (isset($inherited[$begleiter]) && !isset($style[$device][$begleiter])) {
$style[$device][$begleiter] = $inherited[$begleiter];
}
}
}
$inherited = array_replace($inherited, $style[$device] ?? []);
}
return $style;
}
/**
* Die Variante, die ein Hintergrundbild auf diesem Geraet bekommt.
*
* Oeffentlich und statisch, weil der Seitenkopf dieselbe Wahl treffen muss, wenn er das Bild
* vorab holt. Zwei Stellen mit derselben Regel waeren zwei Stellen, die auseinanderlaufen.
*
* @param list<array{src: string, width: int}> $variants
*
* @return array{src: string, width: int}
*/
public static function variantFor(array $variants, string $device): array
{
$wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
$chosen = $variants[0];
foreach ($variants as $variant) {
if ($variant['width'] <= $wanted || $chosen['width'] < $wanted) {
$chosen = $variant;
}
}
return $chosen;
}
/**
* The column shares of a row: "2 1" becomes two tracks of 2fr and 1fr. Shares inherit from the
* wider device like every other style. A device whose layout stacks the columns - "one" from
* the classic tokens or a single grid column - gets one track instead: the desktop rule carries
* an id and would otherwise outrank the class that stacks the columns on a phone.
*/
private function columnTracks(string $id, array $style, array $layout, int $count): string
{
$css = $previous = '';
$widths = null;
$stacked = false;
foreach (['desktop', 'tablet', 'mobile'] as $device) {
$values = is_array($style[$device] ?? null) ? $style[$device] : [];
$widths = $values['columnWidths'] ?? $widths;
if (isset($values['gridColumns'])) {
$stacked = 1 === (int) $values['gridColumns'];
} elseif (isset($layout['design'][$device]['columns'])) {
$stacked = 'one' === $layout['design'][$device]['columns'];
}
if (null === $widths || $count < 2) {
continue;
}
$rule = sprintf('#%s{grid-template-columns:%s}', $id, $stacked ? 'minmax(0,1fr)' : $this->tracks((string) $widths, $count));
if ($rule === $previous) {
continue;
}
$css .= isset(self::BREAKPOINTS[$device])
? sprintf('@media (max-width:%dpx){%s}', self::BREAKPOINTS[$device], $rule)
: $rule;
$previous = $rule;
}
return $css;
}
/** One track per column: a column added later gets the average share, surplus shares are dropped. */
private function tracks(string $widths, int $count): string
{
$shares = array_values(array_filter(array_map(floatval(...), explode(' ', $widths)), static fn (float $share): bool => $share > 0));
$shares = array_slice($shares, 0, $count);
$average = [] === $shares ? 1.0 : array_sum($shares) / count($shares);
while (count($shares) < $count) {
$shares[] = $average;
}
$format = static fn (float $share): string => rtrim(rtrim(number_format($share, 1, '.', ''), '0'), '.');
return implode(' ', array_map(static fn (float $share): string => sprintf('minmax(0,%sfr)', $format($share)), $shares));
}
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);
}
}
}