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:
co-authored by
Claude Opus 5
parent
792ec44d5e
commit
3beeb2fc22
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user