@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/README.md +227 -125
- package/dist/browser.d.mts +524 -111
- package/dist/browser.mjs +1707 -868
- package/dist/{convention-X3zLTlJ8.mjs → convention-CepUwWmT.mjs} +18 -1
- package/dist/{convention-Dr8jxG70.cjs → convention-krwh7Y6Q.cjs} +23 -0
- package/dist/index.cjs +3239 -1935
- package/dist/index.d.cts +365 -176
- package/dist/index.d.mts +366 -177
- package/dist/index.mjs +3236 -1934
- package/dist/{writer-DAF0pM25.cjs → writer-DV5hWB2i.cjs} +25 -27
- package/dist/{writer-BcWqa_7I.mjs → writer-Dc_lx22j.mjs} +25 -27
- package/package.json +1 -1
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-
|
|
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
|
|
146
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
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
|
|
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
|
-
...
|
|
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: {
|
|
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
|
-
|
|
935
|
-
while (trimmedBase.endsWith("/")) trimmedBase = trimmedBase.slice(0, -1);
|
|
1083
|
+
const trimmedBase = trimTrailingSlashes(base);
|
|
936
1084
|
if (path === "" || path === "/") return trimmedBase;
|
|
937
|
-
|
|
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
|
|
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/
|
|
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,
|
|
1104
|
-
* group keys so only declared
|
|
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
|
|
1320
|
+
* @returns A clean record of named params.
|
|
1108
1321
|
* @example
|
|
1109
1322
|
* ```ts
|
|
1110
|
-
*
|
|
1323
|
+
* extractGroups({ slug: "hello", "0": "x" }); // { slug: "hello" }
|
|
1111
1324
|
* ```
|
|
1112
1325
|
*/
|
|
1113
|
-
function
|
|
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
|
|
1360
|
+
if (withLang) return extractGroups(withLang.pathname.groups);
|
|
1139
1361
|
const bare = matchers.bare.exec({ pathname });
|
|
1140
1362
|
if (bare) {
|
|
1141
|
-
const params =
|
|
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/
|
|
1392
|
+
//#region src/plugins/router/builders/compile.ts
|
|
1171
1393
|
/**
|
|
1172
|
-
* @file router plugin —
|
|
1394
|
+
* @file router plugin — compilation + validation domain.
|
|
1173
1395
|
*
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
1181
|
-
*
|
|
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
|
|
1184
|
-
* @returns
|
|
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
|
-
*
|
|
1413
|
+
* isPatternRooted("/{slug}/"); // true
|
|
1189
1414
|
* ```
|
|
1190
1415
|
*/
|
|
1191
|
-
function
|
|
1192
|
-
|
|
1193
|
-
return state.table;
|
|
1416
|
+
function isPatternRooted(pattern) {
|
|
1417
|
+
return pattern.startsWith("/");
|
|
1194
1418
|
}
|
|
1195
1419
|
/**
|
|
1196
|
-
*
|
|
1420
|
+
* Whether a pattern's `{` and `}` braces are balanced — every placeholder must
|
|
1421
|
+
* be closed so segment parsing cannot drift.
|
|
1197
1422
|
*
|
|
1198
|
-
* @param
|
|
1199
|
-
* @returns
|
|
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
|
-
*
|
|
1427
|
+
* hasBalancedBraces("/{slug}/"); // true
|
|
1203
1428
|
* ```
|
|
1204
1429
|
*/
|
|
1205
|
-
function
|
|
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
|
-
*
|
|
1217
|
-
*
|
|
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
|
|
1220
|
-
* @returns
|
|
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
|
-
*
|
|
1441
|
+
* hasValidLangCount("/{lang:?}/{slug}/"); // true
|
|
1224
1442
|
* ```
|
|
1225
1443
|
*/
|
|
1226
|
-
function
|
|
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
|
-
*
|
|
1235
|
-
*
|
|
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
|
|
1238
|
-
* @param
|
|
1239
|
-
* @
|
|
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
|
-
*
|
|
1243
|
-
* api.match("/en/hello/");
|
|
1458
|
+
* assertRouteValid("home", "/{slug}/");
|
|
1244
1459
|
* ```
|
|
1245
1460
|
*/
|
|
1246
|
-
function
|
|
1247
|
-
|
|
1248
|
-
|
|
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
|
|
1418
|
-
* @throws {Error} If routes are empty
|
|
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$
|
|
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" }
|
|
1524
|
+
* buildUrl("/{slug}/", { slug: "hello" }); // "/hello/"
|
|
1498
1525
|
* ```
|
|
1499
1526
|
*/
|
|
1500
|
-
function buildUrl(pattern, params
|
|
1527
|
+
function buildUrl(pattern, params) {
|
|
1501
1528
|
const out = [];
|
|
1502
1529
|
for (const segment of pattern.split("/")) {
|
|
1503
1530
|
const placeholder = parsePlaceholder(segment);
|
|
1504
|
-
|
|
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
|
|
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
|
|
1538
|
-
const
|
|
1539
|
-
|
|
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
|
|
1782
|
+
* Build a URL for a named route from params.
|
|
1550
1783
|
*
|
|
1551
|
-
* @param
|
|
1552
|
-
* @
|
|
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
|
-
*
|
|
1790
|
+
* api.toUrl("article", { lang: "en", slug: "hello" });
|
|
1556
1791
|
* ```
|
|
1557
1792
|
*/
|
|
1558
|
-
toUrl(params) {
|
|
1559
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
1567
|
-
* @returns The output file path.
|
|
1801
|
+
* @returns A fresh read-only array of resolved typed routes.
|
|
1568
1802
|
* @example
|
|
1569
1803
|
* ```ts
|
|
1570
|
-
*
|
|
1804
|
+
* for (const r of api.entries()) r.toUrl({ slug: "x" });
|
|
1571
1805
|
* ```
|
|
1572
1806
|
*/
|
|
1573
|
-
|
|
1574
|
-
return
|
|
1807
|
+
entries() {
|
|
1808
|
+
return readTable(state).compiled.map((entry) => toTypedRoute(entry));
|
|
1575
1809
|
},
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
*
|
|
1582
|
-
*
|
|
1583
|
-
*
|
|
1584
|
-
*
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
*
|
|
1591
|
-
*
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
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
|
-
*
|
|
1634
|
-
*
|
|
1635
|
-
*
|
|
1636
|
-
*
|
|
1637
|
-
*
|
|
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
|
|
1640
|
-
* @returns
|
|
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
|
-
*
|
|
1644
|
-
*
|
|
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
|
|
1650
|
-
|
|
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((
|
|
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: {
|
|
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}
|
|
1845
|
-
*
|
|
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
|
|
2108
|
+
* @example Register routes via config, then start/build
|
|
1848
2109
|
* ```ts
|
|
2110
|
+
* import * as routes from "./routes";
|
|
1849
2111
|
* const app = createApp({
|
|
1850
|
-
*
|
|
1851
|
-
*
|
|
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
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
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
|
-
|
|
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("
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
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 =
|
|
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 `<${
|
|
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 = "[
|
|
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
|
-
* `[
|
|
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
|
-
*
|
|
2418
|
-
*
|
|
2966
|
+
* ```ts
|
|
2967
|
+
* validateDataConfig({ outputDir: "_data", baseUrl: "/_data/" });
|
|
2968
|
+
* ```
|
|
2419
2969
|
*/
|
|
2420
|
-
function
|
|
2421
|
-
|
|
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/
|
|
2974
|
+
//#region src/plugins/data/index.ts
|
|
2457
2975
|
/**
|
|
2458
|
-
*
|
|
2459
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
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$
|
|
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 ??
|
|
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
|
-
*
|
|
3275
|
-
* `data` reader,
|
|
3276
|
-
* route's OWN `render` (the same component the build used for SSG) and
|
|
3277
|
-
*
|
|
3278
|
-
*
|
|
3279
|
-
*
|
|
3280
|
-
*
|
|
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
|
|
3846
|
+
* @returns The resolved render inputs, or `false` when the DATA path cannot run.
|
|
3284
3847
|
* @example
|
|
3285
|
-
*
|
|
3848
|
+
* const resolved = await resolveDataRender("/en/world/");
|
|
3286
3849
|
*/
|
|
3287
|
-
const
|
|
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
|
|
3294
|
-
if (
|
|
3295
|
-
|
|
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
|
-
*
|
|
3410
|
-
*
|
|
3411
|
-
*
|
|
3412
|
-
*
|
|
3413
|
-
|
|
3414
|
-
|
|
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(
|
|
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
|
|
3466
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
3479
|
-
*
|
|
3480
|
-
*
|
|
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
|
-
*
|
|
4239
|
+
* ```ts
|
|
4240
|
+
* const api = contentApi(ctx);
|
|
4241
|
+
* ```
|
|
3484
4242
|
*/
|
|
3485
|
-
function
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
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
|
-
*
|
|
3499
|
-
*
|
|
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
|
-
*
|
|
3502
|
-
*
|
|
3503
|
-
* @
|
|
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
|
-
*
|
|
3507
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
*
|
|
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
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
3551
|
-
*
|
|
3552
|
-
*
|
|
3553
|
-
*
|
|
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
|
-
*
|
|
3557
|
-
*
|
|
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
|
-
* @
|
|
3562
|
-
* @param
|
|
3563
|
-
* @returns
|
|
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
|
-
* //
|
|
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
|
-
|
|
3573
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
3587
|
-
*
|
|
3588
|
-
*
|
|
3589
|
-
*
|
|
3590
|
-
*
|
|
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
|
-
*
|
|
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
|
|
3596
|
-
* @returns The
|
|
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
|
-
*
|
|
4380
|
+
* extractSlugFromPath("content/intro/en.md"); // "intro"
|
|
3600
4381
|
* ```
|
|
3601
4382
|
*/
|
|
3602
|
-
function
|
|
3603
|
-
return
|
|
4383
|
+
function extractSlugFromPath(filePath) {
|
|
4384
|
+
return filePath.split(/[/\\]/).at(PATH_SLUG_INDEX);
|
|
3604
4385
|
}
|
|
3605
4386
|
/**
|
|
3606
|
-
*
|
|
3607
|
-
*
|
|
3608
|
-
*
|
|
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 -
|
|
3611
|
-
* @returns The {@link
|
|
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 =
|
|
3615
|
-
* await api.
|
|
3616
|
-
* await api.at("/en/hello/"); // browser
|
|
4396
|
+
* const api = createContentApi(apiContext);
|
|
4397
|
+
* const byLocale = await api.loadAll();
|
|
3617
4398
|
* ```
|
|
3618
4399
|
*/
|
|
3619
|
-
function
|
|
4400
|
+
function createContentApi(ctx) {
|
|
3620
4401
|
return {
|
|
3621
4402
|
/**
|
|
3622
|
-
*
|
|
3623
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
4409
|
+
* const byLocale = await api.loadAll();
|
|
3631
4410
|
* ```
|
|
3632
4411
|
*/
|
|
3633
|
-
async
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
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
|
-
*
|
|
3645
|
-
* `
|
|
3646
|
-
*
|
|
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
|
|
3649
|
-
* @param
|
|
3650
|
-
* @
|
|
3651
|
-
* @
|
|
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.
|
|
4449
|
+
* const article = await api.load("intro", "uk");
|
|
3655
4450
|
* ```
|
|
3656
4451
|
*/
|
|
3657
|
-
async
|
|
3658
|
-
const
|
|
3659
|
-
|
|
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
|
-
*
|
|
4462
|
+
* Render a raw Markdown string to HTML through the provider's pipeline.
|
|
3663
4463
|
*
|
|
3664
|
-
* @param
|
|
3665
|
-
* @returns The
|
|
4464
|
+
* @param md - Raw Markdown source.
|
|
4465
|
+
* @returns The rendered HTML string.
|
|
3666
4466
|
* @example
|
|
3667
4467
|
* ```ts
|
|
3668
|
-
* api.
|
|
4468
|
+
* const html = await api.renderMarkdown("# Hi");
|
|
3669
4469
|
* ```
|
|
3670
4470
|
*/
|
|
3671
|
-
|
|
3672
|
-
return
|
|
4471
|
+
async renderMarkdown(md) {
|
|
4472
|
+
return ctx.provider.render(md);
|
|
3673
4473
|
},
|
|
3674
4474
|
/**
|
|
3675
|
-
*
|
|
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
|
|
3678
|
-
* @returns The output-relative file path.
|
|
4479
|
+
* @param paths - File paths to invalidate.
|
|
3679
4480
|
* @example
|
|
3680
4481
|
* ```ts
|
|
3681
|
-
* api.
|
|
4482
|
+
* api.invalidate(["src/content/intro/en.md"]);
|
|
3682
4483
|
* ```
|
|
3683
4484
|
*/
|
|
3684
|
-
|
|
3685
|
-
|
|
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/
|
|
4523
|
+
//#region src/plugins/content/config.ts
|
|
3691
4524
|
/**
|
|
3692
|
-
* Typed default
|
|
3693
|
-
*
|
|
3694
|
-
*
|
|
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("
|
|
4531
|
+
* createPlugin("content", { config: defaultContentConfig });
|
|
3700
4532
|
* ```
|
|
3701
4533
|
*/
|
|
3702
|
-
const
|
|
3703
|
-
outputDir: "_data",
|
|
3704
|
-
baseUrl: "/_data/"
|
|
3705
|
-
};
|
|
4534
|
+
const defaultContentConfig = { providers: [] };
|
|
3706
4535
|
//#endregion
|
|
3707
|
-
//#region src/plugins/
|
|
4536
|
+
//#region src/plugins/content/events.ts
|
|
3708
4537
|
/**
|
|
3709
|
-
*
|
|
3710
|
-
* `
|
|
3711
|
-
*
|
|
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
|
|
4560
|
+
* @param _ctx.global - Global plugin registry.
|
|
3715
4561
|
* @param _ctx.config - Resolved plugin configuration.
|
|
3716
|
-
* @returns Fresh
|
|
4562
|
+
* @returns Fresh content shell state: an empty article cache.
|
|
3717
4563
|
* @example
|
|
3718
4564
|
* ```ts
|
|
3719
|
-
* const state =
|
|
4565
|
+
* const state = createContentState({ global: {}, config: { providers: [] } });
|
|
3720
4566
|
* ```
|
|
3721
4567
|
*/
|
|
3722
|
-
function
|
|
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/
|
|
4572
|
+
//#region src/plugins/content/validate.ts
|
|
3730
4573
|
/**
|
|
3731
|
-
* Validates the resolved
|
|
3732
|
-
*
|
|
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 -
|
|
3735
|
-
* @throws {Error} If `
|
|
4578
|
+
* @param config - Resolved content plugin configuration.
|
|
4579
|
+
* @throws {Error} If `providers` is empty.
|
|
3736
4580
|
* @example
|
|
3737
4581
|
* ```ts
|
|
3738
|
-
*
|
|
4582
|
+
* validateContentConfig(config);
|
|
3739
4583
|
* ```
|
|
3740
4584
|
*/
|
|
3741
|
-
function
|
|
3742
|
-
if (
|
|
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/
|
|
4589
|
+
//#region src/plugins/content/index.ts
|
|
3746
4590
|
/**
|
|
3747
|
-
* @file
|
|
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 content — Complex Plugin skeleton (wiring-only).
|
|
3755
4592
|
*
|
|
3756
|
-
*
|
|
3757
|
-
* a
|
|
3758
|
-
*
|
|
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
|
-
*
|
|
3764
|
-
*
|
|
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
|
-
*
|
|
3769
|
-
* // router.mode !== "ssg". Just compose the plugin:
|
|
4607
|
+
* import { contentPlugin, fileSystemContent } from "@moku-labs/web";
|
|
3770
4608
|
* const app = createApp({
|
|
3771
|
-
* plugins: [
|
|
3772
|
-
* pluginConfigs: {
|
|
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
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
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/
|
|
4627
|
+
//#region src/plugins/content/types.ts
|
|
3788
4628
|
var types_exports = /* @__PURE__ */ __exportAll({});
|
|
3789
4629
|
//#endregion
|
|
3790
|
-
//#region src/plugins/
|
|
4630
|
+
//#region src/plugins/data/types.ts
|
|
3791
4631
|
var types_exports$1 = /* @__PURE__ */ __exportAll({});
|
|
3792
4632
|
//#endregion
|
|
3793
|
-
//#region src/plugins/
|
|
4633
|
+
//#region src/plugins/env/types.ts
|
|
3794
4634
|
var types_exports$2 = /* @__PURE__ */ __exportAll({});
|
|
3795
4635
|
//#endregion
|
|
3796
|
-
//#region src/plugins/
|
|
4636
|
+
//#region src/plugins/head/types.ts
|
|
3797
4637
|
var types_exports$3 = /* @__PURE__ */ __exportAll({});
|
|
3798
4638
|
//#endregion
|
|
3799
|
-
//#region src/plugins/
|
|
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: "
|
|
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
|
-
*
|
|
3853
|
-
*
|
|
3854
|
-
*
|
|
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
|
|
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 };
|