@tschk/moonshine-router 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/graph.ts +26 -7
- package/src/index.ts +1 -0
- package/src/navigation.ts +45 -8
- package/test/link.test.tsx +38 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tschk/moonshine-router",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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.3",
|
|
20
|
+
"@tschk/moonshine-framework": "^0.3.3"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/bun": "1.3.14",
|
package/src/graph.ts
CHANGED
|
@@ -48,6 +48,21 @@ function compareMatches(
|
|
|
48
48
|
return 0;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* First path segment, or null when it is absent or percent-encoded. Callers use
|
|
53
|
+
* it to skip routes that begin with a different literal segment; decoding is
|
|
54
|
+
* the identity for segments without `%`, so the comparison is exact.
|
|
55
|
+
*/
|
|
56
|
+
function firstSegment(pathname: string): string | null {
|
|
57
|
+
let start = 0;
|
|
58
|
+
while (start < pathname.length && pathname.charCodeAt(start) === 47) start++;
|
|
59
|
+
if (start >= pathname.length) return null;
|
|
60
|
+
let end = pathname.indexOf("/", start);
|
|
61
|
+
if (end === -1) end = pathname.length;
|
|
62
|
+
const part = pathname.slice(start, end);
|
|
63
|
+
return part.includes("%") ? null : part;
|
|
64
|
+
}
|
|
65
|
+
|
|
51
66
|
export type RouteMatch<T = RouteDefinition> = {
|
|
52
67
|
route: T;
|
|
53
68
|
path: string;
|
|
@@ -59,27 +74,31 @@ export function matchRoutes<T extends RouteDefinition>(
|
|
|
59
74
|
graph: RouteGraph<T>,
|
|
60
75
|
pathname: string,
|
|
61
76
|
): RouteMatch<T> | null {
|
|
62
|
-
const
|
|
77
|
+
const first = firstSegment(pathname);
|
|
78
|
+
let best: {
|
|
63
79
|
route: T;
|
|
64
80
|
pattern: string;
|
|
65
81
|
params: Record<string, string>;
|
|
66
82
|
score: number[];
|
|
67
|
-
}
|
|
83
|
+
} | null = null;
|
|
68
84
|
|
|
69
85
|
for (const { route, compiled } of graph.routes) {
|
|
86
|
+
if (first !== null) {
|
|
87
|
+
const head = compiled.segments[0];
|
|
88
|
+
if (head && head.kind === "static" && head.value !== first) continue;
|
|
89
|
+
}
|
|
70
90
|
const m = compiled.match(pathname);
|
|
71
91
|
if (!m) continue;
|
|
72
|
-
|
|
92
|
+
const candidate = {
|
|
73
93
|
route,
|
|
74
94
|
pattern: compiled.pattern,
|
|
75
95
|
params: m.params,
|
|
76
96
|
score: m.score,
|
|
77
|
-
}
|
|
97
|
+
};
|
|
98
|
+
if (best === null || compareMatches(candidate, best) < 0) best = candidate;
|
|
78
99
|
}
|
|
79
100
|
|
|
80
|
-
if (
|
|
81
|
-
matches.sort(compareMatches);
|
|
82
|
-
const best = matches[0]!;
|
|
101
|
+
if (best === null) return null;
|
|
83
102
|
return {
|
|
84
103
|
route: best.route,
|
|
85
104
|
path: pathname,
|
package/src/index.ts
CHANGED
package/src/navigation.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
createElement,
|
|
5
5
|
useContext,
|
|
6
6
|
useEffect,
|
|
7
|
+
useMemo,
|
|
7
8
|
useRef,
|
|
8
9
|
useState,
|
|
9
10
|
useSyncExternalStore,
|
|
@@ -162,12 +163,18 @@ export function MoonshineRouter(props: MoonshineRouterProps): ReactNode {
|
|
|
162
163
|
typeof window === "undefined" || props.path !== undefined,
|
|
163
164
|
);
|
|
164
165
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
166
|
+
// Compiling every pattern is a sizeable share of this component's render, and
|
|
167
|
+
// the result depends only on the route list.
|
|
168
|
+
const graph = useMemo(
|
|
169
|
+
() =>
|
|
170
|
+
createRouteGraph<InternalRoute>(
|
|
171
|
+
props.routes.map((route) => ({
|
|
172
|
+
...route,
|
|
173
|
+
id: route.id ?? route.path,
|
|
174
|
+
file: route.file ?? "",
|
|
175
|
+
})),
|
|
176
|
+
),
|
|
177
|
+
[props.routes],
|
|
171
178
|
);
|
|
172
179
|
|
|
173
180
|
useEffect(() => {
|
|
@@ -207,6 +214,35 @@ export function MoonshineRouter(props: MoonshineRouterProps): ReactNode {
|
|
|
207
214
|
);
|
|
208
215
|
}
|
|
209
216
|
|
|
217
|
+
function stripControls(value: string): string {
|
|
218
|
+
let out = "";
|
|
219
|
+
for (const ch of value) {
|
|
220
|
+
const code = ch.charCodeAt(0);
|
|
221
|
+
if (code <= 0x1f || code === 0x7f) continue;
|
|
222
|
+
out += ch;
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const SAFE_SCHEMES = new Set(["http:", "https:", "mailto:", "tel:", "ftp:"]);
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* A scheme-bearing href reaches the browser on modifier/middle click, where
|
|
231
|
+
* the click handler deliberately does nothing, so `javascript:` and friends
|
|
232
|
+
* would execute. Allow relative targets and known-safe schemes only.
|
|
233
|
+
*/
|
|
234
|
+
export function safeHref(href: string): string {
|
|
235
|
+
// Browsers strip embedded control characters before parsing the scheme, so
|
|
236
|
+
// strip them here too or "java\tscript:" would slip past the check.
|
|
237
|
+
const trimmed = stripControls(href).trim();
|
|
238
|
+
if (trimmed === "") return "#";
|
|
239
|
+
// Leading "/", "?", "#" are unambiguously relative and cannot carry a scheme.
|
|
240
|
+
if (/^[/?#]/.test(trimmed)) return trimmed;
|
|
241
|
+
const scheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.exec(trimmed);
|
|
242
|
+
if (!scheme) return trimmed;
|
|
243
|
+
return SAFE_SCHEMES.has(scheme[0].toLowerCase()) ? trimmed : "#";
|
|
244
|
+
}
|
|
245
|
+
|
|
210
246
|
/** Anchor that uses client-side navigation. */
|
|
211
247
|
export function Link(props: {
|
|
212
248
|
href: string;
|
|
@@ -215,10 +251,11 @@ export function Link(props: {
|
|
|
215
251
|
replace?: boolean;
|
|
216
252
|
}): ReactNode {
|
|
217
253
|
const nav = useNavigate();
|
|
254
|
+
const href = safeHref(props.href);
|
|
218
255
|
return createElement(
|
|
219
256
|
"a",
|
|
220
257
|
{
|
|
221
|
-
href
|
|
258
|
+
href,
|
|
222
259
|
className: props.className,
|
|
223
260
|
onClick: (event: MouseEvent) => {
|
|
224
261
|
if (
|
|
@@ -232,7 +269,7 @@ export function Link(props: {
|
|
|
232
269
|
return;
|
|
233
270
|
}
|
|
234
271
|
event.preventDefault();
|
|
235
|
-
nav(
|
|
272
|
+
nav(href, { replace: props.replace });
|
|
236
273
|
},
|
|
237
274
|
},
|
|
238
275
|
props.children,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createElement } from "react";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
import { Link, safeHref } from "../src/navigation";
|
|
5
|
+
|
|
6
|
+
function hrefOf(href: string): string {
|
|
7
|
+
const html = renderToStaticMarkup(createElement(Link, { href }, "go"));
|
|
8
|
+
return /href="([^"]*)"/.exec(html)?.[1] ?? "";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("safeHref", () => {
|
|
12
|
+
test("neutralizes script-bearing schemes", () => {
|
|
13
|
+
expect(safeHref("javascript:alert(1)")).toBe("#");
|
|
14
|
+
expect(safeHref("JavaScript:alert(1)")).toBe("#");
|
|
15
|
+
expect(safeHref(" javascript:alert(1)")).toBe("#");
|
|
16
|
+
expect(safeHref("java\tscript:alert(1)")).toBe("#");
|
|
17
|
+
expect(safeHref("data:text/html,<script>alert(1)</script>")).toBe("#");
|
|
18
|
+
expect(safeHref("vbscript:msgbox(1)")).toBe("#");
|
|
19
|
+
expect(safeHref("")).toBe("#");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("preserves relative and known-safe absolute targets", () => {
|
|
23
|
+
expect(safeHref("/users/1")).toBe("/users/1");
|
|
24
|
+
expect(safeHref("users/1")).toBe("users/1");
|
|
25
|
+
expect(safeHref("?q=1")).toBe("?q=1");
|
|
26
|
+
expect(safeHref("#anchor")).toBe("#anchor");
|
|
27
|
+
expect(safeHref("//example.com/x")).toBe("//example.com/x");
|
|
28
|
+
expect(safeHref("https://example.com/x")).toBe("https://example.com/x");
|
|
29
|
+
expect(safeHref("mailto:a@b.c")).toBe("mailto:a@b.c");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("Link", () => {
|
|
34
|
+
test("does not render a script-bearing href", () => {
|
|
35
|
+
expect(hrefOf("javascript:alert(1)")).toBe("#");
|
|
36
|
+
expect(hrefOf("/ok")).toBe("/ok");
|
|
37
|
+
});
|
|
38
|
+
});
|