Assets and builds
Copying and compiling assets, cache-busting URLs, running an external asset pipeline, cleaning the build folder, building several sites from one source, and how the dev watcher handles file changes.
Assets
Any static files you have in the assets directory will be copied to the build directory
Minified assets
Outside dev, every .css, .js and .mjs the copy writes is minified in place, with terser and clean-css, the same minifiers that already minify each page's inline CSS and JS. It happens before the file is hashed, so a hashed name changes exactly when the served bytes do. Three kinds of file are left as they are:
- a
*.min.cssor*.min.js, already minified; - a Sass compile, which is already
compressed; - a file the minifier cannot read, which is copied unchanged with a warning naming it. The build does not fail. A stylesheet that uses native CSS nesting is one: clean-css does not understand nesting, so rather than drop the nested rules kiss ships that file as it was written.
A page that loads its own file with integrity="sha384-…" names the hash of the bytes you wrote, and minifying (or purging) changes them, so a browser would refuse the file. The build checks each such reference against the file it wrote, and fails on a mismatch, naming the page. Name the file *.min.js or *.min.css so it is copied as written, set assets: { minify: false }, or take the hash of the file the build writes.
assets: { minify: false } turns it off. Minifying is cheap and never makes a file bigger: kiss keeps the original whenever the minified file is no smaller. What it saves depends on the file. On the fleet's sites, measured after Brotli, an unminified script or stylesheet came out about 17% smaller, and one already minified about 1–2%. Removing unused CSS and JavaScript saves far more: see Purged CSS below, and the kiss-performance skill's recipes for JavaScript.
Purged CSS
A framework's stylesheet copied whole (Bootstrap, Foundation, Bulma) is mostly rules no page uses. On one measured site, Bootstrap's CSS was 94% unused. Name it, and kiss removes those rules once every page is written:
new Kiss({
assets: {
hash: true,
purge: {
files: ['css/bootstrap.css'], // as a template asks for it
safelist: ['show', 'active', 'in', 'collapsing', /^modal/], // classes a script adds
},
},
})
At the settle, kiss runs PurgeCSS (opens in a new tab) over each named stylesheet against the HTML this build wrote, so every class a page carries counts, whether it came from a template, a model, a helper or a partial. It also reads the build's own .js files (scripts: true, the default), for the class names a script adds. A purged stylesheet is minified again (assets.minify). Under assets.hash it is re-hashed, and every page that linked the old name is rewritten to the new one, before the link scan, the audit, the cache headers and the report run. Nothing else in the build has to know.
What counts as used:
- Every class a page's
classattribute holds, exactly as written. Tailwind's names survive whole:md:hover:bg-ink-900/80,min-h-[60vh],[&_p]:mb-6,p-1.5. Before 2.14 they were split on/ [ ] . :and removed (#78 (opens in a new tab)). - Every word in the pages and scripts, with any bracketed part kept whole, for a class a script adds by name (
classList.add('open')). - A rule on a state a script flips is always kept, whatever the markup holds now:
[aria-expanded="true"] .chevronstays on a page whose button saysfalse. The attributes arearia-busy,aria-checked,aria-current,aria-disabled,aria-expanded,aria-hidden,aria-invalid,aria-pressed,aria-selected,disabled,hiddenandinert. - A rule whose selector is pseudo-classes or pseudo-elements alone is always kept:
:focus-visible { outline: … },::selection. PurgeCSS removes these on its own (AIKB/upstream.md), which took every keyboard focus ring off one site.
A selector goes when any part of it names something no page or script has: .card .title is removed from a site with no .card.
- The safelist is the site's job. A class a script builds from pieces (
'modal-' + name,'tab-panel-' + state) is in no page and no script, so it is removed, and its component breaks only when it opens. Open every interactive component in the browser after turning this on. A string insafelistis matched exactly, and a regular expression as written, against every name in a selector: each class, id, element, attribute and pseudo-class. Either keeps every rule whose selector names a match anywhere:'show'keeps.show > .btn.dropdown-toggleeven on a site with no.btn,'active'keeps.btn:activeas well as.active, and/open/keeps every[open]rule. That is wider than 2.13, which kept such a rule only when the rest of its selector was on a page as well. A broad pattern now keeps more CSS: anchor each one (/^modal/, not/modal/). - Purged first, minified once, after. The copy does not minify a named stylesheet, so PurgeCSS still reads its comments:
/* purgecss start ignore */ … /* purgecss end ignore */keeps a block whatever the pages say. - Not in dev.
--devserves the whole stylesheet while you edit, so the preview never hides a rule a page is about to use. - A named file that is not in the build fails the build, with its name. A purge that did nothing would ship the whole file in silence.
- Tailwind saves less, and needs no safelist. It writes only the utilities its own scan finds (§ An asset pipeline), so there is less to remove. On one Tailwind v4 site, 73.6 KB became 54.7 KB, or 9.4 KB to 8.4 KB after Brotli. The classes a page holds are kept as written, so no safelist is needed for them.
Cache-busting asset URLs
Link an asset with the asset helper and the caching policy stops living in your template:
<link rel='stylesheet' href='/{{asset "css/site.css"}}' />
config.assets | What is emitted | What the helper renders |
|---|---|---|
| (default) | public/css/site.css | css/site.css |
{ hash: true } | public/css/site.a1b2c3d4.css | css/site.a1b2c3d4.css |
{ version: '1.4.5' } | public/css/site.css | css/site.css?v=1.4.5 |
Setting both hash and version is a misconfiguration: the hashed filename wins and a warning says so. Ask for the path the file has when nothing is renaming it — a .scss source by its compiled .css name — and the same template line works under all three. The helper renders no leading slash, so the base is yours: /{{asset …}} for a root-relative link, or {{root}}{{asset …}} if your layout already climbs back to the build root (which is what makes a nested page work opened straight off the file system — see root is yours below). The hash is taken over the bytes that were emitted (a stylesheet after sass compiled it), so the URL changes when, and only when, the file a browser downloads changes; the file it replaces is deleted as it is written, so a dev session leaves one stylesheet in the build rather than one per save. Only .css and .js are renamed: an image, a font or robots.txt is reached by URLs kiss does not rewrite — the ones inside a stylesheet, and the ones a host asks for by a fixed name — so those keep their names, and {{asset}} still resolves them.
A file another asset imports keeps its name too. kiss rewrites no import, so under hash: true a .js or .css that a hashed asset reaches by path is copied under its own name. In JavaScript, that means any quoted string starting ./, ../ or / that names a copied file, whatever surrounds it (import './util.js', export … from, import('./x.js'), new URL('./worker.js', import.meta.url)); in CSS, @import "other.css". The build says which files it kept in one info line. It gets no immutable cache header (lib/cache-headers.js marks only renamed files), so a changed util.js is fetched fresh while the app.<hash>.js that imports it stays cached. A bare specifier (import 'lit') needs an import map, which kiss does not write. Only an import of a file the same asset copy emits is seen: an import of a file another assets source writes (a vendor folder copied separately), and a root-relative import on a site served under a path prefix (import '/docs/js/util.js' under a siteUrl of https://example.com/docs/, which is not read as js/util.js), still leave the target hashed and the import broken. Import relatively, within one assets folder.
The other two forms, for a layout that climbs back to the build root and for an absolute URL — the hashed extension and the ?v= query both survive the wrap:
<link rel='stylesheet' href='{{root}}{{asset "css/site.css"}}' />
<link rel='preload' as='style' href='{{absUrl (asset "css/site.css")}}' />
root is yours, not kiss's. There is no root helper — it is a value your page or layout supplies, the climb back to the build root ('', '../', '../../') for a page that knows how deep it is. The small examples set it on the extend ({{#extend "layout" root="../"}}, see examples/3-pages/src/pages/roasts/roast.hbs). examples/11-blog shows the other road: every link through {{link}} and /{{asset …}}, root-relative, no root at all. This matters because an undefined {{root}} is a missing property to Handlebars, not a missing helper: it renders empty and says nothing, so the line still builds and the stylesheet resolves from whatever directory the page happens to sit in — correct at the top level, broken one folder down. If your pages are all at one depth, or you serve from the domain root, use /{{asset …}} and skip root entirely.
An asset pipeline
config.assets.pipeline runs an external tool as part of the build — a CSS toolchain, an icon sprite, anything with a command line. Each step is { name?, run, watch?, cwd?, outputs? }, and kiss knows nothing about the tool itself:
const kiss = new Kiss({
dev: process.argv.includes('--dev'),
assets: {
pipeline: [
{
name: 'tailwind',
run: 'npx @tailwindcss/cli -i src/styles/site.css -o src/assets/css/site.css --minify',
watch:
'npx @tailwindcss/cli -i src/styles/site.css -o src/assets/css/site.css --minify --watch=always',
},
],
},
})
Every run is executed through a shell, in order, awaited, before the asset copy — so a tool that writes into your assets folder has its output copied into the build like any other asset — and again before the copy on every whole-site watch rebuild. A step that exits non-zero (or cannot be spawned) fails the build like a failed page: complete() rejects and the failure is named <pipeline: tailwind>, while the pages and the asset copy still run so the whole build is still reported. report().pipeline carries { name, ok, duration } per step.
name defaults to the program run names (its first token, a quoted path kept whole), which is also all of the command the log, a failure message and the site map show, cwd to process.cwd(), and each command inherits process.env plus KISS_BUILD, KISS_ASSETS and KISS_DEV ('1' or '0'). watch is the dev-mode half: in dev: true only, it is started once — after that step's run has succeeded — kept for the session with its output going through kiss's logger, and ended by close(). A watch process that dies on its own is logged, not a build failure, and a rebuild never starts a second one. Editing a page, a partial or a layout does not re-run the steps; a tool that must see those edits is what watch is for.
Write into your assets folder if you can. The copy then picks the file up, claims it, and hashes it under assets.hash like any other asset. A tool that can only write into the build folder ($KISS_BUILD) declares each file it writes there in outputs, as build-relative paths:
{ name: 'sprite', run: 'node tools/sprite.js', outputs: ['img/icons.svg'] }
kiss deletes each declared file before the step runs and checks it exists after, so a step that succeeds without writing it fails the build (declared output not written: img/icons.svg) rather than shipping the file an earlier build left. A declared file is claimed as this build's, so {{asset 'img/icons.svg'}} names it, a learning package carries it, and a copy of the same name is reported as a collision. It is never hashed: it keeps the name the tool gave it. A file written into the build folder and not declared is not this build's as far as kiss knows: a learning package reports it as not written and leaves it out.
A watch command gets no stdin from kiss, so a tool that stops when stdin closes needs its keep-alive flag — Tailwind's is --watch=always; plain --watch exits at once and the log says pipeline watch "tailwind" exited (code 0). Scoped re-renders — a page view, a partial or a layout edit under watch — deliberately do not re-run the steps; a tool that must see those edits is what watch is for. kiss-ssg check runs the pipeline exactly as a build does, so a check is not read-only over your working tree: it regenerates whatever the steps generate. examples/10-asset-pipeline/ (npm run eg10) is a runnable version that needs nothing installed.
Two silent traps in the Tailwind recipe
Neither of these is kiss's behaviour — both are Tailwind's — but the command above is what people copy, so they belong next to it.
@source adds to Tailwind's project walk; it does not replace it. Tailwind v4 scans your whole project for class-name candidates (everything not excluded by .gitignore, node_modules, a lockfile, a binary or a CSS file), and an @source line registers additional paths. A site that writes an explicit @source list, reasonably reading it as the source list, is still scanning its own markdown. Tailwind treats every file as plain text, so an ordinary English word is a candidate class — and with assets.hash: true the stylesheet's content hash is its filename. Typing the word invisible into a tracked README.md compiles .invisible{visibility:hidden}, gives css/site.<hash>.css a new name, and changes the <link href> on every page; kiss-ssg check then reports the whole site as changed with nothing in the page sources to explain it. On Tailwind 4.3.3 that single word moved the stylesheet from 4,381 to 4,410 bytes.
It is intermittent, which is what makes it expensive: a word counts only if it forms a clean candidate token, so capitalize in a sentence compiles a rule while capitalize. or capitalize, does not, and a word matching an already-compiled utility changes nothing. The hash therefore moves on some documentation edits and not others.
To scan only what you mean, disable the walk and list your sources — remembering that an @source path resolves against the stylesheet's own folder, not the working directory:
@import 'tailwindcss' source(none);
@source '../views';
@source '../partials';
A path that matches nothing is silent as well: source(none) plus an @source that resolves nowhere compiles an empty utility layer rather than failing.
Guard it rather than remember it. Both failures are silent and both produce a plausible file, so a few lines in the build — a pipeline step, or a CI check — that fail when source(none) is missing from the entry stylesheet, or when any @source path no longer resolves, catch the whole class. It needs nothing built, and it is what caught this on a real site.
Keep the -i. Running npx @tailwindcss/cli -o out.css without -i exits 0, prints a normal timing line and writes a plausible stylesheet — but it never reads your entry file. The CLI substitutes a default input of @import 'tailwindcss', so what is lost is not a handful of rules but everything you authored: every @layer components class, every @theme token, every @font-face, every plugin import and every @source line. Verified on 4.3.3 — an entry file declaring .btn, .card, .callout and a self-hosted @font-face compiled all of them with -i and none of them without; on a real 20-page site the output fell from 66,384 to 24,990 bytes, leaving generic utilities. Nothing reports an error. A project whose dev command passes -i while its build command does not is correct on every developer's machine and wrong only on the deployed site, which is how this survives for months.
Responsive images
{{image}} turns a copied JPEG, PNG, WebP or AVIF into a <picture>: an AVIF and a WebP <source>, and a fallback <img>, each at the widths the recipe allows that image. The path is the one {{asset}} takes:
{{image
'img/hero.jpg'
alt='A swan on the river at dawn'
sizes='(min-width: 60rem) 50vw, 100vw'
eager=true
class='hero'
}}
For a 1200×800 photo with the default recipe, that writes (one line in the page, wrapped here):
<picture>
<source
type="image/avif"
srcset="
/img/hero-480.<hash>.avif 480w,
/img/hero-960.<hash>.avif 960w,
/img/hero-1200.<hash>.avif 1200w
"
sizes="(min-width: 60rem) 50vw, 100vw"
/>
<source
type="image/webp"
srcset="… 480w, … 960w, … 1200w"
sizes="(min-width: 60rem) 50vw, 100vw"
/>
<img
src="/img/hero-960.<hash>.jpg"
srcset="… 480w, … 960w, … 1200w"
sizes="(min-width: 60rem) 50vw, 100vw"
width="1200"
height="800"
alt="A swan on the river at dawn"
fetchpriority="high"
decoding="async"
class="hero"
/>
</picture>
alt=andsizes=are required, and a missing one fails the build (it warns in dev), as an unknown{{link}}id does.alt=""is the explicit decorative image.sizesis the width the image takes in your layout: no default is right for every layout, and the browser picks the file by it.- Lazy by default. Without
eager=truethe image getsloading="lazy"and everysizesbecomesauto, …, so a browser that knows the laid-out width uses it.eager=trueis for the first image a visitor sees: it writesfetchpriority="high"and noloading. - The widths are each recipe width narrower than the source, plus the source's own width when it falls between two of them or below the smallest. kiss never enlarges an image.
widthandheightare the largest variant's, so the box is reserved before the file arrives. The fallbacksrcis the middle width. - The fallback format is the source's for a PNG, and JPEG for a JPEG, WebP or AVIF, so the oldest browsers still get a resized file. A WebP or AVIF with transparency loses it in that JPEG.
- Turned the right way up. A photo whose EXIF says it was taken sideways (orientations 5–8) is served rotated, with
widthandheightswapped to match. - Refused, with a message: an SVG (write
<img src="/{{asset "logo.svg"}}">; a vector needs no widths), a GIF, an animated PNG, WebP or AVIF (only the first frame would survive), a path no copy emitted, and the attributes kiss writes itself:src,srcset,width,height,loading,fetchpriority. Any other attribute passes through to the<img>, escaped. root=is the base the URLs are written against,/by default. A learning package or a{{root}}site passes its own (root=root).- In dev,
{{image}}writes the copied original as one<img>, at its own size, with the samealtand loading attributes, and encodes nothing. Saving the image re-renders the pages that use it.
Names. A variant is img/hero-960.<hash>.avif beside its source, the hash taken over the copied file's bytes and the recipe (format, width, quality). It changes exactly when the source or the recipe does, whatever assets.hash says, so the cache headers mark every variant immutable on every host (Cache headers). The variants stay out of report().assets; report().images counts them (requested, encoded, cached, cache), null when no page rendered an {{image}}.
Encoding happens at the settle, after every page is written, with sharp (opens in a new tab): each source is decoded once, turned the right way up, resized and encoded per variant. All metadata is stripped, GPS included. A source that cannot be encoded fails the build as <images: img/x.jpg>, naming the pages that asked for it. A variant no page asks for any more is removed from the build. Images in a model's Markdown () do not pass through {{image}}.
The encode cache is committed. Every variant is also kept in assets.images.cache (.kiss-cache/images, beside the build script), keyed by the same digest as its name, and a hit is a copy. Commit that folder: encoding then happens once, on the machine where an image was added, and CI and the host copy. A build that had to encode logs one line saying how many variants were not in the committed cache. kiss-ssg check writes to this folder too, the one thing it writes outside the build. With cache: false, every build encodes everything. The folder is the cache's alone: new Kiss() refuses one that is the project root, or that sits inside or around src, the assets, layouts, pages, partials, models, controllers, helpers, AIKB or build folder, and kiss-ssg images removes only files named like a cache entry. The cache key leaves out sharp's version on purpose: an upgrade would otherwise re-encode and re-commit every image, and a variant from an older sharp is still a correct image of the same source and recipe.
npx kiss-ssg images <script> is the cache's sweep. It runs the build staged and discarded, as check does, encodes every miss, removes the cache entries no page asked for, and prints how many were added, removed and kept, with their bytes. A build never prunes, because a cache shared by branches would lose what another branch's pages use; a failed build is never pruned against either. --refresh encodes everything again. It also lists each original under folders.assets that carries a GPS location or is wider than the recipe's largest width, and --write rewrites those in place: turned the right way up, metadata stripped, resized down to that width, a JPEG at quality 90 and a PNG losslessly (a WebP at 90, an AVIF at 75), and replaced only when the result is smaller or the file had GPS. Without --write nothing in src/ changes; with it, read the diff in git before committing.
The recipe and its defaults. The qualities were checked by eye on real sites' photos and artwork; the widths are a starting point:
new Kiss({
assets: {
images: {
widths: [480, 768, 960, 1440, 1920], // 768: a full-width image on a phone
formats: ['avif', 'webp'], // the <source>s, in order; [] for the fallback alone
quality: { avif: 50, webp: 75, jpeg: 80 },
cache: '.kiss-cache/images', // or false
},
},
})
The audit's image-oversized, image-location-metadata and img-not-responsive report the images {{image}} would fix, and the one it does not (Checking a build). An existing <img src="{{asset …}}"> is migrated by hand, or by an agent, with img-not-responsive as the worklist: never automatically.
Cleaning the build folder
cleanBuild decides what happens to folders.build, and it takes three values.
true (the default) empties the build folder in the Kiss constructor — before a single model has resolved or page rendered — and nothing puts it back if the build then fails. For a build folder you regenerate from scratch every time that is exactly right. For one that holds output you have already published, it is not: a re-run to fix one typo destroys the old output first, and a build that fails leaves the folder empty or half-built.
false never cleans; files from earlier builds stay where they are.
'atomic' is the safe re-run. The build goes into a staging folder beside the build folder, and the whole thing is swapped into place only when complete() resolves:
const kiss = new Kiss({
cleanBuild: 'atomic',
folders: { build: `./handbooks/${cohort}` },
})
kiss.scan().generate()
await kiss.complete() // the folder is replaced here, in one step
Until that line, the previous output is untouched — and if any page fails, complete() rejects, the staging folder is deleted and the old output is still there, byte for byte. If the staging folder cannot be deleted — a Windows antivirus or indexer lock that outlasts a few retries — kiss logs one warning naming it; nothing was published from it, so delete it by hand, and the next cleanBuild: 'atomic' build sweeps leftovers when it starts. Everything the build writes follows the staging folder: pages, copied assets, sitemap.xml, and a .copyAssets() you aimed explicitly at the build folder. The swap itself is two renames — your old output is renamed aside, the new build is renamed into place, and the old folder is then deleted — so there is no moment where the folder is half-copied, and a failed swap puts the old output straight back. The usual reason a swap fails is on Windows: a program holding a file open in the build folder — a preview server such as VS Code's Live Server, an editor, antivirus. kiss retries for about a second and a half, then complete() rejects with an error naming the folder and that likely cause; the old output is left in place and the staging folder is deleted. Close the program, or serve another folder, and build again. A build killed mid-flight leaves a .kiss-staging-… or .kiss-old-… folder beside your build folder; the next build removes it and says so. Two things to know: only complete() promotes, so a chain that ends at .generate() swaps nothing in; and in dev: true it behaves as true and says so in one line, because the dev server has been serving the build folder since it started.
The staging folder is a sibling named <build>.kiss-staging-<pid>-<random>, and close() removes it. Any cleanBuild value other than true, false and 'atomic' throws.
'atomic' is for one-shot builds: in dev: true it degrades to true (the dev server serves the build folder from the start) and logs one notice saying so. The consequence, which is the part that bites: a dev run writes its output into folders.build like any other build, so if that folder is what you publish (GitHub Pages' ./docs, a committed public/), one npm run dev leaves a dev build in the published folder — carrying the injected livereload <script>, expanded rather than compressed CSS, no HTML minification, and the per-page .json siblings (each page's options, its model included; never config). Point dev at a different, git-ignored build folder (build: dev ? './.kiss-dev' : './docs') rather than relying on remembering, and only complete() promotes — a chain that ends at .generate() never swaps anything in. Folder safety is checked before the constructor creates, empties, copies, or stages anything, whatever cleanBuild is set to (including false):
folders.srcmust be a dedicated source folder, never the working project root (.,./, its absolute path, or an alias) or a filesystem root. Use./srcor another dedicated directory.folders.buildmust not be the working project root or a filesystem root, even whensrcis disabled or located elsewhere.- The build folder must not equal or contain any configured source folder:
src,pages,layouts,partials,models,controllers,assets,helpers, oraikb. - The build folder must not sit inside any of those content folders either.
srcis the exception:src/publicis allowed when it is separate from every individual content folder.
The checks cover both the configured paths and their real filesystem locations, including symlinks, Windows junctions and case differences, and missing descendants below an existing alias. A folder set to null is disabled and excluded; an unreadable or dangling link is rejected rather than assumed safe. Errors name the conflicting folder setting. Move overlapping inputs and output into separate folders; changing cleanBuild does not bypass the guard.
This protects configured source locations, not arbitrary files or custom code: validate variable output names (./handbooks/${cohort}) so an empty value cannot select an archive root. Pipeline commands and explicitly directed extra copies remain the site's responsibility.
Atomic watch limitation: cleanBuild: 'atomic' is supported for one-shot builds only. A non-dev .watch() session does not reset atomic promotion after failure: later replays can report success while the published output stays unchanged. Use ordinary watch mode for preview and a separate one-shot atomic build for publication.
Building more than one site from one source tree
kiss has no notion of "versions" or "sites" — it is one Kiss instance building to one folders.build. That is enough to build several outputs from one shared src/, each frozen once and never touched again: an archive of per-intake handbooks, a menu rebuilt every season, a docs site published per release. Four things kiss already gives you, combined:
One
Kissinstance per output,folders.buildas the discriminator. Everything else — pages, partials, models, controllers — is shared; only the build folder differs between runs:const kiss = new Kiss({ folders: { build: `./menus/${season}` } })An arbitrary config key, carried into every view. Pass whatever varies between outputs straight into
new Kiss({...})and read it back asconfig.<key>— it is the only thing that needs to differ in the template:new Kiss({ season, folders: { build: `./menus/${season}` } })<h1>The {{config.season}} menu</h1>folders.assets: nullplus an explicitcopyAssets(src, buildDir)when each output must own its assets outright, rather than sharing a folder kiss would otherwise keep re-copying from:const kiss = new Kiss({ folders: { build: menuDir, assets: null } }) kiss.copyAssets('./shared/assets', menuDir).scan().generate()cleanBuild: 'atomic', so a re-run that fixes a typo in this output can never destroy — or half-build — an output already published (see Cleaning the build folder above).
The one thing kiss cannot validate for you: the value that becomes folders.build is yours before it ever reaches the constructor. Check it looks like a slug — not empty, no .., no path separators — before building, since an empty or malformed value resolves against the parent of every output you have already published, not just the one you meant to build.
See examples/7-versioned-outputs/router.js for a full runnable version: one seasonal menu per season, each with its own copied assets, plus a small second build that lists every season folder found on disk. examples/ ships in the published package, so node_modules/kiss-ssg/examples/README.md is a copy you can run without cloning the repo. Examples 1–6 and 10 are the feature reference, one idea each; 7–9, 11 and 12 are exemplars — whole sites to copy by shape: versioned outputs, a data-fed site with one broken record, the v1 → v2 migration recipes, a blog with pagination, tag pages, a feed, a generated robots.txt and a redirect, and a single-file page converted into a kiss site. Each example is a site in its own folder with its own router.js, run from that folder the way a real site is. Every example builds and exits by default (npm run eg1 … eg11); pass --dev to run examples 1–6, 8, 9, 10 and 11 as a live dev server instead (7 takes a season slug in place of --dev, and 8 exits 1 by design).
Development file changes
The development watcher handles asset additions, edits, renames and deletions, removing only outputs tracked for those copies. It watches configured content folders outside src too, excludes build output, and applies intentionally empty saves after a short grace period. Asset updates and page rebuilds share a serial queue. Pages re-render when emitted CSS/JS URLs change under hashing, including directory deletions; image/font edits keep the fast path. One changed Sass output swaps its stylesheet, while multiple affected outputs reload the page. Writers register output ownership after successful writes: collisions warn, and generated pages and auxiliary outputs take precedence over later asset copies and cleanup. Changes to the build script or cached imported modules still require the documented restart.
