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
311 lines
9.2 KiB
PHP
311 lines
9.2 KiB
PHP
<?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;
|
||
}
|
||
}
|