├── .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 |
6 |
7 |
rr-next-routes
8 |
9 |

10 |

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 |
44 |
--------------------------------------------------------------------------------
/assets/rr-next-routes-light.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
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({})
--------------------------------------------------------------------------------