@moku-labs/web 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  }
@@ -2518,13 +2534,20 @@ function route(pattern) {
2518
2534
  return set("load", loader);
2519
2535
  },
2520
2536
  /**
2521
- * Attach a layout wrapper component.
2537
+ * Attach a ctx-aware layout wrapper that frames the page in persistent chrome.
2538
+ * The wrapper receives the route's `LayoutContext` (render context + `.meta()`
2539
+ * bag) and the page children. Applied in the SSG render path only — client
2540
+ * navigation keeps the chrome and swaps just the inner region.
2522
2541
  *
2523
- * @param component - The layout component.
2542
+ * @param component - The layout component `(ctx, children) => VNode`.
2524
2543
  * @returns The same builder for chaining.
2525
2544
  * @example
2526
2545
  * ```ts
2527
- * route("/").layout((children) => children);
2546
+ * route("/")
2547
+ * .meta({ activeTab: "home" })
2548
+ * .layout((ctx, children) => (
2549
+ * <Shell locale={ctx.locale} active={ctx.meta.activeTab}>{children}</Shell>
2550
+ * ));
2528
2551
  * ```
2529
2552
  */
2530
2553
  layout(component) {
@@ -2544,6 +2567,21 @@ function route(pattern) {
2544
2567
  return set("render", handler);
2545
2568
  },
2546
2569
  /**
2570
+ * Attach the client-side validation gate (raw `unknown` → this route's data
2571
+ * type). Runs at the trust boundary before `render` on the client; throw to
2572
+ * reject malformed data (spa falls back to HTML-over-fetch).
2573
+ *
2574
+ * @param handler - The validator/parser.
2575
+ * @returns The same builder for chaining.
2576
+ * @example
2577
+ * ```ts
2578
+ * route("/shop/{id}/").parse(raw => ProductSchema.parse(raw));
2579
+ * ```
2580
+ */
2581
+ parse(handler) {
2582
+ return set("parse", handler);
2583
+ },
2584
+ /**
2547
2585
  * Attach the head/SEO handler.
2548
2586
  *
2549
2587
  * @param handler - The head handler.
@@ -2570,9 +2608,11 @@ function route(pattern) {
2570
2608
  return set("generate", handler);
2571
2609
  },
2572
2610
  /**
2573
- * Merge an arbitrary metadata bag into the route's `_meta`.
2611
+ * Merge an arbitrary metadata bag into the route's `_meta`. The bag MUST be
2612
+ * JSON-serializable — it is projected verbatim into `clientManifest()` and
2613
+ * shipped to the browser, so functions/symbols/class instances are unsupported.
2574
2614
  *
2575
- * @param meta - Metadata to merge.
2615
+ * @param meta - JSON-serializable metadata to merge.
2576
2616
  * @returns The same builder for chaining.
2577
2617
  * @example
2578
2618
  * ```ts
@@ -2642,7 +2682,10 @@ function defineRoutes(routes) {
2642
2682
  * ```
2643
2683
  */
2644
2684
  function createState$4(_ctx) {
2645
- return { table: null };
2685
+ return {
2686
+ table: null,
2687
+ mode: _ctx.config.mode ?? "hybrid"
2688
+ };
2646
2689
  }
2647
2690
  /**
2648
2691
  * Router plugin — typed, named route definitions with locale-aware URL generation
@@ -3260,6 +3303,29 @@ function resolveEntrypoints(candidates) {
3260
3303
  return [];
3261
3304
  }
3262
3305
  /**
3306
+ * Resolve the authoritative JS client entrypoint (#8): when `config.clientEntry` is
3307
+ * set, use it directly (the authoritative override); otherwise fall back to the
3308
+ * conventional candidate scan. When neither yields an entry, `ctx.log.warn` (no
3309
+ * client bundle is produced) and an empty list is returned.
3310
+ *
3311
+ * @param ctx - Plugin context (provides `config`, `log`).
3312
+ * @returns The resolved JS entrypoint list (possibly empty).
3313
+ * @example
3314
+ * ```ts
3315
+ * resolveJsEntrypoints(ctx);
3316
+ * ```
3317
+ */
3318
+ function resolveJsEntrypoints(ctx) {
3319
+ const { clientEntry } = ctx.config;
3320
+ if (typeof clientEntry === "string" && clientEntry.length > 0) return [clientEntry];
3321
+ const scanned = resolveEntrypoints(JS_ENTRY_CANDIDATES);
3322
+ if (scanned.length === 0) ctx.log.warn("build:bundle", {
3323
+ clientEntry: "none",
3324
+ scanned: JS_ENTRY_CANDIDATES
3325
+ });
3326
+ return scanned;
3327
+ }
3328
+ /**
3263
3329
  * Run one bundler pass for a single asset kind and record the hashed output
3264
3330
  * paths under `state.buildCache` keyed by the original entry basename.
3265
3331
  *
@@ -3307,7 +3373,7 @@ async function bundle(ctx, options = {}) {
3307
3373
  const runner = options.runner ?? defaultRunner;
3308
3374
  const { minify, outDir } = ctx.config;
3309
3375
  const cssEntrypoints = options.cssEntrypoints ?? resolveEntrypoints(CSS_ENTRY_CANDIDATES);
3310
- const jsEntrypoints = options.jsEntrypoints ?? resolveEntrypoints(JS_ENTRY_CANDIDATES);
3376
+ const jsEntrypoints = options.jsEntrypoints ?? resolveJsEntrypoints(ctx);
3311
3377
  await runOne(ctx, runner, "css", cssEntrypoints, node_path$1.default.join(outDir, "assets"), minify);
3312
3378
  await runOne(ctx, runner, "js", jsEntrypoints, node_path$1.default.join(outDir, "assets"), minify);
3313
3379
  }
@@ -3474,119 +3540,370 @@ async function processImages(ctx, options = {}) {
3474
3540
  return copied;
3475
3541
  }
3476
3542
  //#endregion
3477
- //#region src/plugins/build/phases/og-images.tsx
3543
+ //#region src/plugins/build/phases/locale-redirects.ts
3478
3544
  /**
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).
3545
+ * @file build phase — locale-redirects. For each non-prefixed route path, emits a
3546
+ * redirect HTML page (`<meta http-equiv="refresh">` + canonical `<link>`) at the
3547
+ * bare path that points at the default-locale-prefixed URL. Deliberately does NOT
3548
+ * emit a Cloudflare `_redirects` catch-all (an SSG infinite-loop trap). Gated by
3549
+ * `config.localeRedirects` (false/unset disables).
3483
3550
  */
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
3551
  /**
3496
- * Compute the content-hash cache key for an article: `sha256(title+template+size)`.
3552
+ * Render a redirect HTML page: a `0;url` refresh meta + a canonical link to `target`.
3497
3553
  *
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.
3554
+ * @param target - The default-locale-prefixed URL to redirect to.
3555
+ * @returns The complete redirect HTML document string.
3502
3556
  * @example
3503
3557
  * ```ts
3504
- * ogHash("Hello", "default", { width: 1200, height: 630 });
3558
+ * redirectHtml("/en/about/");
3505
3559
  * ```
3506
3560
  */
3507
- function ogHash(title, template, size) {
3508
- return (0, node_crypto.createHash)("sha256").update(`${title}|${template}|${size.width}x${size.height}`).digest("hex");
3561
+ function redirectHtml(target) {
3562
+ 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
3563
  }
3510
3564
  /**
3511
- * Resolve the first font file in `fontDir` and read its bytes for Satori.
3565
+ * Correlate manifest definitions to compiled `TypedRoute` entries by pattern (the
3566
+ * shared stable key); routes without a compiled entry are skipped.
3512
3567
  *
3513
- * @param fontDir - Directory containing at least one font file.
3514
- * @returns The font name + bytes, or `null` when no font is present.
3568
+ * @param router - The router API exposing `manifest` + `entries`.
3569
+ * @returns Pairs of `[definition, entry]` for every correlated route.
3515
3570
  * @example
3516
3571
  * ```ts
3517
- * await loadFont("./fonts");
3572
+ * pairRoutes(router);
3518
3573
  * ```
3519
3574
  */
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
- };
3575
+ function pairRoutes(router) {
3576
+ const byPattern = /* @__PURE__ */ new Map();
3577
+ for (const entry of router.entries()) byPattern.set(entry.pattern, entry);
3578
+ const pairs = [];
3579
+ for (const definition of router.manifest()) {
3580
+ const entry = byPattern.get(definition.pattern);
3581
+ if (entry) pairs.push([definition, entry]);
3582
+ }
3583
+ return pairs;
3528
3584
  }
3529
3585
  /**
3530
- * The default PNG renderer: Satori renders a card to SVG, resvg rasterizes to PNG.
3586
+ * Expand one route into bare→default redirect jobs for the default locale. Uses
3587
+ * `generate?.(defaultLocale)` (or a single empty-params instance) and emits a job
3588
+ * only when the bare file path differs from the default-locale URL (i.e. the route
3589
+ * is locale-prefixed) — otherwise no redirect is needed.
3531
3590
  *
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.
3591
+ * @param definition - The route definition (carries `generate`).
3592
+ * @param entry - The compiled `TypedRoute` (owns `toFile`/`toUrl`).
3593
+ * @param defaultLocale - The default locale to redirect bare paths to.
3594
+ * @returns Redirect jobs of `{ file, target }` for this route.
3535
3595
  * @example
3536
3596
  * ```ts
3537
- * const render = makeDefaultRenderer({ fontDir: "./fonts" });
3597
+ * await expandRedirects(def, entry, "en");
3538
3598
  * ```
3539
3599
  */
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
- };
3600
+ async function expandRedirects(definition, entry, defaultLocale) {
3601
+ const parameterSets = definition._handlers.generate ? await definition._handlers.generate(defaultLocale) : [{}];
3602
+ const jobs = [];
3603
+ for (const raw of parameterSets) {
3604
+ const params = raw ?? {};
3605
+ const file = entry.toFile(params);
3606
+ const target = entry.toUrl({
3607
+ ...params,
3608
+ lang: defaultLocale
3609
+ });
3610
+ if (target !== entry.toUrl(params)) jobs.push({
3611
+ file,
3612
+ target
3613
+ });
3614
+ }
3615
+ return jobs;
3568
3616
  }
3569
3617
  /**
3570
- * Select the published articles to render OG images for (default-locale set).
3618
+ * Emits one bare-path redirect HTML page per locale-prefixed route path, each a
3619
+ * `0;url` refresh + canonical link to the default-locale URL. Never writes a
3620
+ * Cloudflare `_redirects` file. No-op (returns `null`) when `localeRedirects` is
3621
+ * false/unset.
3571
3622
  *
3572
- * @param byLocale - The cached locale-keyed article map.
3573
- * @returns The published articles across the first cached locale.
3623
+ * @param ctx - Plugin context (provides `require`, `config`, `log`).
3624
+ * @returns The count of redirect pages written, or `null` when disabled.
3574
3625
  * @example
3575
3626
  * ```ts
3576
- * selectArticles(byLocale);
3627
+ * const result = await generateLocaleRedirects(ctx);
3577
3628
  * ```
3578
3629
  */
3579
- function selectArticles(byLocale) {
3630
+ async function generateLocaleRedirects(ctx) {
3631
+ if (!ctx.config.localeRedirects) {
3632
+ ctx.log.debug("build:locale-redirects", { skipped: true });
3633
+ return null;
3634
+ }
3635
+ const router = ctx.require(routerPlugin);
3636
+ const defaultLocale = ctx.require(i18nPlugin).defaultLocale();
3637
+ const jobs = (await Promise.all(pairRoutes(router).map(([definition, entry]) => expandRedirects(definition, entry, defaultLocale)))).flat();
3638
+ await Promise.all(jobs.map(async ({ file, target }) => {
3639
+ const filePath = node_path$1.default.join(ctx.config.outDir, file);
3640
+ await (0, node_fs_promises.mkdir)(node_path$1.default.dirname(filePath), { recursive: true });
3641
+ await (0, node_fs_promises.writeFile)(filePath, redirectHtml(target), "utf8");
3642
+ }));
3643
+ ctx.log.debug("build:locale-redirects", { written: jobs.length });
3644
+ return { written: jobs.length };
3645
+ }
3646
+ //#endregion
3647
+ //#region src/plugins/build/phases/not-found.ts
3648
+ /**
3649
+ * @file build phase — not-found. Emits `outDir/404.html` from configured route
3650
+ * content or a built-in default. Gated by `config.notFound` (false/unset disables).
3651
+ */
3652
+ /** The built-in default 404 page body when no custom route content is supplied. */
3653
+ const DEFAULT_BODY = "<h1>404</h1><p>The page you requested could not be found.</p>";
3654
+ /**
3655
+ * Wrap a body fragment in a minimal HTML document for the 404 page.
3656
+ *
3657
+ * @param body - The inner body HTML (default or configured).
3658
+ * @returns The complete HTML document string.
3659
+ * @example
3660
+ * ```ts
3661
+ * wrap("<h1>404</h1>");
3662
+ * ```
3663
+ */
3664
+ function wrap(body) {
3665
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>404 — Not Found</title></head><body>${body}</body></html>`;
3666
+ }
3667
+ /**
3668
+ * Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
3669
+ * default page; when it is `{ route }`, writes the supplied route content verbatim
3670
+ * inside the document shell. No-op (returns `null`) when `notFound` is false/unset.
3671
+ *
3672
+ * @param ctx - Plugin context (provides `config`, `log`).
3673
+ * @returns The written file path, or `null` when disabled.
3674
+ * @example
3675
+ * ```ts
3676
+ * const result = await generateNotFound(ctx);
3677
+ * ```
3678
+ */
3679
+ async function generateNotFound(ctx) {
3680
+ const { notFound, outDir } = ctx.config;
3681
+ if (!notFound) {
3682
+ ctx.log.debug("build:not-found", { skipped: true });
3683
+ return null;
3684
+ }
3685
+ const body = typeof notFound === "object" && notFound.route ? notFound.route : DEFAULT_BODY;
3686
+ await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
3687
+ const file = node_path$1.default.join(outDir, "404.html");
3688
+ await (0, node_fs_promises.writeFile)(file, wrap(body), "utf8");
3689
+ ctx.log.debug("build:not-found", { path: file });
3690
+ return { path: file };
3691
+ }
3692
+ //#endregion
3693
+ //#region src/plugins/build/phases/og-images.tsx
3694
+ /**
3695
+ * @file build phase 4 — og-images. Renders one OG image per published article via
3696
+ * Satori → SVG → resvg → PNG, bounded by `p-limit(4)`, with a persisted
3697
+ * content-hash cache (`<outDir>/.cache/og-images.json`) skipping unchanged articles.
3698
+ * Gated by config.ogImage (object enables; false disables).
3699
+ */
3700
+ /** Default OG image dimensions when `size` is omitted. */
3701
+ const DEFAULT_SIZE = {
3702
+ width: 1200,
3703
+ height: 630
3704
+ };
3705
+ /** Recognized font file extensions. */
3706
+ const FONT_EXTENSIONS$1 = [
3707
+ ".ttf",
3708
+ ".otf",
3709
+ ".woff"
3710
+ ];
3711
+ /**
3712
+ * Compute a stable cache key for the `fonts` configuration so a font change
3713
+ * invalidates cached PNGs. Hashes the name/path/weight/style of each entry (order
3714
+ * preserved); an empty/omitted list yields a fixed sentinel.
3715
+ *
3716
+ * @param fonts - The configured OG fonts (optional).
3717
+ * @returns A short stable key derived from the fonts list.
3718
+ * @example
3719
+ * ```ts
3720
+ * fontsKey([{ name: "Inter", path: "./Inter.ttf" }]);
3721
+ * ```
3722
+ */
3723
+ function fontsKey(fonts) {
3724
+ if (!fonts || fonts.length === 0) return "default-font";
3725
+ const parts = fonts.map((font) => `${font.name}:${font.path}:${font.weight ?? 400}:${font.style ?? "normal"}`);
3726
+ return (0, node_crypto.createHash)("sha256").update(parts.join("|")).digest("hex").slice(0, 16);
3727
+ }
3728
+ /**
3729
+ * Compute the content-hash cache key for an article OG image. Covers the FULL
3730
+ * {@link RichOgInput} (title/description/date/tags/author/locale/siteName/size),
3731
+ * the resolved `template`, and a {@link fontsKey} of the fonts list — so changing
3732
+ * any input field OR the fonts invalidates the cached PNG.
3733
+ *
3734
+ * @param input - The full rich OG input for the card.
3735
+ * @param template - The resolved OG template identifier.
3736
+ * @param fontsHash - The {@link fontsKey} of the configured fonts.
3737
+ * @returns The hex-encoded SHA-256 digest.
3738
+ * @example
3739
+ * ```ts
3740
+ * ogHash(input, "default", fontsKey());
3741
+ * ```
3742
+ */
3743
+ function ogHash(input, template, fontsHash) {
3744
+ const payload = [
3745
+ input.title,
3746
+ input.description,
3747
+ input.date,
3748
+ input.tags.join(","),
3749
+ input.author ?? "",
3750
+ input.locale,
3751
+ input.siteName,
3752
+ `${input.size.width}x${input.size.height}`,
3753
+ template,
3754
+ fontsHash
3755
+ ].join("|");
3756
+ return (0, node_crypto.createHash)("sha256").update(payload).digest("hex");
3757
+ }
3758
+ /**
3759
+ * Load the configured OG fonts ONCE per build. When `ogImage.fonts` is set, each
3760
+ * `path` is read to a Buffer (outside any per-image loop) and mapped to a Satori
3761
+ * font entry; otherwise the first font file found in `fontDir` is used as a single
3762
+ * 400/normal fallback.
3763
+ *
3764
+ * @param og - The font directory + optional explicit fonts list.
3765
+ * @param og.fontDir - Directory scanned for a fallback font when `fonts` is unset.
3766
+ * @param og.fonts - Explicit named fonts (each loaded once).
3767
+ * @returns The loaded fonts (empty when no font is available).
3768
+ * @example
3769
+ * ```ts
3770
+ * await loadFonts({ fontDir: "./fonts" });
3771
+ * ```
3772
+ */
3773
+ async function loadFonts(og) {
3774
+ if (og.fonts && og.fonts.length > 0) return Promise.all(og.fonts.map(async (font) => ({
3775
+ name: font.name,
3776
+ data: await (0, node_fs_promises.readFile)(font.path),
3777
+ weight: font.weight ?? 400,
3778
+ style: font.style ?? "normal"
3779
+ })));
3780
+ if (!(0, node_fs.existsSync)(og.fontDir)) return [];
3781
+ const file = (await (0, node_fs_promises.readdir)(og.fontDir)).find((name) => FONT_EXTENSIONS$1.some((extension) => name.endsWith(extension)));
3782
+ if (!file) return [];
3783
+ return [{
3784
+ name: "OG",
3785
+ data: await (0, node_fs_promises.readFile)(node_path.default.join(og.fontDir, file)),
3786
+ weight: 400,
3787
+ style: "normal"
3788
+ }];
3789
+ }
3790
+ /**
3791
+ * The built-in default OG card — a centered title on a dark background. Used when
3792
+ * no custom `ogImage.render` hook is configured. (`@jsxImportSource preact`.)
3793
+ *
3794
+ * @param input - The rich OG input (only `title` is used by the default card).
3795
+ * @returns The Preact `VNode` for the default card.
3796
+ * @example
3797
+ * ```ts
3798
+ * defaultCard(input);
3799
+ * ```
3800
+ */
3801
+ function defaultCard(input) {
3802
+ return (0, preact.h)("div", { style: {
3803
+ display: "flex",
3804
+ width: "100%",
3805
+ height: "100%",
3806
+ alignItems: "center",
3807
+ justifyContent: "center",
3808
+ fontSize: 64,
3809
+ background: "#0b0b0c",
3810
+ color: "#ffffff"
3811
+ } }, input.title);
3812
+ }
3813
+ /**
3814
+ * The default PNG renderer: a Preact `VNode` (custom `render` hook or the built-in
3815
+ * card) is rendered to SVG by Satori, then rasterized to PNG by resvg. Both native
3816
+ * deps are imported LAZILY (browser-safe goal); the VNode→Satori-input cast happens
3817
+ * at this single framework boundary only.
3818
+ *
3819
+ * @param ctx - The renderer wiring (preloaded fonts + optional custom card).
3820
+ * @param ctx.fonts - Fonts loaded once for the whole render pass.
3821
+ * @param ctx.render - Optional custom card renderer; defaults to {@link defaultCard}.
3822
+ * @returns An {@link OgPngRenderer} bound to the loaded fonts + renderer.
3823
+ * @example
3824
+ * ```ts
3825
+ * const render = makeDefaultRenderer({ fonts, render: undefined });
3826
+ * ```
3827
+ */
3828
+ function makeDefaultRenderer(ctx) {
3829
+ return async (input) => {
3830
+ if (ctx.fonts.length === 0) throw new Error("[web] build.ogImage no font available for rendering");
3831
+ const { default: satori } = await import("satori");
3832
+ const { Resvg } = await import("@resvg/resvg-js");
3833
+ return new Resvg(await satori((ctx.render ?? defaultCard)(input), {
3834
+ width: input.size.width,
3835
+ height: input.size.height,
3836
+ fonts: ctx.fonts
3837
+ })).render().asPng();
3838
+ };
3839
+ }
3840
+ /**
3841
+ * Select the published articles to render OG images for (default-locale set).
3842
+ *
3843
+ * @param byLocale - The cached locale-keyed article map.
3844
+ * @returns The published articles across the first cached locale.
3845
+ * @example
3846
+ * ```ts
3847
+ * selectArticles(byLocale);
3848
+ * ```
3849
+ */
3850
+ function selectArticles(byLocale) {
3580
3851
  return ([...byLocale.values()][0] ?? []).filter((article) => article.computed.status === "published");
3581
3852
  }
3582
3853
  /**
3854
+ * Build the {@link RichOgInput} for one article from its frontmatter/computed
3855
+ * fields plus the resolved size and site name.
3856
+ *
3857
+ * @param article - The published article to render a card for.
3858
+ * @param size - The resolved OG output dimensions.
3859
+ * @param siteName - The site name (from the site plugin, or `""` when unavailable).
3860
+ * @returns The fully-populated rich OG input.
3861
+ * @example
3862
+ * ```ts
3863
+ * buildInput(article, { width: 1200, height: 630 }, "Blog");
3864
+ * ```
3865
+ */
3866
+ function buildInput(article, size, siteName) {
3867
+ const input = {
3868
+ title: article.frontmatter.title,
3869
+ description: article.frontmatter.description,
3870
+ date: article.frontmatter.date,
3871
+ tags: [...article.frontmatter.tags],
3872
+ locale: article.locale,
3873
+ siteName,
3874
+ size
3875
+ };
3876
+ if (article.frontmatter.author !== void 0) input.author = article.frontmatter.author;
3877
+ return input;
3878
+ }
3879
+ /**
3880
+ * Resolve the site name via `ctx.require(sitePlugin)`, falling back to `""` when the
3881
+ * site API is unavailable (e.g. unit mocks that omit it).
3882
+ *
3883
+ * @param ctx - Plugin context (provides `require`).
3884
+ * @returns The site name, or `""` when the site plugin is not wired.
3885
+ * @example
3886
+ * ```ts
3887
+ * resolveSiteName(ctx);
3888
+ * ```
3889
+ */
3890
+ function resolveSiteName(ctx) {
3891
+ try {
3892
+ return ctx.require(sitePlugin).name();
3893
+ } catch {
3894
+ return "";
3895
+ }
3896
+ }
3897
+ /**
3583
3898
  * 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.
3899
+ * Computes {@link ogHash} (full {@link RichOgInput} + template + fonts) per article
3900
+ * and skips regeneration when the hash matches `state.ogImageHashCache`; writes the
3901
+ * cache back to `<outDir>/.cache/og-images.json`. The configured `ogImage.render`
3902
+ * hook (when present) builds each card; otherwise the built-in card is used. Fonts
3903
+ * are loaded ONCE for the whole pass. No-op when `config.ogImage` is false.
3587
3904
  *
3588
- * @param ctx - Plugin context (provides `state`, `config`, `log`).
3589
- * @param options - Optional dependency-injection seam (PNG renderer).
3905
+ * @param ctx - Plugin context (provides `require`, `state`, `config`, `log`).
3906
+ * @param options - Optional dependency-injection seam (PNG rasterizer).
3590
3907
  * @returns The render/skip counts + peak concurrency, or `null` when disabled.
3591
3908
  * @example
3592
3909
  * ```ts
@@ -3600,9 +3917,17 @@ async function generateOgImages(ctx, options = {}) {
3600
3917
  return null;
3601
3918
  }
3602
3919
  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 });
3920
+ const config = og;
3921
+ const size = config.size ?? DEFAULT_SIZE;
3922
+ const template = config.template ?? "default";
3923
+ const fontsHash = fontsKey(config.fonts);
3924
+ const fonts = options.renderPng ? [] : await loadFonts(config);
3925
+ const renderHook = config.render ? { render: config.render } : {};
3926
+ const renderPng = options.renderPng ?? makeDefaultRenderer({
3927
+ fonts,
3928
+ ...renderHook
3929
+ });
3930
+ const siteName = resolveSiteName(ctx);
3606
3931
  const articles = selectArticles(readCachedContent(ctx));
3607
3932
  const cache = ctx.state.ogImageHashCache;
3608
3933
  await loadDiskCache(ctx.config.outDir, cache);
@@ -3614,7 +3939,8 @@ async function generateOgImages(ctx, options = {}) {
3614
3939
  const outDir = node_path.default.join(ctx.config.outDir, "og");
3615
3940
  await Promise.all(articles.map((article) => limit(async () => {
3616
3941
  const key = article.computed.contentId;
3617
- const hash = ogHash(article.frontmatter.title, template, size);
3942
+ const input = buildInput(article, size, siteName);
3943
+ const hash = ogHash(input, template, fontsHash);
3618
3944
  if (cache.get(key) === hash) {
3619
3945
  skipped += 1;
3620
3946
  return;
@@ -3622,10 +3948,7 @@ async function generateOgImages(ctx, options = {}) {
3622
3948
  active += 1;
3623
3949
  peakConcurrency = Math.max(peakConcurrency, active);
3624
3950
  try {
3625
- const png = await renderPng({
3626
- title: article.frontmatter.title,
3627
- ...size
3628
- });
3951
+ const png = await renderPng(input);
3629
3952
  await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
3630
3953
  await (0, node_fs_promises.writeFile)(node_path.default.join(outDir, `${key}.png`), png);
3631
3954
  cache.set(key, hash);
@@ -3680,28 +4003,329 @@ async function persistDiskCache(outDir, cache) {
3680
4003
  await (0, node_fs_promises.writeFile)(node_path.default.join(dir, "og-images.json"), JSON.stringify(Object.fromEntries(cache)), "utf8");
3681
4004
  }
3682
4005
  //#endregion
4006
+ //#region src/plugins/data/load-json.ts
4007
+ /**
4008
+ * @file `loadJson` — the data plugin's isomorphic JSON read primitive (the
4009
+ * SSG↔SPA seam). Internal to the `data` plugin (NOT a framework-root export):
4010
+ * `data.load(locale)` uses it, and consumers read through `app.data.load(locale)`.
4011
+ *
4012
+ * A read runs in BOTH worlds: on Node it reads the emitted data file from disk;
4013
+ * on the client (browser) it fetches the same data over HTTP. `loadJson` is the
4014
+ * single point where those two worlds differ — everything above it (the route's
4015
+ * `load`/`render`) is shared, so SSR/client parity is structural, not hoped-for.
4016
+ *
4017
+ * The browser path uses the `fetch` global. The Node path lazy-imports
4018
+ * `node:fs/promises` via `await import(...)`, so a browser bundle that includes
4019
+ * `loadJson` never statically pulls `node:*` (the bundler splits the Node branch
4020
+ * into its own chunk that the browser never loads).
4021
+ */
4022
+ /**
4023
+ * Read + parse a JSON resource, isomorphically. In a browser (`document`
4024
+ * defined) it `fetch`es `pathOrUrl`; on Node it reads the file from disk. Throws
4025
+ * on a failed fetch or unreadable file so the caller (`route.load`/`data.load`)
4026
+ * can decide whether to fall back.
4027
+ *
4028
+ * @template T - The expected shape of the parsed JSON.
4029
+ * @param pathOrUrl - A site-root URL (browser) or filesystem path (Node).
4030
+ * @returns The parsed JSON, typed as `T`.
4031
+ * @throws {Error} If the browser fetch is not OK, or the Node file read fails.
4032
+ * @example
4033
+ * ```ts
4034
+ * // Browser: fetch("/_data/en/articles.json")
4035
+ * // Node: read "dist/_data/en/articles.json"
4036
+ * const articles = await loadJson<Article[]>("/_data/en/articles.json");
4037
+ * ```
4038
+ */
4039
+ async function loadJson(pathOrUrl) {
4040
+ if (typeof document === "undefined") {
4041
+ const { readFile } = await import("node:fs/promises");
4042
+ return JSON.parse(await readFile(pathOrUrl, "utf8"));
4043
+ }
4044
+ const response = await fetch(pathOrUrl);
4045
+ if (!response.ok) throw new Error(`[web] loadJson: failed to fetch ${pathOrUrl} (${String(response.status)}).`);
4046
+ return response.json();
4047
+ }
4048
+ //#endregion
4049
+ //#region src/plugins/data/api.ts
4050
+ /**
4051
+ * @file data plugin — API factory (the agnostic data provider surface).
4052
+ *
4053
+ * Node-free by construction: this module statically imports only types + the pure
4054
+ * convention. The Node write side (`write()`) reaches its `node:fs` writer through
4055
+ * a lazy `await import("./writer")` at call time, so a browser bundle that composes
4056
+ * `data` for the read side never pulls `node:*`. The read side (`at()`) uses only
4057
+ * the isomorphic `loadJson` (whose Node branch is itself lazy).
4058
+ */
4059
+ /**
4060
+ * Trim a single trailing slash from a config dir so `fileFor` joins cleanly.
4061
+ *
4062
+ * @param dir - The configured output dir (e.g. `"_data"` or `"_data/"`).
4063
+ * @returns The dir without a trailing slash.
4064
+ * @example
4065
+ * ```ts
4066
+ * trimTrailingSlash("_data/"); // "_data"
4067
+ * ```
4068
+ */
4069
+ function trimTrailingSlash(dir) {
4070
+ return dir.endsWith("/") ? dir.slice(0, -1) : dir;
4071
+ }
4072
+ /**
4073
+ * Builds the data provider — the agnostic bridge. `write()` is the Node persist
4074
+ * side; `at()` is the browser read side; `urlFor`/`fileFor` are the pure
4075
+ * convention. No `onStart`/`onStop` (holds no long-lived resource).
4076
+ *
4077
+ * @param ctx - The data plugin context.
4078
+ * @returns The {@link DataProvider} mounted at `app.data`.
4079
+ * @example
4080
+ * ```ts
4081
+ * const api = dataApi(ctx);
4082
+ * await api.write([{ path: "/en/hello/", data: article }]); // Node build
4083
+ * await api.at("/en/hello/"); // browser
4084
+ * ```
4085
+ */
4086
+ function dataApi(ctx) {
4087
+ return {
4088
+ /**
4089
+ * READ (browser) — fetch (and cache) the persisted data for a page path.
4090
+ * Returns the raw JSON as `unknown` (the caller's `route.parse` validates it),
4091
+ * or `null` if the fetch/parse fails (so `spa` can fall back to HTML).
4092
+ *
4093
+ * @param path - The page URL path (e.g. `/en/hello/`).
4094
+ * @returns The page's raw data, or `null` on failure.
4095
+ * @example
4096
+ * ```ts
4097
+ * const raw = await api.at("/en/hello/");
4098
+ * ```
4099
+ */
4100
+ async at(path) {
4101
+ if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
4102
+ try {
4103
+ const data = await loadJson(`${ctx.config.baseUrl}${require_convention.dataSuffix(path)}`);
4104
+ ctx.state.cache.set(path, data);
4105
+ return data;
4106
+ } catch {
4107
+ return null;
4108
+ }
4109
+ },
4110
+ /**
4111
+ * WRITE (Node) — persist one JSON file per entry, keyed by page path. Called by
4112
+ * `build` after it expands routes. Lazily loads its `node:fs` writer (keeping a
4113
+ * browser bundle node-free).
4114
+ *
4115
+ * @param entries - The per-page data to persist.
4116
+ * @param options - Optional `{ outDir }` override (defaults to `./dist`).
4117
+ * @param options.outDir - Build output directory the write happens under.
4118
+ * @returns A summary of the written files.
4119
+ * @example
4120
+ * ```ts
4121
+ * await api.write([{ path: "/en/hello/", data: article }], { outDir: "dist" });
4122
+ * ```
4123
+ */
4124
+ async write(entries, options) {
4125
+ const { writeData } = await Promise.resolve().then(() => require("./writer-DAF0pM25.cjs"));
4126
+ return writeData(ctx, entries, options);
4127
+ },
4128
+ /**
4129
+ * PURE — the browser fetch URL for a page path.
4130
+ *
4131
+ * @param path - The page URL path.
4132
+ * @returns The site-root-relative data URL.
4133
+ * @example
4134
+ * ```ts
4135
+ * api.urlFor("/en/hello/"); // "/_data/en/hello/index.json"
4136
+ * ```
4137
+ */
4138
+ urlFor(path) {
4139
+ return `${ctx.config.baseUrl}${require_convention.dataSuffix(path)}`;
4140
+ },
4141
+ /**
4142
+ * PURE — the `outDir`-relative file path for a page path.
4143
+ *
4144
+ * @param path - The page URL path.
4145
+ * @returns The output-relative file path.
4146
+ * @example
4147
+ * ```ts
4148
+ * api.fileFor("/en/hello/"); // "_data/en/hello/index.json"
4149
+ * ```
4150
+ */
4151
+ fileFor(path) {
4152
+ return `${trimTrailingSlash(ctx.config.outputDir)}/${require_convention.dataSuffix(path)}`;
4153
+ }
4154
+ };
4155
+ }
4156
+ //#endregion
4157
+ //#region src/plugins/data/config.ts
4158
+ /**
4159
+ * Typed default data config (R6: no inline `as`). `outputDir` is the WRITE path
4160
+ * (filesystem, relative to the build `outDir`); `baseUrl` is the matching READ URL
4161
+ * (site-root-relative) the browser fetches from — the defaults agree
4162
+ * (`"_data"` ↔ `"/_data/"`).
4163
+ *
4164
+ * @example
4165
+ * ```ts
4166
+ * createPlugin("data", { config: defaultDataConfig });
4167
+ * ```
4168
+ */
4169
+ const defaultDataConfig = {
4170
+ outputDir: "_data",
4171
+ baseUrl: "/_data/"
4172
+ };
4173
+ //#endregion
4174
+ //#region src/plugins/data/state.ts
4175
+ /**
4176
+ * Creates initial data state: a null `lastWrite` slot (populated by the Node
4177
+ * `write()` side) and an empty `cache` (populated lazily by the browser `at(path)`
4178
+ * side on first fetch).
4179
+ *
4180
+ * @param _ctx - Minimal context with global and config.
4181
+ * @param _ctx.global - Global framework configuration.
4182
+ * @param _ctx.config - Resolved plugin configuration.
4183
+ * @returns Fresh data state with no recorded write and an empty per-path cache.
4184
+ * @example
4185
+ * ```ts
4186
+ * const state = createDataState({ global: {}, config });
4187
+ * ```
4188
+ */
4189
+ function createDataState(_ctx) {
4190
+ return {
4191
+ lastWrite: null,
4192
+ cache: /* @__PURE__ */ new Map()
4193
+ };
4194
+ }
4195
+ //#endregion
4196
+ //#region src/plugins/data/validate.ts
4197
+ /**
4198
+ * Validates the resolved data config: the browser `baseUrl` must be a non-empty,
4199
+ * site-root-relative URL path. The emit/read pipelines are wired in build waves 3/4.
4200
+ *
4201
+ * @param config - The resolved plugin configuration.
4202
+ * @throws {Error} If `baseUrl` is empty or not a rooted URL path.
4203
+ * @example
4204
+ * ```ts
4205
+ * validateDataConfig({ outputDir: "_data", baseUrl: "/_data/" });
4206
+ * ```
4207
+ */
4208
+ function validateDataConfig(config) {
4209
+ 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/").`);
4210
+ }
4211
+ //#endregion
4212
+ //#region src/plugins/data/index.ts
4213
+ /**
4214
+ * @file data — Standard tier plugin (wiring-only). The AGNOSTIC data provider for
4215
+ * the SSG→DATA→SPA pattern.
4216
+ *
4217
+ * Owns ONE contract — `page path → persisted JSON file` — and nothing about what
4218
+ * the data is: `write(entries)` persists per-page JSON on Node (build supplies the
4219
+ * entries it already expanded); `at(path)` fetches + caches it in the browser as
4220
+ * `unknown`, which the route's `parse` validates before `render`. NOT a framework
4221
+ * default — the consumer composes it where needed (Node build AND/OR browser app).
4222
+ *
4223
+ * **No hard `depends`** — fully browser-composable; the `node:fs` writer is behind
4224
+ * a lazy `import()` inside `write()`. Build ordering is a call-site contract: build
4225
+ * writes data during its pages phase (after its Phase-0 clean), via `app.data.write`.
4226
+ * No `onStart`/`onStop`.
4227
+ * @see README.md
4228
+ */
4229
+ /**
4230
+ * Data plugin — the agnostic data provider. Mounts `write(entries)` (Node persist),
4231
+ * `at(path)` (browser read), and the pure `urlFor`/`fileFor` convention at `app.data`.
4232
+ *
4233
+ * @example
4234
+ * ```ts
4235
+ * // Node build: `build` calls app.data.write(...) during its pages phase when
4236
+ * // router.mode !== "ssg". Just compose the plugin:
4237
+ * const app = createApp({
4238
+ * plugins: [dataPlugin, contentPlugin, buildPlugin],
4239
+ * pluginConfigs: { content: { contentDir: "./content" }, router: { routes, mode: "hybrid" } }
4240
+ * });
4241
+ * await app.start();
4242
+ * await app.build.run(); // writes HTML + per-page data sidecars
4243
+ *
4244
+ * // Browser app: compose `dataPlugin` too; spa fetches via app.data.at(path) on nav.
4245
+ * ```
4246
+ */
4247
+ const dataPlugin = createPlugin$1("data", {
4248
+ config: defaultDataConfig,
4249
+ createState: createDataState,
4250
+ onInit: (ctx) => validateDataConfig(ctx.config),
4251
+ api: dataApi
4252
+ });
4253
+ //#endregion
3683
4254
  //#region src/plugins/build/phases/pages.tsx
3684
4255
  /**
3685
4256
  * @file build phase 3 — pages. Pulls `router.manifest()` + `head.render(route, data)`
3686
4257
  * and SSR-renders each route to static HTML (preact-render-to-string). Appends the
3687
4258
  * build-id meta tag after `head.render()` returns. Does NOT compose `<head>` itself.
3688
4259
  */
4260
+ /** Template placeholder for the composed `<head>` inner HTML. */
4261
+ const HEAD_PLACEHOLDER = "<!--moku:head-->";
4262
+ /** Template placeholder for the SSR-rendered body HTML. */
4263
+ const BODY_PLACEHOLDER = "<!--moku:body-->";
4264
+ /** Template placeholder for the injected asset `<link>`/`<script>` tags. */
4265
+ const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
3689
4266
  /**
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).
4267
+ * Read the bundle phase's hashed asset manifest for one kind from `state.buildCache`
4268
+ * as a typed {@link BuildCacheEntry} (no `Map<string, unknown>` reads).
3692
4269
  *
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.
4270
+ * @param ctx - Plugin context (provides `state`).
4271
+ * @param kind - The asset kind key (`"css"` / `"js"`).
4272
+ * @returns The hashed-path manifest entry, or an empty object when absent.
4273
+ * @example
4274
+ * ```ts
4275
+ * readManifest(ctx, "css");
4276
+ * ```
4277
+ */
4278
+ function readManifest(ctx, kind) {
4279
+ const entry = ctx.state.buildCache.get(kind);
4280
+ return entry && typeof entry === "object" ? entry : {};
4281
+ }
4282
+ /**
4283
+ * Build the asset `<link>`/`<script>` tag block from the hashed manifests. Returns
4284
+ * an empty string when `config.injectAssets === false`. Asset paths are emitted as
4285
+ * absolute (`/`-rooted) URLs.
4286
+ *
4287
+ * @param ctx - Plugin context (provides `state`, `config`).
4288
+ * @returns The injected asset tags, or `""` when injection is disabled.
4289
+ * @example
4290
+ * ```ts
4291
+ * buildAssetTags(ctx);
4292
+ * ```
4293
+ */
4294
+ function buildAssetTags(ctx) {
4295
+ if (ctx.config.injectAssets === false) return "";
4296
+ const css = Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`);
4297
+ const js = Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`);
4298
+ return [...css, ...js].join("");
4299
+ }
4300
+ /**
4301
+ * Compose the full static HTML document with the in-code shell, injecting the
4302
+ * build-id meta tag into `<head>` AFTER the head plugin's composed HTML (build
4303
+ * metadata, not content) and the asset tags at the end of `<head>`.
4304
+ *
4305
+ * @param parts - The composed head/body/assets/locale pieces.
3697
4306
  * @returns The complete HTML document string.
3698
4307
  * @example
3699
4308
  * ```ts
3700
- * renderDocument("<title>Hi</title>", "<h1>Hi</h1>", "run-1", "en");
4309
+ * renderDocument({ head: "<title>Hi</title>", body: "<h1>Hi</h1>", assets: "", locale: "en" });
4310
+ * ```
4311
+ */
4312
+ function renderDocument(parts) {
4313
+ return `<!DOCTYPE html><html lang="${parts.locale}"><head>${parts.head}${parts.assets}</head><body>${parts.body}</body></html>`;
4314
+ }
4315
+ /**
4316
+ * Fill a shell template's `<!--moku:head-->` / `<!--moku:body-->` /
4317
+ * `<!--moku:assets-->` placeholders deterministically at build time.
4318
+ *
4319
+ * @param template - The raw shell template HTML.
4320
+ * @param parts - The composed head/body/assets pieces.
4321
+ * @returns The filled document string.
4322
+ * @example
4323
+ * ```ts
4324
+ * fillTemplate(shell, { head, body, assets, locale: "en" });
3701
4325
  * ```
3702
4326
  */
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>`;
4327
+ function fillTemplate(template, parts) {
4328
+ return template.replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
3705
4329
  }
3706
4330
  /**
3707
4331
  * Expand one route definition into its concrete page instances across all
@@ -3778,18 +4402,24 @@ function adaptHeadConfig(config) {
3778
4402
  return adapted;
3779
4403
  }
3780
4404
  /**
3781
- * Render one page instance to its static HTML document and write it to disk.
4405
+ * Render one page instance to its static HTML document and write it to disk. Uses
4406
+ * the configured shell `template` (filled at build time) when supplied, otherwise
4407
+ * the in-code shell; injects the precomputed asset tags + build-id meta.
3782
4408
  *
3783
4409
  * @param ctx - Plugin context (provides `require`, `state`, `config`).
3784
4410
  * @param instance - The concrete page instance to render.
4411
+ * @param shell - Wiring shared across instances (asset tags + optional template).
4412
+ * @param shell.assets - The injected asset `<link>`/`<script>` tags.
4413
+ * @param shell.template - The shell template HTML, or `null` for the in-code shell.
3785
4414
  * @returns The instance's URL and rendered HTML (HTML reused for the root page).
3786
4415
  * @example
3787
4416
  * ```ts
3788
- * await renderInstance(ctx, instance);
4417
+ * await renderInstance(ctx, instance, { assets: "", template: null });
3789
4418
  * ```
3790
4419
  */
3791
- async function renderInstance(ctx, instance) {
4420
+ async function renderInstance(ctx, instance, shell) {
3792
4421
  const { definition, entry, params, locale, name } = instance;
4422
+ const hasData = definition._handlers.load !== void 0;
3793
4423
  const data = definition._handlers.load ? await definition._handlers.load(params, locale) : void 0;
3794
4424
  const routeContext = {
3795
4425
  params,
@@ -3806,14 +4436,29 @@ async function renderInstance(ctx, instance) {
3806
4436
  };
3807
4437
  if (headConfig) resolved.head = adaptHeadConfig(headConfig);
3808
4438
  const headHtml = ctx.require(headPlugin).render(resolved, data);
4439
+ const buildIdMeta = `<meta name="build-id" content="${ctx.state.runId ?? ""}">`;
3809
4440
  const vnode = definition._handlers.render?.(routeContext);
3810
- const html = renderDocument(headHtml, vnode ? (0, preact_render_to_string.renderToString)(vnode) : "", ctx.state.runId ?? "", locale);
4441
+ const layoutCtx = {
4442
+ ...routeContext,
4443
+ meta: definition._meta
4444
+ };
4445
+ const page = vnode && definition._handlers.layout ? definition._handlers.layout(layoutCtx, vnode) : vnode;
4446
+ const bodyHtml = page ? (0, preact_render_to_string.renderToString)(page) : "";
4447
+ const parts = {
4448
+ head: `${headHtml}${buildIdMeta}`,
4449
+ body: bodyHtml,
4450
+ assets: shell.assets,
4451
+ locale
4452
+ };
4453
+ const html = shell.template === null ? renderDocument(parts) : fillTemplate(shell.template, parts);
3811
4454
  const filePath = (0, node_path.join)(ctx.config.outDir, entry.toFile(params));
3812
4455
  await (0, node_fs_promises.mkdir)((0, node_path.dirname)(filePath), { recursive: true });
3813
4456
  await (0, node_fs_promises.writeFile)(filePath, html, "utf8");
3814
4457
  return {
3815
4458
  url,
3816
- html
4459
+ html,
4460
+ data,
4461
+ hasData
3817
4462
  };
3818
4463
  }
3819
4464
  /**
@@ -3831,14 +4476,56 @@ async function renderInstance(ctx, instance) {
3831
4476
  * const { pageCount, rootHtml } = await renderPages(ctx);
3832
4477
  * ```
3833
4478
  */
4479
+ /**
4480
+ * Enforce the data-validation contract: in `hybrid`/`spa` mode, any route that
4481
+ * has BOTH a `render` and a `load` (so it will be client-data-navigated) MUST
4482
+ * declare a `.parse()` validator — otherwise fetched JSON would reach `render`
4483
+ * unvalidated. Converts a runtime safety hole into a build error.
4484
+ *
4485
+ * @param manifest - The route definitions from `router.manifest()`.
4486
+ * @param mode - The resolved render mode.
4487
+ * @throws {Error} If a data-navigable route is missing `.parse()` in hybrid/spa mode.
4488
+ * @example
4489
+ * ```ts
4490
+ * assertDataValidators(router.manifest(), router.mode());
4491
+ * ```
4492
+ */
4493
+ function assertDataValidators(manifest, mode) {
4494
+ if (mode === "ssg") return;
4495
+ for (const definition of manifest) {
4496
+ const { render, load, parse } = definition._handlers;
4497
+ 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.`);
4498
+ }
4499
+ }
3834
4500
  async function renderPages(ctx) {
3835
4501
  const router = ctx.require(routerPlugin);
3836
4502
  const manifest = router.manifest();
3837
4503
  ctx.state.manifest = [...manifest];
4504
+ const mode = router.mode();
4505
+ assertDataValidators(manifest, mode);
3838
4506
  const byPattern = makeEntryMap(router);
3839
4507
  const locales = ctx.require(i18nPlugin).locales();
4508
+ const templatePath = ctx.config.template;
4509
+ const template = typeof templatePath === "string" && (0, node_fs.existsSync)(templatePath) ? await (0, node_fs_promises.readFile)(templatePath, "utf8") : null;
4510
+ const shell = {
4511
+ assets: buildAssetTags(ctx),
4512
+ template
4513
+ };
3840
4514
  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)));
4515
+ const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance, shell)));
4516
+ if (mode !== "ssg" && ctx.has("data")) {
4517
+ const entries = rendered.filter((page) => page.hasData).map((page) => ({
4518
+ path: page.url,
4519
+ data: page.data
4520
+ }));
4521
+ if (entries.length > 0) {
4522
+ const summary = await ctx.require(dataPlugin).write(entries, { outDir: ctx.config.outDir });
4523
+ ctx.log.debug("build:data", {
4524
+ files: summary.fileCount,
4525
+ bytes: summary.bytes
4526
+ });
4527
+ }
4528
+ }
3842
4529
  const root = rendered.find((page) => page.url === "/" || page.url === "");
3843
4530
  ctx.log.debug("build:pages", { count: rendered.length });
3844
4531
  return {
@@ -3846,6 +4533,37 @@ async function renderPages(ctx) {
3846
4533
  rootHtml: root?.html ?? null
3847
4534
  };
3848
4535
  }
4536
+ /**
4537
+ * Copies the configured `publicDir` (default `"public"`) verbatim into `outDir`,
4538
+ * preserving the nested directory structure. Skips silently (returns `null`) when
4539
+ * the source directory does not exist.
4540
+ *
4541
+ * @param ctx - Plugin context (provides `config`, `log`).
4542
+ * @returns The copy result, or `null` when the public directory is absent.
4543
+ * @example
4544
+ * ```ts
4545
+ * const result = await copyPublic(ctx);
4546
+ * ```
4547
+ */
4548
+ async function copyPublic(ctx) {
4549
+ const from = ctx.config.publicDir ?? "public";
4550
+ if (!(0, node_fs.existsSync)(from)) {
4551
+ ctx.log.debug("build:public", {
4552
+ skipped: true,
4553
+ from
4554
+ });
4555
+ return null;
4556
+ }
4557
+ await (0, node_fs_promises.cp)(from, ctx.config.outDir, { recursive: true });
4558
+ ctx.log.debug("build:public", {
4559
+ from,
4560
+ dest: ctx.config.outDir
4561
+ });
4562
+ return {
4563
+ from: node_path$1.default.normalize(from),
4564
+ copied: 1
4565
+ };
4566
+ }
3849
4567
  //#endregion
3850
4568
  //#region src/plugins/build/phases/sitemap.ts
3851
4569
  /**
@@ -3949,6 +4667,9 @@ const PHASE_ORDER = [
3949
4667
  "feeds",
3950
4668
  "sitemap",
3951
4669
  "og-images",
4670
+ "public",
4671
+ "not-found",
4672
+ "locale-redirects",
3952
4673
  "root-index"
3953
4674
  ];
3954
4675
  /**
@@ -3992,10 +4713,11 @@ function resetRun(ctx) {
3992
4713
  ctx.state.runId = `${Date.now()}-${(0, node_crypto.randomUUID)()}`;
3993
4714
  }
3994
4715
  /**
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).
4716
+ * Phase 4 — run feeds / sitemap / og-images / public / not-found / locale-redirects
4717
+ * concurrently, each gated by its config flag (or, for `public`, the presence of the
4718
+ * source dir), isolated with `Promise.allSettled` so one failure does not lose the
4719
+ * others. A disabled output is skipped entirely it emits NO `build:phase` boundary
4720
+ * (the `withPhase` wrapper is gated on the config flag, not just the phase body).
3999
4721
  *
4000
4722
  * @param ctx - The phase context.
4001
4723
  * @example
@@ -4008,6 +4730,9 @@ async function runOutputs(ctx) {
4008
4730
  if (ctx.config.feeds) tasks.push(withPhase(ctx, "feeds", () => generateFeeds(ctx)));
4009
4731
  if (ctx.config.sitemap) tasks.push(withPhase(ctx, "sitemap", () => generateSitemap(ctx)));
4010
4732
  if (ctx.config.ogImage) tasks.push(withPhase(ctx, "og-images", () => generateOgImages(ctx)));
4733
+ if ((0, node_fs.existsSync)(ctx.config.publicDir ?? "public")) tasks.push(withPhase(ctx, "public", () => copyPublic(ctx)));
4734
+ if (ctx.config.notFound) tasks.push(withPhase(ctx, "not-found", () => generateNotFound(ctx)));
4735
+ if (ctx.config.localeRedirects) tasks.push(withPhase(ctx, "locale-redirects", () => generateLocaleRedirects(ctx)));
4011
4736
  const settled = await Promise.allSettled(tasks);
4012
4737
  for (const outcome of settled) if (outcome.status === "rejected") ctx.log.error("build:outputs", { reason: String(outcome.reason) });
4013
4738
  }
@@ -4150,6 +4875,9 @@ function validateFonts(og) {
4150
4875
  */
4151
4876
  function validateConfig$1(config) {
4152
4877
  if (typeof config.outDir !== "string" || config.outDir.trim().length === 0) throw new Error(`${ERROR_PREFIX$5}.outDir: must be a non-empty string.`);
4878
+ if (config.publicDir !== void 0 && typeof config.publicDir !== "string") throw new Error(`${ERROR_PREFIX$5}.publicDir: must be a string when set.`);
4879
+ if (config.template !== void 0 && typeof config.template !== "string") throw new Error(`${ERROR_PREFIX$5}.template: must be a string path when set.`);
4880
+ if (config.clientEntry !== void 0 && typeof config.clientEntry !== "string") throw new Error(`${ERROR_PREFIX$5}.clientEntry: must be a string path when set.`);
4153
4881
  if (config.ogImage) validateFonts(config.ogImage);
4154
4882
  }
4155
4883
  //#endregion
@@ -5117,7 +5845,7 @@ function spaEvents(register) {
5117
5845
  }
5118
5846
  //#endregion
5119
5847
  //#region src/plugins/spa/types.ts
5120
- var types_exports$7 = /* @__PURE__ */ __exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
5848
+ var types_exports$8 = /* @__PURE__ */ require_convention.__exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
5121
5849
  /** Allowed hook names — single source of truth for fail-fast validation. */
5122
5850
  const COMPONENT_HOOK_NAMES = [
5123
5851
  "onCreate",
@@ -5587,11 +6315,12 @@ function resolveClickTarget(event) {
5587
6315
  * Navigation API is unavailable).
5588
6316
  *
5589
6317
  * @param handlers - The navigation lifecycle callbacks.
6318
+ * @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
5590
6319
  * @returns A teardown that removes the attached listeners.
5591
6320
  * @example
5592
6321
  * const dispose = attachHistoryFallback(handlers);
5593
6322
  */
5594
- function attachHistoryFallback(handlers) {
6323
+ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
5595
6324
  /**
5596
6325
  * Intercept an internal-link click and run a History-API navigation.
5597
6326
  *
@@ -5612,7 +6341,7 @@ function attachHistoryFallback(handlers) {
5612
6341
  }
5613
6342
  saveScrollPosition(location.pathname);
5614
6343
  history.pushState({ scrollY: 0 }, "", url.pathname);
5615
- performNavigation(url.pathname, handlers).then(() => window.scrollTo(0, 0)).catch(() => {});
6344
+ navigate(url.pathname).then(() => window.scrollTo(0, 0)).catch(() => {});
5616
6345
  };
5617
6346
  /**
5618
6347
  * Re-run navigation on back/forward, restoring the saved scroll position.
@@ -5621,7 +6350,7 @@ function attachHistoryFallback(handlers) {
5621
6350
  * globalThis.addEventListener("popstate", onPopState);
5622
6351
  */
5623
6352
  const onPopState = () => {
5624
- performNavigation(location.pathname, handlers).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
6353
+ navigate(location.pathname).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
5625
6354
  };
5626
6355
  document.addEventListener("click", onClick);
5627
6356
  globalThis.addEventListener("popstate", onPopState);
@@ -5635,11 +6364,12 @@ function attachHistoryFallback(handlers) {
5635
6364
  *
5636
6365
  * @param navigation - The Navigation API object to attach the listener to.
5637
6366
  * @param handlers - The navigation lifecycle callbacks.
6367
+ * @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
5638
6368
  * @returns A teardown that removes the `navigate` listener.
5639
6369
  * @example
5640
6370
  * const dispose = attachNavigationApi(navigation, handlers);
5641
6371
  */
5642
- function attachNavigationApi(navigation, handlers) {
6372
+ function attachNavigationApi(navigation, handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
5643
6373
  /**
5644
6374
  * Handle a `navigate` event: classify, then intercept with fetch-and-swap.
5645
6375
  *
@@ -5664,7 +6394,7 @@ function attachNavigationApi(navigation, handlers) {
5664
6394
  navEvent.intercept({
5665
6395
  scroll: "manual",
5666
6396
  handler: async () => {
5667
- await performNavigation(url.pathname, handlers);
6397
+ await navigate(url.pathname);
5668
6398
  if (navEvent.navigationType === "traverse") navEvent.scroll();
5669
6399
  else window.scrollTo(0, 0);
5670
6400
  }
@@ -5678,13 +6408,14 @@ function attachNavigationApi(navigation, handlers) {
5678
6408
  * fallback. Returns a teardown removing every listener it attached.
5679
6409
  *
5680
6410
  * @param handlers - The navigation lifecycle callbacks the kernel supplies.
6411
+ * @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
5681
6412
  * @returns A teardown removing all attached listeners.
5682
6413
  * @example
5683
- * const dispose = attachRouter(handlers);
6414
+ * const dispose = attachRouter(handlers, navigate);
5684
6415
  */
5685
- function attachRouter(handlers) {
6416
+ function attachRouter(handlers, navigate) {
5686
6417
  const navigation = getNavigation();
5687
- return navigation ? attachNavigationApi(navigation, handlers) : attachHistoryFallback(handlers);
6418
+ return navigation ? attachNavigationApi(navigation, handlers, navigate) : attachHistoryFallback(handlers, navigate);
5688
6419
  }
5689
6420
  //#endregion
5690
6421
  //#region src/plugins/spa/state.ts
@@ -5803,6 +6534,20 @@ function currentLocationUrl() {
5803
6534
  return location.pathname + location.search;
5804
6535
  }
5805
6536
  /**
6537
+ * Apply the matched route's `head` config to the live document (minimal client
6538
+ * head-sync for the DATA path: title only — the full meta sync runs on the
6539
+ * HTML-over-fetch path from the fetched `<head>`).
6540
+ *
6541
+ * @param route - The matched route definition.
6542
+ * @param routeContext - The render context (params/data/locale).
6543
+ * @example
6544
+ * syncDataHead(hit.route, { params, data, locale });
6545
+ */
6546
+ function syncDataHead(route, routeContext) {
6547
+ const title = route._handlers.head?.(routeContext)?.title;
6548
+ if (title !== void 0 && title !== "") document.title = title;
6549
+ }
6550
+ /**
5806
6551
  * Builds the single shared SPA kernel — a pure factory over state/config/emit.
5807
6552
  * Unit-testable with a mock state object and a spy emit; no Moku ctx involved.
5808
6553
  *
@@ -5866,6 +6611,71 @@ function createSpaKernel(state, config, emit, deps) {
5866
6611
  onEnd: handleEnd,
5867
6612
  onError: handleError
5868
6613
  };
6614
+ /**
6615
+ * The client DATA path: match `pathname`, fetch the page's PERSISTED data via the
6616
+ * `data` reader, VALIDATE it through the route's `parse` gate, then run the
6617
+ * route's OWN `render` (the same component the build used for SSG) and
6618
+ * Preact-render the VNode into the swap region. Returns `false` (touching nothing
6619
+ * the fallback cares about) on no-match / no-render / no-data / fetch-miss /
6620
+ * parse-throw, so the caller falls back to HTML-over-fetch. `route.load` does NOT
6621
+ * run on the client — the build already persisted its output.
6622
+ *
6623
+ * @param pathname - The destination pathname (search stripped for matching).
6624
+ * @returns `true` if the route was rendered from validated data, else `false`.
6625
+ * @example
6626
+ * if (await tryDataRender("/en/world/")) return;
6627
+ */
6628
+ const tryDataRender = async (pathname) => {
6629
+ if (!deps.dataAt) return false;
6630
+ const matchPath = pathname.split("?")[0] ?? pathname;
6631
+ const hit = deps.router.match(matchPath);
6632
+ if (!hit?.route._handlers.render) return false;
6633
+ try {
6634
+ const raw = await deps.dataAt(pathname);
6635
+ if (raw === null) return false;
6636
+ const data = hit.route._handlers.parse ? hit.route._handlers.parse(raw) : raw;
6637
+ const locale = hit.params.lang ?? document.documentElement.lang ?? "";
6638
+ const routeContext = {
6639
+ params: hit.params,
6640
+ data,
6641
+ locale
6642
+ };
6643
+ const vnode = hit.route._handlers.render(routeContext);
6644
+ const region = document.querySelector(resolved.swapSelector);
6645
+ if (!region) return false;
6646
+ handleStart(pathname);
6647
+ const { renderVNode } = await Promise.resolve().then(() => require("./render-BSTM0Akv.cjs"));
6648
+ syncDataHead(hit.route, routeContext);
6649
+ unmountPageSpecific(state, emit);
6650
+ runSwap(() => {
6651
+ region.replaceChildren();
6652
+ renderVNode(vnode, region);
6653
+ scanAndMount(state, emit, resolved.swapSelector);
6654
+ notifyNavEnd(state);
6655
+ }, resolved.viewTransitions);
6656
+ state.currentUrl = pathname;
6657
+ progress?.done();
6658
+ emit("spa:navigated", { url: pathname });
6659
+ return true;
6660
+ } catch {
6661
+ return false;
6662
+ }
6663
+ };
6664
+ /**
6665
+ * Unified navigation: try the client DATA path first (only when the `data`
6666
+ * plugin is composed), then fall back to HTML-over-fetch (which itself falls
6667
+ * back to a full `location.href` reload). Injected into the router so every
6668
+ * navigation entry point (Navigation API, History, programmatic) goes through it.
6669
+ *
6670
+ * @param pathname - The destination pathname.
6671
+ * @returns A promise resolving once the swap (or fallback) is dispatched.
6672
+ * @example
6673
+ * await navigate("/en/world/");
6674
+ */
6675
+ const navigate = async (pathname) => {
6676
+ if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
6677
+ await performNavigation(pathname, handlers);
6678
+ };
5869
6679
  return {
5870
6680
  /**
5871
6681
  * Register config components and seed currentUrl from the document.
@@ -5888,7 +6698,7 @@ function createSpaKernel(state, config, emit, deps) {
5888
6698
  if (state.started) throw new Error(`${ERROR_PREFIX} spa kernel already started.\n Call app.stop() before booting again (single boot per app).`);
5889
6699
  progress = createProgressBar(resolved.progressBar);
5890
6700
  state.currentUrl = currentLocationUrl();
5891
- state.destroyRouter = attachRouter(handlers);
6701
+ state.destroyRouter = attachRouter(handlers, navigate);
5892
6702
  scanAndMount(state, emit, resolved.swapSelector);
5893
6703
  state.started = true;
5894
6704
  },
@@ -5911,7 +6721,7 @@ function createSpaKernel(state, config, emit, deps) {
5911
6721
  */
5912
6722
  processNav(path) {
5913
6723
  if (typeof document === "undefined") return;
5914
- performNavigation(path, handlers).catch(() => {});
6724
+ navigate(path).catch(() => {});
5915
6725
  },
5916
6726
  /**
5917
6727
  * Scan the swap region and mount components for matching elements.
@@ -5938,20 +6748,41 @@ function createSpaKernel(state, config, emit, deps) {
5938
6748
  };
5939
6749
  }
5940
6750
  /**
6751
+ * Structural by-name handle for the OPTIONAL `data` plugin. `ctx.require` resolves
6752
+ * a plugin by its `name` at runtime, so this lets `spa` obtain the `data` reader
6753
+ * WITHOUT importing the `data` plugin or its types — keeping `spa` decoupled and
6754
+ * its `depends` at `[router, head]`. The phantom types only the `at` slice it uses.
6755
+ */
6756
+ const dataPluginHandle = {
6757
+ name: "data",
6758
+ spec: void 0,
6759
+ _phantom: {
6760
+ config: void 0,
6761
+ state: void 0,
6762
+ api: void 0,
6763
+ events: {}
6764
+ }
6765
+ };
6766
+ /**
5941
6767
  * Builds the shared kernel from the plugin context, stores it on `ctx.state`
5942
6768
  * 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.
6769
+ * config.components, seed currentUrl). Captures the OPTIONAL `data` reader when
6770
+ * the `data` plugin is composed (enabling client DATA navigation).
5945
6771
  *
5946
- * @param ctx - The plugin context (state/config/emit/require/log).
6772
+ * @param ctx - The plugin context (state/config/emit/require/has/log).
5947
6773
  * @example
5948
6774
  * initSpa(ctx);
5949
6775
  */
5950
6776
  function initSpa(ctx) {
5951
- const kernel = createSpaKernel(ctx.state, ctx.config, ctx.emit, {
6777
+ const deps = {
5952
6778
  router: ctx.require(routerPlugin),
5953
6779
  head: ctx.require(headPlugin)
5954
- });
6780
+ };
6781
+ if (ctx.has("data")) {
6782
+ const reader = ctx.require(dataPluginHandle);
6783
+ deps.dataAt = (path) => reader.at(path);
6784
+ }
6785
+ const kernel = createSpaKernel(ctx.state, ctx.config, ctx.emit, deps);
5955
6786
  ctx.state.kernel = kernel;
5956
6787
  kernelRef.current = kernel;
5957
6788
  kernel.init();
@@ -6048,29 +6879,182 @@ const spaPlugin = createPlugin$1("spa", {
6048
6879
  });
6049
6880
  //#endregion
6050
6881
  //#region src/plugins/build/types.ts
6051
- var types_exports = /* @__PURE__ */ __exportAll({});
6882
+ var types_exports = /* @__PURE__ */ require_convention.__exportAll({});
6052
6883
  //#endregion
6053
6884
  //#region src/plugins/content/types.ts
6054
- var types_exports$1 = /* @__PURE__ */ __exportAll({});
6885
+ var types_exports$1 = /* @__PURE__ */ require_convention.__exportAll({});
6886
+ //#endregion
6887
+ //#region src/plugins/data/types.ts
6888
+ var types_exports$2 = /* @__PURE__ */ require_convention.__exportAll({});
6055
6889
  //#endregion
6056
6890
  //#region src/plugins/deploy/types.ts
6057
- var types_exports$2 = /* @__PURE__ */ __exportAll({});
6891
+ var types_exports$3 = /* @__PURE__ */ require_convention.__exportAll({});
6058
6892
  //#endregion
6059
6893
  //#region src/plugins/env/types.ts
6060
- var types_exports$3 = /* @__PURE__ */ __exportAll({});
6894
+ var types_exports$4 = /* @__PURE__ */ require_convention.__exportAll({});
6061
6895
  //#endregion
6062
6896
  //#region src/plugins/head/types.ts
6063
- var types_exports$4 = /* @__PURE__ */ __exportAll({});
6897
+ var types_exports$5 = /* @__PURE__ */ require_convention.__exportAll({});
6064
6898
  //#endregion
6065
6899
  //#region src/plugins/log/types.ts
6066
- var types_exports$5 = /* @__PURE__ */ __exportAll({});
6900
+ var types_exports$6 = /* @__PURE__ */ require_convention.__exportAll({});
6067
6901
  //#endregion
6068
6902
  //#region src/plugins/router/types.ts
6069
- var types_exports$6 = /* @__PURE__ */ __exportAll({});
6903
+ var types_exports$7 = /* @__PURE__ */ require_convention.__exportAll({});
6904
+ //#endregion
6905
+ //#region src/plugins/env/providers.ts
6906
+ /**
6907
+ * @file env plugin — built-in providers: dotenv, processEnv, cloudflareBindings.
6908
+ */
6909
+ /** Default dotenv file path: optional local overrides. */
6910
+ const DEFAULT_DOTENV_PATH = ".env.local";
6911
+ /** Property on `globalThis` that the consumer sets per Cloudflare request. */
6912
+ const CLOUDFLARE_GLOBAL = "__CLOUDFLARE_ENV__";
6913
+ /**
6914
+ * Strips a single matching pair of surrounding double or single quotes from a
6915
+ * value. Leaves unquoted values (and trailing inline comments) untouched.
6916
+ *
6917
+ * @param value - The already-trimmed raw value.
6918
+ * @returns The value with one outer quote pair removed, if present.
6919
+ * @example
6920
+ * ```ts
6921
+ * stripQuotes('"a"'); // "a"
6922
+ * stripQuotes("plain # c"); // "plain # c"
6923
+ * ```
6924
+ */
6925
+ function stripQuotes(value) {
6926
+ if (value.length >= 2) {
6927
+ const first = value[0];
6928
+ const last = value.at(-1);
6929
+ if ((first === "\"" || first === "'") && first === last) return value.slice(1, -1);
6930
+ }
6931
+ return value;
6932
+ }
6933
+ /**
6934
+ * Parses `.env`-style text into a flat record. Handles CRLF/LF, blank lines,
6935
+ * full-line `#` comments, first-`=` splitting, key/value trimming, and a single
6936
+ * outer quote pair. Does not strip trailing inline comments on unquoted values.
6937
+ *
6938
+ * @param text - The raw file contents.
6939
+ * @returns A flat record of parsed key/value pairs.
6940
+ * @example
6941
+ * ```ts
6942
+ * parseDotenv('A=1\nB="two"'); // { A: "1", B: "two" }
6943
+ * ```
6944
+ */
6945
+ function parseDotenv(text) {
6946
+ const out = {};
6947
+ for (const line of text.split(/\r?\n/)) {
6948
+ const trimmed = line.trim();
6949
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
6950
+ const eq = trimmed.indexOf("=");
6951
+ if (eq === -1) continue;
6952
+ const key = trimmed.slice(0, eq).trim();
6953
+ out[key] = stripQuotes(trimmed.slice(eq + 1).trim());
6954
+ }
6955
+ return out;
6956
+ }
6957
+ /**
6958
+ * A zero-dependency `.env`-style provider that re-reads and re-parses the file
6959
+ * from disk on every `load()`. Missing file resolves to `{}` (optional
6960
+ * overrides). Strips a single outer quote pair; does not strip trailing inline
6961
+ * comments on unquoted values.
6962
+ *
6963
+ * @param path - Path to the dotenv file. Defaults to `.env.local`.
6964
+ * @returns An {@link EnvProvider} named `dotenv:<path>` that reads fresh per call.
6965
+ * @example
6966
+ * ```ts
6967
+ * const provider = dotenv(".env.local");
6968
+ * provider.load(); // { PUBLIC_API_URL: "/api", ... }
6969
+ * ```
6970
+ */
6971
+ function dotenv(path = DEFAULT_DOTENV_PATH) {
6972
+ return {
6973
+ name: `dotenv:${path}`,
6974
+ /**
6975
+ * Reads and parses the dotenv file fresh from disk; `{}` if it is missing.
6976
+ *
6977
+ * @returns The parsed environment record, or `{}` when the file is absent.
6978
+ * @example
6979
+ * ```ts
6980
+ * dotenv(".env.local").load();
6981
+ * ```
6982
+ */
6983
+ load() {
6984
+ if (!(0, node_fs.existsSync)(path)) return {};
6985
+ return parseDotenv((0, node_fs.readFileSync)(path, "utf8"));
6986
+ }
6987
+ };
6988
+ }
6989
+ /**
6990
+ * A provider that returns a shallow copy of `process.env` at `load()` time.
6991
+ *
6992
+ * @returns An {@link EnvProvider} named `process-env`.
6993
+ * @example
6994
+ * ```ts
6995
+ * const provider = processEnv();
6996
+ * provider.load().HOME; // current process value
6997
+ * ```
6998
+ */
6999
+ function processEnv() {
7000
+ return {
7001
+ name: "process-env",
7002
+ /**
7003
+ * Returns a shallow copy of `process.env` at call time.
7004
+ *
7005
+ * @returns A fresh shallow copy of `process.env`.
7006
+ * @example
7007
+ * ```ts
7008
+ * processEnv().load();
7009
+ * ```
7010
+ */
7011
+ load() {
7012
+ return { ...process.env };
7013
+ }
7014
+ };
7015
+ }
7016
+ /**
7017
+ * A provider that reads live, per-request Cloudflare bindings from
7018
+ * `globalThis.__CLOUDFLARE_ENV__` at `load()` time (`?? {}` when absent). Never
7019
+ * caches the binding object; the consumer owns the global's request lifecycle.
7020
+ *
7021
+ * @returns An {@link EnvProvider} named `cloudflare`.
7022
+ * @example
7023
+ * ```ts
7024
+ * globalThis.__CLOUDFLARE_ENV__ = env; // set by the request handler
7025
+ * const provider = cloudflareBindings();
7026
+ * provider.load(); // reads the current request's bindings
7027
+ * ```
7028
+ */
7029
+ function cloudflareBindings() {
7030
+ return {
7031
+ name: "cloudflare",
7032
+ /**
7033
+ * Reads `globalThis.__CLOUDFLARE_ENV__` fresh, never caching the bindings.
7034
+ *
7035
+ * @returns The current Cloudflare bindings, or `{}` when the global is unset.
7036
+ * @example
7037
+ * ```ts
7038
+ * cloudflareBindings().load();
7039
+ * ```
7040
+ */
7041
+ load() {
7042
+ return globalThis[CLOUDFLARE_GLOBAL] ?? {};
7043
+ }
7044
+ };
7045
+ }
6070
7046
  //#endregion
6071
7047
  //#region src/index.ts
6072
7048
  /**
6073
7049
  * @file `@moku-labs/web` — a Moku Layer-2 content static-site + SPA framework.
7050
+ *
7051
+ * `createApp`'s defaults are the **isomorphic** plugins that run unchanged on both
7052
+ * Node and the browser (`site`, `i18n`, `router`, `head`, `spa`, plus the
7053
+ * `log`/`env` core). The Node-only plugins (`content`, `build`, `deploy`,
7054
+ * `data`) are exported for Layer-3 composition: add them with
7055
+ * `createApp({ plugins: [...] })` in a Node build; omit them in a browser app.
7056
+ * The framework never hard-blocks either runtime — the consumer composes the
7057
+ * variant it needs and supplies the matching `env` provider.
6074
7058
  * @see README.md
6075
7059
  */
6076
7060
  const framework = createCore(coreConfig, {
@@ -6078,11 +7062,8 @@ const framework = createCore(coreConfig, {
6078
7062
  sitePlugin,
6079
7063
  i18nPlugin,
6080
7064
  routerPlugin,
6081
- contentPlugin,
6082
7065
  headPlugin,
6083
- buildPlugin,
6084
- spaPlugin,
6085
- deployPlugin
7066
+ spaPlugin
6086
7067
  ],
6087
7068
  pluginConfigs: {}
6088
7069
  });
@@ -6091,22 +7072,28 @@ const framework = createCore(coreConfig, {
6091
7072
  * Your overrides are merged over the framework defaults through the 4-level config
6092
7073
  * cascade, every plugin's lifecycle runs, and a fully-typed, frozen app is returned.
6093
7074
  *
7075
+ * The defaults are the isomorphic plugin set (`site`, `i18n`, `router`, `head`,
7076
+ * `spa` + `log`/`env` core). Add the Node-only plugins for an SSG build:
7077
+ * `createApp({ plugins: [contentPlugin, buildPlugin, deployPlugin] })`.
7078
+ *
6094
7079
  * @param options - Optional configuration:
6095
- * - `pluginConfigs` — per-plugin overrides, keyed by plugin name
6096
- * (`site`, `i18n`, `router`, `content`, `head`, `build`, `spa`, `deploy`, `env`).
7080
+ * - `pluginConfigs` — per-plugin overrides, keyed by plugin name.
6097
7081
  * - `config` — global framework config (e.g. `{ mode: "development" }`).
6098
- * - `plugins` — extra consumer plugins, merged into the app and its return type.
7082
+ * - `plugins` — extra plugins (Node-only built-ins or your own) merged into the app and its type.
6099
7083
  * - `onReady` / `onError` / `onStart` / `onStop` — lifecycle callbacks.
6100
7084
  * @returns The initialized app: `start()`, `stop()`, every plugin's API, and `log`.
6101
7085
  * @example
6102
7086
  * ```ts
7087
+ * // Node SSG build — add the node-only plugins:
6103
7088
  * const app = createApp({
7089
+ * plugins: [contentPlugin, buildPlugin, deployPlugin],
6104
7090
  * pluginConfigs: {
6105
7091
  * site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes" },
6106
7092
  * router: { routes: defineRoutes({ home: route("/"), post: route("/blog/{slug}/") }) }
6107
7093
  * }
6108
7094
  * });
6109
7095
  * await app.start();
7096
+ * await app.build.run();
6110
7097
  * ```
6111
7098
  */
6112
7099
  const createApp = framework.createApp;
@@ -6139,50 +7126,60 @@ Object.defineProperty(exports, "Content", {
6139
7126
  return types_exports$1;
6140
7127
  }
6141
7128
  });
6142
- Object.defineProperty(exports, "Deploy", {
7129
+ Object.defineProperty(exports, "Data", {
6143
7130
  enumerable: true,
6144
7131
  get: function() {
6145
7132
  return types_exports$2;
6146
7133
  }
6147
7134
  });
6148
- Object.defineProperty(exports, "Env", {
7135
+ Object.defineProperty(exports, "Deploy", {
6149
7136
  enumerable: true,
6150
7137
  get: function() {
6151
7138
  return types_exports$3;
6152
7139
  }
6153
7140
  });
6154
- Object.defineProperty(exports, "Head", {
7141
+ Object.defineProperty(exports, "Env", {
6155
7142
  enumerable: true,
6156
7143
  get: function() {
6157
7144
  return types_exports$4;
6158
7145
  }
6159
7146
  });
6160
- Object.defineProperty(exports, "Log", {
7147
+ Object.defineProperty(exports, "Head", {
6161
7148
  enumerable: true,
6162
7149
  get: function() {
6163
7150
  return types_exports$5;
6164
7151
  }
6165
7152
  });
6166
- Object.defineProperty(exports, "Router", {
7153
+ Object.defineProperty(exports, "Log", {
6167
7154
  enumerable: true,
6168
7155
  get: function() {
6169
7156
  return types_exports$6;
6170
7157
  }
6171
7158
  });
6172
- Object.defineProperty(exports, "Spa", {
7159
+ Object.defineProperty(exports, "Router", {
6173
7160
  enumerable: true,
6174
7161
  get: function() {
6175
7162
  return types_exports$7;
6176
7163
  }
6177
7164
  });
7165
+ Object.defineProperty(exports, "Spa", {
7166
+ enumerable: true,
7167
+ get: function() {
7168
+ return types_exports$8;
7169
+ }
7170
+ });
7171
+ exports.browserEnv = browserEnv;
6178
7172
  exports.buildArticleHead = buildArticleHead;
6179
7173
  exports.buildPlugin = buildPlugin;
6180
7174
  exports.canonical = canonical;
7175
+ exports.cloudflareBindings = cloudflareBindings;
6181
7176
  exports.contentPlugin = contentPlugin;
6182
7177
  exports.createApp = createApp;
6183
7178
  exports.createPlugin = createPlugin;
7179
+ exports.dataPlugin = dataPlugin;
6184
7180
  exports.defineRoutes = defineRoutes;
6185
7181
  exports.deployPlugin = deployPlugin;
7182
+ exports.dotenv = dotenv;
6186
7183
  exports.envPlugin = envPlugin;
6187
7184
  exports.feedLink = feedLink;
6188
7185
  exports.headPlugin = headPlugin;
@@ -6192,6 +7189,7 @@ exports.jsonLd = jsonLd;
6192
7189
  exports.logPlugin = logPlugin;
6193
7190
  exports.meta = meta;
6194
7191
  exports.og = og;
7192
+ exports.processEnv = processEnv;
6195
7193
  exports.route = route;
6196
7194
  exports.routerPlugin = routerPlugin;
6197
7195
  exports.sitePlugin = sitePlugin;