-
-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat: plugin-driven route generation #6122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9287fab
010da8e
35190fa
8b4c530
39328d6
da28d98
d5ea6a9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() | ||
|
|
@@ -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, | ||
|
|
@@ -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, | ||
|
|
@@ -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, | ||
|
|
@@ -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( | ||
|
|
@@ -743,6 +760,7 @@ export class Generator { | |
| return `.lazy(() => import('./${importPath}').then((d) => ${exportAccessor}))` | ||
| })() | ||
| : '', | ||
| node.extension || '', | ||
| ].join(''), | ||
| ].join('\n\n') | ||
| }) | ||
|
|
@@ -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]) | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
|
@@ -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') | ||
|
|
@@ -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 | ||
|
|
@@ -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 ?? | ||
|
|
@@ -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, | ||
|
|
||
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? let's start with the ideal API from a plugin-author perspective and then work backwards
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: TanStack/router
Length of output: 1279
🏁 Script executed:
Repository: TanStack/router
Length of output: 4563
🏁 Script executed:
Repository: TanStack/router
Length of output: 2220
🏁 Script executed:
Repository: TanStack/router
Length of output: 770
🏁 Script executed:
Repository: TanStack/router
Length of output: 5506
🏁 Script executed:
Repository: TanStack/router
Length of output: 1340
🏁 Script executed:
Repository: TanStack/router
Length of output: 1516
Type cast
config as Configmay cause runtime issues for plugins.The function receives
Pick<Config, 'plugins' | 'routesDirectory' | 'routeFilePrefix' | 'routeFileIgnorePrefix' | 'routeFileIgnorePattern' | 'disableLogging' | 'routeToken' | 'indexToken' | 'experimental'>but casts it to the fullConfigtype, hiding a mismatch. The mdx plugin (and potentially others) accesses properties likeconfig.verboseFileRoutesandconfig.generatedRouteTreethat aren't included in the Pick, which will beundefinedat runtime.Consider either:
transformNodestype signature to accept the same Pick type