@tschk/moonshine-router 0.4.0 → 0.4.4
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 +45 -10
- package/package.json +9 -4
- package/src/index.ts +4 -2
- package/src/navigation.ts +46 -79
- package/src/pattern.ts +113 -100
- package/src/runtime.ts +108 -0
- package/test/dom.ts +0 -109
- package/test/graph.test.ts +0 -23
- package/test/link.test.tsx +0 -94
- package/test/navigation.test.ts +0 -76
- package/test/pattern.test.ts +0 -114
- package/tsconfig.json +0 -4
package/README.md
CHANGED
|
@@ -1,22 +1,57 @@
|
|
|
1
1
|
# `@tschk/moonshine-router`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Use the neutral subpaths without installing React:
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createRouteGraph, matchRoutes } from "@tschk/moonshine-router/graph";
|
|
7
|
+
import {
|
|
8
|
+
createMoonshineRouter,
|
|
9
|
+
splitLocation,
|
|
10
|
+
} from "@tschk/moonshine-router/runtime";
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The graph provides pattern matching, ambiguity detection, and precedence:
|
|
14
|
+
`static > dynamic > optional > rest`. The browser runtime provides isolated
|
|
15
|
+
location and history signals, push/replace navigation, and explicit browser
|
|
16
|
+
history synchronization. Relative destinations resolve against the owning
|
|
17
|
+
runtime's current location. Location signals contain pathname and query;
|
|
18
|
+
fragments are passed to browser history. It does not fetch route data or mount
|
|
19
|
+
page components.
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
const router = createMoonshineRouter();
|
|
23
|
+
const output = document.querySelector("output")!;
|
|
24
|
+
const update = () => {
|
|
25
|
+
output.textContent = router.getLocation();
|
|
26
|
+
};
|
|
27
|
+
const unsubscribe = router.location.subscribe(update);
|
|
28
|
+
const sync = () => router.syncFromBrowser();
|
|
29
|
+
window.addEventListener("popstate", sync);
|
|
30
|
+
update();
|
|
31
|
+
|
|
32
|
+
router.navigate("/counter?start=2", { state: { from: "home" } });
|
|
33
|
+
|
|
34
|
+
// When this app is removed:
|
|
35
|
+
unsubscribe();
|
|
36
|
+
window.removeEventListener("popstate", sync);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The package root retains React navigation exports and requires React installed.
|
|
40
|
+
Existing React imports remain supported:
|
|
4
41
|
|
|
5
42
|
```ts
|
|
6
|
-
import { createRouteGraph, matchRoutes } from "@tschk/moonshine-router";
|
|
7
43
|
import {
|
|
8
44
|
MoonshineRouter,
|
|
9
45
|
Link,
|
|
10
46
|
useParams,
|
|
11
47
|
useNavigate,
|
|
12
|
-
} from "@tschk/moonshine
|
|
48
|
+
} from "@tschk/moonshine-router";
|
|
13
49
|
```
|
|
14
50
|
|
|
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
51
|
See [docs/ROUTING.md](../../docs/ROUTING.md).
|
|
52
|
+
|
|
53
|
+
Each router runtime owns a `fragment` signal containing its current hash,
|
|
54
|
+
including `#`. Navigation and browser synchronization update it alongside
|
|
55
|
+
history and location. `getLocation()` and `location` retain pathname plus query
|
|
56
|
+
only. An explicit initial URL supplies its own fragment; the default runtime
|
|
57
|
+
starts from the browser URL.
|
package/package.json
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tschk/moonshine-router",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "Renderer-neutral route graph and matcher with optional React navigation",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.ts",
|
|
8
8
|
"types": "./src/index.ts",
|
|
9
9
|
"exports": {
|
|
10
|
-
".": "./src/index.ts"
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./graph": "./src/graph.ts",
|
|
12
|
+
"./runtime": "./src/runtime.ts"
|
|
11
13
|
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src"
|
|
16
|
+
],
|
|
12
17
|
"scripts": {
|
|
13
18
|
"format": "bunx prettier --check .",
|
|
14
19
|
"lint": "bunx oxlint .",
|
|
@@ -16,8 +21,8 @@
|
|
|
16
21
|
"test": "bun test"
|
|
17
22
|
},
|
|
18
23
|
"dependencies": {
|
|
19
|
-
"@tschk/moonshine": "^0.
|
|
20
|
-
"@tschk/moonshine-framework": "^0.
|
|
24
|
+
"@tschk/moonshine": "^0.4.4",
|
|
25
|
+
"@tschk/moonshine-framework": "^0.4.4"
|
|
21
26
|
},
|
|
22
27
|
"devDependencies": {
|
|
23
28
|
"@types/bun": "1.3.14",
|
package/src/index.ts
CHANGED
|
@@ -11,10 +11,10 @@ export { createRouteGraph, matchRoutes } from "./graph.js";
|
|
|
11
11
|
export type { RouteGraph, RouteMatch } from "./graph.js";
|
|
12
12
|
|
|
13
13
|
export {
|
|
14
|
-
createMoonshineRouter,
|
|
15
14
|
navigate,
|
|
16
15
|
getLocation,
|
|
17
16
|
useLocation,
|
|
17
|
+
useHistory,
|
|
18
18
|
useParams,
|
|
19
19
|
useNavigate,
|
|
20
20
|
useRouter,
|
|
@@ -24,7 +24,9 @@ export {
|
|
|
24
24
|
safeHref,
|
|
25
25
|
} from "./navigation.js";
|
|
26
26
|
export type {
|
|
27
|
-
MoonshineRouterInstance,
|
|
28
27
|
MoonshineRouterProps,
|
|
29
28
|
MoonshineRouteDefinition,
|
|
30
29
|
} from "./navigation.js";
|
|
30
|
+
|
|
31
|
+
export { createMoonshineRouter, splitLocation } from "./runtime.js";
|
|
32
|
+
export type { MoonshineRouterInstance, HistorySnapshot } from "./runtime.js";
|
package/src/navigation.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createSignal, type Signal } from "@tschk/moonshine";
|
|
2
1
|
import {
|
|
3
2
|
createContext,
|
|
4
3
|
createElement,
|
|
@@ -12,86 +11,35 @@ import {
|
|
|
12
11
|
import { type RouteDefinition } from "@tschk/moonshine-framework";
|
|
13
12
|
import { createRouteGraph, matchRoutes, type RouteMatch } from "./graph.js";
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
* same pathname back, notify no subscriber, and leave every `useSearchParams`
|
|
23
|
-
* caller rendering the previous query — a tab bar driven by `?section=` would
|
|
24
|
-
* change the URL and not the page. The hash stays out: it is still pushed to
|
|
25
|
-
* history, but these hooks do not read it and including it would corrupt the
|
|
26
|
-
* query when consumers split on the first "?".
|
|
27
|
-
*/
|
|
28
|
-
function getBrowserPath(): string {
|
|
29
|
-
if (typeof window === "undefined") return "/";
|
|
30
|
-
return (window.location.pathname || "/") + window.location.search;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Splits `"/a/b?x=1"` into its pathname and its raw query string. */
|
|
34
|
-
export function splitLocation(location: string): [string, string] {
|
|
35
|
-
const index = location.indexOf("?");
|
|
36
|
-
return index === -1
|
|
37
|
-
? [location, ""]
|
|
38
|
-
: [location.slice(0, index), location.slice(index + 1)];
|
|
39
|
-
}
|
|
14
|
+
import {
|
|
15
|
+
createMoonshineRouter,
|
|
16
|
+
getBrowserPath,
|
|
17
|
+
splitLocation,
|
|
18
|
+
type HistorySnapshot,
|
|
19
|
+
type MoonshineRouterInstance,
|
|
20
|
+
} from "./runtime.js";
|
|
40
21
|
|
|
41
|
-
export type
|
|
42
|
-
/** Navigate via History API (no full reload). */
|
|
43
|
-
navigate: (to: string, options?: { replace?: boolean }) => void;
|
|
44
|
-
/** Current pathname and query (reactive signal). */
|
|
45
|
-
getLocation: () => string;
|
|
46
|
-
/** Subscribe-friendly location signal, carrying pathname and query. */
|
|
47
|
-
location: Signal<string>;
|
|
48
|
-
/** Sync signal from `window.location`. */
|
|
49
|
-
syncFromBrowser: () => void;
|
|
50
|
-
};
|
|
22
|
+
export type RouteParams = Record<string, string>;
|
|
51
23
|
|
|
52
|
-
|
|
53
|
-
export
|
|
54
|
-
initialPath = getBrowserPath(),
|
|
55
|
-
): MoonshineRouterInstance {
|
|
56
|
-
const location = createSignal(initialPath);
|
|
24
|
+
export { createMoonshineRouter, splitLocation } from "./runtime.js";
|
|
25
|
+
export type { MoonshineRouterInstance } from "./runtime.js";
|
|
57
26
|
|
|
58
|
-
|
|
59
|
-
location.set(getBrowserPath());
|
|
60
|
-
};
|
|
27
|
+
const INITIAL_HISTORY: HistorySnapshot = { action: "POP", state: null };
|
|
61
28
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
location.set(to);
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
const url = new URL(to, window.location.origin);
|
|
68
|
-
if (options?.replace) {
|
|
69
|
-
window.history.replaceState({}, "", url.pathname + url.search + url.hash);
|
|
70
|
-
} else {
|
|
71
|
-
window.history.pushState({}, "", url.pathname + url.search + url.hash);
|
|
72
|
-
}
|
|
73
|
-
location.set(url.pathname + url.search);
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
return {
|
|
77
|
-
navigate,
|
|
78
|
-
getLocation: () => location(),
|
|
79
|
-
location,
|
|
80
|
-
syncFromBrowser,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Active mounted router for module-level navigate/getLocation. */
|
|
85
|
-
let activeRouter: MoonshineRouterInstance | null = null;
|
|
29
|
+
/** Mount tokens keep module-level navigation valid regardless of unmount order. */
|
|
30
|
+
const mountedRouters: Array<{ runtime: MoonshineRouterInstance }> = [];
|
|
86
31
|
/** Fallback when nothing mounted (tests / early navigate). */
|
|
87
32
|
const fallbackRouter = createMoonshineRouter();
|
|
88
33
|
|
|
89
34
|
function currentRouter(): MoonshineRouterInstance {
|
|
90
|
-
return
|
|
35
|
+
return mountedRouters.at(-1)?.runtime ?? fallbackRouter;
|
|
91
36
|
}
|
|
92
37
|
|
|
93
38
|
/** Navigate via History API (targets active mounted router). */
|
|
94
|
-
export function navigate(
|
|
39
|
+
export function navigate(
|
|
40
|
+
to: string,
|
|
41
|
+
options?: { replace?: boolean; state?: unknown },
|
|
42
|
+
): void {
|
|
95
43
|
currentRouter().navigate(to, options);
|
|
96
44
|
}
|
|
97
45
|
|
|
@@ -106,7 +54,18 @@ export function useLocation(): string {
|
|
|
106
54
|
return useSyncExternalStore(
|
|
107
55
|
source.subscribe,
|
|
108
56
|
() => source.peek(),
|
|
109
|
-
() => "/",
|
|
57
|
+
() => ctx?.runtime.location.peek() ?? "/",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read scoped history without leaking process-level fallback state into SSR. */
|
|
62
|
+
export function useHistory(): HistorySnapshot {
|
|
63
|
+
const ctx = useContext(RouterContext);
|
|
64
|
+
const source = ctx?.runtime.history ?? fallbackRouter.history;
|
|
65
|
+
return useSyncExternalStore(
|
|
66
|
+
source.subscribe,
|
|
67
|
+
source.peek,
|
|
68
|
+
() => ctx?.runtime.history.peek() ?? INITIAL_HISTORY,
|
|
110
69
|
);
|
|
111
70
|
}
|
|
112
71
|
|
|
@@ -195,20 +154,22 @@ export function MoonshineRouter(props: MoonshineRouterProps): ReactNode {
|
|
|
195
154
|
);
|
|
196
155
|
|
|
197
156
|
useEffect(() => {
|
|
198
|
-
const
|
|
199
|
-
|
|
157
|
+
const mounted = { runtime };
|
|
158
|
+
mountedRouters.push(mounted);
|
|
159
|
+
const deactivate = () => {
|
|
160
|
+
const index = mountedRouters.indexOf(mounted);
|
|
161
|
+
if (index !== -1) mountedRouters.splice(index, 1);
|
|
162
|
+
};
|
|
200
163
|
if (props.path === undefined) {
|
|
201
164
|
runtime.syncFromBrowser();
|
|
202
165
|
const onPop = () => runtime.syncFromBrowser();
|
|
203
166
|
window.addEventListener("popstate", onPop);
|
|
204
167
|
return () => {
|
|
205
168
|
window.removeEventListener("popstate", onPop);
|
|
206
|
-
|
|
169
|
+
deactivate();
|
|
207
170
|
};
|
|
208
171
|
}
|
|
209
|
-
return
|
|
210
|
-
activeRouter = prev;
|
|
211
|
-
};
|
|
172
|
+
return deactivate;
|
|
212
173
|
}, [runtime, props.path]);
|
|
213
174
|
|
|
214
175
|
const matched = matchRoutes<InternalRoute>(graph, pathname);
|
|
@@ -263,7 +224,8 @@ export function safeHref(href: string): string {
|
|
|
263
224
|
* carrying a scheme (`https:`, `mailto:`, `tel:`), a protocol-relative `//host`,
|
|
264
225
|
* a bare fragment, or an explicit non-`_self` target belongs to the browser: it
|
|
265
226
|
* opens a mail client, a new tab, or another origin, none of which this router
|
|
266
|
-
* can do.
|
|
227
|
+
* can do. A present `download` attribute also belongs to the browser; null
|
|
228
|
+
* and false match React's omitted-attribute behavior.
|
|
267
229
|
*
|
|
268
230
|
* `safeHref` deliberately allows those schemes through — it only strips ones
|
|
269
231
|
* that could execute. Allowing a scheme is not the same as claiming to handle
|
|
@@ -276,7 +238,12 @@ export function safeHref(href: string): string {
|
|
|
276
238
|
* a copy — three of them already did, byte for byte, and the copy this module
|
|
277
239
|
* lacked is exactly the one that was wrong.
|
|
278
240
|
*/
|
|
279
|
-
export function isExternal(
|
|
241
|
+
export function isExternal(
|
|
242
|
+
href: string,
|
|
243
|
+
target?: string,
|
|
244
|
+
download?: unknown,
|
|
245
|
+
): boolean {
|
|
246
|
+
if (download != null && download !== false) return true;
|
|
280
247
|
if (target && target !== "_self") return true;
|
|
281
248
|
return (
|
|
282
249
|
/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href) ||
|
package/src/pattern.ts
CHANGED
|
@@ -44,9 +44,12 @@ function decodePart(part: string): string | null {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
function parsePattern(pattern: string): {
|
|
48
|
+
segments: Segment[];
|
|
49
|
+
precedence: number[];
|
|
50
|
+
normalized: string;
|
|
51
|
+
} {
|
|
52
|
+
const parts = splitPath(pattern);
|
|
50
53
|
const segments: Segment[] = [];
|
|
51
54
|
const precedence: number[] = [];
|
|
52
55
|
let normalized = "";
|
|
@@ -75,113 +78,123 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
75
78
|
normalized = "/";
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
decoded.push(d);
|
|
81
|
+
return { segments, precedence, normalized };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Decodes the tail a `*rest` segment captures.
|
|
86
|
+
*
|
|
87
|
+
* Per part, not on the joined string. Decoding after the join lets an encoded
|
|
88
|
+
* separator smuggle structure past the router: `/files/%2e%2e%2fsecret` used
|
|
89
|
+
* to arrive as `path: "../secret"`, and a handler doing the obvious
|
|
90
|
+
* `join(dir, params.path)` then read outside its directory. `a%2Fb` and `a/b`
|
|
91
|
+
* were likewise indistinguishable. Same rules the static server already
|
|
92
|
+
* applies in `resolveStaticPath`, so the two agree about what a path segment
|
|
93
|
+
* may contain.
|
|
94
|
+
*/
|
|
95
|
+
function decodeRest(parts: readonly string[]): string | null {
|
|
96
|
+
const decoded: string[] = [];
|
|
97
|
+
for (const part of parts) {
|
|
98
|
+
const d = decodePart(part);
|
|
99
|
+
if (
|
|
100
|
+
d === null ||
|
|
101
|
+
d === "." ||
|
|
102
|
+
d === ".." ||
|
|
103
|
+
d.includes("\0") ||
|
|
104
|
+
d.includes("/")
|
|
105
|
+
) {
|
|
106
|
+
return null;
|
|
105
107
|
}
|
|
106
|
-
|
|
108
|
+
decoded.push(d);
|
|
109
|
+
}
|
|
110
|
+
return decoded.join("/");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function withHead(
|
|
114
|
+
rest: MatchResult,
|
|
115
|
+
points: number,
|
|
116
|
+
binding?: [string, string],
|
|
117
|
+
): MatchResult {
|
|
118
|
+
return {
|
|
119
|
+
params: binding
|
|
120
|
+
? { [binding[0]]: binding[1], ...rest.params }
|
|
121
|
+
: rest.params,
|
|
122
|
+
score: [points, ...rest.score],
|
|
107
123
|
};
|
|
124
|
+
}
|
|
108
125
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Matches `segments` from `si` against `pathParts` from `pi`.
|
|
128
|
+
*
|
|
129
|
+
* Recursive because an optional segment has to be able to give its part back.
|
|
130
|
+
* The old linear pass consumed a part whenever one was present, so
|
|
131
|
+
* `/:lang?/about` matched `/en/about` and then failed `/about` — the i18n
|
|
132
|
+
* prefix route 404ing on its own default locale, silently, because a failed
|
|
133
|
+
* match is indistinguishable from no route. Consuming is tried first so the
|
|
134
|
+
* greedy reading still wins where both work.
|
|
135
|
+
*/
|
|
136
|
+
function matchSegments(
|
|
137
|
+
segments: readonly Segment[],
|
|
138
|
+
pathParts: readonly string[],
|
|
139
|
+
si: number = 0,
|
|
140
|
+
pi: number = 0,
|
|
141
|
+
): MatchResult | null {
|
|
142
|
+
if (si === segments.length) {
|
|
143
|
+
return pi === pathParts.length ? { params: {}, score: [] } : null;
|
|
144
|
+
}
|
|
123
145
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const withHead = (
|
|
128
|
-
rest: MatchResult,
|
|
129
|
-
points: number,
|
|
130
|
-
binding?: [string, string],
|
|
131
|
-
): MatchResult => ({
|
|
132
|
-
params: binding
|
|
133
|
-
? { [binding[0]]: binding[1], ...rest.params }
|
|
134
|
-
: rest.params,
|
|
135
|
-
score: [points, ...rest.score],
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
if (segment.kind === "rest") {
|
|
139
|
-
const value = decodeRest(pathParts.slice(pi));
|
|
140
|
-
if (value === null) return null;
|
|
141
|
-
const rest = matchFrom(si + 1, pathParts.length);
|
|
142
|
-
return (
|
|
143
|
-
rest && withHead(rest, SEGMENT_PRECEDENCE.rest, [segment.name, value])
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
+
const segment = segments[si];
|
|
147
|
+
const part = pathParts[pi];
|
|
146
148
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
149
|
+
if (segment.kind === "rest") {
|
|
150
|
+
const value = decodeRest(pathParts.slice(pi));
|
|
151
|
+
if (value === null) return null;
|
|
152
|
+
const rest = matchSegments(segments, pathParts, si + 1, pathParts.length);
|
|
153
|
+
return (
|
|
154
|
+
rest && withHead(rest, SEGMENT_PRECEDENCE.rest, [segment.name, value])
|
|
155
|
+
);
|
|
156
|
+
}
|
|
154
157
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
158
|
+
if (segment.kind === "static") {
|
|
159
|
+
if (part === undefined) return null;
|
|
160
|
+
const decoded = decodePart(part);
|
|
161
|
+
if (decoded === null || decoded !== segment.value) return null;
|
|
162
|
+
const rest = matchSegments(segments, pathParts, si + 1, pi + 1);
|
|
163
|
+
return rest && withHead(rest, SEGMENT_PRECEDENCE.static);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (segment.kind === "dynamic") {
|
|
167
|
+
if (part === undefined) return null;
|
|
168
|
+
const decoded = decodePart(part);
|
|
169
|
+
if (decoded === null) return null;
|
|
170
|
+
const rest = matchSegments(segments, pathParts, si + 1, pi + 1);
|
|
171
|
+
return (
|
|
172
|
+
rest &&
|
|
173
|
+
withHead(rest, SEGMENT_PRECEDENCE.dynamic, [segment.name, decoded])
|
|
174
|
+
);
|
|
175
|
+
}
|
|
165
176
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
+
// optional: consume first, then fall back to skipping.
|
|
178
|
+
if (part !== undefined) {
|
|
179
|
+
const decoded = decodePart(part);
|
|
180
|
+
if (decoded !== null) {
|
|
181
|
+
const rest = matchSegments(segments, pathParts, si + 1, pi + 1);
|
|
182
|
+
if (rest)
|
|
183
|
+
return withHead(rest, SEGMENT_PRECEDENCE.optional, [
|
|
184
|
+
segment.name,
|
|
185
|
+
decoded,
|
|
186
|
+
]);
|
|
177
187
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
188
|
+
}
|
|
189
|
+
const skipped = matchSegments(segments, pathParts, si + 1, pi);
|
|
190
|
+
return skipped && withHead(skipped, 0);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function compilePattern(pattern: string): CompiledPattern {
|
|
194
|
+
const { segments, precedence, normalized } = parsePattern(pattern);
|
|
181
195
|
|
|
182
196
|
const match = (pathname: string): MatchResult | null => {
|
|
183
|
-
|
|
184
|
-
return matchFrom(0, 0);
|
|
197
|
+
return matchSegments(segments, splitPath(pathname));
|
|
185
198
|
};
|
|
186
199
|
|
|
187
200
|
return { pattern, segments, precedence, normalized, match };
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { batch, createSignal, type Signal } from "@tschk/moonshine";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The location the signal carries: pathname plus query.
|
|
5
|
+
*
|
|
6
|
+
* The query has to be in the signal, not read off `window.location` at render
|
|
7
|
+
* time. A navigation that changes only the query would otherwise write the
|
|
8
|
+
* same pathname back, notify no subscriber, and leave every `useSearchParams`
|
|
9
|
+
* caller rendering the previous query — a tab bar driven by `?section=` would
|
|
10
|
+
* change the URL and not the page. The hash stays out: it is still pushed to
|
|
11
|
+
* history and retained in its own fragment signal; including it here would
|
|
12
|
+
* corrupt the query when consumers split on the first "?".
|
|
13
|
+
*/
|
|
14
|
+
export function getBrowserPath(): string {
|
|
15
|
+
if (typeof window === "undefined") return "/";
|
|
16
|
+
return (window.location.pathname || "/") + window.location.search;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Splits `"/a/b?x=1"` into its pathname and its raw query string. */
|
|
20
|
+
export function splitLocation(location: string): [string, string] {
|
|
21
|
+
const index = location.indexOf("?");
|
|
22
|
+
return index === -1
|
|
23
|
+
? [location, ""]
|
|
24
|
+
: [location.slice(0, index), location.slice(index + 1)];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type HistorySnapshot = {
|
|
28
|
+
action: "POP" | "PUSH" | "REPLACE";
|
|
29
|
+
state: unknown;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type MoonshineRouterInstance = {
|
|
33
|
+
/** Navigate via History API (no full reload). */
|
|
34
|
+
navigate: (
|
|
35
|
+
to: string,
|
|
36
|
+
options?: { replace?: boolean; state?: unknown },
|
|
37
|
+
) => void;
|
|
38
|
+
/** Current pathname and query (reactive signal). */
|
|
39
|
+
getLocation: () => string;
|
|
40
|
+
/** Subscribe-friendly location signal, carrying pathname and query. */
|
|
41
|
+
location: Signal<string>;
|
|
42
|
+
/** Reactive history metadata, including same-path state-only navigation. */
|
|
43
|
+
history: Signal<HistorySnapshot>;
|
|
44
|
+
/** Current fragment including #, independently owned from path/query. */
|
|
45
|
+
fragment: Signal<string>;
|
|
46
|
+
/** Sync signals from browser location and history (a POP navigation). */
|
|
47
|
+
syncFromBrowser: () => void;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Create an isolated router runtime (no shared global path). */
|
|
51
|
+
export function createMoonshineRouter(
|
|
52
|
+
initialPath = getBrowserPath() +
|
|
53
|
+
(typeof window === "undefined" ? "" : window.location.hash),
|
|
54
|
+
): MoonshineRouterInstance {
|
|
55
|
+
const origin =
|
|
56
|
+
typeof window === "undefined"
|
|
57
|
+
? "http://moonshine.invalid"
|
|
58
|
+
: window.location.origin;
|
|
59
|
+
const initial = new URL(initialPath, origin);
|
|
60
|
+
const location = createSignal(initial.pathname + initial.search);
|
|
61
|
+
const fragment = createSignal(initial.hash);
|
|
62
|
+
const history = createSignal<{
|
|
63
|
+
action: "POP" | "PUSH" | "REPLACE";
|
|
64
|
+
state: unknown;
|
|
65
|
+
}>({
|
|
66
|
+
action: "POP",
|
|
67
|
+
state: typeof window === "undefined" ? null : window.history.state,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const syncFromBrowser = () => {
|
|
71
|
+
batch(() => {
|
|
72
|
+
history.set({
|
|
73
|
+
action: "POP",
|
|
74
|
+
state: typeof window === "undefined" ? null : window.history.state,
|
|
75
|
+
});
|
|
76
|
+
location.set(getBrowserPath());
|
|
77
|
+
fragment.set(typeof window === "undefined" ? "" : window.location.hash);
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const navigate = (
|
|
82
|
+
to: string,
|
|
83
|
+
options?: { replace?: boolean; state?: unknown },
|
|
84
|
+
) => {
|
|
85
|
+
const url = new URL(to, new URL(location.peek(), origin));
|
|
86
|
+
const nextLocation = url.pathname + url.search;
|
|
87
|
+
const state = options?.state ?? null;
|
|
88
|
+
if (typeof window !== "undefined") {
|
|
89
|
+
const href = url.pathname + url.search + url.hash;
|
|
90
|
+
if (options?.replace) window.history.replaceState(state, "", href);
|
|
91
|
+
else window.history.pushState(state, "", href);
|
|
92
|
+
}
|
|
93
|
+
batch(() => {
|
|
94
|
+
history.set({ action: options?.replace ? "REPLACE" : "PUSH", state });
|
|
95
|
+
location.set(nextLocation);
|
|
96
|
+
fragment.set(url.hash);
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
navigate,
|
|
102
|
+
getLocation: () => location(),
|
|
103
|
+
location,
|
|
104
|
+
history,
|
|
105
|
+
fragment,
|
|
106
|
+
syncFromBrowser,
|
|
107
|
+
};
|
|
108
|
+
}
|
package/test/dom.ts
DELETED
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
|
2
|
-
import { act } from "react";
|
|
3
|
-
import { createRoot } from "react-dom/client";
|
|
4
|
-
import type { ReactNode } from "react";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* `bun test` shares one process across files, so registering happy-dom leaks
|
|
8
|
-
* its browser implementations into every other suite: its `Request` strips
|
|
9
|
-
* forbidden headers like `Cookie`, and its streams are not the ones SSR
|
|
10
|
-
* renderers write to. Keep the DOM, put Bun's platform globals back.
|
|
11
|
-
*/
|
|
12
|
-
const keepNative = [
|
|
13
|
-
"Request",
|
|
14
|
-
"Response",
|
|
15
|
-
"Headers",
|
|
16
|
-
"fetch",
|
|
17
|
-
"ReadableStream",
|
|
18
|
-
"WritableStream",
|
|
19
|
-
"TransformStream",
|
|
20
|
-
"TextEncoder",
|
|
21
|
-
"TextDecoder",
|
|
22
|
-
"AbortController",
|
|
23
|
-
"AbortSignal",
|
|
24
|
-
"Blob",
|
|
25
|
-
"FormData",
|
|
26
|
-
"URL",
|
|
27
|
-
"URLSearchParams",
|
|
28
|
-
"setTimeout",
|
|
29
|
-
"clearTimeout",
|
|
30
|
-
"setInterval",
|
|
31
|
-
"clearInterval",
|
|
32
|
-
"queueMicrotask",
|
|
33
|
-
] as const;
|
|
34
|
-
|
|
35
|
-
if (!("happyDOM" in globalThis)) {
|
|
36
|
-
const scope = globalThis as unknown as Record<string, unknown>;
|
|
37
|
-
const native = Object.fromEntries(keepNative.map((key) => [key, scope[key]]));
|
|
38
|
-
GlobalRegistrator.register({ url: "https://example.com/start" });
|
|
39
|
-
Object.assign(scope, native);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
(
|
|
43
|
-
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
44
|
-
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
45
|
-
|
|
46
|
-
const mounted: { unmount(): void }[] = [];
|
|
47
|
-
|
|
48
|
-
/** Mounts `element` into a fresh detached container and flushes effects. */
|
|
49
|
-
export async function render(element: ReactNode): Promise<HTMLElement> {
|
|
50
|
-
const container = document.createElement("div");
|
|
51
|
-
document.body.append(container);
|
|
52
|
-
const root = createRoot(container);
|
|
53
|
-
mounted.push(root);
|
|
54
|
-
await act(async () => {
|
|
55
|
-
root.render(element);
|
|
56
|
-
});
|
|
57
|
-
return container;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Unmounts every root from the current test. Without this, trees left
|
|
62
|
-
* subscribed to the router signal re-render on the next test's navigation.
|
|
63
|
-
*/
|
|
64
|
-
export async function cleanup(): Promise<void> {
|
|
65
|
-
await act(async () => {
|
|
66
|
-
for (const root of mounted.splice(0)) root.unmount();
|
|
67
|
-
});
|
|
68
|
-
document.body.replaceChildren();
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Dispatches a real bubbling click so React's root listener runs, and reports
|
|
73
|
-
* whether the handler claimed the event.
|
|
74
|
-
*
|
|
75
|
-
* A document-level guard records `defaultPrevented` and then cancels the event
|
|
76
|
-
* regardless, so happy-dom does not follow un-intercepted anchors and leave the
|
|
77
|
-
* document on a URL later tests cannot navigate from.
|
|
78
|
-
*/
|
|
79
|
-
export async function click(
|
|
80
|
-
node: Element,
|
|
81
|
-
init: MouseEventInit = {},
|
|
82
|
-
): Promise<{ defaultPrevented: boolean }> {
|
|
83
|
-
let defaultPrevented = false;
|
|
84
|
-
const guard = (event: Event): void => {
|
|
85
|
-
defaultPrevented = event.defaultPrevented;
|
|
86
|
-
event.preventDefault();
|
|
87
|
-
};
|
|
88
|
-
document.addEventListener("click", guard);
|
|
89
|
-
try {
|
|
90
|
-
await act(async () => {
|
|
91
|
-
node.dispatchEvent(
|
|
92
|
-
new MouseEvent("click", {
|
|
93
|
-
bubbles: true,
|
|
94
|
-
cancelable: true,
|
|
95
|
-
button: 0,
|
|
96
|
-
...init,
|
|
97
|
-
}),
|
|
98
|
-
);
|
|
99
|
-
});
|
|
100
|
-
} finally {
|
|
101
|
-
document.removeEventListener("click", guard);
|
|
102
|
-
}
|
|
103
|
-
return { defaultPrevented };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Resets the document location between navigation assertions. */
|
|
107
|
-
export function setLocation(url: string): void {
|
|
108
|
-
window.history.replaceState({}, "", new URL(url, "https://example.com").href);
|
|
109
|
-
}
|
package/test/graph.test.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
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
|
-
});
|
package/test/link.test.tsx
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { createElement } from "react";
|
|
3
|
-
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
-
import { isExternal, Link, safeHref } from "../src/navigation";
|
|
5
|
-
import { click, render, setLocation, cleanup } from "./dom";
|
|
6
|
-
|
|
7
|
-
function hrefOf(href: string): string {
|
|
8
|
-
const html = renderToStaticMarkup(createElement(Link, { href }, "go"));
|
|
9
|
-
return /href="([^"]*)"/.exec(html)?.[1] ?? "";
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
describe("safeHref", () => {
|
|
13
|
-
test("neutralizes script-bearing schemes", () => {
|
|
14
|
-
expect(safeHref("javascript:alert(1)")).toBe("#");
|
|
15
|
-
expect(safeHref("JavaScript:alert(1)")).toBe("#");
|
|
16
|
-
expect(safeHref(" javascript:alert(1)")).toBe("#");
|
|
17
|
-
expect(safeHref("java\tscript:alert(1)")).toBe("#");
|
|
18
|
-
expect(safeHref("data:text/html,<script>alert(1)</script>")).toBe("#");
|
|
19
|
-
expect(safeHref("vbscript:msgbox(1)")).toBe("#");
|
|
20
|
-
expect(safeHref("")).toBe("#");
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test("preserves relative and known-safe absolute targets", () => {
|
|
24
|
-
expect(safeHref("/users/1")).toBe("/users/1");
|
|
25
|
-
expect(safeHref("users/1")).toBe("users/1");
|
|
26
|
-
expect(safeHref("?q=1")).toBe("?q=1");
|
|
27
|
-
expect(safeHref("#anchor")).toBe("#anchor");
|
|
28
|
-
expect(safeHref("//example.com/x")).toBe("//example.com/x");
|
|
29
|
-
expect(safeHref("https://example.com/x")).toBe("https://example.com/x");
|
|
30
|
-
expect(safeHref("mailto:a@b.c")).toBe("mailto:a@b.c");
|
|
31
|
-
});
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
describe("Link", () => {
|
|
35
|
-
test("does not render a script-bearing href", () => {
|
|
36
|
-
expect(hrefOf("javascript:alert(1)")).toBe("#");
|
|
37
|
-
expect(hrefOf("/ok")).toBe("/ok");
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe("isExternal", () => {
|
|
42
|
-
test("a scheme, a protocol-relative host, a fragment or a target is the browser's", () => {
|
|
43
|
-
expect(isExternal("https://github.com/me")).toBe(true);
|
|
44
|
-
expect(isExternal("mailto:me@example.com")).toBe(true);
|
|
45
|
-
expect(isExternal("tel:+15551234")).toBe(true);
|
|
46
|
-
expect(isExternal("//cdn.example.com/x")).toBe(true);
|
|
47
|
-
expect(isExternal("#section")).toBe(true);
|
|
48
|
-
expect(isExternal("/about", "_blank")).toBe(true);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("a same-document path is the router's", () => {
|
|
52
|
-
expect(isExternal("/about")).toBe(false);
|
|
53
|
-
expect(isExternal("/about", "_self")).toBe(false);
|
|
54
|
-
expect(isExternal("/a?b=1")).toBe(false);
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe("Link and external targets", () => {
|
|
59
|
-
// The regression: safeHref *allows* https/mailto/tel — it only strips schemes
|
|
60
|
-
// that could execute — and Link took that as permission to intercept them.
|
|
61
|
-
// preventDefault ran, navigate rebuilt the URL against the current origin, and
|
|
62
|
-
// `https://github.com/me` became a push to `/me` on this app. The href looked
|
|
63
|
-
// correct the whole time, so it only surfaced as a 404 on click.
|
|
64
|
-
afterEach(cleanup);
|
|
65
|
-
beforeEach(() => setLocation("/start"));
|
|
66
|
-
|
|
67
|
-
async function clicked(href: string, target?: string) {
|
|
68
|
-
const container = await render(createElement(Link, { href, target }, "go"));
|
|
69
|
-
const node = container.querySelector("a");
|
|
70
|
-
if (!node) throw new Error("Link did not render an anchor");
|
|
71
|
-
return click(node);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
test("leaves an external URL to the browser", async () => {
|
|
75
|
-
expect((await clicked("https://github.com/me")).defaultPrevented).toBe(
|
|
76
|
-
false,
|
|
77
|
-
);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("leaves mailto and tel to the browser", async () => {
|
|
81
|
-
expect((await clicked("mailto:me@example.com")).defaultPrevented).toBe(
|
|
82
|
-
false,
|
|
83
|
-
);
|
|
84
|
-
expect((await clicked("tel:+15551234")).defaultPrevented).toBe(false);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("leaves an explicit target to the browser", async () => {
|
|
88
|
-
expect((await clicked("/about", "_blank")).defaultPrevented).toBe(false);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("still intercepts a same-document path", async () => {
|
|
92
|
-
expect((await clicked("/about")).defaultPrevented).toBe(true);
|
|
93
|
-
});
|
|
94
|
-
});
|
package/test/navigation.test.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { splitLocation, safeHref, isExternal } from "../src/navigation";
|
|
3
|
-
|
|
4
|
-
describe("splitLocation", () => {
|
|
5
|
-
test("splits path and query", () => {
|
|
6
|
-
expect(splitLocation("/path?query=1")).toEqual(["/path", "query=1"]);
|
|
7
|
-
});
|
|
8
|
-
test("handles no query string", () => {
|
|
9
|
-
expect(splitLocation("/path")).toEqual(["/path", ""]);
|
|
10
|
-
});
|
|
11
|
-
test("handles empty string", () => {
|
|
12
|
-
expect(splitLocation("")).toEqual(["", ""]);
|
|
13
|
-
});
|
|
14
|
-
test("handles query string only", () => {
|
|
15
|
-
expect(splitLocation("?query=1")).toEqual(["", "query=1"]);
|
|
16
|
-
});
|
|
17
|
-
test("handles multiple question marks by splitting on the first", () => {
|
|
18
|
-
expect(splitLocation("/path?query=1?other=2")).toEqual([
|
|
19
|
-
"/path",
|
|
20
|
-
"query=1?other=2",
|
|
21
|
-
]);
|
|
22
|
-
});
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
describe("safeHref", () => {
|
|
26
|
-
test("allows safe schemes", () => {
|
|
27
|
-
expect(safeHref("http://example.com")).toBe("http://example.com");
|
|
28
|
-
expect(safeHref("https://example.com")).toBe("https://example.com");
|
|
29
|
-
expect(safeHref("mailto:user@example.com")).toBe("mailto:user@example.com");
|
|
30
|
-
expect(safeHref("tel:+1234567890")).toBe("tel:+1234567890");
|
|
31
|
-
expect(safeHref("ftp://example.com")).toBe("ftp://example.com");
|
|
32
|
-
});
|
|
33
|
-
test("allows relative paths", () => {
|
|
34
|
-
expect(safeHref("/path")).toBe("/path");
|
|
35
|
-
expect(safeHref("?query=1")).toBe("?query=1");
|
|
36
|
-
expect(safeHref("#hash")).toBe("#hash");
|
|
37
|
-
expect(safeHref("path")).toBe("path");
|
|
38
|
-
});
|
|
39
|
-
test("rewrites unsafe schemes to #", () => {
|
|
40
|
-
expect(safeHref("javascript:alert(1)")).toBe("#");
|
|
41
|
-
expect(safeHref("vbscript:msgbox(1)")).toBe("#");
|
|
42
|
-
expect(safeHref("data:text/html,<h1>hello</h1>")).toBe("#");
|
|
43
|
-
});
|
|
44
|
-
test("strips control characters to prevent evasion", () => {
|
|
45
|
-
expect(safeHref("java\x09script:alert(1)")).toBe("#");
|
|
46
|
-
expect(safeHref("java\nscript:alert(1)")).toBe("#");
|
|
47
|
-
});
|
|
48
|
-
test("handles empty string and spaces", () => {
|
|
49
|
-
expect(safeHref("")).toBe("#");
|
|
50
|
-
expect(safeHref(" ")).toBe("#");
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
describe("isExternal", () => {
|
|
55
|
-
test("identifies scheme-bearing URLs as external", () => {
|
|
56
|
-
expect(isExternal("http://example.com")).toBe(true);
|
|
57
|
-
expect(isExternal("https://example.com")).toBe(true);
|
|
58
|
-
expect(isExternal("mailto:test@test.com")).toBe(true);
|
|
59
|
-
});
|
|
60
|
-
test("identifies protocol-relative URLs as external", () => {
|
|
61
|
-
expect(isExternal("//example.com")).toBe(true);
|
|
62
|
-
});
|
|
63
|
-
test("identifies hash-only URLs as external (browser handles them)", () => {
|
|
64
|
-
expect(isExternal("#section")).toBe(true);
|
|
65
|
-
});
|
|
66
|
-
test("identifies target other than _self as external", () => {
|
|
67
|
-
expect(isExternal("/path", "_blank")).toBe(true);
|
|
68
|
-
expect(isExternal("/path", "_parent")).toBe(true);
|
|
69
|
-
});
|
|
70
|
-
test("identifies same-document relative paths as internal", () => {
|
|
71
|
-
expect(isExternal("/path")).toBe(false);
|
|
72
|
-
expect(isExternal("path")).toBe(false);
|
|
73
|
-
expect(isExternal("/path?query=1")).toBe(false);
|
|
74
|
-
expect(isExternal("/path", "_self")).toBe(false);
|
|
75
|
-
});
|
|
76
|
-
});
|
package/test/pattern.test.ts
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
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
|
-
});
|
|
59
|
-
|
|
60
|
-
describe("optional segments backtrack", () => {
|
|
61
|
-
// The regression: the matcher consumed a part whenever one was present and
|
|
62
|
-
// never gave it back, so `/:lang?/about` matched `/en/about` and then failed
|
|
63
|
-
// `/about` — an i18n prefix route 404ing on its own default locale. A failed
|
|
64
|
-
// match is indistinguishable from no route, so it surfaced as a bare 404.
|
|
65
|
-
test("matches with the optional segment absent", () => {
|
|
66
|
-
expect(matchPath("/:lang?/about", "/about")?.params).toEqual({});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("still binds the optional segment when it is present", () => {
|
|
70
|
-
expect(matchPath("/:lang?/about", "/en/about")?.params).toEqual({
|
|
71
|
-
lang: "en",
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("does not match when the rest of the pattern cannot", () => {
|
|
76
|
-
expect(matchPath("/:lang?/about", "/en/contact")).toBeNull();
|
|
77
|
-
expect(matchPath("/:lang?/about", "/a/b/about")).toBeNull();
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("handles two optionals before a static tail", () => {
|
|
81
|
-
expect(matchPath("/:a?/:b?/end", "/end")?.params).toEqual({});
|
|
82
|
-
expect(matchPath("/:a?/:b?/end", "/x/end")?.params).toEqual({ a: "x" });
|
|
83
|
-
expect(matchPath("/:a?/:b?/end", "/x/y/end")?.params).toEqual({
|
|
84
|
-
a: "x",
|
|
85
|
-
b: "y",
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe("rest segments decode per part", () => {
|
|
91
|
-
// Decoding the joined string let an encoded separator smuggle structure past
|
|
92
|
-
// the router: a handler doing `join(dir, params.path)` on the result read
|
|
93
|
-
// outside its directory, and `a%2Fb` was indistinguishable from `a/b`.
|
|
94
|
-
test("rejects an encoded traversal", () => {
|
|
95
|
-
expect(matchPath("/files/*path", "/files/%2e%2e%2fsecret")).toBeNull();
|
|
96
|
-
expect(matchPath("/files/*path", "/files/..%2Fsecret")).toBeNull();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("rejects an encoded separator", () => {
|
|
100
|
-
expect(matchPath("/files/*path", "/files/a%2Fb")).toBeNull();
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("still captures an ordinary nested path", () => {
|
|
104
|
-
expect(matchPath("/files/*path", "/files/a/b/c.txt")?.params).toEqual({
|
|
105
|
-
path: "a/b/c.txt",
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test("still decodes ordinary escapes within a part", () => {
|
|
110
|
-
expect(matchPath("/files/*path", "/files/my%20file.txt")?.params).toEqual({
|
|
111
|
-
path: "my file.txt",
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
});
|
package/tsconfig.json
DELETED