@moku-labs/web 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +38 -0
- package/dist/bin/moku.cjs +809 -0
- package/dist/bin/moku.d.cts +1 -0
- package/dist/bin/moku.d.mts +1 -0
- package/dist/bin/moku.mjs +809 -0
- package/dist/factory-BBVQO5ZG.d.mts +90 -0
- package/dist/factory-CixCpR9C.cjs +1710 -0
- package/dist/factory-D0m7Xil2.d.cts +90 -0
- package/dist/factory-DwpBwjDk.mjs +1602 -0
- package/dist/index-CWdZdegx.d.mts +349 -0
- package/dist/index.cjs +46 -0
- package/dist/index.d.cts +135 -0
- package/dist/index.d.mts +135 -0
- package/dist/index.mjs +36 -0
- package/dist/plugins/head/build.cjs +35 -0
- package/dist/plugins/head/build.d.cts +17 -0
- package/dist/plugins/head/build.d.mts +17 -0
- package/dist/plugins/head/build.mjs +27 -0
- package/dist/plugins/spa/index.cjs +26 -0
- package/dist/plugins/spa/index.d.cts +30 -0
- package/dist/plugins/spa/index.d.mts +30 -0
- package/dist/plugins/spa/index.mjs +24 -0
- package/dist/primitives-BBo4wxUL.d.cts +69 -0
- package/dist/primitives-BYUp6kae.cjs +100 -0
- package/dist/primitives-gO5i1tD8.mjs +58 -0
- package/dist/primitives-kuZFxqV7.d.mts +69 -0
- package/dist/project-BTNUWbGQ.mjs +1020 -0
- package/dist/project-C1vtMxE8.cjs +1081 -0
- package/dist/route-builder-Lv6HUVvP.d.cts +349 -0
- package/dist/test.cjs +82 -0
- package/dist/test.d.cts +61 -0
- package/dist/test.d.mts +61 -0
- package/dist/test.mjs +79 -0
- package/package.json +100 -0
|
@@ -0,0 +1,1602 @@
|
|
|
1
|
+
import { a as meta, i as jsonLd, n as feedLink, o as og, r as hreflang, s as twitter, t as canonical } from "./primitives-gO5i1tD8.mjs";
|
|
2
|
+
import { buildArticleHead } from "./plugins/head/build.mjs";
|
|
3
|
+
import { createCoreConfig, createCorePlugin } from "@moku-labs/core";
|
|
4
|
+
|
|
5
|
+
//#region src/plugins/env/api.ts
|
|
6
|
+
/**
|
|
7
|
+
* Build the env plugin's public API surface over the resolved state.
|
|
8
|
+
*
|
|
9
|
+
* Reads from `ctx.state.resolved` (all validated values) and `ctx.state.publicMap`
|
|
10
|
+
* (subset where `schema[key].public === true`). State Maps are frozen at `onInit`
|
|
11
|
+
* by `validateSchema`, so the API is effectively read-only.
|
|
12
|
+
*
|
|
13
|
+
* @param ctx - The env plugin context ({ state, config }).
|
|
14
|
+
* @returns An EnvApi with `get`, `has`, `require`, `getPublic`, `getPublicMap`.
|
|
15
|
+
* @example
|
|
16
|
+
* const api = createEnvApi({ state, config })
|
|
17
|
+
* api.get('PUBLIC_SITE_URL') // 'https://example.com' | undefined
|
|
18
|
+
* api.require('API_SECRET') // 'sekret' or throws
|
|
19
|
+
* api.getPublic() // Readonly<Record<string, string>>
|
|
20
|
+
* api.getPublicMap() // ReadonlyMap<string, string>
|
|
21
|
+
*/
|
|
22
|
+
const createEnvApi = (ctx) => ({
|
|
23
|
+
get: (key) => ctx.state.resolved.get(key),
|
|
24
|
+
require: (key) => {
|
|
25
|
+
const v = ctx.state.resolved.get(key);
|
|
26
|
+
if (v === void 0) throw new Error(`env: required key "${key}" missing`);
|
|
27
|
+
return v;
|
|
28
|
+
},
|
|
29
|
+
getPublic: () => Object.freeze(Object.fromEntries(ctx.state.publicMap)),
|
|
30
|
+
getPublicMap: () => ctx.state.publicMap,
|
|
31
|
+
has: (key) => ctx.state.resolved.has(key)
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/plugins/env/state.ts
|
|
36
|
+
const createEnvState = () => ({
|
|
37
|
+
resolved: /* @__PURE__ */ new Map(),
|
|
38
|
+
publicMap: /* @__PURE__ */ new Map()
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/plugins/env/validate.ts
|
|
43
|
+
/**
|
|
44
|
+
* Deep-freeze a Map: forbid `set`, `clear`, `delete` mutations after sealing.
|
|
45
|
+
*
|
|
46
|
+
* `Object.freeze(map)` only freezes the binding — it does NOT block `map.set()`.
|
|
47
|
+
* We override the mutator methods with a non-configurable, non-writable property
|
|
48
|
+
* that throws on call, then freeze the binding for defense in depth.
|
|
49
|
+
*
|
|
50
|
+
* @param m - The Map to seal.
|
|
51
|
+
* @returns The same Map instance, now immutable.
|
|
52
|
+
* @example
|
|
53
|
+
* const m = freezeMap(new Map([['a', '1']]))
|
|
54
|
+
* m.get('a') // '1'
|
|
55
|
+
* m.set('b', '2') // throws TypeError
|
|
56
|
+
*/
|
|
57
|
+
const freezeMap = (m) => {
|
|
58
|
+
const throwFrozen = () => {
|
|
59
|
+
throw new TypeError("env: map is frozen and cannot be mutated");
|
|
60
|
+
};
|
|
61
|
+
Object.defineProperty(m, "set", {
|
|
62
|
+
value: throwFrozen,
|
|
63
|
+
writable: false,
|
|
64
|
+
configurable: false
|
|
65
|
+
});
|
|
66
|
+
Object.defineProperty(m, "clear", {
|
|
67
|
+
value: throwFrozen,
|
|
68
|
+
writable: false,
|
|
69
|
+
configurable: false
|
|
70
|
+
});
|
|
71
|
+
Object.defineProperty(m, "delete", {
|
|
72
|
+
value: throwFrozen,
|
|
73
|
+
writable: false,
|
|
74
|
+
configurable: false
|
|
75
|
+
});
|
|
76
|
+
Object.freeze(m);
|
|
77
|
+
return m;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Walk providers in declaration order; first non-undefined wins per key.
|
|
81
|
+
* Empty strings are coerced to undefined unconditionally before precedence.
|
|
82
|
+
*
|
|
83
|
+
* @param providers - The provider list from EnvConfig.
|
|
84
|
+
* @returns A merged record of resolved values.
|
|
85
|
+
*/
|
|
86
|
+
const mergeProviders = (providers) => {
|
|
87
|
+
const merged = {};
|
|
88
|
+
for (const provider of providers) {
|
|
89
|
+
const loaded = provider.load();
|
|
90
|
+
for (const key of Object.keys(loaded)) {
|
|
91
|
+
const raw = loaded[key];
|
|
92
|
+
const value = raw === "" ? void 0 : raw;
|
|
93
|
+
if (merged[key] === void 0 && value !== void 0) merged[key] = value;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return merged;
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Enforce the PUBLIC_ prefix cross-check in both directions over schema keys.
|
|
100
|
+
*
|
|
101
|
+
* @param schema - The env schema.
|
|
102
|
+
* @param publicPrefix - The configured prefix (default `PUBLIC_`).
|
|
103
|
+
* @throws Error when a schema key violates the cross-check.
|
|
104
|
+
*/
|
|
105
|
+
const assertPublicPrefix = (schema, publicPrefix) => {
|
|
106
|
+
for (const key of Object.keys(schema)) {
|
|
107
|
+
const spec = schema[key];
|
|
108
|
+
if (!spec) continue;
|
|
109
|
+
const hasPrefix = key.startsWith(publicPrefix);
|
|
110
|
+
if (spec.public && !hasPrefix) throw new Error(`env: schema key "${key}" is public:true but does not start with prefix "${publicPrefix}"`);
|
|
111
|
+
if (!spec.public && hasPrefix) throw new Error(`env: schema key "${key}" starts with prefix "${publicPrefix}" but is public:false`);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Apply schema-declared defaults for keys still unresolved after provider merge.
|
|
116
|
+
*
|
|
117
|
+
* @param merged - The merged provider record (mutated in place).
|
|
118
|
+
* @param schema - The env schema.
|
|
119
|
+
*/
|
|
120
|
+
const applyDefaults = (merged, schema) => {
|
|
121
|
+
for (const key of Object.keys(schema)) {
|
|
122
|
+
const spec = schema[key];
|
|
123
|
+
if (!spec) continue;
|
|
124
|
+
if (merged[key] === void 0 && spec.default !== void 0) merged[key] = spec.default;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Enforce that every `required: true` schema key has a resolved value.
|
|
129
|
+
*
|
|
130
|
+
* @param merged - The merged provider record (post-defaults).
|
|
131
|
+
* @param schema - The env schema.
|
|
132
|
+
* @throws Error including the missing key name.
|
|
133
|
+
*/
|
|
134
|
+
const assertRequired = (merged, schema) => {
|
|
135
|
+
for (const key of Object.keys(schema)) {
|
|
136
|
+
if (!schema[key]?.required) continue;
|
|
137
|
+
if (merged[key] === void 0) throw new Error(`env: required key "${key}" is missing (unresolved after providers + default)`);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Populate the `resolved` and `publicMap` state Maps from a validated merge.
|
|
142
|
+
*
|
|
143
|
+
* @param state - The env state to populate.
|
|
144
|
+
* @param merged - The validated merged record.
|
|
145
|
+
* @param schema - The env schema (drives publicMap subset selection).
|
|
146
|
+
*/
|
|
147
|
+
const populateState = (state, merged, schema) => {
|
|
148
|
+
for (const key of Object.keys(merged)) {
|
|
149
|
+
const value = merged[key];
|
|
150
|
+
if (value !== void 0) state.resolved.set(key, value);
|
|
151
|
+
}
|
|
152
|
+
for (const key of Object.keys(schema)) {
|
|
153
|
+
if (!schema[key]?.public) continue;
|
|
154
|
+
const value = state.resolved.get(key);
|
|
155
|
+
if (value !== void 0) state.publicMap.set(key, value);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* Validate the env schema, resolve values from providers, and populate state.
|
|
160
|
+
*
|
|
161
|
+
* Walks providers in order (first non-undefined wins per key), coerces empty
|
|
162
|
+
* strings to undefined UNCONDITIONALLY, enforces the PUBLIC_ prefix cross-check
|
|
163
|
+
* in both directions, applies defaults, enforces required keys, and freezes
|
|
164
|
+
* both `resolved` and `publicMap` state Maps.
|
|
165
|
+
*
|
|
166
|
+
* This MUST be called from the env plugin's `onInit` so failures surface at
|
|
167
|
+
* `createApp()` time — never on first `.get()`.
|
|
168
|
+
*
|
|
169
|
+
* @param ctx - The env plugin context ({ state, config }).
|
|
170
|
+
* @throws Error when PUBLIC_ prefix cross-check fails (in either direction).
|
|
171
|
+
* @throws Error when a required key resolves to undefined.
|
|
172
|
+
* @example
|
|
173
|
+
* validateSchema({ state, config: { schema, providers, publicPrefix: 'PUBLIC_' } })
|
|
174
|
+
*/
|
|
175
|
+
const validateSchema = (ctx) => {
|
|
176
|
+
const { schema, providers, publicPrefix } = ctx.config;
|
|
177
|
+
const merged = mergeProviders(providers);
|
|
178
|
+
assertPublicPrefix(schema, publicPrefix);
|
|
179
|
+
applyDefaults(merged, schema);
|
|
180
|
+
assertRequired(merged, schema);
|
|
181
|
+
populateState(ctx.state, merged, schema);
|
|
182
|
+
freezeMap(ctx.state.resolved);
|
|
183
|
+
freezeMap(ctx.state.publicMap);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/plugins/env/index.ts
|
|
188
|
+
/** @file Core plugin: universal env injection with schema + providers + PUBLIC_ cross-validation at onInit. */
|
|
189
|
+
const env = createCorePlugin("env", {
|
|
190
|
+
config: {
|
|
191
|
+
schema: {},
|
|
192
|
+
providers: [],
|
|
193
|
+
publicPrefix: "PUBLIC_"
|
|
194
|
+
},
|
|
195
|
+
createState: createEnvState,
|
|
196
|
+
api: createEnvApi,
|
|
197
|
+
onInit: (ctx) => {
|
|
198
|
+
validateSchema(ctx);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/plugins/log/expect.ts
|
|
204
|
+
/**
|
|
205
|
+
* Dedicated assertion error for the log expect DSL — keeps stack traces clean
|
|
206
|
+
* and lets test runners distinguish framework assertions from generic Errors.
|
|
207
|
+
*/
|
|
208
|
+
var LogExpectAssertionError = class extends Error {
|
|
209
|
+
constructor(message) {
|
|
210
|
+
super(message);
|
|
211
|
+
this.name = "LogExpectAssertionError";
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
/** Element-wise array match — same length AND each pair satisfies matchesPartial. */
|
|
215
|
+
const matchArrays = (actual, partial) => {
|
|
216
|
+
if (!Array.isArray(actual) || actual.length !== partial.length) return false;
|
|
217
|
+
return partial.every((v, i) => matchesPartial(actual[i], v));
|
|
218
|
+
};
|
|
219
|
+
/** Subset object match — every key in `partial` recursively present on `actual`. */
|
|
220
|
+
const matchObjects = (actual, partial) => {
|
|
221
|
+
for (const key of Object.keys(partial)) if (!matchesPartial(actual[key], partial[key])) return false;
|
|
222
|
+
return true;
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* Subset-equality matcher. Returns true when every key in `partial` exists on
|
|
226
|
+
* `actual` with an equal (or recursively-matching) value.
|
|
227
|
+
*
|
|
228
|
+
* Semantics:
|
|
229
|
+
* - Primitives compared with `Object.is`.
|
|
230
|
+
* - Plain objects matched recursively via this same function.
|
|
231
|
+
* - Arrays compared element-wise via this same function (length must match).
|
|
232
|
+
* - `actual` must be a non-null object for any non-empty partial.
|
|
233
|
+
*
|
|
234
|
+
* @param actual - The candidate value (typically `entry.data`).
|
|
235
|
+
* @param partial - Expected subset shape.
|
|
236
|
+
* @returns Whether `actual` contains the partial shape.
|
|
237
|
+
*/
|
|
238
|
+
const matchesPartial = (actual, partial) => {
|
|
239
|
+
if (Object.is(actual, partial)) return true;
|
|
240
|
+
if (partial === null || typeof partial !== "object") return false;
|
|
241
|
+
if (actual === null || typeof actual !== "object") return false;
|
|
242
|
+
if (Array.isArray(partial)) return matchArrays(actual, partial);
|
|
243
|
+
if (Array.isArray(actual)) return false;
|
|
244
|
+
return matchObjects(actual, partial);
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* Find the first entry index matching event name + optional partial.
|
|
248
|
+
* @param entries - Array to scan.
|
|
249
|
+
* @param event - Event name.
|
|
250
|
+
* @param partial - Optional partial data match.
|
|
251
|
+
* @param from - Start index (inclusive).
|
|
252
|
+
* @returns Matching index or -1.
|
|
253
|
+
*/
|
|
254
|
+
const findEntry = (entries, event, partial, from = 0) => {
|
|
255
|
+
for (let i = from; i < entries.length; i++) {
|
|
256
|
+
const e = entries[i];
|
|
257
|
+
if (!e || e.event !== event) continue;
|
|
258
|
+
if (partial === void 0) return i;
|
|
259
|
+
if (matchesPartial(e.data, partial)) return i;
|
|
260
|
+
}
|
|
261
|
+
return -1;
|
|
262
|
+
};
|
|
263
|
+
/**
|
|
264
|
+
* Create a fluent assertion chain bound to a live entries array.
|
|
265
|
+
*
|
|
266
|
+
* The chain reads `entries` on each call — assertions reflect the current
|
|
267
|
+
* state, not a snapshot at chain creation. Returned methods always return the
|
|
268
|
+
* same chain object so they can be chained.
|
|
269
|
+
*
|
|
270
|
+
* @param entries - Reference to the plugin state entries (live, not copied).
|
|
271
|
+
* @returns An {@link ExpectChain} with toHaveEvent / toHaveEventInOrder / toNotHaveEvent.
|
|
272
|
+
* @example
|
|
273
|
+
* ```ts
|
|
274
|
+
* createExpectChain(state.entries)
|
|
275
|
+
* .toHaveEvent('build:phase', { phase: 'content' })
|
|
276
|
+
* .toHaveEventInOrder(['build:start', 'build:complete'])
|
|
277
|
+
* .toNotHaveEvent('build:error')
|
|
278
|
+
* ```
|
|
279
|
+
*/
|
|
280
|
+
const createExpectChain = (entries) => {
|
|
281
|
+
const chain = {
|
|
282
|
+
toHaveEvent: (event, partial) => {
|
|
283
|
+
if (findEntry(entries, event, partial) === -1) throw new LogExpectAssertionError(`Expected log to contain event "${event}"${partial ? ` matching ${JSON.stringify(partial)}` : ""}, but no matching entry was found.`);
|
|
284
|
+
return chain;
|
|
285
|
+
},
|
|
286
|
+
toHaveEventInOrder: (events) => {
|
|
287
|
+
let cursor = 0;
|
|
288
|
+
for (const name of events) {
|
|
289
|
+
const idx = findEntry(entries, name, void 0, cursor);
|
|
290
|
+
if (idx === -1) throw new LogExpectAssertionError(`Expected log to contain event "${name}" in order after index ${cursor - 1}, but it was not found. Sequence: ${JSON.stringify(events)}.`);
|
|
291
|
+
cursor = idx + 1;
|
|
292
|
+
}
|
|
293
|
+
return chain;
|
|
294
|
+
},
|
|
295
|
+
toNotHaveEvent: (event, partial) => {
|
|
296
|
+
const idx = findEntry(entries, event, partial);
|
|
297
|
+
if (idx !== -1) throw new LogExpectAssertionError(`Expected log to NOT contain event "${event}"${partial ? ` matching ${JSON.stringify(partial)}` : ""}, but entry at index ${idx} matched.`);
|
|
298
|
+
return chain;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
return chain;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
//#endregion
|
|
305
|
+
//#region src/plugins/log/api.ts
|
|
306
|
+
/** @file log plugin API factory — info/debug/warn/error append + sink fan-out, plus trace/reset/addSink/expect. */
|
|
307
|
+
/**
|
|
308
|
+
* Build a LogEntry and dispatch it to every registered sink.
|
|
309
|
+
* @param ctx - Plugin context (state + config).
|
|
310
|
+
* @param level - Severity level.
|
|
311
|
+
* @param event - Event identifier.
|
|
312
|
+
* @param data - Optional structured payload.
|
|
313
|
+
*/
|
|
314
|
+
const append = (ctx, level, event, data) => {
|
|
315
|
+
const entry = {
|
|
316
|
+
level,
|
|
317
|
+
event,
|
|
318
|
+
data,
|
|
319
|
+
ts: Date.now()
|
|
320
|
+
};
|
|
321
|
+
ctx.state.entries.push(entry);
|
|
322
|
+
for (const sink of ctx.state.sinks) sink.write(entry);
|
|
323
|
+
};
|
|
324
|
+
/**
|
|
325
|
+
* Creates the log API bound to a plugin context.
|
|
326
|
+
*
|
|
327
|
+
* @param ctx - Core plugin context containing state and config.
|
|
328
|
+
* @returns A {@link LogApi} suitable for injection onto regular plugin contexts.
|
|
329
|
+
* @example
|
|
330
|
+
* ```ts
|
|
331
|
+
* const api = createLogApi({ state: createLogState(), config: { level: 'debug', mode: 'test' } })
|
|
332
|
+
* api.info('hello', { x: 1 })
|
|
333
|
+
* api.expect().toHaveEvent('hello', { x: 1 })
|
|
334
|
+
* ```
|
|
335
|
+
*/
|
|
336
|
+
const createLogApi = (ctx) => ({
|
|
337
|
+
info: (event, data) => append(ctx, "info", event, data),
|
|
338
|
+
debug: (event, data) => append(ctx, "debug", event, data),
|
|
339
|
+
warn: (event, data) => append(ctx, "warn", event, data),
|
|
340
|
+
error: (event, data, err) => {
|
|
341
|
+
append(ctx, "error", event, err === void 0 ? data : {
|
|
342
|
+
...data && typeof data === "object" ? data : {},
|
|
343
|
+
error: {
|
|
344
|
+
message: err.message,
|
|
345
|
+
stack: err.stack
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
},
|
|
349
|
+
trace: () => Object.freeze([...ctx.state.entries]),
|
|
350
|
+
expect: () => createExpectChain(ctx.state.entries),
|
|
351
|
+
addSink: (sink) => {
|
|
352
|
+
ctx.state.sinks.push(sink);
|
|
353
|
+
},
|
|
354
|
+
reset: () => {
|
|
355
|
+
ctx.state.entries.length = 0;
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
//#endregion
|
|
360
|
+
//#region src/plugins/log/sinks/console.ts
|
|
361
|
+
const RANK = {
|
|
362
|
+
debug: 0,
|
|
363
|
+
info: 1,
|
|
364
|
+
warn: 2,
|
|
365
|
+
error: 3
|
|
366
|
+
};
|
|
367
|
+
/**
|
|
368
|
+
* Numeric rank for a level — higher = more severe.
|
|
369
|
+
* @param level - Log level.
|
|
370
|
+
* @returns Numeric severity.
|
|
371
|
+
*/
|
|
372
|
+
const levelRank = (level) => RANK[level];
|
|
373
|
+
/**
|
|
374
|
+
* Build a console sink that emits JSON-stringified entries to the appropriate
|
|
375
|
+
* console channel (`console.log` for debug/info, `console.warn` for warn,
|
|
376
|
+
* `console.error` for error). Entries below `minLevel` are dropped.
|
|
377
|
+
*
|
|
378
|
+
* @param minLevel - Minimum severity to emit (default `'info'`).
|
|
379
|
+
* @returns A {@link LogSink}.
|
|
380
|
+
* @example
|
|
381
|
+
* ```ts
|
|
382
|
+
* const sink = consoleSink('warn')
|
|
383
|
+
* sink.write({ level: 'info', event: 'x', ts: 0 }) // dropped
|
|
384
|
+
* sink.write({ level: 'error', event: 'boom', ts: 0 }) // console.error(...)
|
|
385
|
+
* ```
|
|
386
|
+
*/
|
|
387
|
+
const consoleSink = (minLevel = "info") => {
|
|
388
|
+
const min = levelRank(minLevel);
|
|
389
|
+
return { write: (entry) => {
|
|
390
|
+
if (levelRank(entry.level) < min) return;
|
|
391
|
+
const serialized = JSON.stringify(entry);
|
|
392
|
+
if (entry.level === "error") console.error(serialized);
|
|
393
|
+
else if (entry.level === "warn") console.warn(serialized);
|
|
394
|
+
else console.log(serialized);
|
|
395
|
+
} };
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
//#endregion
|
|
399
|
+
//#region src/plugins/log/sinks/json.ts
|
|
400
|
+
/**
|
|
401
|
+
* Build a sink that writes `JSON.stringify(entry) + '\n'` to a Node writable
|
|
402
|
+
* stream. Use with `process.stderr`, a `fs.WriteStream`, or any
|
|
403
|
+
* `NodeJS.WritableStream`-compatible target.
|
|
404
|
+
*
|
|
405
|
+
* @param stream - A writable stream that accepts string chunks.
|
|
406
|
+
* @returns A {@link LogSink}.
|
|
407
|
+
* @example
|
|
408
|
+
* ```ts
|
|
409
|
+
* import { jsonSink } from '@moku-labs/web/log/sinks/json'
|
|
410
|
+
* const sink = jsonSink(process.stderr)
|
|
411
|
+
* sink.write({ level: 'error', event: 'crash', ts: Date.now() })
|
|
412
|
+
* ```
|
|
413
|
+
*/
|
|
414
|
+
const jsonSink = (stream) => ({ write: (entry) => {
|
|
415
|
+
stream.write(`${JSON.stringify(entry)}\n`);
|
|
416
|
+
} });
|
|
417
|
+
|
|
418
|
+
//#endregion
|
|
419
|
+
//#region src/plugins/log/state.ts
|
|
420
|
+
const createLogState = () => ({
|
|
421
|
+
entries: [],
|
|
422
|
+
sinks: []
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
//#endregion
|
|
426
|
+
//#region src/plugins/log/index.ts
|
|
427
|
+
/** @file Core plugin: in-memory trace + expect() DSL for LLM-verifiable test assertions. Sinks at onInit (not onStart). */
|
|
428
|
+
const log = createCorePlugin("log", {
|
|
429
|
+
config: {
|
|
430
|
+
level: "info",
|
|
431
|
+
mode: "auto"
|
|
432
|
+
},
|
|
433
|
+
createState: createLogState,
|
|
434
|
+
api: createLogApi,
|
|
435
|
+
onInit: (ctx) => {
|
|
436
|
+
const mode = ctx.config.mode === "auto" ? resolveAutoMode() : ctx.config.mode;
|
|
437
|
+
if (mode === "test") return;
|
|
438
|
+
ctx.state.sinks.push(consoleSink(ctx.config.level));
|
|
439
|
+
if (mode === "production") ctx.state.sinks.push(jsonSink(process.stderr));
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
/**
|
|
443
|
+
* Resolves the effective log mode when `config.mode === 'auto'`.
|
|
444
|
+
*
|
|
445
|
+
* Reads `process.env.NODE_ENV`: `'test'` or `'production'` flow through;
|
|
446
|
+
* any other value (including undefined or non-Node runtimes) resolves to `'dev'`.
|
|
447
|
+
*
|
|
448
|
+
* @returns The resolved mode used to pick default sinks at `onInit`.
|
|
449
|
+
*/
|
|
450
|
+
function resolveAutoMode() {
|
|
451
|
+
const nodeEnv = typeof process !== "undefined" ? process.env.NODE_ENV : void 0;
|
|
452
|
+
if (nodeEnv === "test" || nodeEnv === "production") return nodeEnv;
|
|
453
|
+
return "dev";
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
//#endregion
|
|
457
|
+
//#region src/config.ts
|
|
458
|
+
/** @file Framework config — single createCoreConfig call. Exports createPlugin, createCore. */
|
|
459
|
+
/** Bound factory chain for moku-web. */
|
|
460
|
+
const coreConfig = createCoreConfig("moku-web", {
|
|
461
|
+
config: { mode: "production" },
|
|
462
|
+
plugins: [log, env]
|
|
463
|
+
});
|
|
464
|
+
const { createPlugin, createCore } = coreConfig;
|
|
465
|
+
|
|
466
|
+
//#endregion
|
|
467
|
+
//#region src/plugins/i18n/api.ts
|
|
468
|
+
const createI18nApi = (ctx) => ({
|
|
469
|
+
locales: () => ctx.state.config.locales,
|
|
470
|
+
defaultLocale: () => ctx.state.config.defaultLocale,
|
|
471
|
+
localeName: (locale) => ctx.state.config.localeNames[locale],
|
|
472
|
+
ogLocale: (locale) => ctx.state.config.ogLocaleMap?.[locale] ?? locale,
|
|
473
|
+
t: (locale, key) => ctx.state.config.translations?.[locale]?.[key] ?? key
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
//#endregion
|
|
477
|
+
//#region src/plugins/i18n/state.ts
|
|
478
|
+
const createI18nState = (config) => ({ config });
|
|
479
|
+
|
|
480
|
+
//#endregion
|
|
481
|
+
//#region src/plugins/i18n/validate.ts
|
|
482
|
+
/**
|
|
483
|
+
* Recursively `Object.freeze` an object and all nested object/array values.
|
|
484
|
+
*
|
|
485
|
+
* Required because plain `Object.freeze` is shallow — nested records like
|
|
486
|
+
* `localeNames` and `translations` would remain mutable otherwise.
|
|
487
|
+
*
|
|
488
|
+
* @param value - The value to freeze (no-op for primitives).
|
|
489
|
+
*/
|
|
490
|
+
const deepFreeze$1 = (value) => {
|
|
491
|
+
if (value === null || typeof value !== "object") return;
|
|
492
|
+
if (Object.isFrozen(value)) return;
|
|
493
|
+
Object.freeze(value);
|
|
494
|
+
for (const key of Object.keys(value)) deepFreeze$1(value[key]);
|
|
495
|
+
};
|
|
496
|
+
/**
|
|
497
|
+
* Validate i18n config and replace state.config with a deeply frozen copy.
|
|
498
|
+
*
|
|
499
|
+
* Runs in the i18n plugin's `onInit`. Failures surface at `createApp()` time,
|
|
500
|
+
* never on first API call.
|
|
501
|
+
*
|
|
502
|
+
* @param ctx - The i18n plugin context ({ state, config }).
|
|
503
|
+
* @throws Error when `defaultLocale` is not a member of `locales`.
|
|
504
|
+
* @throws Error when `locales` is empty.
|
|
505
|
+
*/
|
|
506
|
+
const validateAndFreeze$2 = (ctx) => {
|
|
507
|
+
const { locales, defaultLocale } = ctx.config;
|
|
508
|
+
if (locales.length === 0) throw new Error("i18n: locales must contain at least one entry");
|
|
509
|
+
if (!locales.includes(defaultLocale)) throw new Error(`i18n: defaultLocale "${defaultLocale}" must be one of locales [${locales.join(", ")}]`);
|
|
510
|
+
const frozen = { ...ctx.config };
|
|
511
|
+
deepFreeze$1(frozen);
|
|
512
|
+
ctx.state.config = frozen;
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
//#endregion
|
|
516
|
+
//#region src/plugins/i18n/index.ts
|
|
517
|
+
/** @file i18n plugin: locales + translations + t() helper. Standard tier. Reads ctx.env (Core). */
|
|
518
|
+
const defaultConfig$2 = {
|
|
519
|
+
locales: [],
|
|
520
|
+
defaultLocale: "",
|
|
521
|
+
localeNames: {},
|
|
522
|
+
ogLocaleMap: {},
|
|
523
|
+
translations: {}
|
|
524
|
+
};
|
|
525
|
+
const i18n = createPlugin("i18n", {
|
|
526
|
+
config: defaultConfig$2,
|
|
527
|
+
createState: (ctx) => createI18nState(ctx.config),
|
|
528
|
+
api: createI18nApi,
|
|
529
|
+
onInit: validateAndFreeze$2
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
//#endregion
|
|
533
|
+
//#region src/plugins/site/api.ts
|
|
534
|
+
const createSiteApi = (ctx) => ({
|
|
535
|
+
get: () => ctx.state.config,
|
|
536
|
+
name: () => ctx.state.config.name,
|
|
537
|
+
url: () => ctx.state.config.url,
|
|
538
|
+
author: () => ctx.state.config.author,
|
|
539
|
+
description: () => ctx.state.config.description
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
//#endregion
|
|
543
|
+
//#region src/plugins/site/state.ts
|
|
544
|
+
const createSiteState = (config) => ({ config });
|
|
545
|
+
|
|
546
|
+
//#endregion
|
|
547
|
+
//#region src/plugins/site/validate.ts
|
|
548
|
+
/**
|
|
549
|
+
* Validate site URL: must be http(s), must not have a trailing slash.
|
|
550
|
+
*
|
|
551
|
+
* @param url - The URL string from SiteConfig.
|
|
552
|
+
* @throws Error when URL is empty, lacks http(s) protocol, or has a trailing slash.
|
|
553
|
+
*/
|
|
554
|
+
const assertValidUrl = (url) => {
|
|
555
|
+
if (url === "") throw new Error("site: url is required (received empty string)");
|
|
556
|
+
if (url.endsWith("/")) throw new Error(`site: url "${url}" must not have a trailing slash`);
|
|
557
|
+
let parsed;
|
|
558
|
+
try {
|
|
559
|
+
parsed = new URL(url);
|
|
560
|
+
} catch {
|
|
561
|
+
throw new Error(`site: url "${url}" is not a valid URL`);
|
|
562
|
+
}
|
|
563
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`site: url "${url}" must use http(s) protocol (got "${parsed.protocol}")`);
|
|
564
|
+
};
|
|
565
|
+
/**
|
|
566
|
+
* Validate site config and replace state.config with a frozen copy.
|
|
567
|
+
*
|
|
568
|
+
* Runs in the site plugin's `onInit`. Failures surface at `createApp()` time,
|
|
569
|
+
* never on first `.get()`. After this call, `state.config` is `Object.freeze`d
|
|
570
|
+
* so consumer code cannot mutate it.
|
|
571
|
+
*
|
|
572
|
+
* @param ctx - The site plugin context ({ state, config }).
|
|
573
|
+
* @throws Error when the URL is invalid (empty, non-http(s), or trailing slash).
|
|
574
|
+
*/
|
|
575
|
+
const validateAndFreeze$1 = (ctx) => {
|
|
576
|
+
assertValidUrl(ctx.config.url);
|
|
577
|
+
ctx.state.config = Object.freeze({ ...ctx.config });
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
//#endregion
|
|
581
|
+
//#region src/plugins/site/index.ts
|
|
582
|
+
/** @file site plugin: global site metadata. Standard tier. */
|
|
583
|
+
const site = createPlugin("site", {
|
|
584
|
+
config: {
|
|
585
|
+
name: "",
|
|
586
|
+
url: "",
|
|
587
|
+
author: "",
|
|
588
|
+
description: ""
|
|
589
|
+
},
|
|
590
|
+
createState: (ctx) => createSiteState(ctx.config),
|
|
591
|
+
api: createSiteApi,
|
|
592
|
+
onInit: validateAndFreeze$1
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
//#endregion
|
|
596
|
+
//#region src/plugins/router/match.ts
|
|
597
|
+
const PARAM_RE = /^\{([a-z_][a-z0-9_]*)(:\?)?\}$/i;
|
|
598
|
+
const hasBrace = (part) => part.includes("{") || part.includes("}");
|
|
599
|
+
const assertSegment = (name, pattern, part) => {
|
|
600
|
+
if (part === "") throw new Error(`router: route "${name}" pattern contains empty segment — got "${pattern}"`);
|
|
601
|
+
if (hasBrace(part) && !PARAM_RE.test(part)) throw new Error(`router: route "${name}" has malformed param segment "${part}" in pattern "${pattern}"`);
|
|
602
|
+
};
|
|
603
|
+
/**
|
|
604
|
+
* Assert a route pattern is well-formed.
|
|
605
|
+
*
|
|
606
|
+
* Rules:
|
|
607
|
+
* - must start with `/`
|
|
608
|
+
* - each non-root segment is either static (no braces) or `{name}` / `{name:?}`
|
|
609
|
+
* where `name` matches `[a-z_][a-z0-9_]*` (case-insensitive)
|
|
610
|
+
*
|
|
611
|
+
* @param name - The route key from the routes map (for error messages).
|
|
612
|
+
* @param pattern - The pattern to validate.
|
|
613
|
+
* @throws Error when the pattern is malformed.
|
|
614
|
+
*/
|
|
615
|
+
const validatePattern = (name, pattern) => {
|
|
616
|
+
if (!pattern.startsWith("/")) throw new Error(`router: route "${name}" pattern must start with "/" — got "${pattern}"`);
|
|
617
|
+
if (pattern === "/") return;
|
|
618
|
+
const parts = (pattern.endsWith("/") ? pattern.slice(0, -1) : pattern).split("/").slice(1);
|
|
619
|
+
for (const part of parts) assertSegment(name, pattern, part);
|
|
620
|
+
};
|
|
621
|
+
/**
|
|
622
|
+
* Classify a raw segment string into a `Segment`.
|
|
623
|
+
*
|
|
624
|
+
* @param part - One slash-separated portion of a pattern.
|
|
625
|
+
* @returns Either a static segment or a param segment.
|
|
626
|
+
*/
|
|
627
|
+
const classifySegment = (part) => {
|
|
628
|
+
const m = PARAM_RE.exec(part);
|
|
629
|
+
if (m === null) return {
|
|
630
|
+
kind: "static",
|
|
631
|
+
value: part
|
|
632
|
+
};
|
|
633
|
+
return {
|
|
634
|
+
kind: "param",
|
|
635
|
+
name: m[1],
|
|
636
|
+
optional: m[2] === ":?"
|
|
637
|
+
};
|
|
638
|
+
};
|
|
639
|
+
/**
|
|
640
|
+
* Parse a route pattern into segments + trailing-slash flag.
|
|
641
|
+
*
|
|
642
|
+
* @param pattern - The route pattern (must begin with `/`).
|
|
643
|
+
* @returns The parsed pattern.
|
|
644
|
+
*/
|
|
645
|
+
const parsePattern = (pattern) => {
|
|
646
|
+
const trailingSlash = pattern.length > 1 && pattern.endsWith("/");
|
|
647
|
+
return {
|
|
648
|
+
segments: (trailingSlash ? pattern.slice(0, -1) : pattern).split("/").slice(1).filter((p) => p !== "").map(classifySegment),
|
|
649
|
+
trailingSlash
|
|
650
|
+
};
|
|
651
|
+
};
|
|
652
|
+
/**
|
|
653
|
+
* Compute a specificity score for a pattern. Higher scores match first.
|
|
654
|
+
*
|
|
655
|
+
* Each static segment contributes +100; each required param -1; each optional
|
|
656
|
+
* param -2. This guarantees any pure-static pattern outranks any pattern with
|
|
657
|
+
* params, and required params outrank optional params at equal static counts.
|
|
658
|
+
*
|
|
659
|
+
* @param pattern - The route pattern.
|
|
660
|
+
* @returns A specificity score (may be negative for purely dynamic patterns).
|
|
661
|
+
*/
|
|
662
|
+
const computeSpecificity = (pattern) => {
|
|
663
|
+
const { segments } = parsePattern(pattern);
|
|
664
|
+
let score = 0;
|
|
665
|
+
for (const s of segments) if (s.kind === "static") score += 100;
|
|
666
|
+
else score -= s.optional ? 2 : 1;
|
|
667
|
+
return score;
|
|
668
|
+
};
|
|
669
|
+
/**
|
|
670
|
+
* Split a URL into its path segments + trailing-slash flag.
|
|
671
|
+
*
|
|
672
|
+
* @param url - The URL path (must begin with `/`).
|
|
673
|
+
* @returns The split URL pieces.
|
|
674
|
+
*/
|
|
675
|
+
const splitUrl = (url) => {
|
|
676
|
+
const trailingSlash = url.length > 1 && url.endsWith("/");
|
|
677
|
+
return {
|
|
678
|
+
parts: (trailingSlash ? url.slice(0, -1) : url).split("/").slice(1).filter((p) => p !== ""),
|
|
679
|
+
trailingSlash
|
|
680
|
+
};
|
|
681
|
+
};
|
|
682
|
+
/**
|
|
683
|
+
* Verify the trailing-slash convention matches between pattern and URL.
|
|
684
|
+
*
|
|
685
|
+
* @param parsed - Parsed pattern.
|
|
686
|
+
* @param url - URL string.
|
|
687
|
+
* @param trailingSlash - Whether the URL has a trailing slash.
|
|
688
|
+
* @returns True when the trailing-slash style matches.
|
|
689
|
+
*/
|
|
690
|
+
const trailingSlashMatches = (parsed, url, trailingSlash) => {
|
|
691
|
+
if (url === "/") return true;
|
|
692
|
+
return parsed.trailingSlash === trailingSlash;
|
|
693
|
+
};
|
|
694
|
+
/**
|
|
695
|
+
* Apply one segment against a URL part; mutate params or return `false` on miss.
|
|
696
|
+
*
|
|
697
|
+
* @param seg - The pattern segment to apply.
|
|
698
|
+
* @param part - The URL part at this position.
|
|
699
|
+
* @param params - Accumulator for extracted params (mutated on success).
|
|
700
|
+
* @returns True when this segment matches the part.
|
|
701
|
+
*/
|
|
702
|
+
const applySegment = (seg, part, params) => {
|
|
703
|
+
if (seg.kind === "static") return seg.value === part;
|
|
704
|
+
params[seg.name] = part;
|
|
705
|
+
return true;
|
|
706
|
+
};
|
|
707
|
+
/**
|
|
708
|
+
* Walk segments + URL parts, extracting param values. Optional params are
|
|
709
|
+
* skipped from the left when the URL is shorter than the pattern.
|
|
710
|
+
*
|
|
711
|
+
* @param segments - The pattern segments.
|
|
712
|
+
* @param parts - The URL parts.
|
|
713
|
+
* @returns The extracted params, or `null` when the URL does not match.
|
|
714
|
+
*/
|
|
715
|
+
const extractParams = (segments, parts) => {
|
|
716
|
+
const requiredCount = segments.filter((s) => !(s.kind === "param" && s.optional)).length;
|
|
717
|
+
if (parts.length < requiredCount || parts.length > segments.length) return null;
|
|
718
|
+
let skipsLeft = segments.length - parts.length;
|
|
719
|
+
const params = {};
|
|
720
|
+
let i = 0;
|
|
721
|
+
for (const seg of segments) {
|
|
722
|
+
if (seg.kind === "param" && seg.optional && skipsLeft > 0) {
|
|
723
|
+
skipsLeft -= 1;
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
const part = parts[i];
|
|
727
|
+
if (part === void 0 || !applySegment(seg, part, params)) return null;
|
|
728
|
+
i += 1;
|
|
729
|
+
}
|
|
730
|
+
return params;
|
|
731
|
+
};
|
|
732
|
+
/**
|
|
733
|
+
* Try to match a single pattern against a URL path; return params or null.
|
|
734
|
+
*
|
|
735
|
+
* @param pattern - The route pattern.
|
|
736
|
+
* @param url - The URL path.
|
|
737
|
+
* @returns Extracted params, or `null` when the pattern does not match.
|
|
738
|
+
*/
|
|
739
|
+
const tryMatch = (pattern, url) => {
|
|
740
|
+
const parsed = parsePattern(pattern);
|
|
741
|
+
const { parts, trailingSlash } = splitUrl(url);
|
|
742
|
+
if (!trailingSlashMatches(parsed, url, trailingSlash)) return null;
|
|
743
|
+
return extractParams(parsed.segments, parts);
|
|
744
|
+
};
|
|
745
|
+
/**
|
|
746
|
+
* Walk entries in specificity order and return the first match.
|
|
747
|
+
*
|
|
748
|
+
* @param entries - Route entries (must be sorted by specificity descending).
|
|
749
|
+
* @param url - The URL path to match.
|
|
750
|
+
* @returns The matching entry and params, or `null` when nothing matches.
|
|
751
|
+
*/
|
|
752
|
+
const matchUrl = (entries, url) => {
|
|
753
|
+
for (const entry of entries) {
|
|
754
|
+
const params = tryMatch(entry.spec.pattern, url);
|
|
755
|
+
if (params !== null) return {
|
|
756
|
+
entry,
|
|
757
|
+
params
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
return null;
|
|
761
|
+
};
|
|
762
|
+
/**
|
|
763
|
+
* Render one pattern segment as a URL piece, or `null` if the segment is an
|
|
764
|
+
* absent optional param.
|
|
765
|
+
*
|
|
766
|
+
* @param seg - The pattern segment.
|
|
767
|
+
* @param params - The provided param map.
|
|
768
|
+
* @param pattern - The full pattern (for error messages).
|
|
769
|
+
* @returns The rendered piece, or `null` to drop the segment.
|
|
770
|
+
* @throws Error when a required param is missing.
|
|
771
|
+
*/
|
|
772
|
+
const renderSegment = (seg, params, pattern) => {
|
|
773
|
+
if (seg.kind === "static") return seg.value;
|
|
774
|
+
const value = params[seg.name];
|
|
775
|
+
if (value !== void 0) return value;
|
|
776
|
+
if (seg.optional) return null;
|
|
777
|
+
throw new Error(`router: missing required param "${seg.name}" for pattern "${pattern}"`);
|
|
778
|
+
};
|
|
779
|
+
/**
|
|
780
|
+
* Build a URL string from a pattern by substituting params.
|
|
781
|
+
*
|
|
782
|
+
* Optional params absent from `params` collapse out of the URL. Required params
|
|
783
|
+
* missing from `params` throw — callers should never attempt to generate an
|
|
784
|
+
* incomplete URL.
|
|
785
|
+
*
|
|
786
|
+
* @param pattern - The route pattern.
|
|
787
|
+
* @param params - Map of param name -> value.
|
|
788
|
+
* @returns The generated URL.
|
|
789
|
+
* @throws Error when a required param is missing.
|
|
790
|
+
*/
|
|
791
|
+
const generateUrl = (pattern, params) => {
|
|
792
|
+
const parsed = parsePattern(pattern);
|
|
793
|
+
const pieces = [];
|
|
794
|
+
for (const seg of parsed.segments) {
|
|
795
|
+
const rendered = renderSegment(seg, params, pattern);
|
|
796
|
+
if (rendered !== null) pieces.push(rendered);
|
|
797
|
+
}
|
|
798
|
+
const body = pieces.join("/");
|
|
799
|
+
if (body === "") return "/";
|
|
800
|
+
return `/${body}${parsed.trailingSlash ? "/" : ""}`;
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
//#endregion
|
|
804
|
+
//#region src/plugins/router/api.ts
|
|
805
|
+
/** @file Router API factory — routes, toUrl, match, entries. */
|
|
806
|
+
/**
|
|
807
|
+
* Build the router public API from plugin context.
|
|
808
|
+
*
|
|
809
|
+
* `match` and `entries` delegate to the precomputed sorted entries; `toUrl` looks
|
|
810
|
+
* up the named route and substitutes the supplied params via `generateUrl`.
|
|
811
|
+
*
|
|
812
|
+
* @param ctx - Plugin execution context with `state` and `config`.
|
|
813
|
+
* @returns The router API.
|
|
814
|
+
*/
|
|
815
|
+
const createRouterApi = (ctx) => ({
|
|
816
|
+
routes: ctx.state.routes,
|
|
817
|
+
toUrl: (name, params) => {
|
|
818
|
+
const spec = ctx.state.routes[name];
|
|
819
|
+
if (spec === void 0) throw new Error(`router: unknown route name "${String(name)}"`);
|
|
820
|
+
return generateUrl(spec.pattern, params);
|
|
821
|
+
},
|
|
822
|
+
match: (url) => matchUrl(ctx.state.entries, url),
|
|
823
|
+
entries: () => ctx.state.entries
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
//#endregion
|
|
827
|
+
//#region src/plugins/router/state.ts
|
|
828
|
+
/** @file Router state factory — builds sorted RouteEntry list from a routes map. */
|
|
829
|
+
/**
|
|
830
|
+
* Create the router's mutable state from a `routes` map.
|
|
831
|
+
*
|
|
832
|
+
* Each entry receives a precomputed specificity score; entries are sorted
|
|
833
|
+
* descending so `matchUrl` walks the most specific patterns first. The returned
|
|
834
|
+
* `entries` array is frozen to prevent external mutation.
|
|
835
|
+
*
|
|
836
|
+
* @param routes - Map of route name -> RouteSpec.
|
|
837
|
+
* @returns A `RouterState` with `routes` (as given) and a frozen, sorted `entries` list.
|
|
838
|
+
*/
|
|
839
|
+
const createRouterState$1 = (routes) => {
|
|
840
|
+
const entries = Object.entries(routes).map(([name, spec]) => ({
|
|
841
|
+
name,
|
|
842
|
+
spec,
|
|
843
|
+
specificity: computeSpecificity(spec.pattern)
|
|
844
|
+
}));
|
|
845
|
+
entries.sort((a, b) => b.specificity - a.specificity);
|
|
846
|
+
return {
|
|
847
|
+
routes,
|
|
848
|
+
entries: Object.freeze(entries)
|
|
849
|
+
};
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
//#endregion
|
|
853
|
+
//#region src/plugins/router/route-builder.ts
|
|
854
|
+
/**
|
|
855
|
+
* Build a `RouteSpec` via a fluent, non-accumulating builder.
|
|
856
|
+
*
|
|
857
|
+
* Methods mutate a single internal `RouteSpec` and return the same builder typed
|
|
858
|
+
* as `RouteBuilder` (NOT `RouteBuilder<T>`). Type information lives on the
|
|
859
|
+
* `RouteSpec` shape, not in builder generics — this keeps TS inference O(1) per
|
|
860
|
+
* route and prevents the 50+ route inference collapse described in D5.
|
|
861
|
+
*
|
|
862
|
+
* @param pattern - URL pattern (e.g. `/about`, `/{slug}`, `/{lang:?}/{slug}/`).
|
|
863
|
+
* @returns A `RouteBuilder` that mutates an internal spec.
|
|
864
|
+
* @example
|
|
865
|
+
* ```ts
|
|
866
|
+
* const home = route('/{lang:?}/').render(({ locale }) => <Home locale={locale} />)
|
|
867
|
+
* ```
|
|
868
|
+
*/
|
|
869
|
+
function route(pattern) {
|
|
870
|
+
const spec = { pattern };
|
|
871
|
+
const builder = {
|
|
872
|
+
load: (fn) => {
|
|
873
|
+
spec.load = fn;
|
|
874
|
+
return builder;
|
|
875
|
+
},
|
|
876
|
+
layout: (component) => {
|
|
877
|
+
spec.layout = component;
|
|
878
|
+
return builder;
|
|
879
|
+
},
|
|
880
|
+
render: (fn) => {
|
|
881
|
+
spec.render = fn;
|
|
882
|
+
return builder;
|
|
883
|
+
},
|
|
884
|
+
generate: (fn) => {
|
|
885
|
+
spec.generate = fn;
|
|
886
|
+
return builder;
|
|
887
|
+
},
|
|
888
|
+
head: (fn) => {
|
|
889
|
+
spec.head = fn;
|
|
890
|
+
return builder;
|
|
891
|
+
},
|
|
892
|
+
meta: (data) => {
|
|
893
|
+
spec.meta = data;
|
|
894
|
+
return builder;
|
|
895
|
+
},
|
|
896
|
+
toJson: (fn) => {
|
|
897
|
+
spec.toJson = fn;
|
|
898
|
+
return builder;
|
|
899
|
+
},
|
|
900
|
+
toFile: (fn) => {
|
|
901
|
+
spec.toFile = fn;
|
|
902
|
+
return builder;
|
|
903
|
+
},
|
|
904
|
+
_spec: () => spec
|
|
905
|
+
};
|
|
906
|
+
return builder;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
//#endregion
|
|
910
|
+
//#region src/plugins/router/index.ts
|
|
911
|
+
/** @file router plugin: fluent route() builder + RouteSpec + matching. Standard tier. */
|
|
912
|
+
const defaultConfig$1 = { routes: {} };
|
|
913
|
+
const router = createPlugin("router", {
|
|
914
|
+
depends: [site, i18n],
|
|
915
|
+
config: defaultConfig$1,
|
|
916
|
+
events: (register) => ({ "router:registered": register("Routes registered at init") }),
|
|
917
|
+
createState: (ctx) => createRouterState$1(ctx.config.routes),
|
|
918
|
+
api: createRouterApi,
|
|
919
|
+
onInit: (ctx) => {
|
|
920
|
+
for (const entry of ctx.state.entries) validatePattern(entry.name, entry.spec.pattern);
|
|
921
|
+
ctx.emit("router:registered", { routeCount: ctx.state.entries.length });
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
//#endregion
|
|
926
|
+
//#region src/plugins/head/api.ts
|
|
927
|
+
/** @file head plugin API factory — render(), buildArticleHead, and primitives surface. */
|
|
928
|
+
/**
|
|
929
|
+
* HTML-escape a string for safe insertion into an attribute value or text node.
|
|
930
|
+
* Order matters: `&` must be escaped first so we do not double-escape the
|
|
931
|
+
* `&...;` sequences emitted by subsequent rules.
|
|
932
|
+
*
|
|
933
|
+
* @param raw - The unsafe string.
|
|
934
|
+
* @returns Escaped string safe to interpolate between double quotes or as text.
|
|
935
|
+
*/
|
|
936
|
+
const escapeHtml = (raw) => raw.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
937
|
+
/**
|
|
938
|
+
* Serialize a single HeadElement to its HTML string form.
|
|
939
|
+
*
|
|
940
|
+
* `script` elements emit `content` verbatim — the jsonLd primitive is
|
|
941
|
+
* responsible for the unicode-escape XSS hardening, so additional HTML
|
|
942
|
+
* escaping here would corrupt the JSON payload. All attribute values are
|
|
943
|
+
* always HTML-escaped.
|
|
944
|
+
*
|
|
945
|
+
* @param element - The HeadElement to serialize.
|
|
946
|
+
* @returns A single line of HTML.
|
|
947
|
+
*/
|
|
948
|
+
const renderElement = (element) => {
|
|
949
|
+
const attributes = Object.entries(element.attrs).map(([key, value]) => `${key}="${escapeHtml(value)}"`).join(" ");
|
|
950
|
+
if (element.tag === "script") return `<script ${attributes}>${element.content ?? ""}<\/script>`;
|
|
951
|
+
if (element.tag === "title") return `<title>${escapeHtml(element.content ?? "")}</title>`;
|
|
952
|
+
return attributes.length === 0 ? `<${element.tag}>` : `<${element.tag} ${attributes}>`;
|
|
953
|
+
};
|
|
954
|
+
const hasCanonical = (elements) => Boolean(elements?.some((element) => element.tag === "link" && element.attrs.rel === "canonical"));
|
|
955
|
+
/**
|
|
956
|
+
* Factory for head plugin API. The returned `render` closes over the (post-onInit)
|
|
957
|
+
* frozen plugin config so `autoCanonical` and other config flags are observed.
|
|
958
|
+
*
|
|
959
|
+
* @param ctx - Plugin context with `state` (HeadState) and `config` (HeadPluginConfig).
|
|
960
|
+
* @returns The HeadApi surface (`render`, `primitives`, `buildArticleHead`).
|
|
961
|
+
*/
|
|
962
|
+
const createHeadApi = (ctx) => {
|
|
963
|
+
const render = (config, renderCtx) => {
|
|
964
|
+
const parts = [];
|
|
965
|
+
if (config.title !== void 0) parts.push(`<title>${escapeHtml(config.title)}</title>`);
|
|
966
|
+
if (config.elements) for (const element of config.elements) parts.push(renderElement(element));
|
|
967
|
+
if (ctx.state.config.autoCanonical === true && !hasCanonical(config.elements) && renderCtx.url.length > 0) parts.push(renderElement(canonical(renderCtx.url)));
|
|
968
|
+
return parts.join("\n");
|
|
969
|
+
};
|
|
970
|
+
return {
|
|
971
|
+
render,
|
|
972
|
+
primitives: {
|
|
973
|
+
meta,
|
|
974
|
+
og,
|
|
975
|
+
jsonLd,
|
|
976
|
+
canonical,
|
|
977
|
+
hreflang,
|
|
978
|
+
twitter,
|
|
979
|
+
feedLink
|
|
980
|
+
},
|
|
981
|
+
buildArticleHead
|
|
982
|
+
};
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
//#endregion
|
|
986
|
+
//#region src/plugins/head/state.ts
|
|
987
|
+
const createHeadState = (config) => ({
|
|
988
|
+
config,
|
|
989
|
+
ogImageDefaults: config.ogImage ?? null
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
//#endregion
|
|
993
|
+
//#region src/plugins/head/validate.ts
|
|
994
|
+
/**
|
|
995
|
+
* Validate optional `ogImage` config — checks that each font entry has a
|
|
996
|
+
* non-empty `subset` and a non-empty `weights` array.
|
|
997
|
+
*
|
|
998
|
+
* @param ogImage - The OG image config to validate.
|
|
999
|
+
* @throws Error when any font entry is missing required fields.
|
|
1000
|
+
*/
|
|
1001
|
+
const assertValidOgImage = (ogImage) => {
|
|
1002
|
+
if (!ogImage.fonts) return;
|
|
1003
|
+
for (const font of ogImage.fonts) {
|
|
1004
|
+
if (typeof font.subset !== "string" || font.subset.length === 0) throw new Error("head: ogImage.fonts entry requires a non-empty subset");
|
|
1005
|
+
if (!Array.isArray(font.weights) || font.weights.length === 0) throw new Error("head: ogImage.fonts entry requires a non-empty weights array");
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
/**
|
|
1009
|
+
* Recursively `Object.freeze` an object and all nested object/array values.
|
|
1010
|
+
* Mirrors the i18n plugin pattern — plain `Object.freeze` is shallow and would
|
|
1011
|
+
* leave nested config records like `ogImage.fonts` mutable.
|
|
1012
|
+
*
|
|
1013
|
+
* @param value - The value to freeze (no-op for primitives or already-frozen values).
|
|
1014
|
+
*/
|
|
1015
|
+
const deepFreeze = (value) => {
|
|
1016
|
+
if (value === null || typeof value !== "object") return;
|
|
1017
|
+
if (Object.isFrozen(value)) return;
|
|
1018
|
+
Object.freeze(value);
|
|
1019
|
+
for (const key of Object.keys(value)) deepFreeze(value[key]);
|
|
1020
|
+
};
|
|
1021
|
+
/**
|
|
1022
|
+
* Validate head config and replace state.config with a deeply frozen copy.
|
|
1023
|
+
*
|
|
1024
|
+
* Runs in the head plugin's `onInit`. Failures surface at `createApp()` time.
|
|
1025
|
+
* `state.ogImageDefaults` is updated to reference the frozen ogImage (or null).
|
|
1026
|
+
*
|
|
1027
|
+
* @param ctx - The head plugin context ({ state, config }).
|
|
1028
|
+
* @throws Error when `ogImage.fonts` entries are missing required fields.
|
|
1029
|
+
*/
|
|
1030
|
+
const validateAndFreeze = (ctx) => {
|
|
1031
|
+
if (ctx.config.ogImage) assertValidOgImage(ctx.config.ogImage);
|
|
1032
|
+
const frozen = { ...ctx.config };
|
|
1033
|
+
if (frozen.ogImage) {
|
|
1034
|
+
frozen.ogImage = { ...frozen.ogImage };
|
|
1035
|
+
if (frozen.ogImage.fonts) frozen.ogImage.fonts = frozen.ogImage.fonts.map((font) => ({
|
|
1036
|
+
...font,
|
|
1037
|
+
weights: [...font.weights]
|
|
1038
|
+
}));
|
|
1039
|
+
}
|
|
1040
|
+
deepFreeze(frozen);
|
|
1041
|
+
ctx.state.config = frozen;
|
|
1042
|
+
ctx.state.ogImageDefaults = frozen.ogImage ?? null;
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
//#endregion
|
|
1046
|
+
//#region src/plugins/head/index.ts
|
|
1047
|
+
/** @file head plugin: SEO primitives + render. Standard tier. jsonLd unicode-escape XSS fix non-negotiable. */
|
|
1048
|
+
const defaultConfig = {
|
|
1049
|
+
titleSeparator: " — ",
|
|
1050
|
+
autoCanonical: true
|
|
1051
|
+
};
|
|
1052
|
+
const head = createPlugin("head", {
|
|
1053
|
+
depends: [
|
|
1054
|
+
site,
|
|
1055
|
+
i18n,
|
|
1056
|
+
router
|
|
1057
|
+
],
|
|
1058
|
+
config: defaultConfig,
|
|
1059
|
+
createState: (ctx) => createHeadState(ctx.config),
|
|
1060
|
+
api: createHeadApi,
|
|
1061
|
+
onInit: validateAndFreeze
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
//#endregion
|
|
1065
|
+
//#region src/plugins/spa/components/api.ts
|
|
1066
|
+
const createComponentsSubApi = (ctx) => ({ register: (def) => {
|
|
1067
|
+
ctx.state.components.registered.push(def);
|
|
1068
|
+
} });
|
|
1069
|
+
|
|
1070
|
+
//#endregion
|
|
1071
|
+
//#region src/plugins/spa/head/sync.ts
|
|
1072
|
+
/** @file Head metadata sync on SPA navigation — title, meta name/property, link[rel=canonical]. */
|
|
1073
|
+
/**
|
|
1074
|
+
* Replace `<title>` text in the current document with the one from `doc.head`.
|
|
1075
|
+
*
|
|
1076
|
+
* @param doc - The source document whose head supplies the new title.
|
|
1077
|
+
*/
|
|
1078
|
+
const syncTitle = (doc) => {
|
|
1079
|
+
const incoming = doc.head.querySelector("title")?.textContent;
|
|
1080
|
+
if (incoming !== null && incoming !== void 0) document.title = incoming;
|
|
1081
|
+
};
|
|
1082
|
+
/**
|
|
1083
|
+
* Resolve the selector for a single incoming meta element, or `null` if it has
|
|
1084
|
+
* neither `name` nor `property` attribute (and is therefore unidentifiable).
|
|
1085
|
+
*
|
|
1086
|
+
* @param incoming - The meta element from the source document.
|
|
1087
|
+
* @returns A CSS selector string or `null`.
|
|
1088
|
+
*/
|
|
1089
|
+
const metaSelector = (incoming) => {
|
|
1090
|
+
const name = incoming.getAttribute("name");
|
|
1091
|
+
if (name !== null) return `meta[name="${name}"]`;
|
|
1092
|
+
const property = incoming.getAttribute("property");
|
|
1093
|
+
if (property !== null) return `meta[property="${property}"]`;
|
|
1094
|
+
return null;
|
|
1095
|
+
};
|
|
1096
|
+
/**
|
|
1097
|
+
* Mirror every `<meta>` tag from src head into the current head, replacing
|
|
1098
|
+
* existing tags that match on either `name` or `property` attribute.
|
|
1099
|
+
*
|
|
1100
|
+
* @param doc - The source document.
|
|
1101
|
+
*/
|
|
1102
|
+
const syncMetaTags = (doc) => {
|
|
1103
|
+
for (const incoming of doc.head.querySelectorAll("meta")) {
|
|
1104
|
+
const selector = metaSelector(incoming);
|
|
1105
|
+
if (selector === null) continue;
|
|
1106
|
+
const existing = document.head.querySelector(selector);
|
|
1107
|
+
const clone = incoming.cloneNode(true);
|
|
1108
|
+
if (existing) existing.replaceWith(clone);
|
|
1109
|
+
else document.head.append(clone);
|
|
1110
|
+
}
|
|
1111
|
+
};
|
|
1112
|
+
/**
|
|
1113
|
+
* Replace `<link rel="canonical">` in the current head if the src doc has one.
|
|
1114
|
+
*
|
|
1115
|
+
* @param doc - The source document.
|
|
1116
|
+
*/
|
|
1117
|
+
const syncCanonical = (doc) => {
|
|
1118
|
+
const incoming = doc.head.querySelector("link[rel=\"canonical\"]");
|
|
1119
|
+
if (!incoming) return;
|
|
1120
|
+
const existing = document.head.querySelector("link[rel=\"canonical\"]");
|
|
1121
|
+
const clone = incoming.cloneNode(true);
|
|
1122
|
+
if (existing) existing.replaceWith(clone);
|
|
1123
|
+
else document.head.append(clone);
|
|
1124
|
+
};
|
|
1125
|
+
/**
|
|
1126
|
+
* Sync the SEO-relevant subset of a foreign document's head into the current
|
|
1127
|
+
* document — title, meta[name|property], link[rel="canonical"]. Idempotent:
|
|
1128
|
+
* running twice with the same source produces the same DOM.
|
|
1129
|
+
*
|
|
1130
|
+
* @param doc - The source document (typically fetched + parsed for SPA nav).
|
|
1131
|
+
*/
|
|
1132
|
+
const syncHead = (doc) => {
|
|
1133
|
+
syncTitle(doc);
|
|
1134
|
+
syncMetaTags(doc);
|
|
1135
|
+
syncCanonical(doc);
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1138
|
+
//#endregion
|
|
1139
|
+
//#region src/plugins/spa/head/api.ts
|
|
1140
|
+
/**
|
|
1141
|
+
* Build the head sub-API.
|
|
1142
|
+
*
|
|
1143
|
+
* `update(doc)` records the incoming document on state and (when running in a
|
|
1144
|
+
* browser) mirrors the SEO-relevant subset of its `<head>` into the live
|
|
1145
|
+
* document via {@link syncHead}.
|
|
1146
|
+
*
|
|
1147
|
+
* @param ctx - SPA context with shared state.
|
|
1148
|
+
* @returns The head sub-API.
|
|
1149
|
+
*/
|
|
1150
|
+
const createHeadSubApi = (ctx) => ({ update: (doc) => {
|
|
1151
|
+
ctx.state.head.currentDoc = doc;
|
|
1152
|
+
if (typeof window !== "undefined") syncHead(doc);
|
|
1153
|
+
} });
|
|
1154
|
+
|
|
1155
|
+
//#endregion
|
|
1156
|
+
//#region src/plugins/spa/progress/api.ts
|
|
1157
|
+
/**
|
|
1158
|
+
* Build the progress sub-API.
|
|
1159
|
+
*
|
|
1160
|
+
* `start()` and `done()` toggle `state.progress.active`; visual rendering is
|
|
1161
|
+
* delegated to a separate progress bar instance installed by `spa.onStart`
|
|
1162
|
+
* (which subscribes to the same state transitions). This keeps `api.ts` pure
|
|
1163
|
+
* for testing — no DOM coupling.
|
|
1164
|
+
*
|
|
1165
|
+
* @param ctx - SPA context with shared state.
|
|
1166
|
+
* @returns The progress sub-API.
|
|
1167
|
+
*/
|
|
1168
|
+
const createProgressSubApi = (ctx) => ({
|
|
1169
|
+
start: () => {
|
|
1170
|
+
ctx.state.progress.active = true;
|
|
1171
|
+
},
|
|
1172
|
+
done: () => {
|
|
1173
|
+
ctx.state.progress.active = false;
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
//#endregion
|
|
1178
|
+
//#region src/plugins/spa/router/api.ts
|
|
1179
|
+
/**
|
|
1180
|
+
* Build the router sub-API.
|
|
1181
|
+
*
|
|
1182
|
+
* `current()` exposes the canonical URL stored on state; `navigate(url)` updates
|
|
1183
|
+
* that URL, appends to history, and emits `nav:start` (with `fromUrl`) before
|
|
1184
|
+
* the transition and `nav:end` after. `destroy()` is a synchronous teardown
|
|
1185
|
+
* hook that clears the in-memory history buffer.
|
|
1186
|
+
*
|
|
1187
|
+
* @param ctx - SPA context with shared state.
|
|
1188
|
+
* @returns The router sub-API.
|
|
1189
|
+
*/
|
|
1190
|
+
const createRouterSubApi = (ctx) => ({
|
|
1191
|
+
current: () => ctx.state.router.currentUrl,
|
|
1192
|
+
navigate: async (url) => {
|
|
1193
|
+
const fromUrl = ctx.state.router.currentUrl;
|
|
1194
|
+
ctx.state.eventBus.emit("nav:start", {
|
|
1195
|
+
url,
|
|
1196
|
+
fromUrl
|
|
1197
|
+
});
|
|
1198
|
+
ctx.state.router.currentUrl = url;
|
|
1199
|
+
ctx.state.router.history.push(url);
|
|
1200
|
+
ctx.state.eventBus.emit("nav:end", { url });
|
|
1201
|
+
},
|
|
1202
|
+
destroy: () => {
|
|
1203
|
+
ctx.state.router.history.length = 0;
|
|
1204
|
+
}
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
//#endregion
|
|
1208
|
+
//#region src/plugins/spa/api.ts
|
|
1209
|
+
/** @file spa plugin composed API factory — namespaced sub-modules sharing state. */
|
|
1210
|
+
const createSpaApi = (ctx) => ({
|
|
1211
|
+
router: createRouterSubApi(ctx),
|
|
1212
|
+
head: createHeadSubApi(ctx),
|
|
1213
|
+
progress: createProgressSubApi(ctx),
|
|
1214
|
+
components: createComponentsSubApi(ctx)
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
//#endregion
|
|
1218
|
+
//#region src/plugins/spa/components/lifecycle.ts
|
|
1219
|
+
/**
|
|
1220
|
+
* Mount a single component instance against an element.
|
|
1221
|
+
*
|
|
1222
|
+
* Calls `onCreate(element)` then `onMount(mountCtx)` in order; both hooks are
|
|
1223
|
+
* awaited so async setup completes before this function resolves. Emits
|
|
1224
|
+
* `component:create` and `component:mount` on the supplied event bus.
|
|
1225
|
+
*
|
|
1226
|
+
* @param def - The component definition produced by `createComponent`.
|
|
1227
|
+
* @param element - The DOM element this instance is bound to.
|
|
1228
|
+
* @param mountCtx - The mount context (container, optional pageData, url).
|
|
1229
|
+
* @param eventBus - The SPA event bus used for `component:*` notifications.
|
|
1230
|
+
* @returns The created `ComponentInstance` ({ def, element }).
|
|
1231
|
+
*/
|
|
1232
|
+
const mountComponent = async (def, element, mountCtx, eventBus) => {
|
|
1233
|
+
if (def.hooks.onCreate) await def.hooks.onCreate(element);
|
|
1234
|
+
eventBus.emit("component:create", {
|
|
1235
|
+
name: def.name,
|
|
1236
|
+
element
|
|
1237
|
+
});
|
|
1238
|
+
if (def.hooks.onMount) await def.hooks.onMount(mountCtx);
|
|
1239
|
+
eventBus.emit("component:mount", {
|
|
1240
|
+
name: def.name,
|
|
1241
|
+
element
|
|
1242
|
+
});
|
|
1243
|
+
return {
|
|
1244
|
+
def,
|
|
1245
|
+
element
|
|
1246
|
+
};
|
|
1247
|
+
};
|
|
1248
|
+
/**
|
|
1249
|
+
* Unmount a component instance.
|
|
1250
|
+
*
|
|
1251
|
+
* Calls `onUnMount(unmountCtx)` then `onDestroy(element)` in order; both hooks
|
|
1252
|
+
* are awaited. Emits `component:unmount` and `component:destroy` on the bus.
|
|
1253
|
+
*
|
|
1254
|
+
* @param instance - The instance returned by `mountComponent`.
|
|
1255
|
+
* @param unmountCtx - The unmount context (reason, element).
|
|
1256
|
+
* @param eventBus - The SPA event bus.
|
|
1257
|
+
*/
|
|
1258
|
+
const unmountComponent = async (instance, unmountCtx, eventBus) => {
|
|
1259
|
+
if (instance.def.hooks.onUnMount) await instance.def.hooks.onUnMount(unmountCtx);
|
|
1260
|
+
eventBus.emit("component:unmount", {
|
|
1261
|
+
name: instance.def.name,
|
|
1262
|
+
reason: unmountCtx.reason
|
|
1263
|
+
});
|
|
1264
|
+
if (instance.def.hooks.onDestroy) await instance.def.hooks.onDestroy(instance.element);
|
|
1265
|
+
eventBus.emit("component:destroy", { name: instance.def.name });
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
//#endregion
|
|
1269
|
+
//#region src/plugins/spa/client-init.ts
|
|
1270
|
+
/** @file Client boot helpers — discover [data-component] islands and mount registered defs against them. */
|
|
1271
|
+
/**
|
|
1272
|
+
* Walk `document.querySelectorAll('[data-component]')` and mount any element
|
|
1273
|
+
* whose `data-component` attribute matches a registered `ComponentDef`.
|
|
1274
|
+
*
|
|
1275
|
+
* Idempotent — instances already present in `state.components.instances` are
|
|
1276
|
+
* skipped so repeat boots (e.g. HMR) do not double-mount.
|
|
1277
|
+
*
|
|
1278
|
+
* @param state - Shared SPA state (registered defs + instances map + eventBus).
|
|
1279
|
+
* @param url - The current URL to thread into each mount context.
|
|
1280
|
+
*/
|
|
1281
|
+
const initComponents = async (state, url) => {
|
|
1282
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1283
|
+
for (const def of state.components.registered) byName.set(def.name, def);
|
|
1284
|
+
const nodes = document.querySelectorAll("[data-component]");
|
|
1285
|
+
for (const element of nodes) {
|
|
1286
|
+
const name = element.getAttribute("data-component");
|
|
1287
|
+
if (name === null) continue;
|
|
1288
|
+
const def = byName.get(name);
|
|
1289
|
+
if (def === void 0) continue;
|
|
1290
|
+
if (state.components.instances.has(element)) continue;
|
|
1291
|
+
const instance = await mountComponent(def, element, {
|
|
1292
|
+
container: element,
|
|
1293
|
+
url
|
|
1294
|
+
}, state.eventBus);
|
|
1295
|
+
state.components.instances.set(element, instance);
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
/**
|
|
1299
|
+
* Unmount every tracked component instance and clear the registry map.
|
|
1300
|
+
*
|
|
1301
|
+
* @param state - Shared SPA state.
|
|
1302
|
+
*/
|
|
1303
|
+
const teardownComponents = async (state) => {
|
|
1304
|
+
for (const [element, instance] of state.components.instances) await unmountComponent(instance, {
|
|
1305
|
+
reason: "destroy",
|
|
1306
|
+
element
|
|
1307
|
+
}, state.eventBus);
|
|
1308
|
+
state.components.instances.clear();
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
//#endregion
|
|
1312
|
+
//#region src/plugins/spa/progress/nprogress.ts
|
|
1313
|
+
/** @file NProgress-style progress bar. Delays visible mount by 150ms to avoid flicker on fast navigations. */
|
|
1314
|
+
const SHOW_DELAY_MS = 150;
|
|
1315
|
+
const PROGRESS_ATTR = "data-moku-progress";
|
|
1316
|
+
/**
|
|
1317
|
+
* Mount the progress bar element into `document.body`.
|
|
1318
|
+
*
|
|
1319
|
+
* @returns The created DOM element.
|
|
1320
|
+
*/
|
|
1321
|
+
const mountBar = () => {
|
|
1322
|
+
const bar = document.createElement("div");
|
|
1323
|
+
bar.setAttribute(PROGRESS_ATTR, "");
|
|
1324
|
+
bar.style.cssText = "position:fixed;top:0;left:0;right:0;height:2px;background:#3b82f6;z-index:9999;pointer-events:none;";
|
|
1325
|
+
document.body.append(bar);
|
|
1326
|
+
return bar;
|
|
1327
|
+
};
|
|
1328
|
+
/**
|
|
1329
|
+
* Unmount any existing progress bar from the DOM.
|
|
1330
|
+
*/
|
|
1331
|
+
const unmountBar = () => {
|
|
1332
|
+
for (const node of document.querySelectorAll(`[${PROGRESS_ATTR}]`)) node.remove();
|
|
1333
|
+
};
|
|
1334
|
+
/**
|
|
1335
|
+
* Create a progress bar controller with `start()` / `done()` lifecycle.
|
|
1336
|
+
*
|
|
1337
|
+
* `start()` arms a 150ms timer; the bar is only inserted into the DOM if the
|
|
1338
|
+
* timer elapses without an intervening `done()` (avoids flicker on instant
|
|
1339
|
+
* navigations). `done()` cancels the pending show OR removes the visible bar.
|
|
1340
|
+
*
|
|
1341
|
+
* @returns Object with `start()` and `done()` methods.
|
|
1342
|
+
*/
|
|
1343
|
+
const createProgressBar = () => {
|
|
1344
|
+
let timer = null;
|
|
1345
|
+
return {
|
|
1346
|
+
start: () => {
|
|
1347
|
+
if (timer !== null) clearTimeout(timer);
|
|
1348
|
+
timer = setTimeout(() => {
|
|
1349
|
+
mountBar();
|
|
1350
|
+
timer = null;
|
|
1351
|
+
}, SHOW_DELAY_MS);
|
|
1352
|
+
},
|
|
1353
|
+
done: () => {
|
|
1354
|
+
if (timer !== null) {
|
|
1355
|
+
clearTimeout(timer);
|
|
1356
|
+
timer = null;
|
|
1357
|
+
}
|
|
1358
|
+
unmountBar();
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
};
|
|
1362
|
+
|
|
1363
|
+
//#endregion
|
|
1364
|
+
//#region src/plugins/spa/router/navigation.ts
|
|
1365
|
+
/**
|
|
1366
|
+
* `true` if the click event carries any modifier (Ctrl/Meta/Shift/Alt) or is
|
|
1367
|
+
* not a primary-button click — both cases should pass through to the browser.
|
|
1368
|
+
*
|
|
1369
|
+
* @param event - The click event.
|
|
1370
|
+
*/
|
|
1371
|
+
const isModifiedClick = (event) => event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey;
|
|
1372
|
+
/**
|
|
1373
|
+
* Locate the nearest `<a>` element in the click event's composed path, if any.
|
|
1374
|
+
*
|
|
1375
|
+
* @param event - The click event.
|
|
1376
|
+
* @returns The anchor element or `undefined`.
|
|
1377
|
+
*/
|
|
1378
|
+
const findAnchor = (event) => {
|
|
1379
|
+
return (event.composedPath ? event.composedPath() : [event.target]).find((node) => node instanceof HTMLAnchorElement || node?.tagName === "A");
|
|
1380
|
+
};
|
|
1381
|
+
/**
|
|
1382
|
+
* `true` if the anchor opts out of SPA navigation (foreign target, download,
|
|
1383
|
+
* empty/missing href, or external origin).
|
|
1384
|
+
*
|
|
1385
|
+
* @param anchor - The candidate anchor.
|
|
1386
|
+
* @returns Boolean opt-out.
|
|
1387
|
+
*/
|
|
1388
|
+
const shouldBypass = (anchor) => {
|
|
1389
|
+
if (anchor.target !== "" && anchor.target !== "_self") return true;
|
|
1390
|
+
if (anchor.hasAttribute("download")) return true;
|
|
1391
|
+
const href = anchor.getAttribute("href");
|
|
1392
|
+
return href === null || href === "";
|
|
1393
|
+
};
|
|
1394
|
+
/**
|
|
1395
|
+
* Decide whether a click event on (or inside) an anchor should be hijacked for
|
|
1396
|
+
* SPA navigation.
|
|
1397
|
+
*
|
|
1398
|
+
* @param event - The click MouseEvent.
|
|
1399
|
+
* @returns The href to navigate to, or `null` if the click should pass through.
|
|
1400
|
+
*/
|
|
1401
|
+
const resolveNavTarget = (event) => {
|
|
1402
|
+
if (isModifiedClick(event)) return null;
|
|
1403
|
+
const anchor = findAnchor(event);
|
|
1404
|
+
if (!anchor || shouldBypass(anchor)) return null;
|
|
1405
|
+
try {
|
|
1406
|
+
const url = new URL(anchor.href, window.location.href);
|
|
1407
|
+
if (url.origin !== window.location.origin) return null;
|
|
1408
|
+
return url.href;
|
|
1409
|
+
} catch {
|
|
1410
|
+
return null;
|
|
1411
|
+
}
|
|
1412
|
+
};
|
|
1413
|
+
/**
|
|
1414
|
+
* Install navigation listeners: popstate (back/forward) + delegated click on
|
|
1415
|
+
* anchors. The supplied `onNavigate` callback is invoked with the new URL when
|
|
1416
|
+
* a navigation should occur; the handler also calls `event.preventDefault()`
|
|
1417
|
+
* to suppress the browser's full-page load.
|
|
1418
|
+
*
|
|
1419
|
+
* Uses the Navigation API when available (`'navigation' in window`); otherwise
|
|
1420
|
+
* relies on the History API + popstate fallback used by happy-dom.
|
|
1421
|
+
*
|
|
1422
|
+
* @param onNavigate - Callback invoked with the destination URL.
|
|
1423
|
+
* @returns Handler with `destroy()` to remove all installed listeners.
|
|
1424
|
+
*/
|
|
1425
|
+
const createNavigationHandler = (onNavigate) => {
|
|
1426
|
+
const onPopState = () => {
|
|
1427
|
+
onNavigate(window.location.href);
|
|
1428
|
+
};
|
|
1429
|
+
const onClick = (event) => {
|
|
1430
|
+
const href = resolveNavTarget(event);
|
|
1431
|
+
if (href === null) return;
|
|
1432
|
+
event.preventDefault();
|
|
1433
|
+
onNavigate(href);
|
|
1434
|
+
};
|
|
1435
|
+
globalThis.addEventListener("popstate", onPopState);
|
|
1436
|
+
document.addEventListener("click", onClick);
|
|
1437
|
+
return { destroy: () => {
|
|
1438
|
+
globalThis.removeEventListener("popstate", onPopState);
|
|
1439
|
+
document.removeEventListener("click", onClick);
|
|
1440
|
+
} };
|
|
1441
|
+
};
|
|
1442
|
+
|
|
1443
|
+
//#endregion
|
|
1444
|
+
//#region src/plugins/spa/boot.ts
|
|
1445
|
+
/** @file SPA client runtime — extracted from index.ts to keep wiring ≤30 lines. */
|
|
1446
|
+
/** Per-state handle registry — keyed by SpaState for HMR-safe teardown. */
|
|
1447
|
+
const handlesByState = /* @__PURE__ */ new WeakMap();
|
|
1448
|
+
/**
|
|
1449
|
+
* Boot the SPA client: mount registered components against discovered islands,
|
|
1450
|
+
* install the progress bar (when enabled), and wire the navigation handler.
|
|
1451
|
+
*
|
|
1452
|
+
* @param ctx - Plugin context with shared state and config.
|
|
1453
|
+
*/
|
|
1454
|
+
const bootClient = async (ctx) => {
|
|
1455
|
+
ctx.state.router.currentUrl = window.location.href;
|
|
1456
|
+
await initComponents(ctx.state, ctx.state.router.currentUrl);
|
|
1457
|
+
const progressBar = ctx.config.config.progressBar === true ? createProgressBar() : null;
|
|
1458
|
+
const navHandler = createNavigationHandler(async (url) => {
|
|
1459
|
+
progressBar?.start();
|
|
1460
|
+
ctx.state.eventBus.emit("nav:start", {
|
|
1461
|
+
url,
|
|
1462
|
+
fromUrl: ctx.state.router.currentUrl
|
|
1463
|
+
});
|
|
1464
|
+
ctx.state.router.currentUrl = url;
|
|
1465
|
+
ctx.state.router.history.push(url);
|
|
1466
|
+
ctx.state.eventBus.emit("nav:end", { url });
|
|
1467
|
+
progressBar?.done();
|
|
1468
|
+
});
|
|
1469
|
+
handlesByState.set(ctx.state, {
|
|
1470
|
+
navHandler,
|
|
1471
|
+
progressBar
|
|
1472
|
+
});
|
|
1473
|
+
};
|
|
1474
|
+
/**
|
|
1475
|
+
* Tear down a previously-booted SPA client: destroy listeners and progress bar,
|
|
1476
|
+
* unmount instances, clear the handle registry.
|
|
1477
|
+
*
|
|
1478
|
+
* @param state - The SPA state whose client resources should be released.
|
|
1479
|
+
*/
|
|
1480
|
+
const teardownClient = async (state) => {
|
|
1481
|
+
const handles = handlesByState.get(state);
|
|
1482
|
+
handles?.navHandler?.destroy();
|
|
1483
|
+
handles?.progressBar?.done();
|
|
1484
|
+
handlesByState.delete(state);
|
|
1485
|
+
await teardownComponents(state);
|
|
1486
|
+
state.components.instances.clear();
|
|
1487
|
+
};
|
|
1488
|
+
/**
|
|
1489
|
+
* Build the client runtime for a single SPA plugin context.
|
|
1490
|
+
*
|
|
1491
|
+
* `start()` registers consumer components and runs the boot sequence; `stop()`
|
|
1492
|
+
* tears it down. Bound to the supplied context so a single call site can drive
|
|
1493
|
+
* the full lifecycle without re-passing state/config.
|
|
1494
|
+
*
|
|
1495
|
+
* @param ctx - Plugin context with state and config.
|
|
1496
|
+
* @returns A `ClientRuntime` with `start` and `stop` methods.
|
|
1497
|
+
*/
|
|
1498
|
+
const createClientRuntime = (ctx) => ({
|
|
1499
|
+
start: async () => {
|
|
1500
|
+
ctx.state.components.instances.clear();
|
|
1501
|
+
for (const def of ctx.config.components) ctx.state.components.registered.push(def);
|
|
1502
|
+
await bootClient(ctx);
|
|
1503
|
+
},
|
|
1504
|
+
stop: async () => {
|
|
1505
|
+
ctx.state.components.instances.clear();
|
|
1506
|
+
await teardownClient(ctx.state);
|
|
1507
|
+
}
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
//#endregion
|
|
1511
|
+
//#region src/plugins/spa/events.ts
|
|
1512
|
+
/**
|
|
1513
|
+
* Register the SPA plugin's event map with the kernel's event registrar.
|
|
1514
|
+
*
|
|
1515
|
+
* @param register - The kernel-provided register function.
|
|
1516
|
+
* @returns The event map describing each event payload + human description.
|
|
1517
|
+
*/
|
|
1518
|
+
const registerSpaEvents = (register) => ({
|
|
1519
|
+
"component:create": register("Component instance created"),
|
|
1520
|
+
"component:mount": register("Component mounted"),
|
|
1521
|
+
"component:unmount": register("Component unmounted"),
|
|
1522
|
+
"component:destroy": register("Component destroyed"),
|
|
1523
|
+
"nav:start": register("Navigation started"),
|
|
1524
|
+
"nav:end": register("Navigation completed")
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
//#endregion
|
|
1528
|
+
//#region src/plugins/spa/components/state.ts
|
|
1529
|
+
const createComponentsState = () => ({
|
|
1530
|
+
instances: /* @__PURE__ */ new Map(),
|
|
1531
|
+
registered: []
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
//#endregion
|
|
1535
|
+
//#region src/plugins/spa/head/state.ts
|
|
1536
|
+
const createHeadSubState = () => ({ currentDoc: null });
|
|
1537
|
+
|
|
1538
|
+
//#endregion
|
|
1539
|
+
//#region src/plugins/spa/progress/state.ts
|
|
1540
|
+
const createProgressState = () => ({
|
|
1541
|
+
active: false,
|
|
1542
|
+
visible: false
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
//#endregion
|
|
1546
|
+
//#region src/plugins/spa/router/state.ts
|
|
1547
|
+
const createRouterState = () => ({
|
|
1548
|
+
currentUrl: "",
|
|
1549
|
+
history: []
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
//#endregion
|
|
1553
|
+
//#region src/plugins/spa/state.ts
|
|
1554
|
+
/** @file spa plugin composed state factory — ALL fresh instances, no module-level singletons. Critical for Vitest worker isolation. */
|
|
1555
|
+
const createEventBus = () => {
|
|
1556
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
1557
|
+
return {
|
|
1558
|
+
on: (event, listener) => {
|
|
1559
|
+
let set = listeners.get(event);
|
|
1560
|
+
if (!set) {
|
|
1561
|
+
set = /* @__PURE__ */ new Set();
|
|
1562
|
+
listeners.set(event, set);
|
|
1563
|
+
}
|
|
1564
|
+
set.add(listener);
|
|
1565
|
+
return () => {
|
|
1566
|
+
listeners.get(event)?.delete(listener);
|
|
1567
|
+
};
|
|
1568
|
+
},
|
|
1569
|
+
emit: (event, payload) => {
|
|
1570
|
+
const set = listeners.get(event);
|
|
1571
|
+
if (set) for (const listener of set) listener(payload);
|
|
1572
|
+
}
|
|
1573
|
+
};
|
|
1574
|
+
};
|
|
1575
|
+
const createSpaState = () => ({
|
|
1576
|
+
router: createRouterState(),
|
|
1577
|
+
head: createHeadSubState(),
|
|
1578
|
+
progress: createProgressState(),
|
|
1579
|
+
components: createComponentsState(),
|
|
1580
|
+
eventBus: createEventBus()
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1583
|
+
//#endregion
|
|
1584
|
+
//#region src/plugins/spa/components/factory.ts
|
|
1585
|
+
const VALID_HOOKS = new Set([
|
|
1586
|
+
"onCreate",
|
|
1587
|
+
"onMount",
|
|
1588
|
+
"onNavStart",
|
|
1589
|
+
"onNavEnd",
|
|
1590
|
+
"onUnMount",
|
|
1591
|
+
"onDestroy"
|
|
1592
|
+
]);
|
|
1593
|
+
const createComponent = (name, hooks) => {
|
|
1594
|
+
for (const key of Object.keys(hooks)) if (!VALID_HOOKS.has(key)) throw new Error(`[spa] Unknown component hook: "${key}" in component "${name}"`);
|
|
1595
|
+
return {
|
|
1596
|
+
name,
|
|
1597
|
+
hooks
|
|
1598
|
+
};
|
|
1599
|
+
};
|
|
1600
|
+
|
|
1601
|
+
//#endregion
|
|
1602
|
+
export { createEnvState as _, createSpaApi as a, route as c, coreConfig as d, createCore as f, validateSchema as g, createLogApi as h, createClientRuntime as i, site as l, createLogState as m, createSpaState as n, head as o, createPlugin as p, registerSpaEvents as r, router as s, createComponent as t, i18n as u, createEnvApi as v };
|