@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.cjs CHANGED
@@ -1,72 +1,39 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- //#region \0rolldown/runtime.js
3
- var __create = Object.create;
4
- var __defProp = Object.defineProperty;
5
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
- var __getOwnPropNames = Object.getOwnPropertyNames;
7
- var __getProtoOf = Object.getPrototypeOf;
8
- var __hasOwnProp = Object.prototype.hasOwnProperty;
9
- var __exportAll = (all, no_symbols) => {
10
- let target = {};
11
- for (var name in all) __defProp(target, name, {
12
- get: all[name],
13
- enumerable: true
14
- });
15
- if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
16
- return target;
17
- };
18
- var __copyProps = (to, from, except, desc) => {
19
- if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
20
- key = keys[i];
21
- if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
22
- get: ((k) => from[k]).bind(null, key),
23
- enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
24
- });
25
- }
26
- return to;
27
- };
28
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
29
- value: mod,
30
- enumerable: true
31
- }) : target, mod));
32
- //#endregion
2
+ const require_convention = require("./convention-Dr8jxG70.cjs");
33
3
  let _moku_labs_core = require("@moku-labs/core");
34
- let node_fs = require("node:fs");
35
4
  let node_fs_promises = require("node:fs/promises");
36
5
  let node_path = require("node:path");
37
- let node_path$1 = __toESM(node_path, 1);
38
- node_path = __toESM(node_path);
6
+ let node_path$1 = require_convention.__toESM(node_path, 1);
7
+ node_path = require_convention.__toESM(node_path);
39
8
  let gray_matter = require("gray-matter");
40
- gray_matter = __toESM(gray_matter, 1);
9
+ gray_matter = require_convention.__toESM(gray_matter, 1);
41
10
  let _shikijs_rehype = require("@shikijs/rehype");
42
- _shikijs_rehype = __toESM(_shikijs_rehype, 1);
11
+ _shikijs_rehype = require_convention.__toESM(_shikijs_rehype, 1);
43
12
  let rehype_sanitize = require("rehype-sanitize");
44
- rehype_sanitize = __toESM(rehype_sanitize, 1);
13
+ rehype_sanitize = require_convention.__toESM(rehype_sanitize, 1);
45
14
  let rehype_stringify = require("rehype-stringify");
46
- rehype_stringify = __toESM(rehype_stringify, 1);
15
+ rehype_stringify = require_convention.__toESM(rehype_stringify, 1);
47
16
  let unified = require("unified");
48
17
  let rehype_raw = require("rehype-raw");
49
- rehype_raw = __toESM(rehype_raw, 1);
18
+ rehype_raw = require_convention.__toESM(rehype_raw, 1);
50
19
  let remark_directive = require("remark-directive");
51
- remark_directive = __toESM(remark_directive, 1);
20
+ remark_directive = require_convention.__toESM(remark_directive, 1);
52
21
  let remark_frontmatter = require("remark-frontmatter");
53
- remark_frontmatter = __toESM(remark_frontmatter, 1);
22
+ remark_frontmatter = require_convention.__toESM(remark_frontmatter, 1);
54
23
  let remark_gfm = require("remark-gfm");
55
- remark_gfm = __toESM(remark_gfm, 1);
24
+ remark_gfm = require_convention.__toESM(remark_gfm, 1);
56
25
  let remark_parse = require("remark-parse");
57
- remark_parse = __toESM(remark_parse, 1);
26
+ remark_parse = require_convention.__toESM(remark_parse, 1);
58
27
  let remark_rehype = require("remark-rehype");
59
- remark_rehype = __toESM(remark_rehype, 1);
28
+ remark_rehype = require_convention.__toESM(remark_rehype, 1);
60
29
  let unist_util_visit = require("unist-util-visit");
61
30
  let hast_util_sanitize = require("hast-util-sanitize");
62
31
  let reading_time = require("reading-time");
63
- reading_time = __toESM(reading_time, 1);
32
+ reading_time = require_convention.__toESM(reading_time, 1);
33
+ let node_fs = require("node:fs");
64
34
  let node_crypto = require("node:crypto");
65
35
  let feed = require("feed");
66
- let _resvg_resvg_js = require("@resvg/resvg-js");
67
- let satori = require("satori");
68
- satori = __toESM(satori);
69
- let preact_jsx_runtime = require("preact/jsx-runtime");
36
+ let preact = require("preact");
70
37
  let preact_render_to_string = require("preact-render-to-string");
71
38
  //#region src/plugins/env/api.ts
72
39
  /** Error prefix for all env API failures. */
@@ -295,112 +262,45 @@ function validateSchema(ctx) {
295
262
  freezeMap(state.publicMap);
296
263
  }
297
264
  //#endregion
298
- //#region src/plugins/env/providers.ts
299
- /**
300
- * @file env plugin — built-in providers: dotenv, processEnv, cloudflareBindings.
301
- */
302
- /** Default dotenv file path: optional local overrides. */
303
- const DEFAULT_DOTENV_PATH = ".env.local";
304
- /**
305
- * Strips a single matching pair of surrounding double or single quotes from a
306
- * value. Leaves unquoted values (and trailing inline comments) untouched.
307
- *
308
- * @param value - The already-trimmed raw value.
309
- * @returns The value with one outer quote pair removed, if present.
310
- * @example
311
- * ```ts
312
- * stripQuotes('"a"'); // "a"
313
- * stripQuotes("plain # c"); // "plain # c"
314
- * ```
315
- */
316
- function stripQuotes(value) {
317
- if (value.length >= 2) {
318
- const first = value[0];
319
- const last = value.at(-1);
320
- if ((first === "\"" || first === "'") && first === last) return value.slice(1, -1);
321
- }
322
- return value;
323
- }
324
- /**
325
- * Parses `.env`-style text into a flat record. Handles CRLF/LF, blank lines,
326
- * full-line `#` comments, first-`=` splitting, key/value trimming, and a single
327
- * outer quote pair. Does not strip trailing inline comments on unquoted values.
328
- *
329
- * @param text - The raw file contents.
330
- * @returns A flat record of parsed key/value pairs.
331
- * @example
332
- * ```ts
333
- * parseDotenv('A=1\nB="two"'); // { A: "1", B: "two" }
334
- * ```
335
- */
336
- function parseDotenv(text) {
337
- const out = {};
338
- for (const line of text.split(/\r?\n/)) {
339
- const trimmed = line.trim();
340
- if (trimmed === "" || trimmed.startsWith("#")) continue;
341
- const eq = trimmed.indexOf("=");
342
- if (eq === -1) continue;
343
- const key = trimmed.slice(0, eq).trim();
344
- out[key] = stripQuotes(trimmed.slice(eq + 1).trim());
345
- }
346
- return out;
347
- }
265
+ //#region src/plugins/env/providers.browser.ts
266
+ /** Default `globalThis` property holding a runtime-injected public-env snapshot. */
267
+ const DEFAULT_GLOBAL_KEY = "__ENV__";
348
268
  /**
349
- * A zero-dependency `.env`-style provider that re-reads and re-parses the file
350
- * from disk on every `load()`. Missing file resolves to `{}` (optional
351
- * overrides). Strips a single outer quote pair; does not strip trailing inline
352
- * comments on unquoted values.
269
+ * A browser-safe {@link EnvProvider} that reads `import.meta.env` and an optional
270
+ * `globalThis[globalKey]` snapshot, merging them with the runtime global winning.
271
+ * Contains zero `node:*` imports, so it is safe to include in the client bundle.
272
+ * Never throws on missing sources — each absent source resolves to `{}`.
353
273
  *
354
- * @param path - Path to the dotenv file. Defaults to `.env.local`.
355
- * @returns An {@link EnvProvider} named `dotenv:<path>` that reads fresh per call.
274
+ * @param options - Optional settings.
275
+ * @param options.globalKey - `globalThis` key to read a public-env snapshot from. Defaults to `"__ENV__"`.
276
+ * @returns An {@link EnvProvider} named `browser-env`.
356
277
  * @example
357
278
  * ```ts
358
- * const provider = dotenv(".env.local");
279
+ * const provider = browserEnv();
359
280
  * provider.load(); // { PUBLIC_API_URL: "/api", ... }
360
281
  * ```
361
282
  */
362
- function dotenv(path = DEFAULT_DOTENV_PATH) {
363
- return {
364
- name: `dotenv:${path}`,
365
- /**
366
- * Reads and parses the dotenv file fresh from disk; `{}` if it is missing.
367
- *
368
- * @returns The parsed environment record, or `{}` when the file is absent.
369
- * @example
370
- * ```ts
371
- * dotenv(".env.local").load();
372
- * ```
373
- */
374
- load() {
375
- if (!(0, node_fs.existsSync)(path)) return {};
376
- return parseDotenv((0, node_fs.readFileSync)(path, "utf8"));
377
- }
378
- };
379
- }
380
- /**
381
- * A provider that returns a shallow copy of `process.env` at `load()` time.
382
- *
383
- * @returns An {@link EnvProvider} named `process-env`.
384
- * @example
385
- * ```ts
386
- * const provider = processEnv();
387
- * provider.load().HOME; // current process value
388
- * ```
389
- */
390
- function processEnv() {
283
+ function browserEnv(options) {
284
+ const globalKey = options?.globalKey ?? DEFAULT_GLOBAL_KEY;
391
285
  return {
392
- name: "process-env",
286
+ name: "browser-env",
393
287
  /**
394
- * Returns a shallow copy of `process.env` at call time.
288
+ * Merges `import.meta.env` with `globalThis[globalKey]`, the runtime global
289
+ * winning. Each absent source resolves to `{}`; never throws.
395
290
  *
396
- * @returns A fresh shallow copy of `process.env`.
291
+ * @returns The merged environment record.
397
292
  * @example
398
293
  * ```ts
399
- * processEnv().load();
294
+ * browserEnv().load();
400
295
  * ```
401
296
  */
402
297
  load() {
403
- return { ...process.env };
298
+ const importEnv = {}.env ?? {};
299
+ const globalObject = globalThis[globalKey] ?? {};
300
+ return {
301
+ ...importEnv,
302
+ ...globalObject
303
+ };
404
304
  }
405
305
  };
406
306
  }
@@ -846,10 +746,7 @@ const logPlugin = (0, _moku_labs_core.createCorePlugin)("log", {
846
746
  const coreConfig = (0, _moku_labs_core.createCoreConfig)("web", {
847
747
  config: { mode: "production" },
848
748
  plugins: [logPlugin, envPlugin],
849
- pluginConfigs: {
850
- log: { mode: "production" },
851
- env: { providers: [dotenv(), processEnv()] }
852
- }
749
+ pluginConfigs: { log: { mode: "production" } }
853
750
  });
854
751
  /**
855
752
  * Create a custom plugin bound to this framework's `Config`/`Events` and the core
@@ -1418,6 +1315,23 @@ function articleToUrl(locale, slug) {
1418
1315
  return `/${locale}/${slug}/`;
1419
1316
  }
1420
1317
  /**
1318
+ * Build the canonical "article not found" error for {@link createContentApi.load}.
1319
+ * Centralised so the null-resolve path and the production draft-suppression path
1320
+ * throw an IDENTICAL message — drafts must be indistinguishable from missing
1321
+ * articles in production (no new error shape).
1322
+ *
1323
+ * @param slug - Article directory name.
1324
+ * @param locale - Requested locale code.
1325
+ * @returns The not-found Error to throw.
1326
+ * @example
1327
+ * ```ts
1328
+ * throw articleNotFound("intro", "uk");
1329
+ * ```
1330
+ */
1331
+ function articleNotFound(slug, locale) {
1332
+ return /* @__PURE__ */ new Error(`[web] content article "${slug}" not found for locale "${locale}".\n Looked for ${slug}/${locale}.md and the default-locale fallback.`);
1333
+ }
1334
+ /**
1421
1335
  * Plugin `api` factory: assembles the kernel-free {@link ContentApiContext} from
1422
1336
  * the plugin context (resolving i18n via `ctx.require`) and delegates to
1423
1337
  * {@link createContentApi}. Referenced directly as the plugin's `api` so
@@ -1651,11 +1565,16 @@ function createContentApi(ctx) {
1651
1565
  /**
1652
1566
  * Resolve and render a single article for one locale with locale fallback.
1653
1567
  * Throws a `[web] content` error when neither the requested nor the
1654
- * default-locale file exists.
1568
+ * default-locale file exists. In production a `draft` article is suppressed
1569
+ * and throws the SAME not-found error (drafts must be indistinguishable from
1570
+ * missing articles so unpublished content is never disclosed); in
1571
+ * development drafts load normally.
1655
1572
  *
1656
1573
  * @param slug - Article directory name.
1657
1574
  * @param locale - Requested locale code.
1658
1575
  * @returns The resolved Article.
1576
+ * @throws {Error} `[web] content` not-found when no file matches, or when the
1577
+ * resolved article is a draft and `global.mode === "production"`.
1659
1578
  * @example
1660
1579
  * ```ts
1661
1580
  * const article = await api.load("intro", "uk");
@@ -1663,7 +1582,8 @@ function createContentApi(ctx) {
1663
1582
  */
1664
1583
  async load(slug, locale) {
1665
1584
  const article = await resolveArticle(ctx, slug, locale);
1666
- 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.`);
1585
+ if (article === null) throw articleNotFound(slug, locale);
1586
+ if (ctx.global.mode === "production" && article.computed.status === "draft") throw articleNotFound(slug, locale);
1667
1587
  const cache = ctx.state.articles.get(locale) ?? /* @__PURE__ */ new Map();
1668
1588
  cache.set(slug, article);
1669
1589
  ctx.state.articles.set(locale, cache);
@@ -2141,6 +2061,24 @@ function toTypedRoute(entry) {
2141
2061
  };
2142
2062
  }
2143
2063
  /**
2064
+ * Project a compiled route into the serializable {@link ClientRoute} view: only
2065
+ * `pattern` / `name` / `meta`, with a fresh `meta` copy and NO `_handlers` closures.
2066
+ *
2067
+ * @param entry - The compiled route entry.
2068
+ * @returns A `ClientRoute` carrying only JSON-serializable fields.
2069
+ * @example
2070
+ * ```ts
2071
+ * toClientRoute(compiledEntry); // { pattern, name, meta }
2072
+ * ```
2073
+ */
2074
+ function toClientRoute(entry) {
2075
+ return {
2076
+ pattern: entry.pattern,
2077
+ name: entry.name,
2078
+ meta: { ...entry.meta }
2079
+ };
2080
+ }
2081
+ /**
2144
2082
  * Creates the router plugin API surface. Every closure reads the compiled table
2145
2083
  * from `ctx.state` and returns values/fresh copies — never the raw state arrays.
2146
2084
  *
@@ -2210,11 +2148,113 @@ function createApi$4(ctx) {
2210
2148
  */
2211
2149
  manifest() {
2212
2150
  return [...readTable(state).byName.values()].map((entry) => entry.definition);
2151
+ },
2152
+ /**
2153
+ * Serializable, specificity-sorted projection of the route table for client
2154
+ * shipping — `{ pattern, name, meta }` entries with NO `_handlers` closures.
2155
+ *
2156
+ * @returns A fresh, frozen, specificity-sorted read-only array of client routes.
2157
+ * @example
2158
+ * ```ts
2159
+ * const json = JSON.stringify(api.clientManifest());
2160
+ * ```
2161
+ */
2162
+ clientManifest() {
2163
+ return Object.freeze(readTable(state).compiled.map((entry) => toClientRoute(entry)));
2164
+ },
2165
+ /**
2166
+ * The resolved render mode (single source of truth for static/hybrid/spa).
2167
+ *
2168
+ * @returns `"ssg" | "spa" | "hybrid"`.
2169
+ * @example
2170
+ * ```ts
2171
+ * if (api.mode() !== "ssg") emitClientData();
2172
+ * ```
2173
+ */
2174
+ mode() {
2175
+ return state.mode;
2213
2176
  }
2214
2177
  };
2215
2178
  }
2216
2179
  //#endregion
2180
+ //#region src/plugins/router/iso-match.ts
2181
+ /**
2182
+ * Parse a single path segment into its `{…}` placeholder, or `false` for a static
2183
+ * segment. Plain loop over the brace delimiters (no backtracking regex). Shared by
2184
+ * the build-time compiler and this isomorphic matcher so the two never diverge.
2185
+ *
2186
+ * @param segment - One `/`-delimited segment, e.g. `{slug}` or `about`.
2187
+ * @returns The parsed placeholder, or `false` when the segment is static.
2188
+ * @example
2189
+ * ```ts
2190
+ * parsePlaceholder("{slug:?}"); // { name: "slug", optional: true }
2191
+ * ```
2192
+ */
2193
+ function parsePlaceholder$1(segment) {
2194
+ if (!segment.startsWith("{") || !segment.endsWith("}")) return false;
2195
+ const inner = segment.slice(1, -1);
2196
+ if (inner.endsWith(":?")) return {
2197
+ name: inner.slice(0, -2),
2198
+ optional: true
2199
+ };
2200
+ return {
2201
+ name: inner,
2202
+ optional: false
2203
+ };
2204
+ }
2205
+ /**
2206
+ * Counts the dynamic (`{param}` / `{param:?}` / `:param`) segments in a route
2207
+ * pattern — fewer dynamic segments rank as more specific. The optional `{lang:?}`
2208
+ * segment is excluded so locale-prefixing does not affect priority (identical to
2209
+ * the build-time compiler's count, which sourced this logic).
2210
+ *
2211
+ * @param pattern - The route pattern string.
2212
+ * @returns The number of dynamic (non-lang) segments.
2213
+ * @example
2214
+ * ```ts
2215
+ * dynamicSegmentCount("/blog/{slug}/"); // 1
2216
+ * dynamicSegmentCount("/{lang:?}/{slug}/"); // 1
2217
+ * ```
2218
+ */
2219
+ function dynamicSegmentCount(pattern) {
2220
+ let count = 0;
2221
+ for (const segment of pattern.split("/")) {
2222
+ const placeholder = parsePlaceholder$1(segment);
2223
+ const isBraceDynamic = placeholder && !(placeholder.name === "lang" && placeholder.optional);
2224
+ const isColonDynamic = !placeholder && segment.startsWith(":");
2225
+ if (isBraceDynamic || isColonDynamic) count += 1;
2226
+ }
2227
+ return count;
2228
+ }
2229
+ /**
2230
+ * Comparator that orders two routes most-specific-first (fewest dynamic segments
2231
+ * first). Equal specificity yields `0` so a stable sort preserves declaration
2232
+ * order — the exact ordering the compiled matcher table uses, guaranteeing
2233
+ * build-time and client-time route resolution can never diverge.
2234
+ *
2235
+ * @param a - First route (carries its `pattern` string).
2236
+ * @param a.pattern - First route's pattern string.
2237
+ * @param b - Second route (carries its `pattern` string).
2238
+ * @param b.pattern - Second route's pattern string.
2239
+ * @returns Negative if `a` is more specific, positive if `b` is, `0` on a tie.
2240
+ * @example
2241
+ * ```ts
2242
+ * routes.toSorted(bySpecificity);
2243
+ * ```
2244
+ */
2245
+ function bySpecificity(a, b) {
2246
+ return dynamicSegmentCount(a.pattern) - dynamicSegmentCount(b.pattern);
2247
+ }
2248
+ //#endregion
2217
2249
  //#region src/plugins/router/builders/compile.ts
2250
+ /**
2251
+ * @file router plugin — compilation + validation domain.
2252
+ *
2253
+ * Pure functions invoked from `onInit`: validate the route map, then compile each
2254
+ * route into URLPattern matchers + URL/file builders, count dynamic segments,
2255
+ * sort by specificity, and assemble the immutable `MatcherTable`. Receives DATA
2256
+ * only (`CompileInput`) — never the plugin ctx.
2257
+ */
2218
2258
  /** Shared `[web]` error prefix for router validation failures. */
2219
2259
  const ERROR_PREFIX$8 = "[web] router";
2220
2260
  /**
@@ -2329,27 +2369,6 @@ function buildFilePath(pattern, params) {
2329
2369
  return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
2330
2370
  }
2331
2371
  /**
2332
- * Count dynamic segments in a pattern (lower = more specific). The optional
2333
- * `{lang:?}` segment is excluded so locale-prefixing does not affect priority.
2334
- *
2335
- * @param pattern - The route pattern.
2336
- * @returns The number of dynamic (non-lang) segments.
2337
- * @example
2338
- * ```ts
2339
- * countDynamicSegments("/{lang:?}/{slug}/"); // 1
2340
- * ```
2341
- */
2342
- function countDynamicSegments(pattern) {
2343
- let count = 0;
2344
- for (const segment of pattern.split("/")) {
2345
- const placeholder = parsePlaceholder(segment);
2346
- const isBraceDynamic = placeholder && !(placeholder.name === "lang" && placeholder.optional);
2347
- const isColonDynamic = !placeholder && segment.startsWith(":");
2348
- if (isBraceDynamic || isColonDynamic) count += 1;
2349
- }
2350
- return count;
2351
- }
2352
- /**
2353
2372
  * Compile a single route definition into its `CompiledRoute` entry.
2354
2373
  *
2355
2374
  * @param name - The route name key.
@@ -2371,7 +2390,7 @@ function compileRoute(name, definition, input) {
2371
2390
  return {
2372
2391
  name,
2373
2392
  pattern,
2374
- dynamicSegmentCount: countDynamicSegments(pattern),
2393
+ dynamicSegmentCount: dynamicSegmentCount(pattern),
2375
2394
  matchers,
2376
2395
  matchFn: createMatchFunction(matchers, input.defaultLocale),
2377
2396
  /**
@@ -2428,10 +2447,7 @@ function compileRoutes(input) {
2428
2447
  byName.set(name, entry);
2429
2448
  }
2430
2449
  return {
2431
- compiled: declarationOrder.map((entry, index) => ({
2432
- entry,
2433
- index
2434
- })).toSorted((a, b) => a.entry.dynamicSegmentCount === b.entry.dynamicSegmentCount ? a.index - b.index : a.entry.dynamicSegmentCount - b.entry.dynamicSegmentCount).map((wrapped) => wrapped.entry),
2450
+ compiled: declarationOrder.toSorted(bySpecificity),
2435
2451
  byName
2436
2452
  };
2437
2453
  }
@@ -2544,6 +2560,21 @@ function route(pattern) {
2544
2560
  return set("render", handler);
2545
2561
  },
2546
2562
  /**
2563
+ * Attach the client-side validation gate (raw `unknown` → this route's data
2564
+ * type). Runs at the trust boundary before `render` on the client; throw to
2565
+ * reject malformed data (spa falls back to HTML-over-fetch).
2566
+ *
2567
+ * @param handler - The validator/parser.
2568
+ * @returns The same builder for chaining.
2569
+ * @example
2570
+ * ```ts
2571
+ * route("/shop/{id}/").parse(raw => ProductSchema.parse(raw));
2572
+ * ```
2573
+ */
2574
+ parse(handler) {
2575
+ return set("parse", handler);
2576
+ },
2577
+ /**
2547
2578
  * Attach the head/SEO handler.
2548
2579
  *
2549
2580
  * @param handler - The head handler.
@@ -2570,9 +2601,11 @@ function route(pattern) {
2570
2601
  return set("generate", handler);
2571
2602
  },
2572
2603
  /**
2573
- * Merge an arbitrary metadata bag into the route's `_meta`.
2604
+ * Merge an arbitrary metadata bag into the route's `_meta`. The bag MUST be
2605
+ * JSON-serializable — it is projected verbatim into `clientManifest()` and
2606
+ * shipped to the browser, so functions/symbols/class instances are unsupported.
2574
2607
  *
2575
- * @param meta - Metadata to merge.
2608
+ * @param meta - JSON-serializable metadata to merge.
2576
2609
  * @returns The same builder for chaining.
2577
2610
  * @example
2578
2611
  * ```ts
@@ -2642,7 +2675,10 @@ function defineRoutes(routes) {
2642
2675
  * ```
2643
2676
  */
2644
2677
  function createState$4(_ctx) {
2645
- return { table: null };
2678
+ return {
2679
+ table: null,
2680
+ mode: _ctx.config.mode ?? "hybrid"
2681
+ };
2646
2682
  }
2647
2683
  /**
2648
2684
  * Router plugin — typed, named route definitions with locale-aware URL generation
@@ -3260,6 +3296,29 @@ function resolveEntrypoints(candidates) {
3260
3296
  return [];
3261
3297
  }
3262
3298
  /**
3299
+ * Resolve the authoritative JS client entrypoint (#8): when `config.clientEntry` is
3300
+ * set, use it directly (the authoritative override); otherwise fall back to the
3301
+ * conventional candidate scan. When neither yields an entry, `ctx.log.warn` (no
3302
+ * client bundle is produced) and an empty list is returned.
3303
+ *
3304
+ * @param ctx - Plugin context (provides `config`, `log`).
3305
+ * @returns The resolved JS entrypoint list (possibly empty).
3306
+ * @example
3307
+ * ```ts
3308
+ * resolveJsEntrypoints(ctx);
3309
+ * ```
3310
+ */
3311
+ function resolveJsEntrypoints(ctx) {
3312
+ const { clientEntry } = ctx.config;
3313
+ if (typeof clientEntry === "string" && clientEntry.length > 0) return [clientEntry];
3314
+ const scanned = resolveEntrypoints(JS_ENTRY_CANDIDATES);
3315
+ if (scanned.length === 0) ctx.log.warn("build:bundle", {
3316
+ clientEntry: "none",
3317
+ scanned: JS_ENTRY_CANDIDATES
3318
+ });
3319
+ return scanned;
3320
+ }
3321
+ /**
3263
3322
  * Run one bundler pass for a single asset kind and record the hashed output
3264
3323
  * paths under `state.buildCache` keyed by the original entry basename.
3265
3324
  *
@@ -3307,7 +3366,7 @@ async function bundle(ctx, options = {}) {
3307
3366
  const runner = options.runner ?? defaultRunner;
3308
3367
  const { minify, outDir } = ctx.config;
3309
3368
  const cssEntrypoints = options.cssEntrypoints ?? resolveEntrypoints(CSS_ENTRY_CANDIDATES);
3310
- const jsEntrypoints = options.jsEntrypoints ?? resolveEntrypoints(JS_ENTRY_CANDIDATES);
3369
+ const jsEntrypoints = options.jsEntrypoints ?? resolveJsEntrypoints(ctx);
3311
3370
  await runOne(ctx, runner, "css", cssEntrypoints, node_path$1.default.join(outDir, "assets"), minify);
3312
3371
  await runOne(ctx, runner, "js", jsEntrypoints, node_path$1.default.join(outDir, "assets"), minify);
3313
3372
  }
@@ -3474,119 +3533,370 @@ async function processImages(ctx, options = {}) {
3474
3533
  return copied;
3475
3534
  }
3476
3535
  //#endregion
3477
- //#region src/plugins/build/phases/og-images.tsx
3536
+ //#region src/plugins/build/phases/locale-redirects.ts
3478
3537
  /**
3479
- * @file build phase 4 og-images. Renders one OG image per published article via
3480
- * Satori SVG resvg PNG, bounded by `p-limit(4)`, with a persisted
3481
- * content-hash cache (`<outDir>/.cache/og-images.json`) skipping unchanged articles.
3482
- * Gated by config.ogImage (object enables; false disables).
3538
+ * @file build phase — locale-redirects. For each non-prefixed route path, emits a
3539
+ * redirect HTML page (`<meta http-equiv="refresh">` + canonical `<link>`) at the
3540
+ * bare path that points at the default-locale-prefixed URL. Deliberately does NOT
3541
+ * emit a Cloudflare `_redirects` catch-all (an SSG infinite-loop trap). Gated by
3542
+ * `config.localeRedirects` (false/unset disables).
3483
3543
  */
3484
- /** Default OG image dimensions when `size` is omitted. */
3485
- const DEFAULT_SIZE = {
3486
- width: 1200,
3487
- height: 630
3488
- };
3489
- /** Recognized font file extensions. */
3490
- const FONT_EXTENSIONS$1 = [
3491
- ".ttf",
3492
- ".otf",
3493
- ".woff"
3494
- ];
3495
3544
  /**
3496
- * Compute the content-hash cache key for an article: `sha256(title+template+size)`.
3545
+ * Render a redirect HTML page: a `0;url` refresh meta + a canonical link to `target`.
3497
3546
  *
3498
- * @param title - The article title.
3499
- * @param template - The resolved OG template identifier.
3500
- * @param size - The output dimensions.
3501
- * @returns The hex-encoded SHA-256 digest.
3547
+ * @param target - The default-locale-prefixed URL to redirect to.
3548
+ * @returns The complete redirect HTML document string.
3502
3549
  * @example
3503
3550
  * ```ts
3504
- * ogHash("Hello", "default", { width: 1200, height: 630 });
3551
+ * redirectHtml("/en/about/");
3505
3552
  * ```
3506
3553
  */
3507
- function ogHash(title, template, size) {
3508
- return (0, node_crypto.createHash)("sha256").update(`${title}|${template}|${size.width}x${size.height}`).digest("hex");
3554
+ function redirectHtml(target) {
3555
+ 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>`;
3509
3556
  }
3510
3557
  /**
3511
- * Resolve the first font file in `fontDir` and read its bytes for Satori.
3558
+ * Correlate manifest definitions to compiled `TypedRoute` entries by pattern (the
3559
+ * shared stable key); routes without a compiled entry are skipped.
3512
3560
  *
3513
- * @param fontDir - Directory containing at least one font file.
3514
- * @returns The font name + bytes, or `null` when no font is present.
3561
+ * @param router - The router API exposing `manifest` + `entries`.
3562
+ * @returns Pairs of `[definition, entry]` for every correlated route.
3515
3563
  * @example
3516
3564
  * ```ts
3517
- * await loadFont("./fonts");
3565
+ * pairRoutes(router);
3518
3566
  * ```
3519
3567
  */
3520
- async function loadFont(fontDir) {
3521
- if (!(0, node_fs.existsSync)(fontDir)) return void 0;
3522
- const font = (await (0, node_fs_promises.readdir)(fontDir)).find((name) => FONT_EXTENSIONS$1.some((extension) => name.endsWith(extension)));
3523
- if (!font) return void 0;
3524
- return {
3525
- name: "OG",
3526
- data: await (0, node_fs_promises.readFile)(node_path.default.join(fontDir, font))
3527
- };
3568
+ function pairRoutes(router) {
3569
+ const byPattern = /* @__PURE__ */ new Map();
3570
+ for (const entry of router.entries()) byPattern.set(entry.pattern, entry);
3571
+ const pairs = [];
3572
+ for (const definition of router.manifest()) {
3573
+ const entry = byPattern.get(definition.pattern);
3574
+ if (entry) pairs.push([definition, entry]);
3575
+ }
3576
+ return pairs;
3528
3577
  }
3529
3578
  /**
3530
- * The default PNG renderer: Satori renders a card to SVG, resvg rasterizes to PNG.
3579
+ * Expand one route into bare→default redirect jobs for the default locale. Uses
3580
+ * `generate?.(defaultLocale)` (or a single empty-params instance) and emits a job
3581
+ * only when the bare file path differs from the default-locale URL (i.e. the route
3582
+ * is locale-prefixed) — otherwise no redirect is needed.
3531
3583
  *
3532
- * @param ctx - The font directory + template wiring for the renderer.
3533
- * @param ctx.fontDir - Directory containing at least one font file.
3534
- * @returns An {@link OgPngRenderer} bound to the loaded font.
3584
+ * @param definition - The route definition (carries `generate`).
3585
+ * @param entry - The compiled `TypedRoute` (owns `toFile`/`toUrl`).
3586
+ * @param defaultLocale - The default locale to redirect bare paths to.
3587
+ * @returns Redirect jobs of `{ file, target }` for this route.
3535
3588
  * @example
3536
3589
  * ```ts
3537
- * const render = makeDefaultRenderer({ fontDir: "./fonts" });
3590
+ * await expandRedirects(def, entry, "en");
3538
3591
  * ```
3539
3592
  */
3540
- function makeDefaultRenderer(ctx) {
3541
- const fontPromise = loadFont(ctx.fontDir);
3542
- return async ({ title, width, height }) => {
3543
- const font = await fontPromise;
3544
- if (!font) throw new Error("[web] build.ogImage no font available for rendering");
3545
- return new _resvg_resvg_js.Resvg(await (0, satori.default)(/* @__PURE__ */ (0, preact_jsx_runtime.jsx)("div", {
3546
- style: {
3547
- display: "flex",
3548
- width: "100%",
3549
- height: "100%",
3550
- alignItems: "center",
3551
- justifyContent: "center",
3552
- fontSize: 64,
3553
- background: "#0b0b0c",
3554
- color: "#ffffff"
3555
- },
3556
- children: title
3557
- }), {
3558
- width,
3559
- height,
3560
- fonts: [{
3561
- name: font.name,
3562
- data: font.data,
3563
- weight: 400,
3564
- style: "normal"
3565
- }]
3566
- })).render().asPng();
3567
- };
3593
+ async function expandRedirects(definition, entry, defaultLocale) {
3594
+ const parameterSets = definition._handlers.generate ? await definition._handlers.generate(defaultLocale) : [{}];
3595
+ const jobs = [];
3596
+ for (const raw of parameterSets) {
3597
+ const params = raw ?? {};
3598
+ const file = entry.toFile(params);
3599
+ const target = entry.toUrl({
3600
+ ...params,
3601
+ lang: defaultLocale
3602
+ });
3603
+ if (target !== entry.toUrl(params)) jobs.push({
3604
+ file,
3605
+ target
3606
+ });
3607
+ }
3608
+ return jobs;
3568
3609
  }
3569
3610
  /**
3570
- * Select the published articles to render OG images for (default-locale set).
3611
+ * Emits one bare-path redirect HTML page per locale-prefixed route path, each a
3612
+ * `0;url` refresh + canonical link to the default-locale URL. Never writes a
3613
+ * Cloudflare `_redirects` file. No-op (returns `null`) when `localeRedirects` is
3614
+ * false/unset.
3571
3615
  *
3572
- * @param byLocale - The cached locale-keyed article map.
3573
- * @returns The published articles across the first cached locale.
3616
+ * @param ctx - Plugin context (provides `require`, `config`, `log`).
3617
+ * @returns The count of redirect pages written, or `null` when disabled.
3574
3618
  * @example
3575
3619
  * ```ts
3576
- * selectArticles(byLocale);
3620
+ * const result = await generateLocaleRedirects(ctx);
3577
3621
  * ```
3578
3622
  */
3579
- function selectArticles(byLocale) {
3623
+ async function generateLocaleRedirects(ctx) {
3624
+ if (!ctx.config.localeRedirects) {
3625
+ ctx.log.debug("build:locale-redirects", { skipped: true });
3626
+ return null;
3627
+ }
3628
+ const router = ctx.require(routerPlugin);
3629
+ const defaultLocale = ctx.require(i18nPlugin).defaultLocale();
3630
+ const jobs = (await Promise.all(pairRoutes(router).map(([definition, entry]) => expandRedirects(definition, entry, defaultLocale)))).flat();
3631
+ await Promise.all(jobs.map(async ({ file, target }) => {
3632
+ const filePath = node_path$1.default.join(ctx.config.outDir, file);
3633
+ await (0, node_fs_promises.mkdir)(node_path$1.default.dirname(filePath), { recursive: true });
3634
+ await (0, node_fs_promises.writeFile)(filePath, redirectHtml(target), "utf8");
3635
+ }));
3636
+ ctx.log.debug("build:locale-redirects", { written: jobs.length });
3637
+ return { written: jobs.length };
3638
+ }
3639
+ //#endregion
3640
+ //#region src/plugins/build/phases/not-found.ts
3641
+ /**
3642
+ * @file build phase — not-found. Emits `outDir/404.html` from configured route
3643
+ * content or a built-in default. Gated by `config.notFound` (false/unset disables).
3644
+ */
3645
+ /** The built-in default 404 page body when no custom route content is supplied. */
3646
+ const DEFAULT_BODY = "<h1>404</h1><p>The page you requested could not be found.</p>";
3647
+ /**
3648
+ * Wrap a body fragment in a minimal HTML document for the 404 page.
3649
+ *
3650
+ * @param body - The inner body HTML (default or configured).
3651
+ * @returns The complete HTML document string.
3652
+ * @example
3653
+ * ```ts
3654
+ * wrap("<h1>404</h1>");
3655
+ * ```
3656
+ */
3657
+ function wrap(body) {
3658
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>404 — Not Found</title></head><body>${body}</body></html>`;
3659
+ }
3660
+ /**
3661
+ * Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
3662
+ * default page; when it is `{ route }`, writes the supplied route content verbatim
3663
+ * inside the document shell. No-op (returns `null`) when `notFound` is false/unset.
3664
+ *
3665
+ * @param ctx - Plugin context (provides `config`, `log`).
3666
+ * @returns The written file path, or `null` when disabled.
3667
+ * @example
3668
+ * ```ts
3669
+ * const result = await generateNotFound(ctx);
3670
+ * ```
3671
+ */
3672
+ async function generateNotFound(ctx) {
3673
+ const { notFound, outDir } = ctx.config;
3674
+ if (!notFound) {
3675
+ ctx.log.debug("build:not-found", { skipped: true });
3676
+ return null;
3677
+ }
3678
+ const body = typeof notFound === "object" && notFound.route ? notFound.route : DEFAULT_BODY;
3679
+ await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
3680
+ const file = node_path$1.default.join(outDir, "404.html");
3681
+ await (0, node_fs_promises.writeFile)(file, wrap(body), "utf8");
3682
+ ctx.log.debug("build:not-found", { path: file });
3683
+ return { path: file };
3684
+ }
3685
+ //#endregion
3686
+ //#region src/plugins/build/phases/og-images.tsx
3687
+ /**
3688
+ * @file build phase 4 — og-images. Renders one OG image per published article via
3689
+ * Satori → SVG → resvg → PNG, bounded by `p-limit(4)`, with a persisted
3690
+ * content-hash cache (`<outDir>/.cache/og-images.json`) skipping unchanged articles.
3691
+ * Gated by config.ogImage (object enables; false disables).
3692
+ */
3693
+ /** Default OG image dimensions when `size` is omitted. */
3694
+ const DEFAULT_SIZE = {
3695
+ width: 1200,
3696
+ height: 630
3697
+ };
3698
+ /** Recognized font file extensions. */
3699
+ const FONT_EXTENSIONS$1 = [
3700
+ ".ttf",
3701
+ ".otf",
3702
+ ".woff"
3703
+ ];
3704
+ /**
3705
+ * Compute a stable cache key for the `fonts` configuration so a font change
3706
+ * invalidates cached PNGs. Hashes the name/path/weight/style of each entry (order
3707
+ * preserved); an empty/omitted list yields a fixed sentinel.
3708
+ *
3709
+ * @param fonts - The configured OG fonts (optional).
3710
+ * @returns A short stable key derived from the fonts list.
3711
+ * @example
3712
+ * ```ts
3713
+ * fontsKey([{ name: "Inter", path: "./Inter.ttf" }]);
3714
+ * ```
3715
+ */
3716
+ function fontsKey(fonts) {
3717
+ if (!fonts || fonts.length === 0) return "default-font";
3718
+ const parts = fonts.map((font) => `${font.name}:${font.path}:${font.weight ?? 400}:${font.style ?? "normal"}`);
3719
+ return (0, node_crypto.createHash)("sha256").update(parts.join("|")).digest("hex").slice(0, 16);
3720
+ }
3721
+ /**
3722
+ * Compute the content-hash cache key for an article OG image. Covers the FULL
3723
+ * {@link RichOgInput} (title/description/date/tags/author/locale/siteName/size),
3724
+ * the resolved `template`, and a {@link fontsKey} of the fonts list — so changing
3725
+ * any input field OR the fonts invalidates the cached PNG.
3726
+ *
3727
+ * @param input - The full rich OG input for the card.
3728
+ * @param template - The resolved OG template identifier.
3729
+ * @param fontsHash - The {@link fontsKey} of the configured fonts.
3730
+ * @returns The hex-encoded SHA-256 digest.
3731
+ * @example
3732
+ * ```ts
3733
+ * ogHash(input, "default", fontsKey());
3734
+ * ```
3735
+ */
3736
+ function ogHash(input, template, fontsHash) {
3737
+ const payload = [
3738
+ input.title,
3739
+ input.description,
3740
+ input.date,
3741
+ input.tags.join(","),
3742
+ input.author ?? "",
3743
+ input.locale,
3744
+ input.siteName,
3745
+ `${input.size.width}x${input.size.height}`,
3746
+ template,
3747
+ fontsHash
3748
+ ].join("|");
3749
+ return (0, node_crypto.createHash)("sha256").update(payload).digest("hex");
3750
+ }
3751
+ /**
3752
+ * Load the configured OG fonts ONCE per build. When `ogImage.fonts` is set, each
3753
+ * `path` is read to a Buffer (outside any per-image loop) and mapped to a Satori
3754
+ * font entry; otherwise the first font file found in `fontDir` is used as a single
3755
+ * 400/normal fallback.
3756
+ *
3757
+ * @param og - The font directory + optional explicit fonts list.
3758
+ * @param og.fontDir - Directory scanned for a fallback font when `fonts` is unset.
3759
+ * @param og.fonts - Explicit named fonts (each loaded once).
3760
+ * @returns The loaded fonts (empty when no font is available).
3761
+ * @example
3762
+ * ```ts
3763
+ * await loadFonts({ fontDir: "./fonts" });
3764
+ * ```
3765
+ */
3766
+ async function loadFonts(og) {
3767
+ if (og.fonts && og.fonts.length > 0) return Promise.all(og.fonts.map(async (font) => ({
3768
+ name: font.name,
3769
+ data: await (0, node_fs_promises.readFile)(font.path),
3770
+ weight: font.weight ?? 400,
3771
+ style: font.style ?? "normal"
3772
+ })));
3773
+ if (!(0, node_fs.existsSync)(og.fontDir)) return [];
3774
+ const file = (await (0, node_fs_promises.readdir)(og.fontDir)).find((name) => FONT_EXTENSIONS$1.some((extension) => name.endsWith(extension)));
3775
+ if (!file) return [];
3776
+ return [{
3777
+ name: "OG",
3778
+ data: await (0, node_fs_promises.readFile)(node_path.default.join(og.fontDir, file)),
3779
+ weight: 400,
3780
+ style: "normal"
3781
+ }];
3782
+ }
3783
+ /**
3784
+ * The built-in default OG card — a centered title on a dark background. Used when
3785
+ * no custom `ogImage.render` hook is configured. (`@jsxImportSource preact`.)
3786
+ *
3787
+ * @param input - The rich OG input (only `title` is used by the default card).
3788
+ * @returns The Preact `VNode` for the default card.
3789
+ * @example
3790
+ * ```ts
3791
+ * defaultCard(input);
3792
+ * ```
3793
+ */
3794
+ function defaultCard(input) {
3795
+ return (0, preact.h)("div", { style: {
3796
+ display: "flex",
3797
+ width: "100%",
3798
+ height: "100%",
3799
+ alignItems: "center",
3800
+ justifyContent: "center",
3801
+ fontSize: 64,
3802
+ background: "#0b0b0c",
3803
+ color: "#ffffff"
3804
+ } }, input.title);
3805
+ }
3806
+ /**
3807
+ * The default PNG renderer: a Preact `VNode` (custom `render` hook or the built-in
3808
+ * card) is rendered to SVG by Satori, then rasterized to PNG by resvg. Both native
3809
+ * deps are imported LAZILY (browser-safe goal); the VNode→Satori-input cast happens
3810
+ * at this single framework boundary only.
3811
+ *
3812
+ * @param ctx - The renderer wiring (preloaded fonts + optional custom card).
3813
+ * @param ctx.fonts - Fonts loaded once for the whole render pass.
3814
+ * @param ctx.render - Optional custom card renderer; defaults to {@link defaultCard}.
3815
+ * @returns An {@link OgPngRenderer} bound to the loaded fonts + renderer.
3816
+ * @example
3817
+ * ```ts
3818
+ * const render = makeDefaultRenderer({ fonts, render: undefined });
3819
+ * ```
3820
+ */
3821
+ function makeDefaultRenderer(ctx) {
3822
+ return async (input) => {
3823
+ if (ctx.fonts.length === 0) throw new Error("[web] build.ogImage no font available for rendering");
3824
+ const { default: satori } = await import("satori");
3825
+ const { Resvg } = await import("@resvg/resvg-js");
3826
+ return new Resvg(await satori((ctx.render ?? defaultCard)(input), {
3827
+ width: input.size.width,
3828
+ height: input.size.height,
3829
+ fonts: ctx.fonts
3830
+ })).render().asPng();
3831
+ };
3832
+ }
3833
+ /**
3834
+ * Select the published articles to render OG images for (default-locale set).
3835
+ *
3836
+ * @param byLocale - The cached locale-keyed article map.
3837
+ * @returns The published articles across the first cached locale.
3838
+ * @example
3839
+ * ```ts
3840
+ * selectArticles(byLocale);
3841
+ * ```
3842
+ */
3843
+ function selectArticles(byLocale) {
3580
3844
  return ([...byLocale.values()][0] ?? []).filter((article) => article.computed.status === "published");
3581
3845
  }
3582
3846
  /**
3847
+ * Build the {@link RichOgInput} for one article from its frontmatter/computed
3848
+ * fields plus the resolved size and site name.
3849
+ *
3850
+ * @param article - The published article to render a card for.
3851
+ * @param size - The resolved OG output dimensions.
3852
+ * @param siteName - The site name (from the site plugin, or `""` when unavailable).
3853
+ * @returns The fully-populated rich OG input.
3854
+ * @example
3855
+ * ```ts
3856
+ * buildInput(article, { width: 1200, height: 630 }, "Blog");
3857
+ * ```
3858
+ */
3859
+ function buildInput(article, size, siteName) {
3860
+ const input = {
3861
+ title: article.frontmatter.title,
3862
+ description: article.frontmatter.description,
3863
+ date: article.frontmatter.date,
3864
+ tags: [...article.frontmatter.tags],
3865
+ locale: article.locale,
3866
+ siteName,
3867
+ size
3868
+ };
3869
+ if (article.frontmatter.author !== void 0) input.author = article.frontmatter.author;
3870
+ return input;
3871
+ }
3872
+ /**
3873
+ * Resolve the site name via `ctx.require(sitePlugin)`, falling back to `""` when the
3874
+ * site API is unavailable (e.g. unit mocks that omit it).
3875
+ *
3876
+ * @param ctx - Plugin context (provides `require`).
3877
+ * @returns The site name, or `""` when the site plugin is not wired.
3878
+ * @example
3879
+ * ```ts
3880
+ * resolveSiteName(ctx);
3881
+ * ```
3882
+ */
3883
+ function resolveSiteName(ctx) {
3884
+ try {
3885
+ return ctx.require(sitePlugin).name();
3886
+ } catch {
3887
+ return "";
3888
+ }
3889
+ }
3890
+ /**
3583
3891
  * Renders OG images for published articles with a `p-limit(4)` concurrency pool.
3584
- * Computes `sha256(title+template+size)` per article and skips regeneration when
3585
- * the hash matches `state.ogImageHashCache`; writes the cache back to
3586
- * `<outDir>/.cache/og-images.json`. No-op when `config.ogImage` is false.
3892
+ * Computes {@link ogHash} (full {@link RichOgInput} + template + fonts) per article
3893
+ * and skips regeneration when the hash matches `state.ogImageHashCache`; writes the
3894
+ * cache back to `<outDir>/.cache/og-images.json`. The configured `ogImage.render`
3895
+ * hook (when present) builds each card; otherwise the built-in card is used. Fonts
3896
+ * are loaded ONCE for the whole pass. No-op when `config.ogImage` is false.
3587
3897
  *
3588
- * @param ctx - Plugin context (provides `state`, `config`, `log`).
3589
- * @param options - Optional dependency-injection seam (PNG renderer).
3898
+ * @param ctx - Plugin context (provides `require`, `state`, `config`, `log`).
3899
+ * @param options - Optional dependency-injection seam (PNG rasterizer).
3590
3900
  * @returns The render/skip counts + peak concurrency, or `null` when disabled.
3591
3901
  * @example
3592
3902
  * ```ts
@@ -3600,9 +3910,17 @@ async function generateOgImages(ctx, options = {}) {
3600
3910
  return null;
3601
3911
  }
3602
3912
  const { default: pLimit } = await import("p-limit");
3603
- const size = og.size ?? DEFAULT_SIZE;
3604
- const template = og.template ?? "default";
3605
- const renderPng = options.renderPng ?? makeDefaultRenderer({ fontDir: og.fontDir });
3913
+ const config = og;
3914
+ const size = config.size ?? DEFAULT_SIZE;
3915
+ const template = config.template ?? "default";
3916
+ const fontsHash = fontsKey(config.fonts);
3917
+ const fonts = options.renderPng ? [] : await loadFonts(config);
3918
+ const renderHook = config.render ? { render: config.render } : {};
3919
+ const renderPng = options.renderPng ?? makeDefaultRenderer({
3920
+ fonts,
3921
+ ...renderHook
3922
+ });
3923
+ const siteName = resolveSiteName(ctx);
3606
3924
  const articles = selectArticles(readCachedContent(ctx));
3607
3925
  const cache = ctx.state.ogImageHashCache;
3608
3926
  await loadDiskCache(ctx.config.outDir, cache);
@@ -3614,7 +3932,8 @@ async function generateOgImages(ctx, options = {}) {
3614
3932
  const outDir = node_path.default.join(ctx.config.outDir, "og");
3615
3933
  await Promise.all(articles.map((article) => limit(async () => {
3616
3934
  const key = article.computed.contentId;
3617
- const hash = ogHash(article.frontmatter.title, template, size);
3935
+ const input = buildInput(article, size, siteName);
3936
+ const hash = ogHash(input, template, fontsHash);
3618
3937
  if (cache.get(key) === hash) {
3619
3938
  skipped += 1;
3620
3939
  return;
@@ -3622,10 +3941,7 @@ async function generateOgImages(ctx, options = {}) {
3622
3941
  active += 1;
3623
3942
  peakConcurrency = Math.max(peakConcurrency, active);
3624
3943
  try {
3625
- const png = await renderPng({
3626
- title: article.frontmatter.title,
3627
- ...size
3628
- });
3944
+ const png = await renderPng(input);
3629
3945
  await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
3630
3946
  await (0, node_fs_promises.writeFile)(node_path.default.join(outDir, `${key}.png`), png);
3631
3947
  cache.set(key, hash);
@@ -3680,28 +3996,329 @@ async function persistDiskCache(outDir, cache) {
3680
3996
  await (0, node_fs_promises.writeFile)(node_path.default.join(dir, "og-images.json"), JSON.stringify(Object.fromEntries(cache)), "utf8");
3681
3997
  }
3682
3998
  //#endregion
3999
+ //#region src/plugins/data/load-json.ts
4000
+ /**
4001
+ * @file `loadJson` — the data plugin's isomorphic JSON read primitive (the
4002
+ * SSG↔SPA seam). Internal to the `data` plugin (NOT a framework-root export):
4003
+ * `data.load(locale)` uses it, and consumers read through `app.data.load(locale)`.
4004
+ *
4005
+ * A read runs in BOTH worlds: on Node it reads the emitted data file from disk;
4006
+ * on the client (browser) it fetches the same data over HTTP. `loadJson` is the
4007
+ * single point where those two worlds differ — everything above it (the route's
4008
+ * `load`/`render`) is shared, so SSR/client parity is structural, not hoped-for.
4009
+ *
4010
+ * The browser path uses the `fetch` global. The Node path lazy-imports
4011
+ * `node:fs/promises` via `await import(...)`, so a browser bundle that includes
4012
+ * `loadJson` never statically pulls `node:*` (the bundler splits the Node branch
4013
+ * into its own chunk that the browser never loads).
4014
+ */
4015
+ /**
4016
+ * Read + parse a JSON resource, isomorphically. In a browser (`document`
4017
+ * defined) it `fetch`es `pathOrUrl`; on Node it reads the file from disk. Throws
4018
+ * on a failed fetch or unreadable file so the caller (`route.load`/`data.load`)
4019
+ * can decide whether to fall back.
4020
+ *
4021
+ * @template T - The expected shape of the parsed JSON.
4022
+ * @param pathOrUrl - A site-root URL (browser) or filesystem path (Node).
4023
+ * @returns The parsed JSON, typed as `T`.
4024
+ * @throws {Error} If the browser fetch is not OK, or the Node file read fails.
4025
+ * @example
4026
+ * ```ts
4027
+ * // Browser: fetch("/_data/en/articles.json")
4028
+ * // Node: read "dist/_data/en/articles.json"
4029
+ * const articles = await loadJson<Article[]>("/_data/en/articles.json");
4030
+ * ```
4031
+ */
4032
+ async function loadJson(pathOrUrl) {
4033
+ if (typeof document === "undefined") {
4034
+ const { readFile } = await import("node:fs/promises");
4035
+ return JSON.parse(await readFile(pathOrUrl, "utf8"));
4036
+ }
4037
+ const response = await fetch(pathOrUrl);
4038
+ if (!response.ok) throw new Error(`[web] loadJson: failed to fetch ${pathOrUrl} (${String(response.status)}).`);
4039
+ return response.json();
4040
+ }
4041
+ //#endregion
4042
+ //#region src/plugins/data/api.ts
4043
+ /**
4044
+ * @file data plugin — API factory (the agnostic data provider surface).
4045
+ *
4046
+ * Node-free by construction: this module statically imports only types + the pure
4047
+ * convention. The Node write side (`write()`) reaches its `node:fs` writer through
4048
+ * a lazy `await import("./writer")` at call time, so a browser bundle that composes
4049
+ * `data` for the read side never pulls `node:*`. The read side (`at()`) uses only
4050
+ * the isomorphic `loadJson` (whose Node branch is itself lazy).
4051
+ */
4052
+ /**
4053
+ * Trim a single trailing slash from a config dir so `fileFor` joins cleanly.
4054
+ *
4055
+ * @param dir - The configured output dir (e.g. `"_data"` or `"_data/"`).
4056
+ * @returns The dir without a trailing slash.
4057
+ * @example
4058
+ * ```ts
4059
+ * trimTrailingSlash("_data/"); // "_data"
4060
+ * ```
4061
+ */
4062
+ function trimTrailingSlash(dir) {
4063
+ return dir.endsWith("/") ? dir.slice(0, -1) : dir;
4064
+ }
4065
+ /**
4066
+ * Builds the data provider — the agnostic bridge. `write()` is the Node persist
4067
+ * side; `at()` is the browser read side; `urlFor`/`fileFor` are the pure
4068
+ * convention. No `onStart`/`onStop` (holds no long-lived resource).
4069
+ *
4070
+ * @param ctx - The data plugin context.
4071
+ * @returns The {@link DataProvider} mounted at `app.data`.
4072
+ * @example
4073
+ * ```ts
4074
+ * const api = dataApi(ctx);
4075
+ * await api.write([{ path: "/en/hello/", data: article }]); // Node build
4076
+ * await api.at("/en/hello/"); // browser
4077
+ * ```
4078
+ */
4079
+ function dataApi(ctx) {
4080
+ return {
4081
+ /**
4082
+ * READ (browser) — fetch (and cache) the persisted data for a page path.
4083
+ * Returns the raw JSON as `unknown` (the caller's `route.parse` validates it),
4084
+ * or `null` if the fetch/parse fails (so `spa` can fall back to HTML).
4085
+ *
4086
+ * @param path - The page URL path (e.g. `/en/hello/`).
4087
+ * @returns The page's raw data, or `null` on failure.
4088
+ * @example
4089
+ * ```ts
4090
+ * const raw = await api.at("/en/hello/");
4091
+ * ```
4092
+ */
4093
+ async at(path) {
4094
+ if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
4095
+ try {
4096
+ const data = await loadJson(`${ctx.config.baseUrl}${require_convention.dataSuffix(path)}`);
4097
+ ctx.state.cache.set(path, data);
4098
+ return data;
4099
+ } catch {
4100
+ return null;
4101
+ }
4102
+ },
4103
+ /**
4104
+ * WRITE (Node) — persist one JSON file per entry, keyed by page path. Called by
4105
+ * `build` after it expands routes. Lazily loads its `node:fs` writer (keeping a
4106
+ * browser bundle node-free).
4107
+ *
4108
+ * @param entries - The per-page data to persist.
4109
+ * @param options - Optional `{ outDir }` override (defaults to `./dist`).
4110
+ * @param options.outDir - Build output directory the write happens under.
4111
+ * @returns A summary of the written files.
4112
+ * @example
4113
+ * ```ts
4114
+ * await api.write([{ path: "/en/hello/", data: article }], { outDir: "dist" });
4115
+ * ```
4116
+ */
4117
+ async write(entries, options) {
4118
+ const { writeData } = await Promise.resolve().then(() => require("./writer-DAF0pM25.cjs"));
4119
+ return writeData(ctx, entries, options);
4120
+ },
4121
+ /**
4122
+ * PURE — the browser fetch URL for a page path.
4123
+ *
4124
+ * @param path - The page URL path.
4125
+ * @returns The site-root-relative data URL.
4126
+ * @example
4127
+ * ```ts
4128
+ * api.urlFor("/en/hello/"); // "/_data/en/hello/index.json"
4129
+ * ```
4130
+ */
4131
+ urlFor(path) {
4132
+ return `${ctx.config.baseUrl}${require_convention.dataSuffix(path)}`;
4133
+ },
4134
+ /**
4135
+ * PURE — the `outDir`-relative file path for a page path.
4136
+ *
4137
+ * @param path - The page URL path.
4138
+ * @returns The output-relative file path.
4139
+ * @example
4140
+ * ```ts
4141
+ * api.fileFor("/en/hello/"); // "_data/en/hello/index.json"
4142
+ * ```
4143
+ */
4144
+ fileFor(path) {
4145
+ return `${trimTrailingSlash(ctx.config.outputDir)}/${require_convention.dataSuffix(path)}`;
4146
+ }
4147
+ };
4148
+ }
4149
+ //#endregion
4150
+ //#region src/plugins/data/config.ts
4151
+ /**
4152
+ * Typed default data config (R6: no inline `as`). `outputDir` is the WRITE path
4153
+ * (filesystem, relative to the build `outDir`); `baseUrl` is the matching READ URL
4154
+ * (site-root-relative) the browser fetches from — the defaults agree
4155
+ * (`"_data"` ↔ `"/_data/"`).
4156
+ *
4157
+ * @example
4158
+ * ```ts
4159
+ * createPlugin("data", { config: defaultDataConfig });
4160
+ * ```
4161
+ */
4162
+ const defaultDataConfig = {
4163
+ outputDir: "_data",
4164
+ baseUrl: "/_data/"
4165
+ };
4166
+ //#endregion
4167
+ //#region src/plugins/data/state.ts
4168
+ /**
4169
+ * Creates initial data state: a null `lastWrite` slot (populated by the Node
4170
+ * `write()` side) and an empty `cache` (populated lazily by the browser `at(path)`
4171
+ * side on first fetch).
4172
+ *
4173
+ * @param _ctx - Minimal context with global and config.
4174
+ * @param _ctx.global - Global framework configuration.
4175
+ * @param _ctx.config - Resolved plugin configuration.
4176
+ * @returns Fresh data state with no recorded write and an empty per-path cache.
4177
+ * @example
4178
+ * ```ts
4179
+ * const state = createDataState({ global: {}, config });
4180
+ * ```
4181
+ */
4182
+ function createDataState(_ctx) {
4183
+ return {
4184
+ lastWrite: null,
4185
+ cache: /* @__PURE__ */ new Map()
4186
+ };
4187
+ }
4188
+ //#endregion
4189
+ //#region src/plugins/data/validate.ts
4190
+ /**
4191
+ * Validates the resolved data config: the browser `baseUrl` must be a non-empty,
4192
+ * site-root-relative URL path. The emit/read pipelines are wired in build waves 3/4.
4193
+ *
4194
+ * @param config - The resolved plugin configuration.
4195
+ * @throws {Error} If `baseUrl` is empty or not a rooted URL path.
4196
+ * @example
4197
+ * ```ts
4198
+ * validateDataConfig({ outputDir: "_data", baseUrl: "/_data/" });
4199
+ * ```
4200
+ */
4201
+ function validateDataConfig(config) {
4202
+ 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/").`);
4203
+ }
4204
+ //#endregion
4205
+ //#region src/plugins/data/index.ts
4206
+ /**
4207
+ * @file data — Standard tier plugin (wiring-only). The AGNOSTIC data provider for
4208
+ * the SSG→DATA→SPA pattern.
4209
+ *
4210
+ * Owns ONE contract — `page path → persisted JSON file` — and nothing about what
4211
+ * the data is: `write(entries)` persists per-page JSON on Node (build supplies the
4212
+ * entries it already expanded); `at(path)` fetches + caches it in the browser as
4213
+ * `unknown`, which the route's `parse` validates before `render`. NOT a framework
4214
+ * default — the consumer composes it where needed (Node build AND/OR browser app).
4215
+ *
4216
+ * **No hard `depends`** — fully browser-composable; the `node:fs` writer is behind
4217
+ * a lazy `import()` inside `write()`. Build ordering is a call-site contract: build
4218
+ * writes data during its pages phase (after its Phase-0 clean), via `app.data.write`.
4219
+ * No `onStart`/`onStop`.
4220
+ * @see README.md
4221
+ */
4222
+ /**
4223
+ * Data plugin — the agnostic data provider. Mounts `write(entries)` (Node persist),
4224
+ * `at(path)` (browser read), and the pure `urlFor`/`fileFor` convention at `app.data`.
4225
+ *
4226
+ * @example
4227
+ * ```ts
4228
+ * // Node build: `build` calls app.data.write(...) during its pages phase when
4229
+ * // router.mode !== "ssg". Just compose the plugin:
4230
+ * const app = createApp({
4231
+ * plugins: [dataPlugin, contentPlugin, buildPlugin],
4232
+ * pluginConfigs: { content: { contentDir: "./content" }, router: { routes, mode: "hybrid" } }
4233
+ * });
4234
+ * await app.start();
4235
+ * await app.build.run(); // writes HTML + per-page data sidecars
4236
+ *
4237
+ * // Browser app: compose `dataPlugin` too; spa fetches via app.data.at(path) on nav.
4238
+ * ```
4239
+ */
4240
+ const dataPlugin = createPlugin$1("data", {
4241
+ config: defaultDataConfig,
4242
+ createState: createDataState,
4243
+ onInit: (ctx) => validateDataConfig(ctx.config),
4244
+ api: dataApi
4245
+ });
4246
+ //#endregion
3683
4247
  //#region src/plugins/build/phases/pages.tsx
3684
4248
  /**
3685
4249
  * @file build phase 3 — pages. Pulls `router.manifest()` + `head.render(route, data)`
3686
4250
  * and SSR-renders each route to static HTML (preact-render-to-string). Appends the
3687
4251
  * build-id meta tag after `head.render()` returns. Does NOT compose `<head>` itself.
3688
4252
  */
4253
+ /** Template placeholder for the composed `<head>` inner HTML. */
4254
+ const HEAD_PLACEHOLDER = "<!--moku:head-->";
4255
+ /** Template placeholder for the SSR-rendered body HTML. */
4256
+ const BODY_PLACEHOLDER = "<!--moku:body-->";
4257
+ /** Template placeholder for the injected asset `<link>`/`<script>` tags. */
4258
+ const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
3689
4259
  /**
3690
- * Compose the full static HTML document, injecting the build-id meta tag into
3691
- * `<head>` AFTER the head plugin's composed HTML (build metadata, not content).
4260
+ * Read the bundle phase's hashed asset manifest for one kind from `state.buildCache`
4261
+ * as a typed {@link BuildCacheEntry} (no `Map<string, unknown>` reads).
3692
4262
  *
3693
- * @param headHtml - The composed `<head>` inner HTML from `head.render`.
3694
- * @param bodyHtml - The SSR-rendered body HTML.
3695
- * @param runId - The per-run build id injected as `<meta name="build-id">`.
3696
- * @param locale - The page locale for the `<html lang>` attribute.
4263
+ * @param ctx - Plugin context (provides `state`).
4264
+ * @param kind - The asset kind key (`"css"` / `"js"`).
4265
+ * @returns The hashed-path manifest entry, or an empty object when absent.
4266
+ * @example
4267
+ * ```ts
4268
+ * readManifest(ctx, "css");
4269
+ * ```
4270
+ */
4271
+ function readManifest(ctx, kind) {
4272
+ const entry = ctx.state.buildCache.get(kind);
4273
+ return entry && typeof entry === "object" ? entry : {};
4274
+ }
4275
+ /**
4276
+ * Build the asset `<link>`/`<script>` tag block from the hashed manifests. Returns
4277
+ * an empty string when `config.injectAssets === false`. Asset paths are emitted as
4278
+ * absolute (`/`-rooted) URLs.
4279
+ *
4280
+ * @param ctx - Plugin context (provides `state`, `config`).
4281
+ * @returns The injected asset tags, or `""` when injection is disabled.
4282
+ * @example
4283
+ * ```ts
4284
+ * buildAssetTags(ctx);
4285
+ * ```
4286
+ */
4287
+ function buildAssetTags(ctx) {
4288
+ if (ctx.config.injectAssets === false) return "";
4289
+ const css = Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`);
4290
+ const js = Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`);
4291
+ return [...css, ...js].join("");
4292
+ }
4293
+ /**
4294
+ * Compose the full static HTML document with the in-code shell, injecting the
4295
+ * build-id meta tag into `<head>` AFTER the head plugin's composed HTML (build
4296
+ * metadata, not content) and the asset tags at the end of `<head>`.
4297
+ *
4298
+ * @param parts - The composed head/body/assets/locale pieces.
3697
4299
  * @returns The complete HTML document string.
3698
4300
  * @example
3699
4301
  * ```ts
3700
- * renderDocument("<title>Hi</title>", "<h1>Hi</h1>", "run-1", "en");
4302
+ * renderDocument({ head: "<title>Hi</title>", body: "<h1>Hi</h1>", assets: "", locale: "en" });
4303
+ * ```
4304
+ */
4305
+ function renderDocument(parts) {
4306
+ return `<!DOCTYPE html><html lang="${parts.locale}"><head>${parts.head}${parts.assets}</head><body>${parts.body}</body></html>`;
4307
+ }
4308
+ /**
4309
+ * Fill a shell template's `<!--moku:head-->` / `<!--moku:body-->` /
4310
+ * `<!--moku:assets-->` placeholders deterministically at build time.
4311
+ *
4312
+ * @param template - The raw shell template HTML.
4313
+ * @param parts - The composed head/body/assets pieces.
4314
+ * @returns The filled document string.
4315
+ * @example
4316
+ * ```ts
4317
+ * fillTemplate(shell, { head, body, assets, locale: "en" });
3701
4318
  * ```
3702
4319
  */
3703
- function renderDocument(headHtml, bodyHtml, runId, locale) {
3704
- return `<!DOCTYPE html><html lang="${locale}"><head>${headHtml}${`<meta name="build-id" content="${runId}">`}</head><body>${bodyHtml}</body></html>`;
4320
+ function fillTemplate(template, parts) {
4321
+ return template.replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
3705
4322
  }
3706
4323
  /**
3707
4324
  * Expand one route definition into its concrete page instances across all
@@ -3778,18 +4395,24 @@ function adaptHeadConfig(config) {
3778
4395
  return adapted;
3779
4396
  }
3780
4397
  /**
3781
- * Render one page instance to its static HTML document and write it to disk.
4398
+ * Render one page instance to its static HTML document and write it to disk. Uses
4399
+ * the configured shell `template` (filled at build time) when supplied, otherwise
4400
+ * the in-code shell; injects the precomputed asset tags + build-id meta.
3782
4401
  *
3783
4402
  * @param ctx - Plugin context (provides `require`, `state`, `config`).
3784
4403
  * @param instance - The concrete page instance to render.
4404
+ * @param shell - Wiring shared across instances (asset tags + optional template).
4405
+ * @param shell.assets - The injected asset `<link>`/`<script>` tags.
4406
+ * @param shell.template - The shell template HTML, or `null` for the in-code shell.
3785
4407
  * @returns The instance's URL and rendered HTML (HTML reused for the root page).
3786
4408
  * @example
3787
4409
  * ```ts
3788
- * await renderInstance(ctx, instance);
4410
+ * await renderInstance(ctx, instance, { assets: "", template: null });
3789
4411
  * ```
3790
4412
  */
3791
- async function renderInstance(ctx, instance) {
4413
+ async function renderInstance(ctx, instance, shell) {
3792
4414
  const { definition, entry, params, locale, name } = instance;
4415
+ const hasData = definition._handlers.load !== void 0;
3793
4416
  const data = definition._handlers.load ? await definition._handlers.load(params, locale) : void 0;
3794
4417
  const routeContext = {
3795
4418
  params,
@@ -3806,14 +4429,24 @@ async function renderInstance(ctx, instance) {
3806
4429
  };
3807
4430
  if (headConfig) resolved.head = adaptHeadConfig(headConfig);
3808
4431
  const headHtml = ctx.require(headPlugin).render(resolved, data);
4432
+ const buildIdMeta = `<meta name="build-id" content="${ctx.state.runId ?? ""}">`;
3809
4433
  const vnode = definition._handlers.render?.(routeContext);
3810
- const html = renderDocument(headHtml, vnode ? (0, preact_render_to_string.renderToString)(vnode) : "", ctx.state.runId ?? "", locale);
4434
+ const bodyHtml = vnode ? (0, preact_render_to_string.renderToString)(vnode) : "";
4435
+ const parts = {
4436
+ head: `${headHtml}${buildIdMeta}`,
4437
+ body: bodyHtml,
4438
+ assets: shell.assets,
4439
+ locale
4440
+ };
4441
+ const html = shell.template === null ? renderDocument(parts) : fillTemplate(shell.template, parts);
3811
4442
  const filePath = (0, node_path.join)(ctx.config.outDir, entry.toFile(params));
3812
4443
  await (0, node_fs_promises.mkdir)((0, node_path.dirname)(filePath), { recursive: true });
3813
4444
  await (0, node_fs_promises.writeFile)(filePath, html, "utf8");
3814
4445
  return {
3815
4446
  url,
3816
- html
4447
+ html,
4448
+ data,
4449
+ hasData
3817
4450
  };
3818
4451
  }
3819
4452
  /**
@@ -3831,14 +4464,56 @@ async function renderInstance(ctx, instance) {
3831
4464
  * const { pageCount, rootHtml } = await renderPages(ctx);
3832
4465
  * ```
3833
4466
  */
4467
+ /**
4468
+ * Enforce the data-validation contract: in `hybrid`/`spa` mode, any route that
4469
+ * has BOTH a `render` and a `load` (so it will be client-data-navigated) MUST
4470
+ * declare a `.parse()` validator — otherwise fetched JSON would reach `render`
4471
+ * unvalidated. Converts a runtime safety hole into a build error.
4472
+ *
4473
+ * @param manifest - The route definitions from `router.manifest()`.
4474
+ * @param mode - The resolved render mode.
4475
+ * @throws {Error} If a data-navigable route is missing `.parse()` in hybrid/spa mode.
4476
+ * @example
4477
+ * ```ts
4478
+ * assertDataValidators(router.manifest(), router.mode());
4479
+ * ```
4480
+ */
4481
+ function assertDataValidators(manifest, mode) {
4482
+ if (mode === "ssg") return;
4483
+ for (const definition of manifest) {
4484
+ const { render, load, parse } = definition._handlers;
4485
+ 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.`);
4486
+ }
4487
+ }
3834
4488
  async function renderPages(ctx) {
3835
4489
  const router = ctx.require(routerPlugin);
3836
4490
  const manifest = router.manifest();
3837
4491
  ctx.state.manifest = [...manifest];
4492
+ const mode = router.mode();
4493
+ assertDataValidators(manifest, mode);
3838
4494
  const byPattern = makeEntryMap(router);
3839
4495
  const locales = ctx.require(i18nPlugin).locales();
4496
+ const templatePath = ctx.config.template;
4497
+ const template = typeof templatePath === "string" && (0, node_fs.existsSync)(templatePath) ? await (0, node_fs_promises.readFile)(templatePath, "utf8") : null;
4498
+ const shell = {
4499
+ assets: buildAssetTags(ctx),
4500
+ template
4501
+ };
3840
4502
  const instances = (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, byPattern)))).flat();
3841
- const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance)));
4503
+ const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance, shell)));
4504
+ if (mode !== "ssg" && ctx.has("data")) {
4505
+ const entries = rendered.filter((page) => page.hasData).map((page) => ({
4506
+ path: page.url,
4507
+ data: page.data
4508
+ }));
4509
+ if (entries.length > 0) {
4510
+ const summary = await ctx.require(dataPlugin).write(entries, { outDir: ctx.config.outDir });
4511
+ ctx.log.debug("build:data", {
4512
+ files: summary.fileCount,
4513
+ bytes: summary.bytes
4514
+ });
4515
+ }
4516
+ }
3842
4517
  const root = rendered.find((page) => page.url === "/" || page.url === "");
3843
4518
  ctx.log.debug("build:pages", { count: rendered.length });
3844
4519
  return {
@@ -3846,6 +4521,37 @@ async function renderPages(ctx) {
3846
4521
  rootHtml: root?.html ?? null
3847
4522
  };
3848
4523
  }
4524
+ /**
4525
+ * Copies the configured `publicDir` (default `"public"`) verbatim into `outDir`,
4526
+ * preserving the nested directory structure. Skips silently (returns `null`) when
4527
+ * the source directory does not exist.
4528
+ *
4529
+ * @param ctx - Plugin context (provides `config`, `log`).
4530
+ * @returns The copy result, or `null` when the public directory is absent.
4531
+ * @example
4532
+ * ```ts
4533
+ * const result = await copyPublic(ctx);
4534
+ * ```
4535
+ */
4536
+ async function copyPublic(ctx) {
4537
+ const from = ctx.config.publicDir ?? "public";
4538
+ if (!(0, node_fs.existsSync)(from)) {
4539
+ ctx.log.debug("build:public", {
4540
+ skipped: true,
4541
+ from
4542
+ });
4543
+ return null;
4544
+ }
4545
+ await (0, node_fs_promises.cp)(from, ctx.config.outDir, { recursive: true });
4546
+ ctx.log.debug("build:public", {
4547
+ from,
4548
+ dest: ctx.config.outDir
4549
+ });
4550
+ return {
4551
+ from: node_path$1.default.normalize(from),
4552
+ copied: 1
4553
+ };
4554
+ }
3849
4555
  //#endregion
3850
4556
  //#region src/plugins/build/phases/sitemap.ts
3851
4557
  /**
@@ -3949,6 +4655,9 @@ const PHASE_ORDER = [
3949
4655
  "feeds",
3950
4656
  "sitemap",
3951
4657
  "og-images",
4658
+ "public",
4659
+ "not-found",
4660
+ "locale-redirects",
3952
4661
  "root-index"
3953
4662
  ];
3954
4663
  /**
@@ -3992,10 +4701,11 @@ function resetRun(ctx) {
3992
4701
  ctx.state.runId = `${Date.now()}-${(0, node_crypto.randomUUID)()}`;
3993
4702
  }
3994
4703
  /**
3995
- * Phase 4 — run feeds / sitemap / og-images concurrently, each gated by its config
3996
- * flag, isolated with `Promise.allSettled` so one failure does not lose the others.
3997
- * A disabled output is skipped entirely it emits NO `build:phase` boundary (the
3998
- * `withPhase` wrapper is gated on the config flag, not just the phase body).
4704
+ * Phase 4 — run feeds / sitemap / og-images / public / not-found / locale-redirects
4705
+ * concurrently, each gated by its config flag (or, for `public`, the presence of the
4706
+ * source dir), isolated with `Promise.allSettled` so one failure does not lose the
4707
+ * others. A disabled output is skipped entirely it emits NO `build:phase` boundary
4708
+ * (the `withPhase` wrapper is gated on the config flag, not just the phase body).
3999
4709
  *
4000
4710
  * @param ctx - The phase context.
4001
4711
  * @example
@@ -4008,6 +4718,9 @@ async function runOutputs(ctx) {
4008
4718
  if (ctx.config.feeds) tasks.push(withPhase(ctx, "feeds", () => generateFeeds(ctx)));
4009
4719
  if (ctx.config.sitemap) tasks.push(withPhase(ctx, "sitemap", () => generateSitemap(ctx)));
4010
4720
  if (ctx.config.ogImage) tasks.push(withPhase(ctx, "og-images", () => generateOgImages(ctx)));
4721
+ if ((0, node_fs.existsSync)(ctx.config.publicDir ?? "public")) tasks.push(withPhase(ctx, "public", () => copyPublic(ctx)));
4722
+ if (ctx.config.notFound) tasks.push(withPhase(ctx, "not-found", () => generateNotFound(ctx)));
4723
+ if (ctx.config.localeRedirects) tasks.push(withPhase(ctx, "locale-redirects", () => generateLocaleRedirects(ctx)));
4011
4724
  const settled = await Promise.allSettled(tasks);
4012
4725
  for (const outcome of settled) if (outcome.status === "rejected") ctx.log.error("build:outputs", { reason: String(outcome.reason) });
4013
4726
  }
@@ -4150,6 +4863,9 @@ function validateFonts(og) {
4150
4863
  */
4151
4864
  function validateConfig$1(config) {
4152
4865
  if (typeof config.outDir !== "string" || config.outDir.trim().length === 0) throw new Error(`${ERROR_PREFIX$5}.outDir: must be a non-empty string.`);
4866
+ if (config.publicDir !== void 0 && typeof config.publicDir !== "string") throw new Error(`${ERROR_PREFIX$5}.publicDir: must be a string when set.`);
4867
+ if (config.template !== void 0 && typeof config.template !== "string") throw new Error(`${ERROR_PREFIX$5}.template: must be a string path when set.`);
4868
+ if (config.clientEntry !== void 0 && typeof config.clientEntry !== "string") throw new Error(`${ERROR_PREFIX$5}.clientEntry: must be a string path when set.`);
4153
4869
  if (config.ogImage) validateFonts(config.ogImage);
4154
4870
  }
4155
4871
  //#endregion
@@ -5117,7 +5833,7 @@ function spaEvents(register) {
5117
5833
  }
5118
5834
  //#endregion
5119
5835
  //#region src/plugins/spa/types.ts
5120
- var types_exports$7 = /* @__PURE__ */ __exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
5836
+ var types_exports$8 = /* @__PURE__ */ require_convention.__exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
5121
5837
  /** Allowed hook names — single source of truth for fail-fast validation. */
5122
5838
  const COMPONENT_HOOK_NAMES = [
5123
5839
  "onCreate",
@@ -5587,11 +6303,12 @@ function resolveClickTarget(event) {
5587
6303
  * Navigation API is unavailable).
5588
6304
  *
5589
6305
  * @param handlers - The navigation lifecycle callbacks.
6306
+ * @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
5590
6307
  * @returns A teardown that removes the attached listeners.
5591
6308
  * @example
5592
6309
  * const dispose = attachHistoryFallback(handlers);
5593
6310
  */
5594
- function attachHistoryFallback(handlers) {
6311
+ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
5595
6312
  /**
5596
6313
  * Intercept an internal-link click and run a History-API navigation.
5597
6314
  *
@@ -5612,7 +6329,7 @@ function attachHistoryFallback(handlers) {
5612
6329
  }
5613
6330
  saveScrollPosition(location.pathname);
5614
6331
  history.pushState({ scrollY: 0 }, "", url.pathname);
5615
- performNavigation(url.pathname, handlers).then(() => window.scrollTo(0, 0)).catch(() => {});
6332
+ navigate(url.pathname).then(() => window.scrollTo(0, 0)).catch(() => {});
5616
6333
  };
5617
6334
  /**
5618
6335
  * Re-run navigation on back/forward, restoring the saved scroll position.
@@ -5621,7 +6338,7 @@ function attachHistoryFallback(handlers) {
5621
6338
  * globalThis.addEventListener("popstate", onPopState);
5622
6339
  */
5623
6340
  const onPopState = () => {
5624
- performNavigation(location.pathname, handlers).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
6341
+ navigate(location.pathname).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
5625
6342
  };
5626
6343
  document.addEventListener("click", onClick);
5627
6344
  globalThis.addEventListener("popstate", onPopState);
@@ -5635,11 +6352,12 @@ function attachHistoryFallback(handlers) {
5635
6352
  *
5636
6353
  * @param navigation - The Navigation API object to attach the listener to.
5637
6354
  * @param handlers - The navigation lifecycle callbacks.
6355
+ * @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
5638
6356
  * @returns A teardown that removes the `navigate` listener.
5639
6357
  * @example
5640
6358
  * const dispose = attachNavigationApi(navigation, handlers);
5641
6359
  */
5642
- function attachNavigationApi(navigation, handlers) {
6360
+ function attachNavigationApi(navigation, handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
5643
6361
  /**
5644
6362
  * Handle a `navigate` event: classify, then intercept with fetch-and-swap.
5645
6363
  *
@@ -5664,7 +6382,7 @@ function attachNavigationApi(navigation, handlers) {
5664
6382
  navEvent.intercept({
5665
6383
  scroll: "manual",
5666
6384
  handler: async () => {
5667
- await performNavigation(url.pathname, handlers);
6385
+ await navigate(url.pathname);
5668
6386
  if (navEvent.navigationType === "traverse") navEvent.scroll();
5669
6387
  else window.scrollTo(0, 0);
5670
6388
  }
@@ -5678,13 +6396,14 @@ function attachNavigationApi(navigation, handlers) {
5678
6396
  * fallback. Returns a teardown removing every listener it attached.
5679
6397
  *
5680
6398
  * @param handlers - The navigation lifecycle callbacks the kernel supplies.
6399
+ * @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
5681
6400
  * @returns A teardown removing all attached listeners.
5682
6401
  * @example
5683
- * const dispose = attachRouter(handlers);
6402
+ * const dispose = attachRouter(handlers, navigate);
5684
6403
  */
5685
- function attachRouter(handlers) {
6404
+ function attachRouter(handlers, navigate) {
5686
6405
  const navigation = getNavigation();
5687
- return navigation ? attachNavigationApi(navigation, handlers) : attachHistoryFallback(handlers);
6406
+ return navigation ? attachNavigationApi(navigation, handlers, navigate) : attachHistoryFallback(handlers, navigate);
5688
6407
  }
5689
6408
  //#endregion
5690
6409
  //#region src/plugins/spa/state.ts
@@ -5803,6 +6522,20 @@ function currentLocationUrl() {
5803
6522
  return location.pathname + location.search;
5804
6523
  }
5805
6524
  /**
6525
+ * Apply the matched route's `head` config to the live document (minimal client
6526
+ * head-sync for the DATA path: title only — the full meta sync runs on the
6527
+ * HTML-over-fetch path from the fetched `<head>`).
6528
+ *
6529
+ * @param route - The matched route definition.
6530
+ * @param routeContext - The render context (params/data/locale).
6531
+ * @example
6532
+ * syncDataHead(hit.route, { params, data, locale });
6533
+ */
6534
+ function syncDataHead(route, routeContext) {
6535
+ const title = route._handlers.head?.(routeContext)?.title;
6536
+ if (title !== void 0 && title !== "") document.title = title;
6537
+ }
6538
+ /**
5806
6539
  * Builds the single shared SPA kernel — a pure factory over state/config/emit.
5807
6540
  * Unit-testable with a mock state object and a spy emit; no Moku ctx involved.
5808
6541
  *
@@ -5866,6 +6599,71 @@ function createSpaKernel(state, config, emit, deps) {
5866
6599
  onEnd: handleEnd,
5867
6600
  onError: handleError
5868
6601
  };
6602
+ /**
6603
+ * The client DATA path: match `pathname`, fetch the page's PERSISTED data via the
6604
+ * `data` reader, VALIDATE it through the route's `parse` gate, then run the
6605
+ * route's OWN `render` (the same component the build used for SSG) and
6606
+ * Preact-render the VNode into the swap region. Returns `false` (touching nothing
6607
+ * the fallback cares about) on no-match / no-render / no-data / fetch-miss /
6608
+ * parse-throw, so the caller falls back to HTML-over-fetch. `route.load` does NOT
6609
+ * run on the client — the build already persisted its output.
6610
+ *
6611
+ * @param pathname - The destination pathname (search stripped for matching).
6612
+ * @returns `true` if the route was rendered from validated data, else `false`.
6613
+ * @example
6614
+ * if (await tryDataRender("/en/world/")) return;
6615
+ */
6616
+ const tryDataRender = async (pathname) => {
6617
+ if (!deps.dataAt) return false;
6618
+ const matchPath = pathname.split("?")[0] ?? pathname;
6619
+ const hit = deps.router.match(matchPath);
6620
+ if (!hit?.route._handlers.render) return false;
6621
+ try {
6622
+ const raw = await deps.dataAt(pathname);
6623
+ if (raw === null) return false;
6624
+ const data = hit.route._handlers.parse ? hit.route._handlers.parse(raw) : raw;
6625
+ const locale = hit.params.lang ?? document.documentElement.lang ?? "";
6626
+ const routeContext = {
6627
+ params: hit.params,
6628
+ data,
6629
+ locale
6630
+ };
6631
+ const vnode = hit.route._handlers.render(routeContext);
6632
+ const region = document.querySelector(resolved.swapSelector);
6633
+ if (!region) return false;
6634
+ handleStart(pathname);
6635
+ const { renderVNode } = await Promise.resolve().then(() => require("./render-BSTM0Akv.cjs"));
6636
+ syncDataHead(hit.route, routeContext);
6637
+ unmountPageSpecific(state, emit);
6638
+ runSwap(() => {
6639
+ region.replaceChildren();
6640
+ renderVNode(vnode, region);
6641
+ scanAndMount(state, emit, resolved.swapSelector);
6642
+ notifyNavEnd(state);
6643
+ }, resolved.viewTransitions);
6644
+ state.currentUrl = pathname;
6645
+ progress?.done();
6646
+ emit("spa:navigated", { url: pathname });
6647
+ return true;
6648
+ } catch {
6649
+ return false;
6650
+ }
6651
+ };
6652
+ /**
6653
+ * Unified navigation: try the client DATA path first (only when the `data`
6654
+ * plugin is composed), then fall back to HTML-over-fetch (which itself falls
6655
+ * back to a full `location.href` reload). Injected into the router so every
6656
+ * navigation entry point (Navigation API, History, programmatic) goes through it.
6657
+ *
6658
+ * @param pathname - The destination pathname.
6659
+ * @returns A promise resolving once the swap (or fallback) is dispatched.
6660
+ * @example
6661
+ * await navigate("/en/world/");
6662
+ */
6663
+ const navigate = async (pathname) => {
6664
+ if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
6665
+ await performNavigation(pathname, handlers);
6666
+ };
5869
6667
  return {
5870
6668
  /**
5871
6669
  * Register config components and seed currentUrl from the document.
@@ -5888,7 +6686,7 @@ function createSpaKernel(state, config, emit, deps) {
5888
6686
  if (state.started) throw new Error(`${ERROR_PREFIX} spa kernel already started.\n Call app.stop() before booting again (single boot per app).`);
5889
6687
  progress = createProgressBar(resolved.progressBar);
5890
6688
  state.currentUrl = currentLocationUrl();
5891
- state.destroyRouter = attachRouter(handlers);
6689
+ state.destroyRouter = attachRouter(handlers, navigate);
5892
6690
  scanAndMount(state, emit, resolved.swapSelector);
5893
6691
  state.started = true;
5894
6692
  },
@@ -5911,7 +6709,7 @@ function createSpaKernel(state, config, emit, deps) {
5911
6709
  */
5912
6710
  processNav(path) {
5913
6711
  if (typeof document === "undefined") return;
5914
- performNavigation(path, handlers).catch(() => {});
6712
+ navigate(path).catch(() => {});
5915
6713
  },
5916
6714
  /**
5917
6715
  * Scan the swap region and mount components for matching elements.
@@ -5938,20 +6736,41 @@ function createSpaKernel(state, config, emit, deps) {
5938
6736
  };
5939
6737
  }
5940
6738
  /**
6739
+ * Structural by-name handle for the OPTIONAL `data` plugin. `ctx.require` resolves
6740
+ * a plugin by its `name` at runtime, so this lets `spa` obtain the `data` reader
6741
+ * WITHOUT importing the `data` plugin or its types — keeping `spa` decoupled and
6742
+ * its `depends` at `[router, head]`. The phantom types only the `at` slice it uses.
6743
+ */
6744
+ const dataPluginHandle = {
6745
+ name: "data",
6746
+ spec: void 0,
6747
+ _phantom: {
6748
+ config: void 0,
6749
+ state: void 0,
6750
+ api: void 0,
6751
+ events: {}
6752
+ }
6753
+ };
6754
+ /**
5941
6755
  * Builds the shared kernel from the plugin context, stores it on `ctx.state`
5942
6756
  * and `kernelRef`, and runs its init step (validate config, register
5943
- * config.components, seed currentUrl). Extracted from index.ts onInit to keep
5944
- * wiring under budget.
6757
+ * config.components, seed currentUrl). Captures the OPTIONAL `data` reader when
6758
+ * the `data` plugin is composed (enabling client DATA navigation).
5945
6759
  *
5946
- * @param ctx - The plugin context (state/config/emit/require/log).
6760
+ * @param ctx - The plugin context (state/config/emit/require/has/log).
5947
6761
  * @example
5948
6762
  * initSpa(ctx);
5949
6763
  */
5950
6764
  function initSpa(ctx) {
5951
- const kernel = createSpaKernel(ctx.state, ctx.config, ctx.emit, {
6765
+ const deps = {
5952
6766
  router: ctx.require(routerPlugin),
5953
6767
  head: ctx.require(headPlugin)
5954
- });
6768
+ };
6769
+ if (ctx.has("data")) {
6770
+ const reader = ctx.require(dataPluginHandle);
6771
+ deps.dataAt = (path) => reader.at(path);
6772
+ }
6773
+ const kernel = createSpaKernel(ctx.state, ctx.config, ctx.emit, deps);
5955
6774
  ctx.state.kernel = kernel;
5956
6775
  kernelRef.current = kernel;
5957
6776
  kernel.init();
@@ -6048,29 +6867,182 @@ const spaPlugin = createPlugin$1("spa", {
6048
6867
  });
6049
6868
  //#endregion
6050
6869
  //#region src/plugins/build/types.ts
6051
- var types_exports = /* @__PURE__ */ __exportAll({});
6870
+ var types_exports = /* @__PURE__ */ require_convention.__exportAll({});
6052
6871
  //#endregion
6053
6872
  //#region src/plugins/content/types.ts
6054
- var types_exports$1 = /* @__PURE__ */ __exportAll({});
6873
+ var types_exports$1 = /* @__PURE__ */ require_convention.__exportAll({});
6874
+ //#endregion
6875
+ //#region src/plugins/data/types.ts
6876
+ var types_exports$2 = /* @__PURE__ */ require_convention.__exportAll({});
6055
6877
  //#endregion
6056
6878
  //#region src/plugins/deploy/types.ts
6057
- var types_exports$2 = /* @__PURE__ */ __exportAll({});
6879
+ var types_exports$3 = /* @__PURE__ */ require_convention.__exportAll({});
6058
6880
  //#endregion
6059
6881
  //#region src/plugins/env/types.ts
6060
- var types_exports$3 = /* @__PURE__ */ __exportAll({});
6882
+ var types_exports$4 = /* @__PURE__ */ require_convention.__exportAll({});
6061
6883
  //#endregion
6062
6884
  //#region src/plugins/head/types.ts
6063
- var types_exports$4 = /* @__PURE__ */ __exportAll({});
6885
+ var types_exports$5 = /* @__PURE__ */ require_convention.__exportAll({});
6064
6886
  //#endregion
6065
6887
  //#region src/plugins/log/types.ts
6066
- var types_exports$5 = /* @__PURE__ */ __exportAll({});
6888
+ var types_exports$6 = /* @__PURE__ */ require_convention.__exportAll({});
6067
6889
  //#endregion
6068
6890
  //#region src/plugins/router/types.ts
6069
- var types_exports$6 = /* @__PURE__ */ __exportAll({});
6891
+ var types_exports$7 = /* @__PURE__ */ require_convention.__exportAll({});
6892
+ //#endregion
6893
+ //#region src/plugins/env/providers.ts
6894
+ /**
6895
+ * @file env plugin — built-in providers: dotenv, processEnv, cloudflareBindings.
6896
+ */
6897
+ /** Default dotenv file path: optional local overrides. */
6898
+ const DEFAULT_DOTENV_PATH = ".env.local";
6899
+ /** Property on `globalThis` that the consumer sets per Cloudflare request. */
6900
+ const CLOUDFLARE_GLOBAL = "__CLOUDFLARE_ENV__";
6901
+ /**
6902
+ * Strips a single matching pair of surrounding double or single quotes from a
6903
+ * value. Leaves unquoted values (and trailing inline comments) untouched.
6904
+ *
6905
+ * @param value - The already-trimmed raw value.
6906
+ * @returns The value with one outer quote pair removed, if present.
6907
+ * @example
6908
+ * ```ts
6909
+ * stripQuotes('"a"'); // "a"
6910
+ * stripQuotes("plain # c"); // "plain # c"
6911
+ * ```
6912
+ */
6913
+ function stripQuotes(value) {
6914
+ if (value.length >= 2) {
6915
+ const first = value[0];
6916
+ const last = value.at(-1);
6917
+ if ((first === "\"" || first === "'") && first === last) return value.slice(1, -1);
6918
+ }
6919
+ return value;
6920
+ }
6921
+ /**
6922
+ * Parses `.env`-style text into a flat record. Handles CRLF/LF, blank lines,
6923
+ * full-line `#` comments, first-`=` splitting, key/value trimming, and a single
6924
+ * outer quote pair. Does not strip trailing inline comments on unquoted values.
6925
+ *
6926
+ * @param text - The raw file contents.
6927
+ * @returns A flat record of parsed key/value pairs.
6928
+ * @example
6929
+ * ```ts
6930
+ * parseDotenv('A=1\nB="two"'); // { A: "1", B: "two" }
6931
+ * ```
6932
+ */
6933
+ function parseDotenv(text) {
6934
+ const out = {};
6935
+ for (const line of text.split(/\r?\n/)) {
6936
+ const trimmed = line.trim();
6937
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
6938
+ const eq = trimmed.indexOf("=");
6939
+ if (eq === -1) continue;
6940
+ const key = trimmed.slice(0, eq).trim();
6941
+ out[key] = stripQuotes(trimmed.slice(eq + 1).trim());
6942
+ }
6943
+ return out;
6944
+ }
6945
+ /**
6946
+ * A zero-dependency `.env`-style provider that re-reads and re-parses the file
6947
+ * from disk on every `load()`. Missing file resolves to `{}` (optional
6948
+ * overrides). Strips a single outer quote pair; does not strip trailing inline
6949
+ * comments on unquoted values.
6950
+ *
6951
+ * @param path - Path to the dotenv file. Defaults to `.env.local`.
6952
+ * @returns An {@link EnvProvider} named `dotenv:<path>` that reads fresh per call.
6953
+ * @example
6954
+ * ```ts
6955
+ * const provider = dotenv(".env.local");
6956
+ * provider.load(); // { PUBLIC_API_URL: "/api", ... }
6957
+ * ```
6958
+ */
6959
+ function dotenv(path = DEFAULT_DOTENV_PATH) {
6960
+ return {
6961
+ name: `dotenv:${path}`,
6962
+ /**
6963
+ * Reads and parses the dotenv file fresh from disk; `{}` if it is missing.
6964
+ *
6965
+ * @returns The parsed environment record, or `{}` when the file is absent.
6966
+ * @example
6967
+ * ```ts
6968
+ * dotenv(".env.local").load();
6969
+ * ```
6970
+ */
6971
+ load() {
6972
+ if (!(0, node_fs.existsSync)(path)) return {};
6973
+ return parseDotenv((0, node_fs.readFileSync)(path, "utf8"));
6974
+ }
6975
+ };
6976
+ }
6977
+ /**
6978
+ * A provider that returns a shallow copy of `process.env` at `load()` time.
6979
+ *
6980
+ * @returns An {@link EnvProvider} named `process-env`.
6981
+ * @example
6982
+ * ```ts
6983
+ * const provider = processEnv();
6984
+ * provider.load().HOME; // current process value
6985
+ * ```
6986
+ */
6987
+ function processEnv() {
6988
+ return {
6989
+ name: "process-env",
6990
+ /**
6991
+ * Returns a shallow copy of `process.env` at call time.
6992
+ *
6993
+ * @returns A fresh shallow copy of `process.env`.
6994
+ * @example
6995
+ * ```ts
6996
+ * processEnv().load();
6997
+ * ```
6998
+ */
6999
+ load() {
7000
+ return { ...process.env };
7001
+ }
7002
+ };
7003
+ }
7004
+ /**
7005
+ * A provider that reads live, per-request Cloudflare bindings from
7006
+ * `globalThis.__CLOUDFLARE_ENV__` at `load()` time (`?? {}` when absent). Never
7007
+ * caches the binding object; the consumer owns the global's request lifecycle.
7008
+ *
7009
+ * @returns An {@link EnvProvider} named `cloudflare`.
7010
+ * @example
7011
+ * ```ts
7012
+ * globalThis.__CLOUDFLARE_ENV__ = env; // set by the request handler
7013
+ * const provider = cloudflareBindings();
7014
+ * provider.load(); // reads the current request's bindings
7015
+ * ```
7016
+ */
7017
+ function cloudflareBindings() {
7018
+ return {
7019
+ name: "cloudflare",
7020
+ /**
7021
+ * Reads `globalThis.__CLOUDFLARE_ENV__` fresh, never caching the bindings.
7022
+ *
7023
+ * @returns The current Cloudflare bindings, or `{}` when the global is unset.
7024
+ * @example
7025
+ * ```ts
7026
+ * cloudflareBindings().load();
7027
+ * ```
7028
+ */
7029
+ load() {
7030
+ return globalThis[CLOUDFLARE_GLOBAL] ?? {};
7031
+ }
7032
+ };
7033
+ }
6070
7034
  //#endregion
6071
7035
  //#region src/index.ts
6072
7036
  /**
6073
7037
  * @file `@moku-labs/web` — a Moku Layer-2 content static-site + SPA framework.
7038
+ *
7039
+ * `createApp`'s defaults are the **isomorphic** plugins that run unchanged on both
7040
+ * Node and the browser (`site`, `i18n`, `router`, `head`, `spa`, plus the
7041
+ * `log`/`env` core). The Node-only plugins (`content`, `build`, `deploy`,
7042
+ * `data`) are exported for Layer-3 composition: add them with
7043
+ * `createApp({ plugins: [...] })` in a Node build; omit them in a browser app.
7044
+ * The framework never hard-blocks either runtime — the consumer composes the
7045
+ * variant it needs and supplies the matching `env` provider.
6074
7046
  * @see README.md
6075
7047
  */
6076
7048
  const framework = createCore(coreConfig, {
@@ -6078,11 +7050,8 @@ const framework = createCore(coreConfig, {
6078
7050
  sitePlugin,
6079
7051
  i18nPlugin,
6080
7052
  routerPlugin,
6081
- contentPlugin,
6082
7053
  headPlugin,
6083
- buildPlugin,
6084
- spaPlugin,
6085
- deployPlugin
7054
+ spaPlugin
6086
7055
  ],
6087
7056
  pluginConfigs: {}
6088
7057
  });
@@ -6091,22 +7060,28 @@ const framework = createCore(coreConfig, {
6091
7060
  * Your overrides are merged over the framework defaults through the 4-level config
6092
7061
  * cascade, every plugin's lifecycle runs, and a fully-typed, frozen app is returned.
6093
7062
  *
7063
+ * The defaults are the isomorphic plugin set (`site`, `i18n`, `router`, `head`,
7064
+ * `spa` + `log`/`env` core). Add the Node-only plugins for an SSG build:
7065
+ * `createApp({ plugins: [contentPlugin, buildPlugin, deployPlugin] })`.
7066
+ *
6094
7067
  * @param options - Optional configuration:
6095
- * - `pluginConfigs` — per-plugin overrides, keyed by plugin name
6096
- * (`site`, `i18n`, `router`, `content`, `head`, `build`, `spa`, `deploy`, `env`).
7068
+ * - `pluginConfigs` — per-plugin overrides, keyed by plugin name.
6097
7069
  * - `config` — global framework config (e.g. `{ mode: "development" }`).
6098
- * - `plugins` — extra consumer plugins, merged into the app and its return type.
7070
+ * - `plugins` — extra plugins (Node-only built-ins or your own) merged into the app and its type.
6099
7071
  * - `onReady` / `onError` / `onStart` / `onStop` — lifecycle callbacks.
6100
7072
  * @returns The initialized app: `start()`, `stop()`, every plugin's API, and `log`.
6101
7073
  * @example
6102
7074
  * ```ts
7075
+ * // Node SSG build — add the node-only plugins:
6103
7076
  * const app = createApp({
7077
+ * plugins: [contentPlugin, buildPlugin, deployPlugin],
6104
7078
  * pluginConfigs: {
6105
7079
  * site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes" },
6106
7080
  * router: { routes: defineRoutes({ home: route("/"), post: route("/blog/{slug}/") }) }
6107
7081
  * }
6108
7082
  * });
6109
7083
  * await app.start();
7084
+ * await app.build.run();
6110
7085
  * ```
6111
7086
  */
6112
7087
  const createApp = framework.createApp;
@@ -6139,50 +7114,60 @@ Object.defineProperty(exports, "Content", {
6139
7114
  return types_exports$1;
6140
7115
  }
6141
7116
  });
6142
- Object.defineProperty(exports, "Deploy", {
7117
+ Object.defineProperty(exports, "Data", {
6143
7118
  enumerable: true,
6144
7119
  get: function() {
6145
7120
  return types_exports$2;
6146
7121
  }
6147
7122
  });
6148
- Object.defineProperty(exports, "Env", {
7123
+ Object.defineProperty(exports, "Deploy", {
6149
7124
  enumerable: true,
6150
7125
  get: function() {
6151
7126
  return types_exports$3;
6152
7127
  }
6153
7128
  });
6154
- Object.defineProperty(exports, "Head", {
7129
+ Object.defineProperty(exports, "Env", {
6155
7130
  enumerable: true,
6156
7131
  get: function() {
6157
7132
  return types_exports$4;
6158
7133
  }
6159
7134
  });
6160
- Object.defineProperty(exports, "Log", {
7135
+ Object.defineProperty(exports, "Head", {
6161
7136
  enumerable: true,
6162
7137
  get: function() {
6163
7138
  return types_exports$5;
6164
7139
  }
6165
7140
  });
6166
- Object.defineProperty(exports, "Router", {
7141
+ Object.defineProperty(exports, "Log", {
6167
7142
  enumerable: true,
6168
7143
  get: function() {
6169
7144
  return types_exports$6;
6170
7145
  }
6171
7146
  });
6172
- Object.defineProperty(exports, "Spa", {
7147
+ Object.defineProperty(exports, "Router", {
6173
7148
  enumerable: true,
6174
7149
  get: function() {
6175
7150
  return types_exports$7;
6176
7151
  }
6177
7152
  });
7153
+ Object.defineProperty(exports, "Spa", {
7154
+ enumerable: true,
7155
+ get: function() {
7156
+ return types_exports$8;
7157
+ }
7158
+ });
7159
+ exports.browserEnv = browserEnv;
6178
7160
  exports.buildArticleHead = buildArticleHead;
6179
7161
  exports.buildPlugin = buildPlugin;
6180
7162
  exports.canonical = canonical;
7163
+ exports.cloudflareBindings = cloudflareBindings;
6181
7164
  exports.contentPlugin = contentPlugin;
6182
7165
  exports.createApp = createApp;
6183
7166
  exports.createPlugin = createPlugin;
7167
+ exports.dataPlugin = dataPlugin;
6184
7168
  exports.defineRoutes = defineRoutes;
6185
7169
  exports.deployPlugin = deployPlugin;
7170
+ exports.dotenv = dotenv;
6186
7171
  exports.envPlugin = envPlugin;
6187
7172
  exports.feedLink = feedLink;
6188
7173
  exports.headPlugin = headPlugin;
@@ -6192,6 +7177,7 @@ exports.jsonLd = jsonLd;
6192
7177
  exports.logPlugin = logPlugin;
6193
7178
  exports.meta = meta;
6194
7179
  exports.og = og;
7180
+ exports.processEnv = processEnv;
6195
7181
  exports.route = route;
6196
7182
  exports.routerPlugin = routerPlugin;
6197
7183
  exports.sitePlugin = sitePlugin;