Reject imports Stylesheets::bundle() cannot inline faithfully

Plan step 37 review. bundle() left an @import it did not recognise
exactly as written, in the middle of the output, where a browser ignores
it: a layer(), supports() or media condition was silently dropped, an
unquoted url(./x.css) import was left in place with its path mangled by
the url() rewrite, and a bare specifier or absolute URL vanished the same
way. An @import after a rule or inside a @layer block was inlined anyway,
nesting a whole file's layers inside another. Each now throws, naming the
file; unquoted url() imports and @IMPORT inline like the other forms.

The cache keyed only the top-level files' mtimes, so a changed
button.css left a cached all.css bundle standing in a long-lived worker;
a cached bundle is now served only while every file it inlined keeps its
mtime. A null and an empty $base no longer share a cache key, a leading
@charset or byte-order mark is dropped from each inlined file, and a
test pins the import graph free of cycles, the one case where a
depth-first bundle would place a stylesheet before a file it imports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 03:07:26 +02:00
co-authored by Claude Opus 5
parent 792ec44d5e
commit 3beeb2fc22
2 changed files with 199 additions and 70 deletions
+102
View File
@@ -102,6 +102,68 @@ it('keeps a file two others import once, at its first position', function () {
}
});
it('inlines every plain import form once, and nothing written in a comment or a string', function () {
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
File::makeDirectory("{$dir}/components", recursive: true);
foreach (['double', 'quoted-url', 'bare-url'] as $name) {
// A leading byte-order mark and @charset mean nothing past a stylesheet's first byte.
File::put("{$dir}/components/{$name}.css", "\u{FEFF}@charset \"UTF-8\";\n.{$name} {}\n");
}
File::put("{$dir}/entry.css", <<<'CSS'
/* An example in a header: @import './components/never.css'; url(never.png) — it's fine. */
@layer a, b;
@import "./components/double.css";
@import url('./components/quoted-url.css');
@IMPORT url( ./components/bare-url.css );
@import './components/../components/double.css';
.entry::before { content: "@import './components/never.css'; url(never.png)"; }
CSS);
try {
$bundled = Stylesheets::bundle(["{$dir}/entry.css"]);
expect(substr_count($bundled, '.double {}'))->toBe(1)
->and($bundled)->toContain('.quoted-url {}')
->toContain('.bare-url {}')
->toContain("/* An example in a header: @import './components/never.css'; url(never.png) — it's fine. */")
->toContain(".entry::before { content: \"@import './components/never.css'; url(never.png)\"; }")
->not->toContain('@charset')
->not->toContain("\u{FEFF}")
// The two left are the comment's and the string's.
->and(substr_count(strtolower($bundled), '@import'))->toBe(2);
} finally {
File::deleteDirectory($dir);
}
});
it('throws, naming the file, for an import the bundle could not inline without changing its meaning', function (string $css, string $reason) {
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
File::makeDirectory($dir);
File::put("{$dir}/x.css", '.x {}');
File::put("{$dir}/entry.css", $css);
try {
Stylesheets::bundle(["{$dir}/entry.css"]);
$this->fail("Stylesheets::bundle() should have thrown for {$css}");
} catch (RuntimeException $exception) {
expect($exception->getMessage())->toContain('entry.css')->toContain($reason);
} finally {
File::deleteDirectory($dir);
}
})->with([
'a layer() condition' => ["@import './x.css' layer(x);", 'without layer()'],
'a supports() condition' => ["@import './x.css' supports(display: grid);", 'without layer()'],
'a media condition' => ['@import url(./x.css) screen;', 'without layer()'],
'an absolute URL' => ["@import 'https://example.com/x.css';", 'absolute URL'],
'an absolute path' => ["@import '/x.css';", 'absolute URL'],
'a package specifier' => ["@import 'tailwindcss';", '"tailwindcss", which does not exist'],
'an import after a rule' => [".a {}\n@import './x.css';", 'after a rule'],
'an import inside a block' => ["@layer b { @import './x.css'; }", 'inside a block'],
]);
it('rewrites a relative url() against the entry file\'s directory, or against $base when given', function () {
// Mirrors resources/css/'s own shape: an entry (all.css) sitting where the bundle is "from",
// a component one level under it (components/menu.css), and an asset one level above that
@@ -145,6 +207,32 @@ it('rewrites a relative url() against the entry file\'s directory, or against $b
}
});
/**
* Deduplication keeps a file at its first position, so an override that ties a rule it imports on
* specificity (the navigation rail's header FAB over fab.css was one) still wins only while every
* stylesheet's rules land after those of each file it imports. A depth-first inline guarantees
* that for every import edge — except around a cycle, where one file of the two must come first.
* So no package stylesheet may reach itself through its imports.
*/
it('keeps the package\'s import graph free of cycles, so a stylesheet always lands after what it imports', function () {
$edges = 0;
$walk = function (string $file, array $stack) use (&$walk, &$edges): void {
expect(in_array($file, $stack, true))->toBeFalse('import cycle: '.implode(' → ', array_map(basename(...), [...$stack, $file])));
preg_match_all("/@import\\s+'([^']+)'/", stylesheetsBundleWithoutComments(File::get($file)), $matches);
foreach ($matches[1] as $import) {
$edges++;
$walk((string) realpath(dirname($file).'/'.$import), [...$stack, $file]);
}
};
$walk(stylesheetsBundlePath('all.css'), []);
expect($edges)->toBeGreaterThan(100);
});
it('does not loop on a cycle, and keeps both files\' rules', function () {
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
File::makeDirectory($dir);
@@ -206,6 +294,20 @@ it('caches per file list and mtime, and resetCache() forces a fresh read', funct
expect(Stylesheets::bundle(["{$dir}/cached.css"]))
->toBe('.after {}', 'a changed mtime should invalidate the cache on its own');
// So does a later mtime on a file the bundle reached only through an @import — a changed
// button.css must not leave a cached all.css bundle standing.
File::put("{$dir}/entry.css", "@import './cached.css';\n");
touch("{$dir}/entry.css", 1_700_000_000);
expect(Stylesheets::bundle(["{$dir}/entry.css"]))->toContain('.after {}');
File::put("{$dir}/cached.css", '.nested-change {}');
touch("{$dir}/cached.css", 1_700_000_200);
expect(Stylesheets::bundle(["{$dir}/entry.css"]))
->toContain('.nested-change {}')
->and(Stylesheets::bundle(["{$dir}/entry.css"], ''))->toContain('.nested-change {}');
} finally {
Stylesheets::resetCache();
File::deleteDirectory($dir);