bosia 0.2.2 → 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.
Files changed (87) hide show
  1. package/README.md +39 -39
  2. package/package.json +56 -53
  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 +8 -8
  8. package/src/cli/feat.ts +291 -132
  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 -153
  15. package/src/core/client/appState.svelte.ts +57 -0
  16. package/src/core/client/enhance.ts +112 -0
  17. package/src/core/client/hydrate.ts +97 -65
  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 +165 -168
  25. package/src/core/env.ts +155 -128
  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 +192 -139
  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 -118
  34. package/src/core/renderer.ts +359 -265
  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 +543 -370
  39. package/src/core/types.ts +25 -20
  40. package/src/lib/client.ts +12 -0
  41. package/src/lib/index.ts +8 -8
  42. package/src/lib/utils.ts +44 -30
  43. package/templates/default/.prettierignore +5 -0
  44. package/templates/default/.prettierrc.json +9 -0
  45. package/templates/default/README.md +5 -5
  46. package/templates/default/package.json +22 -18
  47. package/templates/default/src/app.css +80 -80
  48. package/templates/default/src/app.d.ts +3 -3
  49. package/templates/default/src/routes/+error.svelte +7 -10
  50. package/templates/default/src/routes/+layout.svelte +2 -2
  51. package/templates/default/src/routes/+page.svelte +31 -29
  52. package/templates/default/src/routes/about/+page.svelte +3 -3
  53. package/templates/default/tsconfig.json +20 -20
  54. package/templates/demo/.prettierignore +5 -0
  55. package/templates/demo/.prettierrc.json +9 -0
  56. package/templates/demo/README.md +9 -9
  57. package/templates/demo/package.json +22 -17
  58. package/templates/demo/src/app.css +80 -80
  59. package/templates/demo/src/app.d.ts +3 -3
  60. package/templates/demo/src/hooks.server.ts +9 -9
  61. package/templates/demo/src/routes/(public)/+layout.svelte +45 -23
  62. package/templates/demo/src/routes/(public)/+page.svelte +96 -67
  63. package/templates/demo/src/routes/(public)/about/+page.svelte +13 -25
  64. package/templates/demo/src/routes/(public)/all/[...catchall]/+page.svelte +24 -28
  65. package/templates/demo/src/routes/(public)/blog/+page.svelte +55 -46
  66. package/templates/demo/src/routes/(public)/blog/[slug]/+page.server.ts +36 -38
  67. package/templates/demo/src/routes/(public)/blog/[slug]/+page.svelte +60 -42
  68. package/templates/demo/src/routes/+error.svelte +10 -7
  69. package/templates/demo/src/routes/+layout.server.ts +4 -4
  70. package/templates/demo/src/routes/+layout.svelte +2 -2
  71. package/templates/demo/src/routes/actions-test/+page.server.ts +16 -16
  72. package/templates/demo/src/routes/actions-test/+page.svelte +49 -49
  73. package/templates/demo/src/routes/api/hello/+server.ts +25 -25
  74. package/templates/demo/tsconfig.json +20 -20
  75. package/templates/todo/.prettierignore +5 -0
  76. package/templates/todo/.prettierrc.json +9 -0
  77. package/templates/todo/README.md +9 -9
  78. package/templates/todo/package.json +22 -17
  79. package/templates/todo/src/app.css +80 -80
  80. package/templates/todo/src/app.d.ts +7 -7
  81. package/templates/todo/src/hooks.server.ts +9 -9
  82. package/templates/todo/src/routes/+error.svelte +10 -7
  83. package/templates/todo/src/routes/+layout.server.ts +4 -4
  84. package/templates/todo/src/routes/+layout.svelte +2 -2
  85. package/templates/todo/src/routes/+page.svelte +44 -44
  86. package/templates/todo/template.json +1 -1
  87. package/templates/todo/tsconfig.json +20 -20
@@ -1,9 +1,10 @@
1
- import { hydrate } from "svelte";
1
+ 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
+ import { appState } from "./appState.svelte.ts";
7
8
 
8
9
  // Pre-compile route patterns into RegExp at startup (shared by App.svelte and router via module reference)
9
10
  compileRoutes(clientRoutes);
@@ -11,40 +12,71 @@ compileRoutes(clientRoutes);
11
12
  // ─── Hydration ────────────────────────────────────────────
12
13
 
13
14
  async function main() {
14
- const path = window.location.pathname;
15
-
16
- router.init();
17
- router.currentRoute = path;
18
- initPrefetch();
19
-
20
- // Resolve the current route so we can pre-load the components
21
- // before handing off to App.svelte (avoids a flash of "Loading...")
22
- const match = findMatch(clientRoutes, path);
23
-
24
- let ssrPageComponent = null;
25
- let ssrLayoutComponents: any[] = [];
26
-
27
- if (match) {
28
- const [pageMod, ...layoutMods] = await Promise.all([
29
- match.route.page(),
30
- ...match.route.layouts.map(l => l()),
31
- ]);
32
- ssrPageComponent = pageMod.default;
33
- ssrLayoutComponents = layoutMods.map(m => m.default);
34
- router.params = match.params;
35
- }
36
-
37
- hydrate(App, {
38
- target: document.getElementById("app")!,
39
- props: {
40
- ssrMode: false,
41
- ssrPageComponent,
42
- ssrLayoutComponents,
43
- ssrPageData: (window as any).__BOSIA_PAGE_DATA__ ?? {},
44
- ssrLayoutData: (window as any).__BOSIA_LAYOUT_DATA__ ?? [],
45
- ssrFormData: (window as any).__BOSIA_FORM_DATA__ ?? null,
46
- },
47
- });
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
+ }
48
80
  }
49
81
 
50
82
  main();
@@ -52,33 +84,33 @@ main();
52
84
  // ─── Hot Reload (dev only) ────────────────────────────────
53
85
 
54
86
  if (process.env.NODE_ENV !== "production") {
55
- let connectedOnce = false;
56
- let retryDelay = 1000;
57
-
58
- function connectSSE() {
59
- const es = new EventSource("/__bosia/sse");
60
-
61
- es.addEventListener("reload", () => {
62
- console.log("[Bosia] Reloading...");
63
- window.location.reload();
64
- });
65
-
66
- es.onopen = () => {
67
- retryDelay = 1000;
68
- if (connectedOnce) {
69
- // Server came back up after a restart — reload immediately
70
- window.location.reload();
71
- }
72
- connectedOnce = true;
73
- };
74
-
75
- es.onerror = () => {
76
- es.close();
77
- console.log(`[Bosia] SSE disconnected. Retrying in ${retryDelay / 1000}s...`);
78
- setTimeout(connectSSE, retryDelay);
79
- retryDelay = Math.min(retryDelay + 1000, 5000);
80
- };
81
- }
82
-
83
- 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();
84
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
  }
@@ -2,56 +2,69 @@
2
2
  // Svelte 5 rune-based reactive router.
3
3
  // Singleton used by App.svelte and hydrate.ts.
4
4
 
5
- import { findMatch } from "../matcher.ts";
5
+ import { findMatch, canonicalPathname } from "../matcher.ts";
6
6
  import { clientRoutes } from "bosia:routes";
7
7
 
8
- export const router = new class Router {
9
- currentRoute = $state(typeof window !== "undefined" ? window.location.pathname + window.location.search + window.location.hash : "/");
10
- params = $state<Record<string, string>>({});
11
- /** True when navigation was triggered by a link click / navigate() call, false on popstate (back/forward). */
12
- isPush = $state(true);
13
-
14
- navigate(path: string) {
15
- if (this.currentRoute === path) return;
16
- // Unknown route — let the server handle it (renders +error.svelte with 404)
17
- const pathname = path.split("?")[0].split("#")[0];
18
- if (!findMatch(clientRoutes, pathname)) {
19
- window.location.href = path;
20
- return;
21
- }
22
- this.isPush = true;
23
- this.currentRoute = path;
24
- if (typeof history !== "undefined") {
25
- history.pushState({}, "", path);
26
- }
27
- }
28
-
29
- init() {
30
- if (typeof window === "undefined") return;
31
-
32
- // Intercept <a> clicks for client-side navigation
33
- window.addEventListener("click", (e) => {
34
- // Let browser handle non-primary buttons, modifier-clicks, already-handled events
35
- if (e.button !== 0) return;
36
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
37
- if (e.defaultPrevented) return;
38
-
39
- const anchor = (e.target as HTMLElement).closest("a");
40
- if (!anchor) return;
41
- if (anchor.origin !== window.location.origin) return;
42
- if (anchor.target) return;
43
- if (anchor.hasAttribute("download")) return;
44
- if (anchor.rel.split(/\s+/).includes("external")) return;
45
- if (anchor.protocol !== "https:" && anchor.protocol !== "http:") return;
46
-
47
- e.preventDefault();
48
- this.navigate(anchor.pathname + anchor.search + anchor.hash);
49
- });
50
-
51
- // Browser back/forward
52
- window.addEventListener("popstate", () => {
53
- this.isPush = false;
54
- this.currentRoute = window.location.pathname + window.location.search + window.location.hash;
55
- });
56
- }
57
- }();
8
+ export const router = new (class Router {
9
+ currentRoute = $state(
10
+ typeof window !== "undefined"
11
+ ? window.location.pathname + window.location.search + window.location.hash
12
+ : "/",
13
+ );
14
+ params = $state<Record<string, string>>({});
15
+ /** True when navigation was triggered by a link click / navigate() call, false on popstate (back/forward). */
16
+ isPush = $state(true);
17
+
18
+ navigate(path: string) {
19
+ if (this.currentRoute === path) return;
20
+ // Unknown route — let the server handle it (renders +error.svelte with 404)
21
+ const queryHash = path.slice(path.split("?")[0].split("#")[0].length);
22
+ const pathname = path.split("?")[0].split("#")[0];
23
+ const match = findMatch(clientRoutes, pathname);
24
+ if (!match) {
25
+ window.location.href = path;
26
+ return;
27
+ }
28
+ // Canonicalize trailing slash before navigating (matches server 308 behavior)
29
+ const canonical = canonicalPathname(
30
+ pathname,
31
+ (match.route as any).trailingSlash ?? "never",
32
+ );
33
+ const finalPath = canonical !== null ? canonical + queryHash : path;
34
+ this.isPush = true;
35
+ this.currentRoute = finalPath;
36
+ if (typeof history !== "undefined") {
37
+ history.pushState({}, "", finalPath);
38
+ }
39
+ }
40
+
41
+ init() {
42
+ if (typeof window === "undefined") return;
43
+
44
+ // Intercept <a> clicks for client-side navigation
45
+ window.addEventListener("click", (e) => {
46
+ // Let browser handle non-primary buttons, modifier-clicks, already-handled events
47
+ if (e.button !== 0) return;
48
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
49
+ if (e.defaultPrevented) return;
50
+
51
+ const anchor = (e.target as HTMLElement).closest("a");
52
+ if (!anchor) return;
53
+ if (anchor.origin !== window.location.origin) return;
54
+ if (anchor.target) return;
55
+ if (anchor.hasAttribute("download")) return;
56
+ if (anchor.rel.split(/\s+/).includes("external")) return;
57
+ if (anchor.protocol !== "https:" && anchor.protocol !== "http:") return;
58
+
59
+ e.preventDefault();
60
+ this.navigate(anchor.pathname + anchor.search + anchor.hash);
61
+ });
62
+
63
+ // Browser back/forward
64
+ window.addEventListener("popstate", () => {
65
+ this.isPush = false;
66
+ this.currentRoute =
67
+ window.location.pathname + window.location.search + window.location.hash;
68
+ });
69
+ }
70
+ })();