diff --git a/.github/workflows/browser-tests.yml b/.github/workflows/browser-tests.yml index 896ca3b..b40e4fc 100644 --- a/.github/workflows/browser-tests.yml +++ b/.github/workflows/browser-tests.yml @@ -1,6 +1,8 @@ name: browser-tests on: + pull_request: + branches: [development, main] workflow_run: workflows: [tests] types: [completed] diff --git a/composer.json b/composer.json index 38eb72d..38cce54 100644 --- a/composer.json +++ b/composer.json @@ -78,7 +78,8 @@ "cd ./workbench && npm ci", "ln -sf $PWD/workbench/jsconfig.json ./vendor/orchestra/testbench-core/laravel", "ln -sf $PWD/workbench/node_modules/ ./vendor/orchestra/testbench-core/laravel", - "ln -sf $PWD/workbench/resources/js/ ./vendor/orchestra/testbench-core/laravel/resources" + "ln -sf $PWD/workbench/resources/js/ ./vendor/orchestra/testbench-core/laravel/resources", + "ln -sf $PWD/workbench/resources/css/ ./vendor/orchestra/testbench-core/laravel/resources" ], "serve": [ diff --git a/config/bundle.php b/config/bundle.php index d830d3a..e50f667 100644 --- a/config/bundle.php +++ b/config/bundle.php @@ -11,7 +11,7 @@ | and disable this on your local development environment. | */ - 'caching_enabled' => env('BUNDLE_CACHING_ENABLED', app()->isProduction()), + 'caching' => env('BUNDLE_CACHING', true), /* |-------------------------------------------------------------------------- @@ -47,7 +47,7 @@ | won't impact performance when your imports are build for prod. | */ - 'sourcemaps_enabled' => env('BUNDLE_SOURCEMAPS_ENABLED', false), + 'sourcemaps' => env('BUNDLE_SOURCEMAPS', false), /* |-------------------------------------------------------------------------- diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 3b3b968..5fdd917 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -95,13 +95,13 @@ All code is minified by default. This can make issues harder to debug at times. ## Sourcemaps -Sourcemaps are disabled by default. You may enable this by setting `BUNDLE_SOURCEMAPS_ENABLED` to true in your env file or by publishing and updating the bundle config. +Sourcemaps are disabled by default. You may enable this by setting `BUNDLE_SOURCEMAPS` to true in your env file or by publishing and updating the bundle config. Sourcemaps will be generated in a separate file so this won't affect performance for the end user. {: .note } -> If your project stored previously bundled files you need to run the [bundle:clear](https://laravel-bundle.dev/advanced-usage.html#artisan-bundleclear) command +> If your project stored previously bundled files you need to run the [bundle:clear](https://laravel-bundle.dev/advanced-usage.html#artisan-bundleclear) command after enabling/disabling this feature. ## Cache-Control headers @@ -148,9 +148,3 @@ BundleManager::fake(); ``` When you'd like to use Dusk for browser testing you need to run Bundle in order for your tests not to blow up. Simply don't fake the BundleManager in your DuskTestCase. - -## CSS Loader - -Bun doesn't ship with a css loader. They have it on [the roadmap](https://github.com/oven-sh/bun/issues/159){:target="\_blank"} but no release date is known at this time. We plan to support css loading out-of-the-box as soon as Bun does! - -We'd like to experiment with Bun plugin support soon. If that is released before Bun's builtin css loader does, it might be possible to write your own plugin to achieve this. diff --git a/docs/caveats.md b/docs/caveats.md index 419ac7f..3c9362c 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -1,5 +1,5 @@ --- -nav_order: 7 +nav_order: 8 title: Caveats image: "/assets/social-square.png" --- diff --git a/docs/css-loading.md b/docs/css-loading.md new file mode 100644 index 0000000..5b495a8 --- /dev/null +++ b/docs/css-loading.md @@ -0,0 +1,74 @@ +--- +nav_order: 5 +title: CSS loading +image: "/assets/social-square.png" +--- + +## CSS Loading + +**Beta** + +Bun doesn't ship with a CSS loader. They have it on [the roadmap](https://github.com/oven-sh/bun/issues/159){:target="\_blank"} but no release date is known at this time. + +We provide a custom CSS loader plugin that just works™. Built on top of [Lightning CSS](https://lightningcss.dev/). Just use the `x-import` directive to load a css file directly. Bundle transpiles them and injects it on your page with zero effort. + +```html + + +``` + +Because we use Bun as a runtime when processing your files there is no need to install Lightning CSS as a dependency. When Bun encounters a import that is not installed it will fall back to it's on internal [module resolution algorithm](https://bun.sh/docs/runtime/autoimport) & install the dependency on the fly. + +That being said; We do recommend installing Lightning CSS in your project. + +```bash +npm install lightningcss --save-dev +``` + +### Sass + +[Sass](https://sass-lang.com/) is supported out of the box. Just like with Lightning CSS you don't have to install Sass as a dependency, but it is recommended. + +```bash +npm install lightningcss --save-dev +``` + +Note that compiled Sass is processed with LightningCSS afterwards, so if you plan on only processing scss files it is recommended to install both Lightning CSS & Sass. + +### Local CSS loading + +This works similar to [local modules](https://laravel-bundle.dev/local-modules.html). Simply add a new path alias to your `jsconfig.json` file. + +```json +{ + "compilerOptions": { + "paths": { + "~/css": ["./resources/css/*"] + } + } +} +``` + +Now you can load css from your resources directory. + +```html + +``` + +### Browser targeting + +Bundle automatically compiles many modern CSS syntax features to more compatible output that is supported in your target browsers. This includes some features that are not supported by browsers yet, like nested selectors & media queries, without using a preprocessor like Sass. [Check here](https://lightningcss.dev/transpilation.html#syntax-lowering) for the list of the many cool new syntaxes Lightning CSS supports. + +You can define what browsers to target using your `package.json` file: + +```json +{ + "browserslist": ["last 2 versions", ">= 1%", "IE 11"] +} +``` + +
+ +{: .note } + +> Bundle currently only supports browserslist using your `package.json` file. A dedicated `.browserslistrc` is not suppported at this time. diff --git a/docs/integrations/index.md b/docs/integrations/index.md index 006ea8c..0112e3b 100644 --- a/docs/integrations/index.md +++ b/docs/integrations/index.md @@ -1,5 +1,5 @@ --- -nav_order: 6 +nav_order: 7 has_children: true title: Integration examples image: "/assets/social-square.png" diff --git a/docs/production-builds.md b/docs/production-builds.md index 3c65b4c..4b63249 100644 --- a/docs/production-builds.md +++ b/docs/production-builds.md @@ -1,5 +1,5 @@ --- -nav_order: 5 +nav_order: 6 title: Production builds image: "/assets/social-square.png" --- diff --git a/docs/roadmap.md b/docs/roadmap.md index ab01a23..1a9285f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,5 @@ --- -nav_order: 8 +nav_order: 9 title: Roadmap image: "/assets/social-square.png" --- @@ -8,7 +8,15 @@ image: "/assets/social-square.png" Bundle is under active development. If you feel there are features missing or you've got a great idea that's not on on the roadmap please [open a discussion](https://github.com/gwleuverink/bundle/discussions/categories/ideas){:target="\_blank"} on GitHub. -## CSS loader +## ✅ Injecting Bundle's core on every page + +**_Added in [v0.1.3](https://github.com/gwleuverink/bundle/releases/tag/v0.1.3)_** + +This will reduce every import's size slightly. But more importantly; it will greatly decrease the chance of unexpected behaviour caused by race conditions, since the Bundle's core is available on pageload. + +## ✅ CSS loader + +**_Added in [v0.1.4](https://github.com/gwleuverink/bundle/releases/tag/v0.1.4)_** Bun doesn't ship with a CSS loader. They have it on [the roadmap](https://github.com/oven-sh/bun/issues/159){:target="\_blank"} but no release date is known at this time. We plan to support CSS loading out-of-the-box as soon as Bun does! @@ -98,12 +106,6 @@ It would be incredible if this object could be forwarded to Alpine directly like ``` -## ✅ Injecting Bundle's core on every page - -**_Added in [v0.1.3](https://github.com/gwleuverink/bundle/releases/tag/v0.1.3)_** - -This will reduce every import's size slightly. But more importantly; it will greatly decrease the chance of unexpected behaviour caused by race conditions, since the Bundle's core is available on pageload. - ## Optionally assigning a import to the window scope It could be convenient to provide an api to assign imports to a window variable by use of a prop. This idea is still settling in so might change. diff --git a/phpunit.xml b/phpunit.xml index 7d0904f..950f805 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -4,11 +4,18 @@ bootstrap="vendor/autoload.php" colors="true" > + + + + + + ./tests + ./app diff --git a/src/BundleManager.php b/src/BundleManager.php index 5c27304..67b3aab 100644 --- a/src/BundleManager.php +++ b/src/BundleManager.php @@ -31,10 +31,14 @@ public function __construct(BundlerContract $bundler) public function bundle(string $script): SplFileInfo { - $file = "{$this->hash($script)}.min.js"; + $min = $this->config()->get('minify') + ? '.min' + : ''; + + $file = "{$this->hash($script)}{$min}.js"; // Return cached file if available - if ($this->config()->get('caching_enabled') && $cached = $this->fromDisk($file)) { + if ($this->config()->get('caching') && $cached = $this->fromDisk($file)) { return $cached; } @@ -44,7 +48,7 @@ public function bundle(string $script): SplFileInfo // Attempt bundling & cleanup try { $processed = $this->bundler->build( - sourcemaps: $this->config()->get('sourcemaps_enabled'), + sourcemaps: $this->config()->get('sourcemaps'), minify: $this->config()->get('minify'), inputPath: $this->tempDisk()->path(''), outputPath: $this->buildDisk()->path(''), diff --git a/src/Bundlers/Bun.php b/src/Bundlers/Bun/Bun.php similarity index 95% rename from src/Bundlers/Bun.php rename to src/Bundlers/Bun/Bun.php index ea62fc4..fa8fb61 100644 --- a/src/Bundlers/Bun.php +++ b/src/Bundlers/Bun/Bun.php @@ -1,6 +1,6 @@ args($options)}") ->throw(function ($res) use ($inputPath, $fileName): void { $failed = file_get_contents($inputPath . $fileName); + + // TODO: needs to be reworked throw new BundlingFailedException($res, $failed); }); diff --git a/src/Bundlers/Bun/bin/bun.js b/src/Bundlers/Bun/bin/bun.js new file mode 100644 index 0000000..383d540 --- /dev/null +++ b/src/Bundlers/Bun/bin/bun.js @@ -0,0 +1,69 @@ +// NOTE: we don't have to check if Bun is installed sinsce this script is invoked with the Bun runtime + +import { parseArgs } from "util"; +import { exit } from "./utils/dump"; +import cssLoader from "./plugins/css-loader"; + +const options = parseArgs({ + args: Bun.argv, + strict: true, + allowPositionals: true, + + options: { + entrypoint: { + type: "string", + }, + + inputPath: { + type: "string", + }, + + outputPath: { + type: "string", + }, + + sourcemaps: { + type: "boolean", + }, + + minify: { + type: "boolean", + }, + }, +}).values; + +const result = await Bun.build({ + entrypoints: [options.entrypoint], + publicPath: options.outputPath, + outdir: options.outputPath, + root: options.inputPath, + minify: options.minify, + + sourcemap: options.sourcemaps ? "external" : "none", + + naming: { + entry: "[dir]/[name].[ext]", + chunk: "chunks/[name]-[hash].[ext]", // Not in use without --splitting + asset: "assets/[name]-[hash].[ext]", // Not in use without --splitting + }, + + target: "browser", + format: "esm", + + plugins: [ + cssLoader({ + minify: Boolean(options.minify), + sourcemaps: Boolean(options.sourcemaps), + }), + ], +}); + +if (!result.success) { + // for (const message of result.logs) { + // console.error(message); + // } + // process.exit(1); + + // TODO: needs to be reworked + exit('build-failed', '', result.logs.map(log => log.message)) +} diff --git a/src/Bundlers/Bun/bin/plugins/css-loader.js b/src/Bundlers/Bun/bin/plugins/css-loader.js new file mode 100644 index 0000000..78c64d2 --- /dev/null +++ b/src/Bundlers/Bun/bin/plugins/css-loader.js @@ -0,0 +1,120 @@ +import determineTargets from "./../utils/browser-targets"; +import { exit, dd } from "./../utils/dump"; + +const defaultOptions = { + browserslist: [], + minify: true, + sourcemaps: false, +}; + +export default function (options = {}) { + return { + name: "css-loader", + async setup(build) { + // Compile plain css with Lightning CSS + build.onLoad({ filter: /\.css$/ }, async (args) => { + const css = await compileCss(args.path, { + ...defaultOptions, + ...options, + }); + + return { + contents: `export default ${css}`, + loader: "js", + }; + }); + + // Compile sass pass output through Lightning CSS + build.onLoad({ filter: /\.scss$/ }, async (args) => { + const css = await compileSass(args.path, { + ...defaultOptions, + ...options, + }); + + return { + contents: `export default ${css}`, + loader: "js", + }; + }); + }, + }; +} + +const compileCss = async function (filename, opts) { + const lightningcss = await import("lightningcss-wasm").catch((error) => { + exit("lightningcss-not-installed"); + }); + + const targets = await determineTargets(opts.browserslist); + let { code, map } = lightningcss.bundle({ + targets, + filename, + + errorRecovery: true, + minify: opts.minify, + sourceMap: opts.sourcemaps, + }); + + let css = code.toString(); + if (map) { + map = rewriteSourcemapPaths(map); + css = `${css}\n/*# sourceMappingURL=data:application/json;base64,${btoa(JSON.stringify(map))} */` + } + + return JSON.stringify(css); +}; + +const compileSass = async function (filename, opts) { + const lightningcss = await import("lightningcss-wasm").catch((error) => { + exit("lightningcss-not-installed"); + }); + + const sass = await import("sass").catch((error) => { + exit("sass-not-installed"); + }); + + const targets = await determineTargets(opts.browserslist); + + // NOTE: we could use a custom importer to remap sourcemap url's here. But might be able to reuse The one we use for the CSS loader + const source = await sass.compileAsync(filename, { + sourceMap: opts.sourcemaps, + sourceMapIncludeSources: opts.sourcemaps // inlines source countent. refactor when adding extenral sourcemaps + }); + + let { code, map } = lightningcss.transform({ + targets, + code: Buffer.from(source.css), + filename: opts.sourcemaps + ? filename + : null, + + errorRecovery: true, + minify: opts.minify, + sourceMap: opts.sourcemaps, + inputSourceMap: JSON.stringify(source.sourceMap), + }); + + let css = code.toString(); + + if (map) { + map = rewriteSourcemapPaths(map); + css = `${css}\n/*# sourceMappingURL=data:application/json;base64,${btoa(JSON.stringify(map))} */` + } + + return JSON.stringify(css); +}; + +const rewriteSourcemapPaths = function (map) { + + const replacePath = process.env["APP_ENV"] === "testing" + ? process.cwd().replace(/^\/+|\/+$/g, "") + "/workbench" + : process.cwd().replace(/^\/+|\/public+$/g, ""); + + map = JSON.parse(map); + + map.sources = map.sources.map((path) => { + return path.replace(replacePath, "..") + }); + + return map; +}; diff --git a/src/Bundlers/Bun/bin/utils/browser-targets.js b/src/Bundlers/Bun/bin/utils/browser-targets.js new file mode 100644 index 0000000..70e3a8b --- /dev/null +++ b/src/Bundlers/Bun/bin/utils/browser-targets.js @@ -0,0 +1,69 @@ +import { browserslistToTargets } from "lightningcss-wasm"; +import { readFile, exists } from "fs/promises"; +import path from "path"; + +/** + * Use targets from config or when none given try + * to detect browserslist from package.json + */ +export default async function (browserslist) { + // If config was given, return browserlist immediately + if (browserslist?.length) { + return browserslistToTargets(browserslist); + } + + // Otherwise read from package.json + const pkg = await packageJson(); + browserslist = pkg.browserslist || []; + + if (browserslist?.length) { + return browserslistToTargets(browserslist); + } + + // If no package.json found or browserslist was not defined + return undefined; +} + +/** + * Get contents of nearest package.json + */ +async function packageJson() { + try { + const path = await findNearestPackageJson(); + const content = await readFile(path, "utf8"); + return JSON.parse(content); + } catch (error) { + console.error( + "Error reading browserslist from package.json:", + error.message + ); + return []; + } +} + +/** + * Get path of nearest package.json + */ +async function findNearestPackageJson() { + + let currentDir = process.cwd(); + + if(process.env['APP_ENV'] === 'testing') { + currentDir += '/workbench'; + } + + while (true) { + const packageJsonPath = path.join(currentDir, "package.json"); + + if (await exists(packageJsonPath)) { + return packageJsonPath; + } + + // package.json file could not be found. + if (currentDir === path.dirname(currentDir)) { + return null; + } + + currentDir = path.dirname(currentDir); + } +} diff --git a/src/Bundlers/Bun/bin/utils/dump.js b/src/Bundlers/Bun/bin/utils/dump.js new file mode 100644 index 0000000..8e0bf78 --- /dev/null +++ b/src/Bundlers/Bun/bin/utils/dump.js @@ -0,0 +1,17 @@ +export function dd(output) { + console.error(output); + process.exit(1); +} + +/** Outputs a object to be caught by BundlingFailedException */ +export function exit(id, message = "", output = "") { + console.error( + JSON.stringify({ + id: "bundle:" + id, + message, + output, + }) + ); + + process.exit(1); +} diff --git a/src/Bundlers/bin/bun.js b/src/Bundlers/bin/bun.js deleted file mode 100644 index 1ffb2f8..0000000 --- a/src/Bundlers/bin/bun.js +++ /dev/null @@ -1,56 +0,0 @@ -import { parseArgs } from "util"; - -const options = parseArgs({ - args: Bun.argv, - strict: true, - allowPositionals: true, - - options: { - entrypoint: { - type: "string", - }, - - inputPath: { - type: "string", - }, - - outputPath: { - type: "string", - }, - - sourcemaps: { - type: "boolean", - }, - - minify: { - type: "boolean", - }, - }, -}).values; - -const result = await Bun.build({ - entrypoints: [options.entrypoint], - publicPath: options.outputPath, - outdir: options.outputPath, - root: options.inputPath, - minify: options.minify, - - sourcemap: options.sourcemaps ? "external" : "none", - - naming: { - entry: '[dir]/[name].[ext]', - chunk: "chunks/[name]-[hash].[ext]", // Not in use without --splitting - asset: "assets/[name]-[hash].[ext]", // Not in use without --splitting - }, - - target: "browser", - format: "esm", -}); - -if (!result.success) { - console.error("Build failed"); - for (const message of result.logs) { - console.error(message); - } - process.exit(1); // Exit with an error code -} diff --git a/src/Components/Import.php b/src/Components/Import.php index 7dcc827..7546cb3 100644 --- a/src/Components/Import.php +++ b/src/Components/Import.php @@ -25,10 +25,10 @@ public function render() } } - /** Builds the core JavaScript & packages it up in a bundle */ + /** Builds the imported JavaScript & packages it up in a bundle */ protected function bundle() { - $js = $this->core(); + $js = $this->import(); // Render script tag with bundled code return view('x-import::script', [ @@ -53,13 +53,13 @@ protected function raiseConsoleErrorOrException(BundlingFailedException $e) return <<< HTML - + HTML; } - /** Builds Bundle's core JavaScript */ - protected function core(): string + /** Builds a bundle for the JavaScript import */ + protected function import(): string { return <<< JS //-------------------------------------------------------------------------- @@ -74,7 +74,7 @@ protected function core(): string (() => { // Check if module is already loaded under a different alias - const previous = document.querySelector(`script[data-module="{$this->module}"`) + const previous = document.querySelector(`script[data-module="{$this->module}"]`) // Was previously loaded & needs to be pushed to import map if(previous && '{$this->as}') { @@ -84,6 +84,13 @@ protected function core(): string } } + // Handle CSS injection & return early (no need to add css to import map) + if('{$this->module}'.endsWith('.css') || '{$this->module}'.endsWith('.scss')) { + return import('{$this->module}').then(result => { + window.x_inject_styles(result.default, previous) + }) + } + // Assign the import to the window.x_import_modules object (or invoke IIFE) '{$this->as}' // Assign it under an alias diff --git a/src/Exceptions/BundlingFailedException.php b/src/Exceptions/BundlingFailedException.php index 8247582..09055c7 100644 --- a/src/Exceptions/BundlingFailedException.php +++ b/src/Exceptions/BundlingFailedException.php @@ -5,6 +5,7 @@ namespace Leuverink\Bundle\Exceptions; use RuntimeException; +use Illuminate\Support\Arr; use Spatie\Ignition\Contracts\Solution; use Spatie\Ignition\Contracts\BaseSolution; use Illuminate\Contracts\Process\ProcessResult; @@ -14,12 +15,14 @@ class BundlingFailedException extends RuntimeException implements ProvidesSoluti { public ProcessResult $result; + // TODO: needs to be reworked. It's getting too big & handles too many JS error cases + // Maybe split it up & devise a system that maps JS errors raised by Bun to appropriate Exception classes public function __construct(ProcessResult $result, $script = null) { $this->result = $result; $failed = $script ?? $result->command(); - // dd($result->errorOutput()); + // dd($this->output()); parent::__construct( "Bundling failed: {$failed}", @@ -28,14 +31,47 @@ public function __construct(ProcessResult $result, $script = null) // TODO: Consider different approach for providing contextual debug info if (app()->isLocal() && config()->get('app.debug')) { - dump(['error output', $result->errorOutput()]); + dump(['error output', $result->output()]); } } + /** Format output as defined in error function in bin/utils/dump.js */ + public function output(): object + { + $output = json_decode($this->result->errorOutput()); + if ( + gettype($output) === 'object' && + property_exists($output, 'id') && + property_exists($output, 'message') && + property_exists($output, 'output') + ) { + return $output; + } + + return (object) [ + 'id' => null, + 'message' => '', + 'output' => $this->result->errorOutput(), + ]; + } + + public function consoleOutput(): string + { + $output = $this->output(); + + if ($output->message) { + return $output->message; + } + + return Arr::wrap($output->output)[0]; + } + public function getSolution(): Solution { return match (true) { str_contains($this->result->errorOutput(), 'bun: No such file or directory') => $this->bunNotInstalledSolution(), + str_contains($this->output()->id, 'bundle:sass-not-installed') => $this->sassNotInstalledSolution(), + str_contains($this->output()->id, 'bundle:lightningcss-not-installed') => $this->lightningcssNotInstalledSolution(), str_contains($this->result->errorOutput(), 'error: Could not resolve') => $this->moduleNotResolvableSolution(), str_contains($this->result->errorOutput(), 'tsconfig.json: ENOENT') => $this->missingJsconfigFileSolution(), str_contains($this->result->errorOutput(), 'Cannot find tsconfig') => $this->missingJsconfigFileSolution(), @@ -52,6 +88,20 @@ private function bunNotInstalledSolution() ->setSolutionDescription('Bun is not installed. Try running `npm install bun --save-dev`'); } + private function sassNotInstalledSolution() + { + return BaseSolution::create() + ->setSolutionTitle('Sass is not installed.') + ->setSolutionDescription('You need to install Sass in order to load .scss files. Try running `npm install sass --save-dev`'); + } + + private function lightningcssNotInstalledSolution() + { + return BaseSolution::create() + ->setSolutionTitle('Lightning CSS is not installed.') + ->setSolutionDescription('You need to install Lightning CSS in order to load .css files. Try running `npm install lightningcss --save-dev`'); + } + private function moduleNotResolvableSolution() { $module = str($this->result->errorOutput())->after('"')->before('"')->toString(); diff --git a/src/InjectCore.php b/src/InjectCore.php index 028efd4..5c188d5 100644 --- a/src/InjectCore.php +++ b/src/InjectCore.php @@ -140,6 +140,23 @@ protected function core(): string } }; + //-------------------------------------------------------------------------- + // Inject styles + //-------------------------------------------------------------------------- + window.x_inject_styles = function (css, scriptTag) { + if (typeof document === 'undefined') { + return; + } + + // TODO: Add CSP nonce when adding CSP support + const style = document.createElement('style'); + style.dataset['module'] = scriptTag.dataset['module']; + style.innerHTML = css; + + // Inject the style tag after the script that invoked this function + scriptTag.parentNode.insertBefore(style, scriptTag.nextSibling); + } + JS; } } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 2e2c5a2..e23c3c4 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -4,12 +4,12 @@ namespace Leuverink\Bundle; -use Leuverink\Bundle\Bundlers\Bun; use Leuverink\Bundle\Commands\Build; use Leuverink\Bundle\Commands\Clear; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; +use Leuverink\Bundle\Bundlers\Bun\Bun; use Leuverink\Bundle\Components\Import; use Illuminate\Foundation\Http\Events\RequestHandled; use Illuminate\Support\ServiceProvider as BaseServiceProvider; diff --git a/tests/Browser/ComponentTest.php b/tests/Browser/ComponentTest.php index 31d1423..80aad95 100644 --- a/tests/Browser/ComponentTest.php +++ b/tests/Browser/ComponentTest.php @@ -195,6 +195,6 @@ public function it_logs_console_errors_when_debug_mode_disabled() HTML) ->assertScript(<<< 'JS' document.querySelector('script[data-module="~/nonexistent-module"').innerHTML - JS, 'throw "BUNDLING ERROR: No module found at path \'~/nonexistent-module\'"'); + JS, 'throw "BUNDLING ERROR: Could not resolve: "~/nonexistent-module". Maybe you need to "bun install"?"'); } } diff --git a/tests/Browser/CssLoaderTest.php b/tests/Browser/CssLoaderTest.php new file mode 100644 index 0000000..71db189 --- /dev/null +++ b/tests/Browser/CssLoaderTest.php @@ -0,0 +1,212 @@ +blade(<<< 'HTML' + + HTML); + + // Expect CSS rendered on page + $browser->assertScript( + 'document.querySelector(`style[data-module="css/red-background.css"]`).innerHTML', + 'html{background:red}' + ); + + // Doesn't raise console errors + $this->assertEmpty($browser->driver->manage()->getLog('browser')); + } + + /** @test */ + public function it_handles_css_files() + { + $browser = $this->blade(<<< 'HTML' + + HTML); + + // Expect CSS rendered on page + $browser->assertScript( + 'document.querySelector(`style[data-module="css/red-background.css"]`).innerHTML', + 'html{background:red}' + ); + + // Doesn't raise console errors + $this->assertEmpty($browser->driver->manage()->getLog('browser')); + } + + /** @test */ + public function it_supports_sass() + { + $browser = $this->blade(<<< 'HTML' + + HTML); + + // Expect CSS rendered on page + $browser->assertScript( + 'document.querySelector(`style[data-module="css/blue-background.scss"]`).innerHTML', + 'html body{background:#00f}' + ); + + // Doesn't raise console errors + $this->assertEmpty($browser->driver->manage()->getLog('browser')); + } + + /** @test */ + public function it_processes_css_imports() + { + $browser = $this->blade(<<< 'HTML' + + HTML); + + // Expect CSS rendered on page + $browser->assertScript( + 'document.querySelector(`style[data-module="css/imported-red-background.css"]`).innerHTML', + 'html{background:red}' + ); + + // Doesn't raise console errors + $this->assertEmpty($browser->driver->manage()->getLog('browser')); + } + + /** @test */ + public function it_minifies_css_when_minification_enabled() + { + $this->beforeServingApplication(function ($app, $config) { + $config->set('bundle.minify', true); + }); + + $browser = $this->blade(<<< 'HTML' + + HTML); + + // Expect CSS rendered on page + $browser->assertScript( + 'document.querySelector(`style[data-module="css/red-background.css"]`).innerHTML', + 'html{background:red}' + ); + + // Doesn't raise console errors + $this->assertEmpty($browser->driver->manage()->getLog('browser')); + } + + /** @test */ + public function it_doesnt_minify_css_when_minification_disabled() + { + $this->beforeServingApplication(function ($app, $config) { + $config->set('bundle.minify', false); + }); + + $browser = $this->blade(<<< 'HTML' + + HTML); + + // Expect CSS rendered on page + $browser->assertScript( + 'document.querySelector(`style[data-module="css/red-background.css"]`).innerHTML', + <<< 'CSS' + html { + background: red; + } + + CSS + ); + + // Doesn't raise console errors + $this->assertEmpty($browser->driver->manage()->getLog('browser')); + } + + /** @test */ + public function it_generates_sourcemaps_when_enabled() + { + $this->beforeServingApplication(function ($app, $config) { + $config->set('bundle.minify', true); + $config->set('bundle.sourcemaps', true); + }); + + $browser = $this->blade(<<< 'HTML' + + HTML); + + // Assert output contains encoded sourcemap (flaky. asserting on encoded sting) + $browser->assertScript( + 'document.querySelector(`style[data-module="css/red-background.css"]`).innerHTML.startsWith("html{background:red}\n/*# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VSb290IjpudWxsLCJtYXBwaW5ncyI6IkFBQUEi")', + true + ); + + // Doesn't raise console errors + $this->assertEmpty($browser->driver->manage()->getLog('browser')); + } + + /** @test */ + public function it_doesnt_generate_sourcemaps_by_default() + { + $this->beforeServingApplication(function ($app, $config) { + $config->set('bundle.minify', true); + $config->set('bundle.sourcemaps', false); + }); + + $browser = $this->blade(<<< 'HTML' + + HTML); + + $browser->assertScript( + 'document.querySelector(`style[data-module="css/red-background.css"]`).innerHTML', + 'html{background:red}' + ); + + // Doesn't raise console errors + $this->assertEmpty($browser->driver->manage()->getLog('browser')); + } + + /** @test */ + public function it_generates_scss_sourcemaps_when_enabled() + { + $this->beforeServingApplication(function ($app, $config) { + $config->set('bundle.minify', true); + $config->set('bundle.sourcemaps', true); + }); + + $browser = $this->blade(<<< 'HTML' + + HTML); + + // Assert output contains encoded sourcemap (flaky. asserting on encoded sting) + $browser->assertScript( + 'document.querySelector(`style[data-module="css/blue-background.scss"]`).innerHTML.startsWith("html body{background:#00f}\n/*# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VSb290Ij")', + true + ); + + // Doesn't raise console errors + $this->assertEmpty($browser->driver->manage()->getLog('browser')); + } + + /** @test */ + public function it_doesnt_generate_scss_sourcemaps_by_default() + { + $this->beforeServingApplication(function ($app, $config) { + $config->set('bundle.minify', true); + $config->set('bundle.sourcemaps', false); + }); + + $browser = $this->blade(<<< 'HTML' + + HTML); + + $browser->assertScript( + 'document.querySelector(`style[data-module="css/blue-background.scss"]`).innerHTML', + 'html body{background:#00f}' + ); + + // Doesn't raise console errors + $this->assertEmpty($browser->driver->manage()->getLog('browser')); + } +} diff --git a/tests/DuskTestCase.php b/tests/DuskTestCase.php index 4c8f0bb..3400bfb 100644 --- a/tests/DuskTestCase.php +++ b/tests/DuskTestCase.php @@ -15,6 +15,12 @@ class DuskTestCase extends BaseTestCase { use WithWorkbench; + public static function setUpBeforeClass(): void + { + Options::withoutUI(); + parent::setUpBeforeClass(); + } + protected function setUp(): void { parent::setUp(); @@ -23,12 +29,6 @@ protected function setUp(): void $this->artisan('bundle:clear'); } - public static function setUpBeforeClass(): void - { - Options::withoutUI(); - parent::setUpBeforeClass(); - } - protected function tearDown(): void { $this->artisan('bundle:clear'); @@ -63,6 +63,8 @@ public function blade(string $blade) // Create a temporary route $this->beforeServingApplication(function ($app, $config) use ($page) { $config->set('app.debug', true); + $config->set('bundle.cache_control_headers', 'no-cache, no-store, must-revalidate'); + $app->make(Route::class)::get('test-blade', fn () => $page); }); @@ -87,6 +89,7 @@ public function serveLivewire($component) $this->beforeServingApplication(function ($app, $config) use (&$component) { $config->set('app.debug', true); $config->set('app.key', 'base64:q1fQla64BmAKJBOnRKuXvfddVoqEuSLv1eOEEO91uGI='); + $config->set('bundle.cache_control_headers', 'no-cache, no-store, must-revalidate'); // Needs to register so component is findable in update calls Livewire::component($component); diff --git a/tests/Feature/IntegrationTest.php b/tests/Feature/IntegrationTest.php index 49d0a41..d5c4fc5 100644 --- a/tests/Feature/IntegrationTest.php +++ b/tests/Feature/IntegrationTest.php @@ -50,7 +50,7 @@ it('generates sourcemaps when enabled') ->defer( - fn () => config()->set('bundle.sourcemaps_enabled', true) + fn () => config()->set('bundle.sourcemaps', true) ) ->bundle( <<< 'JS' @@ -120,7 +120,7 @@ expect($component->render()) ->toContain( 'throw', - "BUNDLING ERROR: No module found at path '~/foo'" + 'BUNDLING ERROR: Could not resolve: "~/foo". Maybe you need to "bun install"?' ) ->not->toThrow(BundlingFailedException::class); }); diff --git a/workbench/jsconfig.json b/workbench/jsconfig.json index 1292e87..2b3508e 100644 --- a/workbench/jsconfig.json +++ b/workbench/jsconfig.json @@ -1,7 +1,8 @@ { "compilerOptions": { "paths": { - "~/*": ["./resources/js/*"] + "~/*": ["./resources/js/*"], + "css/*": ["./resources/css/*"] } } } diff --git a/workbench/package.json b/workbench/package.json index dc897d8..dbb2b1a 100644 --- a/workbench/package.json +++ b/workbench/package.json @@ -1,4 +1,9 @@ { + "browserslist": [ + "last 2 versions", + ">= 1%", + "IE 11" + ], "devDependencies": { "bun": "^1.0.21", "lodash": "^4.17.21", diff --git a/workbench/resources/css/blue-background.scss b/workbench/resources/css/blue-background.scss new file mode 100644 index 0000000..e4639da --- /dev/null +++ b/workbench/resources/css/blue-background.scss @@ -0,0 +1,7 @@ +$bg_color: blue; + +html { + body { + background: $bg_color; + } +} diff --git a/workbench/resources/css/imported-red-background.css b/workbench/resources/css/imported-red-background.css new file mode 100644 index 0000000..89abde6 --- /dev/null +++ b/workbench/resources/css/imported-red-background.css @@ -0,0 +1 @@ +@import "./red-background.css"; diff --git a/workbench/resources/css/red-background.css b/workbench/resources/css/red-background.css new file mode 100644 index 0000000..74031be --- /dev/null +++ b/workbench/resources/css/red-background.css @@ -0,0 +1,3 @@ +html { + background: red; +}