@barefootjs/router 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/dist/a11y.d.ts +24 -0
- package/dist/a11y.d.ts.map +1 -0
- package/dist/cache.d.ts +15 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +655 -0
- package/dist/morph.d.ts +30 -0
- package/dist/morph.d.ts.map +1 -0
- package/dist/region.d.ts +105 -0
- package/dist/region.d.ts.map +1 -0
- package/dist/router.d.ts +13 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/seams.d.ts +29 -0
- package/dist/seams.d.ts.map +1 -0
- package/dist/types.d.ts +135 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +65 -0
- package/src/a11y.ts +66 -0
- package/src/cache.ts +77 -0
- package/src/index.ts +5 -0
- package/src/morph.ts +69 -0
- package/src/region.ts +297 -0
- package/src/router.ts +450 -0
- package/src/seams.ts +130 -0
- package/src/types.ts +140 -0
package/dist/region.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a fetched page and lift out the swappable region subtree(s).
|
|
3
|
+
*
|
|
4
|
+
* The router fetches an ordinary full-page HTML response (no protocol header),
|
|
5
|
+
* parses it client-side, and matches its `[bf-region]` boundaries against the
|
|
6
|
+
* live document. v0 swaps a single broad region; v2 matches compiler-derived
|
|
7
|
+
* nested/sibling regions by their stable `bf-region` id and swaps only the
|
|
8
|
+
* deepest ones whose *owned* content differs (spec/router.md "Regions"). Island
|
|
9
|
+
* module scripts (`<script type=module src>`) sit at body-end, outside any
|
|
10
|
+
* region, so they are collected from the whole parsed document.
|
|
11
|
+
*/
|
|
12
|
+
import type { RouterState } from './types.ts';
|
|
13
|
+
/** Parse a fetched page's HTML into a detached document. */
|
|
14
|
+
export declare function parseDocument(html: string): Document;
|
|
15
|
+
/**
|
|
16
|
+
* Same-origin absolute URLs of `<script type=module src>` in a tree, resolved
|
|
17
|
+
* against `baseUrl`. For a fetched page that is the response's **final URL**
|
|
18
|
+
* (not `location`), so a relative module `src` in the incoming document loads
|
|
19
|
+
* from the right place (spec/router.md lifecycle step 4).
|
|
20
|
+
*/
|
|
21
|
+
export declare function collectModuleScripts(root: ParentNode, baseUrl: string): Set<string>;
|
|
22
|
+
/** A swap target: a live region element and its counterpart in the incoming doc. */
|
|
23
|
+
export interface RegionSwap {
|
|
24
|
+
current: Element;
|
|
25
|
+
incoming: Element;
|
|
26
|
+
}
|
|
27
|
+
export type SwapPlan =
|
|
28
|
+
/**
|
|
29
|
+
* Matched compiler-derived regions: swap exactly `targets` (may be empty when
|
|
30
|
+
* nothing changed). `incomingKeys` is the owned-content key per matched region
|
|
31
|
+
* id from the **incoming server render** — the caller commits it as the new
|
|
32
|
+
* per-region baseline (see {@link planRegionSwaps}).
|
|
33
|
+
*/
|
|
34
|
+
{
|
|
35
|
+
mode: 'regions';
|
|
36
|
+
targets: RegionSwap[];
|
|
37
|
+
incomingKeys: Map<string, string>;
|
|
38
|
+
}
|
|
39
|
+
/** Region ids don't line up (or collide): fall back to the single broadest-region swap (v0). */
|
|
40
|
+
| {
|
|
41
|
+
mode: 'broadest';
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* A region's **owned** content: its inner HTML with every *nested* `[bf-region]`
|
|
45
|
+
* subtree masked out (replaced by an id-keyed placeholder), and with per-render
|
|
46
|
+
* **volatile hydration scaffolding** normalized away. Two regions compare equal
|
|
47
|
+
* when only their nested regions' interiors differ — so an outer region stays
|
|
48
|
+
* mounted when just an inner region changed (the deepest differing region is the
|
|
49
|
+
* one that swaps).
|
|
50
|
+
*
|
|
51
|
+
* The normalization matters: a top-level island's scope id is randomized per
|
|
52
|
+
* server render (`<div bf-s="Counter_a1b2c3">`), so two renders of the *same*
|
|
53
|
+
* region are never byte-identical. Comparing raw `innerHTML` would flag every
|
|
54
|
+
* region containing an island as "changed" and swap away its state — the
|
|
55
|
+
* opposite of v2's goal. The diff therefore compares *content*, ignoring the
|
|
56
|
+
* scope-id-carrying markers (`bf-s`, `bf-h`, and the id inside `bf-scope:`
|
|
57
|
+
* comments); the structural markers (`bf` slot refs, `bf-m` slot ids, `bf-r`)
|
|
58
|
+
* and any props stay, so a real content/prop change is still detected.
|
|
59
|
+
*/
|
|
60
|
+
export declare function ownedContentKey(region: Element, selector: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* Decide which regions to swap between the live document and a parsed incoming
|
|
63
|
+
* document. When both expose the **same set** of region ids, swap the *topmost*
|
|
64
|
+
* regions whose owned content differs (an ancestor swap rebuilds its nested
|
|
65
|
+
* regions, so a nested candidate inside another candidate is dropped). When the
|
|
66
|
+
* id sets differ or collide, fall back to the broadest single-region swap.
|
|
67
|
+
*
|
|
68
|
+
* The "differs" test compares the incoming region's **server-rendered** owned
|
|
69
|
+
* content against `baselines` — the owned-content key captured from the server
|
|
70
|
+
* render currently displayed in that region — **not** the live DOM. A live
|
|
71
|
+
* region's DOM may have been mutated by its islands (signal-driven updates), so
|
|
72
|
+
* comparing against it would flag an unchanged region as changed and swap away
|
|
73
|
+
* its state — the opposite of v2's goal. The caller seeds `baselines` from the
|
|
74
|
+
* initial document and refreshes it from `incomingKeys` after each navigation.
|
|
75
|
+
* A region missing from `baselines` falls back to its live owned content.
|
|
76
|
+
*/
|
|
77
|
+
export declare function planRegionSwaps(currentRoot: ParentNode, incomingRoot: ParentNode, selector: string, baselines: ReadonlyMap<string, string>): SwapPlan;
|
|
78
|
+
/**
|
|
79
|
+
* Capture the owned-content key of every `[bf-region]` in `root`, keyed by id —
|
|
80
|
+
* the per-region server-render baseline the swap planner compares against.
|
|
81
|
+
*/
|
|
82
|
+
export declare function captureRegionBaselines(root: ParentNode, selector: string): Map<string, string>;
|
|
83
|
+
/**
|
|
84
|
+
* True when `region` contains every other `[bf-region]` in its document — i.e.
|
|
85
|
+
* it is a single root whose swap rebuilds them all. Used by the broadest
|
|
86
|
+
* fallback: a root may be swapped wholesale (the v0 behaviour), but if the
|
|
87
|
+
* regions are siblings a single swap would only half-update the page, so the
|
|
88
|
+
* caller hard-navigates instead.
|
|
89
|
+
*/
|
|
90
|
+
export declare function isRootRegion(region: Element, selector: string): boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Adopt an incoming region's child nodes into the live document so they're ready
|
|
93
|
+
* to insert (the parsed nodes belong to a detached document).
|
|
94
|
+
*/
|
|
95
|
+
export declare function importRegionChildren(incoming: Element): Node[];
|
|
96
|
+
/**
|
|
97
|
+
* Prefetch-path read: parse once, confirm the page belongs to this shell, and
|
|
98
|
+
* return only its island module srcs (resolved against `baseUrl`). It does
|
|
99
|
+
* **not** clone/import any region subtree — prefetch only needs the module list,
|
|
100
|
+
* and importing a large region is wasted work. Returns `null` when the page has
|
|
101
|
+
* no region.
|
|
102
|
+
*/
|
|
103
|
+
export declare function collectRegionModuleSrcs(html: string, selector: string, baseUrl: string): string[] | null;
|
|
104
|
+
export declare function loadNewModules(state: RouterState, srcs: string[]): Promise<void>;
|
|
105
|
+
//# sourceMappingURL=region.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"region.d.ts","sourceRoot":"","sources":["../src/region.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAE7C,4DAA4D;AAC5D,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,CAEpD;AAGD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAcnF;AAED,oFAAoF;AACpF,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,OAAO,CAAA;IAChB,QAAQ,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,MAAM,QAAQ;AAClB;;;;;GAKG;AACD;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,UAAU,EAAE,CAAC;IAAC,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE;AAC/E,gGAAgG;GAC9F;IAAE,IAAI,EAAE,UAAU,CAAA;CAAE,CAAA;AAiBxB;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAazE;AAuED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAC7B,WAAW,EAAE,UAAU,EACvB,YAAY,EAAE,UAAU,EACxB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,GACrC,QAAQ,CAoBV;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAM9F;AAQD;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAMvE;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,OAAO,GAAG,IAAI,EAAE,CAE9D;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,GACd,MAAM,EAAE,GAAG,IAAI,CAIjB;AAED,wBAAsB,cAAc,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAmBtF"}
|
package/dist/router.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The router controller: event interception, programmatic navigation, prefetch.
|
|
3
|
+
*
|
|
4
|
+
* One router is active at a time (`active`). `startRouter` installs all the
|
|
5
|
+
* seams itself (correct by default — no opt-in step), delegates events on
|
|
6
|
+
* `document`/`window`, and returns a `Router` handle. A navigation swaps only
|
|
7
|
+
* the `[bf-region]` children, disposing the outgoing islands and re-hydrating
|
|
8
|
+
* the incoming ones, with last-wins semantics across overlapping navigations.
|
|
9
|
+
*/
|
|
10
|
+
import type { NavigateOptions, Router, RouterOptions } from './types.ts';
|
|
11
|
+
export declare function startRouter(options?: RouterOptions): Router;
|
|
12
|
+
export declare function navigate(url: string, options?: NavigateOptions): Promise<void>;
|
|
13
|
+
//# sourceMappingURL=router.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAuBH,OAAO,KAAK,EACV,eAAe,EAEf,MAAM,EACN,aAAa,EAEd,MAAM,YAAY,CAAA;AAInB,wBAAgB,WAAW,CAAC,OAAO,GAAE,aAAkB,GAAG,MAAM,CAkE/D;AA4FD,wBAAsB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB,GAAG,OAAO,CAAC,IAAI,CAAC,CA6JxF"}
|
package/dist/seams.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Window bridges to the optional `@barefootjs/client` runtime.
|
|
3
|
+
*
|
|
4
|
+
* The router core never statically imports `@barefootjs/client` — that keeps it
|
|
5
|
+
* an *optional* peer, so a static-shell site ships the router with zero client
|
|
6
|
+
* runtime. Every coupling lives here, and every coupling is correct by default:
|
|
7
|
+
* `dispose`/`rehydrate` degrade through the SAME fallback chain ending at
|
|
8
|
+
* `@barefootjs/client/runtime`, and neither silently no-ops on a page that has
|
|
9
|
+
* islands (spec/router.md "Seams & correctness" — the #1910 `setupStreaming()`
|
|
10
|
+
* footgun, where islands leaked unless the dev opted in, is what this avoids).
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Push the committed query string into the env signal, if a `searchParams()`
|
|
14
|
+
* consumer has registered the seam. Returns whether the seam exists — the
|
|
15
|
+
* caller uses that boolean to decide query-only-vs-structural navigation.
|
|
16
|
+
*/
|
|
17
|
+
export declare function pushSearchSeam(search: string): boolean;
|
|
18
|
+
export declare function defaultRehydrate(region: Element): Promise<void>;
|
|
19
|
+
export declare function defaultDispose(region: Element): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Does the region contain BarefootJS hydration markers (`bf-s` scope roots or
|
|
22
|
+
* `bf-h` child-scope hosts)? If so, the client runtime *should* be on the page,
|
|
23
|
+
* and failing to reach it is an error worth surfacing — not the silent
|
|
24
|
+
* "static shell" path.
|
|
25
|
+
*/
|
|
26
|
+
export declare function regionHasIslands(region: Element): boolean;
|
|
27
|
+
/** Full browser navigation — the "never worse than an MPA" floor. */
|
|
28
|
+
export declare function hardNavigate(url: string): void;
|
|
29
|
+
//# sourceMappingURL=seams.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"seams.d.ts","sourceRoot":"","sources":["../src/seams.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AA8BH;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAMtD;AAED,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CA+BrE;AAED,wBAAsB,cAAc,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAgBnE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAMzD;AAUD,qEAAqE;AACrE,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAE9C"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public + internal types for the BarefootJS partial-navigation router.
|
|
3
|
+
*
|
|
4
|
+
* A **region** (`[bf-region]`, spec/router.md) is the page-lifecycle boundary
|
|
5
|
+
* the router swaps: everything outside it persists across a navigation;
|
|
6
|
+
* everything inside is disposed, re-loaded, and re-hydrated. v0 swaps a single
|
|
7
|
+
* authored region (the broadest match); compiler-derived nested regions are v2.
|
|
8
|
+
*/
|
|
9
|
+
export interface RouterOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Selector for the swappable region. Defaults to `[bf-region]`. The first
|
|
12
|
+
* match in the document is the v0 swap point ("single authored region");
|
|
13
|
+
* if a page has none, the router hard-navigates (never worse than an MPA).
|
|
14
|
+
*/
|
|
15
|
+
region?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Re-hydrate the freshly swapped region. Defaults to {@link defaultRehydrate},
|
|
18
|
+
* which prefers the `@barefootjs/client` runtime's subtree-scoped walk and
|
|
19
|
+
* degrades through the shared fallback chain. Never silently no-ops on a page
|
|
20
|
+
* that has islands.
|
|
21
|
+
*/
|
|
22
|
+
rehydrate?: (region: Element) => void | Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Dispose the outgoing islands after they are swapped out. Receives a
|
|
25
|
+
* detached holder — a shallow clone of the region element (same tag,
|
|
26
|
+
* attributes, id, classes) holding the previous region children — so tearing
|
|
27
|
+
* it down never affects the live document. When `morph` is on, any matched
|
|
28
|
+
* `[data-bf-permanent]` node was moved into the new tree first and so is
|
|
29
|
+
* absent from the holder; with `morph: false` the permanent nodes remain in
|
|
30
|
+
* the holder and are disposed normally. Defaults to {@link defaultDispose}.
|
|
31
|
+
* Pass `() => {}` to opt out (e.g. a fully static shell with no client
|
|
32
|
+
* runtime).
|
|
33
|
+
*/
|
|
34
|
+
dispose?: (region: Element) => void | Promise<void>;
|
|
35
|
+
/** Import an island module by src. Defaults to `(src) => import(src)`. */
|
|
36
|
+
loadModule?: (src: string) => Promise<unknown>;
|
|
37
|
+
/**
|
|
38
|
+
* Fetch a page's HTML. Defaults to the global `fetch`. Inject to add auth
|
|
39
|
+
* headers / a custom transport, or to drive the router under test without
|
|
40
|
+
* monkeypatching the global.
|
|
41
|
+
*/
|
|
42
|
+
fetch?: typeof fetch;
|
|
43
|
+
/** Decide whether to intercept an anchor click. Defaults to {@link defaultShouldIntercept}. */
|
|
44
|
+
shouldIntercept?: (anchor: HTMLAnchorElement, event: Event) => boolean;
|
|
45
|
+
/** Scroll to the top of the document after a swap. Default `true`. */
|
|
46
|
+
scrollToTop?: boolean;
|
|
47
|
+
/** Move focus to the swapped region + announce the route change. Default `true`. */
|
|
48
|
+
manageFocus?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Preserve `[data-bf-permanent]` elements across a swap — their live DOM node
|
|
51
|
+
* (state, media playback, scroll, hydrated scope) is moved into the incoming
|
|
52
|
+
* tree instead of disposed and recreated (spec/router.md v1). Default `true`;
|
|
53
|
+
* a no-op when no permanent elements are present. Pass `false` for a plain
|
|
54
|
+
* `replaceChildren` swap.
|
|
55
|
+
*/
|
|
56
|
+
morph?: boolean;
|
|
57
|
+
/** Enable hover/focus/pointerdown prefetch. Default `true`. */
|
|
58
|
+
prefetch?: boolean;
|
|
59
|
+
/** Hover dwell before prefetch (ms). Default `65`. */
|
|
60
|
+
prefetchDelay?: number;
|
|
61
|
+
/** How long a cached page stays fresh (no refetch), ms. Default `15000`. */
|
|
62
|
+
cacheFreshMs?: number;
|
|
63
|
+
/** How long a cached page may be served (stale-while-revalidate), ms. Default `60000`. */
|
|
64
|
+
cacheStaleMs?: number;
|
|
65
|
+
/** Max cached pages (LRU). Default `30`. */
|
|
66
|
+
cacheCap?: number;
|
|
67
|
+
}
|
|
68
|
+
export interface NavigateOptions {
|
|
69
|
+
/**
|
|
70
|
+
* How to commit history. `'push'` (default) adds an entry, `'replace'`
|
|
71
|
+
* overwrites the current one, `false` writes nothing (used by `popstate`,
|
|
72
|
+
* where the browser already moved history).
|
|
73
|
+
*/
|
|
74
|
+
history?: 'push' | 'replace' | false;
|
|
75
|
+
}
|
|
76
|
+
export interface Router {
|
|
77
|
+
/** Tear down all listeners and abort any in-flight navigation. */
|
|
78
|
+
stop(): void;
|
|
79
|
+
/** Navigate programmatically. SSR no-op. */
|
|
80
|
+
navigate: (url: string, options?: NavigateOptions) => Promise<void>;
|
|
81
|
+
/** Warm the cache (and modulepreload islands) for a URL. */
|
|
82
|
+
prefetch: (url: string) => void;
|
|
83
|
+
}
|
|
84
|
+
/** A fetched page: its HTML plus the redirect-resolved final URL. */
|
|
85
|
+
export interface PageSnapshot {
|
|
86
|
+
html: string;
|
|
87
|
+
finalUrl: string;
|
|
88
|
+
}
|
|
89
|
+
export interface CacheEntry {
|
|
90
|
+
snap: Promise<PageSnapshot | null>;
|
|
91
|
+
/** Past this (jittered) instant → serve cached AND refresh in the background. */
|
|
92
|
+
refreshAt: number;
|
|
93
|
+
/** Past this instant → too old to serve, refetch synchronously. */
|
|
94
|
+
staleAt: number;
|
|
95
|
+
/** Single-flight guard for the background refresh. */
|
|
96
|
+
refreshing: boolean;
|
|
97
|
+
}
|
|
98
|
+
/** Mutable state for the one active router instance. */
|
|
99
|
+
export interface RouterState {
|
|
100
|
+
regionSelector: string;
|
|
101
|
+
rehydrate: (region: Element) => void | Promise<void>;
|
|
102
|
+
dispose: (region: Element) => void | Promise<void>;
|
|
103
|
+
loadModule: (src: string) => Promise<unknown>;
|
|
104
|
+
/** Fetch used for page loads (injectable; defaults to the global `fetch`). */
|
|
105
|
+
fetchFn: typeof fetch;
|
|
106
|
+
/** Absolute URLs of island modules already loaded (deduped across navigations). */
|
|
107
|
+
loadedModules: Set<string>;
|
|
108
|
+
prefetchEnabled: boolean;
|
|
109
|
+
prefetchDelay: number;
|
|
110
|
+
cacheFreshMs: number;
|
|
111
|
+
cacheStaleMs: number;
|
|
112
|
+
cacheCap: number;
|
|
113
|
+
cache: Map<string, CacheEntry>;
|
|
114
|
+
/** Module srcs already `<link rel=modulepreload>`-ed. */
|
|
115
|
+
preloaded: Set<string>;
|
|
116
|
+
shouldIntercept: (anchor: HTMLAnchorElement, event: Event) => boolean;
|
|
117
|
+
scrollToTop: boolean;
|
|
118
|
+
manageFocus: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* Per-region server-render baseline: `bf-region` id → owned-content key of the
|
|
121
|
+
* server render currently displayed there. The swap planner compares an
|
|
122
|
+
* incoming region against this (not the island-mutated live DOM) so an
|
|
123
|
+
* unchanged region keeps its state; refreshed after each navigation.
|
|
124
|
+
*/
|
|
125
|
+
regionBaselines: Map<string, string>;
|
|
126
|
+
morph: boolean;
|
|
127
|
+
/** Pathname of the currently-displayed region (for the query-only short-circuit). */
|
|
128
|
+
currentPath: string;
|
|
129
|
+
inflight: AbortController | null;
|
|
130
|
+
/** Hover-prefetch dwell timer + the anchor it is counting down for (per instance). */
|
|
131
|
+
hoverTimer: ReturnType<typeof setTimeout> | null;
|
|
132
|
+
hoverAnchor: HTMLAnchorElement | null;
|
|
133
|
+
stop: () => void;
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@barefootjs/router",
|
|
3
|
+
"version": "0.14.0",
|
|
4
|
+
"description": "Backend-agnostic partial-navigation client router for BarefootJS — swaps only the page region and re-hydrates the islands inside it",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "bun run clean && bun run build:js && bun run build:types",
|
|
27
|
+
"build:js": "bun build ./src/index.ts --outdir ./dist --format esm --external @barefootjs/shared --external @barefootjs/client",
|
|
28
|
+
"build:types": "tsgo --emitDeclarationOnly --outDir ./dist",
|
|
29
|
+
"test": "bun test",
|
|
30
|
+
"clean": "rm -rf dist",
|
|
31
|
+
"prepack": "node ../../scripts/swap-publish-config.mjs pack",
|
|
32
|
+
"postpack": "node ../../scripts/swap-publish-config.mjs unpack"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"barefoot",
|
|
36
|
+
"router",
|
|
37
|
+
"spa",
|
|
38
|
+
"partial-navigation",
|
|
39
|
+
"islands",
|
|
40
|
+
"region"
|
|
41
|
+
],
|
|
42
|
+
"author": "kobaken <kentafly88@gmail.com>",
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "https://github.com/piconic-ai/barefootjs",
|
|
47
|
+
"directory": "packages/router"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@barefootjs/shared": "0.14.0"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@barefootjs/client": ">=0.14.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"@barefootjs/client": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@barefootjs/client": "^0.14.0",
|
|
62
|
+
"@happy-dom/global-registrator": "^20.0.11",
|
|
63
|
+
"typescript": "^5.0.0"
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/a11y.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focus management and route-change announcement on swap (spec/router.md
|
|
3
|
+
* lifecycle step 7).
|
|
4
|
+
*
|
|
5
|
+
* A client swap replaces content without a browser navigation, so the browser
|
|
6
|
+
* does none of the accessibility work a real page load does: focus is left on a
|
|
7
|
+
* now-detached node and a screen reader never hears that the route changed.
|
|
8
|
+
* After a region swap the router (a) moves focus into the new region and (b)
|
|
9
|
+
* announces the route via a polite live region, mirroring what Remix/Turbo do.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const LIVE_REGION_ID = 'bf-route-announcer'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Move focus into the freshly swapped region so keyboard/AT users resume there
|
|
16
|
+
* rather than at the top of a detached tree. Prefer the region's first heading
|
|
17
|
+
* (gives a screen reader the page context); fall back to the region element
|
|
18
|
+
* itself, made programmatically focusable without becoming a tab stop.
|
|
19
|
+
*/
|
|
20
|
+
export function focusRegion(region: Element): void {
|
|
21
|
+
if (typeof document === 'undefined') return
|
|
22
|
+
const target =
|
|
23
|
+
(region.querySelector('h1, h2, [role="heading"]') as HTMLElement | null) ??
|
|
24
|
+
(region as HTMLElement)
|
|
25
|
+
if (!target) return
|
|
26
|
+
// `tabindex=-1` makes an otherwise non-focusable element focusable via script
|
|
27
|
+
// without inserting it into the tab order. Leave an author-set tabindex alone.
|
|
28
|
+
if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1')
|
|
29
|
+
try {
|
|
30
|
+
target.focus({ preventScroll: true })
|
|
31
|
+
} catch {
|
|
32
|
+
// Older engines reject the options arg — fall back to a bare focus().
|
|
33
|
+
target.focus()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Announce the new route through a singleton `aria-live="polite"` region kept
|
|
39
|
+
* visually hidden. Setting its text after the swap lets a screen reader read
|
|
40
|
+
* the new page title without stealing focus.
|
|
41
|
+
*/
|
|
42
|
+
export function announceNavigation(title: string | null): void {
|
|
43
|
+
if (typeof document === 'undefined') return
|
|
44
|
+
const message = (title ?? document.title ?? '').trim()
|
|
45
|
+
if (!message) return
|
|
46
|
+
const announcer = ensureAnnouncer()
|
|
47
|
+
// Clearing first guarantees the value changes, so AT re-announces even when
|
|
48
|
+
// two consecutive routes share a title.
|
|
49
|
+
announcer.textContent = ''
|
|
50
|
+
announcer.textContent = message
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ensureAnnouncer(): HTMLElement {
|
|
54
|
+
const existing = document.getElementById(LIVE_REGION_ID)
|
|
55
|
+
if (existing) return existing
|
|
56
|
+
const el = document.createElement('div')
|
|
57
|
+
el.id = LIVE_REGION_ID
|
|
58
|
+
el.setAttribute('aria-live', 'polite')
|
|
59
|
+
el.setAttribute('aria-atomic', 'true')
|
|
60
|
+
el.setAttribute('role', 'status')
|
|
61
|
+
// Visually hidden but available to assistive tech (the standard sr-only clip).
|
|
62
|
+
el.style.cssText =
|
|
63
|
+
'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0'
|
|
64
|
+
document.body.appendChild(el)
|
|
65
|
+
return el
|
|
66
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale-while-revalidate snapshot cache with an LRU bound.
|
|
3
|
+
*
|
|
4
|
+
* The cache stores *Promises*, so a prefetch and a near-simultaneous click for
|
|
5
|
+
* the same URL share a single in-flight request (single-flight). Three states,
|
|
6
|
+
* keyed on `now` vs the entry's `refreshAt`/`staleAt`:
|
|
7
|
+
* - fresh (now < refreshAt) → serve cached, no refetch.
|
|
8
|
+
* - aging (refreshAt ≤ now < staleAt) → serve cached AND refresh in the
|
|
9
|
+
* background (single-flight), so a swap is instant while the next is fresh.
|
|
10
|
+
* - stale (now ≥ staleAt) / miss → fetch synchronously; never serve stale.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { PageSnapshot, RouterState } from './types.ts'
|
|
14
|
+
|
|
15
|
+
const REFRESH_JITTER = 0.3 // ±30% on the per-entry refresh threshold
|
|
16
|
+
|
|
17
|
+
export function fetchSnapshot(state: RouterState, url: string): Promise<PageSnapshot | null> {
|
|
18
|
+
return (async () => {
|
|
19
|
+
try {
|
|
20
|
+
const res = await state.fetchFn(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
|
|
21
|
+
if (!res.ok) return null
|
|
22
|
+
// Response-URL base resolution: a redirect commits at the real URL.
|
|
23
|
+
const finalUrl = res.redirected && res.url ? res.url : url
|
|
24
|
+
const html = await res.text()
|
|
25
|
+
return { html, finalUrl }
|
|
26
|
+
} catch {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
})()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function storeEntry(state: RouterState, url: string, snap: Promise<PageSnapshot | null>): void {
|
|
33
|
+
const now = Date.now()
|
|
34
|
+
const jitter = 1 + (Math.random() * 2 - 1) * REFRESH_JITTER // 0.7 … 1.3
|
|
35
|
+
state.cache.set(url, {
|
|
36
|
+
snap,
|
|
37
|
+
refreshAt: now + state.cacheFreshMs * jitter,
|
|
38
|
+
staleAt: now + state.cacheStaleMs,
|
|
39
|
+
refreshing: false,
|
|
40
|
+
})
|
|
41
|
+
// Don't cache failures: evict once the promise resolves to null.
|
|
42
|
+
void snap.then((result) => {
|
|
43
|
+
if (result === null && state.cache.get(url)?.snap === snap) state.cache.delete(url)
|
|
44
|
+
})
|
|
45
|
+
// Bound the cache (LRU: a hit re-inserts, so the first key is least-recent).
|
|
46
|
+
while (state.cache.size > state.cacheCap) {
|
|
47
|
+
const lru = state.cache.keys().next().value
|
|
48
|
+
if (lru === undefined) break
|
|
49
|
+
state.cache.delete(lru)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function loadPage(state: RouterState, url: string): Promise<PageSnapshot | null> {
|
|
54
|
+
const hit = state.cache.get(url)
|
|
55
|
+
const now = Date.now()
|
|
56
|
+
|
|
57
|
+
if (hit && now < hit.staleAt) {
|
|
58
|
+
if (now >= hit.refreshAt && !hit.refreshing) {
|
|
59
|
+
// Aging → refresh in the background (single-flight), keep serving cached.
|
|
60
|
+
hit.refreshing = true
|
|
61
|
+
const fresh = fetchSnapshot(state, url)
|
|
62
|
+
void fresh.then((result) => {
|
|
63
|
+
if (result !== null) storeEntry(state, url, fresh) // swap in the fresh window
|
|
64
|
+
else if (state.cache.get(url) === hit) hit.refreshing = false // failed → keep old, retry later
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
// LRU bump: move this entry to the most-recent position.
|
|
68
|
+
state.cache.delete(url)
|
|
69
|
+
state.cache.set(url, hit)
|
|
70
|
+
return hit.snap
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Miss or past staleAt → fetch fresh (and cache it).
|
|
74
|
+
const snap = fetchSnapshot(state, url)
|
|
75
|
+
storeEntry(state, url, snap)
|
|
76
|
+
return snap
|
|
77
|
+
}
|
package/src/index.ts
ADDED
package/src/morph.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permanent-node preservation during a region swap (spec/router.md **v1**,
|
|
3
|
+
* "persistence within a region": `data-bf-permanent` + idiomorph-style
|
|
4
|
+
* morphing).
|
|
5
|
+
*
|
|
6
|
+
* A plain swap (`replaceChildren`) tears the whole region down and rebuilds it,
|
|
7
|
+
* so anything with live state inside the region — a playing `<video>`, a
|
|
8
|
+
* scrolled list, an open `<details>`, an input's value — is lost across a
|
|
9
|
+
* navigation. An element marked `data-bf-permanent` is instead **preserved**:
|
|
10
|
+
* its *live* DOM node (with its state, media playback, scroll, and already-
|
|
11
|
+
* hydrated reactive scope) is moved into the incoming tree at the matching
|
|
12
|
+
* position, rather than disposed and recreated. (Focus is not preserved when
|
|
13
|
+
* the router's default `manageFocus` moves it to the region heading post-swap.)
|
|
14
|
+
*
|
|
15
|
+
* The match is keyed by the `data-bf-permanent` value (falling back to `id`),
|
|
16
|
+
* so the same logical element is recognised across the two page documents —
|
|
17
|
+
* idiomorph's id-keyed node reuse, scoped to the nodes the author marked.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Stable key for a permanent element: its `data-bf-permanent` value, else `id`. */
|
|
21
|
+
function permanentKey(el: Element): string | null {
|
|
22
|
+
const attr = el.getAttribute('data-bf-permanent')
|
|
23
|
+
if (attr) return attr
|
|
24
|
+
if (el.id) return el.id
|
|
25
|
+
return null // marked but unkeyed → cannot be matched across documents; treated as ordinary content
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Assemble the incoming region nodes, moving any **live** `[data-bf-permanent]`
|
|
30
|
+
* node from `current` into the matching position of the new tree. Returns the
|
|
31
|
+
* fragment to insert.
|
|
32
|
+
*
|
|
33
|
+
* Call this **before** disposing `current`: moving the live nodes out first is
|
|
34
|
+
* exactly what spares them from the dispose walk, and re-inserting them keeps
|
|
35
|
+
* their hydrated scope marks so the subsequent re-hydration walk skips them
|
|
36
|
+
* (`hydrateElementScope` is a no-op on an already-hydrated node).
|
|
37
|
+
*/
|
|
38
|
+
export function buildMorphedContent(current: Element, incoming: Node[]): DocumentFragment {
|
|
39
|
+
const fragment = document.createDocumentFragment()
|
|
40
|
+
for (const node of incoming) fragment.appendChild(node)
|
|
41
|
+
|
|
42
|
+
// Index the live permanent nodes currently mounted in the region.
|
|
43
|
+
const liveByKey = new Map<string, Element>()
|
|
44
|
+
for (const el of current.querySelectorAll('[data-bf-permanent]')) {
|
|
45
|
+
const key = permanentKey(el)
|
|
46
|
+
if (key && !liveByKey.has(key)) liveByKey.set(key, el)
|
|
47
|
+
}
|
|
48
|
+
if (liveByKey.size === 0) return fragment
|
|
49
|
+
|
|
50
|
+
// For each incoming placeholder with a matching key, swap in the live node.
|
|
51
|
+
for (const placeholder of Array.from(fragment.querySelectorAll('[data-bf-permanent]'))) {
|
|
52
|
+
// Nested permanents: this list is a snapshot. Once an *outer* placeholder is
|
|
53
|
+
// replaced by a live node, its descendant placeholders detach from
|
|
54
|
+
// `fragment` — and that live node already carries its own nested permanents
|
|
55
|
+
// from the old tree, so we must NOT then yank those out into a stale
|
|
56
|
+
// placeholder. Skip any placeholder no longer rooted in `fragment`.
|
|
57
|
+
if (!fragment.contains(placeholder)) continue
|
|
58
|
+
const key = permanentKey(placeholder)
|
|
59
|
+
if (!key) continue
|
|
60
|
+
const live = liveByKey.get(key)
|
|
61
|
+
if (!live) continue
|
|
62
|
+
// Moves `live` out of `current` and into the new tree at the placeholder's
|
|
63
|
+
// position; the freshly-parsed placeholder is discarded.
|
|
64
|
+
placeholder.replaceWith(live)
|
|
65
|
+
liveByKey.delete(key) // one live node per key
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return fragment
|
|
69
|
+
}
|