@phillipsharring/handlr-build
v0.10.2
Published
Build mechanics for Handlr sites: HTML compiler, page baker, Vite dev plugin
Maintainers
Readme
@phillipsharring/handlr-build

Build mechanics for Handlr sites: HTML compiler, static page baker, and Vite dev plugin.
This package contains everything needed to build a Handlr site, separate from the runtime concerns (HTMX, Handlebars, auth) that live in @phillipsharring/handlr-frontend. Static sites can depend on handlr-build alone; full apps depend on both.
Install
npm install -D @phillipsharring/handlr-build vite @tailwindcss/vite tailwindcssProject shape
my-site/
├── content/
│ ├── layouts/ # base.html, etc - shared shells
│ ├── components/ # custom-tag templates: lnk.html, callout.html, ...
│ └── pages/ # one HTML file per route
├── public/ # static assets, copied as-is
├── src/
│ ├── app.js # vite entry - at minimum, imports CSS
│ └── styles/ # CSS (tailwind v4 @theme block, etc)
├── site.config.js # siteName, siteUrl, copyright, ...
└── vite.config.jsvite.config.js
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
import { handlrBuild } from '@phillipsharring/handlr-build/vite';
import siteConfig from './site.config.js';
export default defineConfig({
root: 'src',
publicDir: '../public',
plugins: [tailwindcss(), handlrBuild({ siteConfig })],
build: {
outDir: '../dist',
manifest: true,
emptyOutDir: true,
rollupOptions: { input: { app: './src/app.js' } },
},
});package.json
{
"scripts": {
"dev": "vite",
"build": "vite build && handlr-build-pages",
"preview": "vite preview"
}
}Page format
<layout name="base" title="About" />
<page-head>
<meta name="description" content="..." />
</page-head>
<h1>About</h1>
<callout type="info">Page content here.</callout><layout name="..." title="..." />self-closing tag at the top picks the layout fromcontent/layouts/<page-head>...</page-head>block injects extra<head>content- Everything after is the page body, slotted into the layout's
[[app]]placeholder
Component format
content/components/callout.html:
<aside class="rounded border border-blue-300 bg-blue-50 p-4">
[[#if title]]<h3 class="font-bold">[[title]]</h3>[[/if]]
[[slot]]
</aside>[[prop]]- HTML-escaped prop[[{prop}]]- raw prop (for attribute values, HTML snippets)[[slot]]- child content[[#if flag]] ... [[else]] ... [[/if]]- boolean conditional on a prop's truthiness
Custom tags can be either HTML custom-element style (<my-callout> - must contain a hyphen) or single-word tags (<callout> - works as long as a matching callout.html exists in content/components/).
Programmatic API
import { renderPage, buildPages } from '@phillipsharring/handlr-build';
// Bake all pages under content/pages/ to dist/
await buildPages({ root: process.cwd(), siteConfig });
// Render a single page (used by buildPages and the dev plugin)
const html = await renderPage({
layoutsDir: 'content/layouts',
componentsDir: 'content/components', // string OR string[]
pagePath: 'content/pages/index.html',
siteConfig,
jsSrc: '/assets/app-XXXX.js',
cssHref: '/assets/app-XXXX.css',
});componentsDir: string or array
renderPage accepts componentsDir as either a single directory or an array of directories. When resolving a custom tag like <callout>, the compiler tries each directory in order and uses the first match. This enables a module system to contribute partials from src/modules/*/partials/ alongside the project-level content/components/ without changing the API.
Output shape: flatRoutes
By default buildPages() writes each route as a directory containing an index.html:
/about/ -> dist/about/index.html
/blog/post/ -> dist/blog/post/index.htmlSet flatRoutes: true to emit extensionless sibling files instead — handy when your host serves abstract URLs (/about, not /about/):
/about/ -> dist/about
/blog/post/ -> dist/blog/postSet it once in site.config.js — both the handlr-build-pages build step and the dev plugin read it from there, so dev and prod stay consistent:
// site.config.js
export default {
siteName: 'My Site',
flatRoutes: true,
// or: flatRoutes: { keepExtension: ['404', 'errors/offline'] }
};The handlr-build-pages CLI forwards siteConfig.flatRoutes to the build, and handlrBuild({ siteConfig }) in vite.config.js falls back to the same field — no extra wiring needed. (You can still pass flatRoutes directly to buildPages() or handlrBuild() to override the config field, e.g. in a custom build script: await buildPages({ root: process.cwd(), siteConfig, flatRoutes: true }).)
- The root route always stays
dist/index.html(it can't be an extensionless file). - The
404page is kept asdist/404.htmlby default, since CloudFront and most static hosts want a real.htmlfile for the error document. Override the keep-list with route keys (no slashes):flatRoutes: { keepExtension: ['404', 'errors/offline'] }. The list replaces the default, so include404if you still want it kept. - Nested-route conflicts are a hard error. A page at
/blog/flattens to the filedist/blog, which can't coexist with a nested page at/blog/post/that needsdist/blog/to be a directory. The build fails with both source files named; either move a page or add the parent tokeepExtension. - Default is
flatRoutes: false— no behavior change.
Dev serving already resolves /about without a redirect regardless of this setting; under flatRoutes the dev plugin additionally runs the same nested-route conflict check so vite dev fails the same way npm run build would.
Minification: minify
Set minify: true in site.config.js to run each baked page through html-minifier-terser before it's written to disk:
// site.config.js
export default {
siteName: 'My Site',
minify: true,
// or: minify: { removeComments: false } // override individual options
};html-minifier-terser is an optional peer dependency — install it only if you minify:
npm install -D html-minifier-terserminify: trueuses these defaults:removeAttributeQuotes,collapseWhitespace,removeComments,removeRedundantAttributes,removeScriptTypeAttributes,removeTagWhitespace.- Pass an object to override individual options — it's merged onto the defaults, so
minify: { removeComments: false }keeps everything else on. - If
minifyis on but the peer dep isn't installed, the build throws a clear error telling you to install it (it fails before rendering any page, not midway through). - Minification is build-only —
vite devalways serves unminified HTML so it stays readable for debugging. - Default is
minify: false— no behavior change. With it off, the peer dep is never loaded or required.
Like flatRoutes, the handlr-build-pages CLI reads minify from site.config.js; you can also pass it directly to buildPages({ ..., minify: true }) in a custom build script.
Dev stylesheet: avoiding FOUC
In dev, Vite serves CSS that's imported from your JS entry by injecting it via JavaScript after the script runs — so pages can paint unstyled for a moment (flash of unstyled content), most visible on content-heavy static sites. Production is unaffected (the hashed <link> from the manifest is render-blocking).
To fix dev, point handlr-build at your source stylesheet so it emits a real render-blocking <link>:
// site.config.js
export default {
siteName: 'My Site',
// dev-server URL of your CSS (relative to vite `root`), WITH `?direct`:
devCss: '/styles/style.css?direct',
};The ?direct query is required. Vite serves a processed .css module as JavaScript (Content-Type: text/javascript) so it can inject + HMR it — and the browser refuses a <link rel="stylesheet"> with that MIME type. Vite's ?direct query forces it to return the compiled CSS as real text/css, which is what makes the link render-blocking. (If devCss points at a static file in public/ or a CDN, it's already real CSS — omit ?direct.)
handlrBuild({ siteConfig }) reads it (or pass handlrBuild({ devCss: '…' }) directly). Vite still hot-reloads the linked stylesheet, so HMR is unaffected. Keep importing the CSS from your JS entry too — that's what bundles it for the production build; in dev it just loads alongside the link harmlessly. The build ignores devCss.
Hosting
handlr-build emits a plain dist/ tree — host it anywhere that serves static files. Two deployment notes:
flatRoutesandContent-Typeon S3. Extensionless files (dist/about) have no extension for S3 to infer a MIME type from, so a naiveaws s3 synctags themapplication/octet-stream(orbinary/octet-stream) and CloudFront serves them as a download instead of a page. After syncing, re-tag the extensionless objects astext/html— e.g. a smallapply-metadatastep that runsaws s3 cpwith--content-type text/html --metadata-directive REPLACEover the flattened routes. Files kept with their extension (index.html,404.html) are unaffected.- The Vite build manifest is cleaned up for you.
vite buildwritesdist/.vite/manifest.jsonfor handlr-build to read asset hashes from;buildPages()deletes it (and the now-empty.vite/dir) once those hashes are baked into the pages, so it never reaches your bucket. No--exclude '.vite/*'needed on the sync.
License
MIT
