bosia 0.2.3 → 0.3.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 +39 -39
- package/package.json +56 -54
- package/src/ambient.d.ts +31 -0
- package/src/cli/add.ts +120 -114
- package/src/cli/build.ts +10 -10
- package/src/cli/create.ts +142 -137
- package/src/cli/dev.ts +8 -8
- package/src/cli/feat.ts +266 -258
- package/src/cli/index.ts +51 -42
- package/src/cli/registry.ts +136 -115
- package/src/cli/start.ts +17 -17
- package/src/cli/test.ts +25 -0
- package/src/core/build.ts +72 -56
- package/src/core/client/App.svelte +177 -156
- package/src/core/client/appState.svelte.ts +33 -31
- package/src/core/client/enhance.ts +83 -78
- package/src/core/client/hydrate.ts +95 -81
- package/src/core/client/prefetch.ts +101 -94
- package/src/core/client/router.svelte.ts +64 -51
- package/src/core/cookies.ts +70 -66
- package/src/core/cors.ts +44 -35
- package/src/core/csrf.ts +38 -38
- package/src/core/dedup.ts +17 -17
- package/src/core/dev.ts +165 -168
- package/src/core/env.ts +155 -148
- package/src/core/envCodegen.ts +73 -73
- package/src/core/errors.ts +48 -49
- package/src/core/hooks.ts +50 -50
- package/src/core/html.ts +184 -145
- package/src/core/matcher.ts +130 -121
- package/src/core/paths.ts +8 -10
- package/src/core/plugin.ts +113 -107
- package/src/core/prerender.ts +191 -122
- package/src/core/renderer.ts +359 -286
- package/src/core/routeFile.ts +140 -127
- package/src/core/routeTypes.ts +144 -83
- package/src/core/scanner.ts +125 -95
- package/src/core/server.ts +538 -424
- package/src/core/types.ts +25 -20
- package/src/lib/index.ts +8 -8
- package/src/lib/utils.ts +44 -30
- package/templates/default/.prettierignore +5 -0
- package/templates/default/.prettierrc.json +9 -0
- package/templates/default/README.md +5 -5
- package/templates/default/package.json +22 -18
- package/templates/default/src/app.css +80 -80
- package/templates/default/src/app.d.ts +3 -3
- package/templates/default/src/routes/+error.svelte +7 -10
- package/templates/default/src/routes/+layout.svelte +2 -2
- package/templates/default/src/routes/+page.svelte +31 -29
- package/templates/default/src/routes/about/+page.svelte +3 -3
- package/templates/default/tsconfig.json +20 -20
- package/templates/demo/.prettierignore +5 -0
- package/templates/demo/.prettierrc.json +9 -0
- package/templates/demo/README.md +9 -9
- package/templates/demo/package.json +22 -17
- package/templates/demo/src/app.css +80 -80
- package/templates/demo/src/app.d.ts +3 -3
- package/templates/demo/src/hooks.server.ts +9 -9
- package/templates/demo/src/routes/(public)/+layout.svelte +45 -23
- package/templates/demo/src/routes/(public)/+page.svelte +96 -67
- package/templates/demo/src/routes/(public)/about/+page.svelte +13 -25
- package/templates/demo/src/routes/(public)/all/[...catchall]/+page.svelte +24 -28
- package/templates/demo/src/routes/(public)/blog/+page.svelte +55 -46
- package/templates/demo/src/routes/(public)/blog/[slug]/+page.server.ts +36 -38
- package/templates/demo/src/routes/(public)/blog/[slug]/+page.svelte +60 -42
- package/templates/demo/src/routes/+error.svelte +10 -7
- package/templates/demo/src/routes/+layout.server.ts +4 -4
- package/templates/demo/src/routes/+layout.svelte +2 -2
- package/templates/demo/src/routes/actions-test/+page.server.ts +16 -16
- package/templates/demo/src/routes/actions-test/+page.svelte +49 -49
- package/templates/demo/src/routes/api/hello/+server.ts +25 -25
- package/templates/demo/tsconfig.json +20 -20
- package/templates/todo/.prettierignore +5 -0
- package/templates/todo/.prettierrc.json +9 -0
- package/templates/todo/README.md +9 -9
- package/templates/todo/package.json +22 -17
- package/templates/todo/src/app.css +80 -80
- package/templates/todo/src/app.d.ts +7 -7
- package/templates/todo/src/hooks.server.ts +9 -9
- package/templates/todo/src/routes/+error.svelte +10 -7
- package/templates/todo/src/routes/+layout.server.ts +4 -4
- package/templates/todo/src/routes/+layout.svelte +2 -2
- package/templates/todo/src/routes/+page.svelte +44 -44
- package/templates/todo/template.json +1 -1
- package/templates/todo/tsconfig.json +20 -20
|
@@ -9,99 +9,104 @@ import { appState, refreshData } from "./appState.svelte.ts";
|
|
|
9
9
|
import { router } from "./router.svelte.ts";
|
|
10
10
|
|
|
11
11
|
export type ActionResult =
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
| { type: "success"; status: number; data: any }
|
|
13
|
+
| { type: "failure"; status: number; data: any }
|
|
14
|
+
| { type: "redirect"; status: number; location: string }
|
|
15
|
+
| { type: "error"; status: number; message: string };
|
|
16
16
|
|
|
17
17
|
export type SubmitFunction = (input: {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}) =>
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
18
|
+
formData: FormData;
|
|
19
|
+
formElement: HTMLFormElement;
|
|
20
|
+
action: URL;
|
|
21
|
+
cancel: () => void;
|
|
22
|
+
submitter: HTMLElement | null;
|
|
23
|
+
}) =>
|
|
24
|
+
| void
|
|
25
|
+
| ((opts: {
|
|
26
|
+
result: ActionResult;
|
|
27
|
+
formElement: HTMLFormElement;
|
|
28
|
+
update: (opts?: { reset?: boolean; invalidateAll?: boolean }) => Promise<void>;
|
|
29
|
+
}) => void | Promise<void>);
|
|
28
30
|
|
|
29
31
|
async function applyResult(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
result: ActionResult,
|
|
33
|
+
form: HTMLFormElement,
|
|
34
|
+
opts: { reset?: boolean; invalidateAll?: boolean } = {},
|
|
33
35
|
): Promise<void> {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
const reset = opts.reset !== false;
|
|
37
|
+
const invalidateAll = opts.invalidateAll !== false;
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
39
|
+
if (result.type === "redirect") {
|
|
40
|
+
router.navigate(result.location);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (result.type === "error") {
|
|
44
|
+
appState.form = { error: { message: result.message, status: result.status } };
|
|
45
|
+
console.warn(`[bosia] action error ${result.status}: ${result.message}`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (result.type === "failure") {
|
|
49
|
+
appState.form = result.data;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// success
|
|
53
|
+
appState.form = result.data;
|
|
54
|
+
if (reset) form.reset();
|
|
55
|
+
if (invalidateAll) {
|
|
56
|
+
await refreshData(window.location.pathname + window.location.search);
|
|
57
|
+
}
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
export function enhance(form: HTMLFormElement, submit?: SubmitFunction) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
async function handleSubmit(event: SubmitEvent) {
|
|
62
|
+
event.preventDefault();
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
const submitter = (event.submitter as HTMLElement | null) ?? null;
|
|
65
|
+
const formData = new FormData(form, submitter as HTMLElement | undefined);
|
|
64
66
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
// Resolve action URL — preserve `?/actionName` if the submitter or form sets it
|
|
68
|
+
const actionAttr =
|
|
69
|
+
(submitter as HTMLButtonElement | HTMLInputElement | null)?.formAction ||
|
|
70
|
+
form.action ||
|
|
71
|
+
window.location.href;
|
|
72
|
+
const action = new URL(actionAttr, window.location.href);
|
|
70
73
|
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
let cancelled = false;
|
|
75
|
+
const cancel = () => {
|
|
76
|
+
cancelled = true;
|
|
77
|
+
};
|
|
73
78
|
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
const callback = submit?.({ formData, formElement: form, action, cancel, submitter });
|
|
80
|
+
if (cancelled) return;
|
|
76
81
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
82
|
+
let result: ActionResult;
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(action, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
body: formData,
|
|
87
|
+
headers: { "x-bosia-action": "1", accept: "application/json" },
|
|
88
|
+
});
|
|
89
|
+
result = (await res.json()) as ActionResult;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const message = (err as Error)?.message ?? "Network error";
|
|
92
|
+
result = { type: "error", status: 0, message };
|
|
93
|
+
console.warn("[bosia] enhance: submission failed", err);
|
|
94
|
+
}
|
|
90
95
|
|
|
91
|
-
|
|
92
|
-
|
|
96
|
+
const update = (opts?: { reset?: boolean; invalidateAll?: boolean }) =>
|
|
97
|
+
applyResult(result, form, opts ?? {});
|
|
93
98
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
if (typeof callback === "function") {
|
|
100
|
+
await callback({ result, formElement: form, update });
|
|
101
|
+
} else {
|
|
102
|
+
await update();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
100
105
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
form.addEventListener("submit", handleSubmit);
|
|
107
|
+
return {
|
|
108
|
+
destroy() {
|
|
109
|
+
form.removeEventListener("submit", handleSubmit);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
107
112
|
}
|
|
@@ -2,7 +2,7 @@ import { hydrate, mount } from "svelte";
|
|
|
2
2
|
import App from "./App.svelte";
|
|
3
3
|
import { router } from "./router.svelte.ts";
|
|
4
4
|
import { initPrefetch } from "./prefetch.ts";
|
|
5
|
-
import { findMatch, compileRoutes } from "../matcher.ts";
|
|
5
|
+
import { findMatch, compileRoutes, canonicalPathname } from "../matcher.ts";
|
|
6
6
|
import { clientRoutes } from "bosia:routes";
|
|
7
7
|
import { appState } from "./appState.svelte.ts";
|
|
8
8
|
|
|
@@ -12,57 +12,71 @@ compileRoutes(clientRoutes);
|
|
|
12
12
|
// ─── Hydration ────────────────────────────────────────────
|
|
13
13
|
|
|
14
14
|
async function main() {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
15
|
+
const path = window.location.pathname;
|
|
16
|
+
|
|
17
|
+
router.init();
|
|
18
|
+
|
|
19
|
+
// Resolve the current route so we can pre-load the components
|
|
20
|
+
// before handing off to App.svelte (avoids a flash of "Loading...")
|
|
21
|
+
const match = findMatch(clientRoutes, path);
|
|
22
|
+
|
|
23
|
+
// Canonicalize trailing slash on initial mount — server already 308'd if
|
|
24
|
+
// SSR'd, but `ssr=false` shells and prerendered pages can land on the
|
|
25
|
+
// non-canonical URL. replaceState (no extra history entry).
|
|
26
|
+
if (match) {
|
|
27
|
+
const canonical = canonicalPathname(path, (match.route as any).trailingSlash ?? "never");
|
|
28
|
+
if (canonical !== null && typeof history !== "undefined") {
|
|
29
|
+
history.replaceState(
|
|
30
|
+
history.state,
|
|
31
|
+
"",
|
|
32
|
+
canonical + window.location.search + window.location.hash,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
router.currentRoute = window.location.pathname + window.location.search + window.location.hash;
|
|
37
|
+
initPrefetch();
|
|
38
|
+
|
|
39
|
+
let ssrPageComponent = null;
|
|
40
|
+
let ssrLayoutComponents: any[] = [];
|
|
41
|
+
|
|
42
|
+
if (match) {
|
|
43
|
+
const [pageMod, ...layoutMods] = await Promise.all([
|
|
44
|
+
match.route.page(),
|
|
45
|
+
...match.route.layouts.map((l) => l()),
|
|
46
|
+
]);
|
|
47
|
+
ssrPageComponent = pageMod.default;
|
|
48
|
+
ssrLayoutComponents = layoutMods.map((m) => m.default);
|
|
49
|
+
router.params = match.params;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ssrPageData = (window as any).__BOSIA_PAGE_DATA__ ?? {};
|
|
53
|
+
const ssrLayoutData = (window as any).__BOSIA_LAYOUT_DATA__ ?? [];
|
|
54
|
+
const ssrFormData = (window as any).__BOSIA_FORM_DATA__ ?? null;
|
|
55
|
+
|
|
56
|
+
// Seed shared client state so `use:enhance` and other helpers
|
|
57
|
+
// start from the same values App.svelte renders during hydration.
|
|
58
|
+
appState.pageData = ssrPageData;
|
|
59
|
+
appState.layoutData = ssrLayoutData;
|
|
60
|
+
appState.routeParams = ssrPageData?.params ?? match?.params ?? {};
|
|
61
|
+
appState.form = ssrFormData;
|
|
62
|
+
|
|
63
|
+
const target = document.getElementById("app")!;
|
|
64
|
+
const props = {
|
|
65
|
+
ssrMode: false,
|
|
66
|
+
ssrPageComponent,
|
|
67
|
+
ssrLayoutComponents,
|
|
68
|
+
ssrPageData,
|
|
69
|
+
ssrLayoutData,
|
|
70
|
+
ssrFormData,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// ssr=false → server shipped empty shell, no hydration markers exist.
|
|
74
|
+
// Use mount() instead of hydrate() to render fresh on the client.
|
|
75
|
+
if ((window as any).__BOSIA_SSR__ === false) {
|
|
76
|
+
mount(App, { target, props });
|
|
77
|
+
} else {
|
|
78
|
+
hydrate(App, { target, props });
|
|
79
|
+
}
|
|
66
80
|
}
|
|
67
81
|
|
|
68
82
|
main();
|
|
@@ -70,33 +84,33 @@ main();
|
|
|
70
84
|
// ─── Hot Reload (dev only) ────────────────────────────────
|
|
71
85
|
|
|
72
86
|
if (process.env.NODE_ENV !== "production") {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
87
|
+
let connectedOnce = false;
|
|
88
|
+
let retryDelay = 1000;
|
|
89
|
+
|
|
90
|
+
function connectSSE() {
|
|
91
|
+
const es = new EventSource("/__bosia/sse");
|
|
92
|
+
|
|
93
|
+
es.addEventListener("reload", () => {
|
|
94
|
+
console.log("[Bosia] Reloading...");
|
|
95
|
+
window.location.reload();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
es.onopen = () => {
|
|
99
|
+
retryDelay = 1000;
|
|
100
|
+
if (connectedOnce) {
|
|
101
|
+
// Server came back up after a restart — reload immediately
|
|
102
|
+
window.location.reload();
|
|
103
|
+
}
|
|
104
|
+
connectedOnce = true;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
es.onerror = () => {
|
|
108
|
+
es.close();
|
|
109
|
+
console.log(`[Bosia] SSE disconnected. Retrying in ${retryDelay / 1000}s...`);
|
|
110
|
+
setTimeout(connectSSE, retryDelay);
|
|
111
|
+
retryDelay = Math.min(retryDelay + 1000, 5000);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
connectSSE();
|
|
102
116
|
}
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
/** Builds the `/__bosia/data/…` URL for a given client path. */
|
|
6
6
|
export function dataUrl(path: string): string {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
const url = new URL(path, window.location.origin);
|
|
8
|
+
let p = url.pathname.replace(/\/$/, "");
|
|
9
|
+
return `/__bosia/data${p || "/index"}.json${url.search}`;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export const prefetchCache = new Map<string, { data: any; ts: number }>();
|
|
@@ -17,108 +17,115 @@ const pending = new Set<string>();
|
|
|
17
17
|
|
|
18
18
|
/** Returns cached prefetch data for a path and removes it from cache. */
|
|
19
19
|
export function consumePrefetch(path: string): any | null {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
const entry = prefetchCache.get(path);
|
|
21
|
+
if (entry === undefined) return null;
|
|
22
|
+
prefetchCache.delete(path);
|
|
23
|
+
if (Date.now() - entry.ts > 30_000) return null;
|
|
24
|
+
return entry.data;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/** Prefetches data for a path and stores in cache. No-op if already cached/in-flight. */
|
|
28
28
|
export async function prefetchPath(path: string): Promise<void> {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
29
|
+
const existing = prefetchCache.get(path);
|
|
30
|
+
if (existing && Date.now() - existing.ts <= 30_000) return;
|
|
31
|
+
if (existing) prefetchCache.delete(path);
|
|
32
|
+
if (pending.has(path)) return;
|
|
33
|
+
|
|
34
|
+
pending.add(path);
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(dataUrl(path));
|
|
37
|
+
if (res.ok) {
|
|
38
|
+
if (prefetchCache.size >= MAX_PREFETCH_ENTRIES) {
|
|
39
|
+
const oldest = prefetchCache.keys().next().value;
|
|
40
|
+
if (oldest !== undefined) prefetchCache.delete(oldest);
|
|
41
|
+
}
|
|
42
|
+
prefetchCache.set(path, { data: await res.json(), ts: Date.now() });
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Silently ignore — prefetch is best-effort
|
|
46
|
+
} finally {
|
|
47
|
+
pending.delete(path);
|
|
48
|
+
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function getLinkHref(anchor: HTMLAnchorElement): string | null {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
if (anchor.origin !== window.location.origin) return null;
|
|
53
|
+
if (anchor.target) return null;
|
|
54
|
+
if (anchor.hasAttribute("download")) return null;
|
|
55
|
+
return anchor.pathname + anchor.search;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function observeViewportLinks(container: Element | Document = document) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
59
|
+
const observer = new IntersectionObserver(
|
|
60
|
+
(entries) => {
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (!entry.isIntersecting) continue;
|
|
63
|
+
const anchor = entry.target as HTMLAnchorElement;
|
|
64
|
+
const href = getLinkHref(anchor);
|
|
65
|
+
if (href) prefetchPath(href);
|
|
66
|
+
observer.unobserve(anchor);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
{ rootMargin: "0px" },
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const links = (
|
|
73
|
+
container === document ? document : (container as Element)
|
|
74
|
+
).querySelectorAll<HTMLAnchorElement>("a[data-bosia-preload='viewport']");
|
|
75
|
+
|
|
76
|
+
for (const link of links) {
|
|
77
|
+
observer.observe(link);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return observer;
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
export function initPrefetch(): void {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
84
|
+
// ── Hover strategy (event delegation, 20ms debounce) ─────
|
|
85
|
+
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
|
86
|
+
|
|
87
|
+
document.addEventListener("mouseover", (e) => {
|
|
88
|
+
if (!(e.target instanceof Element)) return;
|
|
89
|
+
// Early exit: skip if no [data-bosia-preload="hover"] ancestor exists
|
|
90
|
+
const preloadEl = e.target.closest("[data-bosia-preload]");
|
|
91
|
+
if (!preloadEl || preloadEl.getAttribute("data-bosia-preload") !== "hover") return;
|
|
92
|
+
const anchor = e.target.closest("a") as HTMLAnchorElement | null;
|
|
93
|
+
if (!anchor) return;
|
|
94
|
+
const href = getLinkHref(anchor);
|
|
95
|
+
if (!href) return;
|
|
96
|
+
|
|
97
|
+
if (hoverTimer) clearTimeout(hoverTimer);
|
|
98
|
+
hoverTimer = setTimeout(() => prefetchPath(href), 100);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
document.addEventListener("mouseout", () => {
|
|
102
|
+
if (hoverTimer) {
|
|
103
|
+
clearTimeout(hoverTimer);
|
|
104
|
+
hoverTimer = null;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── Viewport strategy ─────────────────────────────────────
|
|
109
|
+
const observer = observeViewportLinks();
|
|
110
|
+
|
|
111
|
+
// Pick up links added after initial render (e.g., after client navigation)
|
|
112
|
+
const mutation = new MutationObserver((records) => {
|
|
113
|
+
for (const record of records) {
|
|
114
|
+
for (const node of record.addedNodes) {
|
|
115
|
+
if (!(node instanceof Element)) continue;
|
|
116
|
+
// The node itself might be a viewport link
|
|
117
|
+
if (node.matches("a[data-bosia-preload='viewport']")) {
|
|
118
|
+
observer.observe(node as HTMLAnchorElement);
|
|
119
|
+
}
|
|
120
|
+
// Or it might contain viewport links
|
|
121
|
+
for (const link of node.querySelectorAll<HTMLAnchorElement>(
|
|
122
|
+
"a[data-bosia-preload='viewport']",
|
|
123
|
+
)) {
|
|
124
|
+
observer.observe(link);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
mutation.observe(document.body, { childList: true, subtree: true });
|
|
124
131
|
}
|