Plan step 41 review: an application's CSS entry may import what a Vite build resolves but Stylesheets::bundle() refuses (tailwindcss mid- migration, a font URL, a layer() import); resolvedFiles() threw on each, failing the application's whole design test. It now follows relative imports only and skips the rest. Each missing stylesheet, shadowed tag and ->links() is reported once, at its first use, instead of at every occurrence; a CSS entry that does not exist says so; the @import line goes through vendor/ when Composer symlinks the package. missingStylesheets() also switched on the literal-value check for the entry's whole import graph, vendor stylesheets included, with no way to check imports alone. Check (iii) now reads the .css files scan() is given, like every other check; the entry's imports still feed the class exemptions, now escaped selectors (.hover\:underline) included. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
458 lines
20 KiB
PHP
458 lines
20 KiB
PHP
<?php
|
|
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Str;
|
|
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
|
|
|
|
/**
|
|
* `Stylesheets::bundle()` (plan step 37) is what the showcase and the error page's fallback serve
|
|
* without the application's own Vite build, so it has to do in PHP what Vite's bundled
|
|
* postcss-import already does for a normal build: inline every `@import` once, first occurrence
|
|
* kept, and leave a `url()` reachable from wherever the bundle ends up.
|
|
*
|
|
* This lives apart from StylesheetsTest.php, which is about the *shape* of the source tree (every
|
|
* file's own header, layer statement and imports) — a concern that holds whether or not anything
|
|
* ever bundles them. This file is about the bundler itself: what `bundle()` produces, and, at the
|
|
* end, a real Vite build to pin the claim it is modelled on.
|
|
*
|
|
* Every helper here is named `stylesheetsBundle*` rather than reusing StylesheetsTest.php's
|
|
* `stylesheet*` names — the two files may run in the same Pest process, and a duplicate top-level
|
|
* function name is a fatal error, the same reason the four component-group stylesheet test files
|
|
* each name their own `assert…TokensAndPxBreakpoints()` uniquely.
|
|
*/
|
|
function stylesheetsBundlePath(string $path = ''): string
|
|
{
|
|
return (string) realpath(__DIR__.'/../../resources/css'.($path === '' ? '' : '/'.$path));
|
|
}
|
|
|
|
/**
|
|
* `$css` with every comment and quoted string blanked out — the same idea StylesheetsTest.php's
|
|
* `stylesheetWithoutComments()` uses, kept local here for the reason above.
|
|
*/
|
|
function stylesheetsBundleWithoutComments(string $css): string
|
|
{
|
|
return (string) preg_replace('~("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')|/\*.*?\*/~s', '$1', $css);
|
|
}
|
|
|
|
/**
|
|
* The first line of a stylesheet's header comment, the sentence naming what it draws — unique per
|
|
* file (checked below), untouched by `bundle()` since it only ever rewrites an `@import` or a
|
|
* `url()`, never a comment. A reliable per-file fingerprint in `bundle()`'s output, which is not
|
|
* minified.
|
|
*/
|
|
function stylesheetsBundleMarker(string $file): string
|
|
{
|
|
$lines = explode("\n", File::get($file));
|
|
|
|
return trim((string) preg_replace('/^\s*\*\s?/', '', $lines[1] ?? ''));
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
function stylesheetsBundleComponentAndLayoutFiles(): array
|
|
{
|
|
return collect(File::files(stylesheetsBundlePath('components')))
|
|
->merge(File::files(stylesheetsBundlePath('layout')))
|
|
->map(fn (SplFileInfo $file): string => (string) $file->getRealPath())
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
it('bundles all.css with every import resolved, foundation first, every component and layout file once', function () {
|
|
$bundled = Stylesheets::bundle([stylesheetsBundlePath('all.css')]);
|
|
|
|
expect(stylesheetsBundleWithoutComments($bundled))->not->toContain('@import');
|
|
|
|
$files = stylesheetsBundleComponentAndLayoutFiles();
|
|
$markers = collect($files)->mapWithKeys(fn (string $file): array => [$file => stylesheetsBundleMarker($file)]);
|
|
|
|
expect($markers)->toHaveCount(count($files))
|
|
->and($markers->unique()->count())->toBe($markers->count(), 'two files share a header marker');
|
|
|
|
foreach ($markers as $file => $marker) {
|
|
expect(substr_count($bundled, $marker))->toBe(1, basename($file).' should appear exactly once in the bundle');
|
|
}
|
|
|
|
$foundationPosition = strpos($bundled, stylesheetsBundleMarker(stylesheetsBundlePath('foundation.css')));
|
|
$earliestOtherPosition = $markers->map(fn (string $marker): int => (int) strpos($bundled, $marker))->min();
|
|
|
|
expect($foundationPosition)->toBeInt()
|
|
->and($foundationPosition)->toBeLessThan($earliestOtherPosition);
|
|
});
|
|
|
|
it('resolves the files an entry imports, transitively, without bundling any content', function () {
|
|
$files = Stylesheets::resolvedFiles([stylesheetsBundlePath('components/split-button.css')]);
|
|
|
|
expect($files)->toContain(
|
|
stylesheetsBundlePath('components/split-button.css'),
|
|
stylesheetsBundlePath('components/button.css'),
|
|
stylesheetsBundlePath('components/menu.css'),
|
|
stylesheetsBundlePath('components/icon.css'),
|
|
);
|
|
});
|
|
|
|
it('resolves past what an application entry imports and bundle() refuses, without throwing', function () {
|
|
$dir = sys_get_temp_dir().'/livewire-material-resolved-'.Str::random(8);
|
|
File::makeDirectory($dir);
|
|
File::put($dir.'/theme.css', '.theme {}');
|
|
File::put($dir.'/app.css', implode("\n", [
|
|
"@import 'tailwindcss';",
|
|
"@import url('https://fonts.googleapis.com/css2?family=Roboto');",
|
|
"@import './theme.css' layer(app);",
|
|
"@import './missing.css';",
|
|
"@import '".stylesheetsBundlePath('components/icon.css')."';",
|
|
]));
|
|
|
|
try {
|
|
$files = Stylesheets::resolvedFiles([$dir.'/app.css']);
|
|
} finally {
|
|
File::deleteDirectory($dir);
|
|
}
|
|
|
|
expect($files)->toBe([realpath(sys_get_temp_dir()).'/'.basename($dir).'/app.css', realpath(sys_get_temp_dir()).'/'.basename($dir).'/theme.css']);
|
|
});
|
|
|
|
it('keeps a file two others import once, at its first position', function () {
|
|
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
|
File::makeDirectory($dir);
|
|
|
|
File::put("{$dir}/shared.css", "@layer material.reset;\n.shared { color: green; }\n");
|
|
File::put("{$dir}/a.css", "@import './shared.css';\n.a { color: red; }\n");
|
|
File::put("{$dir}/b.css", "@import './shared.css';\n.b { color: blue; }\n");
|
|
|
|
try {
|
|
$bundled = Stylesheets::bundle(["{$dir}/a.css", "{$dir}/b.css"]);
|
|
|
|
expect(substr_count($bundled, '.shared { color: green; }'))->toBe(1)
|
|
->and(strpos($bundled, '.shared'))->toBeLessThan(strpos($bundled, '.a { color: red; }'))
|
|
->and($bundled)->toContain('.b { color: blue; }')
|
|
->and(stylesheetsBundleWithoutComments($bundled))->not->toContain('@import');
|
|
} finally {
|
|
File::deleteDirectory($dir);
|
|
}
|
|
});
|
|
|
|
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
|
|
// again (resources/fonts/, resources/svg/, siblings of resources/css/) — so a url() two
|
|
// directories away from the file that wrote it lands one directory away from the entry.
|
|
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
|
File::makeDirectory("{$dir}/css/components", recursive: true);
|
|
File::makeDirectory("{$dir}/fonts");
|
|
|
|
File::put("{$dir}/css/entry.css", "@import './components/widget.css';\n");
|
|
File::put("{$dir}/css/components/widget.css", <<<'CSS'
|
|
@font-face { src: url('../../fonts/widget.woff2') format('woff2'); }
|
|
.widget { background: url("icon.svg"), url(https://example.com/a.png), url(#gradient), url(data:image/png;base64,AA==); }
|
|
CSS);
|
|
|
|
try {
|
|
$bundled = Stylesheets::bundle(["{$dir}/css/entry.css"]);
|
|
|
|
// Resolved against the entry's own directory (css/) when no $base is given: the font
|
|
// climbs out of css/ to the sibling fonts/ directory, the same shape resources/css/ has
|
|
// relative to resources/fonts/; the icon, sitting beside widget.css, stays inside components/.
|
|
expect($bundled)->toContain("url('../fonts/widget.woff2')")
|
|
->toContain('url("components/icon.svg")')
|
|
// An absolute URL, a # fragment and a data: URI are left exactly as written.
|
|
->toContain('url(https://example.com/a.png)')
|
|
->toContain('url(#gradient)')
|
|
->toContain('url(data:image/png;base64,AA==)');
|
|
|
|
// $base stands for wherever the entry itself (the thing "resources/css/" is here) is
|
|
// served from: the icon, which never left that directory, lands right under $base, while
|
|
// the font's one directory climbed above the entry the same way it did with no $base at
|
|
// all, so it climbs one directory above $base too — off the end of "assets", not inside it.
|
|
Stylesheets::resetCache();
|
|
$withBase = Stylesheets::bundle(["{$dir}/css/entry.css"], 'https://cdn.example.com/assets');
|
|
|
|
expect($withBase)->toContain("url('https://cdn.example.com/fonts/widget.woff2')")
|
|
->toContain('url("https://cdn.example.com/assets/components/icon.svg")')
|
|
->toContain('url(https://example.com/a.png)');
|
|
} finally {
|
|
File::deleteDirectory($dir);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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);
|
|
|
|
File::put("{$dir}/a.css", "@import './b.css';\n.a {}\n");
|
|
File::put("{$dir}/b.css", "@import './a.css';\n.b {}\n");
|
|
|
|
try {
|
|
$bundled = Stylesheets::bundle(["{$dir}/a.css"]);
|
|
|
|
expect($bundled)->toContain('.a {}')->toContain('.b {}');
|
|
} finally {
|
|
File::deleteDirectory($dir);
|
|
}
|
|
});
|
|
|
|
it('throws naming the importer when an import does not exist', function () {
|
|
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
|
File::makeDirectory($dir);
|
|
|
|
File::put("{$dir}/broken.css", "@import './missing.css';\n");
|
|
|
|
try {
|
|
Stylesheets::bundle(["{$dir}/broken.css"]);
|
|
|
|
$this->fail('Stylesheets::bundle() should have thrown.');
|
|
} catch (RuntimeException $exception) {
|
|
expect($exception->getMessage())->toContain('broken.css')->toContain('missing.css');
|
|
} finally {
|
|
File::deleteDirectory($dir);
|
|
}
|
|
});
|
|
|
|
it('caches per file list and mtime, and resetCache() forces a fresh read', function () {
|
|
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
|
File::makeDirectory($dir);
|
|
File::put("{$dir}/cached.css", '.before {}');
|
|
touch("{$dir}/cached.css", 1_700_000_000);
|
|
|
|
try {
|
|
expect(Stylesheets::bundle(["{$dir}/cached.css"]))->toBe('.before {}');
|
|
|
|
// The mtime is unchanged, so the cache serves the old content even though the file itself
|
|
// now holds something else.
|
|
File::put("{$dir}/cached.css", '.changed-but-same-mtime {}');
|
|
touch("{$dir}/cached.css", 1_700_000_000);
|
|
|
|
expect(Stylesheets::bundle(["{$dir}/cached.css"]))
|
|
->toBe('.before {}', 'an unchanged mtime should still be served from the cache');
|
|
|
|
Stylesheets::resetCache();
|
|
|
|
expect(Stylesheets::bundle(["{$dir}/cached.css"]))
|
|
->toBe('.changed-but-same-mtime {}', 'resetCache() should force a fresh read');
|
|
|
|
// A later mtime invalidates the cache on its own, with no resetCache() needed.
|
|
File::put("{$dir}/cached.css", '.after {}');
|
|
touch("{$dir}/cached.css", 1_700_000_100);
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Every innermost rule of a minified stylesheet — `selector{declarations}` with no block inside —
|
|
* keyed by the at-rules around it (`@layer material.components{@media (hover:hover){…`), so the
|
|
* same rule written for two different conditions counts as two rules, not one repeated.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
function stylesheetsBundleLeafRules(string $css): array
|
|
{
|
|
$leaves = [];
|
|
$stack = [];
|
|
$start = 0;
|
|
$length = strlen($css);
|
|
|
|
for ($i = 0; $i < $length; $i++) {
|
|
$char = $css[$i];
|
|
|
|
if ($char === '"' || $char === "'") {
|
|
$close = strpos($css, $char, $i + 1);
|
|
|
|
if ($close === false) {
|
|
break;
|
|
}
|
|
|
|
$i = $close;
|
|
} elseif ($char === '{') {
|
|
$stack[] = ['prelude' => trim(substr($css, $start, $i - $start)), 'open' => $i, 'leaf' => true];
|
|
|
|
if (count($stack) > 1) {
|
|
$stack[count($stack) - 2]['leaf'] = false;
|
|
}
|
|
|
|
$start = $i + 1;
|
|
} elseif ($char === '}') {
|
|
$block = array_pop($stack);
|
|
|
|
if ($block !== null && $block['leaf']) {
|
|
$context = implode('{', array_column($stack, 'prelude'));
|
|
$leaves[] = $context.'{'.$block['prelude'].'{'.substr($css, $block['open'] + 1, $i - $block['open'] - 1).'}';
|
|
}
|
|
|
|
$start = $i + 1;
|
|
} elseif ($char === ';' && ($stack === [] || ! $stack[count($stack) - 1]['leaf'])) {
|
|
// A statement between blocks (`@layer a,b;`) is not part of the next rule's selector.
|
|
$start = $i + 1;
|
|
}
|
|
}
|
|
|
|
return $leaves;
|
|
}
|
|
|
|
/**
|
|
* The Vite deduplication `bundle()` is modelled on, pinned against the real bundler
|
|
* (tests/Fixtures/dedup.vite.config.mjs, no plugin in the graph): `all.css`, and
|
|
* tests/Fixtures/dedup-app.css, an entry shaped like an application's — outside the package, the
|
|
* foundation, then split button, app bar, modal and pagination, which each import button.css, and
|
|
* button.css once more.
|
|
*
|
|
* The built CSS is minified, so this compares rules rather than files: no innermost rule may
|
|
* appear twice under the same at-rules. A build that inlined every occurrence of a shared
|
|
* stylesheet repeats hundreds of them (the Workbench's entry did while it shared one file with
|
|
* `@tailwindcss/vite`: button.css's hover and disabled rules thirteen times), while a file's plain
|
|
* root rule alone is no evidence — the minifier folds identical copies of that one together. In
|
|
* the application entry, button.css must also keep its first position: before split-button.css,
|
|
* the first file that imports it, not at the end where the application named it again.
|
|
*/
|
|
it('lands every rule once, at its first position, in a real Vite build of all.css and of an application entry', function () {
|
|
$node = config('livewire-material.node', 'node');
|
|
|
|
if (Process::run([$node, '--version'])->failed()) {
|
|
$this->markTestSkipped("Node ({$node}) could not run.");
|
|
}
|
|
|
|
$vite = __DIR__.'/../../node_modules/vite/bin/vite.js';
|
|
|
|
if (! is_file($vite)) {
|
|
$this->markTestSkipped('Vite is not installed (node_modules/vite is missing).');
|
|
}
|
|
|
|
$outDir = sys_get_temp_dir().'/livewire-material-dedup-'.Str::random(8);
|
|
|
|
try {
|
|
$result = Process::path(__DIR__.'/../..')
|
|
->env(['DEDUP_OUT_DIR' => $outDir])
|
|
->timeout(120)
|
|
->run([$node, $vite, 'build', '--config', 'tests/Fixtures/dedup.vite.config.mjs']);
|
|
|
|
expect($result->successful())->toBeTrue($result->errorOutput());
|
|
|
|
$manifest = json_decode(File::get("{$outDir}/.vite/manifest.json"), true, flags: JSON_THROW_ON_ERROR);
|
|
|
|
foreach (['resources/css/all.css', 'tests/Fixtures/dedup-app.css'] as $entry) {
|
|
$css = File::get($outDir.'/'.$manifest[$entry]['file']);
|
|
$leaves = stylesheetsBundleLeafRules($css);
|
|
$repeated = array_keys(array_filter(array_count_values($leaves), fn (int $count): bool => $count > 1));
|
|
|
|
expect(count($leaves))->toBeGreaterThan(100, $entry)
|
|
->and($repeated)->toBe([], "{$entry} repeats a rule, so a shared stylesheet landed more than once");
|
|
}
|
|
|
|
$app = File::get($outDir.'/'.$manifest['tests/Fixtures/dedup-app.css']['file']);
|
|
|
|
expect(strpos($app, '[data-md-button]{'))->toBeInt()
|
|
->toBeLessThan((int) strpos($app, '[data-md-split-button]{'))
|
|
->and(strpos($app, '[data-md-app-bar]'))->toBeGreaterThan((int) strpos($app, '[data-md-split-button]{'));
|
|
} finally {
|
|
File::deleteDirectory($outDir);
|
|
}
|
|
});
|