@moku-labs/web
v2.3.1
Published
Content static-site generator + SPA web framework for TypeScript, built on @moku-labs/core.
Maintainers
Readme
@moku-labs/web
Content static-site generator + progressive SPA framework for TypeScript.
Author Markdown, declare type-safe routes, ship SEO-complete static HTML —
then hydrate islands and navigate on the client. The same render runs at build and in the browser,
so SSG ⇄ SPA parity is structural, not duplicated.
Built on the @moku-labs/core micro-kernel — three layers of isolation, plugins all the way down, types doing the heavy lifting.
Quick start · How it works · The route is the contract · Plugins · Rendering modes · Scripts · Docs
bun add @moku-labs/web preact preact-render-to-string[!NOTE]
preact(andpreact-render-to-string, used by the SSG build) are peer dependencies — your app compiles its JSX against the same singlepreactinstance the framework renders with. Most package managers install peers automatically, but declare them explicitly so you own the version: a second nested copy of preact silently breaks hooks and island hydration.
[!NOTE] Status:
1.x— stable. The architecture and public API are stable and follow semver — breaking changes land only in a new major. The npm badge above tracks the current release.
Why @moku-labs/web
- SSG first, SPA when you want it. Render Preact pages to static HTML for SEO and instant first paint, then progressively enhance with island hydration and client-side navigation — opt in per project with a single switch.
- The route is the contract. One typed
route()builder ownsload→render→head. The build and the client run the samerender, so there's no second code path to keep in sync. Jump to the example ↓ - SEO complete out of the box. Title templates, canonical +
hreflang, Open Graph / Twitter cards, JSON-LD, RSS / Atom / JSON feeds,sitemap.xml, and generated OG images. - The
/browserentry is guaranteed node-free. A dedicated client entry whose static import graph references zero node modules — native code can never leak into your bundle, no matter your bundler or tree-shaking. A CI gate keeps it under budget (~50 kB gzip today, 60 kB budget). Why this matters ↓ - Plugins all the way down. A tiny isomorphic core (
site,i18n,router,head,spa) plus opt-in node-only plugins (content,build,deploy,cli), each independently documented and composed in onecreateAppcall. - Types do the heavy lifting.
ctx.datais inferred from your.load(), path params from the route pattern, plugin APIs from their specs — no codegen, noas. - i18n is built in. Locale-aware routes, default-locale fallback,
hreflang/og:localemaps.
Quick start
A complete static blog is two files: the routes, and the app that builds them.
// routes.tsx — the route IS the contract: load → render → head
import { route, contentPlugin } from "@moku-labs/web";
import { Article } from "./components";
export const home = route("/")
.render(() => <h1>My Blog</h1>)
.head(() => ({ title: "My Blog" }));
export const post = route("/{slug}/")
// generate the page list at build time…
.generate((ctx) => listSlugs(ctx.locale).map((slug) => ({ slug })))
// …load() runs at build only; pull sibling plugins via ctx.require → widens ctx.data
.load((ctx) => ctx.require(contentPlugin).load(ctx.params.slug, ctx.locale))
// render() runs at build AND on the client; ctx.url builds type-safe links
.render((ctx) => <Article article={ctx.data} url={ctx.url} />)
.head((ctx) => ({ title: ctx.data.title, description: ctx.data.description }));// app.ts — compose the build, then run it
import { createApp, contentPlugin, buildPlugin, fileSystemContent, processEnv } from "@moku-labs/web";
import * as routes from "./routes";
const app = createApp({
plugins: [contentPlugin, buildPlugin], // node-only plugins — opt in for the build
config: { mode: "ssg" }, // "ssg" | "spa" | "hybrid" (see Rendering modes)
pluginConfigs: {
site: { name: "My Blog", url: "https://blog.dev", author: "Me", description: "A personal blog." },
i18n: { locales: ["en"], defaultLocale: "en" },
content: { providers: [fileSystemContent({ contentDir: "./content" })] },
router: { routes }, // a declarative route map (an `import * as` namespace works)
env: { providers: [processEnv()] },
},
});
await app.build.run(); // → dist/ : static HTML + feed.xml + sitemap.xmlContent lives on disk as content/{slug}/{locale}.md with YAML frontmatter. Drafts are excluded when config.stage is production (the default); they surface in development and test.
content/
hello-world/
en.md # frontmatter: title, date, description, tags, language, draft?, author?
uk.md # same slug, another locale — i18n fallback handles the rest
second-post/
en.mdAdd client-side navigation
Import from the /browser entry. It's the same createApp over the same isomorphic defaults, with all node-only code excluded and env pre-wired — so env needs zero config.
// client.ts — guaranteed node-free; env reads import.meta.env out of the box
import { createApp, dataPlugin } from "@moku-labs/web/browser";
import * as routes from "./routes"; // render shells only — never the node content source
const app = createApp({
plugins: [dataPlugin], // opt in for DATA-driven navigation (mode spa | hybrid)
config: { mode: "spa" },
pluginConfigs: { router: { routes } },
});
await app.start(); // spa mounts islands onto the SSR'd DOM and intercepts navigationPer-route transitions, scroll, and programmatic nav
The behaviour of a navigation is declared on the route (a typed framework directive, not free-form .meta()), with an app-wide default on the spa plugin. Both .transition() and .scroll() override the default for navigations to that route:
spa: { islands, viewTransitions: "crossfade", scrollRestoration: "top" } // app defaults
route("/board/{id}").transition("slide") // board↔board slides
route("/board/{id}/issue/{issueId}")
.transition("morph") // the card morphs into the panel
.scroll("preserve") // overlay: don't move the board behind itviewTransitionsacceptsboolean(true→"crossfade",false→"none") or a namedTransitionMode("none" | "crossfade" | "slide" | "morph"). A named transition tags the swap so your CSS can target it two ways —:root[data-view-transition~="slide"](every View-Transitions engine) and the standards-track:active-view-transition-type(slide)— and a sharedview-transition-nameleft across the swap morphs one element into another. Reduced-motion and unsupported browsers fall back to an instant swap.scrollRestorationis"top"(reset, the default) or"preserve"(keep the current scroll — e.g. opening an overlay route over content that must stay still). Back/forward always restores its saved position.ctx.navigate(path, options?)— every island can navigate programmatically with noapphandle (an always-present context member, likectx.set/ctx.url).app.spa.navigatetakes the same optional{ scroll }.navigate(path, options?)/hardNavigate(url)— importable from@moku-labs/web/browserfor module-scope callers (shared chrome/nav helpers with noappor islandctx); they bind to the single booted app and no-op beforeapp.start().hardNavigate(alsoapp.spa.hardNavigate) does a REAL full-page load for crossing a boundary the SPA can't swap — a different layout or an auth split — by detaching the SPA's own interceptor first, solocation.assignisn't caught and converted to a region swap.
createIsland("issue", {
// `null` renders nothing but stays mountable (Preact unmount, not innerHTML="") —
// the correct "empty" for a persistent overlay panel, so it re-opens cleanly:
render: (s) => (s.open ? h(IssuePanel, { issue: s.issue }) : null),
events: { "click [data-close]": (ctx) => ctx.navigate(ctx.url("board", { id: ctx.params.id })) },
});Live realtime channels
createChannel is a self-healing client WebSocket primitive for islands that consume a server push stream (e.g. a Durable Object). It owns reconnect-with-backoff, an optional keepalive, a pre-seed buffer that closes the connect→load race, refcounted subscribe (first in connects, last out disconnects), and an optimistic deliverLocal echo — browser-only and tree-shaken away unless used:
import { createChannel } from "@moku-labs/web/browser";
const board = createChannel<BoardPatch>({
url: (id) => `${location.origin.replace(/^http/, "ws")}/ws/board/${id}`,
keepAlive: { send: "ping", ignore: "pong" },
bufferUntilSeed: true,
});
// island onMount:
const off = board.subscribe(boardId, (patch) => applyPatch(ctx, patch));
ctx.cleanup(off);
const snapshot = await getBoard(boardId);
board.seed(boardId); // flush anything that arrived during the loadHow it works
Three layers, one createApp
You only ever touch Layer 3: a single createApp call. Defaults are the isomorphic plugins that run unchanged on Node and in the browser. The node-only plugins are exported but not defaults — you add them for a build. You never import from @moku-labs/core directly.
flowchart TB
A["Your app — createApp({ plugins, pluginConfigs })"]:::l3
ISO["<b>Isomorphic defaults</b> — run on Node + browser<br/>site · i18n · router · head · spa<br/>+ log · env (core)"]:::l2
NODE["<b>Opt-in</b> — composed per target<br/>content · build · deploy · cli (node-only)<br/>data (isomorphic, optional)"]:::l2
CORE["@moku-labs/core micro-kernel<br/>4-level config cascade · events · lifecycle"]:::l1
A --> ISO
A -. "add for an SSG build" .-> NODE
ISO --> CORE
NODE --> CORE
classDef l3 fill:#0b7285,stroke:#08525f,color:#fff;
classDef l2 fill:#1864ab,stroke:#0d3d6e,color:#fff;
classDef l1 fill:#343a40,stroke:#111,color:#fff;The same render runs in both places
build calls each route's load() then render() to emit static HTML, and (in spa/hybrid mode) data.write() persists that page's load() output as a JSON sidecar. On a client navigation, data.at() fetches that JSON and spa re-runs the route's own render from it. One render function, two runtimes — parity is structural.
flowchart LR
subgraph BUILD["① Build time · Node"]
direction TB
L1["load(ctx) → data"] --> R1["render(data) → static HTML"]
L1 --> W1["data.write → JSON sidecar (one per page URL)"]
end
subgraph CLIENT["② Client · browser"]
direction TB
N2["in-app navigation"] --> A2["data.at(path) → JSON"] --> R2["render(data) → live DOM"]
end
R1 -. "same render() — no second code path" .-> R2The /browser entry is guaranteed node-free
There are two entry points, and the difference is a hard guarantee, not a tree-shaking hope:
| Entry | Format | For | Includes |
|---|---|---|---|
| @moku-labs/web | dual ESM + CJS | Node SSG builds | the full surface — add content / build / deploy / cli and wire dotenv() / processEnv() |
| @moku-labs/web/browser | ESM-only | client bundles | the same createApp over the same isomorphic defaults, plus dataPlugin, the route DSL, createIsland, browserEnv, and the SEO head primitives — with all node-only code excluded (build / deploy / cli and the dotenv / processEnv / fileSystemContent providers), and browserEnv() pre-wired as the default env provider |
Importing @moku-labs/web/browser can never drag node/native code into a client bundle, regardless of bundler or tree-shaking — its static import graph references zero node-only modules. This is stronger and more reliable than importing the main entry and relying on "sideEffects": false, where building entries together can merge node code into a shared chunk. (The browser entry keeps the contentPlugin shell so build-only loaders can ctx.require(contentPlugin); the node Markdown source lives in fileSystemContent, which the entry does not export.) CI proves it:
bun run check:bundle # asserts: zero static node/native imports + under the gzip budgetThe route is the contract
A route is a fluent builder. Each step is optional except render, and every type flows from it:
route("/{lang:?}/{slug}/") // path-params are inferred → ctx.params.{lang, slug}
.generate((ctx) => Params[]) // build-time: which concrete pages to emit
.load((ctx) => Data) // build-only: ctx.require / ctx.has pull sibling plugins; widens ctx.data
.render((ctx) => VNode) // build AND client: ctx.url builds links; ctx.data is typed from .load()
.head((ctx) => HeadConfig) // SEO <head>: ctx.url + ctx.data availablectx.params— inferred from the pattern.{seg}is required,{seg:?}is optional (used here for an optional locale prefix).ctx.require(plugin)/ctx.has(plugin)— pull a sibling plugin's API the spec way (instance-only, no module globals). Available inload/generate.ctx.url(name, params)— type-safe link builder. Available inrender/head.ctx.data— typed from.load()'s return. On a client nav the fetched JSON isctx.datadirectly (no validation step); a missing or malformed sidecar simply falls back to HTML-over-fetch. Omit.load()for a static page —buildstill emits an empty{}sidecar so hybrid nav resolves cleanly.
Register routes declaratively via pluginConfigs.router.routes — the single source of truth, compiled once at init.
SEO <head> primitives are exported for .head() handlers: meta, og, twitter, jsonLd, canonical, hreflang, feedLink, and buildArticleHead.
Plugins
Each plugin is small, single-purpose, and documented on its own. Click a name for its README — config, full API, and design notes.
| Plugin | Kind | Responsibility |
|---|---|---|
| site | isomorphic default | Global, frozen site identity (name, URL, author) + canonical URL helper |
| i18n | isomorphic default | Locale registry, default-locale fallback, translations, og:locale map |
| router | isomorphic default | Type-safe route() DSL, path-param inference, matcher, URL/file derivation, mode() |
| head | isomorphic default | <head> composition: title template, canonical, OG/Twitter, JSON-LD, hreflang, feeds |
| spa | isomorphic default | Client runtime: island hydration + intercepted navigation + progress bar (inert on Node) |
| content | node-only | Markdown → sanitized HTML pipeline, frontmatter, reading time, locale-keyed Article model |
| build | node-only | SSG orchestrator: pages, feeds (RSS/Atom/JSON), sitemap, OG images → dist/ |
| deploy | node-only | Cloudflare Pages: wrangler.jsonc scaffolding, secret scrubbing, deploy |
| cli | node-only | Developer CLI — build / serve / preview / deploy with the animated Velocity Panel UI (lockup + version banner, live phase tree, boxed panels, live pulse) |
| data | optional provider | Agnostic page path → JSON contract: write() on Node, at() in the browser, for DATA nav |
| env | core | Multi-provider environment / secret injection, validated and frozen at onInit |
| log | core | Structured logging + an in-memory trace with an expect() DSL for testable workflows |
Rendering modes
One global switch — config.mode (default hybrid), read by plugins via router.mode() — decides how much of the SPA machinery runs.
| Mode | Build emits | Client behavior | Use for |
|---|---|---|---|
| ssg | HTML only | static pages (full reload on nav) | content sites, docs, marketing |
| spa | HTML + JSON sidecars | DATA navigation: fetch JSON, re-render in place | app-like sites |
| hybrid | HTML + JSON sidecars | DATA nav where available, else HTML-over-fetch | best of both |
[!TIP] Compose
dataPluginon both sides for DATA navigation — on Node sobuildcan write sidecars, and on the browser entry sospacan read them. Omit it for a plain static site. It has no hard dependencies and tree-shakes away when unused.
Scripts
bun run build # build with tsdown (dual ESM+CJS + ESM-only browser entry)
bun run test # all tests (vitest)
bun run test:unit # unit tests only
bun run test:integration # integration tests only
bun run test:coverage # tests with coverage (85% threshold)
bun run lint # biome check + eslint
bun run lint:fix # auto-fix lint issues
bun run format # format with biome
bun run validate # publint — verify package export map
bun run check:bundle # assert the browser bundle is node-free + under the gzip budgetRequirements
- Node
>= 24— the engines floor declared inpackage.json. (The route matcher is nativeRegExp— noURLPatternglobal needed, in Node or in any browser.) - Bun
>= 1.3.14— the package manager and test runner. Usebunexclusively (never npm/yarn/pnpm). - TypeScript in strict mode, with
exactOptionalPropertyTypesandnoUncheckedIndexedAccess.
Docs
- Per-plugin internals — the linked READMEs in the Plugins table.
- Architecture & specifications — the @moku-labs/core specification.
- For AI agents / LLM codegen —
llms.txt(concise) andllms-full.txt(complete), plus the Moku Claude toolkit described inCLAUDE.md. - Contributing — see
CLAUDE.mdfor code style, the three-layer model, and the test layout.
