@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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,qBAAqB,CAAA;AAG5B,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,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"}
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,qBAAqB,CAAA;AAE5B,+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;AAsB9G,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"}
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"}
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { Router } from '../engine/router.js';
3
- import type { RouteMatch, RouteNode } from '../engine/types.js';
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: RouteNode[];
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,EAAE,CAAA;IACnB,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"}
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.9",
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
- expect(routes[0].id).toBe('route:/:layout')
124
- expect(routes[0].children[0].id).toBe('route:/')
125
- expect(routes[0].children[1].id).toBe('route:/about')
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
 
@@ -1,111 +1,67 @@
1
- import type { RouteMatch, RouteNode } from './types.js'
1
+ import type { RouteMatch, RouteTree, TrieNode } from './types.js'
2
2
 
3
3
  /**
4
- * Match a URL against the route tree.
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
- * When multiple routes match, selects the most specific one (fewest dynamic params).
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(nodes: RouteNode[], url: URL): RouteMatch[] | null {
10
+ export function matchRoutes(tree: RouteTree, url: URL): RouteMatch[] | null {
9
11
  const pathname = url.pathname
10
- return pickBestMatch(nodes, pathname)
12
+ const segments = pathname.split('/').filter(Boolean)
13
+ return matchTrie(tree.root, segments, 0, {}, pathname)
11
14
  }
12
15
 
13
- /**
14
- * Find the best match among sibling nodes.
15
- * Tries all siblings and picks the most specific match (fewest dynamic params).
16
- */
17
- function pickBestMatch(nodes: RouteNode[], pathname: string): RouteMatch[] | null {
18
- let best: RouteMatch[] | null = null
19
- let bestParamCount = Infinity
20
-
21
- for (const node of nodes) {
22
- const result = matchNode(node, pathname)
23
- if (result) {
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
- return best
34
- }
31
+ const segment = segments[index]
35
32
 
36
- /**
37
- * Count total dynamic params across all matches in a chain.
38
- */
39
- function countParams(matches: RouteMatch[]): number {
40
- let count = 0
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
- function matchNode(node: RouteNode, pathname: string): RouteMatch[] | null {
48
- // Layout nodes: try to match children, prepend self if any child matches
49
- if (node.isLayout && !node.isIndex) {
50
- const childMatch = pickBestMatch(node.children, pathname)
51
- if (childMatch) {
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
- // Leaf/index nodes: try to match the URLPattern
63
- if (node.pattern) {
64
- const result = node.pattern.exec({ pathname })
65
- if (result) {
66
- const params = extractParamsFromResult(result)
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
- * Extract params from a URLPattern exec result.
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 extractLayoutParams(node: RouteNode, pathname: string): Record<string, string> {
104
- if (node.prefixPattern) {
105
- const result = node.prefixPattern.exec({ pathname })
106
- if (result) {
107
- return extractParamsFromResult(result)
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
  }
@@ -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 with a compiled URLPattern.
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
- // Build the pattern — layouts get a prefix pattern for param extraction,
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
- // Set parent reference on children
81
- for (const child of node.children) {
82
- child.parent = node
52
+ for (const entry of entries) {
53
+ insertIntoTrie(root, entry.pattern, entry.chain)
83
54
  }
84
55
 
85
- return node
56
+ return { root }
86
57
  }
87
58
 
88
- /**
89
- * Build a route tree from an array of RouteConfig.
90
- */
91
- export function buildRouteTree(configs: RouteConfig[], parentPath = ''): RouteNode[] {
92
- return buildRouteNodes(configs, parentPath)
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 buildRouteNodes(configs: RouteConfig[], parentPath: string): RouteNode[] {
96
- const nodes: RouteNode[] = []
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
- nodes.push(node)
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
- return nodes
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
  }