Skip to content

Commit 7533586

Browse files
committed
perf: add support for attrs and compiling parsePath
1 parent 21584ac commit 7533586

File tree

6 files changed

+608
-35
lines changed

6 files changed

+608
-35
lines changed

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,42 @@ interface ParsedPath {
371371
}
372372
```
373373

374+
### `compileParsePath(options?)`
375+
376+
Pre-compile parsing options into a reusable function. Useful in hot paths (e.g., dev server file watchers) where `parsePath` would otherwise reconstruct the same regexes on every call.
377+
378+
```ts
379+
function compileParsePath(options?: ParsePathOptions): CompiledParsePath
380+
381+
interface CompiledParsePath {
382+
(filePaths: string[]): ParsedPath[]
383+
}
384+
```
385+
386+
Returns a callable with the same signature as `parsePath` (minus the options argument). The regexes for root stripping, extension matching, and mode detection are built once at compile time.
387+
388+
```js
389+
import { addFile, buildTree, compileParsePath, toVueRouter4 } from 'unrouting'
390+
391+
const opts = { roots: ['pages/'], modes: ['client', 'server'] }
392+
393+
// Compile once at startup
394+
const parse = compileParsePath(opts)
395+
const tree = buildTree(initialFiles, opts)
396+
397+
// In a file watcher callback — no regex re-compilation
398+
addFile(tree, 'pages/new-page.vue', parse)
399+
const routes = toVueRouter4(tree)
400+
```
401+
402+
The compiled function can be passed directly to `addFile` as the options argument:
403+
404+
```js
405+
// These are equivalent, but the compiled version avoids re-building regexes:
406+
addFile(tree, file, parse) // pre-compiled (fast)
407+
addFile(tree, file, opts) // raw options (re-compiles each call)
408+
```
409+
374410
### `parseSegment(segment, absolutePath?, warn?)`
375411

376412
Parse a single filesystem segment into typed tokens. Useful for modules that need to parse custom paths (e.g., i18n locale-specific routes).

src/converters.ts

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface VueRoute {
1717
modes?: string[]
1818
children: VueRoute[]
1919
meta?: Record<string, unknown>
20+
[key: string]: unknown
2021
}
2122

2223
export interface VueRouterEmitOptions {
@@ -29,6 +30,27 @@ export interface VueRouterEmitOptions {
2930

3031
/** Called when two routes resolve to the same generated name. */
3132
onDuplicateRouteName?: (name: string, file: string, existingFile: string) => void
33+
34+
/**
35+
* Collapse mode arrays into single-value attributes.
36+
*
37+
* Each key becomes a top-level property on the route. Modes that match a
38+
* value in the array are collapsed: when a route has exactly one matching
39+
* mode, the attribute is set to that value string. When a route has multiple
40+
* matching modes, the attribute is set to the array of matching modes.
41+
* When none match, the attribute is omitted.
42+
*
43+
* @example
44+
* // Input: route has modes: ['server']
45+
* toVueRouter4(tree, { attrs: { mode: ['client', 'server'] } })
46+
* // Output: { ..., mode: 'server' } (no `modes` property)
47+
*
48+
* @example
49+
* // Custom method-based routing
50+
* toVueRouter4(tree, { attrs: { method: ['get', 'post'] } })
51+
* // For a route with modes: ['get'] → { ..., method: 'get' }
52+
*/
53+
attrs?: Record<string, string[]>
3254
}
3355

3456
export interface Rou3Route {
@@ -95,10 +117,81 @@ function flattenTree(tree: RouteTree): FlatFileInfo[] {
95117

96118
// --- Vue Router 4 ------------------------------------------------------------
97119

120+
/**
121+
* Cached intermediate result stored on the tree.
122+
* @internal
123+
*/
124+
interface CachedVueRouterResult {
125+
/** The computed routes (used as template for cloning). */
126+
routes: VueRoute[]
127+
/** The options fingerprint — if options change, cache is invalid. */
128+
optionsKey: string
129+
}
130+
131+
/** Deep-clone a VueRoute array. */
132+
function cloneRoutes(routes: VueRoute[]): VueRoute[] {
133+
return routes.map(r => cloneRoute(r))
134+
}
135+
136+
function cloneRoute(route: VueRoute): VueRoute {
137+
const clone: VueRoute = {
138+
path: route.path,
139+
file: route.file,
140+
children: route.children.length ? cloneRoutes(route.children) : [],
141+
}
142+
if (route.name !== undefined)
143+
clone.name = route.name
144+
if (route.modes)
145+
clone.modes = [...route.modes]
146+
if (route.meta) {
147+
clone.meta = { ...route.meta }
148+
if (route.meta.groups)
149+
clone.meta.groups = [...(route.meta.groups as string[])]
150+
}
151+
if (route.components)
152+
clone.components = { ...route.components }
153+
154+
// Clone any extra attrs (e.g. mode, method)
155+
for (const key of Object.keys(route)) {
156+
if (!(key in clone)) {
157+
clone[key] = route[key]
158+
}
159+
}
160+
161+
return clone
162+
}
163+
164+
function optionsToKey(options?: VueRouterEmitOptions): string {
165+
if (!options)
166+
return ''
167+
const parts: string[] = []
168+
if (options.getRouteName)
169+
parts.push('n')
170+
if (options.onDuplicateRouteName)
171+
parts.push('d')
172+
if (options.attrs) {
173+
for (const [k, v] of Object.entries(options.attrs)) {
174+
parts.push(`a:${k}=${v.join(',')}`)
175+
}
176+
}
177+
return parts.join('|')
178+
}
179+
98180
/**
99181
* Convert a route tree to Vue Router 4 route definitions.
182+
*
183+
* Results are cached on the tree and deep-cloned on return, so mutations
184+
* to the returned array do not affect the cache. The cache is automatically
185+
* invalidated when `addFile` / `removeFile` mark the tree as dirty.
100186
*/
101187
export function toVueRouter4(tree: RouteTree, options?: VueRouterEmitOptions): VueRoute[] {
188+
const key = optionsToKey(options)
189+
const cached = (tree as any)['~cachedVueRouter'] as CachedVueRouterResult | undefined
190+
191+
if (!tree['~dirty'] && cached && cached.optionsKey === key) {
192+
return cloneRoutes(cached.routes)
193+
}
194+
102195
const fileInfos = flattenTree(tree)
103196

104197
fileInfos.sort((a, b) =>
@@ -157,7 +250,13 @@ export function toVueRouter4(tree: RouteTree, options?: VueRouterEmitOptions): V
157250
parent.push(route)
158251
}
159252

160-
return prepareRoutes(routes, undefined, options)
253+
const result = prepareRoutes(routes, undefined, options)
254+
255+
// Cache on the tree
256+
;(tree as any)['~cachedVueRouter'] = { routes: result, optionsKey: key } satisfies CachedVueRouterResult
257+
tree['~dirty'] = false
258+
259+
return cloneRoutes(result)
161260
}
162261

163262
// --- rou3 --------------------------------------------------------------------
@@ -381,6 +480,7 @@ function prepareRoutes(
381480
names = new Map<string, string>(),
382481
): VueRoute[] {
383482
const getRouteName = options?.getRouteName || defaultGetRouteName
483+
const attrs = options?.attrs
384484

385485
for (const route of routes) {
386486
route.scoreSegments = computeScoreSegments(route)
@@ -420,15 +520,35 @@ function prepareRoutes(
420520
out.components[v.viewName] = v.path
421521
}
422522

423-
// Modes
523+
// Collect modes from all sibling files
424524
const allModes = new Set<string>()
425525
for (const f of route.siblingFiles) {
426526
if (f.modes) {
427527
for (const m of f.modes) allModes.add(m)
428528
}
429529
}
430-
if (allModes.size > 0)
530+
531+
// Apply attrs: collapse modes into named attributes
532+
if (attrs && allModes.size > 0) {
533+
let modesConsumed = false
534+
for (const [attrName, attrValues] of Object.entries(attrs)) {
535+
const matched = attrValues.filter(v => allModes.has(v))
536+
if (matched.length === 1) {
537+
out[attrName] = matched[0]
538+
modesConsumed = true
539+
}
540+
else if (matched.length > 1) {
541+
out[attrName] = matched
542+
modesConsumed = true
543+
}
544+
}
545+
// Only emit `modes` if not fully consumed by attrs
546+
if (!modesConsumed)
547+
out.modes = [...allModes]
548+
}
549+
else if (allModes.size > 0) {
431550
out.modes = [...allModes]
551+
}
432552

433553
return out
434554
})

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
export type { RegExpRoute, Rou3Route, ToVueRouterSegmentOptions, VueRoute, VueRouterEmitOptions } from './converters'
22
export { toRegExp, toRou3, toVueRouter4, toVueRouterPath, toVueRouterSegment } from './converters'
33

4-
export type { ParsedPath, ParsedPathSegment, ParsedPathSegmentToken, ParsePathOptions, SegmentType } from './parse'
5-
export { parsePath, parseSegment } from './parse'
4+
export type { CompiledParsePath, ParsedPath, ParsedPathSegment, ParsedPathSegmentToken, ParsePathOptions, SegmentType } from './parse'
5+
export { compileParsePath, parsePath, parseSegment } from './parse'
66

77
export type { BuildTreeOptions, InputFile, RouteNode, RouteNodeFile, RouteTree } from './tree'
88
export { addFile, buildTree, isPageNode, removeFile, walkTree } from './tree'

src/parse.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,67 @@ export interface ParsedPath {
4343

4444
const VIEW_MATCH_RE = /@([\w-]+)(?:\.|$)/
4545
const VIEW_STRIP_RE = /@[\w-]+/
46+
const DEFAULT_EXT_RE = /\.\w+$/
4647

4748
export function parsePath(filePaths: string[], options: ParsePathOptions = {}): ParsedPath[] {
4849
const EXT_RE = options.extensions
4950
? new RegExp(`\\.(${options.extensions.map(ext => ext.replace(/^\./, '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})$`)
50-
: /\.\w+$/
51+
: DEFAULT_EXT_RE
5152

5253
const sortedRoots = [...options.roots || []].sort((a, b) => b.length - a.length)
5354
const PREFIX_RE = sortedRoots.length > 0
5455
? new RegExp(`^(?:${sortedRoots.map(root => escapeStringRegexp(withTrailingSlash(root))).join('|')})`)
5556
: undefined
5657

5758
const supportedModes = options.modes || []
59+
60+
return parsePathInner(filePaths, EXT_RE, PREFIX_RE, supportedModes, options.warn)
61+
}
62+
63+
/**
64+
* Pre-compile parsing options for repeated calls.
65+
*
66+
* Returns a callable that has the same signature as `parsePath` (minus options)
67+
* but reuses pre-built regexes and mode lists, avoiding re-compilation on each
68+
* invocation.
69+
*
70+
* @example
71+
* const parse = compileParsePath({ roots: ['pages/'], modes: ['client', 'server'] })
72+
* const result = parse(['pages/index.vue'])
73+
*/
74+
export interface CompiledParsePath {
75+
(filePaths: string[]): ParsedPath[]
76+
/**
77+
* @internal
78+
*/
79+
'~compiled': true
80+
}
81+
82+
export function compileParsePath(options: ParsePathOptions = {}): CompiledParsePath {
83+
const EXT_RE = options.extensions
84+
? new RegExp(`\\.(${options.extensions.map(ext => ext.replace(/^\./, '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})$`)
85+
: DEFAULT_EXT_RE
86+
87+
const sortedRoots = [...options.roots || []].sort((a, b) => b.length - a.length)
88+
const PREFIX_RE = sortedRoots.length > 0
89+
? new RegExp(`^(?:${sortedRoots.map(root => escapeStringRegexp(withTrailingSlash(root))).join('|')})`)
90+
: undefined
91+
92+
const supportedModes = options.modes || []
93+
const warn = options.warn
94+
95+
const fn = (filePaths: string[]) => parsePathInner(filePaths, EXT_RE, PREFIX_RE, supportedModes, warn)
96+
;(fn as CompiledParsePath)['~compiled'] = true
97+
return fn as CompiledParsePath
98+
}
99+
100+
function parsePathInner(
101+
filePaths: string[],
102+
EXT_RE: RegExp,
103+
PREFIX_RE: RegExp | undefined,
104+
supportedModes: string[],
105+
warn?: (message: string) => void,
106+
): ParsedPath[] {
58107
const results: ParsedPath[] = []
59108

60109
for (let filePath of filePaths) {
@@ -95,7 +144,7 @@ export function parsePath(filePaths: string[], options: ParsePathOptions = {}):
95144

96145
results.push({
97146
file: originalFilePath,
98-
segments: segments.map(s => parseSegment(s, originalFilePath, options.warn)),
147+
segments: segments.map(s => parseSegment(s, originalFilePath, warn)),
99148
meta: hasMeta
100149
? {
101150
...(hasModes ? { modes } : undefined),

0 commit comments

Comments
 (0)