diff --git a/src/actions/guides/frontend/asset-handling.cr b/src/actions/guides/frontend/asset-handling.cr index 951f3b53..1eedf5c4 100644 --- a/src/actions/guides/frontend/asset-handling.cr +++ b/src/actions/guides/frontend/asset-handling.cr @@ -5,114 +5,266 @@ class Guides::Frontend::AssetHandling < GuideAction "Asset Handling" end + ANCHOR_BUN = "perma-bun" + ANCHOR_CONFIGURATION = "perma-configuration" + ANCHOR_ENTRY_POINTS = "perma-entry-points" + ANCHOR_JAVASCRIPT = "perma-javascript" + ANCHOR_CSS = "perma-css" + ANCHOR_STATIC_ASSETS = "perma-static-assets" + ANCHOR_PLUGINS = "perma-plugins" + ANCHOR_CUSTOM_PLUGINS = "perma-custom-plugins" + ANCHOR_LOADING_ASSETS = "perma-loading-assets" + ANCHOR_LIVE_RELOAD = "perma-live-reload" + ANCHOR_FINGERPRINTING = "perma-fingerprinting" + ANCHOR_COMPRESSION = "perma-compression" + ANCHOR_ASSET_HOST = "perma-asset-host" + ANCHOR_DEPLOYING = "perma-deploying" + ANCHOR_LEGACY_BUNDLERS = "perma-legacy-bundlers" + def markdown : String <<-MD - ## Asset handling with Webpack and Laravel Mix + #{permalink(ANCHOR_BUN)} + ## Asset handling with Bun + + Lucky uses [Bun](https://bun.sh) as its built-in asset bundler. It handles JavaScript, TypeScript, CSS, images, and fonts out of the box with zero configuration. Bun is extremely fast and requires no additional dependencies beyond Bun itself. + + The default setup works for most apps without any configuration. When you need more control, everything can be customized through a single `config/bun.json` file. + + #{permalink(ANCHOR_CONFIGURATION)} + ## Configuration + + Lucky's Bun integration works without the configuration file. The defaults are: + + * Entry points: `src/js/app.js` and `src/css/app.css` + * Output directory: `public/assets` + * Public path: `/assets` + * Static asset directories: `src/images` and `src/fonts` + * Watch directories: `src/js`, `src/css`, `src/images`, and `src/fonts` + * Plugins: `aliases` and `cssGlobs` for CSS, `aliases` and `jsGlobs` for JavaScript + * Manifest: `public/bun-manifest.json` + * Dev server: `127.0.0.1:3002` + + To customize these defaults, create a `config/bun.json` file in your project root: + + ```json + { + "entryPoints": { + "js": ["src/js/app.js", "src/js/admin.js"], + "css": "src/css/app.css" + }, + "staticDirs": ["src/images", "src/fonts"], + "watchDirs": ["src/js", "src/css", "src/images", "src/fonts"], + "outDir": "public/assets", + "publicPath": "/assets", + "manifestPath": "public/bun-manifest.json", + "plugins": { + "css": ["aliases", "cssGlobs"], + "js": ["aliases", "jsGlobs"] + }, + "devServer": { + "host": "127.0.0.1", + "port": 3002 + } + } + ``` - By default Lucky comes set up with asset handling using Webpack through - [Laravel Mix](https://laravel-mix.com/). Laravel Mix is a wrapper - for common Webpack functionality that makes configuring Webpack much simpler. + All fields are optional. Only include the ones you want to override. - ### Why Laravel Mix instead of plain Webpack? + > Entry points can be a single string or an array of strings. - Lucky uses Laravel Mix because it is very simple to configure, it's fast, and - it works well for a lot of apps. It is also has methods for configuring - webpack as you normally would so you have the full power of Webpack when you - need something more complex. + ### Docker and remote development - For a lot of people the default Laravel Mix setup will work out of the box, or with - little configuration. + If you're running Lucky inside Docker or a remote container, you may need the dev server to listen on all interfaces while the browser connects to a specific host: - > Keep in mind that Lucky does not lock you in to using webpack. - > You can configure other build methods such as Gulp, Grunt, or your own custom at any time. + ```json + { + "devServer": { + "host": "localhost", + "listenHost": "0.0.0.0", + "port": 3002 + } + } + ``` - ## Configuring Webpack + #{permalink(ANCHOR_JAVASCRIPT)} + ## Structuring your JavaScript - There is a `webpack.mix.js` file in your project root. You can modify this to - set up React, change your entry points, etc. + The default entry point for JavaScript is `src/js/app.js`. Bun supports JavaScript, TypeScript, JSX, and TSX out of the box, so you can use `.js`, `.ts`, `.jsx`, or `.tsx` files without any additional setup. - Check out the [Laravel Mix documentation](https://laravel-mix.com/docs) - for more examples of what you can do. + To add new JavaScript, create files in `src/js/` and import them from your entry point: - ## Structuring your JavaScript + ```javascript + // src/js/app.js + import "./my-component.js" + ``` - Babel is set up so you can use new features of JavaScript. + #{permalink(ANCHOR_ENTRY_POINTS)} + ### Multiple entry points - The entry point for JavaScript is `src/js/app.js`. You'll see that by default - it imports RailsUjs to handle AJAX, links - with PUT and DELETE requests, and a few other things. Check the - [RailsUjs](https://github.com/rails/jquery-ujs/wiki) docs for more info. + If you need separate bundles (for example, an admin area), add additional entry points in `config/bun.json`: - > *RailsUjs is required* if you are rendering HTML pages in Lucky. If - you remove it, PUT and DELETE links will no longer work correctly. + ```json + { + "entryPoints": { + "js": ["src/js/app.js", "src/js/admin.js"] + } + } + ``` - To add new JavaScript add files to `src/js/{filename}` and import them in `src/js/app.js` + Each entry point produces its own output file. + #{permalink(ANCHOR_CSS)} ## Structuring your CSS - The main css file is `src/css/app.scss` and is rendered using SASS. - Laravel Mix also sets up autoprefixer so your styles automatically have - vendor prefixes. + The default CSS entry point is `src/css/app.css`. Organize your styles by creating files in `src/css/` and importing them: + + ```css + /* src/css/app.css */ + @import './components/buttons.css'; + @import './components/forms.css'; + ``` + + ### Glob imports in CSS + + Lucky includes a `cssGlobs` plugin that lets you import multiple CSS files with a glob pattern: + + ```css + /* Import all CSS files in the components directory */ + @import './components/**/*.css'; + ``` + + This expands to individual `@import` statements for each matching file, sorted alphabetically for deterministic output. - You can import other files by putting them in `src/css/*`, and importing - them from `app.scss`. For example, you might put a component in - `src/css/components/_btn.scss`. Remember to end the file with `.scss` so - it's imported correctly. + #{permalink(ANCHOR_STATIC_ASSETS)} + ## Images, fonts and other static assets - Lucky comes with some helpful plugins to make CSS a pleasure to write: + Place images in `src/images/` and fonts in `src/fonts/`. These directories are configured as `staticDirs` by default. All files in these directories are copied to the output directory and included in the asset manifest. - * [Normalize.css](https://necolas.github.io/normalize.css/) for making styles consistent + In production, static assets are fingerprinted with a content hash just like JavaScript and CSS files. - ## Removing unwanted packages + You can add more static directories in `config/bun.json`: - Lucky comes with a few JavaScript and CSS packages by default. If you want to - remove the ones you don't want, run `yarn remove {package_name}`. + ```json + { + "staticDirs": ["src/images", "src/fonts", "src/pdfs"] + } + ``` - If it is a CSS or JavaScript package you may also need to remove the imports from - `src/css/app.css` or `src/js/app.js`. + ### Root aliases in CSS - ## Images, fonts and other assets + The `aliases` plugin lets you reference files from the project root using `$/` in CSS `url()` declarations: - You can put images, fonts and other assets in the `public/assets` folder. + ```css + background: url('$/src/images/my-background.jpg'); + ``` - By default there is a `public/assets/images` folder, but you can add more, such - as: `fonts`, `pdfs`, etc. + This resolves to the absolute path at build time, so you don't need to worry about relative path depth. + + #{permalink(ANCHOR_PLUGINS)} + ## Built-in plugins + + Lucky's Bun setup comes with three built-in plugins enabled by default: + + * **aliases**: Resolves `$/` root aliases in both CSS and JavaScript imports. For example, `import x from '$/lib/utils.js'` resolves to the project root. + * **cssGlobs**: Expands glob patterns in CSS `@import` statements, as described in the CSS section above. + * **jsGlobs**: Compiles glob imports in JavaScript into an object mapping filenames to their default exports. + + ### The jsGlobs plugin + + The `jsGlobs` plugin lets you import multiple modules at once using a `glob:` prefix: + + ```javascript + import components from 'glob:./components/**/*.js' + ``` + + This generates an object where each key is the relative filename (without extension) and the value is the module's default export: + + ```javascript + // Generated code: + import _glob_components_tooltip from './components/tooltip.js' + import _glob_components_shared_modal from './components/shared/modal.js' + const components = { + 'tooltip': _glob_components_tooltip, + 'shared/modal': _glob_components_shared_modal + } + ``` + + > The `components` object in the example above can then be used to, for example, register Alpine.js components or Stimulus.js controllers in one go. + + #{permalink(ANCHOR_CUSTOM_PLUGINS)} + ### Custom plugins + + You can add your own plugins by referencing a file path in `config/bun.json`: + + ```json + { + "plugins": { + "css": ["aliases", "cssGlobs", "config/bun/my-css-plugin.js"], + "js": ["aliases", "jsGlobs"] + } + } + ``` - ### Background images in CSS + > When you override the `plugins` key, it replaces the defaults entirely. Make sure to include the built-in plugins you still want. - Webpack is set up to ensure your background images are present and - fingerprinted. To have Webpack check your background images and make sure - they are ready for caching, make sure to use relative URLs: + A plugin is a JavaScript file that exports a factory function. The factory receives a context object (with `root`, `config`, `dev`, `prod`, and `manifest` properties) and returns one of two things: - ```scss - // Webpack will find the image and rewrite the URL - background: url("../public/assets/images/my-background.jpg") + **A transform function** that takes the file content as a string and returns the transformed content. All built-in plugins use this approach. Transforms are chained in order and run on every file matching the plugin type (CSS or JS). - // Webpack will not do anything special because the path is not relative - background: url("/images/my-background.jpg") + ```javascript + // config/bun/my-css-plugin.js + export default function myPlugin(context) { + return (content, args) => { + return content.replace(/old-token/g, 'new-token') + } + } ``` + **A raw Bun plugin object** for when you need to hook into Bun's build pipeline at a lower level, for example to handle custom file types or custom loaders. See the [Bun plugin documentation](https://bun.sh/docs/bundler/plugins) for details. + + ```javascript + // config/bun/my-bun-plugin.js + export default function myPlugin(context) { + return { + name: 'my-plugin', + setup(build) { + build.onLoad({filter: /\\.custom$/}, async (args) => { + return {contents: '...', loader: 'js'} + }) + } + } + } + ``` + + #{permalink(ANCHOR_LOADING_ASSETS)} ## Loading assets - You can get a path to your assets by using the `asset` helper in pages. + Use the `asset` macro in pages and components to get the path to a built asset: ```crystal - # Use this in a page or component + # In a page or component # Will find the asset in public/assets/images/logo.png img src: asset("images/logo.png") ``` - Note that assets are checked at compile time so if it is not found, Lucky will - let you know. It will also let you know if you had a typo and suggest an asset - that is close to what you typed. + Assets are checked at compile time. If an asset is not found, Lucky will let you know and suggest similar asset names if you made a typo. + + Use `css_link` and `js_link` for stylesheets and scripts: + + ```crystal + # In your layout's head + css_link asset("css/app.css") + js_link asset("js/app.js") + ``` - If the path of the asset is only known at runtime, you can use the `dynamic_asset` - helper instead. + If the path of the asset is only known at runtime, use the `dynamic_asset` method instead: ```crystal img src: dynamic_asset("images/\#{name}.png") ``` + > `dynamic_asset` does not check assets at compile time. Make sure to test that the asset exists. + ### Using assets outside of pages and components You can use `Lucky::AssetHelpers.asset` just about anywhere: @@ -121,39 +273,53 @@ class Guides::Frontend::AssetHandling < GuideAction Lucky::AssetHelpers.asset("images/logo.png") ``` - ## Automatic reloading + #{permalink(ANCHOR_LIVE_RELOAD)} + ## Live reload - Lucky comes with [Browsersync](https://www.browsersync.io) hooked up. When you - run `lucky dev` Browsersync will open a tab and automatically reload styles and - JavaScript for you. When you change any application files the browser will - reload once compilation has been successful. + Lucky's Bun integration includes a built-in live reload server. When you run `lucky dev`, a WebSocket server starts on port 3002 (configurable) and watches your asset directories for changes. - You can customize Browsersync in the `bs-config.js` file. You can see a list of - [options for Browsersync](https://browsersync.io/docs/options). + To enable live reload in your pages, add the reload tag to your layout: + ```crystal + # In your main layout + bun_reload_connect_tag + ``` + + This tag only renders in development, so there is no need to wrap it in a conditional. + + When you change a CSS file, only the stylesheets are hot-reloaded without a full page refresh. Changes to JavaScript, images, or fonts trigger a full page reload. + + #{permalink(ANCHOR_FINGERPRINTING)} ## Asset fingerprinting - Fingerprinting means that every asset has a special string of characters - appended to the filename so that the browser can cache the file safely. When an - asset changes, the fingerprint changes and the browser will use the new version. + In production, all assets are fingerprinted with a content hash appended to the filename (e.g. `app-8dc912a1.js`). This allows browsers to cache assets indefinitely. When an asset changes, the hash changes and browsers automatically fetch the new version. - Make sure to use the `asset` macro to get fingerprinted assets. + In development, assets use plain filenames without hashes for easier debugging. - ## Asset compression + Make sure to use the `asset` macro to get the correct fingerprinted paths. - Lucky supports static asset compression out of the box with a convenient middleware handler, `Lucky::StaticCompressionHandler`. You can add to the set of [default middleware handlers](#{Guides::HttpAndRouting::HTTPHandlers.path(anchor: Guides::HttpAndRouting::HTTPHandlers::ANCHOR_BUILT_IN_HANDLERS)}) as necessary in `src/app_server.cr`: + #{permalink(ANCHOR_COMPRESSION)} + ## Disabling asset caching in development + + Lucky includes a `Lucky::DevAssetCacheHandler` middleware that prevents the browser from caching assets during development. This ensures you always see the latest version of your assets without needing to hard-refresh. + + Add it to your middleware stack in `src/app_server.cr` with the `enabled` parameter to limit it to development: ```crystal - [ - # ... - Lucky::StaticCompressionHandler.new("./public", file_ext: "br", content_encoding: "br"), - Lucky::StaticCompressionHandler.new("./public", file_ext: "gz", content_encoding: "gzip"), - # ... - ] + # In src/app_server.cr + def middleware : Array(HTTP::Handler) + [ + # ... + Lucky::DevAssetCacheHandler.new(enabled: LuckyEnv.development?), + Lucky::StaticFileHandler.new("./public", fallthrough: false, directory_listing: false), + # ... + ] of HTTP::Handler + end ``` - Multiple instances of the `StaticCompressionHandler` can be leveraged to permit different types of compression based on browser support. For example, the settings above would serve Brotli-compressed assets for browsers that support it, and gzipped assets for those that don't. + When enabled, it sets `Cache-Control: no-store, no-cache, must-revalidate` on all asset responses so the browser always fetches fresh files. + #{permalink(ANCHOR_ASSET_HOST)} ## Asset host Once your app is in production, you may want to serve up your assets through a @@ -172,13 +338,41 @@ class Guides::Frontend::AssetHandling < GuideAction end ``` + #{permalink(ANCHOR_DEPLOYING)} ## Deploying to production - If you deploy to Heroku, then you won't need to do anything. Lucky is already - set up to build assets in production. + Before compiling your project for production, build the assets with: + + ```bash + bun run src/bun/bake.js --prod + ``` + + This minifies all JavaScript and CSS, fingerprints every asset, and generates the manifest at `public/bun-manifest.json`. Then compile your Lucky app as usual. + + ## Loading the asset manifest + + Lucky loads the asset manifest at compile time. In your `src/app.cr`, you should have: + + ```crystal + Lucky::AssetHelpers.load_manifest + ``` + + This loads the Bun manifest by default. If you are migrating from an older setup, you can specify a different bundler: + + ```crystal + # Laravel Mix: + Lucky::AssetHelpers.load_manifest(from: :mix) + + # Vite: + Lucky::AssetHelpers.load_manifest(from: :vite) + ``` + + #{permalink(ANCHOR_LEGACY_BUNDLERS)} + ## Legacy bundlers (Mix and Vite) + + Older Lucky projects may use Laravel Mix (Webpack) or Vite for asset handling. These setups are still supported for backwards compatibility, but new projects use Bun by default. - If deploying outside Heroku, make sure to run `yarn prod` *before* compiling - your project. + If you are starting a new project, there is no need to set up Mix or Vite. If you have an existing project using one of these bundlers, it will continue to work, just make sure to pass the `from:` option when loading the manifest as shown above. MD end end diff --git a/src/js/app.js b/src/js/app.js index 39f4eb6a..835ad402 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -11,6 +11,9 @@ import hljs from 'highlight.js/lib/core'; import javascript from 'highlight.js/lib/languages/javascript'; hljs.registerLanguage('javascript', javascript); +import css from 'highlight.js/lib/languages/css'; +hljs.registerLanguage('css', css); + import scss from 'highlight.js/lib/languages/scss'; hljs.registerLanguage('scss', scss);