@moku-labs/web 2.0.0 → 2.1.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 +45 -0
- package/dist/browser.d.mts +207 -10
- package/dist/browser.mjs +439 -26
- package/dist/index.cjs +439 -25
- package/dist/index.d.cts +207 -10
- package/dist/index.d.mts +207 -10
- package/dist/index.mjs +439 -26
- package/dist/{render-yXHc9BWI.mjs → render-CkdqOmsm.mjs} +7 -1
- package/dist/{render-DHUcHCYs.cjs → render-rf8FAg_Z.cjs} +7 -1
- package/dist/testing.d.mts +36 -1
- package/dist/testing.mjs +18 -3
- package/package.json +3 -3
package/dist/browser.mjs
CHANGED
|
@@ -1380,6 +1380,22 @@ function route(pattern) {
|
|
|
1380
1380
|
const builder = {
|
|
1381
1381
|
pattern: carrier.pattern,
|
|
1382
1382
|
_meta: carrier._meta,
|
|
1383
|
+
/**
|
|
1384
|
+
* The named view-transition behaviour set via `.transition()` (live read).
|
|
1385
|
+
*
|
|
1386
|
+
* @returns The transition mode, or undefined when unset (app default applies).
|
|
1387
|
+
*/
|
|
1388
|
+
get _transition() {
|
|
1389
|
+
return carrier._transition;
|
|
1390
|
+
},
|
|
1391
|
+
/**
|
|
1392
|
+
* The scroll behaviour set via `.scroll()` (live read).
|
|
1393
|
+
*
|
|
1394
|
+
* @returns The scroll mode, or undefined when unset (app default applies).
|
|
1395
|
+
*/
|
|
1396
|
+
get _scroll() {
|
|
1397
|
+
return carrier._scroll;
|
|
1398
|
+
},
|
|
1383
1399
|
_handlers: carrier._handlers,
|
|
1384
1400
|
...createBuilderMethods(set),
|
|
1385
1401
|
/**
|
|
@@ -1397,6 +1413,38 @@ function route(pattern) {
|
|
|
1397
1413
|
meta(meta) {
|
|
1398
1414
|
Object.assign(carrier._meta, meta);
|
|
1399
1415
|
return builder;
|
|
1416
|
+
},
|
|
1417
|
+
/**
|
|
1418
|
+
* Declare the view-transition behaviour for navigations TO this route — a typed
|
|
1419
|
+
* framework directive read by the SPA kernel, overriding the app-wide
|
|
1420
|
+
* `spa.viewTransitions` default.
|
|
1421
|
+
*
|
|
1422
|
+
* @param mode - The {@link TransitionMode} (`none`/`crossfade`/`slide`/`morph`).
|
|
1423
|
+
* @returns The same builder for chaining.
|
|
1424
|
+
* @example
|
|
1425
|
+
* ```ts
|
|
1426
|
+
* route("/board/{id}/issue/{issueId}").transition("morph");
|
|
1427
|
+
* ```
|
|
1428
|
+
*/
|
|
1429
|
+
transition(mode) {
|
|
1430
|
+
carrier._transition = mode;
|
|
1431
|
+
return builder;
|
|
1432
|
+
},
|
|
1433
|
+
/**
|
|
1434
|
+
* Declare the scroll behaviour for navigations TO this route — a typed framework
|
|
1435
|
+
* directive read by the SPA kernel, overriding the app-wide `spa.scrollRestoration`
|
|
1436
|
+
* default.
|
|
1437
|
+
*
|
|
1438
|
+
* @param mode - The {@link ScrollMode} (`top`/`preserve`).
|
|
1439
|
+
* @returns The same builder for chaining.
|
|
1440
|
+
* @example
|
|
1441
|
+
* ```ts
|
|
1442
|
+
* route("/board/{id}/issue/{issueId}").scroll("preserve");
|
|
1443
|
+
* ```
|
|
1444
|
+
*/
|
|
1445
|
+
scroll(mode) {
|
|
1446
|
+
carrier._scroll = mode;
|
|
1447
|
+
return builder;
|
|
1400
1448
|
}
|
|
1401
1449
|
};
|
|
1402
1450
|
return builder;
|
|
@@ -2203,11 +2251,12 @@ function createApi(ctx) {
|
|
|
2203
2251
|
* Programmatically navigate to a path (client runtime; no-op without a DOM).
|
|
2204
2252
|
*
|
|
2205
2253
|
* @param path - Target path (pathname, optionally with search/hash).
|
|
2254
|
+
* @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
|
|
2206
2255
|
* @example
|
|
2207
2256
|
* app.spa.navigate("/about");
|
|
2208
2257
|
*/
|
|
2209
|
-
navigate(path) {
|
|
2210
|
-
ctx.state.kernel?.processNav(path);
|
|
2258
|
+
navigate(path, options) {
|
|
2259
|
+
ctx.state.kernel?.processNav(path, options);
|
|
2211
2260
|
},
|
|
2212
2261
|
/**
|
|
2213
2262
|
* Read the current resolved URL.
|
|
@@ -2650,13 +2699,15 @@ let commitVNodeFunction;
|
|
|
2650
2699
|
* await loadRenderChunk();
|
|
2651
2700
|
*/
|
|
2652
2701
|
async function loadRenderChunk() {
|
|
2653
|
-
renderChunk ??= import("./render-
|
|
2702
|
+
renderChunk ??= import("./render-CkdqOmsm.mjs");
|
|
2654
2703
|
commitVNodeFunction = (await renderChunk).commitVNode;
|
|
2655
2704
|
}
|
|
2656
2705
|
/**
|
|
2657
2706
|
* Commit a {@link RenderResult} into a host: `string` → `innerHTML`, `Node` →
|
|
2658
|
-
* `replaceChildren`, `
|
|
2659
|
-
*
|
|
2707
|
+
* `replaceChildren`, `null` → Preact unmount via the lazy gate (`render(null, host)` —
|
|
2708
|
+
* stays re-mountable, the correct "empty" for a persistent render-island), `void`/`undefined`
|
|
2709
|
+
* → no-op (the render mutated the DOM itself), and a Preact `VNode` → committed through the
|
|
2710
|
+
* lazy gate (loading it on demand if needed).
|
|
2660
2711
|
*
|
|
2661
2712
|
* @param host - The island host element to render into.
|
|
2662
2713
|
* @param result - The value returned by the island's `render`.
|
|
@@ -2783,6 +2834,18 @@ function buildContext(state, instance) {
|
|
|
2783
2834
|
return instance.route.url;
|
|
2784
2835
|
},
|
|
2785
2836
|
/**
|
|
2837
|
+
* Programmatically navigate the SPA (delegates to the kernel's navigator through
|
|
2838
|
+
* `state.navigate` — the same decoupled seam as `island`). No-op before the kernel binds.
|
|
2839
|
+
*
|
|
2840
|
+
* @param path - The internal destination path.
|
|
2841
|
+
* @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
|
|
2842
|
+
* @example
|
|
2843
|
+
* ctx.navigate(ctx.url("issue", { id, issueId }));
|
|
2844
|
+
*/
|
|
2845
|
+
navigate(path, options) {
|
|
2846
|
+
state.navigate?.(path, options);
|
|
2847
|
+
},
|
|
2848
|
+
/**
|
|
2786
2849
|
* The live per-instance state (`undefined` for legacy hooks-only islands).
|
|
2787
2850
|
*
|
|
2788
2851
|
* @returns The current state.
|
|
@@ -3402,20 +3465,48 @@ function prefersReducedMotion() {
|
|
|
3402
3465
|
* Because the swap is post-fetch, doing it here also avoids the visible "scroll up,
|
|
3403
3466
|
* THEN the page loads" pause that scrolling in the router (pre-fetch) produced.
|
|
3404
3467
|
*
|
|
3468
|
+
* Named transitions (`transition.types`, e.g. `["slide"]`) are exposed to CSS two ways
|
|
3469
|
+
* for the transition's lifetime, so a consumer can style the motion per navigation:
|
|
3470
|
+
* • a `:root[data-view-transition~="slide"]` attribute (works on EVERY View-Transitions
|
|
3471
|
+
* engine, including those without the `types` dictionary form);
|
|
3472
|
+
* • the standards-track `types` option (`:active-view-transition-type(slide)`) when the
|
|
3473
|
+
* engine supports the `startViewTransition({ update, types })` dictionary form.
|
|
3474
|
+
* Empty `types` ⇒ the default root crossfade. A shared `view-transition-name` left on an
|
|
3475
|
+
* element across the swap (e.g. a card and the panel it opens) morphs one into the other.
|
|
3476
|
+
*
|
|
3405
3477
|
* @param doSwap - The synchronous DOM mutation to perform.
|
|
3406
|
-
* @param
|
|
3478
|
+
* @param transition - The resolved {@link SwapTransition} (enabled + named types).
|
|
3407
3479
|
* @param beforeCapture - Optional hook run synchronously just before the swap/capture
|
|
3408
3480
|
* (e.g. scroll to the destination position).
|
|
3409
3481
|
* @example
|
|
3410
|
-
* runSwap(() => current.replaceWith(next),
|
|
3482
|
+
* runSwap(() => current.replaceWith(next), { enabled: true, types: ["slide"] });
|
|
3411
3483
|
*/
|
|
3412
|
-
function runSwap(doSwap,
|
|
3484
|
+
function runSwap(doSwap, transition, beforeCapture) {
|
|
3413
3485
|
const reduced = prefersReducedMotion();
|
|
3414
3486
|
const docWithVt = document;
|
|
3415
|
-
const canUseViewTransitions =
|
|
3487
|
+
const canUseViewTransitions = transition.enabled && !reduced && typeof docWithVt.startViewTransition === "function";
|
|
3416
3488
|
beforeCapture?.();
|
|
3417
|
-
if (canUseViewTransitions)
|
|
3418
|
-
|
|
3489
|
+
if (!canUseViewTransitions) {
|
|
3490
|
+
doSwap();
|
|
3491
|
+
return;
|
|
3492
|
+
}
|
|
3493
|
+
const root = typeof document === "undefined" ? void 0 : document.documentElement;
|
|
3494
|
+
const hasTypes = transition.types.length > 0;
|
|
3495
|
+
if (hasTypes && root) root.dataset.viewTransition = transition.types.join(" ");
|
|
3496
|
+
const clearMarker = () => {
|
|
3497
|
+
if (hasTypes && root) delete root.dataset.viewTransition;
|
|
3498
|
+
};
|
|
3499
|
+
let result;
|
|
3500
|
+
if (hasTypes) try {
|
|
3501
|
+
result = docWithVt.startViewTransition({
|
|
3502
|
+
update: doSwap,
|
|
3503
|
+
types: transition.types
|
|
3504
|
+
});
|
|
3505
|
+
} catch {
|
|
3506
|
+
result = docWithVt.startViewTransition(doSwap);
|
|
3507
|
+
}
|
|
3508
|
+
else result = docWithVt.startViewTransition(doSwap);
|
|
3509
|
+
Promise.resolve(result?.finished).then(clearMarker).catch(clearMarker);
|
|
3419
3510
|
}
|
|
3420
3511
|
/**
|
|
3421
3512
|
* Replace the `swapSelector` region of the live document with the matching
|
|
@@ -3429,22 +3520,22 @@ function runSwap(doSwap, viewTransitions, beforeCapture) {
|
|
|
3429
3520
|
*
|
|
3430
3521
|
* @param doc - The fetched document (DOMParser-parsed) holding the new region.
|
|
3431
3522
|
* @param swapSelector - CSS selector for the region to replace.
|
|
3432
|
-
* @param
|
|
3523
|
+
* @param transition - The resolved {@link SwapTransition} (enabled + named types).
|
|
3433
3524
|
* @param onSwapped - Callback run after the DOM mutation (mount/notify/scroll).
|
|
3434
3525
|
* @param beforeCapture - Optional hook run synchronously just before the swap/capture
|
|
3435
3526
|
* (forwarded to {@link runSwap} — e.g. scroll to the destination position).
|
|
3436
3527
|
* @returns `true` when the swap was dispatched, `false` when either document lacks the region.
|
|
3437
3528
|
* @example
|
|
3438
|
-
* swapRegion(doc, "main > section", false, () => mountNew());
|
|
3529
|
+
* swapRegion(doc, "main > section", { enabled: false, types: [] }, () => mountNew());
|
|
3439
3530
|
*/
|
|
3440
|
-
function swapRegion(doc, swapSelector,
|
|
3531
|
+
function swapRegion(doc, swapSelector, transition, onSwapped, beforeCapture) {
|
|
3441
3532
|
const newContent = doc.querySelector(swapSelector);
|
|
3442
3533
|
const currentContent = document.querySelector(swapSelector);
|
|
3443
3534
|
if (!newContent || !currentContent) return false;
|
|
3444
3535
|
runSwap(() => {
|
|
3445
3536
|
currentContent.replaceWith(newContent);
|
|
3446
3537
|
onSwapped();
|
|
3447
|
-
},
|
|
3538
|
+
}, transition, beforeCapture);
|
|
3448
3539
|
return true;
|
|
3449
3540
|
}
|
|
3450
3541
|
/**
|
|
@@ -3601,10 +3692,25 @@ function attachRouter(handlers, navigate) {
|
|
|
3601
3692
|
const ERROR_PREFIX$1 = "[web]";
|
|
3602
3693
|
/** Last-resort `swapSelector` when neither config nor defaults supply one. */
|
|
3603
3694
|
const FALLBACK_SWAP_SELECTOR = "main > section";
|
|
3695
|
+
/**
|
|
3696
|
+
* Resolve the `viewTransitions` config value into the default {@link TransitionMode}:
|
|
3697
|
+
* `true` → `"crossfade"`, `false` → `"none"`, a named mode → itself.
|
|
3698
|
+
*
|
|
3699
|
+
* @param raw - The raw `viewTransitions` config value (boolean or named mode).
|
|
3700
|
+
* @returns The resolved default transition mode.
|
|
3701
|
+
* @example
|
|
3702
|
+
* resolveDefaultTransition(true); // "crossfade"
|
|
3703
|
+
*/
|
|
3704
|
+
function resolveDefaultTransition(raw) {
|
|
3705
|
+
if (raw === true) return "crossfade";
|
|
3706
|
+
if (raw === false) return "none";
|
|
3707
|
+
return raw;
|
|
3708
|
+
}
|
|
3604
3709
|
/** Default SPA config (declared as a value — no inline assertion). */
|
|
3605
3710
|
const defaultSpaConfig = {
|
|
3606
3711
|
swapSelector: "main > section",
|
|
3607
3712
|
viewTransitions: false,
|
|
3713
|
+
scrollRestoration: "top",
|
|
3608
3714
|
progressBar: true,
|
|
3609
3715
|
islands: []
|
|
3610
3716
|
};
|
|
@@ -3641,9 +3747,12 @@ function resolveSpaConfig(config) {
|
|
|
3641
3747
|
const swapSelector = config.swapSelector ?? defaultSpaConfig.swapSelector ?? FALLBACK_SWAP_SELECTOR;
|
|
3642
3748
|
if (swapSelector.trim() === "") throw new Error(`${ERROR_PREFIX$1} spa.swapSelector must be a non-empty string.\n Set a CSS selector for the page region to swap (e.g. "main > section").`);
|
|
3643
3749
|
if (!isValidSelector(swapSelector)) throw new Error(`${ERROR_PREFIX$1} spa.swapSelector is not a valid CSS selector: "${swapSelector}".\n Provide a syntactically valid selector.`);
|
|
3750
|
+
const defaultTransition = resolveDefaultTransition(config.viewTransitions ?? false);
|
|
3644
3751
|
return {
|
|
3645
3752
|
swapSelector,
|
|
3646
|
-
viewTransitions:
|
|
3753
|
+
viewTransitions: defaultTransition !== "none",
|
|
3754
|
+
defaultTransition,
|
|
3755
|
+
scrollRestoration: config.scrollRestoration ?? "top",
|
|
3647
3756
|
progressBar: config.progressBar ?? true,
|
|
3648
3757
|
islands: config.islands ?? []
|
|
3649
3758
|
};
|
|
@@ -3668,6 +3777,7 @@ function createState(_ctx) {
|
|
|
3668
3777
|
currentUrl: "",
|
|
3669
3778
|
destroyRouter: null,
|
|
3670
3779
|
started: false,
|
|
3780
|
+
navigate: null,
|
|
3671
3781
|
kernel: null
|
|
3672
3782
|
};
|
|
3673
3783
|
}
|
|
@@ -3738,6 +3848,52 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
3738
3848
|
const resolved = resolveSpaConfig(config);
|
|
3739
3849
|
let progress;
|
|
3740
3850
|
let pendingScrollToTop = true;
|
|
3851
|
+
let pendingTransition = resolveTransition(resolved.defaultTransition);
|
|
3852
|
+
/**
|
|
3853
|
+
* Resolve a {@link TransitionMode} into the runtime {@link SwapTransition} the swap
|
|
3854
|
+
* consumes: `"none"` ⇒ disabled; `"crossfade"` ⇒ enabled, no named types (default root
|
|
3855
|
+
* crossfade); any other mode ⇒ enabled with that name as the View-Transition type. The
|
|
3856
|
+
* app-wide `viewTransitions` enabled flag gates everything: when it is off AND the route
|
|
3857
|
+
* declares no transition, no transition runs.
|
|
3858
|
+
*
|
|
3859
|
+
* @param mode - The effective transition mode for this navigation.
|
|
3860
|
+
* @returns The resolved swap descriptor (enabled + named types).
|
|
3861
|
+
* @example
|
|
3862
|
+
* pendingTransition = resolveTransition("slide");
|
|
3863
|
+
*/
|
|
3864
|
+
function resolveTransition(mode) {
|
|
3865
|
+
if (mode === "none") return {
|
|
3866
|
+
enabled: false,
|
|
3867
|
+
types: []
|
|
3868
|
+
};
|
|
3869
|
+
if (mode === "crossfade") return {
|
|
3870
|
+
enabled: true,
|
|
3871
|
+
types: []
|
|
3872
|
+
};
|
|
3873
|
+
return {
|
|
3874
|
+
enabled: true,
|
|
3875
|
+
types: [mode]
|
|
3876
|
+
};
|
|
3877
|
+
}
|
|
3878
|
+
/**
|
|
3879
|
+
* Resolve the per-navigation scroll + transition intent from the DESTINATION route,
|
|
3880
|
+
* falling back to the app-wide defaults. A route's `.scroll()`/`.transition()` win; an
|
|
3881
|
+
* explicit per-call `scrollOverride` (from `ctx.navigate`/`app.spa.navigate`) wins over
|
|
3882
|
+
* the route. `forwardScrollToTop` carries the navigation-type intent (forward ⇒ `true`,
|
|
3883
|
+
* traverse ⇒ `false`); a `"preserve"` resolution forces no scroll-to-top either way.
|
|
3884
|
+
*
|
|
3885
|
+
* @param pathname - The destination pathname (search stripped for matching).
|
|
3886
|
+
* @param forwardScrollToTop - Whether this nav would scroll to top by default (forward vs traverse).
|
|
3887
|
+
* @param scrollOverride - An explicit per-call scroll override, if any.
|
|
3888
|
+
* @example
|
|
3889
|
+
* applyNavIntent("/board/abc/issue/1", true, undefined);
|
|
3890
|
+
*/
|
|
3891
|
+
const applyNavIntent = (pathname, forwardScrollToTop, scrollOverride) => {
|
|
3892
|
+
const route = deps.router.match(pathname.split("?")[0] ?? pathname)?.route;
|
|
3893
|
+
const scrollMode = scrollOverride ?? route?._scroll ?? resolved.scrollRestoration;
|
|
3894
|
+
pendingScrollToTop = forwardScrollToTop && scrollMode !== "preserve";
|
|
3895
|
+
pendingTransition = resolveTransition(route?._transition ?? resolved.defaultTransition);
|
|
3896
|
+
};
|
|
3741
3897
|
/**
|
|
3742
3898
|
* Apply the in-flight navigation's scroll intent — the swap's `beforeCapture` hook.
|
|
3743
3899
|
* For a forward nav it scrolls to top BEFORE the swap (and, with view transitions on,
|
|
@@ -3803,7 +3959,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
3803
3959
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
3804
3960
|
syncHead(deps.head, doc);
|
|
3805
3961
|
unmountPageSpecific(state, emit);
|
|
3806
|
-
if (!swapRegion(doc, resolved.swapSelector,
|
|
3962
|
+
if (!swapRegion(doc, resolved.swapSelector, pendingTransition, () => {
|
|
3807
3963
|
const routeSlice = islandRouteContext(pathname);
|
|
3808
3964
|
scanAndMount(state, emit, resolved.swapSelector, routeSlice);
|
|
3809
3965
|
notifyNavEnd(state, routeSlice);
|
|
@@ -3903,7 +4059,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
3903
4059
|
const commitDataRender = async (pathname, resolvedRender, signal) => {
|
|
3904
4060
|
if (signal?.aborted) return;
|
|
3905
4061
|
const { route, vnode, routeContext, region } = resolvedRender;
|
|
3906
|
-
const { renderVNode } = await import("./render-
|
|
4062
|
+
const { renderVNode } = await import("./render-CkdqOmsm.mjs");
|
|
3907
4063
|
if (signal?.aborted) return;
|
|
3908
4064
|
syncDataHead(deps.head, route, routeContext);
|
|
3909
4065
|
unmountPageSpecific(state, emit);
|
|
@@ -3922,7 +4078,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
3922
4078
|
scanAndMount(state, emit, resolved.swapSelector, routeSlice);
|
|
3923
4079
|
notifyNavEnd(state, routeSlice);
|
|
3924
4080
|
};
|
|
3925
|
-
runSwap(renderAndMount,
|
|
4081
|
+
runSwap(renderAndMount, pendingTransition, applyPendingScroll);
|
|
3926
4082
|
state.currentUrl = pathname;
|
|
3927
4083
|
progress?.done();
|
|
3928
4084
|
emit("spa:navigated", { url: pathname });
|
|
@@ -3972,7 +4128,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
3972
4128
|
return;
|
|
3973
4129
|
}
|
|
3974
4130
|
const { vnode, region } = resolvedRender;
|
|
3975
|
-
const { renderVNode } = await import("./render-
|
|
4131
|
+
const { renderVNode } = await import("./render-CkdqOmsm.mjs");
|
|
3976
4132
|
renderVNode(vnode, region);
|
|
3977
4133
|
scanAndMount(state, emit, resolved.swapSelector, routeSlice);
|
|
3978
4134
|
};
|
|
@@ -3987,12 +4143,14 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
3987
4143
|
* (default `true`; forward navs). Traverse passes `false` to keep its restored scroll.
|
|
3988
4144
|
* @param signal - Aborts when this navigation is superseded (`navEvent.signal`);
|
|
3989
4145
|
* a superseded navigation never applies its swap (no stale last-write-wins).
|
|
4146
|
+
* @param scrollOverride - An explicit per-call scroll override (from `ctx.navigate`/
|
|
4147
|
+
* `app.spa.navigate`); wins over the destination route's `.scroll()` + the app default.
|
|
3990
4148
|
* @returns A promise resolving once the swap (or fallback) is dispatched.
|
|
3991
4149
|
* @example
|
|
3992
4150
|
* await navigate("/en/world/");
|
|
3993
4151
|
*/
|
|
3994
|
-
const navigate = async (pathname, scrollToTop = true, signal) => {
|
|
3995
|
-
|
|
4152
|
+
const navigate = async (pathname, scrollToTop = true, signal, scrollOverride) => {
|
|
4153
|
+
applyNavIntent(pathname, scrollToTop, scrollOverride);
|
|
3996
4154
|
handleStart(pathname);
|
|
3997
4155
|
if (deps.router.mode() !== "ssg" && await tryDataRender(pathname, signal)) return;
|
|
3998
4156
|
if (signal?.aborted) return;
|
|
@@ -4008,6 +4166,10 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
4008
4166
|
init() {
|
|
4009
4167
|
for (const island of resolved.islands) registerIsland(state, island);
|
|
4010
4168
|
state.currentUrl = currentLocationUrl();
|
|
4169
|
+
state.navigate = (path, options) => {
|
|
4170
|
+
if (typeof document === "undefined") return;
|
|
4171
|
+
navigate(path, true, void 0, options?.scroll).catch(() => {});
|
|
4172
|
+
};
|
|
4011
4173
|
},
|
|
4012
4174
|
/**
|
|
4013
4175
|
* Boot navigation interception + initial scan (throws if already started).
|
|
@@ -4038,15 +4200,17 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
4038
4200
|
registerIsland(state, island);
|
|
4039
4201
|
},
|
|
4040
4202
|
/**
|
|
4041
|
-
* Process a navigation to `path` (fetch then swap; full reload on error).
|
|
4203
|
+
* Process a navigation to `path` (fetch then swap; full reload on error). An optional
|
|
4204
|
+
* per-call `scroll` override flows through to the swap's scroll intent.
|
|
4042
4205
|
*
|
|
4043
4206
|
* @param path - The target path to navigate to.
|
|
4207
|
+
* @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
|
|
4044
4208
|
* @example
|
|
4045
4209
|
* kernel.processNav("/about");
|
|
4046
4210
|
*/
|
|
4047
|
-
processNav(path) {
|
|
4211
|
+
processNav(path, options) {
|
|
4048
4212
|
if (typeof document === "undefined") return;
|
|
4049
|
-
navigate(path).catch(() => {});
|
|
4213
|
+
navigate(path, true, void 0, options?.scroll).catch(() => {});
|
|
4050
4214
|
},
|
|
4051
4215
|
/**
|
|
4052
4216
|
* Scan the swap region and mount islands for matching elements.
|
|
@@ -4138,6 +4302,255 @@ function disposeSpa() {
|
|
|
4138
4302
|
}
|
|
4139
4303
|
}
|
|
4140
4304
|
//#endregion
|
|
4305
|
+
//#region src/plugins/spa/channel.ts
|
|
4306
|
+
/** Default keepalive interval (ms). */
|
|
4307
|
+
const DEFAULT_KEEPALIVE_MS = 3e4;
|
|
4308
|
+
/** Default first reconnect delay (ms). */
|
|
4309
|
+
const DEFAULT_RECONNECT_BASE_MS = 500;
|
|
4310
|
+
/** Default reconnect ceiling (ms). */
|
|
4311
|
+
const DEFAULT_RECONNECT_MAX_MS = 8e3;
|
|
4312
|
+
/**
|
|
4313
|
+
* Default frame parser — `JSON.parse`, dropping frames that fail to parse.
|
|
4314
|
+
*
|
|
4315
|
+
* @param raw - The raw text frame.
|
|
4316
|
+
* @returns The parsed value, or undefined on a parse error.
|
|
4317
|
+
* @example
|
|
4318
|
+
* const msg = defaultParse<BoardPatch>('{"type":"x"}');
|
|
4319
|
+
*/
|
|
4320
|
+
function defaultParse(raw) {
|
|
4321
|
+
try {
|
|
4322
|
+
return JSON.parse(raw);
|
|
4323
|
+
} catch {
|
|
4324
|
+
return;
|
|
4325
|
+
}
|
|
4326
|
+
}
|
|
4327
|
+
/**
|
|
4328
|
+
* Create a live realtime channel — a single self-healing WebSocket bound to one channel
|
|
4329
|
+
* `id` at a time, with refcounted subscription, auto-reconnect, an optional keepalive, a
|
|
4330
|
+
* pre-seed buffer, and an optimistic local-echo path. See the file header for the model.
|
|
4331
|
+
*
|
|
4332
|
+
* @param options - The channel configuration (see {@link ChannelOptions}).
|
|
4333
|
+
* @returns The {@link Channel} control surface.
|
|
4334
|
+
* @example
|
|
4335
|
+
* const board = createChannel<BoardPatch>({ url: (id) => `wss://host/ws/board/${id}` });
|
|
4336
|
+
*/
|
|
4337
|
+
function createChannel(options) {
|
|
4338
|
+
const parse = options.parse ?? defaultParse;
|
|
4339
|
+
const reconnectCfg = options.reconnect === false ? void 0 : options.reconnect ?? {};
|
|
4340
|
+
const baseMs = reconnectCfg?.baseMs ?? DEFAULT_RECONNECT_BASE_MS;
|
|
4341
|
+
const maxMs = reconnectCfg?.maxMs ?? DEFAULT_RECONNECT_MAX_MS;
|
|
4342
|
+
const keepEveryMs = options.keepAlive?.everyMs ?? DEFAULT_KEEPALIVE_MS;
|
|
4343
|
+
/** The live socket, or undefined when disconnected. */
|
|
4344
|
+
let socket;
|
|
4345
|
+
/** The id we WANT to stay subscribed to (drives reconnect); undefined ⇒ intentionally off. */
|
|
4346
|
+
let desiredId;
|
|
4347
|
+
/** Whether the consumer has seeded — gates live delivery vs. buffering. */
|
|
4348
|
+
let seeded = false;
|
|
4349
|
+
/** Frames received before {@link Channel.seed}, replayed in arrival order on seed. */
|
|
4350
|
+
let buffer = [];
|
|
4351
|
+
/** Message handlers fanned out on every delivered frame. */
|
|
4352
|
+
const handlers = /* @__PURE__ */ new Set();
|
|
4353
|
+
/** Consecutive closes since the last clean open — drives the backoff delay. */
|
|
4354
|
+
let reconnectAttempts = 0;
|
|
4355
|
+
/** Pending reconnect timer handle, or undefined when none is scheduled. */
|
|
4356
|
+
let reconnectTimer;
|
|
4357
|
+
/** Keepalive interval handle, or undefined when off. */
|
|
4358
|
+
let keepAliveTimer;
|
|
4359
|
+
/**
|
|
4360
|
+
* Fan a message out to every handler now, or queue it when buffering before seed.
|
|
4361
|
+
*
|
|
4362
|
+
* @param message - The message to deliver or buffer.
|
|
4363
|
+
* @example
|
|
4364
|
+
* deliver(patch);
|
|
4365
|
+
*/
|
|
4366
|
+
function deliver(message) {
|
|
4367
|
+
if (options.bufferUntilSeed && !seeded) {
|
|
4368
|
+
buffer.push(message);
|
|
4369
|
+
return;
|
|
4370
|
+
}
|
|
4371
|
+
for (const handler of handlers) handler(message);
|
|
4372
|
+
}
|
|
4373
|
+
/**
|
|
4374
|
+
* Parse + route an incoming frame, dropping the keepalive reply and unparseable frames.
|
|
4375
|
+
*
|
|
4376
|
+
* @param event - The socket message event.
|
|
4377
|
+
* @example
|
|
4378
|
+
* socket.addEventListener("message", dispatch);
|
|
4379
|
+
*/
|
|
4380
|
+
function dispatch(event) {
|
|
4381
|
+
if (typeof event.data !== "string") return;
|
|
4382
|
+
if (options.keepAlive?.ignore !== void 0 && event.data === options.keepAlive.ignore) return;
|
|
4383
|
+
const message = parse(event.data);
|
|
4384
|
+
if (message !== void 0) deliver(message);
|
|
4385
|
+
}
|
|
4386
|
+
/**
|
|
4387
|
+
* Send the keepalive frame when the socket is open (no-op otherwise).
|
|
4388
|
+
*
|
|
4389
|
+
* @example
|
|
4390
|
+
* ping();
|
|
4391
|
+
*/
|
|
4392
|
+
function ping() {
|
|
4393
|
+
if (options.keepAlive && socket?.readyState === WebSocket.OPEN) socket.send(options.keepAlive.send);
|
|
4394
|
+
}
|
|
4395
|
+
/**
|
|
4396
|
+
* Open the live socket for {@link desiredId}, wiring delivery + auto-reconnect. Shared by
|
|
4397
|
+
* the first connect and every backoff retry.
|
|
4398
|
+
*
|
|
4399
|
+
* @example
|
|
4400
|
+
* openSocket();
|
|
4401
|
+
*/
|
|
4402
|
+
function openSocket() {
|
|
4403
|
+
const id = desiredId;
|
|
4404
|
+
if (id === void 0 || typeof WebSocket === "undefined") return;
|
|
4405
|
+
const ws = new WebSocket(options.url(id));
|
|
4406
|
+
socket = ws;
|
|
4407
|
+
ws.addEventListener("message", dispatch);
|
|
4408
|
+
ws.addEventListener("open", () => {
|
|
4409
|
+
reconnectAttempts = 0;
|
|
4410
|
+
});
|
|
4411
|
+
ws.addEventListener("close", () => {
|
|
4412
|
+
if (socket !== ws || desiredId === void 0) return;
|
|
4413
|
+
socket = void 0;
|
|
4414
|
+
if (reconnectCfg) scheduleReconnect();
|
|
4415
|
+
});
|
|
4416
|
+
}
|
|
4417
|
+
/**
|
|
4418
|
+
* Schedule a reconnect to {@link desiredId} with exponential backoff (capped), unless one
|
|
4419
|
+
* is already pending or reconnection is off.
|
|
4420
|
+
*
|
|
4421
|
+
* @example
|
|
4422
|
+
* scheduleReconnect();
|
|
4423
|
+
*/
|
|
4424
|
+
function scheduleReconnect() {
|
|
4425
|
+
if (reconnectTimer !== void 0 || desiredId === void 0) return;
|
|
4426
|
+
const delay = Math.min(baseMs * 2 ** reconnectAttempts, maxMs);
|
|
4427
|
+
reconnectAttempts += 1;
|
|
4428
|
+
reconnectTimer = setTimeout(() => {
|
|
4429
|
+
reconnectTimer = void 0;
|
|
4430
|
+
if (desiredId !== void 0) openSocket();
|
|
4431
|
+
}, delay);
|
|
4432
|
+
}
|
|
4433
|
+
/**
|
|
4434
|
+
* Tear down the live socket + timers and re-arm the pre-seed buffer, keeping handlers so a
|
|
4435
|
+
* later connect resumes delivering to them. Clearing {@link desiredId} first marks the drop
|
|
4436
|
+
* intentional (no reconnect).
|
|
4437
|
+
*
|
|
4438
|
+
* @example
|
|
4439
|
+
* teardown();
|
|
4440
|
+
*/
|
|
4441
|
+
function teardown() {
|
|
4442
|
+
desiredId = void 0;
|
|
4443
|
+
if (reconnectTimer !== void 0) {
|
|
4444
|
+
clearTimeout(reconnectTimer);
|
|
4445
|
+
reconnectTimer = void 0;
|
|
4446
|
+
}
|
|
4447
|
+
if (keepAliveTimer !== void 0) {
|
|
4448
|
+
clearInterval(keepAliveTimer);
|
|
4449
|
+
keepAliveTimer = void 0;
|
|
4450
|
+
}
|
|
4451
|
+
if (socket) {
|
|
4452
|
+
socket.removeEventListener("message", dispatch);
|
|
4453
|
+
socket.close();
|
|
4454
|
+
socket = void 0;
|
|
4455
|
+
}
|
|
4456
|
+
seeded = false;
|
|
4457
|
+
buffer = [];
|
|
4458
|
+
}
|
|
4459
|
+
/**
|
|
4460
|
+
* Open (or switch) the socket to `id`, re-arming the keepalive + pre-seed buffer.
|
|
4461
|
+
*
|
|
4462
|
+
* @param id - The channel id to connect to.
|
|
4463
|
+
* @example
|
|
4464
|
+
* connect("board-123");
|
|
4465
|
+
*/
|
|
4466
|
+
function connect(id) {
|
|
4467
|
+
const alive = socket?.readyState === WebSocket.CONNECTING || socket?.readyState === WebSocket.OPEN;
|
|
4468
|
+
if (desiredId === id && alive) return;
|
|
4469
|
+
teardown();
|
|
4470
|
+
desiredId = id;
|
|
4471
|
+
seeded = false;
|
|
4472
|
+
buffer = [];
|
|
4473
|
+
reconnectAttempts = 0;
|
|
4474
|
+
openSocket();
|
|
4475
|
+
if (options.keepAlive) keepAliveTimer = setInterval(ping, keepEveryMs);
|
|
4476
|
+
}
|
|
4477
|
+
return {
|
|
4478
|
+
/**
|
|
4479
|
+
* Subscribe to `id`, connecting (or switching) the single active socket. Refcounted.
|
|
4480
|
+
*
|
|
4481
|
+
* @param id - The channel id to subscribe to.
|
|
4482
|
+
* @param handler - Called with each delivered message.
|
|
4483
|
+
* @returns A function that removes this handler (last out disconnects).
|
|
4484
|
+
* @example
|
|
4485
|
+
* const off = board.subscribe(boardId, (patch) => applyPatch(ctx, patch));
|
|
4486
|
+
*/
|
|
4487
|
+
subscribe(id, handler) {
|
|
4488
|
+
handlers.add(handler);
|
|
4489
|
+
connect(id);
|
|
4490
|
+
return () => {
|
|
4491
|
+
handlers.delete(handler);
|
|
4492
|
+
if (handlers.size === 0) teardown();
|
|
4493
|
+
};
|
|
4494
|
+
},
|
|
4495
|
+
/**
|
|
4496
|
+
* Mark `id` seeded and flush the pre-seed buffer (no-op unless it is the active id).
|
|
4497
|
+
*
|
|
4498
|
+
* @param id - The channel id whose buffer to flush.
|
|
4499
|
+
* @example
|
|
4500
|
+
* board.seed(boardId);
|
|
4501
|
+
*/
|
|
4502
|
+
seed(id) {
|
|
4503
|
+
if (id !== desiredId || seeded) return;
|
|
4504
|
+
seeded = true;
|
|
4505
|
+
const queued = buffer;
|
|
4506
|
+
buffer = [];
|
|
4507
|
+
for (const message of queued) for (const handler of handlers) handler(message);
|
|
4508
|
+
},
|
|
4509
|
+
/**
|
|
4510
|
+
* Deliver a message to the local handlers immediately (optimistic echo) for the active id.
|
|
4511
|
+
*
|
|
4512
|
+
* @param id - The channel id (ignored unless it is the active id).
|
|
4513
|
+
* @param message - The message to deliver locally now.
|
|
4514
|
+
* @example
|
|
4515
|
+
* board.deliverLocal(boardId, { type: "issue.moved", issueId });
|
|
4516
|
+
*/
|
|
4517
|
+
deliverLocal(id, message) {
|
|
4518
|
+
if (id !== desiredId) return;
|
|
4519
|
+
for (const handler of handlers) handler(message);
|
|
4520
|
+
},
|
|
4521
|
+
/**
|
|
4522
|
+
* Send a raw text frame over the live socket (no-op when not open).
|
|
4523
|
+
*
|
|
4524
|
+
* @param raw - The frame to send.
|
|
4525
|
+
* @example
|
|
4526
|
+
* board.send("ping");
|
|
4527
|
+
*/
|
|
4528
|
+
send(raw) {
|
|
4529
|
+
if (socket?.readyState === WebSocket.OPEN) socket.send(raw);
|
|
4530
|
+
},
|
|
4531
|
+
/**
|
|
4532
|
+
* The currently-subscribed channel id, or undefined when disconnected.
|
|
4533
|
+
*
|
|
4534
|
+
* @returns The active id, or undefined.
|
|
4535
|
+
* @example
|
|
4536
|
+
* board.current();
|
|
4537
|
+
*/
|
|
4538
|
+
current() {
|
|
4539
|
+
return desiredId;
|
|
4540
|
+
},
|
|
4541
|
+
/**
|
|
4542
|
+
* Close the socket, cancel reconnect, and clear all handlers.
|
|
4543
|
+
*
|
|
4544
|
+
* @example
|
|
4545
|
+
* board.disconnect();
|
|
4546
|
+
*/
|
|
4547
|
+
disconnect() {
|
|
4548
|
+
handlers.clear();
|
|
4549
|
+
teardown();
|
|
4550
|
+
}
|
|
4551
|
+
};
|
|
4552
|
+
}
|
|
4553
|
+
//#endregion
|
|
4141
4554
|
//#region src/plugins/spa/lazy-embed.ts
|
|
4142
4555
|
/**
|
|
4143
4556
|
* @file `lazyEmbed` island — activates the static embed facades emitted by the
|
|
@@ -4903,4 +5316,4 @@ const createApp = core.createApp;
|
|
|
4903
5316
|
*/
|
|
4904
5317
|
const createPlugin = core.createPlugin;
|
|
4905
5318
|
//#endregion
|
|
4906
|
-
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, createIsland, createPlugin, createUrls, dataPlugin, defineRoutes, envPlugin, feedLink, headPlugin, hreflang, i18nPlugin, jsonLd, lazyEmbed, logPlugin, meta, og, route, routerPlugin, sitePlugin, spaPlugin, twitter };
|
|
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 };
|