@moku-labs/web 2.1.0 → 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.
@@ -4156,6 +4167,27 @@ function createSpaKernel(state, config, emit, deps) {
4156
4167
  if (signal?.aborted) return;
4157
4168
  await performNavigation(pathname, handlers, signal);
4158
4169
  };
4170
+ /**
4171
+ * Programmatic navigation entry (`app.spa.navigate` / `ctx.navigate`). A link click or the
4172
+ * Navigation API commits the address bar before the interceptor calls {@link navigate}; a
4173
+ * programmatic call does NOT, so it must commit the URL itself first — otherwise the content
4174
+ * swaps but the address bar (and the back button / refresh / deep link) stays on the old URL.
4175
+ * `history.pushState` updates the URL WITHOUT firing a `navigate` event, so it can't
4176
+ * double-trigger the interceptor. The current scroll is saved first so a later back restores it.
4177
+ *
4178
+ * @param path - The destination path (pathname + optional search).
4179
+ * @param scroll - The optional per-call scroll override.
4180
+ * @example
4181
+ * navigateProgrammatic("/board/abc/issue/1", "preserve");
4182
+ */
4183
+ const navigateProgrammatic = (path, scroll) => {
4184
+ if (typeof document === "undefined") return;
4185
+ if (path !== currentLocationUrl()) {
4186
+ saveScrollPosition(currentLocationUrl());
4187
+ history.pushState({}, "", path);
4188
+ }
4189
+ navigate(path, true, void 0, scroll).catch(() => {});
4190
+ };
4159
4191
  return {
4160
4192
  /**
4161
4193
  * Register config islands and seed currentUrl from the document.
@@ -4167,8 +4199,7 @@ function createSpaKernel(state, config, emit, deps) {
4167
4199
  for (const island of resolved.islands) registerIsland(state, island);
4168
4200
  state.currentUrl = currentLocationUrl();
4169
4201
  state.navigate = (path, options) => {
4170
- if (typeof document === "undefined") return;
4171
- navigate(path, true, void 0, options?.scroll).catch(() => {});
4202
+ navigateProgrammatic(path, options?.scroll);
4172
4203
  };
4173
4204
  },
4174
4205
  /**
@@ -4209,8 +4240,24 @@ function createSpaKernel(state, config, emit, deps) {
4209
4240
  * kernel.processNav("/about");
4210
4241
  */
4211
4242
  processNav(path, options) {
4243
+ navigateProgrammatic(path, options?.scroll);
4244
+ },
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) {
4212
4257
  if (typeof document === "undefined") return;
4213
- navigate(path, true, void 0, options?.scroll).catch(() => {});
4258
+ state.destroyRouter?.();
4259
+ state.destroyRouter = null;
4260
+ location.assign(url);
4214
4261
  },
4215
4262
  /**
4216
4263
  * Scan the swap region and mount islands for matching elements.
@@ -4267,6 +4314,49 @@ function initSpa(ctx) {
4267
4314
  let teardown;
4268
4315
  /** Captured log ref — onStop has no `ctx.log` (spec/08 §4). */
4269
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
+ }
4270
4360
  /**
4271
4361
  * Capture the teardown + log handles during `onStart` (no-op without a DOM —
4272
4362
  * the SSR/build guard, so onStop has nothing to release). The kernel built in
@@ -4299,6 +4389,7 @@ function disposeSpa() {
4299
4389
  } finally {
4300
4390
  teardown = void 0;
4301
4391
  logRef = void 0;
4392
+ boundNavigators = void 0;
4302
4393
  }
4303
4394
  }
4304
4395
  //#endregion
@@ -4673,6 +4764,7 @@ const spaPlugin = createPlugin$1("spa", {
4673
4764
  onStart(ctx) {
4674
4765
  captureTeardown(ctx);
4675
4766
  ctx.state.kernel?.boot();
4767
+ if (ctx.state.kernel) bindKernelNavigators(ctx.state.kernel);
4676
4768
  },
4677
4769
  onStop: disposeSpa
4678
4770
  });
@@ -5316,4 +5408,4 @@ const createApp = core.createApp;
5316
5408
  */
5317
5409
  const createPlugin = core.createPlugin;
5318
5410
  //#endregion
5319
- 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.
@@ -10862,6 +10873,27 @@ function createSpaKernel(state, config, emit, deps) {
10862
10873
  if (signal?.aborted) return;
10863
10874
  await performNavigation(pathname, handlers, signal);
10864
10875
  };
10876
+ /**
10877
+ * Programmatic navigation entry (`app.spa.navigate` / `ctx.navigate`). A link click or the
10878
+ * Navigation API commits the address bar before the interceptor calls {@link navigate}; a
10879
+ * programmatic call does NOT, so it must commit the URL itself first — otherwise the content
10880
+ * swaps but the address bar (and the back button / refresh / deep link) stays on the old URL.
10881
+ * `history.pushState` updates the URL WITHOUT firing a `navigate` event, so it can't
10882
+ * double-trigger the interceptor. The current scroll is saved first so a later back restores it.
10883
+ *
10884
+ * @param path - The destination path (pathname + optional search).
10885
+ * @param scroll - The optional per-call scroll override.
10886
+ * @example
10887
+ * navigateProgrammatic("/board/abc/issue/1", "preserve");
10888
+ */
10889
+ const navigateProgrammatic = (path, scroll) => {
10890
+ if (typeof document === "undefined") return;
10891
+ if (path !== currentLocationUrl()) {
10892
+ saveScrollPosition(currentLocationUrl());
10893
+ history.pushState({}, "", path);
10894
+ }
10895
+ navigate(path, true, void 0, scroll).catch(() => {});
10896
+ };
10865
10897
  return {
10866
10898
  /**
10867
10899
  * Register config islands and seed currentUrl from the document.
@@ -10873,8 +10905,7 @@ function createSpaKernel(state, config, emit, deps) {
10873
10905
  for (const island of resolved.islands) registerIsland(state, island);
10874
10906
  state.currentUrl = currentLocationUrl();
10875
10907
  state.navigate = (path, options) => {
10876
- if (typeof document === "undefined") return;
10877
- navigate(path, true, void 0, options?.scroll).catch(() => {});
10908
+ navigateProgrammatic(path, options?.scroll);
10878
10909
  };
10879
10910
  },
10880
10911
  /**
@@ -10915,8 +10946,24 @@ function createSpaKernel(state, config, emit, deps) {
10915
10946
  * kernel.processNav("/about");
10916
10947
  */
10917
10948
  processNav(path, options) {
10949
+ navigateProgrammatic(path, options?.scroll);
10950
+ },
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) {
10918
10963
  if (typeof document === "undefined") return;
10919
- navigate(path, true, void 0, options?.scroll).catch(() => {});
10964
+ state.destroyRouter?.();
10965
+ state.destroyRouter = null;
10966
+ location.assign(url);
10920
10967
  },
10921
10968
  /**
10922
10969
  * Scan the swap region and mount islands for matching elements.
@@ -10973,6 +11020,49 @@ function initSpa(ctx) {
10973
11020
  let teardown;
10974
11021
  /** Captured log ref — onStop has no `ctx.log` (spec/08 §4). */
10975
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
+ }
10976
11066
  /**
10977
11067
  * Capture the teardown + log handles during `onStart` (no-op without a DOM —
10978
11068
  * the SSR/build guard, so onStop has nothing to release). The kernel built in
@@ -11005,6 +11095,7 @@ function disposeSpa() {
11005
11095
  } finally {
11006
11096
  teardown = void 0;
11007
11097
  logRef = void 0;
11098
+ boundNavigators = void 0;
11008
11099
  }
11009
11100
  }
11010
11101
  //#endregion
@@ -11379,6 +11470,7 @@ const spaPlugin = createPlugin$1("spa", {
11379
11470
  onStart(ctx) {
11380
11471
  captureTeardown(ctx);
11381
11472
  ctx.state.kernel?.boot();
11473
+ if (ctx.state.kernel) bindKernelNavigators(ctx.state.kernel);
11382
11474
  },
11383
11475
  onStop: disposeSpa
11384
11476
  });
@@ -13691,6 +13783,7 @@ Object.defineProperty(exports, "envPlugin", {
13691
13783
  });
13692
13784
  exports.feedLink = feedLink;
13693
13785
  exports.fileSystemContent = fileSystemContent;
13786
+ exports.hardNavigate = hardNavigate;
13694
13787
  exports.headPlugin = headPlugin;
13695
13788
  exports.hreflang = hreflang;
13696
13789
  exports.i18nPlugin = i18nPlugin;
@@ -13703,6 +13796,7 @@ Object.defineProperty(exports, "logPlugin", {
13703
13796
  }
13704
13797
  });
13705
13798
  exports.meta = meta;
13799
+ exports.navigate = navigate;
13706
13800
  exports.og = og;
13707
13801
  Object.defineProperty(exports, "processEnv", {
13708
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.
@@ -10849,6 +10860,27 @@ function createSpaKernel(state, config, emit, deps) {
10849
10860
  if (signal?.aborted) return;
10850
10861
  await performNavigation(pathname, handlers, signal);
10851
10862
  };
10863
+ /**
10864
+ * Programmatic navigation entry (`app.spa.navigate` / `ctx.navigate`). A link click or the
10865
+ * Navigation API commits the address bar before the interceptor calls {@link navigate}; a
10866
+ * programmatic call does NOT, so it must commit the URL itself first — otherwise the content
10867
+ * swaps but the address bar (and the back button / refresh / deep link) stays on the old URL.
10868
+ * `history.pushState` updates the URL WITHOUT firing a `navigate` event, so it can't
10869
+ * double-trigger the interceptor. The current scroll is saved first so a later back restores it.
10870
+ *
10871
+ * @param path - The destination path (pathname + optional search).
10872
+ * @param scroll - The optional per-call scroll override.
10873
+ * @example
10874
+ * navigateProgrammatic("/board/abc/issue/1", "preserve");
10875
+ */
10876
+ const navigateProgrammatic = (path, scroll) => {
10877
+ if (typeof document === "undefined") return;
10878
+ if (path !== currentLocationUrl()) {
10879
+ saveScrollPosition(currentLocationUrl());
10880
+ history.pushState({}, "", path);
10881
+ }
10882
+ navigate(path, true, void 0, scroll).catch(() => {});
10883
+ };
10852
10884
  return {
10853
10885
  /**
10854
10886
  * Register config islands and seed currentUrl from the document.
@@ -10860,8 +10892,7 @@ function createSpaKernel(state, config, emit, deps) {
10860
10892
  for (const island of resolved.islands) registerIsland(state, island);
10861
10893
  state.currentUrl = currentLocationUrl();
10862
10894
  state.navigate = (path, options) => {
10863
- if (typeof document === "undefined") return;
10864
- navigate(path, true, void 0, options?.scroll).catch(() => {});
10895
+ navigateProgrammatic(path, options?.scroll);
10865
10896
  };
10866
10897
  },
10867
10898
  /**
@@ -10902,8 +10933,24 @@ function createSpaKernel(state, config, emit, deps) {
10902
10933
  * kernel.processNav("/about");
10903
10934
  */
10904
10935
  processNav(path, options) {
10936
+ navigateProgrammatic(path, options?.scroll);
10937
+ },
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) {
10905
10950
  if (typeof document === "undefined") return;
10906
- navigate(path, true, void 0, options?.scroll).catch(() => {});
10951
+ state.destroyRouter?.();
10952
+ state.destroyRouter = null;
10953
+ location.assign(url);
10907
10954
  },
10908
10955
  /**
10909
10956
  * Scan the swap region and mount islands for matching elements.
@@ -10960,6 +11007,49 @@ function initSpa(ctx) {
10960
11007
  let teardown;
10961
11008
  /** Captured log ref — onStop has no `ctx.log` (spec/08 §4). */
10962
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
+ }
10963
11053
  /**
10964
11054
  * Capture the teardown + log handles during `onStart` (no-op without a DOM —
10965
11055
  * the SSR/build guard, so onStop has nothing to release). The kernel built in
@@ -10992,6 +11082,7 @@ function disposeSpa() {
10992
11082
  } finally {
10993
11083
  teardown = void 0;
10994
11084
  logRef = void 0;
11085
+ boundNavigators = void 0;
10995
11086
  }
10996
11087
  }
10997
11088
  //#endregion
@@ -11366,6 +11457,7 @@ const spaPlugin = createPlugin$1("spa", {
11366
11457
  onStart(ctx) {
11367
11458
  captureTeardown(ctx);
11368
11459
  ctx.state.kernel?.boot();
11460
+ if (ctx.state.kernel) bindKernelNavigators(ctx.state.kernel);
11369
11461
  },
11370
11462
  onStop: disposeSpa
11371
11463
  });
@@ -13589,4 +13681,4 @@ const createApp = core.createApp;
13589
13681
  */
13590
13682
  const createPlugin = core.createPlugin;
13591
13683
  //#endregion
13592
- 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.0"
135
+ "version": "2.2.0"
136
136
  }