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:
co-authored by
Claude Sonnet 5
parent
a967cfcc2b
commit
792ec44d5e
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
<?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('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);
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
} finally {
|
||||
Stylesheets::resetCache();
|
||||
File::deleteDirectory($dir);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The Vite deduplication `bundle()` is modelled on, pinned against the real bundler: a Vite build
|
||||
* of `all.css` alone, no other plugin in the graph (tests/Fixtures/dedup.vite.config.mjs — its
|
||||
* header explains why this is a separate, minimal entry rather than the Workbench's own).
|
||||
*
|
||||
* The built CSS is minified, so rather than compare byte-for-byte against `bundle()`'s own
|
||||
* (unminified) output, this counts one plain, unwrapped rule per file — `[data-md-<name>]{…}`
|
||||
* exactly as button.css, icon.css, menu.css and list-item.css each write it before any `:not()`,
|
||||
* `:is()` or `:where()` wrapping — and asserts each appears exactly once. A rule behind such
|
||||
* wrapping is not used as a marker because Lightning CSS (Tailwind's minifier, which the
|
||||
* Workbench's own build also carries) may re-split a single nested rule into several flat ones
|
||||
* sharing a declaration block, which would inflate a literal count for a reason that has nothing
|
||||
* to do with import deduplication; these four files' very first, plain, unwrapped selector is not
|
||||
* affected by that and stays a reliable one-file-once fingerprint. They are also each imported
|
||||
* from several other stylesheets (button.css: 11 places; icon.css and menu.css: a dozen more
|
||||
* between them; list-item.css: list.css and carousel-item.css), so a broken dedup would double or
|
||||
* triple the count rather than leave it at one.
|
||||
*/
|
||||
it('lands each of four widely-imported component stylesheets exactly once in a real Vite build', 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);
|
||||
$css = File::get($outDir.'/'.$manifest['resources/css/all.css']['file']);
|
||||
|
||||
foreach (['button', 'icon', 'menu', 'list-item'] as $name) {
|
||||
$needle = "[data-md-{$name}]{";
|
||||
$start = strpos($css, $needle);
|
||||
|
||||
expect($start)->toBeInt("{$needle} should be in the built CSS");
|
||||
|
||||
$rule = substr($css, $start, strpos($css, '}', $start) - $start + 1);
|
||||
|
||||
expect(substr_count($css, $rule))->toBe(1, "{$name}.css's root rule should land once");
|
||||
}
|
||||
} finally {
|
||||
File::deleteDirectory($outDir);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// Builds resources/css/all.css alone, with no other plugin in the graph: it isolates Vite's own
|
||||
// bundled postcss-import, whose `skipDuplicates` option is the mechanism
|
||||
// docs/plans/material-3-alignment.md's "Tailwind's footprint" paragraph measured and
|
||||
// StylesheetsBundleTest.php pins against the real bundler — a stylesheet several files import
|
||||
// lands once, at its first position.
|
||||
//
|
||||
// The Workbench's own entry (vite.config.js) still shares one file with `@tailwindcss/vite` until
|
||||
// plan step 39 removes it, and that plugin bundles its whole reachable module graph itself,
|
||||
// independently of Vite's CSS pipeline; it does not carry the same `skipDuplicates` option, so a
|
||||
// shared component stylesheet currently lands once per import site there, not once overall. That
|
||||
// is a pre-existing limitation of mixing the two in one entry, not something plan step 37
|
||||
// introduces or can fix without moving Tailwind out of the Workbench's entry (step 39's job) — this
|
||||
// fixture is what actually exercises the claim being pinned, decoupled from it.
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: process.env.DEDUP_OUT_DIR,
|
||||
emptyOutDir: true,
|
||||
manifest: true,
|
||||
rollupOptions: {
|
||||
input: 'resources/css/all.css',
|
||||
},
|
||||
},
|
||||
logLevel: 'silent',
|
||||
});
|
||||
Reference in New Issue
Block a user