diff --git a/packages/router-generator/src/filesystem/physical/getRouteNodes.ts b/packages/router-generator/src/filesystem/physical/getRouteNodes.ts index 4adcf6d7cdd..481b5345385 100644 --- a/packages/router-generator/src/filesystem/physical/getRouteNodes.ts +++ b/packages/router-generator/src/filesystem/physical/getRouteNodes.ts @@ -27,6 +27,7 @@ export function isVirtualConfigFile(fileName: string): boolean { export async function getRouteNodes( config: Pick< Config, + | 'plugins' | 'routesDirectory' | 'routeFilePrefix' | 'routeFileIgnorePrefix' @@ -38,13 +39,17 @@ export async function getRouteNodes( >, root: string, ): Promise { - 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 = [] + let routeNodes: Array = [] const allPhysicalDirectories: Array = [] async function recurse(dir: string) { @@ -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 { @@ -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) { @@ -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 + } + } + } + // Find the root route node - prefer the actual route file over component/loader files const rootRouteNode = routeNodes.find( diff --git a/packages/router-generator/src/generator.ts b/packages/router-generator/src/generator.ts index baf9dd89a2e..0fc714ad3d4 100644 --- a/packages/router-generator/src/generator.ts +++ b/packages/router-generator/src/generator.ts @@ -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,7 +566,7 @@ export class Generator { ) const virtualRouteNodes = sortedRouteNodes - .filter((d) => d.isVirtual) + .filter((d) => !isExternal(d)) .map((node) => { return `const ${ node.variableName @@ -569,7 +574,7 @@ export class Generator { }) const imports: Array = [] - if (acc.routeNodes.some((n) => n.isVirtual)) { + if (acc.routeNodes.some((n) => !isExternal(n))) { imports.push({ specifiers: [{ imported: 'createFileRoute' }], source: this.targetTemplate.fullPkg, @@ -683,17 +688,23 @@ 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( @@ -701,7 +712,7 @@ export class Generator { 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]) + 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 ).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, diff --git a/packages/router-generator/src/plugin/types.ts b/packages/router-generator/src/plugin/types.ts index 57d210b40ba..1c2cda0052f 100644 --- a/packages/router-generator/src/plugin/types.ts +++ b/packages/router-generator/src/plugin/types.ts @@ -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 { - 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 + routeNodes: Array + rootRouteNode: RouteNode + acc: HandleNodeAccumulator + config: Config + }) => { imports?: Array } | 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 routeNodes: Array 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 + config: Config + }) => Array | void } diff --git a/packages/router-generator/src/types.ts b/packages/router-generator/src/types.ts index 997d5ff7a20..491c95fbbc0 100644 --- a/packages/router-generator/src/types.ts +++ b/packages/router-generator/src/types.ts @@ -1,3 +1,5 @@ +import { TargetTemplate } from './template' + export type RouteNode = { filePath: string fullPath: string @@ -14,6 +16,10 @@ export type RouteNode = { children?: Array parent?: RouteNode createFileRouteProps?: Set + componentImport?: { exportName?: string; keepExtension?: boolean } + extension?: string + skipTransform?: boolean + template?: TargetTemplate['route'] _isExperimentalNonNestedRoute?: boolean } diff --git a/packages/router-generator/src/utils.ts b/packages/router-generator/src/utils.ts index 5f497c85440..6ecedb0bb7e 100644 --- a/packages/router-generator/src/utils.ts +++ b/packages/router-generator/src/utils.ts @@ -753,14 +753,15 @@ export function getImportPath( config: Config, generatedRouteTreePath: string, ): string { + const relativePath = path.relative( + path.dirname(generatedRouteTreePath), + path.resolve(config.routesDirectory, node.filePath), + ) + const keepExtension = node.componentImport?.keepExtension ?? false return replaceBackslash( - removeExt( - path.relative( - path.dirname(generatedRouteTreePath), - path.resolve(config.routesDirectory, node.filePath), - ), - config.addExtensions, - ), + keepExtension + ? relativePath + : removeExt(relativePath, config.addExtensions), ) } @@ -771,12 +772,17 @@ export function getImportForRouteNode( root: string, ): ImportDeclaration { let source = '' + const keepExtension = node.componentImport?.keepExtension ?? false if (config.importRoutesUsingAbsolutePaths) { + const absolutePath = path.resolve( + root, + config.routesDirectory, + node.filePath, + ) source = replaceBackslash( - removeExt( - path.resolve(root, config.routesDirectory, node.filePath), - config.addExtensions, - ), + keepExtension + ? absolutePath + : removeExt(absolutePath, config.addExtensions), ) } else { source = `./${getImportPath(node, config, generatedRouteTreePath)}` @@ -785,7 +791,7 @@ export function getImportForRouteNode( source, specifiers: [ { - imported: 'Route', + imported: node.componentImport?.exportName ?? 'Route', local: `${node.variableName}RouteImport`, }, ], diff --git a/packages/router-generator/tests/generator.test.ts b/packages/router-generator/tests/generator.test.ts index 4caeff1ee4f..85f53574bb5 100644 --- a/packages/router-generator/tests/generator.test.ts +++ b/packages/router-generator/tests/generator.test.ts @@ -10,6 +10,7 @@ import { route, } from '@tanstack/virtual-file-routes' import { Generator, getConfig } from '../src' +import { mdxRouteGen } from './plugins/mdx' import type { Config } from '../src' function makeFolderDir(folder: string) { @@ -154,6 +155,9 @@ function rewriteConfigByFolderName( config.routeFileIgnorePattern = 'ignoredPattern' config.routeFilePrefix = 'r&' break + case 'mdx-route-gen': + config.plugins = [mdxRouteGen()] + break default: break } diff --git a/packages/router-generator/tests/generator/mdx-route-gen/routeTree.nonnested.snapshot.ts b/packages/router-generator/tests/generator/mdx-route-gen/routeTree.nonnested.snapshot.ts new file mode 100644 index 00000000000..3db1cc0d327 --- /dev/null +++ b/packages/router-generator/tests/generator/mdx-route-gen/routeTree.nonnested.snapshot.ts @@ -0,0 +1,299 @@ +/* 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 { createFileRoute } from '@tanstack/react-router' +import { default as ContactLazyRouteComponent } from './routes/contact.lazy.mdx' +import { default as AboutRouteComponent } from './routes/about.mdx' +import { default as PostsIndexRouteComponent } from './routes/posts/index.mdx' +import { default as BlogIndexRouteComponent } from './routes/blog.index.mdx' +import { default as PostsPostIdRouteComponent } from './routes/posts/$postId.mdx' +import { default as LayoutFaqRouteComponent } from './routes/_layout/faq.mdx' +import { default as marketingPricingRouteComponent } from './routes/(marketing)/pricing.mdx' + +import { Route as rootRouteImport } from './routes/__root' +import { Route as DocsRouteImport } from './routes/docs' +import { Route as LayoutRouteImport } from './routes/_layout' +import { Route as PostsRouteRouteImport } from './routes/posts/route' +import { Route as IndexRouteImport } from './routes/index' + +const ContactLazyRouteImport = createFileRoute('/contact')() +const AboutRouteImport = createFileRoute('/about')() +const PostsIndexRouteImport = createFileRoute('/posts/')() +const BlogIndexRouteImport = createFileRoute('/blog/')() +const PostsPostIdRouteImport = createFileRoute('/posts/$postId')() +const LayoutFaqRouteImport = createFileRoute('/_layout/faq')() +const marketingPricingRouteImport = createFileRoute('/(marketing)/pricing')() + +const ContactLazyRoute = ContactLazyRouteImport.update({ + id: '/contact', + path: '/contact', + getParentRoute: () => rootRouteImport, +} as any) + .lazy(() => import('./routes/contact.lazy.mdx').then((d) => d.default)) + .update({ component: ContactLazyRouteComponent }) +const DocsRoute = DocsRouteImport.update({ + id: '/docs', + path: '/docs', + getParentRoute: () => rootRouteImport, +} as any) +const AboutRoute = AboutRouteImport.update({ + id: '/about', + path: '/about', + getParentRoute: () => rootRouteImport, +} as any).update({ component: AboutRouteComponent }) +const LayoutRoute = LayoutRouteImport.update({ + id: '/_layout', + getParentRoute: () => rootRouteImport, +} as any) +const PostsRouteRoute = PostsRouteRouteImport.update({ + id: '/posts', + path: '/posts', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const PostsIndexRoute = PostsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => PostsRouteRoute, +} as any).update({ component: PostsIndexRouteComponent }) +const BlogIndexRoute = BlogIndexRouteImport.update({ + id: '/blog/', + path: '/blog/', + getParentRoute: () => rootRouteImport, +} as any).update({ component: BlogIndexRouteComponent }) +const PostsPostIdRoute = PostsPostIdRouteImport.update({ + id: '/$postId', + path: '/$postId', + getParentRoute: () => PostsRouteRoute, +} as any).update({ component: PostsPostIdRouteComponent }) +const LayoutFaqRoute = LayoutFaqRouteImport.update({ + id: '/faq', + path: '/faq', + getParentRoute: () => LayoutRoute, +} as any).update({ component: LayoutFaqRouteComponent }) +const marketingPricingRoute = marketingPricingRouteImport + .update({ + id: '/(marketing)/pricing', + path: '/pricing', + getParentRoute: () => rootRouteImport, + } as any) + .update({ component: marketingPricingRouteComponent }) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/posts': typeof PostsRouteRouteWithChildren + '/about': typeof AboutRoute + '/docs': typeof DocsRoute + '/contact': typeof ContactLazyRoute + '/pricing': typeof marketingPricingRoute + '/faq': typeof LayoutFaqRoute + '/posts/$postId': typeof PostsPostIdRoute + '/blog': typeof BlogIndexRoute + '/posts/': typeof PostsIndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/about': typeof AboutRoute + '/docs': typeof DocsRoute + '/contact': typeof ContactLazyRoute + '/pricing': typeof marketingPricingRoute + '/faq': typeof LayoutFaqRoute + '/posts/$postId': typeof PostsPostIdRoute + '/blog': typeof BlogIndexRoute + '/posts': typeof PostsIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/posts': typeof PostsRouteRouteWithChildren + '/_layout': typeof LayoutRouteWithChildren + '/about': typeof AboutRoute + '/docs': typeof DocsRoute + '/contact': typeof ContactLazyRoute + '/(marketing)/pricing': typeof marketingPricingRoute + '/_layout/faq': typeof LayoutFaqRoute + '/posts/$postId': typeof PostsPostIdRoute + '/blog/': typeof BlogIndexRoute + '/posts/': typeof PostsIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/posts' + | '/about' + | '/docs' + | '/contact' + | '/pricing' + | '/faq' + | '/posts/$postId' + | '/blog' + | '/posts/' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/about' + | '/docs' + | '/contact' + | '/pricing' + | '/faq' + | '/posts/$postId' + | '/blog' + | '/posts' + id: + | '__root__' + | '/' + | '/posts' + | '/_layout' + | '/about' + | '/docs' + | '/contact' + | '/(marketing)/pricing' + | '/_layout/faq' + | '/posts/$postId' + | '/blog/' + | '/posts/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + PostsRouteRoute: typeof PostsRouteRouteWithChildren + LayoutRoute: typeof LayoutRouteWithChildren + AboutRoute: typeof AboutRoute + DocsRoute: typeof DocsRoute + ContactLazyRoute: typeof ContactLazyRoute + marketingPricingRoute: typeof marketingPricingRoute + BlogIndexRoute: typeof BlogIndexRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/contact': { + id: '/contact' + path: '/contact' + fullPath: '/contact' + preLoaderRoute: typeof ContactLazyRouteImport + parentRoute: typeof rootRouteImport + } + '/docs': { + id: '/docs' + path: '/docs' + fullPath: '/docs' + preLoaderRoute: typeof DocsRouteImport + parentRoute: typeof rootRouteImport + } + '/about': { + id: '/about' + path: '/about' + fullPath: '/about' + preLoaderRoute: typeof AboutRouteImport + parentRoute: typeof rootRouteImport + } + '/_layout': { + id: '/_layout' + path: '' + fullPath: '' + preLoaderRoute: typeof LayoutRouteImport + parentRoute: typeof rootRouteImport + } + '/posts': { + id: '/posts' + path: '/posts' + fullPath: '/posts' + preLoaderRoute: typeof PostsRouteRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/posts/': { + id: '/posts/' + path: '/' + fullPath: '/posts/' + preLoaderRoute: typeof PostsIndexRouteImport + parentRoute: typeof PostsRouteRoute + } + '/blog/': { + id: '/blog/' + path: '/blog' + fullPath: '/blog' + preLoaderRoute: typeof BlogIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/posts/$postId': { + id: '/posts/$postId' + path: '/$postId' + fullPath: '/posts/$postId' + preLoaderRoute: typeof PostsPostIdRouteImport + parentRoute: typeof PostsRouteRoute + } + '/_layout/faq': { + id: '/_layout/faq' + path: '/faq' + fullPath: '/faq' + preLoaderRoute: typeof LayoutFaqRouteImport + parentRoute: typeof LayoutRoute + } + '/(marketing)/pricing': { + id: '/(marketing)/pricing' + path: '/pricing' + fullPath: '/pricing' + preLoaderRoute: typeof marketingPricingRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +interface PostsRouteRouteChildren { + PostsPostIdRoute: typeof PostsPostIdRoute + PostsIndexRoute: typeof PostsIndexRoute +} + +const PostsRouteRouteChildren: PostsRouteRouteChildren = { + PostsPostIdRoute: PostsPostIdRoute, + PostsIndexRoute: PostsIndexRoute, +} + +const PostsRouteRouteWithChildren = PostsRouteRoute._addFileChildren( + PostsRouteRouteChildren, +) + +interface LayoutRouteChildren { + LayoutFaqRoute: typeof LayoutFaqRoute +} + +const LayoutRouteChildren: LayoutRouteChildren = { + LayoutFaqRoute: LayoutFaqRoute, +} + +const LayoutRouteWithChildren = + LayoutRoute._addFileChildren(LayoutRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + PostsRouteRoute: PostsRouteRouteWithChildren, + LayoutRoute: LayoutRouteWithChildren, + AboutRoute: AboutRoute, + DocsRoute: DocsRoute, + ContactLazyRoute: ContactLazyRoute, + marketingPricingRoute: marketingPricingRoute, + BlogIndexRoute: BlogIndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/packages/router-generator/tests/generator/mdx-route-gen/routeTree.snapshot.ts b/packages/router-generator/tests/generator/mdx-route-gen/routeTree.snapshot.ts new file mode 100644 index 00000000000..3db1cc0d327 --- /dev/null +++ b/packages/router-generator/tests/generator/mdx-route-gen/routeTree.snapshot.ts @@ -0,0 +1,299 @@ +/* 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 { createFileRoute } from '@tanstack/react-router' +import { default as ContactLazyRouteComponent } from './routes/contact.lazy.mdx' +import { default as AboutRouteComponent } from './routes/about.mdx' +import { default as PostsIndexRouteComponent } from './routes/posts/index.mdx' +import { default as BlogIndexRouteComponent } from './routes/blog.index.mdx' +import { default as PostsPostIdRouteComponent } from './routes/posts/$postId.mdx' +import { default as LayoutFaqRouteComponent } from './routes/_layout/faq.mdx' +import { default as marketingPricingRouteComponent } from './routes/(marketing)/pricing.mdx' + +import { Route as rootRouteImport } from './routes/__root' +import { Route as DocsRouteImport } from './routes/docs' +import { Route as LayoutRouteImport } from './routes/_layout' +import { Route as PostsRouteRouteImport } from './routes/posts/route' +import { Route as IndexRouteImport } from './routes/index' + +const ContactLazyRouteImport = createFileRoute('/contact')() +const AboutRouteImport = createFileRoute('/about')() +const PostsIndexRouteImport = createFileRoute('/posts/')() +const BlogIndexRouteImport = createFileRoute('/blog/')() +const PostsPostIdRouteImport = createFileRoute('/posts/$postId')() +const LayoutFaqRouteImport = createFileRoute('/_layout/faq')() +const marketingPricingRouteImport = createFileRoute('/(marketing)/pricing')() + +const ContactLazyRoute = ContactLazyRouteImport.update({ + id: '/contact', + path: '/contact', + getParentRoute: () => rootRouteImport, +} as any) + .lazy(() => import('./routes/contact.lazy.mdx').then((d) => d.default)) + .update({ component: ContactLazyRouteComponent }) +const DocsRoute = DocsRouteImport.update({ + id: '/docs', + path: '/docs', + getParentRoute: () => rootRouteImport, +} as any) +const AboutRoute = AboutRouteImport.update({ + id: '/about', + path: '/about', + getParentRoute: () => rootRouteImport, +} as any).update({ component: AboutRouteComponent }) +const LayoutRoute = LayoutRouteImport.update({ + id: '/_layout', + getParentRoute: () => rootRouteImport, +} as any) +const PostsRouteRoute = PostsRouteRouteImport.update({ + id: '/posts', + path: '/posts', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const PostsIndexRoute = PostsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => PostsRouteRoute, +} as any).update({ component: PostsIndexRouteComponent }) +const BlogIndexRoute = BlogIndexRouteImport.update({ + id: '/blog/', + path: '/blog/', + getParentRoute: () => rootRouteImport, +} as any).update({ component: BlogIndexRouteComponent }) +const PostsPostIdRoute = PostsPostIdRouteImport.update({ + id: '/$postId', + path: '/$postId', + getParentRoute: () => PostsRouteRoute, +} as any).update({ component: PostsPostIdRouteComponent }) +const LayoutFaqRoute = LayoutFaqRouteImport.update({ + id: '/faq', + path: '/faq', + getParentRoute: () => LayoutRoute, +} as any).update({ component: LayoutFaqRouteComponent }) +const marketingPricingRoute = marketingPricingRouteImport + .update({ + id: '/(marketing)/pricing', + path: '/pricing', + getParentRoute: () => rootRouteImport, + } as any) + .update({ component: marketingPricingRouteComponent }) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/posts': typeof PostsRouteRouteWithChildren + '/about': typeof AboutRoute + '/docs': typeof DocsRoute + '/contact': typeof ContactLazyRoute + '/pricing': typeof marketingPricingRoute + '/faq': typeof LayoutFaqRoute + '/posts/$postId': typeof PostsPostIdRoute + '/blog': typeof BlogIndexRoute + '/posts/': typeof PostsIndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/about': typeof AboutRoute + '/docs': typeof DocsRoute + '/contact': typeof ContactLazyRoute + '/pricing': typeof marketingPricingRoute + '/faq': typeof LayoutFaqRoute + '/posts/$postId': typeof PostsPostIdRoute + '/blog': typeof BlogIndexRoute + '/posts': typeof PostsIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/posts': typeof PostsRouteRouteWithChildren + '/_layout': typeof LayoutRouteWithChildren + '/about': typeof AboutRoute + '/docs': typeof DocsRoute + '/contact': typeof ContactLazyRoute + '/(marketing)/pricing': typeof marketingPricingRoute + '/_layout/faq': typeof LayoutFaqRoute + '/posts/$postId': typeof PostsPostIdRoute + '/blog/': typeof BlogIndexRoute + '/posts/': typeof PostsIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/posts' + | '/about' + | '/docs' + | '/contact' + | '/pricing' + | '/faq' + | '/posts/$postId' + | '/blog' + | '/posts/' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/about' + | '/docs' + | '/contact' + | '/pricing' + | '/faq' + | '/posts/$postId' + | '/blog' + | '/posts' + id: + | '__root__' + | '/' + | '/posts' + | '/_layout' + | '/about' + | '/docs' + | '/contact' + | '/(marketing)/pricing' + | '/_layout/faq' + | '/posts/$postId' + | '/blog/' + | '/posts/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + PostsRouteRoute: typeof PostsRouteRouteWithChildren + LayoutRoute: typeof LayoutRouteWithChildren + AboutRoute: typeof AboutRoute + DocsRoute: typeof DocsRoute + ContactLazyRoute: typeof ContactLazyRoute + marketingPricingRoute: typeof marketingPricingRoute + BlogIndexRoute: typeof BlogIndexRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/contact': { + id: '/contact' + path: '/contact' + fullPath: '/contact' + preLoaderRoute: typeof ContactLazyRouteImport + parentRoute: typeof rootRouteImport + } + '/docs': { + id: '/docs' + path: '/docs' + fullPath: '/docs' + preLoaderRoute: typeof DocsRouteImport + parentRoute: typeof rootRouteImport + } + '/about': { + id: '/about' + path: '/about' + fullPath: '/about' + preLoaderRoute: typeof AboutRouteImport + parentRoute: typeof rootRouteImport + } + '/_layout': { + id: '/_layout' + path: '' + fullPath: '' + preLoaderRoute: typeof LayoutRouteImport + parentRoute: typeof rootRouteImport + } + '/posts': { + id: '/posts' + path: '/posts' + fullPath: '/posts' + preLoaderRoute: typeof PostsRouteRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/posts/': { + id: '/posts/' + path: '/' + fullPath: '/posts/' + preLoaderRoute: typeof PostsIndexRouteImport + parentRoute: typeof PostsRouteRoute + } + '/blog/': { + id: '/blog/' + path: '/blog' + fullPath: '/blog' + preLoaderRoute: typeof BlogIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/posts/$postId': { + id: '/posts/$postId' + path: '/$postId' + fullPath: '/posts/$postId' + preLoaderRoute: typeof PostsPostIdRouteImport + parentRoute: typeof PostsRouteRoute + } + '/_layout/faq': { + id: '/_layout/faq' + path: '/faq' + fullPath: '/faq' + preLoaderRoute: typeof LayoutFaqRouteImport + parentRoute: typeof LayoutRoute + } + '/(marketing)/pricing': { + id: '/(marketing)/pricing' + path: '/pricing' + fullPath: '/pricing' + preLoaderRoute: typeof marketingPricingRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +interface PostsRouteRouteChildren { + PostsPostIdRoute: typeof PostsPostIdRoute + PostsIndexRoute: typeof PostsIndexRoute +} + +const PostsRouteRouteChildren: PostsRouteRouteChildren = { + PostsPostIdRoute: PostsPostIdRoute, + PostsIndexRoute: PostsIndexRoute, +} + +const PostsRouteRouteWithChildren = PostsRouteRoute._addFileChildren( + PostsRouteRouteChildren, +) + +interface LayoutRouteChildren { + LayoutFaqRoute: typeof LayoutFaqRoute +} + +const LayoutRouteChildren: LayoutRouteChildren = { + LayoutFaqRoute: LayoutFaqRoute, +} + +const LayoutRouteWithChildren = + LayoutRoute._addFileChildren(LayoutRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + PostsRouteRoute: PostsRouteRouteWithChildren, + LayoutRoute: LayoutRouteWithChildren, + AboutRoute: AboutRoute, + DocsRoute: DocsRoute, + ContactLazyRoute: ContactLazyRoute, + marketingPricingRoute: marketingPricingRoute, + BlogIndexRoute: BlogIndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/packages/router-generator/tests/generator/mdx-route-gen/routes/(marketing)/pricing.mdx b/packages/router-generator/tests/generator/mdx-route-gen/routes/(marketing)/pricing.mdx new file mode 100644 index 00000000000..dc807b8ee82 --- /dev/null +++ b/packages/router-generator/tests/generator/mdx-route-gen/routes/(marketing)/pricing.mdx @@ -0,0 +1,3 @@ +# Pricing + +This is an MDX route inside a route group. diff --git a/packages/router-generator/tests/generator/mdx-route-gen/routes/__root.tsx b/packages/router-generator/tests/generator/mdx-route-gen/routes/__root.tsx new file mode 100644 index 00000000000..f463b796b44 --- /dev/null +++ b/packages/router-generator/tests/generator/mdx-route-gen/routes/__root.tsx @@ -0,0 +1,5 @@ +import { createRootRoute, Outlet } from '@tanstack/react-router' + +export const Route = createRootRoute({ + component: () => , +}) diff --git a/packages/router-generator/tests/generator/mdx-route-gen/routes/_layout.tsx b/packages/router-generator/tests/generator/mdx-route-gen/routes/_layout.tsx new file mode 100644 index 00000000000..5a49c30df6b --- /dev/null +++ b/packages/router-generator/tests/generator/mdx-route-gen/routes/_layout.tsx @@ -0,0 +1,5 @@ +import { createFileRoute, Outlet } from '@tanstack/react-router' + +export const Route = createFileRoute('/_layout')({ + component: () => , +}) diff --git a/packages/router-generator/tests/generator/mdx-route-gen/routes/_layout/faq.mdx b/packages/router-generator/tests/generator/mdx-route-gen/routes/_layout/faq.mdx new file mode 100644 index 00000000000..2b2e0eea1fa --- /dev/null +++ b/packages/router-generator/tests/generator/mdx-route-gen/routes/_layout/faq.mdx @@ -0,0 +1,3 @@ +# FAQ + +This is an MDX route under a pathless layout. diff --git a/packages/router-generator/tests/generator/mdx-route-gen/routes/about.mdx b/packages/router-generator/tests/generator/mdx-route-gen/routes/about.mdx new file mode 100644 index 00000000000..f3d8e46cb70 --- /dev/null +++ b/packages/router-generator/tests/generator/mdx-route-gen/routes/about.mdx @@ -0,0 +1,3 @@ +# About + +This is a standalone MDX route. diff --git a/packages/router-generator/tests/generator/mdx-route-gen/routes/blog.index.mdx b/packages/router-generator/tests/generator/mdx-route-gen/routes/blog.index.mdx new file mode 100644 index 00000000000..7f63f012ac2 --- /dev/null +++ b/packages/router-generator/tests/generator/mdx-route-gen/routes/blog.index.mdx @@ -0,0 +1,3 @@ +# Blog Index + +This is the blog index MDX route. diff --git a/packages/router-generator/tests/generator/mdx-route-gen/routes/contact.lazy.mdx b/packages/router-generator/tests/generator/mdx-route-gen/routes/contact.lazy.mdx new file mode 100644 index 00000000000..a02b9057049 --- /dev/null +++ b/packages/router-generator/tests/generator/mdx-route-gen/routes/contact.lazy.mdx @@ -0,0 +1,3 @@ +# Contact + +This is a lazy-loaded MDX route. diff --git a/packages/router-generator/tests/generator/mdx-route-gen/routes/docs.mdx b/packages/router-generator/tests/generator/mdx-route-gen/routes/docs.mdx new file mode 100644 index 00000000000..c3e0fc743ae --- /dev/null +++ b/packages/router-generator/tests/generator/mdx-route-gen/routes/docs.mdx @@ -0,0 +1,3 @@ +# Docs + +This MDX has a TSX sibling, so it should be skipped. diff --git a/packages/router-generator/tests/generator/mdx-route-gen/routes/docs.tsx b/packages/router-generator/tests/generator/mdx-route-gen/routes/docs.tsx new file mode 100644 index 00000000000..e80ae42de84 --- /dev/null +++ b/packages/router-generator/tests/generator/mdx-route-gen/routes/docs.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/docs')({ + component: () =>
Docs
, +}) diff --git a/packages/router-generator/tests/generator/mdx-route-gen/routes/index.tsx b/packages/router-generator/tests/generator/mdx-route-gen/routes/index.tsx new file mode 100644 index 00000000000..fa6ed849667 --- /dev/null +++ b/packages/router-generator/tests/generator/mdx-route-gen/routes/index.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: () =>
Home
, +}) diff --git a/packages/router-generator/tests/generator/mdx-route-gen/routes/posts/$postId.mdx b/packages/router-generator/tests/generator/mdx-route-gen/routes/posts/$postId.mdx new file mode 100644 index 00000000000..476954e69ae --- /dev/null +++ b/packages/router-generator/tests/generator/mdx-route-gen/routes/posts/$postId.mdx @@ -0,0 +1,3 @@ +# Post Detail + +This is a dynamic MDX route with $postId param. diff --git a/packages/router-generator/tests/generator/mdx-route-gen/routes/posts/index.mdx b/packages/router-generator/tests/generator/mdx-route-gen/routes/posts/index.mdx new file mode 100644 index 00000000000..1904d3c8d33 --- /dev/null +++ b/packages/router-generator/tests/generator/mdx-route-gen/routes/posts/index.mdx @@ -0,0 +1,3 @@ +# Posts + +This is the posts index MDX route. diff --git a/packages/router-generator/tests/generator/mdx-route-gen/routes/posts/route.tsx b/packages/router-generator/tests/generator/mdx-route-gen/routes/posts/route.tsx new file mode 100644 index 00000000000..8396771ca47 --- /dev/null +++ b/packages/router-generator/tests/generator/mdx-route-gen/routes/posts/route.tsx @@ -0,0 +1,5 @@ +import { createFileRoute, Outlet } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts')({ + component: () => , +}) diff --git a/packages/router-generator/tests/generator/vue-route-gen/routeTree.nonnested.snapshot.ts b/packages/router-generator/tests/generator/vue-route-gen/routeTree.nonnested.snapshot.ts new file mode 100644 index 00000000000..d9b0e2be238 --- /dev/null +++ b/packages/router-generator/tests/generator/vue-route-gen/routeTree.nonnested.snapshot.ts @@ -0,0 +1,35 @@ +/* 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' + +export interface FileRoutesByFullPath {} +export interface FileRoutesByTo {} +export interface FileRoutesById { + __root__: typeof rootRouteImport +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: never + fileRoutesByTo: FileRoutesByTo + to: never + id: '__root__' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren {} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath {} +} + +const rootRouteChildren: RootRouteChildren = {} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/packages/router-generator/tests/generator/vue-route-gen/routeTree.snapshot.ts b/packages/router-generator/tests/generator/vue-route-gen/routeTree.snapshot.ts new file mode 100644 index 00000000000..d9b0e2be238 --- /dev/null +++ b/packages/router-generator/tests/generator/vue-route-gen/routeTree.snapshot.ts @@ -0,0 +1,35 @@ +/* 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' + +export interface FileRoutesByFullPath {} +export interface FileRoutesByTo {} +export interface FileRoutesById { + __root__: typeof rootRouteImport +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: never + fileRoutesByTo: FileRoutesByTo + to: never + id: '__root__' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren {} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath {} +} + +const rootRouteChildren: RootRouteChildren = {} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/packages/router-generator/tests/generator/vue-route-gen/routes/__root.tsx b/packages/router-generator/tests/generator/vue-route-gen/routes/__root.tsx new file mode 100644 index 00000000000..f463b796b44 --- /dev/null +++ b/packages/router-generator/tests/generator/vue-route-gen/routes/__root.tsx @@ -0,0 +1,5 @@ +import { createRootRoute, Outlet } from '@tanstack/react-router' + +export const Route = createRootRoute({ + component: () => , +}) diff --git a/packages/router-generator/tests/plugins/mdx.ts b/packages/router-generator/tests/plugins/mdx.ts new file mode 100644 index 00000000000..d2716b51ee7 --- /dev/null +++ b/packages/router-generator/tests/plugins/mdx.ts @@ -0,0 +1,110 @@ +import path from 'node:path' +import { removeExt, replaceBackslash } from '../../src/utils' +import type { GeneratorPlugin } from '../../src/plugin/types' +import type { ImportDeclaration } from '../../src/types' + +export function mdxRouteGen(): GeneratorPlugin { + return { + name: 'mdx', + + isBuiltInFile({ fileName }) { + return fileName.endsWith('.mdx') + }, + + transformNodes({ config, routeNodes }) { + return routeNodes.map((node) => { + const isMdx = node.filePath.endsWith('.mdx') + + if (isMdx) { + const base = removeExt(node.filePath) + const hasTsxSibling = routeNodes.some( + (n) => + n !== node && + !n.filePath.endsWith('.mdx') && + removeExt(n.filePath) === base, + ) + + // If MDX has a TSX sibling - skip it (TSX is the route) + if (hasTsxSibling) { + return { + ...node, + isVirtual: true, + skipTransform: true, + } + } + + return { + ...node, + componentImport: { exportName: 'default', keepExtension: true }, + extension: `.update({ component: ${node.variableName}RouteComponent })`, + skipTransform: true, + } + } + + // If not an MDX file - check if it has an MDX sibling + const base = removeExt(node.filePath) + const mdxSibling = routeNodes.find( + (n) => + n !== node && + n.filePath.endsWith('.mdx') && + removeExt(n.filePath) === base, + ) + + // TSX has an MDX sibling - set template for scaffolding + if (mdxSibling) { + return { + ...node, + template: { + template: () => + [ + '%%tsrImports%%', + '\n\n', + '%%tsrExportStart%%{\n component: RouteComponent\n }%%tsrExportEnd%%\n', + ].join(''), + imports: { + tsrImports: () => + `import RouteComponent from \'./${mdxSibling.filePath}\';`, + tsrExportStart: (routePath) => + `export const Route = createFileRoute('${routePath}')(`, + tsrExportEnd: () => ');', + }, + }, + } + } + + return node + }) + }, + + getAdditionalRouteTreeContent({ routeNodes, config }) { + const mdxNodes = routeNodes.filter( + (n) => + n.filePath.endsWith('.mdx') && + n.extension?.includes('RouteComponent'), + ) + if (mdxNodes.length === 0) return + + const imports: Array = [] + + for (const node of mdxNodes) { + const importPath = replaceBackslash( + path.relative( + path.dirname(config.generatedRouteTree), + path.resolve(config.routesDirectory, node.filePath), + ), + ) + imports.push({ + source: `./${importPath}`, + specifiers: [ + { + imported: 'default', + local: `${node.variableName}RouteComponent`, + }, + ], + }) + } + + return { imports } + }, + } +}