+~*,&\s]+$/'; /** One width condition, or several joined by `and`. */ private const MEDIA_CONDITION = '/^\(\s*(min|max)-width\s*:\s*\d{1,5}(px|em|rem)\s*\)(\s+and\s+\(\s*(min|max)-width\s*:\s*\d{1,5}(px|em|rem)\s*\))*$/i'; /* * These bound pathological input and keep the patterns above cheap. They are not the * protection — the property and function tables are. Real values and selectors in the * kit corpus sit far below both, so a cap tight enough to be felt would only be cutting * off legitimate work. */ /** What an unscoped selector is contained by. */ private const SCOPE = '.af-root'; private const MAX_SELECTOR_LEN = 1024; private const MAX_VALUE_LEN = 4096; /** @var array|null */ private static ?array $table = null; /** How many declarations and selectors the last call refused. */ private static array $dropped = ['decls' => 0, 'selectors' => 0, 'atRules' => 0]; private static function table(): array { if (self::$table === null) { $file = dirname(__DIR__, 2) . '/packages/core/src/css-properties.json'; $json = is_readable($file) ? json_decode((string) file_get_contents($file), true) : null; $json = is_array($json) ? $json : []; /* * An unreadable table must refuse rather than permit. The function list is what * holds values in check, so an empty one refuses every value that calls anything — * visible in the pages, rather than silently letting everything through. */ self::$table = [ 'propertyPattern' => (string) ($json['propertyPattern'] ?? '/^$/'), 'customPropertyPattern' => (string) ($json['customPropertyPattern'] ?? '/^$/'), 'denied' => array_fill_keys( array_map('strtolower', (array) ($json['deniedProperties'] ?? [])), true ), 'functions' => array_fill_keys( array_map('strtolower', (array) ($json['functions'] ?? [])), true ), 'selectorFunctions' => array_fill_keys( array_map('strtolower', (array) ($json['selectorFunctions'] ?? [])), true ), 'outside' => array_fill_keys( array_map('strtolower', (array) ($json['outsideProperties'] ?? [])), true ), 'ok' => isset($json['propertyPattern'], $json['functions']), ]; } return self::$table; } /** Counts of what was refused, which also resets them. */ public static function dropped(): array { $d = self::$dropped; self::$dropped = ['decls' => 0, 'selectors' => 0, 'atRules' => 0]; return $d; } /* ---------------------------------------------------------------- properties */ /** * Whether a property name may be written at all. * * A name is judged by shape, not by membership of a list. The reasoning is in the table * file: a name cannot execute anything, so an allow list would buy no safety while every * omission from it would quietly drop a legitimate declaration from someone's design. The * strictness that matters is in `value_ok()`, which refuses `url()` and any function the * table does not list. */ private static function property_ok(string $prop): bool { $table = self::table(); if ($prop === '' || empty($table['ok'])) { return false; } if (isset($table['denied'][$prop])) { return false; } if (str_starts_with($prop, '--')) { return (bool) preg_match('#' . $table['customPropertyPattern'] . '#', $prop); } return (bool) preg_match('#' . $table['propertyPattern'] . '#', $prop); } private static function value_ok(string $value): bool { if ($value === '' || strlen($value) > self::MAX_VALUE_LEN) { return false; } /* A backslash, a comment or a stray brace would each let a value end the rule. */ if (preg_match('#[\\\\{};@<]|/\*|\*/#', $value)) { return false; } if (substr_count($value, '(') !== substr_count($value, ')')) { return false; } /* * `url()` is refused outright. It is the one thing in a value that fetches, and there * is no use for it here: a fill or an image is its own structured field, carrying an * attachment id. Refusing it also empties the legacy binding properties of any payload. */ foreach (self::function_names($value) as $fn) { if ($fn === 'url' || !isset(self::table()['functions'][$fn])) { return false; } } return true; } /** @return array Every `name(` in the value, lowercased. */ private static function function_names(string $value): array { preg_match_all('/([-A-Za-z][-A-Za-z0-9]*)\s*\(/', $value, $m); return array_map('strtolower', $m[1]); } /* ---------------------------------------------------------------- selectors */ /** * Validate a selector and keep it inside the design. * * A part written with nothing but element names, `*` and pseudo-classes sweeps the whole * document, so it is contained: `body{display:none}` becomes `.af-root body` and matches * nothing. A part that names a class, an id or an attribute is aimed at something * specific and is left as written. * * The line is drawn there because whether an element sits inside the design cannot be * told from the selector, and this file does not guess. Plenty of what a design has to * style is printed outside its own root by something else — a booking modal goes out on * `wp_footer` — so containing `.arb-modal` would not make it safe, it would make it match * nothing. The rules that genuinely sweep the document are the ones worth stopping, and * they are the ones that name nothing. * * A nested rule is never given the prefix: the browser already reads it relative to the * rule it sits in, and prefixing it would move it somewhere it was never meant to match. * * @param bool $contain Whether this selector stands at the top level and needs containing. * @param bool $outside_ok True when the rule only sets what a design may legitimately set * outside itself; see `outside_ok()`. It applies to a part aimed at * the document itself, not to any sweeping part — `div p{margin:0}` * is still contained. */ private static function selector(string $sel, bool $contain, bool $outside_ok = false): ?string { $sel = trim((string) preg_replace('/\s+/', ' ', $sel)); if ($sel === '' || strlen($sel) > self::MAX_SELECTOR_LEN) { return null; } if (!preg_match(self::SELECTOR_CHARS, $sel)) { return null; } foreach (self::function_names($sel) as $fn) { if (!isset(self::table()['selectorFunctions'][$fn])) { return null; } } $parts = []; foreach (explode(',', $sel) as $part) { $part = trim($part); if ($part === '') { continue; } $targeted = (bool) preg_match('/[.#\[]/', $part); $document = (bool) preg_match('/^(html|body|:root)\b/', $part); $keep = !$contain || $targeted || ($document && $outside_ok); $parts[] = $keep ? $part : self::SCOPE . ' ' . $part; } return $parts ? implode(',', $parts) : null; } /** * Whether a rule may stand outside the design rather than be contained by it. * * Two things a design legitimately does outside its own root: define custom properties, * which do nothing by themselves, and make room for a part of itself that is pinned to * the viewport — a fixed sidebar sets `html body { padding-left }` so the page's own * content is not underneath it. Both are in `outsideProperties`. * * The distinction matters because containing such a rule does not make it safe, it makes * it do nothing: `html` and `body` are ancestors of the design's root, so * `.af-root html body` matches nothing at all. Containment is the right answer for a * selector that points *inside* the design, and the wrong answer for one that points * above it — which is why that case is decided here instead. */ private static function outside_ok(string $body): bool { $decls = self::split_decls($body); if (!$decls) { return false; } $allowed = self::table()['outside']; foreach ($decls as [$prop, $_]) { if (!str_starts_with($prop, '--') && !isset($allowed[$prop])) { return false; } } return true; } /* ---------------------------------------------------------------- parsing */ /** * Split a declaration list. * * @return array */ private static function split_decls(string $body): array { $out = []; foreach (explode(';', $body) as $decl) { $decl = trim($decl); if ($decl === '') { continue; } $colon = strpos($decl, ':'); if ($colon === false || $colon === 0) { continue; } $out[] = [ strtolower(trim(substr($decl, 0, $colon))), trim(preg_replace('/\s+/', ' ', substr($decl, $colon + 1))), ]; } return $out; } /** Keep the declarations that pass, in source order. */ private static function filter_decls(string $body): string { $out = ''; foreach (self::split_decls($body) as [$prop, $value]) { if (self::property_ok($prop) && self::value_ok($value)) { $out .= $prop . ':' . $value . ';'; continue; } self::$dropped['decls']++; } return $out; } /** * Walk a stylesheet into its top-level pieces. * * @return array `decls`, `rule` or `at`. */ private static function pieces(string $css): array { /* Comments go first: a closing comment marker inside one ends it in the wrong place. */ $css = (string) preg_replace('#/\*.*?\*/#s', '', $css); $out = []; $head = ''; $len = strlen($css); for ($i = 0; $i < $len; $i++) { if ($css[$i] !== '{') { $head .= $css[$i]; continue; } /* * Everything up to the last semicolon is a declaration list; the selector is what * follows it. Without this split, `color:red; & p {` would be read as one long * selector and both halves would be lost. */ $cut = strrpos($head, ';'); $lead = $cut === false ? '' : substr($head, 0, $cut + 1); $sel = $cut === false ? $head : substr($head, $cut + 1); if (trim($lead) !== '') { $out[] = ['kind' => 'decls', 'head' => '', 'body' => trim($lead)]; } $depth = 1; $body = ''; for ($i++; $i < $len; $i++) { if ($css[$i] === '{') { $depth++; } elseif ($css[$i] === '}') { $depth--; if ($depth === 0) { break; } } $body .= $css[$i]; } $sel = trim($sel); if ($sel !== '') { $out[] = [ 'kind' => str_starts_with($sel, '@') ? 'at' : 'rule', 'head' => $sel, 'body' => $body, ]; } $head = ''; } $tail = trim($head); if ($tail !== '') { $out[] = ['kind' => 'decls', 'head' => '', 'body' => $tail]; } return $out; } /* ---------------------------------------------------------------- emission */ /** * A stylesheet written as text, rebuilt from what passed. * * Top-level declarations outside any rule are discarded: without a selector there is * nothing to apply them to, and guessing one would style something nobody named. */ public static function sheet(string $css): string { $out = ''; foreach (self::pieces($css) as $piece) { if ($piece['kind'] === 'decls') { self::$dropped['decls'] += count(self::split_decls($piece['body'])); continue; } if ($piece['kind'] === 'at') { $out .= self::at_rule($piece['head'], $piece['body']); continue; } $sel = self::selector($piece['head'], true, self::outside_ok($piece['body'])); if ($sel === null) { self::$dropped['selectors']++; continue; } $decls = self::filter_decls(self::strip_nested($piece['body'], $sel, $nested)); $body = $decls . $nested; if ($body !== '') { $out .= $sel . '{' . $body . '}'; } } return $out; } /** * One node's own rules. * * Almost every value here is a plain declaration list, which is merged into the node's * own rule. A nested rule is kept nested, so its selector stays relative to the node * exactly as the browser reads it. */ public static function node(string $css): string { $nested = ''; $decls = self::filter_decls(self::strip_nested($css, '&', $nested)); return $decls . $nested; } /** * Pull nested rules out of a declaration body and emit them validated. * * @param string $scope Shown in no output; present so the caller's intent is explicit. * @param string $nested Receives the emitted nested rules. * @return string The body with its nested rules removed. */ private static function strip_nested(string $body, string $scope, ?string &$nested): string { $nested = ''; if (!str_contains($body, '{')) { return $body; } $decls = ''; foreach (self::pieces($body) as $piece) { if ($piece['kind'] === 'decls') { $decls .= rtrim(trim($piece['body']), ';') . ';'; continue; } if ($piece['kind'] === 'at') { $nested .= self::at_rule($piece['head'], $piece['body']); continue; } $sel = self::selector($piece['head'], false); if ($sel === null) { self::$dropped['selectors']++; continue; } $inner = self::filter_decls($piece['body']); if ($inner !== '') { $nested .= $sel . '{' . $inner . '}'; } } return $decls; } /** An animation name, and the keyframe offsets its body may use. */ private const KEYFRAMES_NAME = '/^@keyframes\s+([A-Za-z_-][A-Za-z0-9_-]{0,63})$/i'; private const KEYFRAME_STOP = '/^(from|to|\d{1,3}(\.\d+)?%)$/i'; /** * An animation definition. * * Its body holds offsets, not element selectors, so it does not go through `sheet()`: * `from` is not something `.af-root` could contain, and prefixing it would make the whole * animation inert. Each offset is rebuilt from what matched, and the declarations are * filtered as everywhere else. */ private static function keyframes(string $name, string $body): string { $out = ''; foreach (self::pieces($body) as $piece) { if ($piece['kind'] !== 'rule') { self::$dropped['selectors']++; continue; } $stops = []; foreach (explode(',', $piece['head']) as $stop) { $stop = trim($stop); if ($stop !== '' && preg_match(self::KEYFRAME_STOP, $stop)) { $stops[] = strtolower($stop); } } if (!$stops) { self::$dropped['selectors']++; continue; } $decls = self::filter_decls($piece['body']); if ($decls !== '') { $out .= implode(',', $stops) . '{' . $decls . '}'; } } return $out === '' ? '' : '@keyframes ' . $name . '{' . $out . '}'; } /** A width media query and an animation definition survive; every other at-rule is dropped. */ private static function at_rule(string $head, string $body): string { if (preg_match(self::KEYFRAMES_NAME, trim((string) preg_replace('/\s+/', ' ', $head)), $k)) { return self::keyframes($k[1], $body); } /* `@media(max-width:640px)` is written without a space as often as with one. */ if (!preg_match('/^@media\s*(\(.+)$/is', $head, $m)) { self::$dropped['atRules']++; return ''; } $cond = trim((string) preg_replace('/\s+/', ' ', $m[1])); if (!preg_match(self::MEDIA_CONDITION, $cond)) { self::$dropped['atRules']++; return ''; } /* Rebuilt from what was matched, so the text in the output is this file's, not the input's. */ preg_match_all('/\(\s*(min|max)-width\s*:\s*(\d{1,5})(px|em|rem)\s*\)/i', $cond, $all, PREG_SET_ORDER); $parts = []; foreach ($all as $one) { $parts[] = '(' . strtolower($one[1]) . '-width:' . (int) $one[2] . strtolower($one[3]) . ')'; } if (!$parts) { self::$dropped['atRules']++; return ''; } $inner = self::sheet($body); return $inner === '' ? '' : '@media ' . implode(' and ', $parts) . '{' . $inner . '}'; } }