Add Stylesheets::bundle(), a build-free CSS import resolver

Plan step 37: the showcase and the error page's fallback serve CSS
without the application's Vite build, so nothing deduplicates their
@imports for them the way Vite's bundled postcss-import does — a
browser's native @import fetches every occurrence, it does not skip a
file it already loaded. Stylesheets::bundle(array $files, ?string
$base = null): string does in PHP what that build step does: it
inlines every @import depth-first, each file once, first occurrence
kept; leaves a bare specifier or an absolute URL untouched; rewrites a
relative url() against $base (or, without one, against the directory
of $files[0]); breaks a cycle instead of looping; throws naming the
importer when an import is missing; and caches per resolved file list
and mtime, with resetCache() for tests.

tests/Feature/StylesheetsBundleTest.php covers bundle()'s own
behaviour (dedup, url() rewriting, cycles, the missing-import
exception, the cache) and, at the end, pins the Vite deduplication
bundle() is modelled on against a real build — of all.css alone
(tests/Fixtures/dedup.vite.config.mjs), whose docblock explains why:
the Workbench's own entry still shares one file with @tailwindcss/vite
until plan step 39 removes it, and that plugin bundles its whole
reachable module graph itself, without the same dedup guarantee.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 02:49:59 +02:00
co-authored by Claude Sonnet 5
parent a967cfcc2b
commit 792ec44d5e
3 changed files with 656 additions and 0 deletions
+358
View File
@@ -0,0 +1,358 @@
<?php
namespace NoNameWeb\LivewireMaterial\Support;
use RuntimeException;
/**
* Bundles a list of package stylesheets into one string, without a JavaScript build: the showcase
* and the error page's fallback (`ErrorPage::fallbackStyles()`, plan step 40) serve CSS on their
* own, outside the application's Vite build, so nothing deduplicates their `@import`s for them —
* a browser's native `@import` fetches and applies every occurrence, it does not skip a file it
* has already loaded. This class does in PHP what Vite's bundled postcss-import does in the
* application's build: it inlines every `@import`, once per file, first occurrence kept.
*
* `bundle()` walks each given file's `@import`s depth-first, in source order, and replaces each
* one with the imported file's own content (which is itself walked the same way) the first time
* that file is reached; a later `@import` of the same file, anywhere in the graph, is simply
* dropped, since the file's rules are already in the output at their first position. Only a
* relative import — `@import './x.css';` or `@import '../x.css';`, with or without `url()` — is
* resolved this way; a bare specifier (`@import 'tailwindcss';`) or an absolute URL is left
* exactly as written, the way postcss-import treats the first as a package to resolve elsewhere
* and the second as a network resource. The `@layer` statement every package stylesheet opens
* with is ordinary content here: it is not deduplicated or hoisted, because a repeated `@layer`
* statement naming the same sub-layers in the same order is valid anywhere at the top level and
* changes nothing — the output keeps one copy per file it inlines.
*
* A `url()` this class finds — a font, an SVG mask — is only ever relative in the tree this class
* ships with, so only a relative one is rewritten (an absolute URL, a `data:` URI or a `#`
* fragment is left alone): resolved against the file that wrote it, then re-expressed relative to
* the directory of `$files[0]` — `resources/css/` for the package's own entry points, so without
* `$base` a font or an SVG stays reachable exactly as it would from a file sitting there. `$base`
* relocates that path instead, onto the directory or URL the bundle is actually served from.
*/
final class Stylesheets
{
/**
* @var array<string, string>
*/
private static array $cache = [];
/**
* Every file in `$files`, with its own and every transitively imported file's content inlined
* once, first occurrence kept; relative `url()`s resolved against `$base` (or, without one,
* against the directory of `$files[0]`).
*
* @param list<string> $files
*/
public static function bundle(array $files, ?string $base = null): string
{
if ($files === []) {
return '';
}
$key = self::cacheKey($files, $base);
if (array_key_exists($key, self::$cache)) {
return self::$cache[$key];
}
$root = dirname(self::resolve($files[0], null));
$seen = [];
$parts = [];
foreach ($files as $file) {
$parts[] = self::inline(self::resolve($file, null), $seen, [], $root, $base);
}
return self::$cache[$key] = implode('', $parts);
}
/**
* Drops every cached bundle: a test that writes its own fixtures between calls needs a fresh
* read, since the cache otherwise lives for the rest of the worker process.
*/
public static function resetCache(): void
{
self::$cache = [];
}
/**
* @param list<string> $files
*/
private static function cacheKey(array $files, ?string $base): string
{
$parts = array_map(function (string $file): string {
$real = realpath($file);
return $real === false ? $file : $real.'@'.filemtime($real);
}, $files);
return implode('|', $parts).'#'.($base ?? '');
}
private static function resolve(string $path, ?string $importer): string
{
$real = realpath($path);
if ($real === false) {
throw new RuntimeException($importer === null
? "Stylesheets::bundle() cannot read \"{$path}\": it does not exist."
: "{$importer} imports \"{$path}\", which does not exist.");
}
return $real;
}
/**
* @param array<string, true> $seen every file already inlined, by resolved path
* @param list<string> $stack the files currently being inlined, to break a cycle
*/
private static function inline(string $file, array &$seen, array $stack, string $root, ?string $base): string
{
if (isset($seen[$file]) || in_array($file, $stack, true)) {
return '';
}
$seen[$file] = true;
$stack[] = $file;
$css = (string) file_get_contents($file);
$masked = self::mask($css);
$dir = dirname($file);
$result = '';
$cursor = 0;
// A child's own content is already rewritten against its own directory by its own call
// below, so only this file's own text — never the spliced-in content of another file —
// is passed to rewriteUrls() here; running it again over the concatenated result would
// resolve an already-rewritten url() a second time, against the wrong directory.
foreach (self::imports($css, $masked) as $import) {
$result .= self::rewriteUrls(substr($css, $cursor, $import['start'] - $cursor), $dir, $root, $base);
$result .= self::isRelativeImport($import['target'])
? self::inline(self::resolve($dir.'/'.$import['target'], self::name($file)), $seen, $stack, $root, $base)
: substr($css, $import['start'], $import['end'] - $import['start']);
$cursor = $import['end'];
}
return $result.self::rewriteUrls(substr($css, $cursor), $dir, $root, $base);
}
/**
* The path a stylesheet is named by in an exception: relative to the package, when it is
* inside it, so a message reads `components/button.css imports …` rather than a long
* absolute path.
*/
private static function name(string $file): string
{
$package = dirname(__DIR__, 2).'/resources/css/';
return str_starts_with($file, $package) ? substr($file, strlen($package)) : $file;
}
/**
* `$css` with every comment and quoted string blinded to spaces (newlines kept), same length —
* so a search on it for `@import` or `url(` never matches one written inside a comment or a
* string, while every offset still lines up with `$css` itself.
*/
private static function mask(string $css): string
{
$masked = $css;
$length = strlen($css);
$i = 0;
while ($i < $length) {
if ($css[$i] === '/' && ($css[$i + 1] ?? '') === '*') {
$end = strpos($css, '*/', $i + 2);
$end = $end === false ? $length : $end + 2;
$masked = self::blind($masked, $i, $end);
$i = $end;
continue;
}
if ($css[$i] === '"' || $css[$i] === "'") {
$quote = $css[$i];
$j = $i + 1;
while ($j < $length && $css[$j] !== $quote) {
$j += $css[$j] === '\\' ? 2 : 1;
}
$j = min($j + 1, $length);
$masked = self::blind($masked, $i, $j);
$i = $j;
continue;
}
$i++;
}
return $masked;
}
private static function blind(string $masked, int $start, int $end): string
{
for ($i = $start; $i < $end; $i++) {
if ($masked[$i] !== "\n") {
$masked[$i] = ' ';
}
}
return $masked;
}
/**
* Every top-level `@import` statement in `$css`, found through `$masked` so one inside a
* comment or a string never counts, each with the byte range of the whole statement (the
* semicolon included) and the quoted target it names.
*
* @return list<array{start: int, end: int, target: string}>
*/
private static function imports(string $css, string $masked): array
{
$imports = [];
if (preg_match_all('/@import\b/', $masked, $matches, PREG_OFFSET_CAPTURE) === 0) {
return $imports;
}
foreach ($matches[0] as [, $start]) {
$end = strpos($masked, ';', $start);
$end = $end === false ? strlen($css) : $end + 1;
$statement = substr($css, $start, $end - $start);
if (preg_match('/^@import\s+(?:url\(\s*)?([\'"])(.*?)\1\)?\s*;?\s*$/s', trim($statement), $match) === 1) {
$imports[] = ['start' => $start, 'end' => $end, 'target' => $match[2]];
}
}
return $imports;
}
/**
* A bare specifier (`tailwindcss`) or an absolute URL is not resolved against the importing
* file — only `./x` and `../x` are, the two forms every package stylesheet writes.
*/
private static function isRelativeImport(string $target): bool
{
return str_starts_with($target, './') || str_starts_with($target, '../');
}
/**
* Every relative `url()` in `$css` re-expressed against `$root` (or `$base`, once joined onto
* it below), resolved first against `$dir`, the directory of the file that wrote it. An
* absolute URL, a `data:` URI or a `#` fragment is left untouched.
*/
private static function rewriteUrls(string $css, string $dir, string $root, ?string $base): string
{
$masked = self::mask($css);
if (preg_match_all('/url\(/', $masked, $matches, PREG_OFFSET_CAPTURE) === 0) {
return $css;
}
$result = '';
$cursor = 0;
foreach ($matches[0] as [, $start]) {
if ($start < $cursor) {
continue;
}
$close = strpos($masked, ')', $start);
if ($close === false) {
continue;
}
$call = substr($css, $start, $close - $start + 1);
if (preg_match('/^url\(\s*([\'"]?)(.*?)\1\s*\)$/s', $call, $match) !== 1 || ! self::isRelativeUrl($match[2])) {
continue;
}
$result .= substr($css, $cursor, $start - $cursor);
$absolute = self::normalise($dir.'/'.$match[2]);
$target = self::relative($root, $absolute);
$target = $base === null ? $target : self::join($base, $target);
$result .= "url({$match[1]}{$target}{$match[1]})";
$cursor = $close + 1;
}
return $result.substr($css, $cursor);
}
private static function isRelativeUrl(string $value): bool
{
if ($value === '' || str_starts_with($value, '#') || str_starts_with($value, '/')) {
return false;
}
return preg_match('/^[a-z][a-z0-9+.-]*:/i', $value) !== 1;
}
/**
* `$path`, its `.` and `..` segments resolved away. Pure string handling — the target need not
* exist on disk, since a bundle a test builds may not ship the fonts and SVGs it points at.
*/
private static function normalise(string $path): string
{
$absolute = str_starts_with($path, '/');
$segments = [];
foreach (explode('/', $path) as $segment) {
if ($segment === '' || $segment === '.') {
continue;
}
if ($segment === '..' && $segments !== [] && end($segments) !== '..') {
array_pop($segments);
} else {
$segments[] = $segment;
}
}
return ($absolute ? '/' : '').implode('/', $segments);
}
/**
* `$to`, expressed relative to the directory `$from`.
*/
private static function relative(string $from, string $to): string
{
$fromParts = array_values(array_filter(explode('/', $from), fn (string $part): bool => $part !== ''));
$toParts = array_values(array_filter(explode('/', $to), fn (string $part): bool => $part !== ''));
$i = 0;
while ($i < count($fromParts) && $i < count($toParts) && $fromParts[$i] === $toParts[$i]) {
$i++;
}
$path = implode('/', [
...array_fill(0, count($fromParts) - $i, '..'),
...array_slice($toParts, $i),
]);
return $path === '' ? '.' : $path;
}
/**
* `$relative` appended to `$base`, a directory or a URL: `..` in `$relative` climbs out of
* `$base`'s own path, never past a URL's scheme and host.
*/
private static function join(string $base, string $relative): string
{
$base = rtrim($base, '/');
if (preg_match('~^([a-z][a-z0-9+.-]*://[^/]*)(/.*)?$~i', $base, $match) === 1) {
return $match[1].self::normalise(($match[2] ?? '').'/'.$relative);
}
return self::normalise($base.'/'.$relative);
}
}