Skip to content

Commit b26b3db

Browse files
authored
feat!: url pattern compatibility (#178)
1 parent 3b23ad1 commit b26b3db

27 files changed

Lines changed: 4574 additions & 75 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
- run: corepack enable
1818
- uses: actions/setup-node@v4
1919
with:
20-
node-version: 20
20+
node-version: lts/*
2121
cache: pnpm
2222
- run: pnpm install
2323
- run: pnpm lint

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
test/wpt

AGENTS.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,17 @@ src/
1313
types.ts # TypeScript interfaces & param inference types
1414
context.ts # createRouter() factory
1515
object.ts # NullProtoObj (null-prototype object constructor)
16+
_escape.ts # URLPattern backslash escape handling (placeholder approach)
17+
_group-delimiters.ts# Non-capturing group ({...}) expansion helper
18+
_segment-wildcards.ts# Wildcard segment capture handling
1619
regexp.ts # routeToRegExp() utility
1720
compiler.ts # JIT/AOT compiler (generates optimized match functions)
1821
operations/
1922
add.ts # addRoute() - insert routes into the radix tree
2023
find.ts # findRoute() - single-match lookup
2124
find-all.ts # findAllRoutes() - multi-match lookup
2225
remove.ts # removeRoute() - remove routes from tree
23-
_utils.ts # Shared internal utilities
26+
_utils.ts # Shared utilities (escaping, path splitting, normalization)
2427
test/
2528
router.test.ts # Core router tests
2629
find.test.ts # Route matching tests (interpreter vs compiled)
@@ -80,6 +83,33 @@ interface Node<T> {
8083
- Inlines regex patterns for param validation
8184
- Compare interpreter vs compiled output in tests
8285

86+
### URLPattern group delimiters
87+
88+
- `src/_group-delimiters.ts` expands non-capturing group delimiters before route insertion/removal/regexp generation.
89+
- Supported forms: `{...}` and `{...}?` (plus single-segment `{...}+` / `{...}*` converted to `(?:...)+/*` regex fragments).
90+
- Limitation: `{...}+` / `{...}*` are rejected when group body contains `/` (cross-segment repetition unsupported in radix tree).
91+
92+
### URLPattern backslash escaping
93+
94+
Two separate escape systems handle `\x` in route patterns:
95+
96+
1. **Router escape encoding** (`_utils.ts`): `encodeEscapes()` converts `\:`, `\(`, `\)`, `\{`, `\}` to `\uFFFD` + single-char placeholders (A-E) before segment splitting, preventing these chars from being interpreted as route syntax. `decodeEscaped()` converts them back for static node keys. Other `\x` (like `\*`) are left for existing `segment === "\\*"` handling.
97+
98+
2. **Regex escape handling** (`_escape.ts`): `replaceEscapesOutsideGroups()` replaces `\x` outside `(...)` groups with `\uFFFE` placeholder, preserving regex syntax inside groups (e.g., `\d` in `(\d+)`). `resolveEscapePlaceholders()` then converts placeholders to regex-safe literals. Used by `routeToRegExp()` and `getParamRegexp()` in `add.ts`.
99+
100+
Key invariant: `\uFFFD` (U+FFFD) is used for router-level escaping, `\uFFFE` (U+FFFE) for regex-level escaping — they must not collide.
101+
102+
### Input path normalization
103+
104+
`normalizePath()` in `_utils.ts` resolves `.` and `..` segments in lookup paths (fast-path: skip if no `/.` found). Both `findRoute()` and `findAllRoutes()` normalize before matching. The compiler inlines equivalent logic in generated code.
105+
106+
### Wildcard segment captures
107+
108+
- **Breaking change:** unnamed captures now use URLPattern-style numeric keys (`"0"`, `"1"`, ...) instead of legacy `_0`, `_1`, ...
109+
- Unescaped `*` inside a segment is treated as an unnamed capture (`"0"`, `"1"`, ...), including mid-pattern forms like `/*.png` and `/file-*-*.png`.
110+
- Wildcard capture indexing is shared with unnamed regex groups in the same route.
111+
- `removeRoute()` now treats wildcard-segment patterns as dynamic segments (same classification as add/find/regexp).
112+
83113
## Build & Scripts
84114

85115
- **Builder:** `obuild` (config in `build.config.mjs`)
@@ -105,6 +135,7 @@ pnpm bench:deno # Benchmarks (deno)
105135
- **Snapshots:** Tree structure and compiled code snapshots
106136
- **Type tests:** `vitest typecheck` via `types.test-d.ts`
107137
- Run a single test: `pnpm vitest run test/<file>.test.ts`
138+
- **WPT compat tests:** `test/wpt.test.ts` validates URLPattern compatibility using Web Platform Test data. Known diffs are tracked in `KNOWN_DIFFS`, `REGEXP_ONLY_KNOWN_DIFFS`, and `ROUTER_KNOWN_DIFFS` sets with reason comments.
108139

109140
## Code Conventions
110141

README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,72 @@ findRoute(router, "GET", "/");
9494
> [!TIP]
9595
> If you need to register a pattern containing literal `:` or `*`, you can escape them with `\\`. For example, `/static\\:path/\\*\\*` matches only the static `/static:path/**` route.
9696
97+
## Route Patterns
98+
99+
rou3 supports [URLPattern](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API)-compatible syntax.
100+
101+
| Pattern | Example Match | Params |
102+
| --- | --- | --- |
103+
| `/path/to/resource` | `/path/to/resource` | `{}` |
104+
| `/users/:name` | `/users/foo` | `{ name: "foo" }` |
105+
| `/path/**` | `/path/foo/bar` | `{}` |
106+
| `/path/**:rest` | `/path/foo/bar` | `{ rest: "foo/bar" }` |
107+
| `/files/*.png` | `/files/icon.png` | `{ "0": "icon" }` |
108+
| `/files/file-*-*.png` | `/files/file-a-b.png` | `{ "0": "a", "1": "b" }` |
109+
| `/users/:id(\\d+)` | `/users/123` | `{ id: "123" }` |
110+
| `/files/:ext(png\|jpg)` | `/files/png` | `{ ext: "png" }` |
111+
| `/path/(\\d+)` | `/path/123` | `{ "0": "123" }` |
112+
| `/users/:id?` | `/users` or `/users/123` | `{}` or `{ id: "123" }` |
113+
| `/files/:path+` | `/files/a/b/c` | `{ path: "a/b/c" }` |
114+
| `/files/:path*` | `/files` or `/files/a/b` | `{}` or `{ path: "a/b" }` |
115+
| `/book{s}?` | `/book` or `/books` | `{}` |
116+
| `/blog/:id(\\d+){-:title}?` | `/blog/123` or `/blog/123-my-post` | `{ id: "123" }` or `{ id: "123", title: "my-post" }` |
117+
118+
- **Named params** (`:name`) match a single segment.
119+
- **Single-segment wildcards** (`*`) capture unnamed params (`0`, `1`, ...) and can be used as full or mid-segment tokens (for example `/*` or `/*.png`).
120+
- **Wildcards** (`**`) match zero or more segments. Use `**:name` to capture.
121+
- **Regex constraints** (`:name(regex)`) restrict matching. Constrained and unconstrained params can coexist on the same node (constrained checked first).
122+
- **Unnamed groups** (`(regex)`) capture into auto-indexed keys `0`, `1`, etc.
123+
- **Modifiers:** `:name?` (optional), `:name+` (one or more), `:name*` (zero or more). Can combine with regex: `:id(\d+)?`.
124+
- **Non-capturing groups** (`{...}`): supported with inline (`/foo{bar}`) and optional (`/foo{bar}?`) forms.
125+
- **Current limitation:** repeating non-capturing groups (`{...}+`, `{...}*`) are supported only within a single segment (no `/` inside the group body).
126+
- **Backslash escaping** (`\`): escape special characters like `:`, `*`, `(`, `)`, `{`, `}` with a backslash (e.g., `/static\:path` matches literal `/static:path`).
127+
128+
### Differences from URLPattern
129+
130+
rou3 aims for URLPattern-compatible syntax but has intentional differences due to its radix-tree design:
131+
132+
| Feature | URLPattern | rou3 |
133+
| --- | --- | --- |
134+
| `*` (single star) | Greedy catch-all `(.*)` across `/` | Single-segment unnamed param `([^/]*)` |
135+
| `**` (double star) | Literal `**` | Catch-all wildcard (zero or more segments) |
136+
| `(.*)` in segment | Greedy match across `/` | Segment-scoped (does not cross `/`) |
137+
| `{...}+` / `{...}*` groups | Cross-segment group repetition | Only supported within a single segment (no `/` in group body) |
138+
| Path normalization (`.`/`..`) | Resolves `.`/`..` in input paths | Not done by default (opt-in with `{ normalize: true }`) |
139+
| Case sensitivity | Can be case-insensitive | Always case-sensitive |
140+
| Non-`/`-prefixed paths | Supported | Paths must start with `/` |
141+
| Unicode param names | Supports Unicode identifiers | Params use `\w` (ASCII word chars only) |
142+
| Percent-encoding | Normalizes `%xx` sequences | Does not decode percent-encoded input |
143+
144+
### Path normalization
145+
146+
By default, `findRoute` and `findAllRoutes` do **not** resolve `.`/`..` segments in input paths. If your input paths may contain relative segments, enable normalization:
147+
148+
```js
149+
findRoute(router, "GET", "/foo/bar/../baz", { normalize: true });
150+
// Matches "/foo/baz"
151+
152+
findAllRoutes(router, "GET", "/foo/./bar", { normalize: true });
153+
// Matches "/foo/bar"
154+
```
155+
156+
The compiled router also supports this via the `normalize` option:
157+
158+
```js
159+
const match = compileRouter(router, { normalize: true });
160+
match("GET", "/foo/bar/../baz"); // Matches "/foo/baz"
161+
```
162+
97163
## Compiler
98164

99165
<!-- automd:jsdocs src="./src/compiler.ts" -->

src/_escape.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const _P = "\uFFFE";
2+
3+
export function replaceEscapesOutsideGroups(segment: string): string {
4+
let r = "",
5+
d = 0;
6+
for (let i = 0; i < segment.length; i++) {
7+
const c = segment.charCodeAt(i);
8+
if (c === 40) d++;
9+
else if (c === 41 && d > 0) d--;
10+
else if (c === 92 && d === 0 && i + 1 < segment.length) {
11+
const n = segment[i + 1];
12+
if (n !== ":" && n !== "(" && n !== "*" && n !== "\\") {
13+
r += _P + n;
14+
i++;
15+
continue;
16+
}
17+
}
18+
r += segment[i];
19+
}
20+
return r;
21+
}
22+
23+
export function resolveEscapePlaceholders(str: string): string {
24+
return str.replace(/\uFFFE(.)/g, (_, c: string) =>
25+
/[.*+?^${}()|[\]\\]/.test(c) ? `\\${c}` : c,
26+
);
27+
}

src/_group-delimiters.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
export function expandGroupDelimiters(path: string): string[] | undefined {
2+
let i = 0;
3+
let depth = 0;
4+
5+
for (; i < path.length; i++) {
6+
const c = path.charCodeAt(i);
7+
if (c === 92 /* \\ */) {
8+
i++;
9+
continue;
10+
}
11+
if (c === 40 /* ( */) {
12+
depth++;
13+
continue;
14+
}
15+
if (c === 41 /* ) */ && depth > 0) {
16+
depth--;
17+
continue;
18+
}
19+
if (c === 123 /* { */ && depth === 0) {
20+
break;
21+
}
22+
}
23+
24+
if (i >= path.length) {
25+
return;
26+
}
27+
28+
let j = i + 1;
29+
depth = 0;
30+
31+
for (; j < path.length; j++) {
32+
const c = path.charCodeAt(j);
33+
if (c === 92 /* \\ */) {
34+
j++;
35+
continue;
36+
}
37+
if (c === 40 /* ( */) {
38+
depth++;
39+
continue;
40+
}
41+
if (c === 41 /* ) */ && depth > 0) {
42+
depth--;
43+
continue;
44+
}
45+
if (c === 125 /* } */ && depth === 0) {
46+
break;
47+
}
48+
}
49+
50+
if (j >= path.length) {
51+
return;
52+
}
53+
54+
const mod = path[j + 1];
55+
const hasMod = mod === "?" || mod === "+" || mod === "*";
56+
const pre = path.slice(0, i);
57+
const body = path.slice(i + 1, j);
58+
const suf = path.slice(j + (hasMod ? 2 : 1));
59+
60+
if (!hasMod) {
61+
return [pre + body + suf];
62+
}
63+
64+
if (mod === "?") {
65+
return [pre + body + suf, pre + suf];
66+
}
67+
68+
if (body.includes("/")) {
69+
throw new Error("unsupported group repetition across segments");
70+
}
71+
72+
return [`${pre}(?:${body})${mod}${suf}`];
73+
}

src/_segment-wildcards.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
export const UNNAMED_GROUP_PREFIX = "__rou3_unnamed_";
2+
const _unnamedGroupPrefixLength = UNNAMED_GROUP_PREFIX.length;
3+
4+
export function hasSegmentWildcard(segment: string): boolean {
5+
let depth = 0;
6+
7+
for (let i = 0; i < segment.length; i++) {
8+
const ch = segment.charCodeAt(i);
9+
if (ch === 92 /* \\ */) {
10+
i++;
11+
continue;
12+
}
13+
if (ch === 40 /* ( */) {
14+
depth++;
15+
continue;
16+
}
17+
if (ch === 41 /* ) */ && depth > 0) {
18+
depth--;
19+
continue;
20+
}
21+
if (ch === 42 /* * */ && depth === 0) {
22+
return true;
23+
}
24+
}
25+
26+
return false;
27+
}
28+
29+
export function replaceSegmentWildcards(
30+
segment: string,
31+
unnamedStart: number,
32+
toGroupKey: (index: number) => string = toUnnamedGroupKey,
33+
): [string, number] {
34+
let depth = 0;
35+
let nextIndex = unnamedStart;
36+
let replaced = "";
37+
38+
for (let i = 0; i < segment.length; i++) {
39+
const ch = segment.charCodeAt(i);
40+
41+
if (ch === 92 /* \\ */) {
42+
replaced += segment[i];
43+
if (i + 1 < segment.length) {
44+
replaced += segment[++i];
45+
}
46+
continue;
47+
}
48+
49+
if (ch === 40 /* ( */) {
50+
depth++;
51+
replaced += segment[i];
52+
continue;
53+
}
54+
55+
if (ch === 41 /* ) */ && depth > 0) {
56+
depth--;
57+
replaced += segment[i];
58+
continue;
59+
}
60+
61+
if (ch === 42 /* * */ && depth === 0) {
62+
replaced += `(?<${toGroupKey(nextIndex++)}>[^/]*)`;
63+
continue;
64+
}
65+
66+
replaced += segment[i];
67+
}
68+
69+
return [replaced, nextIndex];
70+
}
71+
72+
export function toUnnamedGroupKey(index: number): string {
73+
return `${UNNAMED_GROUP_PREFIX}${index}`;
74+
}
75+
76+
export function normalizeUnnamedGroupKey(key: string): string {
77+
return key.startsWith(UNNAMED_GROUP_PREFIX)
78+
? key.slice(_unnamedGroupPrefixLength)
79+
: key;
80+
}

src/compiler.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import { UNNAMED_GROUP_PREFIX } from "./_segment-wildcards.ts";
12
import type { MatchedRoute, MethodData, Node, RouterContext } from "./types.ts";
23

34
export interface RouterCompilerOptions<T = any> {
45
matchAll?: boolean;
6+
normalize?: boolean;
57
serialize?: (data: T) => string;
68
}
79

@@ -106,7 +108,15 @@ function compileRouteMatch(ctx: CompilerContext): string {
106108
return ctx.opts?.matchAll ? `return [];` : "";
107109
}
108110

109-
return `${ctx.opts?.matchAll ? `let r=[];` : ""}if(p.charCodeAt(p.length-1)===47)p=p.slice(0,-1)||"/";${code}${ctx.opts?.matchAll ? "return r;" : ""}`;
111+
const normalizeHelper = code.includes("_normalizeGroups(")
112+
? `const _prefix=${JSON.stringify(UNNAMED_GROUP_PREFIX)},_prefixLen=${UNNAMED_GROUP_PREFIX.length};const _normalizeGroups=(g)=>{if(!g)return g;for(const k in g){if(k.startsWith(_prefix)){g[k.slice(_prefixLen)]=g[k];delete g[k]}}return g;};`
113+
: "";
114+
115+
const normalizePathHelper = ctx.opts?.normalize
116+
? `if(p.includes("/.")){let _r=[];for(let _v of p.split("/")){if(_v===".")continue;_v===".."&&_r.length>1?_r.pop():_r.push(_v)}p=_r.join("/")||"/"}`
117+
: "";
118+
119+
return `${ctx.opts?.matchAll ? `let r=[];` : ""}${normalizeHelper}${normalizePathHelper}if(p.charCodeAt(p.length-1)===47)p=p.slice(0,-1)||"/";${code}${ctx.opts?.matchAll ? "return r;" : ""}`;
110120
}
111121

112122
function compileMethodMatch(
@@ -166,7 +176,7 @@ function compileFinalMatch(
166176
ret +=
167177
typeof map[1] === "string"
168178
? `${JSON.stringify(map[1])}:${params[i]},`
169-
: `...(${map[1].toString()}.exec(${params[i]}))?.groups,`;
179+
: `..._normalizeGroups((${map[1].toString()}.exec(${params[i]}))?.groups),`;
170180
}
171181
ret += "}";
172182
}

0 commit comments

Comments
 (0)