@moku-labs/web 0.5.6 → 1.0.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/browser.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./chunk-D7D4PA-g.mjs";
2
- import { t as dataSuffix } from "./convention-X3zLTlJ8.mjs";
2
+ import { n as relativeDataFile, t as dataSuffix } from "./convention-CepUwWmT.mjs";
3
3
  import { createCoreConfig, createCorePlugin } from "@moku-labs/core";
4
4
  //#region src/plugins/env/api.ts
5
5
  /** Error prefix for all env API failures. */
@@ -115,6 +115,12 @@ function createEnvState() {
115
115
  const FROZEN_MESSAGE = "env: map is frozen and cannot be mutated";
116
116
  /** Error prefix for all resolution-pipeline failures. */
117
117
  const ERROR_PREFIX$9 = "[web]";
118
+ /** The `Map` mutators redefined as throwers when a map is frozen. */
119
+ const FROZEN_METHODS = [
120
+ "set",
121
+ "clear",
122
+ "delete"
123
+ ];
118
124
  /**
119
125
  * Throws the canonical frozen-map error; installed as a map's `set`/`clear`/`delete`.
120
126
  *
@@ -128,6 +134,21 @@ function frozenThrower() {
128
134
  throw new TypeError(FROZEN_MESSAGE);
129
135
  }
130
136
  /**
137
+ * Coerces a raw provider value to its effective presence: an empty string counts
138
+ * as "absent" so a `KEY=""` falls through to later providers.
139
+ *
140
+ * @param raw - The raw value a provider supplied for a key (possibly `undefined`).
141
+ * @returns The value, or `undefined` when it is missing or an empty string.
142
+ * @example
143
+ * ```ts
144
+ * coerceEmpty(""); // => undefined
145
+ * coerceEmpty("3000"); // => "3000"
146
+ * ```
147
+ */
148
+ function coerceEmpty(raw) {
149
+ return raw === "" ? void 0 : raw;
150
+ }
151
+ /**
131
152
  * Merges providers in array order, coercing empty strings to `undefined` before
132
153
  * precedence so a `KEY=""` falls through to later providers. First non-empty
133
154
  * value wins.
@@ -139,14 +160,11 @@ function frozenThrower() {
139
160
  * mergeProviders({ providers: [a, b], schema: {}, publicPrefix: "PUBLIC_" });
140
161
  * ```
141
162
  */
142
- function mergeProviders(config) {
163
+ function mergeProviders$1(config) {
143
164
  const merged = {};
144
- for (const provider of config.providers) {
145
- const values = provider.load();
146
- for (const [key, raw] of Object.entries(values)) {
147
- const value = raw === "" ? void 0 : raw;
148
- if (value !== void 0 && merged[key] === void 0) merged[key] = value;
149
- }
165
+ for (const provider of config.providers) for (const [key, raw] of Object.entries(provider.load())) {
166
+ const value = coerceEmpty(raw);
167
+ if (value !== void 0 && merged[key] === void 0) merged[key] = value;
150
168
  }
151
169
  return merged;
152
170
  }
@@ -181,11 +199,7 @@ function crossCheckPublicPrefix(config) {
181
199
  * ```
182
200
  */
183
201
  function freezeMap(map) {
184
- for (const method of [
185
- "set",
186
- "clear",
187
- "delete"
188
- ]) Object.defineProperty(map, method, {
202
+ for (const method of FROZEN_METHODS) Object.defineProperty(map, method, {
189
203
  value: frozenThrower,
190
204
  writable: false,
191
205
  configurable: false,
@@ -194,6 +208,41 @@ function freezeMap(map) {
194
208
  Object.freeze(map);
195
209
  }
196
210
  /**
211
+ * Populates `state.publicMap` with the schema-driven public subset: every
212
+ * `public:true` schema key that resolved to a defined value. This map is the only
213
+ * sanctioned input to a browser-facing `define`, so it stays schema-scoped (never
214
+ * includes non-schema provider keys).
215
+ *
216
+ * @param schema - The per-variable schema from {@link EnvConfig}.
217
+ * @param merged - The merged provider values keyed by variable name.
218
+ * @param publicMap - The mutable public map to fill in place.
219
+ * @example
220
+ * ```ts
221
+ * populatePublicMap(config.schema, merged, state.publicMap);
222
+ * ```
223
+ */
224
+ function populatePublicMap(schema, merged, publicMap) {
225
+ for (const [key, spec] of Object.entries(schema)) {
226
+ const value = merged[key];
227
+ if (spec.public === true && value !== void 0) publicMap.set(key, value);
228
+ }
229
+ }
230
+ /**
231
+ * Populates `state.resolved` with EVERY merged key that carries a defined value
232
+ * (spec/02 Lifecycle §5), including non-schema provider keys so
233
+ * `ctx.env.require()` works for dynamic keys.
234
+ *
235
+ * @param merged - The merged provider values keyed by variable name.
236
+ * @param resolved - The mutable resolved map to fill in place.
237
+ * @example
238
+ * ```ts
239
+ * populateResolved(merged, state.resolved);
240
+ * ```
241
+ */
242
+ function populateResolved(merged, resolved) {
243
+ for (const [key, value] of Object.entries(merged)) resolved.set(key, value);
244
+ }
245
+ /**
197
246
  * Resolves, validates, and freezes the environment table at `onInit`.
198
247
  *
199
248
  * Pipeline order: merge providers (with empty-string → undefined coercion) →
@@ -213,17 +262,14 @@ function freezeMap(map) {
213
262
  function validateSchema(ctx) {
214
263
  const { config, state } = ctx;
215
264
  const { schema } = config;
216
- const merged = mergeProviders(config);
265
+ const merged = mergeProviders$1(config);
217
266
  crossCheckPublicPrefix(config);
218
267
  for (const [key, spec] of Object.entries(schema)) {
219
268
  if (merged[key] === void 0 && spec.default !== void 0) merged[key] = spec.default;
220
269
  if (merged[key] === void 0 && spec.required === true) throw new Error(`${ERROR_PREFIX$9} env: required variable "${key}" is not defined by any provider or default.`);
221
270
  }
222
- for (const [key, spec] of Object.entries(schema)) {
223
- const value = merged[key];
224
- if (spec.public === true && value !== void 0) state.publicMap.set(key, value);
225
- }
226
- for (const [key, value] of Object.entries(merged)) state.resolved.set(key, value);
271
+ populatePublicMap(schema, merged, state.publicMap);
272
+ populateResolved(merged, state.resolved);
227
273
  freezeMap(state.resolved);
228
274
  freezeMap(state.publicMap);
229
275
  }
@@ -325,7 +371,7 @@ var LogExpectAssertionError = class extends Error {
325
371
  * isPlainObject([1]); // false
326
372
  * ```
327
373
  */
328
- function isPlainObject(value) {
374
+ function isPlainObject$1(value) {
329
375
  return typeof value === "object" && value !== null && !Array.isArray(value);
330
376
  }
331
377
  /**
@@ -351,8 +397,8 @@ function matchesPartial(actual, partial) {
351
397
  if (!Array.isArray(actual) || actual.length !== partial.length) return false;
352
398
  return partial.every((value, index) => matchesPartial(actual[index], value));
353
399
  }
354
- if (isPlainObject(partial)) {
355
- if (!isPlainObject(actual)) return false;
400
+ if (isPlainObject$1(partial)) {
401
+ if (!isPlainObject$1(actual)) return false;
356
402
  return Object.keys(partial).every((key) => key in actual && matchesPartial(actual[key], partial[key]));
357
403
  }
358
404
  return false;
@@ -387,6 +433,22 @@ function describePartial(partial) {
387
433
  return partial === void 0 ? "" : ` matching ${JSON.stringify(partial)}`;
388
434
  }
389
435
  /**
436
+ * Find the first entry with `event` at or after `startIndex`, scanning forward.
437
+ *
438
+ * @param entries - The trace array to scan.
439
+ * @param event - Event name to find.
440
+ * @param startIndex - Index to begin scanning from (inclusive).
441
+ * @returns The index of the first match, or `-1` when none exists from `startIndex` on.
442
+ * @example
443
+ * ```ts
444
+ * findEventAtOrAfter([{ event: "a" }, { event: "b" }] as LogEntry[], "b", 0); // 1
445
+ * ```
446
+ */
447
+ function findEventAtOrAfter(entries, event, startIndex) {
448
+ for (let index = startIndex; index < entries.length; index++) if (entries[index]?.event === event) return index;
449
+ return -1;
450
+ }
451
+ /**
390
452
  * Create a fluent assertion chain bound to the live `entries` array. Each method
391
453
  * reads `entries` at call time, so assertions reflect later logging.
392
454
  *
@@ -429,13 +491,9 @@ function createExpectChain(entries) {
429
491
  toHaveEventInOrder(events) {
430
492
  let cursor = 0;
431
493
  for (const [position, event] of events.entries()) {
432
- let nextIndex = -1;
433
- for (let index = cursor; index < entries.length; index++) if (entries[index]?.event === event) {
434
- nextIndex = index;
435
- break;
436
- }
437
- if (nextIndex === -1) throw new LogExpectAssertionError(`Expected events in order ${JSON.stringify(events)}, but "${event}" (index ${position}) was not found at or after position ${cursor}.`);
438
- cursor = nextIndex + 1;
494
+ const matchIndex = findEventAtOrAfter(entries, event, cursor);
495
+ if (matchIndex === -1) throw new LogExpectAssertionError(`Expected events in order ${JSON.stringify(events)}, but "${event}" (index ${position}) was not found at or after position ${cursor}.`);
496
+ cursor = matchIndex + 1;
439
497
  }
440
498
  return chain;
441
499
  },
@@ -491,13 +549,28 @@ function append(state, level, event, data) {
491
549
  for (const sink of state.sinks) sink.write(entry);
492
550
  }
493
551
  /**
494
- * Merge an `Error`'s `message`/`stack` into `data` under an `error` key,
495
- * preserving existing keys. Non-object `data` is coerced to `{}` first so a
496
- * thrown error is never silently dropped.
552
+ * Tests whether a value is a non-null, non-array plain object.
553
+ *
554
+ * @param value - The value to test.
555
+ * @returns `true` when `value` is a non-null object that is not an array.
556
+ * @example
557
+ * ```ts
558
+ * isPlainObject({ a: 1 }); // true
559
+ * isPlainObject([1]); // false
560
+ * ```
561
+ */
562
+ function isPlainObject(value) {
563
+ return typeof value === "object" && value !== null && !Array.isArray(value);
564
+ }
565
+ /**
566
+ * Merge an `Error`'s `message`/`stack` into `data` under an `error` key. The
567
+ * `error` field is always preserved; only a plain object `data` contributes its
568
+ * keys. Non-plain-object `data` (arrays and primitives) is replaced by `{}` —
569
+ * its original value is not retained — so the merge target is always a record.
497
570
  *
498
571
  * @param data - Original payload (any shape).
499
572
  * @param error - The originating error to merge.
500
- * @returns A new object carrying the original keys plus the `error` field.
573
+ * @returns A new object carrying any plain-object keys plus the `error` field.
501
574
  * @example
502
575
  * ```ts
503
576
  * mergeError({ target: "cf" }, new Error("boom"));
@@ -506,7 +579,7 @@ function append(state, level, event, data) {
506
579
  */
507
580
  function mergeError(data, error) {
508
581
  return {
509
- ...typeof data === "object" && data !== null && !Array.isArray(data) ? data : {},
582
+ ...isPlainObject(data) ? data : {},
510
583
  error: {
511
584
  message: error.message,
512
585
  stack: error.stack
@@ -730,8 +803,22 @@ const logPlugin = createCorePlugin("log", {
730
803
  * @file Framework configuration — Config + Events types, core plugin registration.
731
804
  * @see README.md
732
805
  */
806
+ /**
807
+ * Step 1 of the factory chain — captures the framework's `Config`/`Events` contract
808
+ * and registers the core plugins (`log`, `env`) whose APIs are injected onto every
809
+ * regular plugin's context. Consumers never use this directly; it backs the exported
810
+ * {@link createPlugin} and {@link createCore}.
811
+ *
812
+ * @example
813
+ * ```ts
814
+ * const { createPlugin, createCore } = coreConfig;
815
+ * ```
816
+ */
733
817
  const coreConfig = createCoreConfig("web", {
734
- config: { mode: "production" },
818
+ config: {
819
+ stage: "production",
820
+ mode: "hybrid"
821
+ },
735
822
  plugins: [logPlugin, envPlugin],
736
823
  pluginConfigs: { log: { mode: "production" } }
737
824
  });
@@ -917,6 +1004,40 @@ const i18nPlugin = createPlugin$1("i18n", {
917
1004
  //#region src/plugins/site/api.ts
918
1005
  /** Error prefix for all site lifecycle/validation failures. */
919
1006
  const ERROR_PREFIX$7 = "[web]";
1007
+ /** URL protocols that qualify a parsed URL as an absolute http/https URL. */
1008
+ const HTTP_PROTOCOLS = new Set(["http:", "https:"]);
1009
+ /**
1010
+ * Strips every trailing "/" from a value, so it can own the single slash that a
1011
+ * join boundary inserts.
1012
+ *
1013
+ * @param value - The string to trim (e.g. an absolute base URL).
1014
+ * @returns The value with all trailing slashes removed.
1015
+ * @example
1016
+ * ```ts
1017
+ * trimTrailingSlashes("https://blog.dev//"); // "https://blog.dev"
1018
+ * ```
1019
+ */
1020
+ function trimTrailingSlashes(value) {
1021
+ let trimmed = value;
1022
+ while (trimmed.endsWith("/")) trimmed = trimmed.slice(0, -1);
1023
+ return trimmed;
1024
+ }
1025
+ /**
1026
+ * Strips every leading "/" from a value, so the join boundary is the only slash
1027
+ * separating it from the base.
1028
+ *
1029
+ * @param value - The string to trim (e.g. a relative path).
1030
+ * @returns The value with all leading slashes removed.
1031
+ * @example
1032
+ * ```ts
1033
+ * trimLeadingSlashes("//about/"); // "about/"
1034
+ * ```
1035
+ */
1036
+ function trimLeadingSlashes(value) {
1037
+ let trimmed = value;
1038
+ while (trimmed.startsWith("/")) trimmed = trimmed.slice(1);
1039
+ return trimmed;
1040
+ }
920
1041
  /**
921
1042
  * Joins a relative path against an absolute base URL, normalizing the slash
922
1043
  * boundary to exactly one "/". Returns the base unchanged for an empty or
@@ -931,12 +1052,9 @@ const ERROR_PREFIX$7 = "[web]";
931
1052
  * ```
932
1053
  */
933
1054
  function joinCanonical(base, path) {
934
- let trimmedBase = base;
935
- while (trimmedBase.endsWith("/")) trimmedBase = trimmedBase.slice(0, -1);
1055
+ const trimmedBase = trimTrailingSlashes(base);
936
1056
  if (path === "" || path === "/") return trimmedBase;
937
- let trimmedPath = path;
938
- while (trimmedPath.startsWith("/")) trimmedPath = trimmedPath.slice(1);
939
- return `${trimmedBase}/${trimmedPath}`;
1057
+ return `${trimmedBase}/${trimLeadingSlashes(path)}`;
940
1058
  }
941
1059
  /**
942
1060
  * Validates that a string is a non-empty trimmed value.
@@ -964,7 +1082,7 @@ function isNonEmpty(value) {
964
1082
  function isAbsoluteUrl(value) {
965
1083
  try {
966
1084
  const parsed = new URL(value);
967
- return parsed.protocol === "http:" || parsed.protocol === "https:";
1085
+ return HTTP_PROTOCOLS.has(parsed.protocol);
968
1086
  } catch {
969
1087
  return false;
970
1088
  }
@@ -1098,19 +1216,86 @@ const sitePlugin = createPlugin$1("site", {
1098
1216
  api: createSiteApi
1099
1217
  });
1100
1218
  //#endregion
1101
- //#region src/plugins/router/builders/match.ts
1219
+ //#region src/plugins/router/iso-match.ts
1220
+ /**
1221
+ * Parse a single path segment into its `{…}` placeholder, or `false` for a static
1222
+ * segment. Plain loop over the brace delimiters (no backtracking regex). Shared by
1223
+ * the build-time compiler and this isomorphic matcher so the two never diverge.
1224
+ *
1225
+ * @param segment - One `/`-delimited segment, e.g. `{slug}` or `about`.
1226
+ * @returns The parsed placeholder, or `false` when the segment is static.
1227
+ * @example
1228
+ * ```ts
1229
+ * parsePlaceholder("{slug:?}"); // { name: "slug", optional: true }
1230
+ * ```
1231
+ */
1232
+ function parsePlaceholder(segment) {
1233
+ if (!segment.startsWith("{") || !segment.endsWith("}")) return false;
1234
+ const inner = segment.slice(1, -1);
1235
+ if (inner.endsWith(":?")) return {
1236
+ name: inner.slice(0, -2),
1237
+ optional: true
1238
+ };
1239
+ return {
1240
+ name: inner,
1241
+ optional: false
1242
+ };
1243
+ }
1244
+ /**
1245
+ * Counts the dynamic (`{param}` / `{param:?}` / `:param`) segments in a route
1246
+ * pattern — fewer dynamic segments rank as more specific. The optional `{lang:?}`
1247
+ * segment is excluded so locale-prefixing does not affect priority (identical to
1248
+ * the build-time compiler's count, which sourced this logic).
1249
+ *
1250
+ * @param pattern - The route pattern string.
1251
+ * @returns The number of dynamic (non-lang) segments.
1252
+ * @example
1253
+ * ```ts
1254
+ * dynamicSegmentCount("/blog/{slug}/"); // 1
1255
+ * dynamicSegmentCount("/{lang:?}/{slug}/"); // 1
1256
+ * ```
1257
+ */
1258
+ function dynamicSegmentCount(pattern) {
1259
+ let count = 0;
1260
+ for (const segment of pattern.split("/")) {
1261
+ const placeholder = parsePlaceholder(segment);
1262
+ const isBraceDynamic = placeholder && !(placeholder.name === "lang" && placeholder.optional);
1263
+ const isColonDynamic = !placeholder && segment.startsWith(":");
1264
+ if (isBraceDynamic || isColonDynamic) count += 1;
1265
+ }
1266
+ return count;
1267
+ }
1268
+ /**
1269
+ * Comparator that orders two routes most-specific-first (fewest dynamic segments
1270
+ * first). Equal specificity yields `0` so a stable sort preserves declaration
1271
+ * order — the exact ordering the compiled matcher table uses, guaranteeing
1272
+ * build-time and client-time route resolution can never diverge.
1273
+ *
1274
+ * @param a - First route (carries its `pattern` string).
1275
+ * @param a.pattern - First route's pattern string.
1276
+ * @param b - Second route (carries its `pattern` string).
1277
+ * @param b.pattern - Second route's pattern string.
1278
+ * @returns Negative if `a` is more specific, positive if `b` is, `0` on a tie.
1279
+ * @example
1280
+ * ```ts
1281
+ * routes.toSorted(bySpecificity);
1282
+ * ```
1283
+ */
1284
+ function bySpecificity(a, b) {
1285
+ return dynamicSegmentCount(a.pattern) - dynamicSegmentCount(b.pattern);
1286
+ }
1102
1287
  /**
1103
- * Extract named groups from a `URLPattern` match result, stripping numeric/regex
1104
- * group keys so only declared param names remain.
1288
+ * Extract named groups from a `URLPattern` match result, dropping numeric/regex
1289
+ * group keys and `undefined` values so only declared, present params remain.
1105
1290
  *
1106
1291
  * @param groups - The `URLPatternResult.pathname.groups` object.
1107
- * @returns A clean record of named params (numeric keys + undefined values dropped).
1292
+ * @returns A clean record of named params.
1108
1293
  * @example
1109
1294
  * ```ts
1110
- * extractParams({ slug: "hello", "0": "x" }); // { slug: "hello" }
1295
+ * extractGroups({ slug: "hello", "0": "x" }); // { slug: "hello" }
1111
1296
  * ```
1112
1297
  */
1113
- function extractParams(groups) {
1298
+ function extractGroups(groups) {
1114
1299
  const params = {};
1115
1300
  for (const [key, value] of Object.entries(groups)) {
1116
1301
  if (/^\d+$/.test(key)) continue;
@@ -1118,6 +1303,15 @@ function extractParams(groups) {
1118
1303
  }
1119
1304
  return params;
1120
1305
  }
1306
+ //#endregion
1307
+ //#region src/plugins/router/builders/match.ts
1308
+ /**
1309
+ * @file router plugin — runtime matching domain.
1310
+ *
1311
+ * Pure functions that turn compiled patterns into a pathname matcher: build the
1312
+ * lang-aware/bare `URLPattern` pair, the `matchFn` (withLang first, bare fallback
1313
+ * injecting `defaultLocale`), and extract/strip params. No `ctx` here.
1314
+ */
1121
1315
  /**
1122
1316
  * Build a pathname matcher for a single route: tries the `withLang` URLPattern,
1123
1317
  * then the `bare` pattern injecting `defaultLocale` on miss.
@@ -1135,10 +1329,10 @@ function extractParams(groups) {
1135
1329
  function createMatchFunction(matchers, defaultLocale) {
1136
1330
  return (pathname) => {
1137
1331
  const withLang = matchers.withLang.exec({ pathname });
1138
- if (withLang) return extractParams(withLang.pathname.groups);
1332
+ if (withLang) return extractGroups(withLang.pathname.groups);
1139
1333
  const bare = matchers.bare.exec({ pathname });
1140
1334
  if (bare) {
1141
- const params = extractParams(bare.pathname.groups);
1335
+ const params = extractGroups(bare.pathname.groups);
1142
1336
  params.lang = defaultLocale;
1143
1337
  return params;
1144
1338
  }
@@ -1167,294 +1361,84 @@ function matchRoute(compiled, pathname) {
1167
1361
  return null;
1168
1362
  }
1169
1363
  //#endregion
1170
- //#region src/plugins/router/api.ts
1364
+ //#region src/plugins/router/builders/compile.ts
1171
1365
  /**
1172
- * @file router plugin — API factory.
1366
+ * @file router plugin — compilation + validation domain.
1173
1367
  *
1174
- * Closures over `ctx.state.table` exposing `match` / `toUrl` / `entries` /
1175
- * `manifest`. Returns values/copies, never the raw `ctx.state` reference (spec/11 §2.4).
1368
+ * Pure functions invoked from `onInit`: validate the route map, then compile each
1369
+ * route into URLPattern matchers + URL/file builders, count dynamic segments,
1370
+ * sort by specificity, and assemble the immutable `MatcherTable`. Receives DATA
1371
+ * only (`CompileInput`) — never the plugin ctx.
1176
1372
  */
1177
- /** Error prefix for router API failures. */
1373
+ /** Shared `[web]` error prefix for router validation failures. */
1178
1374
  const ERROR_PREFIX$6 = "[web] router";
1375
+ /** Maximum number of optional `{lang:?}` segments a single pattern may declare. */
1376
+ const MAX_LANG_SEGMENTS = 1;
1179
1377
  /**
1180
- * Read the compiled matcher table, throwing if `onInit` has not run yet. This
1181
- * `null` cannot occur in practice post-`onInit`; the guard documents the invariant.
1378
+ * Whether a pattern is rooted every route pattern must be absolute (start
1379
+ * with `/`) so it composes cleanly with the locale prefix and base URL.
1182
1380
  *
1183
- * @param state - The router plugin state holder.
1184
- * @returns The compiled, non-null matcher table.
1185
- * @throws {Error} If the matcher table has not been compiled yet.
1381
+ * @param pattern - The user pattern to check.
1382
+ * @returns `true` when the pattern starts with `/`.
1186
1383
  * @example
1187
1384
  * ```ts
1188
- * const table = readTable(ctx.state);
1385
+ * isPatternRooted("/{slug}/"); // true
1189
1386
  * ```
1190
1387
  */
1191
- function readTable(state) {
1192
- if (state.table === null) throw new Error(`${ERROR_PREFIX$6}: matcher table accessed before onInit compiled it.`);
1193
- return state.table;
1388
+ function isPatternRooted(pattern) {
1389
+ return pattern.startsWith("/");
1194
1390
  }
1195
1391
  /**
1196
- * Project a compiled route into the public `TypedRoute` URL-utility view.
1392
+ * Whether a pattern's `{` and `}` braces are balanced — every placeholder must
1393
+ * be closed so segment parsing cannot drift.
1197
1394
  *
1198
- * @param entry - The compiled route entry.
1199
- * @returns A `TypedRoute` exposing pattern/name/meta + toUrl/toFile/match.
1395
+ * @param pattern - The user pattern to check.
1396
+ * @returns `true` when open and close brace counts are equal.
1200
1397
  * @example
1201
1398
  * ```ts
1202
- * toTypedRoute(compiledEntry).toUrl({ slug: "x" });
1399
+ * hasBalancedBraces("/{slug}/"); // true
1203
1400
  * ```
1204
1401
  */
1205
- function toTypedRoute(entry) {
1206
- return {
1207
- pattern: entry.pattern,
1208
- name: entry.name,
1209
- meta: { ...entry.meta },
1210
- toUrl: entry.toUrl,
1211
- toFile: entry.toFile,
1212
- match: entry.matchFn
1213
- };
1402
+ function hasBalancedBraces(pattern) {
1403
+ return (pattern.match(/\{/g) ?? []).length === (pattern.match(/\}/g) ?? []).length;
1214
1404
  }
1215
1405
  /**
1216
- * Project a compiled route into the serializable {@link ClientRoute} view: only
1217
- * `pattern` / `name` / `meta`, with a fresh `meta` copy and NO `_handlers` closures.
1406
+ * Whether a pattern declares at most one optional `{lang:?}` segment — the
1407
+ * locale prefix is single-slot, so a second occurrence is ambiguous.
1218
1408
  *
1219
- * @param entry - The compiled route entry.
1220
- * @returns A `ClientRoute` carrying only JSON-serializable fields.
1409
+ * @param pattern - The user pattern to check.
1410
+ * @returns `true` when the pattern has zero or one `{lang:?}` segments.
1221
1411
  * @example
1222
1412
  * ```ts
1223
- * toClientRoute(compiledEntry); // { pattern, name, meta }
1413
+ * hasValidLangCount("/{lang:?}/{slug}/"); // true
1224
1414
  * ```
1225
1415
  */
1226
- function toClientRoute(entry) {
1227
- return {
1228
- pattern: entry.pattern,
1229
- name: entry.name,
1230
- meta: { ...entry.meta }
1231
- };
1416
+ function hasValidLangCount(pattern) {
1417
+ return (pattern.match(/\{lang:\?\}/g) ?? []).length <= MAX_LANG_SEGMENTS;
1232
1418
  }
1233
1419
  /**
1234
- * Creates the router plugin API surface. Every closure reads the compiled table
1235
- * from `ctx.state` and returns values/fresh copies never the raw state arrays.
1420
+ * Validate the route map (fail-fast in `onInit`). Throws with the `[web]` prefix
1421
+ * naming the offending route/pattern on any failure: empty map, a pattern not
1422
+ * starting with `/`, unbalanced `{…}` braces, or more than one `{lang:?}` segment.
1236
1423
  *
1237
- * @param ctx - Plugin context.
1238
- * @param ctx.state - The router state holding the compiled matcher table.
1239
- * @returns The {@link RouterApi} surface mounted at `ctx.router`.
1424
+ * @param routes - The route map registered via `pluginConfigs.router.routes`.
1425
+ * @throws {Error} If routes are empty or a pattern is malformed.
1240
1426
  * @example
1241
1427
  * ```ts
1242
- * const api = createApi({ state });
1243
- * api.match("/en/hello/");
1244
- * ```
1245
- */
1246
- function createApi$2(ctx) {
1247
- const { state } = ctx;
1248
- return {
1249
- /**
1250
- * Match a pathname against the compiled route table (specificity-sorted).
1251
- *
1252
- * @param pathname - URL pathname, e.g. `/en/hello/`.
1253
- * @returns `{ params, route }` for the most specific match, or `null`.
1254
- * @example
1255
- * ```ts
1256
- * api.match("/en/hello/");
1257
- * ```
1258
- */
1259
- match(pathname) {
1260
- return matchRoute(readTable(state).compiled, pathname);
1261
- },
1262
- /**
1263
- * Build a URL for a named route from params.
1264
- *
1265
- * @param routeName - Route name key from the route map.
1266
- * @param params - Param values to substitute into the pattern.
1267
- * @returns The resolved URL string (e.g. `/en/hello/`).
1268
- * @throws {Error} If `routeName` is unknown.
1269
- * @example
1270
- * ```ts
1271
- * api.toUrl("article", { lang: "en", slug: "hello" });
1272
- * ```
1273
- */
1274
- toUrl(routeName, params) {
1275
- const entry = readTable(state).byName.get(routeName);
1276
- if (!entry) throw new Error(`${ERROR_PREFIX$6}: unknown route name "${routeName}".`);
1277
- return entry.toUrl(params);
1278
- },
1279
- /**
1280
- * All resolved routes as typed URL utilities, in specificity order.
1281
- *
1282
- * @returns A fresh read-only array of resolved typed routes.
1283
- * @example
1284
- * ```ts
1285
- * for (const r of api.entries()) r.toUrl({ slug: "x" });
1286
- * ```
1287
- */
1288
- entries() {
1289
- return readTable(state).compiled.map((entry) => toTypedRoute(entry));
1290
- },
1291
- /**
1292
- * The typed route set for build-time consumption (declaration order). An API
1293
- * return, NOT a config readback — preserves per-route types despite erasure.
1294
- *
1295
- * @returns A fresh read-only array of the typed route definitions.
1296
- * @example
1297
- * ```ts
1298
- * for (const def of api.manifest()) def._handlers.load?.({}, "en");
1299
- * ```
1300
- */
1301
- manifest() {
1302
- return [...readTable(state).byName.values()].map((entry) => entry.definition);
1303
- },
1304
- /**
1305
- * Serializable, specificity-sorted projection of the route table for client
1306
- * shipping — `{ pattern, name, meta }` entries with NO `_handlers` closures.
1307
- *
1308
- * @returns A fresh, frozen, specificity-sorted read-only array of client routes.
1309
- * @example
1310
- * ```ts
1311
- * const json = JSON.stringify(api.clientManifest());
1312
- * ```
1313
- */
1314
- clientManifest() {
1315
- return Object.freeze(readTable(state).compiled.map((entry) => toClientRoute(entry)));
1316
- },
1317
- /**
1318
- * The resolved render mode (single source of truth for static/hybrid/spa).
1319
- *
1320
- * @returns `"ssg" | "spa" | "hybrid"`.
1321
- * @example
1322
- * ```ts
1323
- * if (api.mode() !== "ssg") emitClientData();
1324
- * ```
1325
- */
1326
- mode() {
1327
- return state.mode;
1328
- }
1329
- };
1330
- }
1331
- //#endregion
1332
- //#region src/plugins/router/iso-match.ts
1333
- /**
1334
- * Parse a single path segment into its `{…}` placeholder, or `false` for a static
1335
- * segment. Plain loop over the brace delimiters (no backtracking regex). Shared by
1336
- * the build-time compiler and this isomorphic matcher so the two never diverge.
1337
- *
1338
- * @param segment - One `/`-delimited segment, e.g. `{slug}` or `about`.
1339
- * @returns The parsed placeholder, or `false` when the segment is static.
1340
- * @example
1341
- * ```ts
1342
- * parsePlaceholder("{slug:?}"); // { name: "slug", optional: true }
1343
- * ```
1344
- */
1345
- function parsePlaceholder$1(segment) {
1346
- if (!segment.startsWith("{") || !segment.endsWith("}")) return false;
1347
- const inner = segment.slice(1, -1);
1348
- if (inner.endsWith(":?")) return {
1349
- name: inner.slice(0, -2),
1350
- optional: true
1351
- };
1352
- return {
1353
- name: inner,
1354
- optional: false
1355
- };
1356
- }
1357
- /**
1358
- * Counts the dynamic (`{param}` / `{param:?}` / `:param`) segments in a route
1359
- * pattern — fewer dynamic segments rank as more specific. The optional `{lang:?}`
1360
- * segment is excluded so locale-prefixing does not affect priority (identical to
1361
- * the build-time compiler's count, which sourced this logic).
1362
- *
1363
- * @param pattern - The route pattern string.
1364
- * @returns The number of dynamic (non-lang) segments.
1365
- * @example
1366
- * ```ts
1367
- * dynamicSegmentCount("/blog/{slug}/"); // 1
1368
- * dynamicSegmentCount("/{lang:?}/{slug}/"); // 1
1369
- * ```
1370
- */
1371
- function dynamicSegmentCount(pattern) {
1372
- let count = 0;
1373
- for (const segment of pattern.split("/")) {
1374
- const placeholder = parsePlaceholder$1(segment);
1375
- const isBraceDynamic = placeholder && !(placeholder.name === "lang" && placeholder.optional);
1376
- const isColonDynamic = !placeholder && segment.startsWith(":");
1377
- if (isBraceDynamic || isColonDynamic) count += 1;
1378
- }
1379
- return count;
1380
- }
1381
- /**
1382
- * Comparator that orders two routes most-specific-first (fewest dynamic segments
1383
- * first). Equal specificity yields `0` so a stable sort preserves declaration
1384
- * order — the exact ordering the compiled matcher table uses, guaranteeing
1385
- * build-time and client-time route resolution can never diverge.
1386
- *
1387
- * @param a - First route (carries its `pattern` string).
1388
- * @param a.pattern - First route's pattern string.
1389
- * @param b - Second route (carries its `pattern` string).
1390
- * @param b.pattern - Second route's pattern string.
1391
- * @returns Negative if `a` is more specific, positive if `b` is, `0` on a tie.
1392
- * @example
1393
- * ```ts
1394
- * routes.toSorted(bySpecificity);
1395
- * ```
1396
- */
1397
- function bySpecificity(a, b) {
1398
- return dynamicSegmentCount(a.pattern) - dynamicSegmentCount(b.pattern);
1399
- }
1400
- //#endregion
1401
- //#region src/plugins/router/builders/compile.ts
1402
- /**
1403
- * @file router plugin — compilation + validation domain.
1404
- *
1405
- * Pure functions invoked from `onInit`: validate the route map, then compile each
1406
- * route into URLPattern matchers + URL/file builders, count dynamic segments,
1407
- * sort by specificity, and assemble the immutable `MatcherTable`. Receives DATA
1408
- * only (`CompileInput`) — never the plugin ctx.
1409
- */
1410
- /** Shared `[web]` error prefix for router validation failures. */
1411
- const ERROR_PREFIX$5 = "[web] router";
1412
- /**
1413
- * Validate the route map (fail-fast in `onInit`). Throws with the `[web]` prefix
1414
- * naming the offending route/pattern on any failure: empty map, a pattern not
1415
- * starting with `/`, unbalanced `{…}` braces, or more than one `{lang:?}` segment.
1416
- *
1417
- * @param routes - The route map from config.
1418
- * @throws {Error} If routes are empty, a pattern is malformed, or names collide.
1419
- * @example
1420
- * ```ts
1421
- * validateRoutes({ home: route("/") });
1428
+ * validateRoutes({ home: route("/") });
1422
1429
  * ```
1423
1430
  */
1424
1431
  function validateRoutes(routes) {
1425
1432
  const names = Object.keys(routes);
1426
- if (names.length === 0) throw new Error(`${ERROR_PREFIX$5}: route map is empty — provide at least one route via pluginConfigs.router.routes.`);
1433
+ if (names.length === 0) throw new Error(`${ERROR_PREFIX$6}: route map is empty.\n Register at least one route via pluginConfigs.router.routes.`);
1427
1434
  for (const name of names) {
1428
1435
  const pattern = routes[name]?.pattern ?? "";
1429
- if (!pattern.startsWith("/")) throw new Error(`${ERROR_PREFIX$5}: route "${name}" pattern must start with "/" (got "${pattern}").`);
1430
- if ((pattern.match(/\{/g) ?? []).length !== (pattern.match(/\}/g) ?? []).length) throw new Error(`${ERROR_PREFIX$5}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
1431
- if ((pattern.match(/\{lang:\?\}/g) ?? []).length > 1) throw new Error(`${ERROR_PREFIX$5}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
1436
+ if (!isPatternRooted(pattern)) throw new Error(`${ERROR_PREFIX$6}: route "${name}" pattern must start with "/" (got "${pattern}").`);
1437
+ if (!hasBalancedBraces(pattern)) throw new Error(`${ERROR_PREFIX$6}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
1438
+ if (!hasValidLangCount(pattern)) throw new Error(`${ERROR_PREFIX$6}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
1432
1439
  }
1433
1440
  }
1434
1441
  /**
1435
- * Parse a single path segment into its placeholder, or `false` for a static
1436
- * segment. Uses a plain loop over the brace delimiters (no backtracking regex).
1437
- *
1438
- * @param segment - One `/`-delimited segment, e.g. `{slug}` or `about`.
1439
- * @returns The parsed placeholder, or `false` when the segment is static.
1440
- * @example
1441
- * ```ts
1442
- * parsePlaceholder("{slug:?}"); // { name: "slug", optional: true }
1443
- * ```
1444
- */
1445
- function parsePlaceholder(segment) {
1446
- if (!segment.startsWith("{") || !segment.endsWith("}")) return false;
1447
- const inner = segment.slice(1, -1);
1448
- if (inner.endsWith(":?")) return {
1449
- name: inner.slice(0, -2),
1450
- optional: true
1451
- };
1452
- return {
1453
- name: inner,
1454
- optional: false
1455
- };
1456
- }
1457
- /**
1458
1442
  * Convert a user pattern to a `URLPattern` source string, in a `withLang` or
1459
1443
  * `bare` variant (the latter strips the optional `{lang:?}` segment). Walks the
1460
1444
  * pattern one `/`-segment at a time (no backtracking regex).
@@ -1486,22 +1470,29 @@ function patternToUrlPattern(pattern, variant, langRegex) {
1486
1470
  }
1487
1471
  /**
1488
1472
  * Build a URL from a pattern and params (substitutes `{param}` / `{param:?}`).
1489
- * Walks segment-by-segment (no backtracking regex).
1473
+ * Walks segment-by-segment (no backtracking regex). An optional placeholder whose
1474
+ * param is absent has its segment skipped entirely (no empty segment), so a missing
1475
+ * `{lang:?}` collapses cleanly instead of leaving a double slash.
1490
1476
  *
1491
1477
  * @param pattern - The route pattern.
1492
1478
  * @param params - Param values to substitute.
1493
- * @param _baseUrl - Site base URL (reserved for absolute-link construction).
1494
1479
  * @returns The resolved relative URL string.
1495
1480
  * @example
1496
1481
  * ```ts
1497
- * buildUrl("/{slug}/", { slug: "hello" }, "https://blog.dev");
1482
+ * buildUrl("/{slug}/", { slug: "hello" }); // "/hello/"
1498
1483
  * ```
1499
1484
  */
1500
- function buildUrl(pattern, params, _baseUrl) {
1485
+ function buildUrl(pattern, params) {
1501
1486
  const out = [];
1502
1487
  for (const segment of pattern.split("/")) {
1503
1488
  const placeholder = parsePlaceholder(segment);
1504
- out.push(placeholder ? params[placeholder.name] ?? "" : segment);
1489
+ if (!placeholder) {
1490
+ out.push(segment);
1491
+ continue;
1492
+ }
1493
+ const value = params[placeholder.name] ?? "";
1494
+ if (placeholder.optional && value === "") continue;
1495
+ out.push(value);
1505
1496
  }
1506
1497
  return out.join("/");
1507
1498
  }
@@ -1517,10 +1508,63 @@ function buildUrl(pattern, params, _baseUrl) {
1517
1508
  * ```
1518
1509
  */
1519
1510
  function buildFilePath(pattern, params) {
1520
- const cleanPath = buildUrl(pattern, params, "").replace(/^\//, "").replace(/\/$/, "");
1511
+ const cleanPath = buildUrl(pattern, params).replace(/^\//, "").replace(/\/$/, "");
1521
1512
  return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
1522
1513
  }
1523
1514
  /**
1515
+ * Build both URLPattern matchers for a route — the `withLang` variant (locale
1516
+ * prefix injected) and the `bare` variant (optional `{lang:?}` stripped) — from
1517
+ * the user pattern and the active locale alternation.
1518
+ *
1519
+ * @param pattern - The user pattern, e.g. `/{lang:?}/{slug}/`.
1520
+ * @param locales - Active locale codes, joined into the alternation regex.
1521
+ * @returns The frozen `{ withLang, bare }` matcher pair.
1522
+ * @example
1523
+ * ```ts
1524
+ * const matchers = buildMatchers("/{lang:?}/{slug}/", ["en", "uk"]);
1525
+ * ```
1526
+ */
1527
+ function buildMatchers(pattern, locales) {
1528
+ const langRegex = `(${locales.join("|")})`;
1529
+ return {
1530
+ withLang: new URLPattern({ pathname: patternToUrlPattern(pattern, "withLang", langRegex) }),
1531
+ bare: new URLPattern({ pathname: patternToUrlPattern(pattern, "bare", langRegex) })
1532
+ };
1533
+ }
1534
+ /**
1535
+ * Build the `toUrl` closure for a route — resolves the pattern against params
1536
+ * into a relative URL. Captured per-route so callers need not re-supply the
1537
+ * pattern.
1538
+ *
1539
+ * @param pattern - The route pattern bound into the closure.
1540
+ * @returns A function mapping params to the resolved relative URL.
1541
+ * @example
1542
+ * ```ts
1543
+ * const toUrl = createToUrlFn("/{slug}/");
1544
+ * toUrl({ slug: "x" }); // "/x/"
1545
+ * ```
1546
+ */
1547
+ function createToUrlFunction(pattern) {
1548
+ return (params) => buildUrl(pattern, params);
1549
+ }
1550
+ /**
1551
+ * Build the `toFile` closure for a route — resolves the output file path from
1552
+ * params. Honors a custom `.toFile()` override (captured in `_handlers.toFile`)
1553
+ * when present, falling back to the pattern-derived `…/index.html` path.
1554
+ *
1555
+ * @param pattern - The route pattern bound into the closure.
1556
+ * @param definition - The route definition carrying any `toFile` override.
1557
+ * @returns A function mapping params to the output file path.
1558
+ * @example
1559
+ * ```ts
1560
+ * const toFile = createToFileFn("/{slug}/", definition);
1561
+ * toFile({ slug: "x" }); // "x/index.html"
1562
+ * ```
1563
+ */
1564
+ function createToFileFunction(pattern, definition) {
1565
+ return (params) => definition._handlers.toFile?.(params) ?? buildFilePath(pattern, params);
1566
+ }
1567
+ /**
1524
1568
  * Compile a single route definition into its `CompiledRoute` entry.
1525
1569
  *
1526
1570
  * @param name - The route name key.
@@ -1534,45 +1578,17 @@ function buildFilePath(pattern, params) {
1534
1578
  */
1535
1579
  function compileRoute(name, definition, input) {
1536
1580
  const { pattern } = definition;
1537
- const langRegex = `(${input.locales.join("|")})`;
1538
- const matchers = {
1539
- withLang: new URLPattern({ pathname: patternToUrlPattern(pattern, "withLang", langRegex) }),
1540
- bare: new URLPattern({ pathname: patternToUrlPattern(pattern, "bare", langRegex) })
1541
- };
1581
+ const matchers = buildMatchers(pattern, input.locales);
1582
+ const toUrl = createToUrlFunction(pattern);
1583
+ const toFile = createToFileFunction(pattern, definition);
1542
1584
  return {
1543
1585
  name,
1544
1586
  pattern,
1545
1587
  dynamicSegmentCount: dynamicSegmentCount(pattern),
1546
1588
  matchers,
1547
1589
  matchFn: createMatchFunction(matchers, input.defaultLocale),
1548
- /**
1549
- * Build a URL for this route from params.
1550
- *
1551
- * @param params - Param values to substitute.
1552
- * @returns The resolved relative URL.
1553
- * @example
1554
- * ```ts
1555
- * entry.toUrl({ slug: "x" });
1556
- * ```
1557
- */
1558
- toUrl(params) {
1559
- return buildUrl(pattern, params, input.baseUrl);
1560
- },
1561
- /**
1562
- * Build the output file path for this route from params. Honors a custom
1563
- * `.toFile()` override (captured in `_handlers.toFile`) when present, falling
1564
- * back to the pattern-derived `…/index.html` path otherwise.
1565
- *
1566
- * @param params - Param values to substitute.
1567
- * @returns The output file path.
1568
- * @example
1569
- * ```ts
1570
- * entry.toFile({ slug: "x" });
1571
- * ```
1572
- */
1573
- toFile(params) {
1574
- return definition._handlers.toFile?.(params) ?? buildFilePath(pattern, params);
1575
- },
1590
+ toUrl,
1591
+ toFile,
1576
1592
  definition,
1577
1593
  meta: { ...definition._meta }
1578
1594
  };
@@ -1603,75 +1619,212 @@ function compileRoutes(input) {
1603
1619
  byName
1604
1620
  };
1605
1621
  }
1622
+ //#endregion
1623
+ //#region src/plugins/router/api.ts
1624
+ /**
1625
+ * @file router plugin — API factory.
1626
+ *
1627
+ * Closures over `ctx.state.table` exposing `match` / `toUrl` / `entries` /
1628
+ * `manifest`. Returns values/copies, never the raw `ctx.state` reference (spec/11 §2.4).
1629
+ */
1630
+ /** Error prefix for router API failures. */
1631
+ const ERROR_PREFIX$5 = "[web] router";
1606
1632
  /**
1607
- * onInit orchestrator (data-only seam, keeps `index.ts` wiring-only). Validates
1608
- * the route map then compiles the matcher table from resolved dependency data.
1633
+ * Validate a route map and compile it into the matcher table on `ctx.state`,
1634
+ * resolving the global render `mode` + site base URL + i18n locales at call time.
1635
+ * Called by the router's `onInit` to compile `config.routes`. Re-calling replaces the table.
1609
1636
  *
1610
- * @param config - Resolved router config (`routes` + `mode`).
1611
- * @param baseUrl - Site base URL from `ctx.require(sitePlugin).url()`.
1612
- * @param locales - Available locales from `ctx.require(i18nPlugin).locales()`.
1613
- * @param defaultLocale - Default locale from `ctx.require(i18nPlugin).defaultLocale()`.
1614
- * @returns The compiled, immutable matcher table for `ctx.state.table`.
1637
+ * @param ctx - The router register context (state + global mode + require).
1638
+ * @param routes - The route map to compile (an `import * as routes` namespace works).
1639
+ * @throws {Error} If the route map is empty or a pattern is malformed.
1615
1640
  * @example
1616
1641
  * ```ts
1617
- * ctx.state.table = buildRouterTable(ctx.config, site.url(), i18n.locales(), i18n.defaultLocale());
1642
+ * registerRoutes(ctx, { home: route("/") });
1618
1643
  * ```
1619
1644
  */
1620
- function buildRouterTable(config, baseUrl, locales, defaultLocale) {
1621
- validateRoutes(config.routes);
1622
- return compileRoutes({
1623
- routes: config.routes,
1624
- mode: config.mode ?? "hybrid",
1625
- baseUrl,
1626
- locales,
1627
- defaultLocale
1645
+ function registerRoutes(ctx, routes) {
1646
+ validateRoutes(routes);
1647
+ const i18n = ctx.require(i18nPlugin);
1648
+ ctx.state.table = compileRoutes({
1649
+ routes,
1650
+ mode: ctx.global.mode,
1651
+ baseUrl: ctx.require(sitePlugin).url(),
1652
+ locales: i18n.locales(),
1653
+ defaultLocale: i18n.defaultLocale()
1628
1654
  });
1629
1655
  }
1656
+ /**
1657
+ * Read the compiled matcher table, throwing if `onInit` has not run yet. This
1658
+ * `null` cannot occur in practice post-`onInit`; the guard documents the invariant.
1659
+ *
1660
+ * @param state - The router plugin state holder.
1661
+ * @returns The compiled, non-null matcher table.
1662
+ * @throws {Error} If the matcher table has not been compiled yet.
1663
+ * @example
1664
+ * ```ts
1665
+ * const table = readTable(ctx.state);
1666
+ * ```
1667
+ */
1668
+ function readTable(state) {
1669
+ if (state.table === null) throw new Error(`${ERROR_PREFIX$5}: routes not registered.\n Set pluginConfigs.router.routes before app.start() / app.build.run().`);
1670
+ return state.table;
1671
+ }
1672
+ /**
1673
+ * Project a compiled route into the public `TypedRoute` URL-utility view.
1674
+ *
1675
+ * @param entry - The compiled route entry.
1676
+ * @returns A `TypedRoute` exposing pattern/name/meta + toUrl/toFile/match.
1677
+ * @example
1678
+ * ```ts
1679
+ * toTypedRoute(compiledEntry).toUrl({ slug: "x" });
1680
+ * ```
1681
+ */
1682
+ function toTypedRoute(entry) {
1683
+ return {
1684
+ pattern: entry.pattern,
1685
+ name: entry.name,
1686
+ meta: { ...entry.meta },
1687
+ toUrl: entry.toUrl,
1688
+ toFile: entry.toFile,
1689
+ match: entry.matchFn
1690
+ };
1691
+ }
1692
+ /**
1693
+ * Project a compiled route into the serializable {@link ClientRoute} view: only
1694
+ * `pattern` / `name` / `meta`, with a fresh `meta` copy and NO `_handlers` closures.
1695
+ *
1696
+ * @param entry - The compiled route entry.
1697
+ * @returns A `ClientRoute` carrying only JSON-serializable fields.
1698
+ * @example
1699
+ * ```ts
1700
+ * toClientRoute(compiledEntry); // { pattern, name, meta }
1701
+ * ```
1702
+ */
1703
+ function toClientRoute(entry) {
1704
+ return {
1705
+ pattern: entry.pattern,
1706
+ name: entry.name,
1707
+ meta: { ...entry.meta }
1708
+ };
1709
+ }
1710
+ /**
1711
+ * Creates the router plugin API surface. Every closure reads the compiled table
1712
+ * from `ctx.state` and returns values/fresh copies — never the raw state arrays.
1713
+ *
1714
+ * @param ctx - Plugin context.
1715
+ * @param ctx.state - The router state holding the compiled matcher table.
1716
+ * @returns The {@link RouterApi} surface mounted at `ctx.router`.
1717
+ * @example
1718
+ * ```ts
1719
+ * const api = createApi({ state });
1720
+ * api.match("/en/hello/");
1721
+ * ```
1722
+ */
1723
+ function createApi$2(ctx) {
1724
+ const { state } = ctx;
1725
+ return {
1726
+ /**
1727
+ * Match a pathname against the compiled route table (specificity-sorted).
1728
+ *
1729
+ * @param pathname - URL pathname, e.g. `/en/hello/`.
1730
+ * @returns `{ params, route }` for the most specific match, or `null`.
1731
+ * @example
1732
+ * ```ts
1733
+ * api.match("/en/hello/");
1734
+ * ```
1735
+ */
1736
+ match(pathname) {
1737
+ return matchRoute(readTable(state).compiled, pathname);
1738
+ },
1739
+ /**
1740
+ * Build a URL for a named route from params.
1741
+ *
1742
+ * @param routeName - Route name key from the route map.
1743
+ * @param params - Param values to substitute into the pattern.
1744
+ * @returns The resolved URL string (e.g. `/en/hello/`).
1745
+ * @throws {Error} If `routeName` is unknown.
1746
+ * @example
1747
+ * ```ts
1748
+ * api.toUrl("article", { lang: "en", slug: "hello" });
1749
+ * ```
1750
+ */
1751
+ toUrl(routeName, params) {
1752
+ const entry = readTable(state).byName.get(routeName);
1753
+ if (!entry) throw new Error(`${ERROR_PREFIX$5}: unknown route name "${routeName}".\n Check the name matches a key in the route map registered via pluginConfigs.router.routes.`);
1754
+ return entry.toUrl(params);
1755
+ },
1756
+ /**
1757
+ * All resolved routes as typed URL utilities, in specificity order.
1758
+ *
1759
+ * @returns A fresh read-only array of resolved typed routes.
1760
+ * @example
1761
+ * ```ts
1762
+ * for (const r of api.entries()) r.toUrl({ slug: "x" });
1763
+ * ```
1764
+ */
1765
+ entries() {
1766
+ return readTable(state).compiled.map((entry) => toTypedRoute(entry));
1767
+ },
1768
+ /**
1769
+ * The typed route set for build-time consumption (declaration order). An API
1770
+ * return, NOT a config readback — preserves per-route types despite erasure.
1771
+ *
1772
+ * @returns A fresh read-only array of the typed route definitions.
1773
+ * @example
1774
+ * ```ts
1775
+ * for (const def of api.manifest()) def._handlers.render?.(routeContext);
1776
+ * ```
1777
+ */
1778
+ manifest() {
1779
+ return [...readTable(state).byName.values()].map((entry) => entry.definition);
1780
+ },
1781
+ /**
1782
+ * Serializable, specificity-sorted projection of the route table for client
1783
+ * shipping — `{ pattern, name, meta }` entries with NO `_handlers` closures.
1784
+ *
1785
+ * @returns A fresh, frozen, specificity-sorted read-only array of client routes.
1786
+ * @example
1787
+ * ```ts
1788
+ * const json = JSON.stringify(api.clientManifest());
1789
+ * ```
1790
+ */
1791
+ clientManifest() {
1792
+ return Object.freeze(readTable(state).compiled.map((entry) => toClientRoute(entry)));
1793
+ },
1794
+ /**
1795
+ * The resolved render mode — read from the global framework config (the single
1796
+ * source of truth for static/hybrid/spa). `build`/`spa` gate data nav on it.
1797
+ *
1798
+ * @returns `"ssg" | "spa" | "hybrid"`.
1799
+ * @example
1800
+ * ```ts
1801
+ * if (api.mode() !== "ssg") emitClientData();
1802
+ * ```
1803
+ */
1804
+ mode() {
1805
+ return ctx.global.mode;
1806
+ }
1807
+ };
1808
+ }
1630
1809
  //#endregion
1631
1810
  //#region src/plugins/router/builders/route-builder.ts
1632
1811
  /**
1633
- * Create a fluent route builder from a URL pattern string. Captures the pattern
1634
- * as a literal type for compile-time param inference; `.load()` is the only method
1635
- * that widens the data generic, so `ctx.data` in `.render()`/`.head()` is typed by
1636
- * `.load()`'s return at the CALL SITE. The returned object is itself the route
1637
- * definition (`pattern` / `_meta` / `_handlers`), so it slots straight into a route map.
1812
+ * Build the handler-slot setter methods for one builder instance. Each method
1813
+ * records its handler under the matching slot and returns the builder, so the chain
1814
+ * stays fluent; they differ only in slot name and documented intent. `meta` is NOT
1815
+ * here it merges into `_meta` rather than a handler slot — so the carrier is not
1816
+ * needed, keeping this helper a pure factory over the shared `set` primitive.
1638
1817
  *
1639
- * @param pattern - URL pattern with `{param}` / `{param:?}` placeholders.
1640
- * @returns A `RouteBuilder<RouteState<P>>` carrying the typed fluent chain.
1818
+ * @param set - The record-then-return-builder primitive shared by every method.
1819
+ * @returns The handler-slot setters (`load`/`layout`/`render`/`head`/`generate`/`toJson`/`toFile`).
1641
1820
  * @example
1642
1821
  * ```ts
1643
- * route("/{lang:?}/{slug}/")
1644
- * .load(({ slug }) => loadArticle(slug))
1645
- * .render((ctx) => <Article a={ctx.data} />)
1646
- * .head((ctx) => ({ title: ctx.data.title }));
1822
+ * const methods = createBuilderMethods(set);
1823
+ * methods.render(handler); // records the render handler, returns the builder
1647
1824
  * ```
1648
1825
  */
1649
- function route(pattern) {
1650
- const carrier = {
1651
- pattern,
1652
- _meta: {},
1653
- _handlers: {}
1654
- };
1655
- const handlers = carrier._handlers;
1656
- /**
1657
- * Record a handler under `key` and return the same builder for chaining.
1658
- *
1659
- * @param key - The handler slot name.
1660
- * @param fn - The handler function to store.
1661
- * @returns The same builder instance, for fluent chaining.
1662
- * @example
1663
- * ```ts
1664
- * set("render", handler);
1665
- * ```
1666
- */
1667
- function set(key, fn) {
1668
- handlers[key] = fn;
1669
- return builder;
1670
- }
1671
- const builder = {
1672
- pattern: carrier.pattern,
1673
- _meta: carrier._meta,
1674
- _handlers: carrier._handlers,
1826
+ function createBuilderMethods(set) {
1827
+ return {
1675
1828
  /**
1676
1829
  * Attach a data loader; widens the data generic for downstream handlers.
1677
1830
  *
@@ -1679,7 +1832,7 @@ function route(pattern) {
1679
1832
  * @returns The same builder, with the data generic widened.
1680
1833
  * @example
1681
1834
  * ```ts
1682
- * route("/{slug}/").load(({ slug }) => ({ slug }));
1835
+ * route("/{slug}/").load((ctx) => ({ slug: ctx.params.slug }));
1683
1836
  * ```
1684
1837
  */
1685
1838
  load(loader) {
@@ -1719,21 +1872,6 @@ function route(pattern) {
1719
1872
  return set("render", handler);
1720
1873
  },
1721
1874
  /**
1722
- * Attach the client-side validation gate (raw `unknown` → this route's data
1723
- * type). Runs at the trust boundary before `render` on the client; throw to
1724
- * reject malformed data (spa falls back to HTML-over-fetch).
1725
- *
1726
- * @param handler - The validator/parser.
1727
- * @returns The same builder for chaining.
1728
- * @example
1729
- * ```ts
1730
- * route("/shop/{id}/").parse(raw => ProductSchema.parse(raw));
1731
- * ```
1732
- */
1733
- parse(handler) {
1734
- return set("parse", handler);
1735
- },
1736
- /**
1737
1875
  * Attach the head/SEO handler.
1738
1876
  *
1739
1877
  * @param handler - The head handler.
@@ -1760,6 +1898,78 @@ function route(pattern) {
1760
1898
  return set("generate", handler);
1761
1899
  },
1762
1900
  /**
1901
+ * Attach a JSON serializer for the route's data.
1902
+ *
1903
+ * @param handler - The JSON serializer.
1904
+ * @returns The same builder for chaining.
1905
+ * @example
1906
+ * ```ts
1907
+ * route("/api/").toJson(() => ({ ok: true }));
1908
+ * ```
1909
+ */
1910
+ toJson(handler) {
1911
+ return set("toJson", handler);
1912
+ },
1913
+ /**
1914
+ * Override the output file-path producer.
1915
+ *
1916
+ * @param handler - The file-path producer.
1917
+ * @returns The same builder for chaining.
1918
+ * @example
1919
+ * ```ts
1920
+ * route("/feed/").toFile(() => "feed.xml");
1921
+ * ```
1922
+ */
1923
+ toFile(handler) {
1924
+ return set("toFile", handler);
1925
+ }
1926
+ };
1927
+ }
1928
+ /**
1929
+ * Create a fluent route builder from a URL pattern string. Captures the pattern
1930
+ * as a literal type for compile-time param inference; `.load()` is the only method
1931
+ * that widens the data generic, so `ctx.data` in `.render()`/`.head()` is typed by
1932
+ * `.load()`'s return at the CALL SITE. The returned object is itself the route
1933
+ * definition (`pattern` / `_meta` / `_handlers`), so it slots straight into a route map.
1934
+ *
1935
+ * @param pattern - URL pattern with `{param}` / `{param:?}` placeholders.
1936
+ * @returns A `RouteBuilder<RouteState<P>>` carrying the typed fluent chain.
1937
+ * @example
1938
+ * ```ts
1939
+ * route("/{lang:?}/{slug}/")
1940
+ * .load((ctx) => loadArticle(ctx.params.slug))
1941
+ * .render((ctx) => <Article a={ctx.data} />)
1942
+ * .head((ctx) => ({ title: ctx.data.title }));
1943
+ * ```
1944
+ */
1945
+ function route(pattern) {
1946
+ const carrier = {
1947
+ pattern,
1948
+ _meta: {},
1949
+ _handlers: {}
1950
+ };
1951
+ /**
1952
+ * Record a handler under `key` and return the builder for chaining — the one
1953
+ * primitive every typed handler setter (`load`, `render`, …) delegates to.
1954
+ *
1955
+ * @param key - The handler slot name.
1956
+ * @param fn - The handler function to store.
1957
+ * @returns The same builder instance, for fluent chaining.
1958
+ * @example
1959
+ * ```ts
1960
+ * set("render", handler);
1961
+ * ```
1962
+ */
1963
+ const set = (key, fn) => {
1964
+ carrier._handlers[key] = fn;
1965
+ return builder;
1966
+ };
1967
+ const builder = {
1968
+ pattern: carrier.pattern,
1969
+ _meta: carrier._meta,
1970
+ _handlers: carrier._handlers,
1971
+ ...createBuilderMethods(set),
1972
+ /**
1763
1973
  * Merge an arbitrary metadata bag into the route's `_meta`. The bag MUST be
1764
1974
  * JSON-serializable — it is projected verbatim into `clientManifest()` and
1765
1975
  * shipped to the browser, so functions/symbols/class instances are unsupported.
@@ -1774,32 +1984,6 @@ function route(pattern) {
1774
1984
  meta(meta) {
1775
1985
  Object.assign(carrier._meta, meta);
1776
1986
  return builder;
1777
- },
1778
- /**
1779
- * Attach a JSON serializer for the route's data.
1780
- *
1781
- * @param handler - The JSON serializer.
1782
- * @returns The same builder for chaining.
1783
- * @example
1784
- * ```ts
1785
- * route("/api/").toJson(() => ({ ok: true }));
1786
- * ```
1787
- */
1788
- toJson(handler) {
1789
- return set("toJson", handler);
1790
- },
1791
- /**
1792
- * Override the output file-path producer.
1793
- *
1794
- * @param handler - The file-path producer.
1795
- * @returns The same builder for chaining.
1796
- * @example
1797
- * ```ts
1798
- * route("/feed/").toFile(() => "feed.xml");
1799
- * ```
1800
- */
1801
- toFile(handler) {
1802
- return set("toFile", handler);
1803
1987
  }
1804
1988
  };
1805
1989
  return builder;
@@ -1818,6 +2002,43 @@ function route(pattern) {
1818
2002
  function defineRoutes(routes) {
1819
2003
  return routes;
1820
2004
  }
2005
+ /**
2006
+ * Build a pure, app-free URL builder from a route map. `toUrl(name, params)` resolves
2007
+ * a route's path by pattern substitution using the SAME `buildUrl` as the runtime
2008
+ * `RouterApi.toUrl`, so the helper and the API can never diverge. It needs no running
2009
+ * app, router instance, base URL, or i18n — just the route map the consumer already
2010
+ * holds at module scope. So components, layouts, and hydrated islands import it
2011
+ * directly: no `app.router` reference, no manual "bind", no module global, no
2012
+ * "not bound" guard, and no createApp ↔ routes cycle.
2013
+ *
2014
+ * @param routes - The route map (typically the value returned by {@link defineRoutes}).
2015
+ * @returns A {@link Urls} builder whose `toUrl` accepts only this map's route names.
2016
+ * @example
2017
+ * ```ts
2018
+ * const url = createUrls(routes);
2019
+ * url.toUrl("article", { lang: "en", slug: "hello" }); // "/en/hello/"
2020
+ * ```
2021
+ */
2022
+ function createUrls(routes) {
2023
+ return {
2024
+ /**
2025
+ * Build a route's URL path from its name and params.
2026
+ *
2027
+ * @param name - Route name key from the map.
2028
+ * @param params - Path params to substitute into the pattern. Defaults to `{}`.
2029
+ * @returns The resolved relative URL path.
2030
+ * @throws {Error} If `name` is not present in the route map.
2031
+ * @example
2032
+ * ```ts
2033
+ * url.toUrl("home", { lang: "en" }); // "/en/"
2034
+ * ```
2035
+ */
2036
+ toUrl(name, params = {}) {
2037
+ const definition = routes[name];
2038
+ if (!definition) throw new Error(`[web] router: unknown route name "${String(name)}".\n Check the name matches a key in the route map passed to createUrls.`);
2039
+ return buildUrl(definition.pattern, params);
2040
+ } };
2041
+ }
1821
2042
  //#endregion
1822
2043
  //#region src/plugins/router/state.ts
1823
2044
  /**
@@ -1830,52 +2051,41 @@ function defineRoutes(routes) {
1830
2051
  * @returns The initial router state holder.
1831
2052
  * @example
1832
2053
  * ```ts
1833
- * const state = createState({ global: {}, config: { routes: {} } });
2054
+ * const state = createState({ global: {}, config: { mode: "hybrid" } });
1834
2055
  * ```
1835
2056
  */
1836
2057
  function createState$2(_ctx) {
1837
- return {
1838
- table: null,
1839
- mode: _ctx.config.mode ?? "hybrid"
1840
- };
2058
+ return { table: null };
1841
2059
  }
1842
2060
  /**
1843
2061
  * Router plugin — typed, named route definitions with locale-aware URL generation
1844
- * and matching. Author routes with {@link route} + {@link defineRoutes}. Depends
1845
- * on site (base URL) and i18n (locales).
2062
+ * and matching. Author routes with {@link route}, then register them the normal config
2063
+ * way via `pluginConfigs.router.routes` (compiled at init). Depends on site (base URL)
2064
+ * and i18n (locales).
1846
2065
  *
1847
- * @example Define routes and choose a render mode
2066
+ * @example Register routes via config, then start/build
1848
2067
  * ```ts
2068
+ * import * as routes from "./routes";
1849
2069
  * const app = createApp({
1850
- * pluginConfigs: {
1851
- * router: {
1852
- * routes: defineRoutes({
1853
- * home: route("/"),
1854
- * article: route("/blog/{slug}/")
1855
- * }),
1856
- * mode: "hybrid" // "ssg" | "spa" | "hybrid" (default)
1857
- * }
1858
- * }
2070
+ * config: { mode: "hybrid" }, // render mode is GLOBAL config
2071
+ * pluginConfigs: { router: { routes } } // declarative route map (a namespace works)
1859
2072
  * });
2073
+ * await app.build.run(); // or: await app.start(); — routes compiled at init
1860
2074
  * ```
1861
2075
  */
1862
2076
  const routerPlugin = createPlugin$1("router", {
1863
2077
  depends: [sitePlugin, i18nPlugin],
1864
2078
  helpers: {
1865
2079
  route,
1866
- defineRoutes
1867
- },
1868
- config: {
1869
- routes: {},
1870
- mode: "hybrid"
2080
+ defineRoutes,
2081
+ createUrls
1871
2082
  },
2083
+ config: {},
1872
2084
  createState: createState$2,
1873
- api: createApi$2,
1874
- onInit(ctx) {
1875
- const i18n = ctx.require(i18nPlugin);
1876
- const baseUrl = ctx.require(sitePlugin).url();
1877
- ctx.state.table = buildRouterTable(ctx.config, baseUrl, i18n.locales(), i18n.defaultLocale());
1878
- }
2085
+ onInit: (ctx) => {
2086
+ if (ctx.config.routes) registerRoutes(ctx, ctx.config.routes);
2087
+ },
2088
+ api: createApi$2
1879
2089
  });
1880
2090
  //#endregion
1881
2091
  //#region src/plugins/head/primitives.ts
@@ -2011,10 +2221,34 @@ function feedLink(title, url, type = "application/rss+xml") {
2011
2221
  };
2012
2222
  }
2013
2223
  /**
2224
+ * Build the schema.org `Article` structured-data object for the JSON-LD block,
2225
+ * carrying only the fields the article actually provides (optional fields are
2226
+ * omitted rather than emitted as `undefined`).
2227
+ *
2228
+ * @param articleMeta - Article metadata (title, description, author, dates, image…).
2229
+ * @returns A JSON-serializable `Article` object ready to hand to {@link jsonLd}.
2230
+ * @example buildArticleJsonLd({ title: "Hi", author: "A", published: "2026-01-01" })
2231
+ */
2232
+ function buildArticleJsonLd(articleMeta) {
2233
+ const ld = {
2234
+ "@context": "https://schema.org",
2235
+ "@type": "Article",
2236
+ headline: articleMeta.title
2237
+ };
2238
+ if (articleMeta.description) ld.description = articleMeta.description;
2239
+ if (articleMeta.author) ld.author = articleMeta.author;
2240
+ if (articleMeta.published) ld.datePublished = articleMeta.published;
2241
+ if (articleMeta.modified) ld.dateModified = articleMeta.modified;
2242
+ if (articleMeta.image) ld.image = articleMeta.image;
2243
+ return ld;
2244
+ }
2245
+ /**
2014
2246
  * Compose the full head element set for an article page: og:type=article, published/
2015
2247
  * modified times, author, section, tags, plus a JSON-LD `Article` block and canonical.
2016
2248
  *
2017
2249
  * @param articleMeta - Article metadata (title, description, author, dates, tags, image…).
2250
+ * `image`, when present, is pushed to `og:image` verbatim and must therefore be
2251
+ * an absolute URL (this helper does not resolve relative paths against the site).
2018
2252
  * @param canonicalUrl - The article's canonical absolute URL.
2019
2253
  * @returns An ordered array of serializable head elements.
2020
2254
  * @example buildArticleHead({ title: "Hi", author: "A", published: "2026-01-01" }, "https://x/p")
@@ -2027,17 +2261,7 @@ function buildArticleHead(articleMeta, canonicalUrl) {
2027
2261
  if (articleMeta.section) elements.push(og(`${ARTICLE_PREFIX}section`, articleMeta.section));
2028
2262
  for (const tag of articleMeta.tags ?? []) elements.push(og(`${ARTICLE_PREFIX}tag`, tag));
2029
2263
  if (articleMeta.image) elements.push(og("og:image", articleMeta.image));
2030
- const ld = {
2031
- "@context": "https://schema.org",
2032
- "@type": "Article",
2033
- headline: articleMeta.title
2034
- };
2035
- if (articleMeta.description) ld.description = articleMeta.description;
2036
- if (articleMeta.author) ld.author = articleMeta.author;
2037
- if (articleMeta.published) ld.datePublished = articleMeta.published;
2038
- if (articleMeta.modified) ld.dateModified = articleMeta.modified;
2039
- if (articleMeta.image) ld.image = articleMeta.image;
2040
- elements.push(jsonLd(ld));
2264
+ elements.push(jsonLd(buildArticleJsonLd(articleMeta)));
2041
2265
  return elements;
2042
2266
  }
2043
2267
  //#endregion
@@ -2072,7 +2296,30 @@ function applyTemplate(title, template) {
2072
2296
  * @example resolveImage("/og.png", site) // "https://blog.dev/og.png"
2073
2297
  */
2074
2298
  function resolveImage(image, site) {
2075
- return image.startsWith("http") ? image : site.canonical(image);
2299
+ return /^https?:\/\//.test(image) || image.startsWith("//") ? image : site.canonical(image);
2300
+ }
2301
+ /**
2302
+ * Build the per-locale `hreflang` alternates for a route, plus the `x-default`
2303
+ * fallback (the route's URL with no `lang` override). Each alternate URL is the
2304
+ * route's canonical URL for that locale, absolutized against the site base URL.
2305
+ *
2306
+ * @param locales - The supported locale codes (drives the alternate set).
2307
+ * @param route - The resolved route descriptor (provides `name` + `params`).
2308
+ * @param router - The router slice used to build each locale's URL.
2309
+ * @param site - The site slice used to absolutize each locale's URL.
2310
+ * @returns The ordered `hreflang` element set: one per locale, then `x-default`.
2311
+ * @example buildHreflangAlternates(["en", "fr"], route, router, site)
2312
+ */
2313
+ function buildHreflangAlternates(locales, route, router, site) {
2314
+ const alternates = locales.map((locale) => {
2315
+ return hreflang(locale, site.canonical(router.toUrl(route.name, {
2316
+ ...route.params,
2317
+ lang: locale
2318
+ })));
2319
+ });
2320
+ const xDefaultHref = site.canonical(router.toUrl(route.name, { ...route.params }));
2321
+ alternates.push(hreflang(X_DEFAULT, xDefaultHref));
2322
+ return alternates;
2076
2323
  }
2077
2324
  /**
2078
2325
  * Build the canonical, og, twitter, and hreflang elements for the route from
@@ -2089,7 +2336,6 @@ function resolveImage(image, site) {
2089
2336
  function buildBaseElements(input, resolved) {
2090
2337
  const { route, defaults, site, i18n, router } = input;
2091
2338
  const head = route.head ?? {};
2092
- const image = head.image ?? defaults.defaultOgImage;
2093
2339
  const elements = [
2094
2340
  {
2095
2341
  tag: "title",
@@ -2104,6 +2350,7 @@ function buildBaseElements(input, resolved) {
2104
2350
  twitter("twitter:title", head.title ?? resolved.title),
2105
2351
  twitter("twitter:description", resolved.description)
2106
2352
  ];
2353
+ const image = head.image ?? defaults.defaultOgImage;
2107
2354
  if (image) {
2108
2355
  const abs = resolveImage(image, site);
2109
2356
  elements.push(og("og:image", abs), twitter("twitter:image", abs));
@@ -2111,16 +2358,7 @@ function buildBaseElements(input, resolved) {
2111
2358
  if (defaults.twitterHandle) elements.push(twitter("twitter:site", defaults.twitterHandle));
2112
2359
  const ogLocale = route.locale ? i18n.ogLocale(route.locale) : void 0;
2113
2360
  if (ogLocale) elements.push(og("og:locale", ogLocale));
2114
- elements.push(canonical(resolved.canonicalUrl));
2115
- for (const locale of i18n.locales()) {
2116
- const href = site.canonical(router.toUrl(route.name, {
2117
- ...route.params,
2118
- lang: locale
2119
- }));
2120
- elements.push(hreflang(locale, href));
2121
- }
2122
- const xDefaultHref = site.canonical(router.toUrl(route.name, { ...route.params }));
2123
- elements.push(hreflang(X_DEFAULT, xDefaultHref));
2361
+ elements.push(canonical(resolved.canonicalUrl), ...buildHreflangAlternates(i18n.locales(), route, router, site));
2124
2362
  return elements;
2125
2363
  }
2126
2364
  /**
@@ -2174,6 +2412,17 @@ function escapeHtml(raw) {
2174
2412
  return raw.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
2175
2413
  }
2176
2414
  /**
2415
+ * Serialize an element's attribute map to a space-joined `name="value"` string,
2416
+ * HTML-escaping each value. Returns `""` when there are no attributes.
2417
+ *
2418
+ * @param attributes - The element's attribute map (may be `undefined`).
2419
+ * @returns The serialized attribute string (no leading/trailing space).
2420
+ * @example serializeAttrs({ name: "robots", content: "index" }) // 'name="robots" content="index"'
2421
+ */
2422
+ function serializeAttributes(attributes) {
2423
+ return Object.entries(attributes ?? {}).map(([name, value]) => `${name}="${escapeHtml(value)}"`).join(" ");
2424
+ }
2425
+ /**
2177
2426
  * Serialize a single `HeadElement` to its HTML string form. Attribute values are
2178
2427
  * HTML-escaped; `script` children are emitted verbatim (already unicode-escaped by
2179
2428
  * `jsonLd`); `title` text is HTML-escaped.
@@ -2183,11 +2432,10 @@ function escapeHtml(raw) {
2183
2432
  * @example serializeElement(meta("robots", "index"))
2184
2433
  */
2185
2434
  function serializeElement(element) {
2186
- const attributes = Object.entries(element.attrs ?? {}).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(" ");
2187
- const open = attributes.length === 0 ? element.tag : `${element.tag} ${attributes}`;
2435
+ const attributes = serializeAttributes(element.attrs);
2188
2436
  if (element.tag === "script") return `<script ${attributes}>${element.children ?? ""}<\/script>`;
2189
2437
  if (element.tag === "title") return `<title>${escapeHtml(element.children ?? "")}</title>`;
2190
- return `<${open}>`;
2438
+ return `<${attributes.length === 0 ? element.tag : `${element.tag} ${attributes}`}>`;
2191
2439
  }
2192
2440
  /**
2193
2441
  * Serialize a `HeadElement[]` to `<head>` inner HTML. All attribute values are
@@ -2210,7 +2458,7 @@ function serializeHead(elements) {
2210
2458
  * it to a string. It holds no resource and caches no subscription.
2211
2459
  */
2212
2460
  /** Error prefix for head API invariant failures. */
2213
- const ERROR_PREFIX$4 = "[head]";
2461
+ const ERROR_PREFIX$4 = "[web] head";
2214
2462
  /**
2215
2463
  * Read the normalized defaults, asserting the post-`onInit` invariant (the slot is
2216
2464
  * `null` only before `onInit` assigns it, which cannot occur at render time).
@@ -2267,7 +2515,7 @@ render(route, data) {
2267
2515
  //#endregion
2268
2516
  //#region src/plugins/head/config.ts
2269
2517
  /** Error prefix for all head config-validation failures. */
2270
- const ERROR_PREFIX$3 = "[head] config:";
2518
+ const ERROR_PREFIX$3 = "[web] head";
2271
2519
  /** The allowed `twitterCard` literals (also the runtime guard set). */
2272
2520
  const VALID_TWITTER_CARDS = ["summary", "summary_large_image"];
2273
2521
  /**
@@ -2283,7 +2531,7 @@ const VALID_TWITTER_CARDS = ["summary", "summary_large_image"];
2283
2531
  const defaultConfig = { twitterCard: "summary_large_image" };
2284
2532
  /**
2285
2533
  * Structurally validate the resolved head config (no I/O). Throws a standard
2286
- * `[head] config: …` error when `titleTemplate` is provided without the `%s`
2534
+ * `[web] head: …` error when `titleTemplate` is provided without the `%s`
2287
2535
  * token, or when `twitterCard` is present but not one of the two allowed literals.
2288
2536
  *
2289
2537
  * @param config - The resolved head {@link Config} to validate.
@@ -2294,8 +2542,8 @@ const defaultConfig = { twitterCard: "summary_large_image" };
2294
2542
  * ```
2295
2543
  */
2296
2544
  function validateHeadConfig(config) {
2297
- if (config.titleTemplate !== void 0 && !config.titleTemplate.includes("%s")) throw new Error(`${ERROR_PREFIX$3} titleTemplate must contain the "%s" token (replaced by the route title), received ${JSON.stringify(config.titleTemplate)}.`);
2298
- if (config.twitterCard !== void 0 && !VALID_TWITTER_CARDS.includes(config.twitterCard)) throw new Error(`${ERROR_PREFIX$3} twitterCard must be one of [${VALID_TWITTER_CARDS.join(", ")}], received ${JSON.stringify(config.twitterCard)}.`);
2545
+ if (config.titleTemplate !== void 0 && !config.titleTemplate.includes("%s")) throw new Error(`${ERROR_PREFIX$3}: titleTemplate must contain the "%s" token (replaced by the route title), received ${JSON.stringify(config.titleTemplate)}.`);
2546
+ if (config.twitterCard !== void 0 && !VALID_TWITTER_CARDS.includes(config.twitterCard)) throw new Error(`${ERROR_PREFIX$3}: twitterCard must be one of [${VALID_TWITTER_CARDS.join(", ")}], received ${JSON.stringify(config.twitterCard)}.`);
2299
2547
  }
2300
2548
  /**
2301
2549
  * Validate then build the frozen, normalized {@link HeadDefaults} snapshot read by
@@ -2445,35 +2693,287 @@ function createApi(ctx) {
2445
2693
  *
2446
2694
  * @returns The current pathname + search.
2447
2695
  * @example
2448
- * app.spa.current();
2696
+ * app.spa.current();
2697
+ */
2698
+ current() {
2699
+ return ctx.state.currentUrl;
2700
+ }
2701
+ };
2702
+ }
2703
+ //#endregion
2704
+ //#region src/plugins/spa/events.ts
2705
+ /**
2706
+ * Declares the spa plugin's events. Extracted from index.ts to keep the wiring
2707
+ * file under the line budget.
2708
+ *
2709
+ * @param register - The event registration function supplied by the kernel.
2710
+ * @returns The map of spa event descriptors.
2711
+ * @example
2712
+ * const events = spaEvents(register);
2713
+ */
2714
+ function spaEvents(register) {
2715
+ return {
2716
+ "spa:navigate": register("A navigation has been intercepted and is starting."),
2717
+ "spa:navigated": register("The swap completed and the new URL is active."),
2718
+ "spa:component-mount": register("A component instance attached to an element."),
2719
+ "spa:component-unmount": register("A component instance detached from an element.")
2720
+ };
2721
+ }
2722
+ //#endregion
2723
+ //#region src/plugins/data/load-json.ts
2724
+ /**
2725
+ * @file `loadJson` — the data plugin's isomorphic JSON read primitive (the
2726
+ * SSG↔SPA seam). Internal to the `data` plugin (NOT a framework-root export):
2727
+ * `data.at(path)` uses it, and consumers read through `app.data.at(path)`.
2728
+ *
2729
+ * A read runs in BOTH worlds: on Node it reads the emitted data file from disk;
2730
+ * on the client (browser) it fetches the same data over HTTP. `loadJson` is the
2731
+ * single point where those two worlds differ — everything above it (the route's
2732
+ * `load`/`render`) is shared, so SSR/client parity is structural, not hoped-for.
2733
+ *
2734
+ * The browser path uses the `fetch` global. The Node path lazy-imports
2735
+ * `node:fs/promises` via `await import(...)`, so a browser bundle that includes
2736
+ * `loadJson` never statically pulls `node:*` (the bundler splits the Node branch
2737
+ * into its own chunk that the browser never loads).
2738
+ */
2739
+ /**
2740
+ * Read + parse a JSON resource, isomorphically. In a browser (`document`
2741
+ * defined) it `fetch`es `pathOrUrl`; on Node it reads the file from disk. Throws
2742
+ * on a failed fetch or unreadable file so the caller (`route.load`/`data.at`)
2743
+ * can decide whether to fall back.
2744
+ *
2745
+ * @template T - The expected shape of the parsed JSON.
2746
+ * @param pathOrUrl - A site-root URL (browser) or filesystem path (Node).
2747
+ * @returns The parsed JSON, typed as `T`.
2748
+ * @throws {Error} If the browser fetch is not OK, or the Node file read fails.
2749
+ * @example
2750
+ * ```ts
2751
+ * // Browser: fetch("/_data/en/hello/index.json")
2752
+ * // Node: read "dist/_data/en/hello/index.json"
2753
+ * const article = await loadJson<Article>("/_data/en/hello/index.json");
2754
+ * ```
2755
+ */
2756
+ async function loadJson(pathOrUrl) {
2757
+ if (typeof document === "undefined") {
2758
+ const { readFile } = await import("node:fs/promises");
2759
+ return JSON.parse(await readFile(pathOrUrl, "utf8"));
2760
+ }
2761
+ const response = await fetch(pathOrUrl);
2762
+ if (!response.ok) throw new Error(`[web] loadJson: failed to fetch ${pathOrUrl} (${String(response.status)}).`);
2763
+ return response.json();
2764
+ }
2765
+ //#endregion
2766
+ //#region src/plugins/data/api.ts
2767
+ /**
2768
+ * @file data plugin — API factory (the agnostic data provider surface).
2769
+ *
2770
+ * Node-free by construction: this module statically imports only types + the pure
2771
+ * convention. The Node write side (`write()`) reaches its `node:fs` writer through
2772
+ * a lazy `await import("./writer")` at call time, so a browser bundle that composes
2773
+ * `data` for the read side never pulls `node:*`. The read side (`at()`) uses only
2774
+ * the isomorphic `loadJson` (whose Node branch is itself lazy).
2775
+ */
2776
+ /**
2777
+ * Builds the data provider — the agnostic bridge. `write()` is the Node persist
2778
+ * side; `at()` is the browser read side; `urlFor`/`fileFor` are the pure
2779
+ * convention. No `onStart`/`onStop` (holds no long-lived resource).
2780
+ *
2781
+ * @param ctx - The data plugin context.
2782
+ * @returns The {@link DataProvider} mounted at `app.data`.
2783
+ * @example
2784
+ * ```ts
2785
+ * const api = dataApi(ctx);
2786
+ * await api.write([{ path: "/en/hello/", data: article }]); // Node build
2787
+ * await api.at("/en/hello/"); // browser
2788
+ * ```
2789
+ */
2790
+ function dataApi(ctx) {
2791
+ return {
2792
+ /**
2793
+ * READ (browser) — fetch (and cache) the persisted data for a page path.
2794
+ * Returns the raw JSON as `unknown`, which the route uses directly as `ctx.data`
2795
+ * (no route `.parse()`); returns `null` if the fetch or JSON parse fails (so
2796
+ * `spa` can fall back to HTML).
2797
+ *
2798
+ * @param path - The page URL path (e.g. `/en/hello/`).
2799
+ * @returns The page's raw data, or `null` on failure.
2800
+ * @example
2801
+ * ```ts
2802
+ * const raw = await api.at("/en/hello/");
2803
+ * ```
2804
+ */
2805
+ async at(path) {
2806
+ if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
2807
+ try {
2808
+ const data = await loadJson(`${ctx.config.baseUrl}${dataSuffix(path)}`);
2809
+ ctx.state.cache.set(path, data);
2810
+ return data;
2811
+ } catch {
2812
+ return null;
2813
+ }
2814
+ },
2815
+ /**
2816
+ * WRITE (Node) — persist one JSON file per entry, keyed by page path. Called by
2817
+ * `build` after it expands routes. Lazily loads its `node:fs` writer (keeping a
2818
+ * browser bundle node-free).
2819
+ *
2820
+ * @param entries - The per-page data to persist.
2821
+ * @param options - Optional `{ outDir }` override (defaults to `./dist`).
2822
+ * @param options.outDir - Build output directory the write happens under.
2823
+ * @returns A summary of the written files.
2824
+ * @example
2825
+ * ```ts
2826
+ * await api.write([{ path: "/en/hello/", data: article }], { outDir: "dist" });
2827
+ * ```
2828
+ */
2829
+ async write(entries, options) {
2830
+ const { writeData } = await import("./writer-Dc_lx22j.mjs");
2831
+ return writeData(ctx, entries, options);
2832
+ },
2833
+ /**
2834
+ * PURE — the browser fetch URL for a page path.
2835
+ *
2836
+ * @param path - The page URL path.
2837
+ * @returns The site-root-relative data URL.
2838
+ * @example
2839
+ * ```ts
2840
+ * api.urlFor("/en/hello/"); // "/_data/en/hello/index.json"
2841
+ * ```
2842
+ */
2843
+ urlFor(path) {
2844
+ return `${ctx.config.baseUrl}${dataSuffix(path)}`;
2845
+ },
2846
+ /**
2847
+ * PURE — the `outDir`-relative file path for a page path.
2848
+ *
2849
+ * @param path - The page URL path.
2850
+ * @returns The output-relative file path.
2851
+ * @example
2852
+ * ```ts
2853
+ * api.fileFor("/en/hello/"); // "_data/en/hello/index.json"
2854
+ * ```
2449
2855
  */
2450
- current() {
2451
- return ctx.state.currentUrl;
2856
+ fileFor(path) {
2857
+ return relativeDataFile(ctx.config.outputDir, path);
2452
2858
  }
2453
2859
  };
2454
2860
  }
2455
2861
  //#endregion
2456
- //#region src/plugins/spa/events.ts
2862
+ //#region src/plugins/data/config.ts
2457
2863
  /**
2458
- * Declares the spa plugin's events. Extracted from index.ts to keep the wiring
2459
- * file under the line budget.
2864
+ * Typed default data config (R6: no inline `as`). `outputDir` is the WRITE path
2865
+ * (filesystem, relative to the build `outDir`); `baseUrl` is the matching READ URL
2866
+ * (site-root-relative) the browser fetches from — the defaults agree
2867
+ * (`"_data"` ↔ `"/_data/"`).
2460
2868
  *
2461
- * @param register - The event registration function supplied by the kernel.
2462
- * @returns The map of spa event descriptors.
2463
2869
  * @example
2464
- * const events = spaEvents(register);
2870
+ * ```ts
2871
+ * createPlugin("data", { config: defaultDataConfig });
2872
+ * ```
2465
2873
  */
2466
- function spaEvents(register) {
2874
+ const defaultDataConfig = {
2875
+ outputDir: "_data",
2876
+ baseUrl: "/_data/"
2877
+ };
2878
+ //#endregion
2879
+ //#region src/plugins/data/state.ts
2880
+ /**
2881
+ * Creates initial data state: a null `lastWrite` slot (populated by the Node
2882
+ * `write()` side) and an empty `cache` (populated lazily by the browser `at(path)`
2883
+ * side on first fetch).
2884
+ *
2885
+ * @param _ctx - Minimal context with global and config.
2886
+ * @param _ctx.global - Global framework configuration.
2887
+ * @param _ctx.config - Resolved plugin configuration.
2888
+ * @returns Fresh data state with no recorded write and an empty per-path cache.
2889
+ * @example
2890
+ * ```ts
2891
+ * const state = createDataState({ global: {}, config });
2892
+ * ```
2893
+ */
2894
+ function createDataState(_ctx) {
2467
2895
  return {
2468
- "spa:navigate": register("A navigation has been intercepted and is starting."),
2469
- "spa:navigated": register("The swap completed and the new URL is active."),
2470
- "spa:component-mount": register("A component instance attached to an element."),
2471
- "spa:component-unmount": register("A component instance detached from an element.")
2896
+ lastWrite: null,
2897
+ cache: /* @__PURE__ */ new Map()
2472
2898
  };
2473
2899
  }
2474
2900
  //#endregion
2901
+ //#region src/plugins/data/validate.ts
2902
+ /**
2903
+ * Reports whether a `baseUrl` value is invalid: it must be a string that is a
2904
+ * site-root-relative URL path (i.e. starting with "/").
2905
+ *
2906
+ * @param baseUrl - The candidate `baseUrl` value to check.
2907
+ * @returns `true` when `baseUrl` is not a string or does not start with "/".
2908
+ * @example
2909
+ * ```ts
2910
+ * isInvalidBaseUrl("/_data/"); // false
2911
+ * isInvalidBaseUrl("_data"); // true
2912
+ * ```
2913
+ */
2914
+ function isInvalidBaseUrl(baseUrl) {
2915
+ return typeof baseUrl !== "string" || !baseUrl.startsWith("/");
2916
+ }
2917
+ /**
2918
+ * Validates the resolved data config: the browser `baseUrl` must be a non-empty,
2919
+ * site-root-relative URL path.
2920
+ *
2921
+ * @param config - The resolved plugin configuration.
2922
+ * @throws {Error} If `baseUrl` is empty or not a rooted URL path.
2923
+ * @example
2924
+ * ```ts
2925
+ * validateDataConfig({ outputDir: "_data", baseUrl: "/_data/" });
2926
+ * ```
2927
+ */
2928
+ function validateDataConfig(config) {
2929
+ if (isInvalidBaseUrl(config.baseUrl)) throw new Error(`[web] data.baseUrl: must be a site-root-relative URL path starting with "/" (e.g. "/_data/").`);
2930
+ }
2931
+ //#endregion
2932
+ //#region src/plugins/data/index.ts
2933
+ /**
2934
+ * @file data — Standard tier plugin (wiring-only). The AGNOSTIC data provider for
2935
+ * the SSG→DATA→SPA pattern.
2936
+ *
2937
+ * Owns ONE contract — `page path → persisted JSON file` — and nothing about what
2938
+ * the data is: `write(entries)` persists per-page JSON on Node (build supplies the
2939
+ * entries it already expanded); `at(path)` fetches + caches it in the browser as
2940
+ * `unknown`, which the route uses directly as `ctx.data` in `render`. NOT a framework
2941
+ * default — the consumer composes it where needed (Node build AND/OR browser app).
2942
+ *
2943
+ * **No hard `depends`** — fully browser-composable; the `node:fs` writer is behind
2944
+ * a lazy `import()` inside `write()`. Build ordering is a call-site contract: build
2945
+ * writes data during its pages phase (after its Phase-0 clean), via `app.data.write`.
2946
+ * No `onStart`/`onStop`.
2947
+ * @see README.md
2948
+ */
2949
+ /**
2950
+ * Data plugin — the agnostic data provider. Mounts `write(entries)` (Node persist),
2951
+ * `at(path)` (browser read), and the pure `urlFor`/`fileFor` convention at `app.data`.
2952
+ *
2953
+ * @example
2954
+ * ```ts
2955
+ * // Node build: `build` calls app.data.write(...) during its pages phase when
2956
+ * // router.mode() !== "ssg". Compose the plugin + set the global render mode:
2957
+ * import * as routes from "./routes";
2958
+ * const app = createApp({
2959
+ * plugins: [dataPlugin, contentPlugin, buildPlugin],
2960
+ * config: { mode: "hybrid" },
2961
+ * pluginConfigs: { content: { providers: [fileSystemContent({ contentDir: "./content" })] }, router: { routes } }
2962
+ * });
2963
+ * await app.build.run(); // writes HTML + per-page data sidecars (routes compiled at init)
2964
+ *
2965
+ * // Browser app: compose `dataPlugin` too; spa fetches via app.data.at(path) on nav.
2966
+ * ```
2967
+ */
2968
+ const dataPlugin = createPlugin$1("data", {
2969
+ config: defaultDataConfig,
2970
+ createState: createDataState,
2971
+ onInit: (ctx) => validateDataConfig(ctx.config),
2972
+ api: dataApi
2973
+ });
2974
+ //#endregion
2475
2975
  //#region src/plugins/spa/types.ts
2476
- var types_exports$5 = /* @__PURE__ */ __exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
2976
+ var types_exports$6 = /* @__PURE__ */ __exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
2477
2977
  /** Allowed hook names — single source of truth for fail-fast validation. */
2478
2978
  const COMPONENT_HOOK_NAMES = [
2479
2979
  "onCreate",
@@ -3162,15 +3662,6 @@ function createState(_ctx) {
3162
3662
  /** Error prefix for spa kernel failures (spec/11 Part-3). */
3163
3663
  const ERROR_PREFIX = "[web]";
3164
3664
  /**
3165
- * Module-scope holder for the active SPA kernel. `onStop` receives the minimal
3166
- * teardown context (no `state`/`require`), so the kernel built during `onInit`
3167
- * is parked here for disposal. Single-app-per-process by design (spec/08 §4).
3168
- *
3169
- * @example
3170
- * kernelRef.current = createSpaKernel(state, config, emit, deps);
3171
- */
3172
- const kernelRef = {};
3173
- /**
3174
3665
  * Registers a component definition into state (last-registered-wins).
3175
3666
  *
3176
3667
  * @param state - The plugin state holding registeredComponents.
@@ -3271,51 +3762,101 @@ function createSpaKernel(state, config, emit, deps) {
3271
3762
  onError: handleError
3272
3763
  };
3273
3764
  /**
3274
- * The client DATA path: match `pathname`, fetch the page's PERSISTED data via the
3275
- * `data` reader, VALIDATE it through the route's `parse` gate, then run the
3276
- * route's OWN `render` (the same component the build used for SSG) and
3277
- * Preact-render the VNode into the swap region. Returns `false` (touching nothing
3278
- * the fallback cares about) on no-match / no-render / no-data / fetch-miss /
3279
- * parse-throw, so the caller falls back to HTML-over-fetch. `route.load` does NOT
3280
- * run on the client the build already persisted its output.
3765
+ * Phase 1 of the client DATA path (no side effects): match `pathname`, fetch the
3766
+ * page's PERSISTED data via the `data` reader, build the {@link RouteContext}, run
3767
+ * the route's OWN `render` (the same component the build used for SSG), and locate
3768
+ * the live swap region. The fetched JSON is used DIRECTLY as `ctx.data` (no
3769
+ * validation step `route.parse` was removed). Returns `false` (committing
3770
+ * nothing) on no-`data`-reader / no-match / no-render / no-region / fetch-miss,
3771
+ * so the caller falls back to HTML-over-fetch.
3281
3772
  *
3282
3773
  * @param pathname - The destination pathname (search stripped for matching).
3283
- * @returns `true` if the route was rendered from validated data, else `false`.
3774
+ * @returns The resolved render inputs, or `false` when the DATA path cannot run.
3284
3775
  * @example
3285
- * if (await tryDataRender("/en/world/")) return;
3776
+ * const resolved = await resolveDataRender("/en/world/");
3286
3777
  */
3287
- const tryDataRender = async (pathname) => {
3778
+ const resolveDataRender = async (pathname) => {
3288
3779
  if (!deps.dataAt) return false;
3289
3780
  const matchPath = pathname.split("?")[0] ?? pathname;
3290
3781
  const hit = deps.router.match(matchPath);
3291
3782
  if (!hit?.route._handlers.render) return false;
3783
+ const data = await deps.dataAt(pathname);
3784
+ if (data === null) return false;
3785
+ const locale = hit.params.lang ?? document.documentElement.lang ?? "";
3786
+ const routeContext = {
3787
+ params: hit.params,
3788
+ data,
3789
+ locale,
3790
+ url: (routeName, routeParams = {}) => deps.router.toUrl(routeName, routeParams)
3791
+ };
3792
+ const vnode = hit.route._handlers.render(routeContext);
3793
+ const region = document.querySelector(resolved.swapSelector);
3794
+ if (!region) return false;
3795
+ return {
3796
+ route: hit.route,
3797
+ vnode,
3798
+ routeContext,
3799
+ region
3800
+ };
3801
+ };
3802
+ /**
3803
+ * Phase 2 of the client DATA path (all side effects): begin the navigation,
3804
+ * lazy-load the Preact render layer, sync the document head, unmount the
3805
+ * outgoing page-specific islands, swap the VNode into the region, re-mount, then
3806
+ * record the new URL and emit `spa:navigated`.
3807
+ *
3808
+ * @param pathname - The destination pathname (recorded as the new current URL).
3809
+ * @param resolvedRender - The inputs produced by {@link resolveDataRender}.
3810
+ * @example
3811
+ * await commitDataRender("/en/world/", resolved);
3812
+ */
3813
+ const commitDataRender = async (pathname, resolvedRender) => {
3814
+ const { route, vnode, routeContext, region } = resolvedRender;
3815
+ handleStart(pathname);
3816
+ const { renderVNode } = await import("./render-BNe0s7fr.mjs");
3817
+ syncDataHead(route, routeContext);
3818
+ unmountPageSpecific(state, emit);
3819
+ /**
3820
+ * Render the VNode into the region and re-mount its islands in one paint — the
3821
+ * swap body handed to `runSwap` (optionally wrapped in a View Transition).
3822
+ *
3823
+ * @example
3824
+ * ```ts
3825
+ * runSwap(renderAndMount, resolved.viewTransitions);
3826
+ * ```
3827
+ */
3828
+ const renderAndMount = () => {
3829
+ renderVNode(vnode, region);
3830
+ scanAndMount(state, emit, resolved.swapSelector);
3831
+ notifyNavEnd(state);
3832
+ };
3833
+ runSwap(renderAndMount, resolved.viewTransitions);
3834
+ state.currentUrl = pathname;
3835
+ progress?.done();
3836
+ emit("spa:navigated", { url: pathname });
3837
+ };
3838
+ /**
3839
+ * The client DATA path: resolve the matched route's render inputs from the
3840
+ * page's PERSISTED data ({@link resolveDataRender}), then commit the Preact swap
3841
+ * ({@link commitDataRender}). The fetched JSON is used DIRECTLY as `ctx.data`
3842
+ * (no validation step). `route.load` does NOT run on the client — the build
3843
+ * already persisted its output. Returns `false` (touching nothing the fallback
3844
+ * cares about) on no-match / no-render / null / throw, so the caller falls back
3845
+ * to HTML-over-fetch.
3846
+ *
3847
+ * @param pathname - The destination pathname (search stripped for matching).
3848
+ * @returns `true` if the route was rendered from its data, else `false`.
3849
+ * @example
3850
+ * if (await tryDataRender("/en/world/")) return;
3851
+ */
3852
+ const tryDataRender = async (pathname) => {
3292
3853
  try {
3293
- const raw = await deps.dataAt(pathname);
3294
- if (raw === null) return false;
3295
- const data = hit.route._handlers.parse ? hit.route._handlers.parse(raw) : raw;
3296
- const locale = hit.params.lang ?? document.documentElement.lang ?? "";
3297
- const routeContext = {
3298
- params: hit.params,
3299
- data,
3300
- locale
3301
- };
3302
- const vnode = hit.route._handlers.render(routeContext);
3303
- const region = document.querySelector(resolved.swapSelector);
3304
- if (!region) return false;
3305
- handleStart(pathname);
3306
- const { renderVNode } = await import("./render-BNe0s7fr.mjs");
3307
- syncDataHead(hit.route, routeContext);
3308
- unmountPageSpecific(state, emit);
3309
- runSwap(() => {
3310
- renderVNode(vnode, region);
3311
- scanAndMount(state, emit, resolved.swapSelector);
3312
- notifyNavEnd(state);
3313
- }, resolved.viewTransitions);
3314
- state.currentUrl = pathname;
3315
- progress?.done();
3316
- emit("spa:navigated", { url: pathname });
3854
+ const resolvedRender = await resolveDataRender(pathname);
3855
+ if (resolvedRender === false) return false;
3856
+ await commitDataRender(pathname, resolvedRender);
3317
3857
  return true;
3318
3858
  } catch {
3859
+ progress?.done();
3319
3860
  return false;
3320
3861
  }
3321
3862
  };
@@ -3406,26 +3947,12 @@ function createSpaKernel(state, config, emit, deps) {
3406
3947
  };
3407
3948
  }
3408
3949
  /**
3409
- * Structural by-name handle for the OPTIONAL `data` plugin. `ctx.require` resolves
3410
- * a plugin by its `name` at runtime, so this lets `spa` obtain the `data` reader
3411
- * WITHOUT importing the `data` plugin or its types — keeping `spa` decoupled and
3412
- * its `depends` at `[router, head]`. The phantom types only the `at` slice it uses.
3413
- */
3414
- const dataPluginHandle = {
3415
- name: "data",
3416
- spec: void 0,
3417
- _phantom: {
3418
- config: void 0,
3419
- state: void 0,
3420
- api: void 0,
3421
- events: {}
3422
- }
3423
- };
3424
- /**
3425
- * Builds the shared kernel from the plugin context, stores it on `ctx.state`
3426
- * and `kernelRef`, and runs its init step (validate config, register
3427
- * config.components, seed currentUrl). Captures the OPTIONAL `data` reader when
3428
- * the `data` plugin is composed (enabling client DATA navigation).
3950
+ * Builds the shared kernel from the plugin context, stores it on `ctx.state`,
3951
+ * and runs its init step (validate config, register config.components, seed
3952
+ * currentUrl). Captures the OPTIONAL `data` reader when the `data` plugin is
3953
+ * composed (enabling client DATA navigation) resolved by instance via
3954
+ * `ctx.require(dataPlugin)`, guarded by `ctx.has("data")` so `data` stays optional
3955
+ * (`spa`'s `depends` is `[router, head]`).
3429
3956
  *
3430
3957
  * @param ctx - The plugin context (state/config/emit/require/has/log).
3431
3958
  * @example
@@ -3437,12 +3964,11 @@ function initSpa(ctx) {
3437
3964
  head: ctx.require(headPlugin)
3438
3965
  };
3439
3966
  if (ctx.has("data")) {
3440
- const reader = ctx.require(dataPluginHandle);
3967
+ const reader = ctx.require(dataPlugin);
3441
3968
  deps.dataAt = (path) => reader.at(path);
3442
3969
  }
3443
3970
  const kernel = createSpaKernel(ctx.state, ctx.config, ctx.emit, deps);
3444
3971
  ctx.state.kernel = kernel;
3445
- kernelRef.current = kernel;
3446
3972
  kernel.init();
3447
3973
  }
3448
3974
  //#endregion
@@ -3452,353 +3978,598 @@ let teardown;
3452
3978
  /** Captured log ref — onStop has no `ctx.log` (spec/08 §4). */
3453
3979
  let logRef;
3454
3980
  /**
3455
- * Dispose the active kernel (captured as the teardown closure during onStart).
3456
- *
3457
- * @example
3458
- * disposeKernel();
3459
- */
3460
- function disposeKernel() {
3461
- kernelRef.current?.dispose();
3462
- }
3463
- /**
3464
3981
  * Capture the teardown + log handles during `onStart` (no-op without a DOM —
3465
- * the SSR/build guard, so onStop has nothing to release). The kernel itself is
3466
- * booted by index.ts after this capture.
3982
+ * the SSR/build guard, so onStop has nothing to release). The kernel built in
3983
+ * `onInit` lives on `ctx.state`; its `dispose` is captured into the teardown
3984
+ * closure here. The kernel itself is booted by index.ts after this capture.
3467
3985
  *
3468
- * @param ctx - The plugin context (used for `log` capture).
3986
+ * @param ctx - The plugin context (used for `state.kernel` + `log` capture).
3469
3987
  * @example
3470
3988
  * captureTeardown(ctx);
3471
3989
  */
3472
3990
  function captureTeardown(ctx) {
3473
3991
  if (typeof document === "undefined") return;
3474
3992
  logRef = ctx.log;
3475
- teardown = disposeKernel;
3993
+ const kernel = ctx.state.kernel;
3994
+ teardown = () => kernel?.dispose();
3995
+ }
3996
+ /**
3997
+ * Release everything `captureTeardown`/`onStart` acquired: run teardown in
3998
+ * try/catch (logging via the captured ref), then clear both handles. Idempotent —
3999
+ * a second call is a no-op (spec/11 §4.2) and mirrors `onStart` (§4.1).
4000
+ *
4001
+ * @example
4002
+ * disposeSpa();
4003
+ */
4004
+ function disposeSpa() {
4005
+ try {
4006
+ teardown?.();
4007
+ } catch (error) {
4008
+ logRef?.error("spa:teardown-failed", {}, error);
4009
+ } finally {
4010
+ teardown = void 0;
4011
+ logRef = void 0;
4012
+ }
4013
+ }
4014
+ //#endregion
4015
+ //#region src/plugins/spa/index.ts
4016
+ /**
4017
+ * @file spa — Complex Plugin (WIRING ONLY, ≤30 lines). All logic lives in the
4018
+ * domain files (kernel/router/head/progress/components/lifecycle); index wires.
4019
+ *
4020
+ * Depends: router, head.
4021
+ * Emits: spa:navigate, spa:navigated, spa:component-mount, spa:component-unmount.
4022
+ * @see README.md
4023
+ */
4024
+ /**
4025
+ * SPA plugin — progressive client-side navigation layered over the static site:
4026
+ * swaps a page region on navigation, with an optional progress bar and View
4027
+ * Transitions. Register interactive islands with {@link createComponent}. Depends
4028
+ * on router and head; emits `spa:navigate`, `spa:navigated`, `spa:component-mount`,
4029
+ * and `spa:component-unmount`.
4030
+ *
4031
+ * @example Enable view transitions and a custom swap region
4032
+ * ```ts
4033
+ * const app = createApp({
4034
+ * pluginConfigs: {
4035
+ * spa: {
4036
+ * swapSelector: "main > section",
4037
+ * viewTransitions: true,
4038
+ * progressBar: true
4039
+ * }
4040
+ * }
4041
+ * });
4042
+ * ```
4043
+ */
4044
+ const spaPlugin = createPlugin$1("spa", {
4045
+ depends: [routerPlugin, headPlugin],
4046
+ config: defaultSpaConfig,
4047
+ createState,
4048
+ events: spaEvents,
4049
+ onInit: initSpa,
4050
+ api: createApi,
4051
+ onStart(ctx) {
4052
+ captureTeardown(ctx);
4053
+ ctx.state.kernel?.boot();
4054
+ },
4055
+ onStop: disposeSpa
4056
+ });
4057
+ //#endregion
4058
+ //#region src/plugins/content/api.ts
4059
+ /** Actionable error when the content plugin is composed without any provider. */
4060
+ const NO_PROVIDER = "[web] content: no provider composed.\n Add fileSystemContent(...) to pluginConfigs.content.providers.";
4061
+ /** Zero-pad width for the per-locale ordinal in a `contentId` (e.g. `0001`). */
4062
+ const ID_PADDING = 4;
4063
+ /** Path segment offset of the article slug — the parent directory of the source file. */
4064
+ const PATH_SLUG_INDEX = -2;
4065
+ /**
4066
+ * Collapse the ordered provider list into a single {@link ContentProvider} facade:
4067
+ * `slugs()` are unioned, `readArticle`/`render` use first-match, `invalidate` fans out.
4068
+ * A single-provider list returns that provider directly (the common case).
4069
+ *
4070
+ * @param providers - The ordered content providers from config.
4071
+ * @returns One provider facade over the list.
4072
+ * @example
4073
+ * ```ts
4074
+ * const provider = mergeProviders(ctx.config.providers);
4075
+ * ```
4076
+ */
4077
+ function mergeProviders(providers) {
4078
+ const [first] = providers;
4079
+ if (providers.length === 1 && first !== void 0) return first;
4080
+ return {
4081
+ name: providers.map((provider) => provider.name).join("+") || "content:empty",
4082
+ contentDir: first?.contentDir ?? "",
4083
+ /**
4084
+ * Union of every provider's slugs, sorted.
4085
+ *
4086
+ * @returns The merged slug list.
4087
+ * @example
4088
+ * ```ts
4089
+ * await provider.slugs();
4090
+ * ```
4091
+ */
4092
+ async slugs() {
4093
+ const lists = await Promise.all(providers.map((provider) => provider.slugs()));
4094
+ return [...new Set(lists.flat())].toSorted();
4095
+ },
4096
+ /**
4097
+ * First provider to supply the article wins.
4098
+ *
4099
+ * @param slug - Article directory name.
4100
+ * @param fileLocale - Locale whose source file is read.
4101
+ * @param outLocale - Locale the resulting Article represents.
4102
+ * @param isFallback - Whether this used the default-locale fallback.
4103
+ * @returns The first non-null Article, or `null`.
4104
+ * @example
4105
+ * ```ts
4106
+ * await provider.readArticle("intro", "en", "en", false);
4107
+ * ```
4108
+ */
4109
+ async readArticle(slug, fileLocale, outLocale, isFallback) {
4110
+ return (await Promise.all(providers.map((provider) => provider.readArticle(slug, fileLocale, outLocale, isFallback)))).find((article) => article !== null) ?? null;
4111
+ },
4112
+ /**
4113
+ * Render via the first provider.
4114
+ *
4115
+ * @param markdown - Raw Markdown source.
4116
+ * @returns The rendered HTML.
4117
+ * @throws {Error} If no provider is composed.
4118
+ * @example
4119
+ * ```ts
4120
+ * await provider.render("# Hi");
4121
+ * ```
4122
+ */
4123
+ async render(markdown) {
4124
+ if (first === void 0) throw new Error(NO_PROVIDER);
4125
+ return first.render(markdown);
4126
+ },
4127
+ /**
4128
+ * Fan invalidation out to every provider.
4129
+ *
4130
+ * @param paths - Stale file paths.
4131
+ * @example
4132
+ * ```ts
4133
+ * provider.invalidate(["content/intro/en.md"]);
4134
+ * ```
4135
+ */
4136
+ invalidate(paths) {
4137
+ for (const provider of providers) provider.invalidate?.(paths);
4138
+ }
4139
+ };
4140
+ }
4141
+ /**
4142
+ * Build the canonical "article not found" error for {@link createContentApi.load}.
4143
+ * Centralised so the null-resolve path and the production draft-suppression path
4144
+ * throw an IDENTICAL message — drafts must be indistinguishable from missing
4145
+ * articles in production (no new error shape).
4146
+ *
4147
+ * @param slug - Article directory name.
4148
+ * @param locale - Requested locale code.
4149
+ * @returns The not-found Error to throw.
4150
+ * @example
4151
+ * ```ts
4152
+ * throw articleNotFound("intro", "uk");
4153
+ * ```
4154
+ */
4155
+ function articleNotFound(slug, locale) {
4156
+ return /* @__PURE__ */ new Error(`[web] content article "${slug}" not found for locale "${locale}".\n Looked for ${slug}/${locale}.md and the default-locale fallback.`);
3476
4157
  }
3477
4158
  /**
3478
- * Release everything `captureTeardown`/`onStart` acquired: run teardown in
3479
- * try/catch (logging via the captured ref), then clear both handles. Idempotent —
3480
- * a second call is a no-op (spec/11 §4.2) and mirrors `onStart` (§4.1).
4159
+ * Plugin `api` factory: resolves i18n via `ctx.require`, merges `config.providers` into
4160
+ * one source, assembles the kernel-free {@link ContentApiContext}, and delegates to
4161
+ * {@link createContentApi}. Referenced directly as the plugin's `api` so index.ts stays
4162
+ * wiring-only. Imports no node code (the provider owns it).
3481
4163
  *
4164
+ * @param ctx - Plugin context (state, config, global, emit, require).
4165
+ * @returns The constructed content plugin API surface.
3482
4166
  * @example
3483
- * disposeSpa();
4167
+ * ```ts
4168
+ * const api = contentApi(ctx);
4169
+ * ```
3484
4170
  */
3485
- function disposeSpa() {
3486
- try {
3487
- teardown?.();
3488
- } catch (error) {
3489
- logRef?.error("spa:teardown-failed", {}, error);
3490
- } finally {
3491
- teardown = void 0;
3492
- logRef = void 0;
4171
+ function contentApi(ctx) {
4172
+ const i18nApi = ctx.require(i18nPlugin);
4173
+ /**
4174
+ * Active locale codes from i18n.
4175
+ *
4176
+ * @returns The configured locale list.
4177
+ * @example
4178
+ * ```ts
4179
+ * locales(); // ["en"]
4180
+ * ```
4181
+ */
4182
+ function locales() {
4183
+ return i18nApi.locales();
4184
+ }
4185
+ /**
4186
+ * Default locale code from i18n (fallback source).
4187
+ *
4188
+ * @returns The configured default locale.
4189
+ * @example
4190
+ * ```ts
4191
+ * defaultLocale(); // "en"
4192
+ * ```
4193
+ */
4194
+ function defaultLocale() {
4195
+ return i18nApi.defaultLocale();
3493
4196
  }
4197
+ return createContentApi({
4198
+ state: ctx.state,
4199
+ global: ctx.global,
4200
+ emit: ctx.emit,
4201
+ locales,
4202
+ defaultLocale,
4203
+ provider: mergeProviders(ctx.config.providers)
4204
+ });
3494
4205
  }
3495
- //#endregion
3496
- //#region src/plugins/spa/index.ts
3497
4206
  /**
3498
- * @file spa Complex Plugin (WIRING ONLY, ≤30 lines). All logic lives in the
3499
- * domain files (kernel/router/head/progress/components/lifecycle); index wires.
4207
+ * Resolve one article for `(slug, locale)` with locale fallback via the provider: the
4208
+ * native `{locale}` file is preferred (`isFallback: false`); when absent, the
4209
+ * default-locale file is used (`isFallback: true`, requested locale retained).
4210
+ * Returns `null` when neither exists.
3500
4211
  *
3501
- * Depends: router, head.
3502
- * Emits: spa:navigate, spa:navigated, spa:component-mount, spa:component-unmount.
3503
- * @see README.md
4212
+ * @param ctx - Kernel-free domain context.
4213
+ * @param slug - Article directory name.
4214
+ * @param locale - Requested locale code.
4215
+ * @returns The resolved Article, or `null` when nothing matches.
4216
+ * @example
4217
+ * ```ts
4218
+ * const article = await resolveArticle(ctx, "intro", "uk");
4219
+ * ```
3504
4220
  */
4221
+ async function resolveArticle(ctx, slug, locale) {
4222
+ const native = await ctx.provider.readArticle(slug, locale, locale, false);
4223
+ if (native !== null) return native;
4224
+ const fallbackLocale = ctx.defaultLocale();
4225
+ if (fallbackLocale === locale) return null;
4226
+ return ctx.provider.readArticle(slug, fallbackLocale, locale, true);
4227
+ }
3505
4228
  /**
3506
- * SPA plugin progressive client-side navigation layered over the static site:
3507
- * swaps a page region on navigation, with an optional progress bar and View
3508
- * Transitions. Register interactive islands with {@link createComponent}. Depends
3509
- * on router and head; emits `spa:navigate`, `spa:navigated`, `spa:component-mount`,
3510
- * and `spa:component-unmount`.
4229
+ * Comparator sorting articles by frontmatter date descending (newest first),
4230
+ * breaking ties by slug for deterministic ordering.
3511
4231
  *
3512
- * @example Enable view transitions and a custom swap region
4232
+ * @param a - First article.
4233
+ * @param b - Second article.
4234
+ * @returns Negative when `a` is newer, positive when older, 0 when equal.
4235
+ * @example
3513
4236
  * ```ts
3514
- * const app = createApp({
3515
- * pluginConfigs: {
3516
- * spa: {
3517
- * swapSelector: "main > section",
3518
- * viewTransitions: true,
3519
- * progressBar: true
3520
- * }
3521
- * }
3522
- * });
4237
+ * articles.toSorted(byDateDescending);
3523
4238
  * ```
3524
4239
  */
3525
- const spaPlugin = createPlugin$1("spa", {
3526
- depends: [routerPlugin, headPlugin],
3527
- config: defaultSpaConfig,
3528
- createState,
3529
- events: spaEvents,
3530
- onInit: initSpa,
3531
- api: createApi,
3532
- onStart(ctx) {
3533
- captureTeardown(ctx);
3534
- kernelRef.current?.boot();
3535
- },
3536
- onStop: disposeSpa
3537
- });
3538
- //#endregion
3539
- //#region src/plugins/data/load-json.ts
4240
+ function byDateDescending(a, b) {
4241
+ const byDate = b.frontmatter.date.localeCompare(a.frontmatter.date);
4242
+ return byDate === 0 ? a.computed.slug.localeCompare(b.computed.slug) : byDate;
4243
+ }
3540
4244
  /**
3541
- * @file `loadJson` the data plugin's isomorphic JSON read primitive (the
3542
- * SSG↔SPA seam). Internal to the `data` plugin (NOT a framework-root export):
3543
- * `data.load(locale)` uses it, and consumers read through `app.data.load(locale)`.
3544
- *
3545
- * A read runs in BOTH worlds: on Node it reads the emitted data file from disk;
3546
- * on the client (browser) it fetches the same data over HTTP. `loadJson` is the
3547
- * single point where those two worlds differ — everything above it (the route's
3548
- * `load`/`render`) is shared, so SSR/client parity is structural, not hoped-for.
4245
+ * Project a full Article to a lightweight ArticleCard (no rendered HTML).
3549
4246
  *
3550
- * The browser path uses the `fetch` global. The Node path lazy-imports
3551
- * `node:fs/promises` via `await import(...)`, so a browser bundle that includes
3552
- * `loadJson` never statically pulls `node:*` (the bundler splits the Node branch
3553
- * into its own chunk that the browser never loads).
4247
+ * @param article - The source article.
4248
+ * @returns The card projection.
4249
+ * @example
4250
+ * ```ts
4251
+ * const card = toCard(article);
4252
+ * ```
3554
4253
  */
4254
+ function toCard(article) {
4255
+ return {
4256
+ contentId: article.computed.contentId,
4257
+ status: article.computed.status,
4258
+ title: article.frontmatter.title,
4259
+ date: article.frontmatter.date,
4260
+ description: article.frontmatter.description,
4261
+ tags: article.frontmatter.tags,
4262
+ readingTime: article.computed.readingTime,
4263
+ url: article.url
4264
+ };
4265
+ }
3555
4266
  /**
3556
- * Read + parse a JSON resource, isomorphically. In a browser (`document`
3557
- * defined) it `fetch`es `pathOrUrl`; on Node it reads the file from disk. Throws
3558
- * on a failed fetch or unreadable file so the caller (`route.load`/`data.load`)
3559
- * can decide whether to fall back.
4267
+ * Whether an article belongs in a locale collection: every article is published
4268
+ * outside production, and in production all non-`draft` articles are published.
3560
4269
  *
3561
- * @template T - The expected shape of the parsed JSON.
3562
- * @param pathOrUrl - A site-root URL (browser) or filesystem path (Node).
3563
- * @returns The parsed JSON, typed as `T`.
3564
- * @throws {Error} If the browser fetch is not OK, or the Node file read fails.
4270
+ * @param article - The candidate article.
4271
+ * @param isProduction - Whether the deployment stage is production.
4272
+ * @returns `true` when the article should be included.
3565
4273
  * @example
3566
4274
  * ```ts
3567
- * // Browser: fetch("/_data/en/articles.json")
3568
- * // Node: read "dist/_data/en/articles.json"
3569
- * const articles = await loadJson<Article[]>("/_data/en/articles.json");
4275
+ * isPublished(article, true); // false for a draft in production
3570
4276
  * ```
3571
4277
  */
3572
- async function loadJson(pathOrUrl) {
3573
- if (typeof document === "undefined") {
3574
- const { readFile } = await import("node:fs/promises");
3575
- return JSON.parse(await readFile(pathOrUrl, "utf8"));
3576
- }
3577
- const response = await fetch(pathOrUrl);
3578
- if (!response.ok) throw new Error(`[web] loadJson: failed to fetch ${pathOrUrl} (${String(response.status)}).`);
3579
- return response.json();
4278
+ function isPublished(article, isProduction) {
4279
+ return !isProduction || article.computed.status !== "draft";
3580
4280
  }
3581
- //#endregion
3582
- //#region src/plugins/data/api.ts
3583
4281
  /**
3584
- * @file data plugin API factory (the agnostic data provider surface).
4282
+ * Resolve every slug for one locale, then narrow to the articles that belong in the
4283
+ * locale collection: existing files only, drafts dropped in production, sorted
4284
+ * date-descending. The single load+filter+sort step behind {@link createContentApi.loadAll}.
3585
4285
  *
3586
- * Node-free by construction: this module statically imports only types + the pure
3587
- * convention. The Node write side (`write()`) reaches its `node:fs` writer through
3588
- * a lazy `await import("./writer")` at call time, so a browser bundle that composes
3589
- * `data` for the read side never pulls `node:*`. The read side (`at()`) uses only
3590
- * the isomorphic `loadJson` (whose Node branch is itself lazy).
4286
+ * @param ctx - Kernel-free domain context (provider + i18n helpers + stage).
4287
+ * @param slugs - Every known article slug from the provider.
4288
+ * @param locale - The locale to resolve and collect.
4289
+ * @returns The published (date-descending) articles for this locale.
4290
+ * @example
4291
+ * ```ts
4292
+ * const present = await loadAndFilterArticles(ctx, slugs, "en");
4293
+ * ```
3591
4294
  */
4295
+ async function loadAndFilterArticles(ctx, slugs, locale) {
4296
+ const isProduction = ctx.global.stage === "production";
4297
+ return (await Promise.all(slugs.map((slug) => resolveArticle(ctx, slug, locale)))).filter((article) => article !== null).filter((article) => isPublished(article, isProduction)).toSorted(byDateDescending);
4298
+ }
3592
4299
  /**
3593
- * Trim a single trailing slash from a config dir so `fileFor` joins cleanly.
4300
+ * Derive the article slug from a source file path the parent directory name
4301
+ * (`content/intro/en.md` → `intro`). Returns `undefined` when the path has no
4302
+ * parent segment.
3594
4303
  *
3595
- * @param dir - The configured output dir (e.g. `"_data"` or `"_data/"`).
3596
- * @returns The dir without a trailing slash.
4304
+ * @param filePath - The (possibly stale) source file path.
4305
+ * @returns The slug segment, or `undefined` when none exists.
3597
4306
  * @example
3598
4307
  * ```ts
3599
- * trimTrailingSlash("_data/"); // "_data"
4308
+ * extractSlugFromPath("content/intro/en.md"); // "intro"
3600
4309
  * ```
3601
4310
  */
3602
- function trimTrailingSlash(dir) {
3603
- return dir.endsWith("/") ? dir.slice(0, -1) : dir;
4311
+ function extractSlugFromPath(filePath) {
4312
+ return filePath.split(/[/\\]/).at(PATH_SLUG_INDEX);
3604
4313
  }
3605
4314
  /**
3606
- * Builds the data provider the agnostic bridge. `write()` is the Node persist
3607
- * side; `at()` is the browser read side; `urlFor`/`fileFor` are the pure
3608
- * convention. No `onStart`/`onStop` (holds no long-lived resource).
4315
+ * Creates the content plugin API surface (loadAll, load, renderMarkdown, invalidate,
4316
+ * articleToCard, contentDir) over the kernel-free domain context. Delegates all source
4317
+ * reads to `ctx.provider`; drafts are excluded only in production; `loadAll` emits
4318
+ * `content:ready` and `invalidate` emits `content:invalidated`.
3609
4319
  *
3610
- * @param ctx - The data plugin context.
3611
- * @returns The {@link DataProvider} mounted at `app.data`.
4320
+ * @param ctx - Kernel-free domain context (state, global, emit, i18n helpers, provider).
4321
+ * @returns The content plugin {@link Api} surface.
3612
4322
  * @example
3613
4323
  * ```ts
3614
- * const api = dataApi(ctx);
3615
- * await api.write([{ path: "/en/hello/", data: article }]); // Node build
3616
- * await api.at("/en/hello/"); // browser
4324
+ * const api = createContentApi(apiContext);
4325
+ * const byLocale = await api.loadAll();
3617
4326
  * ```
3618
4327
  */
3619
- function dataApi(ctx) {
4328
+ function createContentApi(ctx) {
3620
4329
  return {
3621
4330
  /**
3622
- * READ (browser) fetch (and cache) the persisted data for a page path.
3623
- * Returns the raw JSON as `unknown` (the caller's `route.parse` validates it),
3624
- * or `null` if the fetch/parse fails (so `spa` can fall back to HTML).
4331
+ * Load every article across every active locale (locale fallback, production
4332
+ * draft exclusion, date sort, `contentId` after sort), cache them, emit `content:ready`.
3625
4333
  *
3626
- * @param path - The page URL path (e.g. `/en/hello/`).
3627
- * @returns The page's raw data, or `null` on failure.
4334
+ * @returns A locale-keyed map of date-descending articles.
3628
4335
  * @example
3629
4336
  * ```ts
3630
- * const raw = await api.at("/en/hello/");
4337
+ * const byLocale = await api.loadAll();
3631
4338
  * ```
3632
4339
  */
3633
- async at(path) {
3634
- if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
3635
- try {
3636
- const data = await loadJson(`${ctx.config.baseUrl}${dataSuffix(path)}`);
3637
- ctx.state.cache.set(path, data);
3638
- return data;
3639
- } catch {
3640
- return null;
4340
+ async loadAll() {
4341
+ const slugs = await ctx.provider.slugs();
4342
+ const locales = ctx.locales();
4343
+ const result = /* @__PURE__ */ new Map();
4344
+ let total = 0;
4345
+ for (const locale of locales) {
4346
+ const present = await loadAndFilterArticles(ctx, slugs, locale);
4347
+ const cache = /* @__PURE__ */ new Map();
4348
+ let index = 0;
4349
+ for (const article of present) {
4350
+ article.computed.contentId = `${locale}:${String(index).padStart(ID_PADDING, "0")}:${article.computed.slug}`;
4351
+ cache.set(article.computed.slug, article);
4352
+ index += 1;
4353
+ }
4354
+ ctx.state.articles.set(locale, cache);
4355
+ result.set(locale, present);
4356
+ total += present.length;
3641
4357
  }
4358
+ ctx.emit("content:ready", {
4359
+ locales,
4360
+ articleCount: total
4361
+ });
4362
+ return result;
3642
4363
  },
3643
4364
  /**
3644
- * WRITE (Node) persist one JSON file per entry, keyed by page path. Called by
3645
- * `build` after it expands routes. Lazily loads its `node:fs` writer (keeping a
3646
- * browser bundle node-free).
4365
+ * Resolve and render a single article for one locale with locale fallback. Throws a
4366
+ * `[web] content` not-found error when no file matches; in production a `draft` is
4367
+ * suppressed and throws the SAME not-found error (drafts indistinguishable from
4368
+ * missing); in development and test drafts load normally.
3647
4369
  *
3648
- * @param entries - The per-page data to persist.
3649
- * @param options - Optional `{ outDir }` override (defaults to `./dist`).
3650
- * @param options.outDir - Build output directory the write happens under.
3651
- * @returns A summary of the written files.
4370
+ * @param slug - Article directory name.
4371
+ * @param locale - Requested locale code.
4372
+ * @returns The resolved Article.
4373
+ * @throws {Error} `[web] content` not-found when no file matches, or when the
4374
+ * resolved article is a draft and `global.stage === "production"`.
3652
4375
  * @example
3653
4376
  * ```ts
3654
- * await api.write([{ path: "/en/hello/", data: article }], { outDir: "dist" });
4377
+ * const article = await api.load("intro", "uk");
3655
4378
  * ```
3656
4379
  */
3657
- async write(entries, options) {
3658
- const { writeData } = await import("./writer-BcWqa_7I.mjs");
3659
- return writeData(ctx, entries, options);
4380
+ async load(slug, locale) {
4381
+ const article = await resolveArticle(ctx, slug, locale);
4382
+ if (article === null) throw articleNotFound(slug, locale);
4383
+ if (ctx.global.stage === "production" && article.computed.status === "draft") throw articleNotFound(slug, locale);
4384
+ const cache = ctx.state.articles.get(locale) ?? /* @__PURE__ */ new Map();
4385
+ cache.set(slug, article);
4386
+ ctx.state.articles.set(locale, cache);
4387
+ return article;
3660
4388
  },
3661
4389
  /**
3662
- * PURE the browser fetch URL for a page path.
4390
+ * Render a raw Markdown string to HTML through the provider's pipeline.
3663
4391
  *
3664
- * @param path - The page URL path.
3665
- * @returns The site-root-relative data URL.
4392
+ * @param md - Raw Markdown source.
4393
+ * @returns The rendered HTML string.
3666
4394
  * @example
3667
4395
  * ```ts
3668
- * api.urlFor("/en/hello/"); // "/_data/en/hello/index.json"
4396
+ * const html = await api.renderMarkdown("# Hi");
3669
4397
  * ```
3670
4398
  */
3671
- urlFor(path) {
3672
- return `${ctx.config.baseUrl}${dataSuffix(path)}`;
4399
+ async renderMarkdown(md) {
4400
+ return ctx.provider.render(md);
3673
4401
  },
3674
4402
  /**
3675
- * PURE the `outDir`-relative file path for a page path.
4403
+ * Mark file paths stale for incremental dev rebuilds: fan invalidation to the
4404
+ * provider and drop the derived slug cache entries so the next `loadAll()` re-reads
4405
+ * only those files. Empty/whitespace paths are ignored. Emits `content:invalidated`.
3676
4406
  *
3677
- * @param path - The page URL path.
3678
- * @returns The output-relative file path.
4407
+ * @param paths - File paths to invalidate.
3679
4408
  * @example
3680
4409
  * ```ts
3681
- * api.fileFor("/en/hello/"); // "_data/en/hello/index.json"
4410
+ * api.invalidate(["src/content/intro/en.md"]);
3682
4411
  * ```
3683
4412
  */
3684
- fileFor(path) {
3685
- return `${trimTrailingSlash(ctx.config.outputDir)}/${dataSuffix(path)}`;
4413
+ invalidate(paths) {
4414
+ const accepted = paths.filter((filePath) => filePath.trim() !== "");
4415
+ ctx.provider.invalidate?.(accepted);
4416
+ for (const filePath of accepted) {
4417
+ const slug = extractSlugFromPath(filePath);
4418
+ if (slug === void 0) continue;
4419
+ for (const cache of ctx.state.articles.values()) cache.delete(slug);
4420
+ }
4421
+ ctx.emit("content:invalidated", { paths: accepted });
4422
+ },
4423
+ /**
4424
+ * Project a full Article to a lightweight ArticleCard for list/grid rendering.
4425
+ *
4426
+ * @param article - The source article.
4427
+ * @returns The card projection.
4428
+ * @example
4429
+ * ```ts
4430
+ * const card = api.articleToCard(article);
4431
+ * ```
4432
+ */
4433
+ articleToCard(article) {
4434
+ return toCard(article);
4435
+ },
4436
+ /**
4437
+ * The configured content source directory (from the first provider).
4438
+ *
4439
+ * @returns The content directory path.
4440
+ * @example
4441
+ * ```ts
4442
+ * api.contentDir(); // "./content"
4443
+ * ```
4444
+ */
4445
+ contentDir() {
4446
+ return ctx.provider.contentDir;
3686
4447
  }
3687
4448
  };
3688
4449
  }
3689
4450
  //#endregion
3690
- //#region src/plugins/data/config.ts
4451
+ //#region src/plugins/content/config.ts
3691
4452
  /**
3692
- * Typed default data config (R6: no inline `as`). `outputDir` is the WRITE path
3693
- * (filesystem, relative to the build `outDir`); `baseUrl` is the matching READ URL
3694
- * (site-root-relative) the browser fetches from the defaults agree
3695
- * (`"_data"` ↔ `"/_data/"`).
4453
+ * Typed default content config (R6: no inline `as`). The provider list defaults to
4454
+ * `[]`; a build MUST compose at least one (e.g. `fileSystemContent(...)`), enforced at
4455
+ * `onInit`. Source + pipeline options now live on the provider, not here.
3696
4456
  *
3697
4457
  * @example
3698
4458
  * ```ts
3699
- * createPlugin("data", { config: defaultDataConfig });
4459
+ * createPlugin("content", { config: defaultContentConfig });
3700
4460
  * ```
3701
4461
  */
3702
- const defaultDataConfig = {
3703
- outputDir: "_data",
3704
- baseUrl: "/_data/"
3705
- };
4462
+ const defaultContentConfig = { providers: [] };
3706
4463
  //#endregion
3707
- //#region src/plugins/data/state.ts
4464
+ //#region src/plugins/content/events.ts
3708
4465
  /**
3709
- * Creates initial data state: a null `lastWrite` slot (populated by the Node
3710
- * `write()` side) and an empty `cache` (populated lazily by the browser `at(path)`
3711
- * side on first fetch).
4466
+ * Registers the content plugin's notification-only events (`content:ready`,
4467
+ * `content:invalidated`) with their typed payloads. Referenced as the plugin's
4468
+ * `events` callback so index.ts stays wiring-only.
4469
+ *
4470
+ * @param register - Kernel-provided typed event registrar.
4471
+ * @returns The content event descriptor map.
4472
+ * @example
4473
+ * ```ts
4474
+ * createPlugin("content", { events: contentEvents });
4475
+ * ```
4476
+ */
4477
+ const contentEvents = (register) => ({
4478
+ "content:ready": register("All articles loaded across locales"),
4479
+ "content:invalidated": register("Article paths marked stale for dev rebuild")
4480
+ });
4481
+ //#endregion
4482
+ //#region src/plugins/content/state.ts
4483
+ /**
4484
+ * Creates initial content plugin shell state — an empty article cache. The lazy
4485
+ * unified processor + discovery caches live in the provider, not here.
3712
4486
  *
3713
4487
  * @param _ctx - Minimal context with global and config.
3714
- * @param _ctx.global - Global framework configuration.
4488
+ * @param _ctx.global - Global plugin registry.
3715
4489
  * @param _ctx.config - Resolved plugin configuration.
3716
- * @returns Fresh data state with no recorded write and an empty per-path cache.
4490
+ * @returns Fresh content shell state: an empty article cache.
3717
4491
  * @example
3718
4492
  * ```ts
3719
- * const state = createDataState({ global: {}, config });
4493
+ * const state = createContentState({ global: {}, config: { providers: [] } });
3720
4494
  * ```
3721
4495
  */
3722
- function createDataState(_ctx) {
3723
- return {
3724
- lastWrite: null,
3725
- cache: /* @__PURE__ */ new Map()
3726
- };
4496
+ function createContentState(_ctx) {
4497
+ return { articles: /* @__PURE__ */ new Map() };
3727
4498
  }
3728
4499
  //#endregion
3729
- //#region src/plugins/data/validate.ts
4500
+ //#region src/plugins/content/validate.ts
3730
4501
  /**
3731
- * Validates the resolved data config: the browser `baseUrl` must be a non-empty,
3732
- * site-root-relative URL path. The emit/read pipelines are wired in build waves 3/4.
4502
+ * Validates the resolved content config (fail-fast at `createApp`). Throws when no
4503
+ * content provider is composed content is useless without a source. Errors use the
4504
+ * `[web]` prefix. (Per-provider options like `contentDir` are validated by the provider.)
3733
4505
  *
3734
- * @param config - The resolved plugin configuration.
3735
- * @throws {Error} If `baseUrl` is empty or not a rooted URL path.
4506
+ * @param config - Resolved content plugin configuration.
4507
+ * @throws {Error} If `providers` is empty.
3736
4508
  * @example
3737
4509
  * ```ts
3738
- * validateDataConfig({ outputDir: "_data", baseUrl: "/_data/" });
4510
+ * validateContentConfig(config);
3739
4511
  * ```
3740
4512
  */
3741
- function validateDataConfig(config) {
3742
- 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/").`);
4513
+ function validateContentConfig(config) {
4514
+ if (!Array.isArray(config.providers) || config.providers.length === 0) throw new Error("[web] content: no provider composed.\n Add fileSystemContent(...) to pluginConfigs.content.providers.");
3743
4515
  }
3744
4516
  //#endregion
3745
- //#region src/plugins/data/index.ts
4517
+ //#region src/plugins/content/index.ts
3746
4518
  /**
3747
- * @file dataStandard tier plugin (wiring-only). The AGNOSTIC data provider for
3748
- * the SSG→DATA→SPA pattern.
3749
- *
3750
- * Owns ONE contract — `page path → persisted JSON file` — and nothing about what
3751
- * the data is: `write(entries)` persists per-page JSON on Node (build supplies the
3752
- * entries it already expanded); `at(path)` fetches + caches it in the browser as
3753
- * `unknown`, which the route's `parse` validates before `render`. NOT a framework
3754
- * default — the consumer composes it where needed (Node build AND/OR browser app).
4519
+ * @file contentComplex Plugin skeleton (wiring-only).
3755
4520
  *
3756
- * **No hard `depends`** fully browser-composable; the `node:fs` writer is behind
3757
- * a lazy `import()` inside `write()`. Build ordering is a call-site contract: build
3758
- * writes data during its pages phase (after its Phase-0 clean), via `app.data.write`.
3759
- * No `onStart`/`onStop`.
4521
+ * Markdown pipeline: discover, parse frontmatter, render to sanitized HTML, and
4522
+ * expose a locale-keyed Article model. Depends on i18n. Emits `content:ready`
4523
+ * and `content:invalidated`.
3760
4524
  * @see README.md
3761
4525
  */
3762
4526
  /**
3763
- * Data plugin — the agnostic data provider. Mounts `write(entries)` (Node persist),
3764
- * `at(path)` (browser read), and the pure `urlFor`/`fileFor` convention at `app.data`.
4527
+ * Content plugin (shell) provider-driven locale-keyed Article model. Orchestration
4528
+ * (locale fallback, draft filtering, sort, caching, events) lives here; source I/O +
4529
+ * the Markdown pipeline live in a {@link ContentProvider} you compose (like `env`
4530
+ * providers). The shell imports zero node code, so `contentPlugin` is browser-safe.
4531
+ * Depends on i18n; emits `content:ready` and `content:invalidated`.
3765
4532
  *
3766
- * @example
4533
+ * @example Compose the node filesystem provider with a content dir + Shiki theme
3767
4534
  * ```ts
3768
- * // Node build: `build` calls app.data.write(...) during its pages phase when
3769
- * // router.mode !== "ssg". Just compose the plugin:
4535
+ * import { contentPlugin, fileSystemContent } from "@moku-labs/web";
3770
4536
  * const app = createApp({
3771
- * plugins: [dataPlugin, contentPlugin, buildPlugin],
3772
- * pluginConfigs: { content: { contentDir: "./content" }, router: { routes, mode: "hybrid" } }
4537
+ * plugins: [contentPlugin],
4538
+ * pluginConfigs: {
4539
+ * content: {
4540
+ * providers: [fileSystemContent({ contentDir: "./content", shikiTheme: "github-dark", defaultAuthor: "Ada" })]
4541
+ * }
4542
+ * }
3773
4543
  * });
3774
- * await app.start();
3775
- * await app.build.run(); // writes HTML + per-page data sidecars
3776
- *
3777
- * // Browser app: compose `dataPlugin` too; spa fetches via app.data.at(path) on nav.
3778
4544
  * ```
3779
4545
  */
3780
- const dataPlugin = createPlugin$1("data", {
3781
- config: defaultDataConfig,
3782
- createState: createDataState,
3783
- onInit: (ctx) => validateDataConfig(ctx.config),
3784
- api: dataApi
4546
+ const contentPlugin = createPlugin$1("content", {
4547
+ depends: [i18nPlugin],
4548
+ events: contentEvents,
4549
+ config: defaultContentConfig,
4550
+ createState: createContentState,
4551
+ onInit: (ctx) => validateContentConfig(ctx.config),
4552
+ api: contentApi
3785
4553
  });
3786
4554
  //#endregion
3787
- //#region src/plugins/data/types.ts
4555
+ //#region src/plugins/content/types.ts
3788
4556
  var types_exports = /* @__PURE__ */ __exportAll({});
3789
4557
  //#endregion
3790
- //#region src/plugins/env/types.ts
4558
+ //#region src/plugins/data/types.ts
3791
4559
  var types_exports$1 = /* @__PURE__ */ __exportAll({});
3792
4560
  //#endregion
3793
- //#region src/plugins/head/types.ts
4561
+ //#region src/plugins/env/types.ts
3794
4562
  var types_exports$2 = /* @__PURE__ */ __exportAll({});
3795
4563
  //#endregion
3796
- //#region src/plugins/log/types.ts
4564
+ //#region src/plugins/head/types.ts
3797
4565
  var types_exports$3 = /* @__PURE__ */ __exportAll({});
3798
4566
  //#endregion
3799
- //#region src/plugins/router/types.ts
4567
+ //#region src/plugins/log/types.ts
3800
4568
  var types_exports$4 = /* @__PURE__ */ __exportAll({});
3801
4569
  //#endregion
4570
+ //#region src/plugins/router/types.ts
4571
+ var types_exports$5 = /* @__PURE__ */ __exportAll({});
4572
+ //#endregion
3802
4573
  //#region src/browser.ts
3803
4574
  /**
3804
4575
  * @file `@moku-labs/web/browser` — the browser-safe entry point.
@@ -3842,20 +4613,16 @@ const core = createCore(coreConfig, {
3842
4613
  *
3843
4614
  * @param options - Optional configuration:
3844
4615
  * - `pluginConfigs` — per-plugin overrides, keyed by plugin name.
3845
- * - `config` — global framework config (e.g. `{ mode: "development" }`).
4616
+ * - `config` — global framework config (e.g. `{ mode: "spa" }`).
3846
4617
  * - `plugins` — extra plugins (e.g. `dataPlugin` or your own) merged into the app and its type.
3847
4618
  * - `onReady` / `onError` / `onStart` / `onStop` — lifecycle callbacks.
3848
4619
  * @returns The initialized app: `start()`, `stop()`, every plugin's API, and `log`.
3849
4620
  * @example
3850
4621
  * ```ts
3851
4622
  * // Client SPA — env works with no wiring (browserEnv is the default provider):
3852
- * const app = createApp({
3853
- * plugins: [dataPlugin],
3854
- * pluginConfigs: {
3855
- * router: { mode: "spa", routes: defineRoutes({ home: route("/"), post: route("/blog/{slug}/") }) }
3856
- * }
3857
- * });
3858
- * await app.start();
4623
+ * import * as routes from "./routes";
4624
+ * const app = createApp({ config: { mode: "spa" }, pluginConfigs: { router: { routes } } });
4625
+ * await app.start(); // routes compiled at init from config
3859
4626
  * app.env.get("PUBLIC_API_URL"); // resolved from import.meta.env
3860
4627
  * ```
3861
4628
  */
@@ -3877,4 +4644,4 @@ const createApp = core.createApp;
3877
4644
  */
3878
4645
  const createPlugin = core.createPlugin;
3879
4646
  //#endregion
3880
- export { types_exports as Data, types_exports$1 as Env, types_exports$2 as Head, types_exports$3 as Log, types_exports$4 as Router, types_exports$5 as Spa, browserEnv, buildArticleHead, canonical, createApp, createComponent, createPlugin, dataPlugin, defineRoutes, envPlugin, feedLink, headPlugin, hreflang, i18nPlugin, jsonLd, logPlugin, meta, og, route, routerPlugin, sitePlugin, spaPlugin, twitter };
4647
+ export { types_exports as Content, types_exports$1 as Data, types_exports$2 as Env, types_exports$3 as Head, types_exports$4 as Log, types_exports$5 as Router, types_exports$6 as Spa, browserEnv, buildArticleHead, canonical, contentPlugin, createApp, createComponent, createPlugin, createUrls, dataPlugin, defineRoutes, envPlugin, feedLink, headPlugin, hreflang, i18nPlugin, jsonLd, logPlugin, meta, og, route, routerPlugin, sitePlugin, spaPlugin, twitter };