├── .github └── workflows │ ├── npm-publish.yml │ └── npm-test.yml ├── .gitignore ├── README.md ├── assets ├── rr-next-routes-dark.svg └── rr-next-routes-light.svg ├── license.md ├── package-lock.json ├── package.json ├── src ├── common │ ├── implementationResolver.ts │ ├── next-routes-common.ts │ ├── types.ts │ └── utils.ts ├── next-routes.ts ├── react-router │ └── index.ts ├── remix │ └── index.ts ├── tests │ ├── react-router.test.ts │ ├── remix.test.ts │ └── sharedTests.ts └── utils.ts ├── tsconfig.json ├── tsup.config.ts └── vitest.config.ts /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: build, test & release 5 | 6 | on: 7 | workflow_dispatch: 8 | push: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 22 20 | - run: npm ci 21 | - run: npm run build 22 | - run: npm test 23 | 24 | publish-npm: 25 | needs: build 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 0 31 | - uses: actions/setup-node@v4 32 | with: 33 | node-version: 22 34 | registry-url: https://registry.npmjs.org/ 35 | - name: Version Check 36 | uses: EndBug/version-check@v2.1.5 37 | id: check 38 | with: 39 | diff-search: true 40 | file-url: "https://unpkg.com/rr-next-routes/package.json" 41 | static-checking: localIsNew 42 | - name: Log when changed 43 | if: steps.check.outputs.changed == 'true' 44 | run: 'echo "Version change found in commit ${{ steps.check.outputs.commit }}! New version: ${{ steps.check.outputs.version }} (${{ steps.check.outputs.type }})"' 45 | - name: Log when unchanged 46 | if: steps.check.outputs.changed == 'false' 47 | run: 'echo "No version change - No release."' 48 | - run: npm ci 49 | if: steps.check.outputs.changed == 'true' 50 | - run: npm run build 51 | if: steps.check.outputs.changed == 'true' 52 | - run: npm publish 53 | if: steps.check.outputs.changed == 'true' 54 | env: 55 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 56 | -------------------------------------------------------------------------------- /.github/workflows/npm-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: build & test 5 | 6 | on: 7 | workflow_dispatch: 8 | push: 9 | branches: 10 | - develop 11 | pull_request: 12 | branches: 13 | - main 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: 22 23 | - run: npm ci 24 | - run: npm run build 25 | - run: npm test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/**/workspace.xml 2 | .idea/**/tasks.xml 3 | .idea/**/usage.statistics.xml 4 | .idea/**/dictionaries 5 | .idea/**/shelf 6 | .idea/**/aws.xml 7 | .idea/**/contentModel.xml 8 | .idea/**/dataSources/ 9 | .idea/**/dataSources.ids 10 | .idea/**/dataSources.local.xml 11 | .idea/**/sqlDataSources.xml 12 | .idea/**/dynamic.xml 13 | .idea/**/uiDesigner.xml 14 | .idea/**/dbnavigator.xml 15 | .idea/**/gradle.xml 16 | .idea/**/libraries 17 | cmake-build-*/ 18 | .idea/**/mongoSettings.xml 19 | *.iws 20 | out/ 21 | .idea_modules/ 22 | atlassian-ide-plugin.xml 23 | .idea/replstate.xml 24 | .idea/sonarlint/ 25 | com_crashlytics_export_strings.xml 26 | crashlytics.properties 27 | crashlytics-build.properties 28 | fabric.properties 29 | .idea/httpRequests 30 | .idea/caches/build_file_checksums.ser 31 | logs 32 | *.log 33 | npm-debug.log* 34 | yarn-debug.log* 35 | yarn-error.log* 36 | lerna-debug.log* 37 | .pnpm-debug.log* 38 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 39 | pids 40 | *.pid 41 | *.seed 42 | *.pid.lock 43 | lib-cov 44 | coverage 45 | *.lcov 46 | .nyc_output 47 | .grunt 48 | bower_components 49 | .lock-wscript 50 | build/Release 51 | node_modules/ 52 | jspm_packages/ 53 | web_modules/ 54 | *.tsbuildinfo 55 | .npm 56 | .eslintcache 57 | .stylelintcache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | .node_repl_history 63 | *.tgz 64 | .yarn-integrity 65 | .env 66 | .env.development.local 67 | .env.test.local 68 | .env.production.local 69 | .env.local 70 | .cache 71 | .parcel-cache 72 | .next 73 | out 74 | .nuxt 75 | dist 76 | .cache/ 77 | .vuepress/dist 78 | .temp 79 | .docusaurus 80 | .serverless/ 81 | .fusebox/ 82 | .dynamodb/ 83 | .tern-port 84 | .vscode-test 85 | .yarn/cache 86 | .yarn/unplugged 87 | .yarn/build-state.yml 88 | .yarn/install-state.gz 89 | .pnp.* 90 | node_modules 91 | /.cache 92 | /build 93 | /public/build 94 | .DS_* 95 | **/*.backup.* 96 | **/*.back.* 97 | *.sublime* 98 | psd 99 | thumb 100 | sketch 101 | /node_modules 102 | /.pnp 103 | .pnp.js 104 | /coverage 105 | /.next/ 106 | /out/ 107 | .DS_Store 108 | *.pem 109 | .env*.local 110 | .vercel 111 | next-env.d.ts 112 | /node_modules 113 | /coverage.data 114 | /coverage/ 115 | /dist 116 | /.idea/* 117 | /Folder.DotSettings.user -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 | rr-next-routes logo 6 | 7 |

rr-next-routes

8 | 9 | NPM version 10 | License 11 |
12 | 13 | ## Features 14 | `rr-next-routes` is a utility library for generating **Next.js-style** routes for your **React Router v7** or **Remix** applications. 15 | It provides a directory-based routing solution for React applications, similar to Next.js, and supports features like layouts, route parameters, and dynamic routing with nested route management. 16 | 17 |
18 | Motivation 19 | 20 |
21 | I really enjoy using file-based (directory-based) routing when working with next.js 22 |
23 | 24 | While there are different solutions like [generouted](https://github.com/oedotme/generouted), most of them require you to modify multiple files and some even bring their own routing. 25 | **rr-next-routes** is a simple drop in solution for project using [remix](https://remix.run) or [react-router v7](https://reactrouter.com/home) in framework mode. 26 | you can even still use the [manual routing](https://reactrouter.com/start/framework/routing) to add more routes to your liking while **rr-next-routes** takes care of the pages dir: 27 | 28 | #### **`routes.ts`** 29 | ``` typescript 30 | import {route, type RouteConfig} from "@react-router/dev/routes"; 31 | import {nextRoutes, appRouterStyle} from "rr-next-routes/react-router"; 32 | //import {nextRoutes, appRouterStyle} from "rr-next-routes/remix"; 33 | 34 | const autoRoutes = nextRoutes({ 35 | ...appRouterStyle, 36 | print: "tree", 37 | }); 38 | 39 | export default [ 40 | ...autoRoutes, 41 | route("some/path", "./some/file.tsx"), 42 | ] satisfies RouteConfig; 43 | ``` 44 |
45 | 46 | --- 47 | 48 | ## Features 49 | #### Keep using **React Router v7** in framework mode and still get: 50 | - Generate route configurations from a file-based structure. 51 | - Supports for (nested) layouts 52 | - Handles dynamic routes, optional parameters, and catch-all routes (`[param].tsx`, `[[param]].tsx`, `[...param].tsx`). 53 | - Compatible with **React Router v7** (framework mode) and **Remix** routing systems. 54 | - Predefined configurations to suit both app-router and page-router patterns. 55 | - Flexible configuration system. 56 | - Configurable printing options: `info`, `table`, or `tree` to console log the generation results 57 | 58 | --- 59 | 60 | ## Installation 61 | You can install the library using npm: 62 | ``` bash 63 | npm install rr-next-routes 64 | ``` 65 | 66 | --- 67 | 68 | ## Usage 69 | ### Example 70 | Here’s a sample usage of `rr-next-routes` for generating route configurations: 71 | #### **`routes.ts`** 72 | ``` typescript 73 | import { type RouteConfig } from "@react-router/dev/routes"; 74 | import {nextRoutes, appRouterStyle} from "rr-next-routes/react-router"; 75 | // for remix use this instead 76 | // import {nextRoutes, appRouterStyle} from "rr-next-routes/remix"; 77 | 78 | const routes = nextRoutes({ print: "info" }); 79 | 80 | export default routes satisfies RouteConfig; 81 | ``` 82 | 83 | --- 84 | 85 | ## Configuration Options 86 | 87 | The `nextRoutes` function accepts an optional configuration object of type `Options`: 88 | 89 | #### `Options` Type 90 | ```typescript 91 | type PrintOption = "no" | "info" | "table" | "tree"; 92 | 93 | type Options = { 94 | folderName?: string; // Folder to scan for routes (default: "pages"). 95 | print?: PrintOption; // Controls printing output (default: "info"). 96 | layoutFileName?: string; // Name of layout files (default: "_layout"). 97 | routeFileNames?: string[]; // Names for route files (e.g., ["page", "index"]). 98 | routeFileNameOnly?: boolean; // Restrict routes to matching routeFileNames. 99 | extensions?: string[]; // File extensions to process (e.g., [".tsx", ".ts"]). 100 | }; 101 | ``` 102 | 103 | ### Predefined Styles 104 | 105 | #### 1. `appRouterStyle (default)` 106 | Best for projects following the Next.js app-router convention: 107 | 108 | ```typescript 109 | export const appRouterStyle: Options = { 110 | folderName: "", 111 | print: "info", 112 | layoutFileName: "layout", 113 | routeFileNames: ["page", "route"], 114 | extensions: [".tsx", ".ts", ".jsx", ".js"], 115 | routeFileNameOnly: true, 116 | }; 117 | ``` 118 | 119 | #### 2. `pageRouterStyle` 120 | Best for projects following the Next.js pages-router convention: 121 | 122 | ```typescript 123 | export const pageRouterStyle: Options = { 124 | folderName: "pages", 125 | print: "info", 126 | layoutFileName: "_layout", 127 | routeFileNames: ["index"], 128 | extensions: [".tsx", ".ts", ".jsx", ".js"], 129 | routeFileNameOnly: false, 130 | }; 131 | ``` 132 | 133 | --- 134 | 135 | ### Example Configurations 136 | 137 | #### Default Configuration: 138 | ```typescript 139 | const routes = nextRoutes(); // Uses default options (appRouterStyle). 140 | ``` 141 | 142 | #### Using Pages Router: 143 | ```typescript 144 | const routes = nextRoutes(pageRouterStyle); 145 | ``` 146 | #### Custom App Router: 147 | ```typescript 148 | const routes = nextRoutes({ 149 | ...appRouterStyle, 150 | print: "tree", 151 | layoutFileName: "_customLayout", 152 | }); 153 | ``` 154 | 155 | --- 156 | 157 | ### Example File Structure (pageRouterStyle) 158 | ``` 159 | app/ 160 | ├── pages/ 161 | │ ├── index.tsx 162 | │ ├── about.tsx 163 | │ ├── dashboard/ 164 | │ │ ├── _layout.tsx 165 | │ │ ├── index.tsx 166 | │ │ ├── settings.tsx 167 | │ ├── profile/ 168 | │ │ ├── name.tsx 169 | │ │ ├── email.tsx 170 | │ │ ├── password.tsx 171 | ``` 172 | ### Generated Routes 173 | The above structure will result in the following routes: 174 | - `/` → `pages/index.tsx` 175 | - `/about` → `pages/about.tsx` 176 | - `/dashboard` → `pages/dashboard/index.tsx` (wrapped by `layout`: `pages/dashboard/_layout.tsx`) 177 | - `/dashboard/settings` → `pages/dashboard/settings.tsx` (wrapped by `layout`: `pages/dashboard/_layout.tsx`) 178 | - `/profile/name` → `pages/profile/name.tsx` 179 | - `/profile/email` → `pages/profile/email.tsx` 180 | - `/profile/password` → `pages/profile/password.tsx` 181 | 182 | ## Dynamic Routes 183 | The library supports Next.js-style dynamic and optional routes: 184 | ### Example 185 | #### File Structure 186 | ``` 187 | app/ 188 | ├── pages/ 189 | │ ├── [id].tsx 190 | │ ├── [[optional]].tsx 191 | │ ├── [...all].tsx 192 | ``` 193 | #### Generated Routes 194 | - `/:id` → `pages/[id].tsx` 195 | - `/:optional?` → `pages/[[optional]].tsx` 196 | - `/*` → `pages/[...all].tsx` 197 | 198 | These routes are created using a special character mapping: 199 | 200 | | File Name | Route Path | 201 | | --- | --- | 202 | | `[id].tsx` | `/:id` | 203 | | `[[optional]].tsx` | `/:optional?` | 204 | | `[...all].tsx` | `/*` | 205 | 206 | 207 | --- 208 | ## Testing 209 | This project uses **Vitest** for testing. To run the tests: 210 | ``` bash 211 | npm test 212 | ``` 213 | 214 | --- 215 | 216 | ## Development 217 | The project supports ES modules and is built using `tsup`. 218 | ### Build 219 | To build the project: 220 | ``` bash 221 | npm run build 222 | ``` 223 | ### Watch (Test in Development Mode) 224 | ``` bash 225 | npm run dev 226 | ``` 227 | 228 | -------------------------------------------------------------------------------- /assets/rr-next-routes-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /assets/rr-next-routes-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Vincent Niehues 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rr-next-routes", 3 | "publishConfig": { 4 | "access": "public", 5 | "registry": "https://registry.npmjs.org/" 6 | }, 7 | "version": "0.0.9", 8 | "description": "generate nextjs-style routes in your react-router-v7 application", 9 | "scripts": { 10 | "dev": "vitest watch", 11 | "build": "tsup", 12 | "test": "vitest run" 13 | }, 14 | "type": "module", 15 | "exports": { 16 | ".": { 17 | "types": "./dist/next-routes.d.ts", 18 | "import": "./dist/next-routes.js" 19 | }, 20 | "./react-router": { 21 | "types": "./dist/react-router/index.d.ts", 22 | "import": "./dist/react-router/index.js" 23 | }, 24 | "./remix": { 25 | "types": "./dist/remix/index.d.ts", 26 | "import": "./dist/remix/index.js" 27 | } 28 | }, 29 | "files": [ 30 | "dist" 31 | ], 32 | "keywords": [ 33 | "better", 34 | "routes", 35 | "react-router", 36 | "nextjs", 37 | "directory", 38 | "based", 39 | "routing" 40 | ], 41 | "author": "Vincent Niehues", 42 | "license": "MIT", 43 | "devDependencies": { 44 | "@react-router/dev": "^7.0.0", 45 | "@remix-run/route-config": "^2.16.4", 46 | "@types/node": "^22.10.5", 47 | "@vitest/coverage-v8": "^2.1.8", 48 | "memfs": "^4.15.3", 49 | "tsup": "^8.3.5", 50 | "typescript": "^5.7.2", 51 | "vite": "^5.4.10", 52 | "vitest": "^2.1.4" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/common/implementationResolver.ts: -------------------------------------------------------------------------------- 1 | // src/internal/resolveImplementation.ts 2 | export function resolveImplementation(): typeof import('../react-router') | typeof import('../remix') { 3 | try { 4 | require('@react-router/dev/routes'); 5 | return require('../react-router'); 6 | } catch (e) { 7 | try { 8 | require('@remix-run/route-config'); 9 | return require('../remix'); 10 | } catch (e2) { 11 | throw new Error( 12 | "Could not import from either @react-router/dev/routes or @remix-run/route-config. " + 13 | "Please install one of these packages to use this library." 14 | ); 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/common/next-routes-common.ts: -------------------------------------------------------------------------------- 1 | import {readdirSync, statSync} from 'node:fs'; 2 | import {join, parse, relative, resolve} from 'node:path'; 3 | import { 4 | deepSortByPath, 5 | isHoistedFolder, 6 | parseParameter, 7 | printRoutesAsTable, 8 | printRoutesAsTree, 9 | transformRoutePath 10 | } from "./utils"; 11 | import {RouteConfigEntry} from "./types"; 12 | 13 | type PrintOption = "no" | "info" | "table" | "tree"; 14 | 15 | export const appRouterStyle: Options = { 16 | folderName: "", 17 | print: "info", 18 | layoutFileName: "layout", 19 | routeFileNames: ["page", "route"], // in nextjs this is the difference between a page (with components) and an api route (without components). in react-router an api route (resource route) just does not export a default component. 20 | extensions: [".tsx", ".ts", ".jsx", ".js"], 21 | routeFileNameOnly: true, // all files with names different from routeFileNames get no routes 22 | enableHoistedFolders: false, 23 | }; 24 | 25 | export const pageRouterStyle: Options = { 26 | folderName: "pages", 27 | print: "info", 28 | layoutFileName: "_layout", //layouts do no exist like that in nextjs pages router so we use a special file. 29 | routeFileNames: ["index"], 30 | extensions: [".tsx", ".ts", ".jsx", ".js"], 31 | routeFileNameOnly: false, // all files without a leading underscore get routes as long as the extension matches 32 | enableHoistedFolders: false, 33 | }; 34 | 35 | export type Options = { 36 | folderName?: string; 37 | layoutFileName?: string; 38 | routeFileNames?: string[]; 39 | routeFileNameOnly?: boolean; 40 | extensions?: string[]; 41 | print?: PrintOption; 42 | enableHoistedFolders?: boolean; 43 | }; 44 | 45 | const defaultOptions: Options = appRouterStyle; 46 | 47 | /** 48 | * Creates a route configuration for a file or directory 49 | * @param name - Name of the file or directory 50 | * @param parentPath - Current path in the route hierarchy 51 | * @param relativePath - Relative path to the file from pages directory 52 | * @param folderName - Name of the current folder (needed for dynamic routes) 53 | * @param routeFileNames - name of the index routes 54 | * @param routeCreator - Function to create a route entry 55 | * @returns Route configuration entry 56 | */ 57 | export function createRouteConfig( 58 | name: string, 59 | parentPath: string, 60 | relativePath: string, 61 | folderName: string, 62 | routeFileNames: string[], 63 | routeCreator: (path: string, file: string) => RouteConfigEntry 64 | ): RouteConfigEntry { 65 | // Handle index routes in dynamic folders 66 | if (routeFileNames.includes(name) && folderName.startsWith('[') && folderName.endsWith(']')) { 67 | const {routeName} = parseParameter(folderName); 68 | const routePath = parentPath === '' ? routeName : `${parentPath.replace(folderName, '')}${routeName}`; 69 | return routeCreator(transformRoutePath(routePath), relativePath); 70 | } 71 | 72 | // Handle regular index routes 73 | if (routeFileNames.includes(name)) { 74 | const routePath = parentPath === '' ? '/' : parentPath; 75 | return routeCreator(transformRoutePath(routePath), relativePath); 76 | } 77 | 78 | // Handle dynamic and regular routes 79 | const {routeName} = parseParameter(name); 80 | const routePath = parentPath === '' ? `/${routeName}` : `${parentPath}/${routeName}`; 81 | return routeCreator(transformRoutePath(routePath), relativePath); 82 | } 83 | 84 | /** 85 | * Generates route configuration from a Next.js-style pages directory 86 | * @param options - Configuration options for route generation 87 | * @param getAppDir - Function to get the app directory 88 | * @param routeCreator - Function to create a route entry 89 | * @param layoutCreator - Function to create a layout entry 90 | * @returns Array of route configurations 91 | */ 92 | export function generateNextRoutes( 93 | options: Options = defaultOptions, 94 | getAppDir: () => string, 95 | routeCreator: (path: string, file: string) => RouteConfigEntry, 96 | layoutCreator: (file: string, children: RouteConfigEntry[]) => RouteConfigEntry 97 | ): RouteConfigEntry[] { 98 | const { 99 | folderName: baseFolder = defaultOptions.folderName!, 100 | print: printOption = defaultOptions.print!, 101 | extensions = defaultOptions.extensions!, 102 | layoutFileName = defaultOptions.layoutFileName!, 103 | routeFileNames = defaultOptions.routeFileNames!, 104 | routeFileNameOnly = defaultOptions.routeFileNameOnly!, 105 | enableHoistedFolders = defaultOptions.enableHoistedFolders!, 106 | } = options; 107 | 108 | let appDirectory = getAppDir(); 109 | 110 | const pagesDir = resolve(appDirectory, baseFolder); 111 | 112 | /** 113 | * Scans a directory and returns its contents 114 | * @param dirPath - Path to the directory to scan 115 | */ 116 | function scanDir(dirPath: string) { 117 | return { 118 | folderName: parse(dirPath).base, 119 | files: readdirSync(dirPath).sort((a, b) => { 120 | const {ext: aExt, name: aName} = parse(a); 121 | return ((routeFileNames.includes(aName) && extensions.includes(aExt)) ? -1 : 1) 122 | }) 123 | }; 124 | } 125 | 126 | /** 127 | * Recursively scans directory and generates route configurations 128 | * @param dir - Current directory path 129 | * @param parentPath - Current path in route hierarchy 130 | */ 131 | function scanDirectory(dir: string, parentPath: string = ''): RouteConfigEntry[] { 132 | const routes: RouteConfigEntry[] = []; 133 | const {files, folderName} = scanDir(dir); 134 | const layoutFile = files.find(item => { 135 | const {ext, name} = parse(item); 136 | return (name === layoutFileName && extensions.includes(ext)); 137 | }); 138 | const currentLevelRoutes: RouteConfigEntry[] = []; 139 | 140 | // Process each file in the directory 141 | files.forEach(item => { 142 | // Early return for excluded items 143 | if (item.startsWith('_')) return; 144 | 145 | const fullPath = join(dir, item); 146 | const stats = statSync(fullPath); 147 | const {name, ext, base} = parse(item); 148 | const relativePath = join(baseFolder, relative(pagesDir, fullPath)); 149 | 150 | // Don't create accessible routes for layout files. 151 | if (layoutFileName && name === layoutFileName) return; 152 | 153 | if (stats.isDirectory()) { 154 | // Handle nested directories 155 | const nestedRoutes = scanDirectory(fullPath, `${parentPath}/${base}`); 156 | (layoutFile && !(enableHoistedFolders && isHoistedFolder(name)) ? currentLevelRoutes : routes).push(...nestedRoutes); 157 | } else if (extensions.includes(ext)) { 158 | // Early return if strict file names are enabled and the current item is not in the list. 159 | if (routeFileNameOnly && !routeFileNames.includes(name)) return; 160 | const routeConfig = createRouteConfig(name, parentPath, relativePath, folderName, routeFileNames, routeCreator); 161 | (layoutFile ? currentLevelRoutes : routes).push(routeConfig); 162 | } 163 | }); 164 | if (layoutFile) { 165 | const layoutPath = join(baseFolder, relative(pagesDir, join(dir, layoutFile))); 166 | routes.push(layoutCreator(layoutPath, currentLevelRoutes)); 167 | } else { 168 | routes.push(...currentLevelRoutes); 169 | } 170 | 171 | return routes; 172 | } 173 | 174 | const results = scanDirectory(pagesDir); 175 | 176 | // Handle printing options 177 | switch (printOption) { 178 | case "tree": 179 | printRoutesAsTree(results); 180 | break; 181 | case "table": 182 | printRoutesAsTable(results); 183 | break; 184 | case "info": 185 | console.log("✅ Generated Routes"); 186 | break; 187 | case "no": 188 | break; 189 | } 190 | 191 | return deepSortByPath(results); 192 | } -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | import {RouteConfigEntry as rrRouteConfigEntry} from "../react-router"; 2 | import {RouteConfigEntry as remixRouteConfigEntry} from "../remix"; 3 | 4 | export type RouteConfigEntry = rrRouteConfigEntry & remixRouteConfigEntry 5 | -------------------------------------------------------------------------------- /src/common/utils.ts: -------------------------------------------------------------------------------- 1 | // This file contains utility functions for route generation 2 | import {RouteConfigEntry} from "./types"; 3 | 4 | export function transformRoutePath(path: string): string { 5 | let transformedPath = path 6 | .replace(/\[\[\s*([^\]]+)\s*]]/g, ':$1?') // Handle optional parameters [[param]] 7 | .replace(/\[\.\.\.\s*([^\]]+)\s*]/g, '*') // Handle catch-all parameters [...param] 8 | .replace(/\[([^\]]+)]/g, ':$1') // Handle regular parameters [param] 9 | .replace(/\/\([^)]*\)\//g, '/') // Handle regular parameters [param] 10 | .replace(/\{([^}]+)\}/g, '$1') // Strip curly braces {param} 11 | .replace(/\/\([^)]*\)/g, ''); // Remove parentheses and contents only if surrounded by slashes 12 | 13 | if(transformedPath === "") { 14 | transformedPath = "/" + transformedPath; 15 | } 16 | 17 | return transformedPath; 18 | } 19 | 20 | export function isHoistedFolder(name: string): boolean { 21 | return /^{[^{}]+}$/.test(name); 22 | } 23 | 24 | function parseDynamicRoute(name: string): { paramName?: string; routeName: string } { 25 | const paramMatch = name.match(/\[(.+?)]/); 26 | if (!paramMatch) return {routeName: name}; 27 | 28 | const paramName = paramMatch[1]; 29 | return { 30 | paramName, 31 | routeName: `:${paramName}` 32 | }; 33 | } 34 | 35 | function parseCatchAllParam(name: string): { paramName?: string; routeName: string } { 36 | const paramMatch = name.match(/\[\.\.\.(.+?)]/); 37 | if (!paramMatch) return {routeName: name}; 38 | 39 | const paramName = paramMatch[1]; 40 | return { 41 | paramName, 42 | routeName: "*" // Placeholder to indicate "catch-all" route for now 43 | }; 44 | } 45 | 46 | function parseOptionalDynamicRoute(name: string): { paramName?: string; routeName: string } { 47 | const paramMatch = name.match(/\[\[(.+?)]]/); 48 | if (!paramMatch) return {routeName: name}; 49 | 50 | const paramName = paramMatch[1]; 51 | return { 52 | paramName, 53 | routeName: `:${paramName}?` 54 | }; 55 | } 56 | 57 | export function parseParameter(name: string): { paramName?: string; routeName: string } { 58 | if (name.startsWith('[[') && name.endsWith(']]')) { 59 | // Optional parameter format: [[param]] 60 | return parseOptionalDynamicRoute(name); 61 | } else if (name.startsWith('[...') && name.endsWith(']')) { 62 | // Catch-all parameter format: [...param] 63 | return parseCatchAllParam(name); 64 | } else if (name.startsWith('[') && name.endsWith(']')) { 65 | // Regular parameter format: [param] 66 | return parseDynamicRoute(name); 67 | } else { 68 | // Not a dynamic parameter 69 | return {routeName: name}; 70 | } 71 | } 72 | 73 | export function deepSortByPath(value: any): any { 74 | if (Array.isArray(value)) { 75 | // Recursively sort arrays based on 'path' 76 | return value 77 | .map(deepSortByPath) // Sort children first 78 | .sort((a: any, b: any) => compareByPath(a, b)); 79 | } 80 | 81 | if (typeof value === 'object' && value !== null) { 82 | if ('path' in value) { 83 | // If the object has a 'path' property, sort its children if any 84 | return { 85 | ...value, 86 | children: value.children ? deepSortByPath(value.children) : undefined, 87 | }; 88 | } 89 | 90 | // Sort object keys for non-'path' objects 91 | return Object.keys(value) 92 | .sort() 93 | .reduce((acc, key) => { 94 | acc[key] = deepSortByPath(value[key]); 95 | return acc; 96 | }, {} as Record); 97 | } 98 | 99 | return value; // Primitive values 100 | } 101 | 102 | function compareByPath(a: any, b: any): number { 103 | const pathA = a.path || ''; 104 | const pathB = b.path || ''; 105 | 106 | // Check if either file path contains a hoisted folder 107 | const aHoisted = a.file?.includes('/{'); 108 | const bHoisted = b.file?.includes('/{'); 109 | 110 | // If one is hoisted and the other isn't, hoisted should come first 111 | if (aHoisted && !bHoisted) return -1; 112 | if (!aHoisted && bHoisted) return 1; 113 | 114 | // If both are hoisted or both are not, sort by path 115 | return pathA.localeCompare(pathB); 116 | } 117 | 118 | export function printRoutesAsTable(routes: RouteConfigEntry[]): void { 119 | function extractRoutesForTable(routes: RouteConfigEntry[], parentLayout: string | null = null): { 120 | routePath: string; 121 | routeFile: string; 122 | parentLayout?: string 123 | }[] { 124 | const result: { routePath: string; routeFile: string; parentLayout?: string }[] = []; 125 | 126 | // Sort routes alphabetically based on `path`. Use `file` for sorting if `path` is undefined. 127 | const sortedRoutes = routes.sort((a, b) => { 128 | const pathA = a.path ?? ''; // Default to empty string if `path` is undefined 129 | const pathB = b.path ?? ''; 130 | return pathA.localeCompare(pathB); 131 | }); 132 | 133 | // Separate routes into paths and layouts 134 | const pathsFirst = sortedRoutes.filter(route => route.path); // Routes with a `path` 135 | const layoutsLast = sortedRoutes.filter(route => !route.path && route.children); // Layouts only 136 | 137 | // Add all routes with `path` first 138 | pathsFirst.forEach(route => { 139 | result.push({ 140 | routePath: route.path!, 141 | routeFile: route.file, 142 | parentLayout: parentLayout ?? undefined 143 | }); 144 | }); 145 | 146 | // Add all layouts and recurse into their children 147 | layoutsLast.forEach(layout => { 148 | result.push({ 149 | routePath: "(layout)", 150 | routeFile: layout.file, 151 | parentLayout: parentLayout ?? undefined 152 | }); 153 | 154 | if (layout.children) { 155 | const layoutChildren = extractRoutesForTable(layout.children, layout.file); // Recurse with layout's file as parent 156 | result.push(...layoutChildren); 157 | } 158 | }); 159 | 160 | return result; 161 | } 162 | 163 | console.groupCollapsed("✅ Generated Routes Table (open to see generated routes)"); 164 | console.table(extractRoutesForTable(routes)); 165 | console.groupEnd(); 166 | } 167 | 168 | export function printRoutesAsTree(routes: RouteConfigEntry[], indent = 0): void { 169 | function printRouteTree(routes: RouteConfigEntry[], indent = 0): void { 170 | const indentation = ' '.repeat(indent); // Indentation for the tree 171 | 172 | // Sort routes alphabetically: 173 | const sortedRoutes = routes.sort((a, b) => { 174 | const pathA = a.path ?? ''; // Use empty string if `path` is undefined 175 | const pathB = b.path ?? ''; 176 | return pathA.localeCompare(pathB); // Compare paths alphabetically 177 | }); 178 | 179 | // Separate routes from layouts 180 | const pathsFirst = sortedRoutes.filter(route => route.path); // Routes with "path" 181 | const layoutsLast = sortedRoutes.filter(route => !route.path && route.children); // Layouts only 182 | 183 | // Print the routes 184 | pathsFirst.forEach(route => { 185 | const routePath = `"${route.path}"`; 186 | console.log(`${indentation}├── ${routePath} (${route.file})`); 187 | }); 188 | 189 | // Print the layouts and recursively handle children 190 | layoutsLast.forEach(route => { 191 | console.log(`${indentation}├── (layout) (${route.file})`); 192 | if (route.children) { 193 | printRouteTree(route.children, indent + 1); // Recursive call for children 194 | } 195 | }); 196 | } 197 | 198 | console.groupCollapsed("✅ Generated Route Tree (open to see generated routes)"); 199 | printRouteTree(routes, indent); 200 | console.groupEnd(); 201 | } -------------------------------------------------------------------------------- /src/next-routes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file serves as the main entry point for the library. 3 | * It tries to import from both @react-router/dev/routes and @remix-run/route-config 4 | * and uses whichever one is available. 5 | */ 6 | 7 | // Export the common types 8 | import {resolveImplementation} from "./common/implementationResolver"; 9 | 10 | export type { Options } from './common/next-routes-common'; 11 | export { appRouterStyle, pageRouterStyle } from './common/next-routes-common'; 12 | 13 | // Try to determine which implementation to use 14 | 15 | const implementation = resolveImplementation(); 16 | 17 | export const nextRoutes = implementation.nextRoutes; 18 | export const generateRouteConfig = implementation.generateRouteConfig; 19 | -------------------------------------------------------------------------------- /src/react-router/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | appRouterStyle, 3 | createRouteConfig, 4 | generateNextRoutes, 5 | pageRouterStyle, 6 | type Options 7 | } from "../common/next-routes-common"; 8 | import {type RouteConfigEntry, getAppDirectory, route, layout} from "@react-router/dev/routes"; 9 | 10 | /** 11 | * @deprecated The method should not be used anymore. please use {@link nextRoutes} instead. 12 | */ 13 | export function generateRouteConfig(options: Options = appRouterStyle): RouteConfigEntry[] { 14 | return nextRoutes(options); 15 | } 16 | 17 | /** 18 | * Generates route configuration from a Next.js-style pages directory for React Router 19 | */ 20 | export function nextRoutes(options: Options = appRouterStyle): RouteConfigEntry[] { 21 | return generateNextRoutes( 22 | options, 23 | getAppDirectory, 24 | route, 25 | layout 26 | ); 27 | } 28 | 29 | export { 30 | appRouterStyle, 31 | pageRouterStyle, 32 | type Options, 33 | type RouteConfigEntry 34 | }; -------------------------------------------------------------------------------- /src/remix/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | appRouterStyle, 3 | createRouteConfig, 4 | generateNextRoutes, 5 | pageRouterStyle, 6 | type Options 7 | } from "../common/next-routes-common"; 8 | import {type RouteConfigEntry, getAppDirectory, route, layout } from "@remix-run/route-config"; 9 | /** 10 | * @deprecated The method should not be used anymore. please use {@link nextRoutes} instead. 11 | */ 12 | export function generateRouteConfig(options: Options = appRouterStyle): RouteConfigEntry[] { 13 | return nextRoutes(options); 14 | } 15 | 16 | /** 17 | * Generates route configuration from a Next.js-style pages directory for Remix 18 | */ 19 | export function nextRoutes(options: Options = appRouterStyle): RouteConfigEntry[] { 20 | return generateNextRoutes( 21 | options, 22 | getAppDirectory, 23 | route, 24 | layout 25 | ); 26 | } 27 | 28 | export { 29 | appRouterStyle, 30 | pageRouterStyle, 31 | type Options, 32 | type RouteConfigEntry 33 | }; 34 | -------------------------------------------------------------------------------- /src/tests/react-router.test.ts: -------------------------------------------------------------------------------- 1 | import {assert, beforeEach, describe, expect, test, vi, beforeAll} from 'vitest' 2 | import * as fs from "node:fs"; 3 | import {runSharedTests} from "./sharedTests"; 4 | 5 | vi.mock("node:fs", async () => { 6 | const memfs = await vi.importActual("memfs"); 7 | return memfs.fs; 8 | }); 9 | 10 | vi.mock('node:fs/promises', async () => { 11 | const memfs: { fs: typeof fs } = await vi.importActual('memfs') 12 | return memfs.fs.promises 13 | }) 14 | 15 | vi.mock(import('@react-router/dev/routes'), async (importOriginal) => { 16 | const mod = await importOriginal() // type is inferred 17 | return { 18 | ...mod, 19 | getAppDirectory: vi.fn(() => './app') 20 | } 21 | }) 22 | 23 | vi.mock('../common/implementationResolver', async () => { 24 | const remixImpl = await vi.importActual( 25 | '../react-router' 26 | ); 27 | return { 28 | resolveImplementation: () => remixImpl, 29 | }; 30 | }); 31 | 32 | runSharedTests('react-router') -------------------------------------------------------------------------------- /src/tests/remix.test.ts: -------------------------------------------------------------------------------- 1 | import {assert, beforeEach, describe, expect, test, vi, beforeAll} from 'vitest' 2 | import * as fs from "node:fs"; 3 | import {runSharedTests} from "./sharedTests"; 4 | 5 | vi.mock("node:fs", async () => { 6 | const memfs = await vi.importActual("memfs"); 7 | return memfs.fs; 8 | }); 9 | 10 | vi.mock('node:fs/promises', async () => { 11 | const memfs: { fs: typeof fs } = await vi.importActual('memfs') 12 | return memfs.fs.promises 13 | }) 14 | 15 | vi.mock(import('@remix-run/route-config'), async (importOriginal) => { 16 | const mod = await importOriginal() // type is inferred 17 | return { 18 | ...mod, 19 | getAppDirectory: vi.fn(() => './app') 20 | } 21 | }) 22 | 23 | vi.mock('../common/implementationResolver', async () => { 24 | const remixImpl = await vi.importActual( 25 | '../remix' 26 | ); 27 | return { 28 | resolveImplementation: () => remixImpl, 29 | }; 30 | }); 31 | 32 | runSharedTests('remix') -------------------------------------------------------------------------------- /src/tests/sharedTests.ts: -------------------------------------------------------------------------------- 1 | import {assert, beforeEach, describe, expect, test} from "vitest"; 2 | import {deepSortByPath, parseParameter, transformRoutePath} from "../common/utils"; 3 | import {vol} from "memfs"; 4 | import {appRouterStyle, nextRoutes, pageRouterStyle} from "../next-routes"; 5 | import {layout, prefix, route} from "@react-router/dev/routes"; 6 | 7 | export function runSharedTests(label: string) { 8 | describe(`${label} implementation`, () => { 9 | describe('direct route transform tests', () => { 10 | 11 | test("catchall", () => { 12 | const parsed = parseParameter("[...all]") 13 | expect(parsed).toEqual({paramName: "all", routeName: "*"}) 14 | 15 | const transformed = transformRoutePath("[...all]") 16 | expect(transformed).toBe("*") 17 | }) 18 | 19 | test("dynamic", () => { 20 | const parsed = parseParameter("[all]") 21 | expect(parsed).toEqual({paramName: "all", routeName: ":all"}) 22 | 23 | const transformed = transformRoutePath("[all]") 24 | expect(transformed).toBe(":all") 25 | }) 26 | 27 | test("dynamic-optional", () => { 28 | const parsed = parseParameter("[[all]]") 29 | expect(parsed).toEqual({paramName: "all", routeName: ":all?"}) 30 | 31 | const transformed = transformRoutePath("[[all]]") 32 | expect(transformed).toBe(":all?") 33 | 34 | const transformed2 = transformRoutePath("/test/[[all]]/test") 35 | expect(transformed2).toBe("/test/:all?/test") 36 | }) 37 | 38 | test("excluded-folder", () => { 39 | const transformed = transformRoutePath("/testA/testB/[[all]]/testC") 40 | expect(transformed).toEqual("/testA/testB/:all?/testC") 41 | 42 | const transformed2 = transformRoutePath("/testA/(testB)/[[all]]/testC") 43 | expect(transformed2).toEqual("/testA/:all?/testC") 44 | }) 45 | }) 46 | 47 | describe('compare with actual routes', () => { 48 | beforeEach(() => { 49 | // Reset volume before each test 50 | vol.reset(); 51 | 52 | // Create a mock file structure 53 | vol.fromJSON({ 54 | './app/pages/index.tsx': 'console.log("Hello")', 55 | './app/pages/about.tsx': 'console.log("Hello")', 56 | './app/pages/dashboard/_layout.tsx': 'console.log("Hello")', 57 | './app/pages/dashboard/index.tsx': 'export const helper = () => {}', 58 | './app/pages/dashboard/settings.tsx': 'export const helper = () => {}', 59 | './app/pages/profile/name.tsx': 'export const helper = () => {}', 60 | './app/pages/profile/password.tsx': 'export const helper = () => {}', 61 | './app/pages/profile/email.tsx': 'export const helper = () => {}', 62 | 63 | './app/pages/patha/_layout.tsx': 'console.log("Hello")', 64 | './app/pages/patha/about.tsx': 'export const helper = () => {}', 65 | './app/pages/patha/settings.tsx': 'export const helper = () => {}', 66 | 67 | './app/pages/patha/(excludedPath)/_layout.tsx': 'export const helper = () => {}', 68 | './app/pages/patha/(excludedPath)/routeb.tsx': 'export const helper = () => {}', 69 | }) 70 | }) 71 | 72 | test('creates index routes correctly', () => { 73 | const contents = nextRoutes(pageRouterStyle); 74 | const expected = [ 75 | layout("pages/dashboard/_layout.tsx", [ 76 | route("/dashboard", "pages/dashboard/index.tsx"), 77 | route("/dashboard/settings", "pages/dashboard/settings.tsx"), 78 | ]), 79 | layout("pages/patha/_layout.tsx", [ 80 | route("/patha/about", "pages/patha/about.tsx"), 81 | route("/patha/settings", "pages/patha/settings.tsx"), 82 | 83 | layout("pages/patha/(excludedPath)/_layout.tsx", [ 84 | route("/patha/routeb", "pages/patha/(excludedPath)/routeb.tsx"), 85 | ]) 86 | ]), 87 | route("/", "pages/index.tsx"), 88 | route("/about", "pages/about.tsx"), 89 | ...prefix("/profile", [ 90 | route("name", "pages/profile/name.tsx"), 91 | route("email", "pages/profile/email.tsx"), 92 | route("password", "pages/profile/password.tsx"), 93 | ]), 94 | ] 95 | 96 | assert.sameDeepMembers(contents, deepSortByPath(expected), 'same members') 97 | }) 98 | }) 99 | 100 | describe('route generation same regardless of print', () => { 101 | beforeEach(() => { 102 | // Reset volume before each test 103 | vol.reset(); 104 | 105 | // Setup test file structure 106 | vol.fromJSON({ 107 | './app': null, 108 | './app/pages': null, 109 | './app/pages/[...catchall].tsx': 'export default () => {}', 110 | './app/pages/index.tsx': 'export default () => {}', 111 | './app/pages/remix.tsx': 'export default () => {}', 112 | './app/pages/testpage.tsx': 'export default () => {}', 113 | 114 | // API directory structure 115 | './app/pages/api': null, 116 | './app/pages/api/auth': null, 117 | './app/pages/api/auth/[...all].ts': 'export default () => {}', 118 | './app/pages/api/theme': null, 119 | './app/pages/api/theme/set.ts': 'export default () => {}', 120 | 121 | // Login directory 122 | './app/pages/login': null, 123 | './app/pages/login/index.tsx': 'export default () => {}', 124 | 125 | // Signup directory 126 | './app/pages/signup': null, 127 | './app/pages/signup/index.tsx': 'export default () => {}', 128 | 129 | // Test directory complex structure 130 | './app/pages/testdir': null, 131 | './app/pages/testdir/_layout.tsx': 'export default () => {}', 132 | './app/pages/testdir/testpage.tsx': 'export default () => {}', 133 | './app/pages/testdir/[testing]': null, 134 | './app/pages/testdir/[testing]/_layout.tsx': 'export default () => {}', 135 | './app/pages/testdir/[testing]/index.tsx': 'export default () => {}', 136 | './app/pages/testdir/[testing]/[[nested]]': null, 137 | './app/pages/testdir/[testing]/[[nested]]/index.tsx': 'export default () => {}', 138 | './app/pages/testdir/[testing]/[[nested]]/test': null, 139 | './app/pages/testdir/[testing]/[[nested]]/test/index.tsx': 'export default () => {}', 140 | 141 | // Test excluded dir 142 | './app/pages/testdir/(testexclude)': null, 143 | './app/pages/testdir/(testexclude)/movedUp.tsx': 'export default () => {}', 144 | }) 145 | }) 146 | 147 | test('generates route config correctly', () => { 148 | const contentsNoPrint = nextRoutes({...pageRouterStyle, print: "no"}); 149 | const contentsInfoPrint = nextRoutes({...pageRouterStyle, print: "info"}); 150 | const contentsTreePrint = nextRoutes({...pageRouterStyle, print: "tree"}); 151 | const contentsTablePrint = nextRoutes({...pageRouterStyle, print: "table"}); 152 | 153 | assert.sameDeepMembers(contentsNoPrint, contentsInfoPrint, 'same members') 154 | assert.sameDeepMembers(contentsNoPrint, contentsTreePrint, 'same members') 155 | assert.sameDeepMembers(contentsNoPrint, contentsTablePrint, 'same members') 156 | 157 | const expected = deepSortByPath([ 158 | { 159 | file: 'pages/[...catchall].tsx', 160 | children: undefined, 161 | path: '/*' 162 | }, 163 | { 164 | file: 'pages/api/auth/[...all].ts', 165 | children: undefined, 166 | path: '/api/auth/*' 167 | }, 168 | { 169 | file: 'pages/api/theme/set.ts', 170 | children: undefined, 171 | path: '/api/theme/set' 172 | }, 173 | { 174 | file: 'pages/login/index.tsx', 175 | children: undefined, 176 | path: '/login' 177 | }, 178 | { 179 | file: 'pages/remix.tsx', 180 | children: undefined, 181 | path: '/remix' 182 | }, 183 | { 184 | file: 'pages/signup/index.tsx', 185 | children: undefined, 186 | path: '/signup' 187 | }, 188 | { 189 | file: 'pages/testpage.tsx', 190 | children: undefined, 191 | path: '/testpage' 192 | }, 193 | { 194 | file: 'pages/testdir/_layout.tsx', 195 | children: [ 196 | { 197 | "file": "pages/testdir/[testing]/_layout.tsx", 198 | "children": [ 199 | { 200 | "file": "pages/testdir/[testing]/index.tsx", 201 | "path": "/testdir/:testing", 202 | "children": undefined 203 | }, 204 | { 205 | "file": "pages/testdir/[testing]/[[nested]]/index.tsx", 206 | "path": "/testdir/:testing/:nested?", 207 | "children": undefined 208 | }, 209 | { 210 | "file": "pages/testdir/[testing]/[[nested]]/test/index.tsx", 211 | "path": "/testdir/:testing/:nested?/test", 212 | "children": undefined 213 | } 214 | ] 215 | }, 216 | { 217 | "file": "pages/testdir/(testexclude)/movedUp.tsx", 218 | "path": "/testdir/movedUp", 219 | "children": undefined 220 | }, 221 | { 222 | "file": "pages/testdir/testpage.tsx", 223 | "path": "/testdir/testpage", 224 | "children": undefined 225 | } 226 | ] 227 | }, 228 | { 229 | file: 'pages/index.tsx', 230 | children: undefined, 231 | path: '/' 232 | }, 233 | ]); 234 | 235 | assert.sameDeepMembers(contentsNoPrint, expected, 'same members') 236 | assert.sameDeepMembers(contentsInfoPrint, expected, 'same members') 237 | assert.sameDeepMembers(contentsTreePrint, expected, 'same members') 238 | assert.sameDeepMembers(contentsTablePrint, expected, 'same members') 239 | }) 240 | }) 241 | 242 | describe('route generation tests', () => { 243 | beforeEach(() => { 244 | // Reset volume before each test 245 | vol.reset(); 246 | 247 | // Create a mock file structure 248 | vol.fromJSON({ 249 | './app': null, 250 | './app/pages': null, 251 | './app/pages/index.tsx': 'console.log("Hello")', 252 | './app/pages/utils': null, 253 | './app/pages/utils/index.tsx': 'export const helper = () => {}', 254 | }) 255 | }) 256 | 257 | 258 | test('creates index routes correctly', () => { 259 | const contents = nextRoutes(pageRouterStyle) 260 | const expected = [ 261 | {file: 'pages/index.tsx', children: undefined, path: '/'}, 262 | { 263 | file: 'pages/utils/index.tsx', 264 | children: undefined, 265 | path: '/utils' 266 | }, 267 | ] 268 | 269 | assert.sameDeepMembers(contents, expected, 'same members') 270 | }) 271 | }) 272 | 273 | describe("creates index route from excluded folder", () => { 274 | beforeEach(() => { 275 | // Reset volume before each test 276 | vol.reset(); 277 | 278 | // Create a mock file structure 279 | vol.fromJSON({ 280 | './app': null, 281 | './app/pages': null, 282 | './app/pages/(marketing)/index.tsx': 'console.log("Hello")', 283 | './app/pages/(marketing)/remix.tsx': 'console.log("Hello")', 284 | './app/pages/(marketing)/_layout.tsx': 'console.log("Hello")', 285 | }) 286 | }) 287 | 288 | 289 | test('creates index routes correctly', () => { 290 | const contents = nextRoutes(pageRouterStyle) 291 | const expected = [ 292 | layout("pages/(marketing)/_layout.tsx", [ 293 | route("/", "pages/(marketing)/index.tsx"), 294 | route("/remix", "pages/(marketing)/remix.tsx"), 295 | ]) 296 | ] 297 | 298 | assert.sameDeepMembers(contents, expected, 'same members') 299 | }) 300 | }) 301 | 302 | describe('same level exclusion tests', () => { 303 | beforeEach(() => { 304 | // Reset volume before each test 305 | vol.reset(); 306 | 307 | // Create a mock file structure 308 | vol.fromJSON({ 309 | './app/pages/index.tsx': "{}", 310 | './app/pages/(excluded)/about.tsx': "{}", 311 | './app/pages/(alsoExluded)/legal.tsx': "{}", 312 | }) 313 | }) 314 | 315 | 316 | test('creates index routes correctly', () => { 317 | const contents = nextRoutes(pageRouterStyle) 318 | const expected = [ 319 | { 320 | file: 'pages/index.tsx', 321 | children: undefined, 322 | path: '/' 323 | }, 324 | { 325 | file: 'pages/(excluded)/about.tsx', 326 | children: undefined, 327 | path: '/about' 328 | }, 329 | { 330 | file: 'pages/(alsoExluded)/legal.tsx', 331 | children: undefined, 332 | path: '/legal' 333 | }, 334 | 335 | ] 336 | 337 | assert.sameDeepMembers(contents, expected, 'same members') 338 | }) 339 | }) 340 | 341 | describe('approuter route generation tests', () => { 342 | beforeEach(() => { 343 | // Reset volume before each test 344 | vol.reset(); 345 | 346 | // Create a mock file structure 347 | vol.fromJSON({ 348 | './app/page.tsx': 'console.log("Hello")', 349 | './app/folder/page.tsx': 'export const helper = () => {}', 350 | './app/folder/notARoute.tsx': 'export const helper = () => {}', 351 | './app/folder/test/(excluded)/route.ts': 'export const helper = () => {}', 352 | './app/folder/test/_discarded/route.ts': 'export const helper = () => {}', 353 | './app/folder/test/_discarded/page.tsx': 'export const helper = () => {}', 354 | './app/[...all]/route.tsx': 'console.log("Hello")', 355 | './app/nested/[...all]/route.tsx': 'console.log("Hello")', 356 | }) 357 | }) 358 | 359 | test('creates index routes correctly', () => { 360 | const contents = nextRoutes(appRouterStyle) 361 | const expected = [ 362 | {file: 'page.tsx', children: undefined, path: '/'}, 363 | {file: 'folder/page.tsx', children: undefined, path: '/folder'}, 364 | {file: 'folder/test/(excluded)/route.ts', children: undefined, path: '/folder/test'}, 365 | {file: '[...all]/route.tsx', children: undefined, path: "/*"}, 366 | {file: 'nested/[...all]/route.tsx', children: undefined, path: "/nested/*"} 367 | ] 368 | 369 | assert.sameDeepMembers(contents, expected, 'same members') 370 | }) 371 | }) 372 | 373 | describe('pagerouter route generation tests', () => { 374 | beforeEach(() => { 375 | // Reset volume before each test 376 | vol.reset(); 377 | 378 | // Create a mock file structure 379 | vol.fromJSON({ 380 | './app/pages/index.tsx': 'console.log("Hello")', 381 | './app/pages/folder/index.tsx': 'export const helper = () => {}', 382 | './app/pages/folder/stillARoute.tsx': 'export const helper = () => {}', 383 | './app/pages/folder/test/(excluded)/route.ts': 'export const helper = () => {}', 384 | './app/pages/folder/test/(excluded)/index.tsx': 'export const helper = () => {}', 385 | './app/pages/folder/test/_discarded/route.ts': 'export const helper = () => {}', 386 | './app/pages/folder/test/_discarded/page.tsx': 'export const helper = () => {}', 387 | }) 388 | }) 389 | 390 | test('creates index routes correctly', () => { 391 | const contents = nextRoutes(pageRouterStyle) 392 | const expected = [ 393 | {file: 'pages/index.tsx', children: undefined, path: '/'}, 394 | {file: 'pages/folder/index.tsx', children: undefined, path: '/folder'}, 395 | {file: 'pages/folder/stillARoute.tsx', children: undefined, path: '/folder/stillARoute'}, 396 | {file: 'pages/folder/test/(excluded)/index.tsx', children: undefined, path: '/folder/test'}, 397 | {file: 'pages/folder/test/(excluded)/route.ts', children: undefined, path: '/folder/test/route'}, 398 | ] 399 | 400 | assert.sameDeepMembers(contents, expected, 'same members') 401 | }) 402 | }) 403 | 404 | describe('complex Route generation tests', () => { 405 | beforeEach(() => { 406 | // Reset volume before each test 407 | vol.reset(); 408 | 409 | // Setup test file structure 410 | vol.fromJSON({ 411 | './app': null, 412 | './app/pages': null, 413 | './app/pages/[...catchall].tsx': 'export default () => {}', 414 | './app/pages/index.tsx': 'export default () => {}', 415 | './app/pages/remix.tsx': 'export default () => {}', 416 | './app/pages/testpage.tsx': 'export default () => {}', 417 | 418 | // API directory structure 419 | './app/pages/api': null, 420 | './app/pages/api/auth': null, 421 | './app/pages/api/auth/[...all].ts': 'export default () => {}', 422 | './app/pages/api/theme': null, 423 | './app/pages/api/theme/set.ts': 'export default () => {}', 424 | 425 | // Login directory 426 | './app/pages/login': null, 427 | './app/pages/login/index.tsx': 'export default () => {}', 428 | 429 | // Signup directory 430 | './app/pages/signup': null, 431 | './app/pages/signup/index.tsx': 'export default () => {}', 432 | 433 | // Test directory complex structure 434 | './app/pages/testdir': null, 435 | './app/pages/testdir/_layout.tsx': 'export default () => {}', 436 | './app/pages/testdir/testpage.tsx': 'export default () => {}', 437 | './app/pages/testdir/[testing]': null, 438 | './app/pages/testdir/[testing]/_layout.tsx': 'export default () => {}', 439 | './app/pages/testdir/[testing]/index.tsx': 'export default () => {}', 440 | './app/pages/testdir/[testing]/[[nested]]': null, 441 | './app/pages/testdir/[testing]/[[nested]]/index.tsx': 'export default () => {}', 442 | './app/pages/testdir/[testing]/[[nested]]/test': null, 443 | './app/pages/testdir/[testing]/[[nested]]/test/index.tsx': 'export default () => {}', 444 | 445 | // Test excluded dir 446 | './app/pages/testdir/(testexclude)': null, 447 | './app/pages/testdir/(testexclude)/movedUp.tsx': 'export default () => {}', 448 | }) 449 | }) 450 | 451 | test('generates route config correctly', () => { 452 | const contents = nextRoutes({...pageRouterStyle, print: "info"}); 453 | const contents2 = nextRoutes({...pageRouterStyle, print: "tree"}); 454 | 455 | assert.sameDeepMembers(contents, contents2, 'same members') 456 | 457 | const expected = [ 458 | { 459 | file: 'pages/[...catchall].tsx', 460 | children: undefined, 461 | path: '/*' 462 | }, 463 | { 464 | file: 'pages/api/auth/[...all].ts', 465 | children: undefined, 466 | path: '/api/auth/*' 467 | }, 468 | { 469 | file: 'pages/api/theme/set.ts', 470 | children: undefined, 471 | path: '/api/theme/set' 472 | }, 473 | { 474 | file: 'pages/login/index.tsx', 475 | children: undefined, 476 | path: '/login' 477 | }, 478 | { 479 | file: 'pages/remix.tsx', 480 | children: undefined, 481 | path: '/remix' 482 | }, 483 | { 484 | file: 'pages/signup/index.tsx', 485 | children: undefined, 486 | path: '/signup' 487 | }, 488 | { 489 | file: 'pages/testpage.tsx', 490 | children: undefined, 491 | path: '/testpage' 492 | }, 493 | { 494 | file: 'pages/testdir/_layout.tsx', 495 | children: [ 496 | { 497 | "file": "pages/testdir/[testing]/_layout.tsx", 498 | "children": [ 499 | { 500 | "file": "pages/testdir/[testing]/index.tsx", 501 | "path": "/testdir/:testing", 502 | "children": undefined 503 | }, 504 | { 505 | "file": "pages/testdir/[testing]/[[nested]]/index.tsx", 506 | "path": "/testdir/:testing/:nested?", 507 | "children": undefined 508 | }, 509 | { 510 | "file": "pages/testdir/[testing]/[[nested]]/test/index.tsx", 511 | "path": "/testdir/:testing/:nested?/test", 512 | "children": undefined 513 | } 514 | ] 515 | }, 516 | { 517 | "file": "pages/testdir/(testexclude)/movedUp.tsx", 518 | "path": "/testdir/movedUp", 519 | "children": undefined 520 | }, 521 | { 522 | "file": "pages/testdir/testpage.tsx", 523 | "path": "/testdir/testpage", 524 | "children": undefined 525 | } 526 | ] 527 | }, 528 | { 529 | file: 'pages/index.tsx', 530 | children: undefined, 531 | path: '/' 532 | }, 533 | ].sort((a, b) => { 534 | const pathA = a.path ?? ''; // Default to empty string if `path` is undefined 535 | const pathB = b.path ?? ''; 536 | return pathA.localeCompare(pathB); 537 | }); 538 | 539 | assert.sameDeepMembers(contents, expected, 'same members') 540 | }) 541 | }) 542 | 543 | describe('different baseDir route generation tests', () => { 544 | // let vol = new Volume(); 545 | 546 | beforeEach(() => { 547 | // Reset volume before each test 548 | vol.reset(); 549 | 550 | // Create a mock file structure 551 | vol.fromJSON({ 552 | './app': null, 553 | './app/diff': null, 554 | './app/diff/index.tsx': 'console.log("Hello")', 555 | './app/diff/utils': null, 556 | './app/diff/utils/index.tsx': 'export const helper = () => {}', 557 | }) 558 | }) 559 | 560 | 561 | test('creates index routes correctly', () => { 562 | const contents = nextRoutes({...pageRouterStyle, folderName: 'diff'}) 563 | const expected = [ 564 | { 565 | file: 'diff/index.tsx', 566 | children: undefined, 567 | path: '/' 568 | }, 569 | { 570 | file: 'diff/utils/index.tsx', 571 | children: undefined, 572 | path: '/utils' 573 | } 574 | ]; 575 | 576 | assert.sameDeepMembers(contents, expected, 'same members') 577 | }) 578 | }) 579 | 580 | describe('AUTOGENERATED BY RIDER nextRoutes', () => { 581 | beforeEach(() => { 582 | vol.reset(); 583 | }); 584 | 585 | test('generates simple index route', () => { 586 | vol.fromJSON({ 587 | './app/pages/index.tsx': 'export default () => {}', 588 | }); 589 | 590 | const routes = nextRoutes(pageRouterStyle); 591 | const expected = [ 592 | {file: 'pages/index.tsx', children: undefined, path: '/'}, 593 | ]; 594 | 595 | assert.sameDeepMembers(routes, expected, 'same members') 596 | }); 597 | 598 | test('generates simple index route from different folder', () => { 599 | vol.fromJSON({ 600 | './app/pages/index.tsx': 'export default () => {}', 601 | './app/different/index.tsx': 'export default () => {}', 602 | }); 603 | 604 | const routes = nextRoutes({...pageRouterStyle, folderName: 'different'}); 605 | const expected = [ 606 | {file: 'different/index.tsx', children: undefined, path: '/'}, 607 | ]; 608 | 609 | assert.sameDeepMembers(routes, expected, 'same members') 610 | }); 611 | 612 | test('handles nested routes with layout files', () => { 613 | vol.fromJSON({ 614 | './app/pages/_layout.tsx': 'export default () => {}', 615 | './app/pages/nested/index.tsx': 'export default () => {}', 616 | }); 617 | 618 | const routes = nextRoutes(pageRouterStyle); 619 | const expected = [ 620 | { 621 | file: 'pages/_layout.tsx', 622 | children: [ 623 | { 624 | file: 'pages/nested/index.tsx', 625 | path: '/nested', 626 | children: undefined 627 | }, 628 | ], 629 | }, 630 | ]; 631 | assert.sameDeepMembers(routes, expected, 'same members') 632 | }); 633 | 634 | test('excludes files starting with underscores except _layout', () => { 635 | vol.fromJSON({ 636 | './app/pages/_hidden.tsx': 'export default () => {}', 637 | './app/pages/_layout.tsx': 'export default () => {}', 638 | './app/pages/index.tsx': 'export default () => {}', 639 | }); 640 | 641 | const routes = nextRoutes(pageRouterStyle); 642 | const expected = [ 643 | { 644 | file: 'pages/_layout.tsx', 645 | children: [ 646 | { 647 | file: 'pages/index.tsx', 648 | path: '/', 649 | children: undefined 650 | } 651 | ], 652 | }, 653 | ]; 654 | 655 | assert.sameDeepMembers(routes, expected, 'same members') 656 | }); 657 | 658 | test('parses dynamic routes correctly', () => { 659 | vol.fromJSON({ 660 | './app/pages/[dynamic].tsx': 'export default () => {}', 661 | }); 662 | 663 | const routes = nextRoutes(pageRouterStyle); 664 | const expected = [ 665 | {file: 'pages/[dynamic].tsx', children: undefined, path: '/:dynamic'}, 666 | ]; 667 | 668 | assert.sameDeepMembers(routes, expected, 'same members') 669 | }); 670 | 671 | test('parses dynamic optional routes correctly', () => { 672 | vol.fromJSON({ 673 | './app/pages/[[optional]].tsx': 'export default () => {}', 674 | }); 675 | 676 | const routes = nextRoutes(pageRouterStyle); 677 | const expected = [ 678 | {file: 'pages/[[optional]].tsx', children: undefined, path: '/:optional?'}, 679 | ]; 680 | 681 | assert.sameDeepMembers(routes, expected, 'same members') 682 | }); 683 | 684 | test('handles catch-all routes', () => { 685 | vol.fromJSON({ 686 | './app/pages/[...all].tsx': 'export default () => {}', 687 | }); 688 | 689 | const routes = nextRoutes(pageRouterStyle); 690 | const expected = [ 691 | {file: 'pages/[...all].tsx', children: undefined, path: '/*'}, 692 | ]; 693 | 694 | assert.sameDeepMembers(routes, expected, 'same members') 695 | }); 696 | 697 | test('ignores excluded folders or files', () => { 698 | vol.fromJSON({ 699 | './app/pages/(excluded)/file.tsx': 'export default () => {}', 700 | './app/pages/index.tsx': 'export default () => {}', 701 | }); 702 | 703 | const routes = nextRoutes(pageRouterStyle); 704 | const expected = [ 705 | {file: 'pages/index.tsx', children: undefined, path: '/'}, 706 | {file: 'pages/(excluded)/file.tsx', children: undefined, path: '/file'}, 707 | ]; 708 | assert.sameDeepMembers(routes, expected, 'same members') 709 | }); 710 | }); 711 | 712 | 713 | 714 | 715 | describe('concert routes structure', () => { 716 | beforeEach(() => { 717 | // Reset volume before each test 718 | vol.reset(); 719 | 720 | // Create a mock file structure 721 | vol.fromJSON({ 722 | './app/routes/concerts/(groupA)/layout.tsx': 'console.log("Hello")', 723 | './app/routes/concerts/(groupA)/trending.tsx': 'export const helper = () => {}', 724 | './app/routes/concerts/(groupA)/index.tsx': 'export const helper = () => {}', 725 | './app/routes/concerts/(groupB)/mine/layout.tsx': 'console.log("Hello")', 726 | './app/routes/concerts/(groupB)/mine/index.tsx': 'export const helper = () => {}', 727 | './app/root.tsx': 'console.log("Hello")' 728 | }) 729 | }) 730 | 731 | test('creates concert routes correctly', () => { 732 | const contents = nextRoutes({ 733 | ...pageRouterStyle, 734 | folderName: "routes", 735 | layoutFileName: "layout", 736 | print: "tree", 737 | }); 738 | const expected = [ 739 | layout("routes/concerts/(groupA)/layout.tsx", [ 740 | route("/concerts", "routes/concerts/(groupA)/index.tsx"), 741 | route("/concerts/trending", "routes/concerts/(groupA)/trending.tsx"), 742 | ]), 743 | layout("routes/concerts/(groupB)/mine/layout.tsx", [ 744 | route("/concerts/mine", "routes/concerts/(groupB)/mine/index.tsx"), 745 | ]) 746 | ] 747 | 748 | expect(deepSortByPath(contents)).toEqual(deepSortByPath(expected)) 749 | }) 750 | }) 751 | 752 | describe('concert routes structure without grouping', () => { 753 | beforeEach(() => { 754 | // Reset volume before each test 755 | vol.reset(); 756 | 757 | // Create a mock file structure 758 | vol.fromJSON({ 759 | './app/routes/concerts/layout.tsx': 'console.log("Hello")', 760 | './app/routes/concerts/trending.tsx': 'export const helper = () => {}', 761 | './app/routes/concerts/index.tsx': 'export const helper = () => {}', 762 | './app/routes/concerts/{mine}/layout.tsx': 'console.log("Hello")', 763 | './app/routes/concerts/{mine}/index.tsx': 'export const helper = () => {}', 764 | './app/root.tsx': 'console.log("Hello")' 765 | }) 766 | }) 767 | 768 | test('creates concert routes correctly', () => { 769 | const contents = nextRoutes({ 770 | ...pageRouterStyle, 771 | folderName: "routes", 772 | layoutFileName: "layout", 773 | print: "tree", 774 | enableHoistedFolders: true, 775 | }); 776 | const expected = [ 777 | layout("routes/concerts/layout.tsx", [ 778 | route("/concerts", "routes/concerts/index.tsx"), 779 | route("/concerts/trending", "routes/concerts/trending.tsx"), 780 | ]), 781 | layout("routes/concerts/{mine}/layout.tsx", [ 782 | route("/concerts/mine", "routes/concerts/{mine}/index.tsx"), 783 | ]) 784 | ] 785 | 786 | expect(deepSortByPath(contents)).toEqual(deepSortByPath(expected)) 787 | }) 788 | }) 789 | 790 | describe('comprehensive route combinations', () => { 791 | beforeEach(() => { 792 | // Reset volume before each test 793 | vol.reset(); 794 | 795 | // Create a mock file structure with all route conventions 796 | vol.fromJSON({ 797 | // Catch-all routes within dynamic routes 798 | './app/pages/[category]/[...products].tsx': 'export default () => {}', 799 | 800 | // Multiple dynamic parameters in the same path segment 801 | './app/pages/[category]/[productId].tsx': 'export default () => {}', 802 | 803 | // Optional parameters followed by regular parameters 804 | './app/pages/[[optional]]/required.tsx': 'export default () => {}', 805 | 806 | // Hoisted folders with dynamic parameters 807 | './app/pages/{section}/[id].tsx': 'export default () => {}', 808 | 809 | // Comprehensive test with all conventions 810 | './app/pages/{section}/_layout.tsx': 'export default () => {}', 811 | './app/pages/{section}/index.tsx': 'export default () => {}', 812 | './app/pages/{section}/[category]/_layout.tsx': 'export default () => {}', 813 | './app/pages/{section}/[category]/index.tsx': 'export default () => {}', 814 | './app/pages/{section}/[category]/[[optional]]/index.tsx': 'export default () => {}', 815 | './app/pages/{section}/[category]/[[optional]]/[...rest].tsx': 'export default () => {}', 816 | './app/pages/{section}/(group)/nested.tsx': 'export default () => {}', 817 | }) 818 | }) 819 | 820 | test('handles catch-all routes within dynamic routes', () => { 821 | const routes = nextRoutes(pageRouterStyle); 822 | const categoryProductsRoute = routes.find(r => r.path === '/:category/*'); 823 | 824 | expect(categoryProductsRoute).toBeDefined(); 825 | expect(categoryProductsRoute?.file).toBe('pages/[category]/[...products].tsx'); 826 | }); 827 | 828 | test('handles multiple dynamic parameters in the same path segment', () => { 829 | const routes = nextRoutes(pageRouterStyle); 830 | const categoryProductRoute = routes.find(r => r.path === '/:category/:productId'); 831 | 832 | expect(categoryProductRoute).toBeDefined(); 833 | expect(categoryProductRoute?.file).toBe('pages/[category]/[productId].tsx'); 834 | }); 835 | 836 | test('handles optional parameters followed by regular parameters', () => { 837 | const routes = nextRoutes(pageRouterStyle); 838 | const optionalRequiredRoute = routes.find(r => r.path === '/:optional?/required'); 839 | 840 | expect(optionalRequiredRoute).toBeDefined(); 841 | expect(optionalRequiredRoute?.file).toBe('pages/[[optional]]/required.tsx'); 842 | }); 843 | 844 | test('handles hoisted folders with dynamic parameters', () => { 845 | const routes = nextRoutes({...pageRouterStyle, enableHoistedFolders: true}); 846 | 847 | // Find the layout for the hoisted section 848 | const sectionLayout = routes.find(r => r.file === 'pages/{section}/_layout.tsx'); 849 | expect(sectionLayout).toBeDefined(); 850 | expect(sectionLayout?.children).toBeDefined(); 851 | 852 | if (sectionLayout?.children) { 853 | // Find the dynamic route within the layout's children 854 | const hoistedDynamicRoute = sectionLayout.children.find(r => r.file === 'pages/{section}/[id].tsx'); 855 | 856 | expect(hoistedDynamicRoute).toBeDefined(); 857 | if (hoistedDynamicRoute) { 858 | // Check the path 859 | expect(hoistedDynamicRoute.path).toBeTruthy(); 860 | expect(hoistedDynamicRoute.path).toContain(':id'); 861 | expect(hoistedDynamicRoute.file).toBe('pages/{section}/[id].tsx'); 862 | } 863 | } 864 | }); 865 | 866 | test('handles comprehensive combination of all route conventions', () => { 867 | const routes = nextRoutes({...pageRouterStyle, enableHoistedFolders: true}); 868 | 869 | // Find the layout for the hoisted section 870 | const sectionLayout = routes.find(r => r.file === 'pages/{section}/_layout.tsx'); 871 | expect(sectionLayout).toBeDefined(); 872 | expect(sectionLayout?.children).toBeDefined(); 873 | 874 | if (sectionLayout?.children) { 875 | // Check for index route 876 | const indexRoute = sectionLayout.children.find(r => r.path === '/section'); 877 | expect(indexRoute).toBeDefined(); 878 | expect(indexRoute?.file).toBe('pages/{section}/index.tsx'); 879 | 880 | // Check for category layout 881 | const categoryLayout = sectionLayout.children.find(r => r.file === 'pages/{section}/[category]/_layout.tsx'); 882 | expect(categoryLayout).toBeDefined(); 883 | expect(categoryLayout?.children).toBeDefined(); 884 | 885 | if (categoryLayout?.children) { 886 | // Check for category index route 887 | const categoryIndex = categoryLayout.children.find(r => r.path === '/section/:category'); 888 | expect(categoryIndex).toBeDefined(); 889 | expect(categoryIndex?.file).toBe('pages/{section}/[category]/index.tsx'); 890 | 891 | // Check for optional parameter route 892 | const optionalRoute = categoryLayout.children.find(r => r.path === '/section/:category/:optional?'); 893 | expect(optionalRoute).toBeDefined(); 894 | expect(optionalRoute?.file).toBe('pages/{section}/[category]/[[optional]]/index.tsx'); 895 | 896 | // Check for catch-all route within optional parameter 897 | const catchAllRoute = categoryLayout.children.find(r => r.path === '/section/:category/:optional?/*'); 898 | expect(catchAllRoute).toBeDefined(); 899 | expect(catchAllRoute?.file).toBe('pages/{section}/[category]/[[optional]]/[...rest].tsx'); 900 | } 901 | 902 | // Check for grouped route 903 | const groupedRoute = sectionLayout.children.find(r => r.path === '/section/nested'); 904 | expect(groupedRoute).toBeDefined(); 905 | expect(groupedRoute?.file).toBe('pages/{section}/(group)/nested.tsx'); 906 | } 907 | }); 908 | 909 | test('compares nextRoutes output with actual react-router-v7 routes', () => { 910 | // Generate routes using nextRoutes 911 | const generatedRoutes = nextRoutes({...pageRouterStyle, enableHoistedFolders: true}); 912 | 913 | // Construct actual react-router-v7 routes 914 | const actualRoutes = [ 915 | // Catch-all routes within dynamic routes 916 | route('/:category/*', 'pages/[category]/[...products].tsx'), 917 | 918 | // Multiple dynamic parameters in the same path segment 919 | route('/:category/:productId', 'pages/[category]/[productId].tsx'), 920 | 921 | // Optional parameters followed by regular parameters 922 | route('/:optional?/required', 'pages/[[optional]]/required.tsx'), 923 | 924 | // Comprehensive test with all conventions 925 | layout('pages/{section}/_layout.tsx', [ 926 | route('/section', 'pages/{section}/index.tsx'), 927 | route('/section/:id', 'pages/{section}/[id].tsx'), 928 | layout('pages/{section}/[category]/_layout.tsx', [ 929 | route('/section/:category', 'pages/{section}/[category]/index.tsx'), 930 | route('/section/:category/:optional?', 'pages/{section}/[category]/[[optional]]/index.tsx'), 931 | route('/section/:category/:optional?/*', 'pages/{section}/[category]/[[optional]]/[...rest].tsx'), 932 | ]), 933 | route('/section/nested', 'pages/{section}/(group)/nested.tsx'), 934 | ]), 935 | ]; 936 | 937 | // Compare the routes 938 | // We need to sort both arrays to ensure consistent comparison 939 | const sortedGeneratedRoutes = deepSortByPath([...generatedRoutes]); 940 | const sortedActualRoutes = deepSortByPath([...actualRoutes]); 941 | 942 | // Check if all actual routes are present in generated routes 943 | // This test might fail if the implementation changes, which is okay according to the issue description 944 | expect(sortedGeneratedRoutes).toEqual(sortedActualRoutes); 945 | }); 946 | }) 947 | 948 | describe('hoisted folders with enableHoistedFolders: false for pageRouterStyle', () => { 949 | beforeEach(() => { 950 | // Reset volume before each test 951 | vol.reset(); 952 | 953 | // Create a mock file structure with hoisted folders 954 | vol.fromJSON({ 955 | './app/pages/{section}/index.tsx': 'export default () => {}', 956 | './app/pages/{section}/about.tsx': 'export default () => {}', 957 | './app/pages/{section}/_layout.tsx': 'export default () => {}', 958 | './app/pages/{section}/[id].tsx': 'export default () => {}', 959 | }) 960 | }) 961 | 962 | test('does not hoist routes when enableHoistedFolders is false', () => { 963 | const routes = nextRoutes({...pageRouterStyle}); 964 | 965 | // With enableHoistedFolders: false, the {section} folder should not be hoisted 966 | // So we should have a layout with children, but the paths should include '{section}' 967 | const sectionLayout = routes.find(r => r.file === 'pages/{section}/_layout.tsx'); 968 | expect(sectionLayout).toBeDefined(); 969 | expect(sectionLayout?.children).toBeDefined(); 970 | 971 | if (sectionLayout?.children) { 972 | // Check for index route - curly braces are always removed from paths 973 | const indexRoute = sectionLayout.children.find(r => r.path === '/section'); 974 | expect(indexRoute).toBeDefined(); 975 | expect(indexRoute?.file).toBe('pages/{section}/index.tsx'); 976 | 977 | // Check for about route 978 | const aboutRoute = sectionLayout.children.find(r => r.path === '/section/about'); 979 | expect(aboutRoute).toBeDefined(); 980 | expect(aboutRoute?.file).toBe('pages/{section}/about.tsx'); 981 | 982 | // Check for dynamic route 983 | const dynamicRoute = sectionLayout.children.find(r => r.path === '/section/:id'); 984 | expect(dynamicRoute).toBeDefined(); 985 | expect(dynamicRoute?.file).toBe('pages/{section}/[id].tsx'); 986 | } 987 | }); 988 | }) 989 | 990 | describe('hoisted folders with enableHoistedFolders: false for appRouterStyle', () => { 991 | beforeEach(() => { 992 | // Reset volume before each test 993 | vol.reset(); 994 | 995 | // Create a mock file structure with hoisted folders 996 | vol.fromJSON({ 997 | './app/{section}/page.tsx': 'export default () => {}', 998 | './app/{section}/about/page.tsx': 'export default () => {}', 999 | './app/{section}/layout.tsx': 'export default () => {}', 1000 | './app/{section}/[id]/page.tsx': 'export default () => {}', 1001 | }) 1002 | }) 1003 | 1004 | test('does not hoist routes when enableHoistedFolders is false', () => { 1005 | const routes = nextRoutes({...appRouterStyle}); 1006 | 1007 | // With enableHoistedFolders: false, the {section} folder should not be hoisted 1008 | // So we should have a layout with children, but the paths should include '{section}' 1009 | const sectionLayout = routes.find(r => r.file === '{section}/layout.tsx'); 1010 | expect(sectionLayout).toBeDefined(); 1011 | expect(sectionLayout?.children).toBeDefined(); 1012 | 1013 | if (sectionLayout?.children) { 1014 | // Check for page route - curly braces are always removed from paths 1015 | const pageRoute = sectionLayout.children.find(r => r.path === '/section'); 1016 | expect(pageRoute).toBeDefined(); 1017 | expect(pageRoute?.file).toBe('{section}/page.tsx'); 1018 | 1019 | // Check for about route 1020 | const aboutRoute = sectionLayout.children.find(r => r.path === '/section/about'); 1021 | expect(aboutRoute).toBeDefined(); 1022 | expect(aboutRoute?.file).toBe('{section}/about/page.tsx'); 1023 | 1024 | // Check for dynamic route 1025 | const dynamicRoute = sectionLayout.children.find(r => r.path === '/section/:id'); 1026 | expect(dynamicRoute).toBeDefined(); 1027 | expect(dynamicRoute?.file).toBe('{section}/[id]/page.tsx'); 1028 | } 1029 | }); 1030 | }) 1031 | }); 1032 | } -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type {RouteConfigEntry} from "@react-router/dev/routes"; 2 | 3 | export function transformRoutePath(path: string): string { 4 | let transformedPath = path 5 | .replace(/\[\[\s*([^\]]+)\s*]]/g, ':$1?') // Handle optional parameters [[param]] 6 | .replace(/\[\.\.\.\s*([^\]]+)\s*]/g, '*') // Handle catch-all parameters [...param] 7 | .replace(/\[([^\]]+)]/g, ':$1') // Handle regular parameters [param] 8 | .replace(/\/\([^)]*\)\//g, '/') // Handle regular parameters [param] 9 | .replace(/\{([^}]+)\}/g, '$1') // Strip curly braces {param} 10 | .replace(/\/\([^)]*\)/g, ''); // Remove parentheses and contents only if surrounded by slashes 11 | 12 | if(transformedPath === "") { 13 | transformedPath = "/" + transformedPath; 14 | } 15 | 16 | return transformedPath; 17 | } 18 | 19 | export function isHoistedFolder(name: string): boolean { 20 | return /^{[^{}]+}$/.test(name); 21 | } 22 | 23 | 24 | function parseDynamicRoute(name: string): { paramName?: string; routeName: string } { 25 | const paramMatch = name.match(/\[(.+?)]/); 26 | if (!paramMatch) return {routeName: name}; 27 | 28 | const paramName = paramMatch[1]; 29 | return { 30 | paramName, 31 | routeName: `:${paramName}` 32 | }; 33 | } 34 | 35 | function parseCatchAllParam(name: string): { paramName?: string; routeName: string } { 36 | const paramMatch = name.match(/\[\.\.\.(.+?)]/); 37 | if (!paramMatch) return {routeName: name}; 38 | 39 | const paramName = paramMatch[1]; 40 | return { 41 | paramName, 42 | routeName: "*" // Placeholder to indicate "catch-all" route for now 43 | }; 44 | } 45 | 46 | function parseOptionalDynamicRoute(name: string): { paramName?: string; routeName: string } { 47 | const paramMatch = name.match(/\[\[(.+?)]]/); 48 | if (!paramMatch) return {routeName: name}; 49 | 50 | const paramName = paramMatch[1]; 51 | return { 52 | paramName, 53 | routeName: `:${paramName}?` 54 | }; 55 | } 56 | 57 | export function parseParameter(name: string): { paramName?: string; routeName: string } { 58 | if (name.startsWith('[[') && name.endsWith(']]')) { 59 | // Optional parameter format: [[param]] 60 | return parseOptionalDynamicRoute(name); 61 | } else if (name.startsWith('[...') && name.endsWith(']')) { 62 | // Catch-all parameter format: [...param] 63 | return parseCatchAllParam(name); 64 | } else if (name.startsWith('[') && name.endsWith(']')) { 65 | // Regular parameter format: [param] 66 | return parseDynamicRoute(name); 67 | } else { 68 | // Not a dynamic parameter 69 | return {routeName: name}; 70 | } 71 | } 72 | 73 | export function deepSortByPath(value: any): any { 74 | if (Array.isArray(value)) { 75 | // Recursively sort arrays based on 'path' 76 | return value 77 | .map(deepSortByPath) // Sort children first 78 | .sort((a: any, b: any) => compareByPath(a, b)); 79 | } 80 | 81 | if (typeof value === 'object' && value !== null) { 82 | if ('path' in value) { 83 | // If the object has a 'path' property, sort its children if any 84 | return { 85 | ...value, 86 | children: value.children ? deepSortByPath(value.children) : undefined, 87 | }; 88 | } 89 | 90 | // Sort object keys for non-'path' objects 91 | return Object.keys(value) 92 | .sort() 93 | .reduce((acc, key) => { 94 | acc[key] = deepSortByPath(value[key]); 95 | return acc; 96 | }, {} as Record); 97 | } 98 | 99 | return value; // Primitive values 100 | } 101 | 102 | function compareByPath(a: any, b: any): number { 103 | const pathA = a.path || ''; 104 | const pathB = b.path || ''; 105 | 106 | // Check if either file path contains a hoisted folder 107 | const aHoisted = a.file?.includes('/{'); 108 | const bHoisted = b.file?.includes('/{'); 109 | 110 | // If one is hoisted and the other isn't, hoisted should come first 111 | if (aHoisted && !bHoisted) return -1; 112 | if (!aHoisted && bHoisted) return 1; 113 | 114 | // If both are hoisted or both are not, sort by path 115 | return pathA.localeCompare(pathB); 116 | } 117 | 118 | 119 | 120 | export function printRoutesAsTable(routes: RouteConfigEntry[]): void { 121 | function extractRoutesForTable(routes: RouteConfigEntry[], parentLayout: string | null = null): { 122 | routePath: string; 123 | routeFile: string; 124 | parentLayout?: string 125 | }[] { 126 | const result: { routePath: string; routeFile: string; parentLayout?: string }[] = []; 127 | 128 | // Sort routes alphabetically based on `path`. Use `file` for sorting if `path` is undefined. 129 | const sortedRoutes = routes.sort((a, b) => { 130 | const pathA = a.path ?? ''; // Default to empty string if `path` is undefined 131 | const pathB = b.path ?? ''; 132 | return pathA.localeCompare(pathB); 133 | }); 134 | 135 | // Separate routes into paths and layouts 136 | const pathsFirst = sortedRoutes.filter(route => route.path); // Routes with a `path` 137 | const layoutsLast = sortedRoutes.filter(route => !route.path && route.children); // Layouts only 138 | 139 | // Add all routes with `path` first 140 | pathsFirst.forEach(route => { 141 | result.push({ 142 | routePath: route.path!, 143 | routeFile: route.file, 144 | parentLayout: parentLayout ?? undefined 145 | }); 146 | }); 147 | 148 | // Add all layouts and recurse into their children 149 | layoutsLast.forEach(layout => { 150 | result.push({ 151 | routePath: "(layout)", 152 | routeFile: layout.file, 153 | parentLayout: parentLayout ?? undefined 154 | }); 155 | 156 | if (layout.children) { 157 | const layoutChildren = extractRoutesForTable(layout.children, layout.file); // Recurse with layout's file as parent 158 | result.push(...layoutChildren); 159 | } 160 | }); 161 | 162 | return result; 163 | } 164 | 165 | console.groupCollapsed("✅ Generated Routes Table (open to see generated routes)"); 166 | console.table(extractRoutesForTable(routes)); 167 | console.groupEnd(); 168 | } 169 | 170 | export function printRoutesAsTree(routes: RouteConfigEntry[], indent = 0): void { 171 | function printRouteTree(routes: RouteConfigEntry[], indent = 0): void { 172 | const indentation = ' '.repeat(indent); // Indentation for the tree 173 | 174 | // Sort routes alphabetically: 175 | const sortedRoutes = routes.sort((a, b) => { 176 | const pathA = a.path ?? ''; // Use empty string if `path` is undefined 177 | const pathB = b.path ?? ''; 178 | return pathA.localeCompare(pathB); // Compare paths alphabetically 179 | }); 180 | 181 | // Separate routes from layouts 182 | const pathsFirst = sortedRoutes.filter(route => route.path); // Routes with "path" 183 | const layoutsLast = sortedRoutes.filter(route => !route.path && route.children); // Layouts only 184 | 185 | // Print the routes 186 | pathsFirst.forEach(route => { 187 | const routePath = `"${route.path}"`; 188 | console.log(`${indentation}├── ${routePath} (${route.file})`); 189 | }); 190 | 191 | // Print the layouts and recursively handle children 192 | layoutsLast.forEach(route => { 193 | console.log(`${indentation}├── (layout) (${route.file})`); 194 | if (route.children) { 195 | printRouteTree(route.children, indent + 1); // Recursive call for children 196 | } 197 | }); 198 | } 199 | 200 | console.groupCollapsed("✅ Generated Route Tree (open to see generated routes)"); 201 | printRouteTree(routes, indent); 202 | console.groupEnd(); 203 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src", "./types"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 5 | "types": ["vite/client", "node"], 6 | "isolatedModules": true, 7 | "esModuleInterop": true, 8 | "jsx": "react-jsx", 9 | "module": "ESNext", 10 | "moduleResolution": "Bundler", 11 | "resolveJsonModule": true, 12 | "target": "ES2022", 13 | "strict": true, 14 | "allowJs": true, 15 | "skipLibCheck": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "baseUrl": ".", 18 | 19 | // Vite takes care of building everything, not tsc. 20 | "noEmit": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: [ 5 | "src/next-routes.ts", // Main entry point 6 | "src/react-router/index.ts", // React Router entry point 7 | "src/remix/index.ts", // Remix entry point 8 | ], 9 | format: ["cjs", "esm"], // CommonJS and ES module builds 10 | dts: true, // Generate declaration file(s) 11 | splitting: false, // Do not use code splitting to generate simpler builds 12 | sourcemap: true, // Include source maps 13 | clean: true, // Clean the output directory before building 14 | external: ["@react-router/dev/routes", "@remix-run/route-config"], // Mark these as external dependencies 15 | }); 16 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vitest/config' 2 | import * as path from "node:path"; 3 | 4 | export default defineConfig({}) --------------------------------------------------------------------------------