@moku-labs/web 2.1.1 → 2.2.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.
package/README.md CHANGED
@@ -141,6 +141,7 @@ route("/board/{id}/issue/{issueId}")
141
141
  - **`viewTransitions`** accepts `boolean` (`true` → `"crossfade"`, `false` → `"none"`) **or** a named `TransitionMode` (`"none" | "crossfade" | "slide" | "morph"`). A named transition tags the swap so your CSS can target it two ways — `:root[data-view-transition~="slide"]` (every View-Transitions engine) and the standards-track `:active-view-transition-type(slide)` — and a shared `view-transition-name` left across the swap morphs one element into another. Reduced-motion and unsupported browsers fall back to an instant swap.
142
142
  - **`scrollRestoration`** is `"top"` (reset, the default) or `"preserve"` (keep the current scroll — e.g. opening an overlay route over content that must stay still). Back/forward always restores its saved position.
143
143
  - **`ctx.navigate(path, options?)`** — every island can navigate programmatically with no `app` handle (an always-present context member, like `ctx.set`/`ctx.url`). `app.spa.navigate` takes the same optional `{ scroll }`.
144
+ - **`navigate(path, options?)` / `hardNavigate(url)`** — importable from `@moku-labs/web/browser` for module-scope callers (shared chrome/nav helpers with no `app` or island `ctx`); they bind to the single booted app and no-op before `app.start()`. `hardNavigate` (also `app.spa.hardNavigate`) does a REAL full-page load for crossing a boundary the SPA can't swap — a different layout or an auth split — by detaching the SPA's own interceptor first, so `location.assign` isn't caught and converted to a region swap.
144
145
 
145
146
  ```ts
146
147
  createIsland("issue", {
@@ -1395,6 +1395,16 @@ interface SpaKernel {
1395
1395
  * kernel.processNav("/about");
1396
1396
  */
1397
1397
  processNav(path: string, options?: NavigateOptions): void;
1398
+ /**
1399
+ * Cross a boundary the SPA cannot swap (different layout / auth split) with a REAL
1400
+ * full-page load — detaches the interceptor first so the navigation isn't caught.
1401
+ *
1402
+ * @param url - The destination URL.
1403
+ * @returns void
1404
+ * @example
1405
+ * kernel.hardNavigate("/signin/");
1406
+ */
1407
+ hardNavigate(url: string): void;
1398
1408
  /**
1399
1409
  * Query the swap region and mount islands for matching elements.
1400
1410
  *
@@ -1456,6 +1466,17 @@ type SpaApi = {
1456
1466
  * app.spa.navigate("/about");
1457
1467
  */
1458
1468
  navigate(path: string, options?: NavigateOptions): void;
1469
+ /**
1470
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
1471
+ * full-page navigation. Detaches the SPA interceptor first, so the navigation is a true
1472
+ * document load (rendering the destination's own layout) rather than an intercepted swap.
1473
+ *
1474
+ * @param url - The destination URL (internal path or absolute).
1475
+ * @returns void
1476
+ * @example
1477
+ * app.spa.hardNavigate("/signin/"); // sign-out / 401: leave the app chrome for the auth split
1478
+ */
1479
+ hardNavigate(url: string): void;
1459
1480
  /**
1460
1481
  * Read the current resolved URL.
1461
1482
  *
@@ -1827,6 +1848,31 @@ declare function createIsland<S extends object = object, A = unknown>(name: stri
1827
1848
  */
1828
1849
  declare const lazyEmbed: IslandDef;
1829
1850
  //#endregion
1851
+ //#region src/plugins/spa/lifecycle.d.ts
1852
+ /**
1853
+ * Navigate the booted SPA to an internal path — the SAME swap pipeline as a link click,
1854
+ * `ctx.navigate`, or `app.spa.navigate`, for module-scope callers with no `app`/island `ctx`.
1855
+ * No-op before `app.start()`. Build `path` from the route map's `urls`, never a literal.
1856
+ *
1857
+ * @param path - The internal destination path (e.g. `urls.toUrl("board", { id })`).
1858
+ * @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
1859
+ * @example
1860
+ * import { navigate } from "@moku-labs/web/browser";
1861
+ * navigate(urls.toUrl("issue", { id, issueId }));
1862
+ */
1863
+ declare function navigate(path: string, options?: NavigateOptions): void;
1864
+ /**
1865
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
1866
+ * full-page load — detaches the SPA interceptor first so the navigation isn't caught and
1867
+ * converted to a swap. For module-scope callers with no `app`/island `ctx`. No-op before boot.
1868
+ *
1869
+ * @param url - The destination URL (internal path or absolute).
1870
+ * @example
1871
+ * import { hardNavigate } from "@moku-labs/web/browser";
1872
+ * hardNavigate(urls.toUrl("signin", {})); // sign-out / 401: leave the app chrome for the auth split
1873
+ */
1874
+ declare function hardNavigate(url: string): void;
1875
+ //#endregion
1830
1876
  //#region src/plugins/spa/index.d.ts
1831
1877
  /**
1832
1878
  * SPA plugin — progressive client-side navigation layered over the static site:
@@ -2614,4 +2660,4 @@ declare const createApp: <const ExtraPlugins extends readonly import("@moku-labs
2614
2660
  */
2615
2661
  declare const createPlugin: import("@moku-labs/core").BoundCreatePluginFunction<Config$5, Events, import("@moku-labs/core").CoreApisFromTuple<[import("@moku-labs/core").CorePluginInstance<"log", import("@moku-labs/common").LogConfig, import("@moku-labs/common").LogState, import("@moku-labs/common").LogApi>, import("@moku-labs/core").CorePluginInstance<"env", import("@moku-labs/common").EnvConfig, import("@moku-labs/common").EnvState, import("@moku-labs/common").EnvApi>]>>;
2616
2662
  //#endregion
2617
- export { type Channel, type ChannelKeepAlive, type ChannelOptions, type ChannelReconnect, types_d_exports as Content, types_d_exports$1 as Data, type Env, types_d_exports$2 as Head, type Log, types_d_exports$3 as Router, types_d_exports$4 as Spa, browserEnv, buildArticleHead, canonical, contentPlugin, createApp, createChannel, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, envPlugin, feedLink, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, route, routerPlugin, sitePlugin, spaPlugin, twitter };
2663
+ export { type Channel, type ChannelKeepAlive, type ChannelOptions, type ChannelReconnect, types_d_exports as Content, types_d_exports$1 as Data, type Env, types_d_exports$2 as Head, type Log, types_d_exports$3 as Router, types_d_exports$4 as Spa, browserEnv, buildArticleHead, canonical, contentPlugin, createApp, createChannel, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, envPlugin, feedLink, hardNavigate, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, navigate, og, route, routerPlugin, sitePlugin, spaPlugin, twitter };
package/dist/browser.mjs CHANGED
@@ -2259,6 +2259,17 @@ function createApi(ctx) {
2259
2259
  ctx.state.kernel?.processNav(path, options);
2260
2260
  },
2261
2261
  /**
2262
+ * Cross a boundary the SPA cannot swap (different layout / auth split) with a REAL
2263
+ * full-page load — detaches the interceptor first so the navigation isn't intercepted.
2264
+ *
2265
+ * @param url - The destination URL.
2266
+ * @example
2267
+ * app.spa.hardNavigate("/signin/");
2268
+ */
2269
+ hardNavigate(url) {
2270
+ ctx.state.kernel?.hardNavigate(url);
2271
+ },
2272
+ /**
2262
2273
  * Read the current resolved URL.
2263
2274
  *
2264
2275
  * @returns The current pathname + search.
@@ -3507,6 +3518,7 @@ function runSwap(doSwap, transition, beforeCapture) {
3507
3518
  }
3508
3519
  else result = docWithVt.startViewTransition(doSwap);
3509
3520
  Promise.resolve(result?.finished).then(clearMarker).catch(clearMarker);
3521
+ Promise.resolve(result?.ready).catch(() => {});
3510
3522
  }
3511
3523
  /**
3512
3524
  * Replace the `swapSelector` region of the live document with the matching
@@ -4232,6 +4244,23 @@ function createSpaKernel(state, config, emit, deps) {
4232
4244
  navigateProgrammatic(path, options?.scroll);
4233
4245
  },
4234
4246
  /**
4247
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
4248
+ * full-page navigation. The click / Navigation-API interceptor catches even
4249
+ * `location.assign`, converting it to a region swap — so this DETACHES that interceptor
4250
+ * first (the same teardown `dispose` runs) and only THEN assigns, guaranteeing a true
4251
+ * document load that renders the destination's own layout. No-op headless.
4252
+ *
4253
+ * @param url - The destination URL (internal path or absolute).
4254
+ * @example
4255
+ * kernel.hardNavigate("/signin/");
4256
+ */
4257
+ hardNavigate(url) {
4258
+ if (typeof document === "undefined") return;
4259
+ state.destroyRouter?.();
4260
+ state.destroyRouter = null;
4261
+ location.assign(url);
4262
+ },
4263
+ /**
4235
4264
  * Scan the swap region and mount islands for matching elements.
4236
4265
  *
4237
4266
  * @example
@@ -4286,6 +4315,49 @@ function initSpa(ctx) {
4286
4315
  let teardown;
4287
4316
  /** Captured log ref — onStop has no `ctx.log` (spec/08 §4). */
4288
4317
  let logRef;
4318
+ /** The booted app's navigators, or undefined before boot / after stop (single-app-per-document). */
4319
+ let boundNavigators;
4320
+ /**
4321
+ * Bind the running app's navigators from its kernel — called by the plugin's `onStart` after
4322
+ * `kernel.boot()`, so the module-level {@link navigate}/{@link hardNavigate} delegate to it.
4323
+ *
4324
+ * @param kernel - The booted spa kernel.
4325
+ * @example
4326
+ * bindKernelNavigators(ctx.state.kernel);
4327
+ */
4328
+ function bindKernelNavigators(kernel) {
4329
+ boundNavigators = {
4330
+ navigate: (path, options) => kernel.processNav(path, options),
4331
+ hardNavigate: (url) => kernel.hardNavigate(url)
4332
+ };
4333
+ }
4334
+ /**
4335
+ * Navigate the booted SPA to an internal path — the SAME swap pipeline as a link click,
4336
+ * `ctx.navigate`, or `app.spa.navigate`, for module-scope callers with no `app`/island `ctx`.
4337
+ * No-op before `app.start()`. Build `path` from the route map's `urls`, never a literal.
4338
+ *
4339
+ * @param path - The internal destination path (e.g. `urls.toUrl("board", { id })`).
4340
+ * @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
4341
+ * @example
4342
+ * import { navigate } from "@moku-labs/web/browser";
4343
+ * navigate(urls.toUrl("issue", { id, issueId }));
4344
+ */
4345
+ function navigate(path, options) {
4346
+ boundNavigators?.navigate(path, options);
4347
+ }
4348
+ /**
4349
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
4350
+ * full-page load — detaches the SPA interceptor first so the navigation isn't caught and
4351
+ * converted to a swap. For module-scope callers with no `app`/island `ctx`. No-op before boot.
4352
+ *
4353
+ * @param url - The destination URL (internal path or absolute).
4354
+ * @example
4355
+ * import { hardNavigate } from "@moku-labs/web/browser";
4356
+ * hardNavigate(urls.toUrl("signin", {})); // sign-out / 401: leave the app chrome for the auth split
4357
+ */
4358
+ function hardNavigate(url) {
4359
+ boundNavigators?.hardNavigate(url);
4360
+ }
4289
4361
  /**
4290
4362
  * Capture the teardown + log handles during `onStart` (no-op without a DOM —
4291
4363
  * the SSR/build guard, so onStop has nothing to release). The kernel built in
@@ -4318,6 +4390,7 @@ function disposeSpa() {
4318
4390
  } finally {
4319
4391
  teardown = void 0;
4320
4392
  logRef = void 0;
4393
+ boundNavigators = void 0;
4321
4394
  }
4322
4395
  }
4323
4396
  //#endregion
@@ -4692,6 +4765,7 @@ const spaPlugin = createPlugin$1("spa", {
4692
4765
  onStart(ctx) {
4693
4766
  captureTeardown(ctx);
4694
4767
  ctx.state.kernel?.boot();
4768
+ if (ctx.state.kernel) bindKernelNavigators(ctx.state.kernel);
4695
4769
  },
4696
4770
  onStop: disposeSpa
4697
4771
  });
@@ -5335,4 +5409,4 @@ const createApp = core.createApp;
5335
5409
  */
5336
5410
  const createPlugin = core.createPlugin;
5337
5411
  //#endregion
5338
- export { types_exports as Content, types_exports$1 as Data, types_exports$2 as Head, types_exports$3 as Router, types_exports$4 as Spa, browserEnv, buildArticleHead, canonical, contentPlugin, createApp, createChannel, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, envPlugin, feedLink, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, route, routerPlugin, sitePlugin, spaPlugin, twitter };
5412
+ export { types_exports as Content, types_exports$1 as Data, types_exports$2 as Head, types_exports$3 as Router, types_exports$4 as Spa, browserEnv, buildArticleHead, canonical, contentPlugin, createApp, createChannel, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, envPlugin, feedLink, hardNavigate, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, navigate, og, route, routerPlugin, sitePlugin, spaPlugin, twitter };
package/dist/index.cjs CHANGED
@@ -9217,6 +9217,17 @@ function createApi(ctx) {
9217
9217
  ctx.state.kernel?.processNav(path, options);
9218
9218
  },
9219
9219
  /**
9220
+ * Cross a boundary the SPA cannot swap (different layout / auth split) with a REAL
9221
+ * full-page load — detaches the interceptor first so the navigation isn't intercepted.
9222
+ *
9223
+ * @param url - The destination URL.
9224
+ * @example
9225
+ * app.spa.hardNavigate("/signin/");
9226
+ */
9227
+ hardNavigate(url) {
9228
+ ctx.state.kernel?.hardNavigate(url);
9229
+ },
9230
+ /**
9220
9231
  * Read the current resolved URL.
9221
9232
  *
9222
9233
  * @returns The current pathname + search.
@@ -10213,6 +10224,7 @@ function runSwap(doSwap, transition, beforeCapture) {
10213
10224
  }
10214
10225
  else result = docWithVt.startViewTransition(doSwap);
10215
10226
  Promise.resolve(result?.finished).then(clearMarker).catch(clearMarker);
10227
+ Promise.resolve(result?.ready).catch(() => {});
10216
10228
  }
10217
10229
  /**
10218
10230
  * Replace the `swapSelector` region of the live document with the matching
@@ -10938,6 +10950,23 @@ function createSpaKernel(state, config, emit, deps) {
10938
10950
  navigateProgrammatic(path, options?.scroll);
10939
10951
  },
10940
10952
  /**
10953
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
10954
+ * full-page navigation. The click / Navigation-API interceptor catches even
10955
+ * `location.assign`, converting it to a region swap — so this DETACHES that interceptor
10956
+ * first (the same teardown `dispose` runs) and only THEN assigns, guaranteeing a true
10957
+ * document load that renders the destination's own layout. No-op headless.
10958
+ *
10959
+ * @param url - The destination URL (internal path or absolute).
10960
+ * @example
10961
+ * kernel.hardNavigate("/signin/");
10962
+ */
10963
+ hardNavigate(url) {
10964
+ if (typeof document === "undefined") return;
10965
+ state.destroyRouter?.();
10966
+ state.destroyRouter = null;
10967
+ location.assign(url);
10968
+ },
10969
+ /**
10941
10970
  * Scan the swap region and mount islands for matching elements.
10942
10971
  *
10943
10972
  * @example
@@ -10992,6 +11021,49 @@ function initSpa(ctx) {
10992
11021
  let teardown;
10993
11022
  /** Captured log ref — onStop has no `ctx.log` (spec/08 §4). */
10994
11023
  let logRef;
11024
+ /** The booted app's navigators, or undefined before boot / after stop (single-app-per-document). */
11025
+ let boundNavigators;
11026
+ /**
11027
+ * Bind the running app's navigators from its kernel — called by the plugin's `onStart` after
11028
+ * `kernel.boot()`, so the module-level {@link navigate}/{@link hardNavigate} delegate to it.
11029
+ *
11030
+ * @param kernel - The booted spa kernel.
11031
+ * @example
11032
+ * bindKernelNavigators(ctx.state.kernel);
11033
+ */
11034
+ function bindKernelNavigators(kernel) {
11035
+ boundNavigators = {
11036
+ navigate: (path, options) => kernel.processNav(path, options),
11037
+ hardNavigate: (url) => kernel.hardNavigate(url)
11038
+ };
11039
+ }
11040
+ /**
11041
+ * Navigate the booted SPA to an internal path — the SAME swap pipeline as a link click,
11042
+ * `ctx.navigate`, or `app.spa.navigate`, for module-scope callers with no `app`/island `ctx`.
11043
+ * No-op before `app.start()`. Build `path` from the route map's `urls`, never a literal.
11044
+ *
11045
+ * @param path - The internal destination path (e.g. `urls.toUrl("board", { id })`).
11046
+ * @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
11047
+ * @example
11048
+ * import { navigate } from "@moku-labs/web/browser";
11049
+ * navigate(urls.toUrl("issue", { id, issueId }));
11050
+ */
11051
+ function navigate(path, options) {
11052
+ boundNavigators?.navigate(path, options);
11053
+ }
11054
+ /**
11055
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
11056
+ * full-page load — detaches the SPA interceptor first so the navigation isn't caught and
11057
+ * converted to a swap. For module-scope callers with no `app`/island `ctx`. No-op before boot.
11058
+ *
11059
+ * @param url - The destination URL (internal path or absolute).
11060
+ * @example
11061
+ * import { hardNavigate } from "@moku-labs/web/browser";
11062
+ * hardNavigate(urls.toUrl("signin", {})); // sign-out / 401: leave the app chrome for the auth split
11063
+ */
11064
+ function hardNavigate(url) {
11065
+ boundNavigators?.hardNavigate(url);
11066
+ }
10995
11067
  /**
10996
11068
  * Capture the teardown + log handles during `onStart` (no-op without a DOM —
10997
11069
  * the SSR/build guard, so onStop has nothing to release). The kernel built in
@@ -11024,6 +11096,7 @@ function disposeSpa() {
11024
11096
  } finally {
11025
11097
  teardown = void 0;
11026
11098
  logRef = void 0;
11099
+ boundNavigators = void 0;
11027
11100
  }
11028
11101
  }
11029
11102
  //#endregion
@@ -11398,6 +11471,7 @@ const spaPlugin = createPlugin$1("spa", {
11398
11471
  onStart(ctx) {
11399
11472
  captureTeardown(ctx);
11400
11473
  ctx.state.kernel?.boot();
11474
+ if (ctx.state.kernel) bindKernelNavigators(ctx.state.kernel);
11401
11475
  },
11402
11476
  onStop: disposeSpa
11403
11477
  });
@@ -13710,6 +13784,7 @@ Object.defineProperty(exports, "envPlugin", {
13710
13784
  });
13711
13785
  exports.feedLink = feedLink;
13712
13786
  exports.fileSystemContent = fileSystemContent;
13787
+ exports.hardNavigate = hardNavigate;
13713
13788
  exports.headPlugin = headPlugin;
13714
13789
  exports.hreflang = hreflang;
13715
13790
  exports.i18nPlugin = i18nPlugin;
@@ -13722,6 +13797,7 @@ Object.defineProperty(exports, "logPlugin", {
13722
13797
  }
13723
13798
  });
13724
13799
  exports.meta = meta;
13800
+ exports.navigate = navigate;
13725
13801
  exports.og = og;
13726
13802
  Object.defineProperty(exports, "processEnv", {
13727
13803
  enumerable: true,
package/dist/index.d.cts CHANGED
@@ -1396,6 +1396,16 @@ interface SpaKernel {
1396
1396
  * kernel.processNav("/about");
1397
1397
  */
1398
1398
  processNav(path: string, options?: NavigateOptions): void;
1399
+ /**
1400
+ * Cross a boundary the SPA cannot swap (different layout / auth split) with a REAL
1401
+ * full-page load — detaches the interceptor first so the navigation isn't caught.
1402
+ *
1403
+ * @param url - The destination URL.
1404
+ * @returns void
1405
+ * @example
1406
+ * kernel.hardNavigate("/signin/");
1407
+ */
1408
+ hardNavigate(url: string): void;
1399
1409
  /**
1400
1410
  * Query the swap region and mount islands for matching elements.
1401
1411
  *
@@ -1457,6 +1467,17 @@ type SpaApi = {
1457
1467
  * app.spa.navigate("/about");
1458
1468
  */
1459
1469
  navigate(path: string, options?: NavigateOptions): void;
1470
+ /**
1471
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
1472
+ * full-page navigation. Detaches the SPA interceptor first, so the navigation is a true
1473
+ * document load (rendering the destination's own layout) rather than an intercepted swap.
1474
+ *
1475
+ * @param url - The destination URL (internal path or absolute).
1476
+ * @returns void
1477
+ * @example
1478
+ * app.spa.hardNavigate("/signin/"); // sign-out / 401: leave the app chrome for the auth split
1479
+ */
1480
+ hardNavigate(url: string): void;
1460
1481
  /**
1461
1482
  * Read the current resolved URL.
1462
1483
  *
@@ -3747,6 +3768,31 @@ declare function createIsland<S extends object = object, A = unknown>(name: stri
3747
3768
  */
3748
3769
  declare const lazyEmbed: IslandDef;
3749
3770
  //#endregion
3771
+ //#region src/plugins/spa/lifecycle.d.ts
3772
+ /**
3773
+ * Navigate the booted SPA to an internal path — the SAME swap pipeline as a link click,
3774
+ * `ctx.navigate`, or `app.spa.navigate`, for module-scope callers with no `app`/island `ctx`.
3775
+ * No-op before `app.start()`. Build `path` from the route map's `urls`, never a literal.
3776
+ *
3777
+ * @param path - The internal destination path (e.g. `urls.toUrl("board", { id })`).
3778
+ * @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
3779
+ * @example
3780
+ * import { navigate } from "@moku-labs/web/browser";
3781
+ * navigate(urls.toUrl("issue", { id, issueId }));
3782
+ */
3783
+ declare function navigate(path: string, options?: NavigateOptions): void;
3784
+ /**
3785
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
3786
+ * full-page load — detaches the SPA interceptor first so the navigation isn't caught and
3787
+ * converted to a swap. For module-scope callers with no `app`/island `ctx`. No-op before boot.
3788
+ *
3789
+ * @param url - The destination URL (internal path or absolute).
3790
+ * @example
3791
+ * import { hardNavigate } from "@moku-labs/web/browser";
3792
+ * hardNavigate(urls.toUrl("signin", {})); // sign-out / 401: leave the app chrome for the auth split
3793
+ */
3794
+ declare function hardNavigate(url: string): void;
3795
+ //#endregion
3750
3796
  //#region src/plugins/spa/index.d.ts
3751
3797
  /**
3752
3798
  * SPA plugin — progressive client-side navigation layered over the static site:
@@ -3946,4 +3992,4 @@ declare const createApp: <const ExtraPlugins extends readonly import("@moku-labs
3946
3992
  */
3947
3993
  declare const createPlugin: import("@moku-labs/core").BoundCreatePluginFunction<Config$8, Events, import("@moku-labs/core").CoreApisFromTuple<[import("@moku-labs/core").CorePluginInstance<"log", import("@moku-labs/common").LogConfig, import("@moku-labs/common").LogState, import("@moku-labs/common").LogApi>, import("@moku-labs/core").CorePluginInstance<"env", import("@moku-labs/common").EnvConfig, import("@moku-labs/common").EnvState, import("@moku-labs/common").EnvApi>]>>;
3948
3994
  //#endregion
3949
- export { types_d_exports as Build, type Channel, type ChannelKeepAlive, type ChannelOptions, type ChannelReconnect, types_d_exports$1 as Cli, types_d_exports$2 as Content, types_d_exports$3 as Data, types_d_exports$4 as Deploy, type EmbedFacade, EmbedFacadeButton, type EmbedFacadeProps, type EmbedOptions, type Env, type GalleryComponent, type GalleryOptions, type GalleryProps, type GallerySlide, GalleryTrack, types_d_exports$5 as Head, type Log, types_d_exports$6 as Router, types_d_exports$7 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createChannel, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
3995
+ export { types_d_exports as Build, type Channel, type ChannelKeepAlive, type ChannelOptions, type ChannelReconnect, types_d_exports$1 as Cli, types_d_exports$2 as Content, types_d_exports$3 as Data, types_d_exports$4 as Deploy, type EmbedFacade, EmbedFacadeButton, type EmbedFacadeProps, type EmbedOptions, type Env, type GalleryComponent, type GalleryOptions, type GalleryProps, type GallerySlide, GalleryTrack, types_d_exports$5 as Head, type Log, types_d_exports$6 as Router, types_d_exports$7 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createChannel, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, hardNavigate, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, navigate, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
package/dist/index.d.mts CHANGED
@@ -1394,6 +1394,16 @@ interface SpaKernel {
1394
1394
  * kernel.processNav("/about");
1395
1395
  */
1396
1396
  processNav(path: string, options?: NavigateOptions): void;
1397
+ /**
1398
+ * Cross a boundary the SPA cannot swap (different layout / auth split) with a REAL
1399
+ * full-page load — detaches the interceptor first so the navigation isn't caught.
1400
+ *
1401
+ * @param url - The destination URL.
1402
+ * @returns void
1403
+ * @example
1404
+ * kernel.hardNavigate("/signin/");
1405
+ */
1406
+ hardNavigate(url: string): void;
1397
1407
  /**
1398
1408
  * Query the swap region and mount islands for matching elements.
1399
1409
  *
@@ -1455,6 +1465,17 @@ type SpaApi = {
1455
1465
  * app.spa.navigate("/about");
1456
1466
  */
1457
1467
  navigate(path: string, options?: NavigateOptions): void;
1468
+ /**
1469
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
1470
+ * full-page navigation. Detaches the SPA interceptor first, so the navigation is a true
1471
+ * document load (rendering the destination's own layout) rather than an intercepted swap.
1472
+ *
1473
+ * @param url - The destination URL (internal path or absolute).
1474
+ * @returns void
1475
+ * @example
1476
+ * app.spa.hardNavigate("/signin/"); // sign-out / 401: leave the app chrome for the auth split
1477
+ */
1478
+ hardNavigate(url: string): void;
1458
1479
  /**
1459
1480
  * Read the current resolved URL.
1460
1481
  *
@@ -3745,6 +3766,31 @@ declare function createIsland<S extends object = object, A = unknown>(name: stri
3745
3766
  */
3746
3767
  declare const lazyEmbed: IslandDef;
3747
3768
  //#endregion
3769
+ //#region src/plugins/spa/lifecycle.d.ts
3770
+ /**
3771
+ * Navigate the booted SPA to an internal path — the SAME swap pipeline as a link click,
3772
+ * `ctx.navigate`, or `app.spa.navigate`, for module-scope callers with no `app`/island `ctx`.
3773
+ * No-op before `app.start()`. Build `path` from the route map's `urls`, never a literal.
3774
+ *
3775
+ * @param path - The internal destination path (e.g. `urls.toUrl("board", { id })`).
3776
+ * @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
3777
+ * @example
3778
+ * import { navigate } from "@moku-labs/web/browser";
3779
+ * navigate(urls.toUrl("issue", { id, issueId }));
3780
+ */
3781
+ declare function navigate(path: string, options?: NavigateOptions): void;
3782
+ /**
3783
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
3784
+ * full-page load — detaches the SPA interceptor first so the navigation isn't caught and
3785
+ * converted to a swap. For module-scope callers with no `app`/island `ctx`. No-op before boot.
3786
+ *
3787
+ * @param url - The destination URL (internal path or absolute).
3788
+ * @example
3789
+ * import { hardNavigate } from "@moku-labs/web/browser";
3790
+ * hardNavigate(urls.toUrl("signin", {})); // sign-out / 401: leave the app chrome for the auth split
3791
+ */
3792
+ declare function hardNavigate(url: string): void;
3793
+ //#endregion
3748
3794
  //#region src/plugins/spa/index.d.ts
3749
3795
  /**
3750
3796
  * SPA plugin — progressive client-side navigation layered over the static site:
@@ -3944,4 +3990,4 @@ declare const createApp: <const ExtraPlugins extends readonly import("@moku-labs
3944
3990
  */
3945
3991
  declare const createPlugin: import("@moku-labs/core").BoundCreatePluginFunction<Config$8, Events, import("@moku-labs/core").CoreApisFromTuple<[import("@moku-labs/core").CorePluginInstance<"log", import("@moku-labs/common").LogConfig, import("@moku-labs/common").LogState, import("@moku-labs/common").LogApi>, import("@moku-labs/core").CorePluginInstance<"env", import("@moku-labs/common").EnvConfig, import("@moku-labs/common").EnvState, import("@moku-labs/common").EnvApi>]>>;
3946
3992
  //#endregion
3947
- export { types_d_exports as Build, type Channel, type ChannelKeepAlive, type ChannelOptions, type ChannelReconnect, types_d_exports$1 as Cli, types_d_exports$2 as Content, types_d_exports$3 as Data, types_d_exports$4 as Deploy, type EmbedFacade, EmbedFacadeButton, type EmbedFacadeProps, type EmbedOptions, type Env, type GalleryComponent, type GalleryOptions, type GalleryProps, type GallerySlide, GalleryTrack, types_d_exports$5 as Head, type Log, types_d_exports$6 as Router, types_d_exports$7 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createChannel, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
3993
+ export { types_d_exports as Build, type Channel, type ChannelKeepAlive, type ChannelOptions, type ChannelReconnect, types_d_exports$1 as Cli, types_d_exports$2 as Content, types_d_exports$3 as Data, types_d_exports$4 as Deploy, type EmbedFacade, EmbedFacadeButton, type EmbedFacadeProps, type EmbedOptions, type Env, type GalleryComponent, type GalleryOptions, type GalleryProps, type GallerySlide, GalleryTrack, types_d_exports$5 as Head, type Log, types_d_exports$6 as Router, types_d_exports$7 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createChannel, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, hardNavigate, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, navigate, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
package/dist/index.mjs CHANGED
@@ -9204,6 +9204,17 @@ function createApi(ctx) {
9204
9204
  ctx.state.kernel?.processNav(path, options);
9205
9205
  },
9206
9206
  /**
9207
+ * Cross a boundary the SPA cannot swap (different layout / auth split) with a REAL
9208
+ * full-page load — detaches the interceptor first so the navigation isn't intercepted.
9209
+ *
9210
+ * @param url - The destination URL.
9211
+ * @example
9212
+ * app.spa.hardNavigate("/signin/");
9213
+ */
9214
+ hardNavigate(url) {
9215
+ ctx.state.kernel?.hardNavigate(url);
9216
+ },
9217
+ /**
9207
9218
  * Read the current resolved URL.
9208
9219
  *
9209
9220
  * @returns The current pathname + search.
@@ -10200,6 +10211,7 @@ function runSwap(doSwap, transition, beforeCapture) {
10200
10211
  }
10201
10212
  else result = docWithVt.startViewTransition(doSwap);
10202
10213
  Promise.resolve(result?.finished).then(clearMarker).catch(clearMarker);
10214
+ Promise.resolve(result?.ready).catch(() => {});
10203
10215
  }
10204
10216
  /**
10205
10217
  * Replace the `swapSelector` region of the live document with the matching
@@ -10925,6 +10937,23 @@ function createSpaKernel(state, config, emit, deps) {
10925
10937
  navigateProgrammatic(path, options?.scroll);
10926
10938
  },
10927
10939
  /**
10940
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
10941
+ * full-page navigation. The click / Navigation-API interceptor catches even
10942
+ * `location.assign`, converting it to a region swap — so this DETACHES that interceptor
10943
+ * first (the same teardown `dispose` runs) and only THEN assigns, guaranteeing a true
10944
+ * document load that renders the destination's own layout. No-op headless.
10945
+ *
10946
+ * @param url - The destination URL (internal path or absolute).
10947
+ * @example
10948
+ * kernel.hardNavigate("/signin/");
10949
+ */
10950
+ hardNavigate(url) {
10951
+ if (typeof document === "undefined") return;
10952
+ state.destroyRouter?.();
10953
+ state.destroyRouter = null;
10954
+ location.assign(url);
10955
+ },
10956
+ /**
10928
10957
  * Scan the swap region and mount islands for matching elements.
10929
10958
  *
10930
10959
  * @example
@@ -10979,6 +11008,49 @@ function initSpa(ctx) {
10979
11008
  let teardown;
10980
11009
  /** Captured log ref — onStop has no `ctx.log` (spec/08 §4). */
10981
11010
  let logRef;
11011
+ /** The booted app's navigators, or undefined before boot / after stop (single-app-per-document). */
11012
+ let boundNavigators;
11013
+ /**
11014
+ * Bind the running app's navigators from its kernel — called by the plugin's `onStart` after
11015
+ * `kernel.boot()`, so the module-level {@link navigate}/{@link hardNavigate} delegate to it.
11016
+ *
11017
+ * @param kernel - The booted spa kernel.
11018
+ * @example
11019
+ * bindKernelNavigators(ctx.state.kernel);
11020
+ */
11021
+ function bindKernelNavigators(kernel) {
11022
+ boundNavigators = {
11023
+ navigate: (path, options) => kernel.processNav(path, options),
11024
+ hardNavigate: (url) => kernel.hardNavigate(url)
11025
+ };
11026
+ }
11027
+ /**
11028
+ * Navigate the booted SPA to an internal path — the SAME swap pipeline as a link click,
11029
+ * `ctx.navigate`, or `app.spa.navigate`, for module-scope callers with no `app`/island `ctx`.
11030
+ * No-op before `app.start()`. Build `path` from the route map's `urls`, never a literal.
11031
+ *
11032
+ * @param path - The internal destination path (e.g. `urls.toUrl("board", { id })`).
11033
+ * @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
11034
+ * @example
11035
+ * import { navigate } from "@moku-labs/web/browser";
11036
+ * navigate(urls.toUrl("issue", { id, issueId }));
11037
+ */
11038
+ function navigate(path, options) {
11039
+ boundNavigators?.navigate(path, options);
11040
+ }
11041
+ /**
11042
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
11043
+ * full-page load — detaches the SPA interceptor first so the navigation isn't caught and
11044
+ * converted to a swap. For module-scope callers with no `app`/island `ctx`. No-op before boot.
11045
+ *
11046
+ * @param url - The destination URL (internal path or absolute).
11047
+ * @example
11048
+ * import { hardNavigate } from "@moku-labs/web/browser";
11049
+ * hardNavigate(urls.toUrl("signin", {})); // sign-out / 401: leave the app chrome for the auth split
11050
+ */
11051
+ function hardNavigate(url) {
11052
+ boundNavigators?.hardNavigate(url);
11053
+ }
10982
11054
  /**
10983
11055
  * Capture the teardown + log handles during `onStart` (no-op without a DOM —
10984
11056
  * the SSR/build guard, so onStop has nothing to release). The kernel built in
@@ -11011,6 +11083,7 @@ function disposeSpa() {
11011
11083
  } finally {
11012
11084
  teardown = void 0;
11013
11085
  logRef = void 0;
11086
+ boundNavigators = void 0;
11014
11087
  }
11015
11088
  }
11016
11089
  //#endregion
@@ -11385,6 +11458,7 @@ const spaPlugin = createPlugin$1("spa", {
11385
11458
  onStart(ctx) {
11386
11459
  captureTeardown(ctx);
11387
11460
  ctx.state.kernel?.boot();
11461
+ if (ctx.state.kernel) bindKernelNavigators(ctx.state.kernel);
11388
11462
  },
11389
11463
  onStop: disposeSpa
11390
11464
  });
@@ -13608,4 +13682,4 @@ const createApp = core.createApp;
13608
13682
  */
13609
13683
  const createPlugin = core.createPlugin;
13610
13684
  //#endregion
13611
- export { types_exports as Build, types_exports$1 as Cli, types_exports$2 as Content, types_exports$3 as Data, types_exports$4 as Deploy, EmbedFacadeButton, GalleryTrack, types_exports$5 as Head, types_exports$6 as Router, types_exports$7 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createChannel, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
13685
+ export { types_exports as Build, types_exports$1 as Cli, types_exports$2 as Content, types_exports$3 as Data, types_exports$4 as Deploy, EmbedFacadeButton, GalleryTrack, types_exports$5 as Head, types_exports$6 as Router, types_exports$7 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createChannel, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, fileSystemContent, hardNavigate, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, navigate, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
package/package.json CHANGED
@@ -132,5 +132,5 @@
132
132
  "test:build-e2e": "bun test src/plugins/build/__tests__/e2e/",
133
133
  "test:coverage": "vitest run --project unit --project integration --coverage"
134
134
  },
135
- "version": "2.1.1"
135
+ "version": "2.2.1"
136
136
  }