Plan step 37 review. The Vite test counted each of four files' plain root rule, which the minifier folds into one even when a build repeats the whole stylesheet: it passed against the Workbench's Tailwind entry, which repeated 231 rules. It now asserts that no innermost rule repeats under the same at-rules, which that build fails and a deduplicating one passes, and it builds a second entry shaped like an application's — outside the package, the foundation, then four component stylesheets that each import button.css, and button.css again — where button.css must also keep its first position. The fixture config refuses to run without DEDUP_OUT_DIR, so it never writes into the package tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
426 lines
19 KiB
PHP
426 lines
19 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('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);
|
|
}
|
|
});
|