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>
562 lines
25 KiB
PHP
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);
|
|
}
|
|
}
|
|
}
|