Docs for v0.0.16.v0.0.18 is the current release.

Asset pipeline

Everything under assets/ is copied to the output untouched. A handful of switches turn that copy into a build step.

assets {
  minify #true
  bundle #true
  fingerprint #true
}
Key Type Default Does
minify block off Minifies CSS with Lightning CSS, and JavaScript as part of bundling. See Minifying.
targets block none The oldest browsers the CSS must run on. Naming any compiles it down to them. See Browser targets.
bundle flag #false Bundles .js, .mjs, .cjs, .jsx, .ts, .mts, .cts and .tsx entry points with rolldown, resolving imports and shaking out dead code.
fingerprint flag #false Puts a content hash in each output filename, and rewrites every reference to match.
sourcemap off | inline | external | hidden off What becomes of each kind of asset’s source map. See Source maps.
tsconfig path discovered The tsconfig.json TypeScript and JSX are transformed against. See TypeScript and JSX.

Images sit in a nested images block and run on their own switches. See Images.

Minifying

minify is a block whose presence turns every kind on, so the one-line spelling above is the whole of it. Name a kind to take just that one back:

assets {
  minify {
    js #false
  }
}
Key Default Does
css off Minifies stylesheets.
js off Minifies JavaScript. Needs bundle, which is what runs it.

NOTE

JavaScript is only touched when bundle is on, because the bundler owns the whole JS step. minify alone minifies stylesheets and copies scripts verbatim.

Browser targets

Minifying makes a stylesheet smaller. Compiling it makes it work in older browsers, and that is a separate ask: name the oldest version of each browser the site supports and Lightning CSS flattens nesting, adds vendor prefixes, and writes fallbacks for modern colour syntaxes, each only where a named browser needs it.

assets {
  targets {
    chrome "100"
    firefox "100"
    safari "15.4"
  }
}
Key Is
android Oldest Android WebView.
chrome Oldest Chrome.
edge Oldest Edge.
firefox Oldest Firefox.
ie Oldest Internet Explorer.
ios Oldest Safari on iOS.
opera Oldest Opera.
safari Oldest Safari.
samsung Oldest Samsung Internet.

A version is one to three numbers, each 0-255: 15, 15.4, 15.4.1. Write it as a string. 15.10 as a KDL number is the float 15.1, which is a different browser, so a number is refused rather than rounded.

A browser you do not name is not a constraint. The floor is set by the ones you name, and naming none leaves the CSS exactly as written.

NOTE

targets works with minify off. The transform and the compaction are independent: you can ship readable CSS that still runs on Safari 15.

Sass

A .scss or .sass file under the asset tree is a stylesheet. It is compiled with grass, served as .css, and goes on through everything a hand-written sheet gets: minify, targets, url() rewriting, fingerprint, integrity, embed.

assets/
  _vars.scss    -> nothing; the sheet that imports it carries it
  style.scss    -> /assets/style.css

There is nothing to turn on and no toolchain to install.

// assets/style.scss
@use "vars";

.card {
  color: vars.$brand;
  .title { font-weight: 600 }
}

Link either spelling. /assets/style.scss is the file you put there and /assets/style.css is what it became, and both resolve to the same compiled, fingerprinted file:

#html.elem("link", attrs: (rel: "stylesheet", href: "/assets/style.scss"))

@use and @import resolve beside the importing sheet first, then across the asset roots – the project’s tree, then the theme’s – so a sheet you wrote can pull in a partial your theme ships, and a partial you add under the same name wins.

Detail Is
Syntax Both. .scss is the CSS-like one, .sass the indented one, picked off the extension.
Partials _vars.scss is import-only, as everywhere else in the tree.
@warn / @debug Written to stderr as the sheet asked.
Source maps Map the compiled CSS, not the Sass: grass emits no mapping to chain back to.
Output Always expanded. minify is Lightning CSS’s pass, one step later.

NOTE

Sass is the sass cargo feature, on in the default build. A binary without it leaves a .scss where it lies and warns that it did.

Tailwind

A tailwind { } block generates a utility stylesheet from the class names the site is written with, using encre-css.

assets {
  tailwind { }
}
#html.elem("p", attrs: (class: "flex gap-2 text-sm"), [hello])

That is the whole of it. The build links the sheet from every page itself, so no template names a file, and the <link> it writes is an ordinary reference: minified, fingerprinted, digested for integrity and inlinable like any other asset. Only the classes the site actually writes get rules. Nothing is downloaded, no node_modules, no watcher.

Key Type Default Does
path asset path tailwind.css Where the sheet is served from, relative to the asset root. Relative and inside the tree, or the build refuses it.
scan list content and templates The trees and files read to find class names. A directory is read whole, a file on its own.
config path none An encre-css config (TOML): theme, safelist, shortcuts, preflight.
preflight flag #true Whether the sheet opens with the reset rules Tailwind puts in front of its utilities.

By default the content and template trees are read, and only the .typ and .md files in them: those are the two languages a page can be written in, and a photograph colocated with a post is not read on every build. Name scan yourself and everything under what you named is read, whatever it is called.

assets {
  tailwind {
    scan "content" "templates" "themes/plume/templates" "assets/app.js"
  }
}

NOTE

A class name has to appear literally somewhere scanned. A class assembled at runtime ("text-" + size) is not a class name anyone can find, in this generator or in Tailwind’s own: put the whole names in a safelist in the encre-css config.

NOTE

A tree named by scan outside content and templates is not watched by baudelaire serve. Add it to serve { include } to rebuild when it changes.
assets {
  tailwind {
    path "css/utilities.css"      // -> /assets/css/utilities.css
  }
}

A site or theme shipping its own file at that path keeps it: the generated one steps aside, as every generated asset does, and the pages go on linking whatever is served there.

The directory

paths { assets } names the tree the pipeline reads, and the last segment is also the URL prefix the results are served under:

paths {
  assets "src/assets"
}

Files land at /assets/..., whatever subdirectory of the project they were authored in. Point the pipeline somewhere else without touching any of the switches above.

How references are rewritten

Write the plain path in your template:

#html.elem("link", attrs: (rel: "stylesheet", href: "/assets/style.css"))
#html.elem("script", attrs: (type: "module", src: "/assets/main.js"))

With fingerprint on, style.css is written as style.9f3c1a2b4d6e8f01.css and both attributes point at the hashed name after the build. The digest is 16 hex digits of blake3 over the file’s bytes.

The rewrite runs on the typed HTML tree, not on the serialized string, so it can’t corrupt markup. Stylesheets get the same treatment inside: url() and @import references are rewritten to the hashed names too, and an imported sheet is fingerprinted before the sheet that imports it.

Serve fingerprinted files with a far-future Cache-Control. The deploy step reads headers { cache { immutable } } for exactly those files.

TIP

A bundled entry can import the fingerprint map from the baudelaire:* virtual modules, so client code names an asset by its logical path.

Source maps

A source map lets a minified bundle read in devtools as the files you wrote. sourcemap takes one word saying what becomes of it:

assets {
  minify #true
  bundle #true
  sourcemap "external"
}
public/assets/app.4f2a1c.js
public/assets/app.4f2a1c.js.map
public/assets/style.9f3c1a.css
public/assets/style.9f3c1a.css.map
Value Does
off Writes nothing, and discloses nothing. The default.
inline Puts the map inside the file, as a data: URI. One file and no second request, but every visitor downloads it whether or not anyone looks.
external Writes the map beside the file and names it in a sourceMappingURL comment. A browser fetches it only when devtools are open.
hidden Writes the map beside the file and points at it from nowhere. For uploading to an error tracker: the file is still served, so this hides the map rather than protecting it.

Each kind of asset takes its own value. The word on the line sets both, and a block narrows from there:

assets {
  // maps for scripts, none for stylesheets
  sourcemap "external" {
    styles "off"
  }
}
assets {
  // ship the bundle's map for the tracker, inline the stylesheet's for dev
  sourcemap {
    scripts "hidden"
    styles  "inline"
  }
}

The value on the line is required: a bare sourcemap is an error rather than a guess, so that a profile naming the block cannot silently re-apply a default over what the base config chose. Writing only a block narrows what is already there and leaves the rest alone.

An external or hidden map is named after the file it maps, fingerprint included, and both names are settled before the comment is written, so the pair survives fingerprint.

WARN

A source map publishes your sources. It has to: the pipeline never writes your .ts, .jsx or unminified .css to the output, so a map that merely named them would resolve to nothing. Each map therefore carries the original text inside it, and anyone can read it.

That is usually the point in development and rarely what you want in production, so it belongs in a profile:

profiles {
  dev {
    assets { sourcemap "external" }
  }
}

The map is fetched only when devtools are open, so it costs a visitor nothing; check { budget { } } does not count it, because no page references it.

TypeScript and JSX

Entry points may be .ts, .mts, .cts, .tsx or .jsx as well as .js, .mjs and .cjs. Types are stripped and JSX is transformed on the way through; the output is always served as .js, and both spellings resolve:

#html.elem("script", attrs: (type: "module", src: "/assets/main.js"))

Nothing is type-checked. The bundler transforms, as esbuild and Vite do; run tsc --noEmit in CI or a before hook for that. The baudelaire:* modules an entry imports are typed by baudelaire mirror: see JS modules.

A tsconfig.json supplies the rest: paths aliases, jsxImportSource, experimentalDecorators. One is discovered per script, walking up from the file as tsc does. Pin one instead when the scripts sit far from it, or when more than one is in reach:

assets {
  bundle #true
  tsconfig "tsconfig.json"
}

The path is relative to the project root, and the build fails if nothing is there. JSX defaults to the automatic runtime, so jsxImportSource (or a plain react dependency) decides what the transformed code imports.

NOTE

A pinned tsconfig.json is not a watched source. Add it to serve { include "tsconfig.json" } for the dev server to rebuild when it changes.

What is an input, not an output

The asset tree holds both what the site serves and what the build reads to make it. Nothing in a file’s extension says which, so three rules mark the inputs, and none of them reaches dist:

In assets/ Why it is not published
_search.js, _app.css, _type.scss A leading _ means import-only: something else pulls it in.
globals.d.ts A type declaration carries no runtime code.
app.ts, app.tsx, app.jsx with bundle off A script no browser can run, and nothing is bundling it.

Nothing else is treated as an input. A file belonging to a toolchain this build does not run – a .less, a .styl – is copied like any other file, because the pipeline has no opinion about tools it does not run. Give it a leading _, or keep it out of paths { assets }, and it stays out of the output.

assets/
  main.js       -> /assets/main.js
  _search.js    -> nothing, inlined into main.js
  app.ts        -> /assets/app.js, bundled (nothing, with `bundle` off)
  style.scss    -> /assets/style.css, compiled
  _brand.scss   -> nothing; the sheet that imports it carries it

Name a preprocessor’s input with a leading _ and it stays out of the output whatever its extension: that is how a tailwindcss -i assets/_app.css recipe publishes only what it produced.

NOTE

This applies to paths { assets } only. static/ is the verbatim escape hatch, and a file there publishes under its own name whatever it is called.

Bypassing the pipeline

Some files have to reach the output root byte for byte: a robots.txt override, .well-known/, a CNAME, an install.sh. Put them under static/.

paths {
  static "static"
}

The tree is mirrored into the output root: no minify, no bundle, no fingerprint, no prefix. It is copied before pages and assets are written, so a generated file at the same path wins. static/ is the lowest-priority source.

Running other tools

PostCSS, Pagefind and friends run as build hooks. A before hook runs ahead of the pipeline, so what it writes into assets/ is minified and fingerprinted like anything else. That is also the route for a preprocessor this build does not embed, LESS and Stylus among them.

WARN

Minification needs the css feature and bundling needs js. A slim build has neither: it warns and copies the files verbatim, and fingerprint turns itself off, since a verbatim stylesheet still names its assets by their pre-hash spelling.