@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.
@@ -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"}
@@ -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"}
@@ -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"}
@@ -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
@@ -0,0 +1,5 @@
1
+ export { startRouter, navigate } from './router.ts'
2
+ export type { RouterOptions, NavigateOptions, Router } from './types.ts'
3
+
4
+ // Re-exported so server-side helpers can reference the swappable-region marker.
5
+ export { BF_REGION } from '@barefootjs/shared'
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
+ }