├── .gitignore ├── .DS_Store ├── src ├── .DS_Store ├── operators │ ├── cleanupLegacyArtifacts.ts │ ├── moveAppDirectory.ts │ ├── handleDependencies.ts │ ├── adaptPages.ts │ ├── initProjectConfig.ts │ ├── adaptHomePage.ts │ └── adaptRootLayout.ts ├── index.ts ├── utils.ts └── page-adapters │ └── adaptStaticPage.ts ├── tsconfig.json ├── package.json ├── CONTRIBUTING.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | /DS_Store -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidiDev/next-to-tanstack/HEAD/.DS_Store -------------------------------------------------------------------------------- /src/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sidiDev/next-to-tanstack/HEAD/src/.DS_Store -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "dist", 5 | "strict": true, 6 | "target": "es6", 7 | "module": "commonjs", 8 | "sourceMap": true, 9 | "esModuleInterop": true, 10 | "moduleResolution": "node" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/operators/cleanupLegacyArtifacts.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, rmSync } from "fs"; 2 | import { join } from "path"; 3 | 4 | const LEGACY_FOLDERS = [".next", ".turbo", "next-env.d.ts"]; 5 | 6 | export function cleanupLegacyArtifacts() { 7 | const cwd = process.cwd(); 8 | 9 | LEGACY_FOLDERS.forEach((folder) => { 10 | const target = join(cwd, folder); 11 | if (existsSync(target)) { 12 | rmSync(target, { recursive: true, force: true }); 13 | console.log(`✔ Removed legacy ${folder} directory`); 14 | } 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/operators/moveAppDirectory.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { join } from "path"; 3 | 4 | // We will move app directory to src directory if useSrc is false (app is at root level) 5 | export function moveAppDirectory(useSrc: boolean) { 6 | if (!useSrc) { 7 | const appDir = join(process.cwd(), "app"); 8 | const srcAppDir = join(process.cwd(), "src", "app"); 9 | 10 | // Check if src directory exists, if not create it 11 | const srcDir = join(process.cwd(), "src"); 12 | if (!fs.existsSync(srcDir)) { 13 | fs.mkdirSync(srcDir, { recursive: true }); 14 | } 15 | 16 | // Move app directory into src/app 17 | if (fs.existsSync(appDir) && !fs.existsSync(srcAppDir)) { 18 | fs.renameSync(appDir, srcAppDir); 19 | console.log("✔ Moved app directory to src/app"); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-to-tanstack", 3 | "version": "0.1.0", 4 | "description": "CLI tool to migrate Next.js projects to TanStack Start", 5 | "main": "index.js", 6 | "bin": { 7 | "next-to-tanstack": "./dist/index.js", 8 | "n2t": "./dist/index.js" 9 | }, 10 | "files": [ 11 | "dist" 12 | ], 13 | "scripts": { 14 | "build": "npx tsc", 15 | "watch": "tsc --watch", 16 | "test": "echo \"Error: no test specified\" && exit 1" 17 | }, 18 | "keywords": [], 19 | "author": "", 20 | "license": "ISC", 21 | "type": "commonjs", 22 | "dependencies": { 23 | "@babel/generator": "^7.28.3", 24 | "@babel/parser": "^7.28.4", 25 | "@babel/traverse": "^7.28.4", 26 | "commander": "^14.0.1", 27 | "execa": "^9.6.0", 28 | "inquirer": "^12.9.6" 29 | }, 30 | "devDependencies": { 31 | "@babel/types": "^7.28.4", 32 | "@types/babel__generator": "^7.27.0", 33 | "@types/babel__traverse": "^7.28.0", 34 | "@types/node": "^24.7.2", 35 | "typescript": "^5.9.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/operators/handleDependencies.ts: -------------------------------------------------------------------------------- 1 | import { execa } from "execa"; 2 | import { detectPackageManager, getNextPackages } from "../utils"; 3 | 4 | export async function handleDependencies() { 5 | const pm = detectPackageManager(); 6 | const nextPackages = getNextPackages(); 7 | 8 | await Promise.all([ 9 | await execa( 10 | pm, 11 | ["uninstall", "next", "@tailwindcss/postcss", ...nextPackages], 12 | { 13 | cwd: process.cwd(), 14 | stdio: "inherit", 15 | reject: false, 16 | } 17 | ), 18 | await execa("rm", ["next.config.*", "postcss.config.*"], { 19 | cwd: process.cwd(), 20 | stdio: "inherit", 21 | shell: true, 22 | }), 23 | await execa( 24 | pm, 25 | ["install", "@tanstack/react-router", "@tanstack/react-start"], 26 | { 27 | cwd: process.cwd(), 28 | stdio: "inherit", 29 | } 30 | ), 31 | 32 | await execa( 33 | pm, 34 | [ 35 | "install", 36 | "-D", 37 | "vite", 38 | "@vitejs/plugin-react", 39 | "@tailwindcss/vite", 40 | "tailwindcss", 41 | "vite-tsconfig-paths", 42 | ], 43 | { 44 | cwd: process.cwd(), 45 | stdio: "inherit", 46 | } 47 | ), 48 | ]); 49 | } 50 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Command } from "commander"; 4 | import inquirer from "inquirer"; 5 | import { handleDependencies } from "./operators/handleDependencies"; 6 | import { initProjectConfig } from "./operators/initProjectConfig"; 7 | import { adaptRootLayout } from "./operators/adaptRootLayout"; 8 | import { adaptHomePage } from "./operators/adaptHomePage"; 9 | import { moveAppDirectory } from "./operators/moveAppDirectory"; 10 | import fs from "fs"; 11 | import path from "path"; 12 | import { adaptPages } from "./operators/adaptPages"; 13 | 14 | const program = new Command(); 15 | 16 | program 17 | .version("0.1.0") 18 | .name("next-to-tanstack") 19 | .description("A CLI to migrate Next.js projects to Tanstack start"); 20 | 21 | program 22 | .command("migrate") 23 | .description("Migrate a Next.js project to Tanstack") 24 | .action(async () => { 25 | const answers = await inquirer.prompt([ 26 | { 27 | type: "confirm", 28 | name: "modify", 29 | message: "✔ ⚠️ This will modify your project. Continue?", 30 | default: true, 31 | }, 32 | ]); 33 | 34 | if (!answers.modify) { 35 | console.log("❌ Migration cancelled"); 36 | process.exit(1); 37 | } 38 | 39 | // Detect project structure 40 | const useSrc = fs.existsSync(path.join(process.cwd(), "src", "app")); 41 | const hasApp = fs.existsSync( 42 | path.join(process.cwd(), useSrc ? "src/app" : "app") 43 | ); 44 | 45 | await handleDependencies(); 46 | await initProjectConfig(useSrc); 47 | await adaptRootLayout(useSrc); 48 | await adaptHomePage(useSrc); 49 | await adaptPages(useSrc); 50 | await moveAppDirectory(useSrc); 51 | // console.log(process.cwd()); 52 | }); 53 | 54 | program.parse(); 55 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest! This project is currently in beta. 4 | 5 | ## How to Contribute 6 | 7 | **At this time, we are only accepting issue reports.** Please do not submit pull requests. 8 | 9 | If you encounter bugs, have feature requests, or want to discuss improvements, please [open an issue on GitHub](https://github.com/sididev/next-to-tanstack/issues). 10 | 11 | ## What to Report 12 | 13 | We'd love to hear about: 14 | 15 | - **Bugs**: Things that don't work as expected 16 | - **Feature requests**: Ideas for new functionality 17 | - **Documentation issues**: Parts that are unclear or missing 18 | - **Use cases**: How you're using the tool and what challenges you face 19 | 20 | High priority items we're aware of: 21 | 22 | - **Support for additional pages**: Handle pages beyond just the home page 23 | - **API routes transformation**: Convert Next.js API routes to TanStack equivalents 24 | - **Nested routes**: Support for folder structure routing 25 | - **Tests**: Any kind of automated testing 26 | - **Error handling**: Better messages when things go wrong 27 | - **Dry-run mode**: Preview changes without modifying files 28 | 29 | ## Project Structure 30 | 31 | For reference, here's the current structure: 32 | 33 | ``` 34 | src/ 35 | ├── index.ts # CLI entry point 36 | ├── utils.ts # Helper functions 37 | └── operators/ # Migration steps 38 | ├── adaptHomePage.ts 39 | ├── adaptRootLayout.ts 40 | ├── handleDependencies.ts 41 | ├── initProjectConfig.ts 42 | └── moveAppDirectory.ts 43 | ``` 44 | 45 | ## Questions? 46 | 47 | Open an issue on GitHub. This is a learning project - questions and feedback are welcome! 48 | 49 | --- 50 | 51 | Remember: this is beta software. Your feedback helps make it better! 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # next-to-tanstack 2 | 3 | > ⚠️ **Beta Software**: This tool is under active development. It currently handles basic migrations but expect bugs and missing features. 4 | 5 | A CLI tool to migrate Next.js projects to TanStack Start. 6 | 7 | ## Quick Start 8 | 9 | Insatll the CLI in your project using: 10 | 11 | ```bash 12 | pnpm add next-to-tanstack 13 | ``` 14 | 15 | or 16 | 17 | ```bash 18 | npm add next-to-tanstack 19 | ``` 20 | 21 | Run the command: 22 | 23 | ```bash 24 | npx next-to-tanstack migrate 25 | ``` 26 | 27 | Or use the short alias: 28 | 29 | ```bash 30 | npx n2t migrate 31 | ``` 32 | 33 | **Important**: This modifies your project files. Commit your changes to git first! 34 | 35 | ## What it does 36 | 37 | - Swaps Next.js dependencies for TanStack Router and Start 38 | - Converts `layout.tsx` → `__root.tsx` 39 | - Converts `page.tsx` → `index.tsx` 40 | - Creates `vite.config.ts` and updates project config 41 | - Transforms Next.js code to TanStack equivalents 42 | 43 | ## Current Limitations 44 | 45 | ⚠️ **This is beta software!** Currently it only handles: 46 | 47 | - Root layout and home page (basic projects) 48 | - App Router projects (not Pages Router) 49 | 50 | **Coming soon:** 51 | 52 | - Support for additional pages and nested routes 53 | - API routes transformation 54 | - Dynamic routes 55 | - Better error handling 56 | 57 | ## What you'll need to do manually 58 | 59 | After migration: 60 | 61 | - Review all transformed files 62 | - Test your application thoroughly 63 | - Handle any API routes 64 | - Configure deployment settings 65 | - Adjust dynamic routes 66 | 67 | ## Requirements 68 | 69 | - Node.js 18+ 70 | - Next.js project with App Router 71 | - Git (to track changes) 72 | 73 | ## Contributing 74 | 75 | Want to help? Check out [CONTRIBUTING.md](./CONTRIBUTING.md) 76 | 77 | This tool is still early and needs your feedback and contributions! 78 | 79 | ## License 80 | 81 | ISC 82 | -------------------------------------------------------------------------------- /src/operators/adaptPages.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync, readFileSync } from "fs"; 2 | import { join } from "path"; 3 | import { adaptStaticPage } from "../page-adapters/adaptStaticPage"; 4 | import { convertDynamicSegment } from "../utils"; 5 | 6 | type PageInfo = { 7 | filename: string; 8 | directories: string; 9 | }; 10 | 11 | export async function adaptPages(useSrc: boolean) { 12 | const cwd = process.cwd(); 13 | const appDir = useSrc ? join(cwd, "src", "app") : join(cwd, "app"); 14 | const staticPageDirs = readdirSync(appDir, { 15 | withFileTypes: true, 16 | }).filter((file) => file.isDirectory() && file.name !== "api"); 17 | 18 | staticPageDirs.forEach((dir) => { 19 | adaptPage(appDir, dir.name, `/${dir.name}`); 20 | }); 21 | } 22 | 23 | function adaptPage( 24 | appDir: string, 25 | name: string, 26 | relativePath: string 27 | ): PageInfo[] { 28 | const pagePath = join(appDir, name); 29 | const page = readdirSync(pagePath, { withFileTypes: true }); 30 | const pages: PageInfo[] = []; 31 | 32 | page.forEach((file) => { 33 | const isPage = 34 | file.name.endsWith(".jsx") || 35 | file.name.endsWith(".tsx") || 36 | file.name.endsWith(".js"); 37 | if (file.isFile() && isPage) { 38 | const isDynamicSegment = relativePath.includes("$"); 39 | pages.push({ 40 | filename: file.name, 41 | directories: relativePath, 42 | }); 43 | adaptStaticPage( 44 | join(pagePath, file.name), 45 | file.name, 46 | relativePath, 47 | isDynamicSegment 48 | ); 49 | } else if (file.isDirectory()) { 50 | // Convert dynamic segments like [slug] to $slug for TanStack Router 51 | const convertedName = convertDynamicSegment(file.name); 52 | const subPages = adaptPage( 53 | pagePath, 54 | file.name, 55 | `${relativePath}/${convertedName}` 56 | ); 57 | pages.push(...subPages); 58 | } 59 | }); 60 | 61 | return pages; 62 | } 63 | -------------------------------------------------------------------------------- /src/operators/initProjectConfig.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { join } from "path"; 3 | 4 | export async function initProjectConfig(useSrc: boolean) { 5 | fs.writeFileSync(join(process.cwd(), "vite.config.ts"), viteConfig); 6 | 7 | const packageJsonPath = join(process.cwd(), "package.json"); 8 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); 9 | 10 | if (packageJson.scripts) { 11 | packageJson.type = "module"; 12 | packageJson.scripts.dev = "vite dev"; 13 | packageJson.scripts.build = "vite build"; 14 | packageJson.scripts.start = "node dist/server/server.js"; 15 | packageJson.scripts.preview = "vite preview"; 16 | } 17 | 18 | if (!useSrc) { 19 | fs.mkdirSync(join(process.cwd(), "src"), { recursive: true }); 20 | } 21 | 22 | // Write back with proper formatting 23 | fs.writeFileSync( 24 | packageJsonPath, 25 | JSON.stringify(packageJson, null, 2) + "\n", 26 | "utf8" 27 | ); 28 | 29 | fs.writeFileSync(join(process.cwd(), "tsconfig.json"), tsconfigJson, "utf8"); 30 | fs.writeFileSync( 31 | join(process.cwd(), "src", "router.tsx"), 32 | routerConfig, 33 | "utf8" 34 | ); 35 | 36 | const eslintConfigFiles = [ 37 | "eslint.config.js", 38 | "eslint.config.mjs", 39 | "eslint.config.cjs", 40 | ]; 41 | 42 | const eslintConfigPath = eslintConfigFiles 43 | .map((file) => join(process.cwd(), file)) 44 | .find((filePath) => fs.existsSync(filePath)); 45 | 46 | if (eslintConfigPath) { 47 | fs.writeFileSync(eslintConfigPath, eslintConfig, "utf8"); 48 | } 49 | } 50 | 51 | const viteConfig = `// vite.config.ts 52 | import { defineConfig } from 'vite' 53 | import { tanstackStart } from '@tanstack/react-start/plugin/vite' 54 | import viteReact from '@vitejs/plugin-react' 55 | import tsconfigPaths from 'vite-tsconfig-paths' 56 | import tailwindcss from '@tailwindcss/vite' 57 | 58 | export default defineConfig({ 59 | server: { 60 | port: 3000, 61 | }, 62 | plugins: [ 63 | tailwindcss(), 64 | // Enables Vite to resolve imports using path aliases. 65 | tsconfigPaths(), 66 | tanstackStart({ 67 | srcDirectory: 'src', // This is the default 68 | router: { 69 | // Specifies the directory TanStack Router uses for your routes. 70 | routesDirectory: 'app', // Defaults to "routes", relative to srcDirectory 71 | }, 72 | }), 73 | viteReact(), 74 | ], 75 | }) 76 | `; 77 | 78 | const tsconfigJson = `{ 79 | "include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], 80 | "compilerOptions": { 81 | "target": "ES2022", 82 | "jsx": "react-jsx", 83 | "module": "ESNext", 84 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 85 | "types": ["vite/client"], 86 | 87 | /* Bundler mode */ 88 | "moduleResolution": "bundler", 89 | "allowImportingTsExtensions": true, 90 | "verbatimModuleSyntax": false, 91 | "noEmit": true, 92 | 93 | /* Linting */ 94 | "skipLibCheck": true, 95 | "strict": true, 96 | "noUnusedLocals": true, 97 | "noUnusedParameters": true, 98 | "noFallthroughCasesInSwitch": true, 99 | "noUncheckedSideEffectImports": true, 100 | "baseUrl": ".", 101 | "paths": { 102 | "@/*": ["./src/*"] 103 | } 104 | } 105 | } 106 | `; 107 | 108 | const routerConfig = `import { createRouter } from "@tanstack/react-router"; 109 | import { routeTree } from "./routeTree.gen"; 110 | 111 | export function getRouter() { 112 | const router = createRouter({ 113 | routeTree, 114 | scrollRestoration: true, 115 | }); 116 | 117 | return router; 118 | } 119 | `; 120 | 121 | const eslintConfig = `export default [ 122 | { 123 | files: ["**/*.{ts,tsx,js,jsx}"], 124 | ignores: [ 125 | "node_modules/**", 126 | "dist/**", 127 | ".next/**", 128 | "build/**", 129 | ".turbo/**", 130 | ], 131 | languageOptions: { 132 | ecmaVersion: "latest", 133 | sourceType: "module", 134 | }, 135 | rules: {}, 136 | }, 137 | ]; 138 | `; 139 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync } from "fs"; 2 | import { join } from "path"; 3 | import * as t from "@babel/types"; 4 | import { NodePath } from "@babel/traverse"; 5 | import { execa } from "execa"; 6 | 7 | export function detectPackageManager(): "pnpm" | "yarn" | "npm" { 8 | const cwd = process.cwd(); 9 | 10 | if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm"; 11 | if (existsSync(join(cwd, "yarn.lock"))) return "yarn"; 12 | return "npm"; 13 | } 14 | 15 | export function isPackageInstalled(packageName: string): boolean { 16 | const cwd = process.cwd(); 17 | 18 | // Check package.json first 19 | const packageJson = JSON.parse( 20 | readFileSync(join(cwd, "package.json"), "utf8") 21 | ); 22 | 23 | const inPackageJson = 24 | packageJson.dependencies?.[packageName] !== undefined || 25 | packageJson.devDependencies?.[packageName] !== undefined; 26 | 27 | // Optionally also check if it exists in node_modules 28 | const inNodeModules = existsSync(join(cwd, "node_modules", packageName)); 29 | 30 | return inPackageJson && inNodeModules; 31 | } 32 | 33 | export function getNextPackages(): string[] { 34 | const cwd = process.cwd(); 35 | const packageJson = JSON.parse( 36 | readFileSync(join(cwd, "package.json"), "utf8") 37 | ); 38 | return Object.keys(packageJson.dependencies).filter( 39 | (dep) => dep.startsWith("@next") || dep.includes("eslint-config-next") 40 | ); 41 | } 42 | 43 | export async function installPackages( 44 | packageNames: string[], 45 | isDev: boolean = false 46 | ): Promise { 47 | const pm = detectPackageManager(); 48 | const args = isDev 49 | ? ["install", "-D", ...packageNames] 50 | : ["install", ...packageNames]; 51 | 52 | await execa(pm, args, { 53 | cwd: process.cwd(), 54 | stdio: "inherit", 55 | }); 56 | } 57 | 58 | export async function imageImportDeclaration( 59 | path: NodePath 60 | ) { 61 | const source = path.node.source.value; 62 | if (source == "next/image") { 63 | const importDeclaration = t.importDeclaration( 64 | [t.importSpecifier(t.identifier("Image"), t.identifier("Image"))], 65 | t.stringLiteral("@unpic/react") 66 | ); 67 | path.replaceWith(importDeclaration); 68 | 69 | if (!isPackageInstalled("@unpic/react")) { 70 | await installPackages(["@unpic/react"]); 71 | } 72 | } 73 | } 74 | 75 | export async function linkImportDeclaration( 76 | path: NodePath 77 | ) { 78 | const source = path.node.source.value; 79 | if (source == "next/link") { 80 | const importDeclaration = t.importDeclaration( 81 | [t.importSpecifier(t.identifier("Link"), t.identifier("Link"))], 82 | t.stringLiteral("@tanstack/react-router") 83 | ); 84 | path.replaceWith(importDeclaration); 85 | } 86 | } 87 | 88 | export function LinkElement(path: NodePath) { 89 | if ( 90 | t.isJSXIdentifier(path.node.openingElement.name) && 91 | path.node.openingElement.name?.name === "Link" 92 | ) { 93 | const filteredAttributes = path.node.openingElement.attributes.flatMap( 94 | (attr) => { 95 | if (!t.isJSXAttribute(attr)) { 96 | return attr; 97 | } 98 | 99 | if (t.isJSXIdentifier(attr.name) && attr.name.name === "href") { 100 | return t.jsxAttribute(t.jsxIdentifier("to"), attr.value); 101 | } 102 | 103 | return attr; 104 | } 105 | ); 106 | 107 | const hasChildren = path.node.children.length > 0; 108 | const linkElement = t.jsxElement( 109 | t.jsxOpeningElement( 110 | t.jsxIdentifier("Link"), 111 | filteredAttributes, 112 | !hasChildren 113 | ), 114 | hasChildren ? t.jsxClosingElement(t.jsxIdentifier("Link")) : null, 115 | path.node.children 116 | ); 117 | path.replaceWith(linkElement); 118 | path.skip(); 119 | } 120 | } 121 | 122 | export function ImageElement(path: NodePath) { 123 | if ( 124 | t.isJSXIdentifier(path.node.openingElement.name) && 125 | path.node.openingElement.name?.name === "Image" 126 | ) { 127 | const filteredAttributes = path.node.openingElement.attributes.flatMap( 128 | (attr) => { 129 | if (!t.isJSXAttribute(attr)) { 130 | return attr; 131 | } 132 | 133 | if (t.isJSXIdentifier(attr.name) && attr.name.name === "priority") { 134 | return t.jsxAttribute( 135 | t.jsxIdentifier("loading"), 136 | t.stringLiteral("lazy") 137 | ); 138 | } 139 | 140 | return attr; 141 | } 142 | ); 143 | 144 | const imageElement = t.jsxElement( 145 | t.jsxOpeningElement(t.jsxIdentifier("Image"), filteredAttributes, true), 146 | null, 147 | [] 148 | ); 149 | path.replaceWith(imageElement); 150 | path.skip(); 151 | } 152 | } 153 | 154 | /** 155 | * Converts a single Next.js dynamic route segment to TanStack Router syntax 156 | * Examples: 157 | * - [slug] -> $slug 158 | * - [id] -> $id 159 | * - [...slug] -> $slug (catch-all routes) 160 | * - [[...slug]] -> $slug (optional catch-all routes) 161 | */ 162 | export function convertDynamicSegment(segment: string): string { 163 | if (/^\[{1,2}\.{0,3}[^\[\]]+\]{1,2}$/.test(segment)) { 164 | return "$" + segment.replace(/^\[{1,2}\.{0,3}/, "").replace(/\]{1,2}$/, ""); 165 | } 166 | return segment; 167 | } 168 | 169 | /** 170 | * Converts a full path with Next.js dynamic route syntax to TanStack Router syntax 171 | * Examples: 172 | * - /blog/[slug] -> /blog/$slug 173 | * - app/blog/[slug] -> app/blog/$slug 174 | * - /posts/[category]/[id] -> /posts/$category/$id 175 | * - /docs/[...slug] -> /docs/$slug 176 | */ 177 | export function convertDynamicPath(path: string): string { 178 | const segments = path.split(/[\/\\]/); 179 | 180 | // Convert each segment and rejoin 181 | const convertedSegments = segments.map((segment) => 182 | convertDynamicSegment(segment) 183 | ); 184 | 185 | const separator = path.includes("\\") ? "\\" : "/"; 186 | return convertedSegments.join(separator); 187 | } 188 | -------------------------------------------------------------------------------- /src/page-adapters/adaptStaticPage.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync, renameSync } from "fs"; 2 | import { parse } from "@babel/parser"; 3 | import traverse from "@babel/traverse"; 4 | import generate from "@babel/generator"; 5 | import * as t from "@babel/types"; 6 | import { 7 | imageImportDeclaration, 8 | linkImportDeclaration, 9 | LinkElement, 10 | ImageElement, 11 | convertDynamicPath, 12 | } from "../utils"; 13 | 14 | export function adaptStaticPage( 15 | pagePath: string, 16 | name: string, 17 | relativePath: string, 18 | isDynamicSegment: boolean 19 | ) { 20 | const page = readFileSync(pagePath, "utf8"); 21 | 22 | const extension = name.split(".").pop(); 23 | 24 | const ast = parse(page, { 25 | sourceType: "module", 26 | plugins: ["jsx", "typescript"], 27 | }); 28 | 29 | let metadataFound = false; 30 | let componentName = "Component"; // Default component name 31 | 32 | traverse(ast, { 33 | enter(path) { 34 | // console.log(path.node.type); 35 | }, 36 | Program(path) { 37 | const tanstackImports = t.importDeclaration( 38 | [ 39 | t.importSpecifier(t.identifier("Outlet"), t.identifier("Outlet")), 40 | t.importSpecifier( 41 | t.identifier("createFileRoute"), 42 | t.identifier("createFileRoute") 43 | ), 44 | t.importSpecifier( 45 | t.identifier("HeadContent"), 46 | t.identifier("HeadContent") 47 | ), 48 | t.importSpecifier(t.identifier("Scripts"), t.identifier("Scripts")), 49 | ], 50 | t.stringLiteral("@tanstack/react-router") 51 | ); 52 | 53 | // console.log("path.node.body", path.node.body); 54 | 55 | path.node.body.unshift(tanstackImports); 56 | }, 57 | Directive(path) { 58 | if (path.node.value.value == "use client") { 59 | path.remove(); 60 | } 61 | }, 62 | ImportDeclaration(path) { 63 | // console.log(path.node.source.value); 64 | const source = path.node.source.value; 65 | 66 | if (source == "next" || source == "next/script") { 67 | path.remove(); 68 | } 69 | 70 | imageImportDeclaration(path); 71 | linkImportDeclaration(path); 72 | }, 73 | ExportNamedDeclaration(path) { 74 | if (path.node.declaration) { 75 | if (t.isVariableDeclaration(path.node.declaration)) { 76 | path.node.declaration.declarations.forEach((declaration) => { 77 | if (t.isIdentifier(declaration.id)) { 78 | if (declaration.id.name === "metadata") { 79 | metadataFound = true; 80 | const init = declaration.init; 81 | 82 | if (t.isObjectExpression(init)) { 83 | const metaItems: t.ObjectExpression[] = []; 84 | 85 | // Transform each metadata property to meta format 86 | init.properties.forEach((prop) => { 87 | if (t.isObjectProperty(prop)) { 88 | const key = t.isIdentifier(prop.key) 89 | ? prop.key.name 90 | : t.isStringLiteral(prop.key) 91 | ? prop.key.value 92 | : null; 93 | 94 | if (key === "title") { 95 | // { title: "value" } -> { title: "value" } 96 | metaItems.push( 97 | t.objectExpression([ 98 | t.objectProperty(t.identifier("title"), prop.value), 99 | ]) 100 | ); 101 | } else if (key === "description") { 102 | // { description: "value" } -> { name: "description", content: "value" } 103 | metaItems.push( 104 | t.objectExpression([ 105 | t.objectProperty( 106 | t.identifier("name"), 107 | t.stringLiteral("description") 108 | ), 109 | t.objectProperty( 110 | t.identifier("content"), 111 | prop.value 112 | ), 113 | ]) 114 | ); 115 | } else { 116 | metaItems.push( 117 | t.objectExpression([ 118 | t.objectProperty( 119 | t.identifier("name"), 120 | t.stringLiteral(key as string) 121 | ), 122 | t.objectProperty( 123 | t.identifier("content"), 124 | prop.value 125 | ), 126 | ]) 127 | ); 128 | } 129 | } 130 | }); 131 | 132 | // Create the Route export 133 | const routeExport = t.exportNamedDeclaration( 134 | t.variableDeclaration("const", [ 135 | t.variableDeclarator( 136 | t.identifier("Route"), 137 | t.callExpression( 138 | t.callExpression(t.identifier("createFileRoute"), [ 139 | t.stringLiteral(relativePath), 140 | ]), 141 | [ 142 | t.objectExpression([ 143 | // head property 144 | t.objectProperty( 145 | t.identifier("head"), 146 | t.arrowFunctionExpression( 147 | [], 148 | t.objectExpression([ 149 | t.objectProperty( 150 | t.identifier("meta"), 151 | t.arrayExpression(metaItems) 152 | ), 153 | ]) 154 | ) 155 | ), 156 | t.objectProperty( 157 | t.identifier("component"), 158 | t.identifier(componentName) 159 | ), 160 | ]), 161 | ] 162 | ) 163 | ), 164 | ]) 165 | ); 166 | 167 | // Replace the metadata export with the Route export 168 | path.replaceWith(routeExport); 169 | } 170 | } else { 171 | } 172 | } 173 | }); 174 | } 175 | } 176 | }, 177 | ExportDefaultDeclaration(path) { 178 | const declaration = path.node.declaration; 179 | 180 | if (t.isFunctionDeclaration(declaration)) { 181 | const funcDeclaration = declaration; 182 | // Capture the component name from the function declaration 183 | if (funcDeclaration.id) { 184 | componentName = funcDeclaration.id.name; 185 | } 186 | const regularFunc = t.functionDeclaration( 187 | funcDeclaration.id, 188 | funcDeclaration.params, 189 | funcDeclaration.body, 190 | funcDeclaration.generator, 191 | funcDeclaration.async 192 | ); 193 | 194 | path.replaceWith(regularFunc); 195 | } else if (t.isArrowFunctionExpression(declaration)) { 196 | // Generate component name from file name 197 | const baseName = name.replace(/\.(tsx|jsx|ts|js)$/, ""); 198 | componentName = 199 | baseName === "page" 200 | ? "Component" 201 | : baseName.charAt(0).toUpperCase() + baseName.slice(1); 202 | 203 | const namedFunction = t.variableDeclaration("const", [ 204 | t.variableDeclarator(t.identifier(componentName), declaration), 205 | ]); 206 | path.replaceWith(namedFunction); 207 | } 208 | }, 209 | JSXElement(path) { 210 | LinkElement(path); 211 | ImageElement(path); 212 | }, 213 | }); 214 | 215 | // If metadata was not found 216 | if (!metadataFound) { 217 | traverse(ast, { 218 | Program(path) { 219 | const metaItems: t.ObjectExpression[] = []; 220 | 221 | const routeExport = t.exportNamedDeclaration( 222 | t.variableDeclaration("const", [ 223 | t.variableDeclarator( 224 | t.identifier("Route"), 225 | t.callExpression( 226 | t.callExpression(t.identifier("createFileRoute"), [ 227 | t.stringLiteral(relativePath), 228 | ]), 229 | [ 230 | t.objectExpression([ 231 | // head property 232 | t.objectProperty( 233 | t.identifier("head"), 234 | t.arrowFunctionExpression( 235 | [], 236 | t.objectExpression([ 237 | t.objectProperty( 238 | t.identifier("meta"), 239 | t.arrayExpression(metaItems) 240 | ), 241 | ]) 242 | ) 243 | ), 244 | t.objectProperty( 245 | t.identifier("component"), 246 | t.identifier(componentName) 247 | ), 248 | ]), 249 | ] 250 | ) 251 | ), 252 | ]) 253 | ); 254 | 255 | // Add it after imports, before other exports 256 | const lastImportIndex = path.node.body.findIndex( 257 | (node) => !t.isImportDeclaration(node) 258 | ); 259 | path.node.body.splice(lastImportIndex, 0, routeExport); 260 | }, 261 | }); 262 | } 263 | 264 | const transformed = generate(ast).code; 265 | 266 | function getFilePath() { 267 | if (!isDynamicSegment) 268 | return pagePath.replace(`page.${extension}`, `index.${extension}`); 269 | return ( 270 | convertDynamicPath(pagePath.replace(`/page.${extension}`, "")) + 271 | `.${extension}` 272 | ); 273 | } 274 | 275 | writeFileSync(pagePath, transformed); 276 | renameSync(pagePath, getFilePath()); 277 | // console.log(transformed); 278 | } 279 | -------------------------------------------------------------------------------- /src/operators/adaptHomePage.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { join } from "path"; 3 | import { parse } from "@babel/parser"; 4 | import traverse from "@babel/traverse"; 5 | import generate from "@babel/generator"; 6 | import * as t from "@babel/types"; 7 | import { 8 | ImageElement, 9 | imageImportDeclaration, 10 | LinkElement, 11 | linkImportDeclaration, 12 | } from "../utils"; 13 | 14 | export function adaptHomePage(useSrc: boolean) { 15 | const appDir = useSrc 16 | ? join(process.cwd(), "src", "app") 17 | : join(process.cwd(), "app"); 18 | 19 | const appFiles = fs.readdirSync(appDir); 20 | 21 | // Regex to match layout.tsx or layout.js and capture the extension 22 | const pageRegex = /^page\.(tsx|js|jsx)$/; 23 | 24 | // Find the layout file 25 | const pageFile = appFiles.find((file) => pageRegex.test(file)); 26 | 27 | if (pageFile) { 28 | const match = pageFile.match(pageRegex); 29 | const extension = match?.[1]; // This will be either 'tsx' or 'js' 30 | 31 | const pagePath = join(appDir, pageFile); 32 | const page = fs.readFileSync(pagePath, "utf8"); 33 | 34 | const ast = parse(page, { 35 | sourceType: "module", 36 | plugins: ["jsx", "typescript"], 37 | }); 38 | 39 | let metadataFound = false; 40 | 41 | traverse(ast, { 42 | enter(path) { 43 | // console.log(path.node.type); 44 | }, 45 | Program(path) { 46 | const tanstackImports = t.importDeclaration( 47 | [ 48 | t.importSpecifier(t.identifier("Outlet"), t.identifier("Outlet")), 49 | t.importSpecifier( 50 | t.identifier("createFileRoute"), 51 | t.identifier("createFileRoute") 52 | ), 53 | t.importSpecifier( 54 | t.identifier("HeadContent"), 55 | t.identifier("HeadContent") 56 | ), 57 | t.importSpecifier(t.identifier("Scripts"), t.identifier("Scripts")), 58 | ], 59 | t.stringLiteral("@tanstack/react-router") 60 | ); 61 | 62 | // console.log("path.node.body", path.node.body); 63 | 64 | path.node.body.unshift(tanstackImports); 65 | }, 66 | Directive(path) { 67 | if (path.node.value.value == "use client") { 68 | path.remove(); 69 | } 70 | }, 71 | ImportDeclaration(path) { 72 | // console.log(path.node.source.value); 73 | const source = path.node.source.value; 74 | 75 | if (source == "next" || source == "next/script") { 76 | path.remove(); 77 | } 78 | imageImportDeclaration(path); 79 | linkImportDeclaration(path); 80 | }, 81 | ExportNamedDeclaration(path) { 82 | if (path.node.declaration) { 83 | if (t.isVariableDeclaration(path.node.declaration)) { 84 | path.node.declaration.declarations.forEach((declaration) => { 85 | if (t.isIdentifier(declaration.id)) { 86 | if (declaration.id.name === "metadata") { 87 | metadataFound = true; 88 | const init = declaration.init; 89 | 90 | if (t.isObjectExpression(init)) { 91 | const metaItems: t.ObjectExpression[] = []; 92 | 93 | // Transform each metadata property to meta format 94 | init.properties.forEach((prop) => { 95 | if (t.isObjectProperty(prop)) { 96 | const key = t.isIdentifier(prop.key) 97 | ? prop.key.name 98 | : t.isStringLiteral(prop.key) 99 | ? prop.key.value 100 | : null; 101 | 102 | if (key === "title") { 103 | // { title: "value" } -> { title: "value" } 104 | metaItems.push( 105 | t.objectExpression([ 106 | t.objectProperty( 107 | t.identifier("title"), 108 | prop.value 109 | ), 110 | ]) 111 | ); 112 | } else if (key === "description") { 113 | // { description: "value" } -> { name: "description", content: "value" } 114 | metaItems.push( 115 | t.objectExpression([ 116 | t.objectProperty( 117 | t.identifier("name"), 118 | t.stringLiteral("description") 119 | ), 120 | t.objectProperty( 121 | t.identifier("content"), 122 | prop.value 123 | ), 124 | ]) 125 | ); 126 | } else { 127 | metaItems.push( 128 | t.objectExpression([ 129 | t.objectProperty( 130 | t.identifier("name"), 131 | t.stringLiteral(key as string) 132 | ), 133 | t.objectProperty( 134 | t.identifier("content"), 135 | prop.value 136 | ), 137 | ]) 138 | ); 139 | } 140 | } 141 | }); 142 | 143 | // Create the Route export 144 | const routeExport = t.exportNamedDeclaration( 145 | t.variableDeclaration("const", [ 146 | t.variableDeclarator( 147 | t.identifier("Route"), 148 | t.callExpression( 149 | t.callExpression(t.identifier("createFileRoute"), [ 150 | t.stringLiteral("/"), 151 | ]), 152 | [ 153 | t.objectExpression([ 154 | // head property 155 | t.objectProperty( 156 | t.identifier("head"), 157 | t.arrowFunctionExpression( 158 | [], 159 | t.objectExpression([ 160 | t.objectProperty( 161 | t.identifier("meta"), 162 | t.arrayExpression(metaItems) 163 | ), 164 | ]) 165 | ) 166 | ), 167 | t.objectProperty( 168 | t.identifier("component"), 169 | t.identifier("Home") 170 | ), 171 | ]), 172 | ] 173 | ) 174 | ), 175 | ]) 176 | ); 177 | 178 | // Replace the metadata export with the Route export 179 | path.replaceWith(routeExport); 180 | } 181 | } else { 182 | } 183 | } 184 | }); 185 | } 186 | } 187 | }, 188 | ExportDefaultDeclaration(path) { 189 | const declaration = path.node.declaration; 190 | 191 | if (t.isFunctionDeclaration(declaration)) { 192 | const funcDeclaration = declaration; 193 | const regularFunc = t.functionDeclaration( 194 | funcDeclaration.id, 195 | funcDeclaration.params, 196 | funcDeclaration.body, 197 | funcDeclaration.generator, 198 | funcDeclaration.async 199 | ); 200 | 201 | path.replaceWith(regularFunc); 202 | } else if (t.isArrowFunctionExpression(declaration)) { 203 | const namedFunction = t.variableDeclaration("const", [ 204 | t.variableDeclarator(t.identifier("Home"), declaration), 205 | ]); 206 | path.replaceWith(namedFunction); 207 | } 208 | }, 209 | JSXElement(path) { 210 | LinkElement(path); 211 | ImageElement(path); 212 | }, 213 | }); 214 | 215 | // If metadata was not found 216 | if (!metadataFound) { 217 | traverse(ast, { 218 | Program(path) { 219 | const metaItems: t.ObjectExpression[] = []; 220 | 221 | const routeExport = t.exportNamedDeclaration( 222 | t.variableDeclaration("const", [ 223 | t.variableDeclarator( 224 | t.identifier("Route"), 225 | t.callExpression( 226 | t.callExpression(t.identifier("createFileRoute"), [ 227 | t.stringLiteral("/"), 228 | ]), 229 | [ 230 | t.objectExpression([ 231 | // head property 232 | t.objectProperty( 233 | t.identifier("head"), 234 | t.arrowFunctionExpression( 235 | [], 236 | t.objectExpression([ 237 | t.objectProperty( 238 | t.identifier("meta"), 239 | t.arrayExpression(metaItems) 240 | ), 241 | ]) 242 | ) 243 | ), 244 | t.objectProperty( 245 | t.identifier("component"), 246 | t.identifier("Home") 247 | ), 248 | ]), 249 | ] 250 | ) 251 | ), 252 | ]) 253 | ); 254 | 255 | // Add it after imports, before other exports 256 | const lastImportIndex = path.node.body.findIndex( 257 | (node) => !t.isImportDeclaration(node) 258 | ); 259 | path.node.body.splice(lastImportIndex, 0, routeExport); 260 | }, 261 | }); 262 | } 263 | 264 | const transformed = generate(ast).code; 265 | fs.writeFileSync(pagePath, transformed); 266 | fs.renameSync( 267 | pagePath, 268 | pagePath.replace(`page.${extension}`, `index.${extension}`) 269 | ); 270 | // console.log(transformed); 271 | } else { 272 | console.error("No page.tsx or page.js found in app directory"); 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/operators/adaptRootLayout.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { join } from "path"; 3 | import { parse } from "@babel/parser"; 4 | import traverse from "@babel/traverse"; 5 | import generate from "@babel/generator"; 6 | import * as t from "@babel/types"; 7 | import { ImageElement, LinkElement } from "../utils"; 8 | 9 | export function adaptRootLayout(useSrc: boolean) { 10 | const appDir = useSrc 11 | ? join(process.cwd(), "src", "app") 12 | : join(process.cwd(), "app"); 13 | 14 | const appFiles = fs.readdirSync(appDir); 15 | 16 | // Regex to match layout.tsx or layout.js and capture the extension 17 | const layoutRegex = /^layout\.(tsx|js|jsx)$/; 18 | 19 | // Find the layout file 20 | const layoutFile = appFiles.find((file) => layoutRegex.test(file)); 21 | 22 | if (layoutFile) { 23 | const match = layoutFile.match(layoutRegex); 24 | const extension = match?.[1]; // This will be either 'tsx' or 'js' 25 | 26 | const rootLayoutPath = join(appDir, layoutFile); 27 | const rootLayout = fs.readFileSync(rootLayoutPath, "utf8"); 28 | 29 | const ast = parse(rootLayout, { 30 | sourceType: "module", 31 | plugins: ["jsx", "typescript"], 32 | }); 33 | 34 | let metadataFound = false; 35 | 36 | const stylesheetsMeta: t.ObjectExpression[] = []; 37 | 38 | // "./globals.css" 39 | 40 | traverse(ast, { 41 | // enter(path) { 42 | // console.log(path.node.type); 43 | // }, 44 | ImportDeclaration(path) { 45 | const value = path.node.source.value; 46 | if (value.endsWith(".css")) { 47 | const name = value.replace(/^\.\/(.*?)\.css$/, "$1css"); 48 | stylesheetsMeta.push( 49 | t.objectExpression([ 50 | t.objectProperty(t.identifier("href"), t.identifier(name)), 51 | t.objectProperty( 52 | t.identifier("rel"), 53 | t.stringLiteral("stylesheet") 54 | ), 55 | ]) 56 | ); 57 | } 58 | }, 59 | }); 60 | 61 | traverse(ast, { 62 | Program(path) { 63 | const tanstackImports = t.importDeclaration( 64 | [ 65 | t.importSpecifier(t.identifier("Outlet"), t.identifier("Outlet")), 66 | t.importSpecifier( 67 | t.identifier("createRootRoute"), 68 | t.identifier("createRootRoute") 69 | ), 70 | t.importSpecifier( 71 | t.identifier("HeadContent"), 72 | t.identifier("HeadContent") 73 | ), 74 | t.importSpecifier(t.identifier("Scripts"), t.identifier("Scripts")), 75 | ], 76 | t.stringLiteral("@tanstack/react-router") 77 | ); 78 | 79 | // console.log("path.node.body", path.node.body); 80 | 81 | path.node.body.unshift(tanstackImports); 82 | }, 83 | JSXElement(path) { 84 | LinkElement(path); 85 | ImageElement(path); 86 | }, 87 | ImportDeclaration(path) { 88 | // console.log(path.node.source.value); 89 | const source = path.node.source.value; 90 | if (source == "next" || source == "next/script") { 91 | path.remove(); 92 | } 93 | 94 | if (source.endsWith(".css")) { 95 | const name = source.replace(/^\.\/(.*?)\.css$/, "$1css"); 96 | const importCss = t.importDeclaration( 97 | [t.importDefaultSpecifier(t.identifier(name as string))], 98 | t.stringLiteral(`${source}?url`) 99 | ); 100 | path.replaceWith(importCss); 101 | path.skip(); 102 | } 103 | }, 104 | ExportDefaultDeclaration(path) { 105 | if ( 106 | t.isFunctionDeclaration(path.node.declaration) && 107 | path.node.declaration.id?.name === "RootLayout" 108 | ) { 109 | const funcDeclaration = path.node.declaration; 110 | 111 | // Remove parameters (no more children prop) 112 | funcDeclaration.params = []; 113 | 114 | // Transform the JSX body 115 | if (t.isBlockStatement(funcDeclaration.body)) { 116 | traverse( 117 | funcDeclaration, 118 | { 119 | // Find and transform the JSX structure 120 | JSXElement(jsxPath) { 121 | // Add with if is found 122 | const openingElement = jsxPath.node.openingElement; 123 | if ( 124 | t.isJSXIdentifier(openingElement.name) && 125 | openingElement.name.name === "html" 126 | ) { 127 | // Find element among children 128 | const bodyIndex = jsxPath.node.children.findIndex( 129 | (child) => { 130 | return ( 131 | t.isJSXElement(child) && 132 | t.isJSXIdentifier(child.openingElement.name) && 133 | child.openingElement.name.name === "body" 134 | ); 135 | } 136 | ); 137 | 138 | if (bodyIndex !== -1) { 139 | // Create 140 | const headElement = t.jsxElement( 141 | t.jsxOpeningElement(t.jsxIdentifier("head"), [], false), 142 | t.jsxClosingElement(t.jsxIdentifier("head")), 143 | [ 144 | t.jsxText("\n "), 145 | t.jsxElement( 146 | t.jsxOpeningElement( 147 | t.jsxIdentifier("HeadContent"), 148 | [], 149 | true 150 | ), 151 | null, 152 | [], 153 | true 154 | ), 155 | t.jsxText("\n "), 156 | ], 157 | false 158 | ); 159 | 160 | // Insert before 161 | jsxPath.node.children.splice( 162 | bodyIndex, 163 | 0, 164 | t.jsxText("\n "), 165 | headElement 166 | ); 167 | } 168 | } 169 | 170 | // Add in after children/Outlet 171 | if ( 172 | t.isJSXIdentifier(openingElement.name) && 173 | openingElement.name.name === "body" 174 | ) { 175 | const scriptsElement = t.jsxElement( 176 | t.jsxOpeningElement(t.jsxIdentifier("Scripts"), [], true), 177 | null, 178 | [], 179 | true 180 | ); 181 | 182 | // Find where {children} or is 183 | let insertIndex = -1; 184 | for ( 185 | let i = jsxPath.node.children.length - 1; 186 | i >= 0; 187 | i-- 188 | ) { 189 | const child = jsxPath.node.children[i]; 190 | 191 | // Check for or {children} 192 | if (t.isJSXElement(child)) { 193 | const childName = child.openingElement.name; 194 | if ( 195 | t.isJSXIdentifier(childName) && 196 | childName.name === "Outlet" 197 | ) { 198 | insertIndex = i + 1; 199 | break; 200 | } 201 | } else if (t.isJSXExpressionContainer(child)) { 202 | if ( 203 | t.isIdentifier(child.expression) && 204 | child.expression.name === "children" 205 | ) { 206 | insertIndex = i + 1; 207 | break; 208 | } 209 | } 210 | } 211 | 212 | if (insertIndex !== -1) { 213 | jsxPath.node.children.splice( 214 | insertIndex, 215 | 0, 216 | t.jsxText("\n "), 217 | scriptsElement 218 | ); 219 | } 220 | } 221 | }, 222 | 223 | // Replace {children} with 224 | JSXExpressionContainer(jsxPath) { 225 | if ( 226 | t.isIdentifier(jsxPath.node.expression) && 227 | jsxPath.node.expression.name === "children" 228 | ) { 229 | const outletElement = t.jsxElement( 230 | t.jsxOpeningElement(t.jsxIdentifier("Outlet"), [], true), 231 | null, 232 | [], 233 | true 234 | ); 235 | 236 | jsxPath.replaceWith(outletElement); 237 | } 238 | }, 239 | }, 240 | path.scope, 241 | path 242 | ); 243 | } 244 | 245 | // Convert from default export to named declaration 246 | const regularFunc = t.functionDeclaration( 247 | funcDeclaration.id, 248 | funcDeclaration.params, 249 | funcDeclaration.body, 250 | funcDeclaration.generator, 251 | funcDeclaration.async 252 | ); 253 | 254 | // Replace the default export with just the function declaration 255 | path.replaceWith(regularFunc); 256 | } 257 | }, 258 | ExportNamedDeclaration(path) { 259 | if (path.node.declaration) { 260 | if (t.isVariableDeclaration(path.node.declaration)) { 261 | path.node.declaration.declarations.forEach((declaration) => { 262 | if (t.isIdentifier(declaration.id)) { 263 | if (declaration.id.name === "metadata") { 264 | metadataFound = true; 265 | const init = declaration.init; 266 | 267 | if (t.isObjectExpression(init)) { 268 | const metaItems = [ 269 | // Default meta tags 270 | t.objectExpression([ 271 | t.objectProperty( 272 | t.identifier("charSet"), 273 | t.stringLiteral("utf-8") 274 | ), 275 | ]), 276 | t.objectExpression([ 277 | t.objectProperty( 278 | t.identifier("name"), 279 | t.stringLiteral("viewport") 280 | ), 281 | t.objectProperty( 282 | t.identifier("content"), 283 | t.stringLiteral("width=device-width, initial-scale=1") 284 | ), 285 | ]), 286 | ]; 287 | 288 | // Transform each metadata property to meta format 289 | init.properties.forEach((prop) => { 290 | if (t.isObjectProperty(prop)) { 291 | const key = t.isIdentifier(prop.key) 292 | ? prop.key.name 293 | : t.isStringLiteral(prop.key) 294 | ? prop.key.value 295 | : null; 296 | 297 | if (key === "title") { 298 | // { title: "value" } -> { title: "value" } 299 | metaItems.push( 300 | t.objectExpression([ 301 | t.objectProperty( 302 | t.identifier("title"), 303 | prop.value 304 | ), 305 | ]) 306 | ); 307 | } else if (key === "description") { 308 | // { description: "value" } -> { name: "description", content: "value" } 309 | metaItems.push( 310 | t.objectExpression([ 311 | t.objectProperty( 312 | t.identifier("name"), 313 | t.stringLiteral("description") 314 | ), 315 | t.objectProperty( 316 | t.identifier("content"), 317 | prop.value 318 | ), 319 | ]) 320 | ); 321 | } else { 322 | metaItems.push( 323 | t.objectExpression([ 324 | t.objectProperty( 325 | t.identifier("name"), 326 | t.stringLiteral(key as string) 327 | ), 328 | t.objectProperty( 329 | t.identifier("content"), 330 | prop.value 331 | ), 332 | ]) 333 | ); 334 | } 335 | } 336 | }); 337 | 338 | // Create the Route export 339 | const routeExport = t.exportNamedDeclaration( 340 | t.variableDeclaration("const", [ 341 | t.variableDeclarator( 342 | t.identifier("Route"), 343 | t.callExpression(t.identifier("createRootRoute"), [ 344 | t.objectExpression([ 345 | // head property 346 | t.objectProperty( 347 | t.identifier("head"), 348 | t.arrowFunctionExpression( 349 | [], 350 | t.objectExpression([ 351 | t.objectProperty( 352 | t.identifier("meta"), 353 | t.arrayExpression(metaItems) 354 | ), 355 | t.objectProperty( 356 | t.identifier("links"), 357 | t.arrayExpression(stylesheetsMeta) 358 | ), 359 | ]) 360 | ) 361 | ), 362 | t.objectProperty( 363 | t.identifier("component"), 364 | t.identifier("RootLayout") 365 | ), 366 | ]), 367 | ]) 368 | ), 369 | ]) 370 | ); 371 | 372 | // Replace the metadata export with the Route export 373 | path.replaceWith(routeExport); 374 | } 375 | } else { 376 | } 377 | } 378 | }); 379 | } 380 | } 381 | }, 382 | }); 383 | 384 | // If metadata was not found 385 | if (!metadataFound) { 386 | traverse(ast, { 387 | Program(path) { 388 | const metaItems = [ 389 | t.objectExpression([ 390 | t.objectProperty( 391 | t.identifier("charSet"), 392 | t.stringLiteral("utf-8") 393 | ), 394 | ]), 395 | t.objectExpression([ 396 | t.objectProperty( 397 | t.identifier("name"), 398 | t.stringLiteral("viewport") 399 | ), 400 | t.objectProperty( 401 | t.identifier("content"), 402 | t.stringLiteral("width=device-width, initial-scale=1") 403 | ), 404 | ]), 405 | ]; 406 | 407 | const routeExport = t.exportNamedDeclaration( 408 | t.variableDeclaration("const", [ 409 | t.variableDeclarator( 410 | t.identifier("Route"), 411 | t.callExpression(t.identifier("createRootRoute"), [ 412 | t.objectExpression([ 413 | t.objectProperty( 414 | t.identifier("head"), 415 | t.arrowFunctionExpression( 416 | [], 417 | t.objectExpression([ 418 | t.objectProperty( 419 | t.identifier("meta"), 420 | t.arrayExpression(metaItems) 421 | ), 422 | t.objectProperty( 423 | t.identifier("links"), 424 | t.arrayExpression(stylesheetsMeta) 425 | ), 426 | ]) 427 | ) 428 | ), 429 | t.objectProperty( 430 | t.identifier("component"), 431 | t.identifier("RootLayout") 432 | ), 433 | ]), 434 | ]) 435 | ), 436 | ]) 437 | ); 438 | 439 | // Add it after imports, before other exports 440 | const lastImportIndex = path.node.body.findIndex( 441 | (node) => !t.isImportDeclaration(node) 442 | ); 443 | path.node.body.splice(lastImportIndex, 0, routeExport); 444 | }, 445 | }); 446 | } 447 | 448 | const transformed = generate(ast).code; 449 | fs.writeFileSync(rootLayoutPath, transformed); 450 | fs.renameSync(rootLayoutPath, rootLayoutPath.replace("layout", "__root")); 451 | // console.log(transformed); 452 | } else { 453 | console.error("No layout.tsx or layout.js found in app directory"); 454 | } 455 | } 456 | --------------------------------------------------------------------------------