@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.
@@ -287,7 +287,10 @@ interface RouteState<P extends string = string, D = unknown> {
287
287
  /** Loaded data type produced by `.load()` (widened only by `.load()`). */
288
288
  readonly data: D;
289
289
  }
290
- /** Render-time context handed to `.render()` / `.head()`; `data` is `.load()`'s return. */
290
+ /**
291
+ * Render-time context handed to `.render()` / `.head()`; `data` is `.load()`'s return,
292
+ * `meta` the route's `.meta()` bag.
293
+ */
291
294
  interface RouteContext<S extends RouteState> {
292
295
  /** Resolved path params. */
293
296
  readonly params: S["params"];
@@ -295,6 +298,13 @@ interface RouteContext<S extends RouteState> {
295
298
  readonly data: S["data"];
296
299
  /** Active locale for this render. */
297
300
  readonly locale: string;
301
+ /**
302
+ * The route's `.meta()` bag (e.g. `{ activeTab: "home" }`). Available in `.render()` and
303
+ * `.head()`, identically at build and on the client — `meta` is compiled into the route and
304
+ * shipped in the client manifest, so a client-only route (dynamic, no `.generate()`, whose
305
+ * `.load()` data is `{}` on the client) can feed static per-route config into its render.
306
+ */
307
+ readonly meta: Record<string, unknown>;
298
308
  /**
299
309
  * Build a link to a named route by pattern substitution — the framework delivers
300
310
  * this on the context (same output as `app.router.toUrl`), so render/head build
@@ -375,21 +385,16 @@ interface GenerateContext {
375
385
  readonly has: (name: string) => boolean;
376
386
  }
377
387
  /**
378
- * Context handed to a route's `.layout()` wrapper: the render-time
379
- * {@link RouteContext} plus the route's `.meta()` bag, so persistent chrome (e.g. a
380
- * TopBar/TabNav) can read `locale` and `meta.activeTab`. Distinct from
381
- * `RouteContext` because the layout is the only handler that needs `meta`; keeping
382
- * it on its own type leaves `.render()`/`.head()` contexts unchanged.
388
+ * Context handed to a route's `.layout()` wrapper identical to {@link RouteContext}
389
+ * (which now carries `meta` for every handler). Retained as a named alias so existing
390
+ * `.layout((ctx, children) => …)` typings keep compiling.
383
391
  *
384
392
  * @remarks
385
393
  * The layout is applied in the SSG render path ONLY. On client (SPA) navigation the
386
- * chrome is persistent and the layout is intentionally NOT re-applied — only the
387
- * inner swap region is replaced. See `build`'s pages phase and `spa`'s kernel.
394
+ * chrome is persistent and the layout is intentionally NOT re-applied — only the inner
395
+ * swap region is replaced. See `build`'s pages phase and `spa`'s kernel.
388
396
  */
389
- interface LayoutContext<S extends RouteState> extends RouteContext<S> {
390
- /** The route's `.meta()` bag (e.g. `{ activeTab: "home" }`). */
391
- readonly meta: Record<string, unknown>;
392
- }
397
+ type LayoutContext<S extends RouteState> = RouteContext<S>;
393
398
  /** Head metadata produced by a route's `.head()` handler. */
394
399
  interface HeadConfig$1 {
395
400
  /** Document title. */
@@ -944,12 +949,25 @@ interface ResolvedSpaConfig {
944
949
  /** Pre-registered components. */
945
950
  components: ComponentDef[];
946
951
  }
947
- /** Context handed to every component lifecycle hook. */
952
+ /**
953
+ * Context handed to every component lifecycle hook — the bound element + page data,
954
+ * plus the matched route's `params`/`meta`/`locale` and a link builder, so an island
955
+ * can read its route context (e.g. a `card` route's `ctx.meta.focus` + `ctx.params.id`)
956
+ * directly, without the page bridging it through `data-*` attributes.
957
+ */
948
958
  interface ComponentContext {
949
959
  /** The element the component instance is bound to. */
950
960
  el: Element;
951
961
  /** Page data extracted from the `script#__DATA__` payload. */
952
962
  data: PageData;
963
+ /** Resolved path params of the route matched for the current URL (empty if unmatched). */
964
+ readonly params: Record<string, string | undefined>;
965
+ /** The matched route's `.meta()` bag (empty if unmatched). */
966
+ readonly meta: Record<string, unknown>;
967
+ /** Active locale for the current route (empty string if unknown). */
968
+ readonly locale: string;
969
+ /** Build a link to a named route by pattern substitution (same output as `app.router.toUrl`). */
970
+ readonly url: (name: string, params?: Record<string, string>) => string;
953
971
  }
954
972
  /** Lifecycle hooks a component may implement. */
955
973
  interface ComponentHooks {
package/dist/browser.mjs CHANGED
@@ -471,6 +471,38 @@ function dynamicSegmentCount(pattern) {
471
471
  return count;
472
472
  }
473
473
  /**
474
+ * Whether a route is rendered ENTIRELY on the client in `spa` mode: a dynamic route
475
+ * (≥1 non-lang param) that declares no build-time `.generate()` enumerator, so its
476
+ * concrete param paths are unknown until runtime.
477
+ *
478
+ * The build SKIPS such a route — emitting a static page for it would write one
479
+ * param-less shell whose path (`/b/{id}`) matches no file (a 404) and carries no
480
+ * param for the islands to read. Instead the SPA client-renders it from the URL on
481
+ * boot and on navigation. Build and client share this ONE predicate (the same way
482
+ * `dynamicSegmentCount`/`bySpecificity` are shared) so the two sides can never
483
+ * disagree about which routes are pre-rendered vs. client-only.
484
+ *
485
+ * Static routes (`/`) and dynamic routes WITH `.generate()` are pre-rendered as
486
+ * usual and so are NOT client-only. In `ssg`/`hybrid` mode nothing is client-only
487
+ * (the build pre-renders every route), so this is always `false` outside `spa`.
488
+ *
489
+ * @param mode - The global render mode (`router.mode()`).
490
+ * @param route - The route to test (only its `pattern` + `.generate()` presence are read).
491
+ * @param route.pattern - The route's URL pattern string.
492
+ * @param route._handlers - The route's handler bag.
493
+ * @param route._handlers.generate - The build-only static-paths enumerator, if any (presence only).
494
+ * @returns `true` when the route is client-only (spa mode, dynamic, no `.generate()`).
495
+ * @example
496
+ * ```ts
497
+ * isClientOnlyRoute("spa", { pattern: "/b/{id}", _handlers: {} }); // true
498
+ * isClientOnlyRoute("spa", { pattern: "/", _handlers: {} }); // false (static)
499
+ * isClientOnlyRoute("hybrid", { pattern: "/b/{id}", _handlers: {} }); // false (not spa)
500
+ * ```
501
+ */
502
+ function isClientOnlyRoute(mode, route) {
503
+ return mode === "spa" && route._handlers.generate === void 0 && dynamicSegmentCount(route.pattern) > 0;
504
+ }
505
+ /**
474
506
  * Comparator that orders two routes most-specific-first (fewest dynamic segments
475
507
  * first). Equal specificity yields `0` so a stable sort preserves declaration
476
508
  * order — the exact ordering the compiled matcher table uses, guaranteeing
@@ -2441,6 +2473,23 @@ const ERROR_PREFIX$2 = "[web]";
2441
2473
  /** The set of legal hook names, frozen for O(1) membership checks. */
2442
2474
  const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
2443
2475
  /**
2476
+ * No-op link builder for the {@link EMPTY_ROUTE} slice (used when no route matched).
2477
+ *
2478
+ * @returns An empty string.
2479
+ * @example
2480
+ * const href = noUrl();
2481
+ */
2482
+ function noUrl() {
2483
+ return "";
2484
+ }
2485
+ /** Empty route slice — used for mounts with no matched route (headless, tests, public `scan()`). */
2486
+ const EMPTY_ROUTE = {
2487
+ params: {},
2488
+ meta: {},
2489
+ locale: "",
2490
+ url: noUrl
2491
+ };
2492
+ /**
2444
2493
  * Validate a single hook entry: its key must be a known hook name and its value
2445
2494
  * must be a function. Throws fail-fast on the first violation.
2446
2495
  *
@@ -2528,18 +2577,25 @@ function runHook(instance, hook, ctx) {
2528
2577
  instance.def.hooks[hook]?.(ctx);
2529
2578
  }
2530
2579
  /**
2531
- * Builds the component context handed to a hook (the bound element + page data).
2580
+ * Builds the component context handed to a hook: the bound element + page data, merged
2581
+ * with the matched route's slice (params/meta/locale/url). Defaults to {@link EMPTY_ROUTE}
2582
+ * when no route is supplied (headless, tests, public `scan()`).
2532
2583
  *
2533
2584
  * @param element - The element the instance is bound to.
2534
2585
  * @param data - The current page data payload.
2586
+ * @param route - The matched-route slice for the current URL.
2535
2587
  * @returns The hook context.
2536
2588
  * @example
2537
- * const ctx = makeContext(element, data);
2589
+ * const ctx = makeContext(element, data, route);
2538
2590
  */
2539
- function makeContext(element, data) {
2591
+ function makeContext(element, data, route = EMPTY_ROUTE) {
2540
2592
  return {
2541
2593
  el: element,
2542
- data
2594
+ data,
2595
+ params: route.params,
2596
+ meta: route.meta,
2597
+ locale: route.locale,
2598
+ url: route.url
2543
2599
  };
2544
2600
  }
2545
2601
  /**
@@ -2553,17 +2609,18 @@ function makeContext(element, data) {
2553
2609
  * @param swapArea - The swap-region element, or null when none was found.
2554
2610
  * @param data - The current page data payload.
2555
2611
  * @param element - The candidate element carrying a `data-component` attribute.
2612
+ * @param route - The matched-route slice for the current URL (params/meta/locale/url).
2556
2613
  * @example
2557
- * mountElement(state, emit, swapArea, data, element);
2614
+ * mountElement(state, emit, swapArea, data, element, route);
2558
2615
  */
2559
- function mountElement(state, emit, swapArea, data, element) {
2616
+ function mountElement(state, emit, swapArea, data, element, route = EMPTY_ROUTE) {
2560
2617
  if (state.instances.has(element)) return;
2561
2618
  const name = element.dataset.component;
2562
2619
  if (!name) return;
2563
2620
  const definition = state.registeredComponents.get(name);
2564
2621
  if (!definition) return;
2565
2622
  const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
2566
- const ctx = makeContext(element, data);
2623
+ const ctx = makeContext(element, data, route);
2567
2624
  runHook(instance, "onCreate", ctx);
2568
2625
  runHook(instance, "onMount", ctx);
2569
2626
  state.instances.set(element, instance);
@@ -2581,14 +2638,15 @@ function mountElement(state, emit, swapArea, data, element) {
2581
2638
  * @param state - The plugin state (registeredComponents + instances).
2582
2639
  * @param emit - The event emitter for spa:component-mount.
2583
2640
  * @param swapSelector - CSS selector bounding page-specific components.
2641
+ * @param route - The matched-route slice for the current URL (params/meta/locale/url).
2584
2642
  * @example
2585
- * scanAndMount(state, emit, "main > section");
2643
+ * scanAndMount(state, emit, "main > section", route);
2586
2644
  */
2587
- function scanAndMount(state, emit, swapSelector) {
2645
+ function scanAndMount(state, emit, swapSelector, route = EMPTY_ROUTE) {
2588
2646
  if (typeof document === "undefined") return;
2589
2647
  const swapArea = document.querySelector(swapSelector);
2590
2648
  const data = extractPageData(document);
2591
- for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element);
2649
+ for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element, route);
2592
2650
  }
2593
2651
  /**
2594
2652
  * Unmounts page-specific instances inside the swap region (runs `onUnMount`
@@ -2654,12 +2712,13 @@ function notifyNavStart(state) {
2654
2712
  * instances were already destroyed and re-created by the swap).
2655
2713
  *
2656
2714
  * @param state - The plugin state holding live instances.
2715
+ * @param route - The matched-route slice for the destination URL (params/meta/locale/url).
2657
2716
  * @example
2658
- * notifyNavEnd(state);
2717
+ * notifyNavEnd(state, route);
2659
2718
  */
2660
- function notifyNavEnd(state) {
2719
+ function notifyNavEnd(state, route = EMPTY_ROUTE) {
2661
2720
  const data = typeof document === "undefined" ? {} : extractPageData(document);
2662
- for (const [element, instance] of state.instances) if (instance.persistent) runHook(instance, "onNavEnd", makeContext(element, data));
2721
+ for (const [element, instance] of state.instances) if (instance.persistent) runHook(instance, "onNavEnd", makeContext(element, data, route));
2663
2722
  }
2664
2723
  //#endregion
2665
2724
  //#region src/plugins/spa/head.ts
@@ -3302,6 +3361,26 @@ function createSpaKernel(state, config, emit, deps) {
3302
3361
  });
3303
3362
  };
3304
3363
  /**
3364
+ * Build the matched-route slice (params/meta/locale/url) for the component context at `path`,
3365
+ * so islands read their route's params/meta directly. An unmatched path yields an empty slice.
3366
+ *
3367
+ * @param path - The URL (pathname + search) to match.
3368
+ * @returns The route slice for the matched route.
3369
+ * @example
3370
+ * scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(pathname));
3371
+ */
3372
+ const componentRouteContext = (path) => {
3373
+ const matchPath = path.split("?")[0] ?? path;
3374
+ const hit = deps.router.match(matchPath);
3375
+ const locale = hit?.params.lang ?? (typeof document === "undefined" ? "" : document.documentElement.lang) ?? "";
3376
+ return {
3377
+ params: hit?.params ?? {},
3378
+ meta: hit?.route._meta ?? {},
3379
+ locale,
3380
+ url: (name, params = {}) => deps.router.toUrl(name, params)
3381
+ };
3382
+ };
3383
+ /**
3305
3384
  * Process one navigation: head-sync, unmount, swap, re-mount, emit navigated.
3306
3385
  * When the region cannot be swapped (either document lacks the swap selector)
3307
3386
  * the SPA nav cannot complete — the head is already synced and the islands torn
@@ -3319,8 +3398,9 @@ function createSpaKernel(state, config, emit, deps) {
3319
3398
  syncHead(deps.head, doc);
3320
3399
  unmountPageSpecific(state, emit);
3321
3400
  if (!swapRegion(doc, resolved.swapSelector, resolved.viewTransitions, () => {
3322
- scanAndMount(state, emit, resolved.swapSelector);
3323
- notifyNavEnd(state);
3401
+ const routeSlice = componentRouteContext(pathname);
3402
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
3403
+ notifyNavEnd(state, routeSlice);
3324
3404
  }, applyPendingScroll)) {
3325
3405
  handleError();
3326
3406
  location.href = pathname;
@@ -3374,17 +3454,22 @@ function createSpaKernel(state, config, emit, deps) {
3374
3454
  * const resolved = await resolveDataRender("/en/world/");
3375
3455
  */
3376
3456
  const resolveDataRender = async (pathname) => {
3377
- if (!deps.dataAt) return false;
3378
3457
  const matchPath = pathname.split("?")[0] ?? pathname;
3379
3458
  const hit = deps.router.match(matchPath);
3380
3459
  if (!hit?.route._handlers.render) return false;
3381
- const data = await deps.dataAt(pathname);
3382
- if (data === null) return false;
3460
+ let data = {};
3461
+ if (!isClientOnlyRoute(deps.router.mode(), hit.route)) {
3462
+ if (!deps.dataAt) return false;
3463
+ const persisted = await deps.dataAt(pathname);
3464
+ if (persisted === null) return false;
3465
+ data = persisted;
3466
+ }
3383
3467
  const locale = hit.params.lang ?? document.documentElement.lang ?? "";
3384
3468
  const routeContext = {
3385
3469
  params: hit.params,
3386
3470
  data,
3387
3471
  locale,
3472
+ meta: hit.route._meta,
3388
3473
  url: (routeName, routeParams = {}) => deps.router.toUrl(routeName, routeParams)
3389
3474
  };
3390
3475
  const vnode = hit.route._handlers.render(routeContext);
@@ -3416,6 +3501,7 @@ function createSpaKernel(state, config, emit, deps) {
3416
3501
  if (signal?.aborted) return;
3417
3502
  syncDataHead(deps.head, route, routeContext);
3418
3503
  unmountPageSpecific(state, emit);
3504
+ const routeSlice = componentRouteContext(pathname);
3419
3505
  /**
3420
3506
  * Render the VNode into the region and re-mount its islands in one paint — the
3421
3507
  * swap body handed to `runSwap` (optionally wrapped in a View Transition).
@@ -3427,8 +3513,8 @@ function createSpaKernel(state, config, emit, deps) {
3427
3513
  */
3428
3514
  const renderAndMount = () => {
3429
3515
  renderVNode(vnode, region);
3430
- scanAndMount(state, emit, resolved.swapSelector);
3431
- notifyNavEnd(state);
3516
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
3517
+ notifyNavEnd(state, routeSlice);
3432
3518
  };
3433
3519
  runSwap(renderAndMount, resolved.viewTransitions, applyPendingScroll);
3434
3520
  state.currentUrl = pathname;
@@ -3461,6 +3547,30 @@ function createSpaKernel(state, config, emit, deps) {
3461
3547
  }
3462
3548
  };
3463
3549
  /**
3550
+ * Initial-load render for a spa client-only route (dynamic, no `.generate()`): the build emitted
3551
+ * no static HTML for it, so the host served a fallback shell. Client-render the matched route into
3552
+ * the swap region from the URL, then mount its islands — the deep-link / refresh paint. Unlike a
3553
+ * navigation there is nothing to unmount and no `spa:navigated` to emit. If the route cannot be
3554
+ * resolved (defensive — a matched client-only route always resolves), fall back to mounting the
3555
+ * served body so boot still wires up whatever islands the shell does carry.
3556
+ *
3557
+ * @param pathname - The current document path (pathname + search).
3558
+ * @example
3559
+ * await bootRender("/b/abc123");
3560
+ */
3561
+ const bootRender = async (pathname) => {
3562
+ const routeSlice = componentRouteContext(pathname);
3563
+ const resolvedRender = await resolveDataRender(pathname);
3564
+ if (resolvedRender === false) {
3565
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
3566
+ return;
3567
+ }
3568
+ const { vnode, region } = resolvedRender;
3569
+ const { renderVNode } = await import("./render-BNe0s7fr.mjs");
3570
+ renderVNode(vnode, region);
3571
+ scanAndMount(state, emit, resolved.swapSelector, routeSlice);
3572
+ };
3573
+ /**
3464
3574
  * Unified navigation: try the client DATA path first (only when the `data`
3465
3575
  * plugin is composed), then fall back to HTML-over-fetch (which itself falls
3466
3576
  * back to a full `location.href` reload). Injected into the router so every
@@ -3505,7 +3615,10 @@ function createSpaKernel(state, config, emit, deps) {
3505
3615
  progress = createProgressBar(resolved.progressBar);
3506
3616
  state.currentUrl = currentLocationUrl();
3507
3617
  state.destroyRouter = attachRouter(handlers, navigate);
3508
- scanAndMount(state, emit, resolved.swapSelector);
3618
+ const matchPath = state.currentUrl.split("?")[0] ?? state.currentUrl;
3619
+ const hit = deps.router.match(matchPath);
3620
+ if (hit?.route._handlers.render && isClientOnlyRoute(deps.router.mode(), hit.route)) bootRender(state.currentUrl);
3621
+ else scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(state.currentUrl));
3509
3622
  state.started = true;
3510
3623
  },
3511
3624
  /**
@@ -3536,7 +3649,7 @@ function createSpaKernel(state, config, emit, deps) {
3536
3649
  * kernel.scan();
3537
3650
  */
3538
3651
  scan() {
3539
- scanAndMount(state, emit, resolved.swapSelector);
3652
+ scanAndMount(state, emit, resolved.swapSelector, componentRouteContext(state.currentUrl));
3540
3653
  },
3541
3654
  /**
3542
3655
  * Tear down router listeners, dispose all instances, reset boot state.