build
Node-only. The static build: every route to HTML, plus bundles, feeds, sitemap, OG images and cache headers.
What it does
Node-only. build renders every route of the route table to static HTML in outDir. Around
the pages it bundles your CSS and client JS, copies images and public/, and writes feeds, a
sitemap, OG images, a 404.html and Cloudflare _headers.
Add it to the Node app: createApp({ plugins: [buildPlugin] }). Run it with
await app.build.run(), or through the cli plugin.
How it works
build orchestrates. It reads everything from other plugins through ctx.require: the routes
from router, the head from head, the articles from content, the locales from i18n. It
does no Markdown, head or URL work of its own. A run goes through fixed phases, in this order:
| Phase | What it does | Runs when |
|---|---|---|
| clean | Deletes and recreates outDir. | always, unless run({ skipClean: true }) |
bundle | Bun.build for CSS and JS, separately, with a content hash in every file name. | always |
content | content.loadAll(), cached for the later phases. | always; empty without content |
images | Copies static image folders. | images |
pages | generate → load → head.render → render for every page, then the JSON sidecars. | always |
content-images | Copies each article's co-located folders to <slug>/…. | images |
feeds | feed.xml, atom.xml, feed.json. | feeds |
sitemap | sitemap.xml, robots.txt. | sitemap |
og-images | One PNG per article with Satori, plus og-default.png. | ogImage |
public | Copies publicDir as it is. | the folder exists |
not-found | 404.html. | notFound |
locale-redirects | Redirect pages for bare locale paths. | localeRedirects |
cache-headers | _headers with cache rules, then your public/_headers. | cacheHeaders |
root-index | Writes the / page to index.html. | a / page was rendered |
The phases from feeds to locale-redirects run together. When one fails, the error is logged
and the others still finish. A disabled phase emits no build:phase event.
The JS entry is clientEntry, or the first file found of src/client/main.ts,
src/client/main.tsx and src/main.ts. The CSS entry is src/client/styles.css or
src/styles/main.css. Every page also gets a <meta name="build-id"> with the run id.
Config
pluginConfigs.build. Every field has a default.
| Option | Type | Default | Description |
|---|---|---|---|
outDir | string | "./dist" | Output folder. Must be inside the project or the OS temp folder: it is deleted on every run. |
minify | boolean | true | Minify the CSS and JS bundles. |
feeds | boolean | true | RSS, Atom and JSON feeds from the content articles. |
sitemap | boolean | true | sitemap.xml and robots.txt. |
images | boolean | true | Copy static images and co-located article folders. |
ogImage | OgImageConfig | false | false | OG image generation. An object turns it on. |
injectAssets | boolean | true | Add the bundle <link> and <script> tags to every page. |
publicDir | string | "public" | Copied into outDir as it is. Skipped when missing. |
notFound | boolean | { body?, path? } | false | true: a built-in page. { body }: your HTML in a minimal shell. { path }: your complete HTML file. |
localeRedirects | boolean | false | Redirect pages for bare locale paths. |
clientEntry | string | the scan above | The client bundle entry. |
env | readonly string[] | none | Variable names written into the client bundle as constants. |
template | string | built-in shell | Path to your HTML shell with <!--moku:lang-->, <!--moku:head-->, <!--moku:assets-->, <!--moku:assets:css-->, <!--moku:assets:js--> and <!--moku:body-->. |
cacheHeaders | boolean | { assets?, pages? } | true | _headers rules. Hashed bundles: public, max-age=31536000, immutable. Everything else: public, max-age=0, must-revalidate. |
OgImageConfig: fontDir (required, a folder with a .ttf, .otf or .woff file), template?,
size? (default 1200 × 630), render?(input) => VNode, fonts?: OgFont[] and
defaultCard?: boolean | ((input) => VNode). The config is shallow-merged, so an ogImage
override replaces the whole object.
env: ["IS_DEVELOPMENT"] makes process.env.IS_DEVELOPMENT and import.meta.env.IS_DEVELOPMENT
constants in the bundle, set to the build machine's value or "". With minify, a branch on an
unset flag is removed together with the dynamic import() it guards.
API
Mounted at app.build, reachable with ctx.require(buildPlugin).
| Method | Signature | Description |
|---|---|---|
run | (options?: RunOptions) => Promise<BuildResult> | Runs the build. Resolves to { outDir, pageCount, durationMs }. |
phases | () => PhaseName[] | The phase names in order. |
RunOptions: outDir? (this run only), skipClean?, overrides? (any of minify, feeds,
sitemap, ogImage, images, localeRedirects, notFound, cacheHeaders for this run) and
changed? (paths changed since the last run, for a fast incremental rebuild).
Events
Emits
| Event | Payload | When |
|---|---|---|
build:phase | { phase: PhaseName; status: "start" | "done"; durationMs?: number } | At each phase boundary. durationMs only on "done". |
build:complete | { outDir: string; pageCount: number; durationMs: number } | Once, after a successful run. |
Listens to
None.
Depends on
- site, router and
head: required, in
depends. - content and i18n: optional. A site
without content builds zero articles; without i18n it builds the
"en"locale. - data: optional. When composed and the mode is not
"ssg", the pages phase writes the JSON sidecars.
Example
import { buildPlugin, createApp, createPlugin, defineRoutes, route } from "@moku-labs/web";
import { h } from "preact";
const routes = defineRoutes({
home: route("/").render(() => h("h1", null, "My Blog")),
about: route("/about/").render(() => h("p", null, "About"))
});
const timingPlugin = createPlugin("timing", {
depends: [buildPlugin],
hooks: ctx => ({
"build:phase": ({ phase, status, durationMs }) => {
if (status === "done") ctx.log.info("build:timing", { phase, durationMs });
}
})
});
const app = createApp({
plugins: [buildPlugin, timingPlugin],
pluginConfigs: {
site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." },
router: { routes },
build: { outDir: "dist", feeds: false, notFound: true }
}
});
const result = await app.build.run();
console.log(`${result.pageCount} pages in ${result.durationMs}ms → ${result.outDir}`);- Makes the build events visible to
hooks, with typed payloads. - No content plugin here, so there is nothing to put in a feed.
bun scripts/build.ts { level: "warn", event: "build:bundle", data: { clientEntry: "none", scanned: [ "src/client/main.ts", "src/client/main.tsx", "src/main.ts" ] } } { level: "info", event: "build:timing", data: { phase: "bundle", durationMs: 1 } } { level: "info", event: "build:timing", data: { phase: "pages", durationMs: 1 } } { level: "info", event: "build:timing", data: { phase: "root-index", durationMs: 0 } } 2 pages in 3ms → dist ls dist 404.html _headers about index.html robots.txt sitemap.xml
The log lines are shortened here: each one is a structured record from ctx.log. The warning
says there is no client bundle, which is right for a site without islands.
build:completehandlers are not awaited. Hooks are notifications. Work that must finish before the process exits goes afterawait app.build.run().outDiris deleted on every run."./", your home folder or the project root fail with[web] build.outDir: … is not a safe clean target.ogImage.fontDiris checked atcreateApp. A missing folder or a folder without a font fails before the build starts.- No client entry is a warning, not an error. Without
clientEntryand without one of the scanned files, the build logsbuild:bundlewithclientEntry: "none"and ships no JS. - Hard-coded bundle URLs break. Bundle names carry a hash. In a
notFoundpage or atemplate, use the<!--moku:assets-->placeholders. buildwrites; it does not serve or deploy. Use cli for a dev server and deploy to ship.
Related
- Build & deploy: the scripts and the first deploy.
- Head & SEO: feeds, sitemap and OG images.
- Data: the JSON sidecars.