Files
Andreas Reinhold / reiniandClaude Opus 5 f31acf82d7 Pin every written-out copy of the rail's collapsed conditions
Plan step 36, navigation review. The Tailwind-era rail-collapsed:
variant is now plain selectors in 26 rules, each repeated in five
window bands, across navigation-rail.css, navigation-rail-item.css,
navigation-rail-section.css and layout/scaffold.css; the tests pinned
a few of them. A new test walks every rule whose :where() names the
rail and fails when a copy drifts from the seven conditions for its
band, or when a rule is drawn collapsed in some bands but not all five.
ComponentStylesheet gains rules() for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 01:12:27 +02:00

311 lines
9.2 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace NoNameWeb\LivewireMaterial\Tests\Support;
use RuntimeException;
/**
* A component stylesheet (resources/css/components/<name>.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<array{selector: string, at: list<string>, declarations: array<string, string>}> */
private array $rules = [];
/** @var list<string> */
private array $statements = [];
/** @var list<string> */
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<string>
*/
public function statements(): array
{
return $this->statements;
}
/**
* The files this stylesheet imports, as written.
*
* @return list<string>
*/
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<string>
*/
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<string> $at
* @return array<string, string>
*/
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;
}
/**
* Every rule, nesting resolved, in source order: for a test that has to walk all of them rather
* than name one.
*
* @return list<array{selector: string, at: list<string>, declarations: array<string, string>}>
*/
public function rules(): array
{
return $this->rules;
}
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<string>
*/
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<string> $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<string>
*/
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<array{statement: string}|array{prelude: string, body: string}>
*/
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;
}
}