assets
INTERNAL_KEY: '_assets'
What it does
The assets module is the bridge between Eleventy's template system and the asset processors. It registers js and css as template formats, gates them with compile guards (filters that decide which files get processed), watches the assets directory for changes, and exposes two inline filters for embedding bundles directly in a template.
esbuild handles JS, PostCSS handles CSS. Both ship with Baseline.
Active when
Always. The assets module is always loaded.
Lifecycle
Build-time only. The module registers template formats, compile guards, a watch target, and the inline filters. It does no cascade-time or transform-time work.
How it works
- Resolve the assets directory. The composition root registers a virtual directory keyed
assetsand exposes the resolved path through_baseline.paths.assets. - Register
jsandcsstemplate formats. Each gets an extension handler withread: falseso the processor owns its own I/O. - Build the asset index. On
eleventy.before, Baseline finds every entry point, compiles each one once, and records what it compiled to. This happens before any page renders, which is what lets a template ask for an asset by name later. Innpm startthe index is listed but nothing is compiled up front, so a reload only pays for the file it changed.11tydata.jsfiles are excluded from the template graph separately. - Apply compile guards. A compile guard is a small filter inside the extension handler. It asks the index whether this file is an entry point: if it is, the already-compiled result is handed back rather than compiled again. Anything else returns undefined and is skipped without error.
- Watch the assets directory. The watch glob covers JS, CSS, and common image formats so an asset edit triggers a reload during
npm start. - Register inline filters. An inline filter runs the processor against a path you give it and wraps the result in
<script>or<style>tags. See the processor subsections below.
Defaults
- Assets directory. The virtual directory keyed
assetsoneleventyConfig.directories. Defaults tosrc/assets/per the directory contract. Available in templates as_baseline.paths.assets. - JS entry points. Any
index.jsunderassets/js/(including nested folders), unless you name your own withassets.esbuild.entryPoints. An entry point is the front door of a bundle; everything else is reached throughimportfrom there. - CSS entry points. Any
index.cssunderassets/css/, unless you name your own withassets.postcss.entryPoints. Reach the rest through@import. - esbuild.
minify: true,target: 'es2020',bundle: true. Defaults live in _baseline/modules/assets/processors/esbuild-process.js. - PostCSS. Resolves your project's
postcss.config.jsfrom the project root (cwd). If none is found, falls back to the config bundled with baseline (postcss-import-ext-glob,postcss-import,postcss-preset-env, andcssnanoin production). The resolved config is cached for the lifetime of the process; restart Eleventy to pick up changes. - Watch target.
assets/**/*.{css,js,svg,png,jpeg,jpg,webp,gif,avif}. A watch target is a glob Eleventy reloads on during--serve. - Output. Compiled files land under
dist/assets/, mirroring the input layout.
Options
| Option | Type | Default | Meaning |
|---|---|---|---|
assets.esbuild |
object | {} |
Forwarded to esbuild on every entry-point build, merged over the defaults (minify: true, target: 'es2020', bundle: true). Any esbuild option is reachable, bar write. Does not reach inlineESbuild. |
assets.esbuild.entryPoints |
string[] | unset | Read by Baseline rather than forwarded. Names your JS entry points instead of the index.js convention. Paths are relative to assets/js/. |
assets.postcss.entryPoints |
string[] | unset | The same for CSS, relative to assets/css/. |
PostCSS has no plugin-level options beyond its entry points. Configure the pipeline itself through your own postcss.config.js. See Customise the assets pipeline for the recipe.
Naming your own entry points
Each list replaces the convention for that processor rather than adding to it, so what you write is exactly what compiles. Name one and the other keeps the convention.
await eleventyConfig.addPlugin(
baseline(settings, {
assets: {
postcss: { entryPoints: ['index.css', 'print.css'] },
esbuild: { entryPoints: ['index.js', 'vendor/index.js'] }
}
})
);
An entry point named here can be called anything; the index convention is only the default. A file you leave off this list stops being compiled, which is the point of replacing rather than merging: there is nothing invisible in the result.
JS processor: esbuild
The JS processor bundles a JavaScript file and returns the compiled text. A pure function with no Eleventy knowledge: takes a path, returns a string.
At the plugin level, override esbuild defaults through options.assets.esbuild:
import baseline, { config as baselineConfig } from '@apleasantview/eleventy-plugin-baseline';
import settings from './src/_data/settings.js';
/** @param {import("@11ty/eleventy").UserConfig} eleventyConfig */
export default async function (eleventyConfig) {
await eleventyConfig.addPlugin(
baseline(settings, {
assets: {
esbuild: {
minify: process.env.ELEVENTY_ENV === 'production',
target: 'es2017'
}
}
})
);
}
export const config = baselineConfig;
The whole object goes to esbuild, so anything in its API is reachable: define, loader, banner, jsx, plugins, external. Three keys carry a Baseline default you can overrule, minify, target and bundle. One is not yours to set and is applied after yours: write, because the processor returns text for Eleventy to emit rather than writing it itself. entryPoints is read by Baseline and not forwarded, because esbuild is called once per entry point.
These apply to compiled entry points only. The inline filter does not see them: it goes straight to the processor, so it always starts from minify: true, target: 'es2020' and bundle: true however you set assets.esbuild. Give the filter anything it should do differently per call.
Per call, the inlineESbuild filter accepts an options object that merges over the processor defaults:
{% set jsPath = _baseline.paths.assets ~ "js/inline-example.js" %}
{{ jsPath | inlineESbuild({ minify: false, target: "es2017" }) | safe }}
attributes is read out of that object and put on the <script> tag instead of being passed to esbuild:
{{ jsPath | inlineESbuild({ minify: false, attributes: { type: "module", defer: true } }) | safe }}
The filter emits the tag, so the tag's attributes are its to take. Unwrapping the output to add one is the alternative, and it is worse than it looks: a tag sanitiser pointed at minified JS eats expressions like for (i = 0; i < xs.length; i++). nonce cannot be done that way at all, because a hash-based CSP needs the digest taken over the bundle this call just produced.
The filter is not limited to the assets directory: pass any absolute path. In Markdown, keep the call unindented and on its own lines so the <script> tag is treated as raw HTML, and pipe through | safe so Nunjucks does not escape it.
On error, a build fails and the message names the file. Under --serve the processor logs and returns /* Error processing JS */ so the page still reloads.
CSS processor: PostCSS
The CSS processor runs a CSS file through PostCSS and returns the compiled text. Pure function, same shape as the JS processor.
It does not take plugin-level options. Configure it through postcss.config.js at your project root. See Customise the assets pipeline for the steps.
The inlinePostCSS filter wraps any CSS file in <style> tags:
{% set cssPath = _baseline.paths.assets ~ "css/critical.css" %}
{{ cssPath | inlinePostCSS | safe }}
It takes an attributes object too, and nothing else, since PostCSS has no per-call options:
{{ cssPath | inlinePostCSS({ attributes: { media: "print" } }) | safe }}
Same Markdown caveat: keep the call unindented, pipe through | safe. Useful for critical CSS that should render before the main stylesheet loads.
On error, a build fails and the message names the file. Under --serve the processor logs and returns /* Error processing CSS */.
Fingerprinted filenames
Compiled assets carry a hash of their own contents in the filename:
dist/assets/css/index.342303cd.css
dist/assets/js/index.edcbd7a1.js
You keep writing the plain path. Declare assets in settings.head and Baseline puts the real filename in the tag, so nothing in your config mentions a hash:
head: {
link: [{ rel: 'stylesheet', href: '/assets/css/index.css' }],
script: [{ src: '/assets/js/index.js', defer: true }]
}
For a path written straight into a layout rather than declared in settings, the asset filter does the same lookup:
<script src="{{ '/assets/js/index.js' | asset }}" defer></script>
A path that is not a compiled entry point passes through untouched, so your favicons and third-party URLs are unaffected. A path inside the assets directory that Baseline does not recognise gets one line in a verbose build, because a typo and an unmanaged file look identical to the lookup.
Why it matters
A filename that never changes meaning is what makes a long cache header honest. Without it you either tell browsers to re-check your CSS on every visit, or you promise it is immutable and serve yesterday's bytes after a deploy.
With hashed names you can say the true thing. On Netlify:
[[headers]]
for = "/assets/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
immutable is only safe where every file under the path is hashed. It tells browsers not to re-check even on a reload, so a stable filename under that rule can stick in someone's cache until it is evicted. Keep unhashed files, fonts and images you drop in by hand, outside the path the rule covers.
The hash moves only when the bytes move, which includes a change in an imported partial while the entry file itself is untouched. A deploy that changes nothing in your CSS leaves the filename alone, so returning visitors download it once rather than once per deploy.
Turning it off
baseline(settings, { assets: { fingerprint: false } })
One case genuinely needs this: a service worker that precaches /assets/css/index.css by exact URL does not match a hashed name. Rewriting the service worker is usually the better answer, but the switch is there.
Fingerprinting applies to production builds only. Under npm start filenames stay plain, so what you see in the browser's network tab is what you wrote.
Tips
- Two entry points by default. Only
index.jsandindex.cssare compiled unless you name your own; reach everything else throughimportand@import. - Images are watched. The watch target covers them too, so editing an SVG or PNG triggers a reload without extra config.
- PostCSS config is optional. The bundled fallback is a sensible starting point, and you only need your own
postcss.config.jsto customise plugins. - Inline sparingly. The inline filters suit small critical-path assets; anything large ends up duplicated on every page.
- Declare assets in
settings.head, not in a layout. That is what lets Baseline resolve the hashed filename for you. Theassetfilter covers the cases where you cannot.
Peer deps
esbuild and postcss. Both ship with Baseline.
See also
- Image shortcode for images, which this module only watches. It resolves its
srcagainst the input directory rather than the assets one. - Filters reference lists
inlineESbuildandinlinePostCSSalongside the rest. - Customise the assets pipeline
- Tutorial: assets pipeline