@moku-labs/web 2.1.1 → 2.2.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/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.
@@ -4232,6 +4243,23 @@ function createSpaKernel(state, config, emit, deps) {
4232
4243
  navigateProgrammatic(path, options?.scroll);
4233
4244
  },
4234
4245
  /**
4246
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
4247
+ * full-page navigation. The click / Navigation-API interceptor catches even
4248
+ * `location.assign`, converting it to a region swap — so this DETACHES that interceptor
4249
+ * first (the same teardown `dispose` runs) and only THEN assigns, guaranteeing a true
4250
+ * document load that renders the destination's own layout. No-op headless.
4251
+ *
4252
+ * @param url - The destination URL (internal path or absolute).
4253
+ * @example
4254
+ * kernel.hardNavigate("/signin/");
4255
+ */
4256
+ hardNavigate(url) {
4257
+ if (typeof document === "undefined") return;
4258
+ state.destroyRouter?.();
4259
+ state.destroyRouter = null;
4260
+ location.assign(url);
4261
+ },
4262
+ /**
4235
4263
  * Scan the swap region and mount islands for matching elements.
4236
4264
  *
4237
4265
  * @example
@@ -4286,6 +4314,49 @@ function initSpa(ctx) {
4286
4314
  let teardown;
4287
4315
  /** Captured log ref — onStop has no `ctx.log` (spec/08 §4). */
4288
4316
  let logRef;
4317
+ /** The booted app's navigators, or undefined before boot / after stop (single-app-per-document). */
4318
+ let boundNavigators;
4319
+ /**
4320
+ * Bind the running app's navigators from its kernel — called by the plugin's `onStart` after
4321
+ * `kernel.boot()`, so the module-level {@link navigate}/{@link hardNavigate} delegate to it.
4322
+ *
4323
+ * @param kernel - The booted spa kernel.
4324
+ * @example
4325
+ * bindKernelNavigators(ctx.state.kernel);
4326
+ */
4327
+ function bindKernelNavigators(kernel) {
4328
+ boundNavigators = {
4329
+ navigate: (path, options) => kernel.processNav(path, options),
4330
+ hardNavigate: (url) => kernel.hardNavigate(url)
4331
+ };
4332
+ }
4333
+ /**
4334
+ * Navigate the booted SPA to an internal path — the SAME swap pipeline as a link click,
4335
+ * `ctx.navigate`, or `app.spa.navigate`, for module-scope callers with no `app`/island `ctx`.
4336
+ * No-op before `app.start()`. Build `path` from the route map's `urls`, never a literal.
4337
+ *
4338
+ * @param path - The internal destination path (e.g. `urls.toUrl("board", { id })`).
4339
+ * @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
4340
+ * @example
4341
+ * import { navigate } from "@moku-labs/web/browser";
4342
+ * navigate(urls.toUrl("issue", { id, issueId }));
4343
+ */
4344
+ function navigate(path, options) {
4345
+ boundNavigators?.navigate(path, options);
4346
+ }
4347
+ /**
4348
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
4349
+ * full-page load — detaches the SPA interceptor first so the navigation isn't caught and
4350
+ * converted to a swap. For module-scope callers with no `app`/island `ctx`. No-op before boot.
4351
+ *
4352
+ * @param url - The destination URL (internal path or absolute).
4353
+ * @example
4354
+ * import { hardNavigate } from "@moku-labs/web/browser";
4355
+ * hardNavigate(urls.toUrl("signin", {})); // sign-out / 401: leave the app chrome for the auth split
4356
+ */
4357
+ function hardNavigate(url) {
4358
+ boundNavigators?.hardNavigate(url);
4359
+ }
4289
4360
  /**
4290
4361
  * Capture the teardown + log handles during `onStart` (no-op without a DOM —
4291
4362
  * the SSR/build guard, so onStop has nothing to release). The kernel built in
@@ -4318,6 +4389,7 @@ function disposeSpa() {
4318
4389
  } finally {
4319
4390
  teardown = void 0;
4320
4391
  logRef = void 0;
4392
+ boundNavigators = void 0;
4321
4393
  }
4322
4394
  }
4323
4395
  //#endregion
@@ -4692,6 +4764,7 @@ const spaPlugin = createPlugin$1("spa", {
4692
4764
  onStart(ctx) {
4693
4765
  captureTeardown(ctx);
4694
4766
  ctx.state.kernel?.boot();
4767
+ if (ctx.state.kernel) bindKernelNavigators(ctx.state.kernel);
4695
4768
  },
4696
4769
  onStop: disposeSpa
4697
4770
  });
@@ -5335,4 +5408,4 @@ const createApp = core.createApp;
5335
5408
  */
5336
5409
  const createPlugin = core.createPlugin;
5337
5410
  //#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 };
5411
+ 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.
@@ -10938,6 +10949,23 @@ function createSpaKernel(state, config, emit, deps) {
10938
10949
  navigateProgrammatic(path, options?.scroll);
10939
10950
  },
10940
10951
  /**
10952
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
10953
+ * full-page navigation. The click / Navigation-API interceptor catches even
10954
+ * `location.assign`, converting it to a region swap — so this DETACHES that interceptor
10955
+ * first (the same teardown `dispose` runs) and only THEN assigns, guaranteeing a true
10956
+ * document load that renders the destination's own layout. No-op headless.
10957
+ *
10958
+ * @param url - The destination URL (internal path or absolute).
10959
+ * @example
10960
+ * kernel.hardNavigate("/signin/");
10961
+ */
10962
+ hardNavigate(url) {
10963
+ if (typeof document === "undefined") return;
10964
+ state.destroyRouter?.();
10965
+ state.destroyRouter = null;
10966
+ location.assign(url);
10967
+ },
10968
+ /**
10941
10969
  * Scan the swap region and mount islands for matching elements.
10942
10970
  *
10943
10971
  * @example
@@ -10992,6 +11020,49 @@ function initSpa(ctx) {
10992
11020
  let teardown;
10993
11021
  /** Captured log ref — onStop has no `ctx.log` (spec/08 §4). */
10994
11022
  let logRef;
11023
+ /** The booted app's navigators, or undefined before boot / after stop (single-app-per-document). */
11024
+ let boundNavigators;
11025
+ /**
11026
+ * Bind the running app's navigators from its kernel — called by the plugin's `onStart` after
11027
+ * `kernel.boot()`, so the module-level {@link navigate}/{@link hardNavigate} delegate to it.
11028
+ *
11029
+ * @param kernel - The booted spa kernel.
11030
+ * @example
11031
+ * bindKernelNavigators(ctx.state.kernel);
11032
+ */
11033
+ function bindKernelNavigators(kernel) {
11034
+ boundNavigators = {
11035
+ navigate: (path, options) => kernel.processNav(path, options),
11036
+ hardNavigate: (url) => kernel.hardNavigate(url)
11037
+ };
11038
+ }
11039
+ /**
11040
+ * Navigate the booted SPA to an internal path — the SAME swap pipeline as a link click,
11041
+ * `ctx.navigate`, or `app.spa.navigate`, for module-scope callers with no `app`/island `ctx`.
11042
+ * No-op before `app.start()`. Build `path` from the route map's `urls`, never a literal.
11043
+ *
11044
+ * @param path - The internal destination path (e.g. `urls.toUrl("board", { id })`).
11045
+ * @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
11046
+ * @example
11047
+ * import { navigate } from "@moku-labs/web/browser";
11048
+ * navigate(urls.toUrl("issue", { id, issueId }));
11049
+ */
11050
+ function navigate(path, options) {
11051
+ boundNavigators?.navigate(path, options);
11052
+ }
11053
+ /**
11054
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
11055
+ * full-page load — detaches the SPA interceptor first so the navigation isn't caught and
11056
+ * converted to a swap. For module-scope callers with no `app`/island `ctx`. No-op before boot.
11057
+ *
11058
+ * @param url - The destination URL (internal path or absolute).
11059
+ * @example
11060
+ * import { hardNavigate } from "@moku-labs/web/browser";
11061
+ * hardNavigate(urls.toUrl("signin", {})); // sign-out / 401: leave the app chrome for the auth split
11062
+ */
11063
+ function hardNavigate(url) {
11064
+ boundNavigators?.hardNavigate(url);
11065
+ }
10995
11066
  /**
10996
11067
  * Capture the teardown + log handles during `onStart` (no-op without a DOM —
10997
11068
  * the SSR/build guard, so onStop has nothing to release). The kernel built in
@@ -11024,6 +11095,7 @@ function disposeSpa() {
11024
11095
  } finally {
11025
11096
  teardown = void 0;
11026
11097
  logRef = void 0;
11098
+ boundNavigators = void 0;
11027
11099
  }
11028
11100
  }
11029
11101
  //#endregion
@@ -11398,6 +11470,7 @@ const spaPlugin = createPlugin$1("spa", {
11398
11470
  onStart(ctx) {
11399
11471
  captureTeardown(ctx);
11400
11472
  ctx.state.kernel?.boot();
11473
+ if (ctx.state.kernel) bindKernelNavigators(ctx.state.kernel);
11401
11474
  },
11402
11475
  onStop: disposeSpa
11403
11476
  });
@@ -13710,6 +13783,7 @@ Object.defineProperty(exports, "envPlugin", {
13710
13783
  });
13711
13784
  exports.feedLink = feedLink;
13712
13785
  exports.fileSystemContent = fileSystemContent;
13786
+ exports.hardNavigate = hardNavigate;
13713
13787
  exports.headPlugin = headPlugin;
13714
13788
  exports.hreflang = hreflang;
13715
13789
  exports.i18nPlugin = i18nPlugin;
@@ -13722,6 +13796,7 @@ Object.defineProperty(exports, "logPlugin", {
13722
13796
  }
13723
13797
  });
13724
13798
  exports.meta = meta;
13799
+ exports.navigate = navigate;
13725
13800
  exports.og = og;
13726
13801
  Object.defineProperty(exports, "processEnv", {
13727
13802
  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.
@@ -10925,6 +10936,23 @@ function createSpaKernel(state, config, emit, deps) {
10925
10936
  navigateProgrammatic(path, options?.scroll);
10926
10937
  },
10927
10938
  /**
10939
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
10940
+ * full-page navigation. The click / Navigation-API interceptor catches even
10941
+ * `location.assign`, converting it to a region swap — so this DETACHES that interceptor
10942
+ * first (the same teardown `dispose` runs) and only THEN assigns, guaranteeing a true
10943
+ * document load that renders the destination's own layout. No-op headless.
10944
+ *
10945
+ * @param url - The destination URL (internal path or absolute).
10946
+ * @example
10947
+ * kernel.hardNavigate("/signin/");
10948
+ */
10949
+ hardNavigate(url) {
10950
+ if (typeof document === "undefined") return;
10951
+ state.destroyRouter?.();
10952
+ state.destroyRouter = null;
10953
+ location.assign(url);
10954
+ },
10955
+ /**
10928
10956
  * Scan the swap region and mount islands for matching elements.
10929
10957
  *
10930
10958
  * @example
@@ -10979,6 +11007,49 @@ function initSpa(ctx) {
10979
11007
  let teardown;
10980
11008
  /** Captured log ref — onStop has no `ctx.log` (spec/08 §4). */
10981
11009
  let logRef;
11010
+ /** The booted app's navigators, or undefined before boot / after stop (single-app-per-document). */
11011
+ let boundNavigators;
11012
+ /**
11013
+ * Bind the running app's navigators from its kernel — called by the plugin's `onStart` after
11014
+ * `kernel.boot()`, so the module-level {@link navigate}/{@link hardNavigate} delegate to it.
11015
+ *
11016
+ * @param kernel - The booted spa kernel.
11017
+ * @example
11018
+ * bindKernelNavigators(ctx.state.kernel);
11019
+ */
11020
+ function bindKernelNavigators(kernel) {
11021
+ boundNavigators = {
11022
+ navigate: (path, options) => kernel.processNav(path, options),
11023
+ hardNavigate: (url) => kernel.hardNavigate(url)
11024
+ };
11025
+ }
11026
+ /**
11027
+ * Navigate the booted SPA to an internal path — the SAME swap pipeline as a link click,
11028
+ * `ctx.navigate`, or `app.spa.navigate`, for module-scope callers with no `app`/island `ctx`.
11029
+ * No-op before `app.start()`. Build `path` from the route map's `urls`, never a literal.
11030
+ *
11031
+ * @param path - The internal destination path (e.g. `urls.toUrl("board", { id })`).
11032
+ * @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
11033
+ * @example
11034
+ * import { navigate } from "@moku-labs/web/browser";
11035
+ * navigate(urls.toUrl("issue", { id, issueId }));
11036
+ */
11037
+ function navigate(path, options) {
11038
+ boundNavigators?.navigate(path, options);
11039
+ }
11040
+ /**
11041
+ * Cross a boundary the SPA cannot swap (a different layout, the auth split) with a REAL
11042
+ * full-page load — detaches the SPA interceptor first so the navigation isn't caught and
11043
+ * converted to a swap. For module-scope callers with no `app`/island `ctx`. No-op before boot.
11044
+ *
11045
+ * @param url - The destination URL (internal path or absolute).
11046
+ * @example
11047
+ * import { hardNavigate } from "@moku-labs/web/browser";
11048
+ * hardNavigate(urls.toUrl("signin", {})); // sign-out / 401: leave the app chrome for the auth split
11049
+ */
11050
+ function hardNavigate(url) {
11051
+ boundNavigators?.hardNavigate(url);
11052
+ }
10982
11053
  /**
10983
11054
  * Capture the teardown + log handles during `onStart` (no-op without a DOM —
10984
11055
  * the SSR/build guard, so onStop has nothing to release). The kernel built in
@@ -11011,6 +11082,7 @@ function disposeSpa() {
11011
11082
  } finally {
11012
11083
  teardown = void 0;
11013
11084
  logRef = void 0;
11085
+ boundNavigators = void 0;
11014
11086
  }
11015
11087
  }
11016
11088
  //#endregion
@@ -11385,6 +11457,7 @@ const spaPlugin = createPlugin$1("spa", {
11385
11457
  onStart(ctx) {
11386
11458
  captureTeardown(ctx);
11387
11459
  ctx.state.kernel?.boot();
11460
+ if (ctx.state.kernel) bindKernelNavigators(ctx.state.kernel);
11388
11461
  },
11389
11462
  onStop: disposeSpa
11390
11463
  });
@@ -13608,4 +13681,4 @@ const createApp = core.createApp;
13608
13681
  */
13609
13682
  const createPlugin = core.createPlugin;
13610
13683
  //#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 };
13684
+ 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.0"
136
136
  }