@moku-labs/web 1.13.2 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.mjs CHANGED
@@ -49,166 +49,9 @@ const createPlugin$1 = coreConfig.createPlugin;
49
49
  */
50
50
  const createCore = coreConfig.createCore;
51
51
  //#endregion
52
- //#region src/plugins/i18n/api.ts
53
- /** Error prefix for all i18n lifecycle failures. */
54
- const ERROR_PREFIX$8 = "[web]";
55
- /**
56
- * Validates the resolved i18n config (fail-fast at `createApp`). Throws when
57
- * `locales` is empty or when `defaultLocale` is not a member of `locales`.
58
- * Errors use the `[web]` prefix with an actionable remediation line.
59
- *
60
- * @param ctx - Plugin context carrying the resolved {@link Config}.
61
- * @param ctx.config - The resolved i18n {@link Config}.
62
- * @throws {Error} If `locales` is empty or `defaultLocale` is not in `locales`.
63
- * @example
64
- * ```ts
65
- * validateI18nConfig({ config: { locales: ["en"], defaultLocale: "en" } });
66
- * ```
67
- */
68
- function validateI18nConfig(ctx) {
69
- const { locales, defaultLocale } = ctx.config;
70
- 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"].`);
71
- 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.`);
72
- }
73
- /**
74
- * Creates the i18n plugin API surface — locale registry accessors plus the
75
- * `t()` translator with default-locale fallback. Every method is a pure read
76
- * of `ctx.config`; none mutate, and `t()` always returns a string.
77
- *
78
- * @param ctx - Plugin context carrying the resolved {@link Config}.
79
- * @param ctx.config - The resolved i18n {@link Config}.
80
- * @returns The {@link Api} accessor surface mounted at `app.i18n`.
81
- * @example
82
- * ```ts
83
- * const api = createI18nApi({ config: { locales: ["en"], defaultLocale: "en" } });
84
- * api.t("en", "nav.home");
85
- * ```
86
- */
87
- function createI18nApi(ctx) {
88
- const { config } = ctx;
89
- return {
90
- /**
91
- * Returns the configured supported locales in declared order.
92
- *
93
- * @returns The configured `locales` list (priority/display order).
94
- * @example
95
- * ```ts
96
- * api.locales(); // ["en", "uk"]
97
- * ```
98
- */
99
- locales() {
100
- return config.locales;
101
- },
102
- /**
103
- * Returns the fallback locale used when a requested locale is absent.
104
- *
105
- * @returns The configured `defaultLocale`.
106
- * @example
107
- * ```ts
108
- * api.defaultLocale(); // "en"
109
- * ```
110
- */
111
- defaultLocale() {
112
- return config.defaultLocale;
113
- },
114
- /**
115
- * Membership guard: whether `x` is one of the supported locales
116
- * (case-sensitive).
117
- *
118
- * @param x - Candidate locale code.
119
- * @returns `true` if `x ∈ locales`, else `false`.
120
- * @example
121
- * ```ts
122
- * api.isLocale("uk"); // true
123
- * ```
124
- */
125
- isLocale(x) {
126
- return config.locales.includes(x);
127
- },
128
- /**
129
- * Human-readable display name for a locale.
130
- *
131
- * @param locale - Locale code to look up.
132
- * @returns The display name, or `undefined` if unmapped.
133
- * @example
134
- * ```ts
135
- * api.localeName("uk"); // "Українська"
136
- * ```
137
- */
138
- localeName(locale) {
139
- return config.localeNames?.[locale];
140
- },
141
- /**
142
- * Open Graph `og:locale` value for a locale.
143
- *
144
- * @param locale - Locale code to look up.
145
- * @returns The `og:locale` value (e.g. `"en_US"`), or `undefined` if unmapped.
146
- * @example
147
- * ```ts
148
- * api.ogLocale("en"); // "en_US"
149
- * ```
150
- */
151
- ogLocale(locale) {
152
- return config.ogLocaleMap?.[locale];
153
- },
154
- /**
155
- * Translate `key` for `locale` with a deterministic fallback chain
156
- * (requested locale → default locale → the key itself). The default-locale
157
- * lookup is skipped when `locale === defaultLocale`.
158
- *
159
- * @param locale - Requested locale code.
160
- * @param key - Translation key (e.g. `"nav.home"`).
161
- * @returns The translated value, the default-locale value, or `key`.
162
- * @example
163
- * ```ts
164
- * api.t("uk", "nav.home"); // "Головна"
165
- * ```
166
- */
167
- t(locale, key) {
168
- const exact = config.translations?.[locale]?.[key];
169
- if (exact !== void 0) return exact;
170
- if (locale !== config.defaultLocale) {
171
- const fallback = config.translations?.[config.defaultLocale]?.[key];
172
- if (fallback !== void 0) return fallback;
173
- }
174
- return key;
175
- }
176
- };
177
- }
178
- /**
179
- * Internationalization plugin — locale registry plus a flat translation helper
180
- * with default-locale fallback. Pure config-as-data (no state or events);
181
- * consumed read-only by content, router, head, and build.
182
- *
183
- * @example Register locales and translations
184
- * ```ts
185
- * const app = createApp({
186
- * pluginConfigs: {
187
- * i18n: {
188
- * locales: ["en", "uk"],
189
- * defaultLocale: "en",
190
- * localeNames: { en: "English", uk: "Українська" },
191
- * translations: { uk: { "nav.home": "Головна" } }
192
- * }
193
- * }
194
- * });
195
- * ```
196
- */
197
- const i18nPlugin = createPlugin$1("i18n", {
198
- config: {
199
- locales: ["en"],
200
- defaultLocale: "en",
201
- localeNames: {},
202
- ogLocaleMap: {},
203
- translations: {}
204
- },
205
- onInit: validateI18nConfig,
206
- api: createI18nApi
207
- });
208
- //#endregion
209
52
  //#region src/plugins/site/api.ts
210
53
  /** Error prefix for all site lifecycle/validation failures. */
211
- const ERROR_PREFIX$7 = "[web]";
54
+ const ERROR_PREFIX$8 = "[web]";
212
55
  /** URL protocols that qualify a parsed URL as an absolute http/https URL. */
213
56
  const HTTP_PROTOCOLS = new Set(["http:", "https:"]);
214
57
  /**
@@ -307,8 +150,8 @@ function isAbsoluteUrl(value) {
307
150
  * ```
308
151
  */
309
152
  function validateSiteConfig(ctx) {
310
- 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.`);
311
- 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".`);
153
+ if (!isNonEmpty(ctx.config.name)) throw new Error(`${ERROR_PREFIX$8} site.name is required.\n Provide a non-empty site name in pluginConfigs.site.name.`);
154
+ if (!isAbsoluteUrl(ctx.config.url)) throw new Error(`${ERROR_PREFIX$8} 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".`);
312
155
  }
313
156
  /**
314
157
  * Creates the site plugin API surface — read-only accessors over frozen config
@@ -421,6 +264,202 @@ const sitePlugin = createPlugin$1("site", {
421
264
  api: createSiteApi
422
265
  });
423
266
  //#endregion
267
+ //#region src/plugins/i18n/api.ts
268
+ /** Error prefix for all i18n lifecycle failures. */
269
+ const ERROR_PREFIX$7 = "[web]";
270
+ /**
271
+ * The framework's default i18n config — a single `"en"` locale with empty lookup
272
+ * maps. Used both as the i18n plugin's `config` default and as the source for
273
+ * {@link fallbackI18n}, so "no i18n config" and "no i18n plugin" resolve identically.
274
+ *
275
+ * @example
276
+ * ```ts
277
+ * createI18nApi({ config: DEFAULT_I18N_CONFIG }).defaultLocale(); // "en"
278
+ * ```
279
+ */
280
+ const DEFAULT_I18N_CONFIG = {
281
+ locales: ["en"],
282
+ defaultLocale: "en",
283
+ localeNames: {},
284
+ ogLocaleMap: {},
285
+ translations: {}
286
+ };
287
+ /**
288
+ * Validates the resolved i18n config (fail-fast at `createApp`). Throws when
289
+ * `locales` is empty or when `defaultLocale` is not a member of `locales`.
290
+ * Errors use the `[web]` prefix with an actionable remediation line.
291
+ *
292
+ * @param ctx - Plugin context carrying the resolved {@link Config}.
293
+ * @param ctx.config - The resolved i18n {@link Config}.
294
+ * @throws {Error} If `locales` is empty or `defaultLocale` is not in `locales`.
295
+ * @example
296
+ * ```ts
297
+ * validateI18nConfig({ config: { locales: ["en"], defaultLocale: "en" } });
298
+ * ```
299
+ */
300
+ function validateI18nConfig(ctx) {
301
+ const { locales, defaultLocale } = ctx.config;
302
+ if (locales.length === 0) throw new Error(`${ERROR_PREFIX$7} i18n.locales must contain at least one locale.\n Set pluginConfigs.i18n.locales to a non-empty array, e.g. ["en"].`);
303
+ if (!locales.includes(defaultLocale)) throw new Error(`${ERROR_PREFIX$7} 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.`);
304
+ }
305
+ /**
306
+ * Creates the i18n plugin API surface — locale registry accessors plus the
307
+ * `t()` translator with default-locale fallback. Every method is a pure read
308
+ * of `ctx.config`; none mutate, and `t()` always returns a string.
309
+ *
310
+ * @param ctx - Plugin context carrying the resolved {@link Config}.
311
+ * @param ctx.config - The resolved i18n {@link Config}.
312
+ * @returns The {@link Api} accessor surface mounted at `app.i18n`.
313
+ * @example
314
+ * ```ts
315
+ * const api = createI18nApi({ config: { locales: ["en"], defaultLocale: "en" } });
316
+ * api.t("en", "nav.home");
317
+ * ```
318
+ */
319
+ function createI18nApi(ctx) {
320
+ const { config } = ctx;
321
+ return {
322
+ /**
323
+ * Returns the configured supported locales in declared order.
324
+ *
325
+ * @returns The configured `locales` list (priority/display order).
326
+ * @example
327
+ * ```ts
328
+ * api.locales(); // ["en", "uk"]
329
+ * ```
330
+ */
331
+ locales() {
332
+ return config.locales;
333
+ },
334
+ /**
335
+ * Returns the fallback locale used when a requested locale is absent.
336
+ *
337
+ * @returns The configured `defaultLocale`.
338
+ * @example
339
+ * ```ts
340
+ * api.defaultLocale(); // "en"
341
+ * ```
342
+ */
343
+ defaultLocale() {
344
+ return config.defaultLocale;
345
+ },
346
+ /**
347
+ * Membership guard: whether `x` is one of the supported locales
348
+ * (case-sensitive).
349
+ *
350
+ * @param x - Candidate locale code.
351
+ * @returns `true` if `x ∈ locales`, else `false`.
352
+ * @example
353
+ * ```ts
354
+ * api.isLocale("uk"); // true
355
+ * ```
356
+ */
357
+ isLocale(x) {
358
+ return config.locales.includes(x);
359
+ },
360
+ /**
361
+ * Human-readable display name for a locale.
362
+ *
363
+ * @param locale - Locale code to look up.
364
+ * @returns The display name, or `undefined` if unmapped.
365
+ * @example
366
+ * ```ts
367
+ * api.localeName("uk"); // "Українська"
368
+ * ```
369
+ */
370
+ localeName(locale) {
371
+ return config.localeNames?.[locale];
372
+ },
373
+ /**
374
+ * Open Graph `og:locale` value for a locale.
375
+ *
376
+ * @param locale - Locale code to look up.
377
+ * @returns The `og:locale` value (e.g. `"en_US"`), or `undefined` if unmapped.
378
+ * @example
379
+ * ```ts
380
+ * api.ogLocale("en"); // "en_US"
381
+ * ```
382
+ */
383
+ ogLocale(locale) {
384
+ return config.ogLocaleMap?.[locale];
385
+ },
386
+ /**
387
+ * Translate `key` for `locale` with a deterministic fallback chain
388
+ * (requested locale → default locale → the key itself). The default-locale
389
+ * lookup is skipped when `locale === defaultLocale`.
390
+ *
391
+ * @param locale - Requested locale code.
392
+ * @param key - Translation key (e.g. `"nav.home"`).
393
+ * @returns The translated value, the default-locale value, or `key`.
394
+ * @example
395
+ * ```ts
396
+ * api.t("uk", "nav.home"); // "Головна"
397
+ * ```
398
+ */
399
+ t(locale, key) {
400
+ const exact = config.translations?.[locale]?.[key];
401
+ if (exact !== void 0) return exact;
402
+ if (locale !== config.defaultLocale) {
403
+ const fallback = config.translations?.[config.defaultLocale]?.[key];
404
+ if (fallback !== void 0) return fallback;
405
+ }
406
+ return key;
407
+ }
408
+ };
409
+ }
410
+ /**
411
+ * The i18n API a consumer sees when the i18n plugin is NOT composed: a single
412
+ * default locale (`"en"`) with empty maps. `locales()` is `["en"]`,
413
+ * `defaultLocale()` is `"en"`, and every map lookup misses (`undefined`, or the
414
+ * key for `t()`). Identical to composing the i18n plugin with its defaults — which
415
+ * is what makes i18n optional: `router`/`head`/`content`/`build` fall back to this
416
+ * when `ctx.has("i18n")` is false, leaving every downstream call unchanged.
417
+ *
418
+ * @example
419
+ * ```ts
420
+ * const i18n = ctx.has("i18n") ? ctx.require(i18nPlugin) : fallbackI18n;
421
+ * i18n.locales(); // ["en"]
422
+ * ```
423
+ */
424
+ const fallbackI18n = createI18nApi({ config: DEFAULT_I18N_CONFIG });
425
+ //#endregion
426
+ //#region src/plugins/i18n/index.ts
427
+ /**
428
+ * i18n — Micro tier. Multi-file layout (index wiring + api.ts + types.ts) so
429
+ * index.ts stays within the ≤30-line wiring-only hook; logic lives in api.ts.
430
+ *
431
+ * Locale registry + flat translation helper with default-locale fallback.
432
+ * Pure config-as-data: no state, no events, no lifecycle resources.
433
+ * Consumed read-only by content/router/head/build via `ctx.require(i18nPlugin)`.
434
+ *
435
+ * @file i18n plugin wiring harness.
436
+ * @see README.md
437
+ */
438
+ /**
439
+ * Internationalization plugin — locale registry plus a flat translation helper
440
+ * with default-locale fallback. Pure config-as-data (no state or events);
441
+ * consumed read-only by content, router, head, and build.
442
+ *
443
+ * @example Register locales and translations
444
+ * ```ts
445
+ * const app = createApp({
446
+ * pluginConfigs: {
447
+ * i18n: {
448
+ * locales: ["en", "uk"],
449
+ * defaultLocale: "en",
450
+ * localeNames: { en: "English", uk: "Українська" },
451
+ * translations: { uk: { "nav.home": "Головна" } }
452
+ * }
453
+ * }
454
+ * });
455
+ * ```
456
+ */
457
+ const i18nPlugin = createPlugin$1("i18n", {
458
+ config: DEFAULT_I18N_CONFIG,
459
+ onInit: validateI18nConfig,
460
+ api: createI18nApi
461
+ });
462
+ //#endregion
424
463
  //#region src/plugins/router/iso-match.ts
425
464
  /**
426
465
  * Parse a single path segment into its `{…}` placeholder, or `false` for a static
@@ -1004,8 +1043,9 @@ function compileRoutes(input) {
1004
1043
  const ERROR_PREFIX$5 = "[web] router";
1005
1044
  /**
1006
1045
  * Validate a route map and compile it into the matcher table on `ctx.state`,
1007
- * resolving the global render `mode` + site base URL + i18n locales at call time.
1008
- * Called by the router's `onInit` to compile `config.routes`. Re-calling replaces the table.
1046
+ * resolving the global render `mode` + site base URL + i18n locales (or the single
1047
+ * default-locale fallback when i18n is not composed) at call time. Called by the
1048
+ * router's `onInit` to compile `config.routes`. Re-calling replaces the table.
1009
1049
  *
1010
1050
  * @param ctx - The router register context (state + global mode + require).
1011
1051
  * @param routes - The route map to compile (an `import * as routes` namespace works).
@@ -1017,7 +1057,7 @@ const ERROR_PREFIX$5 = "[web] router";
1017
1057
  */
1018
1058
  function registerRoutes(ctx, routes) {
1019
1059
  validateRoutes(routes);
1020
- const i18n = ctx.require(i18nPlugin);
1060
+ const i18n = ctx.has("i18n") ? ctx.require(i18nPlugin) : fallbackI18n;
1021
1061
  ctx.state.table = compileRoutes({
1022
1062
  routes,
1023
1063
  mode: ctx.global.mode,
@@ -1442,8 +1482,8 @@ function createState$2(_ctx) {
1442
1482
  /**
1443
1483
  * Router plugin — typed, named route definitions with locale-aware URL generation
1444
1484
  * and matching. Author routes with {@link route}, then register them the normal config
1445
- * way via `pluginConfigs.router.routes` (compiled at init). Depends on site (base URL)
1446
- * and i18n (locales).
1485
+ * way via `pluginConfigs.router.routes` (compiled at init). Depends on site (base URL);
1486
+ * i18n (locales) is OPTIONAL — falls back to a single default locale ("en") when absent.
1447
1487
  *
1448
1488
  * @example Register routes via config, then start/build
1449
1489
  * ```ts
@@ -1456,7 +1496,7 @@ function createState$2(_ctx) {
1456
1496
  * ```
1457
1497
  */
1458
1498
  const routerPlugin = createPlugin$1("router", {
1459
- depends: [sitePlugin, i18nPlugin],
1499
+ depends: [sitePlugin],
1460
1500
  helpers: {
1461
1501
  route,
1462
1502
  defineRoutes,
@@ -1898,7 +1938,8 @@ function serializeHead(elements) {
1898
1938
  *
1899
1939
  * The `render` method pulls `site`/`i18n`/`router` via `ctx.require` at call time,
1900
1940
  * composes the head element set via the shared `compose.ts` module, and serializes
1901
- * it to a string. It holds no resource and caches no subscription.
1941
+ * it to a string. It holds no resource and caches no subscription. `i18n` is
1942
+ * OPTIONAL — a single default-locale fallback is used when it is not composed.
1902
1943
  */
1903
1944
  /** Error prefix for head API invariant failures. */
1904
1945
  const ERROR_PREFIX$4 = "[web] head";
@@ -1950,7 +1991,7 @@ function createApi$1(ctx) {
1950
1991
  data,
1951
1992
  defaults: readDefaults(ctx.state),
1952
1993
  site: ctx.require(sitePlugin),
1953
- i18n: ctx.require(i18nPlugin),
1994
+ i18n: ctx.has("i18n") ? ctx.require(i18nPlugin) : fallbackI18n,
1954
1995
  router: ctx.require(routerPlugin)
1955
1996
  }));
1956
1997
  },
@@ -1970,7 +2011,8 @@ function createApi$1(ctx) {
1970
2011
  */
1971
2012
  siteHead(input) {
1972
2013
  const site = ctx.require(sitePlugin);
1973
- const ogLocale = input.locale === void 0 ? void 0 : ctx.require(i18nPlugin).ogLocale(input.locale);
2014
+ const i18n = ctx.has("i18n") ? ctx.require(i18nPlugin) : fallbackI18n;
2015
+ const ogLocale = input.locale === void 0 ? void 0 : i18n.ogLocale(input.locale);
1974
2016
  return serializeHead(composeSiteHead({
1975
2017
  site,
1976
2018
  defaults: readDefaults(ctx.state),
@@ -2107,7 +2149,7 @@ function createState$1(_ctx) {
2107
2149
  * Head plugin — composes per-route `<head>` metadata (title template, Open Graph,
2108
2150
  * Twitter cards, canonical, hreflang). Use the re-exported SEO primitives
2109
2151
  * ({@link meta}, {@link og}, {@link twitter}, …) inside a route's `.head()`.
2110
- * Depends on site, i18n, and router.
2152
+ * Depends on site and router; i18n is OPTIONAL (single default-locale fallback when absent).
2111
2153
  *
2112
2154
  * @example Set global head defaults
2113
2155
  * ```ts
@@ -2123,11 +2165,7 @@ function createState$1(_ctx) {
2123
2165
  * ```
2124
2166
  */
2125
2167
  const headPlugin = createPlugin$1("head", {
2126
- depends: [
2127
- sitePlugin,
2128
- i18nPlugin,
2129
- routerPlugin
2130
- ],
2168
+ depends: [sitePlugin, routerPlugin],
2131
2169
  helpers: headHelpers,
2132
2170
  config: defaultConfig,
2133
2171
  createState: createState$1,
@@ -2473,6 +2511,23 @@ const ERROR_PREFIX$2 = "[web]";
2473
2511
  /** The set of legal hook names, frozen for O(1) membership checks. */
2474
2512
  const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
2475
2513
  /**
2514
+ * No-op link builder for the {@link EMPTY_ROUTE} slice (used when no route matched).
2515
+ *
2516
+ * @returns An empty string.
2517
+ * @example
2518
+ * const href = noUrl();
2519
+ */
2520
+ function noUrl() {
2521
+ return "";
2522
+ }
2523
+ /** Empty route slice — used for mounts with no matched route (headless, tests, public `scan()`). */
2524
+ const EMPTY_ROUTE = {
2525
+ params: {},
2526
+ meta: {},
2527
+ locale: "",
2528
+ url: noUrl
2529
+ };
2530
+ /**
2476
2531
  * Validate a single hook entry: its key must be a known hook name and its value
2477
2532
  * must be a function. Throws fail-fast on the first violation.
2478
2533
  *
@@ -2560,18 +2615,25 @@ function runHook(instance, hook, ctx) {
2560
2615
  instance.def.hooks[hook]?.(ctx);
2561
2616
  }
2562
2617
  /**
2563
- * Builds the component context handed to a hook (the bound element + page data).
2618
+ * Builds the component context handed to a hook: the bound element + page data, merged
2619
+ * with the matched route's slice (params/meta/locale/url). Defaults to {@link EMPTY_ROUTE}
2620
+ * when no route is supplied (headless, tests, public `scan()`).
2564
2621
  *
2565
2622
  * @param element - The element the instance is bound to.
2566
2623
  * @param data - The current page data payload.
2624
+ * @param route - The matched-route slice for the current URL.
2567
2625
  * @returns The hook context.
2568
2626
  * @example
2569
- * const ctx = makeContext(element, data);
2627
+ * const ctx = makeContext(element, data, route);
2570
2628
  */
2571
- function makeContext(element, data) {
2629
+ function makeContext(element, data, route = EMPTY_ROUTE) {
2572
2630
  return {
2573
2631
  el: element,
2574
- data
2632
+ data,
2633
+ params: route.params,
2634
+ meta: route.meta,
2635
+ locale: route.locale,
2636
+ url: route.url
2575
2637
  };
2576
2638
  }
2577
2639
  /**
@@ -2585,17 +2647,18 @@ function makeContext(element, data) {
2585
2647
  * @param swapArea - The swap-region element, or null when none was found.
2586
2648
  * @param data - The current page data payload.
2587
2649
  * @param element - The candidate element carrying a `data-component` attribute.
2650
+ * @param route - The matched-route slice for the current URL (params/meta/locale/url).
2588
2651
  * @example
2589
- * mountElement(state, emit, swapArea, data, element);
2652
+ * mountElement(state, emit, swapArea, data, element, route);
2590
2653
  */
2591
- function mountElement(state, emit, swapArea, data, element) {
2654
+ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE) {
2592
2655
  if (state.instances.has(element)) return;
2593
2656
  const name = element.dataset.component;
2594
2657
  if (!name) return;
2595
2658
  const definition = state.registeredComponents.get(name);
2596
2659
  if (!definition) return;
2597
2660
  const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
2598
- const ctx = makeContext(element, data);
2661
+ const ctx = makeContext(element, data, route);
2599
2662
  runHook(instance, "onCreate", ctx);
2600
2663
  runHook(instance, "onMount", ctx);
2601
2664
  state.instances.set(element, instance);
@@ -2613,14 +2676,15 @@ function mountElement(state, emit, swapArea, data, element) {
2613
2676
  * @param state - The plugin state (registeredComponents + instances).
2614
2677
  * @param emit - The event emitter for spa:component-mount.
2615
2678
  * @param swapSelector - CSS selector bounding page-specific components.
2679
+ * @param route - The matched-route slice for the current URL (params/meta/locale/url).
2616
2680
  * @example
2617
- * scanAndMount(state, emit, "main > section");
2681
+ * scanAndMount(state, emit, "main > section", route);
2618
2682
  */
2619
- function scanAndMount(state, emit, swapSelector) {
2683
+ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
2620
2684
  if (typeof document === "undefined") return;
2621
2685
  const swapArea = document.querySelector(swapSelector);
2622
2686
  const data = extractPageData(document);
2623
- for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element);
2687
+ for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element, route);
2624
2688
  }
2625
2689
  /**
2626
2690
  * Unmounts page-specific instances inside the swap region (runs `onUnMount`
@@ -2686,12 +2750,13 @@ function notifyNavStart(state) {
2686
2750
  * instances were already destroyed and re-created by the swap).
2687
2751
  *
2688
2752
  * @param state - The plugin state holding live instances.
2753
+ * @param route - The matched-route slice for the destination URL (params/meta/locale/url).
2689
2754
  * @example
2690
- * notifyNavEnd(state);
2755
+ * notifyNavEnd(state, route);
2691
2756
  */
2692
- function notifyNavEnd(state) {
2757
+ function notifyNavEnd(state, route = EMPTY_ROUTE) {
2693
2758
  const data = typeof document === "undefined" ? {} : extractPageData(document);
2694
- for (const [element, instance] of state.instances) if (instance.persistent) runHook(instance, "onNavEnd", makeContext(element, data));
2759
+ for (const [element, instance] of state.instances) if (instance.persistent) runHook(instance, "onNavEnd", makeContext(element, data, route));
2695
2760
  }
2696
2761
  //#endregion
2697
2762
  //#region src/plugins/spa/head.ts
@@ -3334,6 +3399,26 @@ function createSpaKernel(state, config, emit, deps) {
3334
3399
  });
3335
3400
  };
3336
3401
  /**
3402
+ * Build the matched-route slice (params/meta/locale/url) for the component context at `path`,
3403
+ * so islands read their route's params/meta directly. An unmatched path yields an empty slice.
3404
+ *
3405
+ * @param path - The URL (pathname + search) to match.
3406
+ * @returns The route slice for the matched route.
3407
+ * @example
3408
+ * scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(pathname));
3409
+ */
3410
+ const componentRouteContext = (path) => {
3411
+ const matchPath = path.split("?")[0] ?? path;
3412
+ const hit = deps.router.match(matchPath);
3413
+ const locale = hit?.params.lang ?? (typeof document === "undefined" ? "" : document.documentElement.lang) ?? "";
3414
+ return {
3415
+ params: hit?.params ?? {},
3416
+ meta: hit?.route._meta ?? {},
3417
+ locale,
3418
+ url: (name, params = {}) => deps.router.toUrl(name, params)
3419
+ };
3420
+ };
3421
+ /**
3337
3422
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
3338
3423
  * When the region cannot be swapped (either document lacks the swap selector)
3339
3424
  * the SPA nav cannot complete — the head is already synced and the islands torn
@@ -3351,8 +3436,9 @@ function createSpaKernel(state, config, emit, deps) {
3351
3436
  syncHead(deps.head, doc);
3352
3437
  unmountPageSpecific(state, emit);
3353
3438
  if (!swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
3354
- scanAndMount(state, emit, resolved.swapSelector);
3355
- notifyNavEnd(state);
3439
+ const routeSlice = componentRouteContext(pathname);
3440
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
3441
+ notifyNavEnd(state, routeSlice);
3356
3442
  }, applyPendingScroll)) {
3357
3443
  handleError();
3358
3444
  location.href = pathname;
@@ -3421,6 +3507,7 @@ function createSpaKernel(state, config, emit, deps) {
3421
3507
  params: hit.params,
3422
3508
  data,
3423
3509
  locale,
3510
+ meta: hit.route._meta,
3424
3511
  url: (routeName, routeParams = {}) => deps.router.toUrl(routeName, routeParams)
3425
3512
  };
3426
3513
  const vnode = hit.route._handlers.render(routeContext);
@@ -3452,6 +3539,7 @@ function createSpaKernel(state, config, emit, deps) {
3452
3539
  if (signal?.aborted) return;
3453
3540
  syncDataHead(deps.head, route, routeContext);
3454
3541
  unmountPageSpecific(state, emit);
3542
+ const routeSlice = componentRouteContext(pathname);
3455
3543
  /**
3456
3544
  * Render the VNode into the region and re-mount its islands in one paint — the
3457
3545
  * swap body handed to `runSwap` (optionally wrapped in a View Transition).
@@ -3463,8 +3551,8 @@ function createSpaKernel(state, config, emit, deps) {
3463
3551
  */
3464
3552
  const renderAndMount = () => {
3465
3553
  renderVNode(vnode, region);
3466
- scanAndMount(state, emit, resolved.swapSelector);
3467
- notifyNavEnd(state);
3554
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
3555
+ notifyNavEnd(state, routeSlice);
3468
3556
  };
3469
3557
  runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
3470
3558
  state.currentUrl = pathname;
@@ -3509,15 +3597,16 @@ function createSpaKernel(state, config, emit, deps) {
3509
3597
  * await bootRender("/b/abc123");
3510
3598
  */
3511
3599
  const bootRender = async (pathname) => {
3600
+ const routeSlice = componentRouteContext(pathname);
3512
3601
  const resolvedRender = await resolveDataRender(pathname);
3513
3602
  if (resolvedRender === false) {
3514
- scanAndMount(state, emit, resolved.swapSelector);
3603
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
3515
3604
  return;
3516
3605
  }
3517
3606
  const { vnode, region } = resolvedRender;
3518
3607
  const { renderVNode } = await import("./render-BNe0s7fr.mjs");
3519
3608
  renderVNode(vnode, region);
3520
- scanAndMount(state, emit, resolved.swapSelector);
3609
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
3521
3610
  };
3522
3611
  /**
3523
3612
  * Unified navigation: try the client DATA path first (only when the `data`
@@ -3567,7 +3656,7 @@ function createSpaKernel(state, config, emit, deps) {
3567
3656
  const matchPath = state.currentUrl.split("?")[0] ?? state.currentUrl;
3568
3657
  const hit = deps.router.match(matchPath);
3569
3658
  if (hit?.route._handlers.render && isClientOnlyRoute(deps.router.mode(), hit.route)) bootRender(state.currentUrl);
3570
- else scanAndMount(state, emit, resolved.swapSelector);
3659
+ else scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(state.currentUrl));
3571
3660
  state.started = true;
3572
3661
  },
3573
3662
  /**
@@ -3598,7 +3687,7 @@ function createSpaKernel(state, config, emit, deps) {
3598
3687
  * kernel.scan();
3599
3688
  */
3600
3689
  scan() {
3601
- scanAndMount(state, emit, resolved.swapSelector);
3690
+ scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(state.currentUrl));
3602
3691
  },
3603
3692
  /**
3604
3693
  * Tear down router listeners, dispose all instances, reset boot state.
@@ -3908,7 +3997,8 @@ function articleNotFound(slug, locale) {
3908
3997
  return /* @__PURE__ */ new Error(`[web] content article "${slug}" not found for locale "${locale}".\n Looked for ${slug}/${locale}.md and the default-locale fallback.`);
3909
3998
  }
3910
3999
  /**
3911
- * Plugin `api` factory: resolves i18n via `ctx.require`, merges `config.providers` into
4000
+ * Plugin `api` factory: resolves i18n via `ctx.require` (or the single default-locale
4001
+ * fallback when i18n is not composed), merges `config.providers` into
3912
4002
  * one source, assembles the kernel-free {@link ContentApiContext}, and delegates to
3913
4003
  * {@link createContentApi}. Referenced directly as the plugin's `api` so index.ts stays
3914
4004
  * wiring-only. Imports no node code (the provider owns it).
@@ -3921,7 +4011,7 @@ function articleNotFound(slug, locale) {
3921
4011
  * ```
3922
4012
  */
3923
4013
  function contentApi(ctx) {
3924
- const i18nApi = ctx.require(i18nPlugin);
4014
+ const i18nApi = ctx.has("i18n") ? ctx.require(i18nPlugin) : fallbackI18n;
3925
4015
  /**
3926
4016
  * Active locale codes from i18n.
3927
4017
  *
@@ -4326,8 +4416,8 @@ function validateContentConfig(config) {
4326
4416
  * @file content — Complex Plugin skeleton (wiring-only).
4327
4417
  *
4328
4418
  * Markdown pipeline: discover, parse frontmatter, render to sanitized HTML, and
4329
- * expose a locale-keyed Article model. Depends on i18n. Emits `content:ready`
4330
- * and `content:invalidated`.
4419
+ * expose a locale-keyed Article model. i18n is OPTIONAL (single default-locale
4420
+ * fallback when absent). Emits `content:ready` and `content:invalidated`.
4331
4421
  * @see README.md
4332
4422
  */
4333
4423
  /**
@@ -4335,7 +4425,8 @@ function validateContentConfig(config) {
4335
4425
  * (locale fallback, draft filtering, sort, caching, events) lives here; source I/O +
4336
4426
  * the Markdown pipeline live in a {@link ContentProvider} you compose (like `env`
4337
4427
  * providers). The shell imports zero node code, so `contentPlugin` is browser-safe.
4338
- * Depends on i18n; emits `content:ready` and `content:invalidated`.
4428
+ * i18n is OPTIONAL (single default-locale fallback when absent); emits `content:ready`
4429
+ * and `content:invalidated`.
4339
4430
  *
4340
4431
  * @example Compose the node filesystem provider with a content dir + Shiki theme
4341
4432
  * ```ts
@@ -4351,7 +4442,6 @@ function validateContentConfig(config) {
4351
4442
  * ```
4352
4443
  */
4353
4444
  const contentPlugin = createPlugin$1("content", {
4354
- depends: [i18nPlugin],
4355
4445
  events: contentEvents,
4356
4446
  config: defaultContentConfig,
4357
4447
  createState: createContentState,