@moku-labs/web 0.6.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,160 +1,262 @@
1
+ <div align="center">
2
+
1
3
  # @moku-labs/web
2
4
 
3
- **A content static-site generator + SPA web framework for TypeScript.** Built on
4
- [@moku-labs/core](https://github.com/moku-labs/core) — three layers of isolation, plugins all the
5
- way down, types doing the heavy lifting.
5
+ **Content static-site generator + progressive SPA framework for TypeScript.**
6
6
 
7
- ```
8
- bun add @moku-labs/web
9
- ```
7
+ Author Markdown, declare type-safe routes, ship SEO-complete static HTML —
8
+ then hydrate islands and navigate on the client. The *same* `render` runs at build and in the browser,
9
+ so SSG ⇄ SPA parity is structural, not duplicated.
10
+
11
+ Built on the [@moku-labs/core](https://github.com/moku-labs/core) micro-kernel — three layers of isolation, plugins all the way down, types doing the heavy lifting.
12
+
13
+ <br/>
14
+
15
+ [![CI](https://github.com/moku-labs/web/actions/workflows/ci.yml/badge.svg)](https://github.com/moku-labs/web/actions/workflows/ci.yml)
16
+ [![npm](https://img.shields.io/npm/v/@moku-labs/web?logo=npm&color=cb3837&label=npm)](https://www.npmjs.com/package/@moku-labs/web)
17
+ [![types](https://img.shields.io/badge/types-included-3178c6?logo=typescript&logoColor=white)](#requirements)
18
+ [![browser bundle](https://img.shields.io/badge/browser%20entry-~45%20kB%20gzip-2da44e)](#the-browser-entry-is-guaranteed-node-free)
19
+ [![node](https://img.shields.io/badge/node-%3E%3D24-339933?logo=node.js&logoColor=white)](#requirements)
20
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
21
+
22
+ <br/>
10
23
 
11
- > Status: `0.1.0` — early. The API is settling but not yet frozen.
24
+ [Quick start](#quick-start) ·
25
+ [How it works](#how-it-works) ·
26
+ [The route is the contract](#the-route-is-the-contract) ·
27
+ [Plugins](#plugins) ·
28
+ [Rendering modes](#rendering-modes) ·
29
+ [Scripts](#scripts) ·
30
+ [Docs](#docs)
31
+
32
+ </div>
12
33
 
13
34
  ---
14
35
 
15
- ## What it is
36
+ ```sh
37
+ bun add @moku-labs/web
38
+ ```
39
+
40
+ > [!NOTE]
41
+ > **Status: `0.x` — pre-1.0.** The architecture is stable; the public API is settling but not yet frozen. Pin the version — the npm badge above tracks the current release.
16
42
 
17
- `@moku-labs/web` composes a small set of focused plugins into one framework: author Markdown
18
- content, declare type-safe routes, generate SEO-complete HTML + feeds + sitemap at build time, and
19
- optionally hydrate islands and deploy to Cloudflare Pages.
43
+ ## Why @moku-labs/web
20
44
 
21
- The consumer surface is one `createApp` call plus a typed routing DSLyou never import from
22
- `@moku-labs/core` directly.
45
+ - **SSG first, SPA when you want it.** Render [Preact](https://preactjs.com) 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.
46
+ - **The route is the contract.** One typed `route()` builder owns `load` → `render` → `head`. The build and the client run the *same* `render`, so there's no second code path to keep in sync. [Jump to the example ↓](#the-route-is-the-contract)
47
+ - **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.
48
+ - **The `/browser` entry 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 (~45 kB gzip today). [Why this matters ↓](#the-browser-entry-is-guaranteed-node-free)
49
+ - **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](#plugins) and composed in one `createApp` call.
50
+ - **Types do the heavy lifting.** `ctx.data` is inferred from your `.load()`, path params from the route pattern, plugin APIs from their specs — no codegen, no `as`.
51
+ - **i18n is built in.** Locale-aware routes, default-locale fallback, `hreflang` / `og:locale` maps.
23
52
 
24
53
  ## Quick start
25
54
 
55
+ A complete static blog is two files: the routes, and the app that builds them.
56
+
57
+ ```tsx
58
+ // routes.tsx — the route IS the contract: load → render → head
59
+ import { route, contentPlugin } from "@moku-labs/web";
60
+ import { Article } from "./components";
61
+
62
+ export const home = route("/")
63
+ .render(() => <h1>My Blog</h1>)
64
+ .head(() => ({ title: "My Blog" }));
65
+
66
+ export const post = route("/{slug}/")
67
+ // generate the page list at build time…
68
+ .generate((ctx) => listSlugs(ctx.locale).map((slug) => ({ slug })))
69
+ // …load() runs at build only; pull sibling plugins via ctx.require → widens ctx.data
70
+ .load((ctx) => ctx.require(contentPlugin).load(ctx.params.slug, ctx.locale))
71
+ // render() runs at build AND on the client; ctx.url builds type-safe links
72
+ .render((ctx) => <Article article={ctx.data} url={ctx.url} />)
73
+ .head((ctx) => ({ title: ctx.data.title, description: ctx.data.description }));
74
+ ```
75
+
26
76
  ```ts
27
- // A Node SSG build — compose the node-only plugins explicitly:
28
- import {
29
- createApp,
30
- defineRoutes,
31
- route,
32
- contentPlugin,
33
- buildPlugin,
34
- deployPlugin,
35
- dotenv,
36
- processEnv
37
- } from "@moku-labs/web";
38
-
39
- const routes = defineRoutes({
40
- home: route("/")
41
- .render(() => <h1>My Blog</h1>)
42
- .head(() => ({ title: "My Blog" })),
43
- article: route("/{lang:?}/{slug}/")
44
- .generate((locale) => listSlugs(locale).map((slug) => ({ lang: locale, slug })))
45
- .load(({ slug }, locale) => loadArticle(slug, locale)) // widens ctx.data
46
- .render((ctx) => <Article article={ctx.data} />)
47
- .head((ctx) => ({ title: ctx.data.title, description: ctx.data.description }))
48
- });
77
+ // app.ts — compose the build, then run it
78
+ import { createApp, contentPlugin, buildPlugin, fileSystemContent, processEnv } from "@moku-labs/web";
79
+ import * as routes from "./routes";
49
80
 
50
81
  const app = createApp({
51
- plugins: [contentPlugin, buildPlugin, deployPlugin], // node-only — added per target
52
- config: { mode: "production" },
82
+ plugins: [contentPlugin, buildPlugin], // node-only plugins opt in for the build
83
+ config: { mode: "ssg" }, // "ssg" | "spa" | "hybrid" (see Rendering modes)
53
84
  pluginConfigs: {
54
- env: { providers: [dotenv(), processEnv()] },
55
- site: { name: "My Blog", url: "https://blog.dev", author: "Me", description: "A personal blog." },
56
- i18n: { locales: ["en", "uk"], defaultLocale: "en" },
57
- content: { contentDir: "./content" },
58
- router: { routes, mode: "ssg" },
59
- head: { titleTemplate: "%s — My Blog" },
60
- build: { outDir: "dist", feeds: true, sitemap: true }
61
- }
85
+ site: { name: "My Blog", url: "https://blog.dev", author: "Me", description: "A personal blog." },
86
+ i18n: { locales: ["en"], defaultLocale: "en" },
87
+ content: { providers: [fileSystemContent({ contentDir: "./content" })] },
88
+ router: { routes }, // a declarative route map (an `import * as` namespace works)
89
+ env: { providers: [processEnv()] },
90
+ },
62
91
  });
63
92
 
64
- await app.build.run(); // → static site in dist/ (HTML, feed.xml, sitemap.xml)
93
+ await app.build.run(); // → dist/ : static HTML + feed.xml + sitemap.xml
94
+ ```
95
+
96
+ Content 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`.
97
+
65
98
  ```
99
+ content/
100
+ hello-world/
101
+ en.md # frontmatter: title, date, description, tags, language, draft?, author?
102
+ uk.md # same slug, another locale — i18n fallback handles the rest
103
+ second-post/
104
+ en.md
105
+ ```
106
+
107
+ ### Add client-side navigation
66
108
 
67
- Content lives on disk as `content/{slug}/{locale}.md` with YAML frontmatter
68
- (`title`, `date`, `description`, `tags`, `language`, optional `draft`/`author`). Drafts are excluded
69
- from production builds.
70
-
71
- ## Composition model
72
-
73
- `createApp`'s **defaults are the isomorphic plugins** — the ones that run unchanged on
74
- both Node and the browser: `site`, `i18n`, `router`, `head`, `spa` (plus the `log`/`env`
75
- core). The **node-only** plugins (`content`, `build`, `deploy`) are exported but not
76
- defaults — add them with `createApp({ plugins: [...] })` for a Node build. You also choose
77
- the `env` provider per target: `[dotenv(), processEnv()]` on Node, `browserEnv()` in the
78
- browser. The framework never hard-blocks either runtime.
79
-
80
- Two entry points pick the right surface per target:
81
-
82
- - **`@moku-labs/web`** (the `.` entry, dual ESM+CJS) — the full surface, for **Node SSG
83
- builds**: add `contentPlugin`/`buildPlugin`/`deployPlugin` and wire `dotenv()`/`processEnv()`.
84
- - **`@moku-labs/web/browser`** (ESM-only) — the recommended **client/browser** entry. It
85
- re-exports the SAME `createApp`/`createPlugin` over the SAME isomorphic default set
86
- (`site`, `i18n`, `router`, `head`, `spa` + `log`/`env`), plus `dataPlugin`, `defineRoutes`,
87
- `route`, `createComponent`, `browserEnv`, the SEO head primitives, and the browser-relevant type namespaces
88
- (`Data`, `Env`, `Head`, `Log`, `Router`, `Spa`). It **excludes** everything node-only
89
- (`contentPlugin`, `buildPlugin`, `deployPlugin`, the `dotenv`/`processEnv`/`cloudflareBindings`
90
- env providers, and the `Build`/`Content`/`Deploy` type namespaces), and **pre-wires
91
- `browserEnv()`** as the default env provider — so `env` works with **zero** consumer
92
- config (no `pluginConfigs.env.providers` needed), resolving from `import.meta.env` and
93
- `globalThis.__ENV__`.
94
-
95
- Importing `@moku-labs/web/browser` can **never** drag node/native code into a client bundle,
96
- regardless of your bundler or tree-shaking — its static import graph references zero
97
- node-only modules. This is stronger and more reliable than importing `@moku-labs/web` and
98
- relying on `"sideEffects": false` tree-shaking, which is fragile (building entries together
99
- can merge node code into a shared chunk). A CI gate (`bun run check:bundle`) asserts the
100
- built browser bundle has zero static node/native imports and stays under a gzip size budget
101
- (the browser bundle is currently ~35 kB gzip).
102
-
103
- `data` is a special case — an **optional, domain-agnostic data provider**: composed on
104
- Node, `build` calls `data.write(...)` to persist each page's real `load()` output as JSON
105
- (one file per page URL); composed in the browser, `data.at(path)` fetches that JSON and
106
- `spa` re-runs the route's own `render` from it (validated through the route's `parse`
107
- gate) instead of fetching full HTML. The route owns rendering — the SAME `render` runs at
108
- build (SSG) and on the client, so parity is structural. Add `data` on both sides for
109
- client DATA navigation; omit it for a plain static site (HTML-over-fetch).
110
-
111
- The single switch is **`router.mode`** (`"ssg" | "spa" | "hybrid"`): `build` writes data +
112
- `spa` data-renders only when it is not `"ssg"`. A route that opts into data nav MUST
113
- declare `.parse(raw => data)` (validated at the client trust boundary) — `build` errors
114
- otherwise.
115
-
116
- A browser entry is `createApp(...).start()` imported from `@moku-labs/web/browser` over the
117
- defaults (plus `dataPlugin` for DATA nav) — `spa`'s `onStart` mounts islands onto the SSR'd
118
- DOM and intercepts navigation. `dataPlugin` stays consumer-composed (it is not a default):
119
- compose it for client DATA navigation (`router.mode` `"spa"` | `"hybrid"`); its node
120
- write-half is loaded only via dynamic import.
109
+ 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.
121
110
 
122
111
  ```ts
123
- // A browser bundle — guaranteed node-free, env pre-wired:
124
- import { createApp, spaPlugin, dataPlugin, browserEnv, defineRoutes, route } from "@moku-labs/web/browser";
112
+ // client.ts — guaranteed node-free; env reads import.meta.env out of the box
113
+ import { createApp, dataPlugin } from "@moku-labs/web/browser";
114
+ import * as routes from "./routes"; // render shells only — never the node content source
125
115
 
126
- // env works with no wiring — browserEnv is the default provider
127
- const app = createApp({ plugins: [dataPlugin], pluginConfigs: { router: { mode: "spa", routes } } });
128
- await app.start();
116
+ const app = createApp({
117
+ plugins: [dataPlugin], // opt in for DATA-driven navigation (mode spa | hybrid)
118
+ config: { mode: "spa" },
119
+ pluginConfigs: { router: { routes } },
120
+ });
121
+
122
+ await app.start(); // spa mounts islands onto the SSR'd DOM and intercepts navigation
129
123
  ```
130
124
 
125
+ ## How it works
126
+
127
+ ### Three layers, one `createApp`
128
+
129
+ 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.
130
+
131
+ ```mermaid
132
+ flowchart TB
133
+ A["Your app — createApp({ plugins, pluginConfigs })"]:::l3
134
+ ISO["<b>Isomorphic defaults</b> — run on Node + browser<br/>site · i18n · router · head · spa<br/>+ log · env (core)"]:::l2
135
+ NODE["<b>Opt-in</b> — composed per target<br/>content · build · deploy · cli (node-only)<br/>data (isomorphic, optional)"]:::l2
136
+ CORE["@moku-labs/core micro-kernel<br/>4-level config cascade · events · lifecycle"]:::l1
137
+ A --> ISO
138
+ A -. "add for an SSG build" .-> NODE
139
+ ISO --> CORE
140
+ NODE --> CORE
141
+ classDef l3 fill:#0b7285,stroke:#08525f,color:#fff;
142
+ classDef l2 fill:#1864ab,stroke:#0d3d6e,color:#fff;
143
+ classDef l1 fill:#343a40,stroke:#111,color:#fff;
144
+ ```
145
+
146
+ ### The same `render` runs in both places
147
+
148
+ `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.
149
+
150
+ ```mermaid
151
+ flowchart LR
152
+ subgraph BUILD["① Build time · Node"]
153
+ direction TB
154
+ L1["load(ctx) → data"] --> R1["render(data) → static HTML"]
155
+ L1 --> W1["data.write → JSON sidecar (one per page URL)"]
156
+ end
157
+ subgraph CLIENT["② Client · browser"]
158
+ direction TB
159
+ N2["in-app navigation"] --> A2["data.at(path) → JSON"] --> R2["render(data) → live DOM"]
160
+ end
161
+ R1 -. "same render() — no second code path" .-> R2
162
+ ```
163
+
164
+ ### The `/browser` entry is guaranteed node-free
165
+
166
+ There are two entry points, and the difference is a hard guarantee, not a tree-shaking hope:
167
+
168
+ | Entry | Format | For | Includes |
169
+ |---|---|---|---|
170
+ | **`@moku-labs/web`** | dual ESM + CJS | Node SSG builds | the full surface — add `content` / `build` / `deploy` / `cli` and wire `dotenv()` / `processEnv()` |
171
+ | **`@moku-labs/web/browser`** | ESM-only | client bundles | the same `createApp` over the same isomorphic defaults, plus `dataPlugin`, the route DSL, `createComponent`, `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 |
172
+
173
+ 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:
174
+
175
+ ```sh
176
+ bun run check:bundle # asserts: zero static node/native imports + under the gzip budget
177
+ ```
178
+
179
+ ## The route is the contract
180
+
181
+ A route is a fluent builder. Each step is optional except `render`, and every type flows from it:
182
+
183
+ ```ts
184
+ route("/{lang:?}/{slug}/") // path-params are inferred → ctx.params.{lang, slug}
185
+ .generate((ctx) => Params[]) // build-time: which concrete pages to emit
186
+ .load((ctx) => Data) // build-only: ctx.require / ctx.has pull sibling plugins; widens ctx.data
187
+ .render((ctx) => VNode) // build AND client: ctx.url builds links; ctx.data is typed from .load()
188
+ .head((ctx) => HeadConfig) // SEO <head>: ctx.url + ctx.data available
189
+ ```
190
+
191
+ - **`ctx.params`** — inferred from the pattern. `{seg}` is required, `{seg:?}` is optional (used here for an optional locale prefix).
192
+ - **`ctx.require(plugin)` / `ctx.has(plugin)`** — pull a sibling plugin's API the spec way (instance-only, no module globals). Available in `load` / `generate`.
193
+ - **`ctx.url(name, params)`** — type-safe link builder. Available in `render` / `head`.
194
+ - **`ctx.data`** — typed from `.load()`'s return. On a client nav the fetched JSON *is* `ctx.data` directly (no validation step); a missing or malformed sidecar simply falls back to HTML-over-fetch. Omit `.load()` for a static page — `build` still emits an empty `{}` sidecar so hybrid nav resolves cleanly.
195
+
196
+ Register routes declaratively via `pluginConfigs.router.routes` — the single source of truth, compiled once at init.
197
+
198
+ SEO `<head>` primitives are exported for `.head()` handlers: `meta`, `og`, `twitter`, `jsonLd`, `canonical`, `hreflang`, `feedLink`, and `buildArticleHead`.
199
+
131
200
  ## Plugins
132
201
 
133
- | Plugin | Default? | Responsibility |
202
+ Each plugin is small, single-purpose, and documented on its own. **Click a name for its README** — config, full API, and design notes.
203
+
204
+ | Plugin | Kind | Responsibility |
134
205
  |---|---|---|
135
- | `site` | isomorphic | Site identity (name, URL, author) + canonical URL helper |
136
- | `i18n` | isomorphic | Locales, default-locale fallback, translations, hreflang/ogLocale maps |
137
- | `router` | isomorphic | Type-safe route DSL (`route`/`defineRoutes`) with `.load`/`.render`/`.parse`, matching, `mode()`, URL/file derivation |
138
- | `head` | isomorphic | SEO `<head>` composition: title template, canonical, OG/Twitter, JSON-LD, hreflang |
139
- | `spa` | isomorphic | Client runtime: island hydration + intercepted navigation (inert on Node) |
140
- | `content` | node-only | Markdown pipeline → sanitized HTML, frontmatter, reading time, locale model |
141
- | `build` | node-only | SSG orchestrator: pages, feeds (RSS/Atom/JSON), sitemap, OG images |
142
- | `deploy` | node-only | Cloudflare Pages: `wrangler.jsonc` scaffolding + deploy |
143
- | `cli` | node-only | Developer CLI — `build`/`serve`/`preview`/`deploy` with a boxed Panel renderer + live build progress; driven from thin per-command scripts (deploy confirm is TTY-only, CI auto-proceeds) |
144
- | `data` | optional provider | Agnostic: Node `write()` persists per-page JSON (keyed by URL); browser `at()` fetches it for `spa` DATA nav (validated via `route.parse`) |
145
- | `log`, `env` | core | Structured logging + validated environment access |
146
-
147
- SEO primitives are exported for route `.head()` handlers: `meta`, `og`, `twitter`, `jsonLd`,
148
- `canonical`, `hreflang`, `feedLink`, `buildArticleHead`.
206
+ | [`site`](src/plugins/site/README.md) | isomorphic default | Global, frozen site identity (name, URL, author) + canonical URL helper |
207
+ | [`i18n`](src/plugins/i18n/README.md) | isomorphic default | Locale registry, default-locale fallback, translations, `og:locale` map |
208
+ | [`router`](src/plugins/router/README.md) | isomorphic default | Type-safe `route()` DSL, path-param inference, matcher, URL/file derivation, `mode()` |
209
+ | [`head`](src/plugins/head/README.md) | isomorphic default | `<head>` composition: title template, canonical, OG/Twitter, JSON-LD, `hreflang`, feeds |
210
+ | [`spa`](src/plugins/spa/README.md) | isomorphic default | Client runtime: island hydration + intercepted navigation + progress bar (inert on Node) |
211
+ | [`content`](src/plugins/content/README.md) | node-only | Markdown → sanitized HTML pipeline, frontmatter, reading time, locale-keyed `Article` model |
212
+ | [`build`](src/plugins/build/README.md) | node-only | SSG orchestrator: pages, feeds (RSS/Atom/JSON), sitemap, OG images → `dist/` |
213
+ | [`deploy`](src/plugins/deploy/README.md) | node-only | Cloudflare Pages: `wrangler.jsonc` scaffolding, secret scrubbing, deploy |
214
+ | [`cli`](src/plugins/cli/README.md) | node-only | Developer CLI — `build` / `serve` / `preview` / `deploy` with a boxed Panel UI + live progress |
215
+ | [`data`](src/plugins/data/README.md) | optional provider | Agnostic `page path JSON` contract: `write()` on Node, `at()` in the browser, for DATA nav |
216
+ | [`env`](src/plugins/env/README.md) | core | Multi-provider environment / secret injection, validated and frozen at `onInit` |
217
+ | [`log`](src/plugins/log/README.md) | core | Structured logging + an in-memory trace with an `expect()` DSL for testable workflows |
218
+
219
+ ## Rendering modes
220
+
221
+ One global switch — `config.mode` (default `hybrid`), read by plugins via `router.mode()` — decides how much of the SPA machinery runs.
222
+
223
+ | Mode | Build emits | Client behavior | Use for |
224
+ |---|---|---|---|
225
+ | `ssg` | HTML only | static pages (full reload on nav) | content sites, docs, marketing |
226
+ | `spa` | HTML + JSON sidecars | DATA navigation: fetch JSON, re-render in place | app-like sites |
227
+ | `hybrid` | HTML + JSON sidecars | DATA nav where available, else HTML-over-fetch | best of both |
228
+
229
+ > [!TIP]
230
+ > Compose `dataPlugin` on **both** sides for DATA navigation — on Node so `build` can write sidecars, and on the browser entry so `spa` can read them. Omit it for a plain static site. It has no hard dependencies and tree-shakes away when unused.
149
231
 
150
232
  ## Scripts
151
233
 
234
+ ```sh
235
+ bun run build # build with tsdown (dual ESM+CJS + ESM-only browser entry)
236
+ bun run test # all tests (vitest)
237
+ bun run test:unit # unit tests only
238
+ bun run test:integration # integration tests only
239
+ bun run test:coverage # tests with coverage (90% threshold)
240
+ bun run lint # biome check + eslint
241
+ bun run lint:fix # auto-fix lint issues
242
+ bun run format # format with biome
243
+ bun run validate # publint — verify package export map
244
+ bun run check:bundle # assert the browser bundle is node-free + under the gzip budget
152
245
  ```
153
- bun run build # build with tsdown
154
- bun run test # vitest (unit + integration)
155
- bun run lint # biome + eslint
156
- ```
246
+
247
+ ## Requirements
248
+
249
+ - **Node `>= 24`** — the router uses the global [`URLPattern`](https://developer.mozilla.org/docs/Web/API/URLPattern).
250
+ - **Bun `>= 1.3.14`** — the package manager and test runner. Use `bun` exclusively (never npm/yarn/pnpm).
251
+ - **TypeScript** in strict mode, with `exactOptionalPropertyTypes` and `noUncheckedIndexedAccess`.
252
+
253
+ ## Docs
254
+
255
+ - **Per-plugin internals** — the linked READMEs in the [Plugins](#plugins) table.
256
+ - **Architecture & specifications** — the [@moku-labs/core specification](https://github.com/moku-labs/core/tree/main/specification).
257
+ - **For AI agents / LLM codegen** — [`llms.txt`](./llms.txt) (concise) and [`llms-full.txt`](./llms-full.txt) (complete), plus the Moku Claude toolkit described in [`CLAUDE.md`](./CLAUDE.md).
258
+ - **Contributing** — see [`CLAUDE.md`](./CLAUDE.md) for code style, the three-layer model, and the test layout.
157
259
 
158
260
  ## License
159
261
 
160
- MIT © moku-labs
262
+ [MIT](./LICENSE) © [moku-labs](https://github.com/moku-labs)