@moku-labs/web 0.3.1 → 0.4.0

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/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { t as __exportAll } from "./chunk-D7D4PA-g.mjs";
2
+ import { t as dataSuffix } from "./convention-X3zLTlJ8.mjs";
2
3
  import { createCoreConfig, createCorePlugin } from "@moku-labs/core";
3
- import { existsSync, readFileSync, readdirSync } from "node:fs";
4
4
  import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
5
5
  import path, { dirname, join } from "node:path";
6
6
  import matter from "gray-matter";
@@ -17,11 +17,10 @@ import remarkRehype from "remark-rehype";
17
17
  import { visit } from "unist-util-visit";
18
18
  import { defaultSchema } from "hast-util-sanitize";
19
19
  import readingTime from "reading-time";
20
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
20
21
  import { createHash, randomUUID } from "node:crypto";
21
22
  import { Feed } from "feed";
22
- import { Resvg } from "@resvg/resvg-js";
23
- import satori from "satori";
24
- import { jsx } from "preact/jsx-runtime";
23
+ import { h } from "preact";
25
24
  import { renderToString } from "preact-render-to-string";
26
25
  //#region src/plugins/env/api.ts
27
26
  /** Error prefix for all env API failures. */
@@ -250,112 +249,45 @@ function validateSchema(ctx) {
250
249
  freezeMap(state.publicMap);
251
250
  }
252
251
  //#endregion
253
- //#region src/plugins/env/providers.ts
254
- /**
255
- * @file env plugin — built-in providers: dotenv, processEnv, cloudflareBindings.
256
- */
257
- /** Default dotenv file path: optional local overrides. */
258
- const DEFAULT_DOTENV_PATH = ".env.local";
259
- /**
260
- * Strips a single matching pair of surrounding double or single quotes from a
261
- * value. Leaves unquoted values (and trailing inline comments) untouched.
262
- *
263
- * @param value - The already-trimmed raw value.
264
- * @returns The value with one outer quote pair removed, if present.
265
- * @example
266
- * ```ts
267
- * stripQuotes('"a"'); // "a"
268
- * stripQuotes("plain # c"); // "plain # c"
269
- * ```
270
- */
271
- function stripQuotes(value) {
272
- if (value.length >= 2) {
273
- const first = value[0];
274
- const last = value.at(-1);
275
- if ((first === "\"" || first === "'") && first === last) return value.slice(1, -1);
276
- }
277
- return value;
278
- }
279
- /**
280
- * Parses `.env`-style text into a flat record. Handles CRLF/LF, blank lines,
281
- * full-line `#` comments, first-`=` splitting, key/value trimming, and a single
282
- * outer quote pair. Does not strip trailing inline comments on unquoted values.
283
- *
284
- * @param text - The raw file contents.
285
- * @returns A flat record of parsed key/value pairs.
286
- * @example
287
- * ```ts
288
- * parseDotenv('A=1\nB="two"'); // { A: "1", B: "two" }
289
- * ```
290
- */
291
- function parseDotenv(text) {
292
- const out = {};
293
- for (const line of text.split(/\r?\n/)) {
294
- const trimmed = line.trim();
295
- if (trimmed === "" || trimmed.startsWith("#")) continue;
296
- const eq = trimmed.indexOf("=");
297
- if (eq === -1) continue;
298
- const key = trimmed.slice(0, eq).trim();
299
- out[key] = stripQuotes(trimmed.slice(eq + 1).trim());
300
- }
301
- return out;
302
- }
252
+ //#region src/plugins/env/providers.browser.ts
253
+ /** Default `globalThis` property holding a runtime-injected public-env snapshot. */
254
+ const DEFAULT_GLOBAL_KEY = "__ENV__";
303
255
  /**
304
- * A zero-dependency `.env`-style provider that re-reads and re-parses the file
305
- * from disk on every `load()`. Missing file resolves to `{}` (optional
306
- * overrides). Strips a single outer quote pair; does not strip trailing inline
307
- * comments on unquoted values.
256
+ * A browser-safe {@link EnvProvider} that reads `import.meta.env` and an optional
257
+ * `globalThis[globalKey]` snapshot, merging them with the runtime global winning.
258
+ * Contains zero `node:*` imports, so it is safe to include in the client bundle.
259
+ * Never throws on missing sources — each absent source resolves to `{}`.
308
260
  *
309
- * @param path - Path to the dotenv file. Defaults to `.env.local`.
310
- * @returns An {@link EnvProvider} named `dotenv:<path>` that reads fresh per call.
261
+ * @param options - Optional settings.
262
+ * @param options.globalKey - `globalThis` key to read a public-env snapshot from. Defaults to `"__ENV__"`.
263
+ * @returns An {@link EnvProvider} named `browser-env`.
311
264
  * @example
312
265
  * ```ts
313
- * const provider = dotenv(".env.local");
266
+ * const provider = browserEnv();
314
267
  * provider.load(); // { PUBLIC_API_URL: "/api", ... }
315
268
  * ```
316
269
  */
317
- function dotenv(path = DEFAULT_DOTENV_PATH) {
318
- return {
319
- name: `dotenv:${path}`,
320
- /**
321
- * Reads and parses the dotenv file fresh from disk; `{}` if it is missing.
322
- *
323
- * @returns The parsed environment record, or `{}` when the file is absent.
324
- * @example
325
- * ```ts
326
- * dotenv(".env.local").load();
327
- * ```
328
- */
329
- load() {
330
- if (!existsSync(path)) return {};
331
- return parseDotenv(readFileSync(path, "utf8"));
332
- }
333
- };
334
- }
335
- /**
336
- * A provider that returns a shallow copy of `process.env` at `load()` time.
337
- *
338
- * @returns An {@link EnvProvider} named `process-env`.
339
- * @example
340
- * ```ts
341
- * const provider = processEnv();
342
- * provider.load().HOME; // current process value
343
- * ```
344
- */
345
- function processEnv() {
270
+ function browserEnv(options) {
271
+ const globalKey = options?.globalKey ?? DEFAULT_GLOBAL_KEY;
346
272
  return {
347
- name: "process-env",
273
+ name: "browser-env",
348
274
  /**
349
- * Returns a shallow copy of `process.env` at call time.
275
+ * Merges `import.meta.env` with `globalThis[globalKey]`, the runtime global
276
+ * winning. Each absent source resolves to `{}`; never throws.
350
277
  *
351
- * @returns A fresh shallow copy of `process.env`.
278
+ * @returns The merged environment record.
352
279
  * @example
353
280
  * ```ts
354
- * processEnv().load();
281
+ * browserEnv().load();
355
282
  * ```
356
283
  */
357
284
  load() {
358
- return { ...process.env };
285
+ const importEnv = import.meta.env ?? {};
286
+ const globalObject = globalThis[globalKey] ?? {};
287
+ return {
288
+ ...importEnv,
289
+ ...globalObject
290
+ };
359
291
  }
360
292
  };
361
293
  }
@@ -801,10 +733,7 @@ const logPlugin = createCorePlugin("log", {
801
733
  const coreConfig = createCoreConfig("web", {
802
734
  config: { mode: "production" },
803
735
  plugins: [logPlugin, envPlugin],
804
- pluginConfigs: {
805
- log: { mode: "production" },
806
- env: { providers: [dotenv(), processEnv()] }
807
- }
736
+ pluginConfigs: { log: { mode: "production" } }
808
737
  });
809
738
  /**
810
739
  * Create a custom plugin bound to this framework's `Config`/`Events` and the core
@@ -1373,6 +1302,23 @@ function articleToUrl(locale, slug) {
1373
1302
  return `/${locale}/${slug}/`;
1374
1303
  }
1375
1304
  /**
1305
+ * Build the canonical "article not found" error for {@link createContentApi.load}.
1306
+ * Centralised so the null-resolve path and the production draft-suppression path
1307
+ * throw an IDENTICAL message — drafts must be indistinguishable from missing
1308
+ * articles in production (no new error shape).
1309
+ *
1310
+ * @param slug - Article directory name.
1311
+ * @param locale - Requested locale code.
1312
+ * @returns The not-found Error to throw.
1313
+ * @example
1314
+ * ```ts
1315
+ * throw articleNotFound("intro", "uk");
1316
+ * ```
1317
+ */
1318
+ function articleNotFound(slug, locale) {
1319
+ return /* @__PURE__ */ new Error(`[web] content article "${slug}" not found for locale "${locale}".\n Looked for ${slug}/${locale}.md and the default-locale fallback.`);
1320
+ }
1321
+ /**
1376
1322
  * Plugin `api` factory: assembles the kernel-free {@link ContentApiContext} from
1377
1323
  * the plugin context (resolving i18n via `ctx.require`) and delegates to
1378
1324
  * {@link createContentApi}. Referenced directly as the plugin's `api` so
@@ -1606,11 +1552,16 @@ function createContentApi(ctx) {
1606
1552
  /**
1607
1553
  * Resolve and render a single article for one locale with locale fallback.
1608
1554
  * Throws a `[web] content` error when neither the requested nor the
1609
- * default-locale file exists.
1555
+ * default-locale file exists. In production a `draft` article is suppressed
1556
+ * and throws the SAME not-found error (drafts must be indistinguishable from
1557
+ * missing articles so unpublished content is never disclosed); in
1558
+ * development drafts load normally.
1610
1559
  *
1611
1560
  * @param slug - Article directory name.
1612
1561
  * @param locale - Requested locale code.
1613
1562
  * @returns The resolved Article.
1563
+ * @throws {Error} `[web] content` not-found when no file matches, or when the
1564
+ * resolved article is a draft and `global.mode === "production"`.
1614
1565
  * @example
1615
1566
  * ```ts
1616
1567
  * const article = await api.load("intro", "uk");
@@ -1618,7 +1569,8 @@ function createContentApi(ctx) {
1618
1569
  */
1619
1570
  async load(slug, locale) {
1620
1571
  const article = await resolveArticle(ctx, slug, locale);
1621
- if (article === null) throw new Error(`[web] content article "${slug}" not found for locale "${locale}".\n Looked for ${slug}/${locale}.md and the default-locale fallback.`);
1572
+ if (article === null) throw articleNotFound(slug, locale);
1573
+ if (ctx.global.mode === "production" && article.computed.status === "draft") throw articleNotFound(slug, locale);
1622
1574
  const cache = ctx.state.articles.get(locale) ?? /* @__PURE__ */ new Map();
1623
1575
  cache.set(slug, article);
1624
1576
  ctx.state.articles.set(locale, cache);
@@ -2096,6 +2048,24 @@ function toTypedRoute(entry) {
2096
2048
  };
2097
2049
  }
2098
2050
  /**
2051
+ * Project a compiled route into the serializable {@link ClientRoute} view: only
2052
+ * `pattern` / `name` / `meta`, with a fresh `meta` copy and NO `_handlers` closures.
2053
+ *
2054
+ * @param entry - The compiled route entry.
2055
+ * @returns A `ClientRoute` carrying only JSON-serializable fields.
2056
+ * @example
2057
+ * ```ts
2058
+ * toClientRoute(compiledEntry); // { pattern, name, meta }
2059
+ * ```
2060
+ */
2061
+ function toClientRoute(entry) {
2062
+ return {
2063
+ pattern: entry.pattern,
2064
+ name: entry.name,
2065
+ meta: { ...entry.meta }
2066
+ };
2067
+ }
2068
+ /**
2099
2069
  * Creates the router plugin API surface. Every closure reads the compiled table
2100
2070
  * from `ctx.state` and returns values/fresh copies — never the raw state arrays.
2101
2071
  *
@@ -2165,11 +2135,113 @@ function createApi$4(ctx) {
2165
2135
  */
2166
2136
  manifest() {
2167
2137
  return [...readTable(state).byName.values()].map((entry) => entry.definition);
2138
+ },
2139
+ /**
2140
+ * Serializable, specificity-sorted projection of the route table for client
2141
+ * shipping — `{ pattern, name, meta }` entries with NO `_handlers` closures.
2142
+ *
2143
+ * @returns A fresh, frozen, specificity-sorted read-only array of client routes.
2144
+ * @example
2145
+ * ```ts
2146
+ * const json = JSON.stringify(api.clientManifest());
2147
+ * ```
2148
+ */
2149
+ clientManifest() {
2150
+ return Object.freeze(readTable(state).compiled.map((entry) => toClientRoute(entry)));
2151
+ },
2152
+ /**
2153
+ * The resolved render mode (single source of truth for static/hybrid/spa).
2154
+ *
2155
+ * @returns `"ssg" | "spa" | "hybrid"`.
2156
+ * @example
2157
+ * ```ts
2158
+ * if (api.mode() !== "ssg") emitClientData();
2159
+ * ```
2160
+ */
2161
+ mode() {
2162
+ return state.mode;
2168
2163
  }
2169
2164
  };
2170
2165
  }
2171
2166
  //#endregion
2167
+ //#region src/plugins/router/iso-match.ts
2168
+ /**
2169
+ * Parse a single path segment into its `{…}` placeholder, or `false` for a static
2170
+ * segment. Plain loop over the brace delimiters (no backtracking regex). Shared by
2171
+ * the build-time compiler and this isomorphic matcher so the two never diverge.
2172
+ *
2173
+ * @param segment - One `/`-delimited segment, e.g. `{slug}` or `about`.
2174
+ * @returns The parsed placeholder, or `false` when the segment is static.
2175
+ * @example
2176
+ * ```ts
2177
+ * parsePlaceholder("{slug:?}"); // { name: "slug", optional: true }
2178
+ * ```
2179
+ */
2180
+ function parsePlaceholder$1(segment) {
2181
+ if (!segment.startsWith("{") || !segment.endsWith("}")) return false;
2182
+ const inner = segment.slice(1, -1);
2183
+ if (inner.endsWith(":?")) return {
2184
+ name: inner.slice(0, -2),
2185
+ optional: true
2186
+ };
2187
+ return {
2188
+ name: inner,
2189
+ optional: false
2190
+ };
2191
+ }
2192
+ /**
2193
+ * Counts the dynamic (`{param}` / `{param:?}` / `:param`) segments in a route
2194
+ * pattern — fewer dynamic segments rank as more specific. The optional `{lang:?}`
2195
+ * segment is excluded so locale-prefixing does not affect priority (identical to
2196
+ * the build-time compiler's count, which sourced this logic).
2197
+ *
2198
+ * @param pattern - The route pattern string.
2199
+ * @returns The number of dynamic (non-lang) segments.
2200
+ * @example
2201
+ * ```ts
2202
+ * dynamicSegmentCount("/blog/{slug}/"); // 1
2203
+ * dynamicSegmentCount("/{lang:?}/{slug}/"); // 1
2204
+ * ```
2205
+ */
2206
+ function dynamicSegmentCount(pattern) {
2207
+ let count = 0;
2208
+ for (const segment of pattern.split("/")) {
2209
+ const placeholder = parsePlaceholder$1(segment);
2210
+ const isBraceDynamic = placeholder && !(placeholder.name === "lang" && placeholder.optional);
2211
+ const isColonDynamic = !placeholder && segment.startsWith(":");
2212
+ if (isBraceDynamic || isColonDynamic) count += 1;
2213
+ }
2214
+ return count;
2215
+ }
2216
+ /**
2217
+ * Comparator that orders two routes most-specific-first (fewest dynamic segments
2218
+ * first). Equal specificity yields `0` so a stable sort preserves declaration
2219
+ * order — the exact ordering the compiled matcher table uses, guaranteeing
2220
+ * build-time and client-time route resolution can never diverge.
2221
+ *
2222
+ * @param a - First route (carries its `pattern` string).
2223
+ * @param a.pattern - First route's pattern string.
2224
+ * @param b - Second route (carries its `pattern` string).
2225
+ * @param b.pattern - Second route's pattern string.
2226
+ * @returns Negative if `a` is more specific, positive if `b` is, `0` on a tie.
2227
+ * @example
2228
+ * ```ts
2229
+ * routes.toSorted(bySpecificity);
2230
+ * ```
2231
+ */
2232
+ function bySpecificity(a, b) {
2233
+ return dynamicSegmentCount(a.pattern) - dynamicSegmentCount(b.pattern);
2234
+ }
2235
+ //#endregion
2172
2236
  //#region src/plugins/router/builders/compile.ts
2237
+ /**
2238
+ * @file router plugin — compilation + validation domain.
2239
+ *
2240
+ * Pure functions invoked from `onInit`: validate the route map, then compile each
2241
+ * route into URLPattern matchers + URL/file builders, count dynamic segments,
2242
+ * sort by specificity, and assemble the immutable `MatcherTable`. Receives DATA
2243
+ * only (`CompileInput`) — never the plugin ctx.
2244
+ */
2173
2245
  /** Shared `[web]` error prefix for router validation failures. */
2174
2246
  const ERROR_PREFIX$8 = "[web] router";
2175
2247
  /**
@@ -2284,27 +2356,6 @@ function buildFilePath(pattern, params) {
2284
2356
  return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
2285
2357
  }
2286
2358
  /**
2287
- * Count dynamic segments in a pattern (lower = more specific). The optional
2288
- * `{lang:?}` segment is excluded so locale-prefixing does not affect priority.
2289
- *
2290
- * @param pattern - The route pattern.
2291
- * @returns The number of dynamic (non-lang) segments.
2292
- * @example
2293
- * ```ts
2294
- * countDynamicSegments("/{lang:?}/{slug}/"); // 1
2295
- * ```
2296
- */
2297
- function countDynamicSegments(pattern) {
2298
- let count = 0;
2299
- for (const segment of pattern.split("/")) {
2300
- const placeholder = parsePlaceholder(segment);
2301
- const isBraceDynamic = placeholder && !(placeholder.name === "lang" && placeholder.optional);
2302
- const isColonDynamic = !placeholder && segment.startsWith(":");
2303
- if (isBraceDynamic || isColonDynamic) count += 1;
2304
- }
2305
- return count;
2306
- }
2307
- /**
2308
2359
  * Compile a single route definition into its `CompiledRoute` entry.
2309
2360
  *
2310
2361
  * @param name - The route name key.
@@ -2326,7 +2377,7 @@ function compileRoute(name, definition, input) {
2326
2377
  return {
2327
2378
  name,
2328
2379
  pattern,
2329
- dynamicSegmentCount: countDynamicSegments(pattern),
2380
+ dynamicSegmentCount: dynamicSegmentCount(pattern),
2330
2381
  matchers,
2331
2382
  matchFn: createMatchFunction(matchers, input.defaultLocale),
2332
2383
  /**
@@ -2383,10 +2434,7 @@ function compileRoutes(input) {
2383
2434
  byName.set(name, entry);
2384
2435
  }
2385
2436
  return {
2386
- compiled: declarationOrder.map((entry, index) => ({
2387
- entry,
2388
- index
2389
- })).toSorted((a, b) => a.entry.dynamicSegmentCount === b.entry.dynamicSegmentCount ? a.index - b.index : a.entry.dynamicSegmentCount - b.entry.dynamicSegmentCount).map((wrapped) => wrapped.entry),
2437
+ compiled: declarationOrder.toSorted(bySpecificity),
2390
2438
  byName
2391
2439
  };
2392
2440
  }
@@ -2499,6 +2547,21 @@ function route(pattern) {
2499
2547
  return set("render", handler);
2500
2548
  },
2501
2549
  /**
2550
+ * Attach the client-side validation gate (raw `unknown` → this route's data
2551
+ * type). Runs at the trust boundary before `render` on the client; throw to
2552
+ * reject malformed data (spa falls back to HTML-over-fetch).
2553
+ *
2554
+ * @param handler - The validator/parser.
2555
+ * @returns The same builder for chaining.
2556
+ * @example
2557
+ * ```ts
2558
+ * route("/shop/{id}/").parse(raw => ProductSchema.parse(raw));
2559
+ * ```
2560
+ */
2561
+ parse(handler) {
2562
+ return set("parse", handler);
2563
+ },
2564
+ /**
2502
2565
  * Attach the head/SEO handler.
2503
2566
  *
2504
2567
  * @param handler - The head handler.
@@ -2525,9 +2588,11 @@ function route(pattern) {
2525
2588
  return set("generate", handler);
2526
2589
  },
2527
2590
  /**
2528
- * Merge an arbitrary metadata bag into the route's `_meta`.
2591
+ * Merge an arbitrary metadata bag into the route's `_meta`. The bag MUST be
2592
+ * JSON-serializable — it is projected verbatim into `clientManifest()` and
2593
+ * shipped to the browser, so functions/symbols/class instances are unsupported.
2529
2594
  *
2530
- * @param meta - Metadata to merge.
2595
+ * @param meta - JSON-serializable metadata to merge.
2531
2596
  * @returns The same builder for chaining.
2532
2597
  * @example
2533
2598
  * ```ts
@@ -2597,7 +2662,10 @@ function defineRoutes(routes) {
2597
2662
  * ```
2598
2663
  */
2599
2664
  function createState$4(_ctx) {
2600
- return { table: null };
2665
+ return {
2666
+ table: null,
2667
+ mode: _ctx.config.mode ?? "hybrid"
2668
+ };
2601
2669
  }
2602
2670
  /**
2603
2671
  * Router plugin — typed, named route definitions with locale-aware URL generation
@@ -3215,6 +3283,29 @@ function resolveEntrypoints(candidates) {
3215
3283
  return [];
3216
3284
  }
3217
3285
  /**
3286
+ * Resolve the authoritative JS client entrypoint (#8): when `config.clientEntry` is
3287
+ * set, use it directly (the authoritative override); otherwise fall back to the
3288
+ * conventional candidate scan. When neither yields an entry, `ctx.log.warn` (no
3289
+ * client bundle is produced) and an empty list is returned.
3290
+ *
3291
+ * @param ctx - Plugin context (provides `config`, `log`).
3292
+ * @returns The resolved JS entrypoint list (possibly empty).
3293
+ * @example
3294
+ * ```ts
3295
+ * resolveJsEntrypoints(ctx);
3296
+ * ```
3297
+ */
3298
+ function resolveJsEntrypoints(ctx) {
3299
+ const { clientEntry } = ctx.config;
3300
+ if (typeof clientEntry === "string" && clientEntry.length > 0) return [clientEntry];
3301
+ const scanned = resolveEntrypoints(JS_ENTRY_CANDIDATES);
3302
+ if (scanned.length === 0) ctx.log.warn("build:bundle", {
3303
+ clientEntry: "none",
3304
+ scanned: JS_ENTRY_CANDIDATES
3305
+ });
3306
+ return scanned;
3307
+ }
3308
+ /**
3218
3309
  * Run one bundler pass for a single asset kind and record the hashed output
3219
3310
  * paths under `state.buildCache` keyed by the original entry basename.
3220
3311
  *
@@ -3262,7 +3353,7 @@ async function bundle(ctx, options = {}) {
3262
3353
  const runner = options.runner ?? defaultRunner;
3263
3354
  const { minify, outDir } = ctx.config;
3264
3355
  const cssEntrypoints = options.cssEntrypoints ?? resolveEntrypoints(CSS_ENTRY_CANDIDATES);
3265
- const jsEntrypoints = options.jsEntrypoints ?? resolveEntrypoints(JS_ENTRY_CANDIDATES);
3356
+ const jsEntrypoints = options.jsEntrypoints ?? resolveJsEntrypoints(ctx);
3266
3357
  await runOne(ctx, runner, "css", cssEntrypoints, path.join(outDir, "assets"), minify);
3267
3358
  await runOne(ctx, runner, "js", jsEntrypoints, path.join(outDir, "assets"), minify);
3268
3359
  }
@@ -3429,119 +3520,370 @@ async function processImages(ctx, options = {}) {
3429
3520
  return copied;
3430
3521
  }
3431
3522
  //#endregion
3432
- //#region src/plugins/build/phases/og-images.tsx
3523
+ //#region src/plugins/build/phases/locale-redirects.ts
3433
3524
  /**
3434
- * @file build phase 4 og-images. Renders one OG image per published article via
3435
- * Satori SVG resvg PNG, bounded by `p-limit(4)`, with a persisted
3436
- * content-hash cache (`<outDir>/.cache/og-images.json`) skipping unchanged articles.
3437
- * Gated by config.ogImage (object enables; false disables).
3525
+ * @file build phase — locale-redirects. For each non-prefixed route path, emits a
3526
+ * redirect HTML page (`<meta http-equiv="refresh">` + canonical `<link>`) at the
3527
+ * bare path that points at the default-locale-prefixed URL. Deliberately does NOT
3528
+ * emit a Cloudflare `_redirects` catch-all (an SSG infinite-loop trap). Gated by
3529
+ * `config.localeRedirects` (false/unset disables).
3438
3530
  */
3439
- /** Default OG image dimensions when `size` is omitted. */
3440
- const DEFAULT_SIZE = {
3441
- width: 1200,
3442
- height: 630
3443
- };
3444
- /** Recognized font file extensions. */
3445
- const FONT_EXTENSIONS$1 = [
3446
- ".ttf",
3447
- ".otf",
3448
- ".woff"
3449
- ];
3450
3531
  /**
3451
- * Compute the content-hash cache key for an article: `sha256(title+template+size)`.
3532
+ * Render a redirect HTML page: a `0;url` refresh meta + a canonical link to `target`.
3452
3533
  *
3453
- * @param title - The article title.
3454
- * @param template - The resolved OG template identifier.
3455
- * @param size - The output dimensions.
3456
- * @returns The hex-encoded SHA-256 digest.
3534
+ * @param target - The default-locale-prefixed URL to redirect to.
3535
+ * @returns The complete redirect HTML document string.
3457
3536
  * @example
3458
3537
  * ```ts
3459
- * ogHash("Hello", "default", { width: 1200, height: 630 });
3538
+ * redirectHtml("/en/about/");
3460
3539
  * ```
3461
3540
  */
3462
- function ogHash(title, template, size) {
3463
- return createHash("sha256").update(`${title}|${template}|${size.width}x${size.height}`).digest("hex");
3541
+ function redirectHtml(target) {
3542
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${target}"><link rel="canonical" href="${target}"></head><body><a href="${target}">Redirecting…</a></body></html>`;
3464
3543
  }
3465
3544
  /**
3466
- * Resolve the first font file in `fontDir` and read its bytes for Satori.
3545
+ * Correlate manifest definitions to compiled `TypedRoute` entries by pattern (the
3546
+ * shared stable key); routes without a compiled entry are skipped.
3467
3547
  *
3468
- * @param fontDir - Directory containing at least one font file.
3469
- * @returns The font name + bytes, or `null` when no font is present.
3548
+ * @param router - The router API exposing `manifest` + `entries`.
3549
+ * @returns Pairs of `[definition, entry]` for every correlated route.
3470
3550
  * @example
3471
3551
  * ```ts
3472
- * await loadFont("./fonts");
3552
+ * pairRoutes(router);
3473
3553
  * ```
3474
3554
  */
3475
- async function loadFont(fontDir) {
3476
- if (!existsSync(fontDir)) return void 0;
3477
- const font = (await readdir(fontDir)).find((name) => FONT_EXTENSIONS$1.some((extension) => name.endsWith(extension)));
3478
- if (!font) return void 0;
3479
- return {
3480
- name: "OG",
3481
- data: await readFile(path.join(fontDir, font))
3482
- };
3555
+ function pairRoutes(router) {
3556
+ const byPattern = /* @__PURE__ */ new Map();
3557
+ for (const entry of router.entries()) byPattern.set(entry.pattern, entry);
3558
+ const pairs = [];
3559
+ for (const definition of router.manifest()) {
3560
+ const entry = byPattern.get(definition.pattern);
3561
+ if (entry) pairs.push([definition, entry]);
3562
+ }
3563
+ return pairs;
3483
3564
  }
3484
3565
  /**
3485
- * The default PNG renderer: Satori renders a card to SVG, resvg rasterizes to PNG.
3566
+ * Expand one route into bare→default redirect jobs for the default locale. Uses
3567
+ * `generate?.(defaultLocale)` (or a single empty-params instance) and emits a job
3568
+ * only when the bare file path differs from the default-locale URL (i.e. the route
3569
+ * is locale-prefixed) — otherwise no redirect is needed.
3486
3570
  *
3487
- * @param ctx - The font directory + template wiring for the renderer.
3488
- * @param ctx.fontDir - Directory containing at least one font file.
3489
- * @returns An {@link OgPngRenderer} bound to the loaded font.
3571
+ * @param definition - The route definition (carries `generate`).
3572
+ * @param entry - The compiled `TypedRoute` (owns `toFile`/`toUrl`).
3573
+ * @param defaultLocale - The default locale to redirect bare paths to.
3574
+ * @returns Redirect jobs of `{ file, target }` for this route.
3490
3575
  * @example
3491
3576
  * ```ts
3492
- * const render = makeDefaultRenderer({ fontDir: "./fonts" });
3577
+ * await expandRedirects(def, entry, "en");
3493
3578
  * ```
3494
3579
  */
3495
- function makeDefaultRenderer(ctx) {
3496
- const fontPromise = loadFont(ctx.fontDir);
3497
- return async ({ title, width, height }) => {
3498
- const font = await fontPromise;
3499
- if (!font) throw new Error("[web] build.ogImage no font available for rendering");
3500
- return new Resvg(await satori(/* @__PURE__ */ jsx("div", {
3501
- style: {
3502
- display: "flex",
3503
- width: "100%",
3504
- height: "100%",
3505
- alignItems: "center",
3506
- justifyContent: "center",
3507
- fontSize: 64,
3508
- background: "#0b0b0c",
3509
- color: "#ffffff"
3510
- },
3511
- children: title
3512
- }), {
3513
- width,
3514
- height,
3515
- fonts: [{
3516
- name: font.name,
3517
- data: font.data,
3518
- weight: 400,
3519
- style: "normal"
3520
- }]
3521
- })).render().asPng();
3522
- };
3580
+ async function expandRedirects(definition, entry, defaultLocale) {
3581
+ const parameterSets = definition._handlers.generate ? await definition._handlers.generate(defaultLocale) : [{}];
3582
+ const jobs = [];
3583
+ for (const raw of parameterSets) {
3584
+ const params = raw ?? {};
3585
+ const file = entry.toFile(params);
3586
+ const target = entry.toUrl({
3587
+ ...params,
3588
+ lang: defaultLocale
3589
+ });
3590
+ if (target !== entry.toUrl(params)) jobs.push({
3591
+ file,
3592
+ target
3593
+ });
3594
+ }
3595
+ return jobs;
3523
3596
  }
3524
3597
  /**
3525
- * Select the published articles to render OG images for (default-locale set).
3598
+ * Emits one bare-path redirect HTML page per locale-prefixed route path, each a
3599
+ * `0;url` refresh + canonical link to the default-locale URL. Never writes a
3600
+ * Cloudflare `_redirects` file. No-op (returns `null`) when `localeRedirects` is
3601
+ * false/unset.
3526
3602
  *
3527
- * @param byLocale - The cached locale-keyed article map.
3528
- * @returns The published articles across the first cached locale.
3603
+ * @param ctx - Plugin context (provides `require`, `config`, `log`).
3604
+ * @returns The count of redirect pages written, or `null` when disabled.
3529
3605
  * @example
3530
3606
  * ```ts
3531
- * selectArticles(byLocale);
3607
+ * const result = await generateLocaleRedirects(ctx);
3532
3608
  * ```
3533
3609
  */
3534
- function selectArticles(byLocale) {
3535
- return ([...byLocale.values()][0] ?? []).filter((article) => article.computed.status === "published");
3610
+ async function generateLocaleRedirects(ctx) {
3611
+ if (!ctx.config.localeRedirects) {
3612
+ ctx.log.debug("build:locale-redirects", { skipped: true });
3613
+ return null;
3614
+ }
3615
+ const router = ctx.require(routerPlugin);
3616
+ const defaultLocale = ctx.require(i18nPlugin).defaultLocale();
3617
+ const jobs = (await Promise.all(pairRoutes(router).map(([definition, entry]) => expandRedirects(definition, entry, defaultLocale)))).flat();
3618
+ await Promise.all(jobs.map(async ({ file, target }) => {
3619
+ const filePath = path.join(ctx.config.outDir, file);
3620
+ await mkdir(path.dirname(filePath), { recursive: true });
3621
+ await writeFile(filePath, redirectHtml(target), "utf8");
3622
+ }));
3623
+ ctx.log.debug("build:locale-redirects", { written: jobs.length });
3624
+ return { written: jobs.length };
3536
3625
  }
3626
+ //#endregion
3627
+ //#region src/plugins/build/phases/not-found.ts
3537
3628
  /**
3538
- * Renders OG images for published articles with a `p-limit(4)` concurrency pool.
3539
- * Computes `sha256(title+template+size)` per article and skips regeneration when
3540
- * the hash matches `state.ogImageHashCache`; writes the cache back to
3541
- * `<outDir>/.cache/og-images.json`. No-op when `config.ogImage` is false.
3629
+ * @file build phase not-found. Emits `outDir/404.html` from configured route
3630
+ * content or a built-in default. Gated by `config.notFound` (false/unset disables).
3631
+ */
3632
+ /** The built-in default 404 page body when no custom route content is supplied. */
3633
+ const DEFAULT_BODY = "<h1>404</h1><p>The page you requested could not be found.</p>";
3634
+ /**
3635
+ * Wrap a body fragment in a minimal HTML document for the 404 page.
3542
3636
  *
3543
- * @param ctx - Plugin context (provides `state`, `config`, `log`).
3544
- * @param options - Optional dependency-injection seam (PNG renderer).
3637
+ * @param body - The inner body HTML (default or configured).
3638
+ * @returns The complete HTML document string.
3639
+ * @example
3640
+ * ```ts
3641
+ * wrap("<h1>404</h1>");
3642
+ * ```
3643
+ */
3644
+ function wrap(body) {
3645
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>404 — Not Found</title></head><body>${body}</body></html>`;
3646
+ }
3647
+ /**
3648
+ * Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
3649
+ * default page; when it is `{ route }`, writes the supplied route content verbatim
3650
+ * inside the document shell. No-op (returns `null`) when `notFound` is false/unset.
3651
+ *
3652
+ * @param ctx - Plugin context (provides `config`, `log`).
3653
+ * @returns The written file path, or `null` when disabled.
3654
+ * @example
3655
+ * ```ts
3656
+ * const result = await generateNotFound(ctx);
3657
+ * ```
3658
+ */
3659
+ async function generateNotFound(ctx) {
3660
+ const { notFound, outDir } = ctx.config;
3661
+ if (!notFound) {
3662
+ ctx.log.debug("build:not-found", { skipped: true });
3663
+ return null;
3664
+ }
3665
+ const body = typeof notFound === "object" && notFound.route ? notFound.route : DEFAULT_BODY;
3666
+ await mkdir(outDir, { recursive: true });
3667
+ const file = path.join(outDir, "404.html");
3668
+ await writeFile(file, wrap(body), "utf8");
3669
+ ctx.log.debug("build:not-found", { path: file });
3670
+ return { path: file };
3671
+ }
3672
+ //#endregion
3673
+ //#region src/plugins/build/phases/og-images.tsx
3674
+ /**
3675
+ * @file build phase 4 — og-images. Renders one OG image per published article via
3676
+ * Satori → SVG → resvg → PNG, bounded by `p-limit(4)`, with a persisted
3677
+ * content-hash cache (`<outDir>/.cache/og-images.json`) skipping unchanged articles.
3678
+ * Gated by config.ogImage (object enables; false disables).
3679
+ */
3680
+ /** Default OG image dimensions when `size` is omitted. */
3681
+ const DEFAULT_SIZE = {
3682
+ width: 1200,
3683
+ height: 630
3684
+ };
3685
+ /** Recognized font file extensions. */
3686
+ const FONT_EXTENSIONS$1 = [
3687
+ ".ttf",
3688
+ ".otf",
3689
+ ".woff"
3690
+ ];
3691
+ /**
3692
+ * Compute a stable cache key for the `fonts` configuration so a font change
3693
+ * invalidates cached PNGs. Hashes the name/path/weight/style of each entry (order
3694
+ * preserved); an empty/omitted list yields a fixed sentinel.
3695
+ *
3696
+ * @param fonts - The configured OG fonts (optional).
3697
+ * @returns A short stable key derived from the fonts list.
3698
+ * @example
3699
+ * ```ts
3700
+ * fontsKey([{ name: "Inter", path: "./Inter.ttf" }]);
3701
+ * ```
3702
+ */
3703
+ function fontsKey(fonts) {
3704
+ if (!fonts || fonts.length === 0) return "default-font";
3705
+ const parts = fonts.map((font) => `${font.name}:${font.path}:${font.weight ?? 400}:${font.style ?? "normal"}`);
3706
+ return createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 16);
3707
+ }
3708
+ /**
3709
+ * Compute the content-hash cache key for an article OG image. Covers the FULL
3710
+ * {@link RichOgInput} (title/description/date/tags/author/locale/siteName/size),
3711
+ * the resolved `template`, and a {@link fontsKey} of the fonts list — so changing
3712
+ * any input field OR the fonts invalidates the cached PNG.
3713
+ *
3714
+ * @param input - The full rich OG input for the card.
3715
+ * @param template - The resolved OG template identifier.
3716
+ * @param fontsHash - The {@link fontsKey} of the configured fonts.
3717
+ * @returns The hex-encoded SHA-256 digest.
3718
+ * @example
3719
+ * ```ts
3720
+ * ogHash(input, "default", fontsKey());
3721
+ * ```
3722
+ */
3723
+ function ogHash(input, template, fontsHash) {
3724
+ const payload = [
3725
+ input.title,
3726
+ input.description,
3727
+ input.date,
3728
+ input.tags.join(","),
3729
+ input.author ?? "",
3730
+ input.locale,
3731
+ input.siteName,
3732
+ `${input.size.width}x${input.size.height}`,
3733
+ template,
3734
+ fontsHash
3735
+ ].join("|");
3736
+ return createHash("sha256").update(payload).digest("hex");
3737
+ }
3738
+ /**
3739
+ * Load the configured OG fonts ONCE per build. When `ogImage.fonts` is set, each
3740
+ * `path` is read to a Buffer (outside any per-image loop) and mapped to a Satori
3741
+ * font entry; otherwise the first font file found in `fontDir` is used as a single
3742
+ * 400/normal fallback.
3743
+ *
3744
+ * @param og - The font directory + optional explicit fonts list.
3745
+ * @param og.fontDir - Directory scanned for a fallback font when `fonts` is unset.
3746
+ * @param og.fonts - Explicit named fonts (each loaded once).
3747
+ * @returns The loaded fonts (empty when no font is available).
3748
+ * @example
3749
+ * ```ts
3750
+ * await loadFonts({ fontDir: "./fonts" });
3751
+ * ```
3752
+ */
3753
+ async function loadFonts(og) {
3754
+ if (og.fonts && og.fonts.length > 0) return Promise.all(og.fonts.map(async (font) => ({
3755
+ name: font.name,
3756
+ data: await readFile(font.path),
3757
+ weight: font.weight ?? 400,
3758
+ style: font.style ?? "normal"
3759
+ })));
3760
+ if (!existsSync(og.fontDir)) return [];
3761
+ const file = (await readdir(og.fontDir)).find((name) => FONT_EXTENSIONS$1.some((extension) => name.endsWith(extension)));
3762
+ if (!file) return [];
3763
+ return [{
3764
+ name: "OG",
3765
+ data: await readFile(path.join(og.fontDir, file)),
3766
+ weight: 400,
3767
+ style: "normal"
3768
+ }];
3769
+ }
3770
+ /**
3771
+ * The built-in default OG card — a centered title on a dark background. Used when
3772
+ * no custom `ogImage.render` hook is configured. (`@jsxImportSource preact`.)
3773
+ *
3774
+ * @param input - The rich OG input (only `title` is used by the default card).
3775
+ * @returns The Preact `VNode` for the default card.
3776
+ * @example
3777
+ * ```ts
3778
+ * defaultCard(input);
3779
+ * ```
3780
+ */
3781
+ function defaultCard(input) {
3782
+ return h("div", { style: {
3783
+ display: "flex",
3784
+ width: "100%",
3785
+ height: "100%",
3786
+ alignItems: "center",
3787
+ justifyContent: "center",
3788
+ fontSize: 64,
3789
+ background: "#0b0b0c",
3790
+ color: "#ffffff"
3791
+ } }, input.title);
3792
+ }
3793
+ /**
3794
+ * The default PNG renderer: a Preact `VNode` (custom `render` hook or the built-in
3795
+ * card) is rendered to SVG by Satori, then rasterized to PNG by resvg. Both native
3796
+ * deps are imported LAZILY (browser-safe goal); the VNode→Satori-input cast happens
3797
+ * at this single framework boundary only.
3798
+ *
3799
+ * @param ctx - The renderer wiring (preloaded fonts + optional custom card).
3800
+ * @param ctx.fonts - Fonts loaded once for the whole render pass.
3801
+ * @param ctx.render - Optional custom card renderer; defaults to {@link defaultCard}.
3802
+ * @returns An {@link OgPngRenderer} bound to the loaded fonts + renderer.
3803
+ * @example
3804
+ * ```ts
3805
+ * const render = makeDefaultRenderer({ fonts, render: undefined });
3806
+ * ```
3807
+ */
3808
+ function makeDefaultRenderer(ctx) {
3809
+ return async (input) => {
3810
+ if (ctx.fonts.length === 0) throw new Error("[web] build.ogImage no font available for rendering");
3811
+ const { default: satori } = await import("satori");
3812
+ const { Resvg } = await import("@resvg/resvg-js");
3813
+ return new Resvg(await satori((ctx.render ?? defaultCard)(input), {
3814
+ width: input.size.width,
3815
+ height: input.size.height,
3816
+ fonts: ctx.fonts
3817
+ })).render().asPng();
3818
+ };
3819
+ }
3820
+ /**
3821
+ * Select the published articles to render OG images for (default-locale set).
3822
+ *
3823
+ * @param byLocale - The cached locale-keyed article map.
3824
+ * @returns The published articles across the first cached locale.
3825
+ * @example
3826
+ * ```ts
3827
+ * selectArticles(byLocale);
3828
+ * ```
3829
+ */
3830
+ function selectArticles(byLocale) {
3831
+ return ([...byLocale.values()][0] ?? []).filter((article) => article.computed.status === "published");
3832
+ }
3833
+ /**
3834
+ * Build the {@link RichOgInput} for one article from its frontmatter/computed
3835
+ * fields plus the resolved size and site name.
3836
+ *
3837
+ * @param article - The published article to render a card for.
3838
+ * @param size - The resolved OG output dimensions.
3839
+ * @param siteName - The site name (from the site plugin, or `""` when unavailable).
3840
+ * @returns The fully-populated rich OG input.
3841
+ * @example
3842
+ * ```ts
3843
+ * buildInput(article, { width: 1200, height: 630 }, "Blog");
3844
+ * ```
3845
+ */
3846
+ function buildInput(article, size, siteName) {
3847
+ const input = {
3848
+ title: article.frontmatter.title,
3849
+ description: article.frontmatter.description,
3850
+ date: article.frontmatter.date,
3851
+ tags: [...article.frontmatter.tags],
3852
+ locale: article.locale,
3853
+ siteName,
3854
+ size
3855
+ };
3856
+ if (article.frontmatter.author !== void 0) input.author = article.frontmatter.author;
3857
+ return input;
3858
+ }
3859
+ /**
3860
+ * Resolve the site name via `ctx.require(sitePlugin)`, falling back to `""` when the
3861
+ * site API is unavailable (e.g. unit mocks that omit it).
3862
+ *
3863
+ * @param ctx - Plugin context (provides `require`).
3864
+ * @returns The site name, or `""` when the site plugin is not wired.
3865
+ * @example
3866
+ * ```ts
3867
+ * resolveSiteName(ctx);
3868
+ * ```
3869
+ */
3870
+ function resolveSiteName(ctx) {
3871
+ try {
3872
+ return ctx.require(sitePlugin).name();
3873
+ } catch {
3874
+ return "";
3875
+ }
3876
+ }
3877
+ /**
3878
+ * Renders OG images for published articles with a `p-limit(4)` concurrency pool.
3879
+ * Computes {@link ogHash} (full {@link RichOgInput} + template + fonts) per article
3880
+ * and skips regeneration when the hash matches `state.ogImageHashCache`; writes the
3881
+ * cache back to `<outDir>/.cache/og-images.json`. The configured `ogImage.render`
3882
+ * hook (when present) builds each card; otherwise the built-in card is used. Fonts
3883
+ * are loaded ONCE for the whole pass. No-op when `config.ogImage` is false.
3884
+ *
3885
+ * @param ctx - Plugin context (provides `require`, `state`, `config`, `log`).
3886
+ * @param options - Optional dependency-injection seam (PNG rasterizer).
3545
3887
  * @returns The render/skip counts + peak concurrency, or `null` when disabled.
3546
3888
  * @example
3547
3889
  * ```ts
@@ -3555,9 +3897,17 @@ async function generateOgImages(ctx, options = {}) {
3555
3897
  return null;
3556
3898
  }
3557
3899
  const { default: pLimit } = await import("p-limit");
3558
- const size = og.size ?? DEFAULT_SIZE;
3559
- const template = og.template ?? "default";
3560
- const renderPng = options.renderPng ?? makeDefaultRenderer({ fontDir: og.fontDir });
3900
+ const config = og;
3901
+ const size = config.size ?? DEFAULT_SIZE;
3902
+ const template = config.template ?? "default";
3903
+ const fontsHash = fontsKey(config.fonts);
3904
+ const fonts = options.renderPng ? [] : await loadFonts(config);
3905
+ const renderHook = config.render ? { render: config.render } : {};
3906
+ const renderPng = options.renderPng ?? makeDefaultRenderer({
3907
+ fonts,
3908
+ ...renderHook
3909
+ });
3910
+ const siteName = resolveSiteName(ctx);
3561
3911
  const articles = selectArticles(readCachedContent(ctx));
3562
3912
  const cache = ctx.state.ogImageHashCache;
3563
3913
  await loadDiskCache(ctx.config.outDir, cache);
@@ -3569,7 +3919,8 @@ async function generateOgImages(ctx, options = {}) {
3569
3919
  const outDir = path.join(ctx.config.outDir, "og");
3570
3920
  await Promise.all(articles.map((article) => limit(async () => {
3571
3921
  const key = article.computed.contentId;
3572
- const hash = ogHash(article.frontmatter.title, template, size);
3922
+ const input = buildInput(article, size, siteName);
3923
+ const hash = ogHash(input, template, fontsHash);
3573
3924
  if (cache.get(key) === hash) {
3574
3925
  skipped += 1;
3575
3926
  return;
@@ -3577,10 +3928,7 @@ async function generateOgImages(ctx, options = {}) {
3577
3928
  active += 1;
3578
3929
  peakConcurrency = Math.max(peakConcurrency, active);
3579
3930
  try {
3580
- const png = await renderPng({
3581
- title: article.frontmatter.title,
3582
- ...size
3583
- });
3931
+ const png = await renderPng(input);
3584
3932
  await mkdir(outDir, { recursive: true });
3585
3933
  await writeFile(path.join(outDir, `${key}.png`), png);
3586
3934
  cache.set(key, hash);
@@ -3635,28 +3983,329 @@ async function persistDiskCache(outDir, cache) {
3635
3983
  await writeFile(path.join(dir, "og-images.json"), JSON.stringify(Object.fromEntries(cache)), "utf8");
3636
3984
  }
3637
3985
  //#endregion
3986
+ //#region src/plugins/data/load-json.ts
3987
+ /**
3988
+ * @file `loadJson` — the data plugin's isomorphic JSON read primitive (the
3989
+ * SSG↔SPA seam). Internal to the `data` plugin (NOT a framework-root export):
3990
+ * `data.load(locale)` uses it, and consumers read through `app.data.load(locale)`.
3991
+ *
3992
+ * A read runs in BOTH worlds: on Node it reads the emitted data file from disk;
3993
+ * on the client (browser) it fetches the same data over HTTP. `loadJson` is the
3994
+ * single point where those two worlds differ — everything above it (the route's
3995
+ * `load`/`render`) is shared, so SSR/client parity is structural, not hoped-for.
3996
+ *
3997
+ * The browser path uses the `fetch` global. The Node path lazy-imports
3998
+ * `node:fs/promises` via `await import(...)`, so a browser bundle that includes
3999
+ * `loadJson` never statically pulls `node:*` (the bundler splits the Node branch
4000
+ * into its own chunk that the browser never loads).
4001
+ */
4002
+ /**
4003
+ * Read + parse a JSON resource, isomorphically. In a browser (`document`
4004
+ * defined) it `fetch`es `pathOrUrl`; on Node it reads the file from disk. Throws
4005
+ * on a failed fetch or unreadable file so the caller (`route.load`/`data.load`)
4006
+ * can decide whether to fall back.
4007
+ *
4008
+ * @template T - The expected shape of the parsed JSON.
4009
+ * @param pathOrUrl - A site-root URL (browser) or filesystem path (Node).
4010
+ * @returns The parsed JSON, typed as `T`.
4011
+ * @throws {Error} If the browser fetch is not OK, or the Node file read fails.
4012
+ * @example
4013
+ * ```ts
4014
+ * // Browser: fetch("/_data/en/articles.json")
4015
+ * // Node: read "dist/_data/en/articles.json"
4016
+ * const articles = await loadJson<Article[]>("/_data/en/articles.json");
4017
+ * ```
4018
+ */
4019
+ async function loadJson(pathOrUrl) {
4020
+ if (typeof document === "undefined") {
4021
+ const { readFile } = await import("node:fs/promises");
4022
+ return JSON.parse(await readFile(pathOrUrl, "utf8"));
4023
+ }
4024
+ const response = await fetch(pathOrUrl);
4025
+ if (!response.ok) throw new Error(`[web] loadJson: failed to fetch ${pathOrUrl} (${String(response.status)}).`);
4026
+ return response.json();
4027
+ }
4028
+ //#endregion
4029
+ //#region src/plugins/data/api.ts
4030
+ /**
4031
+ * @file data plugin — API factory (the agnostic data provider surface).
4032
+ *
4033
+ * Node-free by construction: this module statically imports only types + the pure
4034
+ * convention. The Node write side (`write()`) reaches its `node:fs` writer through
4035
+ * a lazy `await import("./writer")` at call time, so a browser bundle that composes
4036
+ * `data` for the read side never pulls `node:*`. The read side (`at()`) uses only
4037
+ * the isomorphic `loadJson` (whose Node branch is itself lazy).
4038
+ */
4039
+ /**
4040
+ * Trim a single trailing slash from a config dir so `fileFor` joins cleanly.
4041
+ *
4042
+ * @param dir - The configured output dir (e.g. `"_data"` or `"_data/"`).
4043
+ * @returns The dir without a trailing slash.
4044
+ * @example
4045
+ * ```ts
4046
+ * trimTrailingSlash("_data/"); // "_data"
4047
+ * ```
4048
+ */
4049
+ function trimTrailingSlash(dir) {
4050
+ return dir.endsWith("/") ? dir.slice(0, -1) : dir;
4051
+ }
4052
+ /**
4053
+ * Builds the data provider — the agnostic bridge. `write()` is the Node persist
4054
+ * side; `at()` is the browser read side; `urlFor`/`fileFor` are the pure
4055
+ * convention. No `onStart`/`onStop` (holds no long-lived resource).
4056
+ *
4057
+ * @param ctx - The data plugin context.
4058
+ * @returns The {@link DataProvider} mounted at `app.data`.
4059
+ * @example
4060
+ * ```ts
4061
+ * const api = dataApi(ctx);
4062
+ * await api.write([{ path: "/en/hello/", data: article }]); // Node build
4063
+ * await api.at("/en/hello/"); // browser
4064
+ * ```
4065
+ */
4066
+ function dataApi(ctx) {
4067
+ return {
4068
+ /**
4069
+ * READ (browser) — fetch (and cache) the persisted data for a page path.
4070
+ * Returns the raw JSON as `unknown` (the caller's `route.parse` validates it),
4071
+ * or `null` if the fetch/parse fails (so `spa` can fall back to HTML).
4072
+ *
4073
+ * @param path - The page URL path (e.g. `/en/hello/`).
4074
+ * @returns The page's raw data, or `null` on failure.
4075
+ * @example
4076
+ * ```ts
4077
+ * const raw = await api.at("/en/hello/");
4078
+ * ```
4079
+ */
4080
+ async at(path) {
4081
+ if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
4082
+ try {
4083
+ const data = await loadJson(`${ctx.config.baseUrl}${dataSuffix(path)}`);
4084
+ ctx.state.cache.set(path, data);
4085
+ return data;
4086
+ } catch {
4087
+ return null;
4088
+ }
4089
+ },
4090
+ /**
4091
+ * WRITE (Node) — persist one JSON file per entry, keyed by page path. Called by
4092
+ * `build` after it expands routes. Lazily loads its `node:fs` writer (keeping a
4093
+ * browser bundle node-free).
4094
+ *
4095
+ * @param entries - The per-page data to persist.
4096
+ * @param options - Optional `{ outDir }` override (defaults to `./dist`).
4097
+ * @param options.outDir - Build output directory the write happens under.
4098
+ * @returns A summary of the written files.
4099
+ * @example
4100
+ * ```ts
4101
+ * await api.write([{ path: "/en/hello/", data: article }], { outDir: "dist" });
4102
+ * ```
4103
+ */
4104
+ async write(entries, options) {
4105
+ const { writeData } = await import("./writer-BcWqa_7I.mjs");
4106
+ return writeData(ctx, entries, options);
4107
+ },
4108
+ /**
4109
+ * PURE — the browser fetch URL for a page path.
4110
+ *
4111
+ * @param path - The page URL path.
4112
+ * @returns The site-root-relative data URL.
4113
+ * @example
4114
+ * ```ts
4115
+ * api.urlFor("/en/hello/"); // "/_data/en/hello/index.json"
4116
+ * ```
4117
+ */
4118
+ urlFor(path) {
4119
+ return `${ctx.config.baseUrl}${dataSuffix(path)}`;
4120
+ },
4121
+ /**
4122
+ * PURE — the `outDir`-relative file path for a page path.
4123
+ *
4124
+ * @param path - The page URL path.
4125
+ * @returns The output-relative file path.
4126
+ * @example
4127
+ * ```ts
4128
+ * api.fileFor("/en/hello/"); // "_data/en/hello/index.json"
4129
+ * ```
4130
+ */
4131
+ fileFor(path) {
4132
+ return `${trimTrailingSlash(ctx.config.outputDir)}/${dataSuffix(path)}`;
4133
+ }
4134
+ };
4135
+ }
4136
+ //#endregion
4137
+ //#region src/plugins/data/config.ts
4138
+ /**
4139
+ * Typed default data config (R6: no inline `as`). `outputDir` is the WRITE path
4140
+ * (filesystem, relative to the build `outDir`); `baseUrl` is the matching READ URL
4141
+ * (site-root-relative) the browser fetches from — the defaults agree
4142
+ * (`"_data"` ↔ `"/_data/"`).
4143
+ *
4144
+ * @example
4145
+ * ```ts
4146
+ * createPlugin("data", { config: defaultDataConfig });
4147
+ * ```
4148
+ */
4149
+ const defaultDataConfig = {
4150
+ outputDir: "_data",
4151
+ baseUrl: "/_data/"
4152
+ };
4153
+ //#endregion
4154
+ //#region src/plugins/data/state.ts
4155
+ /**
4156
+ * Creates initial data state: a null `lastWrite` slot (populated by the Node
4157
+ * `write()` side) and an empty `cache` (populated lazily by the browser `at(path)`
4158
+ * side on first fetch).
4159
+ *
4160
+ * @param _ctx - Minimal context with global and config.
4161
+ * @param _ctx.global - Global framework configuration.
4162
+ * @param _ctx.config - Resolved plugin configuration.
4163
+ * @returns Fresh data state with no recorded write and an empty per-path cache.
4164
+ * @example
4165
+ * ```ts
4166
+ * const state = createDataState({ global: {}, config });
4167
+ * ```
4168
+ */
4169
+ function createDataState(_ctx) {
4170
+ return {
4171
+ lastWrite: null,
4172
+ cache: /* @__PURE__ */ new Map()
4173
+ };
4174
+ }
4175
+ //#endregion
4176
+ //#region src/plugins/data/validate.ts
4177
+ /**
4178
+ * Validates the resolved data config: the browser `baseUrl` must be a non-empty,
4179
+ * site-root-relative URL path. The emit/read pipelines are wired in build waves 3/4.
4180
+ *
4181
+ * @param config - The resolved plugin configuration.
4182
+ * @throws {Error} If `baseUrl` is empty or not a rooted URL path.
4183
+ * @example
4184
+ * ```ts
4185
+ * validateDataConfig({ outputDir: "_data", baseUrl: "/_data/" });
4186
+ * ```
4187
+ */
4188
+ function validateDataConfig(config) {
4189
+ if (typeof config.baseUrl !== "string" || !config.baseUrl.startsWith("/")) throw new Error(`[web] data.baseUrl: must be a site-root-relative URL path starting with "/" (e.g. "/_data/").`);
4190
+ }
4191
+ //#endregion
4192
+ //#region src/plugins/data/index.ts
4193
+ /**
4194
+ * @file data — Standard tier plugin (wiring-only). The AGNOSTIC data provider for
4195
+ * the SSG→DATA→SPA pattern.
4196
+ *
4197
+ * Owns ONE contract — `page path → persisted JSON file` — and nothing about what
4198
+ * the data is: `write(entries)` persists per-page JSON on Node (build supplies the
4199
+ * entries it already expanded); `at(path)` fetches + caches it in the browser as
4200
+ * `unknown`, which the route's `parse` validates before `render`. NOT a framework
4201
+ * default — the consumer composes it where needed (Node build AND/OR browser app).
4202
+ *
4203
+ * **No hard `depends`** — fully browser-composable; the `node:fs` writer is behind
4204
+ * a lazy `import()` inside `write()`. Build ordering is a call-site contract: build
4205
+ * writes data during its pages phase (after its Phase-0 clean), via `app.data.write`.
4206
+ * No `onStart`/`onStop`.
4207
+ * @see README.md
4208
+ */
4209
+ /**
4210
+ * Data plugin — the agnostic data provider. Mounts `write(entries)` (Node persist),
4211
+ * `at(path)` (browser read), and the pure `urlFor`/`fileFor` convention at `app.data`.
4212
+ *
4213
+ * @example
4214
+ * ```ts
4215
+ * // Node build: `build` calls app.data.write(...) during its pages phase when
4216
+ * // router.mode !== "ssg". Just compose the plugin:
4217
+ * const app = createApp({
4218
+ * plugins: [dataPlugin, contentPlugin, buildPlugin],
4219
+ * pluginConfigs: { content: { contentDir: "./content" }, router: { routes, mode: "hybrid" } }
4220
+ * });
4221
+ * await app.start();
4222
+ * await app.build.run(); // writes HTML + per-page data sidecars
4223
+ *
4224
+ * // Browser app: compose `dataPlugin` too; spa fetches via app.data.at(path) on nav.
4225
+ * ```
4226
+ */
4227
+ const dataPlugin = createPlugin$1("data", {
4228
+ config: defaultDataConfig,
4229
+ createState: createDataState,
4230
+ onInit: (ctx) => validateDataConfig(ctx.config),
4231
+ api: dataApi
4232
+ });
4233
+ //#endregion
3638
4234
  //#region src/plugins/build/phases/pages.tsx
3639
4235
  /**
3640
4236
  * @file build phase 3 — pages. Pulls `router.manifest()` + `head.render(route, data)`
3641
4237
  * and SSR-renders each route to static HTML (preact-render-to-string). Appends the
3642
4238
  * build-id meta tag after `head.render()` returns. Does NOT compose `<head>` itself.
3643
4239
  */
4240
+ /** Template placeholder for the composed `<head>` inner HTML. */
4241
+ const HEAD_PLACEHOLDER = "<!--moku:head-->";
4242
+ /** Template placeholder for the SSR-rendered body HTML. */
4243
+ const BODY_PLACEHOLDER = "<!--moku:body-->";
4244
+ /** Template placeholder for the injected asset `<link>`/`<script>` tags. */
4245
+ const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
4246
+ /**
4247
+ * Read the bundle phase's hashed asset manifest for one kind from `state.buildCache`
4248
+ * as a typed {@link BuildCacheEntry} (no `Map<string, unknown>` reads).
4249
+ *
4250
+ * @param ctx - Plugin context (provides `state`).
4251
+ * @param kind - The asset kind key (`"css"` / `"js"`).
4252
+ * @returns The hashed-path manifest entry, or an empty object when absent.
4253
+ * @example
4254
+ * ```ts
4255
+ * readManifest(ctx, "css");
4256
+ * ```
4257
+ */
4258
+ function readManifest(ctx, kind) {
4259
+ const entry = ctx.state.buildCache.get(kind);
4260
+ return entry && typeof entry === "object" ? entry : {};
4261
+ }
4262
+ /**
4263
+ * Build the asset `<link>`/`<script>` tag block from the hashed manifests. Returns
4264
+ * an empty string when `config.injectAssets === false`. Asset paths are emitted as
4265
+ * absolute (`/`-rooted) URLs.
4266
+ *
4267
+ * @param ctx - Plugin context (provides `state`, `config`).
4268
+ * @returns The injected asset tags, or `""` when injection is disabled.
4269
+ * @example
4270
+ * ```ts
4271
+ * buildAssetTags(ctx);
4272
+ * ```
4273
+ */
4274
+ function buildAssetTags(ctx) {
4275
+ if (ctx.config.injectAssets === false) return "";
4276
+ const css = Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`);
4277
+ const js = Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`);
4278
+ return [...css, ...js].join("");
4279
+ }
3644
4280
  /**
3645
- * Compose the full static HTML document, injecting the build-id meta tag into
3646
- * `<head>` AFTER the head plugin's composed HTML (build metadata, not content).
4281
+ * Compose the full static HTML document with the in-code shell, injecting the
4282
+ * build-id meta tag into `<head>` AFTER the head plugin's composed HTML (build
4283
+ * metadata, not content) and the asset tags at the end of `<head>`.
3647
4284
  *
3648
- * @param headHtml - The composed `<head>` inner HTML from `head.render`.
3649
- * @param bodyHtml - The SSR-rendered body HTML.
3650
- * @param runId - The per-run build id injected as `<meta name="build-id">`.
3651
- * @param locale - The page locale for the `<html lang>` attribute.
4285
+ * @param parts - The composed head/body/assets/locale pieces.
3652
4286
  * @returns The complete HTML document string.
3653
4287
  * @example
3654
4288
  * ```ts
3655
- * renderDocument("<title>Hi</title>", "<h1>Hi</h1>", "run-1", "en");
4289
+ * renderDocument({ head: "<title>Hi</title>", body: "<h1>Hi</h1>", assets: "", locale: "en" });
4290
+ * ```
4291
+ */
4292
+ function renderDocument(parts) {
4293
+ return `<!DOCTYPE html><html lang="${parts.locale}"><head>${parts.head}${parts.assets}</head><body>${parts.body}</body></html>`;
4294
+ }
4295
+ /**
4296
+ * Fill a shell template's `<!--moku:head-->` / `<!--moku:body-->` /
4297
+ * `<!--moku:assets-->` placeholders deterministically at build time.
4298
+ *
4299
+ * @param template - The raw shell template HTML.
4300
+ * @param parts - The composed head/body/assets pieces.
4301
+ * @returns The filled document string.
4302
+ * @example
4303
+ * ```ts
4304
+ * fillTemplate(shell, { head, body, assets, locale: "en" });
3656
4305
  * ```
3657
4306
  */
3658
- function renderDocument(headHtml, bodyHtml, runId, locale) {
3659
- return `<!DOCTYPE html><html lang="${locale}"><head>${headHtml}${`<meta name="build-id" content="${runId}">`}</head><body>${bodyHtml}</body></html>`;
4307
+ function fillTemplate(template, parts) {
4308
+ return template.replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
3660
4309
  }
3661
4310
  /**
3662
4311
  * Expand one route definition into its concrete page instances across all
@@ -3733,18 +4382,24 @@ function adaptHeadConfig(config) {
3733
4382
  return adapted;
3734
4383
  }
3735
4384
  /**
3736
- * Render one page instance to its static HTML document and write it to disk.
4385
+ * Render one page instance to its static HTML document and write it to disk. Uses
4386
+ * the configured shell `template` (filled at build time) when supplied, otherwise
4387
+ * the in-code shell; injects the precomputed asset tags + build-id meta.
3737
4388
  *
3738
4389
  * @param ctx - Plugin context (provides `require`, `state`, `config`).
3739
4390
  * @param instance - The concrete page instance to render.
4391
+ * @param shell - Wiring shared across instances (asset tags + optional template).
4392
+ * @param shell.assets - The injected asset `<link>`/`<script>` tags.
4393
+ * @param shell.template - The shell template HTML, or `null` for the in-code shell.
3740
4394
  * @returns The instance's URL and rendered HTML (HTML reused for the root page).
3741
4395
  * @example
3742
4396
  * ```ts
3743
- * await renderInstance(ctx, instance);
4397
+ * await renderInstance(ctx, instance, { assets: "", template: null });
3744
4398
  * ```
3745
4399
  */
3746
- async function renderInstance(ctx, instance) {
4400
+ async function renderInstance(ctx, instance, shell) {
3747
4401
  const { definition, entry, params, locale, name } = instance;
4402
+ const hasData = definition._handlers.load !== void 0;
3748
4403
  const data = definition._handlers.load ? await definition._handlers.load(params, locale) : void 0;
3749
4404
  const routeContext = {
3750
4405
  params,
@@ -3761,14 +4416,24 @@ async function renderInstance(ctx, instance) {
3761
4416
  };
3762
4417
  if (headConfig) resolved.head = adaptHeadConfig(headConfig);
3763
4418
  const headHtml = ctx.require(headPlugin).render(resolved, data);
4419
+ const buildIdMeta = `<meta name="build-id" content="${ctx.state.runId ?? ""}">`;
3764
4420
  const vnode = definition._handlers.render?.(routeContext);
3765
- const html = renderDocument(headHtml, vnode ? renderToString(vnode) : "", ctx.state.runId ?? "", locale);
4421
+ const bodyHtml = vnode ? renderToString(vnode) : "";
4422
+ const parts = {
4423
+ head: `${headHtml}${buildIdMeta}`,
4424
+ body: bodyHtml,
4425
+ assets: shell.assets,
4426
+ locale
4427
+ };
4428
+ const html = shell.template === null ? renderDocument(parts) : fillTemplate(shell.template, parts);
3766
4429
  const filePath = join(ctx.config.outDir, entry.toFile(params));
3767
4430
  await mkdir(dirname(filePath), { recursive: true });
3768
4431
  await writeFile(filePath, html, "utf8");
3769
4432
  return {
3770
4433
  url,
3771
- html
4434
+ html,
4435
+ data,
4436
+ hasData
3772
4437
  };
3773
4438
  }
3774
4439
  /**
@@ -3786,14 +4451,56 @@ async function renderInstance(ctx, instance) {
3786
4451
  * const { pageCount, rootHtml } = await renderPages(ctx);
3787
4452
  * ```
3788
4453
  */
4454
+ /**
4455
+ * Enforce the data-validation contract: in `hybrid`/`spa` mode, any route that
4456
+ * has BOTH a `render` and a `load` (so it will be client-data-navigated) MUST
4457
+ * declare a `.parse()` validator — otherwise fetched JSON would reach `render`
4458
+ * unvalidated. Converts a runtime safety hole into a build error.
4459
+ *
4460
+ * @param manifest - The route definitions from `router.manifest()`.
4461
+ * @param mode - The resolved render mode.
4462
+ * @throws {Error} If a data-navigable route is missing `.parse()` in hybrid/spa mode.
4463
+ * @example
4464
+ * ```ts
4465
+ * assertDataValidators(router.manifest(), router.mode());
4466
+ * ```
4467
+ */
4468
+ function assertDataValidators(manifest, mode) {
4469
+ if (mode === "ssg") return;
4470
+ for (const definition of manifest) {
4471
+ const { render, load, parse } = definition._handlers;
4472
+ if (render && load && !parse) throw new Error(`[web] build: route "${definition.pattern}" enables client data navigation (router mode "${mode}") but has no .parse() validator. Add .parse(raw => /* validate → data */) so fetched JSON is validated before render, or set router mode "ssg" to disable data navigation.`);
4473
+ }
4474
+ }
3789
4475
  async function renderPages(ctx) {
3790
4476
  const router = ctx.require(routerPlugin);
3791
4477
  const manifest = router.manifest();
3792
4478
  ctx.state.manifest = [...manifest];
4479
+ const mode = router.mode();
4480
+ assertDataValidators(manifest, mode);
3793
4481
  const byPattern = makeEntryMap(router);
3794
4482
  const locales = ctx.require(i18nPlugin).locales();
4483
+ const templatePath = ctx.config.template;
4484
+ const template = typeof templatePath === "string" && existsSync(templatePath) ? await readFile(templatePath, "utf8") : null;
4485
+ const shell = {
4486
+ assets: buildAssetTags(ctx),
4487
+ template
4488
+ };
3795
4489
  const instances = (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, byPattern)))).flat();
3796
- const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance)));
4490
+ const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance, shell)));
4491
+ if (mode !== "ssg" && ctx.has("data")) {
4492
+ const entries = rendered.filter((page) => page.hasData).map((page) => ({
4493
+ path: page.url,
4494
+ data: page.data
4495
+ }));
4496
+ if (entries.length > 0) {
4497
+ const summary = await ctx.require(dataPlugin).write(entries, { outDir: ctx.config.outDir });
4498
+ ctx.log.debug("build:data", {
4499
+ files: summary.fileCount,
4500
+ bytes: summary.bytes
4501
+ });
4502
+ }
4503
+ }
3797
4504
  const root = rendered.find((page) => page.url === "/" || page.url === "");
3798
4505
  ctx.log.debug("build:pages", { count: rendered.length });
3799
4506
  return {
@@ -3801,6 +4508,37 @@ async function renderPages(ctx) {
3801
4508
  rootHtml: root?.html ?? null
3802
4509
  };
3803
4510
  }
4511
+ /**
4512
+ * Copies the configured `publicDir` (default `"public"`) verbatim into `outDir`,
4513
+ * preserving the nested directory structure. Skips silently (returns `null`) when
4514
+ * the source directory does not exist.
4515
+ *
4516
+ * @param ctx - Plugin context (provides `config`, `log`).
4517
+ * @returns The copy result, or `null` when the public directory is absent.
4518
+ * @example
4519
+ * ```ts
4520
+ * const result = await copyPublic(ctx);
4521
+ * ```
4522
+ */
4523
+ async function copyPublic(ctx) {
4524
+ const from = ctx.config.publicDir ?? "public";
4525
+ if (!existsSync(from)) {
4526
+ ctx.log.debug("build:public", {
4527
+ skipped: true,
4528
+ from
4529
+ });
4530
+ return null;
4531
+ }
4532
+ await cp(from, ctx.config.outDir, { recursive: true });
4533
+ ctx.log.debug("build:public", {
4534
+ from,
4535
+ dest: ctx.config.outDir
4536
+ });
4537
+ return {
4538
+ from: path.normalize(from),
4539
+ copied: 1
4540
+ };
4541
+ }
3804
4542
  //#endregion
3805
4543
  //#region src/plugins/build/phases/sitemap.ts
3806
4544
  /**
@@ -3904,6 +4642,9 @@ const PHASE_ORDER = [
3904
4642
  "feeds",
3905
4643
  "sitemap",
3906
4644
  "og-images",
4645
+ "public",
4646
+ "not-found",
4647
+ "locale-redirects",
3907
4648
  "root-index"
3908
4649
  ];
3909
4650
  /**
@@ -3947,10 +4688,11 @@ function resetRun(ctx) {
3947
4688
  ctx.state.runId = `${Date.now()}-${randomUUID()}`;
3948
4689
  }
3949
4690
  /**
3950
- * Phase 4 — run feeds / sitemap / og-images concurrently, each gated by its config
3951
- * flag, isolated with `Promise.allSettled` so one failure does not lose the others.
3952
- * A disabled output is skipped entirely it emits NO `build:phase` boundary (the
3953
- * `withPhase` wrapper is gated on the config flag, not just the phase body).
4691
+ * Phase 4 — run feeds / sitemap / og-images / public / not-found / locale-redirects
4692
+ * concurrently, each gated by its config flag (or, for `public`, the presence of the
4693
+ * source dir), isolated with `Promise.allSettled` so one failure does not lose the
4694
+ * others. A disabled output is skipped entirely it emits NO `build:phase` boundary
4695
+ * (the `withPhase` wrapper is gated on the config flag, not just the phase body).
3954
4696
  *
3955
4697
  * @param ctx - The phase context.
3956
4698
  * @example
@@ -3963,6 +4705,9 @@ async function runOutputs(ctx) {
3963
4705
  if (ctx.config.feeds) tasks.push(withPhase(ctx, "feeds", () => generateFeeds(ctx)));
3964
4706
  if (ctx.config.sitemap) tasks.push(withPhase(ctx, "sitemap", () => generateSitemap(ctx)));
3965
4707
  if (ctx.config.ogImage) tasks.push(withPhase(ctx, "og-images", () => generateOgImages(ctx)));
4708
+ if (existsSync(ctx.config.publicDir ?? "public")) tasks.push(withPhase(ctx, "public", () => copyPublic(ctx)));
4709
+ if (ctx.config.notFound) tasks.push(withPhase(ctx, "not-found", () => generateNotFound(ctx)));
4710
+ if (ctx.config.localeRedirects) tasks.push(withPhase(ctx, "locale-redirects", () => generateLocaleRedirects(ctx)));
3966
4711
  const settled = await Promise.allSettled(tasks);
3967
4712
  for (const outcome of settled) if (outcome.status === "rejected") ctx.log.error("build:outputs", { reason: String(outcome.reason) });
3968
4713
  }
@@ -4105,6 +4850,9 @@ function validateFonts(og) {
4105
4850
  */
4106
4851
  function validateConfig$1(config) {
4107
4852
  if (typeof config.outDir !== "string" || config.outDir.trim().length === 0) throw new Error(`${ERROR_PREFIX$5}.outDir: must be a non-empty string.`);
4853
+ if (config.publicDir !== void 0 && typeof config.publicDir !== "string") throw new Error(`${ERROR_PREFIX$5}.publicDir: must be a string when set.`);
4854
+ if (config.template !== void 0 && typeof config.template !== "string") throw new Error(`${ERROR_PREFIX$5}.template: must be a string path when set.`);
4855
+ if (config.clientEntry !== void 0 && typeof config.clientEntry !== "string") throw new Error(`${ERROR_PREFIX$5}.clientEntry: must be a string path when set.`);
4108
4856
  if (config.ogImage) validateFonts(config.ogImage);
4109
4857
  }
4110
4858
  //#endregion
@@ -5072,7 +5820,7 @@ function spaEvents(register) {
5072
5820
  }
5073
5821
  //#endregion
5074
5822
  //#region src/plugins/spa/types.ts
5075
- var types_exports$7 = /* @__PURE__ */ __exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
5823
+ var types_exports$8 = /* @__PURE__ */ __exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
5076
5824
  /** Allowed hook names — single source of truth for fail-fast validation. */
5077
5825
  const COMPONENT_HOOK_NAMES = [
5078
5826
  "onCreate",
@@ -5542,11 +6290,12 @@ function resolveClickTarget(event) {
5542
6290
  * Navigation API is unavailable).
5543
6291
  *
5544
6292
  * @param handlers - The navigation lifecycle callbacks.
6293
+ * @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
5545
6294
  * @returns A teardown that removes the attached listeners.
5546
6295
  * @example
5547
6296
  * const dispose = attachHistoryFallback(handlers);
5548
6297
  */
5549
- function attachHistoryFallback(handlers) {
6298
+ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
5550
6299
  /**
5551
6300
  * Intercept an internal-link click and run a History-API navigation.
5552
6301
  *
@@ -5567,7 +6316,7 @@ function attachHistoryFallback(handlers) {
5567
6316
  }
5568
6317
  saveScrollPosition(location.pathname);
5569
6318
  history.pushState({ scrollY: 0 }, "", url.pathname);
5570
- performNavigation(url.pathname, handlers).then(() => window.scrollTo(0, 0)).catch(() => {});
6319
+ navigate(url.pathname).then(() => window.scrollTo(0, 0)).catch(() => {});
5571
6320
  };
5572
6321
  /**
5573
6322
  * Re-run navigation on back/forward, restoring the saved scroll position.
@@ -5576,7 +6325,7 @@ function attachHistoryFallback(handlers) {
5576
6325
  * globalThis.addEventListener("popstate", onPopState);
5577
6326
  */
5578
6327
  const onPopState = () => {
5579
- performNavigation(location.pathname, handlers).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
6328
+ navigate(location.pathname).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
5580
6329
  };
5581
6330
  document.addEventListener("click", onClick);
5582
6331
  globalThis.addEventListener("popstate", onPopState);
@@ -5590,11 +6339,12 @@ function attachHistoryFallback(handlers) {
5590
6339
  *
5591
6340
  * @param navigation - The Navigation API object to attach the listener to.
5592
6341
  * @param handlers - The navigation lifecycle callbacks.
6342
+ * @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
5593
6343
  * @returns A teardown that removes the `navigate` listener.
5594
6344
  * @example
5595
6345
  * const dispose = attachNavigationApi(navigation, handlers);
5596
6346
  */
5597
- function attachNavigationApi(navigation, handlers) {
6347
+ function attachNavigationApi(navigation, handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
5598
6348
  /**
5599
6349
  * Handle a `navigate` event: classify, then intercept with fetch-and-swap.
5600
6350
  *
@@ -5619,7 +6369,7 @@ function attachNavigationApi(navigation, handlers) {
5619
6369
  navEvent.intercept({
5620
6370
  scroll: "manual",
5621
6371
  handler: async () => {
5622
- await performNavigation(url.pathname, handlers);
6372
+ await navigate(url.pathname);
5623
6373
  if (navEvent.navigationType === "traverse") navEvent.scroll();
5624
6374
  else window.scrollTo(0, 0);
5625
6375
  }
@@ -5633,13 +6383,14 @@ function attachNavigationApi(navigation, handlers) {
5633
6383
  * fallback. Returns a teardown removing every listener it attached.
5634
6384
  *
5635
6385
  * @param handlers - The navigation lifecycle callbacks the kernel supplies.
6386
+ * @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
5636
6387
  * @returns A teardown removing all attached listeners.
5637
6388
  * @example
5638
- * const dispose = attachRouter(handlers);
6389
+ * const dispose = attachRouter(handlers, navigate);
5639
6390
  */
5640
- function attachRouter(handlers) {
6391
+ function attachRouter(handlers, navigate) {
5641
6392
  const navigation = getNavigation();
5642
- return navigation ? attachNavigationApi(navigation, handlers) : attachHistoryFallback(handlers);
6393
+ return navigation ? attachNavigationApi(navigation, handlers, navigate) : attachHistoryFallback(handlers, navigate);
5643
6394
  }
5644
6395
  //#endregion
5645
6396
  //#region src/plugins/spa/state.ts
@@ -5758,6 +6509,20 @@ function currentLocationUrl() {
5758
6509
  return location.pathname + location.search;
5759
6510
  }
5760
6511
  /**
6512
+ * Apply the matched route's `head` config to the live document (minimal client
6513
+ * head-sync for the DATA path: title only — the full meta sync runs on the
6514
+ * HTML-over-fetch path from the fetched `<head>`).
6515
+ *
6516
+ * @param route - The matched route definition.
6517
+ * @param routeContext - The render context (params/data/locale).
6518
+ * @example
6519
+ * syncDataHead(hit.route, { params, data, locale });
6520
+ */
6521
+ function syncDataHead(route, routeContext) {
6522
+ const title = route._handlers.head?.(routeContext)?.title;
6523
+ if (title !== void 0 && title !== "") document.title = title;
6524
+ }
6525
+ /**
5761
6526
  * Builds the single shared SPA kernel — a pure factory over state/config/emit.
5762
6527
  * Unit-testable with a mock state object and a spy emit; no Moku ctx involved.
5763
6528
  *
@@ -5821,6 +6586,71 @@ function createSpaKernel(state, config, emit, deps) {
5821
6586
  onEnd: handleEnd,
5822
6587
  onError: handleError
5823
6588
  };
6589
+ /**
6590
+ * The client DATA path: match `pathname`, fetch the page's PERSISTED data via the
6591
+ * `data` reader, VALIDATE it through the route's `parse` gate, then run the
6592
+ * route's OWN `render` (the same component the build used for SSG) and
6593
+ * Preact-render the VNode into the swap region. Returns `false` (touching nothing
6594
+ * the fallback cares about) on no-match / no-render / no-data / fetch-miss /
6595
+ * parse-throw, so the caller falls back to HTML-over-fetch. `route.load` does NOT
6596
+ * run on the client — the build already persisted its output.
6597
+ *
6598
+ * @param pathname - The destination pathname (search stripped for matching).
6599
+ * @returns `true` if the route was rendered from validated data, else `false`.
6600
+ * @example
6601
+ * if (await tryDataRender("/en/world/")) return;
6602
+ */
6603
+ const tryDataRender = async (pathname) => {
6604
+ if (!deps.dataAt) return false;
6605
+ const matchPath = pathname.split("?")[0] ?? pathname;
6606
+ const hit = deps.router.match(matchPath);
6607
+ if (!hit?.route._handlers.render) return false;
6608
+ try {
6609
+ const raw = await deps.dataAt(pathname);
6610
+ if (raw === null) return false;
6611
+ const data = hit.route._handlers.parse ? hit.route._handlers.parse(raw) : raw;
6612
+ const locale = hit.params.lang ?? document.documentElement.lang ?? "";
6613
+ const routeContext = {
6614
+ params: hit.params,
6615
+ data,
6616
+ locale
6617
+ };
6618
+ const vnode = hit.route._handlers.render(routeContext);
6619
+ const region = document.querySelector(resolved.swapSelector);
6620
+ if (!region) return false;
6621
+ handleStart(pathname);
6622
+ const { renderVNode } = await import("./render-BL9Fv6G6.mjs");
6623
+ syncDataHead(hit.route, routeContext);
6624
+ unmountPageSpecific(state, emit);
6625
+ runSwap(() => {
6626
+ region.replaceChildren();
6627
+ renderVNode(vnode, region);
6628
+ scanAndMount(state, emit, resolved.swapSelector);
6629
+ notifyNavEnd(state);
6630
+ }, resolved.viewTransitions);
6631
+ state.currentUrl = pathname;
6632
+ progress?.done();
6633
+ emit("spa:navigated", { url: pathname });
6634
+ return true;
6635
+ } catch {
6636
+ return false;
6637
+ }
6638
+ };
6639
+ /**
6640
+ * Unified navigation: try the client DATA path first (only when the `data`
6641
+ * plugin is composed), then fall back to HTML-over-fetch (which itself falls
6642
+ * back to a full `location.href` reload). Injected into the router so every
6643
+ * navigation entry point (Navigation API, History, programmatic) goes through it.
6644
+ *
6645
+ * @param pathname - The destination pathname.
6646
+ * @returns A promise resolving once the swap (or fallback) is dispatched.
6647
+ * @example
6648
+ * await navigate("/en/world/");
6649
+ */
6650
+ const navigate = async (pathname) => {
6651
+ if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
6652
+ await performNavigation(pathname, handlers);
6653
+ };
5824
6654
  return {
5825
6655
  /**
5826
6656
  * Register config components and seed currentUrl from the document.
@@ -5843,7 +6673,7 @@ function createSpaKernel(state, config, emit, deps) {
5843
6673
  if (state.started) throw new Error(`${ERROR_PREFIX} spa kernel already started.\n Call app.stop() before booting again (single boot per app).`);
5844
6674
  progress = createProgressBar(resolved.progressBar);
5845
6675
  state.currentUrl = currentLocationUrl();
5846
- state.destroyRouter = attachRouter(handlers);
6676
+ state.destroyRouter = attachRouter(handlers, navigate);
5847
6677
  scanAndMount(state, emit, resolved.swapSelector);
5848
6678
  state.started = true;
5849
6679
  },
@@ -5866,7 +6696,7 @@ function createSpaKernel(state, config, emit, deps) {
5866
6696
  */
5867
6697
  processNav(path) {
5868
6698
  if (typeof document === "undefined") return;
5869
- performNavigation(path, handlers).catch(() => {});
6699
+ navigate(path).catch(() => {});
5870
6700
  },
5871
6701
  /**
5872
6702
  * Scan the swap region and mount components for matching elements.
@@ -5893,20 +6723,41 @@ function createSpaKernel(state, config, emit, deps) {
5893
6723
  };
5894
6724
  }
5895
6725
  /**
6726
+ * Structural by-name handle for the OPTIONAL `data` plugin. `ctx.require` resolves
6727
+ * a plugin by its `name` at runtime, so this lets `spa` obtain the `data` reader
6728
+ * WITHOUT importing the `data` plugin or its types — keeping `spa` decoupled and
6729
+ * its `depends` at `[router, head]`. The phantom types only the `at` slice it uses.
6730
+ */
6731
+ const dataPluginHandle = {
6732
+ name: "data",
6733
+ spec: void 0,
6734
+ _phantom: {
6735
+ config: void 0,
6736
+ state: void 0,
6737
+ api: void 0,
6738
+ events: {}
6739
+ }
6740
+ };
6741
+ /**
5896
6742
  * Builds the shared kernel from the plugin context, stores it on `ctx.state`
5897
6743
  * and `kernelRef`, and runs its init step (validate config, register
5898
- * config.components, seed currentUrl). Extracted from index.ts onInit to keep
5899
- * wiring under budget.
6744
+ * config.components, seed currentUrl). Captures the OPTIONAL `data` reader when
6745
+ * the `data` plugin is composed (enabling client DATA navigation).
5900
6746
  *
5901
- * @param ctx - The plugin context (state/config/emit/require/log).
6747
+ * @param ctx - The plugin context (state/config/emit/require/has/log).
5902
6748
  * @example
5903
6749
  * initSpa(ctx);
5904
6750
  */
5905
6751
  function initSpa(ctx) {
5906
- const kernel = createSpaKernel(ctx.state, ctx.config, ctx.emit, {
6752
+ const deps = {
5907
6753
  router: ctx.require(routerPlugin),
5908
6754
  head: ctx.require(headPlugin)
5909
- });
6755
+ };
6756
+ if (ctx.has("data")) {
6757
+ const reader = ctx.require(dataPluginHandle);
6758
+ deps.dataAt = (path) => reader.at(path);
6759
+ }
6760
+ const kernel = createSpaKernel(ctx.state, ctx.config, ctx.emit, deps);
5910
6761
  ctx.state.kernel = kernel;
5911
6762
  kernelRef.current = kernel;
5912
6763
  kernel.init();
@@ -6008,24 +6859,177 @@ var types_exports = /* @__PURE__ */ __exportAll({});
6008
6859
  //#region src/plugins/content/types.ts
6009
6860
  var types_exports$1 = /* @__PURE__ */ __exportAll({});
6010
6861
  //#endregion
6011
- //#region src/plugins/deploy/types.ts
6862
+ //#region src/plugins/data/types.ts
6012
6863
  var types_exports$2 = /* @__PURE__ */ __exportAll({});
6013
6864
  //#endregion
6014
- //#region src/plugins/env/types.ts
6865
+ //#region src/plugins/deploy/types.ts
6015
6866
  var types_exports$3 = /* @__PURE__ */ __exportAll({});
6016
6867
  //#endregion
6017
- //#region src/plugins/head/types.ts
6868
+ //#region src/plugins/env/types.ts
6018
6869
  var types_exports$4 = /* @__PURE__ */ __exportAll({});
6019
6870
  //#endregion
6020
- //#region src/plugins/log/types.ts
6871
+ //#region src/plugins/head/types.ts
6021
6872
  var types_exports$5 = /* @__PURE__ */ __exportAll({});
6022
6873
  //#endregion
6023
- //#region src/plugins/router/types.ts
6874
+ //#region src/plugins/log/types.ts
6024
6875
  var types_exports$6 = /* @__PURE__ */ __exportAll({});
6025
6876
  //#endregion
6877
+ //#region src/plugins/router/types.ts
6878
+ var types_exports$7 = /* @__PURE__ */ __exportAll({});
6879
+ //#endregion
6880
+ //#region src/plugins/env/providers.ts
6881
+ /**
6882
+ * @file env plugin — built-in providers: dotenv, processEnv, cloudflareBindings.
6883
+ */
6884
+ /** Default dotenv file path: optional local overrides. */
6885
+ const DEFAULT_DOTENV_PATH = ".env.local";
6886
+ /** Property on `globalThis` that the consumer sets per Cloudflare request. */
6887
+ const CLOUDFLARE_GLOBAL = "__CLOUDFLARE_ENV__";
6888
+ /**
6889
+ * Strips a single matching pair of surrounding double or single quotes from a
6890
+ * value. Leaves unquoted values (and trailing inline comments) untouched.
6891
+ *
6892
+ * @param value - The already-trimmed raw value.
6893
+ * @returns The value with one outer quote pair removed, if present.
6894
+ * @example
6895
+ * ```ts
6896
+ * stripQuotes('"a"'); // "a"
6897
+ * stripQuotes("plain # c"); // "plain # c"
6898
+ * ```
6899
+ */
6900
+ function stripQuotes(value) {
6901
+ if (value.length >= 2) {
6902
+ const first = value[0];
6903
+ const last = value.at(-1);
6904
+ if ((first === "\"" || first === "'") && first === last) return value.slice(1, -1);
6905
+ }
6906
+ return value;
6907
+ }
6908
+ /**
6909
+ * Parses `.env`-style text into a flat record. Handles CRLF/LF, blank lines,
6910
+ * full-line `#` comments, first-`=` splitting, key/value trimming, and a single
6911
+ * outer quote pair. Does not strip trailing inline comments on unquoted values.
6912
+ *
6913
+ * @param text - The raw file contents.
6914
+ * @returns A flat record of parsed key/value pairs.
6915
+ * @example
6916
+ * ```ts
6917
+ * parseDotenv('A=1\nB="two"'); // { A: "1", B: "two" }
6918
+ * ```
6919
+ */
6920
+ function parseDotenv(text) {
6921
+ const out = {};
6922
+ for (const line of text.split(/\r?\n/)) {
6923
+ const trimmed = line.trim();
6924
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
6925
+ const eq = trimmed.indexOf("=");
6926
+ if (eq === -1) continue;
6927
+ const key = trimmed.slice(0, eq).trim();
6928
+ out[key] = stripQuotes(trimmed.slice(eq + 1).trim());
6929
+ }
6930
+ return out;
6931
+ }
6932
+ /**
6933
+ * A zero-dependency `.env`-style provider that re-reads and re-parses the file
6934
+ * from disk on every `load()`. Missing file resolves to `{}` (optional
6935
+ * overrides). Strips a single outer quote pair; does not strip trailing inline
6936
+ * comments on unquoted values.
6937
+ *
6938
+ * @param path - Path to the dotenv file. Defaults to `.env.local`.
6939
+ * @returns An {@link EnvProvider} named `dotenv:<path>` that reads fresh per call.
6940
+ * @example
6941
+ * ```ts
6942
+ * const provider = dotenv(".env.local");
6943
+ * provider.load(); // { PUBLIC_API_URL: "/api", ... }
6944
+ * ```
6945
+ */
6946
+ function dotenv(path = DEFAULT_DOTENV_PATH) {
6947
+ return {
6948
+ name: `dotenv:${path}`,
6949
+ /**
6950
+ * Reads and parses the dotenv file fresh from disk; `{}` if it is missing.
6951
+ *
6952
+ * @returns The parsed environment record, or `{}` when the file is absent.
6953
+ * @example
6954
+ * ```ts
6955
+ * dotenv(".env.local").load();
6956
+ * ```
6957
+ */
6958
+ load() {
6959
+ if (!existsSync(path)) return {};
6960
+ return parseDotenv(readFileSync(path, "utf8"));
6961
+ }
6962
+ };
6963
+ }
6964
+ /**
6965
+ * A provider that returns a shallow copy of `process.env` at `load()` time.
6966
+ *
6967
+ * @returns An {@link EnvProvider} named `process-env`.
6968
+ * @example
6969
+ * ```ts
6970
+ * const provider = processEnv();
6971
+ * provider.load().HOME; // current process value
6972
+ * ```
6973
+ */
6974
+ function processEnv() {
6975
+ return {
6976
+ name: "process-env",
6977
+ /**
6978
+ * Returns a shallow copy of `process.env` at call time.
6979
+ *
6980
+ * @returns A fresh shallow copy of `process.env`.
6981
+ * @example
6982
+ * ```ts
6983
+ * processEnv().load();
6984
+ * ```
6985
+ */
6986
+ load() {
6987
+ return { ...process.env };
6988
+ }
6989
+ };
6990
+ }
6991
+ /**
6992
+ * A provider that reads live, per-request Cloudflare bindings from
6993
+ * `globalThis.__CLOUDFLARE_ENV__` at `load()` time (`?? {}` when absent). Never
6994
+ * caches the binding object; the consumer owns the global's request lifecycle.
6995
+ *
6996
+ * @returns An {@link EnvProvider} named `cloudflare`.
6997
+ * @example
6998
+ * ```ts
6999
+ * globalThis.__CLOUDFLARE_ENV__ = env; // set by the request handler
7000
+ * const provider = cloudflareBindings();
7001
+ * provider.load(); // reads the current request's bindings
7002
+ * ```
7003
+ */
7004
+ function cloudflareBindings() {
7005
+ return {
7006
+ name: "cloudflare",
7007
+ /**
7008
+ * Reads `globalThis.__CLOUDFLARE_ENV__` fresh, never caching the bindings.
7009
+ *
7010
+ * @returns The current Cloudflare bindings, or `{}` when the global is unset.
7011
+ * @example
7012
+ * ```ts
7013
+ * cloudflareBindings().load();
7014
+ * ```
7015
+ */
7016
+ load() {
7017
+ return globalThis[CLOUDFLARE_GLOBAL] ?? {};
7018
+ }
7019
+ };
7020
+ }
7021
+ //#endregion
6026
7022
  //#region src/index.ts
6027
7023
  /**
6028
7024
  * @file `@moku-labs/web` — a Moku Layer-2 content static-site + SPA framework.
7025
+ *
7026
+ * `createApp`'s defaults are the **isomorphic** plugins that run unchanged on both
7027
+ * Node and the browser (`site`, `i18n`, `router`, `head`, `spa`, plus the
7028
+ * `log`/`env` core). The Node-only plugins (`content`, `build`, `deploy`,
7029
+ * `data`) are exported for Layer-3 composition: add them with
7030
+ * `createApp({ plugins: [...] })` in a Node build; omit them in a browser app.
7031
+ * The framework never hard-blocks either runtime — the consumer composes the
7032
+ * variant it needs and supplies the matching `env` provider.
6029
7033
  * @see README.md
6030
7034
  */
6031
7035
  const framework = createCore(coreConfig, {
@@ -6033,11 +7037,8 @@ const framework = createCore(coreConfig, {
6033
7037
  sitePlugin,
6034
7038
  i18nPlugin,
6035
7039
  routerPlugin,
6036
- contentPlugin,
6037
7040
  headPlugin,
6038
- buildPlugin,
6039
- spaPlugin,
6040
- deployPlugin
7041
+ spaPlugin
6041
7042
  ],
6042
7043
  pluginConfigs: {}
6043
7044
  });
@@ -6046,22 +7047,28 @@ const framework = createCore(coreConfig, {
6046
7047
  * Your overrides are merged over the framework defaults through the 4-level config
6047
7048
  * cascade, every plugin's lifecycle runs, and a fully-typed, frozen app is returned.
6048
7049
  *
7050
+ * The defaults are the isomorphic plugin set (`site`, `i18n`, `router`, `head`,
7051
+ * `spa` + `log`/`env` core). Add the Node-only plugins for an SSG build:
7052
+ * `createApp({ plugins: [contentPlugin, buildPlugin, deployPlugin] })`.
7053
+ *
6049
7054
  * @param options - Optional configuration:
6050
- * - `pluginConfigs` — per-plugin overrides, keyed by plugin name
6051
- * (`site`, `i18n`, `router`, `content`, `head`, `build`, `spa`, `deploy`, `env`).
7055
+ * - `pluginConfigs` — per-plugin overrides, keyed by plugin name.
6052
7056
  * - `config` — global framework config (e.g. `{ mode: "development" }`).
6053
- * - `plugins` — extra consumer plugins, merged into the app and its return type.
7057
+ * - `plugins` — extra plugins (Node-only built-ins or your own) merged into the app and its type.
6054
7058
  * - `onReady` / `onError` / `onStart` / `onStop` — lifecycle callbacks.
6055
7059
  * @returns The initialized app: `start()`, `stop()`, every plugin's API, and `log`.
6056
7060
  * @example
6057
7061
  * ```ts
7062
+ * // Node SSG build — add the node-only plugins:
6058
7063
  * const app = createApp({
7064
+ * plugins: [contentPlugin, buildPlugin, deployPlugin],
6059
7065
  * pluginConfigs: {
6060
7066
  * site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes" },
6061
7067
  * router: { routes: defineRoutes({ home: route("/"), post: route("/blog/{slug}/") }) }
6062
7068
  * }
6063
7069
  * });
6064
7070
  * await app.start();
7071
+ * await app.build.run();
6065
7072
  * ```
6066
7073
  */
6067
7074
  const createApp = framework.createApp;
@@ -6082,4 +7089,4 @@ const createApp = framework.createApp;
6082
7089
  */
6083
7090
  const createPlugin = framework.createPlugin;
6084
7091
  //#endregion
6085
- export { types_exports as Build, types_exports$1 as Content, types_exports$2 as Deploy, types_exports$3 as Env, types_exports$4 as Head, types_exports$5 as Log, types_exports$6 as Router, types_exports$7 as Spa, buildArticleHead, buildPlugin, canonical, contentPlugin, createApp, createPlugin, defineRoutes, deployPlugin, envPlugin, feedLink, headPlugin, hreflang, i18nPlugin, jsonLd, logPlugin, meta, og, route, routerPlugin, sitePlugin, spaPlugin, twitter };
7092
+ export { types_exports as Build, types_exports$1 as Content, types_exports$2 as Data, types_exports$3 as Deploy, types_exports$4 as Env, types_exports$5 as Head, types_exports$6 as Log, types_exports$7 as Router, types_exports$8 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cloudflareBindings, contentPlugin, createApp, createPlugin, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, headPlugin, hreflang, i18nPlugin, jsonLd, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };