@tschk/moonshine-router 0.3.5 → 0.4.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/package.json +3 -3
- package/src/index.ts +1 -0
- package/src/navigation.ts +60 -15
- package/src/pattern.ts +100 -51
- package/test/dom.ts +109 -0
- package/test/link.test.tsx +58 -2
- package/test/navigation.test.ts +76 -0
- package/test/pattern.test.ts +56 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tschk/moonshine-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Renderer-neutral route graph and matcher with optional React navigation",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"type": "module",
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
"test": "bun test"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@tschk/moonshine": "^0.3.
|
|
20
|
-
"@tschk/moonshine-framework": "^0.3.
|
|
19
|
+
"@tschk/moonshine": "^0.3.6",
|
|
20
|
+
"@tschk/moonshine-framework": "^0.3.6"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/bun": "1.3.14",
|
package/src/index.ts
CHANGED
package/src/navigation.ts
CHANGED
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
useEffect,
|
|
7
7
|
useMemo,
|
|
8
8
|
useRef,
|
|
9
|
-
useState,
|
|
10
9
|
useSyncExternalStore,
|
|
11
10
|
type ReactNode,
|
|
12
11
|
} from "react";
|
|
@@ -15,17 +14,36 @@ import { createRouteGraph, matchRoutes, type RouteMatch } from "./graph.js";
|
|
|
15
14
|
|
|
16
15
|
export type RouteParams = Record<string, string>;
|
|
17
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The location the signal carries: pathname plus query.
|
|
19
|
+
*
|
|
20
|
+
* The query has to be in the signal, not read off `window.location` at render
|
|
21
|
+
* time. A navigation that changes only the query would otherwise write the
|
|
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
|
+
*/
|
|
18
28
|
function getBrowserPath(): string {
|
|
19
29
|
if (typeof window === "undefined") return "/";
|
|
20
|
-
return window.location.pathname || "/";
|
|
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)];
|
|
21
39
|
}
|
|
22
40
|
|
|
23
41
|
export type MoonshineRouterInstance = {
|
|
24
42
|
/** Navigate via History API (no full reload). */
|
|
25
43
|
navigate: (to: string, options?: { replace?: boolean }) => void;
|
|
26
|
-
/** Current pathname (reactive signal). */
|
|
44
|
+
/** Current pathname and query (reactive signal). */
|
|
27
45
|
getLocation: () => string;
|
|
28
|
-
/** Subscribe-friendly location signal. */
|
|
46
|
+
/** Subscribe-friendly location signal, carrying pathname and query. */
|
|
29
47
|
location: Signal<string>;
|
|
30
48
|
/** Sync signal from `window.location`. */
|
|
31
49
|
syncFromBrowser: () => void;
|
|
@@ -52,7 +70,7 @@ export function createMoonshineRouter(
|
|
|
52
70
|
} else {
|
|
53
71
|
window.history.pushState({}, "", url.pathname + url.search + url.hash);
|
|
54
72
|
}
|
|
55
|
-
location.set(url.pathname);
|
|
73
|
+
location.set(url.pathname + url.search);
|
|
56
74
|
};
|
|
57
75
|
|
|
58
76
|
return {
|
|
@@ -77,7 +95,7 @@ export function navigate(to: string, options?: { replace?: boolean }): void {
|
|
|
77
95
|
currentRouter().navigate(to, options);
|
|
78
96
|
}
|
|
79
97
|
|
|
80
|
-
/** Current pathname (reactive; active or fallback router). */
|
|
98
|
+
/** Current pathname and query (reactive; active or fallback router). */
|
|
81
99
|
export function getLocation(): string {
|
|
82
100
|
return currentRouter().getLocation();
|
|
83
101
|
}
|
|
@@ -158,10 +176,9 @@ export function MoonshineRouter(props: MoonshineRouterProps): ReactNode {
|
|
|
158
176
|
() => runtime.location.peek(),
|
|
159
177
|
() => props.path ?? "/",
|
|
160
178
|
);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
);
|
|
179
|
+
// Routes are matched on the pathname alone; the query rides along in the
|
|
180
|
+
// signal only so that changing it re-renders.
|
|
181
|
+
const pathname = splitLocation(props.path ?? browserPath)[0];
|
|
165
182
|
|
|
166
183
|
// Compiling every pattern is a sizeable share of this component's render, and
|
|
167
184
|
// the result depends only on the route list.
|
|
@@ -182,7 +199,6 @@ export function MoonshineRouter(props: MoonshineRouterProps): ReactNode {
|
|
|
182
199
|
activeRouter = runtime;
|
|
183
200
|
if (props.path === undefined) {
|
|
184
201
|
runtime.syncFromBrowser();
|
|
185
|
-
setReady(true);
|
|
186
202
|
const onPop = () => runtime.syncFromBrowser();
|
|
187
203
|
window.addEventListener("popstate", onPop);
|
|
188
204
|
return () => {
|
|
@@ -203,10 +219,6 @@ export function MoonshineRouter(props: MoonshineRouterProps): ReactNode {
|
|
|
203
219
|
runtime,
|
|
204
220
|
};
|
|
205
221
|
|
|
206
|
-
if (!ready && props.path === undefined && typeof window !== "undefined") {
|
|
207
|
-
// Avoid hydration flicker on first client paint before popstate sync
|
|
208
|
-
}
|
|
209
|
-
|
|
210
222
|
return createElement(
|
|
211
223
|
RouterContext.Provider,
|
|
212
224
|
{ value },
|
|
@@ -244,21 +256,54 @@ export function safeHref(href: string): string {
|
|
|
244
256
|
}
|
|
245
257
|
|
|
246
258
|
/** Anchor that uses client-side navigation. */
|
|
259
|
+
/**
|
|
260
|
+
* Whether the browser should keep its own handling of `href`.
|
|
261
|
+
*
|
|
262
|
+
* A client router may only intercept a same-document navigation. Anything
|
|
263
|
+
* carrying a scheme (`https:`, `mailto:`, `tel:`), a protocol-relative `//host`,
|
|
264
|
+
* a bare fragment, or an explicit non-`_self` target belongs to the browser: it
|
|
265
|
+
* opens a mail client, a new tab, or another origin, none of which this router
|
|
266
|
+
* can do.
|
|
267
|
+
*
|
|
268
|
+
* `safeHref` deliberately allows those schemes through — it only strips ones
|
|
269
|
+
* that could execute. Allowing a scheme is not the same as claiming to handle
|
|
270
|
+
* it, and conflating the two is how `<Link href="https://github.com/me">` came
|
|
271
|
+
* to push `/me` onto the app's own origin: preventDefault ran, `navigate`
|
|
272
|
+
* rebuilt the URL against `window.location.origin`, and the user landed on a
|
|
273
|
+
* 404 instead of GitHub.
|
|
274
|
+
*
|
|
275
|
+
* Exported so the host adapters share one definition rather than each carrying
|
|
276
|
+
* a copy — three of them already did, byte for byte, and the copy this module
|
|
277
|
+
* lacked is exactly the one that was wrong.
|
|
278
|
+
*/
|
|
279
|
+
export function isExternal(href: string, target?: string): boolean {
|
|
280
|
+
if (target && target !== "_self") return true;
|
|
281
|
+
return (
|
|
282
|
+
/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href) ||
|
|
283
|
+
href.startsWith("//") ||
|
|
284
|
+
href.startsWith("#")
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
247
288
|
export function Link(props: {
|
|
248
289
|
href: string;
|
|
249
290
|
children?: ReactNode;
|
|
250
291
|
className?: string;
|
|
251
292
|
replace?: boolean;
|
|
293
|
+
target?: string;
|
|
252
294
|
}): ReactNode {
|
|
253
295
|
const nav = useNavigate();
|
|
254
296
|
const href = safeHref(props.href);
|
|
297
|
+
const external = isExternal(href, props.target);
|
|
255
298
|
return createElement(
|
|
256
299
|
"a",
|
|
257
300
|
{
|
|
258
301
|
href,
|
|
259
302
|
className: props.className,
|
|
303
|
+
target: props.target,
|
|
260
304
|
onClick: (event: MouseEvent) => {
|
|
261
305
|
if (
|
|
306
|
+
external ||
|
|
262
307
|
event.defaultPrevented ||
|
|
263
308
|
event.button !== 0 ||
|
|
264
309
|
event.metaKey ||
|
package/src/pattern.ts
CHANGED
|
@@ -75,64 +75,113 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
75
75
|
normalized = "/";
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
78
|
+
let pathParts: string[] = [];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Decodes the tail a `*rest` segment captures.
|
|
82
|
+
*
|
|
83
|
+
* Per part, not on the joined string. Decoding after the join lets an encoded
|
|
84
|
+
* separator smuggle structure past the router: `/files/%2e%2e%2fsecret` used
|
|
85
|
+
* to arrive as `path: "../secret"`, and a handler doing the obvious
|
|
86
|
+
* `join(dir, params.path)` then read outside its directory. `a%2Fb` and `a/b`
|
|
87
|
+
* were likewise indistinguishable. Same rules the static server already
|
|
88
|
+
* applies in `resolveStaticPath`, so the two agree about what a path segment
|
|
89
|
+
* may contain.
|
|
90
|
+
*/
|
|
91
|
+
const decodeRest = (parts: readonly string[]): string | null => {
|
|
92
|
+
const decoded: string[] = [];
|
|
93
|
+
for (const part of parts) {
|
|
94
|
+
const d = decodePart(part);
|
|
95
|
+
if (
|
|
96
|
+
d === null ||
|
|
97
|
+
d === "." ||
|
|
98
|
+
d === ".." ||
|
|
99
|
+
d.includes("\0") ||
|
|
100
|
+
d.includes("/")
|
|
101
|
+
) {
|
|
102
|
+
return null;
|
|
95
103
|
}
|
|
104
|
+
decoded.push(d);
|
|
105
|
+
}
|
|
106
|
+
return decoded.join("/");
|
|
107
|
+
};
|
|
96
108
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Matches `segments` from `si` against `pathParts` from `pi`.
|
|
111
|
+
*
|
|
112
|
+
* Recursive because an optional segment has to be able to give its part back.
|
|
113
|
+
* The old linear pass consumed a part whenever one was present, so
|
|
114
|
+
* `/:lang?/about` matched `/en/about` and then failed `/about` — the i18n
|
|
115
|
+
* prefix route 404ing on its own default locale, silently, because a failed
|
|
116
|
+
* match is indistinguishable from no route. Consuming is tried first so the
|
|
117
|
+
* greedy reading still wins where both work.
|
|
118
|
+
*/
|
|
119
|
+
const matchFrom = (si: number, pi: number): MatchResult | null => {
|
|
120
|
+
if (si === segments.length) {
|
|
121
|
+
return pi === pathParts.length ? { params: {}, score: [] } : null;
|
|
122
|
+
}
|
|
107
123
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
124
|
+
const segment = segments[si];
|
|
125
|
+
const part = pathParts[pi];
|
|
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
|
+
}
|
|
118
146
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
147
|
+
if (segment.kind === "static") {
|
|
148
|
+
if (part === undefined) return null;
|
|
149
|
+
const decoded = decodePart(part);
|
|
150
|
+
if (decoded === null || decoded !== segment.value) return null;
|
|
151
|
+
const rest = matchFrom(si + 1, pi + 1);
|
|
152
|
+
return rest && withHead(rest, SEGMENT_PRECEDENCE.static);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (segment.kind === "dynamic") {
|
|
156
|
+
if (part === undefined) return null;
|
|
157
|
+
const decoded = decodePart(part);
|
|
158
|
+
if (decoded === null) return null;
|
|
159
|
+
const rest = matchFrom(si + 1, pi + 1);
|
|
160
|
+
return (
|
|
161
|
+
rest &&
|
|
162
|
+
withHead(rest, SEGMENT_PRECEDENCE.dynamic, [segment.name, decoded])
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// optional: consume first, then fall back to skipping.
|
|
167
|
+
if (part !== undefined) {
|
|
168
|
+
const decoded = decodePart(part);
|
|
169
|
+
if (decoded !== null) {
|
|
170
|
+
const rest = matchFrom(si + 1, pi + 1);
|
|
171
|
+
if (rest)
|
|
172
|
+
return withHead(rest, SEGMENT_PRECEDENCE.optional, [
|
|
173
|
+
segment.name,
|
|
174
|
+
decoded,
|
|
175
|
+
]);
|
|
131
176
|
}
|
|
132
177
|
}
|
|
178
|
+
const skipped = matchFrom(si + 1, pi);
|
|
179
|
+
return skipped && withHead(skipped, 0);
|
|
180
|
+
};
|
|
133
181
|
|
|
134
|
-
|
|
135
|
-
|
|
182
|
+
const match = (pathname: string): MatchResult | null => {
|
|
183
|
+
pathParts = splitPath(pathname);
|
|
184
|
+
return matchFrom(0, 0);
|
|
136
185
|
};
|
|
137
186
|
|
|
138
187
|
return { pattern, segments, precedence, normalized, match };
|
package/test/dom.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
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/link.test.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { createElement } from "react";
|
|
3
3
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
-
import { Link, safeHref } from "../src/navigation";
|
|
4
|
+
import { isExternal, Link, safeHref } from "../src/navigation";
|
|
5
|
+
import { click, render, setLocation, cleanup } from "./dom";
|
|
5
6
|
|
|
6
7
|
function hrefOf(href: string): string {
|
|
7
8
|
const html = renderToStaticMarkup(createElement(Link, { href }, "go"));
|
|
@@ -36,3 +37,58 @@ describe("Link", () => {
|
|
|
36
37
|
expect(hrefOf("/ok")).toBe("/ok");
|
|
37
38
|
});
|
|
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
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
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
CHANGED
|
@@ -56,3 +56,59 @@ describe("matchPath", () => {
|
|
|
56
56
|
expect(matchPath("/a/b", "/a")).toBeNull();
|
|
57
57
|
});
|
|
58
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
|
+
});
|