Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/codegen/generateRouteFileInfoMap.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { relative } from 'pathe'
import type { PrefixTree, TreeNode } from '../core/tree'
import { formatMultilineUnion, stringToStringType } from '../utils'
import { comparePaths } from '../core/pathSorting'

export function generateRouteFileInfoMap(
node: PrefixTree,
Expand Down Expand Up @@ -39,16 +40,16 @@ export function generateRouteFileInfoMap(
}

const code = Array.from(routesInfo.entries())
.map(
([file, { routes, views }]) =>
`
.map(([file, { routes, views }]) => {
const routesSorted = [...routes].sort(comparePaths)
return `
'${file}': {
routes:
${formatMultilineUnion(routes.map(stringToStringType), 6)}
${formatMultilineUnion(routesSorted.map(stringToStringType), 6)}
views:
${formatMultilineUnion(views.map(stringToStringType), 6)}
}`
)
})
.join('\n')

return `export interface _RouteFileInfoMap {
Expand Down
160 changes: 160 additions & 0 deletions src/core/pathSorting.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { describe, it, expect } from 'vitest'
import { comparePaths, sortPaths } from './pathSorting'

/**
* Helper for snapshot-friendly, readable arrays of paths.
* It formats each string on its own line, quoted, and wrapped in brackets.
*/
export function formatPaths(paths: string[]): string {
const lines = paths.map((p) => ` '${p}'`)
return `[\n${lines.join(',\n')}\n]`
}

describe('pathSorting', () => {
it('orders REST-like routes hierarchically', () => {
const input = [
'/users/[id]/other',
'/users',
'/users/[id]',
'/users/[id]/edit',
]
const sorted = [...input].sort(comparePaths)
expect(formatPaths(sorted)).toMatchInlineSnapshot(`
"[
'/users',
'/users/[id]',
'/users/[id]/edit',
'/users/[id]/other'
]"
`)
})

it('keeps files before folders (parent.vue before parent/child.vue)', () => {
const input = ['src/pages/parent/child.vue', 'src/pages/parent.vue']
const sorted = [...input].sort(comparePaths)
expect(formatPaths(sorted)).toMatchInlineSnapshot(`
"[
'src/pages/parent.vue',
'src/pages/parent/child.vue'
]"
`)
})

it('prioritizes index.vue within its directory', () => {
const input = ['src/pages/a.vue', 'src/pages/index.vue', 'src/pages/b.vue']
const sorted = [...input].sort(comparePaths)
expect(formatPaths(sorted)).toMatchInlineSnapshot(`
"[
'src/pages/index.vue',
'src/pages/a.vue',
'src/pages/b.vue'
]"
`)
})

it('index.vue first, then sibling files, then subfolders', () => {
const input = [
'src/pages/about/index.vue',
'src/pages/about.vue',
'src/pages/index.vue',
]
const sorted = [...input].sort(comparePaths)
expect(formatPaths(sorted)).toMatchInlineSnapshot(`
"[
'src/pages/index.vue',
'src/pages/about.vue',
'src/pages/about/index.vue'
]"
`)
})

it('handles dynamic segments in names (mixed [id] and :id tokens)', () => {
const input = [
'/some-nested/file-with-[id]-in-the-middle',
'file-with-:id-in-the-middle',
'/some-nested/file-with-:id-in-the-middle',
]
const sorted = sortPaths(input)
expect(formatPaths(sorted)).toMatchInlineSnapshot(`
"[
'file-with-:id-in-the-middle',
'/some-nested/file-with-:id-in-the-middle',
'/some-nested/file-with-[id]-in-the-middle'
]"
`)
})

it('uses numeric-aware order for sibling files', () => {
const input = [
'src/pages/file10.vue',
'src/pages/file2.vue',
'src/pages/file1.vue',
]
const sorted = sortPaths(input)
expect(formatPaths(sorted)).toMatchInlineSnapshot(`
"[
'src/pages/file1.vue',
'src/pages/file2.vue',
'src/pages/file10.vue'
]"
`)
})

it('keeps hierarchical grouping before plain string order', () => {
const input = ['a/bb/index.vue', 'a/b.vue', 'a/b/c.vue', 'a/a.vue']
const sorted = [...input].sort(comparePaths)
expect(formatPaths(sorted)).toMatchInlineSnapshot(`
"[
'a/a.vue',
'a/b.vue',
'a/b/c.vue',
'a/bb/index.vue'
]"
`)
})

describe('comparePaths / Unicode normalization (NFC)', () => {
it('treats precomposed and decomposed as equivalent and keeps them adjacent', () => {
// café in two forms:
// - precomposed: "é" (U+00E9)
// - decomposed: "e\u0301" (U+0065 + U+0301)
const pre = 'src/pages/café.vue'
const decomp = 'src/pages/cafe\u0301.vue'

const input = ['src/pages/cafe.vue', pre, 'src/pages/cafg.vue', decomp]

const sorted = sortPaths([...input])
expect(formatPaths(sorted)).toMatchInlineSnapshot(`
"[
'src/pages/cafe.vue',
'src/pages/café.vue',
'src/pages/café.vue',
'src/pages/cafg.vue'
]"
`)
// sanity: they are not byte-equal, but should compare equal under NFC-aware compare
expect(pre).not.toBe(decomp)
})

it('normalizes within deeper paths and still applies path rules (index.vue first)', () => {
const pre = '/users/café/index.vue'
const decomp = '/users/cafe\u0301/settings.vue'
const input = [
'/users/cafe/profile.vue',
decomp,
pre,
'/users/cafe/index.vue',
]

const sorted = sortPaths([...input])
expect(formatPaths(sorted)).toMatchInlineSnapshot(`
"[
'/users/cafe/index.vue',
'/users/cafe/profile.vue',
'/users/café/index.vue',
'/users/café/settings.vue'
]"
`)
})
})
})
62 changes: 62 additions & 0 deletions src/core/pathSorting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Deterministic path sorting
*
* Ensures routes, files, and views in typed-router.d.ts are always sorted
* the same way on all systems and locales—preventing developers
* from seeing the file change due to locale differences.
*
* Sorting rules:
* • `index.vue` always comes first in its folder.
* • Files come before folders at the same level.
* • Otherwise, segments are compared with a fixed English collator.
*
* Normalization:
* • Unicode is normalized to NFC so "é" === "é".
*/

// Fixed English collator for stable, locale-independent string comparison.
const collator = new Intl.Collator('en', {
usage: 'sort',
sensitivity: 'variant',
numeric: true,
caseFirst: 'lower',
})

// Normalize to NFC (so "é" === "é"), remove leading "/", and split by "/" into segments.
const toSegments = (p: string) =>
p.normalize('NFC').replace(/^\/+/, '').split('/')

// Compare two paths and return their deterministic sort order.
export function comparePaths(a: string, b: string): number {
const A = toSegments(a)
const B = toSegments(b)

for (let i = 0, n = Math.max(A.length, B.length); i < n; i++) {
const x = A[i]
const y = B[i]

if (x === y) {
if (x === undefined) return 0
continue
}

if (x === undefined) return -1
if (y === undefined) return 1

if (x === 'index.vue' || y === 'index.vue')
return x === 'index.vue' ? -1 : 1

const fileX = i === A.length - 1 && x.includes('.vue')
const fileY = i === B.length - 1 && y.includes('.vue')
if (fileX !== fileY) return fileX ? -1 : 1

// Benchmarks show `Intl.Collator` is much faster than `localeCompare` here
const c = collator.compare(x, y)
if (c) return c
}

return 0
}

// Sort an array of paths deterministically using comparePaths.
export const sortPaths = (paths: string[]) => [...paths].sort(comparePaths)
5 changes: 2 additions & 3 deletions src/core/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import type { TreeNodeValue } from './treeNodeValue'
import { CustomRouteBlock } from './customBlock'
import { RouteMeta } from 'vue-router'
import { comparePaths } from './pathSorting'

export interface TreeNodeOptions extends ResolvedOptions {
treeNodeOptions?: TreeNodeValueOptions
Expand Down Expand Up @@ -167,9 +168,7 @@ export class TreeNode {
* @internal
*/
static compare(a: TreeNode, b: TreeNode): number {
// for this case, ASCII, short list, it's better than Internation Collator
// https://stackoverflow.com/questions/77246375/why-localecompare-can-be-faster-than-collator-compare
return a.path.localeCompare(b.path, 'en')
return comparePaths(a.path, b.path)
}

/**
Expand Down