*/ 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 */ 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); } });