@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 +227 -125
- package/dist/browser.d.mts +524 -111
- package/dist/browser.mjs +1707 -868
- package/dist/{convention-X3zLTlJ8.mjs → convention-CepUwWmT.mjs} +18 -1
- package/dist/{convention-Dr8jxG70.cjs → convention-krwh7Y6Q.cjs} +23 -0
- package/dist/index.cjs +3239 -1935
- package/dist/index.d.cts +365 -176
- package/dist/index.d.mts +366 -177
- package/dist/index.mjs +3236 -1934
- package/dist/{writer-DAF0pM25.cjs → writer-DV5hWB2i.cjs} +25 -27
- package/dist/{writer-BcWqa_7I.mjs → writer-Dc_lx22j.mjs} +25 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,160 +1,262 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
1
3
|
# @moku-labs/web
|
|
2
4
|
|
|
3
|
-
**
|
|
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
|
-
|
|
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
|
+
[](https://github.com/moku-labs/web/actions/workflows/ci.yml)
|
|
16
|
+
[](https://www.npmjs.com/package/@moku-labs/web)
|
|
17
|
+
[](#requirements)
|
|
18
|
+
[](#the-browser-entry-is-guaranteed-node-free)
|
|
19
|
+
[](#requirements)
|
|
20
|
+
[](./LICENSE)
|
|
21
|
+
|
|
22
|
+
<br/>
|
|
10
23
|
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
//
|
|
28
|
-
import {
|
|
29
|
-
|
|
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
|
|
52
|
-
config: { mode: "
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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();
|
|
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
|
-
|
|
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
|
-
//
|
|
124
|
-
import { createApp,
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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` |
|
|
136
|
-
| `i18n` |
|
|
137
|
-
| `router` |
|
|
138
|
-
| `head` |
|
|
139
|
-
| `spa` |
|
|
140
|
-
| `content` |
|
|
141
|
-
| `build` |
|
|
142
|
-
| `deploy` |
|
|
143
|
-
| `cli` |
|
|
144
|
-
| `data` |
|
|
145
|
-
| `
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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)
|