@moku-labs/web 0.6.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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,10 +371,44 @@ 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
  /**
378
+ * Tests whether `actual` is an array that recursively matches every element of
379
+ * the `partial` array (element-wise, with equal length).
380
+ *
381
+ * @param actual - The value to test against (must be an array of equal length).
382
+ * @param partial - The expected partial array shape.
383
+ * @returns `true` when `actual` is an equal-length array matching `partial` element-wise.
384
+ * @example
385
+ * ```ts
386
+ * matchesPartialArray([1, 2], [1, 2]); // true
387
+ * matchesPartialArray([1], [1, 2]); // false (length mismatch)
388
+ * ```
389
+ */
390
+ function matchesPartialArray(actual, partial) {
391
+ if (!Array.isArray(actual) || actual.length !== partial.length) return false;
392
+ return partial.every((value, index) => matchesPartial(actual[index], value));
393
+ }
394
+ /**
395
+ * Tests whether `actual` is a plain object in which every `partial` key
396
+ * recursively matches (extra `actual` keys are ignored).
397
+ *
398
+ * @param actual - The value to test against (must be a plain object).
399
+ * @param partial - The expected partial object shape.
400
+ * @returns `true` when every `partial` key exists in `actual` and matches recursively.
401
+ * @example
402
+ * ```ts
403
+ * matchesPartialObject({ a: 1, b: 2 }, { a: 1 }); // true
404
+ * matchesPartialObject({ a: 1 }, { b: 1 }); // false (missing key)
405
+ * ```
406
+ */
407
+ function matchesPartialObject(actual, partial) {
408
+ if (!isPlainObject$1(actual)) return false;
409
+ return Object.keys(partial).every((key) => key in actual && matchesPartial(actual[key], partial[key]));
410
+ }
411
+ /**
332
412
  * Subset-equality matcher: is `partial` a recursive subset of `actual`?
333
413
  *
334
414
  * Fast path via `Object.is` (covers identical primitives/references and
@@ -347,14 +427,8 @@ function isPlainObject(value) {
347
427
  */
348
428
  function matchesPartial(actual, partial) {
349
429
  if (Object.is(actual, partial)) return true;
350
- if (Array.isArray(partial)) {
351
- if (!Array.isArray(actual) || actual.length !== partial.length) return false;
352
- return partial.every((value, index) => matchesPartial(actual[index], value));
353
- }
354
- if (isPlainObject(partial)) {
355
- if (!isPlainObject(actual)) return false;
356
- return Object.keys(partial).every((key) => key in actual && matchesPartial(actual[key], partial[key]));
357
- }
430
+ if (Array.isArray(partial)) return matchesPartialArray(actual, partial);
431
+ if (isPlainObject$1(partial)) return matchesPartialObject(actual, partial);
358
432
  return false;
359
433
  }
360
434
  /**
@@ -387,6 +461,22 @@ function describePartial(partial) {
387
461
  return partial === void 0 ? "" : ` matching ${JSON.stringify(partial)}`;
388
462
  }
389
463
  /**
464
+ * Find the first entry with `event` at or after `startIndex`, scanning forward.
465
+ *
466
+ * @param entries - The trace array to scan.
467
+ * @param event - Event name to find.
468
+ * @param startIndex - Index to begin scanning from (inclusive).
469
+ * @returns The index of the first match, or `-1` when none exists from `startIndex` on.
470
+ * @example
471
+ * ```ts
472
+ * findEventAtOrAfter([{ event: "a" }, { event: "b" }] as LogEntry[], "b", 0); // 1
473
+ * ```
474
+ */
475
+ function findEventAtOrAfter(entries, event, startIndex) {
476
+ for (let index = startIndex; index < entries.length; index++) if (entries[index]?.event === event) return index;
477
+ return -1;
478
+ }
479
+ /**
390
480
  * Create a fluent assertion chain bound to the live `entries` array. Each method
391
481
  * reads `entries` at call time, so assertions reflect later logging.
392
482
  *
@@ -429,13 +519,9 @@ function createExpectChain(entries) {
429
519
  toHaveEventInOrder(events) {
430
520
  let cursor = 0;
431
521
  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;
522
+ const matchIndex = findEventAtOrAfter(entries, event, cursor);
523
+ 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}.`);
524
+ cursor = matchIndex + 1;
439
525
  }
440
526
  return chain;
441
527
  },
@@ -491,13 +577,28 @@ function append(state, level, event, data) {
491
577
  for (const sink of state.sinks) sink.write(entry);
492
578
  }
493
579
  /**
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.
580
+ * Tests whether a value is a non-null, non-array plain object.
581
+ *
582
+ * @param value - The value to test.
583
+ * @returns `true` when `value` is a non-null object that is not an array.
584
+ * @example
585
+ * ```ts
586
+ * isPlainObject({ a: 1 }); // true
587
+ * isPlainObject([1]); // false
588
+ * ```
589
+ */
590
+ function isPlainObject(value) {
591
+ return typeof value === "object" && value !== null && !Array.isArray(value);
592
+ }
593
+ /**
594
+ * Merge an `Error`'s `message`/`stack` into `data` under an `error` key. The
595
+ * `error` field is always preserved; only a plain object `data` contributes its
596
+ * keys. Non-plain-object `data` (arrays and primitives) is replaced by `{}` —
597
+ * its original value is not retained — so the merge target is always a record.
497
598
  *
498
599
  * @param data - Original payload (any shape).
499
600
  * @param error - The originating error to merge.
500
- * @returns A new object carrying the original keys plus the `error` field.
601
+ * @returns A new object carrying any plain-object keys plus the `error` field.
501
602
  * @example
502
603
  * ```ts
503
604
  * mergeError({ target: "cf" }, new Error("boom"));
@@ -506,7 +607,7 @@ function append(state, level, event, data) {
506
607
  */
507
608
  function mergeError(data, error) {
508
609
  return {
509
- ...typeof data === "object" && data !== null && !Array.isArray(data) ? data : {},
610
+ ...isPlainObject(data) ? data : {},
510
611
  error: {
511
612
  message: error.message,
512
613
  stack: error.stack
@@ -730,8 +831,22 @@ const logPlugin = createCorePlugin("log", {
730
831
  * @file Framework configuration — Config + Events types, core plugin registration.
731
832
  * @see README.md
732
833
  */
834
+ /**
835
+ * Step 1 of the factory chain — captures the framework's `Config`/`Events` contract
836
+ * and registers the core plugins (`log`, `env`) whose APIs are injected onto every
837
+ * regular plugin's context. Consumers never use this directly; it backs the exported
838
+ * {@link createPlugin} and {@link createCore}.
839
+ *
840
+ * @example
841
+ * ```ts
842
+ * const { createPlugin, createCore } = coreConfig;
843
+ * ```
844
+ */
733
845
  const coreConfig = createCoreConfig("web", {
734
- config: { mode: "production" },
846
+ config: {
847
+ stage: "production",
848
+ mode: "hybrid"
849
+ },
735
850
  plugins: [logPlugin, envPlugin],
736
851
  pluginConfigs: { log: { mode: "production" } }
737
852
  });
@@ -917,6 +1032,40 @@ const i18nPlugin = createPlugin$1("i18n", {
917
1032
  //#region src/plugins/site/api.ts
918
1033
  /** Error prefix for all site lifecycle/validation failures. */
919
1034
  const ERROR_PREFIX$7 = "[web]";
1035
+ /** URL protocols that qualify a parsed URL as an absolute http/https URL. */
1036
+ const HTTP_PROTOCOLS = new Set(["http:", "https:"]);
1037
+ /**
1038
+ * Strips every trailing "/" from a value, so it can own the single slash that a
1039
+ * join boundary inserts.
1040
+ *
1041
+ * @param value - The string to trim (e.g. an absolute base URL).
1042
+ * @returns The value with all trailing slashes removed.
1043
+ * @example
1044
+ * ```ts
1045
+ * trimTrailingSlashes("https://blog.dev//"); // "https://blog.dev"
1046
+ * ```
1047
+ */
1048
+ function trimTrailingSlashes(value) {
1049
+ let trimmed = value;
1050
+ while (trimmed.endsWith("/")) trimmed = trimmed.slice(0, -1);
1051
+ return trimmed;
1052
+ }
1053
+ /**
1054
+ * Strips every leading "/" from a value, so the join boundary is the only slash
1055
+ * separating it from the base.
1056
+ *
1057
+ * @param value - The string to trim (e.g. a relative path).
1058
+ * @returns The value with all leading slashes removed.
1059
+ * @example
1060
+ * ```ts
1061
+ * trimLeadingSlashes("//about/"); // "about/"
1062
+ * ```
1063
+ */
1064
+ function trimLeadingSlashes(value) {
1065
+ let trimmed = value;
1066
+ while (trimmed.startsWith("/")) trimmed = trimmed.slice(1);
1067
+ return trimmed;
1068
+ }
920
1069
  /**
921
1070
  * Joins a relative path against an absolute base URL, normalizing the slash
922
1071
  * boundary to exactly one "/". Returns the base unchanged for an empty or
@@ -931,12 +1080,9 @@ const ERROR_PREFIX$7 = "[web]";
931
1080
  * ```
932
1081
  */
933
1082
  function joinCanonical(base, path) {
934
- let trimmedBase = base;
935
- while (trimmedBase.endsWith("/")) trimmedBase = trimmedBase.slice(0, -1);
1083
+ const trimmedBase = trimTrailingSlashes(base);
936
1084
  if (path === "" || path === "/") return trimmedBase;
937
- let trimmedPath = path;
938
- while (trimmedPath.startsWith("/")) trimmedPath = trimmedPath.slice(1);
939
- return `${trimmedBase}/${trimmedPath}`;
1085
+ return `${trimmedBase}/${trimLeadingSlashes(path)}`;
940
1086
  }
941
1087
  /**
942
1088
  * Validates that a string is a non-empty trimmed value.
@@ -964,7 +1110,7 @@ function isNonEmpty(value) {
964
1110
  function isAbsoluteUrl(value) {
965
1111
  try {
966
1112
  const parsed = new URL(value);
967
- return parsed.protocol === "http:" || parsed.protocol === "https:";
1113
+ return HTTP_PROTOCOLS.has(parsed.protocol);
968
1114
  } catch {
969
1115
  return false;
970
1116
  }
@@ -1098,19 +1244,86 @@ const sitePlugin = createPlugin$1("site", {
1098
1244
  api: createSiteApi
1099
1245
  });
1100
1246
  //#endregion
1101
- //#region src/plugins/router/builders/match.ts
1247
+ //#region src/plugins/router/iso-match.ts
1248
+ /**
1249
+ * Parse a single path segment into its `{…}` placeholder, or `false` for a static
1250
+ * segment. Plain loop over the brace delimiters (no backtracking regex). Shared by
1251
+ * the build-time compiler and this isomorphic matcher so the two never diverge.
1252
+ *
1253
+ * @param segment - One `/`-delimited segment, e.g. `{slug}` or `about`.
1254
+ * @returns The parsed placeholder, or `false` when the segment is static.
1255
+ * @example
1256
+ * ```ts
1257
+ * parsePlaceholder("{slug:?}"); // { name: "slug", optional: true }
1258
+ * ```
1259
+ */
1260
+ function parsePlaceholder(segment) {
1261
+ if (!segment.startsWith("{") || !segment.endsWith("}")) return false;
1262
+ const inner = segment.slice(1, -1);
1263
+ if (inner.endsWith(":?")) return {
1264
+ name: inner.slice(0, -2),
1265
+ optional: true
1266
+ };
1267
+ return {
1268
+ name: inner,
1269
+ optional: false
1270
+ };
1271
+ }
1272
+ /**
1273
+ * Counts the dynamic (`{param}` / `{param:?}` / `:param`) segments in a route
1274
+ * pattern — fewer dynamic segments rank as more specific. The optional `{lang:?}`
1275
+ * segment is excluded so locale-prefixing does not affect priority (identical to
1276
+ * the build-time compiler's count, which sourced this logic).
1277
+ *
1278
+ * @param pattern - The route pattern string.
1279
+ * @returns The number of dynamic (non-lang) segments.
1280
+ * @example
1281
+ * ```ts
1282
+ * dynamicSegmentCount("/blog/{slug}/"); // 1
1283
+ * dynamicSegmentCount("/{lang:?}/{slug}/"); // 1
1284
+ * ```
1285
+ */
1286
+ function dynamicSegmentCount(pattern) {
1287
+ let count = 0;
1288
+ for (const segment of pattern.split("/")) {
1289
+ const placeholder = parsePlaceholder(segment);
1290
+ const isBraceDynamic = placeholder && !(placeholder.name === "lang" && placeholder.optional);
1291
+ const isColonDynamic = !placeholder && segment.startsWith(":");
1292
+ if (isBraceDynamic || isColonDynamic) count += 1;
1293
+ }
1294
+ return count;
1295
+ }
1296
+ /**
1297
+ * Comparator that orders two routes most-specific-first (fewest dynamic segments
1298
+ * first). Equal specificity yields `0` so a stable sort preserves declaration
1299
+ * order — the exact ordering the compiled matcher table uses, guaranteeing
1300
+ * build-time and client-time route resolution can never diverge.
1301
+ *
1302
+ * @param a - First route (carries its `pattern` string).
1303
+ * @param a.pattern - First route's pattern string.
1304
+ * @param b - Second route (carries its `pattern` string).
1305
+ * @param b.pattern - Second route's pattern string.
1306
+ * @returns Negative if `a` is more specific, positive if `b` is, `0` on a tie.
1307
+ * @example
1308
+ * ```ts
1309
+ * routes.toSorted(bySpecificity);
1310
+ * ```
1311
+ */
1312
+ function bySpecificity(a, b) {
1313
+ return dynamicSegmentCount(a.pattern) - dynamicSegmentCount(b.pattern);
1314
+ }
1102
1315
  /**
1103
- * Extract named groups from a `URLPattern` match result, stripping numeric/regex
1104
- * group keys so only declared param names remain.
1316
+ * Extract named groups from a `URLPattern` match result, dropping numeric/regex
1317
+ * group keys and `undefined` values so only declared, present params remain.
1105
1318
  *
1106
1319
  * @param groups - The `URLPatternResult.pathname.groups` object.
1107
- * @returns A clean record of named params (numeric keys + undefined values dropped).
1320
+ * @returns A clean record of named params.
1108
1321
  * @example
1109
1322
  * ```ts
1110
- * extractParams({ slug: "hello", "0": "x" }); // { slug: "hello" }
1323
+ * extractGroups({ slug: "hello", "0": "x" }); // { slug: "hello" }
1111
1324
  * ```
1112
1325
  */
1113
- function extractParams(groups) {
1326
+ function extractGroups(groups) {
1114
1327
  const params = {};
1115
1328
  for (const [key, value] of Object.entries(groups)) {
1116
1329
  if (/^\d+$/.test(key)) continue;
@@ -1118,6 +1331,15 @@ function extractParams(groups) {
1118
1331
  }
1119
1332
  return params;
1120
1333
  }
1334
+ //#endregion
1335
+ //#region src/plugins/router/builders/match.ts
1336
+ /**
1337
+ * @file router plugin — runtime matching domain.
1338
+ *
1339
+ * Pure functions that turn compiled patterns into a pathname matcher: build the
1340
+ * lang-aware/bare `URLPattern` pair, the `matchFn` (withLang first, bare fallback
1341
+ * injecting `defaultLocale`), and extract/strip params. No `ctx` here.
1342
+ */
1121
1343
  /**
1122
1344
  * Build a pathname matcher for a single route: tries the `withLang` URLPattern,
1123
1345
  * then the `bare` pattern injecting `defaultLocale` on miss.
@@ -1135,10 +1357,10 @@ function extractParams(groups) {
1135
1357
  function createMatchFunction(matchers, defaultLocale) {
1136
1358
  return (pathname) => {
1137
1359
  const withLang = matchers.withLang.exec({ pathname });
1138
- if (withLang) return extractParams(withLang.pathname.groups);
1360
+ if (withLang) return extractGroups(withLang.pathname.groups);
1139
1361
  const bare = matchers.bare.exec({ pathname });
1140
1362
  if (bare) {
1141
- const params = extractParams(bare.pathname.groups);
1363
+ const params = extractGroups(bare.pathname.groups);
1142
1364
  params.lang = defaultLocale;
1143
1365
  return params;
1144
1366
  }
@@ -1167,255 +1389,87 @@ function matchRoute(compiled, pathname) {
1167
1389
  return null;
1168
1390
  }
1169
1391
  //#endregion
1170
- //#region src/plugins/router/api.ts
1392
+ //#region src/plugins/router/builders/compile.ts
1171
1393
  /**
1172
- * @file router plugin — API factory.
1394
+ * @file router plugin — compilation + validation domain.
1173
1395
  *
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).
1396
+ * Pure functions invoked from `onInit`: validate the route map, then compile each
1397
+ * route into URLPattern matchers + URL/file builders, count dynamic segments,
1398
+ * sort by specificity, and assemble the immutable `MatcherTable`. Receives DATA
1399
+ * only (`CompileInput`) — never the plugin ctx.
1176
1400
  */
1177
- /** Error prefix for router API failures. */
1401
+ /** Shared `[web]` error prefix for router validation failures. */
1178
1402
  const ERROR_PREFIX$6 = "[web] router";
1403
+ /** Maximum number of optional `{lang:?}` segments a single pattern may declare. */
1404
+ const MAX_LANG_SEGMENTS = 1;
1179
1405
  /**
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.
1406
+ * Whether a pattern is rooted every route pattern must be absolute (start
1407
+ * with `/`) so it composes cleanly with the locale prefix and base URL.
1182
1408
  *
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.
1409
+ * @param pattern - The user pattern to check.
1410
+ * @returns `true` when the pattern starts with `/`.
1186
1411
  * @example
1187
1412
  * ```ts
1188
- * const table = readTable(ctx.state);
1413
+ * isPatternRooted("/{slug}/"); // true
1189
1414
  * ```
1190
1415
  */
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;
1416
+ function isPatternRooted(pattern) {
1417
+ return pattern.startsWith("/");
1194
1418
  }
1195
1419
  /**
1196
- * Project a compiled route into the public `TypedRoute` URL-utility view.
1420
+ * Whether a pattern's `{` and `}` braces are balanced — every placeholder must
1421
+ * be closed so segment parsing cannot drift.
1197
1422
  *
1198
- * @param entry - The compiled route entry.
1199
- * @returns A `TypedRoute` exposing pattern/name/meta + toUrl/toFile/match.
1423
+ * @param pattern - The user pattern to check.
1424
+ * @returns `true` when open and close brace counts are equal.
1200
1425
  * @example
1201
1426
  * ```ts
1202
- * toTypedRoute(compiledEntry).toUrl({ slug: "x" });
1427
+ * hasBalancedBraces("/{slug}/"); // true
1203
1428
  * ```
1204
1429
  */
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
- };
1430
+ function hasBalancedBraces(pattern) {
1431
+ return (pattern.match(/\{/g) ?? []).length === (pattern.match(/\}/g) ?? []).length;
1214
1432
  }
1215
1433
  /**
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.
1434
+ * Whether a pattern declares at most one optional `{lang:?}` segment — the
1435
+ * locale prefix is single-slot, so a second occurrence is ambiguous.
1218
1436
  *
1219
- * @param entry - The compiled route entry.
1220
- * @returns A `ClientRoute` carrying only JSON-serializable fields.
1437
+ * @param pattern - The user pattern to check.
1438
+ * @returns `true` when the pattern has zero or one `{lang:?}` segments.
1221
1439
  * @example
1222
1440
  * ```ts
1223
- * toClientRoute(compiledEntry); // { pattern, name, meta }
1441
+ * hasValidLangCount("/{lang:?}/{slug}/"); // true
1224
1442
  * ```
1225
1443
  */
1226
- function toClientRoute(entry) {
1227
- return {
1228
- pattern: entry.pattern,
1229
- name: entry.name,
1230
- meta: { ...entry.meta }
1231
- };
1444
+ function hasValidLangCount(pattern) {
1445
+ return (pattern.match(/\{lang:\?\}/g) ?? []).length <= MAX_LANG_SEGMENTS;
1232
1446
  }
1233
1447
  /**
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.
1448
+ * Assert a single route's pattern is well-formed, throwing the `[web]`-prefixed
1449
+ * error for the first failure: not rooted at `/`, unbalanced `{…}` braces, or
1450
+ * more than one `{lang:?}` segment. Extracted from {@link validateRoutes} so the
1451
+ * loop body stays flat.
1236
1452
  *
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`.
1453
+ * @param name - The route name key, surfaced in any error message.
1454
+ * @param pattern - The route's user pattern to validate.
1455
+ * @throws {Error} When the pattern is malformed.
1240
1456
  * @example
1241
1457
  * ```ts
1242
- * const api = createApi({ state });
1243
- * api.match("/en/hello/");
1458
+ * assertRouteValid("home", "/{slug}/");
1244
1459
  * ```
1245
1460
  */
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);
1461
+ function assertRouteValid(name, pattern) {
1462
+ if (!isPatternRooted(pattern)) throw new Error(`${ERROR_PREFIX$6}: route "${name}" pattern must start with "/" (got "${pattern}").`);
1463
+ if (!hasBalancedBraces(pattern)) throw new Error(`${ERROR_PREFIX$6}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
1464
+ if (!hasValidLangCount(pattern)) throw new Error(`${ERROR_PREFIX$6}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
1399
1465
  }
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
1466
  /**
1413
1467
  * Validate the route map (fail-fast in `onInit`). Throws with the `[web]` prefix
1414
1468
  * naming the offending route/pattern on any failure: empty map, a pattern not
1415
1469
  * starting with `/`, unbalanced `{…}` braces, or more than one `{lang:?}` segment.
1416
1470
  *
1417
- * @param routes - The route map from config.
1418
- * @throws {Error} If routes are empty, a pattern is malformed, or names collide.
1471
+ * @param routes - The route map registered via `pluginConfigs.router.routes`.
1472
+ * @throws {Error} If routes are empty or a pattern is malformed.
1419
1473
  * @example
1420
1474
  * ```ts
1421
1475
  * validateRoutes({ home: route("/") });
@@ -1423,36 +1477,8 @@ const ERROR_PREFIX$5 = "[web] router";
1423
1477
  */
1424
1478
  function validateRoutes(routes) {
1425
1479
  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.`);
1427
- for (const name of names) {
1428
- 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}").`);
1432
- }
1433
- }
1434
- /**
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
- };
1480
+ if (names.length === 0) throw new Error(`${ERROR_PREFIX$6}: route map is empty.\n Register at least one route via pluginConfigs.router.routes.`);
1481
+ for (const name of names) assertRouteValid(name, routes[name]?.pattern ?? "");
1456
1482
  }
1457
1483
  /**
1458
1484
  * Convert a user pattern to a `URLPattern` source string, in a `withLang` or
@@ -1486,22 +1512,29 @@ function patternToUrlPattern(pattern, variant, langRegex) {
1486
1512
  }
1487
1513
  /**
1488
1514
  * Build a URL from a pattern and params (substitutes `{param}` / `{param:?}`).
1489
- * Walks segment-by-segment (no backtracking regex).
1515
+ * Walks segment-by-segment (no backtracking regex). An optional placeholder whose
1516
+ * param is absent has its segment skipped entirely (no empty segment), so a missing
1517
+ * `{lang:?}` collapses cleanly instead of leaving a double slash.
1490
1518
  *
1491
1519
  * @param pattern - The route pattern.
1492
1520
  * @param params - Param values to substitute.
1493
- * @param _baseUrl - Site base URL (reserved for absolute-link construction).
1494
1521
  * @returns The resolved relative URL string.
1495
1522
  * @example
1496
1523
  * ```ts
1497
- * buildUrl("/{slug}/", { slug: "hello" }, "https://blog.dev");
1524
+ * buildUrl("/{slug}/", { slug: "hello" }); // "/hello/"
1498
1525
  * ```
1499
1526
  */
1500
- function buildUrl(pattern, params, _baseUrl) {
1527
+ function buildUrl(pattern, params) {
1501
1528
  const out = [];
1502
1529
  for (const segment of pattern.split("/")) {
1503
1530
  const placeholder = parsePlaceholder(segment);
1504
- out.push(placeholder ? params[placeholder.name] ?? "" : segment);
1531
+ if (!placeholder) {
1532
+ out.push(segment);
1533
+ continue;
1534
+ }
1535
+ const value = params[placeholder.name] ?? "";
1536
+ if (placeholder.optional && value === "") continue;
1537
+ out.push(value);
1505
1538
  }
1506
1539
  return out.join("/");
1507
1540
  }
@@ -1517,10 +1550,63 @@ function buildUrl(pattern, params, _baseUrl) {
1517
1550
  * ```
1518
1551
  */
1519
1552
  function buildFilePath(pattern, params) {
1520
- const cleanPath = buildUrl(pattern, params, "").replace(/^\//, "").replace(/\/$/, "");
1553
+ const cleanPath = buildUrl(pattern, params).replace(/^\//, "").replace(/\/$/, "");
1521
1554
  return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
1522
1555
  }
1523
1556
  /**
1557
+ * Build both URLPattern matchers for a route — the `withLang` variant (locale
1558
+ * prefix injected) and the `bare` variant (optional `{lang:?}` stripped) — from
1559
+ * the user pattern and the active locale alternation.
1560
+ *
1561
+ * @param pattern - The user pattern, e.g. `/{lang:?}/{slug}/`.
1562
+ * @param locales - Active locale codes, joined into the alternation regex.
1563
+ * @returns The frozen `{ withLang, bare }` matcher pair.
1564
+ * @example
1565
+ * ```ts
1566
+ * const matchers = buildMatchers("/{lang:?}/{slug}/", ["en", "uk"]);
1567
+ * ```
1568
+ */
1569
+ function buildMatchers(pattern, locales) {
1570
+ const langRegex = `(${locales.join("|")})`;
1571
+ return {
1572
+ withLang: new URLPattern({ pathname: patternToUrlPattern(pattern, "withLang", langRegex) }),
1573
+ bare: new URLPattern({ pathname: patternToUrlPattern(pattern, "bare", langRegex) })
1574
+ };
1575
+ }
1576
+ /**
1577
+ * Build the `toUrl` closure for a route — resolves the pattern against params
1578
+ * into a relative URL. Captured per-route so callers need not re-supply the
1579
+ * pattern.
1580
+ *
1581
+ * @param pattern - The route pattern bound into the closure.
1582
+ * @returns A function mapping params to the resolved relative URL.
1583
+ * @example
1584
+ * ```ts
1585
+ * const toUrl = createToUrlFn("/{slug}/");
1586
+ * toUrl({ slug: "x" }); // "/x/"
1587
+ * ```
1588
+ */
1589
+ function createToUrlFunction(pattern) {
1590
+ return (params) => buildUrl(pattern, params);
1591
+ }
1592
+ /**
1593
+ * Build the `toFile` closure for a route — resolves the output file path from
1594
+ * params. Honors a custom `.toFile()` override (captured in `_handlers.toFile`)
1595
+ * when present, falling back to the pattern-derived `…/index.html` path.
1596
+ *
1597
+ * @param pattern - The route pattern bound into the closure.
1598
+ * @param definition - The route definition carrying any `toFile` override.
1599
+ * @returns A function mapping params to the output file path.
1600
+ * @example
1601
+ * ```ts
1602
+ * const toFile = createToFileFn("/{slug}/", definition);
1603
+ * toFile({ slug: "x" }); // "x/index.html"
1604
+ * ```
1605
+ */
1606
+ function createToFileFunction(pattern, definition) {
1607
+ return (params) => definition._handlers.toFile?.(params) ?? buildFilePath(pattern, params);
1608
+ }
1609
+ /**
1524
1610
  * Compile a single route definition into its `CompiledRoute` entry.
1525
1611
  *
1526
1612
  * @param name - The route name key.
@@ -1534,144 +1620,253 @@ function buildFilePath(pattern, params) {
1534
1620
  */
1535
1621
  function compileRoute(name, definition, input) {
1536
1622
  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
- };
1623
+ const matchers = buildMatchers(pattern, input.locales);
1624
+ const toUrl = createToUrlFunction(pattern);
1625
+ const toFile = createToFileFunction(pattern, definition);
1542
1626
  return {
1543
1627
  name,
1544
1628
  pattern,
1545
1629
  dynamicSegmentCount: dynamicSegmentCount(pattern),
1546
1630
  matchers,
1547
1631
  matchFn: createMatchFunction(matchers, input.defaultLocale),
1632
+ toUrl,
1633
+ toFile,
1634
+ definition,
1635
+ meta: { ...definition._meta }
1636
+ };
1637
+ }
1638
+ /**
1639
+ * Compile the route map into a specificity-sorted, immutable `MatcherTable`.
1640
+ * Builds both URLPattern variants per route, the `matchFn`, the `toUrl`/`toFile`
1641
+ * closures, and the `byName` index, then sorts ascending by dynamic-segment count
1642
+ * (stable, preserving declaration order among equal-specificity routes).
1643
+ *
1644
+ * @param input - Resolved DATA (routes, mode, baseUrl, locales, defaultLocale).
1645
+ * @returns The compiled, immutable matcher table.
1646
+ * @example
1647
+ * ```ts
1648
+ * compileRoutes({ routes: { home: route("/") }, mode: "hybrid", baseUrl: "https://blog.dev", locales: ["en"], defaultLocale: "en" });
1649
+ * ```
1650
+ */
1651
+ function compileRoutes(input) {
1652
+ const byName = /* @__PURE__ */ new Map();
1653
+ const declarationOrder = [];
1654
+ for (const [name, definition] of Object.entries(input.routes)) {
1655
+ const entry = compileRoute(name, definition, input);
1656
+ declarationOrder.push(entry);
1657
+ byName.set(name, entry);
1658
+ }
1659
+ return {
1660
+ compiled: declarationOrder.toSorted(bySpecificity),
1661
+ byName
1662
+ };
1663
+ }
1664
+ //#endregion
1665
+ //#region src/plugins/router/api.ts
1666
+ /**
1667
+ * @file router plugin — API factory.
1668
+ *
1669
+ * Closures over `ctx.state.table` exposing `match` / `toUrl` / `entries` /
1670
+ * `manifest`. Returns values/copies, never the raw `ctx.state` reference (spec/11 §2.4).
1671
+ */
1672
+ /** Error prefix for router API failures. */
1673
+ const ERROR_PREFIX$5 = "[web] router";
1674
+ /**
1675
+ * Validate a route map and compile it into the matcher table on `ctx.state`,
1676
+ * resolving the global render `mode` + site base URL + i18n locales at call time.
1677
+ * Called by the router's `onInit` to compile `config.routes`. Re-calling replaces the table.
1678
+ *
1679
+ * @param ctx - The router register context (state + global mode + require).
1680
+ * @param routes - The route map to compile (an `import * as routes` namespace works).
1681
+ * @throws {Error} If the route map is empty or a pattern is malformed.
1682
+ * @example
1683
+ * ```ts
1684
+ * registerRoutes(ctx, { home: route("/") });
1685
+ * ```
1686
+ */
1687
+ function registerRoutes(ctx, routes) {
1688
+ validateRoutes(routes);
1689
+ const i18n = ctx.require(i18nPlugin);
1690
+ ctx.state.table = compileRoutes({
1691
+ routes,
1692
+ mode: ctx.global.mode,
1693
+ baseUrl: ctx.require(sitePlugin).url(),
1694
+ locales: i18n.locales(),
1695
+ defaultLocale: i18n.defaultLocale()
1696
+ });
1697
+ }
1698
+ /**
1699
+ * Read the compiled matcher table, throwing if `onInit` has not run yet. This
1700
+ * `null` cannot occur in practice post-`onInit`; the guard documents the invariant.
1701
+ *
1702
+ * @param state - The router plugin state holder.
1703
+ * @returns The compiled, non-null matcher table.
1704
+ * @throws {Error} If the matcher table has not been compiled yet.
1705
+ * @example
1706
+ * ```ts
1707
+ * const table = readTable(ctx.state);
1708
+ * ```
1709
+ */
1710
+ function readTable(state) {
1711
+ if (state.table === null) throw new Error(`${ERROR_PREFIX$5}: routes not registered.\n Set pluginConfigs.router.routes before app.start() / app.build.run().`);
1712
+ return state.table;
1713
+ }
1714
+ /**
1715
+ * Project a compiled route into the public `TypedRoute` URL-utility view.
1716
+ *
1717
+ * @param entry - The compiled route entry.
1718
+ * @returns A `TypedRoute` exposing pattern/name/meta + toUrl/toFile/match.
1719
+ * @example
1720
+ * ```ts
1721
+ * toTypedRoute(compiledEntry).toUrl({ slug: "x" });
1722
+ * ```
1723
+ */
1724
+ function toTypedRoute(entry) {
1725
+ return {
1726
+ pattern: entry.pattern,
1727
+ name: entry.name,
1728
+ meta: { ...entry.meta },
1729
+ toUrl: entry.toUrl,
1730
+ toFile: entry.toFile,
1731
+ match: entry.matchFn
1732
+ };
1733
+ }
1734
+ /**
1735
+ * Project a compiled route into the serializable {@link ClientRoute} view: only
1736
+ * `pattern` / `name` / `meta`, with a fresh `meta` copy and NO `_handlers` closures.
1737
+ *
1738
+ * @param entry - The compiled route entry.
1739
+ * @returns A `ClientRoute` carrying only JSON-serializable fields.
1740
+ * @example
1741
+ * ```ts
1742
+ * toClientRoute(compiledEntry); // { pattern, name, meta }
1743
+ * ```
1744
+ */
1745
+ function toClientRoute(entry) {
1746
+ return {
1747
+ pattern: entry.pattern,
1748
+ name: entry.name,
1749
+ meta: { ...entry.meta }
1750
+ };
1751
+ }
1752
+ /**
1753
+ * Creates the router plugin API surface. Every closure reads the compiled table
1754
+ * from `ctx.state` and returns values/fresh copies — never the raw state arrays.
1755
+ *
1756
+ * @param ctx - Plugin context.
1757
+ * @param ctx.state - The router state holding the compiled matcher table.
1758
+ * @returns The {@link RouterApi} surface mounted at `ctx.router`.
1759
+ * @example
1760
+ * ```ts
1761
+ * const api = createApi({ state });
1762
+ * api.match("/en/hello/");
1763
+ * ```
1764
+ */
1765
+ function createApi$2(ctx) {
1766
+ const { state } = ctx;
1767
+ return {
1768
+ /**
1769
+ * Match a pathname against the compiled route table (specificity-sorted).
1770
+ *
1771
+ * @param pathname - URL pathname, e.g. `/en/hello/`.
1772
+ * @returns `{ params, route }` for the most specific match, or `null`.
1773
+ * @example
1774
+ * ```ts
1775
+ * api.match("/en/hello/");
1776
+ * ```
1777
+ */
1778
+ match(pathname) {
1779
+ return matchRoute(readTable(state).compiled, pathname);
1780
+ },
1548
1781
  /**
1549
- * Build a URL for this route from params.
1782
+ * Build a URL for a named route from params.
1550
1783
  *
1551
- * @param params - Param values to substitute.
1552
- * @returns The resolved relative URL.
1784
+ * @param routeName - Route name key from the route map.
1785
+ * @param params - Param values to substitute into the pattern.
1786
+ * @returns The resolved URL string (e.g. `/en/hello/`).
1787
+ * @throws {Error} If `routeName` is unknown.
1553
1788
  * @example
1554
1789
  * ```ts
1555
- * entry.toUrl({ slug: "x" });
1790
+ * api.toUrl("article", { lang: "en", slug: "hello" });
1556
1791
  * ```
1557
1792
  */
1558
- toUrl(params) {
1559
- return buildUrl(pattern, params, input.baseUrl);
1793
+ toUrl(routeName, params) {
1794
+ const entry = readTable(state).byName.get(routeName);
1795
+ 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.`);
1796
+ return entry.toUrl(params);
1560
1797
  },
1561
1798
  /**
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.
1799
+ * All resolved routes as typed URL utilities, in specificity order.
1565
1800
  *
1566
- * @param params - Param values to substitute.
1567
- * @returns The output file path.
1801
+ * @returns A fresh read-only array of resolved typed routes.
1568
1802
  * @example
1569
1803
  * ```ts
1570
- * entry.toFile({ slug: "x" });
1804
+ * for (const r of api.entries()) r.toUrl({ slug: "x" });
1571
1805
  * ```
1572
1806
  */
1573
- toFile(params) {
1574
- return definition._handlers.toFile?.(params) ?? buildFilePath(pattern, params);
1807
+ entries() {
1808
+ return readTable(state).compiled.map((entry) => toTypedRoute(entry));
1575
1809
  },
1576
- definition,
1577
- meta: { ...definition._meta }
1578
- };
1579
- }
1580
- /**
1581
- * Compile the route map into a specificity-sorted, immutable `MatcherTable`.
1582
- * Builds both URLPattern variants per route, the `matchFn`, the `toUrl`/`toFile`
1583
- * closures, and the `byName` index, then sorts ascending by dynamic-segment count
1584
- * (stable, preserving declaration order among equal-specificity routes).
1585
- *
1586
- * @param input - Resolved DATA (routes, mode, baseUrl, locales, defaultLocale).
1587
- * @returns The compiled, immutable matcher table.
1588
- * @example
1589
- * ```ts
1590
- * compileRoutes({ routes: { home: route("/") }, mode: "hybrid", baseUrl: "https://blog.dev", locales: ["en"], defaultLocale: "en" });
1591
- * ```
1592
- */
1593
- function compileRoutes(input) {
1594
- const byName = /* @__PURE__ */ new Map();
1595
- const declarationOrder = [];
1596
- for (const [name, definition] of Object.entries(input.routes)) {
1597
- const entry = compileRoute(name, definition, input);
1598
- declarationOrder.push(entry);
1599
- byName.set(name, entry);
1600
- }
1601
- return {
1602
- compiled: declarationOrder.toSorted(bySpecificity),
1603
- byName
1810
+ /**
1811
+ * The typed route set for build-time consumption (declaration order). An API
1812
+ * return, NOT a config readback — preserves per-route types despite erasure.
1813
+ *
1814
+ * @returns A fresh read-only array of the typed route definitions.
1815
+ * @example
1816
+ * ```ts
1817
+ * for (const def of api.manifest()) def._handlers.render?.(routeContext);
1818
+ * ```
1819
+ */
1820
+ manifest() {
1821
+ return [...readTable(state).byName.values()].map((entry) => entry.definition);
1822
+ },
1823
+ /**
1824
+ * Serializable, specificity-sorted projection of the route table for client
1825
+ * shipping — `{ pattern, name, meta }` entries with NO `_handlers` closures.
1826
+ *
1827
+ * @returns A fresh, frozen, specificity-sorted read-only array of client routes.
1828
+ * @example
1829
+ * ```ts
1830
+ * const json = JSON.stringify(api.clientManifest());
1831
+ * ```
1832
+ */
1833
+ clientManifest() {
1834
+ return Object.freeze(readTable(state).compiled.map((entry) => toClientRoute(entry)));
1835
+ },
1836
+ /**
1837
+ * The resolved render mode — read from the global framework config (the single
1838
+ * source of truth for static/hybrid/spa). `build`/`spa` gate data nav on it.
1839
+ *
1840
+ * @returns `"ssg" | "spa" | "hybrid"`.
1841
+ * @example
1842
+ * ```ts
1843
+ * if (api.mode() !== "ssg") emitClientData();
1844
+ * ```
1845
+ */
1846
+ mode() {
1847
+ return ctx.global.mode;
1848
+ }
1604
1849
  };
1605
1850
  }
1606
- /**
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.
1609
- *
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`.
1615
- * @example
1616
- * ```ts
1617
- * ctx.state.table = buildRouterTable(ctx.config, site.url(), i18n.locales(), i18n.defaultLocale());
1618
- * ```
1619
- */
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
1628
- });
1629
- }
1630
1851
  //#endregion
1631
1852
  //#region src/plugins/router/builders/route-builder.ts
1632
1853
  /**
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.
1854
+ * Build the handler-slot setter methods for one builder instance. Each method
1855
+ * records its handler under the matching slot and returns the builder, so the chain
1856
+ * stays fluent; they differ only in slot name and documented intent. `meta` is NOT
1857
+ * here it merges into `_meta` rather than a handler slot — so the carrier is not
1858
+ * needed, keeping this helper a pure factory over the shared `set` primitive.
1638
1859
  *
1639
- * @param pattern - URL pattern with `{param}` / `{param:?}` placeholders.
1640
- * @returns A `RouteBuilder<RouteState<P>>` carrying the typed fluent chain.
1860
+ * @param set - The record-then-return-builder primitive shared by every method.
1861
+ * @returns The handler-slot setters (`load`/`layout`/`render`/`head`/`generate`/`toJson`/`toFile`).
1641
1862
  * @example
1642
1863
  * ```ts
1643
- * route("/{lang:?}/{slug}/")
1644
- * .load(({ slug }) => loadArticle(slug))
1645
- * .render((ctx) => <Article a={ctx.data} />)
1646
- * .head((ctx) => ({ title: ctx.data.title }));
1864
+ * const methods = createBuilderMethods(set);
1865
+ * methods.render(handler); // records the render handler, returns the builder
1647
1866
  * ```
1648
1867
  */
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,
1868
+ function createBuilderMethods(set) {
1869
+ return {
1675
1870
  /**
1676
1871
  * Attach a data loader; widens the data generic for downstream handlers.
1677
1872
  *
@@ -1679,7 +1874,7 @@ function route(pattern) {
1679
1874
  * @returns The same builder, with the data generic widened.
1680
1875
  * @example
1681
1876
  * ```ts
1682
- * route("/{slug}/").load(({ slug }) => ({ slug }));
1877
+ * route("/{slug}/").load((ctx) => ({ slug: ctx.params.slug }));
1683
1878
  * ```
1684
1879
  */
1685
1880
  load(loader) {
@@ -1719,21 +1914,6 @@ function route(pattern) {
1719
1914
  return set("render", handler);
1720
1915
  },
1721
1916
  /**
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
1917
  * Attach the head/SEO handler.
1738
1918
  *
1739
1919
  * @param handler - The head handler.
@@ -1760,22 +1940,6 @@ function route(pattern) {
1760
1940
  return set("generate", handler);
1761
1941
  },
1762
1942
  /**
1763
- * Merge an arbitrary metadata bag into the route's `_meta`. The bag MUST be
1764
- * JSON-serializable — it is projected verbatim into `clientManifest()` and
1765
- * shipped to the browser, so functions/symbols/class instances are unsupported.
1766
- *
1767
- * @param meta - JSON-serializable metadata to merge.
1768
- * @returns The same builder for chaining.
1769
- * @example
1770
- * ```ts
1771
- * route("/").meta({ activeTab: "home" });
1772
- * ```
1773
- */
1774
- meta(meta) {
1775
- Object.assign(carrier._meta, meta);
1776
- return builder;
1777
- },
1778
- /**
1779
1943
  * Attach a JSON serializer for the route's data.
1780
1944
  *
1781
1945
  * @param handler - The JSON serializer.
@@ -1802,6 +1966,68 @@ function route(pattern) {
1802
1966
  return set("toFile", handler);
1803
1967
  }
1804
1968
  };
1969
+ }
1970
+ /**
1971
+ * Create a fluent route builder from a URL pattern string. Captures the pattern
1972
+ * as a literal type for compile-time param inference; `.load()` is the only method
1973
+ * that widens the data generic, so `ctx.data` in `.render()`/`.head()` is typed by
1974
+ * `.load()`'s return at the CALL SITE. The returned object is itself the route
1975
+ * definition (`pattern` / `_meta` / `_handlers`), so it slots straight into a route map.
1976
+ *
1977
+ * @param pattern - URL pattern with `{param}` / `{param:?}` placeholders.
1978
+ * @returns A `RouteBuilder<RouteState<P>>` carrying the typed fluent chain.
1979
+ * @example
1980
+ * ```ts
1981
+ * route("/{lang:?}/{slug}/")
1982
+ * .load((ctx) => loadArticle(ctx.params.slug))
1983
+ * .render((ctx) => <Article a={ctx.data} />)
1984
+ * .head((ctx) => ({ title: ctx.data.title }));
1985
+ * ```
1986
+ */
1987
+ function route(pattern) {
1988
+ const carrier = {
1989
+ pattern,
1990
+ _meta: {},
1991
+ _handlers: {}
1992
+ };
1993
+ /**
1994
+ * Record a handler under `key` and return the builder for chaining — the one
1995
+ * primitive every typed handler setter (`load`, `render`, …) delegates to.
1996
+ *
1997
+ * @param key - The handler slot name.
1998
+ * @param fn - The handler function to store.
1999
+ * @returns The same builder instance, for fluent chaining.
2000
+ * @example
2001
+ * ```ts
2002
+ * set("render", handler);
2003
+ * ```
2004
+ */
2005
+ const set = (key, fn) => {
2006
+ carrier._handlers[key] = fn;
2007
+ return builder;
2008
+ };
2009
+ const builder = {
2010
+ pattern: carrier.pattern,
2011
+ _meta: carrier._meta,
2012
+ _handlers: carrier._handlers,
2013
+ ...createBuilderMethods(set),
2014
+ /**
2015
+ * Merge an arbitrary metadata bag into the route's `_meta`. The bag MUST be
2016
+ * JSON-serializable — it is projected verbatim into `clientManifest()` and
2017
+ * shipped to the browser, so functions/symbols/class instances are unsupported.
2018
+ *
2019
+ * @param meta - JSON-serializable metadata to merge.
2020
+ * @returns The same builder for chaining.
2021
+ * @example
2022
+ * ```ts
2023
+ * route("/").meta({ activeTab: "home" });
2024
+ * ```
2025
+ */
2026
+ meta(meta) {
2027
+ Object.assign(carrier._meta, meta);
2028
+ return builder;
2029
+ }
2030
+ };
1805
2031
  return builder;
1806
2032
  }
1807
2033
  /**
@@ -1818,6 +2044,43 @@ function route(pattern) {
1818
2044
  function defineRoutes(routes) {
1819
2045
  return routes;
1820
2046
  }
2047
+ /**
2048
+ * Build a pure, app-free URL builder from a route map. `toUrl(name, params)` resolves
2049
+ * a route's path by pattern substitution using the SAME `buildUrl` as the runtime
2050
+ * `RouterApi.toUrl`, so the helper and the API can never diverge. It needs no running
2051
+ * app, router instance, base URL, or i18n — just the route map the consumer already
2052
+ * holds at module scope. So components, layouts, and hydrated islands import it
2053
+ * directly: no `app.router` reference, no manual "bind", no module global, no
2054
+ * "not bound" guard, and no createApp ↔ routes cycle.
2055
+ *
2056
+ * @param routes - The route map (typically the value returned by {@link defineRoutes}).
2057
+ * @returns A {@link Urls} builder whose `toUrl` accepts only this map's route names.
2058
+ * @example
2059
+ * ```ts
2060
+ * const url = createUrls(routes);
2061
+ * url.toUrl("article", { lang: "en", slug: "hello" }); // "/en/hello/"
2062
+ * ```
2063
+ */
2064
+ function createUrls(routes) {
2065
+ return {
2066
+ /**
2067
+ * Build a route's URL path from its name and params.
2068
+ *
2069
+ * @param name - Route name key from the map.
2070
+ * @param params - Path params to substitute into the pattern. Defaults to `{}`.
2071
+ * @returns The resolved relative URL path.
2072
+ * @throws {Error} If `name` is not present in the route map.
2073
+ * @example
2074
+ * ```ts
2075
+ * url.toUrl("home", { lang: "en" }); // "/en/"
2076
+ * ```
2077
+ */
2078
+ toUrl(name, params = {}) {
2079
+ const definition = routes[name];
2080
+ 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.`);
2081
+ return buildUrl(definition.pattern, params);
2082
+ } };
2083
+ }
1821
2084
  //#endregion
1822
2085
  //#region src/plugins/router/state.ts
1823
2086
  /**
@@ -1830,52 +2093,41 @@ function defineRoutes(routes) {
1830
2093
  * @returns The initial router state holder.
1831
2094
  * @example
1832
2095
  * ```ts
1833
- * const state = createState({ global: {}, config: { routes: {} } });
2096
+ * const state = createState({ global: {}, config: { mode: "hybrid" } });
1834
2097
  * ```
1835
2098
  */
1836
2099
  function createState$2(_ctx) {
1837
- return {
1838
- table: null,
1839
- mode: _ctx.config.mode ?? "hybrid"
1840
- };
2100
+ return { table: null };
1841
2101
  }
1842
2102
  /**
1843
2103
  * 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).
2104
+ * and matching. Author routes with {@link route}, then register them the normal config
2105
+ * way via `pluginConfigs.router.routes` (compiled at init). Depends on site (base URL)
2106
+ * and i18n (locales).
1846
2107
  *
1847
- * @example Define routes and choose a render mode
2108
+ * @example Register routes via config, then start/build
1848
2109
  * ```ts
2110
+ * import * as routes from "./routes";
1849
2111
  * 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
- * }
2112
+ * config: { mode: "hybrid" }, // render mode is GLOBAL config
2113
+ * pluginConfigs: { router: { routes } } // declarative route map (a namespace works)
1859
2114
  * });
2115
+ * await app.build.run(); // or: await app.start(); — routes compiled at init
1860
2116
  * ```
1861
2117
  */
1862
2118
  const routerPlugin = createPlugin$1("router", {
1863
2119
  depends: [sitePlugin, i18nPlugin],
1864
2120
  helpers: {
1865
2121
  route,
1866
- defineRoutes
1867
- },
1868
- config: {
1869
- routes: {},
1870
- mode: "hybrid"
2122
+ defineRoutes,
2123
+ createUrls
1871
2124
  },
2125
+ config: {},
1872
2126
  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
- }
2127
+ onInit: (ctx) => {
2128
+ if (ctx.config.routes) registerRoutes(ctx, ctx.config.routes);
2129
+ },
2130
+ api: createApi$2
1879
2131
  });
1880
2132
  //#endregion
1881
2133
  //#region src/plugins/head/primitives.ts
@@ -2011,10 +2263,34 @@ function feedLink(title, url, type = "application/rss+xml") {
2011
2263
  };
2012
2264
  }
2013
2265
  /**
2266
+ * Build the schema.org `Article` structured-data object for the JSON-LD block,
2267
+ * carrying only the fields the article actually provides (optional fields are
2268
+ * omitted rather than emitted as `undefined`).
2269
+ *
2270
+ * @param articleMeta - Article metadata (title, description, author, dates, image…).
2271
+ * @returns A JSON-serializable `Article` object ready to hand to {@link jsonLd}.
2272
+ * @example buildArticleJsonLd({ title: "Hi", author: "A", published: "2026-01-01" })
2273
+ */
2274
+ function buildArticleJsonLd(articleMeta) {
2275
+ const ld = {
2276
+ "@context": "https://schema.org",
2277
+ "@type": "Article",
2278
+ headline: articleMeta.title
2279
+ };
2280
+ if (articleMeta.description) ld.description = articleMeta.description;
2281
+ if (articleMeta.author) ld.author = articleMeta.author;
2282
+ if (articleMeta.published) ld.datePublished = articleMeta.published;
2283
+ if (articleMeta.modified) ld.dateModified = articleMeta.modified;
2284
+ if (articleMeta.image) ld.image = articleMeta.image;
2285
+ return ld;
2286
+ }
2287
+ /**
2014
2288
  * Compose the full head element set for an article page: og:type=article, published/
2015
2289
  * modified times, author, section, tags, plus a JSON-LD `Article` block and canonical.
2016
2290
  *
2017
2291
  * @param articleMeta - Article metadata (title, description, author, dates, tags, image…).
2292
+ * `image`, when present, is pushed to `og:image` verbatim and must therefore be
2293
+ * an absolute URL (this helper does not resolve relative paths against the site).
2018
2294
  * @param canonicalUrl - The article's canonical absolute URL.
2019
2295
  * @returns An ordered array of serializable head elements.
2020
2296
  * @example buildArticleHead({ title: "Hi", author: "A", published: "2026-01-01" }, "https://x/p")
@@ -2027,17 +2303,7 @@ function buildArticleHead(articleMeta, canonicalUrl) {
2027
2303
  if (articleMeta.section) elements.push(og(`${ARTICLE_PREFIX}section`, articleMeta.section));
2028
2304
  for (const tag of articleMeta.tags ?? []) elements.push(og(`${ARTICLE_PREFIX}tag`, tag));
2029
2305
  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));
2306
+ elements.push(jsonLd(buildArticleJsonLd(articleMeta)));
2041
2307
  return elements;
2042
2308
  }
2043
2309
  //#endregion
@@ -2071,8 +2337,31 @@ function applyTemplate(title, template) {
2071
2337
  * @returns The absolute image URL.
2072
2338
  * @example resolveImage("/og.png", site) // "https://blog.dev/og.png"
2073
2339
  */
2074
- function resolveImage(image, site) {
2075
- return image.startsWith("http") ? image : site.canonical(image);
2340
+ function resolveImage(image, site) {
2341
+ return /^https?:\/\//.test(image) || image.startsWith("//") ? image : site.canonical(image);
2342
+ }
2343
+ /**
2344
+ * Build the per-locale `hreflang` alternates for a route, plus the `x-default`
2345
+ * fallback (the route's URL with no `lang` override). Each alternate URL is the
2346
+ * route's canonical URL for that locale, absolutized against the site base URL.
2347
+ *
2348
+ * @param locales - The supported locale codes (drives the alternate set).
2349
+ * @param route - The resolved route descriptor (provides `name` + `params`).
2350
+ * @param router - The router slice used to build each locale's URL.
2351
+ * @param site - The site slice used to absolutize each locale's URL.
2352
+ * @returns The ordered `hreflang` element set: one per locale, then `x-default`.
2353
+ * @example buildHreflangAlternates(["en", "fr"], route, router, site)
2354
+ */
2355
+ function buildHreflangAlternates(locales, route, router, site) {
2356
+ const alternates = locales.map((locale) => {
2357
+ return hreflang(locale, site.canonical(router.toUrl(route.name, {
2358
+ ...route.params,
2359
+ lang: locale
2360
+ })));
2361
+ });
2362
+ const xDefaultHref = site.canonical(router.toUrl(route.name, { ...route.params }));
2363
+ alternates.push(hreflang(X_DEFAULT, xDefaultHref));
2364
+ return alternates;
2076
2365
  }
2077
2366
  /**
2078
2367
  * Build the canonical, og, twitter, and hreflang elements for the route from
@@ -2089,7 +2378,6 @@ function resolveImage(image, site) {
2089
2378
  function buildBaseElements(input, resolved) {
2090
2379
  const { route, defaults, site, i18n, router } = input;
2091
2380
  const head = route.head ?? {};
2092
- const image = head.image ?? defaults.defaultOgImage;
2093
2381
  const elements = [
2094
2382
  {
2095
2383
  tag: "title",
@@ -2104,6 +2392,7 @@ function buildBaseElements(input, resolved) {
2104
2392
  twitter("twitter:title", head.title ?? resolved.title),
2105
2393
  twitter("twitter:description", resolved.description)
2106
2394
  ];
2395
+ const image = head.image ?? defaults.defaultOgImage;
2107
2396
  if (image) {
2108
2397
  const abs = resolveImage(image, site);
2109
2398
  elements.push(og("og:image", abs), twitter("twitter:image", abs));
@@ -2111,16 +2400,7 @@ function buildBaseElements(input, resolved) {
2111
2400
  if (defaults.twitterHandle) elements.push(twitter("twitter:site", defaults.twitterHandle));
2112
2401
  const ogLocale = route.locale ? i18n.ogLocale(route.locale) : void 0;
2113
2402
  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));
2403
+ elements.push(canonical(resolved.canonicalUrl), ...buildHreflangAlternates(i18n.locales(), route, router, site));
2124
2404
  return elements;
2125
2405
  }
2126
2406
  /**
@@ -2174,6 +2454,17 @@ function escapeHtml(raw) {
2174
2454
  return raw.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
2175
2455
  }
2176
2456
  /**
2457
+ * Serialize an element's attribute map to a space-joined `name="value"` string,
2458
+ * HTML-escaping each value. Returns `""` when there are no attributes.
2459
+ *
2460
+ * @param attributes - The element's attribute map (may be `undefined`).
2461
+ * @returns The serialized attribute string (no leading/trailing space).
2462
+ * @example serializeAttrs({ name: "robots", content: "index" }) // 'name="robots" content="index"'
2463
+ */
2464
+ function serializeAttributes(attributes) {
2465
+ return Object.entries(attributes ?? {}).map(([name, value]) => `${name}="${escapeHtml(value)}"`).join(" ");
2466
+ }
2467
+ /**
2177
2468
  * Serialize a single `HeadElement` to its HTML string form. Attribute values are
2178
2469
  * HTML-escaped; `script` children are emitted verbatim (already unicode-escaped by
2179
2470
  * `jsonLd`); `title` text is HTML-escaped.
@@ -2183,11 +2474,10 @@ function escapeHtml(raw) {
2183
2474
  * @example serializeElement(meta("robots", "index"))
2184
2475
  */
2185
2476
  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}`;
2477
+ const attributes = serializeAttributes(element.attrs);
2188
2478
  if (element.tag === "script") return `<script ${attributes}>${element.children ?? ""}<\/script>`;
2189
2479
  if (element.tag === "title") return `<title>${escapeHtml(element.children ?? "")}</title>`;
2190
- return `<${open}>`;
2480
+ return `<${attributes.length === 0 ? element.tag : `${element.tag} ${attributes}`}>`;
2191
2481
  }
2192
2482
  /**
2193
2483
  * Serialize a `HeadElement[]` to `<head>` inner HTML. All attribute values are
@@ -2210,7 +2500,7 @@ function serializeHead(elements) {
2210
2500
  * it to a string. It holds no resource and caches no subscription.
2211
2501
  */
2212
2502
  /** Error prefix for head API invariant failures. */
2213
- const ERROR_PREFIX$4 = "[head]";
2503
+ const ERROR_PREFIX$4 = "[web] head";
2214
2504
  /**
2215
2505
  * Read the normalized defaults, asserting the post-`onInit` invariant (the slot is
2216
2506
  * `null` only before `onInit` assigns it, which cannot occur at render time).
@@ -2267,7 +2557,7 @@ render(route, data) {
2267
2557
  //#endregion
2268
2558
  //#region src/plugins/head/config.ts
2269
2559
  /** Error prefix for all head config-validation failures. */
2270
- const ERROR_PREFIX$3 = "[head] config:";
2560
+ const ERROR_PREFIX$3 = "[web] head";
2271
2561
  /** The allowed `twitterCard` literals (also the runtime guard set). */
2272
2562
  const VALID_TWITTER_CARDS = ["summary", "summary_large_image"];
2273
2563
  /**
@@ -2283,7 +2573,7 @@ const VALID_TWITTER_CARDS = ["summary", "summary_large_image"];
2283
2573
  const defaultConfig = { twitterCard: "summary_large_image" };
2284
2574
  /**
2285
2575
  * Structurally validate the resolved head config (no I/O). Throws a standard
2286
- * `[head] config: …` error when `titleTemplate` is provided without the `%s`
2576
+ * `[web] head: …` error when `titleTemplate` is provided without the `%s`
2287
2577
  * token, or when `twitterCard` is present but not one of the two allowed literals.
2288
2578
  *
2289
2579
  * @param config - The resolved head {@link Config} to validate.
@@ -2294,8 +2584,8 @@ const defaultConfig = { twitterCard: "summary_large_image" };
2294
2584
  * ```
2295
2585
  */
2296
2586
  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)}.`);
2587
+ 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)}.`);
2588
+ 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
2589
  }
2300
2590
  /**
2301
2591
  * Validate then build the frozen, normalized {@link HeadDefaults} snapshot read by
@@ -2411,69 +2701,321 @@ const headPlugin = createPlugin$1("head", {
2411
2701
  * Creates the spa plugin API surface (registration / control). All methods
2412
2702
  * delegate to the single shared kernel stored in `ctx.state.kernel`.
2413
2703
  *
2414
- * @param ctx - Plugin context exposing `state` (kernel) and `log`.
2415
- * @returns The {@link SpaApi} surface mounted at `app.spa`.
2704
+ * @param ctx - Plugin context exposing `state` (kernel) and `log`.
2705
+ * @returns The {@link SpaApi} surface mounted at `app.spa`.
2706
+ * @example
2707
+ * const api = createApi(ctx);
2708
+ * api.register(counter);
2709
+ */
2710
+ function createApi(ctx) {
2711
+ return {
2712
+ /**
2713
+ * Register a component definition (last-registered-wins); warns on collision.
2714
+ *
2715
+ * @param component - The component definition created via `createComponent`.
2716
+ * @example
2717
+ * app.spa.register(counter);
2718
+ */
2719
+ register(component) {
2720
+ if (ctx.state.registeredComponents.has(component.name)) ctx.log.warn("spa:component-collision", { name: component.name });
2721
+ ctx.state.kernel?.register(component);
2722
+ },
2723
+ /**
2724
+ * Programmatically navigate to a path (client runtime; no-op without a DOM).
2725
+ *
2726
+ * @param path - Target path (pathname, optionally with search/hash).
2727
+ * @example
2728
+ * app.spa.navigate("/about");
2729
+ */
2730
+ navigate(path) {
2731
+ ctx.state.kernel?.processNav(path);
2732
+ },
2733
+ /**
2734
+ * Read the current resolved URL.
2735
+ *
2736
+ * @returns The current pathname + search.
2737
+ * @example
2738
+ * app.spa.current();
2739
+ */
2740
+ current() {
2741
+ return ctx.state.currentUrl;
2742
+ }
2743
+ };
2744
+ }
2745
+ //#endregion
2746
+ //#region src/plugins/spa/events.ts
2747
+ /**
2748
+ * Declares the spa plugin's events. Extracted from index.ts to keep the wiring
2749
+ * file under the line budget.
2750
+ *
2751
+ * @param register - The event registration function supplied by the kernel.
2752
+ * @returns The map of spa event descriptors.
2753
+ * @example
2754
+ * const events = spaEvents(register);
2755
+ */
2756
+ function spaEvents(register) {
2757
+ return {
2758
+ "spa:navigate": register("A navigation has been intercepted and is starting."),
2759
+ "spa:navigated": register("The swap completed and the new URL is active."),
2760
+ "spa:component-mount": register("A component instance attached to an element."),
2761
+ "spa:component-unmount": register("A component instance detached from an element.")
2762
+ };
2763
+ }
2764
+ //#endregion
2765
+ //#region src/plugins/data/load-json.ts
2766
+ /**
2767
+ * @file `loadJson` — the data plugin's isomorphic JSON read primitive (the
2768
+ * SSG↔SPA seam). Internal to the `data` plugin (NOT a framework-root export):
2769
+ * `data.at(path)` uses it, and consumers read through `app.data.at(path)`.
2770
+ *
2771
+ * A read runs in BOTH worlds: on Node it reads the emitted data file from disk;
2772
+ * on the client (browser) it fetches the same data over HTTP. `loadJson` is the
2773
+ * single point where those two worlds differ — everything above it (the route's
2774
+ * `load`/`render`) is shared, so SSR/client parity is structural, not hoped-for.
2775
+ *
2776
+ * The browser path uses the `fetch` global. The Node path lazy-imports
2777
+ * `node:fs/promises` via `await import(...)`, so a browser bundle that includes
2778
+ * `loadJson` never statically pulls `node:*` (the bundler splits the Node branch
2779
+ * into its own chunk that the browser never loads).
2780
+ */
2781
+ /**
2782
+ * Read + parse a JSON resource, isomorphically. In a browser (`document`
2783
+ * defined) it `fetch`es `pathOrUrl`; on Node it reads the file from disk. Throws
2784
+ * on a failed fetch or unreadable file so the caller (`route.load`/`data.at`)
2785
+ * can decide whether to fall back.
2786
+ *
2787
+ * @template T - The expected shape of the parsed JSON.
2788
+ * @param pathOrUrl - A site-root URL (browser) or filesystem path (Node).
2789
+ * @returns The parsed JSON, typed as `T`.
2790
+ * @throws {Error} If the browser fetch is not OK, or the Node file read fails.
2791
+ * @example
2792
+ * ```ts
2793
+ * // Browser: fetch("/_data/en/hello/index.json")
2794
+ * // Node: read "dist/_data/en/hello/index.json"
2795
+ * const article = await loadJson<Article>("/_data/en/hello/index.json");
2796
+ * ```
2797
+ */
2798
+ async function loadJson(pathOrUrl) {
2799
+ if (typeof document === "undefined") {
2800
+ const { readFile } = await import("node:fs/promises");
2801
+ return JSON.parse(await readFile(pathOrUrl, "utf8"));
2802
+ }
2803
+ const response = await fetch(pathOrUrl);
2804
+ if (!response.ok) throw new Error(`[web] loadJson: failed to fetch ${pathOrUrl} (${String(response.status)}).`);
2805
+ return response.json();
2806
+ }
2807
+ //#endregion
2808
+ //#region src/plugins/data/api.ts
2809
+ /**
2810
+ * @file data plugin — API factory (the agnostic data provider surface).
2811
+ *
2812
+ * Node-free by construction: this module statically imports only types + the pure
2813
+ * convention. The Node write side (`write()`) reaches its `node:fs` writer through
2814
+ * a lazy `await import("./writer")` at call time, so a browser bundle that composes
2815
+ * `data` for the read side never pulls `node:*`. The read side (`at()`) uses only
2816
+ * the isomorphic `loadJson` (whose Node branch is itself lazy).
2817
+ */
2818
+ /**
2819
+ * Builds the data provider — the agnostic bridge. `write()` is the Node persist
2820
+ * side; `at()` is the browser read side; `urlFor`/`fileFor` are the pure
2821
+ * convention. No `onStart`/`onStop` (holds no long-lived resource).
2822
+ *
2823
+ * @param ctx - The data plugin context.
2824
+ * @returns The {@link DataProvider} mounted at `app.data`.
2825
+ * @example
2826
+ * ```ts
2827
+ * const api = dataApi(ctx);
2828
+ * await api.write([{ path: "/en/hello/", data: article }]); // Node build
2829
+ * await api.at("/en/hello/"); // browser
2830
+ * ```
2831
+ */
2832
+ function dataApi(ctx) {
2833
+ return {
2834
+ /**
2835
+ * READ (browser) — fetch (and cache) the persisted data for a page path.
2836
+ * Returns the raw JSON as `unknown`, which the route uses directly as `ctx.data`
2837
+ * (no route `.parse()`); returns `null` if the fetch or JSON parse fails (so
2838
+ * `spa` can fall back to HTML).
2839
+ *
2840
+ * @param path - The page URL path (e.g. `/en/hello/`).
2841
+ * @returns The page's raw data, or `null` on failure.
2842
+ * @example
2843
+ * ```ts
2844
+ * const raw = await api.at("/en/hello/");
2845
+ * ```
2846
+ */
2847
+ async at(path) {
2848
+ if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
2849
+ try {
2850
+ const data = await loadJson(`${ctx.config.baseUrl}${dataSuffix(path)}`);
2851
+ ctx.state.cache.set(path, data);
2852
+ return data;
2853
+ } catch {
2854
+ return null;
2855
+ }
2856
+ },
2857
+ /**
2858
+ * WRITE (Node) — persist one JSON file per entry, keyed by page path. Called by
2859
+ * `build` after it expands routes. Lazily loads its `node:fs` writer (keeping a
2860
+ * browser bundle node-free).
2861
+ *
2862
+ * @param entries - The per-page data to persist.
2863
+ * @param options - Optional `{ outDir }` override (defaults to `./dist`).
2864
+ * @param options.outDir - Build output directory the write happens under.
2865
+ * @returns A summary of the written files.
2866
+ * @example
2867
+ * ```ts
2868
+ * await api.write([{ path: "/en/hello/", data: article }], { outDir: "dist" });
2869
+ * ```
2870
+ */
2871
+ async write(entries, options) {
2872
+ const { writeData } = await import("./writer-Dc_lx22j.mjs");
2873
+ return writeData(ctx, entries, options);
2874
+ },
2875
+ /**
2876
+ * PURE — the browser fetch URL for a page path.
2877
+ *
2878
+ * @param path - The page URL path.
2879
+ * @returns The site-root-relative data URL.
2880
+ * @example
2881
+ * ```ts
2882
+ * api.urlFor("/en/hello/"); // "/_data/en/hello/index.json"
2883
+ * ```
2884
+ */
2885
+ urlFor(path) {
2886
+ return `${ctx.config.baseUrl}${dataSuffix(path)}`;
2887
+ },
2888
+ /**
2889
+ * PURE — the `outDir`-relative file path for a page path.
2890
+ *
2891
+ * @param path - The page URL path.
2892
+ * @returns The output-relative file path.
2893
+ * @example
2894
+ * ```ts
2895
+ * api.fileFor("/en/hello/"); // "_data/en/hello/index.json"
2896
+ * ```
2897
+ */
2898
+ fileFor(path) {
2899
+ return relativeDataFile(ctx.config.outputDir, path);
2900
+ }
2901
+ };
2902
+ }
2903
+ //#endregion
2904
+ //#region src/plugins/data/config.ts
2905
+ /**
2906
+ * Typed default data config (R6: no inline `as`). `outputDir` is the WRITE path
2907
+ * (filesystem, relative to the build `outDir`); `baseUrl` is the matching READ URL
2908
+ * (site-root-relative) the browser fetches from — the defaults agree
2909
+ * (`"_data"` ↔ `"/_data/"`).
2910
+ *
2911
+ * @example
2912
+ * ```ts
2913
+ * createPlugin("data", { config: defaultDataConfig });
2914
+ * ```
2915
+ */
2916
+ const defaultDataConfig = {
2917
+ outputDir: "_data",
2918
+ baseUrl: "/_data/"
2919
+ };
2920
+ //#endregion
2921
+ //#region src/plugins/data/state.ts
2922
+ /**
2923
+ * Creates initial data state: a null `lastWrite` slot (populated by the Node
2924
+ * `write()` side) and an empty `cache` (populated lazily by the browser `at(path)`
2925
+ * side on first fetch).
2926
+ *
2927
+ * @param _ctx - Minimal context with global and config.
2928
+ * @param _ctx.global - Global framework configuration.
2929
+ * @param _ctx.config - Resolved plugin configuration.
2930
+ * @returns Fresh data state with no recorded write and an empty per-path cache.
2931
+ * @example
2932
+ * ```ts
2933
+ * const state = createDataState({ global: {}, config });
2934
+ * ```
2935
+ */
2936
+ function createDataState(_ctx) {
2937
+ return {
2938
+ lastWrite: null,
2939
+ cache: /* @__PURE__ */ new Map()
2940
+ };
2941
+ }
2942
+ //#endregion
2943
+ //#region src/plugins/data/validate.ts
2944
+ /**
2945
+ * Reports whether a `baseUrl` value is invalid: it must be a string that is a
2946
+ * site-root-relative URL path (i.e. starting with "/").
2947
+ *
2948
+ * @param baseUrl - The candidate `baseUrl` value to check.
2949
+ * @returns `true` when `baseUrl` is not a string or does not start with "/".
2950
+ * @example
2951
+ * ```ts
2952
+ * isInvalidBaseUrl("/_data/"); // false
2953
+ * isInvalidBaseUrl("_data"); // true
2954
+ * ```
2955
+ */
2956
+ function isInvalidBaseUrl(baseUrl) {
2957
+ return typeof baseUrl !== "string" || !baseUrl.startsWith("/");
2958
+ }
2959
+ /**
2960
+ * Validates the resolved data config: the browser `baseUrl` must be a non-empty,
2961
+ * site-root-relative URL path.
2962
+ *
2963
+ * @param config - The resolved plugin configuration.
2964
+ * @throws {Error} If `baseUrl` is empty or not a rooted URL path.
2416
2965
  * @example
2417
- * const api = createApi(ctx);
2418
- * api.register(counter);
2966
+ * ```ts
2967
+ * validateDataConfig({ outputDir: "_data", baseUrl: "/_data/" });
2968
+ * ```
2419
2969
  */
2420
- function createApi(ctx) {
2421
- return {
2422
- /**
2423
- * Register a component definition (last-registered-wins); warns on collision.
2424
- *
2425
- * @param component - The component definition created via `createComponent`.
2426
- * @example
2427
- * app.spa.register(counter);
2428
- */
2429
- register(component) {
2430
- if (ctx.state.registeredComponents.has(component.name)) ctx.log.warn("spa:component-collision", { name: component.name });
2431
- ctx.state.kernel?.register(component);
2432
- },
2433
- /**
2434
- * Programmatically navigate to a path (client runtime; no-op without a DOM).
2435
- *
2436
- * @param path - Target path (pathname, optionally with search/hash).
2437
- * @example
2438
- * app.spa.navigate("/about");
2439
- */
2440
- navigate(path) {
2441
- ctx.state.kernel?.processNav(path);
2442
- },
2443
- /**
2444
- * Read the current resolved URL.
2445
- *
2446
- * @returns The current pathname + search.
2447
- * @example
2448
- * app.spa.current();
2449
- */
2450
- current() {
2451
- return ctx.state.currentUrl;
2452
- }
2453
- };
2970
+ function validateDataConfig(config) {
2971
+ if (isInvalidBaseUrl(config.baseUrl)) throw new Error(`[web] data.baseUrl: must be a site-root-relative URL path starting with "/" (e.g. "/_data/").`);
2454
2972
  }
2455
2973
  //#endregion
2456
- //#region src/plugins/spa/events.ts
2974
+ //#region src/plugins/data/index.ts
2457
2975
  /**
2458
- * Declares the spa plugin's events. Extracted from index.ts to keep the wiring
2459
- * file under the line budget.
2976
+ * @file data Standard tier plugin (wiring-only). The AGNOSTIC data provider for
2977
+ * the SSG→DATA→SPA pattern.
2978
+ *
2979
+ * Owns ONE contract — `page path → persisted JSON file` — and nothing about what
2980
+ * the data is: `write(entries)` persists per-page JSON on Node (build supplies the
2981
+ * entries it already expanded); `at(path)` fetches + caches it in the browser as
2982
+ * `unknown`, which the route uses directly as `ctx.data` in `render`. NOT a framework
2983
+ * default — the consumer composes it where needed (Node build AND/OR browser app).
2984
+ *
2985
+ * **No hard `depends`** — fully browser-composable; the `node:fs` writer is behind
2986
+ * a lazy `import()` inside `write()`. Build ordering is a call-site contract: build
2987
+ * writes data during its pages phase (after its Phase-0 clean), via `app.data.write`.
2988
+ * No `onStart`/`onStop`.
2989
+ * @see README.md
2990
+ */
2991
+ /**
2992
+ * Data plugin — the agnostic data provider. Mounts `write(entries)` (Node persist),
2993
+ * `at(path)` (browser read), and the pure `urlFor`/`fileFor` convention at `app.data`.
2460
2994
  *
2461
- * @param register - The event registration function supplied by the kernel.
2462
- * @returns The map of spa event descriptors.
2463
2995
  * @example
2464
- * const events = spaEvents(register);
2996
+ * ```ts
2997
+ * // Node build: `build` calls app.data.write(...) during its pages phase when
2998
+ * // router.mode() !== "ssg". Compose the plugin + set the global render mode:
2999
+ * import * as routes from "./routes";
3000
+ * const app = createApp({
3001
+ * plugins: [dataPlugin, contentPlugin, buildPlugin],
3002
+ * config: { mode: "hybrid" },
3003
+ * pluginConfigs: { content: { providers: [fileSystemContent({ contentDir: "./content" })] }, router: { routes } }
3004
+ * });
3005
+ * await app.build.run(); // writes HTML + per-page data sidecars (routes compiled at init)
3006
+ *
3007
+ * // Browser app: compose `dataPlugin` too; spa fetches via app.data.at(path) on nav.
3008
+ * ```
2465
3009
  */
2466
- function spaEvents(register) {
2467
- 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.")
2472
- };
2473
- }
3010
+ const dataPlugin = createPlugin$1("data", {
3011
+ config: defaultDataConfig,
3012
+ createState: createDataState,
3013
+ onInit: (ctx) => validateDataConfig(ctx.config),
3014
+ api: dataApi
3015
+ });
2474
3016
  //#endregion
2475
3017
  //#region src/plugins/spa/types.ts
2476
- var types_exports$5 = /* @__PURE__ */ __exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
3018
+ var types_exports$6 = /* @__PURE__ */ __exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
2477
3019
  /** Allowed hook names — single source of truth for fail-fast validation. */
2478
3020
  const COMPONENT_HOOK_NAMES = [
2479
3021
  "onCreate",
@@ -2490,6 +3032,22 @@ const ERROR_PREFIX$2 = "[web]";
2490
3032
  /** The set of legal hook names, frozen for O(1) membership checks. */
2491
3033
  const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
2492
3034
  /**
3035
+ * Validate a single hook entry: its key must be a known hook name and its value
3036
+ * must be a function. Throws fail-fast on the first violation.
3037
+ *
3038
+ * @param componentName - The owning component name (for error messages).
3039
+ * @param hooks - The hooks object being validated.
3040
+ * @param key - The hook key to validate.
3041
+ * @throws {Error} If `key` is not in `COMPONENT_HOOK_NAMES`.
3042
+ * @throws {TypeError} If the hook value is not a function.
3043
+ * @example
3044
+ * validateHookEntry("counter", hooks, "onMount");
3045
+ */
3046
+ function validateHookEntry(componentName, hooks, key) {
3047
+ if (!HOOK_NAME_SET.has(key)) throw new Error(`${ERROR_PREFIX$2} unknown component hook "${key}" on "${componentName}"\n → valid hooks: ${COMPONENT_HOOK_NAMES.join(", ")}`);
3048
+ if (typeof hooks[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2} component hook "${key}" on "${componentName}" must be a function\n → provide a function or omit the hook`);
3049
+ }
3050
+ /**
2493
3051
  * Create a validated component definition. Validates hook names at registration
2494
3052
  * for fail-fast typo detection (e.g. `onMout` throws immediately) and asserts
2495
3053
  * each provided hook is a function.
@@ -2506,10 +3064,7 @@ const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
2506
3064
  */
2507
3065
  function createComponent(name, hooks) {
2508
3066
  if (name.trim() === "") throw new Error(`${ERROR_PREFIX$2} component name must be a non-empty string\n → pass a unique name to createComponent("name", hooks)`);
2509
- for (const key of Object.keys(hooks)) {
2510
- if (!HOOK_NAME_SET.has(key)) throw new Error(`${ERROR_PREFIX$2} unknown component hook "${key}" on "${name}"\n → valid hooks: ${COMPONENT_HOOK_NAMES.join(", ")}`);
2511
- if (typeof hooks[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2} component hook "${key}" on "${name}" must be a function\n → provide a function or omit the hook`);
2512
- }
3067
+ for (const key of Object.keys(hooks)) validateHookEntry(name, hooks, key);
2513
3068
  return {
2514
3069
  name,
2515
3070
  hooks
@@ -2579,6 +3134,36 @@ function makeContext(element, data) {
2579
3134
  };
2580
3135
  }
2581
3136
  /**
3137
+ * Mounts a single `data-component` element: classifies persistent vs
3138
+ * page-specific, builds the instance, fires `onCreate` then `onMount`, records
3139
+ * it in state, and emits `spa:component-mount`. No-ops if the element is already
3140
+ * mounted, has no component name, or names an unregistered component.
3141
+ *
3142
+ * @param state - The plugin state (registeredComponents + instances).
3143
+ * @param emit - The event emitter for spa:component-mount.
3144
+ * @param swapArea - The swap-region element, or null when none was found.
3145
+ * @param data - The current page data payload.
3146
+ * @param element - The candidate element carrying a `data-component` attribute.
3147
+ * @example
3148
+ * mountElement(state, emit, swapArea, data, element);
3149
+ */
3150
+ function mountElement(state, emit, swapArea, data, element) {
3151
+ if (state.instances.has(element)) return;
3152
+ const name = element.dataset.component;
3153
+ if (!name) return;
3154
+ const definition = state.registeredComponents.get(name);
3155
+ if (!definition) return;
3156
+ const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
3157
+ const ctx = makeContext(element, data);
3158
+ runHook(instance, "onCreate", ctx);
3159
+ runHook(instance, "onMount", ctx);
3160
+ state.instances.set(element, instance);
3161
+ emit("spa:component-mount", {
3162
+ name: definition.name,
3163
+ el: element
3164
+ });
3165
+ }
3166
+ /**
2582
3167
  * Scans the swap region, mounts components for matching `data-component`
2583
3168
  * elements, classifies persistent (outside swap area) vs page-specific (inside),
2584
3169
  * fires `onCreate` then `onMount`, and emits `spa:component-mount` per instance.
@@ -2594,22 +3179,7 @@ function scanAndMount(state, emit, swapSelector) {
2594
3179
  if (typeof document === "undefined") return;
2595
3180
  const swapArea = document.querySelector(swapSelector);
2596
3181
  const data = extractPageData(document);
2597
- for (const element of document.querySelectorAll("[data-component]")) {
2598
- if (state.instances.has(element)) continue;
2599
- const name = element.dataset.component;
2600
- if (!name) continue;
2601
- const definition = state.registeredComponents.get(name);
2602
- if (!definition) continue;
2603
- const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
2604
- const ctx = makeContext(element, data);
2605
- runHook(instance, "onCreate", ctx);
2606
- runHook(instance, "onMount", ctx);
2607
- state.instances.set(element, instance);
2608
- emit("spa:component-mount", {
2609
- name: definition.name,
2610
- el: element
2611
- });
2612
- }
3182
+ for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element);
2613
3183
  }
2614
3184
  /**
2615
3185
  * Unmounts page-specific instances inside the swap region (runs `onUnMount`
@@ -3080,6 +3650,8 @@ function attachRouter(handlers, navigate) {
3080
3650
  //#region src/plugins/spa/state.ts
3081
3651
  /** Error prefix for spa config-validation failures (spec/11 Part-3). */
3082
3652
  const ERROR_PREFIX$1 = "[web]";
3653
+ /** Last-resort `swapSelector` when neither config nor defaults supply one. */
3654
+ const FALLBACK_SWAP_SELECTOR = "main > section";
3083
3655
  /** Default SPA config (declared as a value — no inline assertion). */
3084
3656
  const defaultSpaConfig = {
3085
3657
  swapSelector: "main > section",
@@ -3117,7 +3689,7 @@ function isValidSelector(selector) {
3117
3689
  * const resolved = resolveSpaConfig({ swapSelector: "main > section" });
3118
3690
  */
3119
3691
  function resolveSpaConfig(config) {
3120
- const swapSelector = config.swapSelector ?? defaultSpaConfig.swapSelector ?? "main > section";
3692
+ const swapSelector = config.swapSelector ?? defaultSpaConfig.swapSelector ?? FALLBACK_SWAP_SELECTOR;
3121
3693
  if (swapSelector.trim() === "") throw new Error(`${ERROR_PREFIX$1} spa.swapSelector must be a non-empty string.\n Set a CSS selector for the page region to swap (e.g. "main > section").`);
3122
3694
  if (!isValidSelector(swapSelector)) throw new Error(`${ERROR_PREFIX$1} spa.swapSelector is not a valid CSS selector: "${swapSelector}".\n Provide a syntactically valid selector.`);
3123
3695
  return {
@@ -3162,15 +3734,6 @@ function createState(_ctx) {
3162
3734
  /** Error prefix for spa kernel failures (spec/11 Part-3). */
3163
3735
  const ERROR_PREFIX = "[web]";
3164
3736
  /**
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
3737
  * Registers a component definition into state (last-registered-wins).
3175
3738
  *
3176
3739
  * @param state - The plugin state holding registeredComponents.
@@ -3271,51 +3834,101 @@ function createSpaKernel(state, config, emit, deps) {
3271
3834
  onError: handleError
3272
3835
  };
3273
3836
  /**
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.
3837
+ * Phase 1 of the client DATA path (no side effects): match `pathname`, fetch the
3838
+ * page's PERSISTED data via the `data` reader, build the {@link RouteContext}, run
3839
+ * the route's OWN `render` (the same component the build used for SSG), and locate
3840
+ * the live swap region. The fetched JSON is used DIRECTLY as `ctx.data` (no
3841
+ * validation step `route.parse` was removed). Returns `false` (committing
3842
+ * nothing) on no-`data`-reader / no-match / no-render / no-region / fetch-miss,
3843
+ * so the caller falls back to HTML-over-fetch.
3281
3844
  *
3282
3845
  * @param pathname - The destination pathname (search stripped for matching).
3283
- * @returns `true` if the route was rendered from validated data, else `false`.
3846
+ * @returns The resolved render inputs, or `false` when the DATA path cannot run.
3284
3847
  * @example
3285
- * if (await tryDataRender("/en/world/")) return;
3848
+ * const resolved = await resolveDataRender("/en/world/");
3286
3849
  */
3287
- const tryDataRender = async (pathname) => {
3850
+ const resolveDataRender = async (pathname) => {
3288
3851
  if (!deps.dataAt) return false;
3289
3852
  const matchPath = pathname.split("?")[0] ?? pathname;
3290
3853
  const hit = deps.router.match(matchPath);
3291
3854
  if (!hit?.route._handlers.render) return false;
3855
+ const data = await deps.dataAt(pathname);
3856
+ if (data === null) return false;
3857
+ const locale = hit.params.lang ?? document.documentElement.lang ?? "";
3858
+ const routeContext = {
3859
+ params: hit.params,
3860
+ data,
3861
+ locale,
3862
+ url: (routeName, routeParams = {}) => deps.router.toUrl(routeName, routeParams)
3863
+ };
3864
+ const vnode = hit.route._handlers.render(routeContext);
3865
+ const region = document.querySelector(resolved.swapSelector);
3866
+ if (!region) return false;
3867
+ return {
3868
+ route: hit.route,
3869
+ vnode,
3870
+ routeContext,
3871
+ region
3872
+ };
3873
+ };
3874
+ /**
3875
+ * Phase 2 of the client DATA path (all side effects): begin the navigation,
3876
+ * lazy-load the Preact render layer, sync the document head, unmount the
3877
+ * outgoing page-specific islands, swap the VNode into the region, re-mount, then
3878
+ * record the new URL and emit `spa:navigated`.
3879
+ *
3880
+ * @param pathname - The destination pathname (recorded as the new current URL).
3881
+ * @param resolvedRender - The inputs produced by {@link resolveDataRender}.
3882
+ * @example
3883
+ * await commitDataRender("/en/world/", resolved);
3884
+ */
3885
+ const commitDataRender = async (pathname, resolvedRender) => {
3886
+ const { route, vnode, routeContext, region } = resolvedRender;
3887
+ handleStart(pathname);
3888
+ const { renderVNode } = await import("./render-BNe0s7fr.mjs");
3889
+ syncDataHead(route, routeContext);
3890
+ unmountPageSpecific(state, emit);
3891
+ /**
3892
+ * Render the VNode into the region and re-mount its islands in one paint — the
3893
+ * swap body handed to `runSwap` (optionally wrapped in a View Transition).
3894
+ *
3895
+ * @example
3896
+ * ```ts
3897
+ * runSwap(renderAndMount, resolved.viewTransitions);
3898
+ * ```
3899
+ */
3900
+ const renderAndMount = () => {
3901
+ renderVNode(vnode, region);
3902
+ scanAndMount(state, emit, resolved.swapSelector);
3903
+ notifyNavEnd(state);
3904
+ };
3905
+ runSwap(renderAndMount, resolved.viewTransitions);
3906
+ state.currentUrl = pathname;
3907
+ progress?.done();
3908
+ emit("spa:navigated", { url: pathname });
3909
+ };
3910
+ /**
3911
+ * The client DATA path: resolve the matched route's render inputs from the
3912
+ * page's PERSISTED data ({@link resolveDataRender}), then commit the Preact swap
3913
+ * ({@link commitDataRender}). The fetched JSON is used DIRECTLY as `ctx.data`
3914
+ * (no validation step). `route.load` does NOT run on the client — the build
3915
+ * already persisted its output. Returns `false` (touching nothing the fallback
3916
+ * cares about) on no-match / no-render / null / throw, so the caller falls back
3917
+ * to HTML-over-fetch.
3918
+ *
3919
+ * @param pathname - The destination pathname (search stripped for matching).
3920
+ * @returns `true` if the route was rendered from its data, else `false`.
3921
+ * @example
3922
+ * if (await tryDataRender("/en/world/")) return;
3923
+ */
3924
+ const tryDataRender = async (pathname) => {
3292
3925
  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 });
3926
+ const resolvedRender = await resolveDataRender(pathname);
3927
+ if (resolvedRender === false) return false;
3928
+ await commitDataRender(pathname, resolvedRender);
3317
3929
  return true;
3318
3930
  } catch {
3931
+ progress?.done();
3319
3932
  return false;
3320
3933
  }
3321
3934
  };
@@ -3406,26 +4019,12 @@ function createSpaKernel(state, config, emit, deps) {
3406
4019
  };
3407
4020
  }
3408
4021
  /**
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).
4022
+ * Builds the shared kernel from the plugin context, stores it on `ctx.state`,
4023
+ * and runs its init step (validate config, register config.components, seed
4024
+ * currentUrl). Captures the OPTIONAL `data` reader when the `data` plugin is
4025
+ * composed (enabling client DATA navigation) resolved by instance via
4026
+ * `ctx.require(dataPlugin)`, guarded by `ctx.has("data")` so `data` stays optional
4027
+ * (`spa`'s `depends` is `[router, head]`).
3429
4028
  *
3430
4029
  * @param ctx - The plugin context (state/config/emit/require/has/log).
3431
4030
  * @example
@@ -3437,12 +4036,11 @@ function initSpa(ctx) {
3437
4036
  head: ctx.require(headPlugin)
3438
4037
  };
3439
4038
  if (ctx.has("data")) {
3440
- const reader = ctx.require(dataPluginHandle);
4039
+ const reader = ctx.require(dataPlugin);
3441
4040
  deps.dataAt = (path) => reader.at(path);
3442
4041
  }
3443
4042
  const kernel = createSpaKernel(ctx.state, ctx.config, ctx.emit, deps);
3444
4043
  ctx.state.kernel = kernel;
3445
- kernelRef.current = kernel;
3446
4044
  kernel.init();
3447
4045
  }
3448
4046
  //#endregion
@@ -3452,353 +4050,598 @@ let teardown;
3452
4050
  /** Captured log ref — onStop has no `ctx.log` (spec/08 §4). */
3453
4051
  let logRef;
3454
4052
  /**
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
4053
  * 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.
4054
+ * the SSR/build guard, so onStop has nothing to release). The kernel built in
4055
+ * `onInit` lives on `ctx.state`; its `dispose` is captured into the teardown
4056
+ * closure here. The kernel itself is booted by index.ts after this capture.
3467
4057
  *
3468
- * @param ctx - The plugin context (used for `log` capture).
4058
+ * @param ctx - The plugin context (used for `state.kernel` + `log` capture).
3469
4059
  * @example
3470
4060
  * captureTeardown(ctx);
3471
4061
  */
3472
4062
  function captureTeardown(ctx) {
3473
4063
  if (typeof document === "undefined") return;
3474
4064
  logRef = ctx.log;
3475
- teardown = disposeKernel;
4065
+ const kernel = ctx.state.kernel;
4066
+ teardown = () => kernel?.dispose();
4067
+ }
4068
+ /**
4069
+ * Release everything `captureTeardown`/`onStart` acquired: run teardown in
4070
+ * try/catch (logging via the captured ref), then clear both handles. Idempotent —
4071
+ * a second call is a no-op (spec/11 §4.2) and mirrors `onStart` (§4.1).
4072
+ *
4073
+ * @example
4074
+ * disposeSpa();
4075
+ */
4076
+ function disposeSpa() {
4077
+ try {
4078
+ teardown?.();
4079
+ } catch (error) {
4080
+ logRef?.error("spa:teardown-failed", {}, error);
4081
+ } finally {
4082
+ teardown = void 0;
4083
+ logRef = void 0;
4084
+ }
4085
+ }
4086
+ //#endregion
4087
+ //#region src/plugins/spa/index.ts
4088
+ /**
4089
+ * @file spa — Complex Plugin (WIRING ONLY, ≤30 lines). All logic lives in the
4090
+ * domain files (kernel/router/head/progress/components/lifecycle); index wires.
4091
+ *
4092
+ * Depends: router, head.
4093
+ * Emits: spa:navigate, spa:navigated, spa:component-mount, spa:component-unmount.
4094
+ * @see README.md
4095
+ */
4096
+ /**
4097
+ * SPA plugin — progressive client-side navigation layered over the static site:
4098
+ * swaps a page region on navigation, with an optional progress bar and View
4099
+ * Transitions. Register interactive islands with {@link createComponent}. Depends
4100
+ * on router and head; emits `spa:navigate`, `spa:navigated`, `spa:component-mount`,
4101
+ * and `spa:component-unmount`.
4102
+ *
4103
+ * @example Enable view transitions and a custom swap region
4104
+ * ```ts
4105
+ * const app = createApp({
4106
+ * pluginConfigs: {
4107
+ * spa: {
4108
+ * swapSelector: "main > section",
4109
+ * viewTransitions: true,
4110
+ * progressBar: true
4111
+ * }
4112
+ * }
4113
+ * });
4114
+ * ```
4115
+ */
4116
+ const spaPlugin = createPlugin$1("spa", {
4117
+ depends: [routerPlugin, headPlugin],
4118
+ config: defaultSpaConfig,
4119
+ createState,
4120
+ events: spaEvents,
4121
+ onInit: initSpa,
4122
+ api: createApi,
4123
+ onStart(ctx) {
4124
+ captureTeardown(ctx);
4125
+ ctx.state.kernel?.boot();
4126
+ },
4127
+ onStop: disposeSpa
4128
+ });
4129
+ //#endregion
4130
+ //#region src/plugins/content/api.ts
4131
+ /** Actionable error when the content plugin is composed without any provider. */
4132
+ const NO_PROVIDER = "[web] content: no provider composed.\n Add fileSystemContent(...) to pluginConfigs.content.providers.";
4133
+ /** Zero-pad width for the per-locale ordinal in a `contentId` (e.g. `0001`). */
4134
+ const ID_PADDING = 4;
4135
+ /** Path segment offset of the article slug — the parent directory of the source file. */
4136
+ const PATH_SLUG_INDEX = -2;
4137
+ /**
4138
+ * Collapse the ordered provider list into a single {@link ContentProvider} facade:
4139
+ * `slugs()` are unioned, `readArticle`/`render` use first-match, `invalidate` fans out.
4140
+ * A single-provider list returns that provider directly (the common case).
4141
+ *
4142
+ * @param providers - The ordered content providers from config.
4143
+ * @returns One provider facade over the list.
4144
+ * @example
4145
+ * ```ts
4146
+ * const provider = mergeProviders(ctx.config.providers);
4147
+ * ```
4148
+ */
4149
+ function mergeProviders(providers) {
4150
+ const [first] = providers;
4151
+ if (providers.length === 1 && first !== void 0) return first;
4152
+ return {
4153
+ name: providers.map((provider) => provider.name).join("+") || "content:empty",
4154
+ contentDir: first?.contentDir ?? "",
4155
+ /**
4156
+ * Union of every provider's slugs, sorted.
4157
+ *
4158
+ * @returns The merged slug list.
4159
+ * @example
4160
+ * ```ts
4161
+ * await provider.slugs();
4162
+ * ```
4163
+ */
4164
+ async slugs() {
4165
+ const lists = await Promise.all(providers.map((provider) => provider.slugs()));
4166
+ return [...new Set(lists.flat())].toSorted();
4167
+ },
4168
+ /**
4169
+ * First provider to supply the article wins.
4170
+ *
4171
+ * @param slug - Article directory name.
4172
+ * @param fileLocale - Locale whose source file is read.
4173
+ * @param outLocale - Locale the resulting Article represents.
4174
+ * @param isFallback - Whether this used the default-locale fallback.
4175
+ * @returns The first non-null Article, or `null`.
4176
+ * @example
4177
+ * ```ts
4178
+ * await provider.readArticle("intro", "en", "en", false);
4179
+ * ```
4180
+ */
4181
+ async readArticle(slug, fileLocale, outLocale, isFallback) {
4182
+ return (await Promise.all(providers.map((provider) => provider.readArticle(slug, fileLocale, outLocale, isFallback)))).find((article) => article !== null) ?? null;
4183
+ },
4184
+ /**
4185
+ * Render via the first provider.
4186
+ *
4187
+ * @param markdown - Raw Markdown source.
4188
+ * @returns The rendered HTML.
4189
+ * @throws {Error} If no provider is composed.
4190
+ * @example
4191
+ * ```ts
4192
+ * await provider.render("# Hi");
4193
+ * ```
4194
+ */
4195
+ async render(markdown) {
4196
+ if (first === void 0) throw new Error(NO_PROVIDER);
4197
+ return first.render(markdown);
4198
+ },
4199
+ /**
4200
+ * Fan invalidation out to every provider.
4201
+ *
4202
+ * @param paths - Stale file paths.
4203
+ * @example
4204
+ * ```ts
4205
+ * provider.invalidate(["content/intro/en.md"]);
4206
+ * ```
4207
+ */
4208
+ invalidate(paths) {
4209
+ for (const provider of providers) provider.invalidate?.(paths);
4210
+ }
4211
+ };
4212
+ }
4213
+ /**
4214
+ * Build the canonical "article not found" error for {@link createContentApi.load}.
4215
+ * Centralised so the null-resolve path and the production draft-suppression path
4216
+ * throw an IDENTICAL message — drafts must be indistinguishable from missing
4217
+ * articles in production (no new error shape).
4218
+ *
4219
+ * @param slug - Article directory name.
4220
+ * @param locale - Requested locale code.
4221
+ * @returns The not-found Error to throw.
4222
+ * @example
4223
+ * ```ts
4224
+ * throw articleNotFound("intro", "uk");
4225
+ * ```
4226
+ */
4227
+ function articleNotFound(slug, locale) {
4228
+ 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
4229
  }
3477
4230
  /**
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).
4231
+ * Plugin `api` factory: resolves i18n via `ctx.require`, merges `config.providers` into
4232
+ * one source, assembles the kernel-free {@link ContentApiContext}, and delegates to
4233
+ * {@link createContentApi}. Referenced directly as the plugin's `api` so index.ts stays
4234
+ * wiring-only. Imports no node code (the provider owns it).
3481
4235
  *
4236
+ * @param ctx - Plugin context (state, config, global, emit, require).
4237
+ * @returns The constructed content plugin API surface.
3482
4238
  * @example
3483
- * disposeSpa();
4239
+ * ```ts
4240
+ * const api = contentApi(ctx);
4241
+ * ```
3484
4242
  */
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;
4243
+ function contentApi(ctx) {
4244
+ const i18nApi = ctx.require(i18nPlugin);
4245
+ /**
4246
+ * Active locale codes from i18n.
4247
+ *
4248
+ * @returns The configured locale list.
4249
+ * @example
4250
+ * ```ts
4251
+ * locales(); // ["en"]
4252
+ * ```
4253
+ */
4254
+ function locales() {
4255
+ return i18nApi.locales();
4256
+ }
4257
+ /**
4258
+ * Default locale code from i18n (fallback source).
4259
+ *
4260
+ * @returns The configured default locale.
4261
+ * @example
4262
+ * ```ts
4263
+ * defaultLocale(); // "en"
4264
+ * ```
4265
+ */
4266
+ function defaultLocale() {
4267
+ return i18nApi.defaultLocale();
3493
4268
  }
4269
+ return createContentApi({
4270
+ state: ctx.state,
4271
+ global: ctx.global,
4272
+ emit: ctx.emit,
4273
+ locales,
4274
+ defaultLocale,
4275
+ provider: mergeProviders(ctx.config.providers)
4276
+ });
3494
4277
  }
3495
- //#endregion
3496
- //#region src/plugins/spa/index.ts
3497
4278
  /**
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.
4279
+ * Resolve one article for `(slug, locale)` with locale fallback via the provider: the
4280
+ * native `{locale}` file is preferred (`isFallback: false`); when absent, the
4281
+ * default-locale file is used (`isFallback: true`, requested locale retained).
4282
+ * Returns `null` when neither exists.
3500
4283
  *
3501
- * Depends: router, head.
3502
- * Emits: spa:navigate, spa:navigated, spa:component-mount, spa:component-unmount.
3503
- * @see README.md
4284
+ * @param ctx - Kernel-free domain context.
4285
+ * @param slug - Article directory name.
4286
+ * @param locale - Requested locale code.
4287
+ * @returns The resolved Article, or `null` when nothing matches.
4288
+ * @example
4289
+ * ```ts
4290
+ * const article = await resolveArticle(ctx, "intro", "uk");
4291
+ * ```
3504
4292
  */
4293
+ async function resolveArticle(ctx, slug, locale) {
4294
+ const native = await ctx.provider.readArticle(slug, locale, locale, false);
4295
+ if (native !== null) return native;
4296
+ const fallbackLocale = ctx.defaultLocale();
4297
+ if (fallbackLocale === locale) return null;
4298
+ return ctx.provider.readArticle(slug, fallbackLocale, locale, true);
4299
+ }
3505
4300
  /**
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`.
4301
+ * Comparator sorting articles by frontmatter date descending (newest first),
4302
+ * breaking ties by slug for deterministic ordering.
3511
4303
  *
3512
- * @example Enable view transitions and a custom swap region
4304
+ * @param a - First article.
4305
+ * @param b - Second article.
4306
+ * @returns Negative when `a` is newer, positive when older, 0 when equal.
4307
+ * @example
3513
4308
  * ```ts
3514
- * const app = createApp({
3515
- * pluginConfigs: {
3516
- * spa: {
3517
- * swapSelector: "main > section",
3518
- * viewTransitions: true,
3519
- * progressBar: true
3520
- * }
3521
- * }
3522
- * });
4309
+ * articles.toSorted(byDateDescending);
3523
4310
  * ```
3524
4311
  */
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
4312
+ function byDateDescending(a, b) {
4313
+ const byDate = b.frontmatter.date.localeCompare(a.frontmatter.date);
4314
+ return byDate === 0 ? a.computed.slug.localeCompare(b.computed.slug) : byDate;
4315
+ }
3540
4316
  /**
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.
4317
+ * Project a full Article to a lightweight ArticleCard (no rendered HTML).
3549
4318
  *
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).
4319
+ * @param article - The source article.
4320
+ * @returns The card projection.
4321
+ * @example
4322
+ * ```ts
4323
+ * const card = toCard(article);
4324
+ * ```
3554
4325
  */
4326
+ function toCard(article) {
4327
+ return {
4328
+ contentId: article.computed.contentId,
4329
+ status: article.computed.status,
4330
+ title: article.frontmatter.title,
4331
+ date: article.frontmatter.date,
4332
+ description: article.frontmatter.description,
4333
+ tags: article.frontmatter.tags,
4334
+ readingTime: article.computed.readingTime,
4335
+ url: article.url
4336
+ };
4337
+ }
3555
4338
  /**
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.
4339
+ * Whether an article belongs in a locale collection: every article is published
4340
+ * outside production, and in production all non-`draft` articles are published.
3560
4341
  *
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.
4342
+ * @param article - The candidate article.
4343
+ * @param isProduction - Whether the deployment stage is production.
4344
+ * @returns `true` when the article should be included.
3565
4345
  * @example
3566
4346
  * ```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");
4347
+ * isPublished(article, true); // false for a draft in production
3570
4348
  * ```
3571
4349
  */
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();
4350
+ function isPublished(article, isProduction) {
4351
+ return !isProduction || article.computed.status !== "draft";
3580
4352
  }
3581
- //#endregion
3582
- //#region src/plugins/data/api.ts
3583
4353
  /**
3584
- * @file data plugin API factory (the agnostic data provider surface).
4354
+ * Resolve every slug for one locale, then narrow to the articles that belong in the
4355
+ * locale collection: existing files only, drafts dropped in production, sorted
4356
+ * date-descending. The single load+filter+sort step behind {@link createContentApi.loadAll}.
3585
4357
  *
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).
4358
+ * @param ctx - Kernel-free domain context (provider + i18n helpers + stage).
4359
+ * @param slugs - Every known article slug from the provider.
4360
+ * @param locale - The locale to resolve and collect.
4361
+ * @returns The published (date-descending) articles for this locale.
4362
+ * @example
4363
+ * ```ts
4364
+ * const present = await loadAndFilterArticles(ctx, slugs, "en");
4365
+ * ```
3591
4366
  */
4367
+ async function loadAndFilterArticles(ctx, slugs, locale) {
4368
+ const isProduction = ctx.global.stage === "production";
4369
+ return (await Promise.all(slugs.map((slug) => resolveArticle(ctx, slug, locale)))).filter((article) => article !== null).filter((article) => isPublished(article, isProduction)).toSorted(byDateDescending);
4370
+ }
3592
4371
  /**
3593
- * Trim a single trailing slash from a config dir so `fileFor` joins cleanly.
4372
+ * Derive the article slug from a source file path the parent directory name
4373
+ * (`content/intro/en.md` → `intro`). Returns `undefined` when the path has no
4374
+ * parent segment.
3594
4375
  *
3595
- * @param dir - The configured output dir (e.g. `"_data"` or `"_data/"`).
3596
- * @returns The dir without a trailing slash.
4376
+ * @param filePath - The (possibly stale) source file path.
4377
+ * @returns The slug segment, or `undefined` when none exists.
3597
4378
  * @example
3598
4379
  * ```ts
3599
- * trimTrailingSlash("_data/"); // "_data"
4380
+ * extractSlugFromPath("content/intro/en.md"); // "intro"
3600
4381
  * ```
3601
4382
  */
3602
- function trimTrailingSlash(dir) {
3603
- return dir.endsWith("/") ? dir.slice(0, -1) : dir;
4383
+ function extractSlugFromPath(filePath) {
4384
+ return filePath.split(/[/\\]/).at(PATH_SLUG_INDEX);
3604
4385
  }
3605
4386
  /**
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).
4387
+ * Creates the content plugin API surface (loadAll, load, renderMarkdown, invalidate,
4388
+ * articleToCard, contentDir) over the kernel-free domain context. Delegates all source
4389
+ * reads to `ctx.provider`; drafts are excluded only in production; `loadAll` emits
4390
+ * `content:ready` and `invalidate` emits `content:invalidated`.
3609
4391
  *
3610
- * @param ctx - The data plugin context.
3611
- * @returns The {@link DataProvider} mounted at `app.data`.
4392
+ * @param ctx - Kernel-free domain context (state, global, emit, i18n helpers, provider).
4393
+ * @returns The content plugin {@link Api} surface.
3612
4394
  * @example
3613
4395
  * ```ts
3614
- * const api = dataApi(ctx);
3615
- * await api.write([{ path: "/en/hello/", data: article }]); // Node build
3616
- * await api.at("/en/hello/"); // browser
4396
+ * const api = createContentApi(apiContext);
4397
+ * const byLocale = await api.loadAll();
3617
4398
  * ```
3618
4399
  */
3619
- function dataApi(ctx) {
4400
+ function createContentApi(ctx) {
3620
4401
  return {
3621
4402
  /**
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).
4403
+ * Load every article across every active locale (locale fallback, production
4404
+ * draft exclusion, date sort, `contentId` after sort), cache them, emit `content:ready`.
3625
4405
  *
3626
- * @param path - The page URL path (e.g. `/en/hello/`).
3627
- * @returns The page's raw data, or `null` on failure.
4406
+ * @returns A locale-keyed map of date-descending articles.
3628
4407
  * @example
3629
4408
  * ```ts
3630
- * const raw = await api.at("/en/hello/");
4409
+ * const byLocale = await api.loadAll();
3631
4410
  * ```
3632
4411
  */
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;
4412
+ async loadAll() {
4413
+ const slugs = await ctx.provider.slugs();
4414
+ const locales = ctx.locales();
4415
+ const result = /* @__PURE__ */ new Map();
4416
+ let total = 0;
4417
+ for (const locale of locales) {
4418
+ const present = await loadAndFilterArticles(ctx, slugs, locale);
4419
+ const cache = /* @__PURE__ */ new Map();
4420
+ let index = 0;
4421
+ for (const article of present) {
4422
+ article.computed.contentId = `${locale}:${String(index).padStart(ID_PADDING, "0")}:${article.computed.slug}`;
4423
+ cache.set(article.computed.slug, article);
4424
+ index += 1;
4425
+ }
4426
+ ctx.state.articles.set(locale, cache);
4427
+ result.set(locale, present);
4428
+ total += present.length;
3641
4429
  }
4430
+ ctx.emit("content:ready", {
4431
+ locales,
4432
+ articleCount: total
4433
+ });
4434
+ return result;
3642
4435
  },
3643
4436
  /**
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).
4437
+ * Resolve and render a single article for one locale with locale fallback. Throws a
4438
+ * `[web] content` not-found error when no file matches; in production a `draft` is
4439
+ * suppressed and throws the SAME not-found error (drafts indistinguishable from
4440
+ * missing); in development and test drafts load normally.
3647
4441
  *
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.
4442
+ * @param slug - Article directory name.
4443
+ * @param locale - Requested locale code.
4444
+ * @returns The resolved Article.
4445
+ * @throws {Error} `[web] content` not-found when no file matches, or when the
4446
+ * resolved article is a draft and `global.stage === "production"`.
3652
4447
  * @example
3653
4448
  * ```ts
3654
- * await api.write([{ path: "/en/hello/", data: article }], { outDir: "dist" });
4449
+ * const article = await api.load("intro", "uk");
3655
4450
  * ```
3656
4451
  */
3657
- async write(entries, options) {
3658
- const { writeData } = await import("./writer-BcWqa_7I.mjs");
3659
- return writeData(ctx, entries, options);
4452
+ async load(slug, locale) {
4453
+ const article = await resolveArticle(ctx, slug, locale);
4454
+ if (article === null) throw articleNotFound(slug, locale);
4455
+ if (ctx.global.stage === "production" && article.computed.status === "draft") throw articleNotFound(slug, locale);
4456
+ const cache = ctx.state.articles.get(locale) ?? /* @__PURE__ */ new Map();
4457
+ cache.set(slug, article);
4458
+ ctx.state.articles.set(locale, cache);
4459
+ return article;
3660
4460
  },
3661
4461
  /**
3662
- * PURE the browser fetch URL for a page path.
4462
+ * Render a raw Markdown string to HTML through the provider's pipeline.
3663
4463
  *
3664
- * @param path - The page URL path.
3665
- * @returns The site-root-relative data URL.
4464
+ * @param md - Raw Markdown source.
4465
+ * @returns The rendered HTML string.
3666
4466
  * @example
3667
4467
  * ```ts
3668
- * api.urlFor("/en/hello/"); // "/_data/en/hello/index.json"
4468
+ * const html = await api.renderMarkdown("# Hi");
3669
4469
  * ```
3670
4470
  */
3671
- urlFor(path) {
3672
- return `${ctx.config.baseUrl}${dataSuffix(path)}`;
4471
+ async renderMarkdown(md) {
4472
+ return ctx.provider.render(md);
3673
4473
  },
3674
4474
  /**
3675
- * PURE the `outDir`-relative file path for a page path.
4475
+ * Mark file paths stale for incremental dev rebuilds: fan invalidation to the
4476
+ * provider and drop the derived slug cache entries so the next `loadAll()` re-reads
4477
+ * only those files. Empty/whitespace paths are ignored. Emits `content:invalidated`.
3676
4478
  *
3677
- * @param path - The page URL path.
3678
- * @returns The output-relative file path.
4479
+ * @param paths - File paths to invalidate.
3679
4480
  * @example
3680
4481
  * ```ts
3681
- * api.fileFor("/en/hello/"); // "_data/en/hello/index.json"
4482
+ * api.invalidate(["src/content/intro/en.md"]);
3682
4483
  * ```
3683
4484
  */
3684
- fileFor(path) {
3685
- return `${trimTrailingSlash(ctx.config.outputDir)}/${dataSuffix(path)}`;
4485
+ invalidate(paths) {
4486
+ const accepted = paths.filter((filePath) => filePath.trim() !== "");
4487
+ ctx.provider.invalidate?.(accepted);
4488
+ for (const filePath of accepted) {
4489
+ const slug = extractSlugFromPath(filePath);
4490
+ if (slug === void 0) continue;
4491
+ for (const cache of ctx.state.articles.values()) cache.delete(slug);
4492
+ }
4493
+ ctx.emit("content:invalidated", { paths: accepted });
4494
+ },
4495
+ /**
4496
+ * Project a full Article to a lightweight ArticleCard for list/grid rendering.
4497
+ *
4498
+ * @param article - The source article.
4499
+ * @returns The card projection.
4500
+ * @example
4501
+ * ```ts
4502
+ * const card = api.articleToCard(article);
4503
+ * ```
4504
+ */
4505
+ articleToCard(article) {
4506
+ return toCard(article);
4507
+ },
4508
+ /**
4509
+ * The configured content source directory (from the first provider).
4510
+ *
4511
+ * @returns The content directory path.
4512
+ * @example
4513
+ * ```ts
4514
+ * api.contentDir(); // "./content"
4515
+ * ```
4516
+ */
4517
+ contentDir() {
4518
+ return ctx.provider.contentDir;
3686
4519
  }
3687
4520
  };
3688
4521
  }
3689
4522
  //#endregion
3690
- //#region src/plugins/data/config.ts
4523
+ //#region src/plugins/content/config.ts
3691
4524
  /**
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/"`).
4525
+ * Typed default content config (R6: no inline `as`). The provider list defaults to
4526
+ * `[]`; a build MUST compose at least one (e.g. `fileSystemContent(...)`), enforced at
4527
+ * `onInit`. Source + pipeline options now live on the provider, not here.
3696
4528
  *
3697
4529
  * @example
3698
4530
  * ```ts
3699
- * createPlugin("data", { config: defaultDataConfig });
4531
+ * createPlugin("content", { config: defaultContentConfig });
3700
4532
  * ```
3701
4533
  */
3702
- const defaultDataConfig = {
3703
- outputDir: "_data",
3704
- baseUrl: "/_data/"
3705
- };
4534
+ const defaultContentConfig = { providers: [] };
3706
4535
  //#endregion
3707
- //#region src/plugins/data/state.ts
4536
+ //#region src/plugins/content/events.ts
3708
4537
  /**
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).
4538
+ * Registers the content plugin's notification-only events (`content:ready`,
4539
+ * `content:invalidated`) with their typed payloads. Referenced as the plugin's
4540
+ * `events` callback so index.ts stays wiring-only.
4541
+ *
4542
+ * @param register - Kernel-provided typed event registrar.
4543
+ * @returns The content event descriptor map.
4544
+ * @example
4545
+ * ```ts
4546
+ * createPlugin("content", { events: contentEvents });
4547
+ * ```
4548
+ */
4549
+ const contentEvents = (register) => ({
4550
+ "content:ready": register("All articles loaded across locales"),
4551
+ "content:invalidated": register("Article paths marked stale for dev rebuild")
4552
+ });
4553
+ //#endregion
4554
+ //#region src/plugins/content/state.ts
4555
+ /**
4556
+ * Creates initial content plugin shell state — an empty article cache. The lazy
4557
+ * unified processor + discovery caches live in the provider, not here.
3712
4558
  *
3713
4559
  * @param _ctx - Minimal context with global and config.
3714
- * @param _ctx.global - Global framework configuration.
4560
+ * @param _ctx.global - Global plugin registry.
3715
4561
  * @param _ctx.config - Resolved plugin configuration.
3716
- * @returns Fresh data state with no recorded write and an empty per-path cache.
4562
+ * @returns Fresh content shell state: an empty article cache.
3717
4563
  * @example
3718
4564
  * ```ts
3719
- * const state = createDataState({ global: {}, config });
4565
+ * const state = createContentState({ global: {}, config: { providers: [] } });
3720
4566
  * ```
3721
4567
  */
3722
- function createDataState(_ctx) {
3723
- return {
3724
- lastWrite: null,
3725
- cache: /* @__PURE__ */ new Map()
3726
- };
4568
+ function createContentState(_ctx) {
4569
+ return { articles: /* @__PURE__ */ new Map() };
3727
4570
  }
3728
4571
  //#endregion
3729
- //#region src/plugins/data/validate.ts
4572
+ //#region src/plugins/content/validate.ts
3730
4573
  /**
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.
4574
+ * Validates the resolved content config (fail-fast at `createApp`). Throws when no
4575
+ * content provider is composed content is useless without a source. Errors use the
4576
+ * `[web]` prefix. (Per-provider options like `contentDir` are validated by the provider.)
3733
4577
  *
3734
- * @param config - The resolved plugin configuration.
3735
- * @throws {Error} If `baseUrl` is empty or not a rooted URL path.
4578
+ * @param config - Resolved content plugin configuration.
4579
+ * @throws {Error} If `providers` is empty.
3736
4580
  * @example
3737
4581
  * ```ts
3738
- * validateDataConfig({ outputDir: "_data", baseUrl: "/_data/" });
4582
+ * validateContentConfig(config);
3739
4583
  * ```
3740
4584
  */
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/").`);
4585
+ function validateContentConfig(config) {
4586
+ 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
4587
  }
3744
4588
  //#endregion
3745
- //#region src/plugins/data/index.ts
4589
+ //#region src/plugins/content/index.ts
3746
4590
  /**
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).
4591
+ * @file contentComplex Plugin skeleton (wiring-only).
3755
4592
  *
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`.
4593
+ * Markdown pipeline: discover, parse frontmatter, render to sanitized HTML, and
4594
+ * expose a locale-keyed Article model. Depends on i18n. Emits `content:ready`
4595
+ * and `content:invalidated`.
3760
4596
  * @see README.md
3761
4597
  */
3762
4598
  /**
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`.
4599
+ * Content plugin (shell) provider-driven locale-keyed Article model. Orchestration
4600
+ * (locale fallback, draft filtering, sort, caching, events) lives here; source I/O +
4601
+ * the Markdown pipeline live in a {@link ContentProvider} you compose (like `env`
4602
+ * providers). The shell imports zero node code, so `contentPlugin` is browser-safe.
4603
+ * Depends on i18n; emits `content:ready` and `content:invalidated`.
3765
4604
  *
3766
- * @example
4605
+ * @example Compose the node filesystem provider with a content dir + Shiki theme
3767
4606
  * ```ts
3768
- * // Node build: `build` calls app.data.write(...) during its pages phase when
3769
- * // router.mode !== "ssg". Just compose the plugin:
4607
+ * import { contentPlugin, fileSystemContent } from "@moku-labs/web";
3770
4608
  * const app = createApp({
3771
- * plugins: [dataPlugin, contentPlugin, buildPlugin],
3772
- * pluginConfigs: { content: { contentDir: "./content" }, router: { routes, mode: "hybrid" } }
4609
+ * plugins: [contentPlugin],
4610
+ * pluginConfigs: {
4611
+ * content: {
4612
+ * providers: [fileSystemContent({ contentDir: "./content", shikiTheme: "github-dark", defaultAuthor: "Ada" })]
4613
+ * }
4614
+ * }
3773
4615
  * });
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
4616
  * ```
3779
4617
  */
3780
- const dataPlugin = createPlugin$1("data", {
3781
- config: defaultDataConfig,
3782
- createState: createDataState,
3783
- onInit: (ctx) => validateDataConfig(ctx.config),
3784
- api: dataApi
4618
+ const contentPlugin = createPlugin$1("content", {
4619
+ depends: [i18nPlugin],
4620
+ events: contentEvents,
4621
+ config: defaultContentConfig,
4622
+ createState: createContentState,
4623
+ onInit: (ctx) => validateContentConfig(ctx.config),
4624
+ api: contentApi
3785
4625
  });
3786
4626
  //#endregion
3787
- //#region src/plugins/data/types.ts
4627
+ //#region src/plugins/content/types.ts
3788
4628
  var types_exports = /* @__PURE__ */ __exportAll({});
3789
4629
  //#endregion
3790
- //#region src/plugins/env/types.ts
4630
+ //#region src/plugins/data/types.ts
3791
4631
  var types_exports$1 = /* @__PURE__ */ __exportAll({});
3792
4632
  //#endregion
3793
- //#region src/plugins/head/types.ts
4633
+ //#region src/plugins/env/types.ts
3794
4634
  var types_exports$2 = /* @__PURE__ */ __exportAll({});
3795
4635
  //#endregion
3796
- //#region src/plugins/log/types.ts
4636
+ //#region src/plugins/head/types.ts
3797
4637
  var types_exports$3 = /* @__PURE__ */ __exportAll({});
3798
4638
  //#endregion
3799
- //#region src/plugins/router/types.ts
4639
+ //#region src/plugins/log/types.ts
3800
4640
  var types_exports$4 = /* @__PURE__ */ __exportAll({});
3801
4641
  //#endregion
4642
+ //#region src/plugins/router/types.ts
4643
+ var types_exports$5 = /* @__PURE__ */ __exportAll({});
4644
+ //#endregion
3802
4645
  //#region src/browser.ts
3803
4646
  /**
3804
4647
  * @file `@moku-labs/web/browser` — the browser-safe entry point.
@@ -3842,20 +4685,16 @@ const core = createCore(coreConfig, {
3842
4685
  *
3843
4686
  * @param options - Optional configuration:
3844
4687
  * - `pluginConfigs` — per-plugin overrides, keyed by plugin name.
3845
- * - `config` — global framework config (e.g. `{ mode: "development" }`).
4688
+ * - `config` — global framework config (e.g. `{ mode: "spa" }`).
3846
4689
  * - `plugins` — extra plugins (e.g. `dataPlugin` or your own) merged into the app and its type.
3847
4690
  * - `onReady` / `onError` / `onStart` / `onStop` — lifecycle callbacks.
3848
4691
  * @returns The initialized app: `start()`, `stop()`, every plugin's API, and `log`.
3849
4692
  * @example
3850
4693
  * ```ts
3851
4694
  * // 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();
4695
+ * import * as routes from "./routes";
4696
+ * const app = createApp({ config: { mode: "spa" }, pluginConfigs: { router: { routes } } });
4697
+ * await app.start(); // routes compiled at init from config
3859
4698
  * app.env.get("PUBLIC_API_URL"); // resolved from import.meta.env
3860
4699
  * ```
3861
4700
  */
@@ -3877,4 +4716,4 @@ const createApp = core.createApp;
3877
4716
  */
3878
4717
  const createPlugin = core.createPlugin;
3879
4718
  //#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 };
4719
+ 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 };