@buzola/router 0.0.9 → 0.0.11
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/dist/engine/matcher.d.ts +6 -4
- package/dist/engine/matcher.d.ts.map +1 -1
- package/dist/engine/matcher.js +35 -84
- package/dist/engine/matcher.js.map +1 -1
- package/dist/engine/route-tree.d.ts +8 -6
- package/dist/engine/route-tree.d.ts.map +1 -1
- package/dist/engine/route-tree.js +89 -55
- package/dist/engine/route-tree.js.map +1 -1
- package/dist/engine/router.d.ts +4 -4
- package/dist/engine/router.d.ts.map +1 -1
- package/dist/engine/router.js.map +1 -1
- package/dist/engine/types.d.ts +28 -9
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +1 -1
- package/dist/react/provider.d.ts +2 -2
- package/dist/react/provider.d.ts.map +1 -1
- package/package.json +2 -4
- package/src/__tests__/matcher.test.ts +169 -0
- package/src/__tests__/router.test.ts +9 -3
- package/src/engine/matcher.ts +44 -88
- package/src/engine/route-tree.ts +108 -62
- package/src/engine/router.ts +5 -5
- package/src/engine/types.ts +27 -9
- package/src/env.d.ts +1 -45
- package/src/index.ts +1 -3
- package/src/react/provider.tsx +2 -2
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAEtD,OAAO,EAAE,sBAAsB,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAA;AACnE,YAAY,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAEzE,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AACxE,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AAEpE,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAEjD,OAAO,EAAE,8BAA8B,EAAE,6BAA6B,EAAE,MAAM,gCAAgC,CAAA;AAC9G,YAAY,EAAE,8BAA8B,EAAE,MAAM,gCAAgC,CAAA;AAGpF,YAAY,EACX,SAAS,EACT,mBAAmB,EACnB,aAAa,EACb,sBAAsB,EACtB,mBAAmB,EACnB,eAAe,EACf,iBAAiB,EACjB,UAAU,EACV,cAAc,EACd,cAAc,EACd,WAAW,EACX,UAAU,EACV,SAAS,EACT,WAAW,EACX,SAAS,EACT,cAAc,GACd,MAAM,mBAAmB,CAAA;AAG1B,OAAO,EAAE,CAAC,EAAE,MAAM,oBAAoB,CAAA;AAGtC,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AAC9E,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAA;AAGlG,OAAO,EACN,cAAc,EACd,aAAa,EACb,IAAI,EACJ,MAAM,EACN,YAAY,EACZ,aAAa,EACb,UAAU,EACV,aAAa,EACb,WAAW,EACX,kBAAkB,EAClB,SAAS,EACT,QAAQ,EACR,SAAS,EACT,cAAc,EACd,eAAe,GACf,MAAM,kBAAkB,CAAA;AACzB,YAAY,EAAE,YAAY,EAAE,mBAAmB,EAAE,SAAS,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// ─── Polyfills ──────────────────────────────────────────────────────────────
|
|
2
|
-
import 'urlpattern-polyfill';
|
|
3
1
|
// ─── Engine ─────────────────────────────────────────────────────────────────
|
|
4
2
|
export { LoaderCache } from './engine/loader-cache.js';
|
|
5
3
|
export { NavigationAbortedError, Router } from './engine/router.js';
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,OAAO,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAEtD,OAAO,EAAE,sBAAsB,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAA;AAGnE,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAGxE,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAEjD,OAAO,EAAE,8BAA8B,EAAE,6BAA6B,EAAE,MAAM,gCAAgC,CAAA;AAuB9G,gFAAgF;AAChF,OAAO,EAAE,CAAC,EAAE,MAAM,oBAAoB,CAAA;AAEtC,+EAA+E;AAC/E,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AAG9E,+EAA+E;AAC/E,OAAO,EACN,cAAc,EACd,aAAa,EACb,IAAI,EACJ,MAAM,EACN,YAAY,EACZ,aAAa,EACb,UAAU,EACV,aAAa,EACb,WAAW,EACX,kBAAkB,EAClB,SAAS,EACT,QAAQ,EACR,SAAS,EACT,cAAc,EACd,eAAe,GACf,MAAM,kBAAkB,CAAA"}
|
package/dist/react/provider.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Router } from '../engine/router.js';
|
|
3
|
-
import type { RouteMatch,
|
|
3
|
+
import type { RouteMatch, RouteTree } from '../engine/types.js';
|
|
4
4
|
/** Props passed to the middleware component. */
|
|
5
5
|
export interface MiddlewareProps {
|
|
6
6
|
/** Merged params from all matched routes. */
|
|
@@ -23,7 +23,7 @@ export type BuzolaProviderProps = {
|
|
|
23
23
|
pageRegistry?: never;
|
|
24
24
|
} | {
|
|
25
25
|
router?: never;
|
|
26
|
-
routes:
|
|
26
|
+
routes: RouteTree;
|
|
27
27
|
persistentParams?: () => Record<string, string>;
|
|
28
28
|
pageRegistry?: Record<string, string>;
|
|
29
29
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../../src/react/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA8E,MAAM,OAAO,CAAA;AAElG,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAC5C,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAI/D,gDAAgD;AAChD,MAAM,WAAW,eAAe;IAC/B,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,2CAA2C;IAC3C,OAAO,EAAE,UAAU,EAAE,CAAA;IACrB,uCAAuC;IACvC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CACzB;AAED,gEAAgE;AAChE,MAAM,MAAM,mBAAmB,GAAG,KAAK,CAAC,aAAa,CAAC,eAAe,CAAC,CAAA;AAEtE,MAAM,MAAM,mBAAmB,GAC5B;IACD,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC1B,8FAA8F;IAC9F,UAAU,CAAC,EAAE,mBAAmB,CAAA;CAChC,GACC,CACC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,KAAK,CAAC;IAAC,gBAAgB,CAAC,EAAE,KAAK,CAAC;IAAC,YAAY,CAAC,EAAE,KAAK,CAAA;CAAE,GAClF;IACD,MAAM,CAAC,EAAE,KAAK,CAAA;IACd,MAAM,EAAE,SAAS,
|
|
1
|
+
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../../src/react/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA8E,MAAM,OAAO,CAAA;AAElG,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAC5C,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAI/D,gDAAgD;AAChD,MAAM,WAAW,eAAe;IAC/B,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,2CAA2C;IAC3C,OAAO,EAAE,UAAU,EAAE,CAAA;IACrB,uCAAuC;IACvC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CACzB;AAED,gEAAgE;AAChE,MAAM,MAAM,mBAAmB,GAAG,KAAK,CAAC,aAAa,CAAC,eAAe,CAAC,CAAA;AAEtE,MAAM,MAAM,mBAAmB,GAC5B;IACD,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC1B,8FAA8F;IAC9F,UAAU,CAAC,EAAE,mBAAmB,CAAA;CAChC,GACC,CACC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,KAAK,CAAC;IAAC,gBAAgB,CAAC,EAAE,KAAK,CAAC;IAAC,YAAY,CAAC,EAAE,KAAK,CAAA;CAAE,GAClF;IACD,MAAM,CAAC,EAAE,KAAK,CAAA;IACd,MAAM,EAAE,SAAS,CAAA;IACjB,gBAAgB,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC/C,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACrC,CACD,CAAA;AAEF;;;GAGG;AACH,wBAAgB,cAAc,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,UAAU,EAAE,GAAG,KAAK,EAAE,EAAE,mBAAmB,GAAG,KAAK,CAAC,YAAY,CA+C9H"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@buzola/router",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -37,7 +37,5 @@
|
|
|
37
37
|
"react": "^19.0.0",
|
|
38
38
|
"typescript": "^5.7.0"
|
|
39
39
|
},
|
|
40
|
-
"dependencies": {
|
|
41
|
-
"urlpattern-polyfill": "^10.1.0"
|
|
42
|
-
}
|
|
40
|
+
"dependencies": {}
|
|
43
41
|
}
|
|
@@ -189,6 +189,175 @@ describe('matchRoutes', () => {
|
|
|
189
189
|
expect(matches![0].params).toEqual({})
|
|
190
190
|
})
|
|
191
191
|
|
|
192
|
+
// ─── matchPath override tests (file tree ≠ URL tree) ────────────────────
|
|
193
|
+
|
|
194
|
+
it('matches static route over layout with dynamic matchPath children (real-world admin structure)', () => {
|
|
195
|
+
// Mirrors the actual buzola.gen.ts structure from webmaster/admin:
|
|
196
|
+
// - Layout "project/detail" with matchPath children "/app/project/:id"
|
|
197
|
+
// - Static sibling "project/create" at "/app/project/create"
|
|
198
|
+
const tree = createTestTree([
|
|
199
|
+
{
|
|
200
|
+
path: '/',
|
|
201
|
+
component: dummyComponent,
|
|
202
|
+
isLayout: true,
|
|
203
|
+
children: [
|
|
204
|
+
{
|
|
205
|
+
path: '/app',
|
|
206
|
+
component: dummyComponent,
|
|
207
|
+
isLayout: true,
|
|
208
|
+
children: [
|
|
209
|
+
{
|
|
210
|
+
path: '/project/detail',
|
|
211
|
+
component: dummyComponent,
|
|
212
|
+
isLayout: true,
|
|
213
|
+
children: [
|
|
214
|
+
{ path: '/', matchPath: '/app/project/:id', component: dummyComponent, isIndex: true },
|
|
215
|
+
{ path: '/edit', matchPath: '/app/project/:id/edit', component: dummyComponent },
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
{ path: '/project/create', component: dummyComponent },
|
|
219
|
+
{ path: '/project', component: dummyComponent },
|
|
220
|
+
{ path: '/dashboard', component: dummyComponent },
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
])
|
|
226
|
+
|
|
227
|
+
// Static /app/project/create must NOT match the dynamic /app/project/:id
|
|
228
|
+
const createMatches = matchRoutes(tree, new URL('http://localhost/app/project/create'))
|
|
229
|
+
expect(createMatches).not.toBeNull()
|
|
230
|
+
const createLeaf = createMatches![createMatches!.length - 1]
|
|
231
|
+
expect(createLeaf.node.fullPath).toBe('/app/project/create')
|
|
232
|
+
expect(createLeaf.params).toEqual({})
|
|
233
|
+
|
|
234
|
+
// Dynamic /app/project/42 must match with correct param
|
|
235
|
+
const detailMatches = matchRoutes(tree, new URL('http://localhost/app/project/42'))
|
|
236
|
+
expect(detailMatches).not.toBeNull()
|
|
237
|
+
const detailLeaf = detailMatches![detailMatches!.length - 1]
|
|
238
|
+
expect(detailLeaf.node.fullPath).toBe('/app/project/:id')
|
|
239
|
+
expect(detailLeaf.params).toEqual({ id: '42' })
|
|
240
|
+
|
|
241
|
+
// Nested /app/project/42/edit must match and include layout chain
|
|
242
|
+
const editMatches = matchRoutes(tree, new URL('http://localhost/app/project/42/edit'))
|
|
243
|
+
expect(editMatches).not.toBeNull()
|
|
244
|
+
expect(editMatches!.length).toBeGreaterThanOrEqual(3) // root layout + app layout + detail layout + edit page
|
|
245
|
+
const editLeaf = editMatches![editMatches!.length - 1]
|
|
246
|
+
expect(editLeaf.node.fullPath).toBe('/app/project/:id/edit')
|
|
247
|
+
expect(editLeaf.params).toEqual({ id: '42' })
|
|
248
|
+
|
|
249
|
+
// Layout chain must include the detail layout
|
|
250
|
+
const detailLayout = editMatches!.find(m => m.node.isLayout && m.node.fullPath === '/app/project/detail')
|
|
251
|
+
expect(detailLayout).toBeDefined()
|
|
252
|
+
|
|
253
|
+
// /app/project (index) must match
|
|
254
|
+
const listMatches = matchRoutes(tree, new URL('http://localhost/app/project'))
|
|
255
|
+
expect(listMatches).not.toBeNull()
|
|
256
|
+
const listLeaf = listMatches![listMatches!.length - 1]
|
|
257
|
+
expect(listLeaf.node.fullPath).toBe('/app/project')
|
|
258
|
+
|
|
259
|
+
// /app/dashboard must still work
|
|
260
|
+
const dashMatches = matchRoutes(tree, new URL('http://localhost/app/dashboard'))
|
|
261
|
+
expect(dashMatches).not.toBeNull()
|
|
262
|
+
expect(dashMatches![dashMatches!.length - 1].node.fullPath).toBe('/app/dashboard')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('handles multiple resources with matchPath: create vs :id pattern', () => {
|
|
266
|
+
// Tests the pattern repeated across organization, voucher, feature-flag-set, etc.
|
|
267
|
+
const tree = createTestTree([
|
|
268
|
+
{
|
|
269
|
+
path: '/',
|
|
270
|
+
component: dummyComponent,
|
|
271
|
+
isLayout: true,
|
|
272
|
+
children: [
|
|
273
|
+
{ path: '/organization/create', component: dummyComponent },
|
|
274
|
+
{ path: '/organization/edit', matchPath: '/organization/:id', component: dummyComponent },
|
|
275
|
+
{ path: '/organization', component: dummyComponent },
|
|
276
|
+
{ path: '/voucher/create', component: dummyComponent },
|
|
277
|
+
{ path: '/voucher/edit', matchPath: '/voucher/:id', component: dummyComponent },
|
|
278
|
+
{ path: '/voucher', component: dummyComponent },
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
])
|
|
282
|
+
|
|
283
|
+
// Static create routes
|
|
284
|
+
const orgCreate = matchRoutes(tree, new URL('http://localhost/organization/create'))
|
|
285
|
+
expect(orgCreate).not.toBeNull()
|
|
286
|
+
expect(orgCreate![orgCreate!.length - 1].node.fullPath).toBe('/organization/create')
|
|
287
|
+
expect(orgCreate![orgCreate!.length - 1].params).toEqual({})
|
|
288
|
+
|
|
289
|
+
const voucherCreate = matchRoutes(tree, new URL('http://localhost/voucher/create'))
|
|
290
|
+
expect(voucherCreate).not.toBeNull()
|
|
291
|
+
expect(voucherCreate![voucherCreate!.length - 1].node.fullPath).toBe('/voucher/create')
|
|
292
|
+
|
|
293
|
+
// Dynamic :id routes
|
|
294
|
+
const orgEdit = matchRoutes(tree, new URL('http://localhost/organization/abc-123'))
|
|
295
|
+
expect(orgEdit).not.toBeNull()
|
|
296
|
+
expect(orgEdit![orgEdit!.length - 1].node.fullPath).toBe('/organization/:id')
|
|
297
|
+
expect(orgEdit![orgEdit!.length - 1].params).toEqual({ id: 'abc-123' })
|
|
298
|
+
|
|
299
|
+
const voucherEdit = matchRoutes(tree, new URL('http://localhost/voucher/xyz'))
|
|
300
|
+
expect(voucherEdit).not.toBeNull()
|
|
301
|
+
expect(voucherEdit![voucherEdit!.length - 1].node.fullPath).toBe('/voucher/:id')
|
|
302
|
+
expect(voucherEdit![voucherEdit!.length - 1].params).toEqual({ id: 'xyz' })
|
|
303
|
+
|
|
304
|
+
// Index routes
|
|
305
|
+
const orgList = matchRoutes(tree, new URL('http://localhost/organization'))
|
|
306
|
+
expect(orgList).not.toBeNull()
|
|
307
|
+
expect(orgList![orgList!.length - 1].node.fullPath).toBe('/organization')
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('handles deeply nested matchPath with multiple dynamic segments', () => {
|
|
311
|
+
// Mirrors: /app/project/:id/debug/:sessionId/llm-calls/:callId
|
|
312
|
+
const tree = createTestTree([
|
|
313
|
+
{
|
|
314
|
+
path: '/',
|
|
315
|
+
component: dummyComponent,
|
|
316
|
+
isLayout: true,
|
|
317
|
+
children: [
|
|
318
|
+
{
|
|
319
|
+
path: '/project/detail',
|
|
320
|
+
component: dummyComponent,
|
|
321
|
+
isLayout: true,
|
|
322
|
+
children: [
|
|
323
|
+
{ path: '/', matchPath: '/project/:id', component: dummyComponent, isIndex: true },
|
|
324
|
+
{
|
|
325
|
+
path: '/debug',
|
|
326
|
+
component: dummyComponent,
|
|
327
|
+
isLayout: true,
|
|
328
|
+
children: [
|
|
329
|
+
{ path: '/', matchPath: '/project/:id/debug', component: dummyComponent, isIndex: true },
|
|
330
|
+
{ path: '/llm-calls', matchPath: '/project/:id/debug/:sessionId/llm-calls', component: dummyComponent },
|
|
331
|
+
{ path: '/llm-call', matchPath: '/project/:id/debug/:sessionId/llm-calls/:callId', component: dummyComponent },
|
|
332
|
+
],
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
},
|
|
338
|
+
])
|
|
339
|
+
|
|
340
|
+
// Multi-segment dynamic match
|
|
341
|
+
const callMatches = matchRoutes(tree, new URL('http://localhost/project/p1/debug/s2/llm-calls/c3'))
|
|
342
|
+
expect(callMatches).not.toBeNull()
|
|
343
|
+
const callLeaf = callMatches![callMatches!.length - 1]
|
|
344
|
+
expect(callLeaf.node.fullPath).toBe('/project/:id/debug/:sessionId/llm-calls/:callId')
|
|
345
|
+
expect(callLeaf.params).toEqual({ id: 'p1', sessionId: 's2', callId: 'c3' })
|
|
346
|
+
|
|
347
|
+
// Shorter dynamic match at same prefix
|
|
348
|
+
const listMatches = matchRoutes(tree, new URL('http://localhost/project/p1/debug/s2/llm-calls'))
|
|
349
|
+
expect(listMatches).not.toBeNull()
|
|
350
|
+
const listLeaf = listMatches![listMatches!.length - 1]
|
|
351
|
+
expect(listLeaf.node.fullPath).toBe('/project/:id/debug/:sessionId/llm-calls')
|
|
352
|
+
expect(listLeaf.params).toEqual({ id: 'p1', sessionId: 's2' })
|
|
353
|
+
|
|
354
|
+
// Debug index
|
|
355
|
+
const debugMatches = matchRoutes(tree, new URL('http://localhost/project/p1/debug'))
|
|
356
|
+
expect(debugMatches).not.toBeNull()
|
|
357
|
+
expect(debugMatches![debugMatches!.length - 1].node.fullPath).toBe('/project/:id/debug')
|
|
358
|
+
expect(debugMatches![debugMatches!.length - 1].params).toEqual({ id: 'p1' })
|
|
359
|
+
})
|
|
360
|
+
|
|
192
361
|
it('does not match partial paths', () => {
|
|
193
362
|
const tree = createTestTree([
|
|
194
363
|
{ path: '/about', component: dummyComponent },
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { matchRoutes } from '../engine/matcher.js'
|
|
2
3
|
import { createMemoryNavigationAdapter } from '../engine/navigation-adapter.js'
|
|
3
4
|
import { buildRouteTree } from '../engine/route-tree.js'
|
|
4
5
|
import { Router } from '../engine/router.js'
|
|
@@ -120,9 +121,14 @@ describe('Router', () => {
|
|
|
120
121
|
},
|
|
121
122
|
])
|
|
122
123
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
expect(
|
|
124
|
+
// Verify IDs through matching
|
|
125
|
+
const indexMatches = matchRoutes(routes, new URL('http://localhost/'))!
|
|
126
|
+
expect(indexMatches[0].node.id).toBe('route:/:layout')
|
|
127
|
+
expect(indexMatches[1].node.id).toBe('route:/')
|
|
128
|
+
|
|
129
|
+
const aboutMatches = matchRoutes(routes, new URL('http://localhost/about'))!
|
|
130
|
+
expect(aboutMatches[0].node.id).toBe('route:/:layout')
|
|
131
|
+
expect(aboutMatches[1].node.id).toBe('route:/about')
|
|
126
132
|
})
|
|
127
133
|
})
|
|
128
134
|
|
package/src/engine/matcher.ts
CHANGED
|
@@ -1,111 +1,67 @@
|
|
|
1
|
-
import type { RouteMatch,
|
|
1
|
+
import type { RouteMatch, RouteTree, TrieNode } from './types.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Match a URL against the route
|
|
4
|
+
* Match a URL against the route trie.
|
|
5
5
|
* Returns an array of RouteMatch from root layout to leaf page, or null if no match.
|
|
6
|
-
*
|
|
6
|
+
*
|
|
7
|
+
* Matching priority is structural: static segments are always checked before
|
|
8
|
+
* dynamic segments, which are checked before catch-all segments.
|
|
7
9
|
*/
|
|
8
|
-
export function matchRoutes(
|
|
10
|
+
export function matchRoutes(tree: RouteTree, url: URL): RouteMatch[] | null {
|
|
9
11
|
const pathname = url.pathname
|
|
10
|
-
|
|
12
|
+
const segments = pathname.split('/').filter(Boolean)
|
|
13
|
+
return matchTrie(tree.root, segments, 0, {}, pathname)
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const paramCount = countParams(result)
|
|
25
|
-
if (paramCount < bestParamCount) {
|
|
26
|
-
best = result
|
|
27
|
-
bestParamCount = paramCount
|
|
28
|
-
if (paramCount === 0) break // Can't do better than zero params
|
|
29
|
-
}
|
|
16
|
+
function matchTrie(
|
|
17
|
+
node: TrieNode,
|
|
18
|
+
segments: string[],
|
|
19
|
+
index: number,
|
|
20
|
+
params: Record<string, string>,
|
|
21
|
+
pathname: string,
|
|
22
|
+
): RouteMatch[] | null {
|
|
23
|
+
// All segments consumed — check if this trie node has a terminal route
|
|
24
|
+
if (index === segments.length) {
|
|
25
|
+
if (node.route) {
|
|
26
|
+
return buildMatchChain(node.route.chain, params, pathname)
|
|
30
27
|
}
|
|
28
|
+
return null
|
|
31
29
|
}
|
|
32
30
|
|
|
33
|
-
|
|
34
|
-
}
|
|
31
|
+
const segment = segments[index]
|
|
35
32
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
for (const match of matches) {
|
|
42
|
-
count += Object.keys(match.params).length
|
|
33
|
+
// 1. Try static child first — O(1) Map lookup, always most specific
|
|
34
|
+
const staticChild = node.staticChildren.get(segment)
|
|
35
|
+
if (staticChild) {
|
|
36
|
+
const result = matchTrie(staticChild, segments, index + 1, params, pathname)
|
|
37
|
+
if (result) return result
|
|
43
38
|
}
|
|
44
|
-
return count
|
|
45
|
-
}
|
|
46
39
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
52
|
-
const layoutMatch: RouteMatch = {
|
|
53
|
-
node,
|
|
54
|
-
params: extractLayoutParams(node, pathname),
|
|
55
|
-
pathname,
|
|
56
|
-
}
|
|
57
|
-
return [layoutMatch, ...childMatch]
|
|
58
|
-
}
|
|
59
|
-
return null
|
|
40
|
+
// 2. Try dynamic child — matches any single segment
|
|
41
|
+
if (node.dynamicChild) {
|
|
42
|
+
const newParams = { ...params, [node.dynamicChild.paramName]: decodeURIComponent(segment) }
|
|
43
|
+
const result = matchTrie(node.dynamicChild.node, segments, index + 1, newParams, pathname)
|
|
44
|
+
if (result) return result
|
|
60
45
|
}
|
|
61
46
|
|
|
62
|
-
//
|
|
63
|
-
if (node.
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const match: RouteMatch = { node, params, pathname }
|
|
68
|
-
|
|
69
|
-
// If this node has children (e.g., a page with nested routes), try them too
|
|
70
|
-
if (node.children.length > 0) {
|
|
71
|
-
const childMatch = pickBestMatch(node.children, pathname)
|
|
72
|
-
if (childMatch) {
|
|
73
|
-
return [match, ...childMatch]
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return [match]
|
|
78
|
-
}
|
|
47
|
+
// 3. Try catch-all child — consumes all remaining segments
|
|
48
|
+
if (node.catchAllChild) {
|
|
49
|
+
const remaining = segments.slice(index).map(s => decodeURIComponent(s)).join('/')
|
|
50
|
+
const newParams = { ...params, [node.catchAllChild.paramName]: remaining }
|
|
51
|
+
return buildMatchChain(node.catchAllChild.chain, newParams, pathname)
|
|
79
52
|
}
|
|
80
53
|
|
|
81
54
|
return null
|
|
82
55
|
}
|
|
83
56
|
|
|
84
57
|
/**
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
function extractParamsFromResult(result: URLPatternResult): Record<string, string> {
|
|
88
|
-
const params: Record<string, string> = {}
|
|
89
|
-
const groups = result.pathname.groups
|
|
90
|
-
for (const [key, value] of Object.entries(groups)) {
|
|
91
|
-
// Skip unnamed groups (numeric keys from wildcards/optionals)
|
|
92
|
-
if (value !== undefined && !/^\d+$/.test(key)) {
|
|
93
|
-
params[key] = value
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return params
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Extract params from a layout node using its prefix URLPattern.
|
|
101
|
-
* Falls back to empty params if no prefix pattern is available.
|
|
58
|
+
* Build a RouteMatch[] array from a chain of RouteNodes and collected params.
|
|
59
|
+
* Each match in the chain gets all collected params (they're merged by the React layer anyway).
|
|
102
60
|
*/
|
|
103
|
-
function
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
return {}
|
|
61
|
+
function buildMatchChain(
|
|
62
|
+
chain: import('./types.js').RouteNode[],
|
|
63
|
+
params: Record<string, string>,
|
|
64
|
+
pathname: string,
|
|
65
|
+
): RouteMatch[] {
|
|
66
|
+
return chain.map(node => ({ node, params, pathname }))
|
|
111
67
|
}
|
package/src/engine/route-tree.ts
CHANGED
|
@@ -1,23 +1,6 @@
|
|
|
1
|
-
import type { RouteComponent, RouteConfig, RouteNode, StandardSchema } from './types.js'
|
|
1
|
+
import type { RouteComponent, RouteConfig, RouteNode, RouteTree, StandardSchema, TrieNode } from './types.js'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
* Normalize a path segment — ensure it starts with "/" and strip trailing slashes.
|
|
5
|
-
*/
|
|
6
|
-
function normalizePath(path: string): string {
|
|
7
|
-
if (path === '' || path === '/') return '/'
|
|
8
|
-
const p = path.startsWith('/') ? path : `/${path}`
|
|
9
|
-
return p.endsWith('/') ? p.slice(0, -1) : p
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Join two path segments.
|
|
14
|
-
*/
|
|
15
|
-
function joinPaths(parent: string, child: string): string {
|
|
16
|
-
if (child === '/') return parent === '/' ? '/' : parent
|
|
17
|
-
const base = parent === '/' ? '' : parent
|
|
18
|
-
const segment = child.startsWith('/') ? child : `/${child}`
|
|
19
|
-
return base + segment
|
|
20
|
-
}
|
|
3
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
21
4
|
|
|
22
5
|
export interface CreateRouteNodeOptions {
|
|
23
6
|
path: string
|
|
@@ -25,14 +8,12 @@ export interface CreateRouteNodeOptions {
|
|
|
25
8
|
component?: RouteComponent
|
|
26
9
|
searchSchema?: StandardSchema
|
|
27
10
|
preload?: () => void
|
|
28
|
-
children?: RouteNode[]
|
|
29
|
-
parent?: RouteNode
|
|
30
11
|
isLayout?: boolean
|
|
31
12
|
isIndex?: boolean
|
|
32
13
|
}
|
|
33
14
|
|
|
34
15
|
/**
|
|
35
|
-
* Create a route node
|
|
16
|
+
* Create a route node (layout or page entry in a match chain).
|
|
36
17
|
*/
|
|
37
18
|
export function createRouteNode(options: CreateRouteNodeOptions): RouteNode {
|
|
38
19
|
const {
|
|
@@ -41,59 +22,51 @@ export function createRouteNode(options: CreateRouteNodeOptions): RouteNode {
|
|
|
41
22
|
component,
|
|
42
23
|
searchSchema,
|
|
43
24
|
preload,
|
|
44
|
-
children = [],
|
|
45
|
-
parent,
|
|
46
25
|
isLayout = false,
|
|
47
26
|
isIndex = false,
|
|
48
27
|
} = options
|
|
49
28
|
|
|
50
|
-
|
|
51
|
-
// index and leaf routes get an exact pattern for matching.
|
|
52
|
-
let pattern: URLPattern | undefined
|
|
53
|
-
let prefixPattern: URLPattern | undefined
|
|
54
|
-
if (!isLayout || isIndex) {
|
|
55
|
-
const p = fullPath === '' ? '/' : fullPath
|
|
56
|
-
pattern = new URLPattern({ pathname: p === '/' ? p : `${p}{/}?` })
|
|
57
|
-
}
|
|
58
|
-
if (isLayout && !isIndex) {
|
|
59
|
-
// Layouts get a prefix pattern that matches any path starting with their fullPath.
|
|
60
|
-
// The {/.*}? suffix optionally matches a trailing slash and anything after.
|
|
61
|
-
const prefix = fullPath === '' || fullPath === '/' ? '/' : fullPath
|
|
62
|
-
prefixPattern = new URLPattern({ pathname: prefix === '/' ? '/{.*}?' : `${prefix}{/.*}?` })
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const node: RouteNode = {
|
|
29
|
+
return {
|
|
66
30
|
id: `route:${fullPath || '/'}${isLayout ? ':layout' : ''}`,
|
|
67
31
|
path: normalizePath(path),
|
|
68
32
|
fullPath: normalizePath(fullPath),
|
|
69
|
-
pattern,
|
|
70
|
-
prefixPattern,
|
|
71
33
|
component,
|
|
72
34
|
searchSchema,
|
|
73
35
|
preload,
|
|
74
|
-
children,
|
|
75
|
-
parent,
|
|
76
36
|
isLayout,
|
|
77
37
|
isIndex,
|
|
78
38
|
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build a route tree (segment trie) from an array of RouteConfig.
|
|
43
|
+
*
|
|
44
|
+
* Two phases:
|
|
45
|
+
* 1. Flatten the nested RouteConfig tree into leaf routes, each with its layout chain.
|
|
46
|
+
* 2. Insert each leaf route into a segment trie for O(segments) matching.
|
|
47
|
+
*/
|
|
48
|
+
export function buildRouteTree(configs: RouteConfig[], parentPath = ''): RouteTree {
|
|
49
|
+
const entries = flattenConfigs(configs, parentPath, [])
|
|
50
|
+
const root = createTrieNode()
|
|
79
51
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
child.parent = node
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
insertIntoTrie(root, entry.pattern, entry.chain)
|
|
83
54
|
}
|
|
84
55
|
|
|
85
|
-
return
|
|
56
|
+
return { root }
|
|
86
57
|
}
|
|
87
58
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
59
|
+
// ─── Phase 1: Flatten RouteConfig tree ──────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
interface FlatEntry {
|
|
62
|
+
/** The URL pattern for matching (e.g., "/project/:id/edit"). */
|
|
63
|
+
pattern: string
|
|
64
|
+
/** Layout chain from root to leaf — each becomes a RouteMatch. */
|
|
65
|
+
chain: RouteNode[]
|
|
93
66
|
}
|
|
94
67
|
|
|
95
|
-
function
|
|
96
|
-
const
|
|
68
|
+
function flattenConfigs(configs: RouteConfig[], parentPath: string, layoutChain: RouteNode[]): FlatEntry[] {
|
|
69
|
+
const entries: FlatEntry[] = []
|
|
97
70
|
|
|
98
71
|
for (const config of configs) {
|
|
99
72
|
const segment = config.isPathlessGroup ? '' : config.path
|
|
@@ -110,23 +83,96 @@ function buildRouteNodes(configs: RouteConfig[], parentPath: string): RouteNode[
|
|
|
110
83
|
fullPath = joinPaths(parentPath || '/', segment)
|
|
111
84
|
}
|
|
112
85
|
|
|
113
|
-
const children = config.children
|
|
114
|
-
? buildRouteNodes(config.children, config.isPathlessGroup ? parentPath : fullPath)
|
|
115
|
-
: []
|
|
116
|
-
|
|
117
86
|
const node = createRouteNode({
|
|
118
87
|
path: segment,
|
|
119
88
|
fullPath,
|
|
120
89
|
component: config.component,
|
|
121
90
|
searchSchema: config.searchSchema,
|
|
122
91
|
preload: config.preload,
|
|
123
|
-
children,
|
|
124
92
|
isLayout,
|
|
125
93
|
isIndex,
|
|
126
94
|
})
|
|
127
95
|
|
|
128
|
-
|
|
96
|
+
if (isLayout) {
|
|
97
|
+
// Layout: add to chain and recurse children
|
|
98
|
+
const newChain = [...layoutChain, node]
|
|
99
|
+
if (config.children) {
|
|
100
|
+
const childParent = config.isPathlessGroup ? parentPath : fullPath
|
|
101
|
+
entries.push(...flattenConfigs(config.children, childParent, newChain))
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
// Leaf route: create entry with full chain
|
|
105
|
+
entries.push({
|
|
106
|
+
pattern: fullPath,
|
|
107
|
+
chain: [...layoutChain, node],
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return entries
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Phase 2: Trie construction ─────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
function createTrieNode(): TrieNode {
|
|
118
|
+
return { staticChildren: new Map() }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function insertIntoTrie(root: TrieNode, pattern: string, chain: RouteNode[]): void {
|
|
122
|
+
const segments = splitPathSegments(pattern)
|
|
123
|
+
let node = root
|
|
124
|
+
|
|
125
|
+
for (let i = 0; i < segments.length; i++) {
|
|
126
|
+
const segment = segments[i]
|
|
127
|
+
|
|
128
|
+
if (segment.startsWith(':') && (segment.endsWith('+') || segment.endsWith('*'))) {
|
|
129
|
+
// Catch-all segment — consumes all remaining segments
|
|
130
|
+
const paramName = segment.slice(1, -1)
|
|
131
|
+
node.catchAllChild = { paramName, chain }
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (segment.startsWith(':')) {
|
|
136
|
+
// Dynamic segment
|
|
137
|
+
const paramName = segment.slice(1)
|
|
138
|
+
if (!node.dynamicChild) {
|
|
139
|
+
node.dynamicChild = { paramName, node: createTrieNode() }
|
|
140
|
+
}
|
|
141
|
+
node = node.dynamicChild.node
|
|
142
|
+
} else {
|
|
143
|
+
// Static segment
|
|
144
|
+
let child = node.staticChildren.get(segment)
|
|
145
|
+
if (!child) {
|
|
146
|
+
child = createTrieNode()
|
|
147
|
+
node.staticChildren.set(segment, child)
|
|
148
|
+
}
|
|
149
|
+
node = child
|
|
150
|
+
}
|
|
129
151
|
}
|
|
130
152
|
|
|
131
|
-
|
|
153
|
+
// Terminal — route matches at this trie node
|
|
154
|
+
node.route = { chain }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Split a path pattern into segments, filtering out empty strings.
|
|
161
|
+
* "/app/project/:id" → ["app", "project", ":id"]
|
|
162
|
+
*/
|
|
163
|
+
function splitPathSegments(pattern: string): string[] {
|
|
164
|
+
return pattern.split('/').filter(Boolean)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizePath(path: string): string {
|
|
168
|
+
if (path === '' || path === '/') return '/'
|
|
169
|
+
const p = path.startsWith('/') ? path : `/${path}`
|
|
170
|
+
return p.endsWith('/') ? p.slice(0, -1) : p
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function joinPaths(parent: string, child: string): string {
|
|
174
|
+
if (child === '/') return parent === '/' ? '/' : parent
|
|
175
|
+
const base = parent === '/' ? '' : parent
|
|
176
|
+
const segment = child.startsWith('/') ? child : `/${child}`
|
|
177
|
+
return base + segment
|
|
132
178
|
}
|