@mpen/rerouter 0.1.9 → 0.3.1
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/README.md +80 -18
- package/cli/bin.test.ts +221 -0
- package/cli/bin.ts +342 -0
- package/cli/fixtures/bin/kitchen-sink.tsx +15 -0
- package/cli/fixtures/bin/optional.tsx +3 -0
- package/cli/fixtures/bin/pages/Home.tsx +3 -0
- package/cli/fixtures/bin/pages/KitchenSink.tsx +3 -0
- package/cli/fixtures/bin/pages/Login.tsx +3 -0
- package/cli/fixtures/bin/pages/Match.tsx +3 -0
- package/cli/fixtures/bin/pages/NotFound.tsx +3 -0
- package/cli/fixtures/bin/pages/Optional.tsx +3 -0
- package/cli/fixtures/bin/regexp-groups.tsx +11 -0
- package/cli/fixtures/bin/simple.tsx +1 -0
- package/cli/fixtures/bin/unnamed.tsx +4 -0
- package/cli/tsconfig.json +9 -0
- package/dist/acorn-k7ED_tOl.js +4968 -0
- package/dist/angular--Iqdw9UJ.js +4057 -0
- package/dist/babel-hfWAujRY.js +9878 -0
- package/dist/bin.d.ts +29 -0
- package/dist/bin.js +233 -0
- package/dist/estree-C1Zjnvlw.js +7266 -0
- package/dist/flow-BaD9LyIP.js +52912 -0
- package/dist/glimmer-CvCjW_1V.js +7541 -0
- package/dist/graphql-BdtzBuWh.js +1945 -0
- package/dist/html-DkZtUVbo.js +7137 -0
- package/dist/index.d.ts +278 -0
- package/dist/index.js +247 -0
- package/dist/markdown-Z8Vrc69e.js +6876 -0
- package/dist/meriyah-DeO4stuH.js +7590 -0
- package/dist/postcss-BmgGJ0E5.js +6777 -0
- package/dist/prettier-BT_F8kIx.js +15629 -0
- package/dist/routes-PW-bNm8e.js +135 -0
- package/dist/typescript-DtIxStjy.js +22936 -0
- package/dist/yaml-CWOPBY0q.js +5281 -0
- package/examples/App.tsx +80 -0
- package/examples/dist/BlogPost-c10d9w2p.js +1 -0
- package/examples/dist/FetchLoading-534mdrgz.js +1 -0
- package/examples/dist/FetchLoading-sbxbdkre.js +1 -0
- package/examples/dist/Home-a1258p25.js +1 -0
- package/examples/dist/KitchenSink-821mjg0h.js +1 -0
- package/examples/dist/Login-wywx6bp7.js +1 -0
- package/examples/dist/Match-1e72jm5w.js +1 -0
- package/examples/dist/NotFound-smxj24jw.js +1 -0
- package/examples/dist/SlowLoading-59xxmbfk.js +1 -0
- package/examples/dist/index-0d4kj0rv.js +2 -0
- package/examples/dist/index-3x197sbt.js +9 -0
- package/examples/dist/index-a2hkfx1n.js +9 -0
- package/examples/dist/index-d21me1mc.js +9 -0
- package/examples/dist/index-ktqdknsn.js +2 -0
- package/examples/dist/index-p53qxxzd.js +2 -0
- package/examples/dist/index.html +67 -0
- package/examples/index.html +67 -0
- package/examples/pages/BlogPost.tsx +17 -0
- package/examples/pages/FetchLoading.tsx +53 -0
- package/examples/pages/FetchLoadingItem.tsx +45 -0
- package/examples/pages/Home.tsx +3 -0
- package/examples/pages/KitchenSink.tsx +23 -0
- package/examples/pages/Login.tsx +3 -0
- package/examples/pages/Match.tsx +5 -0
- package/examples/pages/NotFound.tsx +3 -0
- package/examples/pages/SlowLoading.tsx +8 -0
- package/examples/routes.gen.ts +105 -0
- package/examples/routes.ts +40 -0
- package/examples/server/serve-dist.ts +33 -0
- package/examples/server/tsconfig.json +9 -0
- package/package.json +41 -31
- package/src/components/Link.test.tsx +139 -0
- package/src/components/Link.tsx +89 -0
- package/src/components/NavLink.test.tsx +119 -0
- package/src/components/NavLink.tsx +71 -0
- package/src/components/Router.test.tsx +183 -0
- package/src/components/Router.tsx +207 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useUrl.ts +22 -0
- package/src/index.ts +6 -0
- package/src/lib/mergeSearch.test.ts +37 -0
- package/src/lib/mergeSearch.ts +21 -0
- package/src/lib/routes.test.ts +67 -0
- package/src/lib/routes.ts +247 -0
- package/src/lib/url.ts +9 -0
- package/tsconfig.json +10 -0
- package/tsdown.config.ts +21 -0
- package/LICENSE +0 -21
- package/dist/bundle.cjs +0 -422
- package/dist/bundle.d.ts +0 -2
- package/dist/bundle.mjs +0 -420
- package/dist/dev.d.ts +0 -1
- package/dist/log.d.ts +0 -1
- package/dist/uri-template.d.ts +0 -56
package/README.md
CHANGED
|
@@ -1,31 +1,93 @@
|
|
|
1
1
|
# @mpen/rerouter
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
A lightweight, type-safe router for React with a CLI for generating URL helpers.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Small footprint**: Focuses only on the essentials of routing.
|
|
8
|
+
- **Type-safe URL generation**: CLI tool imports your route file and generates helper functions, ensuring you never have broken links.
|
|
9
|
+
- **Support for `path-to-regexp`**: Familiar syntax for route patterns.
|
|
10
|
+
- **Native `URLPattern` support**: Can use the browser's native `URLPattern` API.
|
|
11
|
+
- **Hooks-based**: Easy access to current path and search parameters.
|
|
12
|
+
|
|
5
13
|
## Installation
|
|
6
14
|
|
|
7
|
-
```
|
|
15
|
+
```bash
|
|
8
16
|
bun add @mpen/rerouter
|
|
9
17
|
```
|
|
10
18
|
|
|
11
|
-
##
|
|
19
|
+
## CLI: `rerouter`
|
|
20
|
+
|
|
21
|
+
The package includes a CLI tool to generate type-safe route helpers from your route definitions.
|
|
22
|
+
|
|
23
|
+
### Usage
|
|
24
|
+
|
|
25
|
+
1. Define your routes in a dedicated `.ts` file:
|
|
12
26
|
|
|
13
27
|
```ts
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
// routes.ts
|
|
29
|
+
export default [
|
|
30
|
+
{ name: 'home', pattern: '/', component: () => import('./pages/Home') },
|
|
31
|
+
{ name: 'userProfile', pattern: '/user/:id', component: () => import('./pages/UserProfile') },
|
|
32
|
+
{ pattern: '/user/:id/settings', component: () => import('./pages/UserProfile') },
|
|
33
|
+
]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Keep this file side-effect-free. The CLI imports and evaluates the route file to extract route names and patterns, so avoid top-level browser access, data fetching, app bootstrapping, or eager page component imports. Put route components behind `() => import('./pages/...')` loaders so generation does not pull page modules into the CLI process.
|
|
37
|
+
|
|
38
|
+
The `name` field is optional. Named string-pattern routes are included in generated URL helpers; unnamed routes still match at runtime but are skipped by the generator.
|
|
39
|
+
|
|
40
|
+
2. Run the generator:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
bunx @mpen/rerouter routes.ts -o src/routes.gen.ts
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
3. Use the generated helpers:
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
import { userProfile } from './routes.gen'
|
|
50
|
+
|
|
51
|
+
// Returns "/user/123"
|
|
52
|
+
const url = userProfile({ id: 123 })
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Library Usage
|
|
56
|
+
|
|
57
|
+
### Router
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
import { Router } from '@mpen/rerouter'
|
|
61
|
+
import ROUTES from './routes'
|
|
62
|
+
|
|
63
|
+
function App() {
|
|
64
|
+
return <Router routes={ROUTES} loading={<div>Loading...</div>} />
|
|
65
|
+
}
|
|
25
66
|
```
|
|
26
67
|
|
|
27
|
-
|
|
68
|
+
`loading` is delayed by 400ms by default to avoid flashing fallback UI during quick route
|
|
69
|
+
loads. Pass `loadingDelayMs={0}` to show it immediately, or another millisecond value to tune
|
|
70
|
+
the delay.
|
|
28
71
|
|
|
29
|
-
|
|
72
|
+
### Link
|
|
30
73
|
|
|
31
|
-
|
|
74
|
+
```tsx
|
|
75
|
+
import { Link } from '@mpen/rerouter'
|
|
76
|
+
import { userProfile } from './routes.gen'
|
|
77
|
+
|
|
78
|
+
function Navigation() {
|
|
79
|
+
return <Link href={userProfile({ id: 'me' })}>My Profile</Link>
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Hooks
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
import { useUrlPath, useUrlSearchParams } from '@mpen/rerouter'
|
|
87
|
+
|
|
88
|
+
function MyComponent() {
|
|
89
|
+
const path = useUrlPath()
|
|
90
|
+
const searchParams = useUrlSearchParams()
|
|
91
|
+
// ...
|
|
92
|
+
}
|
|
93
|
+
```
|
package/cli/bin.test.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env bun test
|
|
2
|
+
import { expect, test, describe, it, afterAll, beforeAll } from 'bun:test'
|
|
3
|
+
import { $ } from 'bun'
|
|
4
|
+
import fs from 'node:fs/promises'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import process from 'node:process'
|
|
7
|
+
import { pathToFileURL } from 'node:url'
|
|
8
|
+
import { runRerouterBin } from './bin'
|
|
9
|
+
|
|
10
|
+
const TEMP_DIR = path.resolve(import.meta.dirname, 'temp_bin_test')
|
|
11
|
+
const FIXTURES_DIR = path.resolve(import.meta.dirname, 'fixtures/bin')
|
|
12
|
+
const BIN_PATH = path.resolve(import.meta.dirname, 'bin.ts')
|
|
13
|
+
const BUN_PATH = process.execPath
|
|
14
|
+
|
|
15
|
+
async function ensureDir(dir: string) {
|
|
16
|
+
await fs.mkdir(dir, { recursive: true })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('rerouter bin', () => {
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
await ensureDir(TEMP_DIR)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('writes to stdout by default', async () => {
|
|
25
|
+
const routesFile = path.join(FIXTURES_DIR, 'simple.tsx')
|
|
26
|
+
|
|
27
|
+
const result = await runRerouterBin([routesFile])
|
|
28
|
+
expect(result.stdout).toContain('export function home()')
|
|
29
|
+
expect(result.stderr).toBe('')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('writes to file with -o', async () => {
|
|
33
|
+
const routesFile = path.join(FIXTURES_DIR, 'simple.tsx')
|
|
34
|
+
const outputFile = path.join(TEMP_DIR, 'explicit-output.ts')
|
|
35
|
+
|
|
36
|
+
const result = await runRerouterBin([routesFile, '-o', outputFile])
|
|
37
|
+
|
|
38
|
+
const outputContent = await fs.readFile(outputFile, 'utf8')
|
|
39
|
+
expect(outputContent).toContain('export function home()')
|
|
40
|
+
expect(result.stderr).toBe(`Wrote ${path.relative(process.cwd(), outputFile)}\n`)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('formats output files with --pretty', async () => {
|
|
44
|
+
const routesFile = path.join(FIXTURES_DIR, 'simple.tsx')
|
|
45
|
+
const outputFile = path.join(TEMP_DIR, 'pretty-output.ts')
|
|
46
|
+
|
|
47
|
+
await runRerouterBin([routesFile, '-o', outputFile, '--pretty'])
|
|
48
|
+
|
|
49
|
+
const outputContent = await fs.readFile(outputFile, 'utf8')
|
|
50
|
+
expect(outputContent).toContain("export function home(): string {\n let sb = ''")
|
|
51
|
+
expect(outputContent).toContain(" sb += '/'")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('ignores --pretty when writing to stdout', async () => {
|
|
55
|
+
const routesFile = path.join(FIXTURES_DIR, 'simple.tsx')
|
|
56
|
+
|
|
57
|
+
const { stdout } = await runRerouterBin([routesFile, '-p'])
|
|
58
|
+
|
|
59
|
+
expect(stdout).toContain('export function home(): string {\n let sb = ""')
|
|
60
|
+
expect(stdout).toContain(' sb += "/"')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('writes to adjacent file with -w', async () => {
|
|
64
|
+
const routesFile = path.join(TEMP_DIR, 'write-adjacent.tsx')
|
|
65
|
+
const expectedOutputFile = path.join(TEMP_DIR, 'write-adjacent.gen.ts')
|
|
66
|
+
await fs.copyFile(path.join(FIXTURES_DIR, 'simple.tsx'), routesFile)
|
|
67
|
+
|
|
68
|
+
await runRerouterBin([routesFile, '-w'])
|
|
69
|
+
|
|
70
|
+
const outputContent = await fs.readFile(expectedOutputFile, 'utf8')
|
|
71
|
+
expect(outputContent).toContain('export function home()')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('handles optional groups', async () => {
|
|
75
|
+
const routesFile = path.join(FIXTURES_DIR, 'optional.tsx')
|
|
76
|
+
|
|
77
|
+
const { stdout: outputContent } = await runRerouterBin([routesFile])
|
|
78
|
+
|
|
79
|
+
expect(outputContent).toContain(
|
|
80
|
+
'export function optional(params: {} & AllOrNone<{ "bar": ParamType }>): string {',
|
|
81
|
+
)
|
|
82
|
+
expect(outputContent).toContain('AllOrNone<')
|
|
83
|
+
expect(outputContent).toContain('"bar": ParamType')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('skips unnamed routes', async () => {
|
|
87
|
+
const routesFile = path.join(FIXTURES_DIR, 'unnamed.tsx')
|
|
88
|
+
|
|
89
|
+
const { stdout: outputContent } = await runRerouterBin([routesFile])
|
|
90
|
+
|
|
91
|
+
expect(outputContent).toContain('export function home()')
|
|
92
|
+
expect(outputContent).not.toContain('layout')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('handles regexp params with optional groups', async () => {
|
|
96
|
+
const routesFile = path.join(FIXTURES_DIR, 'regexp-groups.tsx')
|
|
97
|
+
const outputFile = path.join(TEMP_DIR, 'regexp-groups.gen.ts')
|
|
98
|
+
|
|
99
|
+
await runRerouterBin([routesFile, '-o', outputFile])
|
|
100
|
+
|
|
101
|
+
const outputContent = await fs.readFile(outputFile, 'utf8')
|
|
102
|
+
expect(outputContent).toContain('export function blogPost(')
|
|
103
|
+
expect(outputContent).toContain('"id": ParamType')
|
|
104
|
+
expect(outputContent).toContain('"title": ParamType')
|
|
105
|
+
|
|
106
|
+
const { blogPost } = await import(pathToFileURL(outputFile).href)
|
|
107
|
+
expect(blogPost({ id: 123 })).toBe('/blog/123')
|
|
108
|
+
expect(blogPost({ id: 123, title: 'hello world' })).toBe('/blog/123-hello%20world')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('generates importable path helpers', async () => {
|
|
112
|
+
const routesFile = path.join(FIXTURES_DIR, 'kitchen-sink.tsx')
|
|
113
|
+
const outputFile = path.join(TEMP_DIR, 'importable.gen.ts')
|
|
114
|
+
|
|
115
|
+
await runRerouterBin([
|
|
116
|
+
routesFile,
|
|
117
|
+
'-o',
|
|
118
|
+
outputFile,
|
|
119
|
+
'--wildcard-delimiter',
|
|
120
|
+
',',
|
|
121
|
+
'--encode-function',
|
|
122
|
+
'encodeURI',
|
|
123
|
+
])
|
|
124
|
+
|
|
125
|
+
const { home, kitchenSink, login, match } = await import(pathToFileURL(outputFile).href)
|
|
126
|
+
|
|
127
|
+
expect(home()).toBe('/')
|
|
128
|
+
expect(login()).toBe('/login')
|
|
129
|
+
expect(match({ id: 'a/b' })).toBe('/matches/a/b')
|
|
130
|
+
expect(kitchenSink({ foo: 'a/b', baz: 'c', splat: ['x', 'y'] })).toBe(
|
|
131
|
+
'/hello/a/b/bar/c/x,y/xxx',
|
|
132
|
+
)
|
|
133
|
+
expect(
|
|
134
|
+
kitchenSink({
|
|
135
|
+
foo: 'a/b',
|
|
136
|
+
baz: 'c',
|
|
137
|
+
splat: ['x', 'y'],
|
|
138
|
+
optional: 'opt',
|
|
139
|
+
two: 'two',
|
|
140
|
+
}),
|
|
141
|
+
).toBe('/hello/a/b/bar/c/x,y/xxx/opt/lol/two')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('generated path helpers with default options', () => {
|
|
145
|
+
let home: () => string
|
|
146
|
+
let login: () => string
|
|
147
|
+
let match: (params: { id: string }) => string
|
|
148
|
+
let kitchenSink: (params: {
|
|
149
|
+
foo: string
|
|
150
|
+
baz: string
|
|
151
|
+
splat: string[]
|
|
152
|
+
optional?: string
|
|
153
|
+
two?: string
|
|
154
|
+
}) => string
|
|
155
|
+
|
|
156
|
+
beforeAll(async () => {
|
|
157
|
+
const routesFile = path.join(FIXTURES_DIR, 'kitchen-sink.tsx')
|
|
158
|
+
const outputFile = path.join(TEMP_DIR, 'importable-defaults.gen.ts')
|
|
159
|
+
|
|
160
|
+
const result = await runRerouterBin([routesFile])
|
|
161
|
+
await fs.writeFile(outputFile, result.stdout, 'utf8')
|
|
162
|
+
|
|
163
|
+
const generated = await import(pathToFileURL(outputFile).href)
|
|
164
|
+
home = generated.home
|
|
165
|
+
login = generated.login
|
|
166
|
+
match = generated.match
|
|
167
|
+
kitchenSink = generated.kitchenSink
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('home()', () => {
|
|
171
|
+
expect(home()).toBe('/')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('login()', () => {
|
|
175
|
+
expect(login()).toBe('/login')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('match()', () => {
|
|
179
|
+
expect(match({ id: '123' })).toBe('/matches/123')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('match() uses encodeURIComponent', () => {
|
|
183
|
+
expect(match({ id: 'a/b' })).toBe('/matches/a%2Fb')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('kitchenSink() without optional group', () => {
|
|
187
|
+
expect(kitchenSink({ foo: 'a', baz: 'b', splat: ['x', 'y'] })).toBe(
|
|
188
|
+
'/hello/a/bar/b/x/y/xxx',
|
|
189
|
+
)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('kitchenSink() with optional group', () => {
|
|
193
|
+
expect(
|
|
194
|
+
kitchenSink({
|
|
195
|
+
foo: 'a',
|
|
196
|
+
baz: 'b',
|
|
197
|
+
splat: ['x', 'y'],
|
|
198
|
+
optional: 'opt',
|
|
199
|
+
two: 'two',
|
|
200
|
+
}),
|
|
201
|
+
).toBe('/hello/a/bar/b/x/y/xxx/opt/lol/two')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('kitchenSink() requires all-or-none optional group', () => {
|
|
205
|
+
expect(() =>
|
|
206
|
+
kitchenSink({ foo: 'a', baz: 'b', splat: ['x', 'y'], optional: 'opt' } as any),
|
|
207
|
+
).toThrow('Group requires all-or-none: "optional", "two"')
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('can be invoked as a CLI script', async () => {
|
|
212
|
+
const routesFile = path.join(FIXTURES_DIR, 'simple.tsx')
|
|
213
|
+
|
|
214
|
+
const result = await $`${BUN_PATH} ${BIN_PATH} ${routesFile}`.text()
|
|
215
|
+
expect(result).toContain('export function home()')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
afterAll(async () => {
|
|
219
|
+
await fs.rm(TEMP_DIR, { recursive: true, force: true })
|
|
220
|
+
})
|
|
221
|
+
})
|
package/cli/bin.ts
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import process from 'node:process'
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
6
|
+
import { parseArgs, type ParseArgsConfig } from 'node:util'
|
|
7
|
+
import { parse } from 'path-to-regexp'
|
|
8
|
+
import { normalizeLegacyPathToRegexpSyntax, normalizeRoutes, type Route } from '../src/lib/routes'
|
|
9
|
+
|
|
10
|
+
const PARSE_CONFIG = {
|
|
11
|
+
options: {
|
|
12
|
+
output: { type: 'string', short: 'o' },
|
|
13
|
+
write: { type: 'boolean', short: 'w' },
|
|
14
|
+
pretty: { type: 'boolean', short: 'p' },
|
|
15
|
+
'wildcard-delimiter': { type: 'string' },
|
|
16
|
+
'encode-function': { type: 'string' },
|
|
17
|
+
},
|
|
18
|
+
allowPositionals: true,
|
|
19
|
+
strict: true,
|
|
20
|
+
} satisfies ParseArgsConfig
|
|
21
|
+
|
|
22
|
+
type CompileOptions = {
|
|
23
|
+
delimiter?: string
|
|
24
|
+
encode?: string
|
|
25
|
+
functionName?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type RunOptions = {
|
|
29
|
+
cwd?: string
|
|
30
|
+
commandName?: string
|
|
31
|
+
commandArgs?: readonly string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type RunResult = {
|
|
35
|
+
exitCode?: number
|
|
36
|
+
stdout: string
|
|
37
|
+
stderr: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function escapeString(value: string): string {
|
|
41
|
+
return JSON.stringify(value)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function shellEscape(arg: string): string {
|
|
45
|
+
if (/^[a-z0-9/_.-]+$/i.test(arg)) return arg
|
|
46
|
+
return `'${arg.replace(/'/g, "'\\''")}'`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function compilePathGenerator(
|
|
50
|
+
pattern: string,
|
|
51
|
+
{
|
|
52
|
+
delimiter = '/',
|
|
53
|
+
encode = 'encodeURIComponent',
|
|
54
|
+
functionName = 'generate',
|
|
55
|
+
}: CompileOptions = {},
|
|
56
|
+
): string {
|
|
57
|
+
const { tokens } = parse(normalizeLegacyPathToRegexpSyntax(pattern))
|
|
58
|
+
|
|
59
|
+
type Prop = { name: string; type: string }
|
|
60
|
+
|
|
61
|
+
const baseProps: Prop[] = []
|
|
62
|
+
const groupTypes: string[] = []
|
|
63
|
+
|
|
64
|
+
const typeOfParam = (t: any) => (t.type === 'wildcard' ? 'WildcardType' : 'ParamType')
|
|
65
|
+
const makeProp = (name: string, t: any): Prop => ({ name, type: typeOfParam(t) })
|
|
66
|
+
const renderPropsType = (props: readonly Prop[]): string =>
|
|
67
|
+
props.length
|
|
68
|
+
? `{ ${props.map((p) => `${escapeString(p.name)}: ${p.type}`).join('; ')} }`
|
|
69
|
+
: '{}'
|
|
70
|
+
|
|
71
|
+
function collectGroupProps(ts2: any[]): Prop[] {
|
|
72
|
+
const props: Prop[] = []
|
|
73
|
+
for (const t of ts2) {
|
|
74
|
+
if (t.type === 'param' || t.type === 'wildcard') props.push(makeProp(t.name, t))
|
|
75
|
+
else if (t.type === 'group') props.push(...collectGroupProps(t.tokens))
|
|
76
|
+
}
|
|
77
|
+
return props
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function collectTypes(ts2: any[], intoBase = true) {
|
|
81
|
+
for (const t of ts2) {
|
|
82
|
+
if ((t.type === 'param' || t.type === 'wildcard') && intoBase) {
|
|
83
|
+
baseProps.push(makeProp(t.name, t))
|
|
84
|
+
} else if (t.type === 'group') {
|
|
85
|
+
const groupProps = collectGroupProps(t.tokens)
|
|
86
|
+
if (groupProps.length) {
|
|
87
|
+
groupTypes.push(`AllOrNone<${renderPropsType(groupProps)}>`)
|
|
88
|
+
}
|
|
89
|
+
collectTypes(t.tokens, false)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
collectTypes(tokens)
|
|
95
|
+
|
|
96
|
+
const baseParamsType = renderPropsType(baseProps)
|
|
97
|
+
const paramsType = groupTypes.length
|
|
98
|
+
? `${baseParamsType} & ${groupTypes.join(' & ')}`
|
|
99
|
+
: baseParamsType
|
|
100
|
+
|
|
101
|
+
const lines: string[] = []
|
|
102
|
+
const indentUnit = ' '
|
|
103
|
+
const line = (indentLevel: number, text = ''): void => {
|
|
104
|
+
if (text === '') lines.push('')
|
|
105
|
+
else lines.push(indentUnit.repeat(indentLevel) + text)
|
|
106
|
+
}
|
|
107
|
+
const hasAnyParams = baseProps.length > 0 || groupTypes.length > 0
|
|
108
|
+
|
|
109
|
+
if (hasAnyParams) {
|
|
110
|
+
lines.push(`export function ${functionName}(params: ${paramsType}): string {`)
|
|
111
|
+
} else {
|
|
112
|
+
lines.push(`export function ${functionName}(): string {`)
|
|
113
|
+
}
|
|
114
|
+
line(1, `let sb = ""`)
|
|
115
|
+
line(0)
|
|
116
|
+
|
|
117
|
+
const delim = escapeString(delimiter)
|
|
118
|
+
|
|
119
|
+
function collectNames(ts2: any[]): string[] {
|
|
120
|
+
const names: string[] = []
|
|
121
|
+
for (const t of ts2) {
|
|
122
|
+
if (t.type === 'param' || t.type === 'wildcard') names.push(t.name)
|
|
123
|
+
else if (t.type === 'group') names.push(...collectNames(t.tokens))
|
|
124
|
+
}
|
|
125
|
+
return names
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function emitTokens(ts2: any[], indentLevel: number, optional = false): void {
|
|
129
|
+
if (!optional && hasAnyParams) {
|
|
130
|
+
for (const t of ts2) {
|
|
131
|
+
if (t.type === 'param' || t.type === 'wildcard') {
|
|
132
|
+
line(
|
|
133
|
+
indentLevel,
|
|
134
|
+
`if (params[${escapeString(t.name)}] == null) throw new Error(${escapeString(`Missing param: ${t.name}`)})`,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const t of ts2) {
|
|
141
|
+
if (t.type === 'text') {
|
|
142
|
+
line(indentLevel, `sb += ${escapeString(t.value)}`)
|
|
143
|
+
} else if (t.type === 'param') {
|
|
144
|
+
line(indentLevel, `sb += (${encode})(String(params[${escapeString(t.name)}]))`)
|
|
145
|
+
} else if (t.type === 'wildcard') {
|
|
146
|
+
line(
|
|
147
|
+
indentLevel,
|
|
148
|
+
`sb += Array.from(params[${escapeString(t.name)}], v => (${encode})(String(v))).join(${delim})`,
|
|
149
|
+
)
|
|
150
|
+
} else if (t.type === 'group') {
|
|
151
|
+
const names = collectNames(t.tokens).map((name) => escapeString(name))
|
|
152
|
+
if (!names.length) continue
|
|
153
|
+
const all = names.map((n) => `params[${n}] != null`).join(' && ')
|
|
154
|
+
const none = names.map((n) => `params[${n}] == null`).join(' && ')
|
|
155
|
+
const list = names.join(', ')
|
|
156
|
+
|
|
157
|
+
line(indentLevel, `if (${all}) {`)
|
|
158
|
+
emitTokens(t.tokens, indentLevel + 1, true)
|
|
159
|
+
line(indentLevel, `} else if (!(${none})) {`)
|
|
160
|
+
line(
|
|
161
|
+
indentLevel + 1,
|
|
162
|
+
`throw new Error(${escapeString(`Group requires all-or-none: ${list}`)})`,
|
|
163
|
+
)
|
|
164
|
+
line(indentLevel, `}`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
emitTokens(tokens, 1)
|
|
170
|
+
line(0)
|
|
171
|
+
line(1, `return sb`)
|
|
172
|
+
lines.push(`}`)
|
|
173
|
+
return lines.join('\n')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function toRouteFunctionName(routeName: string): string {
|
|
177
|
+
const ident = routeName
|
|
178
|
+
.trim()
|
|
179
|
+
.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
180
|
+
.replace(/^[^a-zA-Z_]+/, '')
|
|
181
|
+
return ident || 'route'
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
type ExtractedRoute = { name: string; pattern: string }
|
|
185
|
+
|
|
186
|
+
async function importRoutes(routesPath: string): Promise<readonly Route[]> {
|
|
187
|
+
const mod = (await import(pathToFileURL(routesPath).href)) as { default?: unknown }
|
|
188
|
+
if (!Array.isArray(mod.default)) {
|
|
189
|
+
throw new Error('Routes file must default export an array of routes.')
|
|
190
|
+
}
|
|
191
|
+
return mod.default as readonly Route[]
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function formatWithPrettier(source: string, outputPath: string): Promise<string> {
|
|
195
|
+
let prettier: typeof import('prettier')
|
|
196
|
+
try {
|
|
197
|
+
prettier = await import('prettier')
|
|
198
|
+
} catch (cause) {
|
|
199
|
+
throw new Error('The --pretty option requires prettier to be installed.', { cause })
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const options = (await prettier.resolveConfig(outputPath)) ?? {}
|
|
203
|
+
return prettier.format(source, { ...options, filepath: outputPath })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function extractRoutes(routes: readonly Route[]): ExtractedRoute[] {
|
|
207
|
+
return normalizeRoutes(routes).flatMap((route) => {
|
|
208
|
+
if (!route.name || typeof route.pattern !== 'string') return []
|
|
209
|
+
return [{ name: route.name, pattern: route.pattern }]
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function main(
|
|
214
|
+
options: Options,
|
|
215
|
+
positionals: Positionals,
|
|
216
|
+
{
|
|
217
|
+
cwd = process.cwd(),
|
|
218
|
+
commandName = 'rerouter',
|
|
219
|
+
commandArgs = process.argv.slice(2),
|
|
220
|
+
}: RunOptions = {},
|
|
221
|
+
): Promise<RunResult> {
|
|
222
|
+
const [routesPathArg] = positionals
|
|
223
|
+
if (!routesPathArg) {
|
|
224
|
+
return {
|
|
225
|
+
exitCode: 1,
|
|
226
|
+
stdout: '',
|
|
227
|
+
stderr: 'Usage: rerouter <routes-file> [-o <output-file>] [-w] [-p|--pretty] [--wildcard-delimiter <string>] [--encode-function <identifier>]\n',
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const routesPath = path.resolve(cwd, routesPathArg)
|
|
232
|
+
let outputPath: string | undefined
|
|
233
|
+
if (options.output) {
|
|
234
|
+
outputPath = path.resolve(cwd, options.output as string)
|
|
235
|
+
} else if (options.write) {
|
|
236
|
+
outputPath = path.join(
|
|
237
|
+
path.dirname(routesPath),
|
|
238
|
+
path.basename(routesPath, path.extname(routesPath)) + '.gen.ts',
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const routes = extractRoutes(await importRoutes(routesPath))
|
|
243
|
+
.map((r) => ({ ...r, pattern: r.pattern.trim() }))
|
|
244
|
+
.filter((r) => r.pattern.startsWith('/') && r.pattern !== '*')
|
|
245
|
+
|
|
246
|
+
const wildcardDelimiter = (options['wildcard-delimiter'] as string | undefined) ?? '/'
|
|
247
|
+
const encodeFunction =
|
|
248
|
+
(options['encode-function'] as string | undefined) ?? 'encodeURIComponent'
|
|
249
|
+
|
|
250
|
+
const commandText = [commandName, ...commandArgs.map(shellEscape)].join(' ')
|
|
251
|
+
|
|
252
|
+
const out: string[] = []
|
|
253
|
+
out.push(`// Do not modify this file. It was auto-generated with the following command:`)
|
|
254
|
+
out.push(`// $ ${commandText}`)
|
|
255
|
+
out.push(``)
|
|
256
|
+
out.push(`type AllOrNone<T> =`)
|
|
257
|
+
out.push(` | Required<T>`)
|
|
258
|
+
out.push(` | { [K in keyof T]?: never }`)
|
|
259
|
+
out.push(``)
|
|
260
|
+
out.push(`type ParamType = string | number | boolean`)
|
|
261
|
+
out.push(`type WildcardType = Iterable<ParamType>`)
|
|
262
|
+
out.push(``)
|
|
263
|
+
|
|
264
|
+
if (!routes.length) {
|
|
265
|
+
out.push(`// No string route patterns found in the default export.`)
|
|
266
|
+
out.push(``)
|
|
267
|
+
} else {
|
|
268
|
+
const usedNames = new Set<string>()
|
|
269
|
+
for (const route of routes) {
|
|
270
|
+
const base = toRouteFunctionName(route.name)
|
|
271
|
+
let name = base
|
|
272
|
+
let i = 2
|
|
273
|
+
while (usedNames.has(name)) {
|
|
274
|
+
name = `${base}_${i++}`
|
|
275
|
+
}
|
|
276
|
+
usedNames.add(name)
|
|
277
|
+
|
|
278
|
+
out.push(
|
|
279
|
+
compilePathGenerator(route.pattern, {
|
|
280
|
+
functionName: name,
|
|
281
|
+
delimiter: wildcardDelimiter,
|
|
282
|
+
encode: encodeFunction,
|
|
283
|
+
}),
|
|
284
|
+
)
|
|
285
|
+
out.push(``)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let finalOutput = out.join('\n')
|
|
290
|
+
if (outputPath) {
|
|
291
|
+
if (options.pretty) {
|
|
292
|
+
finalOutput = await formatWithPrettier(finalOutput, outputPath)
|
|
293
|
+
}
|
|
294
|
+
await fs.writeFile(outputPath, finalOutput, 'utf8')
|
|
295
|
+
return { stdout: '', stderr: `Wrote ${path.relative(cwd, outputPath) || '.'}\n` }
|
|
296
|
+
} else {
|
|
297
|
+
return { stdout: finalOutput, stderr: '' }
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Runs the rerouter CLI implementation without spawning a separate process.
|
|
303
|
+
*
|
|
304
|
+
* @param args - Command line arguments, excluding the binary name.
|
|
305
|
+
* @param options - Runtime options used to resolve paths and render the command comment.
|
|
306
|
+
* @returns Captured stdout, stderr, and an optional process exit code.
|
|
307
|
+
*
|
|
308
|
+
* @example
|
|
309
|
+
* ```ts
|
|
310
|
+
* const result = await runRerouterBin(['./routes.ts'])
|
|
311
|
+
* process.stdout.write(result.stdout)
|
|
312
|
+
* ```
|
|
313
|
+
*
|
|
314
|
+
* @internal
|
|
315
|
+
*/
|
|
316
|
+
export async function runRerouterBin(
|
|
317
|
+
args: readonly string[],
|
|
318
|
+
options: RunOptions = {},
|
|
319
|
+
): Promise<RunResult> {
|
|
320
|
+
const { values, positionals } = parseArgs({ ...PARSE_CONFIG, args: [...args] })
|
|
321
|
+
return main(values, positionals, { ...options, commandArgs: args })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
//#region Invoke main
|
|
325
|
+
type ParsedConfig = ReturnType<typeof parseArgs<typeof PARSE_CONFIG>>
|
|
326
|
+
type Options = ParsedConfig['values']
|
|
327
|
+
type Positionals = ParsedConfig['positionals']
|
|
328
|
+
|
|
329
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
330
|
+
runRerouterBin(process.argv.slice(2)).then(
|
|
331
|
+
(result) => {
|
|
332
|
+
if (result.stdout) process.stdout.write(result.stdout)
|
|
333
|
+
if (result.stderr) process.stderr.write(result.stderr)
|
|
334
|
+
if (typeof result.exitCode === 'number') process.exitCode = result.exitCode
|
|
335
|
+
},
|
|
336
|
+
(err) => {
|
|
337
|
+
console.error(err ?? 'An unknown error occurred')
|
|
338
|
+
process.exitCode = 1
|
|
339
|
+
},
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
//#endregion
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RouteObject } from '@mpen/rerouter'
|
|
2
|
+
|
|
3
|
+
const ROUTES: readonly RouteObject[] = [
|
|
4
|
+
{ name: 'home', pattern: '/', component: () => import('./pages/Home') },
|
|
5
|
+
{
|
|
6
|
+
name: 'kitchenSink',
|
|
7
|
+
pattern: '/hello/:foo/bar/:baz/*splat/xxx{/:optional/lol/:two}',
|
|
8
|
+
component: () => import('./pages/KitchenSink'),
|
|
9
|
+
},
|
|
10
|
+
{ name: 'login', pattern: '/login', component: () => import('./pages/Login') },
|
|
11
|
+
{ name: 'match', pattern: '/matches/:id', component: () => import('./pages/Match') },
|
|
12
|
+
{ name: 'notFound', pattern: '*', component: () => import('./pages/NotFound') },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
export default ROUTES
|