@tanstack/router-generator 1.166.28 → 1.166.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/cjs/config.cjs +0 -1
  2. package/dist/cjs/config.cjs.map +1 -1
  3. package/dist/cjs/config.d.cts +0 -3
  4. package/dist/cjs/generator.cjs +1 -44
  5. package/dist/cjs/generator.cjs.map +1 -1
  6. package/dist/cjs/index.cjs +0 -2
  7. package/dist/cjs/index.d.cts +1 -2
  8. package/dist/cjs/template.cjs +15 -12
  9. package/dist/cjs/template.cjs.map +1 -1
  10. package/dist/cjs/transform/transform.cjs +283 -266
  11. package/dist/cjs/transform/transform.cjs.map +1 -1
  12. package/dist/cjs/transform/transform.d.cts +1 -3
  13. package/dist/cjs/transform/types.d.cts +1 -7
  14. package/dist/cjs/utils.cjs +0 -16
  15. package/dist/cjs/utils.cjs.map +1 -1
  16. package/dist/cjs/utils.d.cts +0 -24
  17. package/dist/esm/config.d.ts +0 -3
  18. package/dist/esm/config.js +0 -1
  19. package/dist/esm/config.js.map +1 -1
  20. package/dist/esm/generator.js +2 -45
  21. package/dist/esm/generator.js.map +1 -1
  22. package/dist/esm/index.d.ts +1 -2
  23. package/dist/esm/index.js +1 -2
  24. package/dist/esm/template.js +15 -12
  25. package/dist/esm/template.js.map +1 -1
  26. package/dist/esm/transform/transform.d.ts +1 -3
  27. package/dist/esm/transform/transform.js +280 -265
  28. package/dist/esm/transform/transform.js.map +1 -1
  29. package/dist/esm/transform/types.d.ts +1 -7
  30. package/dist/esm/utils.d.ts +0 -24
  31. package/dist/esm/utils.js +1 -15
  32. package/dist/esm/utils.js.map +1 -1
  33. package/package.json +5 -5
  34. package/src/config.ts +0 -1
  35. package/src/generator.ts +0 -66
  36. package/src/index.ts +1 -7
  37. package/src/template.ts +16 -36
  38. package/src/transform/transform.ts +633 -446
  39. package/src/transform/types.ts +1 -8
  40. package/src/utils.ts +5 -43
  41. package/dist/cjs/transform/utils.cjs +0 -34
  42. package/dist/cjs/transform/utils.cjs.map +0 -1
  43. package/dist/cjs/transform/utils.d.cts +0 -2
  44. package/dist/esm/transform/utils.d.ts +0 -2
  45. package/dist/esm/transform/utils.js +0 -34
  46. package/dist/esm/transform/utils.js.map +0 -1
  47. package/src/transform/utils.ts +0 -42
@@ -1,529 +1,716 @@
1
+ import MagicString from 'magic-string'
2
+ import * as t from '@babel/types'
1
3
  import { parseAst } from '@tanstack/router-utils'
2
- import { parse, print, types, visit } from 'recast'
3
- import { SourceMapConsumer } from 'source-map'
4
- import { mergeImportDeclarations } from '../utils'
5
- import { ensureStringArgument } from './utils'
6
- import type { ImportDeclaration } from '../types'
7
- import type { RawSourceMap } from 'source-map'
8
4
  import type { TransformOptions, TransformResult } from './types'
9
5
 
10
- const b = types.builders
6
+ const routeConstructors = ['createFileRoute', 'createLazyFileRoute'] as const
11
7
 
12
- export async function transform({
8
+ type RouteConstructorName = (typeof routeConstructors)[number]
9
+ type SupportedRouteId = t.StringLiteral | t.TemplateLiteral
10
+ type NamedImport = {
11
+ imported: string
12
+ local: string
13
+ importKind?: 'type' | 'typeof' | 'value'
14
+ }
15
+
16
+ type ParsedImportDeclaration = {
17
+ declaration: t.ImportDeclaration
18
+ defaultImport?: string
19
+ namespace?: string
20
+ named: Array<NamedImport>
21
+ moduleName: string
22
+ quote: '"' | "'"
23
+ semicolon: boolean
24
+ }
25
+
26
+ type RouteImportAnalysis =
27
+ | { kind: 'ok' }
28
+ | {
29
+ kind: 'rename'
30
+ imported: t.Identifier
31
+ local: t.Identifier
32
+ next: RouteConstructorName
33
+ }
34
+ | { kind: 'normalize' }
35
+
36
+ type RouteCall = {
37
+ callee: t.Identifier & { name: RouteConstructorName }
38
+ routeIdArg: SupportedRouteId
39
+ optionsArg: t.CallExpression['arguments'][number] | undefined
40
+ }
41
+
42
+ type RouteCallAnalysis = {
43
+ calls: Array<RouteCall>
44
+ hasUnsupportedRouteId: boolean
45
+ hasMalformedRouteCall: boolean
46
+ }
47
+
48
+ export function transform({
13
49
  ctx,
14
50
  source,
15
51
  node,
16
- }: TransformOptions): Promise<TransformResult> {
17
- let appliedChanges = false as boolean
18
- let ast: types.namedTypes.File
52
+ }: TransformOptions): TransformResult {
53
+ let ast: ReturnType<typeof parseAst>
54
+
19
55
  try {
20
- ast = parse(source, {
21
- sourceFileName: 'output.ts',
22
- parser: {
23
- parse(code: string) {
24
- return parseAst({
25
- code,
26
- // we need to instruct babel to produce tokens,
27
- // otherwise recast will try to generate the tokens via its own parser and will fail
28
- tokens: true,
29
- })
30
- },
31
- },
32
- })
33
- } catch (e) {
34
- console.error('Error parsing code', ctx.routeId, source, e)
56
+ ast = parseAst({ code: source })
57
+ } catch (error) {
35
58
  return {
36
59
  result: 'error',
37
- error: e,
60
+ error,
38
61
  }
39
62
  }
40
63
 
41
- const preferredQuote = detectPreferredQuoteStyle(ast)
42
-
43
- let routeExportHandled = false as boolean
44
- function onExportFound(decl: types.namedTypes.VariableDeclarator) {
45
- if (decl.init?.type === 'CallExpression') {
46
- const callExpression = decl.init
47
- const firstArgument = callExpression.arguments[0]
48
- if (firstArgument) {
49
- if (firstArgument.type === 'ObjectExpression') {
50
- const staticProperties = firstArgument.properties.flatMap((p) => {
51
- if (p.type === 'ObjectProperty' && p.key.type === 'Identifier') {
52
- return p.key.name
53
- }
54
- return []
55
- })
56
- node.createFileRouteProps = new Set(staticProperties)
57
- }
58
- }
59
- let identifier: types.namedTypes.Identifier | undefined
60
- // `const Route = createFileRoute({ ... })`
61
- if (callExpression.callee.type === 'Identifier') {
62
- identifier = callExpression.callee
63
- if (ctx.verboseFileRoutes) {
64
- // we need to add the string literal via another CallExpression
65
- callExpression.callee = b.callExpression(identifier, [
66
- b.stringLiteral(ctx.routeId),
67
- ])
68
- appliedChanges = true
69
- }
70
- }
71
- // `const Route = createFileRoute('/path')({ ... })`
72
- else if (
73
- callExpression.callee.type === 'CallExpression' &&
74
- callExpression.callee.callee.type === 'Identifier'
75
- ) {
76
- identifier = callExpression.callee.callee
77
- if (!ctx.verboseFileRoutes) {
78
- // we need to remove the route id
79
- callExpression.callee = identifier
80
- appliedChanges = true
81
- } else {
82
- // check if the route id is correct
83
- appliedChanges = ensureStringArgument(
84
- callExpression.callee,
85
- ctx.routeId,
86
- ctx.preferredQuote,
87
- )
88
- }
89
- }
90
- if (identifier === undefined) {
91
- throw new Error(
92
- `expected identifier to be present in ${ctx.routeId} for export "Route"`,
93
- )
94
- }
95
- if (identifier.name === 'createFileRoute' && ctx.lazy) {
96
- identifier.name = 'createLazyFileRoute'
97
- appliedChanges = true
98
- } else if (identifier.name === 'createLazyFileRoute' && !ctx.lazy) {
99
- identifier.name = 'createFileRoute'
100
- appliedChanges = true
101
- }
102
- } else {
103
- throw new Error(
104
- `expected "Route" export to be initialized by a CallExpression`,
105
- )
106
- }
107
- routeExportHandled = true
108
- }
109
-
110
- const program: types.namedTypes.Program = ast.program
111
- // first pass: find Route export
112
- for (const n of program.body) {
113
- if (n.type === 'ExportNamedDeclaration') {
114
- // direct export of a variable declaration, e.g. `export const Route = createFileRoute('/path')`
115
- if (n.declaration?.type === 'VariableDeclaration') {
116
- const decl = n.declaration.declarations[0]
117
- if (
118
- decl &&
119
- decl.type === 'VariableDeclarator' &&
120
- decl.id.type === 'Identifier'
121
- ) {
122
- if (decl.id.name === 'Route') {
123
- onExportFound(decl)
124
- }
125
- }
126
- }
127
- // this is an export without a declaration, e.g. `export { Route }`
128
- else if (n.declaration === null && n.specifiers) {
129
- for (const spec of n.specifiers) {
130
- if (typeof spec.exported.name === 'string') {
131
- if (spec.exported.name === 'Route') {
132
- const variableName = spec.local?.name || spec.exported.name
133
- // find the matching variable declaration by iterating over the top-level declarations
134
- for (const decl of program.body) {
135
- if (
136
- decl.type === 'VariableDeclaration' &&
137
- decl.declarations[0]
138
- ) {
139
- const variable = decl.declarations[0]
140
- if (
141
- variable.type === 'VariableDeclarator' &&
142
- variable.id.type === 'Identifier' &&
143
- variable.id.name === variableName
144
- ) {
145
- onExportFound(variable)
146
- break
147
- }
148
- }
149
- }
150
- }
151
- }
152
- }
153
- }
64
+ const exportedRouteNames = getExportedRouteNames(ast.program.body)
65
+
66
+ if (exportedRouteNames.size === 0) {
67
+ return { result: 'no-route-export' }
68
+ }
69
+
70
+ const {
71
+ calls: routeCalls,
72
+ hasUnsupportedRouteId,
73
+ hasMalformedRouteCall,
74
+ } = findExportedRouteCalls(ast.program.body, exportedRouteNames)
75
+
76
+ if (routeCalls.length === 0 && hasMalformedRouteCall) {
77
+ return {
78
+ result: 'error',
79
+ error: new Error(
80
+ `expected Route export in ${ctx.routeId} to use createFileRoute('/path')({...}) or createLazyFileRoute('/path')({...})`,
81
+ ),
154
82
  }
155
- if (routeExportHandled) {
156
- break
83
+ }
84
+
85
+ if (routeCalls.length === 0 && hasUnsupportedRouteId) {
86
+ return {
87
+ result: 'error',
88
+ error: new Error(
89
+ `expected route id to be a string literal or plain template literal in ${ctx.routeId}`,
90
+ ),
157
91
  }
158
92
  }
159
93
 
160
- if (!routeExportHandled) {
94
+ if (routeCalls.length === 0) {
95
+ return { result: 'not-modified' }
96
+ }
97
+
98
+ if (routeCalls.length > 1) {
161
99
  return {
162
- result: 'no-route-export',
100
+ result: 'error',
101
+ error: new Error(
102
+ `expected exactly one createFileRoute/createLazyFileRoute call in ${ctx.routeId}`,
103
+ ),
163
104
  }
164
105
  }
165
106
 
166
- const imports: {
167
- required: Array<ImportDeclaration>
168
- banned: Array<ImportDeclaration>
169
- } = {
170
- required: [],
171
- banned: [],
107
+ const routeCall = routeCalls[0]!
108
+ const routeIdQuote = getRouteIdQuote(source, routeCall.routeIdArg)
109
+
110
+ const createFileRouteProps = getCreateFileRouteProps(routeCall.optionsArg)
111
+ if (createFileRouteProps) {
112
+ node.createFileRouteProps = createFileRouteProps
172
113
  }
173
114
 
115
+ const expectedCallee = getExpectedRouteConstructor(ctx.lazy)
116
+ const expectedRouteId = `${routeIdQuote}${ctx.routeId}${routeIdQuote}`
117
+ const currentRouteId = source.slice(
118
+ routeCall.routeIdArg.start!,
119
+ routeCall.routeIdArg.end!,
120
+ )
174
121
  const targetModule = `@tanstack/${ctx.target}-router`
175
- if (ctx.verboseFileRoutes === false) {
176
- imports.banned = [
177
- {
178
- source: targetModule,
179
- specifiers: [
180
- { imported: 'createLazyFileRoute' },
181
- { imported: 'createFileRoute' },
182
- ],
183
- },
184
- ]
185
- } else {
186
- if (ctx.lazy) {
187
- imports.required = [
188
- {
189
- source: targetModule,
190
- specifiers: [{ imported: 'createLazyFileRoute' }],
191
- },
192
- ]
193
- imports.banned = [
194
- {
195
- source: targetModule,
196
- specifiers: [{ imported: 'createFileRoute' }],
197
- },
198
- ]
199
- } else {
200
- imports.required = [
201
- {
202
- source: targetModule,
203
- specifiers: [{ imported: 'createFileRoute' }],
204
- },
205
- ]
206
- imports.banned = [
207
- {
208
- source: targetModule,
209
- specifiers: [{ imported: 'createLazyFileRoute' }],
210
- },
211
- ]
212
- }
122
+ const imports = parseTargetImports(ast.program.body, source, targetModule)
123
+
124
+ const s = new MagicString(source)
125
+ let modified = false
126
+
127
+ if (routeCall.callee.name !== expectedCallee) {
128
+ s.update(routeCall.callee.start!, routeCall.callee.end!, expectedCallee)
129
+ modified = true
130
+ }
131
+
132
+ if (currentRouteId !== expectedRouteId) {
133
+ s.update(
134
+ routeCall.routeIdArg.start!,
135
+ routeCall.routeIdArg.end!,
136
+ expectedRouteId,
137
+ )
138
+ modified = true
139
+ }
140
+
141
+ if (
142
+ updateRouteImports({
143
+ imports,
144
+ source,
145
+ s,
146
+ targetModule,
147
+ required: expectedCallee,
148
+ lineEnding: getLineEnding(source),
149
+ })
150
+ ) {
151
+ modified = true
152
+ }
153
+
154
+ if (!modified) {
155
+ return { result: 'not-modified' }
156
+ }
157
+
158
+ return {
159
+ result: 'modified',
160
+ output: s.toString(),
213
161
  }
162
+ }
214
163
 
215
- imports.required = mergeImportDeclarations(imports.required)
216
- imports.banned = mergeImportDeclarations(imports.banned)
164
+ function getExportedRouteNames(body: Array<t.Statement>) {
165
+ const exportedRouteNames = new Set<string>()
217
166
 
218
- const importStatementCandidates: Array<types.namedTypes.ImportDeclaration> =
219
- []
220
- const importDeclarationsToRemove: Array<types.namedTypes.ImportDeclaration> =
221
- []
167
+ for (const statement of body) {
168
+ if (!t.isExportNamedDeclaration(statement) || statement.source) {
169
+ continue
170
+ }
222
171
 
223
- // second pass: apply import rules, but only if a matching export for the plugin was found
224
- for (const n of program.body) {
225
- const findImport =
226
- (opts: { source: string; importKind?: 'type' | 'value' | 'typeof' }) =>
227
- (i: ImportDeclaration) => {
228
- if (i.source === opts.source) {
229
- const importKind = i.importKind || 'value'
230
- const expectedImportKind = opts.importKind || 'value'
231
- return expectedImportKind === importKind
172
+ if (t.isVariableDeclaration(statement.declaration)) {
173
+ for (const declarator of statement.declaration.declarations) {
174
+ if (t.isIdentifier(declarator.id) && declarator.id.name === 'Route') {
175
+ exportedRouteNames.add('Route')
232
176
  }
233
- return false
234
177
  }
235
- if (n.type === 'ImportDeclaration' && typeof n.source.value === 'string') {
236
- const filterImport = findImport({
237
- source: n.source.value,
238
- importKind: n.importKind,
239
- })
240
- let requiredImports = imports.required.filter(filterImport)[0]
241
-
242
- const bannedImports = imports.banned.filter(filterImport)[0]
243
- if (!requiredImports && !bannedImports) {
178
+ }
179
+
180
+ for (const specifier of statement.specifiers) {
181
+ if (
182
+ !t.isExportSpecifier(specifier) ||
183
+ getExportedName(specifier.exported) !== 'Route'
184
+ ) {
244
185
  continue
245
186
  }
246
- const importSpecifiersToRemove: types.namedTypes.ImportDeclaration['specifiers'] =
247
- []
248
- if (n.specifiers) {
249
- for (const spec of n.specifiers) {
250
- if (!requiredImports && !bannedImports) {
251
- break
252
- }
253
- if (
254
- spec.type === 'ImportSpecifier' &&
255
- typeof spec.imported.name === 'string'
256
- ) {
257
- if (requiredImports) {
258
- const requiredImportIndex = requiredImports.specifiers.findIndex(
259
- (imp) => imp.imported === spec.imported.name,
260
- )
261
- if (requiredImportIndex !== -1) {
262
- // import is already present, remove it from requiredImports
263
- requiredImports.specifiers.splice(requiredImportIndex, 1)
264
- if (requiredImports.specifiers.length === 0) {
265
- imports.required = imports.required.splice(
266
- imports.required.indexOf(requiredImports),
267
- 1,
268
- )
269
- requiredImports = undefined
270
- }
271
- } else {
272
- // add the import statement to the candidates
273
- importStatementCandidates.push(n)
274
- }
275
- }
276
- if (bannedImports) {
277
- const bannedImportIndex = bannedImports.specifiers.findIndex(
278
- (imp) => imp.imported === spec.imported.name,
279
- )
280
- if (bannedImportIndex !== -1) {
281
- importSpecifiersToRemove.push(spec)
282
- }
283
- }
284
- }
285
- }
286
- if (importSpecifiersToRemove.length > 0) {
287
- appliedChanges = true
288
- n.specifiers = n.specifiers.filter(
289
- (spec) => !importSpecifiersToRemove.includes(spec),
290
- )
291
-
292
- // mark the import statement as to be deleted if it is now empty
293
- if (n.specifiers.length === 0) {
294
- importDeclarationsToRemove.push(n)
295
- }
296
- }
187
+
188
+ const localName = getLocalBindingName(specifier.local)
189
+ if (localName) {
190
+ exportedRouteNames.add(localName)
297
191
  }
298
192
  }
299
193
  }
300
- imports.required.forEach((requiredImport) => {
301
- if (requiredImport.specifiers.length > 0) {
302
- appliedChanges = true
303
- if (importStatementCandidates.length > 0) {
304
- // find the first import statement that matches both the module and the import kind
305
- const importStatement = importStatementCandidates.find(
306
- (importStatement) => {
307
- if (importStatement.source.value === requiredImport.source) {
308
- const importKind = importStatement.importKind || 'value'
309
- const requiredImportKind = requiredImport.importKind || 'value'
310
- return importKind === requiredImportKind
311
- }
312
- return false
313
- },
314
- )
315
- if (importStatement) {
316
- if (importStatement.specifiers === undefined) {
317
- importStatement.specifiers = []
318
- }
319
- const importSpecifiersToAdd = requiredImport.specifiers.map((spec) =>
320
- b.importSpecifier(
321
- b.identifier(spec.imported),
322
- b.identifier(spec.imported),
323
- ),
324
- )
325
- importStatement.specifiers = [
326
- ...importStatement.specifiers,
327
- ...importSpecifiersToAdd,
328
- ]
329
- return
330
- }
331
- }
332
- const importStatement = b.importDeclaration(
333
- requiredImport.specifiers.map((spec) =>
334
- b.importSpecifier(
335
- b.identifier(spec.imported),
336
- spec.local ? b.identifier(spec.local) : null,
337
- ),
338
- ),
339
- b.stringLiteral(requiredImport.source),
340
- )
341
- program.body.unshift(importStatement)
194
+
195
+ return exportedRouteNames
196
+ }
197
+
198
+ function findExportedRouteCalls(
199
+ body: Array<t.Statement>,
200
+ exportedRouteNames: Set<string>,
201
+ ): RouteCallAnalysis {
202
+ const calls: Array<RouteCall> = []
203
+ let hasUnsupportedRouteId = false
204
+ let hasMalformedRouteCall = false
205
+
206
+ for (const statement of body) {
207
+ const declaration = getVariableDeclaration(statement)
208
+ if (!declaration) {
209
+ continue
342
210
  }
343
- })
344
- if (importDeclarationsToRemove.length > 0) {
345
- appliedChanges = true
346
- for (const importDeclaration of importDeclarationsToRemove) {
347
- // check if the import declaration is still empty
348
- if (importDeclaration.specifiers?.length === 0) {
349
- const index = program.body.indexOf(importDeclaration)
350
- if (index !== -1) {
351
- program.body.splice(index, 1)
211
+
212
+ for (const declarator of declaration.declarations) {
213
+ if (
214
+ !t.isIdentifier(declarator.id) ||
215
+ !exportedRouteNames.has(declarator.id.name)
216
+ ) {
217
+ continue
218
+ }
219
+
220
+ const init = getRouteConstructorInit(declarator.init)
221
+ if (!init) {
222
+ if (isDirectRouteConstructorCall(declarator.init)) {
223
+ hasMalformedRouteCall = true
352
224
  }
225
+ continue
226
+ }
227
+
228
+ const routeIdArg = init.innerCall.arguments[0]
229
+ if (isSupportedRouteId(routeIdArg)) {
230
+ calls.push({
231
+ callee: init.callee,
232
+ routeIdArg,
233
+ optionsArg: init.outerCall.arguments[0],
234
+ })
235
+ } else {
236
+ hasUnsupportedRouteId = true
353
237
  }
354
238
  }
355
239
  }
356
240
 
357
- if (!appliedChanges) {
358
- return {
359
- result: 'not-modified',
360
- }
241
+ return { calls, hasUnsupportedRouteId, hasMalformedRouteCall }
242
+ }
243
+
244
+ function getVariableDeclaration(statement: t.Statement) {
245
+ const declaration = t.isExportNamedDeclaration(statement)
246
+ ? statement.declaration
247
+ : statement
248
+
249
+ return t.isVariableDeclaration(declaration) ? declaration : null
250
+ }
251
+
252
+ function getExportedName(node: t.Identifier | t.StringLiteral) {
253
+ return t.isIdentifier(node) ? node.name : node.value
254
+ }
255
+
256
+ function getLocalBindingName(node: t.Identifier | t.StringLiteral) {
257
+ return t.isIdentifier(node) ? node.name : null
258
+ }
259
+
260
+ function getRouteConstructorInit(expression: t.Expression | null | undefined) {
261
+ if (!expression || !t.isCallExpression(expression)) {
262
+ return null
361
263
  }
362
264
 
363
- const printResult = print(ast, {
364
- reuseWhitespace: true,
365
- sourceMapName: 'output.map',
366
- })
367
- let transformedCode = printResult.code
368
- if (printResult.map) {
369
- const fixedOutput = await fixTransformedOutputText({
370
- originalCode: source,
371
- transformedCode,
372
- sourceMap: printResult.map as RawSourceMap,
373
- preferredQuote,
374
- })
375
- transformedCode = fixedOutput
265
+ if (!t.isCallExpression(expression.callee)) {
266
+ return null
376
267
  }
268
+
269
+ const innerCall = expression.callee
270
+
271
+ if (
272
+ !t.isIdentifier(innerCall.callee) ||
273
+ !isRouteConstructor(innerCall.callee)
274
+ ) {
275
+ return null
276
+ }
277
+
377
278
  return {
378
- result: 'modified',
379
- output: transformedCode,
279
+ callee: innerCall.callee,
280
+ outerCall: expression,
281
+ innerCall,
282
+ }
283
+ }
284
+
285
+ function isDirectRouteConstructorCall(
286
+ expression: t.Expression | null | undefined,
287
+ ) {
288
+ return (
289
+ !!expression &&
290
+ t.isCallExpression(expression) &&
291
+ t.isIdentifier(expression.callee) &&
292
+ isRouteConstructor(expression.callee)
293
+ )
294
+ }
295
+
296
+ function isRouteConstructor(callee: t.Identifier): callee is t.Identifier & {
297
+ name: RouteConstructorName
298
+ } {
299
+ return routeConstructors.includes(callee.name as RouteConstructorName)
300
+ }
301
+
302
+ function isSupportedRouteId(
303
+ arg: t.CallExpression['arguments'][number] | undefined,
304
+ ): arg is SupportedRouteId {
305
+ return (
306
+ !!arg &&
307
+ (t.isStringLiteral(arg) ||
308
+ (t.isTemplateLiteral(arg) && arg.expressions.length === 0))
309
+ )
310
+ }
311
+
312
+ function getRouteIdQuote(
313
+ source: string,
314
+ arg: SupportedRouteId,
315
+ ): '"' | "'" | '`' {
316
+ const raw = source.slice(arg.start!, arg.end!)
317
+
318
+ if (raw.startsWith("'")) return "'"
319
+ if (raw.startsWith('"')) return '"'
320
+ return '`'
321
+ }
322
+
323
+ function getCreateFileRouteProps(
324
+ arg: t.CallExpression['arguments'][number] | undefined,
325
+ ) {
326
+ if (!arg || !t.isObjectExpression(arg)) {
327
+ return undefined
380
328
  }
329
+
330
+ const props = new Set<string>()
331
+
332
+ for (const property of arg.properties) {
333
+ if (!t.isObjectProperty(property) || property.computed) {
334
+ continue
335
+ }
336
+
337
+ if (t.isIdentifier(property.key)) {
338
+ props.add(property.key.name)
339
+ continue
340
+ }
341
+
342
+ if (t.isStringLiteral(property.key)) {
343
+ props.add(property.key.value)
344
+ }
345
+ }
346
+
347
+ return props
381
348
  }
382
349
 
383
- async function fixTransformedOutputText({
384
- originalCode,
385
- transformedCode,
386
- sourceMap,
387
- preferredQuote,
350
+ function parseTargetImports(
351
+ body: Array<t.Statement>,
352
+ source: string,
353
+ targetModule: string,
354
+ ) {
355
+ const imports: Array<ParsedImportDeclaration> = []
356
+
357
+ for (const statement of body) {
358
+ if (
359
+ !t.isImportDeclaration(statement) ||
360
+ statement.importKind === 'type' ||
361
+ statement.source.value !== targetModule
362
+ ) {
363
+ continue
364
+ }
365
+
366
+ const rawSource = source.slice(
367
+ statement.source.start!,
368
+ statement.source.end!,
369
+ )
370
+ const importStatement = source.slice(statement.start!, statement.end!)
371
+
372
+ imports.push({
373
+ declaration: statement,
374
+ defaultImport: statement.specifiers.find((specifier) =>
375
+ t.isImportDefaultSpecifier(specifier),
376
+ )?.local.name,
377
+ namespace: statement.specifiers.find((specifier) =>
378
+ t.isImportNamespaceSpecifier(specifier),
379
+ )?.local.name,
380
+ named: statement.specifiers
381
+ .filter((specifier): specifier is t.ImportSpecifier =>
382
+ t.isImportSpecifier(specifier),
383
+ )
384
+ .map((specifier) => ({
385
+ imported: t.isIdentifier(specifier.imported)
386
+ ? specifier.imported.name
387
+ : specifier.imported.value,
388
+ local: specifier.local.name,
389
+ importKind: specifier.importKind ?? undefined,
390
+ })),
391
+ moduleName: statement.source.value,
392
+ quote: rawSource[0] as '"' | "'",
393
+ semicolon: importStatement.trimEnd().endsWith(';'),
394
+ })
395
+ }
396
+
397
+ return imports
398
+ }
399
+
400
+ function updateRouteImports({
401
+ imports,
402
+ source,
403
+ s,
404
+ targetModule,
405
+ required,
406
+ lineEnding,
388
407
  }: {
389
- originalCode: string
390
- transformedCode: string
391
- sourceMap: RawSourceMap
392
- preferredQuote: '"' | "'"
408
+ imports: Array<ParsedImportDeclaration>
409
+ source: string
410
+ s: MagicString
411
+ targetModule: string
412
+ required: RouteConstructorName
413
+ lineEnding: '\r\n' | '\n' | '\r'
393
414
  }) {
394
- const originalLines = originalCode.split('\n')
395
- const transformedLines = transformedCode.split('\n')
415
+ const analysis = analyzeRouteImports(imports, required)
416
+
417
+ if (analysis.kind === 'ok') {
418
+ return false
419
+ }
420
+
421
+ if (analysis.kind === 'rename') {
422
+ s.update(analysis.imported.start!, analysis.imported.end!, analysis.next)
423
+ s.update(analysis.local.start!, analysis.local.end!, analysis.next)
424
+ return true
425
+ }
396
426
 
397
- const defaultUsesSemicolons = detectSemicolonUsage(originalCode)
427
+ return normalizeRouteImports({
428
+ imports,
429
+ source,
430
+ s,
431
+ targetModule,
432
+ required,
433
+ lineEnding,
434
+ })
435
+ }
436
+
437
+ function analyzeRouteImports(
438
+ imports: Array<ParsedImportDeclaration>,
439
+ required: RouteConstructorName,
440
+ ): RouteImportAnalysis {
441
+ const opposite = getOtherRouteConstructor(required)
442
+ let requiredCount = 0
443
+ let oppositeCount = 0
444
+ let renameCandidate:
445
+ | {
446
+ imported: t.Identifier
447
+ local: t.Identifier
448
+ next: RouteConstructorName
449
+ }
450
+ | undefined
398
451
 
399
- const consumer = await new SourceMapConsumer(sourceMap)
452
+ for (const declaration of imports) {
453
+ for (const specifier of declaration.declaration.specifiers) {
454
+ if (!t.isImportSpecifier(specifier)) {
455
+ continue
456
+ }
400
457
 
401
- const fixedLines = transformedLines.map((line, i) => {
402
- const transformedLineNum = i + 1
458
+ const imported = specifier.imported
459
+ if (!t.isIdentifier(imported)) {
460
+ return { kind: 'normalize' }
461
+ }
403
462
 
404
- let origLineText: string | undefined = undefined
463
+ if (!isRouteConstructorName(imported.name)) {
464
+ continue
465
+ }
405
466
 
406
- for (let col = 0; col < line.length; col++) {
407
- const mapped = consumer.originalPositionFor({
408
- line: transformedLineNum,
409
- column: col,
410
- })
411
- if (mapped.line != null && mapped.line > 0) {
412
- origLineText = originalLines[mapped.line - 1]
413
- break
467
+ if (specifier.local.name !== imported.name) {
468
+ return { kind: 'normalize' }
414
469
  }
415
- }
416
470
 
417
- if (origLineText !== undefined) {
418
- if (origLineText === line) {
419
- return origLineText
471
+ if (imported.name === required) {
472
+ requiredCount++
473
+ continue
474
+ }
475
+
476
+ if (imported.name === opposite) {
477
+ oppositeCount++
478
+ renameCandidate = {
479
+ imported,
480
+ local: specifier.local,
481
+ next: required,
482
+ }
420
483
  }
421
- return fixLine(line, {
422
- originalLine: origLineText,
423
- useOriginalSemicolon: true,
424
- useOriginalQuotes: true,
425
- fallbackQuote: preferredQuote,
426
- })
427
- } else {
428
- return fixLine(line, {
429
- originalLine: null,
430
- useOriginalSemicolon: false,
431
- useOriginalQuotes: false,
432
- fallbackQuote: preferredQuote,
433
- fallbackSemicolon: defaultUsesSemicolons,
434
- })
435
484
  }
436
- })
485
+ }
486
+
487
+ if (requiredCount === 1 && oppositeCount === 0) {
488
+ return { kind: 'ok' }
489
+ }
437
490
 
438
- return fixedLines.join('\n')
491
+ if (requiredCount === 0 && oppositeCount === 1 && renameCandidate) {
492
+ return {
493
+ kind: 'rename',
494
+ ...renameCandidate,
495
+ }
496
+ }
497
+
498
+ return { kind: 'normalize' }
439
499
  }
440
500
 
441
- function fixLine(
442
- line: string,
443
- {
444
- originalLine,
445
- useOriginalSemicolon,
446
- useOriginalQuotes,
447
- fallbackQuote,
448
- fallbackSemicolon = true,
449
- }: {
450
- originalLine: string | null
451
- useOriginalSemicolon: boolean
452
- useOriginalQuotes: boolean
453
- fallbackQuote: string
454
- fallbackSemicolon?: boolean
455
- },
456
- ) {
457
- let result = line
501
+ function normalizeRouteImports({
502
+ imports,
503
+ source,
504
+ s,
505
+ targetModule,
506
+ required,
507
+ lineEnding,
508
+ }: {
509
+ imports: Array<ParsedImportDeclaration>
510
+ source: string
511
+ s: MagicString
512
+ targetModule: string
513
+ required: RouteConstructorName
514
+ lineEnding: '\r\n' | '\n' | '\r'
515
+ }) {
516
+ const owner =
517
+ imports.find((declaration) =>
518
+ hasNamedImport(declaration.named, required),
519
+ ) ?? imports.find((declaration) => !declaration.namespace)
520
+
521
+ let modified = false
522
+
523
+ for (const declaration of imports) {
524
+ const named = normalizeNamedImports({
525
+ named: declaration.named,
526
+ required,
527
+ isOwner: declaration === owner,
528
+ })
529
+
530
+ if (sameNamedImports(declaration.named, named)) {
531
+ continue
532
+ }
533
+
534
+ const replacement = renderImportDeclaration({
535
+ ...declaration,
536
+ named,
537
+ })
458
538
 
459
- if (useOriginalQuotes && originalLine) {
460
- result = fixQuotes(result, originalLine, fallbackQuote)
461
- } else if (!useOriginalQuotes && fallbackQuote) {
462
- result = fixQuotesToPreferred(result, fallbackQuote)
539
+ if (replacement === null) {
540
+ s.remove(
541
+ declaration.declaration.start!,
542
+ getRemovalEnd(source, declaration.declaration.end!),
543
+ )
544
+ modified = true
545
+ continue
546
+ }
547
+
548
+ s.update(
549
+ declaration.declaration.start!,
550
+ declaration.declaration.end!,
551
+ replacement,
552
+ )
553
+ modified = true
463
554
  }
464
555
 
465
- if (useOriginalSemicolon && originalLine) {
466
- const hadSemicolon = originalLine.trimEnd().endsWith(';')
467
- const hasSemicolon = result.trimEnd().endsWith(';')
468
- if (hadSemicolon && !hasSemicolon) result += ';'
469
- if (!hadSemicolon && hasSemicolon) result = result.replace(/;\s*$/, '')
470
- } else if (!useOriginalSemicolon) {
471
- const hasSemicolon = result.trimEnd().endsWith(';')
472
- if (!fallbackSemicolon && hasSemicolon) result = result.replace(/;\s*$/, '')
473
- if (fallbackSemicolon && !hasSemicolon && result.trim()) result += ';'
556
+ if (!owner) {
557
+ const quote = imports[0]?.quote ?? "'"
558
+ const semicolon = imports[0]?.semicolon ?? false
559
+ s.prepend(
560
+ `import { ${required} } from ${quote}${targetModule}${quote}${semicolon ? ';' : ''}${lineEnding}`,
561
+ )
562
+ modified = true
474
563
  }
475
564
 
476
- return result
565
+ return modified
477
566
  }
478
567
 
479
- function fixQuotes(line: string, originalLine: string, fallbackQuote: string) {
480
- let originalQuote = detectQuoteFromLine(originalLine)
481
- if (!originalQuote) {
482
- originalQuote = fallbackQuote
568
+ function normalizeNamedImports({
569
+ named,
570
+ required,
571
+ isOwner,
572
+ }: {
573
+ named: Array<NamedImport>
574
+ required: RouteConstructorName
575
+ isOwner: boolean
576
+ }) {
577
+ const banned = getOtherRouteConstructor(required)
578
+ const nextNamed: Array<NamedImport> = []
579
+ const seen = new Set<string>()
580
+
581
+ for (const specifier of named) {
582
+ if (specifier.imported === banned) {
583
+ continue
584
+ }
585
+
586
+ if (
587
+ specifier.local === required &&
588
+ (specifier.imported !== required || !isOwner)
589
+ ) {
590
+ continue
591
+ }
592
+
593
+ const key = `${specifier.importKind ?? 'value'}:${specifier.imported}:${specifier.local}`
594
+ if (seen.has(key)) {
595
+ continue
596
+ }
597
+
598
+ seen.add(key)
599
+ nextNamed.push(specifier)
600
+ }
601
+
602
+ if (isOwner && !hasNamedImport(nextNamed, required)) {
603
+ nextNamed.push({ imported: required, local: required })
483
604
  }
484
- return fixQuotesToPreferred(line, originalQuote)
605
+
606
+ return nextNamed
607
+ }
608
+
609
+ function getExpectedRouteConstructor(lazy: boolean): RouteConstructorName {
610
+ return lazy ? 'createLazyFileRoute' : 'createFileRoute'
611
+ }
612
+
613
+ function getOtherRouteConstructor(
614
+ constructor: RouteConstructorName,
615
+ ): RouteConstructorName {
616
+ return constructor === 'createFileRoute'
617
+ ? 'createLazyFileRoute'
618
+ : 'createFileRoute'
619
+ }
620
+
621
+ function hasNamedImport(
622
+ named: Array<NamedImport>,
623
+ required: RouteConstructorName,
624
+ ) {
625
+ return named.some(
626
+ (specifier) =>
627
+ specifier.imported === required &&
628
+ specifier.local === required &&
629
+ specifier.importKind !== 'type',
630
+ )
485
631
  }
486
632
 
487
- function fixQuotesToPreferred(line: string, quote: string) {
488
- // Replace existing quotes with preferred quote
489
- return line.replace(
490
- /(['"`])([^'"`\\]*(?:\\.[^'"`\\]*)*)\1/g,
491
- (_, q, content) => {
492
- const escaped = content.replaceAll(quote, `\\${quote}`)
493
- return `${quote}${escaped}${quote}`
494
- },
633
+ function sameNamedImports(left: Array<NamedImport>, right: Array<NamedImport>) {
634
+ return (
635
+ left.length === right.length &&
636
+ left.every(
637
+ (specifier, index) =>
638
+ specifier.imported === right[index]!.imported &&
639
+ specifier.local === right[index]!.local &&
640
+ specifier.importKind === right[index]!.importKind,
641
+ )
495
642
  )
496
643
  }
497
644
 
498
- function detectQuoteFromLine(line: string) {
499
- const match = line.match(/(['"`])(?:\\.|[^\\])*?\1/)
500
- return match ? match[1] : null
645
+ function isRouteConstructorName(value: string): value is RouteConstructorName {
646
+ return routeConstructors.includes(value as RouteConstructorName)
501
647
  }
502
648
 
503
- function detectSemicolonUsage(code: string) {
504
- const lines = code.split('\n').map((l) => l.trim())
505
- const total = lines.length
506
- const withSemis = lines.filter((l) => l.endsWith(';')).length
507
- return withSemis > total / 2
649
+ function renderImportDeclaration(importDeclaration: {
650
+ defaultImport?: string
651
+ namespace?: string
652
+ named: Array<NamedImport>
653
+ moduleName: string
654
+ quote: '"' | "'"
655
+ semicolon: boolean
656
+ }) {
657
+ const parts: Array<string> = []
658
+
659
+ if (importDeclaration.defaultImport) {
660
+ parts.push(importDeclaration.defaultImport)
661
+ }
662
+
663
+ if (importDeclaration.namespace) {
664
+ parts.push(`* as ${importDeclaration.namespace}`)
665
+ }
666
+
667
+ if (importDeclaration.named.length > 0) {
668
+ parts.push(
669
+ `{ ${importDeclaration.named
670
+ .map(
671
+ (specifier) =>
672
+ `${specifier.importKind === 'type' ? 'type ' : ''}${specifier.imported === specifier.local ? specifier.imported : `${specifier.imported} as ${specifier.local}`}`,
673
+ )
674
+ .join(', ')} }`,
675
+ )
676
+ }
677
+
678
+ if (parts.length === 0) {
679
+ return null
680
+ }
681
+
682
+ return `import ${parts.join(', ')} from ${importDeclaration.quote}${importDeclaration.moduleName}${importDeclaration.quote}${importDeclaration.semicolon ? ';' : ''}`
508
683
  }
509
684
 
510
- export function detectPreferredQuoteStyle(ast: types.ASTNode): "'" | '"' {
511
- let single = 0
512
- let double = 0
685
+ function getLineEnding(source: string): '\r\n' | '\n' | '\r' {
686
+ if (source.includes('\r\n')) {
687
+ return '\r\n'
688
+ }
513
689
 
514
- visit(ast, {
515
- visitStringLiteral(path) {
516
- if (path.parent.node.type !== 'JSXAttribute') {
517
- const raw = path.node.extra?.raw
518
- if (raw?.startsWith("'")) single++
519
- else if (raw?.startsWith('"')) double++
520
- }
521
- return false
522
- },
523
- })
690
+ if (source.includes('\n')) {
691
+ return '\n'
692
+ }
524
693
 
525
- if (single >= double) {
526
- return "'"
694
+ if (source.includes('\r')) {
695
+ return '\r'
527
696
  }
528
- return '"'
697
+
698
+ return '\n'
699
+ }
700
+
701
+ function getRemovalEnd(source: string, end: number) {
702
+ let pos = end
703
+ while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t')) {
704
+ pos++
705
+ }
706
+
707
+ if (source[pos] === '\r' && source[pos + 1] === '\n') {
708
+ return pos + 2
709
+ }
710
+
711
+ if (source[pos] === '\n' || source[pos] === '\r') {
712
+ return pos + 1
713
+ }
714
+
715
+ return end
529
716
  }