Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions packages/router-generator/src/filesystem/physical/getRouteNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function isVirtualConfigFile(fileName: string): boolean {
export async function getRouteNodes(
config: Pick<
Config,
| 'plugins'
| 'routesDirectory'
| 'routeFilePrefix'
| 'routeFileIgnorePrefix'
Expand All @@ -38,13 +39,17 @@ export async function getRouteNodes(
>,
root: string,
): Promise<GetRouteNodesResult> {
const { routeFilePrefix, routeFileIgnorePrefix, routeFileIgnorePattern } =
config
const {
plugins = [],
routeFilePrefix,
routeFileIgnorePrefix,
routeFileIgnorePattern,
} = config

const logger = logging({ disabled: config.disableLogging })
const routeFileIgnoreRegExp = new RegExp(routeFileIgnorePattern ?? '', 'g')

const routeNodes: Array<RouteNode> = []
let routeNodes: Array<RouteNode> = []
const allPhysicalDirectories: Array<string> = []

async function recurse(dir: string) {
Expand Down Expand Up @@ -127,9 +132,19 @@ export async function getRouteNodes(
const fullPath = replaceBackslash(path.join(fullDir, dirent.name))
const relativePath = path.posix.join(dir, dirent.name)

const isBuiltinFile =
fullPath.match(/\.(tsx|ts|jsx|js|vue)$/) ||
plugins.find((p) =>
p.isBuiltInFile?.({
fileName: dirent.name,
fullPath,
relativePath,
}),
)

if (dirent.isDirectory()) {
await recurse(relativePath)
} else if (fullPath.match(/\.(tsx|ts|jsx|js|vue)$/)) {
} else if (isBuiltinFile) {
const filePath = replaceBackslash(path.join(dir, dirent.name))
const filePathNoExt = removeExt(filePath)
const {
Expand Down Expand Up @@ -170,7 +185,7 @@ export async function getRouteNodes(
routeType = 'pathless_layout'
}

// Only show deprecation warning for .tsx/.ts files, not .vue files
// Only show deprecation warning for .tsx/.ts files, not .vue or plugin files
// Vue files using .component.vue is the Vue-native way
const isVueFile = filePath.endsWith('.vue')
if (!isVueFile) {
Expand Down Expand Up @@ -240,6 +255,19 @@ export async function getRouteNodes(

await recurse('./')

// Let plugins transform nodes.
for (const plugin of plugins) {
if (plugin.transformNodes) {
const result = plugin.transformNodes({
routeNodes,
config: config as Config,
})
if (result) {
routeNodes = result
}
}
}
Comment on lines +258 to +269
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the getRouteNodes.ts file to see the exact Pick and config usage
echo "=== getRouteNodes.ts config usage (lines 250-270) ==="
rg -n "config as Config" packages/router-generator/src/filesystem/physical/getRouteNodes.ts -B 15 -A 5

echo ""
echo "=== getRouteNodes.ts Pick usage ==="
rg -n "Pick<Config" packages/router-generator/src/filesystem/physical/getRouteNodes.ts -A 10

Repository: TanStack/router

Length of output: 1279


🏁 Script executed:

#!/bin/bash
# Check the function signature of getRouteNodes to see what config parameter type is
echo "=== getRouteNodes function signature ==="
rg -n "export.*async.*getRouteNodes|function getRouteNodes" packages/router-generator/src/filesystem/physical/getRouteNodes.ts -A 8

echo ""
echo "=== transformNodes plugin type definition ==="
rg -n "transformNodes" packages/router-generator/src -l

echo ""
echo "=== Plugin interface definition ==="
rg -n "interface.*Plugin|type.*Plugin" packages/router-generator/src -A 10 | head -80

echo ""
echo "=== Full Config type ==="
rg -n "^export.*type Config|^export.*interface Config" packages/router-generator/src/config.ts -A 20

Repository: TanStack/router

Length of output: 4563


🏁 Script executed:

#!/bin/bash
# Get the full configSchema to see all properties
echo "=== Full configSchema ==="
rg -n "const configSchema" packages/router-generator/src/config.ts -A 50

echo ""
echo "=== transformNodes method signature ==="
rg -n "transformNodes" packages/router-generator/src/plugin/types.ts -A 10

Repository: TanStack/router

Length of output: 2220


🏁 Script executed:

#!/bin/bash
# Check what the full Pick includes in getRouteNodes
echo "=== Full Pick in getRouteNodes (lines 28-40) ==="
sed -n '28,40p' packages/router-generator/src/filesystem/physical/getRouteNodes.ts

echo ""
echo "=== Search for any example/test plugins that might use transformNodes ==="
find packages/router-generator -name "*.ts" -type f | xargs grep -l "transformNodes" | head -5

echo ""
echo "=== Check if any plugins access Config properties beyond the Pick ==="
rg -n "config\." packages/router-generator/src --type ts | grep -i "transform\|plugin" | head -20

Repository: TanStack/router

Length of output: 770


🏁 Script executed:

#!/bin/bash
# Check the mdx plugin to see what properties it accesses from config
echo "=== mdx.ts plugin - what config properties are used ==="
cat packages/router-generator/tests/plugins/mdx.ts

echo ""
echo "=== Search for any other references to transformNodes in the codebase ==="
rg -n "transformNodes" packages/router-generator -A 5 -B 2 --type ts

Repository: TanStack/router

Length of output: 5506


🏁 Script executed:

#!/bin/bash
# Check if verboseFileRoutes and generatedRouteTree are in the Pick
echo "=== Checking if verboseFileRoutes is in the Pick ==="
grep -n "verboseFileRoutes\|generatedRouteTree" packages/router-generator/src/filesystem/physical/getRouteNodes.ts | head -10

echo ""
echo "=== Check baseConfigSchema to see all base properties ==="
sed -n '7,33p' packages/router-generator/src/config.ts

echo ""
echo "=== Verify the mdx plugin is actually being tested/used ==="
find packages/router-generator/tests -name "*.test.ts" -o -name "*.spec.ts" | xargs grep -l "mdx" 2>/dev/null | head -3

Repository: TanStack/router

Length of output: 1340


🏁 Script executed:

#!/bin/bash
# Final verification - check what config.verboseFileRoutes and config.generatedRouteTree are in baseConfigSchema
echo "=== Properties that mdx plugin uses but are not in the Pick ==="
echo "1. config.verboseFileRoutes - is it in baseConfigSchema? (should see result below)"
grep -n "verboseFileRoutes" packages/router-generator/src/config.ts

echo ""
echo "2. config.generatedRouteTree - is it in baseConfigSchema? (should see result below)"
grep -n "generatedRouteTree" packages/router-generator/src/config.ts

echo ""
echo "=== Location of these in schema ==="
rg -n "verboseFileRoutes|generatedRouteTree" packages/router-generator/src/config.ts -B 2

Repository: TanStack/router

Length of output: 1516


Type cast config as Config may cause runtime issues for plugins.

The function receives Pick<Config, 'plugins' | 'routesDirectory' | 'routeFilePrefix' | 'routeFileIgnorePrefix' | 'routeFileIgnorePattern' | 'disableLogging' | 'routeToken' | 'indexToken' | 'experimental'> but casts it to the full Config type, hiding a mismatch. The mdx plugin (and potentially others) accesses properties like config.verboseFileRoutes and config.generatedRouteTree that aren't included in the Pick, which will be undefined at runtime.

Consider either:

  1. Expanding the Pick to include all properties plugins might need, or
  2. Updating the transformNodes type signature to accept the same Pick type


// Find the root route node - prefer the actual route file over component/loader files
const rootRouteNode =
routeNodes.find(
Expand Down
91 changes: 66 additions & 25 deletions packages/router-generator/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,12 +516,13 @@ export class Generator {
}
}

this.plugins.map((plugin) => {
return plugin.onRouteTreeChanged?.({
this.plugins.forEach((plugin) => {
plugin.onRouteTreeChanged?.({
routeTree: buildResult.routeTree,
routeNodes: buildResult.routeNodes,
acc,
rootRouteNode,
config: this.config,
})
})
this.swapCaches()
Expand Down Expand Up @@ -549,8 +550,12 @@ export class Generator {
(d) => d,
])

// Check if node is external module (not virtual and transformation not skipped)
const isExternal = (node: RouteNode) =>
!node.isVirtual && !node.skipTransform

const routeImports = sortedRouteNodes
.filter((d) => !d.isVirtual)
.filter((d) => isExternal(d))
.flatMap((node) =>
getImportForRouteNode(
node,
Expand All @@ -561,15 +566,15 @@ export class Generator {
)

const virtualRouteNodes = sortedRouteNodes
.filter((d) => d.isVirtual)
.filter((d) => !isExternal(d))
.map((node) => {
return `const ${
node.variableName
}RouteImport = createFileRoute('${node.routePath}')()`
})

const imports: Array<ImportDeclaration> = []
if (acc.routeNodes.some((n) => n.isVirtual)) {
if (acc.routeNodes.some((n) => !isExternal(n))) {
imports.push({
specifiers: [{ imported: 'createFileRoute' }],
source: this.targetTemplate.fullPkg,
Expand Down Expand Up @@ -683,25 +688,31 @@ export class Generator {
)
.filter((d) => d[1])
.map((d) => {
// For .vue files, use 'default' as the export name since Vue SFCs export default
const fileNode = d[1]!
const isVueFile = d[1]!.filePath.endsWith('.vue')
const exportName = isVueFile ? 'default' : d[0]
// Keep .vue extension for Vue files since Vite requires it
const importPath = replaceBackslash(
const exportName =
fileNode.componentImport?.exportName ??
// For .vue files, use 'default' as the export name since Vue SFCs export default
(isVueFile ? 'default' : d[0])
const keepExtension =
fileNode.componentImport?.keepExtension ??
// Keep .vue extension for Vue files since Vite requires it
isVueFile
const importPath = replaceBackslash(
keepExtension
? path.relative(
path.dirname(config.generatedRouteTree),
path.resolve(
config.routesDirectory,
d[1]!.filePath,
fileNode.filePath,
),
)
: removeExt(
path.relative(
path.dirname(config.generatedRouteTree),
path.resolve(
config.routesDirectory,
d[1]!.filePath,
fileNode.filePath,
),
),
config.addExtensions,
Expand All @@ -716,12 +727,18 @@ export class Generator {
: '',
lazyComponentNode
? (() => {
// For .vue files, use 'default' export since Vue SFCs export default
const isVueFile = lazyComponentNode.filePath.endsWith('.vue')
const exportAccessor = isVueFile ? 'd.default' : 'd.Route'
// Keep .vue extension for Vue files since Vite requires it
const importPath = replaceBackslash(
const exportName =
lazyComponentNode.componentImport?.exportName ??
// For .vue files, use 'default' export since Vue SFCs export default
(isVueFile ? 'default' : 'Route')
const keepExtension =
lazyComponentNode.componentImport?.keepExtension ??
// Keep .vue extension for Vue files since Vite requires it
isVueFile
const exportAccessor = `d.${exportName}`
const importPath = replaceBackslash(
keepExtension
? path.relative(
path.dirname(config.generatedRouteTree),
path.resolve(
Expand All @@ -743,6 +760,7 @@ export class Generator {
return `.lazy(() => import('./${importPath}').then((d) => ${exportAccessor}))`
})()
: '',
node.extension || '',
].join(''),
].join('\n\n')
})
Expand Down Expand Up @@ -780,22 +798,31 @@ export class Generator {
)
.filter((d) => d[1])
.map((d) => {
// For .vue files, use 'default' as the export name since Vue SFCs export default
const isVueFile = d[1]!.filePath.endsWith('.vue')
const exportName = isVueFile ? 'default' : d[0]
// Keep .vue extension for Vue files since Vite requires it
const importPath = replaceBackslash(
const fileNode = d[1]!
const isVueFile = fileNode.filePath.endsWith('.vue')
const exportName =
fileNode.componentImport?.exportName ??
// For .vue files, use 'default' as the export name since Vue SFCs export default
(isVueFile ? 'default' : d[0])
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can probably have a built-in Vue plugin. Purposely didn't want to make crazy changes in this PR, but open to composing the Vue stuff in a plugin.

const keepExtension =
fileNode.componentImport?.keepExtension ??
// Keep .vue extension for Vue files since Vite requires it
isVueFile
const importPath = replaceBackslash(
keepExtension
? path.relative(
path.dirname(config.generatedRouteTree),
path.resolve(config.routesDirectory, d[1]!.filePath),
path.resolve(
config.routesDirectory,
fileNode.filePath,
),
)
: removeExt(
path.relative(
path.dirname(config.generatedRouteTree),
path.resolve(
config.routesDirectory,
d[1]!.filePath,
fileNode.filePath,
),
),
config.addExtensions,
Expand Down Expand Up @@ -893,6 +920,20 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
config,
)

// Collect additional route tree content from plugins
for (const plugin of this.plugins) {
const result = plugin.getAdditionalRouteTreeContent?.({
routeTree: acc.routeTree,
routeNodes: sortedRouteNodes,
rootRouteNode,
acc,
config,
})
if (result?.imports) {
imports.push(...result.imports)
}
}

let mergedImports = mergeImportDeclarations(imports)
if (config.disableTypes) {
mergedImports = mergedImports.filter((d) => d.importKind !== 'type')
Expand Down Expand Up @@ -1005,7 +1046,7 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
let shouldWriteRouteFile = false
let shouldWriteTree = false
// now we need to either scaffold the file or transform it
if (!existingRouteFile.fileContent) {
if (!existingRouteFile.fileContent && !node.skipTransform) {
shouldWriteRouteFile = true
shouldWriteTree = true
// Creating a new lazy route file
Expand Down Expand Up @@ -1041,7 +1082,7 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
] satisfies Array<FsRouteType>
).every((d) => d !== node._fsRouteType)
) {
const tRouteTemplate = this.targetTemplate.route
const tRouteTemplate = node.template ?? this.targetTemplate.route
updatedCacheEntry.fileContent = await fillTemplate(
this.config,
this.config.customScaffolding?.routeTemplate ??
Expand All @@ -1063,7 +1104,7 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
// Vue SFC files (.vue) don't need transformation as they can't have a Route export
const isVueFile = node.filePath.endsWith('.vue')

if (!isVueFile) {
if (!isVueFile && !node.skipTransform) {
// transform the file
const transformResult = await transform({
source: updatedCacheEntry.fileContent,
Expand Down
61 changes: 55 additions & 6 deletions packages/router-generator/src/plugin/types.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,67 @@
import type { HandleNodeAccumulator, RouteNode } from '../types'
import type {
HandleNodeAccumulator,
ImportDeclaration,
RouteNode,
} from '../types'
import type { Generator } from '../generator'
import type { Config } from '../config'

export interface GeneratorPlugin {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the GeneratorPlugin in its entirety cannot become public API as we can't maintain this.

what's the absolute minimum API that you need to e.g. implement mdx?
we also can't expose those internal complex types such as RouteNode and HandleNodeAccumulator.

let's start with the ideal API from a plugin-author perspective and then work backwards

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is your thoughts on an ideal API? This PR caters specifically to an MDX use case, and I intentionally chose to try keep the API as minimal as possible for such.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have an opinion about a plugin api yet. so what do you need just for the mdx case?

name: string
/**
* Called after a route node file has been transformed.
*/
afterTransform?: (opts: {
node: RouteNode
prevNode: RouteNode | undefined
}) => void

/**
* Called when generating route tree content. Can return additional imports.
*/
getAdditionalRouteTreeContent?: (opts: {
routeTree: Array<RouteNode>
routeNodes: Array<RouteNode>
rootRouteNode: RouteNode
acc: HandleNodeAccumulator
config: Config
}) => { imports?: Array<ImportDeclaration> } | void

/**
* Initialize the plugin with access to the generator instance.
*/
init?: (opts: { generator: Generator }) => void

/**
* Determine if a file is built in.
*/
isBuiltInFile?: (opts: {
fileName: string
fullPath: string
relativePath: string
}) => boolean

/**
* The name of the plugin.
*/
name: string

/**
* Called after route tree is built.
*/
onRouteTreeChanged?: (opts: {
routeTree: Array<RouteNode>
routeNodes: Array<RouteNode>
rootRouteNode: RouteNode
acc: HandleNodeAccumulator
config: Config
}) => void

afterTransform?: (opts: {
node: RouteNode
prevNode: RouteNode | undefined
}) => void
/**
* Transform route nodes after filesystem discovery. Receives all nodes
* and can return a modified array.
*/
transformNodes?: (opts: {
routeNodes: Array<RouteNode>
config: Config
}) => Array<RouteNode> | void
}
6 changes: 6 additions & 0 deletions packages/router-generator/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { TargetTemplate } from './template'

export type RouteNode = {
filePath: string
fullPath: string
Expand All @@ -14,6 +16,10 @@ export type RouteNode = {
children?: Array<RouteNode>
parent?: RouteNode
createFileRouteProps?: Set<string>
componentImport?: { exportName?: string; keepExtension?: boolean }
extension?: string
skipTransform?: boolean
template?: TargetTemplate['route']
_isExperimentalNonNestedRoute?: boolean
}

Expand Down
Loading
Loading