Skip to content

Commit eadec55

Browse files
committed
feat: support raw param parsers
1 parent 64ecd49 commit eadec55

6 files changed

Lines changed: 445 additions & 35 deletions

File tree

packages/router/src/unplugin/codegen/generateParamParsers.spec.ts

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, test } from 'vitest'
1+
import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'
22
import {
33
warnMissingParamParsers,
44
collectMissingParamParsers,
@@ -9,6 +9,7 @@ import {
99
generatePathParamsOptions,
1010
generateCustomParamParsersList,
1111
generateNormalizedParamParsersDeclarations,
12+
isRawParamParserSource,
1213
scanParamParserFiles,
1314
type ParamParsersMap,
1415
} from './generateParamParsers'
@@ -902,3 +903,137 @@ describe('scanParamParserFiles', () => {
902903
}
903904
)
904905
})
906+
907+
describe('isRawParamParserSource', () => {
908+
let warnSpy: ReturnType<typeof vi.spyOn>
909+
beforeEach(() => {
910+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
911+
})
912+
afterEach(() => {
913+
warnSpy.mockRestore()
914+
})
915+
916+
it('returns true when parser is defined via defineParamParserRaw', () => {
917+
const source = `
918+
import { defineParamParserRaw } from 'vue-router/experimental'
919+
export const parser = defineParamParserRaw({
920+
get: v => v,
921+
set: v => v,
922+
})
923+
`
924+
expect(isRawParamParserSource(source)).toBe(true)
925+
})
926+
927+
it('returns false when parser is defined via defineParamParser', () => {
928+
const source = `
929+
import { defineParamParser } from 'vue-router/experimental'
930+
export const parser = defineParamParser({
931+
get: v => v,
932+
set: v => String(v),
933+
})
934+
`
935+
expect(isRawParamParserSource(source)).toBe(false)
936+
})
937+
938+
it('supports aliased imports of defineParamParserRaw', () => {
939+
const source = `
940+
import { defineParamParserRaw as defineRaw } from 'vue-router/experimental'
941+
export const parser = defineRaw({
942+
get: v => v,
943+
set: v => v,
944+
})
945+
`
946+
expect(isRawParamParserSource(source)).toBe(true)
947+
})
948+
949+
it('returns false when the parser export uses a different identifier', () => {
950+
const source = `
951+
import { defineParamParserRaw, defineParamParser } from 'vue-router/experimental'
952+
export const parser = defineParamParser({
953+
get: v => v,
954+
set: v => String(v),
955+
})
956+
export const other = defineParamParserRaw({
957+
get: v => v,
958+
set: v => v,
959+
})
960+
`
961+
expect(isRawParamParserSource(source)).toBe(false)
962+
})
963+
964+
it('returns false when defineParamParserRaw is not imported', () => {
965+
const source = `
966+
export const parser = {
967+
get: v => v,
968+
set: v => v,
969+
}
970+
`
971+
expect(isRawParamParserSource(source)).toBe(false)
972+
})
973+
974+
it('returns false for unparseable source', () => {
975+
expect(isRawParamParserSource('this is not { valid ts')).toBe(false)
976+
})
977+
978+
it('detects raw parser when declared under another name and re-exported as parser', () => {
979+
const source = `
980+
import { defineParamParserRaw } from 'vue-router/experimental'
981+
const myParser = defineParamParserRaw({
982+
get: v => v,
983+
set: v => v,
984+
})
985+
export { myParser as parser }
986+
`
987+
expect(isRawParamParserSource(source)).toBe(true)
988+
})
989+
990+
it('returns false when defineParamParserRaw comes from another module', () => {
991+
const source = `
992+
import { defineParamParserRaw } from 'some-other-package'
993+
export const parser = defineParamParserRaw({
994+
get: v => v,
995+
set: v => v,
996+
})
997+
`
998+
expect(isRawParamParserSource(source)).toBe(false)
999+
})
1000+
1001+
it('warns when parser is re-exported from another module (direct name)', () => {
1002+
const source = `
1003+
import { defineParamParserRaw } from 'vue-router/experimental'
1004+
export { parser } from './other-parser'
1005+
`
1006+
expect(isRawParamParserSource(source, 'my-parser.ts')).toBe(false)
1007+
expect(warnSpy).toHaveBeenCalledWith(
1008+
expect.stringContaining(
1009+
'Cannot statically determine if "parser" is raw in "my-parser.ts"'
1010+
)
1011+
)
1012+
})
1013+
1014+
it('warns when parser is re-exported from another module (aliased)', () => {
1015+
const source = `
1016+
import { defineParamParserRaw } from 'vue-router/experimental'
1017+
export { something as parser } from './other-parser'
1018+
`
1019+
expect(isRawParamParserSource(source, 'my-parser.ts')).toBe(false)
1020+
expect(warnSpy).toHaveBeenCalledWith(
1021+
expect.stringContaining(
1022+
'Cannot statically determine if "parser" is raw in "my-parser.ts"'
1023+
)
1024+
)
1025+
})
1026+
1027+
it('does not warn for re-exports that are not the parser export', () => {
1028+
const source = `
1029+
import { defineParamParserRaw } from 'vue-router/experimental'
1030+
export { somethingElse } from './other'
1031+
export const parser = defineParamParserRaw({
1032+
get: v => v,
1033+
set: v => v,
1034+
})
1035+
`
1036+
expect(isRawParamParserSource(source, 'my-parser.ts')).toBe(true)
1037+
expect(warnSpy).not.toHaveBeenCalled()
1038+
})
1039+
})

packages/router/src/unplugin/codegen/generateParamParsers.ts

Lines changed: 188 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,37 @@ import type { ImportsMap } from '../core/utils'
33
import type { PrefixTree } from '../core/tree'
44
import { toStringLiteral } from '../utils'
55
import { glob } from 'tinyglobby'
6+
import { babelParse, walkAST } from '@vue-macros/common'
7+
import { promises as fs } from 'node:fs'
8+
import { parse as parsePathe, relative, resolve } from 'pathe'
9+
import { camelCase } from 'scule'
10+
import type {
11+
ExportNamedDeclaration,
12+
ImportDeclaration,
13+
Program,
14+
VariableDeclaration,
15+
} from '@babel/types'
616

717
export type ParamParsersMap = Map<
818
string,
919
{
1020
name: string
21+
/**
22+
* The name of the generated type for this parser, e.g. `Param_date`.
23+
*/
1124
typeName: `Param_${string}`
25+
1226
relativePath: string
27+
1328
absolutePath: string
29+
30+
/**
31+
* Whether the parser was created via `defineParamParserRaw`. Raw parsers
32+
* bypass the automatic array/null lifting in the generated route param
33+
* types and force `format: 'array'` for query params. Optional; when
34+
* absent, the parser is treated as non-raw.
35+
*/
36+
isRaw?: boolean
1437
}
1538
>
1639

@@ -22,6 +45,169 @@ const NATIVE_PARAM_PARSERS_TYPES = {
2245
bool: 'boolean',
2346
} satisfies Record<(typeof _NATIVE_PARAM_PARSERS)[number], string>
2447

48+
const RAW_PARAM_PARSER_DEFINER = 'defineParamParserRaw'
49+
const PARAM_PARSER_MODULE = 'vue-router/experimental'
50+
51+
function isInitRawCall(
52+
declarator: VariableDeclaration['declarations'][number],
53+
rawLocalName: string
54+
): boolean {
55+
const init = declarator.init
56+
return (
57+
!!init &&
58+
init.type === 'CallExpression' &&
59+
init.callee.type === 'Identifier' &&
60+
init.callee.name === rawLocalName
61+
)
62+
}
63+
64+
/**
65+
* Detects whether a param parser source file declares its `parser` export via
66+
* `defineParamParserRaw` (from `vue-router/experimental`). Aliased imports are
67+
* supported.
68+
*
69+
* Returns `false` when the file doesn't import the raw definer, when the
70+
* `parser` export uses something else, or when the source can't be parsed.
71+
*
72+
* @internal
73+
*/
74+
export function isRawParamParserSource(
75+
source: string,
76+
filename: string = 'parser.ts'
77+
): boolean {
78+
let ast: Program | undefined
79+
try {
80+
ast = babelParse(source, /\.tsx?$/.test(filename) ? 'ts' : 'js')
81+
} catch {
82+
return false
83+
}
84+
85+
// find the local binding for `defineParamParserRaw` imported from
86+
// `vue-router/experimental`. Aliased imports (`{ defineParamParserRaw as x }`)
87+
// are supported, so we track the local name rather than the imported name.
88+
let rawLocalName: string | null = null
89+
for (const node of ast.body) {
90+
if (node.type !== 'ImportDeclaration') continue
91+
const imp = node as ImportDeclaration
92+
if (imp.source.value !== PARAM_PARSER_MODULE) continue
93+
for (const spec of imp.specifiers) {
94+
if (
95+
spec.type === 'ImportSpecifier' &&
96+
spec.imported.type === 'Identifier' &&
97+
spec.imported.name === RAW_PARAM_PARSER_DEFINER
98+
) {
99+
rawLocalName = spec.local.name
100+
break
101+
}
102+
}
103+
if (rawLocalName) break
104+
}
105+
106+
// no import of the raw definer means the file cannot define a raw parser
107+
if (!rawLocalName) return false
108+
109+
// collect top-level variables initialized via the raw definer so we can
110+
// detect indirect exports like `const p = defineParamParserRaw(...); export { p as parser }`
111+
const rawLocals = new Set<string>()
112+
for (const node of ast.body) {
113+
if (node.type === 'VariableDeclaration') {
114+
for (const declarator of node.declarations) {
115+
if (
116+
declarator.id.type === 'Identifier' &&
117+
isInitRawCall(declarator, rawLocalName)
118+
) {
119+
rawLocals.add(declarator.id.name)
120+
}
121+
}
122+
}
123+
}
124+
125+
// walk export declarations looking for the `parser` export and check whether
126+
// it ultimately comes from the raw definer (inline or via a tracked local)
127+
let isRaw = false
128+
walkAST(ast, {
129+
enter(node) {
130+
if (isRaw) return
131+
if (node.type !== 'ExportNamedDeclaration') return
132+
const exportNode = node as ExportNamedDeclaration
133+
// re-exports (`export { parser } from '...'`) can't be resolved locally:
134+
// we'd need to follow the source module to know if it's raw. Warn so the
135+
// user notices: runtime still works, but the generated types may not match.
136+
if (exportNode.source) {
137+
const reExportsParser = exportNode.specifiers.some(
138+
spec =>
139+
spec.type === 'ExportSpecifier' &&
140+
spec.exported.type === 'Identifier' &&
141+
spec.exported.name === 'parser'
142+
)
143+
if (reExportsParser) {
144+
console.warn(
145+
`Cannot statically determine if "parser" is raw in "${filename}" because it is re-exported from "${exportNode.source.value}". The generated route param types may be incorrect. Define the parser inline in this file with \`defineParamParser\`/\`defineParamParserRaw\` instead of re-exporting it.`
146+
)
147+
}
148+
return
149+
}
150+
// inline form: `export const parser = defineParamParserRaw(...)`
151+
if (exportNode.declaration?.type === 'VariableDeclaration') {
152+
const decl = exportNode.declaration as VariableDeclaration
153+
for (const declarator of decl.declarations) {
154+
if (
155+
declarator.id.type === 'Identifier' &&
156+
declarator.id.name === 'parser' &&
157+
isInitRawCall(declarator, rawLocalName)
158+
) {
159+
isRaw = true
160+
return
161+
}
162+
}
163+
return
164+
}
165+
// indirect form: `export { someLocal as parser }` where someLocal was
166+
// initialized via the raw definer earlier in the file
167+
for (const spec of exportNode.specifiers) {
168+
if (
169+
spec.type === 'ExportSpecifier' &&
170+
spec.exported.type === 'Identifier' &&
171+
spec.exported.name === 'parser' &&
172+
rawLocals.has(spec.local.name)
173+
) {
174+
isRaw = true
175+
return
176+
}
177+
}
178+
},
179+
})
180+
181+
return isRaw
182+
}
183+
184+
/**
185+
* Reads a param parser file from disk and registers (or replaces) the matching
186+
* entry in `paramParsersMap`. Used by both the initial scan and the watcher's
187+
* `add` handler.
188+
*
189+
* @internal
190+
*/
191+
export async function addParamParserToMap(
192+
file: string,
193+
folder: string,
194+
dtsDir: string,
195+
paramParsersMap: ParamParsersMap
196+
): Promise<void> {
197+
const fileName = parsePathe(file).name
198+
const name = camelCase(fileName)
199+
// TODO: could be simplified to only one import that starts with / for vite
200+
const absolutePath = resolve(folder, file)
201+
const source = await fs.readFile(absolutePath, 'utf8')
202+
paramParsersMap.set(fileName, {
203+
name,
204+
typeName: `Param_${name}`,
205+
absolutePath,
206+
relativePath: relative(dtsDir, absolutePath),
207+
isRaw: isRawParamParserSource(source, absolutePath),
208+
})
209+
}
210+
25211
/**
26212
* Scans a folder for param parser files matching `include` while filtering out `exclude`.
27213
* Only flat matches are returned (no nested folders). Exported solely to make this
@@ -69,8 +255,8 @@ export interface MissingParamParser {
69255
/**
70256
* Walks the route tree and returns the set of parser names referenced by any
71257
* path or query param. Native parser names (`int`, `bool`) and references to
72-
* parsers not present on disk are included as-is — callers decide what to do
73-
* with them.
258+
* parsers not present on disk are included as-is, leaving the decision of
259+
* how to handle them to the caller.
74260
*/
75261
export function collectUsedParamParserNames(tree: PrefixTree): Set<string> {
76262
const used = new Set<string>()

packages/router/src/unplugin/codegen/generateRouteMap.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,20 @@ export function generateRouteRecordInfo(
5353
toStringLiteral(node.name),
5454
toStringLiteral(node.fullPath),
5555
options.experimental.paramParsers
56-
? EXPERIMENTAL_generateRouteParams(node, paramParsers, true)
56+
? EXPERIMENTAL_generateRouteParams(
57+
node,
58+
paramParsers,
59+
true,
60+
paramParsersMap
61+
)
5762
: generateRouteParams(node, true),
5863
options.experimental.paramParsers
59-
? EXPERIMENTAL_generateRouteParams(node, paramParsers, false)
64+
? EXPERIMENTAL_generateRouteParams(
65+
node,
66+
paramParsers,
67+
false,
68+
paramParsersMap
69+
)
6070
: generateRouteParams(node, false),
6171
]
6272

0 commit comments

Comments
 (0)