bosia 0.2.3 → 0.3.1

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