@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/index.js
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
// src/router.ts
|
|
2
|
+
import { BF_REGION as BF_REGION2 } from "@barefootjs/shared";
|
|
3
|
+
|
|
4
|
+
// src/cache.ts
|
|
5
|
+
var REFRESH_JITTER = 0.3;
|
|
6
|
+
function fetchSnapshot(state, url) {
|
|
7
|
+
return (async () => {
|
|
8
|
+
try {
|
|
9
|
+
const res = await state.fetchFn(url, { headers: { Accept: "text/html" }, credentials: "same-origin" });
|
|
10
|
+
if (!res.ok)
|
|
11
|
+
return null;
|
|
12
|
+
const finalUrl = res.redirected && res.url ? res.url : url;
|
|
13
|
+
const html = await res.text();
|
|
14
|
+
return { html, finalUrl };
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
})();
|
|
19
|
+
}
|
|
20
|
+
function storeEntry(state, url, snap) {
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
const jitter = 1 + (Math.random() * 2 - 1) * REFRESH_JITTER;
|
|
23
|
+
state.cache.set(url, {
|
|
24
|
+
snap,
|
|
25
|
+
refreshAt: now + state.cacheFreshMs * jitter,
|
|
26
|
+
staleAt: now + state.cacheStaleMs,
|
|
27
|
+
refreshing: false
|
|
28
|
+
});
|
|
29
|
+
snap.then((result) => {
|
|
30
|
+
if (result === null && state.cache.get(url)?.snap === snap)
|
|
31
|
+
state.cache.delete(url);
|
|
32
|
+
});
|
|
33
|
+
while (state.cache.size > state.cacheCap) {
|
|
34
|
+
const lru = state.cache.keys().next().value;
|
|
35
|
+
if (lru === undefined)
|
|
36
|
+
break;
|
|
37
|
+
state.cache.delete(lru);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function loadPage(state, url) {
|
|
41
|
+
const hit = state.cache.get(url);
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
if (hit && now < hit.staleAt) {
|
|
44
|
+
if (now >= hit.refreshAt && !hit.refreshing) {
|
|
45
|
+
hit.refreshing = true;
|
|
46
|
+
const fresh = fetchSnapshot(state, url);
|
|
47
|
+
fresh.then((result) => {
|
|
48
|
+
if (result !== null)
|
|
49
|
+
storeEntry(state, url, fresh);
|
|
50
|
+
else if (state.cache.get(url) === hit)
|
|
51
|
+
hit.refreshing = false;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
state.cache.delete(url);
|
|
55
|
+
state.cache.set(url, hit);
|
|
56
|
+
return hit.snap;
|
|
57
|
+
}
|
|
58
|
+
const snap = fetchSnapshot(state, url);
|
|
59
|
+
storeEntry(state, url, snap);
|
|
60
|
+
return snap;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/region.ts
|
|
64
|
+
import { BF_HOST, BF_PROPS, BF_REGION, BF_SCOPE, BF_SCOPE_COMMENT_PREFIX } from "@barefootjs/shared";
|
|
65
|
+
function parseDocument(html) {
|
|
66
|
+
return new DOMParser().parseFromString(html, "text/html");
|
|
67
|
+
}
|
|
68
|
+
function collectModuleScripts(root, baseUrl) {
|
|
69
|
+
const out = new Set;
|
|
70
|
+
for (const s of root.querySelectorAll('script[type="module"][src]')) {
|
|
71
|
+
const src = s.getAttribute("src");
|
|
72
|
+
if (!src)
|
|
73
|
+
continue;
|
|
74
|
+
try {
|
|
75
|
+
const url = new URL(src, baseUrl);
|
|
76
|
+
if (url.origin === window.location.origin)
|
|
77
|
+
out.add(url.href);
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
function indexRegions(root, selector) {
|
|
83
|
+
const map = new Map;
|
|
84
|
+
for (const el of root.querySelectorAll(selector)) {
|
|
85
|
+
const id = el.getAttribute(BF_REGION) ?? "";
|
|
86
|
+
if (map.has(id))
|
|
87
|
+
return null;
|
|
88
|
+
map.set(id, el);
|
|
89
|
+
}
|
|
90
|
+
return map;
|
|
91
|
+
}
|
|
92
|
+
function ownedContentKey(region, selector) {
|
|
93
|
+
const clone = region.cloneNode(true);
|
|
94
|
+
for (const nested of Array.from(clone.querySelectorAll(selector))) {
|
|
95
|
+
if (!clone.contains(nested))
|
|
96
|
+
continue;
|
|
97
|
+
const mask = (clone.ownerDocument ?? document).createElement("bf-region-mask");
|
|
98
|
+
mask.setAttribute("data-id", nested.getAttribute(BF_REGION) ?? "");
|
|
99
|
+
nested.replaceWith(mask);
|
|
100
|
+
}
|
|
101
|
+
stripVolatileHydration(clone);
|
|
102
|
+
return clone.innerHTML;
|
|
103
|
+
}
|
|
104
|
+
var VOLATILE_ATTRS = [BF_SCOPE, BF_HOST];
|
|
105
|
+
function stripVolatileHydration(root) {
|
|
106
|
+
for (const a of VOLATILE_ATTRS)
|
|
107
|
+
root.removeAttribute(a);
|
|
108
|
+
normalizePropsAttr(root);
|
|
109
|
+
const walker = (root.ownerDocument ?? document).createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT);
|
|
110
|
+
while (walker.nextNode()) {
|
|
111
|
+
const node = walker.currentNode;
|
|
112
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
113
|
+
for (const a of VOLATILE_ATTRS)
|
|
114
|
+
node.removeAttribute(a);
|
|
115
|
+
normalizePropsAttr(node);
|
|
116
|
+
} else if (node.data.startsWith(BF_SCOPE_COMMENT_PREFIX)) {
|
|
117
|
+
node.data = normalizeScopeComment(node.data);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function normalizePropsAttr(el) {
|
|
122
|
+
const raw = el.getAttribute(BF_PROPS);
|
|
123
|
+
if (raw === null)
|
|
124
|
+
return;
|
|
125
|
+
try {
|
|
126
|
+
const obj = JSON.parse(raw);
|
|
127
|
+
if (obj && typeof obj === "object" && "scopeID" in obj) {
|
|
128
|
+
obj.scopeID = "";
|
|
129
|
+
el.setAttribute(BF_PROPS, JSON.stringify(obj));
|
|
130
|
+
}
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
133
|
+
function normalizeScopeComment(data) {
|
|
134
|
+
const parts = data.slice(BF_SCOPE_COMMENT_PREFIX.length).split("|");
|
|
135
|
+
parts[0] = "";
|
|
136
|
+
const kept = parts.filter((p, i) => i === 0 || !p.startsWith("h="));
|
|
137
|
+
return BF_SCOPE_COMMENT_PREFIX + kept.join("|");
|
|
138
|
+
}
|
|
139
|
+
function planRegionSwaps(currentRoot, incomingRoot, selector, baselines) {
|
|
140
|
+
const cur = indexRegions(currentRoot, selector);
|
|
141
|
+
const inc = indexRegions(incomingRoot, selector);
|
|
142
|
+
if (!cur || !inc || !sameKeys(cur, inc))
|
|
143
|
+
return { mode: "broadest" };
|
|
144
|
+
const incomingKeys = new Map;
|
|
145
|
+
const candidates = [];
|
|
146
|
+
for (const [id, current] of cur) {
|
|
147
|
+
const incoming = inc.get(id);
|
|
148
|
+
const incomingKey = ownedContentKey(incoming, selector);
|
|
149
|
+
incomingKeys.set(id, incomingKey);
|
|
150
|
+
const baseline = baselines.get(id) ?? ownedContentKey(current, selector);
|
|
151
|
+
if (incomingKey !== baseline)
|
|
152
|
+
candidates.push({ current, incoming });
|
|
153
|
+
}
|
|
154
|
+
const targets = candidates.filter((c) => !candidates.some((o) => o !== c && o.current.contains(c.current)));
|
|
155
|
+
return { mode: "regions", targets, incomingKeys };
|
|
156
|
+
}
|
|
157
|
+
function captureRegionBaselines(root, selector) {
|
|
158
|
+
const out = new Map;
|
|
159
|
+
for (const el of root.querySelectorAll(selector)) {
|
|
160
|
+
out.set(el.getAttribute(BF_REGION) ?? "", ownedContentKey(el, selector));
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
function sameKeys(a, b) {
|
|
165
|
+
if (a.size !== b.size)
|
|
166
|
+
return false;
|
|
167
|
+
for (const k of a.keys())
|
|
168
|
+
if (!b.has(k))
|
|
169
|
+
return false;
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
function isRootRegion(region, selector) {
|
|
173
|
+
const root = region.ownerDocument ?? document;
|
|
174
|
+
for (const el of root.querySelectorAll(selector)) {
|
|
175
|
+
if (el !== region && !region.contains(el))
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
function importRegionChildren(incoming) {
|
|
181
|
+
return Array.from(incoming.childNodes).map((n) => document.importNode(n, true));
|
|
182
|
+
}
|
|
183
|
+
function collectRegionModuleSrcs(html, selector, baseUrl) {
|
|
184
|
+
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
185
|
+
if (!doc.querySelector(selector))
|
|
186
|
+
return null;
|
|
187
|
+
return [...collectModuleScripts(doc, baseUrl)];
|
|
188
|
+
}
|
|
189
|
+
async function loadNewModules(state, srcs) {
|
|
190
|
+
const fresh = srcs.filter((s) => !state.loadedModules.has(s));
|
|
191
|
+
await Promise.all(fresh.map(async (src) => {
|
|
192
|
+
try {
|
|
193
|
+
await state.loadModule(src);
|
|
194
|
+
state.loadedModules.add(src);
|
|
195
|
+
} catch {}
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// src/morph.ts
|
|
200
|
+
function permanentKey(el) {
|
|
201
|
+
const attr = el.getAttribute("data-bf-permanent");
|
|
202
|
+
if (attr)
|
|
203
|
+
return attr;
|
|
204
|
+
if (el.id)
|
|
205
|
+
return el.id;
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
function buildMorphedContent(current, incoming) {
|
|
209
|
+
const fragment = document.createDocumentFragment();
|
|
210
|
+
for (const node of incoming)
|
|
211
|
+
fragment.appendChild(node);
|
|
212
|
+
const liveByKey = new Map;
|
|
213
|
+
for (const el of current.querySelectorAll("[data-bf-permanent]")) {
|
|
214
|
+
const key = permanentKey(el);
|
|
215
|
+
if (key && !liveByKey.has(key))
|
|
216
|
+
liveByKey.set(key, el);
|
|
217
|
+
}
|
|
218
|
+
if (liveByKey.size === 0)
|
|
219
|
+
return fragment;
|
|
220
|
+
for (const placeholder of Array.from(fragment.querySelectorAll("[data-bf-permanent]"))) {
|
|
221
|
+
if (!fragment.contains(placeholder))
|
|
222
|
+
continue;
|
|
223
|
+
const key = permanentKey(placeholder);
|
|
224
|
+
if (!key)
|
|
225
|
+
continue;
|
|
226
|
+
const live = liveByKey.get(key);
|
|
227
|
+
if (!live)
|
|
228
|
+
continue;
|
|
229
|
+
placeholder.replaceWith(live);
|
|
230
|
+
liveByKey.delete(key);
|
|
231
|
+
}
|
|
232
|
+
return fragment;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/seams.ts
|
|
236
|
+
import {
|
|
237
|
+
BF_HOST as BF_HOST2,
|
|
238
|
+
BF_SCOPE as BF_SCOPE2,
|
|
239
|
+
BF_SEAM_DISPOSE_WITHIN,
|
|
240
|
+
BF_SEAM_HYDRATE,
|
|
241
|
+
BF_SEAM_HYDRATE_WITHIN,
|
|
242
|
+
BF_SEAM_PUSH_SEARCH
|
|
243
|
+
} from "@barefootjs/shared";
|
|
244
|
+
function pushSearchSeam(search) {
|
|
245
|
+
const w = window;
|
|
246
|
+
const push = w[BF_SEAM_PUSH_SEARCH];
|
|
247
|
+
if (typeof push !== "function")
|
|
248
|
+
return false;
|
|
249
|
+
push(search);
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
async function defaultRehydrate(region) {
|
|
253
|
+
const w = window;
|
|
254
|
+
const hydrateWithin = w[BF_SEAM_HYDRATE_WITHIN];
|
|
255
|
+
if (typeof hydrateWithin === "function") {
|
|
256
|
+
hydrateWithin(region);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const hydrateAll = w[BF_SEAM_HYDRATE];
|
|
260
|
+
if (typeof hydrateAll === "function") {
|
|
261
|
+
hydrateAll();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const spec = "@barefootjs/client/runtime";
|
|
266
|
+
const mod = await import(spec);
|
|
267
|
+
if (mod.rehydrateScope)
|
|
268
|
+
mod.rehydrateScope(region);
|
|
269
|
+
else
|
|
270
|
+
mod.rehydrateAll?.();
|
|
271
|
+
} catch {
|
|
272
|
+
warnIfIslandsUnreachable(region, "re-hydrate");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function defaultDispose(region) {
|
|
276
|
+
const w = window;
|
|
277
|
+
const disposeWithin = w[BF_SEAM_DISPOSE_WITHIN];
|
|
278
|
+
if (typeof disposeWithin === "function") {
|
|
279
|
+
disposeWithin(region);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
const spec = "@barefootjs/client/runtime";
|
|
284
|
+
const mod = await import(spec);
|
|
285
|
+
mod.disposeScope?.(region);
|
|
286
|
+
} catch {
|
|
287
|
+
warnIfIslandsUnreachable(region, "dispose");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function regionHasIslands(region) {
|
|
291
|
+
return region.hasAttribute(BF_SCOPE2) || region.hasAttribute(BF_HOST2) || region.querySelector(`[${BF_SCOPE2}],[${BF_HOST2}]`) !== null;
|
|
292
|
+
}
|
|
293
|
+
function warnIfIslandsUnreachable(region, op) {
|
|
294
|
+
if (!regionHasIslands(region))
|
|
295
|
+
return;
|
|
296
|
+
console.error(`[barefootjs/router] could not load @barefootjs/client/runtime to ${op} a region that contains islands. ` + `The swapped content may leak handlers or not be interactive — ensure the runtime is served and mapped in the page's import map.`);
|
|
297
|
+
}
|
|
298
|
+
function hardNavigate(url) {
|
|
299
|
+
window.location.assign(url);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/a11y.ts
|
|
303
|
+
var LIVE_REGION_ID = "bf-route-announcer";
|
|
304
|
+
function focusRegion(region) {
|
|
305
|
+
if (typeof document === "undefined")
|
|
306
|
+
return;
|
|
307
|
+
const target = region.querySelector('h1, h2, [role="heading"]') ?? region;
|
|
308
|
+
if (!target)
|
|
309
|
+
return;
|
|
310
|
+
if (!target.hasAttribute("tabindex"))
|
|
311
|
+
target.setAttribute("tabindex", "-1");
|
|
312
|
+
try {
|
|
313
|
+
target.focus({ preventScroll: true });
|
|
314
|
+
} catch {
|
|
315
|
+
target.focus();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function announceNavigation(title) {
|
|
319
|
+
if (typeof document === "undefined")
|
|
320
|
+
return;
|
|
321
|
+
const message = (title ?? document.title ?? "").trim();
|
|
322
|
+
if (!message)
|
|
323
|
+
return;
|
|
324
|
+
const announcer = ensureAnnouncer();
|
|
325
|
+
announcer.textContent = "";
|
|
326
|
+
announcer.textContent = message;
|
|
327
|
+
}
|
|
328
|
+
function ensureAnnouncer() {
|
|
329
|
+
const existing = document.getElementById(LIVE_REGION_ID);
|
|
330
|
+
if (existing)
|
|
331
|
+
return existing;
|
|
332
|
+
const el = document.createElement("div");
|
|
333
|
+
el.id = LIVE_REGION_ID;
|
|
334
|
+
el.setAttribute("aria-live", "polite");
|
|
335
|
+
el.setAttribute("aria-atomic", "true");
|
|
336
|
+
el.setAttribute("role", "status");
|
|
337
|
+
el.style.cssText = "position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0";
|
|
338
|
+
document.body.appendChild(el);
|
|
339
|
+
return el;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/router.ts
|
|
343
|
+
var active = null;
|
|
344
|
+
function startRouter(options = {}) {
|
|
345
|
+
if (typeof document === "undefined" || typeof window === "undefined") {
|
|
346
|
+
return { stop() {}, navigate: async () => {}, prefetch() {} };
|
|
347
|
+
}
|
|
348
|
+
if (active)
|
|
349
|
+
active.stop();
|
|
350
|
+
const state = {
|
|
351
|
+
regionSelector: options.region ?? `[${BF_REGION2}]`,
|
|
352
|
+
rehydrate: options.rehydrate ?? defaultRehydrate,
|
|
353
|
+
dispose: options.dispose ?? defaultDispose,
|
|
354
|
+
loadModule: options.loadModule ?? ((src) => import(src)),
|
|
355
|
+
fetchFn: options.fetch ?? ((input, init) => fetch(input, init)),
|
|
356
|
+
loadedModules: collectModuleScripts(document, window.location.href),
|
|
357
|
+
prefetchEnabled: options.prefetch ?? true,
|
|
358
|
+
prefetchDelay: options.prefetchDelay ?? 65,
|
|
359
|
+
cacheFreshMs: options.cacheFreshMs ?? 15000,
|
|
360
|
+
cacheStaleMs: options.cacheStaleMs ?? 60000,
|
|
361
|
+
cacheCap: options.cacheCap ?? 30,
|
|
362
|
+
cache: new Map,
|
|
363
|
+
preloaded: new Set,
|
|
364
|
+
shouldIntercept: options.shouldIntercept ?? defaultShouldIntercept,
|
|
365
|
+
scrollToTop: options.scrollToTop ?? true,
|
|
366
|
+
manageFocus: options.manageFocus ?? true,
|
|
367
|
+
morph: options.morph ?? true,
|
|
368
|
+
regionBaselines: captureRegionBaselines(document, options.region ?? `[${BF_REGION2}]`),
|
|
369
|
+
currentPath: window.location.pathname,
|
|
370
|
+
inflight: null,
|
|
371
|
+
hoverTimer: null,
|
|
372
|
+
hoverAnchor: null,
|
|
373
|
+
stop: () => {}
|
|
374
|
+
};
|
|
375
|
+
document.addEventListener("click", onClick);
|
|
376
|
+
window.addEventListener("popstate", onPopState);
|
|
377
|
+
if (state.prefetchEnabled) {
|
|
378
|
+
document.addEventListener("mouseover", onPointerOver);
|
|
379
|
+
document.addEventListener("mouseout", onPointerOut);
|
|
380
|
+
document.addEventListener("focusin", onFocusIn);
|
|
381
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
382
|
+
}
|
|
383
|
+
commitHistory("replace", window.location.href);
|
|
384
|
+
state.stop = () => {
|
|
385
|
+
document.removeEventListener("click", onClick);
|
|
386
|
+
window.removeEventListener("popstate", onPopState);
|
|
387
|
+
document.removeEventListener("mouseover", onPointerOver);
|
|
388
|
+
document.removeEventListener("mouseout", onPointerOut);
|
|
389
|
+
document.removeEventListener("focusin", onFocusIn);
|
|
390
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
391
|
+
clearHoverTimer(state);
|
|
392
|
+
state.inflight?.abort();
|
|
393
|
+
if (active === state)
|
|
394
|
+
active = null;
|
|
395
|
+
};
|
|
396
|
+
active = state;
|
|
397
|
+
return { stop: state.stop, navigate, prefetch: (url) => prefetch(url) };
|
|
398
|
+
}
|
|
399
|
+
function toFragment(nodes) {
|
|
400
|
+
const frag = document.createDocumentFragment();
|
|
401
|
+
for (const node of nodes)
|
|
402
|
+
frag.appendChild(node);
|
|
403
|
+
return frag;
|
|
404
|
+
}
|
|
405
|
+
function commitHistory(mode, url) {
|
|
406
|
+
if (mode === "push") {
|
|
407
|
+
window.history.pushState({ bfRouter: true }, "", url);
|
|
408
|
+
} else {
|
|
409
|
+
const prev = window.history.state ?? {};
|
|
410
|
+
window.history.replaceState({ ...prev, bfRouter: true }, "", url);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function onClick(event) {
|
|
414
|
+
if (!active)
|
|
415
|
+
return;
|
|
416
|
+
if (event.defaultPrevented)
|
|
417
|
+
return;
|
|
418
|
+
if (event.button !== 0)
|
|
419
|
+
return;
|
|
420
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
421
|
+
return;
|
|
422
|
+
const target = event.target;
|
|
423
|
+
const anchor = target?.closest?.("a");
|
|
424
|
+
if (!anchor || !anchor.getAttribute("href"))
|
|
425
|
+
return;
|
|
426
|
+
if (!active.shouldIntercept(anchor, event))
|
|
427
|
+
return;
|
|
428
|
+
event.preventDefault();
|
|
429
|
+
navigate(anchor.href);
|
|
430
|
+
}
|
|
431
|
+
function defaultShouldIntercept(anchor) {
|
|
432
|
+
if (anchor.dataset.bfRouter === "false")
|
|
433
|
+
return false;
|
|
434
|
+
if (anchor.hasAttribute("download"))
|
|
435
|
+
return false;
|
|
436
|
+
const linkTarget = anchor.getAttribute("target");
|
|
437
|
+
if (linkTarget && linkTarget !== "_self")
|
|
438
|
+
return false;
|
|
439
|
+
const rel = anchor.getAttribute("rel") ?? "";
|
|
440
|
+
if (rel.split(/\s+/).includes("external"))
|
|
441
|
+
return false;
|
|
442
|
+
let url;
|
|
443
|
+
try {
|
|
444
|
+
url = new URL(anchor.href, window.location.href);
|
|
445
|
+
} catch {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
if (url.origin !== window.location.origin)
|
|
449
|
+
return false;
|
|
450
|
+
if (url.pathname === window.location.pathname && url.search === window.location.search && url.hash) {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
function onPopState() {
|
|
456
|
+
if (!active)
|
|
457
|
+
return;
|
|
458
|
+
if (window.location.pathname === active.currentPath && pushSearchSeam(window.location.search)) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
navigate(window.location.href, { history: false });
|
|
462
|
+
}
|
|
463
|
+
async function navigate(url, options = {}) {
|
|
464
|
+
if (typeof window === "undefined" || typeof document === "undefined")
|
|
465
|
+
return;
|
|
466
|
+
const mode = options.history ?? "push";
|
|
467
|
+
const state = active;
|
|
468
|
+
if (!state) {
|
|
469
|
+
hardNavigate(url);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const target = new URL(url, window.location.href);
|
|
473
|
+
if (target.origin !== window.location.origin) {
|
|
474
|
+
hardNavigate(url);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (target.pathname === window.location.pathname && target.search !== window.location.search && pushSearchSeam(target.search)) {
|
|
478
|
+
state.inflight?.abort();
|
|
479
|
+
if (mode === "push")
|
|
480
|
+
commitHistory("push", target.href);
|
|
481
|
+
else if (mode === "replace")
|
|
482
|
+
commitHistory("replace", target.href);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
state.inflight?.abort();
|
|
486
|
+
const controller = new AbortController;
|
|
487
|
+
state.inflight = controller;
|
|
488
|
+
try {
|
|
489
|
+
const snap = await loadPage(state, target.href);
|
|
490
|
+
if (controller.signal.aborted)
|
|
491
|
+
return;
|
|
492
|
+
if (!snap) {
|
|
493
|
+
hardNavigate(target.href);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const finalUrl = snap.finalUrl;
|
|
497
|
+
const incomingDoc = parseDocument(snap.html);
|
|
498
|
+
const title = incomingDoc.querySelector("title")?.textContent ?? null;
|
|
499
|
+
const moduleSrcs = [...collectModuleScripts(incomingDoc, finalUrl)];
|
|
500
|
+
const plan = planRegionSwaps(document, incomingDoc, state.regionSelector, state.regionBaselines);
|
|
501
|
+
let targets;
|
|
502
|
+
if (plan.mode === "regions") {
|
|
503
|
+
targets = plan.targets;
|
|
504
|
+
} else {
|
|
505
|
+
const current = document.querySelector(state.regionSelector);
|
|
506
|
+
const incoming = incomingDoc.querySelector(state.regionSelector);
|
|
507
|
+
if (!current || !incoming || !isRootRegion(current, state.regionSelector)) {
|
|
508
|
+
hardNavigate(finalUrl);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
targets = [{ current, incoming }];
|
|
512
|
+
}
|
|
513
|
+
const swapped = [];
|
|
514
|
+
for (const { current, incoming } of targets) {
|
|
515
|
+
const nodes = importRegionChildren(incoming);
|
|
516
|
+
const fragment = state.morph ? buildMorphedContent(current, nodes) : toFragment(nodes);
|
|
517
|
+
const outgoing = current.cloneNode(false);
|
|
518
|
+
while (current.firstChild)
|
|
519
|
+
outgoing.append(current.firstChild);
|
|
520
|
+
current.replaceChildren(fragment);
|
|
521
|
+
swapped.push({ region: current, outgoing });
|
|
522
|
+
}
|
|
523
|
+
if (title !== null)
|
|
524
|
+
document.title = title;
|
|
525
|
+
if (plan.mode === "regions") {
|
|
526
|
+
for (const [id, key] of plan.incomingKeys)
|
|
527
|
+
state.regionBaselines.set(id, key);
|
|
528
|
+
} else {
|
|
529
|
+
state.regionBaselines = captureRegionBaselines(document, state.regionSelector);
|
|
530
|
+
}
|
|
531
|
+
for (const { outgoing } of swapped)
|
|
532
|
+
await state.dispose(outgoing);
|
|
533
|
+
if (controller.signal.aborted)
|
|
534
|
+
return;
|
|
535
|
+
await loadNewModules(state, moduleSrcs);
|
|
536
|
+
if (controller.signal.aborted)
|
|
537
|
+
return;
|
|
538
|
+
if (mode === "push")
|
|
539
|
+
commitHistory("push", finalUrl);
|
|
540
|
+
else if (mode === "replace")
|
|
541
|
+
commitHistory("replace", finalUrl);
|
|
542
|
+
const committed = new URL(finalUrl, window.location.href);
|
|
543
|
+
state.currentPath = committed.pathname;
|
|
544
|
+
pushSearchSeam(committed.search);
|
|
545
|
+
if (state.scrollToTop)
|
|
546
|
+
window.scrollTo(0, 0);
|
|
547
|
+
for (const { region } of swapped)
|
|
548
|
+
await state.rehydrate(region);
|
|
549
|
+
if (controller.signal.aborted)
|
|
550
|
+
return;
|
|
551
|
+
if (state.manageFocus && swapped.length > 0) {
|
|
552
|
+
focusRegion(swapped[0].region);
|
|
553
|
+
announceNavigation(title);
|
|
554
|
+
}
|
|
555
|
+
} finally {
|
|
556
|
+
if (state.inflight === controller)
|
|
557
|
+
state.inflight = null;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function prefetch(url) {
|
|
561
|
+
const state = active;
|
|
562
|
+
if (!state)
|
|
563
|
+
return;
|
|
564
|
+
let target;
|
|
565
|
+
try {
|
|
566
|
+
target = new URL(url, window.location.href);
|
|
567
|
+
} catch {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (target.origin !== window.location.origin)
|
|
571
|
+
return;
|
|
572
|
+
if (target.href === window.location.href)
|
|
573
|
+
return;
|
|
574
|
+
loadPage(state, target.href).then((snap) => {
|
|
575
|
+
if (snap)
|
|
576
|
+
preloadModules(state, snap);
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
function preloadModules(state, snap) {
|
|
580
|
+
const srcs = collectRegionModuleSrcs(snap.html, state.regionSelector, snap.finalUrl) ?? [];
|
|
581
|
+
for (const src of srcs) {
|
|
582
|
+
if (state.loadedModules.has(src) || state.preloaded.has(src))
|
|
583
|
+
continue;
|
|
584
|
+
state.preloaded.add(src);
|
|
585
|
+
const link = document.createElement("link");
|
|
586
|
+
link.rel = "modulepreload";
|
|
587
|
+
link.href = src;
|
|
588
|
+
document.head.appendChild(link);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
function clearHoverTimer(state) {
|
|
592
|
+
if (state.hoverTimer !== null) {
|
|
593
|
+
clearTimeout(state.hoverTimer);
|
|
594
|
+
state.hoverTimer = null;
|
|
595
|
+
}
|
|
596
|
+
state.hoverAnchor = null;
|
|
597
|
+
}
|
|
598
|
+
function prefetchableAnchor(event) {
|
|
599
|
+
const state = active;
|
|
600
|
+
if (!state)
|
|
601
|
+
return null;
|
|
602
|
+
const anchor = event.target?.closest?.("a");
|
|
603
|
+
if (!anchor || !anchor.getAttribute("href"))
|
|
604
|
+
return null;
|
|
605
|
+
if (!state.shouldIntercept(anchor, event))
|
|
606
|
+
return null;
|
|
607
|
+
return anchor;
|
|
608
|
+
}
|
|
609
|
+
function onPointerOver(event) {
|
|
610
|
+
const state = active;
|
|
611
|
+
if (!state)
|
|
612
|
+
return;
|
|
613
|
+
const anchor = prefetchableAnchor(event);
|
|
614
|
+
if (!anchor)
|
|
615
|
+
return;
|
|
616
|
+
if (anchor === state.hoverAnchor)
|
|
617
|
+
return;
|
|
618
|
+
clearHoverTimer(state);
|
|
619
|
+
state.hoverAnchor = anchor;
|
|
620
|
+
const href = anchor.href;
|
|
621
|
+
state.hoverTimer = setTimeout(() => {
|
|
622
|
+
state.hoverTimer = null;
|
|
623
|
+
state.hoverAnchor = null;
|
|
624
|
+
prefetch(href);
|
|
625
|
+
}, state.prefetchDelay);
|
|
626
|
+
}
|
|
627
|
+
function onPointerOut(event) {
|
|
628
|
+
const state = active;
|
|
629
|
+
if (!state || !state.hoverAnchor)
|
|
630
|
+
return;
|
|
631
|
+
const to = event.relatedTarget;
|
|
632
|
+
if (to && state.hoverAnchor.contains(to))
|
|
633
|
+
return;
|
|
634
|
+
clearHoverTimer(state);
|
|
635
|
+
}
|
|
636
|
+
function onFocusIn(event) {
|
|
637
|
+
const anchor = prefetchableAnchor(event);
|
|
638
|
+
if (anchor)
|
|
639
|
+
prefetch(anchor.href);
|
|
640
|
+
}
|
|
641
|
+
function onPointerDown(event) {
|
|
642
|
+
if (event.button !== 0)
|
|
643
|
+
return;
|
|
644
|
+
const anchor = prefetchableAnchor(event);
|
|
645
|
+
if (anchor)
|
|
646
|
+
prefetch(anchor.href);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// src/index.ts
|
|
650
|
+
import { BF_REGION as BF_REGION3 } from "@barefootjs/shared";
|
|
651
|
+
export {
|
|
652
|
+
startRouter,
|
|
653
|
+
navigate,
|
|
654
|
+
BF_REGION3 as BF_REGION
|
|
655
|
+
};
|
package/dist/morph.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
* Assemble the incoming region nodes, moving any **live** `[data-bf-permanent]`
|
|
21
|
+
* node from `current` into the matching position of the new tree. Returns the
|
|
22
|
+
* fragment to insert.
|
|
23
|
+
*
|
|
24
|
+
* Call this **before** disposing `current`: moving the live nodes out first is
|
|
25
|
+
* exactly what spares them from the dispose walk, and re-inserting them keeps
|
|
26
|
+
* their hydrated scope marks so the subsequent re-hydration walk skips them
|
|
27
|
+
* (`hydrateElementScope` is a no-op on an already-hydrated node).
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildMorphedContent(current: Element, incoming: Node[]): DocumentFragment;
|
|
30
|
+
//# sourceMappingURL=morph.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"morph.d.ts","sourceRoot":"","sources":["../src/morph.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAUH;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,gBAAgB,CA+BxF"}
|