# Three layers Source: https://moku-labs.com/docs/concepts/three-layers/ · @moku-labs/core@1.7.6 > createCoreConfig, createCore, createApp. Who writes what. ## The factory chain Moku is a micro-kernel with three layers. Each layer constrains the one above it, and each step of the chain captures its types in a closure, so the next step stays typed with no explicit generics. ```text createCoreConfig(id, { config, plugins? }) Step 1: capture Config, Events, core plugins → { createPlugin, createCore } createCore(coreConfig, { plugins, ... }) Step 2: capture the default plugins → { createApp, createPlugin } createApp({ plugins?, config?, ... }) Step 3: merge the app's extras, run init → App (frozen, plugin APIs mounted by name) ``` | Layer | Who writes it | Can | Cannot | | --- | --- | --- | --- | | 1. `@moku-labs/core` | the kernel | lifecycle, registry, events, config, freeze | know anything about a domain | | 2. Framework, such as `@moku-labs/web` | the framework author | define the config shape, events, default plugins | change the kernel | | 3. Your app | you, or an agent | configure, compose plugins, call typed APIs | change the framework or bypass a plugin | ## Layer 1: createCoreConfig Pure machinery. The framework names itself, declares its global config shape and its event map, and lists the core plugins that land on every `ctx`. `@moku-labs/web` calls it with the id `web`, which is why every kernel error in this framework starts with `[web]`. ## Layer 2: createCore The framework fixes its default plugins. An app can configure them but never remove or reorder them. `@moku-labs/web` ships `site`, `i18n`, `router`, `head` and `spa` as defaults and exports `createApp` and `createPlugin` to you. | Option | Purpose | | --- | --- | | `plugins` | Default plugins, always loaded. | | `pluginConfigs` | Framework-level overrides. | | `onReady` | Runs after every `onInit`. | | `onError` | Receives hook dispatch failures. Lifecycle errors propagate instead. | ## Layer 3: createApp This is the layer you write. Everything comes from the framework: `createApp`, `createPlugin` and the plugin instances. ```ts import { createApp, createPlugin } from "@moku-labs/web"; const greetingPlugin = createPlugin("greeting", { config: { name: "world" }, api: ctx => ({ hello: () => `Hello, ${ctx.config.name}!` }) }); const app = createApp({ plugins: [greetingPlugin], config: { mode: "ssg" }, pluginConfigs: { site: { name: "My Site", url: "https://example.com" }, greeting: { name: "Moku" } } }); console.log(app.greeting.hello()); // "Hello, Moku!" ``` 1. Added after the framework's defaults. 2. Per plugin, by name. Checked against the plugin's config type. The final plugin list is `[...frameworkDefaults, ...yourPlugins]`. `createApp` is synchronous: init runs before it returns. ## Rules - **An app never imports `@moku-labs/core`.** It imports `createApp`, `createPlugin` and plugins from its framework. - **Config is shallow.** `{ ...defaults, ...overrides }`. A nested object is replaced as a whole, so keep config flat. - **Everything is frozen.** The global config, each plugin config and the app. Only `ctx.state` is mutable. # Plugins Source: https://moku-labs.com/docs/concepts/plugins/ · @moku-labs/core@1.7.6 > Every PluginSpec field, context tiers, core plugins. ## PluginSpec Every field is optional. A plugin with only `api` works; a plugin with only `hooks` works. ```ts createPlugin("name", { config: { /* defaults, type C */ }, events: register => ({ "name:event": register("description") }), depends: [otherPlugin], createState: ctx => ({ /* mutable state, type S */ }), api: ctx => ({ /* public methods, mounted on app.name */ }), hooks: ctx => ({ "event:name": payload => { /* handler */ } }), onInit: ctx => { /* all plugins exist; sync only */ }, onStart: async ctx => { /* app is running */ }, onStop: async ctx => { /* teardown, reverse order */ }, helpers: { /* static pure functions, no ctx */ } }); ``` | Field | Rule | | --- | --- | | `config` | Defaults. Shallow-merged with framework and app overrides, then frozen. | | `events` | Declared with the register callback. Compile-time only. | | `depends` | Plugins that must appear earlier in the list. Validation and typing only, no sorting. | | `createState` | Called once with `{ global, config }`. Returns the only mutable store. | | `api` | Synchronous, no side effects. Mounted at `app.`. | | `hooks` | Event handlers with the full `ctx`. Payloads are typed from the merged event map. | | `onInit`, `onStart`, `onStop` | Lifecycle. See [Lifecycle](/docs/concepts/lifecycle/). | | `helpers` | Pure functions on the instance, usable before `createApp`. | Types are inferred from the spec. `createPlugin` never takes explicit generics. ## A plugin with state and a dependency ```ts import { createApp, createPlugin } from "@moku-labs/web"; const clockPlugin = createPlugin("clock", { api: () => ({ now: () => Date.now() }) }); const counterPlugin = createPlugin("counter", { depends: [clockPlugin], config: { step: 1 }, createState: () => ({ count: 0, lastAt: 0 }), api: ctx => ({ increment: () => { ctx.state.count += ctx.config.step; ctx.state.lastAt = ctx.require(clockPlugin).now(); }, value: () => ctx.state.count }) }); const app = createApp({ plugins: [clockPlugin, counterPlugin], pluginConfigs: { site: { name: "Demo", url: "https://example.com" }, counter: { step: 2 } } }); app.counter.increment(); console.log(app.counter.value()); // 2 ``` 1. Must come earlier in the plugins list. 2. require takes the instance, not a string. 3. A closure over state, never the state object itself. ## Context tiers The kernel hands each factory a context sized to its phase. | Phase | `ctx` has | | --- | --- | | `createState` | `{ global, config }` | | `api`, `hooks`, `onInit`, `onStart` | `{ global, config, state, emit, require, has }` plus core APIs such as `ctx.log` | | `onStop` | `{ global, config, state }`: own data only, peers may already be stopped | The type system enforces every restriction. Calling `ctx.emit` in `createState` is a compile error, not a runtime surprise. ## Talking to other plugins `ctx.require(plugin)` takes an instance and returns its typed API, or throws. `ctx.has(name)` is a string check that never throws. Use `require` for request and response: events do not return values. ## Core plugins Infrastructure such as `log` and `env`. Their context is `{ config, state }` only. They cannot have `depends`, `events` or `hooks`, and their APIs land flat on every regular plugin's `ctx`, not on `app`. They init and start first, and stop last. In `@moku-labs/web` they are `log` and `env`, so every plugin can call `ctx.log.info(...)`. ## Anti-patterns - **Leaking state.** Return `value: () => ctx.state.count`, never `{ state: ctx.state }`. - **God plugins.** One plugin per domain concern. Keep `index.ts` near 30 lines; logic goes in `api.ts` and its siblings. - **`emit` for request and response.** Use `require(plugin)` to get a return value. - **Inventing primitives.** No `class XService`, no `app.registerService()`. Write a plugin. # Lifecycle Source: https://moku-labs.com/docs/concepts/lifecycle/ · @moku-labs/core@1.7.6 > init, start, stop. Order, async model, failures. ## Three phases | Phase | Method | Direction | When | Context | | --- | --- | --- | --- | --- | | init | `onInit` | forward | inside `createApp()`, synchronous | full `ctx` | | start | `onStart` | forward | `await app.start()` | full `ctx` | | stop | `onStop` | **reverse** | `await app.stop()` | own `global`, `config`, `state` | Before the lifecycle, each plugin's factories run in this order: 1. `createState` 2. `hooks` 3. `api` 4. `onInit` 5. `onStart` 6. `onStop` ## Async model One plugin at a time, fully awaited. There is no parallelism inside or across phases: plugin A resolves before plugin B begins. `createApp()` is synchronous; `start()` and `stop()` return promises. ```ts import { createApp, createPlugin } from "@moku-labs/web"; const timerPlugin = createPlugin("timer", { createState: (): { handle?: ReturnType } => ({}), onStart: ctx => { ctx.state.handle = setInterval(() => ctx.log.info("timer:tick"), 1000); }, onStop: ctx => { clearInterval(ctx.state.handle); } }); const app = createApp({ plugins: [timerPlugin], pluginConfigs: { site: { name: "Demo", url: "https://example.com" } } }); await app.start(); // onStart, forward, one plugin at a time await app.stop(); // onStop, reverse: infrastructure outlives the plugins that use it ``` 1. Open a resource when the app starts. 2. onStop gets its own state back, nothing else. `onStart` and `onStop` are optional. Add them only when a plugin opens something it must close: a socket, a listener, a timer. ## Failures Lifecycle is not transactional. Errors propagate to the caller. The kernel does not roll back, retry or compensate. After a failure, discard the app and create a new one. `start()` throws when called twice, and `stop()` throws when `start()` never ran. The exact messages are on [What the kernel checks](/docs/concepts/kernel-checks/). ## Core plugins Core plugins have their own timeline: they init and start before regular plugins, and stop after them. `ctx.log` therefore works in every `onStop` of a regular plugin's teardown chain, and the logger is the last thing to go. ## In @moku-labs/web Most web plugins only use `onInit`: `router` compiles its table, `site` and `i18n` validate their config. `spa` is the one default with `onStart` and `onStop`, because it owns browser listeners. # Events Source: https://moku-labs.com/docs/concepts/events/ · @moku-labs/core@1.7.6 > Strict emit, the register callback, who sees what. ## Strict emit Events are notifications: fire-and-forget, typed, no return value. Only known event names compile, and the payload must match. ```ts ctx.emit("auth:login", { userId: "123" }); // OK ctx.emit("auth:login", { wrongKey: true }); // compile error: wrong payload ctx.emit("unknown:event", {}); // compile error: unknown event ``` Handlers for one event run in plugin order, each awaited before the next. A handler error goes to `onError`; it never stops the emitter. ## Declaring events A plugin declares its own events with the register callback. The payload type comes from `register()`, so `createPlugin` still takes no generics. ```ts import { createApp, createPlugin } from "@moku-labs/web"; const authPlugin = createPlugin("auth", { events: register => ({ "auth:login": register<{ userId: string }>("Triggered after user login") }), api: ctx => ({ login: (userId: string) => ctx.emit("auth:login", { userId }) }) }); const auditPlugin = createPlugin("audit", { depends: [authPlugin], createState: (): { entries: string[] } => ({ entries: [] }), hooks: ctx => ({ "auth:login": ({ userId }) => { ctx.state.entries.push(`login:${userId}`); } }), api: ctx => ({ entries: () => [...ctx.state.entries] }) }); const app = createApp({ plugins: [authPlugin, auditPlugin], pluginConfigs: { site: { name: "Demo", url: "https://example.com" } } }); app.auth.login("ada"); ``` 1. The description is documentation; the type is the contract. 2. depends makes auth's events visible to audit's hooks. With a separate event-map type, use `register.map`: ```ts type AuthEvents = { "auth:login": { userId: string }; "auth:logout": { userId: string } }; events: register => register.map({ "auth:login": "Triggered after user login", "auth:logout": "Triggered after user logout" }); ``` ## Who sees which events | Source | Declared in | Visible to | | --- | --- | --- | | Global events | the framework's `createCoreConfig` | all plugins | | Own events | `events: register => ({ ... })` on the plugin | this plugin | | Dependency events | `events` on plugins listed in `depends` | plugins that declare the dependency | ## Events in @moku-labs/web | Event | Emitted by | | --- | --- | | `build:phase`, `build:complete` | `build` | | `content:ready`, `content:invalidated` | `content` | | `deploy:complete` | `deploy` | | `spa:navigate`, `spa:navigated`, `spa:island-mount`, `spa:island-unmount` | `spa` | > **Warning** > > Use `require`, not `emit`, for request and response. Events do not return values. Declaring > `events` as a plain object instead of the register callback is refused when the plugin is created: > see [What the kernel checks](/docs/concepts/kernel-checks/). # What the kernel checks Source: https://moku-labs.com/docs/concepts/kernel-checks/ · @moku-labs/core@1.7.6 > Seven checks and the exact error for each. ## When checks run The kernel refuses a broken composition before any plugin code runs. Every failure is a thrown error in two lines: what is wrong, then how to fix it. The first line starts with the framework id in brackets. In `@moku-labs/web` that is `[web]`. 1. `createPlugin`: the plugin spec is checked, for example `events` must be a function. 2. `createApp`: names and dependencies are checked, before any `createState` or `onInit`. 3. `app.start()`: the app must not be started twice. | Phase | What is checked | | --- | --- | | `createPlugin` | The shape of the spec. | | `createApp` | Unique names, reserved names, core plugin names, every dependency present and earlier in the list. | | `start` and `stop` | `start()` once per app, `stop()` only after `start()`. | Type errors come first. Most mistakes on this page also fail `tsc`, so an editor shows them as you type. The runtime checks catch the rest: a list built at runtime, a cast, plain JavaScript. ## The checks Each message below is captured from the installed kernel when the site is built, by making the mistake for real. It is never typed by hand, so it cannot drift from what your app prints. - `duplicate-name` · checked at `createApp` ```text [web] Duplicate plugin name: "auth". Each plugin must have a unique name. ``` Fix: List each plugin once in createApp({ plugins }). - `missing-dependency` · checked at `createApp` ```text [web] Plugin "billing" depends on "auth", but "auth" is not registered. Add "auth" to your plugin list before "billing". ``` Fix: Add the plugin it depends on to the plugins list. - `dependency-order` · checked at `createApp` ```text [web] Plugin "billing" depends on "auth", but "auth" appears after "billing". Move "auth" before "billing" in your plugin list. ``` Fix: Put a dependency before the plugin that depends on it. - `reserved-name` · checked at `createApp` ```text [web] Plugin name "start" conflicts with a reserved app method. Choose a different plugin name. ``` Fix: Rename the plugin: start, stop, emit and require are app methods. - `core-plugin-clash` · checked at `createApp` ```text [web] Plugin name "log" conflicts with core plugin "log". Choose a different plugin name. ``` Fix: Rename the plugin: log and env are core plugins on every ctx. - `events-object` · checked at `createPlugin` ```text [web] Plugin "analytics" has invalid events: expected a function. Provide a function like: events: register => ({ "event:name": register() }) ``` Fix: Declare events with a function: events: register => ({ ... }). - `double-start` · checked at `start` ```text [web] App already started. start() can only be called once. ``` Fix: Call app.start() once per app. ## Why errors and not warnings An agent reads errors. It skims warnings. A startup error with the plugin name in it is the cheapest review step in the lifecycle, so the kernel makes it impossible to skip. # Claude plugin Source: https://moku-labs.com/docs/claude/ · Claude plugin@0.85.6 > A conductor in Claude Code: idea → plan → build → verify → release, on rails. ## Overview The `moku` plugin for Claude Code takes a project from an idea to a released package or a deployed app, and keeps leading after that. Every later feature, fix and tweak travels the same lifecycle. You never need a command. You talk; the conductor works out where the project stands, proposes the next step and runs it. Underneath, the rails make the critical steps impossible to skip: no code before the project is initialized, no plugin without a plan, no "done" with red tests. - **Talk in plain words.** The conductor drives intake, brainstorm, design, plan, build, verify, e2e, release and close. - **On rails.** A source file cannot be written before the station that is allowed to write it. A refused write names the missing step. - **Knowledge loads itself.** `moku-core`, `moku-plugin` and `moku-testing`, plus one pack per framework such as `moku-web`. ## Install In Claude Code, add the marketplace and install the core plugin: ```sh /plugin marketplace add moku-labs/claude /plugin install moku@moku ``` Then add the pack for each framework you use. Packs install the same way: ```sh /plugin install moku-web@moku /plugin install moku-design@moku ``` Requirements: Bun 1.3.14 or newer, Node 24 or newer, a current Claude Code. ## Stations After a project exists, everything you bring becomes a **change** with a size. The size picks the route. | Size | Sign | Route | | --- | --- | --- | | S | one plugin, public API unchanged | intake → build → verify → close | | M | new plugin, or an API changes | intake → plan → build → verify → e2e → release → close | | L | several plugins, a new framework, an architecture shift | intake → brainstorm → design → plan → build → verify → e2e → release → close | 1. intake 2. plan 3. build 4. verify 5. release Brainstorm, design, e2e and release are optional. Plan, build, verify and the closing checklist (tests green, verify passed, docs current) are not. > **Warning** > > The rails act only in a directory where a moku session was started. Your other projects get no > gate, no hint and no hook output. ## Packs | Pack | For | | --- | --- | | `moku-web` | `@moku-labs/web`, its validator, real-browser e2e with a UX gate | | `moku-design` | the design station and Astra, the artist and second opinion on UX | | `moku-worker` | `@moku-labs/worker`, the Cloudflare Workers backend | | `moku-room` | `@moku-labs/room`, couch multiplayer | | `moku-game` | `@moku-labs/game` and `@moku-labs/editor`, with the playtest station | | `moku-ai` | `@moku-labs/ai`, AI asset builds with keys, providers and cost estimates | | `moku-native` | `@moku-labs/native`, Tauri 2 desktop and mobile builds | | `moku-system` | `@moku-labs/system`, store, notify, clipboard, tray and deep links | | `moku-common` | `@moku-labs/common`, log, env and the branded CLI kit | ## Start with Claude Describe what you want. The conductor picks the packages. ```text Build a blog with tags and an RSS feed on @moku-labs/web ``` ```text Add a D1-backed comments API with @moku-labs/worker ``` ```text Make a couch quiz game: TV shows questions, phones answer ``` ```text Ship this app to macOS and iOS ``` # @moku-labs/core Source: https://moku-labs.com/docs/core/ · @moku-labs/core@1.7.6 > Micro-kernel plugin framework. Under 8 KB, zero deps. The kernel says no. ## Overview Moku Core is the micro-kernel under every Moku framework. Two runtime exports, a bundle under 8 KB gzipped, zero dependencies. The type system does the heavy lifting. It enforces three layers. The kernel is pure machinery. A framework defines the config shape, the event contract and the default plugins. An app only configures, composes plugins and calls typed APIs. Code written in a plugin cannot break the framework, and app code cannot break a plugin. - **Each plugin owns its parts.** Config, state, API and events, resolved by the kernel. - **Explicit dependencies.** `depends: [otherPlugin]`, validated when the app is created. - **Strict emit.** Only known event names compile. A wrong payload is a type error. > **Note** > > You build an app on a framework such as `@moku-labs/web`, never on the core directly. Install the > core when you write a framework of your own. ## Install ```sh bun add @moku-labs/core ``` ## Quick start A framework and an app in one file: `createCoreConfig` defines the framework, `createCore` fixes its default plugins, `createApp` is what the app calls. ```ts import { createCoreConfig } from "@moku-labs/core"; type Config = { siteName: string }; type Events = { "router:navigate": { from: string; to: string } }; const coreConfig = createCoreConfig("my-site", { config: { siteName: "Untitled" } }); const routerPlugin = coreConfig.createPlugin("router", { config: { basePath: "/" }, createState: () => ({ currentPath: "/" }), api: ctx => ({ navigate: (path: string) => { const from = ctx.state.currentPath; ctx.state.currentPath = path; ctx.emit("router:navigate", { from, to: path }); }, current: () => ctx.state.currentPath }) }); const framework = coreConfig.createCore(coreConfig, { plugins: [routerPlugin] }); const app = framework.createApp({ config: { siteName: "My Blog" }, pluginConfigs: { router: { basePath: "/blog" } } }); await app.start(); app.router.navigate("/about"); await app.stop(); ``` 1. "my-site" prefixes every kernel error. 2. Fully typed: the API comes from the plugin spec. The chain is the whole API: `createCoreConfig` → `createCore` → `createApp`. The second runtime export, `createCorePlugin`, builds infrastructure plugins such as `log` and `env`. ## Start with Claude Describe what you want. The conductor picks the packages. ```text Build a blog with tags and an RSS feed on @moku-labs/web ``` ```text Add a D1-backed comments API with @moku-labs/worker ``` ```text Make a couch quiz game: TV shows questions, phones answer ``` ```text Ship this app to macOS and iOS ``` # @moku-labs/common Source: https://moku-labs.com/docs/common/ · @moku-labs/common@0.3.10 > Shared plugins: log, env, branded CLI. ## Overview `@moku-labs/common` is a catalog of shared plugins for the Moku family. You author a plugin once and reuse it in every framework. It has no `createApp` and no defaults of its own: you register its plugins in your framework's `createCoreConfig`. - **Write once, reuse everywhere.** Logging and env live here, not in every framework. - **Core plugins.** Built with `createCorePlugin`, so their APIs land on every plugin's context as `ctx.log` and `ctx.env`. - **A node-free browser entry.** `@moku-labs/common/browser` never pulls a `node:*` module into a client bundle. A CI gate proves it. | Export | What it does | | --- | --- | | `logPlugin` | In-memory trace and an `expect()` DSL for testable workflows. Injected as `ctx.log`. | | `envPlugin` | Validated, frozen environment from several providers. Injected as `ctx.env`. | | `dotenv`, `processEnv`, `cloudflareBindings` | Env providers for Node and Workers. | | `browserEnv` | Env provider for the browser, from `@moku-labs/common/browser`. | | `createBrandConsole`, `createBrandPrompts` | The branded terminal kit, from `@moku-labs/common/cli`. | ## Install ```sh bun add @moku-labs/common @moku-labs/core ``` ## Quick start Register the plugins in your framework's `createCoreConfig`. Every plugin then gets `ctx.log` and `ctx.env`. ```ts import { envPlugin, logPlugin } from "@moku-labs/common"; import { createCoreConfig } from "@moku-labs/core"; type Config = { stage: "production" | "development" }; type Events = {}; export const coreConfig = createCoreConfig( "my-framework", { config: { stage: "production" }, plugins: [logPlugin, envPlugin] } ); export const greeterPlugin = coreConfig.createPlugin("greeter", { api: ctx => ({ greet: (name: string) => ctx.log.info("greeter:greet", { name }) }) }); ``` 1. ctx.log and ctx.env on every plugin. Pick the env provider that matches each target: ```ts import { dotenv, processEnv } from "@moku-labs/common"; // Node import { browserEnv } from "@moku-labs/common/browser"; // browser, node-free ``` ## Start with Claude Describe what you want. The conductor picks the packages. ```text Build a blog with tags and an RSS feed on @moku-labs/web ``` ```text Add a D1-backed comments API with @moku-labs/worker ``` ```text Make a couch quiz game: TV shows questions, phones answer ``` ```text Ship this app to macOS and iOS ``` # @moku-labs/web Source: https://moku-labs.com/docs/web/ · @moku-labs/web@2.4.11 > Content static-site generator and SPA framework. Same render at build and in the browser. ## What it is `@moku-labs/web` renders Preact pages to static HTML for search engines and first paint. Then it hydrates islands and, if you want it, navigates on the client from JSON. The same `render` function runs at build and in the browser, so the two can never disagree. It is a Moku framework: the `@moku-labs/core` kernel plus a list of plugins. You write one `createApp` call. The browser-safe plugins are always there. You add the Node-only plugins for the build. ### Five things it does - **[SSG and SPA parity](/docs/web/how-it-works/).** One route, two runtimes. The build writes HTML, the browser re-runs the same `render` with the saved data. - **[A typed route](/docs/web/guides/routing/).** `route("/{slug}/").load().render().head()`. Params come from the pattern, `ctx.data` from `load`. - **[Full SEO](/docs/web/guides/head/).** Title template, canonical, Open Graph, Twitter, JSON-LD, hreflang, feeds, sitemap and OG images. - **[A node-free browser entry](/docs/web/api/).** `@moku-labs/web/browser` has zero Node imports in its graph. About 50 kB gzip. - **[Islands](/docs/web/guides/islands/).** Only the interactive parts run in the browser, each with its own state, render, events and API. ## Quick start 1. **Install** the framework and its peers: `bun add @moku-labs/web @moku-labs/core @moku-labs/common preact preact-render-to-string`. 2. **Write the routes** with `route()`: a pattern, a `load`, a `render`, a `head`. 3. **Compose the app** with `createApp`, adding `contentPlugin` and `buildPlugin` for the build. 4. **Build** with `await app.build.run()`. The site is in `dist/`. A whole static blog fits in two files: ```tsx import { defineRoutes, route } from "@moku-labs/web/browser"; export const routes = defineRoutes({ home: route("/") .render(() =>

My Blog

) .head(() => ({ title: "My Blog" })), about: route("/about/") .render(() =>

Notes on the web.

) .head(() => ({ title: "About" })) }); ``` 1. `.head()` feeds the title template, canonical and OG tags. The [quick start](/docs/web/quick-start/) goes from an empty folder to a deployed site. ## Two entry points | Entry | Format | Use it for | |---|---|---| | `@moku-labs/web` | ESM and CJS | The Node build. Everything, including `buildPlugin`, `deployPlugin`, `cliPlugin`, `fileSystemContent` and the Node env providers. | | `@moku-labs/web/browser` | ESM only | The client bundle. The same `createApp` over the same defaults, plus `dataPlugin`, the route helpers, `createIsland` and the head helpers. `env` reads `browserEnv()` with no config. | ## The eleven plugins The defaults are loaded by every `createApp`. You can only configure them. The others you add in `plugins: [...]`. | Plugin | Kind | What it does | |---|---|---| | [site](/docs/web/plugins/site/) | default | Site name, URL, author, description, canonical URLs. | | [i18n](/docs/web/plugins/i18n/) | default | Locales, default locale, translations, `og:locale` map. | | [router](/docs/web/plugins/router/) | default | The route table, the matcher, URL and file paths, the render mode. | | [head](/docs/web/plugins/head/) | default | The `` of every page. | | [spa](/docs/web/plugins/spa/) | default | Islands, intercepted navigation, progress bar. Inert on Node. | | [content](/docs/web/plugins/content/) | Node build | Markdown articles to HTML, with locale fallback. | | [collection](/docs/web/plugins/collection/) | optional | `(collection, shard)` → JSON file, read on demand. | | [data](/docs/web/plugins/data/) | optional | Page path → JSON file, for client navigation. | | [build](/docs/web/plugins/build/) | Node-only | The static build into `dist/`. | | [deploy](/docs/web/plugins/deploy/) | Node-only | Cloudflare Pages through wrangler. | | [cli](/docs/web/plugins/cli/) | Node-only | `build`, `serve`, `preview`, `deploy` with the branded panel. | Two core plugins come with every app: `log` and `env`, from `@moku-labs/common`. ## Start with the Claude plugin Install the moku plugin in Claude Code and describe the site in plain words. It knows this framework, writes the routes, the islands and the tests, and runs the build. ```sh $ /plugin marketplace add moku-labs/claude $ /plugin install moku@moku-labs ``` ```text Build a blog with tags and an RSS feed on @moku-labs/web ``` ```text Add a search box that reads a JSON index built at deploy time ``` ## Where to next - [Quick start](/docs/web/quick-start/): a static blog in two files, client navigation in a third. - [How it works](/docs/web/how-it-works/): one route, two places. - [Routing](/docs/web/guides/routing/): `route().load().render().head()`, the single contract. - [Islands](/docs/web/guides/islands/): the parts that run in the browser. - [API reference](/docs/web/api/): every export of both entries. # Quick start Source: https://moku-labs.com/docs/web/quick-start/ · @moku-labs/web@2.4.11 > From an empty folder to a deployed static blog with client navigation. ## A static blog in eight steps 1. **Create the project and install.** `preact`, `preact-render-to-string`, `@moku-labs/core` and `@moku-labs/common` are peer dependencies. Declare them yourself: a second copy of Preact breaks hooks and island hydration. ```sh $ mkdir my-blog && cd my-blog $ bun init -y $ bun add @moku-labs/web @moku-labs/core @moku-labs/common preact preact-render-to-string ``` 2. **Point JSX at Preact.** Routes and islands are `.tsx`. Without these two lines Bun looks for React and the build fails. ```json { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact", "module": "Preserve", "moduleResolution": "bundler", "strict": true } } ``` 3. **Add content.** One folder per article, one file per locale. The frontmatter fields `title`, `date`, `description`, `tags` and `language` are required. ```md --- title: Hello world date: 2026-01-15 description: The first post. tags: [intro] language: en --- Some text. ``` 4. **Write the routes.** A route is a pattern plus `generate`, `load`, `render` and `head`. `generate` and `load` run at build only. `render` and `head` run at build and in the browser. 5. **Compose the app.** The defaults (`site`, `i18n`, `router`, `head`, `spa`) are always there. Add the Node-only plugins the build needs. 6. **Add one script per command.** The `cli` plugin has no argument parser. Each script calls one method. 7. **Build.** `bun run build` writes static HTML, feeds, a sitemap and the JSON sidecars into `dist/`. 8. **Deploy.** `bun run deploy` runs a guided wizard: it checks the Cloudflare credentials, offers to scaffold `wrangler.jsonc`, test-builds and ships `dist/` to Cloudflare Pages. ## The code The routes import from `@moku-labs/web/browser`, because the client bundle imports this file too. The app imports from `@moku-labs/web`, the Node entry. ```tsx import { contentPlugin, defineRoutes, route } from "@moku-labs/web/browser"; export const routes = defineRoutes({ home: route("/{lang:?}/") .generate(ctx => [{ lang: ctx.locale }]) .load(async ctx => { const content = ctx.require(contentPlugin); const byLocale = await content.loadAll(); return (byLocale.get(ctx.locale) ?? []).map(article => content.articleToCard(article)); }) .render(ctx => ( )) .head(() => ({ title: "Home" })), post: route("/{lang:?}/{slug}/") .generate(async ctx => { const byLocale = await ctx.require(contentPlugin).loadAll(); return (byLocale.get(ctx.locale) ?? []).map(article => ({ lang: ctx.locale, slug: article.computed.slug })); }) .load(ctx => ctx.require(contentPlugin).load(ctx.params.slug, ctx.locale)) .render(ctx =>
) .head(ctx => ({ title: ctx.data.frontmatter.title, description: ctx.data.frontmatter.description })) }); ``` 1. One home page per locale. Without `generate` only the default locale gets one. 2. Sibling plugins come through `ctx.require`. The return type becomes `ctx.data`. ```ts import { buildPlugin, cliPlugin, contentPlugin, createApp, dataPlugin, deployPlugin, dotenv, fileSystemContent, processEnv } from "@moku-labs/web"; import { routes } from "./routes"; export const app = createApp({ plugins: [contentPlugin, dataPlugin, buildPlugin, deployPlugin, cliPlugin], config: { mode: "hybrid" }, pluginConfigs: { site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." }, i18n: { locales: ["en", "uk"], defaultLocale: "en" }, router: { routes }, head: { titleTemplate: "%s · My Blog" }, content: { providers: [fileSystemContent({ contentDir: "content" })] }, build: { clientEntry: "src/client.tsx", notFound: true }, env: { providers: [processEnv(), dotenv(".env")] } } }); ``` 1. Node-only plugins are opt-in. `dataPlugin` makes the build write the JSON sidecars. 2. `notFound: true` writes `dist/404.html`. `cli.build()` fails without it. ```ts import { app } from "../src/app"; await app.cli.build(); ``` ```json { "scripts": { "build": "bun scripts/build.ts", "dev": "bun scripts/serve.ts", "preview": "bun scripts/preview.ts", "deploy": "bun scripts/deploy.ts" } } ``` `scripts/serve.ts` and `scripts/preview.ts` are the same two lines with `app.cli.serve()` and `app.cli.preview()`. `scripts/deploy.ts` turns on the guided wizard unless `--cli` is passed: ```ts import { app } from "../src/app"; await app.cli.deploy({ guided: !process.argv.includes("--cli") }); ``` ## Build ```sh $ bun run build ▟▙ moku web build v2.4.11 ✓ bundle · 5ms ✓ images · 0ms ✓ content · 1239ms ✓ pages · 5ms ✓ content-images · 0ms ✓ not-found · 1ms ✓ feeds · 2ms ✓ sitemap · 1ms ✓ cache-headers · 0ms ✓ root-index · 0ms ✓ BUILD · 9 pages 1252ms · ./dist/ ``` Nine pages from two articles, `hello-world` in English and Ukrainian and `second-post` in English only: the English pages at bare paths, a copy of each under `/en/` with a canonical link to the bare URL, and the Ukrainian pages under `/uk/`. The second article has no `uk.md`, so `/uk/second-post/` shows the English text. ## Add client navigation The browser entry has the same `createApp` over the same defaults, with every Node-only module left out. Name it in `build.clientEntry` and the build bundles it into `dist/assets/`. ```tsx import { createApp, dataPlugin, defineRoutes, route } from "@moku-labs/web/browser"; const routes = defineRoutes({ home: route("/").render(() =>

My Blog

) }); const app = createApp({ plugins: [dataPlugin], config: { mode: "hybrid" }, pluginConfigs: { site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." }, router: { routes } } }); await app.start(); ``` 1. In your app, import the shared `./routes` here instead. 2. With `data` composed, a link click fetches the page JSON and runs the route's `render`. 3. Mounts islands on the static HTML and starts intercepting links. The browser app needs the same `site`, `i18n` and `router` config as the Node app. Put the shared values in one module and import it in both. ## Deploy Put the Cloudflare credentials in `.env` (keep it out of git) and in the repository secrets for CI. Then deploy. ```sh CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... ``` ```sh $ bun run deploy ▟▙ moku web deploy v2.4.11 ✓ DEPLOYED · main 9.4s → https://my-blog.pages.dev · 3f2a9c1e ``` The project name is the site name as a slug: `My Blog` deploys to the Pages project `my-blog`. See [Build & deploy](/docs/web/guides/deploy/) for the guided wizard and CI. - **`load` and `generate` run at build only.** The browser never calls them. On client navigation it reads the JSON the build saved. - **Import routes from `@moku-labs/web/browser`.** The client bundle imports the route file. A route file that imports `@moku-labs/web` drags Node code into the bundle and the build fails on a Node-only dependency. - **`site.name` and `site.url` are required.** `createApp` throws `[web] site.name is required.` when they are missing, in the Node app and in the browser app. - **`cli.build()` checks for `dist/404.html`.** Set `build.notFound`, or Cloudflare Pages treats the project as an SPA. ## Next - [How it works](/docs/web/how-it-works/): what the build writes and what the browser does with it. - [Routing](/docs/web/guides/routing/): patterns, builder methods, links. - [Islands](/docs/web/guides/islands/): make parts of a page interactive. - [Build & deploy](/docs/web/guides/deploy/): scripts, the dev server, Cloudflare Pages. # How it works Source: https://moku-labs.com/docs/web/how-it-works/ · @moku-labs/web@2.4.11 > One route, two places: the build renders HTML, the browser renders the saved data. ## The picture *Diagram: Build to SPA* 1. **Build.** Node runs every route: `generate` lists the pages, `load` fetches the data, `head` and `render` turn it into HTML. The kernel checked the plugin list before anything ran. 2. **Static HTML.** Each URL is a finished `index.html`. It works with JavaScript off, and it is what search engines see. 3. **Hydration.** `app.start()` in the browser mounts the islands onto that HTML. Nothing else is re-rendered. 4. **Data navigation.** A link click matches the route, fetches the page JSON with `data.at(path)` and runs the same `render` with it. ## The same render runs in both places A route is one object. The build and the browser read different parts of it. | Handler | Build (Node) | Browser | |---|---|---| | `.generate(ctx)` | lists the param sets to pre-render | never | | `.load(ctx)` | fetches the page data | never | | `.render(ctx)` | renders the HTML | renders the swap region on navigation | | `.head(ctx)` | composes `` | the head is copied from the fetched page or re-titled | In `spa` and `hybrid` mode the build saves what `load` returned next to the HTML, as JSON. The browser fetches that file and passes it to `render` as `ctx.data`. There is no second projection of the data and no second render function, so the HTML and the client view cannot drift apart. ## Where the data comes from ```text BUILD (mode !== "ssg") ON DISK CLIENT NAVIGATION for each page of each route: dist/hello-world/index.html click /second-post/ data = route.load(ctx) ──► (static HTML: SEO + first paint) 1. router.match(path) html = render(ctx) to string ──► 2. data.at(path) → fetch data.write([{ path, data }]) ──► dist/_data/hello-world/index.json /_data/second-post/index.json (the load() result, never HTML) 3. ctx.data = the JSON 4. route.render(ctx) → swap ``` The sidecar of a list page holds what its `load` returned, for example slim cards. A detail page holds one article. A route without `load` still gets an empty `{}` sidecar, so client navigation to it never falls back. ```sh $ ls dist dist/_data dist: 404.html _data _headers assets atom.xml en feed.json feed.xml hello-world index.html robots.txt second-post sitemap.xml uk dist/_data: en hello-world index.json second-post uk $ cat dist/_data/index.json [{"contentId":"en:0000:second-post","status":"published","title":"Second post",...}] ``` ## Rendering modes One global switch, `createApp({ config: { mode } })`, read by every plugin through `router.mode()`. | Mode | The build writes | The browser does | Use it for | |---|---|---|---| | `ssg` | HTML only | full page loads, or HTML-over-fetch when the `spa` plugin intercepts | docs, marketing, content sites | | `spa` | HTML and JSON sidecars | data navigation: fetch JSON, render in place | app-like sites | | `hybrid` | HTML and JSON sidecars | data navigation where a sidecar exists, else HTML-over-fetch | the default | Data navigation needs the `data` plugin on both sides: in the Node app so the build writes the sidecars, in the browser app so `spa` can read them. Without it, every navigation fetches the next page's HTML and swaps the `main > section` region. ## Two apps, one route table ```ts import { buildPlugin, contentPlugin, createApp, dataPlugin, fileSystemContent } from "@moku-labs/web"; import { routes } from "./routes"; export const app = createApp({ plugins: [contentPlugin, dataPlugin, buildPlugin], config: { mode: "hybrid" }, pluginConfigs: { site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." }, router: { routes }, content: { providers: [fileSystemContent({ contentDir: "content" })] } } }); ``` 1. The Node app imports the routes with their loaders. ```ts import { createApp, dataPlugin } from "@moku-labs/web/browser"; import { routes } from "./routes"; const app = createApp({ plugins: [dataPlugin], config: { mode: "hybrid" }, pluginConfigs: { site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." }, router: { routes } } }); await app.start(); ``` 1. The browser entry cannot pull in `node:fs`. 2. The same table. Its loaders ship but never run here. A complete app on the browser entry, in one file. It compiles as it is: ```tsx import { createApp, createIsland, defineRoutes, route } from "@moku-labs/web/browser"; const routes = defineRoutes({ home: route("/").render(() =>

Home

), about: route("/about/").render(() =>

About

) }); const clock = createIsland("clock", { onMount(ctx) { const timer = setInterval(() => { ctx.el.textContent = new Date().toLocaleTimeString(); }, 1000); ctx.cleanup(() => clearInterval(timer)); } }); const app = createApp({ config: { mode: "hybrid" }, pluginConfigs: { site: { name: "Demo", url: "https://demo.dev", author: "", description: "" }, router: { routes }, spa: { islands: [clock] } } }); await app.start(); ``` 1. Runs when the island is destroyed, for example on navigation. - **`load` runs at build only.** Do not expect it on navigation. The browser reads the JSON the build wrote. - **No HTML in the JSON.** The route owns rendering, `data` owns transport. A missing or broken sidecar falls back to fetching the HTML, then to a full page load. - **Use `@moku-labs/web/browser` for the client.** Its import graph has no Node module. Tree-shaking the root entry is not the same guarantee: bundling entries together can merge Node code into a shared chunk. - **The layout is not re-applied on the client.** `.layout()` frames the page at build. On navigation the chrome stays and only the swap region changes. ## Related - [Routing](/docs/web/guides/routing/): the route builder in full. - [Data](/docs/web/guides/data/): the sidecars and the `data` plugin. - [SPA navigation](/docs/web/guides/spa/): interception, transitions, scroll. - [spa plugin](/docs/web/plugins/spa/): config, API, events. # Routing Source: https://moku-labs.com/docs/web/guides/routing/ · @moku-labs/web@2.4.11 > route().load().render().head(): one typed builder is the contract for the build and the browser. ## How routing works A route is a URL pattern plus optional handlers. You write routes with `route()`, a plain function that runs before `createApp`. You register them in one place, `pluginConfigs.router.routes`. The router plugin compiles the table once, while `createApp` runs. At build, the `build` plugin walks the table: it expands each route into concrete pages with `generate`, loads their data, composes the head and renders the HTML. In the browser, `spa` matches the URL against the same table and runs `render` again with the saved data. - pattern - **generate** - **load** - **head** - **render** - index.html Types flow along the chain. `ctx.params` comes from the pattern, `ctx.data` from what `load` returns. There is no codegen and no cast. ## Patterns | Syntax | Meaning | Example | |---|---|---| | `{name}` | Required param | `/{slug}/` → `ctx.params.slug: string` | | `{name:?}` | Optional param. `toUrl` drops the segment when it is missing. | `/archive/{page:?}/` → `ctx.params.page?: string` | | `{lang:?}` | Locale slot. The default locale is served at bare paths. It does not count toward specificity. | `/{lang:?}/{slug}/` | When two patterns match a URL, the one with fewer dynamic segments wins. On a tie the route declared first wins. Matching uses a plain `RegExp`, so it works in every browser, including those without `URLPattern`. ## Define routes Three files: the route table, a component that builds links, and the app that registers the table. ```tsx import { contentPlugin, defineRoutes, route } from "@moku-labs/web/browser"; import type { Content } from "@moku-labs/web/browser"; function PostList(props: { cards: Content.ArticleCard[] }) { return ( ); } export const routes = defineRoutes({ home: route("/{lang:?}/") .generate(ctx => [{ lang: ctx.locale }]) .load(async ctx => { const content = ctx.require(contentPlugin); const byLocale = await content.loadAll(); return (byLocale.get(ctx.locale) ?? []).map(article => content.articleToCard(article)); }) .render(ctx => ) .head(() => ({ title: "Home" })), post: route("/{lang:?}/{slug}/") .generate(async ctx => { const byLocale = await ctx.require(contentPlugin).loadAll(); return (byLocale.get(ctx.locale) ?? []).map(article => ({ lang: ctx.locale, slug: article.computed.slug })); }) .load(ctx => ctx.require(contentPlugin).load(ctx.params.slug, ctx.locale)) .render(ctx =>
) .head(ctx => ({ title: ctx.data.frontmatter.title })) .meta({ section: "blog" }) }); ``` 1. `defineRoutes` keeps the literal type of each route for IntelliSense. 2. `generate` returns the param sets to pre-render, once per locale. 3. `load` gets `{ params, locale, require, has }`. Its return type is `ctx.data` below. 4. `meta` is a JSON bag, shipped to the browser in `clientManifest()`. ```tsx import type { Content } from "@moku-labs/web/browser"; type Url = (name: string, params?: Record) => string; export function PostLink(props: { card: Content.ArticleCard; slug: string; url: Url }) { return (

{props.card.title}

); } ``` 1. Inside `render` and `head`, `ctx.url(name, params)` builds links. Pass it down. ```ts import { buildPlugin, contentPlugin, createApp, fileSystemContent } from "@moku-labs/web"; import { routes } from "./routes"; export const app = createApp({ plugins: [contentPlugin, buildPlugin], config: { mode: "hybrid" }, pluginConfigs: { site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." }, router: { routes }, i18n: { locales: ["en", "uk"], defaultLocale: "en" }, content: { providers: [fileSystemContent({ contentDir: "content" })] } } }); ``` 1. The render mode is global config, not router config. 2. The only way to register routes. An `import * as routes` namespace works too. ## Load data A route without `load` is a static page: `ctx.data` is `unknown` and the build writes an empty `{}` sidecar. Add `load` and `ctx.data` takes its return type, in `render` and in `head`. ```diff import { contentPlugin, defineRoutes, route } from "@moku-labs/web/browser"; export const routes = defineRoutes({ post: route("/{slug}/") - .render(ctx =>

{ctx.params.slug}

) - .head(ctx => ({ title: ctx.params.slug })) + .load(ctx => ctx.require(contentPlugin).load(ctx.params.slug, ctx.locale)) + .render(ctx =>

{ctx.data.frontmatter.title}

) + .head(ctx => ({ title: ctx.data.frontmatter.title, description: ctx.data.frontmatter.description })) }); ``` `load` may be async. It runs inside the build plugin, so `ctx.require` reaches any plugin in the Node app, and `ctx.has(name)` tells you whether an optional one is there. ## Set the head `.head(ctx)` returns the page's title, description and extra elements. The [head plugin](/docs/web/plugins/head/) adds the title template, canonical, Open Graph, Twitter and hreflang tags around it. ```tsx import { defineRoutes, jsonLd, route } from "@moku-labs/web/browser"; export const routes = defineRoutes({ about: route("/about/") .render(() =>

About

) .head(ctx => ({ title: "About", description: "Who writes this blog.", image: "/og/about.png", elements: [jsonLd({ "@context": "https://schema.org", "@type": "AboutPage", url: ctx.url("about") })] })) }); ``` 1. Becomes `` through `head.titleTemplate`. 2. Relative images are made absolute against `site.url`. ## Dynamic paths A route with a dynamic segment renders one page per param set that `generate` returns. The build calls `generate` once per locale, default locale first. Two param sets that resolve to the same file are written once. | Route | `generate` | Pages written | |---|---|---| | `/{slug}/` | `[{ slug: "a" }, { slug: "b" }]` | `a/index.html`, `b/index.html` | | `/{lang:?}/` with `en`, `uk` | `[{ lang: ctx.locale }]` | `index.html`, `en/index.html`, `uk/index.html` | | `/{lang:?}/` with `en`, `uk` | none | `index.html` and `en/index.html` only | | `/b/{id}/` in `mode: "spa"` | none | none: a client-only route, rendered in the browser from the URL | `.toFile(params => path)` overrides where a page is written. The URL stays the pattern's URL. ## At build ```sh $ bun run build ▟▙ moku web build v2.4.11 ✓ bundle · 5ms ✓ content · 1239ms ✓ pages · 5ms ✓ sitemap · 1ms ✓ BUILD · 9 pages 1252ms · ./dist/ $ find dist -name index.html | sort dist/en/hello-world/index.html dist/en/index.html dist/en/second-post/index.html dist/hello-world/index.html dist/index.html dist/second-post/index.html dist/uk/hello-world/index.html dist/uk/index.html dist/uk/second-post/index.html ``` The English pages live at bare paths. The `/en/…` copies carry a canonical link to the bare URL, so an explicit `/en/` link works without a redirect. ## Builder methods | Method | Runs | What it does | |---|---|---| | `.generate(ctx => params[])` | build | Param sets to pre-render. `ctx = { locale, require, has }`. May be async. | | `.load(ctx => D)` | build | Page data. `ctx = { params, locale, require, has }`. Widens `ctx.data` to `D`. May be async. | | `.render(ctx => VNode)` | build and browser | The page. `ctx = { params, data, locale, meta, url }`. | | `.head(ctx => HeadConfig)` | build and browser | `{ title?, description?, canonical?, image?, elements? }`. Same `ctx` as `render`. | | `.layout((ctx, children) => VNode)` | build | Wraps the page in chrome. Not re-applied on client navigation. | | `.meta(record)` | | A JSON-serializable bag, readable as `ctx.meta` in `render`, `head`, `layout` and islands. | | `.transition(mode)` | browser | `"none"`, `"crossfade"`, `"slide"` or `"morph"` for navigations to this route. | | `.scroll(mode)` | browser | `"top"` or `"preserve"` for navigations to this route. | | `.toFile(params => path)` | build | Output file override. | | `.toJson(ctx => unknown)` | | Stores a JSON serializer on the route. No built-in plugin reads it in 2.4.11. | ## Links Inside `render` and `head`, `ctx.url(name, params)` builds a link. It gives the same result as `app.router.toUrl`. Outside a route, in components and islands, use `createUrls`: ```ts import { createUrls, defineRoutes, route } from "@moku-labs/web/browser"; const routes = defineRoutes({ post: route("/{lang:?}/{slug}/") }); export const urls = createUrls(routes, "en"); urls.toUrl("post", { lang: "en", slug: "hello" }); // "/hello/" urls.toUrl("post", { lang: "uk", slug: "hello" }); // "/uk/hello/" ``` 1. Pass the default locale. The helper is app-free and cannot read i18n. Without the second argument every locale keeps its prefix: `"/en/hello/"`. `toUrl` only accepts names from the map, and throws `[web] router: unknown route name "…".` on a name it does not know. ## RouterApi Mounted at `app.router` and reachable with `ctx.require(routerPlugin)`. | Method | Returns | |---|---| | `match(pathname)` | `{ params, route }` for the most specific match, or `null`. | | `toUrl(routeName, params)` | The URL. Throws on an unknown name. | | `entries()` | Every route as `{ pattern, name, meta, toUrl, toFile, match }`, most specific first. | | `manifest()` | The full definitions with handlers, in declaration order. The build reads this. | | `clientManifest()` | `{ pattern, name, meta }[]` with no functions, JSON-safe. | | `mode()` | `"ssg"`, `"spa"` or `"hybrid"`, from the global config. | - **`load` and `generate` never run in the browser.** The route file still ships to the client. Keep heavy Node-only code behind `ctx.require` in the loader, not in top-level imports of the route file. - **There is no runtime registration.** `pluginConfigs.router.routes` is the only path. Without it every router call throws `[web] router: routes not registered.` - **The mode is global.** Set `createApp({ config: { mode } })`. Router config has only `routes`. - **A `{lang:?}` route without `generate` gets one page.** The build expands it for the default locale only. Return `[{ lang: ctx.locale }]` to get a page per locale. - **Only `{lang:?}` matches without its segment.** `/archive/{page:?}/` matches `/archive/2/`, not `/archive/`. Declare a second route for the bare path. - **Patterns start with `/`.** `route("about/")` fails `createApp` with `[web] router: route "about" pattern must start with "/" (got "about/").` ## Related - [router plugin](/docs/web/plugins/router/): config, API, the matching model. - [Head & SEO](/docs/web/guides/head/): what `.head()` turns into. - [i18n](/docs/web/guides/i18n/): the `{lang:?}` slot and bare default-locale paths. - [SPA navigation](/docs/web/guides/spa/): `.transition()` and `.scroll()` in action. # Islands Source: https://moku-labs.com/docs/web/guides/islands/ · @moku-labs/web@2.4.11 > createIsland: state, render, events and an API for the parts of a page that run in the browser. ## How islands work An island is the part of a page that runs in the browser. The build renders the whole page to HTML, islands included. In the browser, `app.start()` finds every element with `data-island="name"` and mounts the island registered under that name onto it. An island outside the swap region (`spa.swapSelector`, `main > section` by default) is **persistent**: it survives navigation and hears `onNavStart` and `onNavEnd`. An island inside the swap region is **page-specific**: it is destroyed and created again with the page. `createIsland(name, spec)` takes a spec shaped like a plugin: | Key | What it is | |---|---| | `state(ctx)` | Builds the per-element state once, at mount. | | `render(state, ctx)` | Returns what the element shows. Runs after mount and after every `ctx.set`, batched. | | `events` | `"event selector"` → handler. One delegated listener per event type, removed on destroy. | | `api(ctx)` | A public API other islands reach with `ctx.island(name)`. | | `onCreate` … `onDestroy` | Lifecycle hooks. | Every key is optional. A spec with only hooks is fine. ## Example ```tsx import { createIsland } from "@moku-labs/web/browser"; type LikeState = { count: number; liked: boolean }; export const likeButton = createIsland("like-button", { state: (ctx): LikeState => ({ count: Number(ctx.el.getAttribute("data-count") ?? 0), liked: false }), render: state => ( <button type="button" aria-pressed={state.liked}> Like {state.count} </button> ), events: { "click button": ctx => ctx.set(previous => ({ liked: !previous.liked, count: previous.count + (previous.liked ? -1 : 1) })) }, api: ctx => ({ count: () => ctx.state.count }) }); ``` 1. The name matches `data-island="like-button"`. It must not be empty. 2. State is per element. Seed it from the server-rendered attributes. 3. `ctx.set` merges a patch, or an updater's result, and schedules one render. 4. Other islands read it with `ctx.island("like-button")`. ```tsx export function PostFooter(props: { likes: number }) { return ( <footer> <div data-island="like-button" data-count={String(props.likes)}> <button type="button">Like {props.likes}</button> </div> </footer> ); } ``` ```ts import { createApp } from "@moku-labs/web/browser"; import { site } from "./site"; import { likeButton } from "./islands/like-button"; import { routes } from "./routes"; const app = createApp({ pluginConfigs: { site, router: { routes }, spa: { islands: [likeButton] } } }); await app.start(); ``` The static HTML already shows `Like 3`, so the page reads right with JavaScript off. On mount the island renders over it and takes over the clicks. ## At build Islands ship in the client bundle. The build bundles `build.clientEntry` (or the first of `src/client/main.ts`, `src/client/main.tsx`, `src/main.ts`) and links it into every page. ```sh $ bun run build ▟▙ moku web build v2.4.11 ✓ bundle · 5ms ✓ pages · 5ms ✓ BUILD · 9 pages 1252ms · ./dist/ $ ls dist/assets chunk-9g7zwee8.js chunk-a5gpvyg6.js chunk-hm1r9zq7.js chunk-ja8ap6vw.js client-7wyejjw7.js main-gzka1ns2.css ``` Every bundle file name carries a content hash, so the generated `_headers` can mark them immutable. ## The island context Every hook, `render`, `state`, event handler and `api` gets the same `ctx`. | Member | What it is | |---|---| | `el` | The bound element. | | `data` | Page data parsed from `script#__DATA__`, or `{}`. | | `params`, `meta`, `locale` | The matched route's params, `.meta()` bag and locale. | | `url(name, params?)` | A link, same output as `app.router.toUrl`. | | `navigate(path, options?)` | SPA navigation, no `app` needed. `options = { scroll?, replace? }`. | | `state` | The live state. `undefined` for a hooks-only island. | | `set(patch)` | Merge a patch, or `prev => patch`, and schedule one render. | | `flush()` | Render now. Mostly for tests. | | `cleanup(dispose)` | Run on destroy, last in first out: timers, subscriptions, global listeners. | | `island<T>(name)` | Another island's `api`, or `undefined`. | ## Lifecycle hooks | Hook | When | |---|---| | `onCreate` | Once, before the instance attaches. | | `onMount` | After it attaches. May be async. | | `onNavStart` | A navigation begins while it is mounted. | | `onNavEnd` | A navigation finished. Persistent islands only. | | `onUnMount` | Before it detaches. | | `onDestroy` | Once, after it detaches. | A hooks-only island that marks navigation state on a persistent element: ```ts import { createIsland } from "@moku-labs/web/browser"; export const navState = createIsland("nav-state", { onNavStart({ el }) { el.setAttribute("data-loading", ""); }, onNavEnd({ el }) { el.removeAttribute("data-loading"); } }); ``` ## Talk to another island ```tsx import { createIsland } from "@moku-labs/web/browser"; type LikeApi = { count: () => number }; export const share = createIsland("share", { state: () => ({ text: "Share" }), render: state => <button type="button">{state.text}</button>, events: { "click button": ctx => { const likes = ctx.island<LikeApi>("like-button")?.count() ?? 0; ctx.set({ text: `Share (${likes} likes)` }); } } }); ``` 1. `undefined` when no `like-button` island is registered. ## Realtime channels `createChannel` is a self-healing WebSocket for islands that consume a server push stream, such as a Durable Object from `@moku-labs/worker`. It reconnects with backoff, can send a keepalive, buffers frames until you `seed`, and counts subscribers: the first connects, the last disconnects. ```ts import { createChannel, createIsland } from "@moku-labs/web/browser"; type BoardPatch = { type: "card.moved"; cardId: string; to: string }; const board = createChannel<BoardPatch>({ url: id => `${location.origin.replace(/^http/, "ws")}/ws/board/${id}`, keepAlive: { send: "ping", ignore: "pong" }, bufferUntilSeed: true }); export const boardIsland = createIsland("board", { state: () => ({ moves: 0 }), render: state => `<p>${state.moves} moves</p>`, onMount(ctx) { const boardId = ctx.params.id ?? ""; const off = board.subscribe(boardId, () => ctx.set(previous => ({ moves: previous.moves + 1 }))); ctx.cleanup(off); board.seed(boardId); } }); ``` 1. Frames that arrive before `seed` are queued, so the connect-then-load race drops nothing. ## Test an island `@moku-labs/web/testing` mounts one island through the real spa internals, with no `createApp`, router or network. The test runner needs a DOM (happy-dom or jsdom). ```ts import { createIsland } from "@moku-labs/web/browser"; import { mountIsland } from "@moku-labs/web/testing"; import { expect, it } from "vitest"; const counter = createIsland("counter", { state: () => ({ count: 0 }), render: state => `<button type="button">${state.count}</button>`, events: { "click button": ctx => ctx.set(previous => ({ count: previous.count + 1 })) } }); it("counts clicks", () => { const island = mountIsland<{ count: number }>(counter); island.flush(); island.fire("click button"); island.flush(); expect(island.el.textContent).toBe("1"); }); ``` 1. Same `"event selector"` grammar as the `events` map. `renderIsland(render, { state })` renders only the `render` function, for the cheapest view tests. An island that returns JSX needs `await island.settle()` first, which loads the lazy Preact render chunk. - **There is no `createComponent` in 2.4.11.** The `state`, `render`, `events` and `api` spec lives on `createIsland`. - **`render` replaces the server markup on mount.** Render the same markup the server rendered, or the element jumps. - **Return `null` for an empty island.** It unmounts the Preact tree and stays mountable. Returning `""` corrupts Preact's tree and the island never renders again. - **`ctx.data` reads `script#__DATA__`.** The build does not write that script. Render it in your page when an island needs page data, or read `data-*` attributes. - **A duplicate name replaces the first island.** `register` logs `spa:island-collision` and the last one wins. - **A typo in a hook name throws.** `onMout` fails `createIsland` with `[web] unknown island hook "onMout" on "x"` and the list of valid hooks. ## Related - [spa plugin](/docs/web/plugins/spa/): `islands`, `swapSelector`, the API at `app.spa`. - [SPA navigation](/docs/web/guides/spa/): what happens to islands on a link click. - [API reference](/docs/web/api/): `createIsland`, `createChannel`, `lazyEmbed`. # Content & collections Source: https://moku-labs.com/docs/web/guides/content/ · @moku-labs/web@2.4.11 > Markdown articles with locale fallback, and sharded JSON collections read on demand. ## How content works The `content` plugin turns Markdown files into `Article` objects. It does not read files itself: a **provider** does. On Node you compose `fileSystemContent`, which reads `content/<slug>/<locale>.md`, parses the frontmatter and renders the body to HTML with Shiki highlighting. The plugin adds locale fallback, draft filtering, date sorting and a cache on top. Routes reach it in build-only loaders with `ctx.require(contentPlugin)`. Nothing of the Markdown pipeline ships to the browser. | `Article` field | Holds | |---|---| | `frontmatter` | `title`, `date`, `description`, `tags`, `language`, `draft?`, `author?` | | `computed` | `slug`, `readingTime` (minutes, at least 1), `contentId`, `status`, `wordCount` | | `html` | The rendered body | | `locale`, `isFallback` | The requested locale, and whether the default-locale file was used | | `url` | The article URL in this locale, e.g. `/uk/second-post/` | For data that is not an article and that the browser loads on demand, such as a search index, the `collection` plugin writes JSON shards at build and reads them in the browser. ## Example ```text content/ hello-world/ en.md # frontmatter: title, date, description, tags, language uk.md # same slug, another locale images/ cover.png # co-located, copied to dist/hello-world/images/ second-post/ en.md # no uk.md: /uk/second-post/ falls back to English ``` ```ts import { buildPlugin, contentPlugin, createApp, fileSystemContent } from "@moku-labs/web"; import { routes } from "./routes"; export const app = createApp({ plugins: [contentPlugin, buildPlugin], pluginConfigs: { site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." }, content: { providers: [ fileSystemContent({ contentDir: "content", shikiTheme: "github-dark", defaultAuthor: "Ada" }) ] }, i18n: { locales: ["en", "uk"], defaultLocale: "en" }, router: { routes } } }); ``` 1. A bundled Shiki theme name, or your own theme object. 2. Used when an article's frontmatter has no `author`. ```tsx import { contentPlugin, defineRoutes, route } from "@moku-labs/web/browser"; export const routes = defineRoutes({ post: route("/{lang:?}/{slug}/") .generate(async ctx => { const byLocale = await ctx.require(contentPlugin).loadAll(); return (byLocale.get(ctx.locale) ?? []).map(article => ({ lang: ctx.locale, slug: article.computed.slug })); }) .load(ctx => ctx.require(contentPlugin).load(ctx.params.slug, ctx.locale)) .render(ctx => ( <article> {ctx.data.isFallback ? <p>This post is not translated yet.</p> : null} <h1>{ctx.data.frontmatter.title}</h1> <div dangerouslySetInnerHTML={{ __html: ctx.data.html }} /> </article> )) .head(ctx => ({ title: ctx.data.frontmatter.title, description: ctx.data.frontmatter.description })) }); ``` 1. Cached: every page of every route can call it, the files are read once. ## Locale fallback and drafts `load(slug, locale)` reads `<slug>/<locale>.md`. When that file is missing it reads the default locale's file and sets `isFallback: true`. `locale` and `url` stay the requested locale, so the page is written at `/uk/second-post/`. `loadAll()` does the same for every slug and every locale, then sorts each locale's list by date, newest first. An article with `draft: true` is left out when the global `stage` is `"production"`, the default. In `"development"` and `"test"` it loads normally. In production a draft passed to `load()` throws the same not-found error as a missing file, so drafts cannot be probed. ## The Markdown pipeline The framework pipeline already includes GitHub-flavoured Markdown, the generic directive syntax, Shiki and, last, `rehype-sanitize`. | Option of `fileSystemContent` | Default | What it does | |---|---|---| | `contentDir` | required | The article root. | | `trustedContent` | `false` | `false` runs `rehype-sanitize` as the last step. Set `true` only when you write every file yourself. | | `shikiTheme` | `"github-dark"` | A bundled theme name or a `ThemeRegistration` object. | | `defaultAuthor` | none | Author for articles without one. | | `extraRemarkPlugins` | `[]` | Added after the framework's remark plugins. They never replace them. | | `extraRehypePlugins` | `[]` | Added after the framework transforms, before Shiki and sanitize. | | `mermaid` | off | Renders `mermaid` fences to inline SVG at build. Needs `trustedContent` and the optional peer `mermaid-isomorphic`. | | `embed` | off | `::embed{src="…" title="…"}` becomes a click-to-load iframe facade. Needs `trustedContent`. | | `gallery` | off | `::gallery{src="./images/dir/" caption="…"}` becomes an image set from a co-located folder. Needs `trustedContent`. | `::embed` needs the `lazyEmbed` island in the browser to swap the facade for the real iframe: ```tsx import { createApp, defineRoutes, lazyEmbed, route } from "@moku-labs/web/browser"; const routes = defineRoutes({ home: route("/").render(() => <h1>My Blog</h1>) }); const app = createApp({ pluginConfigs: { site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." }, router: { routes }, spa: { islands: [lazyEmbed] } } }); await app.start(); ``` 1. Activates every `[data-island="lazy-embed"]` facade on click. ## Collections A collection is a set of JSON files the build writes and the browser reads on demand. You name the collection and the shard; the plugin owns the file layout: `<outDir>/<collection>/<shard>.json`, fetched from `<baseUrl><collection>/<shard>.json`. ```ts import { collectionPlugin, contentPlugin, createApp, fileSystemContent } from "@moku-labs/web"; const app = createApp({ plugins: [contentPlugin, collectionPlugin], pluginConfigs: { site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." }, i18n: { locales: ["en", "uk"], defaultLocale: "en" }, content: { providers: [fileSystemContent({ contentDir: "content" })] } } }); const byLocale = await app.content.loadAll(); const shards = [...byLocale].map(([locale, articles]) => ({ collection: "search", shard: locale, data: articles.map(article => app.content.articleToCard(article)) })); const summary = await app.collection.write(shards, { outDir: "dist" }); console.log(summary.files); ``` 1. A shard key may contain `/`, e.g. `"en/animals"`, which becomes a folder. ```ts import { loadCollectionShard } from "@moku-labs/web/browser"; import type { Content } from "@moku-labs/web/browser"; export async function searchTitles(query: string): Promise<Content.ArticleCard[]> { const cards = await loadCollectionShard<Content.ArticleCard[]>("/", "search", "en"); const needle = query.toLowerCase(); return cards.filter(card => card.title.toLowerCase().includes(needle)); } ``` 1. Fetches `/search/en.json` and throws on a non-OK response. ```sh $ bun scripts/write-index.ts [ "search/en.json", "search/uk.json" ] $ head -c 120 dist/search/uk.json [{"contentId":"uk:0000:second-post","status":"published","title":"Second post","date":"2026-02-01", ``` `collectionPlugin` is exported from `@moku-labs/web` only. In the browser bundle, read shards with `loadCollectionShard` or build the URL with `collectionUrl` from `@moku-labs/web/browser`. - **`content` needs a provider.** The default `providers: []` fails `createApp` with `[web] content: no provider composed.` - **Five frontmatter fields are required.** `title`, `date`, `description`, `tags` and `language`. A missing one fails the build with the field name. - **`load()` throws on a missing article.** `[web] content article "nope" not found for locale "en".` Build page lists from `loadAll()`, not from guessed slugs. - **`loadAll()` returns a cached object.** Copy before you sort or change it. - **`trustedContent: false` strips more than scripts.** Inline `style` and Shiki's per-token colours go too. Use `true` only for Markdown you write yourself. - **Write collections after the build.** `build.run()` cleans `outDir` first, so a shard written before it is deleted. ## Related - [content plugin](/docs/web/plugins/content/): the full API and every option. - [collection plugin](/docs/web/plugins/collection/): `write`, `at`, `urlFor`, `fileFor`. - [Routing](/docs/web/guides/routing/): `generate` and `load` with `ctx.require`. - [i18n](/docs/web/guides/i18n/): locales and the fallback chain. # Head & SEO Source: https://moku-labs.com/docs/web/guides/head/ · @moku-labs/web@2.4.11 > Title, canonical, Open Graph, Twitter, JSON-LD, hreflang, feeds, sitemap and OG images. ## How the head works Each route's `.head(ctx)` returns a small object: `title`, `description`, `canonical`, `image` and extra `elements`. The `head` plugin composes the full `<head>` from it, in this order: 1. **Title.** `head.titleTemplate` with `%s` replaced by the route title, or the site name. 2. **Description.** The route description, or `site.description`. 3. **Open Graph and Twitter.** Title, description, URL, and `og:image` / `twitter:image` from the route `image` or `head.defaultOgImage`, made absolute against `site.url`. `og:locale` when `i18n.ogLocaleMap` has the page locale. 4. **Canonical.** The route `canonical`, or `site.url` joined with the page URL. 5. **hreflang.** One alternate link per locale, plus `x-default`. 6. **Your elements.** The route `elements`, last. An element with the same key as a generated one replaces it. The build writes this into every page. On client navigation `spa` copies the head from the fetched page, or sets the title through `head.composeTitle` on the data path. A head element is a plain object, not a Preact node, so the same descriptors serialize at build and apply in the browser. The `build` plugin adds the site-wide files: feeds, `sitemap.xml`, `robots.txt` and OG images. ## Example ```ts import { buildPlugin, contentPlugin, createApp, fileSystemContent } from "@moku-labs/web"; import { routes } from "./routes"; export const app = createApp({ plugins: [contentPlugin, buildPlugin], pluginConfigs: { site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." }, head: { titleTemplate: "%s · My Blog", defaultOgImage: "/og-default.png", twitterCard: "summary_large_image", twitterHandle: "@moku_labs" }, i18n: { locales: ["en", "uk"], defaultLocale: "en", ogLocaleMap: { en: "en_US", uk: "uk_UA" } }, router: { routes }, content: { providers: [fileSystemContent({ contentDir: "content" })] } } }); ``` 1. Must contain `%s`, or `createApp` throws. ```tsx import { buildArticleHead, contentPlugin, defineRoutes, feedLink, route } from "@moku-labs/web/browser"; export const routes = defineRoutes({ post: route("/{slug}/") .load(ctx => ctx.require(contentPlugin).load(ctx.params.slug, ctx.locale)) .render(ctx => <article dangerouslySetInnerHTML={{ __html: ctx.data.html }} />) .head(ctx => ({ title: ctx.data.frontmatter.title, description: ctx.data.frontmatter.description, image: `/og/${ctx.data.computed.slug}.png`, elements: [ ...buildArticleHead( { title: ctx.data.frontmatter.title, author: ctx.data.frontmatter.author ?? "Ada", published: ctx.data.frontmatter.date, tags: ctx.data.frontmatter.tags }, `https://blog.dev${ctx.data.url}` ), feedLink("My Blog", "/feed.xml") ] })) }); ``` 1. Relative is fine here: the head plugin makes it absolute. 2. Canonical, `og:type=article`, published time, author, tags and an Article JSON-LD block. ## What the build writes ```sh $ bun run build ▟▙ moku web build v2.4.11 ✓ pages · 5ms ✓ feeds · 2ms ✓ sitemap · 1ms ✓ BUILD · 9 pages 1252ms · ./dist/ $ cat dist/robots.txt User-agent: * Allow: / Sitemap: https://blog.dev/sitemap.xml ``` The head of `/uk/hello-world/`, as the build wrote it (one tag per line here): ```html <title>Привіт, світ · My Blog ``` The `build-id` meta and the asset tags are added by the build, after the head plugin. The sitemap lists the canonical URLs only, so the `/en/…` copies of default-locale pages are not in it. ## Head helpers Plain functions that return head elements. Use them in `elements`. Each one is exported from both entries. | Helper | Signature | Emits | |---|---|---| | `meta` | `meta(name, content)` | ``, key `meta:` | | `og` | `og(property, content)` | ``, key `meta:` | | `twitter` | `twitter(name, content)` | ``, key `meta:` | | `jsonLd` | `jsonLd(data)` | `