@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.
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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 };