.css), read the way a render test asks * about it: its imports, its layer blocks, and the declarations of a rule by its selector. * * Nesting is resolved as the browser resolves it — `&` stands for the parent selector, and a * nested selector without `&` is a descendant of it — so a test names the selector the rule * applies to (`[data-md-button]:focus-visible`), not how the file happens to nest it. At-rules * (`@media`, `@starting-style`, `@supports`) are kept beside the selector they wrap. */ final class ComponentStylesheet { /** @var list, declarations: array}> */ private array $rules = []; /** @var list */ private array $statements = []; /** @var list */ private array $blocks = []; private function __construct(public readonly string $name, public readonly string $css) { $source = self::withoutComments($css); foreach (self::items($source) as $item) { if (isset($item['statement'])) { $this->statements[] = $item['statement']; continue; } $this->blocks[] = $item['prelude']; $this->collect($item['prelude'], $item['body'], null, []); } } public static function read(string $name): self { $path = self::path($name); if (! is_file($path)) { throw new RuntimeException("No stylesheet [{$name}]."); } return new self($name, (string) file_get_contents($path)); } public static function path(string $name): string { return dirname(__DIR__, 2)."/resources/css/components/{$name}.css"; } /** * The statements before the first block, in order: the layer statement and the imports. * * @return list */ public function statements(): array { return $this->statements; } /** * The files this stylesheet imports, as written. * * @return list */ public function imports(): array { return array_values(array_filter(array_map( fn (string $statement): ?string => preg_match('/^@import\s+([\'"])(.+?)\1;$/', $statement, $match) === 1 ? $match[2] : null, $this->statements, ))); } /** * The preludes of the top-level blocks. * * @return list */ public function blocks(): array { return $this->blocks; } /** * The declarations of every rule for this selector (and inside these at-rules, outermost * first), merged in source order. * * @param list $at * @return array */ public function declarations(string $selector, array $at = []): array { $selector = self::normalise($selector); $at = array_map(self::normalise(...), $at); $found = false; $declarations = []; foreach ($this->rules as $rule) { if ($rule['selector'] === $selector && $rule['at'] === $at) { $found = true; $declarations = array_merge($declarations, $rule['declarations']); } } if (! $found) { throw new RuntimeException("{$this->name}.css has no rule [{$selector}]".($at === [] ? '' : ' inside ['.implode(' › ', $at).']').'.'); } return $declarations; } public function has(string $selector, array $at = []): bool { try { $this->declarations($selector, $at); return true; } catch (RuntimeException) { return false; } } /** * Every media query the stylesheet writes. * * @return list */ public function mediaQueries(): array { preg_match_all('/@media\s*([^{]+)\{/', self::withoutComments($this->css), $matches); return array_map(fn (string $query): string => self::normalise($query), $matches[1]); } /** * @param list $at */ private function collect(string $prelude, string $body, ?string $parent, array $at): void { if (str_starts_with($prelude, '@layer')) { foreach (self::items($body) as $item) { if (isset($item['prelude'])) { $this->collect($item['prelude'], $item['body'], $parent, $at); } } return; } if (str_starts_with($prelude, '@')) { $at[] = self::normalise($prelude); $selector = $parent; } else { $selector = $parent === null ? self::normalise($prelude) : self::nest($parent, $prelude); } $declarations = []; foreach (self::items($body, true) as $item) { if (isset($item['prelude'])) { $this->collect($item['prelude'], $item['body'], $selector, $at); } elseif (isset($item['statement']) && str_contains($item['statement'], ':') && $selector !== null) { [$property, $value] = explode(':', rtrim($item['statement'], ';'), 2); $declarations[trim($property)] = self::normalise($value); } } if ($selector !== null && $declarations !== []) { $this->rules[] = ['selector' => $selector, 'at' => $at, 'declarations' => $declarations]; } } private static function nest(string $parent, string $child): string { $parents = self::split($parent); return implode(', ', array_merge(...array_map( fn (string $each): array => array_map( fn (string $part): string => str_contains($part, '&') ? str_replace('&', $each, $part) : "{$each} {$part}", self::split($child), ), $parents, ))); } /** * A selector list split at its top-level commas. * * @return list */ private static function split(string $selector): array { $parts = []; $depth = 0; $current = ''; foreach (str_split(self::normalise($selector)) as $char) { $depth += match ($char) { '(', '[' => 1, ')', ']' => -1, default => 0, }; if ($char === ',' && $depth === 0) { $parts[] = trim($current); $current = ''; continue; } $current .= $char; } $parts[] = trim($current); return $parts; } private static function normalise(string $text): string { return trim((string) preg_replace('/\s+/', ' ', $text)); } private static function withoutComments(string $css): string { return (string) preg_replace('~("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')|/\*.*?\*/~s', '$1', $css); } /** * The statements and blocks at the top of a piece of CSS. Inside a rule a declaration may end * at the closing brace without a semicolon. * * @return list */ private static function items(string $css, bool $declarations = false): array { $items = []; $start = 0; $depth = 0; $parens = 0; $quote = null; $opening = 0; for ($i = 0, $length = strlen($css); $i < $length; $i++) { $char = $css[$i]; if ($quote !== null) { if ($char === '\\') { $i++; } elseif ($char === $quote) { $quote = null; } continue; } if ($char === '"' || $char === "'") { $quote = $char; } elseif ($char === '(') { $parens++; } elseif ($char === ')') { $parens--; } elseif ($char === '{') { if ($depth++ === 0) { $opening = $i; } } elseif ($char === '}' && --$depth === 0) { $items[] = [ 'prelude' => self::normalise(substr($css, $start, $opening - $start)), 'body' => substr($css, $opening + 1, $i - $opening - 1), ]; $start = $i + 1; } elseif ($char === ';' && $depth === 0 && $parens === 0) { $items[] = ['statement' => self::normalise(substr($css, $start, $i - $start)).';']; $start = $i + 1; } } $rest = trim(substr($css, $start)); if ($rest !== '') { if (! $declarations) { throw new RuntimeException('The stylesheet ends inside an unclosed rule.'); } $items[] = ['statement' => self::normalise($rest).';']; } return $items; } }