@pyreon/router 0.11.5 → 0.11.7
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 +14 -12
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +9 -9
- package/package.json +13 -13
- package/src/components.tsx +23 -23
- package/src/index.ts +7 -7
- package/src/loader.ts +4 -4
- package/src/match.ts +41 -41
- package/src/router.ts +86 -86
- package/src/scroll.ts +12 -12
- package/src/tests/loader.test.ts +210 -210
- package/src/tests/match.test.ts +202 -202
- package/src/tests/router.test.ts +1483 -1422
- package/src/tests/setup.ts +1 -1
- package/src/types.ts +12 -12
package/src/tests/match.test.ts
CHANGED
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
parseQueryMulti,
|
|
8
8
|
resolveRoute,
|
|
9
9
|
stringifyQuery,
|
|
10
|
-
} from
|
|
11
|
-
import type { RouteRecord } from
|
|
10
|
+
} from '../match'
|
|
11
|
+
import type { RouteRecord } from '../types'
|
|
12
12
|
|
|
13
13
|
const Home = () => null
|
|
14
14
|
const About = () => null
|
|
@@ -17,261 +17,261 @@ const NotFound = () => null
|
|
|
17
17
|
|
|
18
18
|
// ─── parseQuery — edge cases ─────────────────────────────────────────────────
|
|
19
19
|
|
|
20
|
-
describe(
|
|
21
|
-
test(
|
|
22
|
-
expect(parseQuery(
|
|
20
|
+
describe('parseQuery — edge cases', () => {
|
|
21
|
+
test('handles URI-encoded keys', () => {
|
|
22
|
+
expect(parseQuery('hello%20world=value')).toEqual({ 'hello world': 'value' })
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
test(
|
|
25
|
+
test('handles multiple equals signs in value', () => {
|
|
26
26
|
// Only the first `=` is the delimiter
|
|
27
|
-
expect(parseQuery(
|
|
27
|
+
expect(parseQuery('expr=a=b')).toEqual({ expr: 'a=b' })
|
|
28
28
|
})
|
|
29
29
|
|
|
30
|
-
test(
|
|
31
|
-
expect(parseQuery(
|
|
30
|
+
test('last value wins for duplicate keys', () => {
|
|
31
|
+
expect(parseQuery('a=1&a=2')).toEqual({ a: '2' })
|
|
32
32
|
})
|
|
33
33
|
|
|
34
|
-
test(
|
|
34
|
+
test('handles empty key (skipped)', () => {
|
|
35
35
|
// "=value" has empty key, should be skipped
|
|
36
|
-
expect(parseQuery(
|
|
36
|
+
expect(parseQuery('=value')).toEqual({})
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
-
test(
|
|
40
|
-
expect(parseQuery(
|
|
39
|
+
test('handles key-only entry with no equals', () => {
|
|
40
|
+
expect(parseQuery('active')).toEqual({ active: '' })
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
-
test(
|
|
44
|
-
expect(parseQuery(
|
|
43
|
+
test('handles mixed entries', () => {
|
|
44
|
+
expect(parseQuery('a=1&flag&b=2')).toEqual({ a: '1', flag: '', b: '2' })
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
-
test(
|
|
48
|
-
expect(parseQuery(
|
|
47
|
+
test('decodes both keys and values', () => {
|
|
48
|
+
expect(parseQuery('na%2Fme=val%26ue')).toEqual({ 'na/me': 'val&ue' })
|
|
49
49
|
})
|
|
50
50
|
})
|
|
51
51
|
|
|
52
52
|
// ─── parseQueryMulti ─────────────────────────────────────────────────────────
|
|
53
53
|
|
|
54
|
-
describe(
|
|
55
|
-
test(
|
|
56
|
-
expect(parseQueryMulti(
|
|
54
|
+
describe('parseQueryMulti', () => {
|
|
55
|
+
test('returns empty object for empty string', () => {
|
|
56
|
+
expect(parseQueryMulti('')).toEqual({})
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
-
test(
|
|
60
|
-
expect(parseQueryMulti(
|
|
59
|
+
test('single value stays as string', () => {
|
|
60
|
+
expect(parseQueryMulti('color=red')).toEqual({ color: 'red' })
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
-
test(
|
|
64
|
-
expect(parseQueryMulti(
|
|
63
|
+
test('duplicate keys become arrays', () => {
|
|
64
|
+
expect(parseQueryMulti('color=red&color=blue')).toEqual({ color: ['red', 'blue'] })
|
|
65
65
|
})
|
|
66
66
|
|
|
67
|
-
test(
|
|
68
|
-
expect(parseQueryMulti(
|
|
67
|
+
test('triple duplicate keys become array of three', () => {
|
|
68
|
+
expect(parseQueryMulti('a=1&a=2&a=3')).toEqual({ a: ['1', '2', '3'] })
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
-
test(
|
|
72
|
-
expect(parseQueryMulti(
|
|
73
|
-
color: [
|
|
74
|
-
size:
|
|
71
|
+
test('mixed single and multi values', () => {
|
|
72
|
+
expect(parseQueryMulti('color=red&color=blue&size=lg')).toEqual({
|
|
73
|
+
color: ['red', 'blue'],
|
|
74
|
+
size: 'lg',
|
|
75
75
|
})
|
|
76
76
|
})
|
|
77
77
|
|
|
78
|
-
test(
|
|
79
|
-
expect(parseQueryMulti(
|
|
78
|
+
test('key without value', () => {
|
|
79
|
+
expect(parseQueryMulti('flag')).toEqual({ flag: '' })
|
|
80
80
|
})
|
|
81
81
|
|
|
82
|
-
test(
|
|
83
|
-
expect(parseQueryMulti(
|
|
82
|
+
test('key without value duplicated', () => {
|
|
83
|
+
expect(parseQueryMulti('flag&flag')).toEqual({ flag: ['', ''] })
|
|
84
84
|
})
|
|
85
85
|
|
|
86
|
-
test(
|
|
87
|
-
expect(parseQueryMulti(
|
|
86
|
+
test('empty key is skipped', () => {
|
|
87
|
+
expect(parseQueryMulti('=value')).toEqual({})
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
-
test(
|
|
91
|
-
expect(parseQueryMulti(
|
|
90
|
+
test('decodes URI-encoded keys and values', () => {
|
|
91
|
+
expect(parseQueryMulti('na%2Fme=val%26ue')).toEqual({ 'na/me': 'val&ue' })
|
|
92
92
|
})
|
|
93
93
|
})
|
|
94
94
|
|
|
95
95
|
// ─── stringifyQuery — edge cases ─────────────────────────────────────────────
|
|
96
96
|
|
|
97
|
-
describe(
|
|
98
|
-
test(
|
|
99
|
-
const result = stringifyQuery({
|
|
100
|
-
expect(result).toBe(
|
|
97
|
+
describe('stringifyQuery — edge cases', () => {
|
|
98
|
+
test('encodes special characters', () => {
|
|
99
|
+
const result = stringifyQuery({ 'key with space': 'value&more' })
|
|
100
|
+
expect(result).toBe('?key%20with%20space=value%26more')
|
|
101
101
|
})
|
|
102
102
|
|
|
103
|
-
test(
|
|
104
|
-
expect(stringifyQuery({ page:
|
|
103
|
+
test('handles single key-value pair', () => {
|
|
104
|
+
expect(stringifyQuery({ page: '1' })).toBe('?page=1')
|
|
105
105
|
})
|
|
106
106
|
|
|
107
|
-
test(
|
|
108
|
-
expect(stringifyQuery({ debug:
|
|
107
|
+
test('handles key with empty value', () => {
|
|
108
|
+
expect(stringifyQuery({ debug: '' })).toBe('?debug')
|
|
109
109
|
})
|
|
110
110
|
})
|
|
111
111
|
|
|
112
112
|
// ─── matchPath — edge cases ──────────────────────────────────────────────────
|
|
113
113
|
|
|
114
|
-
describe(
|
|
115
|
-
test(
|
|
116
|
-
const result = matchPath(
|
|
117
|
-
expect(result).toEqual({ path:
|
|
114
|
+
describe('matchPath — edge cases', () => {
|
|
115
|
+
test('splat param captures remaining path', () => {
|
|
116
|
+
const result = matchPath('/files/:path*', '/files/a/b/c')
|
|
117
|
+
expect(result).toEqual({ path: 'a/b/c' })
|
|
118
118
|
})
|
|
119
119
|
|
|
120
|
-
test(
|
|
121
|
-
const result = matchPath(
|
|
122
|
-
expect(result).toEqual({ path:
|
|
120
|
+
test('splat param captures single segment', () => {
|
|
121
|
+
const result = matchPath('/files/:path*', '/files/readme.txt')
|
|
122
|
+
expect(result).toEqual({ path: 'readme.txt' })
|
|
123
123
|
})
|
|
124
124
|
|
|
125
|
-
test(
|
|
126
|
-
const result = matchPath(
|
|
127
|
-
expect(result).toEqual({ id:
|
|
125
|
+
test('optional param matches with value', () => {
|
|
126
|
+
const result = matchPath('/user/:id?', '/user/42')
|
|
127
|
+
expect(result).toEqual({ id: '42' })
|
|
128
128
|
})
|
|
129
129
|
|
|
130
|
-
test(
|
|
131
|
-
const result = matchPath(
|
|
130
|
+
test('optional param matches without value', () => {
|
|
131
|
+
const result = matchPath('/user/:id?', '/user')
|
|
132
132
|
expect(result).toEqual({})
|
|
133
133
|
})
|
|
134
134
|
|
|
135
|
-
test(
|
|
136
|
-
expect(matchPath(
|
|
135
|
+
test('returns null for too many path segments', () => {
|
|
136
|
+
expect(matchPath('/a/b', '/a/b/c')).toBeNull()
|
|
137
137
|
})
|
|
138
138
|
|
|
139
|
-
test(
|
|
140
|
-
expect(matchPath(
|
|
139
|
+
test('exact static match returns empty params', () => {
|
|
140
|
+
expect(matchPath('/about', '/about')).toEqual({})
|
|
141
141
|
})
|
|
142
142
|
|
|
143
|
-
test(
|
|
144
|
-
expect(matchPath(
|
|
143
|
+
test('root path matches root pattern', () => {
|
|
144
|
+
expect(matchPath('/', '/')).toEqual({})
|
|
145
145
|
})
|
|
146
146
|
|
|
147
|
-
test(
|
|
148
|
-
expect(matchPath(
|
|
147
|
+
test('mismatched static segment returns null', () => {
|
|
148
|
+
expect(matchPath('/foo', '/bar')).toBeNull()
|
|
149
149
|
})
|
|
150
150
|
|
|
151
|
-
test(
|
|
152
|
-
const result = matchPath(
|
|
153
|
-
expect(result).toEqual({ name:
|
|
151
|
+
test('decodes URI-encoded segments', () => {
|
|
152
|
+
const result = matchPath('/user/:name', '/user/hello%20world')
|
|
153
|
+
expect(result).toEqual({ name: 'hello world' })
|
|
154
154
|
})
|
|
155
155
|
|
|
156
|
-
test(
|
|
157
|
-
const result = matchPath(
|
|
158
|
-
expect(result).toEqual({ a:
|
|
156
|
+
test('multiple params in a row', () => {
|
|
157
|
+
const result = matchPath('/:a/:b/:c', '/x/y/z')
|
|
158
|
+
expect(result).toEqual({ a: 'x', b: 'y', c: 'z' })
|
|
159
159
|
})
|
|
160
160
|
})
|
|
161
161
|
|
|
162
162
|
// ─── resolveRoute — edge cases ───────────────────────────────────────────────
|
|
163
163
|
|
|
164
|
-
describe(
|
|
164
|
+
describe('resolveRoute — edge cases', () => {
|
|
165
165
|
const routes: RouteRecord[] = [
|
|
166
|
-
{ path:
|
|
167
|
-
{ path:
|
|
168
|
-
{ path:
|
|
166
|
+
{ path: '/', component: Home },
|
|
167
|
+
{ path: '/about', component: About },
|
|
168
|
+
{ path: '/user/:id', component: User },
|
|
169
169
|
{
|
|
170
|
-
path:
|
|
170
|
+
path: '/admin',
|
|
171
171
|
component: Home,
|
|
172
172
|
meta: { requiresAuth: true },
|
|
173
173
|
children: [
|
|
174
|
-
{ path:
|
|
175
|
-
{ path:
|
|
174
|
+
{ path: 'users', component: User },
|
|
175
|
+
{ path: 'settings', component: About },
|
|
176
176
|
],
|
|
177
177
|
},
|
|
178
|
-
{ path:
|
|
178
|
+
{ path: '*', component: NotFound },
|
|
179
179
|
]
|
|
180
180
|
|
|
181
|
-
test(
|
|
182
|
-
const r = resolveRoute(
|
|
183
|
-
expect(r.path).toBe(
|
|
181
|
+
test('resolves root path with empty query', () => {
|
|
182
|
+
const r = resolveRoute('/', routes)
|
|
183
|
+
expect(r.path).toBe('/')
|
|
184
184
|
expect(r.params).toEqual({})
|
|
185
185
|
expect(r.query).toEqual({})
|
|
186
|
-
expect(r.hash).toBe(
|
|
186
|
+
expect(r.hash).toBe('')
|
|
187
187
|
})
|
|
188
188
|
|
|
189
|
-
test(
|
|
189
|
+
test('resolves path with query and hash in path portion', () => {
|
|
190
190
|
// Hash in the path portion (before ?) is extracted from pathAndHash
|
|
191
|
-
const r = resolveRoute(
|
|
192
|
-
expect(r.path).toBe(
|
|
193
|
-
expect(r.hash).toBe(
|
|
194
|
-
expect(r.query).toEqual({ key:
|
|
191
|
+
const r = resolveRoute('/about#section?key=val', routes)
|
|
192
|
+
expect(r.path).toBe('/about')
|
|
193
|
+
expect(r.hash).toBe('section')
|
|
194
|
+
expect(r.query).toEqual({ key: 'val' })
|
|
195
195
|
})
|
|
196
196
|
|
|
197
|
-
test(
|
|
197
|
+
test('resolves path with query containing hash (hash after query)', () => {
|
|
198
198
|
// When hash follows query: /about?key=val#section
|
|
199
199
|
// The # is part of the query value since ? comes first
|
|
200
|
-
const r = resolveRoute(
|
|
201
|
-
expect(r.path).toBe(
|
|
200
|
+
const r = resolveRoute('/about?key=val#section', routes)
|
|
201
|
+
expect(r.path).toBe('/about')
|
|
202
202
|
// The hash ends up in the query value since it's after the ?
|
|
203
|
-
expect(r.query.key).toContain(
|
|
203
|
+
expect(r.query.key).toContain('val')
|
|
204
204
|
})
|
|
205
205
|
|
|
206
|
-
test(
|
|
207
|
-
const r = resolveRoute(
|
|
206
|
+
test('resolves nested route with merged meta', () => {
|
|
207
|
+
const r = resolveRoute('/admin/users', routes)
|
|
208
208
|
expect(r.matched.length).toBe(2)
|
|
209
209
|
expect(r.meta.requiresAuth).toBe(true)
|
|
210
210
|
})
|
|
211
211
|
|
|
212
|
-
test(
|
|
213
|
-
const r = resolveRoute(
|
|
214
|
-
expect(r.params.id).toBe(
|
|
212
|
+
test('resolves dynamic param route', () => {
|
|
213
|
+
const r = resolveRoute('/user/123', routes)
|
|
214
|
+
expect(r.params.id).toBe('123')
|
|
215
215
|
expect(r.matched.length).toBeGreaterThan(0)
|
|
216
216
|
})
|
|
217
217
|
|
|
218
|
-
test(
|
|
219
|
-
const r = resolveRoute(
|
|
218
|
+
test('wildcard catches unmatched paths', () => {
|
|
219
|
+
const r = resolveRoute('/totally/unknown/path', routes)
|
|
220
220
|
expect(r.matched.length).toBeGreaterThan(0)
|
|
221
221
|
expect(r.matched[r.matched.length - 1]?.component).toBe(NotFound)
|
|
222
222
|
})
|
|
223
223
|
|
|
224
|
-
test(
|
|
224
|
+
test('returns empty matched for no match without wildcard', () => {
|
|
225
225
|
const simpleRoutes: RouteRecord[] = [
|
|
226
|
-
{ path:
|
|
227
|
-
{ path:
|
|
226
|
+
{ path: '/', component: Home },
|
|
227
|
+
{ path: '/about', component: About },
|
|
228
228
|
]
|
|
229
|
-
const r = resolveRoute(
|
|
229
|
+
const r = resolveRoute('/nonexistent', simpleRoutes)
|
|
230
230
|
expect(r.matched).toHaveLength(0)
|
|
231
231
|
})
|
|
232
232
|
|
|
233
|
-
test(
|
|
233
|
+
test('resolves path with hash before query (edge case)', () => {
|
|
234
234
|
// hash in the path portion (before ?), query is separate
|
|
235
|
-
const r = resolveRoute(
|
|
236
|
-
expect(r.hash).toBe(
|
|
235
|
+
const r = resolveRoute('/#anchor?key=val', routes)
|
|
236
|
+
expect(r.hash).toBe('anchor')
|
|
237
237
|
})
|
|
238
238
|
|
|
239
|
-
test(
|
|
239
|
+
test('resolves deeply nested routes', () => {
|
|
240
240
|
const deepRoutes: RouteRecord[] = [
|
|
241
241
|
{
|
|
242
|
-
path:
|
|
242
|
+
path: '/a',
|
|
243
243
|
component: Home,
|
|
244
244
|
children: [
|
|
245
245
|
{
|
|
246
|
-
path:
|
|
246
|
+
path: 'b',
|
|
247
247
|
component: About,
|
|
248
|
-
children: [{ path:
|
|
248
|
+
children: [{ path: 'c', component: User }],
|
|
249
249
|
},
|
|
250
250
|
],
|
|
251
251
|
},
|
|
252
252
|
]
|
|
253
|
-
const r = resolveRoute(
|
|
253
|
+
const r = resolveRoute('/a/b/c', deepRoutes)
|
|
254
254
|
expect(r.matched.length).toBe(3)
|
|
255
255
|
})
|
|
256
256
|
|
|
257
|
-
test(
|
|
258
|
-
const optRoutes: RouteRecord[] = [{ path:
|
|
259
|
-
const withParam = resolveRoute(
|
|
260
|
-
expect(withParam.params.slug).toBe(
|
|
257
|
+
test('optional param route matches with and without param', () => {
|
|
258
|
+
const optRoutes: RouteRecord[] = [{ path: '/page/:slug?', component: Home }]
|
|
259
|
+
const withParam = resolveRoute('/page/about', optRoutes)
|
|
260
|
+
expect(withParam.params.slug).toBe('about')
|
|
261
261
|
|
|
262
|
-
const withoutParam = resolveRoute(
|
|
262
|
+
const withoutParam = resolveRoute('/page', optRoutes)
|
|
263
263
|
expect(withoutParam.matched.length).toBeGreaterThan(0)
|
|
264
264
|
})
|
|
265
265
|
|
|
266
|
-
test(
|
|
267
|
-
const splatRoutes: RouteRecord[] = [{ path:
|
|
268
|
-
const r = resolveRoute(
|
|
269
|
-
expect(r.params.rest).toBe(
|
|
266
|
+
test('splat route captures all remaining segments', () => {
|
|
267
|
+
const splatRoutes: RouteRecord[] = [{ path: '/docs/:rest*', component: Home }]
|
|
268
|
+
const r = resolveRoute('/docs/api/reference/types', splatRoutes)
|
|
269
|
+
expect(r.params.rest).toBe('api/reference/types')
|
|
270
270
|
})
|
|
271
271
|
|
|
272
|
-
test(
|
|
273
|
-
const r1 = resolveRoute(
|
|
274
|
-
const r2 = resolveRoute(
|
|
272
|
+
test('caches compiled routes (same reference gives same result)', () => {
|
|
273
|
+
const r1 = resolveRoute('/about', routes)
|
|
274
|
+
const r2 = resolveRoute('/about', routes)
|
|
275
275
|
expect(r1.path).toBe(r2.path)
|
|
276
276
|
expect(r1.matched.length).toBe(r2.matched.length)
|
|
277
277
|
})
|
|
@@ -279,189 +279,189 @@ describe("resolveRoute — edge cases", () => {
|
|
|
279
279
|
|
|
280
280
|
// ─── resolveRoute — alias support ────────────────────────────────────────────
|
|
281
281
|
|
|
282
|
-
describe(
|
|
283
|
-
test(
|
|
282
|
+
describe('resolveRoute — alias', () => {
|
|
283
|
+
test('alias string resolves to same component', () => {
|
|
284
284
|
const aliasRoutes: RouteRecord[] = [
|
|
285
|
-
{ path:
|
|
285
|
+
{ path: '/user/:id', alias: '/profile/:id', component: User },
|
|
286
286
|
]
|
|
287
|
-
const r = resolveRoute(
|
|
287
|
+
const r = resolveRoute('/profile/42', aliasRoutes)
|
|
288
288
|
expect(r.matched.length).toBeGreaterThan(0)
|
|
289
289
|
expect(r.matched[0]?.component).toBe(User)
|
|
290
|
-
expect(r.params.id).toBe(
|
|
290
|
+
expect(r.params.id).toBe('42')
|
|
291
291
|
})
|
|
292
292
|
|
|
293
|
-
test(
|
|
293
|
+
test('alias array resolves multiple paths to same component', () => {
|
|
294
294
|
const aliasRoutes: RouteRecord[] = [
|
|
295
|
-
{ path:
|
|
295
|
+
{ path: '/home', alias: ['/index', '/main'], component: Home },
|
|
296
296
|
]
|
|
297
|
-
const r1 = resolveRoute(
|
|
298
|
-
const r2 = resolveRoute(
|
|
297
|
+
const r1 = resolveRoute('/index', aliasRoutes)
|
|
298
|
+
const r2 = resolveRoute('/main', aliasRoutes)
|
|
299
299
|
expect(r1.matched[0]?.component).toBe(Home)
|
|
300
300
|
expect(r2.matched[0]?.component).toBe(Home)
|
|
301
301
|
})
|
|
302
302
|
|
|
303
|
-
test(
|
|
304
|
-
const aliasRoutes: RouteRecord[] = [{ path:
|
|
305
|
-
const r = resolveRoute(
|
|
303
|
+
test('primary path still works with alias defined', () => {
|
|
304
|
+
const aliasRoutes: RouteRecord[] = [{ path: '/home', alias: '/index', component: Home }]
|
|
305
|
+
const r = resolveRoute('/home', aliasRoutes)
|
|
306
306
|
expect(r.matched[0]?.component).toBe(Home)
|
|
307
307
|
})
|
|
308
308
|
})
|
|
309
309
|
|
|
310
310
|
// ─── buildPath — edge cases ──────────────────────────────────────────────────
|
|
311
311
|
|
|
312
|
-
describe(
|
|
313
|
-
test(
|
|
314
|
-
const result = buildPath(
|
|
315
|
-
expect(result).toBe(
|
|
312
|
+
describe('buildPath — edge cases', () => {
|
|
313
|
+
test('omits segment for missing optional param', () => {
|
|
314
|
+
const result = buildPath('/user/:id?', {})
|
|
315
|
+
expect(result).toBe('/user')
|
|
316
316
|
})
|
|
317
317
|
|
|
318
|
-
test(
|
|
319
|
-
const result = buildPath(
|
|
320
|
-
expect(result).toBe(
|
|
318
|
+
test('includes segment for provided optional param', () => {
|
|
319
|
+
const result = buildPath('/user/:id?', { id: '42' })
|
|
320
|
+
expect(result).toBe('/user/42')
|
|
321
321
|
})
|
|
322
322
|
|
|
323
|
-
test(
|
|
323
|
+
test('splat param preserves slashes', () => {
|
|
324
324
|
// buildPath regex captures the full param name including * via [^/]+
|
|
325
325
|
// so the key in params must match what the regex captures
|
|
326
|
-
const result = buildPath(
|
|
327
|
-
expect(result).toBe(
|
|
326
|
+
const result = buildPath('/docs/:path*', { 'path*': 'api/reference/types' })
|
|
327
|
+
expect(result).toBe('/docs/api/reference/types')
|
|
328
328
|
})
|
|
329
329
|
|
|
330
|
-
test(
|
|
331
|
-
const result = buildPath(
|
|
332
|
-
expect(result).toBe(
|
|
330
|
+
test('encodes special characters in params', () => {
|
|
331
|
+
const result = buildPath('/user/:name', { name: 'hello world' })
|
|
332
|
+
expect(result).toBe('/user/hello%20world')
|
|
333
333
|
})
|
|
334
334
|
|
|
335
|
-
test(
|
|
336
|
-
const result = buildPath(
|
|
337
|
-
expect(result).toBe(
|
|
335
|
+
test('handles path with no params', () => {
|
|
336
|
+
const result = buildPath('/about', {})
|
|
337
|
+
expect(result).toBe('/about')
|
|
338
338
|
})
|
|
339
339
|
|
|
340
|
-
test(
|
|
341
|
-
const result = buildPath(
|
|
342
|
-
expect(result).toBe(
|
|
340
|
+
test('handles root path', () => {
|
|
341
|
+
const result = buildPath('/', {})
|
|
342
|
+
expect(result).toBe('/')
|
|
343
343
|
})
|
|
344
344
|
|
|
345
|
-
test(
|
|
345
|
+
test('encodes splat param segments individually', () => {
|
|
346
346
|
// buildPath regex captures full param name including * via [^/]+
|
|
347
|
-
const result = buildPath(
|
|
348
|
-
expect(result).toBe(
|
|
347
|
+
const result = buildPath('/files/:path*', { 'path*': 'dir/my file.txt' })
|
|
348
|
+
expect(result).toBe('/files/dir/my%20file.txt')
|
|
349
349
|
})
|
|
350
350
|
})
|
|
351
351
|
|
|
352
352
|
// ─── findRouteByName — edge cases ────────────────────────────────────────────
|
|
353
353
|
|
|
354
|
-
describe(
|
|
355
|
-
test(
|
|
354
|
+
describe('findRouteByName — edge cases', () => {
|
|
355
|
+
test('finds deeply nested route', () => {
|
|
356
356
|
const routes: RouteRecord[] = [
|
|
357
357
|
{
|
|
358
|
-
path:
|
|
358
|
+
path: '/a',
|
|
359
359
|
component: Home,
|
|
360
360
|
children: [
|
|
361
361
|
{
|
|
362
|
-
path:
|
|
362
|
+
path: 'b',
|
|
363
363
|
component: About,
|
|
364
|
-
children: [{ path:
|
|
364
|
+
children: [{ path: 'c', component: User, name: 'deep' }],
|
|
365
365
|
},
|
|
366
366
|
],
|
|
367
367
|
},
|
|
368
368
|
]
|
|
369
|
-
const found = findRouteByName(
|
|
369
|
+
const found = findRouteByName('deep', routes)
|
|
370
370
|
expect(found).not.toBeNull()
|
|
371
|
-
expect(found?.path).toBe(
|
|
371
|
+
expect(found?.path).toBe('c')
|
|
372
372
|
})
|
|
373
373
|
|
|
374
|
-
test(
|
|
374
|
+
test('returns first match in definition order', () => {
|
|
375
375
|
const routes: RouteRecord[] = [
|
|
376
|
-
{ path:
|
|
377
|
-
{ path:
|
|
376
|
+
{ path: '/first', component: Home, name: 'dup' },
|
|
377
|
+
{ path: '/second', component: About, name: 'dup' },
|
|
378
378
|
]
|
|
379
|
-
const found = findRouteByName(
|
|
380
|
-
expect(found?.path).toBe(
|
|
379
|
+
const found = findRouteByName('dup', routes)
|
|
380
|
+
expect(found?.path).toBe('/first')
|
|
381
381
|
})
|
|
382
382
|
|
|
383
|
-
test(
|
|
384
|
-
expect(findRouteByName(
|
|
383
|
+
test('returns null for empty routes array', () => {
|
|
384
|
+
expect(findRouteByName('anything', [])).toBeNull()
|
|
385
385
|
})
|
|
386
386
|
})
|
|
387
387
|
|
|
388
388
|
// ─── buildNameIndex — edge cases ─────────────────────────────────────────────
|
|
389
389
|
|
|
390
|
-
describe(
|
|
391
|
-
test(
|
|
390
|
+
describe('buildNameIndex — edge cases', () => {
|
|
391
|
+
test('handles empty routes', () => {
|
|
392
392
|
const index = buildNameIndex([])
|
|
393
393
|
expect(index.size).toBe(0)
|
|
394
394
|
})
|
|
395
395
|
|
|
396
|
-
test(
|
|
396
|
+
test('does not index routes without names', () => {
|
|
397
397
|
const routes: RouteRecord[] = [
|
|
398
|
-
{ path:
|
|
399
|
-
{ path:
|
|
398
|
+
{ path: '/', component: Home },
|
|
399
|
+
{ path: '/about', component: About },
|
|
400
400
|
]
|
|
401
401
|
const index = buildNameIndex(routes)
|
|
402
402
|
expect(index.size).toBe(0)
|
|
403
403
|
})
|
|
404
404
|
|
|
405
|
-
test(
|
|
405
|
+
test('indexes deeply nested named routes', () => {
|
|
406
406
|
const routes: RouteRecord[] = [
|
|
407
407
|
{
|
|
408
|
-
path:
|
|
408
|
+
path: '/a',
|
|
409
409
|
component: Home,
|
|
410
|
-
name:
|
|
410
|
+
name: 'a',
|
|
411
411
|
children: [
|
|
412
412
|
{
|
|
413
|
-
path:
|
|
413
|
+
path: 'b',
|
|
414
414
|
component: About,
|
|
415
|
-
name:
|
|
416
|
-
children: [{ path:
|
|
415
|
+
name: 'b',
|
|
416
|
+
children: [{ path: 'c', component: User, name: 'c' }],
|
|
417
417
|
},
|
|
418
418
|
],
|
|
419
419
|
},
|
|
420
420
|
]
|
|
421
421
|
const index = buildNameIndex(routes)
|
|
422
422
|
expect(index.size).toBe(3)
|
|
423
|
-
expect(index.get(
|
|
423
|
+
expect(index.get('c')?.path).toBe('c')
|
|
424
424
|
})
|
|
425
425
|
})
|
|
426
426
|
|
|
427
427
|
// ─── resolveRoute — dynamic first segment ────────────────────────────────────
|
|
428
428
|
|
|
429
|
-
describe(
|
|
430
|
-
test(
|
|
431
|
-
const routes: RouteRecord[] = [{ path:
|
|
432
|
-
const r = resolveRoute(
|
|
429
|
+
describe('resolveRoute — dynamic first segment routing', () => {
|
|
430
|
+
test('matches route where first segment is a param', () => {
|
|
431
|
+
const routes: RouteRecord[] = [{ path: '/:lang/about', component: About }]
|
|
432
|
+
const r = resolveRoute('/en/about', routes)
|
|
433
433
|
expect(r.matched.length).toBeGreaterThan(0)
|
|
434
|
-
expect(r.params.lang).toBe(
|
|
434
|
+
expect(r.params.lang).toBe('en')
|
|
435
435
|
})
|
|
436
436
|
|
|
437
|
-
test(
|
|
437
|
+
test('static routes take priority over dynamic first segment', () => {
|
|
438
438
|
const routes: RouteRecord[] = [
|
|
439
|
-
{ path:
|
|
440
|
-
{ path:
|
|
439
|
+
{ path: '/about', component: About },
|
|
440
|
+
{ path: '/:slug', component: User },
|
|
441
441
|
]
|
|
442
|
-
const r = resolveRoute(
|
|
442
|
+
const r = resolveRoute('/about', routes)
|
|
443
443
|
expect(r.matched[0]?.component).toBe(About)
|
|
444
444
|
})
|
|
445
445
|
})
|
|
446
446
|
|
|
447
447
|
// ─── resolveRoute — wildcard children ────────────────────────────────────────
|
|
448
448
|
|
|
449
|
-
describe(
|
|
450
|
-
test(
|
|
449
|
+
describe('resolveRoute — wildcard patterns', () => {
|
|
450
|
+
test('(.*) catches any path', () => {
|
|
451
451
|
const routes: RouteRecord[] = [
|
|
452
|
-
{ path:
|
|
453
|
-
{ path:
|
|
452
|
+
{ path: '/', component: Home },
|
|
453
|
+
{ path: '(.*)', component: NotFound },
|
|
454
454
|
]
|
|
455
|
-
const r = resolveRoute(
|
|
455
|
+
const r = resolveRoute('/any/path/here', routes)
|
|
456
456
|
expect(r.matched[r.matched.length - 1]?.component).toBe(NotFound)
|
|
457
457
|
})
|
|
458
458
|
|
|
459
|
-
test(
|
|
459
|
+
test('* catches any path', () => {
|
|
460
460
|
const routes: RouteRecord[] = [
|
|
461
|
-
{ path:
|
|
462
|
-
{ path:
|
|
461
|
+
{ path: '/', component: Home },
|
|
462
|
+
{ path: '*', component: NotFound },
|
|
463
463
|
]
|
|
464
|
-
const r = resolveRoute(
|
|
464
|
+
const r = resolveRoute('/any/path/here', routes)
|
|
465
465
|
expect(r.matched[r.matched.length - 1]?.component).toBe(NotFound)
|
|
466
466
|
})
|
|
467
467
|
})
|