Skip to content

feat(router-generator): implement AST-based export detection for routes #4669

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

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
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
17 changes: 8 additions & 9 deletions packages/router-generator/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
createRouteNodesByFullPath,
createRouteNodesById,
createRouteNodesByTo,
detectExportsFromSource,
determineNodePath,
findParent,
format,
Expand Down Expand Up @@ -1207,15 +1208,13 @@ ${acc.routeTree.map((child) => `${child.variableName}${exportName}: typeof ${get
updatedCacheEntry.mtimeMs = stats.mtimeMs
}

const rootRouteExports: Array<string> = []
for (const plugin of this.pluginsWithTransform) {
const exportName = plugin.transformPlugin.exportName
// TODO we need to parse instead of just string match
// otherwise a commented out export will still be detected
if (rootNodeFile.fileContent.includes(`export const ${exportName}`)) {
rootRouteExports.push(exportName)
}
}
const exportNames = this.pluginsWithTransform.map(
(plugin) => plugin.transformPlugin.exportName,
)
const rootRouteExports = detectExportsFromSource(
rootNodeFile.fileContent,
exportNames,
)

updatedCacheEntry.exports = rootRouteExports
node.exports = rootRouteExports
Expand Down
52 changes: 9 additions & 43 deletions packages/router-generator/src/transform/transform.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { parseAst } from '@tanstack/router-utils'
import { parse, print, types, visit } from 'recast'
import { SourceMapConsumer } from 'source-map'
import { mergeImportDeclarations } from '../utils'
import { matchExportInNode, mergeImportDeclarations } from '../utils'
import type { ImportDeclaration } from '../types'
import type { RawSourceMap } from 'source-map'
import type {
Expand Down Expand Up @@ -76,49 +76,15 @@ export async function transform({
}

const program: types.namedTypes.Program = ast.program
// first pass: find registered exports

const exportNames = Array.from(registeredExports.keys())
for (const n of program.body) {
if (registeredExports.size > 0 && n.type === 'ExportNamedDeclaration') {
// direct export of a variable declaration, e.g. `export const Route = createFileRoute('/path')`
if (n.declaration?.type === 'VariableDeclaration') {
const decl = n.declaration.declarations[0]
if (
decl &&
decl.type === 'VariableDeclarator' &&
decl.id.type === 'Identifier'
) {
const plugin = registeredExports.get(decl.id.name)
if (plugin) {
onExportFound(decl, decl.id.name, plugin)
}
}
}
// this is an export without a declaration, e.g. `export { Route }`
else if (n.declaration === null && n.specifiers) {
for (const spec of n.specifiers) {
if (typeof spec.exported.name === 'string') {
const plugin = registeredExports.get(spec.exported.name)
if (plugin) {
const variableName = spec.local?.name || spec.exported.name
// find the matching variable declaration by iterating over the top-level declarations
for (const decl of program.body) {
if (
decl.type === 'VariableDeclaration' &&
decl.declarations[0]
) {
const variable = decl.declarations[0]
if (
variable.type === 'VariableDeclarator' &&
variable.id.type === 'Identifier' &&
variable.id.name === variableName
) {
onExportFound(variable, spec.exported.name, plugin)
break
}
}
}
}
}
if (registeredExports.size > 0) {
const match = matchExportInNode(n, exportNames, program)
if (match) {
const plugin = registeredExports.get(match.exportName)
if (plugin) {
onExportFound(match.decl, match.exportName, plugin)
}
}
}
Expand Down
91 changes: 91 additions & 0 deletions packages/router-generator/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import * as fsp from 'node:fs/promises'
import path from 'node:path'
import * as prettier from 'prettier'
import { parseAst } from '@tanstack/router-utils'
import { parse } from 'recast'
import { rootPathId } from './filesystem/physical/rootPathId'
import type { types } from 'recast'
import type { Config } from './config'
import type { ImportDeclaration, RouteNode } from './types'

Expand Down Expand Up @@ -631,3 +634,91 @@ export function buildFileRoutesByPathInterface(opts: {
}
}`
}

function findVariableDeclaration(
program: types.namedTypes.Program,
variableName: string,
): types.namedTypes.VariableDeclarator | null {
for (const decl of program.body) {
if (decl.type === 'VariableDeclaration' && decl.declarations[0]) {
const variable = decl.declarations[0]
if (
variable.type === 'VariableDeclarator' &&
variable.id.type === 'Identifier' &&
variable.id.name === variableName
) {
return variable
}
}
}
return null
}

export function matchExportInNode(
node: any,
exportNames: Array<string>,
program: types.namedTypes.Program,
): { exportName: string; decl: types.namedTypes.VariableDeclarator } | null {
if (node.type === 'ExportNamedDeclaration') {
// direct export of a variable declaration, e.g. `export const Route = createFileRoute('/path')`
if (node.declaration?.type === 'VariableDeclaration') {
const decl = node.declaration.declarations[0]
if (
decl &&
decl.type === 'VariableDeclarator' &&
decl.id.type === 'Identifier' &&
exportNames.includes(decl.id.name)
) {
return { exportName: decl.id.name, decl }
}
}
// this is an export without a declaration, e.g. `export { Route }`
else if (node.declaration === null && node.specifiers) {
for (const spec of node.specifiers) {
if (
typeof spec.exported.name === 'string' &&
exportNames.includes(spec.exported.name)
) {
const variableName = spec.local?.name || spec.exported.name
const decl = findVariableDeclaration(program, variableName)
if (decl) {
return { exportName: spec.exported.name, decl }
}
}
}
}
}
return null
}

export function detectExportsFromSource(
source: string,
exportNames: Array<string>,
): Array<string> {
try {
const ast = parse(source, {
sourceFileName: 'detect-exports.ts',
parser: {
parse(code: string) {
return parseAst({
code,
tokens: true,
})
},
},
})

const foundExports: Array<string> = []
for (const node of ast.program.body) {
const match = matchExportInNode(node, exportNames, ast.program)
if (match) {
foundExports.push(match.exportName)
}
}
return foundExports
} catch (error) {
return exportNames.filter((exportName) =>
source.includes(`export const ${exportName}`),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/* eslint-disable */

// @ts-nocheck

// noinspection JSUnusedGlobalSymbols

// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.

import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'

const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
}

declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createRootRoute } from '@tanstack/react-router'

const Route = createRootRoute({
component: () => <div>Root Layout</div>,
})

export { Route }
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/')({
component: () => <div>Hello World</div>,
})