Skip to content

Commit aca0792

Browse files
committed
feat: add inferred types for extracted attrs
1 parent ee0ed20 commit aca0792

File tree

3 files changed

+83
-27
lines changed

3 files changed

+83
-27
lines changed

src/converters.ts

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,34 @@ const collator = new Intl.Collator('en-US')
88

99
// --- Types -------------------------------------------------------------------
1010

11-
export interface VueRoute {
11+
/**
12+
* Maps an `attrs` record to typed optional properties on the route.
13+
*
14+
* Each key becomes an optional property whose value is a single literal
15+
* from the array. The attr is only set when exactly one mode matches.
16+
*
17+
* @example
18+
* type R = InferAttrs<{ mode: ['client', 'server'] }>
19+
* // { mode?: 'client' | 'server' }
20+
*/
21+
export type InferAttrs<T extends Record<string, string[]>> = {
22+
[K in keyof T]?: T[K][number]
23+
}
24+
25+
// eslint-disable-next-line ts/no-empty-object-type
26+
export type VueRoute<Attrs extends Record<string, string[]> = {}> = {
1227
name?: string
1328
path: string
1429
file?: string
1530
/** Named view files keyed by view name. Only present when named views exist. */
1631
components?: Record<string, string>
1732
modes?: string[]
18-
children: VueRoute[]
33+
children: VueRoute<Attrs>[]
1934
meta?: Record<string, unknown>
20-
[key: string]: unknown
21-
}
35+
} & ([keyof Attrs] extends [never] ? { [key: string]: unknown } : InferAttrs<Attrs>)
2236

23-
export interface VueRouterEmitOptions {
37+
// eslint-disable-next-line ts/no-empty-object-type
38+
export interface VueRouterEmitOptions<Attrs extends Record<string, string[]> = {}> {
2439
/**
2540
* Custom route name generator.
2641
* Receives `/`-separated name (e.g. `'users/id'`), returns final name.
@@ -32,13 +47,16 @@ export interface VueRouterEmitOptions {
3247
onDuplicateRouteName?: (name: string, file: string, existingFile: string) => void
3348

3449
/**
35-
* Collapse mode arrays into single-value attributes.
50+
* Collapse modes into single-value attributes.
51+
*
52+
* Each key becomes a typed top-level property on the route. When a route has
53+
* exactly one matching mode the attribute is set to that value string; when
54+
* none or multiple modes match, the attribute is omitted and the raw `modes`
55+
* array is emitted instead.
3656
*
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.
57+
* The return type of `toVueRouter4` infers typed properties from the attrs
58+
* definition so that, e.g., `attrs: { mode: ['client', 'server'] }` produces
59+
* routes with `mode?: 'client' | 'server'`.
4260
*
4361
* @example
4462
* // Input: route has modes: ['server']
@@ -50,7 +68,7 @@ export interface VueRouterEmitOptions {
5068
* toVueRouter4(tree, { attrs: { method: ['get', 'post'] } })
5169
* // For a route with modes: ['get'] → { ..., method: 'get' }
5270
*/
53-
attrs?: Record<string, string[]>
71+
attrs?: Attrs
5472
}
5573

5674
export interface Rou3Route {
@@ -161,7 +179,7 @@ function cloneRoute(route: VueRoute): VueRoute {
161179
return clone
162180
}
163181

164-
function optionsToKey(options?: VueRouterEmitOptions): string {
182+
function optionsToKey(options?: VueRouterEmitOptions<Record<string, string[]>>): string {
165183
if (!options)
166184
return ''
167185
const parts: string[] = []
@@ -184,12 +202,16 @@ function optionsToKey(options?: VueRouterEmitOptions): string {
184202
* to the returned array do not affect the cache. The cache is automatically
185203
* invalidated when `addFile` / `removeFile` mark the tree as dirty.
186204
*/
187-
export function toVueRouter4(tree: RouteTree, options?: VueRouterEmitOptions): VueRoute[] {
188-
const key = optionsToKey(options)
205+
export function toVueRouter4<const Attrs extends Record<string, string[]> = never>(
206+
tree: RouteTree,
207+
// eslint-disable-next-line ts/no-empty-object-type
208+
options?: VueRouterEmitOptions<[Attrs] extends [never] ? {} : Attrs>,
209+
): VueRoute<[Attrs] extends [never] ? {} : Attrs>[] { // eslint-disable-line ts/no-empty-object-type
210+
const key = optionsToKey(options as VueRouterEmitOptions<Record<string, string[]>>)
189211
const cached = (tree as any)['~cachedVueRouter'] as CachedVueRouterResult | undefined
190212

191213
if (!tree['~dirty'] && cached && cached.optionsKey === key) {
192-
return cloneRoutes(cached.routes)
214+
return cloneRoutes(cached.routes) as VueRoute<any>[]
193215
}
194216

195217
const fileInfos = flattenTree(tree)
@@ -250,13 +272,13 @@ export function toVueRouter4(tree: RouteTree, options?: VueRouterEmitOptions): V
250272
parent.push(route)
251273
}
252274

253-
const result = prepareRoutes(routes, undefined, options)
275+
const result = prepareRoutes(routes, undefined, options as VueRouterEmitOptions<Record<string, string[]>>)
254276

255277
// Cache on the tree
256278
;(tree as any)['~cachedVueRouter'] = { routes: result, optionsKey: key } satisfies CachedVueRouterResult
257279
tree['~dirty'] = false
258280

259-
return cloneRoutes(result)
281+
return cloneRoutes(result) as VueRoute<any>[]
260282
}
261283

262284
// --- rou3 --------------------------------------------------------------------
@@ -476,7 +498,7 @@ function defaultGetRouteName(rawName: string): string {
476498
function prepareRoutes(
477499
routes: IntermediateRoute[],
478500
parent?: IntermediateRoute,
479-
options?: VueRouterEmitOptions,
501+
options?: VueRouterEmitOptions<Record<string, string[]>>,
480502
names = new Map<string, string>(),
481503
): VueRoute[] {
482504
const getRouteName = options?.getRouteName || defaultGetRouteName
@@ -537,10 +559,6 @@ function prepareRoutes(
537559
out[attrName] = matched[0]
538560
modesConsumed = true
539561
}
540-
else if (matched.length > 1) {
541-
out[attrName] = matched
542-
modesConsumed = true
543-
}
544562
}
545563
// Only emit `modes` if not fully consumed by attrs
546564
if (!modesConsumed)

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type { RegExpRoute, Rou3Route, ToVueRouterSegmentOptions, VueRoute, VueRouterEmitOptions } from './converters'
1+
export type { InferAttrs, RegExpRoute, Rou3Route, ToVueRouterSegmentOptions, VueRoute, VueRouterEmitOptions } from './converters'
22
export { toRegExp, toRou3, toVueRouter4, toVueRouterPath, toVueRouterSegment } from './converters'
33

44
export type { CompiledParsePath, ParsedPath, ParsedPathSegment, ParsedPathSegmentToken, ParsePathOptions, SegmentType } from './parse'

test/unit/converters.spec.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,11 +1127,11 @@ describe('attrs option', () => {
11271127
expect(routes[0].modes).toBeUndefined()
11281128
})
11291129

1130-
it('collapses multiple modes into an array attr', () => {
1130+
it('omits attr and emits modes when multiple modes match', () => {
11311131
const t = buildTree(['page.client.vue', 'page.server.vue'], { modes: ['client', 'server'] })
11321132
const routes = toVueRouter4(t, { attrs: { mode: ['client', 'server'] } })
1133-
expect(routes[0].mode).toEqual(['client', 'server'])
1134-
expect(routes[0].modes).toBeUndefined()
1133+
expect(routes[0].mode).toBeUndefined()
1134+
expect(routes[0].modes).toEqual(['client', 'server'])
11351135
})
11361136

11371137
it('does not add attr when no modes match', () => {
@@ -1178,6 +1178,44 @@ describe('attrs option', () => {
11781178
expect(second[0].mode).toBe('server')
11791179
expect(first[0]).not.toBe(second[0])
11801180
})
1181+
1182+
it('infers typed attrs from options', () => {
1183+
const t = buildTree(['page.server.vue'], { modes: ['client', 'server'] })
1184+
const routes = toVueRouter4(t, { attrs: { mode: ['client', 'server'] } })
1185+
1186+
// Type-level: mode is typed as 'client' | 'server' | undefined
1187+
const mode = routes[0].mode
1188+
expect(mode).toBe('server')
1189+
1190+
// Verify the type is narrow (not `unknown`)
1191+
if (typeof mode === 'string') {
1192+
// mode is 'client' | 'server' here
1193+
expect(['client', 'server']).toContain(mode)
1194+
}
1195+
})
1196+
1197+
it('infers typed attrs for multiple attr definitions', () => {
1198+
const t = buildTree(['page.client.vue'], { modes: ['client', 'server', 'get', 'post'] })
1199+
const routes = toVueRouter4(t, {
1200+
attrs: {
1201+
mode: ['client', 'server'],
1202+
method: ['get', 'post'],
1203+
},
1204+
})
1205+
// Both mode and method should be typed properties
1206+
const _mode: 'client' | 'server' | undefined = routes[0].mode
1207+
const _method: 'get' | 'post' | undefined = routes[0].method
1208+
expect(_mode).toBe('client')
1209+
expect(_method).toBeUndefined()
1210+
})
1211+
1212+
it('preserves index signature when no attrs option given', () => {
1213+
const t = buildTree(['page.vue'])
1214+
const routes = toVueRouter4(t)
1215+
// Without attrs, routes should still have index signature for backward compat
1216+
const _val: unknown = routes[0].anyProperty
1217+
expect(_val).toBeUndefined()
1218+
})
11811219
})
11821220

11831221
describe('compileParsePath', () => {

0 commit comments

Comments
 (0)