@moku-labs/web 0.4.2 → 0.5.1

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