@moku-labs/web 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,72 +1,39 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- //#region \0rolldown/runtime.js
3
- var __create = Object.create;
4
- var __defProp = Object.defineProperty;
5
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
- var __getOwnPropNames = Object.getOwnPropertyNames;
7
- var __getProtoOf = Object.getPrototypeOf;
8
- var __hasOwnProp = Object.prototype.hasOwnProperty;
9
- var __exportAll = (all, no_symbols) => {
10
- let target = {};
11
- for (var name in all) __defProp(target, name, {
12
- get: all[name],
13
- enumerable: true
14
- });
15
- if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
16
- return target;
17
- };
18
- var __copyProps = (to, from, except, desc) => {
19
- if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
20
- key = keys[i];
21
- if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
22
- get: ((k) => from[k]).bind(null, key),
23
- enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
24
- });
25
- }
26
- return to;
27
- };
28
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
29
- value: mod,
30
- enumerable: true
31
- }) : target, mod));
32
- //#endregion
2
+ const require_convention = require("./convention-Dr8jxG70.cjs");
33
3
  let _moku_labs_core = require("@moku-labs/core");
34
- let node_fs = require("node:fs");
35
4
  let node_fs_promises = require("node:fs/promises");
36
5
  let node_path = require("node:path");
37
- let node_path$1 = __toESM(node_path, 1);
38
- node_path = __toESM(node_path);
6
+ let node_path$1 = require_convention.__toESM(node_path, 1);
7
+ node_path = require_convention.__toESM(node_path);
39
8
  let gray_matter = require("gray-matter");
40
- gray_matter = __toESM(gray_matter, 1);
9
+ gray_matter = require_convention.__toESM(gray_matter, 1);
41
10
  let _shikijs_rehype = require("@shikijs/rehype");
42
- _shikijs_rehype = __toESM(_shikijs_rehype, 1);
11
+ _shikijs_rehype = require_convention.__toESM(_shikijs_rehype, 1);
43
12
  let rehype_sanitize = require("rehype-sanitize");
44
- rehype_sanitize = __toESM(rehype_sanitize, 1);
13
+ rehype_sanitize = require_convention.__toESM(rehype_sanitize, 1);
45
14
  let rehype_stringify = require("rehype-stringify");
46
- rehype_stringify = __toESM(rehype_stringify, 1);
15
+ rehype_stringify = require_convention.__toESM(rehype_stringify, 1);
47
16
  let unified = require("unified");
48
17
  let rehype_raw = require("rehype-raw");
49
- rehype_raw = __toESM(rehype_raw, 1);
18
+ rehype_raw = require_convention.__toESM(rehype_raw, 1);
50
19
  let remark_directive = require("remark-directive");
51
- remark_directive = __toESM(remark_directive, 1);
20
+ remark_directive = require_convention.__toESM(remark_directive, 1);
52
21
  let remark_frontmatter = require("remark-frontmatter");
53
- remark_frontmatter = __toESM(remark_frontmatter, 1);
22
+ remark_frontmatter = require_convention.__toESM(remark_frontmatter, 1);
54
23
  let remark_gfm = require("remark-gfm");
55
- remark_gfm = __toESM(remark_gfm, 1);
24
+ remark_gfm = require_convention.__toESM(remark_gfm, 1);
56
25
  let remark_parse = require("remark-parse");
57
- remark_parse = __toESM(remark_parse, 1);
26
+ remark_parse = require_convention.__toESM(remark_parse, 1);
58
27
  let remark_rehype = require("remark-rehype");
59
- remark_rehype = __toESM(remark_rehype, 1);
28
+ remark_rehype = require_convention.__toESM(remark_rehype, 1);
60
29
  let unist_util_visit = require("unist-util-visit");
61
30
  let hast_util_sanitize = require("hast-util-sanitize");
62
31
  let reading_time = require("reading-time");
63
- reading_time = __toESM(reading_time, 1);
32
+ reading_time = require_convention.__toESM(reading_time, 1);
33
+ let node_fs = require("node:fs");
64
34
  let node_crypto = require("node:crypto");
65
35
  let feed = require("feed");
66
- let _resvg_resvg_js = require("@resvg/resvg-js");
67
- let satori = require("satori");
68
- satori = __toESM(satori);
69
- let preact_jsx_runtime = require("preact/jsx-runtime");
36
+ let preact = require("preact");
70
37
  let preact_render_to_string = require("preact-render-to-string");
71
38
  //#region src/plugins/env/api.ts
72
39
  /** Error prefix for all env API failures. */
@@ -295,112 +262,45 @@ function validateSchema(ctx) {
295
262
  freezeMap(state.publicMap);
296
263
  }
297
264
  //#endregion
298
- //#region src/plugins/env/providers.ts
299
- /**
300
- * @file env plugin — built-in providers: dotenv, processEnv, cloudflareBindings.
301
- */
302
- /** Default dotenv file path: optional local overrides. */
303
- const DEFAULT_DOTENV_PATH = ".env.local";
304
- /**
305
- * Strips a single matching pair of surrounding double or single quotes from a
306
- * value. Leaves unquoted values (and trailing inline comments) untouched.
307
- *
308
- * @param value - The already-trimmed raw value.
309
- * @returns The value with one outer quote pair removed, if present.
310
- * @example
311
- * ```ts
312
- * stripQuotes('"a"'); // "a"
313
- * stripQuotes("plain # c"); // "plain # c"
314
- * ```
315
- */
316
- function stripQuotes(value) {
317
- if (value.length >= 2) {
318
- const first = value[0];
319
- const last = value.at(-1);
320
- if ((first === "\"" || first === "'") && first === last) return value.slice(1, -1);
321
- }
322
- return value;
323
- }
324
- /**
325
- * Parses `.env`-style text into a flat record. Handles CRLF/LF, blank lines,
326
- * full-line `#` comments, first-`=` splitting, key/value trimming, and a single
327
- * outer quote pair. Does not strip trailing inline comments on unquoted values.
328
- *
329
- * @param text - The raw file contents.
330
- * @returns A flat record of parsed key/value pairs.
331
- * @example
332
- * ```ts
333
- * parseDotenv('A=1\nB="two"'); // { A: "1", B: "two" }
334
- * ```
335
- */
336
- function parseDotenv(text) {
337
- const out = {};
338
- for (const line of text.split(/\r?\n/)) {
339
- const trimmed = line.trim();
340
- if (trimmed === "" || trimmed.startsWith("#")) continue;
341
- const eq = trimmed.indexOf("=");
342
- if (eq === -1) continue;
343
- const key = trimmed.slice(0, eq).trim();
344
- out[key] = stripQuotes(trimmed.slice(eq + 1).trim());
345
- }
346
- return out;
347
- }
265
+ //#region src/plugins/env/providers.browser.ts
266
+ /** Default `globalThis` property holding a runtime-injected public-env snapshot. */
267
+ const DEFAULT_GLOBAL_KEY = "__ENV__";
348
268
  /**
349
- * A zero-dependency `.env`-style provider that re-reads and re-parses the file
350
- * from disk on every `load()`. Missing file resolves to `{}` (optional
351
- * overrides). Strips a single outer quote pair; does not strip trailing inline
352
- * comments on unquoted values.
269
+ * A browser-safe {@link EnvProvider} that reads `import.meta.env` and an optional
270
+ * `globalThis[globalKey]` snapshot, merging them with the runtime global winning.
271
+ * Contains zero `node:*` imports, so it is safe to include in the client bundle.
272
+ * Never throws on missing sources — each absent source resolves to `{}`.
353
273
  *
354
- * @param path - Path to the dotenv file. Defaults to `.env.local`.
355
- * @returns An {@link EnvProvider} named `dotenv:<path>` that reads fresh per call.
274
+ * @param options - Optional settings.
275
+ * @param options.globalKey - `globalThis` key to read a public-env snapshot from. Defaults to `"__ENV__"`.
276
+ * @returns An {@link EnvProvider} named `browser-env`.
356
277
  * @example
357
278
  * ```ts
358
- * const provider = dotenv(".env.local");
279
+ * const provider = browserEnv();
359
280
  * provider.load(); // { PUBLIC_API_URL: "/api", ... }
360
281
  * ```
361
282
  */
362
- function dotenv(path = DEFAULT_DOTENV_PATH) {
363
- return {
364
- name: `dotenv:${path}`,
365
- /**
366
- * Reads and parses the dotenv file fresh from disk; `{}` if it is missing.
367
- *
368
- * @returns The parsed environment record, or `{}` when the file is absent.
369
- * @example
370
- * ```ts
371
- * dotenv(".env.local").load();
372
- * ```
373
- */
374
- load() {
375
- if (!(0, node_fs.existsSync)(path)) return {};
376
- return parseDotenv((0, node_fs.readFileSync)(path, "utf8"));
377
- }
378
- };
379
- }
380
- /**
381
- * A provider that returns a shallow copy of `process.env` at `load()` time.
382
- *
383
- * @returns An {@link EnvProvider} named `process-env`.
384
- * @example
385
- * ```ts
386
- * const provider = processEnv();
387
- * provider.load().HOME; // current process value
388
- * ```
389
- */
390
- function processEnv() {
283
+ function browserEnv(options) {
284
+ const globalKey = options?.globalKey ?? DEFAULT_GLOBAL_KEY;
391
285
  return {
392
- name: "process-env",
286
+ name: "browser-env",
393
287
  /**
394
- * Returns a shallow copy of `process.env` at call time.
288
+ * Merges `import.meta.env` with `globalThis[globalKey]`, the runtime global
289
+ * winning. Each absent source resolves to `{}`; never throws.
395
290
  *
396
- * @returns A fresh shallow copy of `process.env`.
291
+ * @returns The merged environment record.
397
292
  * @example
398
293
  * ```ts
399
- * processEnv().load();
294
+ * browserEnv().load();
400
295
  * ```
401
296
  */
402
297
  load() {
403
- return { ...process.env };
298
+ const importEnv = {}.env ?? {};
299
+ const globalObject = globalThis[globalKey] ?? {};
300
+ return {
301
+ ...importEnv,
302
+ ...globalObject
303
+ };
404
304
  }
405
305
  };
406
306
  }
@@ -846,12 +746,29 @@ 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
- const { createPlugin: createPlugin$1, createCore } = coreConfig;
751
+ /**
752
+ * Create a custom plugin bound to this framework's `Config`/`Events` and the core
753
+ * plugin APIs (`log`, `env`). Plugin types are fully inferred from the spec
754
+ * object — never write them explicitly. This is the binding every built-in
755
+ * plugin is wired with, and the one consumer plugins should use too.
756
+ *
757
+ * @example
758
+ * ```ts
759
+ * const analytics = createPlugin("analytics", {
760
+ * config: { writeKey: "" },
761
+ * api: (ctx) => ({ track: (event: string) => ctx.log.info("analytics:track", { event }) })
762
+ * });
763
+ * ```
764
+ */
765
+ const createPlugin$1 = coreConfig.createPlugin;
766
+ /**
767
+ * Step 2 of the factory chain — captures the framework's default plugin set and
768
+ * returns the consumer entry points ({@link createApp} + a re-exported
769
+ * `createPlugin`). Wired once in `src/index.ts`; consumers don't call it directly.
770
+ */
771
+ const createCore = coreConfig.createCore;
855
772
  //#endregion
856
773
  //#region src/plugins/i18n/api.ts
857
774
  /** Error prefix for all i18n lifecycle failures. */
@@ -979,6 +896,25 @@ function createI18nApi(ctx) {
979
896
  }
980
897
  };
981
898
  }
899
+ /**
900
+ * Internationalization plugin — locale registry plus a flat translation helper
901
+ * with default-locale fallback. Pure config-as-data (no state or events);
902
+ * consumed read-only by content, router, head, and build.
903
+ *
904
+ * @example Register locales and translations
905
+ * ```ts
906
+ * const app = createApp({
907
+ * pluginConfigs: {
908
+ * i18n: {
909
+ * locales: ["en", "uk"],
910
+ * defaultLocale: "en",
911
+ * localeNames: { en: "English", uk: "Українська" },
912
+ * translations: { uk: { "nav.home": "Головна" } }
913
+ * }
914
+ * }
915
+ * });
916
+ * ```
917
+ */
982
918
  const i18nPlugin = createPlugin$1("i18n", {
983
919
  config: {
984
920
  locales: ["en"],
@@ -1379,6 +1315,23 @@ function articleToUrl(locale, slug) {
1379
1315
  return `/${locale}/${slug}/`;
1380
1316
  }
1381
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
+ /**
1382
1335
  * Plugin `api` factory: assembles the kernel-free {@link ContentApiContext} from
1383
1336
  * the plugin context (resolving i18n via `ctx.require`) and delegates to
1384
1337
  * {@link createContentApi}. Referenced directly as the plugin's `api` so
@@ -1612,11 +1565,16 @@ function createContentApi(ctx) {
1612
1565
  /**
1613
1566
  * Resolve and render a single article for one locale with locale fallback.
1614
1567
  * Throws a `[web] content` error when neither the requested nor the
1615
- * 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.
1616
1572
  *
1617
1573
  * @param slug - Article directory name.
1618
1574
  * @param locale - Requested locale code.
1619
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"`.
1620
1578
  * @example
1621
1579
  * ```ts
1622
1580
  * const article = await api.load("intro", "uk");
@@ -1624,7 +1582,8 @@ function createContentApi(ctx) {
1624
1582
  */
1625
1583
  async load(slug, locale) {
1626
1584
  const article = await resolveArticle(ctx, slug, locale);
1627
- 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);
1628
1587
  const cache = ctx.state.articles.get(locale) ?? /* @__PURE__ */ new Map();
1629
1588
  cache.set(slug, article);
1630
1589
  ctx.state.articles.set(locale, cache);
@@ -1774,6 +1733,26 @@ function validateContentConfig(config) {
1774
1733
  * and `content:invalidated`.
1775
1734
  * @see README.md
1776
1735
  */
1736
+ /**
1737
+ * Content plugin — Markdown pipeline: discovers files, parses frontmatter, renders
1738
+ * to sanitized HTML (rehype-sanitize unless `trustedContent`), and exposes a
1739
+ * locale-keyed Article model. Depends on i18n; emits `content:ready` and
1740
+ * `content:invalidated`.
1741
+ *
1742
+ * @example Point at a content directory and pick a Shiki theme
1743
+ * ```ts
1744
+ * const app = createApp({
1745
+ * pluginConfigs: {
1746
+ * content: {
1747
+ * contentDir: "./content",
1748
+ * shikiTheme: "github-dark",
1749
+ * defaultAuthor: "Ada Lovelace"
1750
+ * // trustedContent: true // ONLY for fully author-controlled Markdown — disables sanitize
1751
+ * }
1752
+ * }
1753
+ * });
1754
+ * ```
1755
+ */
1777
1756
  const contentPlugin = createPlugin$1("content", {
1778
1757
  depends: [i18nPlugin],
1779
1758
  events: contentEvents,
@@ -1937,6 +1916,25 @@ function createSiteApi(ctx) {
1937
1916
  }
1938
1917
  };
1939
1918
  }
1919
+ /**
1920
+ * Site plugin — holds global, frozen site metadata (name, url, author,
1921
+ * description) and builds canonical URLs. Consumed by router, head, and build.
1922
+ * `name` and `url` must be non-empty (validated at `onInit`).
1923
+ *
1924
+ * @example Set your site identity
1925
+ * ```ts
1926
+ * const app = createApp({
1927
+ * pluginConfigs: {
1928
+ * site: {
1929
+ * name: "My Blog",
1930
+ * url: "https://blog.dev",
1931
+ * author: "Ada Lovelace",
1932
+ * description: "Notes on computing"
1933
+ * }
1934
+ * }
1935
+ * });
1936
+ * ```
1937
+ */
1940
1938
  const sitePlugin = createPlugin$1("site", {
1941
1939
  config: {
1942
1940
  name: "",
@@ -2063,6 +2061,24 @@ function toTypedRoute(entry) {
2063
2061
  };
2064
2062
  }
2065
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
+ /**
2066
2082
  * Creates the router plugin API surface. Every closure reads the compiled table
2067
2083
  * from `ctx.state` and returns values/fresh copies — never the raw state arrays.
2068
2084
  *
@@ -2132,11 +2148,113 @@ function createApi$4(ctx) {
2132
2148
  */
2133
2149
  manifest() {
2134
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;
2135
2176
  }
2136
2177
  };
2137
2178
  }
2138
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
2139
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
+ */
2140
2258
  /** Shared `[web]` error prefix for router validation failures. */
2141
2259
  const ERROR_PREFIX$8 = "[web] router";
2142
2260
  /**
@@ -2251,27 +2369,6 @@ function buildFilePath(pattern, params) {
2251
2369
  return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
2252
2370
  }
2253
2371
  /**
2254
- * Count dynamic segments in a pattern (lower = more specific). The optional
2255
- * `{lang:?}` segment is excluded so locale-prefixing does not affect priority.
2256
- *
2257
- * @param pattern - The route pattern.
2258
- * @returns The number of dynamic (non-lang) segments.
2259
- * @example
2260
- * ```ts
2261
- * countDynamicSegments("/{lang:?}/{slug}/"); // 1
2262
- * ```
2263
- */
2264
- function countDynamicSegments(pattern) {
2265
- let count = 0;
2266
- for (const segment of pattern.split("/")) {
2267
- const placeholder = parsePlaceholder(segment);
2268
- const isBraceDynamic = placeholder && !(placeholder.name === "lang" && placeholder.optional);
2269
- const isColonDynamic = !placeholder && segment.startsWith(":");
2270
- if (isBraceDynamic || isColonDynamic) count += 1;
2271
- }
2272
- return count;
2273
- }
2274
- /**
2275
2372
  * Compile a single route definition into its `CompiledRoute` entry.
2276
2373
  *
2277
2374
  * @param name - The route name key.
@@ -2293,7 +2390,7 @@ function compileRoute(name, definition, input) {
2293
2390
  return {
2294
2391
  name,
2295
2392
  pattern,
2296
- dynamicSegmentCount: countDynamicSegments(pattern),
2393
+ dynamicSegmentCount: dynamicSegmentCount(pattern),
2297
2394
  matchers,
2298
2395
  matchFn: createMatchFunction(matchers, input.defaultLocale),
2299
2396
  /**
@@ -2350,10 +2447,7 @@ function compileRoutes(input) {
2350
2447
  byName.set(name, entry);
2351
2448
  }
2352
2449
  return {
2353
- compiled: declarationOrder.map((entry, index) => ({
2354
- entry,
2355
- index
2356
- })).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),
2357
2451
  byName
2358
2452
  };
2359
2453
  }
@@ -2466,6 +2560,21 @@ function route(pattern) {
2466
2560
  return set("render", handler);
2467
2561
  },
2468
2562
  /**
2563
+ * Attach the client-side validation gate (raw `unknown` → this route's data
2564
+ * type). Runs at the trust boundary before `render` on the client; throw to
2565
+ * reject malformed data (spa falls back to HTML-over-fetch).
2566
+ *
2567
+ * @param handler - The validator/parser.
2568
+ * @returns The same builder for chaining.
2569
+ * @example
2570
+ * ```ts
2571
+ * route("/shop/{id}/").parse(raw => ProductSchema.parse(raw));
2572
+ * ```
2573
+ */
2574
+ parse(handler) {
2575
+ return set("parse", handler);
2576
+ },
2577
+ /**
2469
2578
  * Attach the head/SEO handler.
2470
2579
  *
2471
2580
  * @param handler - The head handler.
@@ -2492,9 +2601,11 @@ function route(pattern) {
2492
2601
  return set("generate", handler);
2493
2602
  },
2494
2603
  /**
2495
- * Merge an arbitrary metadata bag into the route's `_meta`.
2604
+ * Merge an arbitrary metadata bag into the route's `_meta`. The bag MUST be
2605
+ * JSON-serializable — it is projected verbatim into `clientManifest()` and
2606
+ * shipped to the browser, so functions/symbols/class instances are unsupported.
2496
2607
  *
2497
- * @param meta - Metadata to merge.
2608
+ * @param meta - JSON-serializable metadata to merge.
2498
2609
  * @returns The same builder for chaining.
2499
2610
  * @example
2500
2611
  * ```ts
@@ -2564,8 +2675,31 @@ function defineRoutes(routes) {
2564
2675
  * ```
2565
2676
  */
2566
2677
  function createState$4(_ctx) {
2567
- return { table: null };
2678
+ return {
2679
+ table: null,
2680
+ mode: _ctx.config.mode ?? "hybrid"
2681
+ };
2568
2682
  }
2683
+ /**
2684
+ * Router plugin — typed, named route definitions with locale-aware URL generation
2685
+ * and matching. Author routes with {@link route} + {@link defineRoutes}. Depends
2686
+ * on site (base URL) and i18n (locales).
2687
+ *
2688
+ * @example Define routes and choose a render mode
2689
+ * ```ts
2690
+ * const app = createApp({
2691
+ * pluginConfigs: {
2692
+ * router: {
2693
+ * routes: defineRoutes({
2694
+ * home: route("/"),
2695
+ * article: route("/blog/{slug}/")
2696
+ * }),
2697
+ * mode: "hybrid" // "ssg" | "spa" | "hybrid" (default)
2698
+ * }
2699
+ * }
2700
+ * });
2701
+ * ```
2702
+ */
2569
2703
  const routerPlugin = createPlugin$1("router", {
2570
2704
  depends: [sitePlugin, i18nPlugin],
2571
2705
  helpers: {
@@ -3079,6 +3213,25 @@ function createState$3(_ctx) {
3079
3213
  * @file head — Standard Plugin wiring harness (logic in primitives/compose/api/config).
3080
3214
  * @see README.md
3081
3215
  */
3216
+ /**
3217
+ * Head plugin — composes per-route `<head>` metadata (title template, Open Graph,
3218
+ * Twitter cards, canonical, hreflang). Use the re-exported SEO primitives
3219
+ * ({@link meta}, {@link og}, {@link twitter}, …) inside a route's `.head()`.
3220
+ * Depends on site, i18n, and router.
3221
+ *
3222
+ * @example Set global head defaults
3223
+ * ```ts
3224
+ * const app = createApp({
3225
+ * pluginConfigs: {
3226
+ * head: {
3227
+ * titleTemplate: "%s — My Blog",
3228
+ * twitterCard: "summary_large_image",
3229
+ * twitterHandle: "@moku_labs"
3230
+ * }
3231
+ * }
3232
+ * });
3233
+ * ```
3234
+ */
3082
3235
  const headPlugin = createPlugin$1("head", {
3083
3236
  depends: [
3084
3237
  sitePlugin,
@@ -3143,6 +3296,29 @@ function resolveEntrypoints(candidates) {
3143
3296
  return [];
3144
3297
  }
3145
3298
  /**
3299
+ * Resolve the authoritative JS client entrypoint (#8): when `config.clientEntry` is
3300
+ * set, use it directly (the authoritative override); otherwise fall back to the
3301
+ * conventional candidate scan. When neither yields an entry, `ctx.log.warn` (no
3302
+ * client bundle is produced) and an empty list is returned.
3303
+ *
3304
+ * @param ctx - Plugin context (provides `config`, `log`).
3305
+ * @returns The resolved JS entrypoint list (possibly empty).
3306
+ * @example
3307
+ * ```ts
3308
+ * resolveJsEntrypoints(ctx);
3309
+ * ```
3310
+ */
3311
+ function resolveJsEntrypoints(ctx) {
3312
+ const { clientEntry } = ctx.config;
3313
+ if (typeof clientEntry === "string" && clientEntry.length > 0) return [clientEntry];
3314
+ const scanned = resolveEntrypoints(JS_ENTRY_CANDIDATES);
3315
+ if (scanned.length === 0) ctx.log.warn("build:bundle", {
3316
+ clientEntry: "none",
3317
+ scanned: JS_ENTRY_CANDIDATES
3318
+ });
3319
+ return scanned;
3320
+ }
3321
+ /**
3146
3322
  * Run one bundler pass for a single asset kind and record the hashed output
3147
3323
  * paths under `state.buildCache` keyed by the original entry basename.
3148
3324
  *
@@ -3190,7 +3366,7 @@ async function bundle(ctx, options = {}) {
3190
3366
  const runner = options.runner ?? defaultRunner;
3191
3367
  const { minify, outDir } = ctx.config;
3192
3368
  const cssEntrypoints = options.cssEntrypoints ?? resolveEntrypoints(CSS_ENTRY_CANDIDATES);
3193
- const jsEntrypoints = options.jsEntrypoints ?? resolveEntrypoints(JS_ENTRY_CANDIDATES);
3369
+ const jsEntrypoints = options.jsEntrypoints ?? resolveJsEntrypoints(ctx);
3194
3370
  await runOne(ctx, runner, "css", cssEntrypoints, node_path$1.default.join(outDir, "assets"), minify);
3195
3371
  await runOne(ctx, runner, "js", jsEntrypoints, node_path$1.default.join(outDir, "assets"), minify);
3196
3372
  }
@@ -3357,10 +3533,160 @@ async function processImages(ctx, options = {}) {
3357
3533
  return copied;
3358
3534
  }
3359
3535
  //#endregion
3360
- //#region src/plugins/build/phases/og-images.tsx
3536
+ //#region src/plugins/build/phases/locale-redirects.ts
3361
3537
  /**
3362
- * @file build phase 4 og-images. Renders one OG image per published article via
3363
- * Satori SVG resvg PNG, bounded by `p-limit(4)`, with a persisted
3538
+ * @file build phase — locale-redirects. For each non-prefixed route path, emits a
3539
+ * redirect HTML page (`<meta http-equiv="refresh">` + canonical `<link>`) at the
3540
+ * bare path that points at the default-locale-prefixed URL. Deliberately does NOT
3541
+ * emit a Cloudflare `_redirects` catch-all (an SSG infinite-loop trap). Gated by
3542
+ * `config.localeRedirects` (false/unset disables).
3543
+ */
3544
+ /**
3545
+ * Render a redirect HTML page: a `0;url` refresh meta + a canonical link to `target`.
3546
+ *
3547
+ * @param target - The default-locale-prefixed URL to redirect to.
3548
+ * @returns The complete redirect HTML document string.
3549
+ * @example
3550
+ * ```ts
3551
+ * redirectHtml("/en/about/");
3552
+ * ```
3553
+ */
3554
+ function redirectHtml(target) {
3555
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${target}"><link rel="canonical" href="${target}"></head><body><a href="${target}">Redirecting…</a></body></html>`;
3556
+ }
3557
+ /**
3558
+ * Correlate manifest definitions to compiled `TypedRoute` entries by pattern (the
3559
+ * shared stable key); routes without a compiled entry are skipped.
3560
+ *
3561
+ * @param router - The router API exposing `manifest` + `entries`.
3562
+ * @returns Pairs of `[definition, entry]` for every correlated route.
3563
+ * @example
3564
+ * ```ts
3565
+ * pairRoutes(router);
3566
+ * ```
3567
+ */
3568
+ function pairRoutes(router) {
3569
+ const byPattern = /* @__PURE__ */ new Map();
3570
+ for (const entry of router.entries()) byPattern.set(entry.pattern, entry);
3571
+ const pairs = [];
3572
+ for (const definition of router.manifest()) {
3573
+ const entry = byPattern.get(definition.pattern);
3574
+ if (entry) pairs.push([definition, entry]);
3575
+ }
3576
+ return pairs;
3577
+ }
3578
+ /**
3579
+ * Expand one route into bare→default redirect jobs for the default locale. Uses
3580
+ * `generate?.(defaultLocale)` (or a single empty-params instance) and emits a job
3581
+ * only when the bare file path differs from the default-locale URL (i.e. the route
3582
+ * is locale-prefixed) — otherwise no redirect is needed.
3583
+ *
3584
+ * @param definition - The route definition (carries `generate`).
3585
+ * @param entry - The compiled `TypedRoute` (owns `toFile`/`toUrl`).
3586
+ * @param defaultLocale - The default locale to redirect bare paths to.
3587
+ * @returns Redirect jobs of `{ file, target }` for this route.
3588
+ * @example
3589
+ * ```ts
3590
+ * await expandRedirects(def, entry, "en");
3591
+ * ```
3592
+ */
3593
+ async function expandRedirects(definition, entry, defaultLocale) {
3594
+ const parameterSets = definition._handlers.generate ? await definition._handlers.generate(defaultLocale) : [{}];
3595
+ const jobs = [];
3596
+ for (const raw of parameterSets) {
3597
+ const params = raw ?? {};
3598
+ const file = entry.toFile(params);
3599
+ const target = entry.toUrl({
3600
+ ...params,
3601
+ lang: defaultLocale
3602
+ });
3603
+ if (target !== entry.toUrl(params)) jobs.push({
3604
+ file,
3605
+ target
3606
+ });
3607
+ }
3608
+ return jobs;
3609
+ }
3610
+ /**
3611
+ * Emits one bare-path redirect HTML page per locale-prefixed route path, each a
3612
+ * `0;url` refresh + canonical link to the default-locale URL. Never writes a
3613
+ * Cloudflare `_redirects` file. No-op (returns `null`) when `localeRedirects` is
3614
+ * false/unset.
3615
+ *
3616
+ * @param ctx - Plugin context (provides `require`, `config`, `log`).
3617
+ * @returns The count of redirect pages written, or `null` when disabled.
3618
+ * @example
3619
+ * ```ts
3620
+ * const result = await generateLocaleRedirects(ctx);
3621
+ * ```
3622
+ */
3623
+ async function generateLocaleRedirects(ctx) {
3624
+ if (!ctx.config.localeRedirects) {
3625
+ ctx.log.debug("build:locale-redirects", { skipped: true });
3626
+ return null;
3627
+ }
3628
+ const router = ctx.require(routerPlugin);
3629
+ const defaultLocale = ctx.require(i18nPlugin).defaultLocale();
3630
+ const jobs = (await Promise.all(pairRoutes(router).map(([definition, entry]) => expandRedirects(definition, entry, defaultLocale)))).flat();
3631
+ await Promise.all(jobs.map(async ({ file, target }) => {
3632
+ const filePath = node_path$1.default.join(ctx.config.outDir, file);
3633
+ await (0, node_fs_promises.mkdir)(node_path$1.default.dirname(filePath), { recursive: true });
3634
+ await (0, node_fs_promises.writeFile)(filePath, redirectHtml(target), "utf8");
3635
+ }));
3636
+ ctx.log.debug("build:locale-redirects", { written: jobs.length });
3637
+ return { written: jobs.length };
3638
+ }
3639
+ //#endregion
3640
+ //#region src/plugins/build/phases/not-found.ts
3641
+ /**
3642
+ * @file build phase — not-found. Emits `outDir/404.html` from configured route
3643
+ * content or a built-in default. Gated by `config.notFound` (false/unset disables).
3644
+ */
3645
+ /** The built-in default 404 page body when no custom route content is supplied. */
3646
+ const DEFAULT_BODY = "<h1>404</h1><p>The page you requested could not be found.</p>";
3647
+ /**
3648
+ * Wrap a body fragment in a minimal HTML document for the 404 page.
3649
+ *
3650
+ * @param body - The inner body HTML (default or configured).
3651
+ * @returns The complete HTML document string.
3652
+ * @example
3653
+ * ```ts
3654
+ * wrap("<h1>404</h1>");
3655
+ * ```
3656
+ */
3657
+ function wrap(body) {
3658
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>404 — Not Found</title></head><body>${body}</body></html>`;
3659
+ }
3660
+ /**
3661
+ * Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
3662
+ * default page; when it is `{ route }`, writes the supplied route content verbatim
3663
+ * inside the document shell. No-op (returns `null`) when `notFound` is false/unset.
3664
+ *
3665
+ * @param ctx - Plugin context (provides `config`, `log`).
3666
+ * @returns The written file path, or `null` when disabled.
3667
+ * @example
3668
+ * ```ts
3669
+ * const result = await generateNotFound(ctx);
3670
+ * ```
3671
+ */
3672
+ async function generateNotFound(ctx) {
3673
+ const { notFound, outDir } = ctx.config;
3674
+ if (!notFound) {
3675
+ ctx.log.debug("build:not-found", { skipped: true });
3676
+ return null;
3677
+ }
3678
+ const body = typeof notFound === "object" && notFound.route ? notFound.route : DEFAULT_BODY;
3679
+ await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
3680
+ const file = node_path$1.default.join(outDir, "404.html");
3681
+ await (0, node_fs_promises.writeFile)(file, wrap(body), "utf8");
3682
+ ctx.log.debug("build:not-found", { path: file });
3683
+ return { path: file };
3684
+ }
3685
+ //#endregion
3686
+ //#region src/plugins/build/phases/og-images.tsx
3687
+ /**
3688
+ * @file build phase 4 — og-images. Renders one OG image per published article via
3689
+ * Satori → SVG → resvg → PNG, bounded by `p-limit(4)`, with a persisted
3364
3690
  * content-hash cache (`<outDir>/.cache/og-images.json`) skipping unchanged articles.
3365
3691
  * Gated by config.ogImage (object enables; false disables).
3366
3692
  */
@@ -3376,76 +3702,131 @@ const FONT_EXTENSIONS$1 = [
3376
3702
  ".woff"
3377
3703
  ];
3378
3704
  /**
3379
- * Compute the content-hash cache key for an article: `sha256(title+template+size)`.
3705
+ * Compute a stable cache key for the `fonts` configuration so a font change
3706
+ * invalidates cached PNGs. Hashes the name/path/weight/style of each entry (order
3707
+ * preserved); an empty/omitted list yields a fixed sentinel.
3380
3708
  *
3381
- * @param title - The article title.
3382
- * @param template - The resolved OG template identifier.
3383
- * @param size - The output dimensions.
3384
- * @returns The hex-encoded SHA-256 digest.
3709
+ * @param fonts - The configured OG fonts (optional).
3710
+ * @returns A short stable key derived from the fonts list.
3385
3711
  * @example
3386
3712
  * ```ts
3387
- * ogHash("Hello", "default", { width: 1200, height: 630 });
3713
+ * fontsKey([{ name: "Inter", path: "./Inter.ttf" }]);
3388
3714
  * ```
3389
3715
  */
3390
- function ogHash(title, template, size) {
3391
- return (0, node_crypto.createHash)("sha256").update(`${title}|${template}|${size.width}x${size.height}`).digest("hex");
3716
+ function fontsKey(fonts) {
3717
+ if (!fonts || fonts.length === 0) return "default-font";
3718
+ const parts = fonts.map((font) => `${font.name}:${font.path}:${font.weight ?? 400}:${font.style ?? "normal"}`);
3719
+ return (0, node_crypto.createHash)("sha256").update(parts.join("|")).digest("hex").slice(0, 16);
3392
3720
  }
3393
3721
  /**
3394
- * Resolve the first font file in `fontDir` and read its bytes for Satori.
3722
+ * Compute the content-hash cache key for an article OG image. Covers the FULL
3723
+ * {@link RichOgInput} (title/description/date/tags/author/locale/siteName/size),
3724
+ * the resolved `template`, and a {@link fontsKey} of the fonts list — so changing
3725
+ * any input field OR the fonts invalidates the cached PNG.
3395
3726
  *
3396
- * @param fontDir - Directory containing at least one font file.
3397
- * @returns The font name + bytes, or `null` when no font is present.
3727
+ * @param input - The full rich OG input for the card.
3728
+ * @param template - The resolved OG template identifier.
3729
+ * @param fontsHash - The {@link fontsKey} of the configured fonts.
3730
+ * @returns The hex-encoded SHA-256 digest.
3398
3731
  * @example
3399
3732
  * ```ts
3400
- * await loadFont("./fonts");
3733
+ * ogHash(input, "default", fontsKey());
3401
3734
  * ```
3402
3735
  */
3403
- async function loadFont(fontDir) {
3404
- if (!(0, node_fs.existsSync)(fontDir)) return void 0;
3405
- const font = (await (0, node_fs_promises.readdir)(fontDir)).find((name) => FONT_EXTENSIONS$1.some((extension) => name.endsWith(extension)));
3406
- if (!font) return void 0;
3407
- return {
3736
+ function ogHash(input, template, fontsHash) {
3737
+ const payload = [
3738
+ input.title,
3739
+ input.description,
3740
+ input.date,
3741
+ input.tags.join(","),
3742
+ input.author ?? "",
3743
+ input.locale,
3744
+ input.siteName,
3745
+ `${input.size.width}x${input.size.height}`,
3746
+ template,
3747
+ fontsHash
3748
+ ].join("|");
3749
+ return (0, node_crypto.createHash)("sha256").update(payload).digest("hex");
3750
+ }
3751
+ /**
3752
+ * Load the configured OG fonts ONCE per build. When `ogImage.fonts` is set, each
3753
+ * `path` is read to a Buffer (outside any per-image loop) and mapped to a Satori
3754
+ * font entry; otherwise the first font file found in `fontDir` is used as a single
3755
+ * 400/normal fallback.
3756
+ *
3757
+ * @param og - The font directory + optional explicit fonts list.
3758
+ * @param og.fontDir - Directory scanned for a fallback font when `fonts` is unset.
3759
+ * @param og.fonts - Explicit named fonts (each loaded once).
3760
+ * @returns The loaded fonts (empty when no font is available).
3761
+ * @example
3762
+ * ```ts
3763
+ * await loadFonts({ fontDir: "./fonts" });
3764
+ * ```
3765
+ */
3766
+ async function loadFonts(og) {
3767
+ if (og.fonts && og.fonts.length > 0) return Promise.all(og.fonts.map(async (font) => ({
3768
+ name: font.name,
3769
+ data: await (0, node_fs_promises.readFile)(font.path),
3770
+ weight: font.weight ?? 400,
3771
+ style: font.style ?? "normal"
3772
+ })));
3773
+ if (!(0, node_fs.existsSync)(og.fontDir)) return [];
3774
+ const file = (await (0, node_fs_promises.readdir)(og.fontDir)).find((name) => FONT_EXTENSIONS$1.some((extension) => name.endsWith(extension)));
3775
+ if (!file) return [];
3776
+ return [{
3408
3777
  name: "OG",
3409
- data: await (0, node_fs_promises.readFile)(node_path.default.join(fontDir, font))
3410
- };
3778
+ data: await (0, node_fs_promises.readFile)(node_path.default.join(og.fontDir, file)),
3779
+ weight: 400,
3780
+ style: "normal"
3781
+ }];
3411
3782
  }
3412
3783
  /**
3413
- * The default PNG renderer: Satori renders a card to SVG, resvg rasterizes to PNG.
3784
+ * The built-in default OG card a centered title on a dark background. Used when
3785
+ * no custom `ogImage.render` hook is configured. (`@jsxImportSource preact`.)
3414
3786
  *
3415
- * @param ctx - The font directory + template wiring for the renderer.
3416
- * @param ctx.fontDir - Directory containing at least one font file.
3417
- * @returns An {@link OgPngRenderer} bound to the loaded font.
3787
+ * @param input - The rich OG input (only `title` is used by the default card).
3788
+ * @returns The Preact `VNode` for the default card.
3789
+ * @example
3790
+ * ```ts
3791
+ * defaultCard(input);
3792
+ * ```
3793
+ */
3794
+ function defaultCard(input) {
3795
+ return (0, preact.h)("div", { style: {
3796
+ display: "flex",
3797
+ width: "100%",
3798
+ height: "100%",
3799
+ alignItems: "center",
3800
+ justifyContent: "center",
3801
+ fontSize: 64,
3802
+ background: "#0b0b0c",
3803
+ color: "#ffffff"
3804
+ } }, input.title);
3805
+ }
3806
+ /**
3807
+ * The default PNG renderer: a Preact `VNode` (custom `render` hook or the built-in
3808
+ * card) is rendered to SVG by Satori, then rasterized to PNG by resvg. Both native
3809
+ * deps are imported LAZILY (browser-safe goal); the VNode→Satori-input cast happens
3810
+ * at this single framework boundary only.
3811
+ *
3812
+ * @param ctx - The renderer wiring (preloaded fonts + optional custom card).
3813
+ * @param ctx.fonts - Fonts loaded once for the whole render pass.
3814
+ * @param ctx.render - Optional custom card renderer; defaults to {@link defaultCard}.
3815
+ * @returns An {@link OgPngRenderer} bound to the loaded fonts + renderer.
3418
3816
  * @example
3419
3817
  * ```ts
3420
- * const render = makeDefaultRenderer({ fontDir: "./fonts" });
3818
+ * const render = makeDefaultRenderer({ fonts, render: undefined });
3421
3819
  * ```
3422
3820
  */
3423
3821
  function makeDefaultRenderer(ctx) {
3424
- const fontPromise = loadFont(ctx.fontDir);
3425
- return async ({ title, width, height }) => {
3426
- const font = await fontPromise;
3427
- if (!font) throw new Error("[web] build.ogImage no font available for rendering");
3428
- return new _resvg_resvg_js.Resvg(await (0, satori.default)(/* @__PURE__ */ (0, preact_jsx_runtime.jsx)("div", {
3429
- style: {
3430
- display: "flex",
3431
- width: "100%",
3432
- height: "100%",
3433
- alignItems: "center",
3434
- justifyContent: "center",
3435
- fontSize: 64,
3436
- background: "#0b0b0c",
3437
- color: "#ffffff"
3438
- },
3439
- children: title
3440
- }), {
3441
- width,
3442
- height,
3443
- fonts: [{
3444
- name: font.name,
3445
- data: font.data,
3446
- weight: 400,
3447
- style: "normal"
3448
- }]
3822
+ return async (input) => {
3823
+ if (ctx.fonts.length === 0) throw new Error("[web] build.ogImage no font available for rendering");
3824
+ const { default: satori } = await import("satori");
3825
+ const { Resvg } = await import("@resvg/resvg-js");
3826
+ return new Resvg(await satori((ctx.render ?? defaultCard)(input), {
3827
+ width: input.size.width,
3828
+ height: input.size.height,
3829
+ fonts: ctx.fonts
3449
3830
  })).render().asPng();
3450
3831
  };
3451
3832
  }
@@ -3463,13 +3844,59 @@ function selectArticles(byLocale) {
3463
3844
  return ([...byLocale.values()][0] ?? []).filter((article) => article.computed.status === "published");
3464
3845
  }
3465
3846
  /**
3847
+ * Build the {@link RichOgInput} for one article from its frontmatter/computed
3848
+ * fields plus the resolved size and site name.
3849
+ *
3850
+ * @param article - The published article to render a card for.
3851
+ * @param size - The resolved OG output dimensions.
3852
+ * @param siteName - The site name (from the site plugin, or `""` when unavailable).
3853
+ * @returns The fully-populated rich OG input.
3854
+ * @example
3855
+ * ```ts
3856
+ * buildInput(article, { width: 1200, height: 630 }, "Blog");
3857
+ * ```
3858
+ */
3859
+ function buildInput(article, size, siteName) {
3860
+ const input = {
3861
+ title: article.frontmatter.title,
3862
+ description: article.frontmatter.description,
3863
+ date: article.frontmatter.date,
3864
+ tags: [...article.frontmatter.tags],
3865
+ locale: article.locale,
3866
+ siteName,
3867
+ size
3868
+ };
3869
+ if (article.frontmatter.author !== void 0) input.author = article.frontmatter.author;
3870
+ return input;
3871
+ }
3872
+ /**
3873
+ * Resolve the site name via `ctx.require(sitePlugin)`, falling back to `""` when the
3874
+ * site API is unavailable (e.g. unit mocks that omit it).
3875
+ *
3876
+ * @param ctx - Plugin context (provides `require`).
3877
+ * @returns The site name, or `""` when the site plugin is not wired.
3878
+ * @example
3879
+ * ```ts
3880
+ * resolveSiteName(ctx);
3881
+ * ```
3882
+ */
3883
+ function resolveSiteName(ctx) {
3884
+ try {
3885
+ return ctx.require(sitePlugin).name();
3886
+ } catch {
3887
+ return "";
3888
+ }
3889
+ }
3890
+ /**
3466
3891
  * Renders OG images for published articles with a `p-limit(4)` concurrency pool.
3467
- * Computes `sha256(title+template+size)` per article and skips regeneration when
3468
- * the hash matches `state.ogImageHashCache`; writes the cache back to
3469
- * `<outDir>/.cache/og-images.json`. No-op when `config.ogImage` is false.
3892
+ * Computes {@link ogHash} (full {@link RichOgInput} + template + fonts) per article
3893
+ * and skips regeneration when the hash matches `state.ogImageHashCache`; writes the
3894
+ * cache back to `<outDir>/.cache/og-images.json`. The configured `ogImage.render`
3895
+ * hook (when present) builds each card; otherwise the built-in card is used. Fonts
3896
+ * are loaded ONCE for the whole pass. No-op when `config.ogImage` is false.
3470
3897
  *
3471
- * @param ctx - Plugin context (provides `state`, `config`, `log`).
3472
- * @param options - Optional dependency-injection seam (PNG renderer).
3898
+ * @param ctx - Plugin context (provides `require`, `state`, `config`, `log`).
3899
+ * @param options - Optional dependency-injection seam (PNG rasterizer).
3473
3900
  * @returns The render/skip counts + peak concurrency, or `null` when disabled.
3474
3901
  * @example
3475
3902
  * ```ts
@@ -3483,9 +3910,17 @@ async function generateOgImages(ctx, options = {}) {
3483
3910
  return null;
3484
3911
  }
3485
3912
  const { default: pLimit } = await import("p-limit");
3486
- const size = og.size ?? DEFAULT_SIZE;
3487
- const template = og.template ?? "default";
3488
- const renderPng = options.renderPng ?? makeDefaultRenderer({ fontDir: og.fontDir });
3913
+ const config = og;
3914
+ const size = config.size ?? DEFAULT_SIZE;
3915
+ const template = config.template ?? "default";
3916
+ const fontsHash = fontsKey(config.fonts);
3917
+ const fonts = options.renderPng ? [] : await loadFonts(config);
3918
+ const renderHook = config.render ? { render: config.render } : {};
3919
+ const renderPng = options.renderPng ?? makeDefaultRenderer({
3920
+ fonts,
3921
+ ...renderHook
3922
+ });
3923
+ const siteName = resolveSiteName(ctx);
3489
3924
  const articles = selectArticles(readCachedContent(ctx));
3490
3925
  const cache = ctx.state.ogImageHashCache;
3491
3926
  await loadDiskCache(ctx.config.outDir, cache);
@@ -3497,7 +3932,8 @@ async function generateOgImages(ctx, options = {}) {
3497
3932
  const outDir = node_path.default.join(ctx.config.outDir, "og");
3498
3933
  await Promise.all(articles.map((article) => limit(async () => {
3499
3934
  const key = article.computed.contentId;
3500
- const hash = ogHash(article.frontmatter.title, template, size);
3935
+ const input = buildInput(article, size, siteName);
3936
+ const hash = ogHash(input, template, fontsHash);
3501
3937
  if (cache.get(key) === hash) {
3502
3938
  skipped += 1;
3503
3939
  return;
@@ -3505,10 +3941,7 @@ async function generateOgImages(ctx, options = {}) {
3505
3941
  active += 1;
3506
3942
  peakConcurrency = Math.max(peakConcurrency, active);
3507
3943
  try {
3508
- const png = await renderPng({
3509
- title: article.frontmatter.title,
3510
- ...size
3511
- });
3944
+ const png = await renderPng(input);
3512
3945
  await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
3513
3946
  await (0, node_fs_promises.writeFile)(node_path.default.join(outDir, `${key}.png`), png);
3514
3947
  cache.set(key, hash);
@@ -3563,28 +3996,329 @@ async function persistDiskCache(outDir, cache) {
3563
3996
  await (0, node_fs_promises.writeFile)(node_path.default.join(dir, "og-images.json"), JSON.stringify(Object.fromEntries(cache)), "utf8");
3564
3997
  }
3565
3998
  //#endregion
3999
+ //#region src/plugins/data/load-json.ts
4000
+ /**
4001
+ * @file `loadJson` — the data plugin's isomorphic JSON read primitive (the
4002
+ * SSG↔SPA seam). Internal to the `data` plugin (NOT a framework-root export):
4003
+ * `data.load(locale)` uses it, and consumers read through `app.data.load(locale)`.
4004
+ *
4005
+ * A read runs in BOTH worlds: on Node it reads the emitted data file from disk;
4006
+ * on the client (browser) it fetches the same data over HTTP. `loadJson` is the
4007
+ * single point where those two worlds differ — everything above it (the route's
4008
+ * `load`/`render`) is shared, so SSR/client parity is structural, not hoped-for.
4009
+ *
4010
+ * The browser path uses the `fetch` global. The Node path lazy-imports
4011
+ * `node:fs/promises` via `await import(...)`, so a browser bundle that includes
4012
+ * `loadJson` never statically pulls `node:*` (the bundler splits the Node branch
4013
+ * into its own chunk that the browser never loads).
4014
+ */
4015
+ /**
4016
+ * Read + parse a JSON resource, isomorphically. In a browser (`document`
4017
+ * defined) it `fetch`es `pathOrUrl`; on Node it reads the file from disk. Throws
4018
+ * on a failed fetch or unreadable file so the caller (`route.load`/`data.load`)
4019
+ * can decide whether to fall back.
4020
+ *
4021
+ * @template T - The expected shape of the parsed JSON.
4022
+ * @param pathOrUrl - A site-root URL (browser) or filesystem path (Node).
4023
+ * @returns The parsed JSON, typed as `T`.
4024
+ * @throws {Error} If the browser fetch is not OK, or the Node file read fails.
4025
+ * @example
4026
+ * ```ts
4027
+ * // Browser: fetch("/_data/en/articles.json")
4028
+ * // Node: read "dist/_data/en/articles.json"
4029
+ * const articles = await loadJson<Article[]>("/_data/en/articles.json");
4030
+ * ```
4031
+ */
4032
+ async function loadJson(pathOrUrl) {
4033
+ if (typeof document === "undefined") {
4034
+ const { readFile } = await import("node:fs/promises");
4035
+ return JSON.parse(await readFile(pathOrUrl, "utf8"));
4036
+ }
4037
+ const response = await fetch(pathOrUrl);
4038
+ if (!response.ok) throw new Error(`[web] loadJson: failed to fetch ${pathOrUrl} (${String(response.status)}).`);
4039
+ return response.json();
4040
+ }
4041
+ //#endregion
4042
+ //#region src/plugins/data/api.ts
4043
+ /**
4044
+ * @file data plugin — API factory (the agnostic data provider surface).
4045
+ *
4046
+ * Node-free by construction: this module statically imports only types + the pure
4047
+ * convention. The Node write side (`write()`) reaches its `node:fs` writer through
4048
+ * a lazy `await import("./writer")` at call time, so a browser bundle that composes
4049
+ * `data` for the read side never pulls `node:*`. The read side (`at()`) uses only
4050
+ * the isomorphic `loadJson` (whose Node branch is itself lazy).
4051
+ */
4052
+ /**
4053
+ * Trim a single trailing slash from a config dir so `fileFor` joins cleanly.
4054
+ *
4055
+ * @param dir - The configured output dir (e.g. `"_data"` or `"_data/"`).
4056
+ * @returns The dir without a trailing slash.
4057
+ * @example
4058
+ * ```ts
4059
+ * trimTrailingSlash("_data/"); // "_data"
4060
+ * ```
4061
+ */
4062
+ function trimTrailingSlash(dir) {
4063
+ return dir.endsWith("/") ? dir.slice(0, -1) : dir;
4064
+ }
4065
+ /**
4066
+ * Builds the data provider — the agnostic bridge. `write()` is the Node persist
4067
+ * side; `at()` is the browser read side; `urlFor`/`fileFor` are the pure
4068
+ * convention. No `onStart`/`onStop` (holds no long-lived resource).
4069
+ *
4070
+ * @param ctx - The data plugin context.
4071
+ * @returns The {@link DataProvider} mounted at `app.data`.
4072
+ * @example
4073
+ * ```ts
4074
+ * const api = dataApi(ctx);
4075
+ * await api.write([{ path: "/en/hello/", data: article }]); // Node build
4076
+ * await api.at("/en/hello/"); // browser
4077
+ * ```
4078
+ */
4079
+ function dataApi(ctx) {
4080
+ return {
4081
+ /**
4082
+ * READ (browser) — fetch (and cache) the persisted data for a page path.
4083
+ * Returns the raw JSON as `unknown` (the caller's `route.parse` validates it),
4084
+ * or `null` if the fetch/parse fails (so `spa` can fall back to HTML).
4085
+ *
4086
+ * @param path - The page URL path (e.g. `/en/hello/`).
4087
+ * @returns The page's raw data, or `null` on failure.
4088
+ * @example
4089
+ * ```ts
4090
+ * const raw = await api.at("/en/hello/");
4091
+ * ```
4092
+ */
4093
+ async at(path) {
4094
+ if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
4095
+ try {
4096
+ const data = await loadJson(`${ctx.config.baseUrl}${require_convention.dataSuffix(path)}`);
4097
+ ctx.state.cache.set(path, data);
4098
+ return data;
4099
+ } catch {
4100
+ return null;
4101
+ }
4102
+ },
4103
+ /**
4104
+ * WRITE (Node) — persist one JSON file per entry, keyed by page path. Called by
4105
+ * `build` after it expands routes. Lazily loads its `node:fs` writer (keeping a
4106
+ * browser bundle node-free).
4107
+ *
4108
+ * @param entries - The per-page data to persist.
4109
+ * @param options - Optional `{ outDir }` override (defaults to `./dist`).
4110
+ * @param options.outDir - Build output directory the write happens under.
4111
+ * @returns A summary of the written files.
4112
+ * @example
4113
+ * ```ts
4114
+ * await api.write([{ path: "/en/hello/", data: article }], { outDir: "dist" });
4115
+ * ```
4116
+ */
4117
+ async write(entries, options) {
4118
+ const { writeData } = await Promise.resolve().then(() => require("./writer-DAF0pM25.cjs"));
4119
+ return writeData(ctx, entries, options);
4120
+ },
4121
+ /**
4122
+ * PURE — the browser fetch URL for a page path.
4123
+ *
4124
+ * @param path - The page URL path.
4125
+ * @returns The site-root-relative data URL.
4126
+ * @example
4127
+ * ```ts
4128
+ * api.urlFor("/en/hello/"); // "/_data/en/hello/index.json"
4129
+ * ```
4130
+ */
4131
+ urlFor(path) {
4132
+ return `${ctx.config.baseUrl}${require_convention.dataSuffix(path)}`;
4133
+ },
4134
+ /**
4135
+ * PURE — the `outDir`-relative file path for a page path.
4136
+ *
4137
+ * @param path - The page URL path.
4138
+ * @returns The output-relative file path.
4139
+ * @example
4140
+ * ```ts
4141
+ * api.fileFor("/en/hello/"); // "_data/en/hello/index.json"
4142
+ * ```
4143
+ */
4144
+ fileFor(path) {
4145
+ return `${trimTrailingSlash(ctx.config.outputDir)}/${require_convention.dataSuffix(path)}`;
4146
+ }
4147
+ };
4148
+ }
4149
+ //#endregion
4150
+ //#region src/plugins/data/config.ts
4151
+ /**
4152
+ * Typed default data config (R6: no inline `as`). `outputDir` is the WRITE path
4153
+ * (filesystem, relative to the build `outDir`); `baseUrl` is the matching READ URL
4154
+ * (site-root-relative) the browser fetches from — the defaults agree
4155
+ * (`"_data"` ↔ `"/_data/"`).
4156
+ *
4157
+ * @example
4158
+ * ```ts
4159
+ * createPlugin("data", { config: defaultDataConfig });
4160
+ * ```
4161
+ */
4162
+ const defaultDataConfig = {
4163
+ outputDir: "_data",
4164
+ baseUrl: "/_data/"
4165
+ };
4166
+ //#endregion
4167
+ //#region src/plugins/data/state.ts
4168
+ /**
4169
+ * Creates initial data state: a null `lastWrite` slot (populated by the Node
4170
+ * `write()` side) and an empty `cache` (populated lazily by the browser `at(path)`
4171
+ * side on first fetch).
4172
+ *
4173
+ * @param _ctx - Minimal context with global and config.
4174
+ * @param _ctx.global - Global framework configuration.
4175
+ * @param _ctx.config - Resolved plugin configuration.
4176
+ * @returns Fresh data state with no recorded write and an empty per-path cache.
4177
+ * @example
4178
+ * ```ts
4179
+ * const state = createDataState({ global: {}, config });
4180
+ * ```
4181
+ */
4182
+ function createDataState(_ctx) {
4183
+ return {
4184
+ lastWrite: null,
4185
+ cache: /* @__PURE__ */ new Map()
4186
+ };
4187
+ }
4188
+ //#endregion
4189
+ //#region src/plugins/data/validate.ts
4190
+ /**
4191
+ * Validates the resolved data config: the browser `baseUrl` must be a non-empty,
4192
+ * site-root-relative URL path. The emit/read pipelines are wired in build waves 3/4.
4193
+ *
4194
+ * @param config - The resolved plugin configuration.
4195
+ * @throws {Error} If `baseUrl` is empty or not a rooted URL path.
4196
+ * @example
4197
+ * ```ts
4198
+ * validateDataConfig({ outputDir: "_data", baseUrl: "/_data/" });
4199
+ * ```
4200
+ */
4201
+ function validateDataConfig(config) {
4202
+ if (typeof config.baseUrl !== "string" || !config.baseUrl.startsWith("/")) throw new Error(`[web] data.baseUrl: must be a site-root-relative URL path starting with "/" (e.g. "/_data/").`);
4203
+ }
4204
+ //#endregion
4205
+ //#region src/plugins/data/index.ts
4206
+ /**
4207
+ * @file data — Standard tier plugin (wiring-only). The AGNOSTIC data provider for
4208
+ * the SSG→DATA→SPA pattern.
4209
+ *
4210
+ * Owns ONE contract — `page path → persisted JSON file` — and nothing about what
4211
+ * the data is: `write(entries)` persists per-page JSON on Node (build supplies the
4212
+ * entries it already expanded); `at(path)` fetches + caches it in the browser as
4213
+ * `unknown`, which the route's `parse` validates before `render`. NOT a framework
4214
+ * default — the consumer composes it where needed (Node build AND/OR browser app).
4215
+ *
4216
+ * **No hard `depends`** — fully browser-composable; the `node:fs` writer is behind
4217
+ * a lazy `import()` inside `write()`. Build ordering is a call-site contract: build
4218
+ * writes data during its pages phase (after its Phase-0 clean), via `app.data.write`.
4219
+ * No `onStart`/`onStop`.
4220
+ * @see README.md
4221
+ */
4222
+ /**
4223
+ * Data plugin — the agnostic data provider. Mounts `write(entries)` (Node persist),
4224
+ * `at(path)` (browser read), and the pure `urlFor`/`fileFor` convention at `app.data`.
4225
+ *
4226
+ * @example
4227
+ * ```ts
4228
+ * // Node build: `build` calls app.data.write(...) during its pages phase when
4229
+ * // router.mode !== "ssg". Just compose the plugin:
4230
+ * const app = createApp({
4231
+ * plugins: [dataPlugin, contentPlugin, buildPlugin],
4232
+ * pluginConfigs: { content: { contentDir: "./content" }, router: { routes, mode: "hybrid" } }
4233
+ * });
4234
+ * await app.start();
4235
+ * await app.build.run(); // writes HTML + per-page data sidecars
4236
+ *
4237
+ * // Browser app: compose `dataPlugin` too; spa fetches via app.data.at(path) on nav.
4238
+ * ```
4239
+ */
4240
+ const dataPlugin = createPlugin$1("data", {
4241
+ config: defaultDataConfig,
4242
+ createState: createDataState,
4243
+ onInit: (ctx) => validateDataConfig(ctx.config),
4244
+ api: dataApi
4245
+ });
4246
+ //#endregion
3566
4247
  //#region src/plugins/build/phases/pages.tsx
3567
4248
  /**
3568
4249
  * @file build phase 3 — pages. Pulls `router.manifest()` + `head.render(route, data)`
3569
4250
  * and SSR-renders each route to static HTML (preact-render-to-string). Appends the
3570
4251
  * build-id meta tag after `head.render()` returns. Does NOT compose `<head>` itself.
3571
4252
  */
4253
+ /** Template placeholder for the composed `<head>` inner HTML. */
4254
+ const HEAD_PLACEHOLDER = "<!--moku:head-->";
4255
+ /** Template placeholder for the SSR-rendered body HTML. */
4256
+ const BODY_PLACEHOLDER = "<!--moku:body-->";
4257
+ /** Template placeholder for the injected asset `<link>`/`<script>` tags. */
4258
+ const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
3572
4259
  /**
3573
- * Compose the full static HTML document, injecting the build-id meta tag into
3574
- * `<head>` AFTER the head plugin's composed HTML (build metadata, not content).
4260
+ * Read the bundle phase's hashed asset manifest for one kind from `state.buildCache`
4261
+ * as a typed {@link BuildCacheEntry} (no `Map<string, unknown>` reads).
3575
4262
  *
3576
- * @param headHtml - The composed `<head>` inner HTML from `head.render`.
3577
- * @param bodyHtml - The SSR-rendered body HTML.
3578
- * @param runId - The per-run build id injected as `<meta name="build-id">`.
3579
- * @param locale - The page locale for the `<html lang>` attribute.
4263
+ * @param ctx - Plugin context (provides `state`).
4264
+ * @param kind - The asset kind key (`"css"` / `"js"`).
4265
+ * @returns The hashed-path manifest entry, or an empty object when absent.
4266
+ * @example
4267
+ * ```ts
4268
+ * readManifest(ctx, "css");
4269
+ * ```
4270
+ */
4271
+ function readManifest(ctx, kind) {
4272
+ const entry = ctx.state.buildCache.get(kind);
4273
+ return entry && typeof entry === "object" ? entry : {};
4274
+ }
4275
+ /**
4276
+ * Build the asset `<link>`/`<script>` tag block from the hashed manifests. Returns
4277
+ * an empty string when `config.injectAssets === false`. Asset paths are emitted as
4278
+ * absolute (`/`-rooted) URLs.
4279
+ *
4280
+ * @param ctx - Plugin context (provides `state`, `config`).
4281
+ * @returns The injected asset tags, or `""` when injection is disabled.
4282
+ * @example
4283
+ * ```ts
4284
+ * buildAssetTags(ctx);
4285
+ * ```
4286
+ */
4287
+ function buildAssetTags(ctx) {
4288
+ if (ctx.config.injectAssets === false) return "";
4289
+ const css = Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`);
4290
+ const js = Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`);
4291
+ return [...css, ...js].join("");
4292
+ }
4293
+ /**
4294
+ * Compose the full static HTML document with the in-code shell, injecting the
4295
+ * build-id meta tag into `<head>` AFTER the head plugin's composed HTML (build
4296
+ * metadata, not content) and the asset tags at the end of `<head>`.
4297
+ *
4298
+ * @param parts - The composed head/body/assets/locale pieces.
3580
4299
  * @returns The complete HTML document string.
3581
4300
  * @example
3582
4301
  * ```ts
3583
- * renderDocument("<title>Hi</title>", "<h1>Hi</h1>", "run-1", "en");
4302
+ * renderDocument({ head: "<title>Hi</title>", body: "<h1>Hi</h1>", assets: "", locale: "en" });
4303
+ * ```
4304
+ */
4305
+ function renderDocument(parts) {
4306
+ return `<!DOCTYPE html><html lang="${parts.locale}"><head>${parts.head}${parts.assets}</head><body>${parts.body}</body></html>`;
4307
+ }
4308
+ /**
4309
+ * Fill a shell template's `<!--moku:head-->` / `<!--moku:body-->` /
4310
+ * `<!--moku:assets-->` placeholders deterministically at build time.
4311
+ *
4312
+ * @param template - The raw shell template HTML.
4313
+ * @param parts - The composed head/body/assets pieces.
4314
+ * @returns The filled document string.
4315
+ * @example
4316
+ * ```ts
4317
+ * fillTemplate(shell, { head, body, assets, locale: "en" });
3584
4318
  * ```
3585
4319
  */
3586
- function renderDocument(headHtml, bodyHtml, runId, locale) {
3587
- return `<!DOCTYPE html><html lang="${locale}"><head>${headHtml}${`<meta name="build-id" content="${runId}">`}</head><body>${bodyHtml}</body></html>`;
4320
+ function fillTemplate(template, parts) {
4321
+ return template.replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
3588
4322
  }
3589
4323
  /**
3590
4324
  * Expand one route definition into its concrete page instances across all
@@ -3661,18 +4395,24 @@ function adaptHeadConfig(config) {
3661
4395
  return adapted;
3662
4396
  }
3663
4397
  /**
3664
- * Render one page instance to its static HTML document and write it to disk.
4398
+ * Render one page instance to its static HTML document and write it to disk. Uses
4399
+ * the configured shell `template` (filled at build time) when supplied, otherwise
4400
+ * the in-code shell; injects the precomputed asset tags + build-id meta.
3665
4401
  *
3666
4402
  * @param ctx - Plugin context (provides `require`, `state`, `config`).
3667
4403
  * @param instance - The concrete page instance to render.
4404
+ * @param shell - Wiring shared across instances (asset tags + optional template).
4405
+ * @param shell.assets - The injected asset `<link>`/`<script>` tags.
4406
+ * @param shell.template - The shell template HTML, or `null` for the in-code shell.
3668
4407
  * @returns The instance's URL and rendered HTML (HTML reused for the root page).
3669
4408
  * @example
3670
4409
  * ```ts
3671
- * await renderInstance(ctx, instance);
4410
+ * await renderInstance(ctx, instance, { assets: "", template: null });
3672
4411
  * ```
3673
4412
  */
3674
- async function renderInstance(ctx, instance) {
4413
+ async function renderInstance(ctx, instance, shell) {
3675
4414
  const { definition, entry, params, locale, name } = instance;
4415
+ const hasData = definition._handlers.load !== void 0;
3676
4416
  const data = definition._handlers.load ? await definition._handlers.load(params, locale) : void 0;
3677
4417
  const routeContext = {
3678
4418
  params,
@@ -3689,14 +4429,24 @@ async function renderInstance(ctx, instance) {
3689
4429
  };
3690
4430
  if (headConfig) resolved.head = adaptHeadConfig(headConfig);
3691
4431
  const headHtml = ctx.require(headPlugin).render(resolved, data);
4432
+ const buildIdMeta = `<meta name="build-id" content="${ctx.state.runId ?? ""}">`;
3692
4433
  const vnode = definition._handlers.render?.(routeContext);
3693
- const html = renderDocument(headHtml, vnode ? (0, preact_render_to_string.renderToString)(vnode) : "", ctx.state.runId ?? "", locale);
4434
+ const bodyHtml = vnode ? (0, preact_render_to_string.renderToString)(vnode) : "";
4435
+ const parts = {
4436
+ head: `${headHtml}${buildIdMeta}`,
4437
+ body: bodyHtml,
4438
+ assets: shell.assets,
4439
+ locale
4440
+ };
4441
+ const html = shell.template === null ? renderDocument(parts) : fillTemplate(shell.template, parts);
3694
4442
  const filePath = (0, node_path.join)(ctx.config.outDir, entry.toFile(params));
3695
4443
  await (0, node_fs_promises.mkdir)((0, node_path.dirname)(filePath), { recursive: true });
3696
4444
  await (0, node_fs_promises.writeFile)(filePath, html, "utf8");
3697
4445
  return {
3698
4446
  url,
3699
- html
4447
+ html,
4448
+ data,
4449
+ hasData
3700
4450
  };
3701
4451
  }
3702
4452
  /**
@@ -3714,14 +4464,56 @@ async function renderInstance(ctx, instance) {
3714
4464
  * const { pageCount, rootHtml } = await renderPages(ctx);
3715
4465
  * ```
3716
4466
  */
4467
+ /**
4468
+ * Enforce the data-validation contract: in `hybrid`/`spa` mode, any route that
4469
+ * has BOTH a `render` and a `load` (so it will be client-data-navigated) MUST
4470
+ * declare a `.parse()` validator — otherwise fetched JSON would reach `render`
4471
+ * unvalidated. Converts a runtime safety hole into a build error.
4472
+ *
4473
+ * @param manifest - The route definitions from `router.manifest()`.
4474
+ * @param mode - The resolved render mode.
4475
+ * @throws {Error} If a data-navigable route is missing `.parse()` in hybrid/spa mode.
4476
+ * @example
4477
+ * ```ts
4478
+ * assertDataValidators(router.manifest(), router.mode());
4479
+ * ```
4480
+ */
4481
+ function assertDataValidators(manifest, mode) {
4482
+ if (mode === "ssg") return;
4483
+ for (const definition of manifest) {
4484
+ const { render, load, parse } = definition._handlers;
4485
+ if (render && load && !parse) throw new Error(`[web] build: route "${definition.pattern}" enables client data navigation (router mode "${mode}") but has no .parse() validator. Add .parse(raw => /* validate → data */) so fetched JSON is validated before render, or set router mode "ssg" to disable data navigation.`);
4486
+ }
4487
+ }
3717
4488
  async function renderPages(ctx) {
3718
4489
  const router = ctx.require(routerPlugin);
3719
4490
  const manifest = router.manifest();
3720
4491
  ctx.state.manifest = [...manifest];
4492
+ const mode = router.mode();
4493
+ assertDataValidators(manifest, mode);
3721
4494
  const byPattern = makeEntryMap(router);
3722
4495
  const locales = ctx.require(i18nPlugin).locales();
4496
+ const templatePath = ctx.config.template;
4497
+ const template = typeof templatePath === "string" && (0, node_fs.existsSync)(templatePath) ? await (0, node_fs_promises.readFile)(templatePath, "utf8") : null;
4498
+ const shell = {
4499
+ assets: buildAssetTags(ctx),
4500
+ template
4501
+ };
3723
4502
  const instances = (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, byPattern)))).flat();
3724
- const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance)));
4503
+ const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance, shell)));
4504
+ if (mode !== "ssg" && ctx.has("data")) {
4505
+ const entries = rendered.filter((page) => page.hasData).map((page) => ({
4506
+ path: page.url,
4507
+ data: page.data
4508
+ }));
4509
+ if (entries.length > 0) {
4510
+ const summary = await ctx.require(dataPlugin).write(entries, { outDir: ctx.config.outDir });
4511
+ ctx.log.debug("build:data", {
4512
+ files: summary.fileCount,
4513
+ bytes: summary.bytes
4514
+ });
4515
+ }
4516
+ }
3725
4517
  const root = rendered.find((page) => page.url === "/" || page.url === "");
3726
4518
  ctx.log.debug("build:pages", { count: rendered.length });
3727
4519
  return {
@@ -3729,6 +4521,37 @@ async function renderPages(ctx) {
3729
4521
  rootHtml: root?.html ?? null
3730
4522
  };
3731
4523
  }
4524
+ /**
4525
+ * Copies the configured `publicDir` (default `"public"`) verbatim into `outDir`,
4526
+ * preserving the nested directory structure. Skips silently (returns `null`) when
4527
+ * the source directory does not exist.
4528
+ *
4529
+ * @param ctx - Plugin context (provides `config`, `log`).
4530
+ * @returns The copy result, or `null` when the public directory is absent.
4531
+ * @example
4532
+ * ```ts
4533
+ * const result = await copyPublic(ctx);
4534
+ * ```
4535
+ */
4536
+ async function copyPublic(ctx) {
4537
+ const from = ctx.config.publicDir ?? "public";
4538
+ if (!(0, node_fs.existsSync)(from)) {
4539
+ ctx.log.debug("build:public", {
4540
+ skipped: true,
4541
+ from
4542
+ });
4543
+ return null;
4544
+ }
4545
+ await (0, node_fs_promises.cp)(from, ctx.config.outDir, { recursive: true });
4546
+ ctx.log.debug("build:public", {
4547
+ from,
4548
+ dest: ctx.config.outDir
4549
+ });
4550
+ return {
4551
+ from: node_path$1.default.normalize(from),
4552
+ copied: 1
4553
+ };
4554
+ }
3732
4555
  //#endregion
3733
4556
  //#region src/plugins/build/phases/sitemap.ts
3734
4557
  /**
@@ -3832,6 +4655,9 @@ const PHASE_ORDER = [
3832
4655
  "feeds",
3833
4656
  "sitemap",
3834
4657
  "og-images",
4658
+ "public",
4659
+ "not-found",
4660
+ "locale-redirects",
3835
4661
  "root-index"
3836
4662
  ];
3837
4663
  /**
@@ -3875,10 +4701,11 @@ function resetRun(ctx) {
3875
4701
  ctx.state.runId = `${Date.now()}-${(0, node_crypto.randomUUID)()}`;
3876
4702
  }
3877
4703
  /**
3878
- * Phase 4 — run feeds / sitemap / og-images concurrently, each gated by its config
3879
- * flag, isolated with `Promise.allSettled` so one failure does not lose the others.
3880
- * A disabled output is skipped entirely it emits NO `build:phase` boundary (the
3881
- * `withPhase` wrapper is gated on the config flag, not just the phase body).
4704
+ * Phase 4 — run feeds / sitemap / og-images / public / not-found / locale-redirects
4705
+ * concurrently, each gated by its config flag (or, for `public`, the presence of the
4706
+ * source dir), isolated with `Promise.allSettled` so one failure does not lose the
4707
+ * others. A disabled output is skipped entirely it emits NO `build:phase` boundary
4708
+ * (the `withPhase` wrapper is gated on the config flag, not just the phase body).
3882
4709
  *
3883
4710
  * @param ctx - The phase context.
3884
4711
  * @example
@@ -3891,6 +4718,9 @@ async function runOutputs(ctx) {
3891
4718
  if (ctx.config.feeds) tasks.push(withPhase(ctx, "feeds", () => generateFeeds(ctx)));
3892
4719
  if (ctx.config.sitemap) tasks.push(withPhase(ctx, "sitemap", () => generateSitemap(ctx)));
3893
4720
  if (ctx.config.ogImage) tasks.push(withPhase(ctx, "og-images", () => generateOgImages(ctx)));
4721
+ if ((0, node_fs.existsSync)(ctx.config.publicDir ?? "public")) tasks.push(withPhase(ctx, "public", () => copyPublic(ctx)));
4722
+ if (ctx.config.notFound) tasks.push(withPhase(ctx, "not-found", () => generateNotFound(ctx)));
4723
+ if (ctx.config.localeRedirects) tasks.push(withPhase(ctx, "locale-redirects", () => generateLocaleRedirects(ctx)));
3894
4724
  const settled = await Promise.allSettled(tasks);
3895
4725
  for (const outcome of settled) if (outcome.status === "rejected") ctx.log.error("build:outputs", { reason: String(outcome.reason) });
3896
4726
  }
@@ -4033,6 +4863,9 @@ function validateFonts(og) {
4033
4863
  */
4034
4864
  function validateConfig$1(config) {
4035
4865
  if (typeof config.outDir !== "string" || config.outDir.trim().length === 0) throw new Error(`${ERROR_PREFIX$5}.outDir: must be a non-empty string.`);
4866
+ if (config.publicDir !== void 0 && typeof config.publicDir !== "string") throw new Error(`${ERROR_PREFIX$5}.publicDir: must be a string when set.`);
4867
+ if (config.template !== void 0 && typeof config.template !== "string") throw new Error(`${ERROR_PREFIX$5}.template: must be a string path when set.`);
4868
+ if (config.clientEntry !== void 0 && typeof config.clientEntry !== "string") throw new Error(`${ERROR_PREFIX$5}.clientEntry: must be a string path when set.`);
4036
4869
  if (config.ogImage) validateFonts(config.ogImage);
4037
4870
  }
4038
4871
  //#endregion
@@ -4086,6 +4919,27 @@ function createState$2(ctx) {
4086
4919
  * @file build — Complex plugin: SSG orchestrator (wiring harness only).
4087
4920
  * @see README.md
4088
4921
  */
4922
+ /**
4923
+ * Build plugin — the static-site-generation orchestrator. Renders every route to
4924
+ * `outDir`, and optionally emits feeds, a sitemap, optimized images, and OG
4925
+ * images. Depends on site, i18n, content, router, and head; emits `build:phase`.
4926
+ *
4927
+ * @example Configure the production build
4928
+ * ```ts
4929
+ * const app = createApp({
4930
+ * pluginConfigs: {
4931
+ * build: {
4932
+ * outDir: "dist",
4933
+ * minify: true,
4934
+ * feeds: true,
4935
+ * sitemap: true,
4936
+ * images: true,
4937
+ * ogImage: false // or an object to enable + configure OG-image generation
4938
+ * }
4939
+ * }
4940
+ * });
4941
+ * ```
4942
+ */
4089
4943
  const buildPlugin = createPlugin$1("build", {
4090
4944
  depends: [
4091
4945
  sitePlugin,
@@ -4885,6 +5739,24 @@ function createState$1(_ctx) {
4885
5739
  * Depends: site. Emits: deploy:complete.
4886
5740
  * @see README.md
4887
5741
  */
5742
+ /**
5743
+ * Deploy plugin — ships the built `outDir` to Cloudflare Pages via the injectable
5744
+ * wrangler subprocess, with entropy-gated secret scrubbing of logged output.
5745
+ * Depends on site; emits `deploy:complete`.
5746
+ *
5747
+ * @example Configure the deploy target
5748
+ * ```ts
5749
+ * const app = createApp({
5750
+ * pluginConfigs: {
5751
+ * deploy: {
5752
+ * target: "cloudflare-pages",
5753
+ * outDir: "dist",
5754
+ * productionBranch: "main"
5755
+ * }
5756
+ * }
5757
+ * });
5758
+ * ```
5759
+ */
4888
5760
  const deployPlugin = createPlugin$1("deploy", {
4889
5761
  config: defaultConfig,
4890
5762
  depends: [sitePlugin],
@@ -4961,7 +5833,7 @@ function spaEvents(register) {
4961
5833
  }
4962
5834
  //#endregion
4963
5835
  //#region src/plugins/spa/types.ts
4964
- var types_exports$7 = /* @__PURE__ */ __exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
5836
+ var types_exports$8 = /* @__PURE__ */ require_convention.__exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
4965
5837
  /** Allowed hook names — single source of truth for fail-fast validation. */
4966
5838
  const COMPONENT_HOOK_NAMES = [
4967
5839
  "onCreate",
@@ -5431,11 +6303,12 @@ function resolveClickTarget(event) {
5431
6303
  * Navigation API is unavailable).
5432
6304
  *
5433
6305
  * @param handlers - The navigation lifecycle callbacks.
6306
+ * @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
5434
6307
  * @returns A teardown that removes the attached listeners.
5435
6308
  * @example
5436
6309
  * const dispose = attachHistoryFallback(handlers);
5437
6310
  */
5438
- function attachHistoryFallback(handlers) {
6311
+ function attachHistoryFallback(handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
5439
6312
  /**
5440
6313
  * Intercept an internal-link click and run a History-API navigation.
5441
6314
  *
@@ -5456,7 +6329,7 @@ function attachHistoryFallback(handlers) {
5456
6329
  }
5457
6330
  saveScrollPosition(location.pathname);
5458
6331
  history.pushState({ scrollY: 0 }, "", url.pathname);
5459
- performNavigation(url.pathname, handlers).then(() => window.scrollTo(0, 0)).catch(() => {});
6332
+ navigate(url.pathname).then(() => window.scrollTo(0, 0)).catch(() => {});
5460
6333
  };
5461
6334
  /**
5462
6335
  * Re-run navigation on back/forward, restoring the saved scroll position.
@@ -5465,7 +6338,7 @@ function attachHistoryFallback(handlers) {
5465
6338
  * globalThis.addEventListener("popstate", onPopState);
5466
6339
  */
5467
6340
  const onPopState = () => {
5468
- performNavigation(location.pathname, handlers).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
6341
+ navigate(location.pathname).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
5469
6342
  };
5470
6343
  document.addEventListener("click", onClick);
5471
6344
  globalThis.addEventListener("popstate", onPopState);
@@ -5479,11 +6352,12 @@ function attachHistoryFallback(handlers) {
5479
6352
  *
5480
6353
  * @param navigation - The Navigation API object to attach the listener to.
5481
6354
  * @param handlers - The navigation lifecycle callbacks.
6355
+ * @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
5482
6356
  * @returns A teardown that removes the `navigate` listener.
5483
6357
  * @example
5484
6358
  * const dispose = attachNavigationApi(navigation, handlers);
5485
6359
  */
5486
- function attachNavigationApi(navigation, handlers) {
6360
+ function attachNavigationApi(navigation, handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
5487
6361
  /**
5488
6362
  * Handle a `navigate` event: classify, then intercept with fetch-and-swap.
5489
6363
  *
@@ -5508,7 +6382,7 @@ function attachNavigationApi(navigation, handlers) {
5508
6382
  navEvent.intercept({
5509
6383
  scroll: "manual",
5510
6384
  handler: async () => {
5511
- await performNavigation(url.pathname, handlers);
6385
+ await navigate(url.pathname);
5512
6386
  if (navEvent.navigationType === "traverse") navEvent.scroll();
5513
6387
  else window.scrollTo(0, 0);
5514
6388
  }
@@ -5522,13 +6396,14 @@ function attachNavigationApi(navigation, handlers) {
5522
6396
  * fallback. Returns a teardown removing every listener it attached.
5523
6397
  *
5524
6398
  * @param handlers - The navigation lifecycle callbacks the kernel supplies.
6399
+ * @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
5525
6400
  * @returns A teardown removing all attached listeners.
5526
6401
  * @example
5527
- * const dispose = attachRouter(handlers);
6402
+ * const dispose = attachRouter(handlers, navigate);
5528
6403
  */
5529
- function attachRouter(handlers) {
6404
+ function attachRouter(handlers, navigate) {
5530
6405
  const navigation = getNavigation();
5531
- return navigation ? attachNavigationApi(navigation, handlers) : attachHistoryFallback(handlers);
6406
+ return navigation ? attachNavigationApi(navigation, handlers, navigate) : attachHistoryFallback(handlers, navigate);
5532
6407
  }
5533
6408
  //#endregion
5534
6409
  //#region src/plugins/spa/state.ts
@@ -5647,6 +6522,20 @@ function currentLocationUrl() {
5647
6522
  return location.pathname + location.search;
5648
6523
  }
5649
6524
  /**
6525
+ * Apply the matched route's `head` config to the live document (minimal client
6526
+ * head-sync for the DATA path: title only — the full meta sync runs on the
6527
+ * HTML-over-fetch path from the fetched `<head>`).
6528
+ *
6529
+ * @param route - The matched route definition.
6530
+ * @param routeContext - The render context (params/data/locale).
6531
+ * @example
6532
+ * syncDataHead(hit.route, { params, data, locale });
6533
+ */
6534
+ function syncDataHead(route, routeContext) {
6535
+ const title = route._handlers.head?.(routeContext)?.title;
6536
+ if (title !== void 0 && title !== "") document.title = title;
6537
+ }
6538
+ /**
5650
6539
  * Builds the single shared SPA kernel — a pure factory over state/config/emit.
5651
6540
  * Unit-testable with a mock state object and a spy emit; no Moku ctx involved.
5652
6541
  *
@@ -5710,6 +6599,71 @@ function createSpaKernel(state, config, emit, deps) {
5710
6599
  onEnd: handleEnd,
5711
6600
  onError: handleError
5712
6601
  };
6602
+ /**
6603
+ * The client DATA path: match `pathname`, fetch the page's PERSISTED data via the
6604
+ * `data` reader, VALIDATE it through the route's `parse` gate, then run the
6605
+ * route's OWN `render` (the same component the build used for SSG) and
6606
+ * Preact-render the VNode into the swap region. Returns `false` (touching nothing
6607
+ * the fallback cares about) on no-match / no-render / no-data / fetch-miss /
6608
+ * parse-throw, so the caller falls back to HTML-over-fetch. `route.load` does NOT
6609
+ * run on the client — the build already persisted its output.
6610
+ *
6611
+ * @param pathname - The destination pathname (search stripped for matching).
6612
+ * @returns `true` if the route was rendered from validated data, else `false`.
6613
+ * @example
6614
+ * if (await tryDataRender("/en/world/")) return;
6615
+ */
6616
+ const tryDataRender = async (pathname) => {
6617
+ if (!deps.dataAt) return false;
6618
+ const matchPath = pathname.split("?")[0] ?? pathname;
6619
+ const hit = deps.router.match(matchPath);
6620
+ if (!hit?.route._handlers.render) return false;
6621
+ try {
6622
+ const raw = await deps.dataAt(pathname);
6623
+ if (raw === null) return false;
6624
+ const data = hit.route._handlers.parse ? hit.route._handlers.parse(raw) : raw;
6625
+ const locale = hit.params.lang ?? document.documentElement.lang ?? "";
6626
+ const routeContext = {
6627
+ params: hit.params,
6628
+ data,
6629
+ locale
6630
+ };
6631
+ const vnode = hit.route._handlers.render(routeContext);
6632
+ const region = document.querySelector(resolved.swapSelector);
6633
+ if (!region) return false;
6634
+ handleStart(pathname);
6635
+ const { renderVNode } = await Promise.resolve().then(() => require("./render-BSTM0Akv.cjs"));
6636
+ syncDataHead(hit.route, routeContext);
6637
+ unmountPageSpecific(state, emit);
6638
+ runSwap(() => {
6639
+ region.replaceChildren();
6640
+ renderVNode(vnode, region);
6641
+ scanAndMount(state, emit, resolved.swapSelector);
6642
+ notifyNavEnd(state);
6643
+ }, resolved.viewTransitions);
6644
+ state.currentUrl = pathname;
6645
+ progress?.done();
6646
+ emit("spa:navigated", { url: pathname });
6647
+ return true;
6648
+ } catch {
6649
+ return false;
6650
+ }
6651
+ };
6652
+ /**
6653
+ * Unified navigation: try the client DATA path first (only when the `data`
6654
+ * plugin is composed), then fall back to HTML-over-fetch (which itself falls
6655
+ * back to a full `location.href` reload). Injected into the router so every
6656
+ * navigation entry point (Navigation API, History, programmatic) goes through it.
6657
+ *
6658
+ * @param pathname - The destination pathname.
6659
+ * @returns A promise resolving once the swap (or fallback) is dispatched.
6660
+ * @example
6661
+ * await navigate("/en/world/");
6662
+ */
6663
+ const navigate = async (pathname) => {
6664
+ if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
6665
+ await performNavigation(pathname, handlers);
6666
+ };
5713
6667
  return {
5714
6668
  /**
5715
6669
  * Register config components and seed currentUrl from the document.
@@ -5732,7 +6686,7 @@ function createSpaKernel(state, config, emit, deps) {
5732
6686
  if (state.started) throw new Error(`${ERROR_PREFIX} spa kernel already started.\n Call app.stop() before booting again (single boot per app).`);
5733
6687
  progress = createProgressBar(resolved.progressBar);
5734
6688
  state.currentUrl = currentLocationUrl();
5735
- state.destroyRouter = attachRouter(handlers);
6689
+ state.destroyRouter = attachRouter(handlers, navigate);
5736
6690
  scanAndMount(state, emit, resolved.swapSelector);
5737
6691
  state.started = true;
5738
6692
  },
@@ -5755,7 +6709,7 @@ function createSpaKernel(state, config, emit, deps) {
5755
6709
  */
5756
6710
  processNav(path) {
5757
6711
  if (typeof document === "undefined") return;
5758
- performNavigation(path, handlers).catch(() => {});
6712
+ navigate(path).catch(() => {});
5759
6713
  },
5760
6714
  /**
5761
6715
  * Scan the swap region and mount components for matching elements.
@@ -5782,20 +6736,41 @@ function createSpaKernel(state, config, emit, deps) {
5782
6736
  };
5783
6737
  }
5784
6738
  /**
6739
+ * Structural by-name handle for the OPTIONAL `data` plugin. `ctx.require` resolves
6740
+ * a plugin by its `name` at runtime, so this lets `spa` obtain the `data` reader
6741
+ * WITHOUT importing the `data` plugin or its types — keeping `spa` decoupled and
6742
+ * its `depends` at `[router, head]`. The phantom types only the `at` slice it uses.
6743
+ */
6744
+ const dataPluginHandle = {
6745
+ name: "data",
6746
+ spec: void 0,
6747
+ _phantom: {
6748
+ config: void 0,
6749
+ state: void 0,
6750
+ api: void 0,
6751
+ events: {}
6752
+ }
6753
+ };
6754
+ /**
5785
6755
  * Builds the shared kernel from the plugin context, stores it on `ctx.state`
5786
6756
  * and `kernelRef`, and runs its init step (validate config, register
5787
- * config.components, seed currentUrl). Extracted from index.ts onInit to keep
5788
- * wiring under budget.
6757
+ * config.components, seed currentUrl). Captures the OPTIONAL `data` reader when
6758
+ * the `data` plugin is composed (enabling client DATA navigation).
5789
6759
  *
5790
- * @param ctx - The plugin context (state/config/emit/require/log).
6760
+ * @param ctx - The plugin context (state/config/emit/require/has/log).
5791
6761
  * @example
5792
6762
  * initSpa(ctx);
5793
6763
  */
5794
6764
  function initSpa(ctx) {
5795
- const kernel = createSpaKernel(ctx.state, ctx.config, ctx.emit, {
6765
+ const deps = {
5796
6766
  router: ctx.require(routerPlugin),
5797
6767
  head: ctx.require(headPlugin)
5798
- });
6768
+ };
6769
+ if (ctx.has("data")) {
6770
+ const reader = ctx.require(dataPluginHandle);
6771
+ deps.dataAt = (path) => reader.at(path);
6772
+ }
6773
+ const kernel = createSpaKernel(ctx.state, ctx.config, ctx.emit, deps);
5799
6774
  ctx.state.kernel = kernel;
5800
6775
  kernelRef.current = kernel;
5801
6776
  kernel.init();
@@ -5857,6 +6832,26 @@ function disposeSpa() {
5857
6832
  * Emits: spa:navigate, spa:navigated, spa:component-mount, spa:component-unmount.
5858
6833
  * @see README.md
5859
6834
  */
6835
+ /**
6836
+ * SPA plugin — progressive client-side navigation layered over the static site:
6837
+ * swaps a page region on navigation, with an optional progress bar and View
6838
+ * Transitions. Register interactive islands with {@link createComponent}. Depends
6839
+ * on router and head; emits `spa:navigate`, `spa:navigated`, `spa:component-mount`,
6840
+ * and `spa:component-unmount`.
6841
+ *
6842
+ * @example Enable view transitions and a custom swap region
6843
+ * ```ts
6844
+ * const app = createApp({
6845
+ * pluginConfigs: {
6846
+ * spa: {
6847
+ * swapSelector: "main > section",
6848
+ * viewTransitions: true,
6849
+ * progressBar: true
6850
+ * }
6851
+ * }
6852
+ * });
6853
+ * ```
6854
+ */
5860
6855
  const spaPlugin = createPlugin$1("spa", {
5861
6856
  depends: [routerPlugin, headPlugin],
5862
6857
  config: defaultSpaConfig,
@@ -5872,38 +6867,240 @@ const spaPlugin = createPlugin$1("spa", {
5872
6867
  });
5873
6868
  //#endregion
5874
6869
  //#region src/plugins/build/types.ts
5875
- var types_exports = /* @__PURE__ */ __exportAll({});
6870
+ var types_exports = /* @__PURE__ */ require_convention.__exportAll({});
5876
6871
  //#endregion
5877
6872
  //#region src/plugins/content/types.ts
5878
- var types_exports$1 = /* @__PURE__ */ __exportAll({});
6873
+ var types_exports$1 = /* @__PURE__ */ require_convention.__exportAll({});
6874
+ //#endregion
6875
+ //#region src/plugins/data/types.ts
6876
+ var types_exports$2 = /* @__PURE__ */ require_convention.__exportAll({});
5879
6877
  //#endregion
5880
6878
  //#region src/plugins/deploy/types.ts
5881
- var types_exports$2 = /* @__PURE__ */ __exportAll({});
6879
+ var types_exports$3 = /* @__PURE__ */ require_convention.__exportAll({});
5882
6880
  //#endregion
5883
6881
  //#region src/plugins/env/types.ts
5884
- var types_exports$3 = /* @__PURE__ */ __exportAll({});
6882
+ var types_exports$4 = /* @__PURE__ */ require_convention.__exportAll({});
5885
6883
  //#endregion
5886
6884
  //#region src/plugins/head/types.ts
5887
- var types_exports$4 = /* @__PURE__ */ __exportAll({});
6885
+ var types_exports$5 = /* @__PURE__ */ require_convention.__exportAll({});
5888
6886
  //#endregion
5889
6887
  //#region src/plugins/log/types.ts
5890
- var types_exports$5 = /* @__PURE__ */ __exportAll({});
6888
+ var types_exports$6 = /* @__PURE__ */ require_convention.__exportAll({});
5891
6889
  //#endregion
5892
6890
  //#region src/plugins/router/types.ts
5893
- var types_exports$6 = /* @__PURE__ */ __exportAll({});
5894
- const { createApp, createPlugin } = createCore(coreConfig, {
6891
+ var types_exports$7 = /* @__PURE__ */ require_convention.__exportAll({});
6892
+ //#endregion
6893
+ //#region src/plugins/env/providers.ts
6894
+ /**
6895
+ * @file env plugin — built-in providers: dotenv, processEnv, cloudflareBindings.
6896
+ */
6897
+ /** Default dotenv file path: optional local overrides. */
6898
+ const DEFAULT_DOTENV_PATH = ".env.local";
6899
+ /** Property on `globalThis` that the consumer sets per Cloudflare request. */
6900
+ const CLOUDFLARE_GLOBAL = "__CLOUDFLARE_ENV__";
6901
+ /**
6902
+ * Strips a single matching pair of surrounding double or single quotes from a
6903
+ * value. Leaves unquoted values (and trailing inline comments) untouched.
6904
+ *
6905
+ * @param value - The already-trimmed raw value.
6906
+ * @returns The value with one outer quote pair removed, if present.
6907
+ * @example
6908
+ * ```ts
6909
+ * stripQuotes('"a"'); // "a"
6910
+ * stripQuotes("plain # c"); // "plain # c"
6911
+ * ```
6912
+ */
6913
+ function stripQuotes(value) {
6914
+ if (value.length >= 2) {
6915
+ const first = value[0];
6916
+ const last = value.at(-1);
6917
+ if ((first === "\"" || first === "'") && first === last) return value.slice(1, -1);
6918
+ }
6919
+ return value;
6920
+ }
6921
+ /**
6922
+ * Parses `.env`-style text into a flat record. Handles CRLF/LF, blank lines,
6923
+ * full-line `#` comments, first-`=` splitting, key/value trimming, and a single
6924
+ * outer quote pair. Does not strip trailing inline comments on unquoted values.
6925
+ *
6926
+ * @param text - The raw file contents.
6927
+ * @returns A flat record of parsed key/value pairs.
6928
+ * @example
6929
+ * ```ts
6930
+ * parseDotenv('A=1\nB="two"'); // { A: "1", B: "two" }
6931
+ * ```
6932
+ */
6933
+ function parseDotenv(text) {
6934
+ const out = {};
6935
+ for (const line of text.split(/\r?\n/)) {
6936
+ const trimmed = line.trim();
6937
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
6938
+ const eq = trimmed.indexOf("=");
6939
+ if (eq === -1) continue;
6940
+ const key = trimmed.slice(0, eq).trim();
6941
+ out[key] = stripQuotes(trimmed.slice(eq + 1).trim());
6942
+ }
6943
+ return out;
6944
+ }
6945
+ /**
6946
+ * A zero-dependency `.env`-style provider that re-reads and re-parses the file
6947
+ * from disk on every `load()`. Missing file resolves to `{}` (optional
6948
+ * overrides). Strips a single outer quote pair; does not strip trailing inline
6949
+ * comments on unquoted values.
6950
+ *
6951
+ * @param path - Path to the dotenv file. Defaults to `.env.local`.
6952
+ * @returns An {@link EnvProvider} named `dotenv:<path>` that reads fresh per call.
6953
+ * @example
6954
+ * ```ts
6955
+ * const provider = dotenv(".env.local");
6956
+ * provider.load(); // { PUBLIC_API_URL: "/api", ... }
6957
+ * ```
6958
+ */
6959
+ function dotenv(path = DEFAULT_DOTENV_PATH) {
6960
+ return {
6961
+ name: `dotenv:${path}`,
6962
+ /**
6963
+ * Reads and parses the dotenv file fresh from disk; `{}` if it is missing.
6964
+ *
6965
+ * @returns The parsed environment record, or `{}` when the file is absent.
6966
+ * @example
6967
+ * ```ts
6968
+ * dotenv(".env.local").load();
6969
+ * ```
6970
+ */
6971
+ load() {
6972
+ if (!(0, node_fs.existsSync)(path)) return {};
6973
+ return parseDotenv((0, node_fs.readFileSync)(path, "utf8"));
6974
+ }
6975
+ };
6976
+ }
6977
+ /**
6978
+ * A provider that returns a shallow copy of `process.env` at `load()` time.
6979
+ *
6980
+ * @returns An {@link EnvProvider} named `process-env`.
6981
+ * @example
6982
+ * ```ts
6983
+ * const provider = processEnv();
6984
+ * provider.load().HOME; // current process value
6985
+ * ```
6986
+ */
6987
+ function processEnv() {
6988
+ return {
6989
+ name: "process-env",
6990
+ /**
6991
+ * Returns a shallow copy of `process.env` at call time.
6992
+ *
6993
+ * @returns A fresh shallow copy of `process.env`.
6994
+ * @example
6995
+ * ```ts
6996
+ * processEnv().load();
6997
+ * ```
6998
+ */
6999
+ load() {
7000
+ return { ...process.env };
7001
+ }
7002
+ };
7003
+ }
7004
+ /**
7005
+ * A provider that reads live, per-request Cloudflare bindings from
7006
+ * `globalThis.__CLOUDFLARE_ENV__` at `load()` time (`?? {}` when absent). Never
7007
+ * caches the binding object; the consumer owns the global's request lifecycle.
7008
+ *
7009
+ * @returns An {@link EnvProvider} named `cloudflare`.
7010
+ * @example
7011
+ * ```ts
7012
+ * globalThis.__CLOUDFLARE_ENV__ = env; // set by the request handler
7013
+ * const provider = cloudflareBindings();
7014
+ * provider.load(); // reads the current request's bindings
7015
+ * ```
7016
+ */
7017
+ function cloudflareBindings() {
7018
+ return {
7019
+ name: "cloudflare",
7020
+ /**
7021
+ * Reads `globalThis.__CLOUDFLARE_ENV__` fresh, never caching the bindings.
7022
+ *
7023
+ * @returns The current Cloudflare bindings, or `{}` when the global is unset.
7024
+ * @example
7025
+ * ```ts
7026
+ * cloudflareBindings().load();
7027
+ * ```
7028
+ */
7029
+ load() {
7030
+ return globalThis[CLOUDFLARE_GLOBAL] ?? {};
7031
+ }
7032
+ };
7033
+ }
7034
+ //#endregion
7035
+ //#region src/index.ts
7036
+ /**
7037
+ * @file `@moku-labs/web` — a Moku Layer-2 content static-site + SPA framework.
7038
+ *
7039
+ * `createApp`'s defaults are the **isomorphic** plugins that run unchanged on both
7040
+ * Node and the browser (`site`, `i18n`, `router`, `head`, `spa`, plus the
7041
+ * `log`/`env` core). The Node-only plugins (`content`, `build`, `deploy`,
7042
+ * `data`) are exported for Layer-3 composition: add them with
7043
+ * `createApp({ plugins: [...] })` in a Node build; omit them in a browser app.
7044
+ * The framework never hard-blocks either runtime — the consumer composes the
7045
+ * variant it needs and supplies the matching `env` provider.
7046
+ * @see README.md
7047
+ */
7048
+ const framework = createCore(coreConfig, {
5895
7049
  plugins: [
5896
7050
  sitePlugin,
5897
7051
  i18nPlugin,
5898
7052
  routerPlugin,
5899
- contentPlugin,
5900
7053
  headPlugin,
5901
- buildPlugin,
5902
- spaPlugin,
5903
- deployPlugin
7054
+ spaPlugin
5904
7055
  ],
5905
7056
  pluginConfigs: {}
5906
7057
  });
7058
+ /**
7059
+ * Create and initialize a `@moku-labs/web` application — the Layer-3 entry point.
7060
+ * Your overrides are merged over the framework defaults through the 4-level config
7061
+ * cascade, every plugin's lifecycle runs, and a fully-typed, frozen app is returned.
7062
+ *
7063
+ * The defaults are the isomorphic plugin set (`site`, `i18n`, `router`, `head`,
7064
+ * `spa` + `log`/`env` core). Add the Node-only plugins for an SSG build:
7065
+ * `createApp({ plugins: [contentPlugin, buildPlugin, deployPlugin] })`.
7066
+ *
7067
+ * @param options - Optional configuration:
7068
+ * - `pluginConfigs` — per-plugin overrides, keyed by plugin name.
7069
+ * - `config` — global framework config (e.g. `{ mode: "development" }`).
7070
+ * - `plugins` — extra plugins (Node-only built-ins or your own) merged into the app and its type.
7071
+ * - `onReady` / `onError` / `onStart` / `onStop` — lifecycle callbacks.
7072
+ * @returns The initialized app: `start()`, `stop()`, every plugin's API, and `log`.
7073
+ * @example
7074
+ * ```ts
7075
+ * // Node SSG build — add the node-only plugins:
7076
+ * const app = createApp({
7077
+ * plugins: [contentPlugin, buildPlugin, deployPlugin],
7078
+ * pluginConfigs: {
7079
+ * site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes" },
7080
+ * router: { routes: defineRoutes({ home: route("/"), post: route("/blog/{slug}/") }) }
7081
+ * }
7082
+ * });
7083
+ * await app.start();
7084
+ * await app.build.run();
7085
+ * ```
7086
+ */
7087
+ const createApp = framework.createApp;
7088
+ /**
7089
+ * Create a custom plugin bound to this framework's `Config`/`Events` and core
7090
+ * APIs. Plugin types are inferred from the spec object — never written explicitly.
7091
+ * Pass the result to {@link createApp} via `plugins`.
7092
+ *
7093
+ * @example
7094
+ * ```ts
7095
+ * const analytics = createPlugin("analytics", {
7096
+ * config: { writeKey: "" },
7097
+ * api: (ctx) => ({ track: (event: string) => ctx.log.info("analytics:track", { event }) })
7098
+ * });
7099
+ *
7100
+ * const app = createApp({ plugins: [analytics] });
7101
+ * ```
7102
+ */
7103
+ const createPlugin = framework.createPlugin;
5907
7104
  //#endregion
5908
7105
  Object.defineProperty(exports, "Build", {
5909
7106
  enumerable: true,
@@ -5917,50 +7114,60 @@ Object.defineProperty(exports, "Content", {
5917
7114
  return types_exports$1;
5918
7115
  }
5919
7116
  });
5920
- Object.defineProperty(exports, "Deploy", {
7117
+ Object.defineProperty(exports, "Data", {
5921
7118
  enumerable: true,
5922
7119
  get: function() {
5923
7120
  return types_exports$2;
5924
7121
  }
5925
7122
  });
5926
- Object.defineProperty(exports, "Env", {
7123
+ Object.defineProperty(exports, "Deploy", {
5927
7124
  enumerable: true,
5928
7125
  get: function() {
5929
7126
  return types_exports$3;
5930
7127
  }
5931
7128
  });
5932
- Object.defineProperty(exports, "Head", {
7129
+ Object.defineProperty(exports, "Env", {
5933
7130
  enumerable: true,
5934
7131
  get: function() {
5935
7132
  return types_exports$4;
5936
7133
  }
5937
7134
  });
5938
- Object.defineProperty(exports, "Log", {
7135
+ Object.defineProperty(exports, "Head", {
5939
7136
  enumerable: true,
5940
7137
  get: function() {
5941
7138
  return types_exports$5;
5942
7139
  }
5943
7140
  });
5944
- Object.defineProperty(exports, "Router", {
7141
+ Object.defineProperty(exports, "Log", {
5945
7142
  enumerable: true,
5946
7143
  get: function() {
5947
7144
  return types_exports$6;
5948
7145
  }
5949
7146
  });
5950
- Object.defineProperty(exports, "Spa", {
7147
+ Object.defineProperty(exports, "Router", {
5951
7148
  enumerable: true,
5952
7149
  get: function() {
5953
7150
  return types_exports$7;
5954
7151
  }
5955
7152
  });
7153
+ Object.defineProperty(exports, "Spa", {
7154
+ enumerable: true,
7155
+ get: function() {
7156
+ return types_exports$8;
7157
+ }
7158
+ });
7159
+ exports.browserEnv = browserEnv;
5956
7160
  exports.buildArticleHead = buildArticleHead;
5957
7161
  exports.buildPlugin = buildPlugin;
5958
7162
  exports.canonical = canonical;
7163
+ exports.cloudflareBindings = cloudflareBindings;
5959
7164
  exports.contentPlugin = contentPlugin;
5960
7165
  exports.createApp = createApp;
5961
7166
  exports.createPlugin = createPlugin;
7167
+ exports.dataPlugin = dataPlugin;
5962
7168
  exports.defineRoutes = defineRoutes;
5963
7169
  exports.deployPlugin = deployPlugin;
7170
+ exports.dotenv = dotenv;
5964
7171
  exports.envPlugin = envPlugin;
5965
7172
  exports.feedLink = feedLink;
5966
7173
  exports.headPlugin = headPlugin;
@@ -5970,6 +7177,7 @@ exports.jsonLd = jsonLd;
5970
7177
  exports.logPlugin = logPlugin;
5971
7178
  exports.meta = meta;
5972
7179
  exports.og = og;
7180
+ exports.processEnv = processEnv;
5973
7181
  exports.route = route;
5974
7182
  exports.routerPlugin = routerPlugin;
5975
7183
  exports.sitePlugin = sitePlugin;