diff --git a/src/codegen/generateRouteFileInfoMap.ts b/src/codegen/generateRouteFileInfoMap.ts index 6bd9bb465..8d6150e65 100644 --- a/src/codegen/generateRouteFileInfoMap.ts +++ b/src/codegen/generateRouteFileInfoMap.ts @@ -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, @@ -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 { diff --git a/src/core/pathSorting.spec.ts b/src/core/pathSorting.spec.ts new file mode 100644 index 000000000..da90421d4 --- /dev/null +++ b/src/core/pathSorting.spec.ts @@ -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' +]" +`) + }) + }) +}) diff --git a/src/core/pathSorting.ts b/src/core/pathSorting.ts new file mode 100644 index 000000000..33cc52394 --- /dev/null +++ b/src/core/pathSorting.ts @@ -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) diff --git a/src/core/tree.ts b/src/core/tree.ts index 40ac04bb1..c0d43ed29 100644 --- a/src/core/tree.ts +++ b/src/core/tree.ts @@ -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 @@ -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) } /**