@moku-labs/web 1.13.1 → 1.14.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/index.cjs CHANGED
@@ -1084,6 +1084,38 @@ function dynamicSegmentCount(pattern) {
1084
1084
  return count;
1085
1085
  }
1086
1086
  /**
1087
+ * Whether a route is rendered ENTIRELY on the client in `spa` mode: a dynamic route
1088
+ * (≥1 non-lang param) that declares no build-time `.generate()` enumerator, so its
1089
+ * concrete param paths are unknown until runtime.
1090
+ *
1091
+ * The build SKIPS such a route — emitting a static page for it would write one
1092
+ * param-less shell whose path (`/b/{id}`) matches no file (a 404) and carries no
1093
+ * param for the islands to read. Instead the SPA client-renders it from the URL on
1094
+ * boot and on navigation. Build and client share this ONE predicate (the same way
1095
+ * `dynamicSegmentCount`/`bySpecificity` are shared) so the two sides can never
1096
+ * disagree about which routes are pre-rendered vs. client-only.
1097
+ *
1098
+ * Static routes (`/`) and dynamic routes WITH `.generate()` are pre-rendered as
1099
+ * usual and so are NOT client-only. In `ssg`/`hybrid` mode nothing is client-only
1100
+ * (the build pre-renders every route), so this is always `false` outside `spa`.
1101
+ *
1102
+ * @param mode - The global render mode (`router.mode()`).
1103
+ * @param route - The route to test (only its `pattern` + `.generate()` presence are read).
1104
+ * @param route.pattern - The route's URL pattern string.
1105
+ * @param route._handlers - The route's handler bag.
1106
+ * @param route._handlers.generate - The build-only static-paths enumerator, if any (presence only).
1107
+ * @returns `true` when the route is client-only (spa mode, dynamic, no `.generate()`).
1108
+ * @example
1109
+ * ```ts
1110
+ * isClientOnlyRoute("spa", { pattern: "/b/{id}", _handlers: {} }); // true
1111
+ * isClientOnlyRoute("spa", { pattern: "/", _handlers: {} }); // false (static)
1112
+ * isClientOnlyRoute("hybrid", { pattern: "/b/{id}", _handlers: {} }); // false (not spa)
1113
+ * ```
1114
+ */
1115
+ function isClientOnlyRoute(mode, route) {
1116
+ return mode === "spa" && route._handlers.generate === void 0 && dynamicSegmentCount(route.pattern) > 0;
1117
+ }
1118
+ /**
1087
1119
  * Comparator that orders two routes most-specific-first (fewest dynamic segments
1088
1120
  * first). Equal specificity yields `0` so a stable sort preserves declaration
1089
1121
  * order — the exact ordering the compiled matcher table uses, guaranteeing
@@ -4528,16 +4560,23 @@ function resolveEntry(byPattern, definition) {
4528
4560
  * generate context is the spec `{ locale, require, has }`, so a `.generate()` handler
4529
4561
  * pulls sibling APIs the spec way.
4530
4562
  *
4563
+ * In `spa` mode a client-only route (dynamic, no `.generate()`) is SKIPPED entirely
4564
+ * (`[]`) — it is rendered on the client from the URL, so emitting a static param-less
4565
+ * shell here would only write a file at the wrong path (a 404 for any real param path)
4566
+ * carrying no param. See {@link isClientOnlyRoute}.
4567
+ *
4531
4568
  * @param definition - The route definition from the manifest.
4532
4569
  * @param locale - The active locale to generate param sets for.
4570
+ * @param mode - The global render mode (`router.mode()`); gates the spa client-only skip.
4533
4571
  * @param ctx - Plugin context (provides `require`/`has` for the generate context).
4534
- * @returns The param sets for this route+locale (`[{}]` when there is no `.generate()`).
4572
+ * @returns The param sets for this route+locale (`[{}]` when there is no `.generate()`; `[]` when client-only).
4535
4573
  * @example
4536
4574
  * ```ts
4537
- * const paramSets = await generateParamSets(def, "en", ctx);
4575
+ * const paramSets = await generateParamSets(def, "en", "hybrid", ctx);
4538
4576
  * ```
4539
4577
  */
4540
- async function generateParameterSets(definition, locale, ctx) {
4578
+ async function generateParameterSets(definition, locale, mode, ctx) {
4579
+ if (isClientOnlyRoute(mode, definition)) return [];
4541
4580
  const generateContext = {
4542
4581
  locale,
4543
4582
  require: ctx.require,
@@ -4561,21 +4600,22 @@ async function generateParameterSets(definition, locale, ctx) {
4561
4600
  * @param locales - Active locale codes from i18n.
4562
4601
  * @param defaultLocale - The i18n default locale (kept when locales collapse to one file).
4563
4602
  * @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
4603
+ * @param mode - The global render mode (`router.mode()`); gates the spa client-only skip.
4564
4604
  * @param ctx - Plugin context (provides `require`/`has` for the generate context).
4565
4605
  * @returns The flattened, file-deduplicated list of page instances for this route.
4566
4606
  * @example
4567
4607
  * ```ts
4568
- * await expandRoute(def, ["en"], "en", byPattern, ctx);
4608
+ * await expandRoute(def, ["en"], "en", byPattern, "hybrid", ctx);
4569
4609
  * ```
4570
4610
  */
4571
- async function expandRoute(definition, locales, defaultLocale, byPattern, ctx) {
4611
+ async function expandRoute(definition, locales, defaultLocale, byPattern, mode, ctx) {
4572
4612
  const entry = resolveEntry(byPattern, definition);
4573
4613
  const { name } = entry;
4574
4614
  const orderedLocales = [defaultLocale, ...locales.filter((locale) => locale !== defaultLocale)];
4575
4615
  const instances = [];
4576
4616
  const claimedFiles = /* @__PURE__ */ new Set();
4577
4617
  for (const locale of orderedLocales) {
4578
- const parameterSets = await generateParameterSets(definition, locale, ctx);
4618
+ const parameterSets = await generateParameterSets(definition, locale, mode, ctx);
4579
4619
  for (const raw of parameterSets) {
4580
4620
  const params = raw ?? {};
4581
4621
  const file = entry.toFile(params);
@@ -4698,8 +4738,8 @@ function composeHeadHtml(ctx, instance, url, routeContext, data) {
4698
4738
  * layout is NOT re-applied on navigation), then serialize with
4699
4739
  * preact-render-to-string. Returns `""` when the route has no `.render()`.
4700
4740
  *
4701
- * @param definition - The route definition (provides `.render()`/`.layout()`/`._meta`).
4702
- * @param routeContext - The route context (params/data/locale/url) — extended with `meta` for the layout.
4741
+ * @param definition - The route definition (provides `.render()`/`.layout()`).
4742
+ * @param routeContext - The route context (params/data/locale/meta/url); `meta` flows to the layout.
4703
4743
  * @returns The SSR-rendered body HTML, or `""` when the route has no `.render()`.
4704
4744
  * @example
4705
4745
  * ```ts
@@ -4709,11 +4749,7 @@ function composeHeadHtml(ctx, instance, url, routeContext, data) {
4709
4749
  function renderBody(definition, routeContext) {
4710
4750
  const vnode = definition._handlers.render?.(routeContext);
4711
4751
  if (!vnode) return "";
4712
- const layoutContext = {
4713
- ...routeContext,
4714
- meta: definition._meta
4715
- };
4716
- return (0, preact_render_to_string.renderToString)(definition._handlers.layout ? definition._handlers.layout(layoutContext, vnode) : vnode);
4752
+ return (0, preact_render_to_string.renderToString)(definition._handlers.layout ? definition._handlers.layout(routeContext, vnode) : vnode);
4717
4753
  }
4718
4754
  /**
4719
4755
  * Hash a page's render inputs (its loaded data) for the render cache. `null` when the
@@ -4847,6 +4883,7 @@ async function renderInstance(ctx, instance, shell, reuse) {
4847
4883
  params,
4848
4884
  data,
4849
4885
  locale,
4886
+ meta: definition._meta,
4850
4887
  url: (routeName, routeParams = {}) => router.toUrl(routeName, routeParams)
4851
4888
  };
4852
4889
  const parts = {
@@ -4909,15 +4946,16 @@ async function prepareShell(ctx) {
4909
4946
  * @param locales - Active locale codes from i18n.
4910
4947
  * @param defaultLocale - The i18n default locale (kept when a route's locales collapse).
4911
4948
  * @param byPattern - Pattern→compiled-`TypedRoute` map (see {@link makeEntryMap}).
4949
+ * @param mode - The global render mode (`router.mode()`); gates the spa client-only skip.
4912
4950
  * @param ctx - Plugin context (provides `require`/`has` for generate contexts).
4913
4951
  * @returns The flattened list of page instances to render.
4914
4952
  * @example
4915
4953
  * ```ts
4916
- * const instances = await expandAllInstances(manifest, ["en"], "en", byPattern, ctx);
4954
+ * const instances = await expandAllInstances(manifest, ["en"], "en", byPattern, "hybrid", ctx);
4917
4955
  * ```
4918
4956
  */
4919
- async function expandAllInstances(manifest, locales, defaultLocale, byPattern, ctx) {
4920
- return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, defaultLocale, byPattern, ctx)))).flat();
4957
+ async function expandAllInstances(manifest, locales, defaultLocale, byPattern, mode, ctx) {
4958
+ return (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, defaultLocale, byPattern, mode, ctx)))).flat();
4921
4959
  }
4922
4960
  /**
4923
4961
  * Persist per-page client-data sidecars when the app opts into client navigation
@@ -5027,14 +5065,15 @@ async function renderInBatches(items, batchSize, worker) {
5027
5065
  async function renderPages(ctx, options) {
5028
5066
  const reuse = options?.reuse === true;
5029
5067
  const router = ctx.require(routerPlugin);
5068
+ const mode = router.mode();
5030
5069
  const manifest = router.manifest();
5031
5070
  ctx.state.manifest = [...manifest];
5032
5071
  const locales = ctx.require(i18nPlugin).locales();
5033
5072
  const byPattern = makeEntryMap(router);
5034
5073
  if (!reuse) ctx.state.renderCache.clear();
5035
5074
  const shell = await prepareShell(ctx);
5036
- const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, shell.defaultLocale, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
5037
- await writeDataSidecars(ctx, rendered, router.mode());
5075
+ const rendered = (await renderInBatches(await expandAllInstances(manifest, locales, shell.defaultLocale, byPattern, mode, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse))).flat();
5076
+ await writeDataSidecars(ctx, rendered, mode);
5038
5077
  ctx.log.debug("build:pages", { count: rendered.length });
5039
5078
  return {
5040
5079
  pageCount: rendered.length,
@@ -9056,6 +9095,23 @@ const ERROR_PREFIX$2 = "[web]";
9056
9095
  /** The set of legal hook names, frozen for O(1) membership checks. */
9057
9096
  const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
9058
9097
  /**
9098
+ * No-op link builder for the {@link EMPTY_ROUTE} slice (used when no route matched).
9099
+ *
9100
+ * @returns An empty string.
9101
+ * @example
9102
+ * const href = noUrl();
9103
+ */
9104
+ function noUrl() {
9105
+ return "";
9106
+ }
9107
+ /** Empty route slice — used for mounts with no matched route (headless, tests, public `scan()`). */
9108
+ const EMPTY_ROUTE = {
9109
+ params: {},
9110
+ meta: {},
9111
+ locale: "",
9112
+ url: noUrl
9113
+ };
9114
+ /**
9059
9115
  * Validate a single hook entry: its key must be a known hook name and its value
9060
9116
  * must be a function. Throws fail-fast on the first violation.
9061
9117
  *
@@ -9143,18 +9199,25 @@ function runHook(instance, hook, ctx) {
9143
9199
  instance.def.hooks[hook]?.(ctx);
9144
9200
  }
9145
9201
  /**
9146
- * Builds the component context handed to a hook (the bound element + page data).
9202
+ * Builds the component context handed to a hook: the bound element + page data, merged
9203
+ * with the matched route's slice (params/meta/locale/url). Defaults to {@link EMPTY_ROUTE}
9204
+ * when no route is supplied (headless, tests, public `scan()`).
9147
9205
  *
9148
9206
  * @param element - The element the instance is bound to.
9149
9207
  * @param data - The current page data payload.
9208
+ * @param route - The matched-route slice for the current URL.
9150
9209
  * @returns The hook context.
9151
9210
  * @example
9152
- * const ctx = makeContext(element, data);
9211
+ * const ctx = makeContext(element, data, route);
9153
9212
  */
9154
- function makeContext(element, data) {
9213
+ function makeContext(element, data, route = EMPTY_ROUTE) {
9155
9214
  return {
9156
9215
  el: element,
9157
- data
9216
+ data,
9217
+ params: route.params,
9218
+ meta: route.meta,
9219
+ locale: route.locale,
9220
+ url: route.url
9158
9221
  };
9159
9222
  }
9160
9223
  /**
@@ -9168,17 +9231,18 @@ function makeContext(element, data) {
9168
9231
  * @param swapArea - The swap-region element, or null when none was found.
9169
9232
  * @param data - The current page data payload.
9170
9233
  * @param element - The candidate element carrying a `data-component` attribute.
9234
+ * @param route - The matched-route slice for the current URL (params/meta/locale/url).
9171
9235
  * @example
9172
- * mountElement(state, emit, swapArea, data, element);
9236
+ * mountElement(state, emit, swapArea, data, element, route);
9173
9237
  */
9174
- function mountElement(state, emit, swapArea, data, element) {
9238
+ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE) {
9175
9239
  if (state.instances.has(element)) return;
9176
9240
  const name = element.dataset.component;
9177
9241
  if (!name) return;
9178
9242
  const definition = state.registeredComponents.get(name);
9179
9243
  if (!definition) return;
9180
9244
  const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
9181
- const ctx = makeContext(element, data);
9245
+ const ctx = makeContext(element, data, route);
9182
9246
  runHook(instance, "onCreate", ctx);
9183
9247
  runHook(instance, "onMount", ctx);
9184
9248
  state.instances.set(element, instance);
@@ -9196,14 +9260,15 @@ function mountElement(state, emit, swapArea, data, element) {
9196
9260
  * @param state - The plugin state (registeredComponents + instances).
9197
9261
  * @param emit - The event emitter for spa:component-mount.
9198
9262
  * @param swapSelector - CSS selector bounding page-specific components.
9263
+ * @param route - The matched-route slice for the current URL (params/meta/locale/url).
9199
9264
  * @example
9200
- * scanAndMount(state, emit, "main > section");
9265
+ * scanAndMount(state, emit, "main > section", route);
9201
9266
  */
9202
- function scanAndMount(state, emit, swapSelector) {
9267
+ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
9203
9268
  if (typeof document === "undefined") return;
9204
9269
  const swapArea = document.querySelector(swapSelector);
9205
9270
  const data = extractPageData(document);
9206
- for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element);
9271
+ for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element, route);
9207
9272
  }
9208
9273
  /**
9209
9274
  * Unmounts page-specific instances inside the swap region (runs `onUnMount`
@@ -9269,12 +9334,13 @@ function notifyNavStart(state) {
9269
9334
  * instances were already destroyed and re-created by the swap).
9270
9335
  *
9271
9336
  * @param state - The plugin state holding live instances.
9337
+ * @param route - The matched-route slice for the destination URL (params/meta/locale/url).
9272
9338
  * @example
9273
- * notifyNavEnd(state);
9339
+ * notifyNavEnd(state, route);
9274
9340
  */
9275
- function notifyNavEnd(state) {
9341
+ function notifyNavEnd(state, route = EMPTY_ROUTE) {
9276
9342
  const data = typeof document === "undefined" ? {} : extractPageData(document);
9277
- for (const [element, instance] of state.instances) if (instance.persistent) runHook(instance, "onNavEnd", makeContext(element, data));
9343
+ for (const [element, instance] of state.instances) if (instance.persistent) runHook(instance, "onNavEnd", makeContext(element, data, route));
9278
9344
  }
9279
9345
  //#endregion
9280
9346
  //#region src/plugins/spa/head.ts
@@ -9917,6 +9983,26 @@ function createSpaKernel(state, config, emit, deps) {
9917
9983
  });
9918
9984
  };
9919
9985
  /**
9986
+ * Build the matched-route slice (params/meta/locale/url) for the component context at `path`,
9987
+ * so islands read their route's params/meta directly. An unmatched path yields an empty slice.
9988
+ *
9989
+ * @param path - The URL (pathname + search) to match.
9990
+ * @returns The route slice for the matched route.
9991
+ * @example
9992
+ * scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(pathname));
9993
+ */
9994
+ const componentRouteContext = (path) => {
9995
+ const matchPath = path.split("?")[0] ?? path;
9996
+ const hit = deps.router.match(matchPath);
9997
+ const locale = hit?.params.lang ?? (typeof document === "undefined" ? "" : document.documentElement.lang) ?? "";
9998
+ return {
9999
+ params: hit?.params ?? {},
10000
+ meta: hit?.route._meta ?? {},
10001
+ locale,
10002
+ url: (name, params = {}) => deps.router.toUrl(name, params)
10003
+ };
10004
+ };
10005
+ /**
9920
10006
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
9921
10007
  * When the region cannot be swapped (either document lacks the swap selector)
9922
10008
  * the SPA nav cannot complete — the head is already synced and the islands torn
@@ -9934,8 +10020,9 @@ function createSpaKernel(state, config, emit, deps) {
9934
10020
  syncHead(deps.head, doc);
9935
10021
  unmountPageSpecific(state, emit);
9936
10022
  if (!swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
9937
- scanAndMount(state, emit, resolved.swapSelector);
9938
- notifyNavEnd(state);
10023
+ const routeSlice = componentRouteContext(pathname);
10024
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
10025
+ notifyNavEnd(state, routeSlice);
9939
10026
  }, applyPendingScroll)) {
9940
10027
  handleError();
9941
10028
  location.href = pathname;
@@ -9989,17 +10076,22 @@ function createSpaKernel(state, config, emit, deps) {
9989
10076
  * const resolved = await resolveDataRender("/en/world/");
9990
10077
  */
9991
10078
  const resolveDataRender = async (pathname) => {
9992
- if (!deps.dataAt) return false;
9993
10079
  const matchPath = pathname.split("?")[0] ?? pathname;
9994
10080
  const hit = deps.router.match(matchPath);
9995
10081
  if (!hit?.route._handlers.render) return false;
9996
- const data = await deps.dataAt(pathname);
9997
- if (data === null) return false;
10082
+ let data = {};
10083
+ if (!isClientOnlyRoute(deps.router.mode(), hit.route)) {
10084
+ if (!deps.dataAt) return false;
10085
+ const persisted = await deps.dataAt(pathname);
10086
+ if (persisted === null) return false;
10087
+ data = persisted;
10088
+ }
9998
10089
  const locale = hit.params.lang ?? document.documentElement.lang ?? "";
9999
10090
  const routeContext = {
10000
10091
  params: hit.params,
10001
10092
  data,
10002
10093
  locale,
10094
+ meta: hit.route._meta,
10003
10095
  url: (routeName, routeParams = {}) => deps.router.toUrl(routeName, routeParams)
10004
10096
  };
10005
10097
  const vnode = hit.route._handlers.render(routeContext);
@@ -10031,6 +10123,7 @@ function createSpaKernel(state, config, emit, deps) {
10031
10123
  if (signal?.aborted) return;
10032
10124
  syncDataHead(deps.head, route, routeContext);
10033
10125
  unmountPageSpecific(state, emit);
10126
+ const routeSlice = componentRouteContext(pathname);
10034
10127
  /**
10035
10128
  * Render the VNode into the region and re-mount its islands in one paint — the
10036
10129
  * swap body handed to `runSwap` (optionally wrapped in a View Transition).
@@ -10042,8 +10135,8 @@ function createSpaKernel(state, config, emit, deps) {
10042
10135
  */
10043
10136
  const renderAndMount = () => {
10044
10137
  renderVNode(vnode, region);
10045
- scanAndMount(state, emit, resolved.swapSelector);
10046
- notifyNavEnd(state);
10138
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
10139
+ notifyNavEnd(state, routeSlice);
10047
10140
  };
10048
10141
  runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
10049
10142
  state.currentUrl = pathname;
@@ -10076,6 +10169,30 @@ function createSpaKernel(state, config, emit, deps) {
10076
10169
  }
10077
10170
  };
10078
10171
  /**
10172
+ * Initial-load render for a spa client-only route (dynamic, no `.generate()`): the build emitted
10173
+ * no static HTML for it, so the host served a fallback shell. Client-render the matched route into
10174
+ * the swap region from the URL, then mount its islands — the deep-link / refresh paint. Unlike a
10175
+ * navigation there is nothing to unmount and no `spa:navigated` to emit. If the route cannot be
10176
+ * resolved (defensive — a matched client-only route always resolves), fall back to mounting the
10177
+ * served body so boot still wires up whatever islands the shell does carry.
10178
+ *
10179
+ * @param pathname - The current document path (pathname + search).
10180
+ * @example
10181
+ * await bootRender("/b/abc123");
10182
+ */
10183
+ const bootRender = async (pathname) => {
10184
+ const routeSlice = componentRouteContext(pathname);
10185
+ const resolvedRender = await resolveDataRender(pathname);
10186
+ if (resolvedRender === false) {
10187
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
10188
+ return;
10189
+ }
10190
+ const { vnode, region } = resolvedRender;
10191
+ const { renderVNode } = await Promise.resolve().then(() => require("./render-DLZEOe4M.cjs"));
10192
+ renderVNode(vnode, region);
10193
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
10194
+ };
10195
+ /**
10079
10196
  * Unified navigation: try the client DATA path first (only when the `data`
10080
10197
  * plugin is composed), then fall back to HTML-over-fetch (which itself falls
10081
10198
  * back to a full `location.href` reload). Injected into the router so every
@@ -10120,7 +10237,10 @@ function createSpaKernel(state, config, emit, deps) {
10120
10237
  progress = createProgressBar(resolved.progressBar);
10121
10238
  state.currentUrl = currentLocationUrl();
10122
10239
  state.destroyRouter = attachRouter(handlers, navigate);
10123
- scanAndMount(state, emit, resolved.swapSelector);
10240
+ const matchPath = state.currentUrl.split("?")[0] ?? state.currentUrl;
10241
+ const hit = deps.router.match(matchPath);
10242
+ if (hit?.route._handlers.render && isClientOnlyRoute(deps.router.mode(), hit.route)) bootRender(state.currentUrl);
10243
+ else scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(state.currentUrl));
10124
10244
  state.started = true;
10125
10245
  },
10126
10246
  /**
@@ -10151,7 +10271,7 @@ function createSpaKernel(state, config, emit, deps) {
10151
10271
  * kernel.scan();
10152
10272
  */
10153
10273
  scan() {
10154
- scanAndMount(state, emit, resolved.swapSelector);
10274
+ scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(state.currentUrl));
10155
10275
  },
10156
10276
  /**
10157
10277
  * Tear down router listeners, dispose all instances, reset boot state.
package/dist/index.d.cts CHANGED
@@ -288,7 +288,10 @@ interface RouteState<P extends string = string, D = unknown> {
288
288
  /** Loaded data type produced by `.load()` (widened only by `.load()`). */
289
289
  readonly data: D;
290
290
  }
291
- /** Render-time context handed to `.render()` / `.head()`; `data` is `.load()`'s return. */
291
+ /**
292
+ * Render-time context handed to `.render()` / `.head()`; `data` is `.load()`'s return,
293
+ * `meta` the route's `.meta()` bag.
294
+ */
292
295
  interface RouteContext<S extends RouteState> {
293
296
  /** Resolved path params. */
294
297
  readonly params: S["params"];
@@ -296,6 +299,13 @@ interface RouteContext<S extends RouteState> {
296
299
  readonly data: S["data"];
297
300
  /** Active locale for this render. */
298
301
  readonly locale: string;
302
+ /**
303
+ * The route's `.meta()` bag (e.g. `{ activeTab: "home" }`). Available in `.render()` and
304
+ * `.head()`, identically at build and on the client — `meta` is compiled into the route and
305
+ * shipped in the client manifest, so a client-only route (dynamic, no `.generate()`, whose
306
+ * `.load()` data is `{}` on the client) can feed static per-route config into its render.
307
+ */
308
+ readonly meta: Record<string, unknown>;
299
309
  /**
300
310
  * Build a link to a named route by pattern substitution — the framework delivers
301
311
  * this on the context (same output as `app.router.toUrl`), so render/head build
@@ -376,21 +386,16 @@ interface GenerateContext {
376
386
  readonly has: (name: string) => boolean;
377
387
  }
378
388
  /**
379
- * Context handed to a route's `.layout()` wrapper: the render-time
380
- * {@link RouteContext} plus the route's `.meta()` bag, so persistent chrome (e.g. a
381
- * TopBar/TabNav) can read `locale` and `meta.activeTab`. Distinct from
382
- * `RouteContext` because the layout is the only handler that needs `meta`; keeping
383
- * it on its own type leaves `.render()`/`.head()` contexts unchanged.
389
+ * Context handed to a route's `.layout()` wrapper identical to {@link RouteContext}
390
+ * (which now carries `meta` for every handler). Retained as a named alias so existing
391
+ * `.layout((ctx, children) => …)` typings keep compiling.
384
392
  *
385
393
  * @remarks
386
394
  * The layout is applied in the SSG render path ONLY. On client (SPA) navigation the
387
- * chrome is persistent and the layout is intentionally NOT re-applied — only the
388
- * inner swap region is replaced. See `build`'s pages phase and `spa`'s kernel.
395
+ * chrome is persistent and the layout is intentionally NOT re-applied — only the inner
396
+ * swap region is replaced. See `build`'s pages phase and `spa`'s kernel.
389
397
  */
390
- interface LayoutContext<S extends RouteState> extends RouteContext<S> {
391
- /** The route's `.meta()` bag (e.g. `{ activeTab: "home" }`). */
392
- readonly meta: Record<string, unknown>;
393
- }
398
+ type LayoutContext<S extends RouteState> = RouteContext<S>;
394
399
  /** Head metadata produced by a route's `.head()` handler. */
395
400
  interface HeadConfig$1 {
396
401
  /** Document title. */
@@ -945,12 +950,25 @@ interface ResolvedSpaConfig {
945
950
  /** Pre-registered components. */
946
951
  components: ComponentDef[];
947
952
  }
948
- /** Context handed to every component lifecycle hook. */
953
+ /**
954
+ * Context handed to every component lifecycle hook — the bound element + page data,
955
+ * plus the matched route's `params`/`meta`/`locale` and a link builder, so an island
956
+ * can read its route context (e.g. a `card` route's `ctx.meta.focus` + `ctx.params.id`)
957
+ * directly, without the page bridging it through `data-*` attributes.
958
+ */
949
959
  interface ComponentContext {
950
960
  /** The element the component instance is bound to. */
951
961
  el: Element;
952
962
  /** Page data extracted from the `script#__DATA__` payload. */
953
963
  data: PageData;
964
+ /** Resolved path params of the route matched for the current URL (empty if unmatched). */
965
+ readonly params: Record<string, string | undefined>;
966
+ /** The matched route's `.meta()` bag (empty if unmatched). */
967
+ readonly meta: Record<string, unknown>;
968
+ /** Active locale for the current route (empty string if unknown). */
969
+ readonly locale: string;
970
+ /** Build a link to a named route by pattern substitution (same output as `app.router.toUrl`). */
971
+ readonly url: (name: string, params?: Record<string, string>) => string;
954
972
  }
955
973
  /** Lifecycle hooks a component may implement. */
956
974
  interface ComponentHooks {
package/dist/index.d.mts CHANGED
@@ -286,7 +286,10 @@ interface RouteState<P extends string = string, D = unknown> {
286
286
  /** Loaded data type produced by `.load()` (widened only by `.load()`). */
287
287
  readonly data: D;
288
288
  }
289
- /** Render-time context handed to `.render()` / `.head()`; `data` is `.load()`'s return. */
289
+ /**
290
+ * Render-time context handed to `.render()` / `.head()`; `data` is `.load()`'s return,
291
+ * `meta` the route's `.meta()` bag.
292
+ */
290
293
  interface RouteContext<S extends RouteState> {
291
294
  /** Resolved path params. */
292
295
  readonly params: S["params"];
@@ -294,6 +297,13 @@ interface RouteContext<S extends RouteState> {
294
297
  readonly data: S["data"];
295
298
  /** Active locale for this render. */
296
299
  readonly locale: string;
300
+ /**
301
+ * The route's `.meta()` bag (e.g. `{ activeTab: "home" }`). Available in `.render()` and
302
+ * `.head()`, identically at build and on the client — `meta` is compiled into the route and
303
+ * shipped in the client manifest, so a client-only route (dynamic, no `.generate()`, whose
304
+ * `.load()` data is `{}` on the client) can feed static per-route config into its render.
305
+ */
306
+ readonly meta: Record<string, unknown>;
297
307
  /**
298
308
  * Build a link to a named route by pattern substitution — the framework delivers
299
309
  * this on the context (same output as `app.router.toUrl`), so render/head build
@@ -374,21 +384,16 @@ interface GenerateContext {
374
384
  readonly has: (name: string) => boolean;
375
385
  }
376
386
  /**
377
- * Context handed to a route's `.layout()` wrapper: the render-time
378
- * {@link RouteContext} plus the route's `.meta()` bag, so persistent chrome (e.g. a
379
- * TopBar/TabNav) can read `locale` and `meta.activeTab`. Distinct from
380
- * `RouteContext` because the layout is the only handler that needs `meta`; keeping
381
- * it on its own type leaves `.render()`/`.head()` contexts unchanged.
387
+ * Context handed to a route's `.layout()` wrapper identical to {@link RouteContext}
388
+ * (which now carries `meta` for every handler). Retained as a named alias so existing
389
+ * `.layout((ctx, children) => …)` typings keep compiling.
382
390
  *
383
391
  * @remarks
384
392
  * The layout is applied in the SSG render path ONLY. On client (SPA) navigation the
385
- * chrome is persistent and the layout is intentionally NOT re-applied — only the
386
- * inner swap region is replaced. See `build`'s pages phase and `spa`'s kernel.
393
+ * chrome is persistent and the layout is intentionally NOT re-applied — only the inner
394
+ * swap region is replaced. See `build`'s pages phase and `spa`'s kernel.
387
395
  */
388
- interface LayoutContext<S extends RouteState> extends RouteContext<S> {
389
- /** The route's `.meta()` bag (e.g. `{ activeTab: "home" }`). */
390
- readonly meta: Record<string, unknown>;
391
- }
396
+ type LayoutContext<S extends RouteState> = RouteContext<S>;
392
397
  /** Head metadata produced by a route's `.head()` handler. */
393
398
  interface HeadConfig$1 {
394
399
  /** Document title. */
@@ -943,12 +948,25 @@ interface ResolvedSpaConfig {
943
948
  /** Pre-registered components. */
944
949
  components: ComponentDef[];
945
950
  }
946
- /** Context handed to every component lifecycle hook. */
951
+ /**
952
+ * Context handed to every component lifecycle hook — the bound element + page data,
953
+ * plus the matched route's `params`/`meta`/`locale` and a link builder, so an island
954
+ * can read its route context (e.g. a `card` route's `ctx.meta.focus` + `ctx.params.id`)
955
+ * directly, without the page bridging it through `data-*` attributes.
956
+ */
947
957
  interface ComponentContext {
948
958
  /** The element the component instance is bound to. */
949
959
  el: Element;
950
960
  /** Page data extracted from the `script#__DATA__` payload. */
951
961
  data: PageData;
962
+ /** Resolved path params of the route matched for the current URL (empty if unmatched). */
963
+ readonly params: Record<string, string | undefined>;
964
+ /** The matched route's `.meta()` bag (empty if unmatched). */
965
+ readonly meta: Record<string, unknown>;
966
+ /** Active locale for the current route (empty string if unknown). */
967
+ readonly locale: string;
968
+ /** Build a link to a named route by pattern substitution (same output as `app.router.toUrl`). */
969
+ readonly url: (name: string, params?: Record<string, string>) => string;
952
970
  }
953
971
  /** Lifecycle hooks a component may implement. */
954
972
  interface ComponentHooks {