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.
- package/README.md +39 -39
- package/package.json +56 -53
- 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 +291 -132
- 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 -153
- package/src/core/client/appState.svelte.ts +57 -0
- package/src/core/client/enhance.ts +112 -0
- package/src/core/client/hydrate.ts +97 -65
- 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 -128
- 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 +192 -139
- 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 -118
- package/src/core/renderer.ts +359 -265
- 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 +543 -370
- package/src/core/types.ts +25 -20
- package/src/lib/client.ts +12 -0
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
+
})();
|