bosia 1.2.1 → 1.2.3
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/package.json +1 -1
- package/src/core/build.ts +10 -2
- package/src/core/client/App.svelte +365 -250
- package/src/core/client/enhance.ts +6 -2
- package/src/core/client/hydrate.ts +7 -2
- package/src/core/client/navigation.ts +46 -27
- package/src/core/client/router.svelte.ts +24 -5
- package/src/core/headScan.ts +6 -0
- package/src/core/matcher.ts +52 -85
- package/src/core/prerender.ts +134 -35
- package/src/core/renderer.ts +2 -2
- package/src/core/routeFile.ts +21 -17
- package/src/core/routeTypes.ts +14 -11
- package/src/core/scanner.ts +76 -13
- package/templates/demo/src/routes/(public)/blog/[slug]/+page.server.ts +1 -1
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// disabled because nothing is wired up until this action runs.
|
|
7
7
|
|
|
8
8
|
import { appState } from "./appState.svelte.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { go } from "./navigation.ts";
|
|
10
10
|
|
|
11
11
|
export type ActionResult =
|
|
12
12
|
| { type: "success"; status: number; data: any }
|
|
@@ -37,7 +37,11 @@ async function applyResult(
|
|
|
37
37
|
const invalidateAll = opts.invalidateAll !== false;
|
|
38
38
|
|
|
39
39
|
if (result.type === "redirect") {
|
|
40
|
-
|
|
40
|
+
// The action changed data — maybe the session (login/logout) — so reload every
|
|
41
|
+
// loader, also on a redirect to this same page (post/redirect/get). Its result
|
|
42
|
+
// is spent: an earlier failure's message must not outlive it.
|
|
43
|
+
appState.form = null;
|
|
44
|
+
await go(result.location, { source: "form" }, invalidateAll);
|
|
41
45
|
return;
|
|
42
46
|
}
|
|
43
47
|
if (result.type === "error") {
|
|
@@ -47,6 +47,9 @@ async function main() {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
router.currentRoute = window.location.pathname + window.location.search + window.location.hash;
|
|
50
|
+
// After canonicalizing, before any await: a click during the chunk loads below
|
|
51
|
+
// moves currentRoute, and App must still know which route the server rendered.
|
|
52
|
+
router.hydratedRoute = router.currentRoute;
|
|
50
53
|
initPrefetch();
|
|
51
54
|
|
|
52
55
|
let ssrPageComponent = null;
|
|
@@ -79,8 +82,10 @@ async function main() {
|
|
|
79
82
|
// next client navigation can decide which loaders to skip without an
|
|
80
83
|
// extra fetch round-trip.
|
|
81
84
|
if (match) {
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
// The server's route, not window.location: a click during the chunk loads
|
|
86
|
+
// above already moved the address bar on.
|
|
87
|
+
const url = new URL(router.hydratedRoute, window.location.origin);
|
|
88
|
+
const ctx = liveContext(url.pathname, match.params, url);
|
|
84
89
|
const ssrPageDeps: LoaderDeps | null = (window as any).__BOSIA_PAGE_DEPS__ ?? null;
|
|
85
90
|
const ssrLayoutDeps: (LoaderDeps | null)[] = (window as any).__BOSIA_LAYOUT_DEPS__ ?? [];
|
|
86
91
|
const pageId = (match.route as any).pageId as string | null;
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { onDestroy } from "svelte";
|
|
14
14
|
import { appState } from "./appState.svelte.ts";
|
|
15
|
-
import { router } from "./router.svelte.ts";
|
|
15
|
+
import { router, type NavType } from "./router.svelte.ts";
|
|
16
16
|
import {
|
|
17
17
|
afterListeners,
|
|
18
18
|
beforeListeners,
|
|
@@ -106,7 +106,7 @@ export function invalidateAll(): Promise<void> {
|
|
|
106
106
|
export interface GotoOptions {
|
|
107
107
|
/** Use `history.replaceState` instead of `history.pushState`. */
|
|
108
108
|
replaceState?: boolean;
|
|
109
|
-
/**
|
|
109
|
+
/** Re-run every loader for the destination — also when it is the current URL. */
|
|
110
110
|
invalidateAll?: boolean;
|
|
111
111
|
/** Skip the default scroll-to-top after navigation. */
|
|
112
112
|
noScroll?: boolean;
|
|
@@ -118,41 +118,60 @@ export interface GotoOptions {
|
|
|
118
118
|
|
|
119
119
|
/**
|
|
120
120
|
* Navigate to `url` via the client router. Returns a Promise that resolves
|
|
121
|
-
* after the navigation has settled (loaders ran, components mounted)
|
|
122
|
-
* immediately
|
|
121
|
+
* after the navigation has settled (loaders ran, components mounted). On the
|
|
122
|
+
* current URL it resolves immediately — unless `invalidateAll` is set, then
|
|
123
|
+
* after the loaders re-ran in place.
|
|
123
124
|
*/
|
|
124
125
|
export function goto(url: string, opts: GotoOptions = {}): Promise<void> {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
router.suppressScroll = true;
|
|
132
|
-
}
|
|
126
|
+
return go(
|
|
127
|
+
url,
|
|
128
|
+
{ replace: opts.replaceState, noScroll: opts.noScroll, source: "goto" },
|
|
129
|
+
!!opts.invalidateAll,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
133
132
|
|
|
134
|
-
|
|
133
|
+
/** Internal — `goto()` and `use:enhance` redirects. `reload` re-runs every loader (enhance: unless `update({ invalidateAll: false })`). */
|
|
134
|
+
export function go(
|
|
135
|
+
url: string,
|
|
136
|
+
opts: { replace?: boolean; noScroll?: boolean; source?: NavType },
|
|
137
|
+
reload: boolean,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
if (typeof window === "undefined") return Promise.resolve();
|
|
135
140
|
|
|
136
141
|
return new Promise<void>((resolve) => {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
const result = router.navigate(url, { replace: opts.replace, source: opts.source });
|
|
143
|
+
// The nav effect runs after this, so marks set now still reach it — and a nav
|
|
144
|
+
// that never starts (cancelled, left the app) leaves none behind for the next one.
|
|
145
|
+
if (result === "started" && opts.noScroll) router.suppressScroll = true;
|
|
146
|
+
const refetch = reload && (result === "started" || result === "same");
|
|
147
|
+
// The tick wakes the effect on the same URL, and on a new one it keeps a
|
|
148
|
+
// prefetch holding pre-mutation data from standing in for the refetch.
|
|
149
|
+
if (refetch) invalidateAll();
|
|
150
|
+
// "same" while a nav to it is still loading waits for it too.
|
|
151
|
+
const wait = result === "started" || (result === "same" && (reload || router.loading));
|
|
152
|
+
// Otherwise nothing will settle: resolve just this call — draining the queue
|
|
153
|
+
// would also release a nav still loading.
|
|
154
|
+
if (wait) appState.navResolvers.push(resolve);
|
|
155
|
+
else resolve();
|
|
144
156
|
});
|
|
145
157
|
}
|
|
146
158
|
|
|
147
159
|
/** Internal — App.svelte calls this after each nav effect settles. */
|
|
148
|
-
|
|
160
|
+
// Takes the waiters now (a nav started meanwhile queues its own), releases them
|
|
161
|
+
// once `after` (the settling nav's scroll) is done.
|
|
162
|
+
export function drainNavResolvers(after?: Promise<unknown>): void {
|
|
149
163
|
const queue = appState.navResolvers;
|
|
150
164
|
appState.navResolvers = [];
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
165
|
+
const run = () => {
|
|
166
|
+
for (const fn of queue) {
|
|
167
|
+
try {
|
|
168
|
+
fn();
|
|
169
|
+
} catch (err) {
|
|
170
|
+
console.warn("[bosia] navResolver threw", err);
|
|
171
|
+
}
|
|
156
172
|
}
|
|
157
|
-
}
|
|
173
|
+
};
|
|
174
|
+
// A throwing flush rejects the scroll; the waiters must not hang on it.
|
|
175
|
+
if (after) after.then(run, run);
|
|
176
|
+
else run();
|
|
158
177
|
}
|
|
@@ -14,6 +14,9 @@ import { withBase } from "../basePath.ts";
|
|
|
14
14
|
// pushes exactly the URL that was in the link, untouched.
|
|
15
15
|
|
|
16
16
|
export type NavType = "link" | "goto" | "popstate" | "form" | "enter";
|
|
17
|
+
/** What `router.navigate()` did: started a client nav, found the URL already current,
|
|
18
|
+
* was cancelled by `beforeNavigate`, or handed off to a full page load. */
|
|
19
|
+
export type NavResult = "started" | "same" | "cancelled" | "left";
|
|
17
20
|
|
|
18
21
|
function buildTarget(path: string): { url: URL; params: Record<string, string> } | null {
|
|
19
22
|
if (typeof window === "undefined") return null;
|
|
@@ -94,8 +97,13 @@ export const router = new (class Router {
|
|
|
94
97
|
/** Captured snapshot for the entry a popstate/reload landed on; consumed once by App.svelte. `undefined` = nothing to restore. */
|
|
95
98
|
pendingSnapshot: unknown = undefined;
|
|
96
99
|
|
|
97
|
-
|
|
98
|
-
|
|
100
|
+
/** The route the server rendered (canonical), recorded by hydrate.ts before App mounts. */
|
|
101
|
+
hydratedRoute = "";
|
|
102
|
+
/** True while App's nav effect is loading a run; `goto()` to the current URL waits on it. */
|
|
103
|
+
loading = false;
|
|
104
|
+
|
|
105
|
+
navigate(path: string, opts: { replace?: boolean; source?: NavType } = {}): NavResult {
|
|
106
|
+
if (this.currentRoute === path) return "same";
|
|
99
107
|
// Unknown route — let the server handle it (renders +error.svelte with 404)
|
|
100
108
|
const queryHash = path.slice(path.split("?")[0].split("#")[0].length);
|
|
101
109
|
const pathname = path.split("?")[0].split("#")[0];
|
|
@@ -114,12 +122,16 @@ export const router = new (class Router {
|
|
|
114
122
|
`[bosia] "${path}" matched no route and is leaving the app — it is mounted at "${base}". Use "${withBase(base, path)}".`,
|
|
115
123
|
);
|
|
116
124
|
}
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
// A replace (loader redirect) must not leave a Back-button trap behind.
|
|
126
|
+
if (opts.replace) window.location.replace(path);
|
|
127
|
+
else window.location.href = path;
|
|
128
|
+
return "left";
|
|
119
129
|
}
|
|
120
130
|
// Canonicalize trailing slash before navigating (matches server 308 behavior)
|
|
121
131
|
const canonical = canonicalPathname(pathname, (match.route as any).trailingSlash ?? "never");
|
|
122
132
|
const finalPath = canonical !== null ? canonical + queryHash : path;
|
|
133
|
+
// `/about/` on `/about`: nothing to do, and no duplicate history entry.
|
|
134
|
+
if (this.currentRoute === finalPath) return "same";
|
|
123
135
|
|
|
124
136
|
const navType: NavType = opts.source ?? "link";
|
|
125
137
|
const fromTarget = buildTarget(this.currentRoute);
|
|
@@ -131,11 +143,12 @@ export const router = new (class Router {
|
|
|
131
143
|
willUnload: false,
|
|
132
144
|
cancel: () => {},
|
|
133
145
|
};
|
|
134
|
-
if (!fireBeforeNavigate(nav)) return;
|
|
146
|
+
if (!fireBeforeNavigate(nav)) return "cancelled";
|
|
135
147
|
|
|
136
148
|
this.lastNavType = navType;
|
|
137
149
|
this.isPush = true;
|
|
138
150
|
this.pendingScroll = null;
|
|
151
|
+
this.suppressScroll = false; // a superseded noScroll goto's flag; go() re-sets its own after
|
|
139
152
|
this.pendingSnapshot = undefined;
|
|
140
153
|
this.currentRoute = finalPath;
|
|
141
154
|
if (typeof history !== "undefined") {
|
|
@@ -148,6 +161,10 @@ export const router = new (class Router {
|
|
|
148
161
|
history.pushState({ bosiaIndex: historyIndex }, "", finalPath);
|
|
149
162
|
}
|
|
150
163
|
}
|
|
164
|
+
// Set now, not when App's effect runs a microtask later: a same-tick goto() to
|
|
165
|
+
// this URL must already see it loading.
|
|
166
|
+
this.loading = true;
|
|
167
|
+
return "started";
|
|
151
168
|
}
|
|
152
169
|
|
|
153
170
|
init() {
|
|
@@ -202,6 +219,7 @@ export const router = new (class Router {
|
|
|
202
219
|
savePageSnapshot();
|
|
203
220
|
historyIndex++;
|
|
204
221
|
history.pushState({ bosiaIndex: historyIndex }, "", finalPath);
|
|
222
|
+
this.suppressScroll = false;
|
|
205
223
|
this.currentRoute = finalPath;
|
|
206
224
|
}
|
|
207
225
|
scrollToHash(anchor.hash);
|
|
@@ -238,6 +256,7 @@ export const router = new (class Router {
|
|
|
238
256
|
|
|
239
257
|
this.lastNavType = "popstate";
|
|
240
258
|
this.isPush = false;
|
|
259
|
+
this.suppressScroll = false;
|
|
241
260
|
this.currentRoute = finalPath;
|
|
242
261
|
});
|
|
243
262
|
|
package/src/core/headScan.ts
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
/** The title a page gets when nothing else sets one. */
|
|
6
6
|
export const DEFAULT_TITLE = "Bosia App";
|
|
7
7
|
|
|
8
|
+
/** The title an error page gets when its components set none: `404 — Not Found`.
|
|
9
|
+
* SSR and client navigation both use it, so the two never drift apart. */
|
|
10
|
+
export function errorTitle(status: number, message: string): string {
|
|
11
|
+
return `${status} — ${message}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
8
14
|
/** Whether a chunk of head markup already sets the document title. Matches a
|
|
9
15
|
* <title> with attributes too. Ignores one in a comment, an attribute value, a
|
|
10
16
|
* script or JSON-LD string, <style>, <noscript>, <template>, or an inline <svg>
|
package/src/core/matcher.ts
CHANGED
|
@@ -4,10 +4,8 @@ import type { RouteMatch, TrailingSlash } from "./types.ts";
|
|
|
4
4
|
// Single shared matcher used by both client and server at runtime.
|
|
5
5
|
// Replaces the old code-generated match functions.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// 2. Dynamic match — "/blog/[slug]" matches "/blog/hello"
|
|
10
|
-
// 3. Catch-all match — "/[...rest]" matches anything
|
|
7
|
+
// Routes arrive sorted by sortRoutes (routeFile.ts): segment by segment,
|
|
8
|
+
// static beats "[param]" beats "[...rest]" (same as SvelteKit). First match wins.
|
|
11
9
|
|
|
12
10
|
// ─── Compiled Route Types ────────────────────────────────
|
|
13
11
|
|
|
@@ -19,6 +17,28 @@ interface CompiledRoute {
|
|
|
19
17
|
|
|
20
18
|
// ─── Pattern Compiler ────────────────────────────────────
|
|
21
19
|
|
|
20
|
+
// Brackets wrap the whole segment; the name has no brackets or slash and doesn't
|
|
21
|
+
// start with a dot (so `[...]` isn't a param named "..."). Anything else —
|
|
22
|
+
// `[id].json`, `[[lang]]` — is static here, and the scanner fails the build on it.
|
|
23
|
+
const SEGMENT = /^\[(\.\.\.)?([^[\]/.][^[\]/]*)\]$/;
|
|
24
|
+
|
|
25
|
+
/** How every part of Bosia reads one route segment (matcher, sortRoutes, scanner):
|
|
26
|
+
* 0 static, 1 `[param]`, 2 `[...rest]`. Doubles as the sort rank. */
|
|
27
|
+
export function segmentRank(seg: string): 0 | 1 | 2 {
|
|
28
|
+
const m = SEGMENT.exec(seg);
|
|
29
|
+
return !m ? 0 : m[1] ? 2 : 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The param name a dynamic segment binds: `[slug]` → `slug`, `[...rest]` → `rest`. */
|
|
33
|
+
export function segmentName(seg: string): string {
|
|
34
|
+
return SEGMENT.exec(seg)?.[2] ?? "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when any segment of the pattern is a param or catch-all. */
|
|
38
|
+
export function isDynamic(pattern: string): boolean {
|
|
39
|
+
return pattern.split("/").some((seg) => segmentRank(seg) > 0);
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
/** Escape regex special chars in a literal string segment. */
|
|
23
43
|
function escapeRegex(s: string): string {
|
|
24
44
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -29,30 +49,25 @@ function escapeRegex(s: string): string {
|
|
|
29
49
|
*/
|
|
30
50
|
function compilePattern(pattern: string): CompiledRoute {
|
|
31
51
|
// No dynamic segments — exact match via ===
|
|
32
|
-
if (!pattern
|
|
52
|
+
if (!isDynamic(pattern)) {
|
|
33
53
|
return { regex: null!, paramNames: [], isExact: true };
|
|
34
54
|
}
|
|
35
55
|
|
|
36
56
|
const paramNames: string[] = [];
|
|
37
57
|
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
paramNames.push(catchallMatch[2]!);
|
|
43
|
-
const escaped = prefix ? escapeRegex(prefix) : "";
|
|
44
|
-
// Root catch-all /[...rest] must have at least one char after /
|
|
45
|
-
const regex = prefix ? new RegExp(`^${escaped}\\/(.+)$`) : new RegExp(`^\\/(.+)$`);
|
|
46
|
-
return { regex, paramNames, isExact: false };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Dynamic segments: /blog/[slug]/comments → ^\/blog\/([^/]+)\/comments$
|
|
58
|
+
// /blog/[slug]/comments → ^\/blog\/([^/]+)\/comments$
|
|
59
|
+
// A trailing [...rest] takes the rest of the path: /[lang]/[...rest]. Only the
|
|
60
|
+
// last segment gets (.+), so a hand-built table can't nest them (ReDoS); the
|
|
61
|
+
// scanner fails the build on a non-last catch-all anyway.
|
|
50
62
|
const segments = pattern.split("/").filter(Boolean);
|
|
51
63
|
let regexStr = "^";
|
|
52
|
-
for (const seg of segments) {
|
|
64
|
+
for (const [i, seg] of segments.entries()) {
|
|
53
65
|
regexStr += "\\/";
|
|
54
|
-
|
|
55
|
-
|
|
66
|
+
const rank = segmentRank(seg);
|
|
67
|
+
if (rank) paramNames.push(segmentName(seg));
|
|
68
|
+
if (rank === 2 && i === segments.length - 1) {
|
|
69
|
+
regexStr += "(.+)";
|
|
70
|
+
} else if (rank) {
|
|
56
71
|
regexStr += "([^/]+)";
|
|
57
72
|
} else {
|
|
58
73
|
regexStr += escapeRegex(seg);
|
|
@@ -66,7 +81,7 @@ function compilePattern(pattern: string): CompiledRoute {
|
|
|
66
81
|
// Exact routes bucketed by pathname, keyed by the routes-array identity so
|
|
67
82
|
// findMatch can O(1) Map.get before linearly scanning the dynamic/catch-all
|
|
68
83
|
// remainder. WeakMap keeps it tied to whichever array (server/api/client) was
|
|
69
|
-
// compiled;
|
|
84
|
+
// compiled; findMatch compiles an array it hasn't seen on first lookup.
|
|
70
85
|
const exactMaps = new WeakMap<object, Map<string, unknown>>();
|
|
71
86
|
|
|
72
87
|
/**
|
|
@@ -82,62 +97,13 @@ export function compileRoutes<T extends { pattern: string }>(
|
|
|
82
97
|
const compiled = compilePattern(route.pattern);
|
|
83
98
|
(route as any)._compiled = compiled;
|
|
84
99
|
// Exact patterns contain no dynamic segments, so pattern === pathname.
|
|
85
|
-
|
|
100
|
+
// First wins, like the sorted scan.
|
|
101
|
+
if (compiled.isExact && !exact.has(route.pattern)) exact.set(route.pattern, route);
|
|
86
102
|
}
|
|
87
103
|
exactMaps.set(routes, exact);
|
|
88
104
|
return routes as (T & { _compiled: CompiledRoute })[];
|
|
89
105
|
}
|
|
90
106
|
|
|
91
|
-
// ─── Legacy Pattern Matcher (fallback for uncompiled routes) ─
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Match a URL pathname against a single route pattern.
|
|
95
|
-
* Returns extracted params if matched, null otherwise.
|
|
96
|
-
*/
|
|
97
|
-
function matchPattern(pattern: string, pathname: string): Record<string, string> | null {
|
|
98
|
-
// Strip trailing slash (but keep "/" as-is)
|
|
99
|
-
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
100
|
-
pathname = pathname.slice(0, -1);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Exact match
|
|
104
|
-
if (pattern === pathname) return {};
|
|
105
|
-
|
|
106
|
-
// Catch-all pattern: /[...name] or /prefix/[...name]
|
|
107
|
-
const catchallMatch = pattern.match(/^(.*?)\/\[\.\.\.(\w+)\]$/);
|
|
108
|
-
if (catchallMatch) {
|
|
109
|
-
const prefix = catchallMatch[1] || "";
|
|
110
|
-
const paramName = catchallMatch[2]!;
|
|
111
|
-
if (prefix === "" || pathname.startsWith(prefix + "/") || pathname === prefix) {
|
|
112
|
-
const rest = prefix ? pathname.slice(prefix.length + 1) : pathname.slice(1);
|
|
113
|
-
// Don't let a root catch-all match "/" with an empty slug.
|
|
114
|
-
// If you want the catch-all to also serve "/", add an explicit +page.svelte at the root.
|
|
115
|
-
if (!prefix && rest === "") return null;
|
|
116
|
-
return { [paramName]: rest };
|
|
117
|
-
}
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Dynamic segments: must have same segment count
|
|
122
|
-
if (!pattern.includes("[")) return null;
|
|
123
|
-
|
|
124
|
-
const patParts = pattern.split("/").filter(Boolean);
|
|
125
|
-
const pathParts = pathname.split("/").filter(Boolean);
|
|
126
|
-
if (patParts.length !== pathParts.length) return null;
|
|
127
|
-
|
|
128
|
-
const params: Record<string, string> = {};
|
|
129
|
-
for (let i = 0; i < patParts.length; i++) {
|
|
130
|
-
const pp = patParts[i]!;
|
|
131
|
-
const val = pathParts[i]!;
|
|
132
|
-
if (pp.startsWith("[") && pp.endsWith("]")) {
|
|
133
|
-
params[pp.slice(1, -1)] = val;
|
|
134
|
-
} else if (pp !== val) {
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
return params;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
107
|
// ─── Route Matching ──────────────────────────────────────
|
|
142
108
|
|
|
143
109
|
/**
|
|
@@ -165,32 +131,33 @@ function matchCompiled(
|
|
|
165
131
|
|
|
166
132
|
/**
|
|
167
133
|
* Find the first matching route from a list.
|
|
168
|
-
* Routes must be pre-sorted by
|
|
134
|
+
* Routes must be pre-sorted by sortRoutes (most specific segment first).
|
|
169
135
|
* Single pass — first match wins.
|
|
170
136
|
*/
|
|
171
137
|
export function findMatch<T extends { pattern: string }>(
|
|
172
138
|
routes: T[],
|
|
173
139
|
pathname: string,
|
|
174
140
|
): RouteMatch<T> | null {
|
|
175
|
-
// Strip trailing slash (but keep "/" as-is)
|
|
141
|
+
// Strip one trailing slash (but keep "/" as-is), same as canonicalPathname.
|
|
176
142
|
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
177
143
|
pathname = pathname.slice(0, -1);
|
|
178
144
|
}
|
|
179
145
|
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
if (exact) {
|
|
183
|
-
|
|
184
|
-
|
|
146
|
+
// Arrays that skipped compileRoutes get compiled on first lookup.
|
|
147
|
+
let exact = exactMaps.get(routes) as Map<string, T> | undefined;
|
|
148
|
+
if (!exact) {
|
|
149
|
+
compileRoutes(routes);
|
|
150
|
+
exact = exactMaps.get(routes) as Map<string, T>;
|
|
185
151
|
}
|
|
186
152
|
|
|
153
|
+
// Exact-match fast path: one Map.get instead of scanning every exact route.
|
|
154
|
+
const hit = exact.get(pathname);
|
|
155
|
+
if (hit) return { route: hit, params: {} };
|
|
156
|
+
|
|
187
157
|
for (const route of routes) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const params = compiled
|
|
192
|
-
? matchCompiled(compiled, route.pattern, pathname)
|
|
193
|
-
: matchPattern(route.pattern, pathname);
|
|
158
|
+
// A route pushed after compileRoutes compiles here, once.
|
|
159
|
+
const compiled: CompiledRoute = ((route as any)._compiled ??= compilePattern(route.pattern));
|
|
160
|
+
const params = matchCompiled(compiled, route.pattern, pathname);
|
|
194
161
|
if (params !== null) return { route, params };
|
|
195
162
|
}
|
|
196
163
|
|