diff --git a/src/Support/Stylesheets.php b/src/Support/Stylesheets.php new file mode 100644 index 00000000..6993fd6b --- /dev/null +++ b/src/Support/Stylesheets.php @@ -0,0 +1,358 @@ + + */ + 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 $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 $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 $seen every file already inlined, by resolved path + * @param list $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 + */ + 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); + } +} diff --git a/tests/Feature/StylesheetsBundleTest.php b/tests/Feature/StylesheetsBundleTest.php new file mode 100644 index 00000000..e7fd060f --- /dev/null +++ b/tests/Feature/StylesheetsBundleTest.php @@ -0,0 +1,272 @@ + + */ +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-]{…}` + * 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); + } +}); diff --git a/tests/Fixtures/dedup.vite.config.mjs b/tests/Fixtures/dedup.vite.config.mjs new file mode 100644 index 00000000..537c81f0 --- /dev/null +++ b/tests/Fixtures/dedup.vite.config.mjs @@ -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', +});