Skip to content

Commit e8c15b3

Browse files
committed
fix: proper scoring
1 parent ce27d6f commit e8c15b3

File tree

6 files changed

+206
-49
lines changed

6 files changed

+206
-49
lines changed

src/codegen/generateRouteResolver.spec.ts

Lines changed: 139 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ describe('generateRouteRecordPath', () => {
5757
generateRouteRecordPath({ importsMap, node, paramParsersMap: new Map() })
5858
).toMatchInlineSnapshot(`
5959
"path: new MatcherPatternPathCustomParams(
60-
/^\\/a\\/([^/]+)$/i,
60+
/^\\/a\\/([^/]+?)$/i,
6161
{
6262
b: {},
6363
},
@@ -76,7 +76,7 @@ describe('generateRouteRecordPath', () => {
7676
})
7777
).toMatchInlineSnapshot(`
7878
"path: new MatcherPatternPathCustomParams(
79-
/^\\/a\\/([^/]+)\\/([^/]+)$/i,
79+
/^\\/a\\/([^/]+?)\\/([^/]+?)$/i,
8080
{
8181
b: {},
8282
c: {},
@@ -92,7 +92,7 @@ describe('generateRouteRecordPath', () => {
9292
generateRouteRecordPath({ importsMap, node, paramParsersMap: new Map() })
9393
).toMatchInlineSnapshot(`
9494
"path: new MatcherPatternPathCustomParams(
95-
/^\\/a\\/([^/]+)?$/i,
95+
/^\\/a\\/([^/]+?)?$/i,
9696
{
9797
b: {},
9898
},
@@ -140,7 +140,7 @@ describe('generateRouteRecordPath', () => {
140140
generateRouteRecordPath({ importsMap, node, paramParsersMap: new Map() })
141141
).toMatchInlineSnapshot(`
142142
"path: new MatcherPatternPathCustomParams(
143-
/^\\/a\\/a-([^/]+)-c-([^/]+)$/i,
143+
/^\\/a\\/a-([^/]+?)-c-([^/]+?)$/i,
144144
{
145145
b: {},
146146
d: {},
@@ -253,10 +253,10 @@ describe('generateRouteResolver', () => {
253253
})
254254
255255
export const resolver = createStaticResolver([
256-
r_0, // /a
257-
r_2, // /b/c
258256
r_3, // /b/c/d
259257
r_5, // /b/e/f
258+
r_2, // /b/c
259+
r_0, // /a
260260
])
261261
"
262262
`)
@@ -285,22 +285,140 @@ describe('generateRouteResolver', () => {
285285

286286
expect(resolver.replace(/^.*?createStaticResolver/s, ''))
287287
.toMatchInlineSnapshot(`
288-
"([
289-
r_1, // /a
290-
r_11, // /b/a-b
291-
r_7, // /b/a-:a
292-
r_8, // /b/a-:a?
293-
r_10, // /b/a-:a+
294-
r_9, // /b/a-:a*
295-
r_3, // /b/:a
296-
r_4, // /b/:a?
297-
r_6, // /b/:a+
298-
r_5, // /b/:a*
299-
r_0, // /:all(.*)
300-
])
301-
"
302-
`)
288+
"([
289+
r_11, // /b/a-b
290+
r_7, // /b/a-:a
291+
r_3, // /b/:a
292+
r_8, // /b/a-:a?
293+
r_4, // /b/:a?
294+
r_10, // /b/a-:a+
295+
r_6, // /b/:a+
296+
r_9, // /b/a-:a*
297+
r_5, // /b/:a*
298+
r_1, // /a
299+
r_0, // /:all(.*)
300+
])
301+
"
302+
`)
303303
})
304304

305305
it.todo('strips off empty parent records')
306306
})
307+
308+
describe('route prioritization in resolver', () => {
309+
function getRouteOrderFromResolver(tree: PrefixTree): string[] {
310+
const resolver = generateRouteResolver(
311+
tree,
312+
DEFAULT_OPTIONS,
313+
new ImportsMap(),
314+
new Map()
315+
)
316+
317+
// Extract the order from the resolver output
318+
const lines = resolver.split('\n').filter((line) => line.includes('// /'))
319+
return lines.map((line) => line.split('// ')[1] || '')
320+
}
321+
322+
it('prioritizes routes correctly in resolver output', () => {
323+
const tree = new PrefixTree(DEFAULT_OPTIONS)
324+
325+
// Create routes with different specificity levels
326+
tree.insert('static', 'static.vue')
327+
tree.insert('[id]', '[id].vue')
328+
tree.insert('[[optional]]', '[[optional]].vue')
329+
tree.insert('prefix-[id]-suffix', 'prefix-[id]-suffix.vue')
330+
tree.insert('[...all]', '[...all].vue')
331+
332+
// Routes should be ordered from most specific to least specific
333+
expect(getRouteOrderFromResolver(tree)).toEqual([
334+
'/static', // static routes first
335+
'/prefix-:id-suffix', // mixed routes with static content
336+
'/:id', // pure parameter routes
337+
'/:optional?', // optional parameter routes
338+
'/:all(.*)', // catch-all routes last
339+
])
340+
})
341+
342+
it('handles nested route prioritization correctly', () => {
343+
const tree = new PrefixTree(DEFAULT_OPTIONS)
344+
345+
// Create nested routes with different patterns
346+
tree.insert('api/users', 'api/users.vue')
347+
tree.insert('api/[resource]', 'api/[resource].vue')
348+
tree.insert('prefix-[param]/static', 'prefix-[param]/static.vue')
349+
tree.insert('[dynamic]/static', '[dynamic]/static.vue')
350+
tree.insert('[x]/[y]', '[x]/[y].vue')
351+
352+
// Routes with more static content should come first
353+
expect(getRouteOrderFromResolver(tree)).toEqual([
354+
'/api/users', // all static segments
355+
'/api/:resource', // static root, param child
356+
'/prefix-:param/static', // mixed root, static child
357+
'/:dynamic/static', // param root, static child
358+
'/:x/:y', // all param segments
359+
])
360+
})
361+
362+
it('orders complex mixed routes appropriately', () => {
363+
const tree = new PrefixTree(DEFAULT_OPTIONS)
364+
365+
// Create routes with various subsegment complexity
366+
tree.insert('users', 'users.vue') // pure static
367+
tree.insert('prefix-[id]', 'prefix-[id].vue') // prefix + param
368+
tree.insert('[id]-suffix', '[id]-suffix.vue') // param + suffix
369+
tree.insert('pre-[a]-mid-[b]-end', 'complex.vue') // complex mixed
370+
tree.insert('[a]-[b]', '[a]-[b].vue') // params with separator
371+
tree.insert('[param]', '[param].vue') // pure param
372+
373+
expect(getRouteOrderFromResolver(tree)).toEqual([
374+
'/users', // pure static wins
375+
'/pre-:a-mid-:b-end', // most static content in mixed
376+
'/:id-suffix', // static suffix
377+
'/prefix-:id', // static prefix
378+
'/:a-:b', // params with static separator
379+
'/:param', // pure param last
380+
])
381+
})
382+
383+
it('handles optional and repeatable params in nested contexts', () => {
384+
const tree = new PrefixTree(DEFAULT_OPTIONS)
385+
386+
// Create nested routes with optional and repeatable params
387+
tree.insert('api/static', 'api/static.vue')
388+
tree.insert('api/[[optional]]', 'api/[[optional]].vue')
389+
tree.insert('api/[required]', 'api/[required].vue')
390+
tree.insert('api/[repeatable]+', 'api/[repeatable]+.vue')
391+
tree.insert('api/[[optional]]+', 'api/[[optional]]+.vue')
392+
tree.insert('api/[...catchall]', 'api/[...catchall].vue')
393+
394+
expect(getRouteOrderFromResolver(tree)).toEqual([
395+
'/api/static', // static segment wins
396+
'/api/:required', // required param
397+
'/api/:optional?', // optional param
398+
'/api/:repeatable+', // repeatable param
399+
'/api/:optional*', // optional repeatable param
400+
'/api/:catchall(.*)', // catch-all last
401+
])
402+
})
403+
404+
it('handles complex subsegments in deeply nested routes', () => {
405+
const tree = new PrefixTree(DEFAULT_OPTIONS)
406+
407+
// Create deeply nested routes with complex subsegment patterns
408+
tree.insert('api/v1/users', 'api/v1/users.vue')
409+
tree.insert('api/v1/user-[id]', 'api/v1/user-[id].vue')
410+
tree.insert('api/v1/[type]/list', 'api/v1/[type]/list.vue')
411+
tree.insert('api/v1/[type]/[id]', 'api/v1/[type]/[id].vue')
412+
tree.insert('api/v1/prefix-[a]-mid-[b]', 'api/v1/prefix-[a]-mid-[b].vue')
413+
tree.insert('api/v1/[x]-[y]-[z]', 'api/v1/[x]-[y]-[z].vue')
414+
415+
expect(getRouteOrderFromResolver(tree)).toEqual([
416+
'/api/v1/users', // all static segments
417+
'/api/v1/user-:id', // mixed with static prefix
418+
'/api/v1/prefix-:a-mid-:b', // complex mixed pattern
419+
'/api/v1/:x-:y-:z', // multiple params with separators (mixed subsegments rank higher)
420+
'/api/v1/:type/list', // param + static child
421+
'/api/v1/:type/:id', // all param segments
422+
])
423+
})
424+
})

src/codegen/generateRouteResolver.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,56 @@ import { ts } from '../utils'
55
import { generateParamsOptions, ParamParsersMap } from './generateParamParsers'
66
import { generatePageImport } from './generateRouteRecords'
77

8+
/**
9+
* Compare two score arrays for sorting routes by priority.
10+
* Higher scores should come first (more specific routes).
11+
*/
12+
function compareRouteScore(a: number[][], b: number[][]): number {
13+
const maxLength = Math.max(a.length, b.length)
14+
15+
for (let i = 0; i < maxLength; i++) {
16+
const aSegment = a[i] || []
17+
const bSegment = b[i] || []
18+
19+
// Compare segment by segment, but consider the "minimum" score of each segment
20+
// since mixed segments with params should rank lower than pure static
21+
const aMinScore = aSegment.length > 0 ? Math.min(...aSegment) : 0
22+
const bMinScore = bSegment.length > 0 ? Math.min(...bSegment) : 0
23+
24+
if (aMinScore !== bMinScore) {
25+
return bMinScore - aMinScore // Higher minimum score wins
26+
}
27+
28+
// If minimum scores are equal, compare average scores
29+
const aAvgScore =
30+
aSegment.length > 0
31+
? aSegment.reduce((sum, s) => sum + s, 0) / aSegment.length
32+
: 0
33+
const bAvgScore =
34+
bSegment.length > 0
35+
? bSegment.reduce((sum, s) => sum + s, 0) / bSegment.length
36+
: 0
37+
38+
if (aAvgScore !== bAvgScore) {
39+
return bAvgScore - aAvgScore // Higher average score wins
40+
}
41+
42+
// If averages are equal, prefer fewer subsegments (less complexity)
43+
if (aSegment.length !== bSegment.length) {
44+
return aSegment.length - bSegment.length
45+
}
46+
}
47+
48+
// If all segments are equal, prefer fewer segments (shorter paths)
49+
return a.length - b.length
50+
}
51+
852
interface GenerateRouteResolverState {
953
id: number
1054
matchableRecords: {
1155
path: string
1256
varName: string
13-
score: number
57+
score: number[][]
1458
}[]
1559
}
1660

@@ -43,7 +87,7 @@ ${records.join('\n\n')}
4387
4488
export const resolver = createStaticResolver([
4589
${state.matchableRecords
46-
.sort((a, b) => b.score - a.score)
90+
.sort((a, b) => compareRouteScore(a.score, b.score))
4791
.map(
4892
({ varName, path }) =>
4993
` ${varName}, ${' '.repeat(String(state.id).length - varName.length + 2)}// ${path}`

src/core/tree.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -300,16 +300,16 @@ export class TreeNode {
300300
return '/^' + re + '$/i'
301301
}
302302

303-
get score(): number {
304-
let score = 666
303+
get score(): number[][] {
304+
const scores: number[][] = []
305305
let node: TreeNode | undefined = this
306306

307307
while (node && !node.isRoot()) {
308-
score = Math.min(score, node.value.score)
308+
scores.unshift(node.value.score)
309309
node = node.parent
310310
}
311311

312-
return score
312+
return scores
313313
}
314314

315315
get matcherParams() {

src/core/treeNodeValue.ts

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ class _TreeNodeValueBase {
195195
export class TreeNodeValueStatic extends _TreeNodeValueBase {
196196
override _type: TreeNodeType.static = TreeNodeType.static
197197

198-
readonly score = 300
198+
readonly score = [300]
199199

200200
constructor(
201201
rawSegment: string,
@@ -210,7 +210,7 @@ export class TreeNodeValueGroup extends _TreeNodeValueBase {
210210
override _type: TreeNodeType.group = TreeNodeType.group
211211
groupName: string
212212

213-
readonly score = 300
213+
readonly score = [300]
214214

215215
constructor(
216216
rawSegment: string,
@@ -253,23 +253,21 @@ export class TreeNodeValueParam extends _TreeNodeValueBase {
253253
this.params = params
254254
}
255255

256-
// FIXME: implement scoring from vue-router
257-
get score(): number {
258-
const malus = Math.max(
259-
...this.params.map((p) =>
260-
p.isSplat ? 500 : (p.optional ? 10 : 0) + (p.repeatable ? 20 : 0)
261-
)
262-
)
256+
// Calculate score for each subsegment to handle mixed static/param parts
257+
get score(): number[] {
258+
return this.subSegments.map((segment) => {
259+
if (typeof segment === 'string') {
260+
// Static subsegment gets highest score
261+
return 300
262+
} else {
263+
// Parameter subsegment - calculate malus based on param properties
264+
const malus = segment.isSplat
265+
? 500
266+
: (segment.optional ? 10 : 0) + (segment.repeatable ? 20 : 0)
263267

264-
return (
265-
80 -
266-
malus +
267-
(this.params.length > 0 &&
268-
this.subSegments.length > 1 &&
269-
this.subSegments.some((s) => typeof s === 'string' && s.length > 0)
270-
? 35
271-
: 0)
272-
)
268+
return 80 - malus
269+
}
270+
})
273271
}
274272

275273
get re(): string {

vitest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { defineConfig } from 'vitest/config'
22
import Vue from '@vitejs/plugin-vue'
33
import { fileURLToPath, URL } from 'url'
44

5-
const __dirname = new URL('.', import.meta.url).pathname
65
export default defineConfig({
76
resolve: {
87
alias: [
@@ -20,6 +19,7 @@ export default defineConfig({
2019
},
2120
],
2221
},
22+
2323
plugins: [Vue()],
2424

2525
test: {

vitest.workspace.js

Lines changed: 0 additions & 3 deletions
This file was deleted.

0 commit comments

Comments
 (0)