@tschk/moonshine-router 0.3.2 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tschk/moonshine-router",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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.2",
20
- "@tschk/moonshine-framework": "^0.3.2"
19
+ "@tschk/moonshine": "^0.3.4",
20
+ "@tschk/moonshine-framework": "^0.3.4"
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 matches: {
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
- matches.push({
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 (matches.length === 0) return null;
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
@@ -20,6 +20,7 @@ export {
20
20
  useRouter,
21
21
  MoonshineRouter,
22
22
  Link,
23
+ safeHref,
23
24
  } from "./navigation.js";
24
25
  export type {
25
26
  MoonshineRouterInstance,
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
- const graph = createRouteGraph<InternalRoute>(
166
- props.routes.map((route) => ({
167
- ...route,
168
- id: route.id ?? route.path,
169
- file: route.file ?? "",
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: props.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(props.href, { replace: props.replace });
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
+ });