Skip to content

Commit 17ad143

Browse files
authored
Merge pull request #73 from mjackson/route-pattern
Route pattern package
2 parents 9adc7ae + 1a2b1a5 commit 17ad143

18 files changed

+824
-0
lines changed

packages/route-pattern/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# `route-pattern` CHANGELOG
2+
3+
This is the changelog for [`route-pattern`](https://github.com/mjackson/remix-the-web/tree/main/packages/route-pattern). It follows [semantic versioning](https://semver.org/).
4+
5+
## HEAD

packages/route-pattern/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2024 Michael Jackson
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

packages/route-pattern/README.md

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
# Route Patterns
2+
3+
Route patterns are strings that describe the structure of URLs you want to match.
4+
5+
This spec discusses what the user sees when they interact with route patterns.
6+
It does not discuss the algorithms nor data structures used by the matching engine, which will be discussed elsewhere.
7+
8+
## Goals
9+
10+
- Simple metric for ranking matches
11+
- Detect unreachable routes
12+
- Benchmark in the same ballpark as existing solutions
13+
14+
## Non-goals
15+
16+
- Matching URL fragments (`#section`)
17+
- Matching URL port (`:8080`)
18+
- Matching URL credentials (`user:pass@`)
19+
- Caching
20+
- Request/Response handling
21+
22+
## Quick example
23+
24+
This example is here to jump-start your intuition.
25+
Don't worry if something is unclear; we'll cover things in excruciating detail in later sections.
26+
27+
```ts
28+
const matcher = createMatcher([
29+
'products/:id',
30+
'products/sku-:sku(/compare/sku-:sku2)',
31+
'blog/:year-:month-:day/:slug(.html)',
32+
'://:tenant.remix.run/admin/users/:userId',
33+
]);
34+
35+
const url = 'https://remix.run/products/wireless-headphones';
36+
const match = matcher.match(url);
37+
38+
console.log(match?.pattern); // 'products/:id'
39+
console.log(match?.params); // { id: 'wireless-headphones' }
40+
```
41+
42+
If you want fine-grained control, you can also get all matches in ranked order:
43+
44+
```ts
45+
const url = 'https://remix.run/products/sku-electronics-12345';
46+
for (const match of matcher.matches(url)) {
47+
console.log(`${match.pattern} -> ${JSON.stringify(match.params)}`);
48+
}
49+
// products/sku-:sku(/compare/sku-:sku2) -> { sku: 'electronics-12345' }
50+
// products/:id -> { id: 'sku-electronics-12345' }
51+
```
52+
53+
## Route pattern parts
54+
55+
Route patterns are composed of 4 parts: protocol, hostname, pathname and search.
56+
You can use any combination of these to create a route pattern, for example:
57+
58+
```ts
59+
'/products'; // pathname
60+
'/search?q'; // pathname + search
61+
'https://remix.run/store'; // protocol + hostname + pathname
62+
'://remix.run/store'; // hostname + pathaname
63+
'file:///usr/bin'; // protocol + pathname
64+
// ...and so on...
65+
```
66+
67+
**Part delimiters:** Route patterns use the first occurrences of `://`, `/`, and `?` as delimiters to split a route pattern into its parts.
68+
Pathname-only route patterns are the most common, so route patterns are assumed to be pathname-only unless `://` or `?` are present.
69+
As a result, hostnames must begin with `://` and searches must begin with `?` to distinguish both from pathnames.
70+
71+
**Case Sensitivity:** Protocol and hostname are case-insensitive, while pathname and search are case-sensitive.
72+
73+
**Omitting parts:** For protocol, hostname, and pathname omitting that part means "match anything" for that part.
74+
However, omitting a pathname means "match the 'empty' pathname" (namely `""` and `"/"`)
75+
76+
```ts
77+
'://api.example.com/users';
78+
// ✓ matches: https://api.example.com/users
79+
// ✓ matches: http://api.example.com/users
80+
// ✓ matches: ftp://api.example.com/users
81+
82+
'/api/users';
83+
// ✓ matches: https://example.com/api/users
84+
// ✓ matches: https://staging.api.com/api/users
85+
// ✓ matches: https://localhost:3000/api/users
86+
87+
'https://api.example.com';
88+
// ✓ matches: https://api.example.com
89+
// ✓ matches: https://api.example.com/
90+
// ✗ doesn't match: https://api.example.com/users
91+
```
92+
93+
## Pattern modifiers
94+
95+
Each pattern modifier — [param](#params), [glob](#globs), or [optional](#optionals) — applies only in the same part of the URL where it appears.
96+
As a result:
97+
98+
- Params and globs do not match characters that appear outside of their part of the route pattern
99+
- Optionals must begin and end within the same part of the route pattern
100+
101+
### Params
102+
103+
| | protocol | hostname | pathname | search |
104+
| ---------- | -------- | -------- | -------- | ------ |
105+
| Supported? |||||
106+
107+
Params match dynamic parts of a URL within a segment.
108+
109+
They are written as a `:` optionally followed by a [JavaScript identifier](#javascript-identifier) that acts as its name:
110+
111+
```ts
112+
'products/:id';
113+
// /products/wireless-headphones → { id: 'wireless-headphones' }
114+
// /products/123 → { id: '123' }
115+
```
116+
117+
When a param name is not given, the matched value won't be returned:
118+
119+
```ts
120+
'products/:-shoes';
121+
// /products/tennis-shoes -> {}
122+
```
123+
124+
Param names must be unique:
125+
126+
```ts
127+
// ❌ Bad - duplicate param name
128+
'users/:id/posts/:id';
129+
130+
// ✅ Good - unique param names
131+
'users/:user/posts/:post';
132+
133+
// ❌ Bad - duplicate param name across hostname and pathname
134+
'://:region.api.example.com/users/:region';
135+
```
136+
137+
Params can be mixed with static text and even other params:
138+
139+
```ts
140+
'users/@:id';
141+
// /users/@sarah → { id: 'sarah' }
142+
143+
'downloads/:filename.pdf';
144+
// /downloads/report.pdf → { filename: 'report' }
145+
146+
'api/v:major.:minor-:channel';
147+
// /api/v2.1-beta → { major: '2', minor: '1', channel: 'beta' }
148+
149+
'://us-:region.:env.api.example.com';
150+
// us-east.staging.api.example.com → { region: 'east', env: 'staging' }
151+
```
152+
153+
### Globs
154+
155+
| | protocol | hostname | pathname | search |
156+
| ---------- | -------- | -------- | -------- | ------ |
157+
| Supported? |||||
158+
159+
Globs match dynamic parts of a URL, but — unlike [params](#params) — they are not limited to a single segment.
160+
161+
They are written as a `*` optionally followed by a [JavaScript identifier](#javascript-identifier) that acts as its name:
162+
163+
```ts
164+
// todo
165+
```
166+
167+
When a glob name is not given, the matched value won't be returned:
168+
169+
```ts
170+
// todo
171+
```
172+
173+
Globs share a namespace with params:
174+
175+
```ts
176+
// todo
177+
```
178+
179+
### Optionals
180+
181+
| | protocol | hostname | pathname | search |
182+
| ---------- | -------- | -------- | -------- | ------ |
183+
| Supported? |||||
184+
185+
You can mark any part of a pattern as optional by enclosing it in parentheses `()`.
186+
187+
```ts
188+
'products/:id(/edit)';
189+
// /products/winter-jacket → { id: 'winter-jacket' }
190+
// /products/winter-jacket/edit → { id: 'winter-jacket' }
191+
192+
'http(s)://api.example.com';
193+
// http://api.example.com → {}
194+
// https://api.example.com → {}
195+
```
196+
197+
Optionals can span any characters and contain static text, params, or wildcards:
198+
199+
```ts
200+
'download/:filename(.pdf)';
201+
// /download/report → { filename: 'report' }
202+
// /download/report.pdf → { filename: 'report' }
203+
204+
'api(/v:version)/users';
205+
// /api/users → {}
206+
// /api/v2/users → { version: '2' }
207+
208+
'users/:id(/settings/:section)(/edit)';
209+
// /users/sarah → { id: 'sarah' }
210+
// /users/sarah/settings/profile → { id: 'sarah', section: 'profile' }
211+
// /users/sarah/settings/profile/edit → { id: 'sarah', section: 'profile' }
212+
213+
'users/:userId(/files/:)';
214+
// /users/sarah → { userId: 'sarah' }
215+
// /users/sarah/files/document.pdf → { userId: 'sarah' }
216+
217+
'users/:userId(/docs/*)';
218+
// /users/sarah → { userId: 'sarah' }
219+
// /users/sarah/docs/projects/readme.md → { userId: 'sarah' }
220+
221+
'users/:userId(/files/*path)';
222+
// /users/sarah → { userId: 'sarah' }
223+
// /users/sarah/files/projects/docs/readme.md → { userId: 'sarah', path: 'projects/docs/readme.md' }
224+
225+
'://(www.)shop.example.com';
226+
// shop.example.com → {}
227+
// www.shop.example.com → {}
228+
229+
'://(:.)api.example.com(/v:)';
230+
// api.example.com → {}
231+
// cdn.api.example.com/v2 → {}
232+
```
233+
234+
Optionals cannot be nested:
235+
236+
```ts
237+
// ❌ Bad - nested optionals not allowed
238+
'users/:id(/settings(/advanced))';
239+
240+
// ✅ Good - use multiple separate patterns
241+
'users/:id';
242+
'users/:id/settings(/advanced)';
243+
```
244+
245+
Optionals cannot span across multiple parts of a route pattern:
246+
247+
```ts
248+
// ❌ Bad - optional starts in protocol, ends in hostname
249+
'http(s://api).example.com';
250+
251+
// ❌ Bad - optional starts in hostname, ends in pathname
252+
'://(api.example.com/users)/settings';
253+
254+
// ❌ Bad - optional spans protocol and pathname
255+
'http(s://example.com/api)';
256+
257+
// ✅ Good - separate optionals for each part
258+
'http(s)://api.example.com(/settings)';
259+
260+
// ✅ Good - optional contained within hostname
261+
'://(www.)example.com';
262+
263+
// ✅ Good - optional contained within pathname
264+
'://example.com(/api/v2)';
265+
```
266+
267+
### Escaping special characters
268+
269+
Use backslash `\` to escape special characters in the patterns language: `:`, `*`, `(` and `)`.
270+
271+
**Note:** In JavaScript code, you'll need `\\` since backslash itself needs to be escaped in a string:
272+
273+
```ts
274+
'/api\\:v2/users';
275+
// ✅ Matches: /api:v2/users (literal colon)
276+
// ❌ Does NOT match: /apiv2/users (param :v2 would consume "v2")
277+
278+
'/files\\*.backup';
279+
// ✅ Matches: /files*.backup (literal asterisk)
280+
// ❌ Does NOT match: /files/document.backup (wildcard * would match "document")
281+
282+
'/docs\\*\\*/readme.md';
283+
// ✅ Matches: /docs**/readme.md (literal asterisks)
284+
// ❌ Does NOT match: /docs/api/v1/readme.md (** would match "api/v1")
285+
286+
'/wiki/Mercury\\(planet\\)';
287+
// ✅ Matches: /wiki/Mercury(planet) (literal parentheses for disambiguation)
288+
// ❌ Does NOT match: /wiki/Mercury (optionals would make "(planet)" optional)
289+
290+
'://api\\*.example.com';
291+
// ✅ Matches: ://api*.example.com (literal asterisk in hostname)
292+
// ❌ Does NOT match: ://api-cdn.example.com (wildcard * would match "-cdn")
293+
294+
'/search\\:query\\(\\*\\*\\)';
295+
// ✅ Matches: /search:query(**) (all literal characters)
296+
```
297+
298+
## Definitions
299+
300+
### JavaScript identifier
301+
302+
For the purposes of this spec, JavaScript identifiers match this regular expression: [`/[a-zA-Z_$0-9][a-zA-Z_$0-9]*/`](https://regexr.com/8fcn3)

packages/route-pattern/package.json

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{
2+
"name": "@mjackson/route-pattern",
3+
"version": "0.1.0",
4+
"description": "Route patterns are strings that describe the structure of URLs you want to match",
5+
"author": "Michael Jackson <[email protected]>",
6+
"license": "MIT",
7+
"repository": {
8+
"type": "git",
9+
"url": "git+https://github.com/mjackson/remix-the-web.git",
10+
"directory": "packages/route-pattern"
11+
},
12+
"homepage": "https://github.com/mjackson/remix-the-web/tree/main/packages/route-pattern#readme",
13+
"files": [
14+
"LICENSE",
15+
"README.md",
16+
"dist",
17+
"src"
18+
],
19+
"type": "module",
20+
"types": "./dist/route-pattern.d.ts",
21+
"module": "./dist/route-pattern.js",
22+
"main": "./dist/route-pattern.cjs",
23+
"exports": {
24+
".": {
25+
"types": "./dist/route-pattern.d.ts",
26+
"import": "./dist/route-pattern.js",
27+
"require": "./dist/route-pattern.cjs",
28+
"default": "./dist/route-pattern.js"
29+
},
30+
"./package.json": "./package.json"
31+
},
32+
"devDependencies": {
33+
"@types/node": "^20.14.10",
34+
"esbuild": "^0.20.0"
35+
},
36+
"scripts": {
37+
"build:types": "tsc --project tsconfig.build.json",
38+
"build:esm": "esbuild src/route-pattern.ts --bundle --outfile=dist/route-pattern.js --format=esm --platform=neutral --sourcemap",
39+
"build:cjs": "esbuild src/route-pattern.ts --bundle --outfile=dist/route-pattern.cjs --format=cjs --platform=node --sourcemap",
40+
"build": "pnpm run clean && pnpm run build:types && pnpm run build:esm && pnpm run build:cjs",
41+
"clean": "rm -rf dist",
42+
"test": "node --experimental-strip-types --disable-warning=ExperimentalWarning --test './src/**/*.test.ts'",
43+
"prepublishOnly": "pnpm run build"
44+
},
45+
"keywords": [
46+
"route",
47+
"pattern",
48+
"url",
49+
"match",
50+
"matcher"
51+
]
52+
}

packages/route-pattern/src/lib/ast.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
type BaseNode =
2+
| { type: 'text'; value: string }
3+
| { type: 'param'; name?: string }
4+
| { type: 'glob'; name?: string };
5+
export type Optional = { type: 'optional'; nodes: Array<BaseNode> };
6+
export type Node = BaseNode | Optional;

0 commit comments

Comments
 (0)