@moku-labs/web 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -7
- package/dist/browser.d.mts +1852 -0
- package/dist/browser.mjs +3866 -0
- package/dist/index.cjs +100 -57
- package/dist/index.d.cts +19 -1
- package/dist/index.d.mts +19 -1
- package/dist/index.mjs +100 -58
- package/package.json +8 -1
package/dist/browser.mjs
ADDED
|
@@ -0,0 +1,3866 @@
|
|
|
1
|
+
import { t as __exportAll } from "./chunk-D7D4PA-g.mjs";
|
|
2
|
+
import { t as dataSuffix } from "./convention-X3zLTlJ8.mjs";
|
|
3
|
+
import { createCoreConfig, createCorePlugin } from "@moku-labs/core";
|
|
4
|
+
//#region src/plugins/env/api.ts
|
|
5
|
+
/** Error prefix for all env API failures. */
|
|
6
|
+
const ERROR_PREFIX$10 = "[web]";
|
|
7
|
+
/**
|
|
8
|
+
* Creates the env plugin API surface mounted at `ctx.env`. Closes over
|
|
9
|
+
* `ctx.state` ({@link EnvState}) and reads the frozen `resolved` / `publicMap`
|
|
10
|
+
* maps; closures never return a raw `ctx.state` reference.
|
|
11
|
+
*
|
|
12
|
+
* @param ctx - Core plugin context carrying the frozen env state.
|
|
13
|
+
* @param ctx.state - The resolved + public {@link EnvState} maps.
|
|
14
|
+
* @returns The {@link EnvApi} accessor surface mounted at `ctx.env`.
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* const api = createEnvApi(ctx);
|
|
18
|
+
* api.get("PUBLIC_API_URL");
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
function createEnvApi(ctx) {
|
|
22
|
+
const { resolved, publicMap } = ctx.state;
|
|
23
|
+
return {
|
|
24
|
+
/**
|
|
25
|
+
* Reads a resolved variable.
|
|
26
|
+
*
|
|
27
|
+
* @param key - Variable name.
|
|
28
|
+
* @returns The value, or `undefined` if not present.
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* api.get("PUBLIC_API_URL");
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
get(key) {
|
|
35
|
+
return resolved.get(key);
|
|
36
|
+
},
|
|
37
|
+
/**
|
|
38
|
+
* Reads a variable that must exist.
|
|
39
|
+
*
|
|
40
|
+
* @param key - Variable name.
|
|
41
|
+
* @returns The value.
|
|
42
|
+
* @throws {Error} If the variable is undefined.
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* api.require("DEPLOY_TOKEN");
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
require(key) {
|
|
49
|
+
const value = resolved.get(key);
|
|
50
|
+
if (value === void 0) throw new Error(`${ERROR_PREFIX$10} env: required variable "${key}" is not defined.`);
|
|
51
|
+
return value;
|
|
52
|
+
},
|
|
53
|
+
/**
|
|
54
|
+
* Tests presence of a resolved variable.
|
|
55
|
+
*
|
|
56
|
+
* @param key - Variable name.
|
|
57
|
+
* @returns `true` if a value is present.
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* api.has("PUBLIC_API_URL");
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
has(key) {
|
|
64
|
+
return resolved.has(key);
|
|
65
|
+
},
|
|
66
|
+
/**
|
|
67
|
+
* Returns all public variables as a frozen plain object — a fresh copy,
|
|
68
|
+
* never the raw state map.
|
|
69
|
+
*
|
|
70
|
+
* @returns A frozen `Record` of public variable names to values.
|
|
71
|
+
* @example
|
|
72
|
+
* ```ts
|
|
73
|
+
* const payload = { ...api.getPublic() };
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
getPublic() {
|
|
77
|
+
return Object.freeze(Object.fromEntries(publicMap));
|
|
78
|
+
},
|
|
79
|
+
/**
|
|
80
|
+
* Returns the already-frozen map of public variables.
|
|
81
|
+
*
|
|
82
|
+
* @returns The frozen public map.
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* [...api.getPublicMap()];
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
getPublicMap() {
|
|
89
|
+
return publicMap;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/plugins/env/state.ts
|
|
95
|
+
/**
|
|
96
|
+
* Creates initial env plugin state: two empty, mutable maps that are populated
|
|
97
|
+
* and frozen by `validateSchema` (the `onInit`) at `createApp` time.
|
|
98
|
+
*
|
|
99
|
+
* @returns A fresh `EnvState` with empty `resolved` and `publicMap` maps.
|
|
100
|
+
* @example
|
|
101
|
+
* ```ts
|
|
102
|
+
* const state = createEnvState();
|
|
103
|
+
* state.resolved.size; // 0
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
function createEnvState() {
|
|
107
|
+
return {
|
|
108
|
+
resolved: /* @__PURE__ */ new Map(),
|
|
109
|
+
publicMap: /* @__PURE__ */ new Map()
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/plugins/env/validate.ts
|
|
114
|
+
/** Error message thrown by every frozen-map mutator. */
|
|
115
|
+
const FROZEN_MESSAGE = "env: map is frozen and cannot be mutated";
|
|
116
|
+
/** Error prefix for all resolution-pipeline failures. */
|
|
117
|
+
const ERROR_PREFIX$9 = "[web]";
|
|
118
|
+
/**
|
|
119
|
+
* Throws the canonical frozen-map error; installed as a map's `set`/`clear`/`delete`.
|
|
120
|
+
*
|
|
121
|
+
* @throws {TypeError} Always, signalling the map is frozen.
|
|
122
|
+
* @example
|
|
123
|
+
* ```ts
|
|
124
|
+
* frozenThrower(); // throws TypeError
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
function frozenThrower() {
|
|
128
|
+
throw new TypeError(FROZEN_MESSAGE);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Merges providers in array order, coercing empty strings to `undefined` before
|
|
132
|
+
* precedence so a `KEY=""` falls through to later providers. First non-empty
|
|
133
|
+
* value wins.
|
|
134
|
+
*
|
|
135
|
+
* @param config - The resolved env config carrying the ordered providers.
|
|
136
|
+
* @returns A flat record of the first defined value found per key.
|
|
137
|
+
* @example
|
|
138
|
+
* ```ts
|
|
139
|
+
* mergeProviders({ providers: [a, b], schema: {}, publicPrefix: "PUBLIC_" });
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
function mergeProviders(config) {
|
|
143
|
+
const merged = {};
|
|
144
|
+
for (const provider of config.providers) {
|
|
145
|
+
const values = provider.load();
|
|
146
|
+
for (const [key, raw] of Object.entries(values)) {
|
|
147
|
+
const value = raw === "" ? void 0 : raw;
|
|
148
|
+
if (value !== void 0 && merged[key] === void 0) merged[key] = value;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return merged;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Bidirectionally enforces the `PUBLIC_` naming convention against each schema
|
|
155
|
+
* entry's `public` flag. Throws on either violation direction.
|
|
156
|
+
*
|
|
157
|
+
* @param config - The resolved env config carrying `schema` + `publicPrefix`.
|
|
158
|
+
* @throws {Error} If a public var lacks the prefix, or a prefixed var is not public.
|
|
159
|
+
* @example
|
|
160
|
+
* ```ts
|
|
161
|
+
* crossCheckPublicPrefix(config); // throws if PUBLIC_X is not public:true
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
function crossCheckPublicPrefix(config) {
|
|
165
|
+
const { schema, publicPrefix } = config;
|
|
166
|
+
for (const [key, spec] of Object.entries(schema)) {
|
|
167
|
+
const hasPrefix = key.startsWith(publicPrefix);
|
|
168
|
+
if (spec.public === true && !hasPrefix) throw new Error(`${ERROR_PREFIX$9} env: "${key}" is marked public but does not start with "${publicPrefix}".`);
|
|
169
|
+
if (hasPrefix && spec.public !== true) throw new Error(`${ERROR_PREFIX$9} env: "${key}" starts with "${publicPrefix}" but is not marked public:true.`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Seals a map so `set`, `clear`, and `delete` throw, then `Object.freeze`s it
|
|
174
|
+
* for defense in depth. Closes the `Object.freeze`-on-`Map` mutability hole by
|
|
175
|
+
* redefining the mutators as non-writable, non-configurable throwers.
|
|
176
|
+
*
|
|
177
|
+
* @param map - The map to freeze in place.
|
|
178
|
+
* @example
|
|
179
|
+
* ```ts
|
|
180
|
+
* freezeMap(state.resolved); // resolved.set(...) now throws
|
|
181
|
+
* ```
|
|
182
|
+
*/
|
|
183
|
+
function freezeMap(map) {
|
|
184
|
+
for (const method of [
|
|
185
|
+
"set",
|
|
186
|
+
"clear",
|
|
187
|
+
"delete"
|
|
188
|
+
]) Object.defineProperty(map, method, {
|
|
189
|
+
value: frozenThrower,
|
|
190
|
+
writable: false,
|
|
191
|
+
configurable: false,
|
|
192
|
+
enumerable: false
|
|
193
|
+
});
|
|
194
|
+
Object.freeze(map);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Resolves, validates, and freezes the environment table at `onInit`.
|
|
198
|
+
*
|
|
199
|
+
* Pipeline order: merge providers (with empty-string → undefined coercion) →
|
|
200
|
+
* `PUBLIC_` bidirectional cross-check → apply defaults → assert required →
|
|
201
|
+
* populate `state.resolved` / `state.publicMap` → freeze both via
|
|
202
|
+
* {@link freezeMap}. Fail-fast: any violation throws at `createApp` time.
|
|
203
|
+
*
|
|
204
|
+
* @param ctx - Core plugin context (`{ config, state }`).
|
|
205
|
+
* @param ctx.config - The resolved {@link EnvConfig}.
|
|
206
|
+
* @param ctx.state - The mutable {@link EnvState} to populate and freeze.
|
|
207
|
+
* @throws {Error} On a `PUBLIC_` cross-check violation or a missing required variable.
|
|
208
|
+
* @example
|
|
209
|
+
* ```ts
|
|
210
|
+
* validateSchema(ctx); // throws on missing required / PUBLIC_ violation
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
function validateSchema(ctx) {
|
|
214
|
+
const { config, state } = ctx;
|
|
215
|
+
const { schema } = config;
|
|
216
|
+
const merged = mergeProviders(config);
|
|
217
|
+
crossCheckPublicPrefix(config);
|
|
218
|
+
for (const [key, spec] of Object.entries(schema)) {
|
|
219
|
+
if (merged[key] === void 0 && spec.default !== void 0) merged[key] = spec.default;
|
|
220
|
+
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
|
+
}
|
|
222
|
+
for (const [key, spec] of Object.entries(schema)) {
|
|
223
|
+
const value = merged[key];
|
|
224
|
+
if (spec.public === true && value !== void 0) state.publicMap.set(key, value);
|
|
225
|
+
}
|
|
226
|
+
for (const [key, value] of Object.entries(merged)) state.resolved.set(key, value);
|
|
227
|
+
freezeMap(state.resolved);
|
|
228
|
+
freezeMap(state.publicMap);
|
|
229
|
+
}
|
|
230
|
+
//#endregion
|
|
231
|
+
//#region src/plugins/env/providers.browser.ts
|
|
232
|
+
/** Default `globalThis` property holding a runtime-injected public-env snapshot. */
|
|
233
|
+
const DEFAULT_GLOBAL_KEY = "__ENV__";
|
|
234
|
+
/**
|
|
235
|
+
* A browser-safe {@link EnvProvider} that reads `import.meta.env` and an optional
|
|
236
|
+
* `globalThis[globalKey]` snapshot, merging them with the runtime global winning.
|
|
237
|
+
* Contains zero `node:*` imports, so it is safe to include in the client bundle.
|
|
238
|
+
* Never throws on missing sources — each absent source resolves to `{}`.
|
|
239
|
+
*
|
|
240
|
+
* @param options - Optional settings.
|
|
241
|
+
* @param options.globalKey - `globalThis` key to read a public-env snapshot from. Defaults to `"__ENV__"`.
|
|
242
|
+
* @returns An {@link EnvProvider} named `browser-env`.
|
|
243
|
+
* @example
|
|
244
|
+
* ```ts
|
|
245
|
+
* const provider = browserEnv();
|
|
246
|
+
* provider.load(); // { PUBLIC_API_URL: "/api", ... }
|
|
247
|
+
* ```
|
|
248
|
+
*/
|
|
249
|
+
function browserEnv(options) {
|
|
250
|
+
const globalKey = options?.globalKey ?? DEFAULT_GLOBAL_KEY;
|
|
251
|
+
return {
|
|
252
|
+
name: "browser-env",
|
|
253
|
+
/**
|
|
254
|
+
* Merges `import.meta.env` with `globalThis[globalKey]`, the runtime global
|
|
255
|
+
* winning. Each absent source resolves to `{}`; never throws.
|
|
256
|
+
*
|
|
257
|
+
* @returns The merged environment record.
|
|
258
|
+
* @example
|
|
259
|
+
* ```ts
|
|
260
|
+
* browserEnv().load();
|
|
261
|
+
* ```
|
|
262
|
+
*/
|
|
263
|
+
load() {
|
|
264
|
+
const importEnv = import.meta.env ?? {};
|
|
265
|
+
const globalObject = globalThis[globalKey] ?? {};
|
|
266
|
+
return {
|
|
267
|
+
...importEnv,
|
|
268
|
+
...globalObject
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Core plugin that resolves, validates, and freezes the environment at `onInit`,
|
|
275
|
+
* exposing a read-only accessor at `ctx.env`. No `onStart`/`onStop` — holds no resource.
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* ```ts
|
|
279
|
+
* createApp({ pluginConfigs: { env: { schema: { PUBLIC_API_URL: { public: true } } } } });
|
|
280
|
+
* ```
|
|
281
|
+
*/
|
|
282
|
+
const envPlugin = createCorePlugin("env", {
|
|
283
|
+
config: {
|
|
284
|
+
schema: {},
|
|
285
|
+
providers: [],
|
|
286
|
+
publicPrefix: "PUBLIC_"
|
|
287
|
+
},
|
|
288
|
+
createState: createEnvState,
|
|
289
|
+
api: createEnvApi,
|
|
290
|
+
onInit: validateSchema
|
|
291
|
+
});
|
|
292
|
+
//#endregion
|
|
293
|
+
//#region src/plugins/log/expect.ts
|
|
294
|
+
/**
|
|
295
|
+
* Named error thrown by `expect()` assertions when a trace condition fails.
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* ```ts
|
|
299
|
+
* throw new LogExpectAssertionError("missing event build:complete");
|
|
300
|
+
* ```
|
|
301
|
+
*/
|
|
302
|
+
var LogExpectAssertionError = class extends Error {
|
|
303
|
+
/**
|
|
304
|
+
* Construct a new assertion error with a descriptive failure message.
|
|
305
|
+
*
|
|
306
|
+
* @param message - Descriptive failure message (event name, partial, index).
|
|
307
|
+
* @example
|
|
308
|
+
* ```ts
|
|
309
|
+
* throw new LogExpectAssertionError("missing event build:complete");
|
|
310
|
+
* ```
|
|
311
|
+
*/
|
|
312
|
+
constructor(message) {
|
|
313
|
+
super(message);
|
|
314
|
+
this.name = "LogExpectAssertionError";
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
/**
|
|
318
|
+
* Tests whether a value is a non-null, non-array plain object.
|
|
319
|
+
*
|
|
320
|
+
* @param value - The value to test.
|
|
321
|
+
* @returns `true` when `value` is a non-null object that is not an array.
|
|
322
|
+
* @example
|
|
323
|
+
* ```ts
|
|
324
|
+
* isPlainObject({ a: 1 }); // true
|
|
325
|
+
* isPlainObject([1]); // false
|
|
326
|
+
* ```
|
|
327
|
+
*/
|
|
328
|
+
function isPlainObject(value) {
|
|
329
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Subset-equality matcher: is `partial` a recursive subset of `actual`?
|
|
333
|
+
*
|
|
334
|
+
* Fast path via `Object.is` (covers identical primitives/references and
|
|
335
|
+
* `null`/`NaN`); primitives compare with `Object.is`; arrays match element-wise
|
|
336
|
+
* with equal length; plain objects require every `partial` key to recursively
|
|
337
|
+
* match (extra `actual` keys ignored).
|
|
338
|
+
*
|
|
339
|
+
* @param actual - The value to test against (typically `entry.data`).
|
|
340
|
+
* @param partial - The expected partial shape.
|
|
341
|
+
* @returns `true` when `partial` is a recursive subset of `actual`.
|
|
342
|
+
* @example
|
|
343
|
+
* ```ts
|
|
344
|
+
* matchesPartial({ a: 1, b: 2 }, { a: 1 }); // true
|
|
345
|
+
* matchesPartial([1, 2], [1]); // false (length mismatch)
|
|
346
|
+
* ```
|
|
347
|
+
*/
|
|
348
|
+
function matchesPartial(actual, partial) {
|
|
349
|
+
if (Object.is(actual, partial)) return true;
|
|
350
|
+
if (Array.isArray(partial)) {
|
|
351
|
+
if (!Array.isArray(actual) || actual.length !== partial.length) return false;
|
|
352
|
+
return partial.every((value, index) => matchesPartial(actual[index], value));
|
|
353
|
+
}
|
|
354
|
+
if (isPlainObject(partial)) {
|
|
355
|
+
if (!isPlainObject(actual)) return false;
|
|
356
|
+
return Object.keys(partial).every((key) => key in actual && matchesPartial(actual[key], partial[key]));
|
|
357
|
+
}
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Tests whether an entry matches `event` and (when provided) `partial`.
|
|
362
|
+
*
|
|
363
|
+
* @param entry - The candidate trace entry.
|
|
364
|
+
* @param event - Required event name.
|
|
365
|
+
* @param partial - Optional partial data shape (subset-matched against `entry.data`).
|
|
366
|
+
* @returns `true` when the entry matches the event and optional partial.
|
|
367
|
+
* @example
|
|
368
|
+
* ```ts
|
|
369
|
+
* entryMatches({ level: "info", event: "a", data: { x: 1 }, ts: 0 }, "a", { x: 1 }); // true
|
|
370
|
+
* ```
|
|
371
|
+
*/
|
|
372
|
+
function entryMatches(entry, event, partial) {
|
|
373
|
+
if (entry.event !== event) return false;
|
|
374
|
+
return partial === void 0 ? true : matchesPartial(entry.data, partial);
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Render a `partial` for an error message, prefixed with a space when present.
|
|
378
|
+
*
|
|
379
|
+
* @param partial - Optional partial data shape.
|
|
380
|
+
* @returns A ` matching <json>` suffix, or an empty string when absent.
|
|
381
|
+
* @example
|
|
382
|
+
* ```ts
|
|
383
|
+
* describePartial({ ok: true }); // ' matching {"ok":true}'
|
|
384
|
+
* ```
|
|
385
|
+
*/
|
|
386
|
+
function describePartial(partial) {
|
|
387
|
+
return partial === void 0 ? "" : ` matching ${JSON.stringify(partial)}`;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Create a fluent assertion chain bound to the live `entries` array. Each method
|
|
391
|
+
* reads `entries` at call time, so assertions reflect later logging.
|
|
392
|
+
*
|
|
393
|
+
* @param entries - The live trace array (read on each assertion call).
|
|
394
|
+
* @returns A fresh {@link ExpectChain} backed by `entries`.
|
|
395
|
+
* @example
|
|
396
|
+
* ```ts
|
|
397
|
+
* createExpectChain(state.entries).toHaveEvent("build:complete");
|
|
398
|
+
* ```
|
|
399
|
+
*/
|
|
400
|
+
function createExpectChain(entries) {
|
|
401
|
+
const chain = {
|
|
402
|
+
/**
|
|
403
|
+
* Assert at least one entry has `event`, optionally matching `partial`.
|
|
404
|
+
*
|
|
405
|
+
* @param event - Event name to find.
|
|
406
|
+
* @param partial - Optional partial data shape (subset-matched).
|
|
407
|
+
* @returns The same chain for chaining.
|
|
408
|
+
* @throws {LogExpectAssertionError} When no matching entry exists.
|
|
409
|
+
* @example
|
|
410
|
+
* ```ts
|
|
411
|
+
* chain.toHaveEvent("build:phase", { status: "start" });
|
|
412
|
+
* ```
|
|
413
|
+
*/
|
|
414
|
+
toHaveEvent(event, partial) {
|
|
415
|
+
if (!entries.some((entry) => entryMatches(entry, event, partial))) throw new LogExpectAssertionError(`Expected trace to contain event "${event}"${describePartial(partial)}, but none was found.`);
|
|
416
|
+
return chain;
|
|
417
|
+
},
|
|
418
|
+
/**
|
|
419
|
+
* Assert all of `events` appear in the trace in the given relative order.
|
|
420
|
+
*
|
|
421
|
+
* @param events - Ordered list of event names (gaps allowed).
|
|
422
|
+
* @returns The same chain for chaining.
|
|
423
|
+
* @throws {LogExpectAssertionError} When the ordering cannot be satisfied.
|
|
424
|
+
* @example
|
|
425
|
+
* ```ts
|
|
426
|
+
* chain.toHaveEventInOrder(["build:phase", "build:complete"]);
|
|
427
|
+
* ```
|
|
428
|
+
*/
|
|
429
|
+
toHaveEventInOrder(events) {
|
|
430
|
+
let cursor = 0;
|
|
431
|
+
for (const [position, event] of events.entries()) {
|
|
432
|
+
let nextIndex = -1;
|
|
433
|
+
for (let index = cursor; index < entries.length; index++) if (entries[index]?.event === event) {
|
|
434
|
+
nextIndex = index;
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
if (nextIndex === -1) throw new LogExpectAssertionError(`Expected events in order ${JSON.stringify(events)}, but "${event}" (index ${position}) was not found at or after position ${cursor}.`);
|
|
438
|
+
cursor = nextIndex + 1;
|
|
439
|
+
}
|
|
440
|
+
return chain;
|
|
441
|
+
},
|
|
442
|
+
/**
|
|
443
|
+
* Assert NO entry has `event` (optionally narrowed by `partial`).
|
|
444
|
+
*
|
|
445
|
+
* @param event - Event name that must be absent.
|
|
446
|
+
* @param partial - Optional partial data shape; only matching entries violate.
|
|
447
|
+
* @returns The same chain for chaining.
|
|
448
|
+
* @throws {LogExpectAssertionError} When a matching entry exists.
|
|
449
|
+
* @example
|
|
450
|
+
* ```ts
|
|
451
|
+
* chain.toNotHaveEvent("deploy:failed");
|
|
452
|
+
* ```
|
|
453
|
+
*/
|
|
454
|
+
toNotHaveEvent(event, partial) {
|
|
455
|
+
const offending = entries.findIndex((entry) => entryMatches(entry, event, partial));
|
|
456
|
+
if (offending !== -1) throw new LogExpectAssertionError(`Expected trace to NOT contain event "${event}"${describePartial(partial)}, but found one at index ${offending}.`);
|
|
457
|
+
return chain;
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
return chain;
|
|
461
|
+
}
|
|
462
|
+
//#endregion
|
|
463
|
+
//#region src/plugins/log/api.ts
|
|
464
|
+
/**
|
|
465
|
+
* @file log plugin — API factory.
|
|
466
|
+
*
|
|
467
|
+
* Builds the `LogApi` over the plugin's `{ config, state }` core context:
|
|
468
|
+
* the leveled loggers (via a shared `append`), the frozen `trace()` snapshot,
|
|
469
|
+
* the live `expect()` chain, `addSink`, and `reset`.
|
|
470
|
+
*/
|
|
471
|
+
/**
|
|
472
|
+
* Append a new entry to the trace and fan it out to every sink in order.
|
|
473
|
+
*
|
|
474
|
+
* @param state - The mutable log state to append to.
|
|
475
|
+
* @param level - Severity level for the entry.
|
|
476
|
+
* @param event - Event identifier.
|
|
477
|
+
* @param data - Optional structured payload.
|
|
478
|
+
* @example
|
|
479
|
+
* ```ts
|
|
480
|
+
* append(state, "info", "content:ready", { count: 12 });
|
|
481
|
+
* ```
|
|
482
|
+
*/
|
|
483
|
+
function append(state, level, event, data) {
|
|
484
|
+
const entry = {
|
|
485
|
+
level,
|
|
486
|
+
event,
|
|
487
|
+
data,
|
|
488
|
+
ts: Date.now()
|
|
489
|
+
};
|
|
490
|
+
state.entries.push(entry);
|
|
491
|
+
for (const sink of state.sinks) sink.write(entry);
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Merge an `Error`'s `message`/`stack` into `data` under an `error` key,
|
|
495
|
+
* preserving existing keys. Non-object `data` is coerced to `{}` first so a
|
|
496
|
+
* thrown error is never silently dropped.
|
|
497
|
+
*
|
|
498
|
+
* @param data - Original payload (any shape).
|
|
499
|
+
* @param error - The originating error to merge.
|
|
500
|
+
* @returns A new object carrying the original keys plus the `error` field.
|
|
501
|
+
* @example
|
|
502
|
+
* ```ts
|
|
503
|
+
* mergeError({ target: "cf" }, new Error("boom"));
|
|
504
|
+
* // { target: "cf", error: { message: "boom", stack: "..." } }
|
|
505
|
+
* ```
|
|
506
|
+
*/
|
|
507
|
+
function mergeError(data, error) {
|
|
508
|
+
return {
|
|
509
|
+
...typeof data === "object" && data !== null && !Array.isArray(data) ? data : {},
|
|
510
|
+
error: {
|
|
511
|
+
message: error.message,
|
|
512
|
+
stack: error.stack
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Create the log plugin API surface injected as `ctx.log` / `app.log`.
|
|
518
|
+
*
|
|
519
|
+
* @param ctx - Core plugin context (`{ config, state }`).
|
|
520
|
+
* @returns The {@link LogApi} bound to `ctx.state`.
|
|
521
|
+
* @example
|
|
522
|
+
* ```ts
|
|
523
|
+
* const log = createLogApi(ctx);
|
|
524
|
+
* log.info("content:ready", { articleCount: 12 });
|
|
525
|
+
* ```
|
|
526
|
+
*/
|
|
527
|
+
function createLogApi(ctx) {
|
|
528
|
+
const { state } = ctx;
|
|
529
|
+
return {
|
|
530
|
+
/**
|
|
531
|
+
* Append an `info` entry and fan it out to every sink.
|
|
532
|
+
*
|
|
533
|
+
* @param event - Event identifier (convention: `domain:action`).
|
|
534
|
+
* @param data - Optional structured payload.
|
|
535
|
+
* @example
|
|
536
|
+
* ```ts
|
|
537
|
+
* log.info("content:ready", { count: 12 });
|
|
538
|
+
* ```
|
|
539
|
+
*/
|
|
540
|
+
info(event, data) {
|
|
541
|
+
append(state, "info", event, data);
|
|
542
|
+
},
|
|
543
|
+
/**
|
|
544
|
+
* Append a `debug` entry and fan it out to every sink.
|
|
545
|
+
*
|
|
546
|
+
* @param event - Event identifier (convention: `domain:action`).
|
|
547
|
+
* @param data - Optional structured payload.
|
|
548
|
+
* @example
|
|
549
|
+
* ```ts
|
|
550
|
+
* log.debug("router:match", { path: "/blog/" });
|
|
551
|
+
* ```
|
|
552
|
+
*/
|
|
553
|
+
debug(event, data) {
|
|
554
|
+
append(state, "debug", event, data);
|
|
555
|
+
},
|
|
556
|
+
/**
|
|
557
|
+
* Append a `warn` entry and fan it out to every sink.
|
|
558
|
+
*
|
|
559
|
+
* @param event - Event identifier (convention: `domain:action`).
|
|
560
|
+
* @param data - Optional structured payload.
|
|
561
|
+
* @example
|
|
562
|
+
* ```ts
|
|
563
|
+
* log.warn("build:skip", { reason: "no sitemap" });
|
|
564
|
+
* ```
|
|
565
|
+
*/
|
|
566
|
+
warn(event, data) {
|
|
567
|
+
append(state, "warn", event, data);
|
|
568
|
+
},
|
|
569
|
+
/**
|
|
570
|
+
* Append an `error` entry. When `error` is provided, its `message`/`stack`
|
|
571
|
+
* are merged into `data` under an `error` key (existing keys preserved);
|
|
572
|
+
* otherwise `data` is recorded as-is.
|
|
573
|
+
*
|
|
574
|
+
* @param event - Event identifier (convention: `domain:action`).
|
|
575
|
+
* @param data - Optional structured payload.
|
|
576
|
+
* @param error - Optional originating Error to merge into `data`.
|
|
577
|
+
* @example
|
|
578
|
+
* ```ts
|
|
579
|
+
* log.error("deploy:failed", { target: "cf" }, err);
|
|
580
|
+
* ```
|
|
581
|
+
*/
|
|
582
|
+
error(event, data, error) {
|
|
583
|
+
append(state, "error", event, error === void 0 ? data : mergeError(data, error));
|
|
584
|
+
},
|
|
585
|
+
/**
|
|
586
|
+
* Return a frozen snapshot (fresh copy) of the entries recorded so far.
|
|
587
|
+
*
|
|
588
|
+
* @returns A readonly, frozen copy of the recorded entries.
|
|
589
|
+
* @example
|
|
590
|
+
* ```ts
|
|
591
|
+
* const entries = log.trace();
|
|
592
|
+
* ```
|
|
593
|
+
*/
|
|
594
|
+
trace() {
|
|
595
|
+
return Object.freeze([...state.entries]);
|
|
596
|
+
},
|
|
597
|
+
/**
|
|
598
|
+
* Return a fluent assertion chain bound to the live entries array.
|
|
599
|
+
*
|
|
600
|
+
* @returns A fresh {@link ExpectChain} reading `state.entries` live.
|
|
601
|
+
* @example
|
|
602
|
+
* ```ts
|
|
603
|
+
* log.expect().toHaveEvent("build:complete");
|
|
604
|
+
* ```
|
|
605
|
+
*/
|
|
606
|
+
expect() {
|
|
607
|
+
return createExpectChain(state.entries);
|
|
608
|
+
},
|
|
609
|
+
/**
|
|
610
|
+
* Register an additional output sink at runtime.
|
|
611
|
+
*
|
|
612
|
+
* @param sink - The sink to add to the fan-out list.
|
|
613
|
+
* @example
|
|
614
|
+
* ```ts
|
|
615
|
+
* log.addSink({ write: (e) => stream.write(JSON.stringify(e)) });
|
|
616
|
+
* ```
|
|
617
|
+
*/
|
|
618
|
+
addSink(sink) {
|
|
619
|
+
state.sinks.push(sink);
|
|
620
|
+
},
|
|
621
|
+
/**
|
|
622
|
+
* Clear all recorded entries while keeping registered sinks.
|
|
623
|
+
*
|
|
624
|
+
* @example
|
|
625
|
+
* ```ts
|
|
626
|
+
* log.reset();
|
|
627
|
+
* ```
|
|
628
|
+
*/
|
|
629
|
+
reset() {
|
|
630
|
+
state.entries.length = 0;
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
//#endregion
|
|
635
|
+
//#region src/plugins/log/sinks.ts
|
|
636
|
+
/**
|
|
637
|
+
* Build the console sink: routes entries by channel — `error` → `console.error`,
|
|
638
|
+
* `warn` → `console.warn`, and `debug`/`info` → `console.log`. The full entry
|
|
639
|
+
* object is forwarded so the console serializes its `event` and `data`.
|
|
640
|
+
*
|
|
641
|
+
* @returns A {@link LogSink} that writes to the matching `console` channel.
|
|
642
|
+
* @example
|
|
643
|
+
* ```ts
|
|
644
|
+
* state.sinks.push(consoleSink());
|
|
645
|
+
* ```
|
|
646
|
+
*/
|
|
647
|
+
function consoleSink() {
|
|
648
|
+
return {
|
|
649
|
+
/**
|
|
650
|
+
* Route a single entry to the console channel matching its level.
|
|
651
|
+
*
|
|
652
|
+
* @param entry - The entry to emit.
|
|
653
|
+
* @example
|
|
654
|
+
* ```ts
|
|
655
|
+
* sink.write({ level: "warn", event: "build:skip", ts: Date.now() });
|
|
656
|
+
* ```
|
|
657
|
+
*/
|
|
658
|
+
write(entry) {
|
|
659
|
+
if (entry.level === "error") console.error(entry);
|
|
660
|
+
else if (entry.level === "warn") console.warn(entry);
|
|
661
|
+
else console.log(entry);
|
|
662
|
+
} };
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Install mode-selected default sinks at onInit. The in-memory trace is always
|
|
666
|
+
* on (`state.entries`); the console sink is added only in dev/production.
|
|
667
|
+
*
|
|
668
|
+
* @param ctx - Core plugin context (`{ config, state }`).
|
|
669
|
+
* @param ctx.config - Resolved log config (`{ mode }`).
|
|
670
|
+
* @param ctx.state - Mutable log state (`{ entries, sinks }`).
|
|
671
|
+
* @example
|
|
672
|
+
* ```ts
|
|
673
|
+
* // mode "dev" -> state.sinks === [consoleSink()]; mode "test" -> state.sinks === []
|
|
674
|
+
* ```
|
|
675
|
+
*/
|
|
676
|
+
function installDefaultSinks(ctx) {
|
|
677
|
+
if (ctx.config.mode === "dev" || ctx.config.mode === "production") ctx.state.sinks.push(consoleSink());
|
|
678
|
+
}
|
|
679
|
+
//#endregion
|
|
680
|
+
//#region src/plugins/log/state.ts
|
|
681
|
+
/**
|
|
682
|
+
* Create fresh log state: an empty append-only trace and an empty sink list.
|
|
683
|
+
* No module-level singletons — guarantees per-`createApp` isolation (two
|
|
684
|
+
* `createApp` calls never share `entries` or `sinks`).
|
|
685
|
+
*
|
|
686
|
+
* @param _ctx - Core plugin context (`{ config }`); unused at construction.
|
|
687
|
+
* @returns A fresh `LogState` with empty `entries` and `sinks` arrays.
|
|
688
|
+
* @example
|
|
689
|
+
* ```ts
|
|
690
|
+
* const state = createLogState({ config: { mode: "test" } }); // { entries: [], sinks: [] }
|
|
691
|
+
* ```
|
|
692
|
+
*/
|
|
693
|
+
function createLogState(_ctx) {
|
|
694
|
+
return {
|
|
695
|
+
entries: [],
|
|
696
|
+
sinks: []
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Core logging plugin — always-on in-memory trace + `expect()` event-trace DSL.
|
|
701
|
+
* API injected as `ctx.log` on every regular plugin and surfaced as `app.log`.
|
|
702
|
+
* No depends / events / hooks (core plugin per spec/03 §5).
|
|
703
|
+
*
|
|
704
|
+
* @see README.md
|
|
705
|
+
*/
|
|
706
|
+
const logPlugin = createCorePlugin("log", {
|
|
707
|
+
config: { mode: "production" },
|
|
708
|
+
createState: createLogState,
|
|
709
|
+
api: createLogApi,
|
|
710
|
+
onInit: installDefaultSinks
|
|
711
|
+
});
|
|
712
|
+
//#endregion
|
|
713
|
+
//#region src/config.ts
|
|
714
|
+
/**
|
|
715
|
+
* @file Framework configuration — Config + Events types, core plugin registration.
|
|
716
|
+
* @see README.md
|
|
717
|
+
*/
|
|
718
|
+
const coreConfig = createCoreConfig("web", {
|
|
719
|
+
config: { mode: "production" },
|
|
720
|
+
plugins: [logPlugin, envPlugin],
|
|
721
|
+
pluginConfigs: { log: { mode: "production" } }
|
|
722
|
+
});
|
|
723
|
+
/**
|
|
724
|
+
* Create a custom plugin bound to this framework's `Config`/`Events` and the core
|
|
725
|
+
* plugin APIs (`log`, `env`). Plugin types are fully inferred from the spec
|
|
726
|
+
* object — never write them explicitly. This is the binding every built-in
|
|
727
|
+
* plugin is wired with, and the one consumer plugins should use too.
|
|
728
|
+
*
|
|
729
|
+
* @example
|
|
730
|
+
* ```ts
|
|
731
|
+
* const analytics = createPlugin("analytics", {
|
|
732
|
+
* config: { writeKey: "" },
|
|
733
|
+
* api: (ctx) => ({ track: (event: string) => ctx.log.info("analytics:track", { event }) })
|
|
734
|
+
* });
|
|
735
|
+
* ```
|
|
736
|
+
*/
|
|
737
|
+
const createPlugin$1 = coreConfig.createPlugin;
|
|
738
|
+
/**
|
|
739
|
+
* Step 2 of the factory chain — captures the framework's default plugin set and
|
|
740
|
+
* returns the consumer entry points ({@link createApp} + a re-exported
|
|
741
|
+
* `createPlugin`). Wired once in `src/index.ts`; consumers don't call it directly.
|
|
742
|
+
*/
|
|
743
|
+
const createCore = coreConfig.createCore;
|
|
744
|
+
//#endregion
|
|
745
|
+
//#region src/plugins/i18n/api.ts
|
|
746
|
+
/** Error prefix for all i18n lifecycle failures. */
|
|
747
|
+
const ERROR_PREFIX$8 = "[web]";
|
|
748
|
+
/**
|
|
749
|
+
* Validates the resolved i18n config (fail-fast at `createApp`). Throws when
|
|
750
|
+
* `locales` is empty or when `defaultLocale` is not a member of `locales`.
|
|
751
|
+
* Errors use the `[web]` prefix with an actionable remediation line.
|
|
752
|
+
*
|
|
753
|
+
* @param ctx - Plugin context carrying the resolved {@link Config}.
|
|
754
|
+
* @param ctx.config - The resolved i18n {@link Config}.
|
|
755
|
+
* @throws {Error} If `locales` is empty or `defaultLocale` is not in `locales`.
|
|
756
|
+
* @example
|
|
757
|
+
* ```ts
|
|
758
|
+
* validateI18nConfig({ config: { locales: ["en"], defaultLocale: "en" } });
|
|
759
|
+
* ```
|
|
760
|
+
*/
|
|
761
|
+
function validateI18nConfig(ctx) {
|
|
762
|
+
const { locales, defaultLocale } = ctx.config;
|
|
763
|
+
if (locales.length === 0) throw new Error(`${ERROR_PREFIX$8} i18n.locales must contain at least one locale.\n Set pluginConfigs.i18n.locales to a non-empty array, e.g. ["en"].`);
|
|
764
|
+
if (!locales.includes(defaultLocale)) throw new Error(`${ERROR_PREFIX$8} i18n.defaultLocale "${defaultLocale}" is not in i18n.locales [${locales.join(", ")}].\n Set pluginConfigs.i18n.defaultLocale to one of the configured locales, or add "${defaultLocale}" to i18n.locales.`);
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Creates the i18n plugin API surface — locale registry accessors plus the
|
|
768
|
+
* `t()` translator with default-locale fallback. Every method is a pure read
|
|
769
|
+
* of `ctx.config`; none mutate, and `t()` always returns a string.
|
|
770
|
+
*
|
|
771
|
+
* @param ctx - Plugin context carrying the resolved {@link Config}.
|
|
772
|
+
* @param ctx.config - The resolved i18n {@link Config}.
|
|
773
|
+
* @returns The {@link Api} accessor surface mounted at `app.i18n`.
|
|
774
|
+
* @example
|
|
775
|
+
* ```ts
|
|
776
|
+
* const api = createI18nApi({ config: { locales: ["en"], defaultLocale: "en" } });
|
|
777
|
+
* api.t("en", "nav.home");
|
|
778
|
+
* ```
|
|
779
|
+
*/
|
|
780
|
+
function createI18nApi(ctx) {
|
|
781
|
+
const { config } = ctx;
|
|
782
|
+
return {
|
|
783
|
+
/**
|
|
784
|
+
* Returns the configured supported locales in declared order.
|
|
785
|
+
*
|
|
786
|
+
* @returns The configured `locales` list (priority/display order).
|
|
787
|
+
* @example
|
|
788
|
+
* ```ts
|
|
789
|
+
* api.locales(); // ["en", "uk"]
|
|
790
|
+
* ```
|
|
791
|
+
*/
|
|
792
|
+
locales() {
|
|
793
|
+
return config.locales;
|
|
794
|
+
},
|
|
795
|
+
/**
|
|
796
|
+
* Returns the fallback locale used when a requested locale is absent.
|
|
797
|
+
*
|
|
798
|
+
* @returns The configured `defaultLocale`.
|
|
799
|
+
* @example
|
|
800
|
+
* ```ts
|
|
801
|
+
* api.defaultLocale(); // "en"
|
|
802
|
+
* ```
|
|
803
|
+
*/
|
|
804
|
+
defaultLocale() {
|
|
805
|
+
return config.defaultLocale;
|
|
806
|
+
},
|
|
807
|
+
/**
|
|
808
|
+
* Membership guard: whether `x` is one of the supported locales
|
|
809
|
+
* (case-sensitive).
|
|
810
|
+
*
|
|
811
|
+
* @param x - Candidate locale code.
|
|
812
|
+
* @returns `true` if `x ∈ locales`, else `false`.
|
|
813
|
+
* @example
|
|
814
|
+
* ```ts
|
|
815
|
+
* api.isLocale("uk"); // true
|
|
816
|
+
* ```
|
|
817
|
+
*/
|
|
818
|
+
isLocale(x) {
|
|
819
|
+
return config.locales.includes(x);
|
|
820
|
+
},
|
|
821
|
+
/**
|
|
822
|
+
* Human-readable display name for a locale.
|
|
823
|
+
*
|
|
824
|
+
* @param locale - Locale code to look up.
|
|
825
|
+
* @returns The display name, or `undefined` if unmapped.
|
|
826
|
+
* @example
|
|
827
|
+
* ```ts
|
|
828
|
+
* api.localeName("uk"); // "Українська"
|
|
829
|
+
* ```
|
|
830
|
+
*/
|
|
831
|
+
localeName(locale) {
|
|
832
|
+
return config.localeNames?.[locale];
|
|
833
|
+
},
|
|
834
|
+
/**
|
|
835
|
+
* Open Graph `og:locale` value for a locale.
|
|
836
|
+
*
|
|
837
|
+
* @param locale - Locale code to look up.
|
|
838
|
+
* @returns The `og:locale` value (e.g. `"en_US"`), or `undefined` if unmapped.
|
|
839
|
+
* @example
|
|
840
|
+
* ```ts
|
|
841
|
+
* api.ogLocale("en"); // "en_US"
|
|
842
|
+
* ```
|
|
843
|
+
*/
|
|
844
|
+
ogLocale(locale) {
|
|
845
|
+
return config.ogLocaleMap?.[locale];
|
|
846
|
+
},
|
|
847
|
+
/**
|
|
848
|
+
* Translate `key` for `locale` with a deterministic fallback chain
|
|
849
|
+
* (requested locale → default locale → the key itself). The default-locale
|
|
850
|
+
* lookup is skipped when `locale === defaultLocale`.
|
|
851
|
+
*
|
|
852
|
+
* @param locale - Requested locale code.
|
|
853
|
+
* @param key - Translation key (e.g. `"nav.home"`).
|
|
854
|
+
* @returns The translated value, the default-locale value, or `key`.
|
|
855
|
+
* @example
|
|
856
|
+
* ```ts
|
|
857
|
+
* api.t("uk", "nav.home"); // "Головна"
|
|
858
|
+
* ```
|
|
859
|
+
*/
|
|
860
|
+
t(locale, key) {
|
|
861
|
+
const exact = config.translations?.[locale]?.[key];
|
|
862
|
+
if (exact !== void 0) return exact;
|
|
863
|
+
if (locale !== config.defaultLocale) {
|
|
864
|
+
const fallback = config.translations?.[config.defaultLocale]?.[key];
|
|
865
|
+
if (fallback !== void 0) return fallback;
|
|
866
|
+
}
|
|
867
|
+
return key;
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Internationalization plugin — locale registry plus a flat translation helper
|
|
873
|
+
* with default-locale fallback. Pure config-as-data (no state or events);
|
|
874
|
+
* consumed read-only by content, router, head, and build.
|
|
875
|
+
*
|
|
876
|
+
* @example Register locales and translations
|
|
877
|
+
* ```ts
|
|
878
|
+
* const app = createApp({
|
|
879
|
+
* pluginConfigs: {
|
|
880
|
+
* i18n: {
|
|
881
|
+
* locales: ["en", "uk"],
|
|
882
|
+
* defaultLocale: "en",
|
|
883
|
+
* localeNames: { en: "English", uk: "Українська" },
|
|
884
|
+
* translations: { uk: { "nav.home": "Головна" } }
|
|
885
|
+
* }
|
|
886
|
+
* }
|
|
887
|
+
* });
|
|
888
|
+
* ```
|
|
889
|
+
*/
|
|
890
|
+
const i18nPlugin = createPlugin$1("i18n", {
|
|
891
|
+
config: {
|
|
892
|
+
locales: ["en"],
|
|
893
|
+
defaultLocale: "en",
|
|
894
|
+
localeNames: {},
|
|
895
|
+
ogLocaleMap: {},
|
|
896
|
+
translations: {}
|
|
897
|
+
},
|
|
898
|
+
onInit: validateI18nConfig,
|
|
899
|
+
api: createI18nApi
|
|
900
|
+
});
|
|
901
|
+
//#endregion
|
|
902
|
+
//#region src/plugins/site/api.ts
|
|
903
|
+
/** Error prefix for all site lifecycle/validation failures. */
|
|
904
|
+
const ERROR_PREFIX$7 = "[web]";
|
|
905
|
+
/**
|
|
906
|
+
* Joins a relative path against an absolute base URL, normalizing the slash
|
|
907
|
+
* boundary to exactly one "/". Returns the base unchanged for an empty or
|
|
908
|
+
* root ("/") path; the supplied path's own trailing slash is preserved.
|
|
909
|
+
*
|
|
910
|
+
* @param base - Absolute base URL from config (may have trailing slash).
|
|
911
|
+
* @param path - Relative path to join (may have leading slash).
|
|
912
|
+
* @returns The joined absolute URL with no double slash at the boundary.
|
|
913
|
+
* @example
|
|
914
|
+
* ```ts
|
|
915
|
+
* joinCanonical("https://blog.dev/", "/about/"); // "https://blog.dev/about/"
|
|
916
|
+
* ```
|
|
917
|
+
*/
|
|
918
|
+
function joinCanonical(base, path) {
|
|
919
|
+
let trimmedBase = base;
|
|
920
|
+
while (trimmedBase.endsWith("/")) trimmedBase = trimmedBase.slice(0, -1);
|
|
921
|
+
if (path === "" || path === "/") return trimmedBase;
|
|
922
|
+
let trimmedPath = path;
|
|
923
|
+
while (trimmedPath.startsWith("/")) trimmedPath = trimmedPath.slice(1);
|
|
924
|
+
return `${trimmedBase}/${trimmedPath}`;
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Validates that a string is a non-empty trimmed value.
|
|
928
|
+
*
|
|
929
|
+
* @param value - The value to test.
|
|
930
|
+
* @returns `true` if the value is a non-empty (trimmed) string.
|
|
931
|
+
* @example
|
|
932
|
+
* ```ts
|
|
933
|
+
* isNonEmpty(" "); // false
|
|
934
|
+
* ```
|
|
935
|
+
*/
|
|
936
|
+
function isNonEmpty(value) {
|
|
937
|
+
return value.trim().length > 0;
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Validates that a string is a parseable absolute http/https URL.
|
|
941
|
+
*
|
|
942
|
+
* @param value - The candidate URL string.
|
|
943
|
+
* @returns `true` if `value` is an absolute http/https URL.
|
|
944
|
+
* @example
|
|
945
|
+
* ```ts
|
|
946
|
+
* isAbsoluteUrl("https://blog.dev"); // true
|
|
947
|
+
* ```
|
|
948
|
+
*/
|
|
949
|
+
function isAbsoluteUrl(value) {
|
|
950
|
+
try {
|
|
951
|
+
const parsed = new URL(value);
|
|
952
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
953
|
+
} catch {
|
|
954
|
+
return false;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Validates the resolved config (fail-fast at `createApp`, synchronous). Throws
|
|
959
|
+
* if `config.name` is empty/whitespace-only, or if `config.url` is not a valid
|
|
960
|
+
* absolute http/https URL. On success, returns without side effects (the plugin
|
|
961
|
+
* manages no resource). Errors use the `[web] site.<field> ...` format.
|
|
962
|
+
*
|
|
963
|
+
* @param ctx - Plugin context.
|
|
964
|
+
* @param ctx.config - The resolved {@link Config} to validate.
|
|
965
|
+
* @throws {Error} If `name` is blank or `url` is not an absolute http/https URL.
|
|
966
|
+
* @example
|
|
967
|
+
* ```ts
|
|
968
|
+
* validateSiteConfig({ config }); // throws on blank name / bad url
|
|
969
|
+
* ```
|
|
970
|
+
*/
|
|
971
|
+
function validateSiteConfig(ctx) {
|
|
972
|
+
if (!isNonEmpty(ctx.config.name)) throw new Error(`${ERROR_PREFIX$7} site.name is required.\n Provide a non-empty site name in pluginConfigs.site.name.`);
|
|
973
|
+
if (!isAbsoluteUrl(ctx.config.url)) throw new Error(`${ERROR_PREFIX$7} site.url must be a valid absolute URL (http/https), received ${JSON.stringify(ctx.config.url)}.\n Provide an absolute URL in pluginConfigs.site.url, e.g. "https://blog.dev".`);
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Creates the site plugin API surface — read-only accessors over frozen config
|
|
977
|
+
* plus the `canonical` helper. Closures read directly from `ctx.config`; none
|
|
978
|
+
* mutate or emit, and they return primitives, never internal references.
|
|
979
|
+
*
|
|
980
|
+
* @param ctx - Plugin context.
|
|
981
|
+
* @param ctx.config - The frozen {@link Config} read by every accessor.
|
|
982
|
+
* @returns The {@link Api} accessor surface mounted at `ctx.site`.
|
|
983
|
+
* @example
|
|
984
|
+
* ```ts
|
|
985
|
+
* const api = createSiteApi({ config });
|
|
986
|
+
* api.canonical("/about/"); // "https://blog.dev/about/"
|
|
987
|
+
* ```
|
|
988
|
+
*/
|
|
989
|
+
function createSiteApi(ctx) {
|
|
990
|
+
const { config } = ctx;
|
|
991
|
+
return {
|
|
992
|
+
/**
|
|
993
|
+
* Returns the configured site name.
|
|
994
|
+
*
|
|
995
|
+
* @returns The human-readable site name from `config.name`.
|
|
996
|
+
* @example
|
|
997
|
+
* ```ts
|
|
998
|
+
* api.name(); // "My Blog"
|
|
999
|
+
* ```
|
|
1000
|
+
*/
|
|
1001
|
+
name() {
|
|
1002
|
+
return config.name;
|
|
1003
|
+
},
|
|
1004
|
+
/**
|
|
1005
|
+
* Returns the configured absolute base URL of the site.
|
|
1006
|
+
*
|
|
1007
|
+
* @returns The base URL from `config.url`.
|
|
1008
|
+
* @example
|
|
1009
|
+
* ```ts
|
|
1010
|
+
* api.url(); // "https://blog.dev"
|
|
1011
|
+
* ```
|
|
1012
|
+
*/
|
|
1013
|
+
url() {
|
|
1014
|
+
return config.url;
|
|
1015
|
+
},
|
|
1016
|
+
/**
|
|
1017
|
+
* Returns the configured site author/byline.
|
|
1018
|
+
*
|
|
1019
|
+
* @returns The author from `config.author`.
|
|
1020
|
+
* @example
|
|
1021
|
+
* ```ts
|
|
1022
|
+
* api.author(); // "Alex"
|
|
1023
|
+
* ```
|
|
1024
|
+
*/
|
|
1025
|
+
author() {
|
|
1026
|
+
return config.author;
|
|
1027
|
+
},
|
|
1028
|
+
/**
|
|
1029
|
+
* Returns the configured site description.
|
|
1030
|
+
*
|
|
1031
|
+
* @returns The description from `config.description`.
|
|
1032
|
+
* @example
|
|
1033
|
+
* ```ts
|
|
1034
|
+
* api.description(); // "A personal blog about web frameworks."
|
|
1035
|
+
* ```
|
|
1036
|
+
*/
|
|
1037
|
+
description() {
|
|
1038
|
+
return config.description;
|
|
1039
|
+
},
|
|
1040
|
+
/**
|
|
1041
|
+
* Joins a path against the configured base `url` to produce an absolute
|
|
1042
|
+
* canonical URL. An empty path (or "/") returns the base URL unchanged.
|
|
1043
|
+
*
|
|
1044
|
+
* @param path - Relative path for the page, e.g. "/about/".
|
|
1045
|
+
* @returns The absolute canonical URL.
|
|
1046
|
+
* @example
|
|
1047
|
+
* ```ts
|
|
1048
|
+
* api.canonical("/about/"); // "https://blog.dev/about/"
|
|
1049
|
+
* ```
|
|
1050
|
+
*/
|
|
1051
|
+
canonical(path) {
|
|
1052
|
+
return joinCanonical(config.url, path);
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Site plugin — holds global, frozen site metadata (name, url, author,
|
|
1058
|
+
* description) and builds canonical URLs. Consumed by router, head, and build.
|
|
1059
|
+
* `name` and `url` must be non-empty (validated at `onInit`).
|
|
1060
|
+
*
|
|
1061
|
+
* @example Set your site identity
|
|
1062
|
+
* ```ts
|
|
1063
|
+
* const app = createApp({
|
|
1064
|
+
* pluginConfigs: {
|
|
1065
|
+
* site: {
|
|
1066
|
+
* name: "My Blog",
|
|
1067
|
+
* url: "https://blog.dev",
|
|
1068
|
+
* author: "Ada Lovelace",
|
|
1069
|
+
* description: "Notes on computing"
|
|
1070
|
+
* }
|
|
1071
|
+
* }
|
|
1072
|
+
* });
|
|
1073
|
+
* ```
|
|
1074
|
+
*/
|
|
1075
|
+
const sitePlugin = createPlugin$1("site", {
|
|
1076
|
+
config: {
|
|
1077
|
+
name: "",
|
|
1078
|
+
url: "",
|
|
1079
|
+
author: "",
|
|
1080
|
+
description: ""
|
|
1081
|
+
},
|
|
1082
|
+
onInit: validateSiteConfig,
|
|
1083
|
+
api: createSiteApi
|
|
1084
|
+
});
|
|
1085
|
+
//#endregion
|
|
1086
|
+
//#region src/plugins/router/builders/match.ts
|
|
1087
|
+
/**
|
|
1088
|
+
* Extract named groups from a `URLPattern` match result, stripping numeric/regex
|
|
1089
|
+
* group keys so only declared param names remain.
|
|
1090
|
+
*
|
|
1091
|
+
* @param groups - The `URLPatternResult.pathname.groups` object.
|
|
1092
|
+
* @returns A clean record of named params (numeric keys + undefined values dropped).
|
|
1093
|
+
* @example
|
|
1094
|
+
* ```ts
|
|
1095
|
+
* extractParams({ slug: "hello", "0": "x" }); // { slug: "hello" }
|
|
1096
|
+
* ```
|
|
1097
|
+
*/
|
|
1098
|
+
function extractParams(groups) {
|
|
1099
|
+
const params = {};
|
|
1100
|
+
for (const [key, value] of Object.entries(groups)) {
|
|
1101
|
+
if (/^\d+$/.test(key)) continue;
|
|
1102
|
+
if (value !== void 0) params[key] = value;
|
|
1103
|
+
}
|
|
1104
|
+
return params;
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Build a pathname matcher for a single route: tries the `withLang` URLPattern,
|
|
1108
|
+
* then the `bare` pattern injecting `defaultLocale` on miss.
|
|
1109
|
+
*
|
|
1110
|
+
* @param matchers - The pre-built `withLang` and `bare` URLPattern pair.
|
|
1111
|
+
* @param matchers.withLang - The locale-aware URLPattern variant.
|
|
1112
|
+
* @param matchers.bare - The bare URLPattern variant (no leading locale segment).
|
|
1113
|
+
* @param defaultLocale - Locale injected when the bare fallback matches.
|
|
1114
|
+
* @returns A function resolving a pathname into params, or `null` on no match.
|
|
1115
|
+
* @example
|
|
1116
|
+
* ```ts
|
|
1117
|
+
* const matchFn = createMatchFunction(matchers, "en");
|
|
1118
|
+
* ```
|
|
1119
|
+
*/
|
|
1120
|
+
function createMatchFunction(matchers, defaultLocale) {
|
|
1121
|
+
return (pathname) => {
|
|
1122
|
+
const withLang = matchers.withLang.exec({ pathname });
|
|
1123
|
+
if (withLang) return extractParams(withLang.pathname.groups);
|
|
1124
|
+
const bare = matchers.bare.exec({ pathname });
|
|
1125
|
+
if (bare) {
|
|
1126
|
+
const params = extractParams(bare.pathname.groups);
|
|
1127
|
+
params.lang = defaultLocale;
|
|
1128
|
+
return params;
|
|
1129
|
+
}
|
|
1130
|
+
return null;
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Scan the specificity-sorted compiled routes and return the first match.
|
|
1135
|
+
*
|
|
1136
|
+
* @param compiled - The compiled routes, sorted by specificity (most specific first).
|
|
1137
|
+
* @param pathname - The pathname to match.
|
|
1138
|
+
* @returns `{ params, route }` for the first matching route, or `null`.
|
|
1139
|
+
* @example
|
|
1140
|
+
* ```ts
|
|
1141
|
+
* matchRoute(compiled, "/en/hello/");
|
|
1142
|
+
* ```
|
|
1143
|
+
*/
|
|
1144
|
+
function matchRoute(compiled, pathname) {
|
|
1145
|
+
for (const entry of compiled) {
|
|
1146
|
+
const params = entry.matchFn(pathname);
|
|
1147
|
+
if (params) return {
|
|
1148
|
+
params,
|
|
1149
|
+
route: entry.definition
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
return null;
|
|
1153
|
+
}
|
|
1154
|
+
//#endregion
|
|
1155
|
+
//#region src/plugins/router/api.ts
|
|
1156
|
+
/**
|
|
1157
|
+
* @file router plugin — API factory.
|
|
1158
|
+
*
|
|
1159
|
+
* Closures over `ctx.state.table` exposing `match` / `toUrl` / `entries` /
|
|
1160
|
+
* `manifest`. Returns values/copies, never the raw `ctx.state` reference (spec/11 §2.4).
|
|
1161
|
+
*/
|
|
1162
|
+
/** Error prefix for router API failures. */
|
|
1163
|
+
const ERROR_PREFIX$6 = "[web] router";
|
|
1164
|
+
/**
|
|
1165
|
+
* Read the compiled matcher table, throwing if `onInit` has not run yet. This
|
|
1166
|
+
* `null` cannot occur in practice post-`onInit`; the guard documents the invariant.
|
|
1167
|
+
*
|
|
1168
|
+
* @param state - The router plugin state holder.
|
|
1169
|
+
* @returns The compiled, non-null matcher table.
|
|
1170
|
+
* @throws {Error} If the matcher table has not been compiled yet.
|
|
1171
|
+
* @example
|
|
1172
|
+
* ```ts
|
|
1173
|
+
* const table = readTable(ctx.state);
|
|
1174
|
+
* ```
|
|
1175
|
+
*/
|
|
1176
|
+
function readTable(state) {
|
|
1177
|
+
if (state.table === null) throw new Error(`${ERROR_PREFIX$6}: matcher table accessed before onInit compiled it.`);
|
|
1178
|
+
return state.table;
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Project a compiled route into the public `TypedRoute` URL-utility view.
|
|
1182
|
+
*
|
|
1183
|
+
* @param entry - The compiled route entry.
|
|
1184
|
+
* @returns A `TypedRoute` exposing pattern/name/meta + toUrl/toFile/match.
|
|
1185
|
+
* @example
|
|
1186
|
+
* ```ts
|
|
1187
|
+
* toTypedRoute(compiledEntry).toUrl({ slug: "x" });
|
|
1188
|
+
* ```
|
|
1189
|
+
*/
|
|
1190
|
+
function toTypedRoute(entry) {
|
|
1191
|
+
return {
|
|
1192
|
+
pattern: entry.pattern,
|
|
1193
|
+
name: entry.name,
|
|
1194
|
+
meta: { ...entry.meta },
|
|
1195
|
+
toUrl: entry.toUrl,
|
|
1196
|
+
toFile: entry.toFile,
|
|
1197
|
+
match: entry.matchFn
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Project a compiled route into the serializable {@link ClientRoute} view: only
|
|
1202
|
+
* `pattern` / `name` / `meta`, with a fresh `meta` copy and NO `_handlers` closures.
|
|
1203
|
+
*
|
|
1204
|
+
* @param entry - The compiled route entry.
|
|
1205
|
+
* @returns A `ClientRoute` carrying only JSON-serializable fields.
|
|
1206
|
+
* @example
|
|
1207
|
+
* ```ts
|
|
1208
|
+
* toClientRoute(compiledEntry); // { pattern, name, meta }
|
|
1209
|
+
* ```
|
|
1210
|
+
*/
|
|
1211
|
+
function toClientRoute(entry) {
|
|
1212
|
+
return {
|
|
1213
|
+
pattern: entry.pattern,
|
|
1214
|
+
name: entry.name,
|
|
1215
|
+
meta: { ...entry.meta }
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Creates the router plugin API surface. Every closure reads the compiled table
|
|
1220
|
+
* from `ctx.state` and returns values/fresh copies — never the raw state arrays.
|
|
1221
|
+
*
|
|
1222
|
+
* @param ctx - Plugin context.
|
|
1223
|
+
* @param ctx.state - The router state holding the compiled matcher table.
|
|
1224
|
+
* @returns The {@link RouterApi} surface mounted at `ctx.router`.
|
|
1225
|
+
* @example
|
|
1226
|
+
* ```ts
|
|
1227
|
+
* const api = createApi({ state });
|
|
1228
|
+
* api.match("/en/hello/");
|
|
1229
|
+
* ```
|
|
1230
|
+
*/
|
|
1231
|
+
function createApi$2(ctx) {
|
|
1232
|
+
const { state } = ctx;
|
|
1233
|
+
return {
|
|
1234
|
+
/**
|
|
1235
|
+
* Match a pathname against the compiled route table (specificity-sorted).
|
|
1236
|
+
*
|
|
1237
|
+
* @param pathname - URL pathname, e.g. `/en/hello/`.
|
|
1238
|
+
* @returns `{ params, route }` for the most specific match, or `null`.
|
|
1239
|
+
* @example
|
|
1240
|
+
* ```ts
|
|
1241
|
+
* api.match("/en/hello/");
|
|
1242
|
+
* ```
|
|
1243
|
+
*/
|
|
1244
|
+
match(pathname) {
|
|
1245
|
+
return matchRoute(readTable(state).compiled, pathname);
|
|
1246
|
+
},
|
|
1247
|
+
/**
|
|
1248
|
+
* Build a URL for a named route from params.
|
|
1249
|
+
*
|
|
1250
|
+
* @param routeName - Route name key from the route map.
|
|
1251
|
+
* @param params - Param values to substitute into the pattern.
|
|
1252
|
+
* @returns The resolved URL string (e.g. `/en/hello/`).
|
|
1253
|
+
* @throws {Error} If `routeName` is unknown.
|
|
1254
|
+
* @example
|
|
1255
|
+
* ```ts
|
|
1256
|
+
* api.toUrl("article", { lang: "en", slug: "hello" });
|
|
1257
|
+
* ```
|
|
1258
|
+
*/
|
|
1259
|
+
toUrl(routeName, params) {
|
|
1260
|
+
const entry = readTable(state).byName.get(routeName);
|
|
1261
|
+
if (!entry) throw new Error(`${ERROR_PREFIX$6}: unknown route name "${routeName}".`);
|
|
1262
|
+
return entry.toUrl(params);
|
|
1263
|
+
},
|
|
1264
|
+
/**
|
|
1265
|
+
* All resolved routes as typed URL utilities, in specificity order.
|
|
1266
|
+
*
|
|
1267
|
+
* @returns A fresh read-only array of resolved typed routes.
|
|
1268
|
+
* @example
|
|
1269
|
+
* ```ts
|
|
1270
|
+
* for (const r of api.entries()) r.toUrl({ slug: "x" });
|
|
1271
|
+
* ```
|
|
1272
|
+
*/
|
|
1273
|
+
entries() {
|
|
1274
|
+
return readTable(state).compiled.map((entry) => toTypedRoute(entry));
|
|
1275
|
+
},
|
|
1276
|
+
/**
|
|
1277
|
+
* The typed route set for build-time consumption (declaration order). An API
|
|
1278
|
+
* return, NOT a config readback — preserves per-route types despite erasure.
|
|
1279
|
+
*
|
|
1280
|
+
* @returns A fresh read-only array of the typed route definitions.
|
|
1281
|
+
* @example
|
|
1282
|
+
* ```ts
|
|
1283
|
+
* for (const def of api.manifest()) def._handlers.load?.({}, "en");
|
|
1284
|
+
* ```
|
|
1285
|
+
*/
|
|
1286
|
+
manifest() {
|
|
1287
|
+
return [...readTable(state).byName.values()].map((entry) => entry.definition);
|
|
1288
|
+
},
|
|
1289
|
+
/**
|
|
1290
|
+
* Serializable, specificity-sorted projection of the route table for client
|
|
1291
|
+
* shipping — `{ pattern, name, meta }` entries with NO `_handlers` closures.
|
|
1292
|
+
*
|
|
1293
|
+
* @returns A fresh, frozen, specificity-sorted read-only array of client routes.
|
|
1294
|
+
* @example
|
|
1295
|
+
* ```ts
|
|
1296
|
+
* const json = JSON.stringify(api.clientManifest());
|
|
1297
|
+
* ```
|
|
1298
|
+
*/
|
|
1299
|
+
clientManifest() {
|
|
1300
|
+
return Object.freeze(readTable(state).compiled.map((entry) => toClientRoute(entry)));
|
|
1301
|
+
},
|
|
1302
|
+
/**
|
|
1303
|
+
* The resolved render mode (single source of truth for static/hybrid/spa).
|
|
1304
|
+
*
|
|
1305
|
+
* @returns `"ssg" | "spa" | "hybrid"`.
|
|
1306
|
+
* @example
|
|
1307
|
+
* ```ts
|
|
1308
|
+
* if (api.mode() !== "ssg") emitClientData();
|
|
1309
|
+
* ```
|
|
1310
|
+
*/
|
|
1311
|
+
mode() {
|
|
1312
|
+
return state.mode;
|
|
1313
|
+
}
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
//#endregion
|
|
1317
|
+
//#region src/plugins/router/iso-match.ts
|
|
1318
|
+
/**
|
|
1319
|
+
* Parse a single path segment into its `{…}` placeholder, or `false` for a static
|
|
1320
|
+
* segment. Plain loop over the brace delimiters (no backtracking regex). Shared by
|
|
1321
|
+
* the build-time compiler and this isomorphic matcher so the two never diverge.
|
|
1322
|
+
*
|
|
1323
|
+
* @param segment - One `/`-delimited segment, e.g. `{slug}` or `about`.
|
|
1324
|
+
* @returns The parsed placeholder, or `false` when the segment is static.
|
|
1325
|
+
* @example
|
|
1326
|
+
* ```ts
|
|
1327
|
+
* parsePlaceholder("{slug:?}"); // { name: "slug", optional: true }
|
|
1328
|
+
* ```
|
|
1329
|
+
*/
|
|
1330
|
+
function parsePlaceholder$1(segment) {
|
|
1331
|
+
if (!segment.startsWith("{") || !segment.endsWith("}")) return false;
|
|
1332
|
+
const inner = segment.slice(1, -1);
|
|
1333
|
+
if (inner.endsWith(":?")) return {
|
|
1334
|
+
name: inner.slice(0, -2),
|
|
1335
|
+
optional: true
|
|
1336
|
+
};
|
|
1337
|
+
return {
|
|
1338
|
+
name: inner,
|
|
1339
|
+
optional: false
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* Counts the dynamic (`{param}` / `{param:?}` / `:param`) segments in a route
|
|
1344
|
+
* pattern — fewer dynamic segments rank as more specific. The optional `{lang:?}`
|
|
1345
|
+
* segment is excluded so locale-prefixing does not affect priority (identical to
|
|
1346
|
+
* the build-time compiler's count, which sourced this logic).
|
|
1347
|
+
*
|
|
1348
|
+
* @param pattern - The route pattern string.
|
|
1349
|
+
* @returns The number of dynamic (non-lang) segments.
|
|
1350
|
+
* @example
|
|
1351
|
+
* ```ts
|
|
1352
|
+
* dynamicSegmentCount("/blog/{slug}/"); // 1
|
|
1353
|
+
* dynamicSegmentCount("/{lang:?}/{slug}/"); // 1
|
|
1354
|
+
* ```
|
|
1355
|
+
*/
|
|
1356
|
+
function dynamicSegmentCount(pattern) {
|
|
1357
|
+
let count = 0;
|
|
1358
|
+
for (const segment of pattern.split("/")) {
|
|
1359
|
+
const placeholder = parsePlaceholder$1(segment);
|
|
1360
|
+
const isBraceDynamic = placeholder && !(placeholder.name === "lang" && placeholder.optional);
|
|
1361
|
+
const isColonDynamic = !placeholder && segment.startsWith(":");
|
|
1362
|
+
if (isBraceDynamic || isColonDynamic) count += 1;
|
|
1363
|
+
}
|
|
1364
|
+
return count;
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* Comparator that orders two routes most-specific-first (fewest dynamic segments
|
|
1368
|
+
* first). Equal specificity yields `0` so a stable sort preserves declaration
|
|
1369
|
+
* order — the exact ordering the compiled matcher table uses, guaranteeing
|
|
1370
|
+
* build-time and client-time route resolution can never diverge.
|
|
1371
|
+
*
|
|
1372
|
+
* @param a - First route (carries its `pattern` string).
|
|
1373
|
+
* @param a.pattern - First route's pattern string.
|
|
1374
|
+
* @param b - Second route (carries its `pattern` string).
|
|
1375
|
+
* @param b.pattern - Second route's pattern string.
|
|
1376
|
+
* @returns Negative if `a` is more specific, positive if `b` is, `0` on a tie.
|
|
1377
|
+
* @example
|
|
1378
|
+
* ```ts
|
|
1379
|
+
* routes.toSorted(bySpecificity);
|
|
1380
|
+
* ```
|
|
1381
|
+
*/
|
|
1382
|
+
function bySpecificity(a, b) {
|
|
1383
|
+
return dynamicSegmentCount(a.pattern) - dynamicSegmentCount(b.pattern);
|
|
1384
|
+
}
|
|
1385
|
+
//#endregion
|
|
1386
|
+
//#region src/plugins/router/builders/compile.ts
|
|
1387
|
+
/**
|
|
1388
|
+
* @file router plugin — compilation + validation domain.
|
|
1389
|
+
*
|
|
1390
|
+
* Pure functions invoked from `onInit`: validate the route map, then compile each
|
|
1391
|
+
* route into URLPattern matchers + URL/file builders, count dynamic segments,
|
|
1392
|
+
* sort by specificity, and assemble the immutable `MatcherTable`. Receives DATA
|
|
1393
|
+
* only (`CompileInput`) — never the plugin ctx.
|
|
1394
|
+
*/
|
|
1395
|
+
/** Shared `[web]` error prefix for router validation failures. */
|
|
1396
|
+
const ERROR_PREFIX$5 = "[web] router";
|
|
1397
|
+
/**
|
|
1398
|
+
* Validate the route map (fail-fast in `onInit`). Throws with the `[web]` prefix
|
|
1399
|
+
* naming the offending route/pattern on any failure: empty map, a pattern not
|
|
1400
|
+
* starting with `/`, unbalanced `{…}` braces, or more than one `{lang:?}` segment.
|
|
1401
|
+
*
|
|
1402
|
+
* @param routes - The route map from config.
|
|
1403
|
+
* @throws {Error} If routes are empty, a pattern is malformed, or names collide.
|
|
1404
|
+
* @example
|
|
1405
|
+
* ```ts
|
|
1406
|
+
* validateRoutes({ home: route("/") });
|
|
1407
|
+
* ```
|
|
1408
|
+
*/
|
|
1409
|
+
function validateRoutes(routes) {
|
|
1410
|
+
const names = Object.keys(routes);
|
|
1411
|
+
if (names.length === 0) throw new Error(`${ERROR_PREFIX$5}: route map is empty — provide at least one route via pluginConfigs.router.routes.`);
|
|
1412
|
+
for (const name of names) {
|
|
1413
|
+
const pattern = routes[name]?.pattern ?? "";
|
|
1414
|
+
if (!pattern.startsWith("/")) throw new Error(`${ERROR_PREFIX$5}: route "${name}" pattern must start with "/" (got "${pattern}").`);
|
|
1415
|
+
if ((pattern.match(/\{/g) ?? []).length !== (pattern.match(/\}/g) ?? []).length) throw new Error(`${ERROR_PREFIX$5}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
|
|
1416
|
+
if ((pattern.match(/\{lang:\?\}/g) ?? []).length > 1) throw new Error(`${ERROR_PREFIX$5}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Parse a single path segment into its placeholder, or `false` for a static
|
|
1421
|
+
* segment. Uses a plain loop over the brace delimiters (no backtracking regex).
|
|
1422
|
+
*
|
|
1423
|
+
* @param segment - One `/`-delimited segment, e.g. `{slug}` or `about`.
|
|
1424
|
+
* @returns The parsed placeholder, or `false` when the segment is static.
|
|
1425
|
+
* @example
|
|
1426
|
+
* ```ts
|
|
1427
|
+
* parsePlaceholder("{slug:?}"); // { name: "slug", optional: true }
|
|
1428
|
+
* ```
|
|
1429
|
+
*/
|
|
1430
|
+
function parsePlaceholder(segment) {
|
|
1431
|
+
if (!segment.startsWith("{") || !segment.endsWith("}")) return false;
|
|
1432
|
+
const inner = segment.slice(1, -1);
|
|
1433
|
+
if (inner.endsWith(":?")) return {
|
|
1434
|
+
name: inner.slice(0, -2),
|
|
1435
|
+
optional: true
|
|
1436
|
+
};
|
|
1437
|
+
return {
|
|
1438
|
+
name: inner,
|
|
1439
|
+
optional: false
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* Convert a user pattern to a `URLPattern` source string, in a `withLang` or
|
|
1444
|
+
* `bare` variant (the latter strips the optional `{lang:?}` segment). Walks the
|
|
1445
|
+
* pattern one `/`-segment at a time (no backtracking regex).
|
|
1446
|
+
*
|
|
1447
|
+
* @param pattern - The user pattern, e.g. `/{lang:?}/{slug}/`.
|
|
1448
|
+
* @param variant - `"withLang"` (locale regex injected) or `"bare"`.
|
|
1449
|
+
* @param langRegex - Locale alternation regex, e.g. `(en|uk)`.
|
|
1450
|
+
* @returns A URLPattern-compatible pathname string.
|
|
1451
|
+
* @example
|
|
1452
|
+
* ```ts
|
|
1453
|
+
* patternToUrlPattern("/{slug}/", "bare", "(en|uk)");
|
|
1454
|
+
* ```
|
|
1455
|
+
*/
|
|
1456
|
+
function patternToUrlPattern(pattern, variant, langRegex) {
|
|
1457
|
+
const out = [];
|
|
1458
|
+
for (const segment of pattern.split("/")) {
|
|
1459
|
+
const placeholder = parsePlaceholder(segment);
|
|
1460
|
+
if (!placeholder) {
|
|
1461
|
+
out.push(segment);
|
|
1462
|
+
continue;
|
|
1463
|
+
}
|
|
1464
|
+
if (placeholder.name === "lang" && placeholder.optional) {
|
|
1465
|
+
if (variant === "withLang") out.push(`:lang${langRegex}`);
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1468
|
+
out.push(`:${placeholder.name}`);
|
|
1469
|
+
}
|
|
1470
|
+
return out.join("/");
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Build a URL from a pattern and params (substitutes `{param}` / `{param:?}`).
|
|
1474
|
+
* Walks segment-by-segment (no backtracking regex).
|
|
1475
|
+
*
|
|
1476
|
+
* @param pattern - The route pattern.
|
|
1477
|
+
* @param params - Param values to substitute.
|
|
1478
|
+
* @param _baseUrl - Site base URL (reserved for absolute-link construction).
|
|
1479
|
+
* @returns The resolved relative URL string.
|
|
1480
|
+
* @example
|
|
1481
|
+
* ```ts
|
|
1482
|
+
* buildUrl("/{slug}/", { slug: "hello" }, "https://blog.dev");
|
|
1483
|
+
* ```
|
|
1484
|
+
*/
|
|
1485
|
+
function buildUrl(pattern, params, _baseUrl) {
|
|
1486
|
+
const out = [];
|
|
1487
|
+
for (const segment of pattern.split("/")) {
|
|
1488
|
+
const placeholder = parsePlaceholder(segment);
|
|
1489
|
+
out.push(placeholder ? params[placeholder.name] ?? "" : segment);
|
|
1490
|
+
}
|
|
1491
|
+
return out.join("/");
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Build an output file path from a pattern and params (always `…/index.html`).
|
|
1495
|
+
*
|
|
1496
|
+
* @param pattern - The route pattern.
|
|
1497
|
+
* @param params - Param values to substitute.
|
|
1498
|
+
* @returns The output file path, e.g. `hello/index.html`.
|
|
1499
|
+
* @example
|
|
1500
|
+
* ```ts
|
|
1501
|
+
* buildFilePath("/{slug}/", { slug: "hello" });
|
|
1502
|
+
* ```
|
|
1503
|
+
*/
|
|
1504
|
+
function buildFilePath(pattern, params) {
|
|
1505
|
+
const cleanPath = buildUrl(pattern, params, "").replace(/^\//, "").replace(/\/$/, "");
|
|
1506
|
+
return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Compile a single route definition into its `CompiledRoute` entry.
|
|
1510
|
+
*
|
|
1511
|
+
* @param name - The route name key.
|
|
1512
|
+
* @param definition - The (opaque) route definition carrier.
|
|
1513
|
+
* @param input - Resolved compile data (locales, defaultLocale, baseUrl, …).
|
|
1514
|
+
* @returns The compiled route entry with matchers + URL utilities.
|
|
1515
|
+
* @example
|
|
1516
|
+
* ```ts
|
|
1517
|
+
* compileRoute("home", routeDef, input);
|
|
1518
|
+
* ```
|
|
1519
|
+
*/
|
|
1520
|
+
function compileRoute(name, definition, input) {
|
|
1521
|
+
const { pattern } = definition;
|
|
1522
|
+
const langRegex = `(${input.locales.join("|")})`;
|
|
1523
|
+
const matchers = {
|
|
1524
|
+
withLang: new URLPattern({ pathname: patternToUrlPattern(pattern, "withLang", langRegex) }),
|
|
1525
|
+
bare: new URLPattern({ pathname: patternToUrlPattern(pattern, "bare", langRegex) })
|
|
1526
|
+
};
|
|
1527
|
+
return {
|
|
1528
|
+
name,
|
|
1529
|
+
pattern,
|
|
1530
|
+
dynamicSegmentCount: dynamicSegmentCount(pattern),
|
|
1531
|
+
matchers,
|
|
1532
|
+
matchFn: createMatchFunction(matchers, input.defaultLocale),
|
|
1533
|
+
/**
|
|
1534
|
+
* Build a URL for this route from params.
|
|
1535
|
+
*
|
|
1536
|
+
* @param params - Param values to substitute.
|
|
1537
|
+
* @returns The resolved relative URL.
|
|
1538
|
+
* @example
|
|
1539
|
+
* ```ts
|
|
1540
|
+
* entry.toUrl({ slug: "x" });
|
|
1541
|
+
* ```
|
|
1542
|
+
*/
|
|
1543
|
+
toUrl(params) {
|
|
1544
|
+
return buildUrl(pattern, params, input.baseUrl);
|
|
1545
|
+
},
|
|
1546
|
+
/**
|
|
1547
|
+
* Build the output file path for this route from params. Honors a custom
|
|
1548
|
+
* `.toFile()` override (captured in `_handlers.toFile`) when present, falling
|
|
1549
|
+
* back to the pattern-derived `…/index.html` path otherwise.
|
|
1550
|
+
*
|
|
1551
|
+
* @param params - Param values to substitute.
|
|
1552
|
+
* @returns The output file path.
|
|
1553
|
+
* @example
|
|
1554
|
+
* ```ts
|
|
1555
|
+
* entry.toFile({ slug: "x" });
|
|
1556
|
+
* ```
|
|
1557
|
+
*/
|
|
1558
|
+
toFile(params) {
|
|
1559
|
+
return definition._handlers.toFile?.(params) ?? buildFilePath(pattern, params);
|
|
1560
|
+
},
|
|
1561
|
+
definition,
|
|
1562
|
+
meta: { ...definition._meta }
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Compile the route map into a specificity-sorted, immutable `MatcherTable`.
|
|
1567
|
+
* Builds both URLPattern variants per route, the `matchFn`, the `toUrl`/`toFile`
|
|
1568
|
+
* closures, and the `byName` index, then sorts ascending by dynamic-segment count
|
|
1569
|
+
* (stable, preserving declaration order among equal-specificity routes).
|
|
1570
|
+
*
|
|
1571
|
+
* @param input - Resolved DATA (routes, mode, baseUrl, locales, defaultLocale).
|
|
1572
|
+
* @returns The compiled, immutable matcher table.
|
|
1573
|
+
* @example
|
|
1574
|
+
* ```ts
|
|
1575
|
+
* compileRoutes({ routes: { home: route("/") }, mode: "hybrid", baseUrl: "https://blog.dev", locales: ["en"], defaultLocale: "en" });
|
|
1576
|
+
* ```
|
|
1577
|
+
*/
|
|
1578
|
+
function compileRoutes(input) {
|
|
1579
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1580
|
+
const declarationOrder = [];
|
|
1581
|
+
for (const [name, definition] of Object.entries(input.routes)) {
|
|
1582
|
+
const entry = compileRoute(name, definition, input);
|
|
1583
|
+
declarationOrder.push(entry);
|
|
1584
|
+
byName.set(name, entry);
|
|
1585
|
+
}
|
|
1586
|
+
return {
|
|
1587
|
+
compiled: declarationOrder.toSorted(bySpecificity),
|
|
1588
|
+
byName
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* onInit orchestrator (data-only seam, keeps `index.ts` wiring-only). Validates
|
|
1593
|
+
* the route map then compiles the matcher table from resolved dependency data.
|
|
1594
|
+
*
|
|
1595
|
+
* @param config - Resolved router config (`routes` + `mode`).
|
|
1596
|
+
* @param baseUrl - Site base URL from `ctx.require(sitePlugin).url()`.
|
|
1597
|
+
* @param locales - Available locales from `ctx.require(i18nPlugin).locales()`.
|
|
1598
|
+
* @param defaultLocale - Default locale from `ctx.require(i18nPlugin).defaultLocale()`.
|
|
1599
|
+
* @returns The compiled, immutable matcher table for `ctx.state.table`.
|
|
1600
|
+
* @example
|
|
1601
|
+
* ```ts
|
|
1602
|
+
* ctx.state.table = buildRouterTable(ctx.config, site.url(), i18n.locales(), i18n.defaultLocale());
|
|
1603
|
+
* ```
|
|
1604
|
+
*/
|
|
1605
|
+
function buildRouterTable(config, baseUrl, locales, defaultLocale) {
|
|
1606
|
+
validateRoutes(config.routes);
|
|
1607
|
+
return compileRoutes({
|
|
1608
|
+
routes: config.routes,
|
|
1609
|
+
mode: config.mode ?? "hybrid",
|
|
1610
|
+
baseUrl,
|
|
1611
|
+
locales,
|
|
1612
|
+
defaultLocale
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
//#endregion
|
|
1616
|
+
//#region src/plugins/router/builders/route-builder.ts
|
|
1617
|
+
/**
|
|
1618
|
+
* Create a fluent route builder from a URL pattern string. Captures the pattern
|
|
1619
|
+
* as a literal type for compile-time param inference; `.load()` is the only method
|
|
1620
|
+
* that widens the data generic, so `ctx.data` in `.render()`/`.head()` is typed by
|
|
1621
|
+
* `.load()`'s return at the CALL SITE. The returned object is itself the route
|
|
1622
|
+
* definition (`pattern` / `_meta` / `_handlers`), so it slots straight into a route map.
|
|
1623
|
+
*
|
|
1624
|
+
* @param pattern - URL pattern with `{param}` / `{param:?}` placeholders.
|
|
1625
|
+
* @returns A `RouteBuilder<RouteState<P>>` carrying the typed fluent chain.
|
|
1626
|
+
* @example
|
|
1627
|
+
* ```ts
|
|
1628
|
+
* route("/{lang:?}/{slug}/")
|
|
1629
|
+
* .load(({ slug }) => loadArticle(slug))
|
|
1630
|
+
* .render((ctx) => <Article a={ctx.data} />)
|
|
1631
|
+
* .head((ctx) => ({ title: ctx.data.title }));
|
|
1632
|
+
* ```
|
|
1633
|
+
*/
|
|
1634
|
+
function route(pattern) {
|
|
1635
|
+
const carrier = {
|
|
1636
|
+
pattern,
|
|
1637
|
+
_meta: {},
|
|
1638
|
+
_handlers: {}
|
|
1639
|
+
};
|
|
1640
|
+
const handlers = carrier._handlers;
|
|
1641
|
+
/**
|
|
1642
|
+
* Record a handler under `key` and return the same builder for chaining.
|
|
1643
|
+
*
|
|
1644
|
+
* @param key - The handler slot name.
|
|
1645
|
+
* @param fn - The handler function to store.
|
|
1646
|
+
* @returns The same builder instance, for fluent chaining.
|
|
1647
|
+
* @example
|
|
1648
|
+
* ```ts
|
|
1649
|
+
* set("render", handler);
|
|
1650
|
+
* ```
|
|
1651
|
+
*/
|
|
1652
|
+
function set(key, fn) {
|
|
1653
|
+
handlers[key] = fn;
|
|
1654
|
+
return builder;
|
|
1655
|
+
}
|
|
1656
|
+
const builder = {
|
|
1657
|
+
pattern: carrier.pattern,
|
|
1658
|
+
_meta: carrier._meta,
|
|
1659
|
+
_handlers: carrier._handlers,
|
|
1660
|
+
/**
|
|
1661
|
+
* Attach a data loader; widens the data generic for downstream handlers.
|
|
1662
|
+
*
|
|
1663
|
+
* @param loader - The loader producing this route's data.
|
|
1664
|
+
* @returns The same builder, with the data generic widened.
|
|
1665
|
+
* @example
|
|
1666
|
+
* ```ts
|
|
1667
|
+
* route("/{slug}/").load(({ slug }) => ({ slug }));
|
|
1668
|
+
* ```
|
|
1669
|
+
*/
|
|
1670
|
+
load(loader) {
|
|
1671
|
+
return set("load", loader);
|
|
1672
|
+
},
|
|
1673
|
+
/**
|
|
1674
|
+
* Attach a ctx-aware layout wrapper that frames the page in persistent chrome.
|
|
1675
|
+
* The wrapper receives the route's `LayoutContext` (render context + `.meta()`
|
|
1676
|
+
* bag) and the page children. Applied in the SSG render path only — client
|
|
1677
|
+
* navigation keeps the chrome and swaps just the inner region.
|
|
1678
|
+
*
|
|
1679
|
+
* @param component - The layout component `(ctx, children) => VNode`.
|
|
1680
|
+
* @returns The same builder for chaining.
|
|
1681
|
+
* @example
|
|
1682
|
+
* ```ts
|
|
1683
|
+
* route("/")
|
|
1684
|
+
* .meta({ activeTab: "home" })
|
|
1685
|
+
* .layout((ctx, children) => (
|
|
1686
|
+
* <Shell locale={ctx.locale} active={ctx.meta.activeTab}>{children}</Shell>
|
|
1687
|
+
* ));
|
|
1688
|
+
* ```
|
|
1689
|
+
*/
|
|
1690
|
+
layout(component) {
|
|
1691
|
+
return set("layout", component);
|
|
1692
|
+
},
|
|
1693
|
+
/**
|
|
1694
|
+
* Attach the page render handler.
|
|
1695
|
+
*
|
|
1696
|
+
* @param handler - The render handler.
|
|
1697
|
+
* @returns The same builder for chaining.
|
|
1698
|
+
* @example
|
|
1699
|
+
* ```ts
|
|
1700
|
+
* route("/").render(() => null);
|
|
1701
|
+
* ```
|
|
1702
|
+
*/
|
|
1703
|
+
render(handler) {
|
|
1704
|
+
return set("render", handler);
|
|
1705
|
+
},
|
|
1706
|
+
/**
|
|
1707
|
+
* Attach the client-side validation gate (raw `unknown` → this route's data
|
|
1708
|
+
* type). Runs at the trust boundary before `render` on the client; throw to
|
|
1709
|
+
* reject malformed data (spa falls back to HTML-over-fetch).
|
|
1710
|
+
*
|
|
1711
|
+
* @param handler - The validator/parser.
|
|
1712
|
+
* @returns The same builder for chaining.
|
|
1713
|
+
* @example
|
|
1714
|
+
* ```ts
|
|
1715
|
+
* route("/shop/{id}/").parse(raw => ProductSchema.parse(raw));
|
|
1716
|
+
* ```
|
|
1717
|
+
*/
|
|
1718
|
+
parse(handler) {
|
|
1719
|
+
return set("parse", handler);
|
|
1720
|
+
},
|
|
1721
|
+
/**
|
|
1722
|
+
* Attach the head/SEO handler.
|
|
1723
|
+
*
|
|
1724
|
+
* @param handler - The head handler.
|
|
1725
|
+
* @returns The same builder for chaining.
|
|
1726
|
+
* @example
|
|
1727
|
+
* ```ts
|
|
1728
|
+
* route("/").head(() => ({ title: "Home" }));
|
|
1729
|
+
* ```
|
|
1730
|
+
*/
|
|
1731
|
+
head(handler) {
|
|
1732
|
+
return set("head", handler);
|
|
1733
|
+
},
|
|
1734
|
+
/**
|
|
1735
|
+
* Attach a static-generation param producer.
|
|
1736
|
+
*
|
|
1737
|
+
* @param handler - The param producer.
|
|
1738
|
+
* @returns The same builder for chaining.
|
|
1739
|
+
* @example
|
|
1740
|
+
* ```ts
|
|
1741
|
+
* route("/{slug}/").generate(() => [{ slug: "x" }]);
|
|
1742
|
+
* ```
|
|
1743
|
+
*/
|
|
1744
|
+
generate(handler) {
|
|
1745
|
+
return set("generate", handler);
|
|
1746
|
+
},
|
|
1747
|
+
/**
|
|
1748
|
+
* Merge an arbitrary metadata bag into the route's `_meta`. The bag MUST be
|
|
1749
|
+
* JSON-serializable — it is projected verbatim into `clientManifest()` and
|
|
1750
|
+
* shipped to the browser, so functions/symbols/class instances are unsupported.
|
|
1751
|
+
*
|
|
1752
|
+
* @param meta - JSON-serializable metadata to merge.
|
|
1753
|
+
* @returns The same builder for chaining.
|
|
1754
|
+
* @example
|
|
1755
|
+
* ```ts
|
|
1756
|
+
* route("/").meta({ activeTab: "home" });
|
|
1757
|
+
* ```
|
|
1758
|
+
*/
|
|
1759
|
+
meta(meta) {
|
|
1760
|
+
Object.assign(carrier._meta, meta);
|
|
1761
|
+
return builder;
|
|
1762
|
+
},
|
|
1763
|
+
/**
|
|
1764
|
+
* Attach a JSON serializer for the route's data.
|
|
1765
|
+
*
|
|
1766
|
+
* @param handler - The JSON serializer.
|
|
1767
|
+
* @returns The same builder for chaining.
|
|
1768
|
+
* @example
|
|
1769
|
+
* ```ts
|
|
1770
|
+
* route("/api/").toJson(() => ({ ok: true }));
|
|
1771
|
+
* ```
|
|
1772
|
+
*/
|
|
1773
|
+
toJson(handler) {
|
|
1774
|
+
return set("toJson", handler);
|
|
1775
|
+
},
|
|
1776
|
+
/**
|
|
1777
|
+
* Override the output file-path producer.
|
|
1778
|
+
*
|
|
1779
|
+
* @param handler - The file-path producer.
|
|
1780
|
+
* @returns The same builder for chaining.
|
|
1781
|
+
* @example
|
|
1782
|
+
* ```ts
|
|
1783
|
+
* route("/feed/").toFile(() => "feed.xml");
|
|
1784
|
+
* ```
|
|
1785
|
+
*/
|
|
1786
|
+
toFile(handler) {
|
|
1787
|
+
return set("toFile", handler);
|
|
1788
|
+
}
|
|
1789
|
+
};
|
|
1790
|
+
return builder;
|
|
1791
|
+
}
|
|
1792
|
+
/**
|
|
1793
|
+
* Typed identity helper for route maps. Preserves the precise literal type of the
|
|
1794
|
+
* route object for IntelliSense at the consumer call site (before config erasure).
|
|
1795
|
+
*
|
|
1796
|
+
* @param routes - The route map object.
|
|
1797
|
+
* @returns The same object, with its precise type preserved.
|
|
1798
|
+
* @example
|
|
1799
|
+
* ```ts
|
|
1800
|
+
* const routes = defineRoutes({ home: route("/"), article: route("/{slug}/") });
|
|
1801
|
+
* ```
|
|
1802
|
+
*/
|
|
1803
|
+
function defineRoutes(routes) {
|
|
1804
|
+
return routes;
|
|
1805
|
+
}
|
|
1806
|
+
//#endregion
|
|
1807
|
+
//#region src/plugins/router/state.ts
|
|
1808
|
+
/**
|
|
1809
|
+
* Creates initial router plugin state — a holder whose `table` is `null` until
|
|
1810
|
+
* `onInit` compiles and assigns the matcher table.
|
|
1811
|
+
*
|
|
1812
|
+
* @param _ctx - Minimal context with global and config.
|
|
1813
|
+
* @param _ctx.global - Global plugin registry.
|
|
1814
|
+
* @param _ctx.config - Resolved router configuration.
|
|
1815
|
+
* @returns The initial router state holder.
|
|
1816
|
+
* @example
|
|
1817
|
+
* ```ts
|
|
1818
|
+
* const state = createState({ global: {}, config: { routes: {} } });
|
|
1819
|
+
* ```
|
|
1820
|
+
*/
|
|
1821
|
+
function createState$2(_ctx) {
|
|
1822
|
+
return {
|
|
1823
|
+
table: null,
|
|
1824
|
+
mode: _ctx.config.mode ?? "hybrid"
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Router plugin — typed, named route definitions with locale-aware URL generation
|
|
1829
|
+
* and matching. Author routes with {@link route} + {@link defineRoutes}. Depends
|
|
1830
|
+
* on site (base URL) and i18n (locales).
|
|
1831
|
+
*
|
|
1832
|
+
* @example Define routes and choose a render mode
|
|
1833
|
+
* ```ts
|
|
1834
|
+
* const app = createApp({
|
|
1835
|
+
* pluginConfigs: {
|
|
1836
|
+
* router: {
|
|
1837
|
+
* routes: defineRoutes({
|
|
1838
|
+
* home: route("/"),
|
|
1839
|
+
* article: route("/blog/{slug}/")
|
|
1840
|
+
* }),
|
|
1841
|
+
* mode: "hybrid" // "ssg" | "spa" | "hybrid" (default)
|
|
1842
|
+
* }
|
|
1843
|
+
* }
|
|
1844
|
+
* });
|
|
1845
|
+
* ```
|
|
1846
|
+
*/
|
|
1847
|
+
const routerPlugin = createPlugin$1("router", {
|
|
1848
|
+
depends: [sitePlugin, i18nPlugin],
|
|
1849
|
+
helpers: {
|
|
1850
|
+
route,
|
|
1851
|
+
defineRoutes
|
|
1852
|
+
},
|
|
1853
|
+
config: {
|
|
1854
|
+
routes: {},
|
|
1855
|
+
mode: "hybrid"
|
|
1856
|
+
},
|
|
1857
|
+
createState: createState$2,
|
|
1858
|
+
api: createApi$2,
|
|
1859
|
+
onInit(ctx) {
|
|
1860
|
+
const i18n = ctx.require(i18nPlugin);
|
|
1861
|
+
const baseUrl = ctx.require(sitePlugin).url();
|
|
1862
|
+
ctx.state.table = buildRouterTable(ctx.config, baseUrl, i18n.locales(), i18n.defaultLocale());
|
|
1863
|
+
}
|
|
1864
|
+
});
|
|
1865
|
+
//#endregion
|
|
1866
|
+
//#region src/plugins/head/primitives.ts
|
|
1867
|
+
/** OG/Twitter article-meta property prefixes (factored to satisfy no-duplicate-string). */
|
|
1868
|
+
const ARTICLE_PREFIX = "article:";
|
|
1869
|
+
/**
|
|
1870
|
+
* Build a `<meta name=… content=…>` descriptor.
|
|
1871
|
+
*
|
|
1872
|
+
* @param name - The meta `name` attribute (e.g. `"description"`, `"robots"`).
|
|
1873
|
+
* @param content - The meta `content` value.
|
|
1874
|
+
* @returns A serializable head element keyed `meta:<name>`.
|
|
1875
|
+
* @example meta("description", "A web framework built on @moku-labs/core")
|
|
1876
|
+
*/
|
|
1877
|
+
function meta(name, content) {
|
|
1878
|
+
return {
|
|
1879
|
+
tag: "meta",
|
|
1880
|
+
attrs: {
|
|
1881
|
+
name,
|
|
1882
|
+
content
|
|
1883
|
+
},
|
|
1884
|
+
key: `meta:${name}`
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
/**
|
|
1888
|
+
* Build an Open Graph `<meta property=… content=…>` descriptor.
|
|
1889
|
+
*
|
|
1890
|
+
* @param property - The OG property, used verbatim (e.g. `"og:title"`, `"og:image"`).
|
|
1891
|
+
* @param content - The property value.
|
|
1892
|
+
* @returns A serializable head element keyed `meta:<property>`.
|
|
1893
|
+
* @example og("og:title", "Home")
|
|
1894
|
+
*/
|
|
1895
|
+
function og(property, content) {
|
|
1896
|
+
return {
|
|
1897
|
+
tag: "meta",
|
|
1898
|
+
attrs: {
|
|
1899
|
+
property,
|
|
1900
|
+
content
|
|
1901
|
+
},
|
|
1902
|
+
key: `meta:${property}`
|
|
1903
|
+
};
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Build a Twitter-card `<meta name=… content=…>` descriptor.
|
|
1907
|
+
*
|
|
1908
|
+
* @param name - The Twitter meta name, used verbatim (e.g. `"twitter:title"`).
|
|
1909
|
+
* @param content - The value.
|
|
1910
|
+
* @returns A serializable head element keyed `meta:<name>`.
|
|
1911
|
+
* @example twitter("twitter:card", "summary_large_image")
|
|
1912
|
+
*/
|
|
1913
|
+
function twitter(name, content) {
|
|
1914
|
+
return {
|
|
1915
|
+
tag: "meta",
|
|
1916
|
+
attrs: {
|
|
1917
|
+
name,
|
|
1918
|
+
content
|
|
1919
|
+
},
|
|
1920
|
+
key: `meta:${name}`
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* Build a JSON-LD `<script type="application/ld+json">` descriptor.
|
|
1925
|
+
*
|
|
1926
|
+
* XSS-SAFE: the serialized JSON has `<`, `>`, and `&` unicode-escaped (`<`,
|
|
1927
|
+
* `>`, `&`) so the payload can never break out of the `<script>` element
|
|
1928
|
+
* or inject markup, while still round-tripping via `JSON.parse`.
|
|
1929
|
+
*
|
|
1930
|
+
* @param data - Any JSON-serializable structured-data object.
|
|
1931
|
+
* @returns A serializable head element carrying the escaped JSON-LD script.
|
|
1932
|
+
* @example jsonLd({ "@context": "https://schema.org", "@type": "Article", headline: "Hi" })
|
|
1933
|
+
*/
|
|
1934
|
+
function jsonLd(data) {
|
|
1935
|
+
return {
|
|
1936
|
+
tag: "script",
|
|
1937
|
+
attrs: { type: "application/ld+json" },
|
|
1938
|
+
children: JSON.stringify(data).replaceAll("<", String.raw`\u003c`).replaceAll(">", String.raw`\u003e`).replaceAll("&", String.raw`\u0026`)
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
/**
|
|
1942
|
+
* Build a canonical `<link rel="canonical" href=…>` descriptor.
|
|
1943
|
+
*
|
|
1944
|
+
* @param url - The canonical absolute URL.
|
|
1945
|
+
* @returns A serializable head element keyed `link:canonical`.
|
|
1946
|
+
* @example canonical("https://example.com/post")
|
|
1947
|
+
*/
|
|
1948
|
+
function canonical(url) {
|
|
1949
|
+
return {
|
|
1950
|
+
tag: "link",
|
|
1951
|
+
attrs: {
|
|
1952
|
+
rel: "canonical",
|
|
1953
|
+
href: url
|
|
1954
|
+
},
|
|
1955
|
+
key: "link:canonical"
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
/**
|
|
1959
|
+
* Build an alternate-language `<link rel="alternate" hreflang=… href=…>` descriptor.
|
|
1960
|
+
*
|
|
1961
|
+
* @param locale - The BCP-47 locale tag (e.g. `"en"`, `"uk"`, `"x-default"`).
|
|
1962
|
+
* @param url - The absolute URL of the localized page.
|
|
1963
|
+
* @returns A serializable head element keyed `link:alternate:<locale>`.
|
|
1964
|
+
* @example hreflang("uk", "https://example.com/uk/post")
|
|
1965
|
+
*/
|
|
1966
|
+
function hreflang(locale, url) {
|
|
1967
|
+
return {
|
|
1968
|
+
tag: "link",
|
|
1969
|
+
attrs: {
|
|
1970
|
+
rel: "alternate",
|
|
1971
|
+
hreflang: locale,
|
|
1972
|
+
href: url
|
|
1973
|
+
},
|
|
1974
|
+
key: `link:alternate:${locale}`
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
/**
|
|
1978
|
+
* Build a feed `<link rel="alternate" type=… title=… href=…>` descriptor.
|
|
1979
|
+
*
|
|
1980
|
+
* @param title - Human-readable feed title.
|
|
1981
|
+
* @param url - The feed URL.
|
|
1982
|
+
* @param type - The feed MIME type. Defaults to `"application/rss+xml"`.
|
|
1983
|
+
* @returns A serializable head element keyed `link:feed:<url>`.
|
|
1984
|
+
* @example feedLink("My Blog", "/feed.xml", "application/atom+xml")
|
|
1985
|
+
*/
|
|
1986
|
+
function feedLink(title, url, type = "application/rss+xml") {
|
|
1987
|
+
return {
|
|
1988
|
+
tag: "link",
|
|
1989
|
+
attrs: {
|
|
1990
|
+
rel: "alternate",
|
|
1991
|
+
type,
|
|
1992
|
+
title,
|
|
1993
|
+
href: url
|
|
1994
|
+
},
|
|
1995
|
+
key: `link:feed:${url}`
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1998
|
+
/**
|
|
1999
|
+
* Compose the full head element set for an article page: og:type=article, published/
|
|
2000
|
+
* modified times, author, section, tags, plus a JSON-LD `Article` block and canonical.
|
|
2001
|
+
*
|
|
2002
|
+
* @param articleMeta - Article metadata (title, description, author, dates, tags, image…).
|
|
2003
|
+
* @param canonicalUrl - The article's canonical absolute URL.
|
|
2004
|
+
* @returns An ordered array of serializable head elements.
|
|
2005
|
+
* @example buildArticleHead({ title: "Hi", author: "A", published: "2026-01-01" }, "https://x/p")
|
|
2006
|
+
*/
|
|
2007
|
+
function buildArticleHead(articleMeta, canonicalUrl) {
|
|
2008
|
+
const elements = [canonical(canonicalUrl), og("og:type", "article")];
|
|
2009
|
+
if (articleMeta.published) elements.push(og(`${ARTICLE_PREFIX}published_time`, articleMeta.published));
|
|
2010
|
+
if (articleMeta.modified) elements.push(og(`${ARTICLE_PREFIX}modified_time`, articleMeta.modified));
|
|
2011
|
+
if (articleMeta.author) elements.push(og(`${ARTICLE_PREFIX}author`, articleMeta.author));
|
|
2012
|
+
if (articleMeta.section) elements.push(og(`${ARTICLE_PREFIX}section`, articleMeta.section));
|
|
2013
|
+
for (const tag of articleMeta.tags ?? []) elements.push(og(`${ARTICLE_PREFIX}tag`, tag));
|
|
2014
|
+
if (articleMeta.image) elements.push(og("og:image", articleMeta.image));
|
|
2015
|
+
const ld = {
|
|
2016
|
+
"@context": "https://schema.org",
|
|
2017
|
+
"@type": "Article",
|
|
2018
|
+
headline: articleMeta.title
|
|
2019
|
+
};
|
|
2020
|
+
if (articleMeta.description) ld.description = articleMeta.description;
|
|
2021
|
+
if (articleMeta.author) ld.author = articleMeta.author;
|
|
2022
|
+
if (articleMeta.published) ld.datePublished = articleMeta.published;
|
|
2023
|
+
if (articleMeta.modified) ld.dateModified = articleMeta.modified;
|
|
2024
|
+
if (articleMeta.image) ld.image = articleMeta.image;
|
|
2025
|
+
elements.push(jsonLd(ld));
|
|
2026
|
+
return elements;
|
|
2027
|
+
}
|
|
2028
|
+
//#endregion
|
|
2029
|
+
//#region src/plugins/head/compose.ts
|
|
2030
|
+
/**
|
|
2031
|
+
* @file head plugin — shared pure composition module (reused by `spa` in Increment B)
|
|
2032
|
+
*
|
|
2033
|
+
* The pure composition logic — `(HeadConfig, defaults, locales, urls) → HeadElement[]` —
|
|
2034
|
+
* lives here so `spa` can import it without making `head` depend on `spa`. Dependency
|
|
2035
|
+
* direction is strictly `spa → head`; `head` must never import `spa`.
|
|
2036
|
+
*/
|
|
2037
|
+
/** The `x-default` hreflang sentinel locale. */
|
|
2038
|
+
const X_DEFAULT = "x-default";
|
|
2039
|
+
/**
|
|
2040
|
+
* Apply a `%s` title template to a resolved title (or return the title verbatim when
|
|
2041
|
+
* no template is configured).
|
|
2042
|
+
*
|
|
2043
|
+
* @param title - The resolved page title.
|
|
2044
|
+
* @param template - The configured title template (may be `undefined`).
|
|
2045
|
+
* @returns The templated title string.
|
|
2046
|
+
* @example applyTemplate("Home", "%s — Site") // "Home — Site"
|
|
2047
|
+
*/
|
|
2048
|
+
function applyTemplate(title, template) {
|
|
2049
|
+
return template === void 0 ? title : template.replaceAll("%s", title);
|
|
2050
|
+
}
|
|
2051
|
+
/**
|
|
2052
|
+
* Resolve a possibly-relative image URL against the site base URL.
|
|
2053
|
+
*
|
|
2054
|
+
* @param image - The image URL (relative or absolute).
|
|
2055
|
+
* @param site - The site slice used to absolutize relative paths.
|
|
2056
|
+
* @returns The absolute image URL.
|
|
2057
|
+
* @example resolveImage("/og.png", site) // "https://blog.dev/og.png"
|
|
2058
|
+
*/
|
|
2059
|
+
function resolveImage(image, site) {
|
|
2060
|
+
return image.startsWith("http") ? image : site.canonical(image);
|
|
2061
|
+
}
|
|
2062
|
+
/**
|
|
2063
|
+
* Build the canonical, og, twitter, and hreflang elements for the route from
|
|
2064
|
+
* the resolved title/description, defaults, and dependency slices.
|
|
2065
|
+
*
|
|
2066
|
+
* @param input - The gathered composition inputs.
|
|
2067
|
+
* @param resolved - The resolved title/description/canonical URL.
|
|
2068
|
+
* @param resolved.title - The templated page title.
|
|
2069
|
+
* @param resolved.description - The resolved description.
|
|
2070
|
+
* @param resolved.canonicalUrl - The resolved absolute canonical URL.
|
|
2071
|
+
* @returns The ordered base element set (excluding route-supplied extras).
|
|
2072
|
+
* @example buildBaseElements(input, { title, description, canonicalUrl })
|
|
2073
|
+
*/
|
|
2074
|
+
function buildBaseElements(input, resolved) {
|
|
2075
|
+
const { route, defaults, site, i18n, router } = input;
|
|
2076
|
+
const head = route.head ?? {};
|
|
2077
|
+
const image = head.image ?? defaults.defaultOgImage;
|
|
2078
|
+
const elements = [
|
|
2079
|
+
{
|
|
2080
|
+
tag: "title",
|
|
2081
|
+
children: resolved.title,
|
|
2082
|
+
key: "title"
|
|
2083
|
+
},
|
|
2084
|
+
meta("description", resolved.description),
|
|
2085
|
+
og("og:title", head.title ?? resolved.title),
|
|
2086
|
+
og("og:description", resolved.description),
|
|
2087
|
+
og("og:url", resolved.canonicalUrl),
|
|
2088
|
+
twitter("twitter:card", defaults.twitterCard),
|
|
2089
|
+
twitter("twitter:title", head.title ?? resolved.title),
|
|
2090
|
+
twitter("twitter:description", resolved.description)
|
|
2091
|
+
];
|
|
2092
|
+
if (image) {
|
|
2093
|
+
const abs = resolveImage(image, site);
|
|
2094
|
+
elements.push(og("og:image", abs), twitter("twitter:image", abs));
|
|
2095
|
+
}
|
|
2096
|
+
if (defaults.twitterHandle) elements.push(twitter("twitter:site", defaults.twitterHandle));
|
|
2097
|
+
const ogLocale = route.locale ? i18n.ogLocale(route.locale) : void 0;
|
|
2098
|
+
if (ogLocale) elements.push(og("og:locale", ogLocale));
|
|
2099
|
+
elements.push(canonical(resolved.canonicalUrl));
|
|
2100
|
+
for (const locale of i18n.locales()) {
|
|
2101
|
+
const href = site.canonical(router.toUrl(route.name, {
|
|
2102
|
+
...route.params,
|
|
2103
|
+
lang: locale
|
|
2104
|
+
}));
|
|
2105
|
+
elements.push(hreflang(locale, href));
|
|
2106
|
+
}
|
|
2107
|
+
const xDefaultHref = site.canonical(router.toUrl(route.name, { ...route.params }));
|
|
2108
|
+
elements.push(hreflang(X_DEFAULT, xDefaultHref));
|
|
2109
|
+
return elements;
|
|
2110
|
+
}
|
|
2111
|
+
/**
|
|
2112
|
+
* De-duplicate elements by `key`, keeping the LAST occurrence (route-supplied
|
|
2113
|
+
* overrides win over generated defaults). Keyless elements are always retained.
|
|
2114
|
+
*
|
|
2115
|
+
* @param elements - The full ordered element list.
|
|
2116
|
+
* @returns The de-duplicated list in first-seen position with last-wins content.
|
|
2117
|
+
* @example dedupeByKey([meta("description", "a"), meta("description", "b")])
|
|
2118
|
+
*/
|
|
2119
|
+
function dedupeByKey(elements) {
|
|
2120
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
2121
|
+
const order = [];
|
|
2122
|
+
const keyless = [];
|
|
2123
|
+
for (const element of elements) {
|
|
2124
|
+
if (element.key === void 0) {
|
|
2125
|
+
keyless.push(element);
|
|
2126
|
+
continue;
|
|
2127
|
+
}
|
|
2128
|
+
if (!byKey.has(element.key)) order.push(element.key);
|
|
2129
|
+
byKey.set(element.key, element);
|
|
2130
|
+
}
|
|
2131
|
+
return [...order.map((k) => byKey.get(k)), ...keyless];
|
|
2132
|
+
}
|
|
2133
|
+
/**
|
|
2134
|
+
* Compose the ordered, de-duplicated `HeadElement[]` for a route from site defaults,
|
|
2135
|
+
* i18n hreflang alternates, and the route's head config.
|
|
2136
|
+
*
|
|
2137
|
+
* @param input - The gathered composition inputs.
|
|
2138
|
+
* @returns The ordered, de-duplicated head element set.
|
|
2139
|
+
* @example composeHead({ route, data, defaults, site, i18n, router })
|
|
2140
|
+
*/
|
|
2141
|
+
function composeHead(input) {
|
|
2142
|
+
const { route, defaults, site, router } = input;
|
|
2143
|
+
const head = route.head ?? {};
|
|
2144
|
+
return dedupeByKey([...buildBaseElements(input, {
|
|
2145
|
+
title: applyTemplate(head.title ?? site.name(), defaults.titleTemplate),
|
|
2146
|
+
description: head.description ?? site.description(),
|
|
2147
|
+
canonicalUrl: head.canonical ?? site.canonical(router.toUrl(route.name, { ...route.params }))
|
|
2148
|
+
}), ...head.elements ?? []]);
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
2151
|
+
* HTML-escape a value for safe insertion into an attribute or text node. `&` is
|
|
2152
|
+
* escaped first so already-escaped entities are not double-escaped.
|
|
2153
|
+
*
|
|
2154
|
+
* @param raw - The unsafe string.
|
|
2155
|
+
* @returns The HTML-escaped string.
|
|
2156
|
+
* @example escapeHtml('a & "b" <c>') // "a & "b" <c>"
|
|
2157
|
+
*/
|
|
2158
|
+
function escapeHtml(raw) {
|
|
2159
|
+
return raw.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
2160
|
+
}
|
|
2161
|
+
/**
|
|
2162
|
+
* Serialize a single `HeadElement` to its HTML string form. Attribute values are
|
|
2163
|
+
* HTML-escaped; `script` children are emitted verbatim (already unicode-escaped by
|
|
2164
|
+
* `jsonLd`); `title` text is HTML-escaped.
|
|
2165
|
+
*
|
|
2166
|
+
* @param element - The element to serialize.
|
|
2167
|
+
* @returns A single line of HTML.
|
|
2168
|
+
* @example serializeElement(meta("robots", "index"))
|
|
2169
|
+
*/
|
|
2170
|
+
function serializeElement(element) {
|
|
2171
|
+
const attributes = Object.entries(element.attrs ?? {}).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(" ");
|
|
2172
|
+
const open = attributes.length === 0 ? element.tag : `${element.tag} ${attributes}`;
|
|
2173
|
+
if (element.tag === "script") return `<script ${attributes}>${element.children ?? ""}<\/script>`;
|
|
2174
|
+
if (element.tag === "title") return `<title>${escapeHtml(element.children ?? "")}</title>`;
|
|
2175
|
+
return `<${open}>`;
|
|
2176
|
+
}
|
|
2177
|
+
/**
|
|
2178
|
+
* Serialize a `HeadElement[]` to `<head>` inner HTML. All attribute values are
|
|
2179
|
+
* HTML-attribute-escaped; JSON-LD payloads are already unicode-escaped by `jsonLd`.
|
|
2180
|
+
*
|
|
2181
|
+
* @param elements - The composed head elements.
|
|
2182
|
+
* @returns The serialized inner HTML of `<head>` (no surrounding `<head>` tags).
|
|
2183
|
+
* @example serializeHead(composeHead(input))
|
|
2184
|
+
*/
|
|
2185
|
+
function serializeHead(elements) {
|
|
2186
|
+
return elements.map((element) => serializeElement(element)).join("");
|
|
2187
|
+
}
|
|
2188
|
+
//#endregion
|
|
2189
|
+
//#region src/plugins/head/api.ts
|
|
2190
|
+
/**
|
|
2191
|
+
* @file head plugin — API factory.
|
|
2192
|
+
*
|
|
2193
|
+
* The `render` method pulls `site`/`i18n`/`router` via `ctx.require` at call time,
|
|
2194
|
+
* composes the head element set via the shared `compose.ts` module, and serializes
|
|
2195
|
+
* it to a string. It holds no resource and caches no subscription.
|
|
2196
|
+
*/
|
|
2197
|
+
/** Error prefix for head API invariant failures. */
|
|
2198
|
+
const ERROR_PREFIX$4 = "[head]";
|
|
2199
|
+
/**
|
|
2200
|
+
* Read the normalized defaults, asserting the post-`onInit` invariant (the slot is
|
|
2201
|
+
* `null` only before `onInit` assigns it, which cannot occur at render time).
|
|
2202
|
+
*
|
|
2203
|
+
* @param state - The head plugin state holder.
|
|
2204
|
+
* @returns The non-null normalized defaults snapshot.
|
|
2205
|
+
* @throws {Error} If `render` is reached before `onInit` populated the defaults.
|
|
2206
|
+
* @example
|
|
2207
|
+
* ```ts
|
|
2208
|
+
* const defaults = readDefaults(ctx.state);
|
|
2209
|
+
* ```
|
|
2210
|
+
*/
|
|
2211
|
+
function readDefaults(state) {
|
|
2212
|
+
if (state.defaults === null) throw new Error(`${ERROR_PREFIX$4}: defaults accessed before onInit normalized them.`);
|
|
2213
|
+
return state.defaults;
|
|
2214
|
+
}
|
|
2215
|
+
/**
|
|
2216
|
+
* Creates the head plugin API surface. The single `render` method resolves
|
|
2217
|
+
* `site`/`i18n`/`router` via `ctx.require`, composes the route's head elements,
|
|
2218
|
+
* and serializes them to `<head>` inner HTML.
|
|
2219
|
+
*
|
|
2220
|
+
* @param ctx - Plugin context exposing `state` and `require`.
|
|
2221
|
+
* @returns The {@link Api} surface mounted at `app.head`.
|
|
2222
|
+
* @example
|
|
2223
|
+
* ```ts
|
|
2224
|
+
* const api = createApi(ctx);
|
|
2225
|
+
* api.render(route, data);
|
|
2226
|
+
* ```
|
|
2227
|
+
*/
|
|
2228
|
+
function createApi$1(ctx) {
|
|
2229
|
+
return {
|
|
2230
|
+
/**
|
|
2231
|
+
* Compose the final `<head>` inner HTML for a route (pulled by `build`).
|
|
2232
|
+
*
|
|
2233
|
+
* @param route - The resolved route descriptor (incl. its `.head()` HeadConfig).
|
|
2234
|
+
* @param data - The page data object passed to the route's loader/render.
|
|
2235
|
+
* @returns The serialized inner HTML of `<head>`.
|
|
2236
|
+
* @example
|
|
2237
|
+
* ```ts
|
|
2238
|
+
* api.render(route, { title: "Post" });
|
|
2239
|
+
* ```
|
|
2240
|
+
*/
|
|
2241
|
+
render(route, data) {
|
|
2242
|
+
return serializeHead(composeHead({
|
|
2243
|
+
route,
|
|
2244
|
+
data,
|
|
2245
|
+
defaults: readDefaults(ctx.state),
|
|
2246
|
+
site: ctx.require(sitePlugin),
|
|
2247
|
+
i18n: ctx.require(i18nPlugin),
|
|
2248
|
+
router: ctx.require(routerPlugin)
|
|
2249
|
+
}));
|
|
2250
|
+
} };
|
|
2251
|
+
}
|
|
2252
|
+
//#endregion
|
|
2253
|
+
//#region src/plugins/head/config.ts
|
|
2254
|
+
/** Error prefix for all head config-validation failures. */
|
|
2255
|
+
const ERROR_PREFIX$3 = "[head] config:";
|
|
2256
|
+
/** The allowed `twitterCard` literals (also the runtime guard set). */
|
|
2257
|
+
const VALID_TWITTER_CARDS = ["summary", "summary_large_image"];
|
|
2258
|
+
/**
|
|
2259
|
+
* Framework default head config. Consumers override via `pluginConfigs.head`.
|
|
2260
|
+
* `twitterCard` defaults to the large-image card; all other fields are absent
|
|
2261
|
+
* (the optional fields are left `undefined` per `exactOptionalPropertyTypes`).
|
|
2262
|
+
*
|
|
2263
|
+
* @example
|
|
2264
|
+
* ```ts
|
|
2265
|
+
* createPlugin("head", { config: defaultConfig });
|
|
2266
|
+
* ```
|
|
2267
|
+
*/
|
|
2268
|
+
const defaultConfig = { twitterCard: "summary_large_image" };
|
|
2269
|
+
/**
|
|
2270
|
+
* Structurally validate the resolved head config (no I/O). Throws a standard
|
|
2271
|
+
* `[head] config: …` error when `titleTemplate` is provided without the `%s`
|
|
2272
|
+
* token, or when `twitterCard` is present but not one of the two allowed literals.
|
|
2273
|
+
*
|
|
2274
|
+
* @param config - The resolved head {@link Config} to validate.
|
|
2275
|
+
* @throws {Error} If `titleTemplate` lacks `%s`, or `twitterCard` is invalid.
|
|
2276
|
+
* @example
|
|
2277
|
+
* ```ts
|
|
2278
|
+
* validateHeadConfig({ titleTemplate: "%s — Site" });
|
|
2279
|
+
* ```
|
|
2280
|
+
*/
|
|
2281
|
+
function validateHeadConfig(config) {
|
|
2282
|
+
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)}.`);
|
|
2283
|
+
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)}.`);
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* Validate then build the frozen, normalized {@link HeadDefaults} snapshot read by
|
|
2287
|
+
* `render`. `twitterCard` is defaulted to `"summary_large_image"`; optional fields
|
|
2288
|
+
* are copied through only when present (preserving `exactOptionalPropertyTypes`).
|
|
2289
|
+
*
|
|
2290
|
+
* @param config - The resolved head {@link Config}.
|
|
2291
|
+
* @returns A frozen normalized defaults snapshot.
|
|
2292
|
+
* @throws {Error} If the config fails {@link validateHeadConfig}.
|
|
2293
|
+
* @example
|
|
2294
|
+
* ```ts
|
|
2295
|
+
* normalizeHeadConfig({ titleTemplate: "%s — Site" });
|
|
2296
|
+
* ```
|
|
2297
|
+
*/
|
|
2298
|
+
function normalizeHeadConfig(config) {
|
|
2299
|
+
validateHeadConfig(config);
|
|
2300
|
+
const defaults = { twitterCard: config.twitterCard ?? "summary_large_image" };
|
|
2301
|
+
if (config.titleTemplate !== void 0) defaults.titleTemplate = config.titleTemplate;
|
|
2302
|
+
if (config.defaultOgImage !== void 0) defaults.defaultOgImage = config.defaultOgImage;
|
|
2303
|
+
if (config.twitterHandle !== void 0) defaults.twitterHandle = config.twitterHandle;
|
|
2304
|
+
return Object.freeze(defaults);
|
|
2305
|
+
}
|
|
2306
|
+
//#endregion
|
|
2307
|
+
//#region src/plugins/head/helpers.ts
|
|
2308
|
+
/**
|
|
2309
|
+
* @file head plugin — SEO primitive helper bundle for plugin registration.
|
|
2310
|
+
*
|
|
2311
|
+
* Aggregates the pure SEO primitive helpers into a single record consumed by the
|
|
2312
|
+
* plugin's `helpers` slot in `index.ts` (kept here so `index.ts` stays wiring-only
|
|
2313
|
+
* and within its line budget). These same helpers are re-exported at the framework
|
|
2314
|
+
* index for direct consumer use.
|
|
2315
|
+
*/
|
|
2316
|
+
/**
|
|
2317
|
+
* The SEO primitive helper bundle registered on the `head` plugin.
|
|
2318
|
+
*
|
|
2319
|
+
* @example
|
|
2320
|
+
* ```ts
|
|
2321
|
+
* createPlugin("head", { helpers: headHelpers });
|
|
2322
|
+
* ```
|
|
2323
|
+
*/
|
|
2324
|
+
const headHelpers = {
|
|
2325
|
+
meta,
|
|
2326
|
+
og,
|
|
2327
|
+
twitter,
|
|
2328
|
+
jsonLd,
|
|
2329
|
+
canonical,
|
|
2330
|
+
hreflang,
|
|
2331
|
+
feedLink,
|
|
2332
|
+
buildArticleHead
|
|
2333
|
+
};
|
|
2334
|
+
//#endregion
|
|
2335
|
+
//#region src/plugins/head/state.ts
|
|
2336
|
+
/**
|
|
2337
|
+
* Creates initial head plugin state.
|
|
2338
|
+
*
|
|
2339
|
+
* Initializes the single `defaults` slot to `null`; `onInit` assigns the normalized
|
|
2340
|
+
* snapshot exactly once.
|
|
2341
|
+
*
|
|
2342
|
+
* @param _ctx - Minimal context with global and config.
|
|
2343
|
+
* @param _ctx.global - Global plugin registry.
|
|
2344
|
+
* @param _ctx.config - Resolved plugin configuration.
|
|
2345
|
+
* @returns The initial head state with a null `defaults` slot.
|
|
2346
|
+
* @example
|
|
2347
|
+
* ```ts
|
|
2348
|
+
* const state = createState({ global: {}, config: {} });
|
|
2349
|
+
* ```
|
|
2350
|
+
*/
|
|
2351
|
+
function createState$1(_ctx) {
|
|
2352
|
+
return { defaults: null };
|
|
2353
|
+
}
|
|
2354
|
+
//#endregion
|
|
2355
|
+
//#region src/plugins/head/index.ts
|
|
2356
|
+
/**
|
|
2357
|
+
* @file head — Standard Plugin wiring harness (logic in primitives/compose/api/config).
|
|
2358
|
+
* @see README.md
|
|
2359
|
+
*/
|
|
2360
|
+
/**
|
|
2361
|
+
* Head plugin — composes per-route `<head>` metadata (title template, Open Graph,
|
|
2362
|
+
* Twitter cards, canonical, hreflang). Use the re-exported SEO primitives
|
|
2363
|
+
* ({@link meta}, {@link og}, {@link twitter}, …) inside a route's `.head()`.
|
|
2364
|
+
* Depends on site, i18n, and router.
|
|
2365
|
+
*
|
|
2366
|
+
* @example Set global head defaults
|
|
2367
|
+
* ```ts
|
|
2368
|
+
* const app = createApp({
|
|
2369
|
+
* pluginConfigs: {
|
|
2370
|
+
* head: {
|
|
2371
|
+
* titleTemplate: "%s — My Blog",
|
|
2372
|
+
* twitterCard: "summary_large_image",
|
|
2373
|
+
* twitterHandle: "@moku_labs"
|
|
2374
|
+
* }
|
|
2375
|
+
* }
|
|
2376
|
+
* });
|
|
2377
|
+
* ```
|
|
2378
|
+
*/
|
|
2379
|
+
const headPlugin = createPlugin$1("head", {
|
|
2380
|
+
depends: [
|
|
2381
|
+
sitePlugin,
|
|
2382
|
+
i18nPlugin,
|
|
2383
|
+
routerPlugin
|
|
2384
|
+
],
|
|
2385
|
+
helpers: headHelpers,
|
|
2386
|
+
config: defaultConfig,
|
|
2387
|
+
createState: createState$1,
|
|
2388
|
+
api: createApi$1,
|
|
2389
|
+
onInit(ctx) {
|
|
2390
|
+
ctx.state.defaults = normalizeHeadConfig(ctx.config);
|
|
2391
|
+
}
|
|
2392
|
+
});
|
|
2393
|
+
//#endregion
|
|
2394
|
+
//#region src/plugins/spa/api.ts
|
|
2395
|
+
/**
|
|
2396
|
+
* Creates the spa plugin API surface (registration / control). All methods
|
|
2397
|
+
* delegate to the single shared kernel stored in `ctx.state.kernel`.
|
|
2398
|
+
*
|
|
2399
|
+
* @param ctx - Plugin context exposing `state` (kernel) and `log`.
|
|
2400
|
+
* @returns The {@link SpaApi} surface mounted at `app.spa`.
|
|
2401
|
+
* @example
|
|
2402
|
+
* const api = createApi(ctx);
|
|
2403
|
+
* api.register(counter);
|
|
2404
|
+
*/
|
|
2405
|
+
function createApi(ctx) {
|
|
2406
|
+
return {
|
|
2407
|
+
/**
|
|
2408
|
+
* Register a component definition (last-registered-wins); warns on collision.
|
|
2409
|
+
*
|
|
2410
|
+
* @param component - The component definition created via `createComponent`.
|
|
2411
|
+
* @example
|
|
2412
|
+
* app.spa.register(counter);
|
|
2413
|
+
*/
|
|
2414
|
+
register(component) {
|
|
2415
|
+
if (ctx.state.registeredComponents.has(component.name)) ctx.log.warn("spa:component-collision", { name: component.name });
|
|
2416
|
+
ctx.state.kernel?.register(component);
|
|
2417
|
+
},
|
|
2418
|
+
/**
|
|
2419
|
+
* Programmatically navigate to a path (client runtime; no-op without a DOM).
|
|
2420
|
+
*
|
|
2421
|
+
* @param path - Target path (pathname, optionally with search/hash).
|
|
2422
|
+
* @example
|
|
2423
|
+
* app.spa.navigate("/about");
|
|
2424
|
+
*/
|
|
2425
|
+
navigate(path) {
|
|
2426
|
+
ctx.state.kernel?.processNav(path);
|
|
2427
|
+
},
|
|
2428
|
+
/**
|
|
2429
|
+
* Read the current resolved URL.
|
|
2430
|
+
*
|
|
2431
|
+
* @returns The current pathname + search.
|
|
2432
|
+
* @example
|
|
2433
|
+
* app.spa.current();
|
|
2434
|
+
*/
|
|
2435
|
+
current() {
|
|
2436
|
+
return ctx.state.currentUrl;
|
|
2437
|
+
}
|
|
2438
|
+
};
|
|
2439
|
+
}
|
|
2440
|
+
//#endregion
|
|
2441
|
+
//#region src/plugins/spa/events.ts
|
|
2442
|
+
/**
|
|
2443
|
+
* Declares the spa plugin's events. Extracted from index.ts to keep the wiring
|
|
2444
|
+
* file under the line budget.
|
|
2445
|
+
*
|
|
2446
|
+
* @param register - The event registration function supplied by the kernel.
|
|
2447
|
+
* @returns The map of spa event descriptors.
|
|
2448
|
+
* @example
|
|
2449
|
+
* const events = spaEvents(register);
|
|
2450
|
+
*/
|
|
2451
|
+
function spaEvents(register) {
|
|
2452
|
+
return {
|
|
2453
|
+
"spa:navigate": register("A navigation has been intercepted and is starting."),
|
|
2454
|
+
"spa:navigated": register("The swap completed and the new URL is active."),
|
|
2455
|
+
"spa:component-mount": register("A component instance attached to an element."),
|
|
2456
|
+
"spa:component-unmount": register("A component instance detached from an element.")
|
|
2457
|
+
};
|
|
2458
|
+
}
|
|
2459
|
+
//#endregion
|
|
2460
|
+
//#region src/plugins/spa/types.ts
|
|
2461
|
+
var types_exports$5 = /* @__PURE__ */ __exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
|
|
2462
|
+
/** Allowed hook names — single source of truth for fail-fast validation. */
|
|
2463
|
+
const COMPONENT_HOOK_NAMES = [
|
|
2464
|
+
"onCreate",
|
|
2465
|
+
"onMount",
|
|
2466
|
+
"onNavStart",
|
|
2467
|
+
"onNavEnd",
|
|
2468
|
+
"onUnMount",
|
|
2469
|
+
"onDestroy"
|
|
2470
|
+
];
|
|
2471
|
+
//#endregion
|
|
2472
|
+
//#region src/plugins/spa/components.ts
|
|
2473
|
+
/** Error prefix for spa fail-fast failures (spec/11 Part-3). */
|
|
2474
|
+
const ERROR_PREFIX$2 = "[web]";
|
|
2475
|
+
/** The set of legal hook names, frozen for O(1) membership checks. */
|
|
2476
|
+
const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
|
|
2477
|
+
/**
|
|
2478
|
+
* Create a validated component definition. Validates hook names at registration
|
|
2479
|
+
* for fail-fast typo detection (e.g. `onMout` throws immediately) and asserts
|
|
2480
|
+
* each provided hook is a function.
|
|
2481
|
+
*
|
|
2482
|
+
* @param name - Unique component name.
|
|
2483
|
+
* @param hooks - Lifecycle hook implementations.
|
|
2484
|
+
* @returns A `ComponentDef` ready to `register`.
|
|
2485
|
+
* @throws {Error} If `name` is empty, any hook key is not in
|
|
2486
|
+
* `COMPONENT_HOOK_NAMES`, or any provided hook value is not a function.
|
|
2487
|
+
* @example
|
|
2488
|
+
* const counter = createComponent("counter", {
|
|
2489
|
+
* onMount({ el }) { el.textContent = "0"; }
|
|
2490
|
+
* });
|
|
2491
|
+
*/
|
|
2492
|
+
function createComponent(name, hooks) {
|
|
2493
|
+
if (name.trim() === "") throw new Error(`${ERROR_PREFIX$2} component name must be a non-empty string\n → pass a unique name to createComponent("name", hooks)`);
|
|
2494
|
+
for (const key of Object.keys(hooks)) {
|
|
2495
|
+
if (!HOOK_NAME_SET.has(key)) throw new Error(`${ERROR_PREFIX$2} unknown component hook "${key}" on "${name}"\n → valid hooks: ${COMPONENT_HOOK_NAMES.join(", ")}`);
|
|
2496
|
+
if (typeof hooks[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2} component hook "${key}" on "${name}" must be a function\n → provide a function or omit the hook`);
|
|
2497
|
+
}
|
|
2498
|
+
return {
|
|
2499
|
+
name,
|
|
2500
|
+
hooks
|
|
2501
|
+
};
|
|
2502
|
+
}
|
|
2503
|
+
/**
|
|
2504
|
+
* Extracts the page data payload from the inline `script#__DATA__` element.
|
|
2505
|
+
* Returns an empty object when the script is absent, empty, or invalid JSON.
|
|
2506
|
+
*
|
|
2507
|
+
* @param doc - The document to read the data script from.
|
|
2508
|
+
* @returns The parsed page data, or `{}` when unavailable.
|
|
2509
|
+
* @example
|
|
2510
|
+
* const data = extractPageData(document);
|
|
2511
|
+
*/
|
|
2512
|
+
function extractPageData(doc) {
|
|
2513
|
+
const text = doc.querySelector("script#__DATA__")?.textContent;
|
|
2514
|
+
if (!text) return {};
|
|
2515
|
+
try {
|
|
2516
|
+
return JSON.parse(text);
|
|
2517
|
+
} catch {
|
|
2518
|
+
return {};
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
/**
|
|
2522
|
+
* Builds a live component instance bound to an element.
|
|
2523
|
+
*
|
|
2524
|
+
* @param definition - The component definition.
|
|
2525
|
+
* @param element - The element the instance binds to.
|
|
2526
|
+
* @param persistent - Whether the instance survives navigation.
|
|
2527
|
+
* @returns The constructed (not-yet-mounted) instance.
|
|
2528
|
+
* @example
|
|
2529
|
+
* const inst = createInstance(definition, element, false);
|
|
2530
|
+
*/
|
|
2531
|
+
function createInstance(definition, element, persistent) {
|
|
2532
|
+
return {
|
|
2533
|
+
def: definition,
|
|
2534
|
+
el: element,
|
|
2535
|
+
persistent
|
|
2536
|
+
};
|
|
2537
|
+
}
|
|
2538
|
+
/**
|
|
2539
|
+
* Invokes a single lifecycle hook on an instance with its component context.
|
|
2540
|
+
* Missing hooks are skipped silently.
|
|
2541
|
+
*
|
|
2542
|
+
* @param instance - The instance whose hook to run.
|
|
2543
|
+
* @param hook - The hook name to invoke.
|
|
2544
|
+
* @param ctx - The component context passed to the hook.
|
|
2545
|
+
* @example
|
|
2546
|
+
* runHook(instance, "onMount", ctx);
|
|
2547
|
+
*/
|
|
2548
|
+
function runHook(instance, hook, ctx) {
|
|
2549
|
+
instance.def.hooks[hook]?.(ctx);
|
|
2550
|
+
}
|
|
2551
|
+
/**
|
|
2552
|
+
* Builds the component context handed to a hook (the bound element + page data).
|
|
2553
|
+
*
|
|
2554
|
+
* @param element - The element the instance is bound to.
|
|
2555
|
+
* @param data - The current page data payload.
|
|
2556
|
+
* @returns The hook context.
|
|
2557
|
+
* @example
|
|
2558
|
+
* const ctx = makeContext(element, data);
|
|
2559
|
+
*/
|
|
2560
|
+
function makeContext(element, data) {
|
|
2561
|
+
return {
|
|
2562
|
+
el: element,
|
|
2563
|
+
data
|
|
2564
|
+
};
|
|
2565
|
+
}
|
|
2566
|
+
/**
|
|
2567
|
+
* Scans the swap region, mounts components for matching `data-component`
|
|
2568
|
+
* elements, classifies persistent (outside swap area) vs page-specific (inside),
|
|
2569
|
+
* fires `onCreate` then `onMount`, and emits `spa:component-mount` per instance.
|
|
2570
|
+
* Already-mounted elements are skipped.
|
|
2571
|
+
*
|
|
2572
|
+
* @param state - The plugin state (registeredComponents + instances).
|
|
2573
|
+
* @param emit - The event emitter for spa:component-mount.
|
|
2574
|
+
* @param swapSelector - CSS selector bounding page-specific components.
|
|
2575
|
+
* @example
|
|
2576
|
+
* scanAndMount(state, emit, "main > section");
|
|
2577
|
+
*/
|
|
2578
|
+
function scanAndMount(state, emit, swapSelector) {
|
|
2579
|
+
if (typeof document === "undefined") return;
|
|
2580
|
+
const swapArea = document.querySelector(swapSelector);
|
|
2581
|
+
const data = extractPageData(document);
|
|
2582
|
+
for (const element of document.querySelectorAll("[data-component]")) {
|
|
2583
|
+
if (state.instances.has(element)) continue;
|
|
2584
|
+
const name = element.dataset.component;
|
|
2585
|
+
if (!name) continue;
|
|
2586
|
+
const definition = state.registeredComponents.get(name);
|
|
2587
|
+
if (!definition) continue;
|
|
2588
|
+
const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
|
|
2589
|
+
const ctx = makeContext(element, data);
|
|
2590
|
+
runHook(instance, "onCreate", ctx);
|
|
2591
|
+
runHook(instance, "onMount", ctx);
|
|
2592
|
+
state.instances.set(element, instance);
|
|
2593
|
+
emit("spa:component-mount", {
|
|
2594
|
+
name: definition.name,
|
|
2595
|
+
el: element
|
|
2596
|
+
});
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
/**
|
|
2600
|
+
* Unmounts page-specific instances inside the swap region (runs `onUnMount`
|
|
2601
|
+
* then `onDestroy`), removes them from state, and emits `spa:component-unmount`.
|
|
2602
|
+
* Persistent instances (outside the swap area) are left in place.
|
|
2603
|
+
*
|
|
2604
|
+
* @param state - The plugin state holding live instances.
|
|
2605
|
+
* @param emit - The event emitter for spa:component-unmount.
|
|
2606
|
+
* @example
|
|
2607
|
+
* unmountPageSpecific(state, emit);
|
|
2608
|
+
*/
|
|
2609
|
+
function unmountPageSpecific(state, emit) {
|
|
2610
|
+
const data = typeof document === "undefined" ? {} : extractPageData(document);
|
|
2611
|
+
for (const [element, instance] of state.instances) {
|
|
2612
|
+
if (instance.persistent) continue;
|
|
2613
|
+
const ctx = makeContext(element, data);
|
|
2614
|
+
runHook(instance, "onUnMount", ctx);
|
|
2615
|
+
runHook(instance, "onDestroy", ctx);
|
|
2616
|
+
state.instances.delete(element);
|
|
2617
|
+
emit("spa:component-unmount", {
|
|
2618
|
+
name: instance.def.name,
|
|
2619
|
+
el: element
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
/**
|
|
2624
|
+
* Disposes ALL live instances (persistent and page-specific) on teardown:
|
|
2625
|
+
* runs `onUnMount` then `onDestroy`, emits `spa:component-unmount`, and clears
|
|
2626
|
+
* the instance map. Used by the kernel's `dispose` on plugin stop.
|
|
2627
|
+
*
|
|
2628
|
+
* @param state - The plugin state holding live instances.
|
|
2629
|
+
* @param emit - The event emitter for spa:component-unmount.
|
|
2630
|
+
* @example
|
|
2631
|
+
* unmountAll(state, emit);
|
|
2632
|
+
*/
|
|
2633
|
+
function unmountAll(state, emit) {
|
|
2634
|
+
const data = typeof document === "undefined" ? {} : extractPageData(document);
|
|
2635
|
+
for (const [element, instance] of state.instances) {
|
|
2636
|
+
const ctx = makeContext(element, data);
|
|
2637
|
+
runHook(instance, "onUnMount", ctx);
|
|
2638
|
+
runHook(instance, "onDestroy", ctx);
|
|
2639
|
+
emit("spa:component-unmount", {
|
|
2640
|
+
name: instance.def.name,
|
|
2641
|
+
el: element
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2644
|
+
state.instances.clear();
|
|
2645
|
+
}
|
|
2646
|
+
/**
|
|
2647
|
+
* Fires `onNavStart` on every currently-mounted instance (persistent instances
|
|
2648
|
+
* receive it across navigations; page-specific ones receive it before unmount).
|
|
2649
|
+
*
|
|
2650
|
+
* @param state - The plugin state holding live instances.
|
|
2651
|
+
* @example
|
|
2652
|
+
* notifyNavStart(state);
|
|
2653
|
+
*/
|
|
2654
|
+
function notifyNavStart(state) {
|
|
2655
|
+
const data = typeof document === "undefined" ? {} : extractPageData(document);
|
|
2656
|
+
for (const [element, instance] of state.instances) runHook(instance, "onNavStart", makeContext(element, data));
|
|
2657
|
+
}
|
|
2658
|
+
/**
|
|
2659
|
+
* Fires `onNavEnd` on persistent instances that survived the swap (page-specific
|
|
2660
|
+
* instances were already destroyed and re-created by the swap).
|
|
2661
|
+
*
|
|
2662
|
+
* @param state - The plugin state holding live instances.
|
|
2663
|
+
* @example
|
|
2664
|
+
* notifyNavEnd(state);
|
|
2665
|
+
*/
|
|
2666
|
+
function notifyNavEnd(state) {
|
|
2667
|
+
const data = typeof document === "undefined" ? {} : extractPageData(document);
|
|
2668
|
+
for (const [element, instance] of state.instances) if (instance.persistent) runHook(instance, "onNavEnd", makeContext(element, data));
|
|
2669
|
+
}
|
|
2670
|
+
//#endregion
|
|
2671
|
+
//#region src/plugins/spa/head.ts
|
|
2672
|
+
/** Single-element head selectors synced by replace/append/remove on navigation. */
|
|
2673
|
+
const META_SELECTORS = [
|
|
2674
|
+
"meta[name=\"description\"]",
|
|
2675
|
+
"meta[property=\"og:title\"]",
|
|
2676
|
+
"meta[property=\"og:description\"]",
|
|
2677
|
+
"meta[property=\"og:url\"]",
|
|
2678
|
+
"meta[property=\"og:image\"]",
|
|
2679
|
+
"meta[property=\"og:type\"]",
|
|
2680
|
+
"meta[property=\"og:locale\"]",
|
|
2681
|
+
"meta[name=\"twitter:card\"]",
|
|
2682
|
+
"meta[name=\"twitter:title\"]",
|
|
2683
|
+
"meta[name=\"twitter:description\"]",
|
|
2684
|
+
"meta[name=\"twitter:image\"]",
|
|
2685
|
+
"meta[name=\"twitter:site\"]",
|
|
2686
|
+
"link[rel=\"canonical\"]"
|
|
2687
|
+
];
|
|
2688
|
+
/** Head element groups fully replaced (remove-all-then-clone) on navigation. */
|
|
2689
|
+
const REPLACE_ALL_SELECTORS = [
|
|
2690
|
+
"script[type=\"application/ld+json\"]",
|
|
2691
|
+
"link[rel=\"alternate\"][hreflang]",
|
|
2692
|
+
"meta[property^=\"article:\"]"
|
|
2693
|
+
];
|
|
2694
|
+
/**
|
|
2695
|
+
* Sync a single head element by selector between the fetched and live document:
|
|
2696
|
+
* replace when both exist, append when only the new doc has it, remove when only
|
|
2697
|
+
* the live doc has it.
|
|
2698
|
+
*
|
|
2699
|
+
* @param selector - CSS selector for the head element to sync.
|
|
2700
|
+
* @param doc - The fetched document (DOMParser-parsed).
|
|
2701
|
+
* @example
|
|
2702
|
+
* syncElement('link[rel="canonical"]', doc);
|
|
2703
|
+
*/
|
|
2704
|
+
function syncElement(selector, doc) {
|
|
2705
|
+
const newElement = doc.querySelector(selector);
|
|
2706
|
+
const oldElement = document.querySelector(selector);
|
|
2707
|
+
if (newElement && oldElement) oldElement.replaceWith(newElement.cloneNode(true));
|
|
2708
|
+
else if (newElement) document.head.append(newElement.cloneNode(true));
|
|
2709
|
+
else if (oldElement) oldElement.remove();
|
|
2710
|
+
}
|
|
2711
|
+
/**
|
|
2712
|
+
* Remove all live matches for a selector and re-clone the fetched document's
|
|
2713
|
+
* matches into the live `<head>`.
|
|
2714
|
+
*
|
|
2715
|
+
* @param selector - CSS selector for the element group to replace wholesale.
|
|
2716
|
+
* @param doc - The fetched document (DOMParser-parsed).
|
|
2717
|
+
* @example
|
|
2718
|
+
* replaceAllBySelector('script[type="application/ld+json"]', doc);
|
|
2719
|
+
*/
|
|
2720
|
+
function replaceAllBySelector(selector, doc) {
|
|
2721
|
+
for (const element of document.querySelectorAll(selector)) element.remove();
|
|
2722
|
+
for (const element of doc.querySelectorAll(selector)) document.head.append(element.cloneNode(true));
|
|
2723
|
+
}
|
|
2724
|
+
/**
|
|
2725
|
+
* Syncs the live document `<head>` after a navigation from the fetched document
|
|
2726
|
+
* (whose head was composed by the `head` plugin). Recomputes
|
|
2727
|
+
* title/meta/canonical/JSON-LD/hreflang/`<html lang>` once and applies them.
|
|
2728
|
+
* The `head` API is accepted to bind the structural dependency (spec/09 deps).
|
|
2729
|
+
*
|
|
2730
|
+
* @param _head - The head plugin API (dependency binding; composition reused via the fetched doc).
|
|
2731
|
+
* @param doc - The fetched document parsed from the navigated page's HTML.
|
|
2732
|
+
* @example
|
|
2733
|
+
* syncHead(headApi, parsedDoc);
|
|
2734
|
+
*/
|
|
2735
|
+
function syncHead(_head, doc) {
|
|
2736
|
+
if (typeof document === "undefined") return;
|
|
2737
|
+
const newTitle = doc.querySelector("title")?.textContent;
|
|
2738
|
+
if (newTitle) document.title = newTitle;
|
|
2739
|
+
const newLang = doc.documentElement.lang;
|
|
2740
|
+
if (newLang) document.documentElement.lang = newLang;
|
|
2741
|
+
for (const selector of META_SELECTORS) syncElement(selector, doc);
|
|
2742
|
+
for (const selector of REPLACE_ALL_SELECTORS) replaceAllBySelector(selector, doc);
|
|
2743
|
+
}
|
|
2744
|
+
//#endregion
|
|
2745
|
+
//#region src/plugins/spa/progress.ts
|
|
2746
|
+
/** Delay before the bar appears, so fast navigations show no indicator. */
|
|
2747
|
+
const START_DELAY_MS = 150;
|
|
2748
|
+
/** Interval between trickle increments while loading. */
|
|
2749
|
+
const TRICKLE_MS = 300;
|
|
2750
|
+
/** Linger before the completed bar is reset/hidden. */
|
|
2751
|
+
const DONE_LINGER_MS = 200;
|
|
2752
|
+
/** Ceiling the bar trickles to while still loading (never reaches 100% until done). */
|
|
2753
|
+
const TRICKLE_CEIL = 90;
|
|
2754
|
+
/** No-op progress bar used when disabled or in a headless context. */
|
|
2755
|
+
const NOOP_BAR = {
|
|
2756
|
+
start() {},
|
|
2757
|
+
done() {}
|
|
2758
|
+
};
|
|
2759
|
+
/**
|
|
2760
|
+
* Creates the in-house progress bar (150ms delay + trickle). A no-op shell when
|
|
2761
|
+
* progress is disabled or no DOM is present. The progress element is created
|
|
2762
|
+
* once (prepended to `<body>` as `<div data-progress>`) and reused across navs.
|
|
2763
|
+
*
|
|
2764
|
+
* @param enabled - Whether the progress bar is active.
|
|
2765
|
+
* @returns A {@link ProgressBar} with `start`/`done`. Disabled/headless → no-ops.
|
|
2766
|
+
* @example
|
|
2767
|
+
* const bar = createProgressBar(true);
|
|
2768
|
+
* bar.start();
|
|
2769
|
+
* bar.done();
|
|
2770
|
+
*/
|
|
2771
|
+
function createProgressBar(enabled) {
|
|
2772
|
+
if (!enabled || typeof document === "undefined") return NOOP_BAR;
|
|
2773
|
+
const element = document.createElement("div");
|
|
2774
|
+
element.dataset.progress = "";
|
|
2775
|
+
document.body.prepend(element);
|
|
2776
|
+
let delayTimer;
|
|
2777
|
+
let trickleTimer;
|
|
2778
|
+
let width = 0;
|
|
2779
|
+
/**
|
|
2780
|
+
* Step the trickle upward toward the ceiling and reschedule.
|
|
2781
|
+
*
|
|
2782
|
+
* @example
|
|
2783
|
+
* trickle();
|
|
2784
|
+
*/
|
|
2785
|
+
const trickle = () => {
|
|
2786
|
+
if (width >= TRICKLE_CEIL) return;
|
|
2787
|
+
width = Math.min(TRICKLE_CEIL, width + 5 + Math.random() * 10);
|
|
2788
|
+
element.style.width = `${String(width)}%`;
|
|
2789
|
+
trickleTimer = setTimeout(trickle, TRICKLE_MS);
|
|
2790
|
+
};
|
|
2791
|
+
/**
|
|
2792
|
+
* Show the bar after the start delay and begin trickling.
|
|
2793
|
+
*
|
|
2794
|
+
* @example
|
|
2795
|
+
* start();
|
|
2796
|
+
*/
|
|
2797
|
+
const start = () => {
|
|
2798
|
+
delayTimer = setTimeout(() => {
|
|
2799
|
+
width = 15;
|
|
2800
|
+
element.style.width = "15%";
|
|
2801
|
+
element.dataset.active = "";
|
|
2802
|
+
trickle();
|
|
2803
|
+
}, START_DELAY_MS);
|
|
2804
|
+
};
|
|
2805
|
+
/**
|
|
2806
|
+
* Complete the bar to 100%, then reset/hide it after a short linger.
|
|
2807
|
+
*
|
|
2808
|
+
* @example
|
|
2809
|
+
* done();
|
|
2810
|
+
*/
|
|
2811
|
+
const done = () => {
|
|
2812
|
+
clearTimeout(delayTimer);
|
|
2813
|
+
clearTimeout(trickleTimer);
|
|
2814
|
+
element.style.width = "100%";
|
|
2815
|
+
element.dataset.active = "";
|
|
2816
|
+
setTimeout(() => {
|
|
2817
|
+
delete element.dataset.active;
|
|
2818
|
+
element.style.width = "0%";
|
|
2819
|
+
width = 0;
|
|
2820
|
+
}, DONE_LINGER_MS);
|
|
2821
|
+
};
|
|
2822
|
+
return {
|
|
2823
|
+
start,
|
|
2824
|
+
done
|
|
2825
|
+
};
|
|
2826
|
+
}
|
|
2827
|
+
//#endregion
|
|
2828
|
+
//#region src/plugins/spa/router.ts
|
|
2829
|
+
/**
|
|
2830
|
+
* Read the Navigation API global, or `undefined` when unsupported.
|
|
2831
|
+
*
|
|
2832
|
+
* @returns The `navigation` object, or `undefined` in unsupporting environments.
|
|
2833
|
+
* @example
|
|
2834
|
+
* const nav = getNavigation();
|
|
2835
|
+
*/
|
|
2836
|
+
function getNavigation() {
|
|
2837
|
+
return globalThis.navigation;
|
|
2838
|
+
}
|
|
2839
|
+
/** File extensions that bypass the SPA router (treated as static assets). */
|
|
2840
|
+
const STATIC_ASSET_RE = /\.(xml|json|png|jpe?g|pdf|ico|svg|webp|woff2?)$/i;
|
|
2841
|
+
/**
|
|
2842
|
+
* Whether a URL is an internal page link (same origin, not a static asset).
|
|
2843
|
+
*
|
|
2844
|
+
* @param url - The URL to classify.
|
|
2845
|
+
* @returns True when same-origin and not a static asset.
|
|
2846
|
+
* @example
|
|
2847
|
+
* isInternalLink(new URL("https://x.dev/about", location.origin));
|
|
2848
|
+
*/
|
|
2849
|
+
function isInternalLink(url) {
|
|
2850
|
+
return url.origin === location.origin && !STATIC_ASSET_RE.test(url.pathname);
|
|
2851
|
+
}
|
|
2852
|
+
/**
|
|
2853
|
+
* Save the current scroll position keyed by path (best-effort; ignores storage errors).
|
|
2854
|
+
*
|
|
2855
|
+
* @param path - The path to key the scroll position under.
|
|
2856
|
+
* @example
|
|
2857
|
+
* saveScrollPosition("/about");
|
|
2858
|
+
*/
|
|
2859
|
+
function saveScrollPosition(path) {
|
|
2860
|
+
try {
|
|
2861
|
+
sessionStorage.setItem(`spa:scroll:${path}`, String(window.scrollY));
|
|
2862
|
+
} catch {}
|
|
2863
|
+
}
|
|
2864
|
+
/**
|
|
2865
|
+
* Restore a previously-saved scroll position for `path`, if any.
|
|
2866
|
+
*
|
|
2867
|
+
* @param path - The path whose saved scroll position to restore.
|
|
2868
|
+
* @example
|
|
2869
|
+
* restoreScrollPosition("/about");
|
|
2870
|
+
*/
|
|
2871
|
+
function restoreScrollPosition(path) {
|
|
2872
|
+
try {
|
|
2873
|
+
const saved = sessionStorage.getItem(`spa:scroll:${path}`);
|
|
2874
|
+
if (saved) window.scrollTo(0, Number(saved));
|
|
2875
|
+
} catch {}
|
|
2876
|
+
}
|
|
2877
|
+
/**
|
|
2878
|
+
* Fetch a page and hand its HTML to the handlers; on any error fall back to a
|
|
2879
|
+
* full browser navigation (`location.href = pathname`).
|
|
2880
|
+
*
|
|
2881
|
+
* @param pathname - The destination pathname.
|
|
2882
|
+
* @param handlers - The navigation lifecycle callbacks.
|
|
2883
|
+
* @returns A promise that resolves once the swap (or fallback) is dispatched.
|
|
2884
|
+
* @example
|
|
2885
|
+
* await performNavigation("/about", handlers);
|
|
2886
|
+
*/
|
|
2887
|
+
async function performNavigation(pathname, handlers) {
|
|
2888
|
+
handlers.onStart(pathname);
|
|
2889
|
+
try {
|
|
2890
|
+
const response = await fetch(pathname);
|
|
2891
|
+
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
|
2892
|
+
handlers.onEnd(await response.text(), pathname);
|
|
2893
|
+
} catch {
|
|
2894
|
+
handlers.onError();
|
|
2895
|
+
location.href = pathname;
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
/**
|
|
2899
|
+
* Run a DOM-mutating swap, optionally wrapped in the View Transitions API when
|
|
2900
|
+
* enabled and supported (instant swap otherwise — never throws).
|
|
2901
|
+
*
|
|
2902
|
+
* @param doSwap - The synchronous DOM mutation to perform.
|
|
2903
|
+
* @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
|
|
2904
|
+
* @example
|
|
2905
|
+
* runSwap(() => current.replaceWith(next), true);
|
|
2906
|
+
*/
|
|
2907
|
+
function runSwap(doSwap, viewTransitions) {
|
|
2908
|
+
const reduced = typeof globalThis.matchMedia === "function" && globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
2909
|
+
const docWithVt = document;
|
|
2910
|
+
if (viewTransitions && !reduced && typeof docWithVt.startViewTransition === "function") docWithVt.startViewTransition(doSwap);
|
|
2911
|
+
else doSwap();
|
|
2912
|
+
}
|
|
2913
|
+
/**
|
|
2914
|
+
* Replace the `swapSelector` region of the live document with the matching
|
|
2915
|
+
* region of `doc`, wrapped per `viewTransitions`. The `onSwapped` callback runs
|
|
2916
|
+
* inside the same transition frame (after the DOM mutation) so component
|
|
2917
|
+
* re-mounting is captured by the transition snapshot.
|
|
2918
|
+
*
|
|
2919
|
+
* @param doc - The fetched document (DOMParser-parsed) holding the new region.
|
|
2920
|
+
* @param swapSelector - CSS selector for the region to replace.
|
|
2921
|
+
* @param viewTransitions - Whether to wrap the swap in `startViewTransition`.
|
|
2922
|
+
* @param onSwapped - Callback run after the DOM mutation (mount/notify/scroll).
|
|
2923
|
+
* @example
|
|
2924
|
+
* swapRegion(doc, "main > section", false, () => mountNew());
|
|
2925
|
+
*/
|
|
2926
|
+
function swapRegion(doc, swapSelector, viewTransitions, onSwapped) {
|
|
2927
|
+
const newContent = doc.querySelector(swapSelector);
|
|
2928
|
+
const currentContent = document.querySelector(swapSelector);
|
|
2929
|
+
if (!newContent || !currentContent) return;
|
|
2930
|
+
runSwap(() => {
|
|
2931
|
+
currentContent.replaceWith(newContent);
|
|
2932
|
+
onSwapped();
|
|
2933
|
+
}, viewTransitions);
|
|
2934
|
+
}
|
|
2935
|
+
/**
|
|
2936
|
+
* Resolve a navigable internal URL from a click event, or `undefined` when the
|
|
2937
|
+
* click should not be intercepted (modifier keys, non-anchor, external, new-tab).
|
|
2938
|
+
*
|
|
2939
|
+
* @param event - The click event to inspect.
|
|
2940
|
+
* @returns The internal URL to navigate to, or `undefined`.
|
|
2941
|
+
* @example
|
|
2942
|
+
* const url = resolveClickTarget(event);
|
|
2943
|
+
*/
|
|
2944
|
+
function resolveClickTarget(event) {
|
|
2945
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return void 0;
|
|
2946
|
+
if (event.defaultPrevented) return void 0;
|
|
2947
|
+
const anchor = event.target?.closest("a");
|
|
2948
|
+
if (!anchor || anchor.target === "_blank") return void 0;
|
|
2949
|
+
let url;
|
|
2950
|
+
try {
|
|
2951
|
+
url = new URL(anchor.href, location.origin);
|
|
2952
|
+
} catch {
|
|
2953
|
+
return;
|
|
2954
|
+
}
|
|
2955
|
+
return isInternalLink(url) ? url : void 0;
|
|
2956
|
+
}
|
|
2957
|
+
/**
|
|
2958
|
+
* Attach the History-API click/popstate interception path (used when the
|
|
2959
|
+
* Navigation API is unavailable).
|
|
2960
|
+
*
|
|
2961
|
+
* @param handlers - The navigation lifecycle callbacks.
|
|
2962
|
+
* @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
|
|
2963
|
+
* @returns A teardown that removes the attached listeners.
|
|
2964
|
+
* @example
|
|
2965
|
+
* const dispose = attachHistoryFallback(handlers);
|
|
2966
|
+
*/
|
|
2967
|
+
function attachHistoryFallback(handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
|
|
2968
|
+
/**
|
|
2969
|
+
* Intercept an internal-link click and run a History-API navigation.
|
|
2970
|
+
*
|
|
2971
|
+
* @param event - The click event.
|
|
2972
|
+
* @example
|
|
2973
|
+
* document.addEventListener("click", onClick);
|
|
2974
|
+
*/
|
|
2975
|
+
const onClick = (event) => {
|
|
2976
|
+
const url = resolveClickTarget(event);
|
|
2977
|
+
if (!url) return;
|
|
2978
|
+
event.preventDefault();
|
|
2979
|
+
if (url.pathname === location.pathname) {
|
|
2980
|
+
window.scrollTo({
|
|
2981
|
+
top: 0,
|
|
2982
|
+
behavior: "smooth"
|
|
2983
|
+
});
|
|
2984
|
+
return;
|
|
2985
|
+
}
|
|
2986
|
+
saveScrollPosition(location.pathname);
|
|
2987
|
+
history.pushState({ scrollY: 0 }, "", url.pathname);
|
|
2988
|
+
navigate(url.pathname).then(() => window.scrollTo(0, 0)).catch(() => {});
|
|
2989
|
+
};
|
|
2990
|
+
/**
|
|
2991
|
+
* Re-run navigation on back/forward, restoring the saved scroll position.
|
|
2992
|
+
*
|
|
2993
|
+
* @example
|
|
2994
|
+
* globalThis.addEventListener("popstate", onPopState);
|
|
2995
|
+
*/
|
|
2996
|
+
const onPopState = () => {
|
|
2997
|
+
navigate(location.pathname).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
|
|
2998
|
+
};
|
|
2999
|
+
document.addEventListener("click", onClick);
|
|
3000
|
+
globalThis.addEventListener("popstate", onPopState);
|
|
3001
|
+
return () => {
|
|
3002
|
+
document.removeEventListener("click", onClick);
|
|
3003
|
+
globalThis.removeEventListener("popstate", onPopState);
|
|
3004
|
+
};
|
|
3005
|
+
}
|
|
3006
|
+
/**
|
|
3007
|
+
* Attach the Navigation-API interception path (the primary path when supported).
|
|
3008
|
+
*
|
|
3009
|
+
* @param navigation - The Navigation API object to attach the listener to.
|
|
3010
|
+
* @param handlers - The navigation lifecycle callbacks.
|
|
3011
|
+
* @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
|
|
3012
|
+
* @returns A teardown that removes the `navigate` listener.
|
|
3013
|
+
* @example
|
|
3014
|
+
* const dispose = attachNavigationApi(navigation, handlers);
|
|
3015
|
+
*/
|
|
3016
|
+
function attachNavigationApi(navigation, handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
|
|
3017
|
+
/**
|
|
3018
|
+
* Handle a `navigate` event: classify, then intercept with fetch-and-swap.
|
|
3019
|
+
*
|
|
3020
|
+
* @param navEvent - The Navigation API navigate event.
|
|
3021
|
+
* @example
|
|
3022
|
+
* navigation.addEventListener("navigate", onNavigate);
|
|
3023
|
+
*/
|
|
3024
|
+
const onNavigate = (navEvent) => {
|
|
3025
|
+
const url = new URL(navEvent.destination.url);
|
|
3026
|
+
if (!navEvent.canIntercept || navEvent.hashChange || navEvent.downloadRequest) return;
|
|
3027
|
+
if (!isInternalLink(url)) return;
|
|
3028
|
+
if (url.pathname === location.pathname) {
|
|
3029
|
+
navEvent.intercept({ handler: () => {
|
|
3030
|
+
window.scrollTo({
|
|
3031
|
+
top: 0,
|
|
3032
|
+
behavior: "smooth"
|
|
3033
|
+
});
|
|
3034
|
+
return Promise.resolve();
|
|
3035
|
+
} });
|
|
3036
|
+
return;
|
|
3037
|
+
}
|
|
3038
|
+
navEvent.intercept({
|
|
3039
|
+
scroll: "manual",
|
|
3040
|
+
handler: async () => {
|
|
3041
|
+
await navigate(url.pathname);
|
|
3042
|
+
if (navEvent.navigationType === "traverse") navEvent.scroll();
|
|
3043
|
+
else window.scrollTo(0, 0);
|
|
3044
|
+
}
|
|
3045
|
+
});
|
|
3046
|
+
};
|
|
3047
|
+
navigation.addEventListener("navigate", onNavigate);
|
|
3048
|
+
return () => navigation.removeEventListener("navigate", onNavigate);
|
|
3049
|
+
}
|
|
3050
|
+
/**
|
|
3051
|
+
* Attach navigation interception: Navigation API (primary) with a History API
|
|
3052
|
+
* fallback. Returns a teardown removing every listener it attached.
|
|
3053
|
+
*
|
|
3054
|
+
* @param handlers - The navigation lifecycle callbacks the kernel supplies.
|
|
3055
|
+
* @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
|
|
3056
|
+
* @returns A teardown removing all attached listeners.
|
|
3057
|
+
* @example
|
|
3058
|
+
* const dispose = attachRouter(handlers, navigate);
|
|
3059
|
+
*/
|
|
3060
|
+
function attachRouter(handlers, navigate) {
|
|
3061
|
+
const navigation = getNavigation();
|
|
3062
|
+
return navigation ? attachNavigationApi(navigation, handlers, navigate) : attachHistoryFallback(handlers, navigate);
|
|
3063
|
+
}
|
|
3064
|
+
//#endregion
|
|
3065
|
+
//#region src/plugins/spa/state.ts
|
|
3066
|
+
/** Error prefix for spa config-validation failures (spec/11 Part-3). */
|
|
3067
|
+
const ERROR_PREFIX$1 = "[web]";
|
|
3068
|
+
/** Default SPA config (declared as a value — no inline assertion). */
|
|
3069
|
+
const defaultSpaConfig = {
|
|
3070
|
+
swapSelector: "main > section",
|
|
3071
|
+
viewTransitions: false,
|
|
3072
|
+
progressBar: true,
|
|
3073
|
+
components: []
|
|
3074
|
+
};
|
|
3075
|
+
/**
|
|
3076
|
+
* Whether a selector is syntactically valid (parseable by the DOM). Falls back
|
|
3077
|
+
* to a permissive `true` in headless contexts without `document`.
|
|
3078
|
+
*
|
|
3079
|
+
* @param selector - The CSS selector to validate.
|
|
3080
|
+
* @returns True when the selector parses (or no DOM is available to check).
|
|
3081
|
+
* @example
|
|
3082
|
+
* isValidSelector("main > section"); // true
|
|
3083
|
+
*/
|
|
3084
|
+
function isValidSelector(selector) {
|
|
3085
|
+
if (typeof document === "undefined") return true;
|
|
3086
|
+
try {
|
|
3087
|
+
document.querySelector(selector);
|
|
3088
|
+
return true;
|
|
3089
|
+
} catch {
|
|
3090
|
+
return false;
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
/**
|
|
3094
|
+
* Validates the spa config and applies defaults (Part-3 errors on an empty or
|
|
3095
|
+
* syntactically-invalid `swapSelector`). Component-hook validation runs later in
|
|
3096
|
+
* `createComponent` when the components are registered.
|
|
3097
|
+
*
|
|
3098
|
+
* @param config - The raw spa config to validate.
|
|
3099
|
+
* @returns The fully-resolved config with defaults applied.
|
|
3100
|
+
* @throws {Error} When `swapSelector` is empty or not a valid CSS selector.
|
|
3101
|
+
* @example
|
|
3102
|
+
* const resolved = resolveSpaConfig({ swapSelector: "main > section" });
|
|
3103
|
+
*/
|
|
3104
|
+
function resolveSpaConfig(config) {
|
|
3105
|
+
const swapSelector = config.swapSelector ?? defaultSpaConfig.swapSelector ?? "main > section";
|
|
3106
|
+
if (swapSelector.trim() === "") throw new Error(`${ERROR_PREFIX$1} spa.swapSelector must be a non-empty string.\n Set a CSS selector for the page region to swap (e.g. "main > section").`);
|
|
3107
|
+
if (!isValidSelector(swapSelector)) throw new Error(`${ERROR_PREFIX$1} spa.swapSelector is not a valid CSS selector: "${swapSelector}".\n Provide a syntactically valid selector.`);
|
|
3108
|
+
return {
|
|
3109
|
+
swapSelector,
|
|
3110
|
+
viewTransitions: config.viewTransitions ?? false,
|
|
3111
|
+
progressBar: config.progressBar ?? true,
|
|
3112
|
+
components: config.components ?? []
|
|
3113
|
+
};
|
|
3114
|
+
}
|
|
3115
|
+
/**
|
|
3116
|
+
* Creates initial spa plugin state. All kernel state lives here — never module
|
|
3117
|
+
* scope. The kernel itself is built in onInit and stored as `kernel`, so
|
|
3118
|
+
* api/onStart/onStop all reuse the single shared instance.
|
|
3119
|
+
*
|
|
3120
|
+
* @param _ctx - Minimal context with global and config.
|
|
3121
|
+
* @param _ctx.global - Global plugin registry.
|
|
3122
|
+
* @param _ctx.config - Resolved plugin configuration.
|
|
3123
|
+
* @returns The initial SPA state with an empty kernel slot.
|
|
3124
|
+
* @example
|
|
3125
|
+
* const state = createState({ global: {}, config: defaultSpaConfig });
|
|
3126
|
+
*/
|
|
3127
|
+
function createState(_ctx) {
|
|
3128
|
+
return {
|
|
3129
|
+
registeredComponents: /* @__PURE__ */ new Map(),
|
|
3130
|
+
instances: /* @__PURE__ */ new Map(),
|
|
3131
|
+
currentUrl: "",
|
|
3132
|
+
destroyRouter: null,
|
|
3133
|
+
started: false,
|
|
3134
|
+
kernel: null
|
|
3135
|
+
};
|
|
3136
|
+
}
|
|
3137
|
+
//#endregion
|
|
3138
|
+
//#region src/plugins/spa/kernel.ts
|
|
3139
|
+
/**
|
|
3140
|
+
* @file spa plugin — pure SPA kernel factory + onInit wiring helper.
|
|
3141
|
+
*
|
|
3142
|
+
* `createSpaKernel(state, config, emit, deps)` is a PURE factory: it closes over
|
|
3143
|
+
* the injected state/config/emit/deps only — never the Moku ctx, never module
|
|
3144
|
+
* singletons. It is unit-testable with a mock state object and a spy emit.
|
|
3145
|
+
* @see README.md
|
|
3146
|
+
*/
|
|
3147
|
+
/** Error prefix for spa kernel failures (spec/11 Part-3). */
|
|
3148
|
+
const ERROR_PREFIX = "[web]";
|
|
3149
|
+
/**
|
|
3150
|
+
* Module-scope holder for the active SPA kernel. `onStop` receives the minimal
|
|
3151
|
+
* teardown context (no `state`/`require`), so the kernel built during `onInit`
|
|
3152
|
+
* is parked here for disposal. Single-app-per-process by design (spec/08 §4).
|
|
3153
|
+
*
|
|
3154
|
+
* @example
|
|
3155
|
+
* kernelRef.current = createSpaKernel(state, config, emit, deps);
|
|
3156
|
+
*/
|
|
3157
|
+
const kernelRef = {};
|
|
3158
|
+
/**
|
|
3159
|
+
* Registers a component definition into state (last-registered-wins).
|
|
3160
|
+
*
|
|
3161
|
+
* @param state - The plugin state holding registeredComponents.
|
|
3162
|
+
* @param component - The component definition to register.
|
|
3163
|
+
* @example
|
|
3164
|
+
* registerComponent(state, counter);
|
|
3165
|
+
*/
|
|
3166
|
+
function registerComponent(state, component) {
|
|
3167
|
+
state.registeredComponents.set(component.name, component);
|
|
3168
|
+
}
|
|
3169
|
+
/**
|
|
3170
|
+
* Resolve the current document URL (pathname + search), or `""` when headless.
|
|
3171
|
+
*
|
|
3172
|
+
* @returns The current URL string.
|
|
3173
|
+
* @example
|
|
3174
|
+
* const url = currentLocationUrl();
|
|
3175
|
+
*/
|
|
3176
|
+
function currentLocationUrl() {
|
|
3177
|
+
if (typeof document === "undefined") return "";
|
|
3178
|
+
return location.pathname + location.search;
|
|
3179
|
+
}
|
|
3180
|
+
/**
|
|
3181
|
+
* Apply the matched route's `head` config to the live document (minimal client
|
|
3182
|
+
* head-sync for the DATA path: title only — the full meta sync runs on the
|
|
3183
|
+
* HTML-over-fetch path from the fetched `<head>`).
|
|
3184
|
+
*
|
|
3185
|
+
* @param route - The matched route definition.
|
|
3186
|
+
* @param routeContext - The render context (params/data/locale).
|
|
3187
|
+
* @example
|
|
3188
|
+
* syncDataHead(hit.route, { params, data, locale });
|
|
3189
|
+
*/
|
|
3190
|
+
function syncDataHead(route, routeContext) {
|
|
3191
|
+
const title = route._handlers.head?.(routeContext)?.title;
|
|
3192
|
+
if (title !== void 0 && title !== "") document.title = title;
|
|
3193
|
+
}
|
|
3194
|
+
/**
|
|
3195
|
+
* Builds the single shared SPA kernel — a pure factory over state/config/emit.
|
|
3196
|
+
* Unit-testable with a mock state object and a spy emit; no Moku ctx involved.
|
|
3197
|
+
*
|
|
3198
|
+
* @param state - The plugin state (all kernel data lives here).
|
|
3199
|
+
* @param config - The raw spa config (defaults resolved internally on init).
|
|
3200
|
+
* @param emit - The event emitter for spa lifecycle events.
|
|
3201
|
+
* @param deps - Resolved router + head APIs reused by the kernel.
|
|
3202
|
+
* @returns The single shared {@link SpaKernel}.
|
|
3203
|
+
* @example
|
|
3204
|
+
* const kernel = createSpaKernel(state, config, emit, { router, head });
|
|
3205
|
+
*/
|
|
3206
|
+
function createSpaKernel(state, config, emit, deps) {
|
|
3207
|
+
const resolved = resolveSpaConfig(config);
|
|
3208
|
+
let progress;
|
|
3209
|
+
/**
|
|
3210
|
+
* Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
|
|
3211
|
+
*
|
|
3212
|
+
* @param html - The fetched page HTML.
|
|
3213
|
+
* @param pathname - The destination pathname.
|
|
3214
|
+
* @example
|
|
3215
|
+
* handleEnd("<html>…</html>", "/about");
|
|
3216
|
+
*/
|
|
3217
|
+
const handleEnd = (html, pathname) => {
|
|
3218
|
+
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
3219
|
+
syncHead(deps.head, doc);
|
|
3220
|
+
unmountPageSpecific(state, emit);
|
|
3221
|
+
swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
|
|
3222
|
+
scanAndMount(state, emit, resolved.swapSelector);
|
|
3223
|
+
notifyNavEnd(state);
|
|
3224
|
+
});
|
|
3225
|
+
state.currentUrl = pathname;
|
|
3226
|
+
progress?.done();
|
|
3227
|
+
emit("spa:navigated", { url: pathname });
|
|
3228
|
+
};
|
|
3229
|
+
/**
|
|
3230
|
+
* Begin a navigation: start progress, notify components, emit navigate.
|
|
3231
|
+
*
|
|
3232
|
+
* @param pathname - The destination pathname.
|
|
3233
|
+
* @example
|
|
3234
|
+
* handleStart("/about");
|
|
3235
|
+
*/
|
|
3236
|
+
const handleStart = (pathname) => {
|
|
3237
|
+
progress?.start();
|
|
3238
|
+
notifyNavStart(state);
|
|
3239
|
+
emit("spa:navigate", {
|
|
3240
|
+
from: state.currentUrl,
|
|
3241
|
+
to: pathname
|
|
3242
|
+
});
|
|
3243
|
+
};
|
|
3244
|
+
/**
|
|
3245
|
+
* Finish the progress bar after a failed navigation (full-reload fallback).
|
|
3246
|
+
*
|
|
3247
|
+
* @example
|
|
3248
|
+
* handleError();
|
|
3249
|
+
*/
|
|
3250
|
+
const handleError = () => {
|
|
3251
|
+
progress?.done();
|
|
3252
|
+
};
|
|
3253
|
+
const handlers = {
|
|
3254
|
+
onStart: handleStart,
|
|
3255
|
+
onEnd: handleEnd,
|
|
3256
|
+
onError: handleError
|
|
3257
|
+
};
|
|
3258
|
+
/**
|
|
3259
|
+
* The client DATA path: match `pathname`, fetch the page's PERSISTED data via the
|
|
3260
|
+
* `data` reader, VALIDATE it through the route's `parse` gate, then run the
|
|
3261
|
+
* route's OWN `render` (the same component the build used for SSG) and
|
|
3262
|
+
* Preact-render the VNode into the swap region. Returns `false` (touching nothing
|
|
3263
|
+
* the fallback cares about) on no-match / no-render / no-data / fetch-miss /
|
|
3264
|
+
* parse-throw, so the caller falls back to HTML-over-fetch. `route.load` does NOT
|
|
3265
|
+
* run on the client — the build already persisted its output.
|
|
3266
|
+
*
|
|
3267
|
+
* @param pathname - The destination pathname (search stripped for matching).
|
|
3268
|
+
* @returns `true` if the route was rendered from validated data, else `false`.
|
|
3269
|
+
* @example
|
|
3270
|
+
* if (await tryDataRender("/en/world/")) return;
|
|
3271
|
+
*/
|
|
3272
|
+
const tryDataRender = async (pathname) => {
|
|
3273
|
+
if (!deps.dataAt) return false;
|
|
3274
|
+
const matchPath = pathname.split("?")[0] ?? pathname;
|
|
3275
|
+
const hit = deps.router.match(matchPath);
|
|
3276
|
+
if (!hit?.route._handlers.render) return false;
|
|
3277
|
+
try {
|
|
3278
|
+
const raw = await deps.dataAt(pathname);
|
|
3279
|
+
if (raw === null) return false;
|
|
3280
|
+
const data = hit.route._handlers.parse ? hit.route._handlers.parse(raw) : raw;
|
|
3281
|
+
const locale = hit.params.lang ?? document.documentElement.lang ?? "";
|
|
3282
|
+
const routeContext = {
|
|
3283
|
+
params: hit.params,
|
|
3284
|
+
data,
|
|
3285
|
+
locale
|
|
3286
|
+
};
|
|
3287
|
+
const vnode = hit.route._handlers.render(routeContext);
|
|
3288
|
+
const region = document.querySelector(resolved.swapSelector);
|
|
3289
|
+
if (!region) return false;
|
|
3290
|
+
handleStart(pathname);
|
|
3291
|
+
const { renderVNode } = await import("./render-BL9Fv6G6.mjs");
|
|
3292
|
+
syncDataHead(hit.route, routeContext);
|
|
3293
|
+
unmountPageSpecific(state, emit);
|
|
3294
|
+
runSwap(() => {
|
|
3295
|
+
region.replaceChildren();
|
|
3296
|
+
renderVNode(vnode, region);
|
|
3297
|
+
scanAndMount(state, emit, resolved.swapSelector);
|
|
3298
|
+
notifyNavEnd(state);
|
|
3299
|
+
}, resolved.viewTransitions);
|
|
3300
|
+
state.currentUrl = pathname;
|
|
3301
|
+
progress?.done();
|
|
3302
|
+
emit("spa:navigated", { url: pathname });
|
|
3303
|
+
return true;
|
|
3304
|
+
} catch {
|
|
3305
|
+
return false;
|
|
3306
|
+
}
|
|
3307
|
+
};
|
|
3308
|
+
/**
|
|
3309
|
+
* Unified navigation: try the client DATA path first (only when the `data`
|
|
3310
|
+
* plugin is composed), then fall back to HTML-over-fetch (which itself falls
|
|
3311
|
+
* back to a full `location.href` reload). Injected into the router so every
|
|
3312
|
+
* navigation entry point (Navigation API, History, programmatic) goes through it.
|
|
3313
|
+
*
|
|
3314
|
+
* @param pathname - The destination pathname.
|
|
3315
|
+
* @returns A promise resolving once the swap (or fallback) is dispatched.
|
|
3316
|
+
* @example
|
|
3317
|
+
* await navigate("/en/world/");
|
|
3318
|
+
*/
|
|
3319
|
+
const navigate = async (pathname) => {
|
|
3320
|
+
if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
|
|
3321
|
+
await performNavigation(pathname, handlers);
|
|
3322
|
+
};
|
|
3323
|
+
return {
|
|
3324
|
+
/**
|
|
3325
|
+
* Register config components and seed currentUrl from the document.
|
|
3326
|
+
*
|
|
3327
|
+
* @example
|
|
3328
|
+
* kernel.init();
|
|
3329
|
+
*/
|
|
3330
|
+
init() {
|
|
3331
|
+
for (const component of resolved.components) registerComponent(state, component);
|
|
3332
|
+
state.currentUrl = currentLocationUrl();
|
|
3333
|
+
},
|
|
3334
|
+
/**
|
|
3335
|
+
* Boot navigation interception + initial scan (throws if already started).
|
|
3336
|
+
*
|
|
3337
|
+
* @example
|
|
3338
|
+
* kernel.boot();
|
|
3339
|
+
*/
|
|
3340
|
+
boot() {
|
|
3341
|
+
if (typeof document === "undefined") return;
|
|
3342
|
+
if (state.started) throw new Error(`${ERROR_PREFIX} spa kernel already started.\n Call app.stop() before booting again (single boot per app).`);
|
|
3343
|
+
progress = createProgressBar(resolved.progressBar);
|
|
3344
|
+
state.currentUrl = currentLocationUrl();
|
|
3345
|
+
state.destroyRouter = attachRouter(handlers, navigate);
|
|
3346
|
+
scanAndMount(state, emit, resolved.swapSelector);
|
|
3347
|
+
state.started = true;
|
|
3348
|
+
},
|
|
3349
|
+
/**
|
|
3350
|
+
* Register a component definition (last-registered-wins).
|
|
3351
|
+
*
|
|
3352
|
+
* @param component - The component definition to register.
|
|
3353
|
+
* @example
|
|
3354
|
+
* kernel.register(counter);
|
|
3355
|
+
*/
|
|
3356
|
+
register(component) {
|
|
3357
|
+
registerComponent(state, component);
|
|
3358
|
+
},
|
|
3359
|
+
/**
|
|
3360
|
+
* Process a navigation to `path` (fetch then swap; full reload on error).
|
|
3361
|
+
*
|
|
3362
|
+
* @param path - The target path to navigate to.
|
|
3363
|
+
* @example
|
|
3364
|
+
* kernel.processNav("/about");
|
|
3365
|
+
*/
|
|
3366
|
+
processNav(path) {
|
|
3367
|
+
if (typeof document === "undefined") return;
|
|
3368
|
+
navigate(path).catch(() => {});
|
|
3369
|
+
},
|
|
3370
|
+
/**
|
|
3371
|
+
* Scan the swap region and mount components for matching elements.
|
|
3372
|
+
*
|
|
3373
|
+
* @example
|
|
3374
|
+
* kernel.scan();
|
|
3375
|
+
*/
|
|
3376
|
+
scan() {
|
|
3377
|
+
scanAndMount(state, emit, resolved.swapSelector);
|
|
3378
|
+
},
|
|
3379
|
+
/**
|
|
3380
|
+
* Tear down router listeners, dispose all instances, reset boot state.
|
|
3381
|
+
*
|
|
3382
|
+
* @example
|
|
3383
|
+
* kernel.dispose();
|
|
3384
|
+
*/
|
|
3385
|
+
dispose() {
|
|
3386
|
+
state.destroyRouter?.();
|
|
3387
|
+
state.destroyRouter = null;
|
|
3388
|
+
unmountAll(state, emit);
|
|
3389
|
+
progress = void 0;
|
|
3390
|
+
state.started = false;
|
|
3391
|
+
}
|
|
3392
|
+
};
|
|
3393
|
+
}
|
|
3394
|
+
/**
|
|
3395
|
+
* Structural by-name handle for the OPTIONAL `data` plugin. `ctx.require` resolves
|
|
3396
|
+
* a plugin by its `name` at runtime, so this lets `spa` obtain the `data` reader
|
|
3397
|
+
* WITHOUT importing the `data` plugin or its types — keeping `spa` decoupled and
|
|
3398
|
+
* its `depends` at `[router, head]`. The phantom types only the `at` slice it uses.
|
|
3399
|
+
*/
|
|
3400
|
+
const dataPluginHandle = {
|
|
3401
|
+
name: "data",
|
|
3402
|
+
spec: void 0,
|
|
3403
|
+
_phantom: {
|
|
3404
|
+
config: void 0,
|
|
3405
|
+
state: void 0,
|
|
3406
|
+
api: void 0,
|
|
3407
|
+
events: {}
|
|
3408
|
+
}
|
|
3409
|
+
};
|
|
3410
|
+
/**
|
|
3411
|
+
* Builds the shared kernel from the plugin context, stores it on `ctx.state`
|
|
3412
|
+
* and `kernelRef`, and runs its init step (validate config, register
|
|
3413
|
+
* config.components, seed currentUrl). Captures the OPTIONAL `data` reader when
|
|
3414
|
+
* the `data` plugin is composed (enabling client DATA navigation).
|
|
3415
|
+
*
|
|
3416
|
+
* @param ctx - The plugin context (state/config/emit/require/has/log).
|
|
3417
|
+
* @example
|
|
3418
|
+
* initSpa(ctx);
|
|
3419
|
+
*/
|
|
3420
|
+
function initSpa(ctx) {
|
|
3421
|
+
const deps = {
|
|
3422
|
+
router: ctx.require(routerPlugin),
|
|
3423
|
+
head: ctx.require(headPlugin)
|
|
3424
|
+
};
|
|
3425
|
+
if (ctx.has("data")) {
|
|
3426
|
+
const reader = ctx.require(dataPluginHandle);
|
|
3427
|
+
deps.dataAt = (path) => reader.at(path);
|
|
3428
|
+
}
|
|
3429
|
+
const kernel = createSpaKernel(ctx.state, ctx.config, ctx.emit, deps);
|
|
3430
|
+
ctx.state.kernel = kernel;
|
|
3431
|
+
kernelRef.current = kernel;
|
|
3432
|
+
kernel.init();
|
|
3433
|
+
}
|
|
3434
|
+
//#endregion
|
|
3435
|
+
//#region src/plugins/spa/lifecycle.ts
|
|
3436
|
+
/** Router/instance teardown captured during onStart (undefined when stopped). */
|
|
3437
|
+
let teardown;
|
|
3438
|
+
/** Captured log ref — onStop has no `ctx.log` (spec/08 §4). */
|
|
3439
|
+
let logRef;
|
|
3440
|
+
/**
|
|
3441
|
+
* Dispose the active kernel (captured as the teardown closure during onStart).
|
|
3442
|
+
*
|
|
3443
|
+
* @example
|
|
3444
|
+
* disposeKernel();
|
|
3445
|
+
*/
|
|
3446
|
+
function disposeKernel() {
|
|
3447
|
+
kernelRef.current?.dispose();
|
|
3448
|
+
}
|
|
3449
|
+
/**
|
|
3450
|
+
* Capture the teardown + log handles during `onStart` (no-op without a DOM —
|
|
3451
|
+
* the SSR/build guard, so onStop has nothing to release). The kernel itself is
|
|
3452
|
+
* booted by index.ts after this capture.
|
|
3453
|
+
*
|
|
3454
|
+
* @param ctx - The plugin context (used for `log` capture).
|
|
3455
|
+
* @example
|
|
3456
|
+
* captureTeardown(ctx);
|
|
3457
|
+
*/
|
|
3458
|
+
function captureTeardown(ctx) {
|
|
3459
|
+
if (typeof document === "undefined") return;
|
|
3460
|
+
logRef = ctx.log;
|
|
3461
|
+
teardown = disposeKernel;
|
|
3462
|
+
}
|
|
3463
|
+
/**
|
|
3464
|
+
* Release everything `captureTeardown`/`onStart` acquired: run teardown in
|
|
3465
|
+
* try/catch (logging via the captured ref), then clear both handles. Idempotent —
|
|
3466
|
+
* a second call is a no-op (spec/11 §4.2) and mirrors `onStart` (§4.1).
|
|
3467
|
+
*
|
|
3468
|
+
* @example
|
|
3469
|
+
* disposeSpa();
|
|
3470
|
+
*/
|
|
3471
|
+
function disposeSpa() {
|
|
3472
|
+
try {
|
|
3473
|
+
teardown?.();
|
|
3474
|
+
} catch (error) {
|
|
3475
|
+
logRef?.error("spa:teardown-failed", {}, error);
|
|
3476
|
+
} finally {
|
|
3477
|
+
teardown = void 0;
|
|
3478
|
+
logRef = void 0;
|
|
3479
|
+
}
|
|
3480
|
+
}
|
|
3481
|
+
//#endregion
|
|
3482
|
+
//#region src/plugins/spa/index.ts
|
|
3483
|
+
/**
|
|
3484
|
+
* @file spa — Complex Plugin (WIRING ONLY, ≤30 lines). All logic lives in the
|
|
3485
|
+
* domain files (kernel/router/head/progress/components/lifecycle); index wires.
|
|
3486
|
+
*
|
|
3487
|
+
* Depends: router, head.
|
|
3488
|
+
* Emits: spa:navigate, spa:navigated, spa:component-mount, spa:component-unmount.
|
|
3489
|
+
* @see README.md
|
|
3490
|
+
*/
|
|
3491
|
+
/**
|
|
3492
|
+
* SPA plugin — progressive client-side navigation layered over the static site:
|
|
3493
|
+
* swaps a page region on navigation, with an optional progress bar and View
|
|
3494
|
+
* Transitions. Register interactive islands with {@link createComponent}. Depends
|
|
3495
|
+
* on router and head; emits `spa:navigate`, `spa:navigated`, `spa:component-mount`,
|
|
3496
|
+
* and `spa:component-unmount`.
|
|
3497
|
+
*
|
|
3498
|
+
* @example Enable view transitions and a custom swap region
|
|
3499
|
+
* ```ts
|
|
3500
|
+
* const app = createApp({
|
|
3501
|
+
* pluginConfigs: {
|
|
3502
|
+
* spa: {
|
|
3503
|
+
* swapSelector: "main > section",
|
|
3504
|
+
* viewTransitions: true,
|
|
3505
|
+
* progressBar: true
|
|
3506
|
+
* }
|
|
3507
|
+
* }
|
|
3508
|
+
* });
|
|
3509
|
+
* ```
|
|
3510
|
+
*/
|
|
3511
|
+
const spaPlugin = createPlugin$1("spa", {
|
|
3512
|
+
depends: [routerPlugin, headPlugin],
|
|
3513
|
+
config: defaultSpaConfig,
|
|
3514
|
+
createState,
|
|
3515
|
+
events: spaEvents,
|
|
3516
|
+
onInit: initSpa,
|
|
3517
|
+
api: createApi,
|
|
3518
|
+
onStart(ctx) {
|
|
3519
|
+
captureTeardown(ctx);
|
|
3520
|
+
kernelRef.current?.boot();
|
|
3521
|
+
},
|
|
3522
|
+
onStop: disposeSpa
|
|
3523
|
+
});
|
|
3524
|
+
//#endregion
|
|
3525
|
+
//#region src/plugins/data/load-json.ts
|
|
3526
|
+
/**
|
|
3527
|
+
* @file `loadJson` — the data plugin's isomorphic JSON read primitive (the
|
|
3528
|
+
* SSG↔SPA seam). Internal to the `data` plugin (NOT a framework-root export):
|
|
3529
|
+
* `data.load(locale)` uses it, and consumers read through `app.data.load(locale)`.
|
|
3530
|
+
*
|
|
3531
|
+
* A read runs in BOTH worlds: on Node it reads the emitted data file from disk;
|
|
3532
|
+
* on the client (browser) it fetches the same data over HTTP. `loadJson` is the
|
|
3533
|
+
* single point where those two worlds differ — everything above it (the route's
|
|
3534
|
+
* `load`/`render`) is shared, so SSR/client parity is structural, not hoped-for.
|
|
3535
|
+
*
|
|
3536
|
+
* The browser path uses the `fetch` global. The Node path lazy-imports
|
|
3537
|
+
* `node:fs/promises` via `await import(...)`, so a browser bundle that includes
|
|
3538
|
+
* `loadJson` never statically pulls `node:*` (the bundler splits the Node branch
|
|
3539
|
+
* into its own chunk that the browser never loads).
|
|
3540
|
+
*/
|
|
3541
|
+
/**
|
|
3542
|
+
* Read + parse a JSON resource, isomorphically. In a browser (`document`
|
|
3543
|
+
* defined) it `fetch`es `pathOrUrl`; on Node it reads the file from disk. Throws
|
|
3544
|
+
* on a failed fetch or unreadable file so the caller (`route.load`/`data.load`)
|
|
3545
|
+
* can decide whether to fall back.
|
|
3546
|
+
*
|
|
3547
|
+
* @template T - The expected shape of the parsed JSON.
|
|
3548
|
+
* @param pathOrUrl - A site-root URL (browser) or filesystem path (Node).
|
|
3549
|
+
* @returns The parsed JSON, typed as `T`.
|
|
3550
|
+
* @throws {Error} If the browser fetch is not OK, or the Node file read fails.
|
|
3551
|
+
* @example
|
|
3552
|
+
* ```ts
|
|
3553
|
+
* // Browser: fetch("/_data/en/articles.json")
|
|
3554
|
+
* // Node: read "dist/_data/en/articles.json"
|
|
3555
|
+
* const articles = await loadJson<Article[]>("/_data/en/articles.json");
|
|
3556
|
+
* ```
|
|
3557
|
+
*/
|
|
3558
|
+
async function loadJson(pathOrUrl) {
|
|
3559
|
+
if (typeof document === "undefined") {
|
|
3560
|
+
const { readFile } = await import("node:fs/promises");
|
|
3561
|
+
return JSON.parse(await readFile(pathOrUrl, "utf8"));
|
|
3562
|
+
}
|
|
3563
|
+
const response = await fetch(pathOrUrl);
|
|
3564
|
+
if (!response.ok) throw new Error(`[web] loadJson: failed to fetch ${pathOrUrl} (${String(response.status)}).`);
|
|
3565
|
+
return response.json();
|
|
3566
|
+
}
|
|
3567
|
+
//#endregion
|
|
3568
|
+
//#region src/plugins/data/api.ts
|
|
3569
|
+
/**
|
|
3570
|
+
* @file data plugin — API factory (the agnostic data provider surface).
|
|
3571
|
+
*
|
|
3572
|
+
* Node-free by construction: this module statically imports only types + the pure
|
|
3573
|
+
* convention. The Node write side (`write()`) reaches its `node:fs` writer through
|
|
3574
|
+
* a lazy `await import("./writer")` at call time, so a browser bundle that composes
|
|
3575
|
+
* `data` for the read side never pulls `node:*`. The read side (`at()`) uses only
|
|
3576
|
+
* the isomorphic `loadJson` (whose Node branch is itself lazy).
|
|
3577
|
+
*/
|
|
3578
|
+
/**
|
|
3579
|
+
* Trim a single trailing slash from a config dir so `fileFor` joins cleanly.
|
|
3580
|
+
*
|
|
3581
|
+
* @param dir - The configured output dir (e.g. `"_data"` or `"_data/"`).
|
|
3582
|
+
* @returns The dir without a trailing slash.
|
|
3583
|
+
* @example
|
|
3584
|
+
* ```ts
|
|
3585
|
+
* trimTrailingSlash("_data/"); // "_data"
|
|
3586
|
+
* ```
|
|
3587
|
+
*/
|
|
3588
|
+
function trimTrailingSlash(dir) {
|
|
3589
|
+
return dir.endsWith("/") ? dir.slice(0, -1) : dir;
|
|
3590
|
+
}
|
|
3591
|
+
/**
|
|
3592
|
+
* Builds the data provider — the agnostic bridge. `write()` is the Node persist
|
|
3593
|
+
* side; `at()` is the browser read side; `urlFor`/`fileFor` are the pure
|
|
3594
|
+
* convention. No `onStart`/`onStop` (holds no long-lived resource).
|
|
3595
|
+
*
|
|
3596
|
+
* @param ctx - The data plugin context.
|
|
3597
|
+
* @returns The {@link DataProvider} mounted at `app.data`.
|
|
3598
|
+
* @example
|
|
3599
|
+
* ```ts
|
|
3600
|
+
* const api = dataApi(ctx);
|
|
3601
|
+
* await api.write([{ path: "/en/hello/", data: article }]); // Node build
|
|
3602
|
+
* await api.at("/en/hello/"); // browser
|
|
3603
|
+
* ```
|
|
3604
|
+
*/
|
|
3605
|
+
function dataApi(ctx) {
|
|
3606
|
+
return {
|
|
3607
|
+
/**
|
|
3608
|
+
* READ (browser) — fetch (and cache) the persisted data for a page path.
|
|
3609
|
+
* Returns the raw JSON as `unknown` (the caller's `route.parse` validates it),
|
|
3610
|
+
* or `null` if the fetch/parse fails (so `spa` can fall back to HTML).
|
|
3611
|
+
*
|
|
3612
|
+
* @param path - The page URL path (e.g. `/en/hello/`).
|
|
3613
|
+
* @returns The page's raw data, or `null` on failure.
|
|
3614
|
+
* @example
|
|
3615
|
+
* ```ts
|
|
3616
|
+
* const raw = await api.at("/en/hello/");
|
|
3617
|
+
* ```
|
|
3618
|
+
*/
|
|
3619
|
+
async at(path) {
|
|
3620
|
+
if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
|
|
3621
|
+
try {
|
|
3622
|
+
const data = await loadJson(`${ctx.config.baseUrl}${dataSuffix(path)}`);
|
|
3623
|
+
ctx.state.cache.set(path, data);
|
|
3624
|
+
return data;
|
|
3625
|
+
} catch {
|
|
3626
|
+
return null;
|
|
3627
|
+
}
|
|
3628
|
+
},
|
|
3629
|
+
/**
|
|
3630
|
+
* WRITE (Node) — persist one JSON file per entry, keyed by page path. Called by
|
|
3631
|
+
* `build` after it expands routes. Lazily loads its `node:fs` writer (keeping a
|
|
3632
|
+
* browser bundle node-free).
|
|
3633
|
+
*
|
|
3634
|
+
* @param entries - The per-page data to persist.
|
|
3635
|
+
* @param options - Optional `{ outDir }` override (defaults to `./dist`).
|
|
3636
|
+
* @param options.outDir - Build output directory the write happens under.
|
|
3637
|
+
* @returns A summary of the written files.
|
|
3638
|
+
* @example
|
|
3639
|
+
* ```ts
|
|
3640
|
+
* await api.write([{ path: "/en/hello/", data: article }], { outDir: "dist" });
|
|
3641
|
+
* ```
|
|
3642
|
+
*/
|
|
3643
|
+
async write(entries, options) {
|
|
3644
|
+
const { writeData } = await import("./writer-BcWqa_7I.mjs");
|
|
3645
|
+
return writeData(ctx, entries, options);
|
|
3646
|
+
},
|
|
3647
|
+
/**
|
|
3648
|
+
* PURE — the browser fetch URL for a page path.
|
|
3649
|
+
*
|
|
3650
|
+
* @param path - The page URL path.
|
|
3651
|
+
* @returns The site-root-relative data URL.
|
|
3652
|
+
* @example
|
|
3653
|
+
* ```ts
|
|
3654
|
+
* api.urlFor("/en/hello/"); // "/_data/en/hello/index.json"
|
|
3655
|
+
* ```
|
|
3656
|
+
*/
|
|
3657
|
+
urlFor(path) {
|
|
3658
|
+
return `${ctx.config.baseUrl}${dataSuffix(path)}`;
|
|
3659
|
+
},
|
|
3660
|
+
/**
|
|
3661
|
+
* PURE — the `outDir`-relative file path for a page path.
|
|
3662
|
+
*
|
|
3663
|
+
* @param path - The page URL path.
|
|
3664
|
+
* @returns The output-relative file path.
|
|
3665
|
+
* @example
|
|
3666
|
+
* ```ts
|
|
3667
|
+
* api.fileFor("/en/hello/"); // "_data/en/hello/index.json"
|
|
3668
|
+
* ```
|
|
3669
|
+
*/
|
|
3670
|
+
fileFor(path) {
|
|
3671
|
+
return `${trimTrailingSlash(ctx.config.outputDir)}/${dataSuffix(path)}`;
|
|
3672
|
+
}
|
|
3673
|
+
};
|
|
3674
|
+
}
|
|
3675
|
+
//#endregion
|
|
3676
|
+
//#region src/plugins/data/config.ts
|
|
3677
|
+
/**
|
|
3678
|
+
* Typed default data config (R6: no inline `as`). `outputDir` is the WRITE path
|
|
3679
|
+
* (filesystem, relative to the build `outDir`); `baseUrl` is the matching READ URL
|
|
3680
|
+
* (site-root-relative) the browser fetches from — the defaults agree
|
|
3681
|
+
* (`"_data"` ↔ `"/_data/"`).
|
|
3682
|
+
*
|
|
3683
|
+
* @example
|
|
3684
|
+
* ```ts
|
|
3685
|
+
* createPlugin("data", { config: defaultDataConfig });
|
|
3686
|
+
* ```
|
|
3687
|
+
*/
|
|
3688
|
+
const defaultDataConfig = {
|
|
3689
|
+
outputDir: "_data",
|
|
3690
|
+
baseUrl: "/_data/"
|
|
3691
|
+
};
|
|
3692
|
+
//#endregion
|
|
3693
|
+
//#region src/plugins/data/state.ts
|
|
3694
|
+
/**
|
|
3695
|
+
* Creates initial data state: a null `lastWrite` slot (populated by the Node
|
|
3696
|
+
* `write()` side) and an empty `cache` (populated lazily by the browser `at(path)`
|
|
3697
|
+
* side on first fetch).
|
|
3698
|
+
*
|
|
3699
|
+
* @param _ctx - Minimal context with global and config.
|
|
3700
|
+
* @param _ctx.global - Global framework configuration.
|
|
3701
|
+
* @param _ctx.config - Resolved plugin configuration.
|
|
3702
|
+
* @returns Fresh data state with no recorded write and an empty per-path cache.
|
|
3703
|
+
* @example
|
|
3704
|
+
* ```ts
|
|
3705
|
+
* const state = createDataState({ global: {}, config });
|
|
3706
|
+
* ```
|
|
3707
|
+
*/
|
|
3708
|
+
function createDataState(_ctx) {
|
|
3709
|
+
return {
|
|
3710
|
+
lastWrite: null,
|
|
3711
|
+
cache: /* @__PURE__ */ new Map()
|
|
3712
|
+
};
|
|
3713
|
+
}
|
|
3714
|
+
//#endregion
|
|
3715
|
+
//#region src/plugins/data/validate.ts
|
|
3716
|
+
/**
|
|
3717
|
+
* Validates the resolved data config: the browser `baseUrl` must be a non-empty,
|
|
3718
|
+
* site-root-relative URL path. The emit/read pipelines are wired in build waves 3/4.
|
|
3719
|
+
*
|
|
3720
|
+
* @param config - The resolved plugin configuration.
|
|
3721
|
+
* @throws {Error} If `baseUrl` is empty or not a rooted URL path.
|
|
3722
|
+
* @example
|
|
3723
|
+
* ```ts
|
|
3724
|
+
* validateDataConfig({ outputDir: "_data", baseUrl: "/_data/" });
|
|
3725
|
+
* ```
|
|
3726
|
+
*/
|
|
3727
|
+
function validateDataConfig(config) {
|
|
3728
|
+
if (typeof config.baseUrl !== "string" || !config.baseUrl.startsWith("/")) throw new Error(`[web] data.baseUrl: must be a site-root-relative URL path starting with "/" (e.g. "/_data/").`);
|
|
3729
|
+
}
|
|
3730
|
+
//#endregion
|
|
3731
|
+
//#region src/plugins/data/index.ts
|
|
3732
|
+
/**
|
|
3733
|
+
* @file data — Standard tier plugin (wiring-only). The AGNOSTIC data provider for
|
|
3734
|
+
* the SSG→DATA→SPA pattern.
|
|
3735
|
+
*
|
|
3736
|
+
* Owns ONE contract — `page path → persisted JSON file` — and nothing about what
|
|
3737
|
+
* the data is: `write(entries)` persists per-page JSON on Node (build supplies the
|
|
3738
|
+
* entries it already expanded); `at(path)` fetches + caches it in the browser as
|
|
3739
|
+
* `unknown`, which the route's `parse` validates before `render`. NOT a framework
|
|
3740
|
+
* default — the consumer composes it where needed (Node build AND/OR browser app).
|
|
3741
|
+
*
|
|
3742
|
+
* **No hard `depends`** — fully browser-composable; the `node:fs` writer is behind
|
|
3743
|
+
* a lazy `import()` inside `write()`. Build ordering is a call-site contract: build
|
|
3744
|
+
* writes data during its pages phase (after its Phase-0 clean), via `app.data.write`.
|
|
3745
|
+
* No `onStart`/`onStop`.
|
|
3746
|
+
* @see README.md
|
|
3747
|
+
*/
|
|
3748
|
+
/**
|
|
3749
|
+
* Data plugin — the agnostic data provider. Mounts `write(entries)` (Node persist),
|
|
3750
|
+
* `at(path)` (browser read), and the pure `urlFor`/`fileFor` convention at `app.data`.
|
|
3751
|
+
*
|
|
3752
|
+
* @example
|
|
3753
|
+
* ```ts
|
|
3754
|
+
* // Node build: `build` calls app.data.write(...) during its pages phase when
|
|
3755
|
+
* // router.mode !== "ssg". Just compose the plugin:
|
|
3756
|
+
* const app = createApp({
|
|
3757
|
+
* plugins: [dataPlugin, contentPlugin, buildPlugin],
|
|
3758
|
+
* pluginConfigs: { content: { contentDir: "./content" }, router: { routes, mode: "hybrid" } }
|
|
3759
|
+
* });
|
|
3760
|
+
* await app.start();
|
|
3761
|
+
* await app.build.run(); // writes HTML + per-page data sidecars
|
|
3762
|
+
*
|
|
3763
|
+
* // Browser app: compose `dataPlugin` too; spa fetches via app.data.at(path) on nav.
|
|
3764
|
+
* ```
|
|
3765
|
+
*/
|
|
3766
|
+
const dataPlugin = createPlugin$1("data", {
|
|
3767
|
+
config: defaultDataConfig,
|
|
3768
|
+
createState: createDataState,
|
|
3769
|
+
onInit: (ctx) => validateDataConfig(ctx.config),
|
|
3770
|
+
api: dataApi
|
|
3771
|
+
});
|
|
3772
|
+
//#endregion
|
|
3773
|
+
//#region src/plugins/data/types.ts
|
|
3774
|
+
var types_exports = /* @__PURE__ */ __exportAll({});
|
|
3775
|
+
//#endregion
|
|
3776
|
+
//#region src/plugins/env/types.ts
|
|
3777
|
+
var types_exports$1 = /* @__PURE__ */ __exportAll({});
|
|
3778
|
+
//#endregion
|
|
3779
|
+
//#region src/plugins/head/types.ts
|
|
3780
|
+
var types_exports$2 = /* @__PURE__ */ __exportAll({});
|
|
3781
|
+
//#endregion
|
|
3782
|
+
//#region src/plugins/log/types.ts
|
|
3783
|
+
var types_exports$3 = /* @__PURE__ */ __exportAll({});
|
|
3784
|
+
//#endregion
|
|
3785
|
+
//#region src/plugins/router/types.ts
|
|
3786
|
+
var types_exports$4 = /* @__PURE__ */ __exportAll({});
|
|
3787
|
+
//#endregion
|
|
3788
|
+
//#region src/browser.ts
|
|
3789
|
+
/**
|
|
3790
|
+
* @file `@moku-labs/web/browser` — the browser-safe entry point.
|
|
3791
|
+
*
|
|
3792
|
+
* A node-excluded view of the main `@moku-labs/web` entry: the SAME `createApp`
|
|
3793
|
+
* over the SAME isomorphic plugin set (`site`, `i18n`, `router`, `head`, `spa`,
|
|
3794
|
+
* plus the `log`/`env` core), but with **zero** node/native code in its static
|
|
3795
|
+
* import graph. Where the main entry re-exports the node-only plugins
|
|
3796
|
+
* (`content`/`build`/`deploy`) and the node env providers (`dotenv`/`processEnv`/
|
|
3797
|
+
* `cloudflareBindings`, which import `node:fs`), this entry omits them entirely —
|
|
3798
|
+
* so importing it can never drag the Node graph into a client bundle, regardless
|
|
3799
|
+
* of the consumer's bundler or tree-shaking. Built as its own ESM-only pass so the
|
|
3800
|
+
* graph never even references the node-only modules (see `tsdown.config.ts`).
|
|
3801
|
+
*
|
|
3802
|
+
* It also pre-wires `browserEnv()` as the default `env` provider, so env (and
|
|
3803
|
+
* `import.meta.env`-based dev/prod/test detection) works with zero consumer config.
|
|
3804
|
+
*
|
|
3805
|
+
* The optional `data` plugin is exported (its read-half is browser-safe) but, like
|
|
3806
|
+
* in the main entry, is consumer-composed for `router.mode("spa"|"hybrid")`.
|
|
3807
|
+
* @see src/index.ts — the full (Node-capable) entry.
|
|
3808
|
+
*/
|
|
3809
|
+
const core = createCore(coreConfig, {
|
|
3810
|
+
plugins: [
|
|
3811
|
+
sitePlugin,
|
|
3812
|
+
i18nPlugin,
|
|
3813
|
+
routerPlugin,
|
|
3814
|
+
headPlugin,
|
|
3815
|
+
spaPlugin
|
|
3816
|
+
],
|
|
3817
|
+
pluginConfigs: { env: { providers: [browserEnv()] } }
|
|
3818
|
+
});
|
|
3819
|
+
/**
|
|
3820
|
+
* Create and initialize a browser-safe `@moku-labs/web` application — the Layer-3
|
|
3821
|
+
* entry point for client bundles. Identical to the main entry's `createApp`, but
|
|
3822
|
+
* this module's import graph contains zero node/native code, and `env` defaults to
|
|
3823
|
+
* the `browserEnv()` provider (reads `import.meta.env` / `globalThis.__ENV__`).
|
|
3824
|
+
*
|
|
3825
|
+
* The defaults are the isomorphic plugin set (`site`, `i18n`, `router`, `head`,
|
|
3826
|
+
* `spa` + `log`/`env` core). For client-data navigation (`router.mode("spa"|"hybrid")`)
|
|
3827
|
+
* compose the `data` plugin — its consume-half (`at()`) is browser-safe.
|
|
3828
|
+
*
|
|
3829
|
+
* @param options - Optional configuration:
|
|
3830
|
+
* - `pluginConfigs` — per-plugin overrides, keyed by plugin name.
|
|
3831
|
+
* - `config` — global framework config (e.g. `{ mode: "development" }`).
|
|
3832
|
+
* - `plugins` — extra plugins (e.g. `dataPlugin` or your own) merged into the app and its type.
|
|
3833
|
+
* - `onReady` / `onError` / `onStart` / `onStop` — lifecycle callbacks.
|
|
3834
|
+
* @returns The initialized app: `start()`, `stop()`, every plugin's API, and `log`.
|
|
3835
|
+
* @example
|
|
3836
|
+
* ```ts
|
|
3837
|
+
* // Client SPA — env works with no wiring (browserEnv is the default provider):
|
|
3838
|
+
* const app = createApp({
|
|
3839
|
+
* plugins: [dataPlugin],
|
|
3840
|
+
* pluginConfigs: {
|
|
3841
|
+
* router: { mode: "spa", routes: defineRoutes({ home: route("/"), post: route("/blog/{slug}/") }) }
|
|
3842
|
+
* }
|
|
3843
|
+
* });
|
|
3844
|
+
* await app.start();
|
|
3845
|
+
* app.env.get("PUBLIC_API_URL"); // resolved from import.meta.env
|
|
3846
|
+
* ```
|
|
3847
|
+
*/
|
|
3848
|
+
const createApp = core.createApp;
|
|
3849
|
+
/**
|
|
3850
|
+
* Create a custom plugin bound to this framework's `Config`/`Events` and core
|
|
3851
|
+
* APIs. Plugin types are inferred from the spec object — never written explicitly.
|
|
3852
|
+
* Pass the result to {@link createApp} via `plugins`.
|
|
3853
|
+
*
|
|
3854
|
+
* @example
|
|
3855
|
+
* ```ts
|
|
3856
|
+
* const analytics = createPlugin("analytics", {
|
|
3857
|
+
* config: { writeKey: "" },
|
|
3858
|
+
* api: (ctx) => ({ track: (event: string) => ctx.log.info("analytics:track", { event }) })
|
|
3859
|
+
* });
|
|
3860
|
+
*
|
|
3861
|
+
* const app = createApp({ plugins: [analytics] });
|
|
3862
|
+
* ```
|
|
3863
|
+
*/
|
|
3864
|
+
const createPlugin = core.createPlugin;
|
|
3865
|
+
//#endregion
|
|
3866
|
+
export { types_exports as Data, types_exports$1 as Env, types_exports$2 as Head, types_exports$3 as Log, types_exports$4 as Router, types_exports$5 as Spa, browserEnv, buildArticleHead, canonical, createApp, createComponent, createPlugin, dataPlugin, defineRoutes, envPlugin, feedLink, headPlugin, hreflang, i18nPlugin, jsonLd, logPlugin, meta, og, route, routerPlugin, sitePlugin, spaPlugin, twitter };
|