@tschk/moonshine-router 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.
- package/README.md +22 -0
- package/package.json +34 -0
- package/src/graph.ts +89 -0
- package/src/index.ts +28 -0
- package/src/navigation.ts +240 -0
- package/src/pattern.ts +148 -0
- package/test/graph.test.ts +23 -0
- package/test/pattern.test.ts +58 -0
- package/tsconfig.json +4 -0
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# `@tschk/moonshine-router`
|
|
2
|
+
|
|
3
|
+
Renderer-neutral route graph and matcher. Optional React navigation helpers.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createRouteGraph, matchRoutes } from "@tschk/moonshine-router";
|
|
7
|
+
import {
|
|
8
|
+
MoonshineRouter,
|
|
9
|
+
Link,
|
|
10
|
+
useParams,
|
|
11
|
+
useNavigate,
|
|
12
|
+
} from "@tschk/moonshine/router";
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Features:
|
|
16
|
+
|
|
17
|
+
- Pattern compiler with static, dynamic, optional, and rest segments.
|
|
18
|
+
- Route graph with ambiguity detection.
|
|
19
|
+
- Match precedence: `static > dynamic > optional > rest`.
|
|
20
|
+
- `MoonshineRouter`, `Link`, `useParams`, `useNavigate`, `useLocation`.
|
|
21
|
+
|
|
22
|
+
See [docs/ROUTING.md](../../docs/ROUTING.md).
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tschk/moonshine-router",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Renderer-neutral route graph and matcher with optional React navigation",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"format": "bunx prettier --check .",
|
|
14
|
+
"lint": "bunx oxlint .",
|
|
15
|
+
"typecheck": "bunx tsc --noEmit",
|
|
16
|
+
"test": "bun test"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@tschk/moonshine": "^0.3.1",
|
|
20
|
+
"@tschk/moonshine-framework": "^0.3.1"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/bun": "1.3.14",
|
|
24
|
+
"typescript": "7.0.2"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"react": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"react": {
|
|
31
|
+
"optional": true
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/graph.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type RouteDefinition } from "@tschk/moonshine-framework";
|
|
2
|
+
import { compilePattern, type CompiledPattern } from "./pattern.js";
|
|
3
|
+
|
|
4
|
+
export type RouteGraph<T = RouteDefinition> = {
|
|
5
|
+
routes: CompiledRoute<T>[];
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
type CompiledRoute<T> = {
|
|
9
|
+
route: T;
|
|
10
|
+
compiled: CompiledPattern;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function createRouteGraph<T extends RouteDefinition>(
|
|
14
|
+
routes: T[],
|
|
15
|
+
): RouteGraph<T> {
|
|
16
|
+
const seen = new Map<string, { id: string; normalized: string }>();
|
|
17
|
+
const compiled: CompiledRoute<T>[] = [];
|
|
18
|
+
|
|
19
|
+
for (const route of routes) {
|
|
20
|
+
const compiledPattern = compilePattern(route.path);
|
|
21
|
+
const key = `${compiledPattern.normalized}|${compiledPattern.precedence.join(",")}`;
|
|
22
|
+
const existing = seen.get(key);
|
|
23
|
+
if (existing) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Ambiguous routes "${existing.id}" and "${route.id}" both match ${existing.normalized}`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
seen.set(key, { id: route.id, normalized: compiledPattern.normalized });
|
|
29
|
+
compiled.push({ route, compiled: compiledPattern });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { routes: compiled };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MISSING = 4;
|
|
36
|
+
|
|
37
|
+
function compareMatches(
|
|
38
|
+
a: { score: number[] },
|
|
39
|
+
b: { score: number[] },
|
|
40
|
+
): number {
|
|
41
|
+
const max = Math.max(a.score.length, b.score.length);
|
|
42
|
+
for (let i = 0; i < max; i++) {
|
|
43
|
+
const av = i < a.score.length ? a.score[i] : MISSING;
|
|
44
|
+
const bv = i < b.score.length ? b.score[i] : MISSING;
|
|
45
|
+
if (av > bv) return -1;
|
|
46
|
+
if (av < bv) return 1;
|
|
47
|
+
}
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type RouteMatch<T = RouteDefinition> = {
|
|
52
|
+
route: T;
|
|
53
|
+
path: string;
|
|
54
|
+
pattern: string;
|
|
55
|
+
params: Record<string, string>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function matchRoutes<T extends RouteDefinition>(
|
|
59
|
+
graph: RouteGraph<T>,
|
|
60
|
+
pathname: string,
|
|
61
|
+
): RouteMatch<T> | null {
|
|
62
|
+
const matches: {
|
|
63
|
+
route: T;
|
|
64
|
+
pattern: string;
|
|
65
|
+
params: Record<string, string>;
|
|
66
|
+
score: number[];
|
|
67
|
+
}[] = [];
|
|
68
|
+
|
|
69
|
+
for (const { route, compiled } of graph.routes) {
|
|
70
|
+
const m = compiled.match(pathname);
|
|
71
|
+
if (!m) continue;
|
|
72
|
+
matches.push({
|
|
73
|
+
route,
|
|
74
|
+
pattern: compiled.pattern,
|
|
75
|
+
params: m.params,
|
|
76
|
+
score: m.score,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (matches.length === 0) return null;
|
|
81
|
+
matches.sort(compareMatches);
|
|
82
|
+
const best = matches[0]!;
|
|
83
|
+
return {
|
|
84
|
+
route: best.route,
|
|
85
|
+
path: pathname,
|
|
86
|
+
pattern: best.pattern,
|
|
87
|
+
params: best.params,
|
|
88
|
+
};
|
|
89
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export { compilePattern, matchPath } from "./pattern.js";
|
|
2
|
+
export type {
|
|
3
|
+
CompiledPattern,
|
|
4
|
+
MatchResult,
|
|
5
|
+
RouteParams,
|
|
6
|
+
Segment,
|
|
7
|
+
SegmentKind,
|
|
8
|
+
} from "./pattern.js";
|
|
9
|
+
|
|
10
|
+
export { createRouteGraph, matchRoutes } from "./graph.js";
|
|
11
|
+
export type { RouteGraph, RouteMatch } from "./graph.js";
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
createMoonshineRouter,
|
|
15
|
+
navigate,
|
|
16
|
+
getLocation,
|
|
17
|
+
useLocation,
|
|
18
|
+
useParams,
|
|
19
|
+
useNavigate,
|
|
20
|
+
useRouter,
|
|
21
|
+
MoonshineRouter,
|
|
22
|
+
Link,
|
|
23
|
+
} from "./navigation.js";
|
|
24
|
+
export type {
|
|
25
|
+
MoonshineRouterInstance,
|
|
26
|
+
MoonshineRouterProps,
|
|
27
|
+
MoonshineRouteDefinition,
|
|
28
|
+
} from "./navigation.js";
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createSignal, type Signal } from "@tschk/moonshine";
|
|
2
|
+
import {
|
|
3
|
+
createContext,
|
|
4
|
+
createElement,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
useSyncExternalStore,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { type RouteDefinition } from "@tschk/moonshine-framework";
|
|
13
|
+
import { createRouteGraph, matchRoutes, type RouteMatch } from "./graph.js";
|
|
14
|
+
|
|
15
|
+
export type RouteParams = Record<string, string>;
|
|
16
|
+
|
|
17
|
+
function getBrowserPath(): string {
|
|
18
|
+
if (typeof window === "undefined") return "/";
|
|
19
|
+
return window.location.pathname || "/";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type MoonshineRouterInstance = {
|
|
23
|
+
/** Navigate via History API (no full reload). */
|
|
24
|
+
navigate: (to: string, options?: { replace?: boolean }) => void;
|
|
25
|
+
/** Current pathname (reactive signal). */
|
|
26
|
+
getLocation: () => string;
|
|
27
|
+
/** Subscribe-friendly location signal. */
|
|
28
|
+
location: Signal<string>;
|
|
29
|
+
/** Sync signal from `window.location`. */
|
|
30
|
+
syncFromBrowser: () => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Create an isolated router runtime (no shared global path). */
|
|
34
|
+
export function createMoonshineRouter(
|
|
35
|
+
initialPath = getBrowserPath(),
|
|
36
|
+
): MoonshineRouterInstance {
|
|
37
|
+
const location = createSignal(initialPath);
|
|
38
|
+
|
|
39
|
+
const syncFromBrowser = () => {
|
|
40
|
+
location.set(getBrowserPath());
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const navigate = (to: string, options?: { replace?: boolean }) => {
|
|
44
|
+
if (typeof window === "undefined") {
|
|
45
|
+
location.set(to);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const url = new URL(to, window.location.origin);
|
|
49
|
+
if (options?.replace) {
|
|
50
|
+
window.history.replaceState({}, "", url.pathname + url.search + url.hash);
|
|
51
|
+
} else {
|
|
52
|
+
window.history.pushState({}, "", url.pathname + url.search + url.hash);
|
|
53
|
+
}
|
|
54
|
+
location.set(url.pathname);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
navigate,
|
|
59
|
+
getLocation: () => location(),
|
|
60
|
+
location,
|
|
61
|
+
syncFromBrowser,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Active mounted router for module-level navigate/getLocation. */
|
|
66
|
+
let activeRouter: MoonshineRouterInstance | null = null;
|
|
67
|
+
/** Fallback when nothing mounted (tests / early navigate). */
|
|
68
|
+
const fallbackRouter = createMoonshineRouter();
|
|
69
|
+
|
|
70
|
+
function currentRouter(): MoonshineRouterInstance {
|
|
71
|
+
return activeRouter ?? fallbackRouter;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Navigate via History API (targets active mounted router). */
|
|
75
|
+
export function navigate(to: string, options?: { replace?: boolean }): void {
|
|
76
|
+
currentRouter().navigate(to, options);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Current pathname (reactive; active or fallback router). */
|
|
80
|
+
export function getLocation(): string {
|
|
81
|
+
return currentRouter().getLocation();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function useLocation(): string {
|
|
85
|
+
const ctx = useContext(RouterContext);
|
|
86
|
+
const source = ctx?.runtime.location ?? fallbackRouter.location;
|
|
87
|
+
return useSyncExternalStore(
|
|
88
|
+
source.subscribe,
|
|
89
|
+
() => source.peek(),
|
|
90
|
+
() => "/",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type RouterContextValue = {
|
|
95
|
+
match: RouteMatch<InternalRoute> | null;
|
|
96
|
+
params: RouteParams;
|
|
97
|
+
navigate: typeof navigate;
|
|
98
|
+
runtime: MoonshineRouterInstance;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const RouterContext = createContext<RouterContextValue | null>(null);
|
|
102
|
+
|
|
103
|
+
export function useParams(): RouteParams {
|
|
104
|
+
return useContext(RouterContext)?.params ?? {};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function useNavigate(): typeof navigate {
|
|
108
|
+
return useContext(RouterContext)?.navigate ?? navigate;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function useRouter(): MoonshineRouterInstance {
|
|
112
|
+
return useContext(RouterContext)?.runtime ?? fallbackRouter;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type MoonshineRouteDefinition = Partial<RouteDefinition> & {
|
|
116
|
+
path: string;
|
|
117
|
+
element: ReactNode;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
type InternalRoute = RouteDefinition & { element: ReactNode };
|
|
121
|
+
|
|
122
|
+
export type MoonshineRouterProps = {
|
|
123
|
+
routes: MoonshineRouteDefinition[];
|
|
124
|
+
/** Fallback when no route matches. */
|
|
125
|
+
fallback?: ReactNode;
|
|
126
|
+
/** Controlled path (defaults to browser location). */
|
|
127
|
+
path?: string;
|
|
128
|
+
/**
|
|
129
|
+
* Optional external runtime. When omitted, each mount owns an isolated
|
|
130
|
+
* instance (no cross-app global path bleed).
|
|
131
|
+
*/
|
|
132
|
+
runtime?: MoonshineRouterInstance;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Minimal file-ish router: declarative routes with `:param` segments.
|
|
137
|
+
*
|
|
138
|
+
* ```tsx
|
|
139
|
+
* <MoonshineRouter
|
|
140
|
+
* routes={[
|
|
141
|
+
* { path: "/", element: <Home /> },
|
|
142
|
+
* { path: "/users/:id", element: <User /> },
|
|
143
|
+
* ]}
|
|
144
|
+
* fallback={<NotFound />}
|
|
145
|
+
* />
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
export function MoonshineRouter(props: MoonshineRouterProps): ReactNode {
|
|
149
|
+
const owned = useRef<MoonshineRouterInstance | null>(null);
|
|
150
|
+
if (!owned.current && !props.runtime) {
|
|
151
|
+
owned.current = createMoonshineRouter(props.path ?? getBrowserPath());
|
|
152
|
+
}
|
|
153
|
+
const runtime = props.runtime ?? owned.current!;
|
|
154
|
+
|
|
155
|
+
const browserPath = useSyncExternalStore(
|
|
156
|
+
runtime.location.subscribe,
|
|
157
|
+
() => runtime.location.peek(),
|
|
158
|
+
() => props.path ?? "/",
|
|
159
|
+
);
|
|
160
|
+
const pathname = props.path ?? browserPath;
|
|
161
|
+
const [ready, setReady] = useState(
|
|
162
|
+
typeof window === "undefined" || props.path !== undefined,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const graph = createRouteGraph<InternalRoute>(
|
|
166
|
+
props.routes.map((route) => ({
|
|
167
|
+
...route,
|
|
168
|
+
id: route.id ?? route.path,
|
|
169
|
+
file: route.file ?? "",
|
|
170
|
+
})),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
const prev = activeRouter;
|
|
175
|
+
activeRouter = runtime;
|
|
176
|
+
if (props.path === undefined) {
|
|
177
|
+
runtime.syncFromBrowser();
|
|
178
|
+
setReady(true);
|
|
179
|
+
const onPop = () => runtime.syncFromBrowser();
|
|
180
|
+
window.addEventListener("popstate", onPop);
|
|
181
|
+
return () => {
|
|
182
|
+
window.removeEventListener("popstate", onPop);
|
|
183
|
+
activeRouter = prev;
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return () => {
|
|
187
|
+
activeRouter = prev;
|
|
188
|
+
};
|
|
189
|
+
}, [runtime, props.path]);
|
|
190
|
+
|
|
191
|
+
const matched = matchRoutes<InternalRoute>(graph, pathname);
|
|
192
|
+
const value: RouterContextValue = {
|
|
193
|
+
match: matched,
|
|
194
|
+
params: matched?.params ?? {},
|
|
195
|
+
navigate: runtime.navigate,
|
|
196
|
+
runtime,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if (!ready && props.path === undefined && typeof window !== "undefined") {
|
|
200
|
+
// Avoid hydration flicker on first client paint before popstate sync
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return createElement(
|
|
204
|
+
RouterContext.Provider,
|
|
205
|
+
{ value },
|
|
206
|
+
matched?.route.element ?? props.fallback ?? null,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Anchor that uses client-side navigation. */
|
|
211
|
+
export function Link(props: {
|
|
212
|
+
href: string;
|
|
213
|
+
children?: ReactNode;
|
|
214
|
+
className?: string;
|
|
215
|
+
replace?: boolean;
|
|
216
|
+
}): ReactNode {
|
|
217
|
+
const nav = useNavigate();
|
|
218
|
+
return createElement(
|
|
219
|
+
"a",
|
|
220
|
+
{
|
|
221
|
+
href: props.href,
|
|
222
|
+
className: props.className,
|
|
223
|
+
onClick: (event: MouseEvent) => {
|
|
224
|
+
if (
|
|
225
|
+
event.defaultPrevented ||
|
|
226
|
+
event.button !== 0 ||
|
|
227
|
+
event.metaKey ||
|
|
228
|
+
event.altKey ||
|
|
229
|
+
event.ctrlKey ||
|
|
230
|
+
event.shiftKey
|
|
231
|
+
) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
event.preventDefault();
|
|
235
|
+
nav(props.href, { replace: props.replace });
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
props.children,
|
|
239
|
+
);
|
|
240
|
+
}
|
package/src/pattern.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
export type RouteParams = Record<string, string>;
|
|
2
|
+
|
|
3
|
+
export type SegmentKind = "static" | "dynamic" | "optional" | "rest";
|
|
4
|
+
|
|
5
|
+
export const SEGMENT_PRECEDENCE: Record<SegmentKind, number> = {
|
|
6
|
+
static: 3,
|
|
7
|
+
dynamic: 2,
|
|
8
|
+
optional: 1,
|
|
9
|
+
rest: 0,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type Segment = {
|
|
13
|
+
kind: SegmentKind;
|
|
14
|
+
name: string;
|
|
15
|
+
value: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type MatchResult = {
|
|
19
|
+
params: RouteParams;
|
|
20
|
+
score: number[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type CompiledPattern = {
|
|
24
|
+
pattern: string;
|
|
25
|
+
segments: Segment[];
|
|
26
|
+
precedence: number[];
|
|
27
|
+
normalized: string;
|
|
28
|
+
match: (pathname: string) => MatchResult | null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function splitPath(path: string): string[] {
|
|
32
|
+
return path
|
|
33
|
+
.replace(/\/+$/, "")
|
|
34
|
+
.replace(/^\/+/, "")
|
|
35
|
+
.split("/")
|
|
36
|
+
.filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function decodePart(part: string): string | null {
|
|
40
|
+
try {
|
|
41
|
+
return decodeURIComponent(part);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function compilePattern(pattern: string): CompiledPattern {
|
|
48
|
+
const raw = pattern.replace(/\/+$/, "").replace(/^\/+/, "");
|
|
49
|
+
const parts = raw === "" ? [] : raw.split("/").filter(Boolean);
|
|
50
|
+
const segments: Segment[] = [];
|
|
51
|
+
const precedence: number[] = [];
|
|
52
|
+
let normalized = "";
|
|
53
|
+
|
|
54
|
+
for (const part of parts) {
|
|
55
|
+
if (part.startsWith("*")) {
|
|
56
|
+
const name = part.length > 1 ? part.slice(1) : "*";
|
|
57
|
+
segments.push({ kind: "rest", name, value: part });
|
|
58
|
+
precedence.push(SEGMENT_PRECEDENCE.rest);
|
|
59
|
+
normalized += "/*rest";
|
|
60
|
+
} else if (part.startsWith(":")) {
|
|
61
|
+
const optional = part.endsWith("?");
|
|
62
|
+
const name = optional ? part.slice(1, -1) : part.slice(1);
|
|
63
|
+
const kind: SegmentKind = optional ? "optional" : "dynamic";
|
|
64
|
+
segments.push({ kind, name, value: name });
|
|
65
|
+
precedence.push(SEGMENT_PRECEDENCE[kind]);
|
|
66
|
+
normalized += optional ? "/:param?" : "/:param";
|
|
67
|
+
} else {
|
|
68
|
+
segments.push({ kind: "static", name: part, value: part });
|
|
69
|
+
precedence.push(SEGMENT_PRECEDENCE.static);
|
|
70
|
+
normalized += "/" + part;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (normalized === "") {
|
|
75
|
+
normalized = "/";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const match = (pathname: string): MatchResult | null => {
|
|
79
|
+
const pathParts = splitPath(pathname);
|
|
80
|
+
const params: RouteParams = {};
|
|
81
|
+
const score: number[] = [];
|
|
82
|
+
let i = 0;
|
|
83
|
+
|
|
84
|
+
for (const segment of segments) {
|
|
85
|
+
if (segment.kind === "rest") {
|
|
86
|
+
const remaining = pathParts.slice(i).join("/");
|
|
87
|
+
try {
|
|
88
|
+
params[segment.name] = decodeURIComponent(remaining);
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
score.push(SEGMENT_PRECEDENCE.rest);
|
|
93
|
+
i = pathParts.length;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (segment.kind === "static") {
|
|
98
|
+
const part = pathParts[i];
|
|
99
|
+
if (part === undefined) return null;
|
|
100
|
+
const decoded = decodePart(part);
|
|
101
|
+
if (decoded === null) return null;
|
|
102
|
+
if (decoded !== segment.value) return null;
|
|
103
|
+
score.push(SEGMENT_PRECEDENCE.static);
|
|
104
|
+
i++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (segment.kind === "dynamic") {
|
|
109
|
+
const part = pathParts[i];
|
|
110
|
+
if (part === undefined) return null;
|
|
111
|
+
const decoded = decodePart(part);
|
|
112
|
+
if (decoded === null) return null;
|
|
113
|
+
params[segment.name] = decoded;
|
|
114
|
+
score.push(SEGMENT_PRECEDENCE.dynamic);
|
|
115
|
+
i++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (segment.kind === "optional") {
|
|
120
|
+
const part = pathParts[i];
|
|
121
|
+
if (part === undefined) {
|
|
122
|
+
score.push(0);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const decoded = decodePart(part);
|
|
126
|
+
if (decoded === null) return null;
|
|
127
|
+
params[segment.name] = decoded;
|
|
128
|
+
score.push(SEGMENT_PRECEDENCE.optional);
|
|
129
|
+
i++;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (i < pathParts.length) return null;
|
|
135
|
+
return { params, score };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return { pattern, segments, precedence, normalized, match };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function matchPath(
|
|
142
|
+
pattern: string,
|
|
143
|
+
pathname: string,
|
|
144
|
+
): { path: string; pattern: string; params: RouteParams } | null {
|
|
145
|
+
const result = compilePattern(pattern).match(pathname);
|
|
146
|
+
if (!result) return null;
|
|
147
|
+
return { path: pathname, pattern, params: result.params };
|
|
148
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createRouteGraph, matchRoutes } from "../src/graph";
|
|
3
|
+
|
|
4
|
+
describe("createRouteGraph", () => {
|
|
5
|
+
test("static beats dynamic beats rest", () => {
|
|
6
|
+
const graph = createRouteGraph([
|
|
7
|
+
{ id: "rest", path: "/blog/*rest", file: "rest.tsx" },
|
|
8
|
+
{ id: "slug", path: "/blog/:slug", file: "slug.tsx" },
|
|
9
|
+
{ id: "new", path: "/blog/new", file: "new.tsx" },
|
|
10
|
+
]);
|
|
11
|
+
expect(matchRoutes(graph, "/blog/new")?.route.id).toBe("new");
|
|
12
|
+
expect(matchRoutes(graph, "/blog/post")?.params).toEqual({ slug: "post" });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("throws for ambiguous routes", () => {
|
|
16
|
+
expect(() =>
|
|
17
|
+
createRouteGraph([
|
|
18
|
+
{ id: "a", path: "/users/:param", file: "a.tsx" },
|
|
19
|
+
{ id: "b", path: "/users/:id", file: "b.tsx" },
|
|
20
|
+
]),
|
|
21
|
+
).toThrow(/Ambiguous routes "a" and "b" both match \/users\/:param/);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { compilePattern, matchPath } from "../src/pattern";
|
|
3
|
+
|
|
4
|
+
describe("compilePattern", () => {
|
|
5
|
+
test("static matches exact", () => {
|
|
6
|
+
const p = compilePattern("/about");
|
|
7
|
+
expect(p.match("/about")?.params).toEqual({});
|
|
8
|
+
expect(p.match("/other")).toBeNull();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("dynamic captures a parameter", () => {
|
|
12
|
+
const p = compilePattern("/users/:id");
|
|
13
|
+
const m = p.match("/users/42");
|
|
14
|
+
expect(m?.params).toEqual({ id: "42" });
|
|
15
|
+
expect(m?.score).toEqual([3, 2]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("optional segment may be omitted", () => {
|
|
19
|
+
const p = compilePattern("/:tab?");
|
|
20
|
+
expect(p.match("/")?.params).toEqual({});
|
|
21
|
+
expect(p.match("/profile")?.params).toEqual({ tab: "profile" });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("rest captures the remainder", () => {
|
|
25
|
+
const p = compilePattern("/blog/*rest");
|
|
26
|
+
const m = p.match("/blog/a/b");
|
|
27
|
+
expect(m?.params).toEqual({ rest: "a/b" });
|
|
28
|
+
expect(m?.score).toEqual([3, 0]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("decodes parameter values", () => {
|
|
32
|
+
const p = compilePattern("/users/:id");
|
|
33
|
+
expect(p.match("/users/bob%20smith")?.params.id).toBe("bob smith");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("malformed encoding returns no match", () => {
|
|
37
|
+
const p = compilePattern("/users/:id");
|
|
38
|
+
expect(p.match("/users/bob%ZZ")).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("precedence reflects segment kind", () => {
|
|
42
|
+
const p = compilePattern("/blog/:slug?");
|
|
43
|
+
expect(p.precedence).toEqual([3, 1]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("matchPath", () => {
|
|
48
|
+
test("returns a match object", () => {
|
|
49
|
+
const m = matchPath("/users/:id", "/users/7");
|
|
50
|
+
expect(m).not.toBeNull();
|
|
51
|
+
expect(m!.params.id).toBe("7");
|
|
52
|
+
expect(m!.pattern).toBe("/users/:id");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("rejects length mismatch", () => {
|
|
56
|
+
expect(matchPath("/a/b", "/a")).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
});
|
package/tsconfig.json
ADDED