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