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