├── .all-contributorsrc
├── .gitignore
├── .prettierrc
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── package-lock.json
├── package.json
├── r
├── remix.config.js
├── src
├── cli.ts
├── index.ts
├── lib.ts
├── migrate.ts
└── routes.ts
├── test
├── __snapshots__
│ └── index.test.ts.snap
├── index.test.ts
└── migrate.test.ts
├── tsconfig.json
└── tsup.config.ts
/.all-contributorsrc:
--------------------------------------------------------------------------------
1 | {
2 | "projectName": "remix-flat-routes",
3 | "projectOwner": "kiliman",
4 | "repoType": "github",
5 | "repoHost": "https://github.com",
6 | "files": ["README.md"],
7 | "imageSize": 100,
8 | "commit": false,
9 | "commitConvention": "gitmoji",
10 | "contributors": [
11 | {
12 | "login": "kiliman",
13 | "name": "Kiliman",
14 | "avatar_url": "https://avatars.githubusercontent.com/u/47168?v=4",
15 | "profile": "https://kiliman.dev/",
16 | "contributions": ["code", "doc"]
17 | },
18 | {
19 | "login": "ryanflorence",
20 | "name": "Ryan Florence",
21 | "avatar_url": "https://avatars.githubusercontent.com/u/100200?v=4",
22 | "profile": "http://remix.run/",
23 | "contributions": ["doc"]
24 | },
25 | {
26 | "login": "brandonpittman",
27 | "name": "Brandon Pittman",
28 | "avatar_url": "https://avatars.githubusercontent.com/u/967145?v=4",
29 | "profile": "https://blp.is/",
30 | "contributions": ["doc", "code"]
31 | },
32 | {
33 | "login": "machour",
34 | "name": "Mehdi Achour",
35 | "avatar_url": "https://avatars.githubusercontent.com/u/304450?v=4",
36 | "profile": "https://github.com/machour",
37 | "contributions": ["doc"]
38 | },
39 | {
40 | "login": "falegh",
41 | "name": "Fidel González",
42 | "avatar_url": "https://avatars.githubusercontent.com/u/49175237?v=4",
43 | "profile": "https://github.com/falegh",
44 | "contributions": ["doc"]
45 | },
46 | {
47 | "login": "haines",
48 | "name": "Andrew Haines",
49 | "avatar_url": "https://avatars.githubusercontent.com/u/785641?v=4",
50 | "profile": "https://www.linkedin.com/in/andrewihaines",
51 | "contributions": ["code"]
52 | },
53 | {
54 | "login": "wonu",
55 | "name": "Wonu Lee",
56 | "avatar_url": "https://avatars.githubusercontent.com/u/9602236?v=4",
57 | "profile": "https://github.com/wonu",
58 | "contributions": ["code"]
59 | },
60 | {
61 | "login": "KnisterPeter",
62 | "name": "Markus Wolf",
63 | "avatar_url": "https://avatars.githubusercontent.com/u/327445?v=4",
64 | "profile": "https://about.me/knisterpeter",
65 | "contributions": ["code"]
66 | },
67 | {
68 | "login": "sarat1669",
69 | "name": "Sarat Chandra Balla",
70 | "avatar_url": "https://avatars.githubusercontent.com/u/11179580?v=4",
71 | "profile": "https://github.com/sarat1669",
72 | "contributions": ["code"]
73 | },
74 | {
75 | "login": "brookslybrand",
76 | "name": "Brooks Lybrand",
77 | "avatar_url": "https://avatars.githubusercontent.com/u/12396812?v=4",
78 | "profile": "https://github.com/brookslybrand",
79 | "contributions": ["doc"]
80 | },
81 | {
82 | "login": "intsanerarity",
83 | "name": "Steven Lahmann",
84 | "avatar_url": "https://avatars.githubusercontent.com/u/149149100?v=4",
85 | "profile": "https://github.com/intsanerarity",
86 | "contributions": ["code"]
87 | },
88 | {
89 | "login": "mikkpokk",
90 | "name": "Mikk Pokk",
91 | "avatar_url": "https://avatars.githubusercontent.com/u/14272312?v=4",
92 | "profile": "https://github.com/mikkpokk",
93 | "contributions": ["code", "doc"]
94 | },
95 | {
96 | "login": "ZipBrandon",
97 | "name": "Brandon",
98 | "avatar_url": "https://avatars.githubusercontent.com/u/68867795?v=4",
99 | "profile": "https://github.com/ZipBrandon",
100 | "contributions": ["code", "doc"]
101 | }
102 | ],
103 | "contributorsPerLine": 7
104 | }
105 |
106 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .cache
3 | build
4 | public/build
5 | .env
6 | dist
7 | app
8 | .DS_Store
9 | .vscode
10 | .idea
11 | tsconfig.tsbuildinfo
12 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true,
4 | "trailingComma": "all",
5 | "arrowParens": "avoid",
6 | "tabWidth": 2
7 | }
8 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # CHANGELOG
2 |
3 | ## v0.8.5
4 | - 🔨 Don't require react-router >=7 if installed; today's version is still kind of independent of react-router.
5 |
6 | ## v0.8.4
7 | - 🐛 Remove unnecessary trailing slash from path [#130](https://github.com/kiliman/remix-flat-routes/issues/130)
8 |
9 | ## v0.8.3
10 | - 🔨 Don't compile unnecessary files during the build
11 |
12 | ## v0.8.2
13 | - 🐛 Fix bug of `npx migrate-flat-routes` due to ESM/CJS build
14 |
15 | ## v0.8.1
16 | - 🐛 Addresses bug of `npx migrate-flat-routes` due to ESM/CJS build
17 |
18 | ## v0.8.0
19 |
20 | Full changes in PR [#70](https://github.com/kiliman/remix-flat-routes/pull/70)
21 | - ✨New option `nestedDirectoryChar` introduced [#57](https://github.com/kiliman/remix-flat-routes/issues/57) [#106](https://github.com/kiliman/remix-flat-routes/issues/106)
22 | - ✨ESM support added
23 |
24 | ## v0.7.2
25 |
26 | Full changes in PR [#148](https://github.com/kiliman/remix-flat-routes/pull/148)
27 | - 🐛 Allow escaping underscore on route file.
28 | - 🔨 Make peer dependency `react-router` optional
29 | - ✨ Add test to verify special character escape cases.
30 |
31 | ## v0.7.1
32 |
33 | Minor fixes. Full changes in PR [#147](https://github.com/kiliman/remix-flat-routes/pull/147)
34 | - 🐛 Use `peerDependency` `react-router: ^7` instead of `^7.0.0`
35 | - 🐛 Fix contributors `projectName` and typo inside `src/index.ts`
36 |
37 | ## v0.7.0
38 |
39 | - 🔨 Removes requirement of @remix-run dependencies [#144](https://github.com/kiliman/remix-flat-routes/pull/144) [#138](https://github.com/kiliman/remix-flat-routes/issues/138)
40 | - 📦 Update dependencies to the latest stable version
41 |
42 | ## v0.6.5
43 |
44 | - 🐛 Check if remix.config.js exists before using during migration [#121](https://github.com/kiliman/remix-flat-routes/issues/121)
45 |
46 | ## v0.6.4
47 |
48 | - 🐛 Import remix.config to use `ignoredRouteFiles` setting [#93](https://github.com/kiliman/remix-flat-routes/issue/93)
49 | - ✨ feat: Follow symlinks [#90](https://github.com/kiliman/remix-flat-routes/pull/90)
50 |
51 | ## v0.6.2
52 |
53 | - 🐛 Fix migration with pathless layouts [#79](https://github.com/kiliman/remix-flat-routes/issue/79)
54 | - ✨ Add `--force` CLI option to remove target folder before migration
55 |
56 | ## v0.6.1
57 |
58 | - ✨ Add `--hybrid` convention to migration script [#78](https://github.com/kiliman/remix-flat-routes/issue/78)
59 |
60 | ## v0.6.0
61 |
62 | - 🔨 Rewrite migration script
63 |
64 | ## v0.5.12
65 |
66 | - 📦 Update @remix-run/v1-route-convention package for v2 dependency [#74](https://github.com/kiliman/remix-flat-routes/issue/74)
67 |
68 | ## v0.5.11
69 |
70 | - 🔨 Update peerDependency to include Remix v2 [#72](https://github.com/kiliman/remix-flat-routes/pull/72)
71 |
72 | ## v0.5.10
73 |
74 | - 🔨 Add support for `+/_.` convention to override parent layout [#58](https://github.com/kiliman/remix-flat-routes/issues/58)
75 |
76 | ## v0.5.9
77 |
78 | - 🔨 Update migration script to use `v1-route-convention` package [#46](https://github.com/kiliman/remix-flat-routes/issues/46)
79 | - 🐛 Normalize Windows path for routes config [#59](https://github.com/kiliman/remix-flat-routes/issues/59)
80 | - 🔥 Remove index hack since it is fixed in Remix
81 | - 🔥 Remove uniqueness check from `v2` routing because it is buggy
82 |
83 | ## v0.5.8
84 |
85 | - 🐛 Fix last segment finding on Windows [#40](https://github.com/kiliman/remix-flat-routes/pull/40)
86 |
87 | ## v0.5.7
88 |
89 | - 🐛 Fix import path for Remix 1.6.2+ [#35](https://github.com/kiliman/remix-flat-routes/pull/35)
90 |
91 | ## v0.5.6
92 |
93 | - 🐛 Simplify regex for routes and fix optional routes with folders [#28](https://github.com/kiliman/remix-flat-routes/issues/28)
94 |
95 | ## v0.5.5
96 |
97 | - 🐛 Handle optional segments with param [#30](https://github.com/kiliman/remix-flat-routes/issues/30)
98 |
99 | ## v0.5.4
100 |
101 | - 🐛 Fix route matching on Windows
102 |
103 | ## v0.5.3
104 |
105 | - 🐛 Make unique route id check optional [#29](https://github.com/kiliman/remix-flat-routes/issues/29)
106 |
107 | ## v0.5.2
108 |
109 | - 🐛 Fix flat-files folder support on Windows [#27](https://github.com/kiliman/remix-flat-routes/issues/27)
110 | - ✨ Add `appDir` option [#26](https://github.com/kiliman/remix-flat-routes/issues/26)
111 |
112 | ## v0.5.1
113 |
114 | - 🔨 Add support for folders with `flat-files` convention [#25](https://github.com/kiliman/remix-flat-routes/discussions/25)
115 |
116 | ## v0.5.0
117 |
118 | - 🔨 Update flatRoutes with new features
119 | - Uses same function as Remix core
120 | - Allows to maintain extended flat-routes function
121 | - Customizations passed in `options`
122 | - Add support for "hybrid" routes
123 | - Add support for extended route filenames
124 | - Add support for multiple route folders
125 | - Add support for custom param prefix character
126 | - Add support for custom base path
127 |
128 | ## v0.4.7
129 |
130 | - 🔨 Modify route ids for index routes to workaround bug in Remix
131 | - See Remix PR [#4560](https://github.com/remix-run/remix/pull/4560)
132 |
133 | ## v0.4.6
134 |
135 | - 🔨 Update build to use tsc compiler to generate type definitions [#21](https://github.com/kiliman/remix-flat-routes/issues/21)
136 |
137 | ## v0.4.5
138 |
139 | - 🐛 Fix path generation to ensure relative paths [#14](https://github.com/kiliman/remix-flat-routes/issues/14)
140 | - Couple of issues in Remix that cause problem when posting to index routes. Here is a link to patches that will fix this problem. https://gist.github.com/kiliman/6ecc2186d487baa248d65f79128f72f6
141 | - 🐛 Handle ignored files starting with dots
142 | - ✨ Add paramPrefixChar to config
143 | - Since the `$` prefix makes it hard to work with files in the shell, you can choose a different character like `^`
144 |
145 | ## v0.4.4
146 |
147 | - 🔨 Add `ignoredRouteFiles` to `flatRoutes` options [#15](https://github.com/kiliman/remix-flat-routes/issues/15)
148 |
149 | ## v0.4.3
150 |
151 | - 🐛 Use correct path for index routes [#13](https://github.com/kiliman/remix-flat-routes/issues/13)
152 |
153 | ## v0.4.2
154 |
155 | - 🐛 Fix params with trailing slash [#11](https://github.com/kiliman/remix-flat-routes/issues/11)
156 |
157 | ## v0.4.1
158 |
159 | - 🐛 Fix parent handling and trailing `_` in path [#11](https://github.com/kiliman/remix-flat-routes/issues/11)
160 |
161 | ## v0.4.0
162 |
163 | - 🔨 Rewrite how parent routes are calculated [#9](https://github.com/kiliman/remix-flat-routes/issues/9)
164 | - 🐛 Use `path.sep` to support Windows [#10](https://github.com/kiliman/remix-flat-routes/issues/10)
165 |
166 | ## v0.3.1
167 |
168 | - 🔨 Add support for MDX files [#7](https://github.com/kiliman/remix-flat-routes/pull/6)
169 |
170 | ## v0.3.0
171 |
172 | - ✨ Add `basePath` option to mount routes to path other than root
173 | - 🔨 Add more TypeScript types
174 | - ♻️ Refactor tests
175 |
176 | ## v0.2.1
177 |
178 | - 🔨 Add shebang to cli.js script
179 | - 🔨 Check that source directory exists before processing
180 |
181 | ## v0.2.0
182 |
183 | - ✨ Add new command to migrate existing routes to new convention
184 | - ✅ Add tests for migration
185 |
186 | ## v0.1.0
187 |
188 | - ✅ Add tests for parseRouteModule
189 | - 🐛 Fix issue with parent modules not matching with dynamic params
190 |
191 | ## v0.0.4
192 |
193 | - 🐛 Fix check for index file
194 |
195 | ## v0.0.2
196 |
197 | - 🔨 Add support for explicit `_layout.tsx` file
198 |
199 | ## v0.0.1
200 |
201 | - 🎉 Initial import
202 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 |
2 | The MIT License (MIT)
3 |
4 | Copyright (c) 2022 kiliman
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Remix Flat Routes
2 |
3 |
4 | [](#contributors-)
5 |
6 |
7 | This package enables you to define your routes using the `flat-routes` convention. This is based on the [gist](https://gist.github.com/ryanflorence/0dcc52c2332c2f6e1b52925195f87baf) by Ryan Florence
8 |
9 | ## 💡 React Router v7 Support
10 |
11 | React Router v7 uses a new routing config. To ease migration from Remix, the team has published an adapter
12 | package that will convert existing Remix file-based routes to the new config format.
13 |
14 | To use your existing file-based routing, install the adapter and update `routes.ts` to wrap your adapter.
15 |
16 | ```bash
17 | npm install -D @react-router/remix-routes-option-adapter
18 | npm install -D remix-flat-routes
19 | ```
20 |
21 | ```ts
22 | // app/routes.ts
23 | import { remixRoutesOptionAdapter } from "@react-router/remix-routes-option-adapter";
24 | import { flatRoutes } from "remix-flat-routes";
25 |
26 | export default remixRoutesOptionAdapter((defineRoutes) => {
27 | return flatRoutes("routes", defineRoutes, {
28 | ignoredRouteFiles: ['**/.*'], // Ignore dot files (like .DS_Store)
29 | //appDir: 'app',
30 | //routeDir: 'routes',
31 | //basePath: '/',
32 | //paramPrefixChar: '$',
33 | //nestedDirectoryChar: '+',
34 | //routeRegex: /((\${nestedDirectoryChar}[\/\\][^\/\\:?*]+)|[\/\\]((index|route|layout|page)|(_[^\/\\:?*]+)|([^\/\\:?*]+\.route)))\.(ts|tsx|js|jsx|md|mdx)$$/,
35 | });
36 | });
37 | ```
38 |
39 | **$\color{#FF0000}{NOTE}$:** If you deploy using Vercel, you may need to replace `nestedDirectoryChar: '+'` inside _app/routes.tsx_ with a different character that is not reserved by Vercel. Make sure to also replace the `+` character in route directories and files afterward.
40 |
41 | ## ✨🎉 New in v0.5.0
42 |
43 | ### Remix v2 flat routes convention
44 |
45 | `remix-flat-routes` was the initial implementation of the flat-routes specification. I added some enhancements
46 | based on user feedback. When Remix v2 added the flat-routes convention as the default, they used _only_ the
47 | original specification.
48 |
49 | If you want enhancements like *hybrid routes*, extended route filenames, custom param prefix, etc., you will
50 | need to continue to use this package.
51 |
52 | `remix-flat-routes` will always maintain compatibility with the default Remix convention. This package is simply
53 | a superset/extension of the core convention.
54 |
55 | > NOTE: popular projects like Kent C. Dodds' [Epic Stack](https://github.com/epicweb-dev/epic-stack) uses `remix-flat-routes`
56 |
57 | ### Hybrid Routes
58 |
59 | You can now use nested folders for your route names, yet still keep the colocation feature of flat routes.
60 |
61 | If you have a large app, its not uncommon to have routes nested many levels deep. With default flat routes, the folder name is the entire route path: `some.really.long.route.edit/index.tsx`
62 |
63 | Often you may have several parent layouts like `_public` or `admin`. Instead of having to repeat the name in every route, you can create top-level folders, then nest your routes under them. This way you can still take advantage of flat folders with colocation.
64 |
65 | **Before**
66 |
67 | ```shell
68 | ❯ tree app/routes-folders
69 | app/routes-folders
70 | ├── _index
71 | │ └── page.tsx
72 | ├── _public
73 | │ └── _layout.tsx
74 | ├── _public.about
75 | │ └── index.tsx
76 | ├── _public.contact[.jpg]
77 | │ └── index.tsx
78 | ├── test.$
79 | │ ├── _route.server.tsx
80 | │ └── _route.tsx
81 | ├── users
82 | │ ├── _layout.tsx
83 | │ └── users.css
84 | ├── users.$userId
85 | │ ├── _route.tsx
86 | │ └── avatar.png
87 | ├── users.$userId_.edit
88 | │ └── _route.tsx
89 | └── users._index
90 | └── index.tsx
91 | ```
92 |
93 | **After**
94 |
95 | ```shell
96 | ❯ tree app/routes-hybrid
97 | app/routes-hybrid
98 | ├── _index
99 | │ └── index.tsx
100 | ├── _public
101 | │ ├── _layout.tsx
102 | │ ├── about
103 | │ │ └── _route.tsx
104 | │ └── contact[.jpg]
105 | │ └── _route.tsx
106 | ├── test.$
107 | │ └── _route.tsx
108 | └── users
109 | ├── $userId
110 | │ ├── _route.tsx
111 | │ └── avatar.png
112 | ├── $userId_.edit
113 | │ └── _route.tsx
114 | ├── _index
115 | │ └── index.tsx
116 | ├── _layout.tsx
117 | └── users.css
118 | ```
119 |
120 | ### Nested folders with `flat-files` convention (✨ New in v0.5.1)
121 |
122 | To create a folder but treat it as flat-file, just append the default nested folder character `+` to the folder name. You can override this character in the options.
123 |
124 | ```
125 | _auth+/forgot-password.tsx => _auth.forgot-password.tsx
126 | ```
127 |
128 | > NOTE: You can include the \_layout.tsx file inside your folder. You do NOT need to have a \_public.tsx or users.tsx file.
129 |
130 | > You can still use flat-folders for colocation. So this is best of both formats.
131 |
132 | ```
133 | ❯ tree app/routes-hybrid-files/
134 | app/routes-hybrid-files/
135 | ├── _auth+
136 | │ ├── forgot-password.tsx
137 | │ └── login.tsx
138 | ├── _public+
139 | │ ├── _layout.tsx
140 | │ ├── about.tsx
141 | │ ├── contact[.jpg].tsx
142 | │ └── index.tsx
143 | ├── project+
144 | │ ├── _layout.tsx
145 | │ ├── parent.child
146 | │ │ └── index.tsx
147 | │ └── parent.child.grandchild
148 | │ ├── index.tsx
149 | │ └── styles.css
150 | └── users+
151 | ├── $userId.tsx
152 | ├── $userId_.edit.tsx
153 | ├── _layout.tsx
154 | └── index.tsx
155 | ```
156 |
157 | ```js
158 |
159 |
160 |
164 |
165 |
166 |
167 |
171 |
172 |
173 |
174 |
178 |
182 |
183 |
184 |
185 |
186 |
190 |
191 |
192 |
193 |
194 | ```
195 |
196 | ### Extended Route Filenames
197 |
198 | In addition to the standard `index | route | page | layout` names, any file that has a `_` prefix will be treated as the route file. This will make it easier to find a specific route instead of looking through a bunch of `route.tsx` files. This was inspired by [SolidStart](https://start.solidjs.com/core-concepts/routing) "Renaming Index" feature.
199 |
200 | So instead of
201 |
202 | ```
203 | _public.about/route.tsx
204 | _public.contact/route.tsx
205 | _public.privacy/route.tsx
206 | ```
207 |
208 | You can name them
209 |
210 | ```
211 | _public.about/_about.tsx
212 | _public.contact/_contact.tsx
213 | _public.privacy/_privacy.tsx
214 | ```
215 |
216 | ### Multiple Route Folders
217 |
218 | You can now pass in additional route folders besides the default `routes` folder. These routes will be merged into a single namespace, so you can have routes in one folder that will use shared routes from another.
219 |
220 | ### Custom Param Prefix
221 |
222 | You can override the default param prefix of `$`. Some shells use the `$` prefix for variables, and this can be an issue due to shell expansion. Use any character that is a valid filename, for example: `^`
223 |
224 | ```
225 | users.^userId.tsx => users/:userId
226 | test.^.tsx => test/*
227 | ```
228 |
229 | ### Custom Base Path
230 |
231 | You can override the default base path of `/`. This will prepend your base path to the root path.
232 |
233 | ### Optional Route Segments
234 |
235 | React Router will introduce a new feature for optional route segments. To use optional segments in flat routes, simply wrap your route name in `()`.
236 |
237 | ```
238 | parent.(optional).tsx => parent/optional?
239 | ```
240 |
241 | ### Custom App Directory
242 |
243 | You can override the default app directory of `app`.
244 |
245 | ## 🛠 Installation
246 |
247 | ```bash
248 | npm install -D remix-flat-routes
249 | ```
250 |
251 | ## ⚙️ Configuration
252 |
253 | Update your _vite.config.ts_ file and use custom routes config option.
254 |
255 | ```ts
256 | import { vitePlugin as remix } from '@remix-run/dev'
257 | import { defineConfig } from 'vite'
258 | import tsconfigPaths from 'vite-tsconfig-paths'
259 | import { flatRoutes } from 'remix-flat-routes'
260 |
261 | export default defineConfig({
262 | plugins: [
263 | remix({
264 | routes(defineRoutes) {
265 | return flatRoutes('routes', defineRoutes, {
266 | ignoredRouteFiles: ['**/.*'], // Ignore dot files (like .DS_Store)
267 | //appDir: 'app',
268 | //routeDir: 'routes',
269 | //basePath: '/',
270 | //paramPrefixChar: '$',
271 | //nestedDirectoryChar: '+',
272 | //routeRegex: /((\${nestedDirectoryChar}[\/\\][^\/\\:?*]+)|[\/\\]((index|route|layout|page)|(_[^\/\\:?*]+)|([^\/\\:?*]+\.route)))\.(ts|tsx|js|jsx|md|mdx)$$/,
273 | })
274 | },
275 | }),
276 | tsconfigPaths(),
277 | // ...
278 | ]
279 | })
280 | ```
281 |
282 | In case you're not using Vite, update your _remix.config.js_ file and use the custom routes config option.
283 |
284 | ```ts
285 | const { flatRoutes } = require('remix-flat-routes')
286 |
287 | /**
288 | * @type {import("@remix-run/dev").AppConfig}
289 | */
290 | module.exports = {
291 | // ignore all files in routes folder to prevent
292 | // default remix convention from picking up routes
293 | ignoredRouteFiles: ['**/*'],
294 | routes: async defineRoutes => {
295 | return flatRoutes('routes', defineRoutes, {
296 | ignoredRouteFiles: ['**/.*'], // Ignore dot files (like .DS_Store)
297 | //appDir: 'app',
298 | //routeDir: 'routes',
299 | //basePath: '/',
300 | //paramPrefixChar: '$',
301 | //nestedDirectoryChar: '+',
302 | //routeRegex: /((\${nestedDirectoryChar}[\/\\][^\/\\:?*]+)|[\/\\]((index|route|layout|page)|(_[^\/\\:?*]+)|([^\/\\:?*]+\.route)))\.(ts|tsx|js|jsx|md|mdx)$$/,
303 | })
304 | },
305 | }
306 | ```
307 |
308 | ### API
309 |
310 | ```ts
311 | function flatRoutes(
312 | routeDir: string | string[],
313 | defineRoutes: DefineRoutesFunction,
314 | options: FlatRoutesOptions,
315 | )
316 |
317 | type FlatRoutesOptions = {
318 | appDir?: string // optional app directory (defaults to app)
319 | routeDir?: string | string[] // optional routes directory (default to routes)
320 | basePath?: string // optional base path (default is '/')
321 | paramPrefixChar?: string // optional param prefix (default is '$')
322 | nestedDirectoryChar?: string // optional nested folder character for hybrid-routes (default is '+')
323 | ignoredRouteFiles?: string[] // optional files to ignore as routes (same as Remix config option)
324 | visitFiles?: VisitFilesFunction // optional visitor (useful for tests to provide files without file system)
325 | routeRegex?: RegExp; // optional route regex to identify files that define routes
326 | }
327 | ```
328 |
329 | NOTE: `routeDir` should be relative to the `app` folder. If you want to use the `routes` folder, you will need to update the `ignoredRouteFiles` property to ignore **all** files: `**/*`
330 |
331 | ## 🔨 Flat Routes Convention
332 |
333 | ### Example (flat-files)
334 |
335 | ```
336 | routes/
337 | _auth.forgot-password.tsx
338 | _auth.login.tsx
339 | _auth.reset-password.tsx
340 | _auth.signup.tsx
341 | _auth.tsx
342 | _landing.about.tsx
343 | _landing.index.tsx
344 | _landing.tsx
345 | app.calendar.$day.tsx
346 | app.calendar.index.tsx
347 | app.calendar.tsx
348 | app.projects.$id.tsx
349 | app.projects.tsx
350 | app.tsx
351 | app_.projects.$id.roadmap.tsx
352 | app_.projects.$id.roadmap[.pdf].tsx
353 | ```
354 |
355 | As React Router routes:
356 |
357 | ```jsx
358 |
359 | }>
360 | } />
361 | } />
362 | } />
363 | } />
364 |
365 | }>
366 | } />
367 | } />
368 |
369 | }>
370 | }>
371 | } />
372 | } />
373 |
374 | }>
375 | } />
376 |
377 |
378 | } />
379 |
380 |
381 | ```
382 |
383 | Individual explanations:
384 |
385 | | filename | url | nests inside of... |
386 | | ------------------------------------- | ------------------------------- | -------------------- |
387 | | `_auth.forgot-password.tsx` | `/forgot-password` | `_auth.tsx` |
388 | | `_auth.login.tsx` | `/login` | `_auth.tsx` |
389 | | `_auth.reset-password.tsx` | `/reset-password` | `_auth.tsx` |
390 | | `_auth.signup.tsx` | `/signup` | `_auth.tsx` |
391 | | `_auth.tsx` | n/a | `root.tsx` |
392 | | `_landing.about.tsx` | `/about` | `_landing.tsx` |
393 | | `_landing.index.tsx` | `/` | `_landing.tsx` |
394 | | `_landing.tsx` | n/a | `root.tsx` |
395 | | `app.calendar.$day.tsx` | `/app/calendar/:day` | `app.calendar.tsx` |
396 | | `app.calendar.index.tsx` | `/app/calendar` | `app.calendar.tsx` |
397 | | `app.projects.$id.tsx` | `/app/projects/:id` | `app.projects.tsx` |
398 | | `app.projects.tsx` | `/app/projects` | `app.tsx` |
399 | | `app.tsx` | `/app` | `root.tsx` |
400 | | `app_.projects.$id.roadmap.tsx` | `/app/projects/:id/roadmap` | `root.tsx` |
401 | | `app_.projects.$id.roadmap[.pdf].tsx` | `/app/projects/:id/roadmap.pdf` | n/a (resource route) |
402 |
403 | ## Nested Layouts
404 |
405 | ### Default match
406 |
407 | By default, `flat-routes` will nest the current route into the parent layout that has the longest matching prefix.
408 |
409 | Given the layout route `app.calendar.tsx`, the following routes will be nested under `app.calendar.tsx` since **`app.calendar`** is the longest matching prefix.
410 |
411 | - `app.calendar.index.tsx`
412 | - `app.calendar.$day.tsx`
413 |
414 | ### Override match
415 |
416 | Sometimes you want to use a parent layout that is higher up in the route hierarchy. With the default Remix convention, you would use dot (`.`) notation instead of nested folders. With `flat-routes`, since routes files always use dots, there is a different convention to specify which layout to nest under.
417 |
418 | Let's say you have an `app.tsx` layout, and you have a route that you don't want to share with the layout, but instead want to match with `root.tsx`. To override the default parent match, append a trailing underscore (`_`) to the segment that is the immediate child of the route you want to nest under.
419 |
420 | `app_.projects.$id.roadmap.tsx` will nest under `root` since there are no matching routes:
421 |
422 | - ❌ `app_.projects.$id.tsx`
423 | - ❌ `app_.projects.tsx`
424 | - ❌ `app_.tsx`
425 | - ✅ `root.tsx`
426 |
427 | ## Conventions
428 |
429 | | filename | convention | behavior |
430 | | ------------------------------- | ---------------------- | ------------------------------- |
431 | | `privacy.jsx` | filename | normal route |
432 | | `pages.tos.jsx` | dot with no layout | normal route, `.` -> `/` |
433 | | `about.jsx` | filename with children | parent layout route |
434 | | `about.contact.jsx` | dot | child route of layout |
435 | | `about.index.jsx` | index filename | index route of layout |
436 | | `about._index.jsx` | alias of index.tsx | index route of layout\* |
437 | | `about_.company.jsx` | trailing underscore | url segment, no layout |
438 | | `app_.projects.$id.roadmap.tsx` | trailing underscore | change default parent layout |
439 | | `_auth.jsx` | leading underscore | layout nesting, no url segment |
440 | | `_auth.login.jsx` | leading underscore | child of pathless layout route |
441 | | `users.$userId.jsx` | leading $ | URL param |
442 | | `docs.$.jsx` | bare $ | splat route |
443 | | `dashboard.route.jsx` | route suffix | optional, ignored completely |
444 | | `investors/[index].jsx` | brackets | escapes conventional characters |
445 |
446 | > NOTE: The underscore prefix for the index route is optional but helps sort the file to the top of the directory listing.
447 |
448 | ## Justification
449 |
450 | - **Make it easier to see the routes your app has defined** - just pop open "routes/" and they are all right there. Since file systems typically sort folders first, when you have dozens of routes it's hard to see today which folders have layouts and which don't. Now all related routes are sorted together.
451 |
452 | - **Decrease refactor/redesign friction** - while code editors are pretty good at fixing up imports when you move files around, and Remix has the `"~"` import alias, it's just generally easier to refactor a code base that doesn't have a bunch of nested folders. Remix will no longer force this.
453 |
454 | Additionally, when redesigning the user interface, it's simpler to adjust the names of files rather than creating/deleting folders and moving routes around to change the way they nest.
455 |
456 | - **Help apps migrate to Remix** - Existing apps typically don't have a nested route folder structure like today's conventions. Moving to Remix is arduous because you have to deal with all of the imports.
457 |
458 | ## Colocation
459 |
460 | While the example is exclusively files, they are really just "import paths". So you could make a folder for a route instead and the `index` file will be imported, allowing all of a route's modules to live alongside each other. This is the _flat-folders_ convention, as opposed to the _flat-files_ convention detailed above.
461 |
462 | ### Example (flat-folders)
463 |
464 | ```
465 | routes/
466 | _auth.forgot-password.tsx
467 | _auth.login.tsx
468 | _auth.tsx
469 | _landing.about.tsx
470 | _landing.index.tsx
471 | _landing.tsx
472 | app.projects.tsx
473 | app.projects.$id.tsx
474 | app.tsx
475 | app_.projects.$id.roadmap.tsx
476 | ```
477 |
478 | Each route becomes a folder with the route name minus the file extension. The route file then is named _index.tsx_.
479 |
480 | So _app.projects.tsx_ becomes _app.projects/index.tsx_
481 |
482 | ```
483 | routes/
484 | _auth/
485 | index.tsx x <- route file (same as _auth.tsx)
486 | _auth.forgot-password/
487 | index.tsx <- route file (same as _auth.forgot-password.tsx)
488 | _auth.login/
489 | index.tsx <- route files (same as _auth.login.tsx)
490 | _landing.about/
491 | index.tsx <- route file (same as _landing.about.tsx)
492 | employee-profile-card.tsx
493 | get-employee-data.server.tsx
494 | team-photo.jpg
495 | _landing.index/
496 | index.tsx <- route file (same as _landing.index.tsx)
497 | scroll-experience.tsx
498 | _landing/
499 | index.tsx <- route file (same as _landing.tsx)
500 | header.tsx
501 | footer.tsx
502 | app/
503 | index.tsx <- route file (same as app.tsx)
504 | primary-nav.tsx
505 | footer.tsx
506 | app_.projects.$id.roadmap/
507 | index.tsx <- route file (same as app_.projects.$id.roadmap.tsx)
508 | chart.tsx
509 | update-timeline.server.tsx
510 | app.projects/
511 | index.tsx <- layout file (sames as app.projects.tsx)
512 | project-card.tsx
513 | get-projects.server.tsx
514 | project-buttons.tsx
515 | app.projects.$id/
516 | index.tsx <- route file (sames as app.projects.$id.tsx)
517 | ```
518 |
519 | ### Aliases
520 |
521 | Since the route file is now named _index.tsx_ and you can colocate additional files in the same route folder, the _index.tsx_ file may get lost in the list of files. You can also use the following aliases for _index.tsx_. The underscore prefix will sort the file to the top of the directory listing.
522 |
523 | - `_index.tsx`
524 | - `_layout.tsx`
525 | - `_route.tsx`
526 |
527 | > NOTE: The _\_layout.tsx_ and _\_route.tsx_ files are simply more explicit about their role. They work the same as _index.tsx_.
528 |
529 | As with flat files, an index route (not to be confused with index route _file_), can also use the underscore prefix. The route `_landing.index` can be saved as `_landing.index/index.tsx` or `_landing._index/_index.tsx`.
530 |
531 | This is a bit more opinionated, but I think it's ultimately what most developers would prefer. Each route becomes its own "mini app" with all of its dependencies together. With the `ignoredRouteFiles` option it's completely unclear which files are routes and which aren't.
532 |
533 | ## 🚚 Migrating Existing Routes
534 |
535 | You can now migrate your existing routes to the new `flat-routes` convention. Simply run:
536 |
537 | ```bash
538 | npx migrate-flat-routes [options]
539 |
540 | Example:
541 | npx migrate-flat-routes ./app/routes ./app/flatroutes --convention=flat-folders
542 |
543 | NOTE:
544 | sourceDir and targetDir are relative to project root
545 |
546 | Options:
547 | --convention=
548 | The convention to use when migrating.
549 | flat-files - Migrates to flat files
550 | flat-folders - Migrates to flat directories with route.tsx files
551 | hybrid - Keep folder structure with '+' suffix and _layout files
552 | --force
553 | Overwrite target directory if it exists
554 | ```
555 |
556 | ## 😍 Contributors
557 |
558 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
559 |
560 |
561 |
562 |
563 |
584 |
585 |
586 |
587 |
588 |
589 |
590 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
591 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "remix-flat-routes",
3 | "version": "0.8.5",
4 | "description": "Package for generating routes using flat convention",
5 | "type": "module",
6 | "main": "dist/index.cjs",
7 | "module": "dist/index.js",
8 | "types": "dist/index.d.ts",
9 | "bin": {
10 | "migrate-flat-routes": "dist/cli.cjs"
11 | },
12 | "files": [
13 | "dist",
14 | "README.md",
15 | "CHANGELOG.md"
16 | ],
17 | "sideEffects": false,
18 | "scripts": {
19 | "build": "tsup",
20 | "test": "jest",
21 | "contributors:add": "all-contributors add",
22 | "contributors:generate": "all-contributors generate",
23 | "prepare": "npm run typecheck && npm run build",
24 | "typecheck": "tsc -b",
25 | "migrate": "rm -rf ./app/flat-files ./app/flat-folders && npm run build && node ./dist/cli.cjs"
26 | },
27 | "keywords": [
28 | "remix",
29 | "react-router",
30 | "flat-routes",
31 | "routing-convention"
32 | ],
33 | "author": {
34 | "name": "Kiliman",
35 | "email": "kiliman@gmail.com",
36 | "url": "https://kiliman.dev"
37 | },
38 | "repository": {
39 | "type": "git",
40 | "url": "https://github.com/kiliman/remix-flat-routes.git"
41 | },
42 | "license": "MIT",
43 | "dependencies": {
44 | "fs-extra": "^11.2.0",
45 | "minimatch": "^10.0.1"
46 | },
47 | "devDependencies": {
48 | "@babel/core": "^7.26.0",
49 | "@babel/preset-env": "^7.26.0",
50 | "@babel/preset-typescript": "^7.26.0",
51 | "@types/fs-extra": "^11.0.4",
52 | "@types/jest": "^29.5.14",
53 | "@types/minimatch": "^5.1.2",
54 | "@types/node": "^22.10.1",
55 | "all-contributors-cli": "^6.26.1",
56 | "babel-jest": "^29.7.0",
57 | "esbuild": "^0.25.0",
58 | "esbuild-register": "^3.6.0",
59 | "formdata-polyfill": "^4.0.10",
60 | "jest": "^29.7.0",
61 | "jest-environment-jsdom": "^29.7.0",
62 | "prettier": "^3.4.1",
63 | "ts-jest": "^29.2.5",
64 | "ts-node": "^10.9.2",
65 | "tslib": "^2.8.1",
66 | "tsup": "^8.3.6",
67 | "typescript": "^5.7.2"
68 | },
69 | "jest": {
70 | "preset": "ts-jest/presets/default-esm",
71 | "testEnvironment": "jsdom"
72 | },
73 | "engines": {
74 | "node": ">=16.6.0"
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/r:
--------------------------------------------------------------------------------
1 | d=$(dirname "$1")
2 | mkdir -p "app/$d"
3 | touch "app/$1"
4 | echo "app/$1"
5 |
--------------------------------------------------------------------------------
/remix.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | ignoredRouteFiles: ['**/.*', '**/__snapshots__/*'],
3 | }
4 |
--------------------------------------------------------------------------------
/src/cli.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import * as fs from 'fs'
4 | import { removeSync } from 'fs-extra'
5 | import { migrate, MigrateOptions } from './migrate'
6 |
7 | main()
8 |
9 | function main() {
10 | const argv = process.argv.slice(2)
11 | if (argv.length < 2) {
12 | usage()
13 | process.exit(1)
14 | }
15 | const sourceDir = argv[0]
16 | const targetDir = argv[1]
17 |
18 | if (sourceDir === targetDir) {
19 | console.error('source and target directories must be different')
20 | process.exit(1)
21 | }
22 |
23 | if (!fs.existsSync(sourceDir)) {
24 | console.error(`source directory '${sourceDir}' does not exist`)
25 | process.exit(1)
26 | }
27 |
28 | let options: MigrateOptions = { convention: 'flat-files', force: false }
29 |
30 | for (let option of argv.slice(2)) {
31 | if (option === '--force') {
32 | options.force = true
33 | continue
34 | }
35 |
36 | if (option.startsWith('--convention=')) {
37 | let convention = option.substring('--convention='.length)
38 | if (
39 | convention === 'flat-files' ||
40 | convention === 'flat-folders' ||
41 | convention === 'hybrid'
42 | ) {
43 | options.convention = convention
44 | } else {
45 | usage()
46 | process.exit(1)
47 | }
48 | } else {
49 | usage()
50 | process.exit(1)
51 | }
52 | }
53 | if (fs.existsSync(targetDir)) {
54 | if (!options.force) {
55 | console.error(`❌ target directory '${targetDir}' already exists`)
56 | console.error(` use --force to overwrite`)
57 | process.exit(1)
58 | }
59 | removeSync(targetDir)
60 | }
61 |
62 | migrate(sourceDir, targetDir, options)
63 | }
64 |
65 | function usage() {
66 | console.log(
67 | `Usage: migrate [options]
68 |
69 | Options:
70 | --convention=
71 | The convention to use when migrating.
72 | flat-files - Migrates to flat files
73 | flat-folders - Migrates to flat directories with route.tsx files
74 | hybrid - Keep folder structure with '+' suffix and _layout files
75 | --force
76 | Overwrite target directory if it exists
77 | `,
78 | )
79 | }
80 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs'
2 | import { minimatch } from 'minimatch'
3 | import * as path from 'path'
4 | import { DefineRouteFunction, RouteManifest } from './routes'
5 |
6 | type RouteInfo = {
7 | id: string
8 | path: string
9 | file: string
10 | name: string
11 | segments: string[]
12 | parentId?: string // first pass parent is undefined
13 | index?: boolean
14 | caseSensitive?: boolean
15 | }
16 |
17 | type DefineRouteOptions = {
18 | caseSensitive?: boolean
19 | index?: boolean
20 | }
21 |
22 | type DefineRouteChildren = {
23 | (): void
24 | }
25 |
26 | export type VisitFilesFunction = (
27 | dir: string,
28 | visitor: (file: string) => void,
29 | baseDir?: string,
30 | ) => void
31 |
32 | export type FlatRoutesOptions = {
33 | appDir?: string
34 | routeDir?: string | string[]
35 | defineRoutes?: DefineRoutesFunction
36 | basePath?: string
37 | visitFiles?: VisitFilesFunction
38 | paramPrefixChar?: string
39 | nestedDirectoryChar?: string
40 | ignoredRouteFiles?: string[]
41 | routeRegex?: RegExp
42 | }
43 |
44 | export type DefineRoutesFunction = (
45 | callback: (route: DefineRouteFunction) => void,
46 | ) => any
47 |
48 | export { flatRoutes }
49 | export type {
50 | DefineRouteChildren,
51 | DefineRouteFunction,
52 | DefineRouteOptions,
53 | RouteInfo,
54 | RouteManifest,
55 | }
56 |
57 | const defaultOptions: FlatRoutesOptions = {
58 | appDir: 'app',
59 | routeDir: 'routes',
60 | basePath: '/',
61 | paramPrefixChar: '$',
62 | nestedDirectoryChar: '+',
63 | routeRegex: /((\${nestedDirectoryChar}[\/\\][^\/\\:?*]+)|[\/\\]((index|route|layout|page)|(_[^\/\\:?*]+)|([^\/\\:?*]+\.route)))\.(ts|tsx|js|jsx|md|mdx)$$/,
64 | }
65 | const defaultDefineRoutes = undefined
66 |
67 | export default function flatRoutes(
68 | routeDir: string | string[],
69 | defineRoutes: DefineRoutesFunction,
70 | options: FlatRoutesOptions = {},
71 | ): RouteManifest {
72 | const routes = _flatRoutes(
73 | options.appDir ?? defaultOptions.appDir!,
74 | options.ignoredRouteFiles ?? [],
75 | {
76 | ...defaultOptions,
77 | ...options,
78 | routeDir,
79 | defineRoutes,
80 | },
81 | )
82 | // update undefined parentIds to 'root'
83 | Object.values(routes).forEach(route => {
84 | if (route.parentId === undefined) {
85 | route.parentId = 'root'
86 | }
87 | })
88 |
89 | return routes
90 | }
91 |
92 | // this function uses the same signature as the one used in core remix
93 | // this way we can continue to enhance this package and still maintain
94 | // compatibility with remix
95 | function _flatRoutes(
96 | appDir: string,
97 | ignoredFilePatternsOrOptions?: string[] | FlatRoutesOptions,
98 | options?: FlatRoutesOptions,
99 | ): RouteManifest {
100 | // get options
101 | let ignoredFilePatterns: string[] = []
102 | if (
103 | ignoredFilePatternsOrOptions &&
104 | !Array.isArray(ignoredFilePatternsOrOptions)
105 | ) {
106 | options = ignoredFilePatternsOrOptions
107 | } else {
108 | ignoredFilePatterns = ignoredFilePatternsOrOptions ?? []
109 | }
110 | if (!options) {
111 | options = defaultOptions
112 | }
113 |
114 | let routeMap: Map = new Map()
115 | let nameMap: Map = new Map()
116 |
117 | let routeDirs = Array.isArray(options.routeDir)
118 | ? options.routeDir
119 | : [options.routeDir ?? 'routes']
120 | let defineRoutes = options.defineRoutes ?? defaultDefineRoutes
121 | if (!defineRoutes) {
122 | throw new Error('You must provide a defineRoutes function')
123 | }
124 | let visitFiles = options.visitFiles ?? defaultVisitFiles
125 | const routeRegex = getRouteRegex(
126 | options.routeRegex ?? defaultOptions.routeRegex!,
127 | options.nestedDirectoryChar ?? defaultOptions.nestedDirectoryChar!,
128 | )
129 |
130 | for (let routeDir of routeDirs) {
131 | visitFiles(path.join(appDir, routeDir), file => {
132 | if (
133 | ignoredFilePatterns &&
134 | ignoredFilePatterns.some(pattern =>
135 | minimatch(file, pattern, { dot: true }),
136 | )
137 | ) {
138 | return
139 | }
140 |
141 | if (isRouteModuleFile(file, routeRegex)) {
142 | let routeInfo = getRouteInfo(routeDir, file, options!)
143 | routeMap.set(routeInfo.id, routeInfo)
144 | nameMap.set(routeInfo.name, routeInfo)
145 | return
146 | }
147 | })
148 | }
149 | // update parentIds for all routes
150 | Array.from(routeMap.values()).forEach(routeInfo => {
151 | let parentId = findParentRouteId(routeInfo, nameMap)
152 | routeInfo.parentId = parentId
153 | })
154 |
155 | // Then, recurse through all routes using the public defineRoutes() API
156 | function defineNestedRoutes(
157 | defineRoute: DefineRouteFunction,
158 | parentId?: string,
159 | ): void {
160 | let childRoutes = Array.from(routeMap.values()).filter(
161 | routeInfo => routeInfo.parentId === parentId,
162 | )
163 | let parentRoute = parentId ? routeMap.get(parentId) : undefined
164 | let parentRoutePath = parentRoute?.path ?? '/'
165 | for (let childRoute of childRoutes) {
166 | let routePath = childRoute?.path?.slice(parentRoutePath.length) ?? ''
167 | // remove leading slash
168 | if (routePath.startsWith('/')) {
169 | routePath = routePath.slice(1)
170 | }
171 | let index = childRoute.index
172 |
173 | if (index) {
174 | let invalidChildRoutes = Object.values(routeMap).filter(
175 | routeInfo => routeInfo.parentId === childRoute.id,
176 | )
177 |
178 | if (invalidChildRoutes.length > 0) {
179 | throw new Error(
180 | `Child routes are not allowed in index routes. Please remove child routes of ${childRoute.id}`,
181 | )
182 | }
183 |
184 | defineRoute(routePath, routeMap.get(childRoute.id!)!.file, {
185 | index: true,
186 | })
187 | } else {
188 | defineRoute(routePath, routeMap.get(childRoute.id!)!.file, () => {
189 | defineNestedRoutes(defineRoute, childRoute.id)
190 | })
191 | }
192 | }
193 | }
194 |
195 | let routes = defineRoutes(defineNestedRoutes)
196 | return routes
197 | }
198 |
199 | const routeModuleExts = ['.js', '.jsx', '.ts', '.tsx', '.md', '.mdx']
200 | const serverRegex = /\.server\.(ts|tsx|js|jsx|md|mdx)$/
201 |
202 | export function isRouteModuleFile(
203 | filename: string,
204 | routeRegex: RegExp,
205 | ): boolean {
206 | // flat files only need correct extension
207 | let isFlatFile = !filename.includes(path.sep)
208 | if (isFlatFile) {
209 | return routeModuleExts.includes(path.extname(filename))
210 | }
211 | let isRoute = routeRegex.test(filename)
212 | if (isRoute) {
213 | // check to see if it ends in .server.tsx because you may have
214 | // a _route.tsx and and _route.server.tsx and only the _route.tsx
215 | // file should be considered a route
216 | let isServer = serverRegex.test(filename)
217 | return !isServer
218 | }
219 | return false
220 | }
221 |
222 | const memoizedRegex = (() => {
223 | const cache: { [key: string]: RegExp } = {}
224 |
225 | return (input: string): RegExp => {
226 | if (input in cache) {
227 | return cache[input]
228 | }
229 |
230 | const newRegex = new RegExp(input)
231 | cache[input] = newRegex
232 |
233 | return newRegex
234 | }
235 | })()
236 |
237 | export function isIndexRoute(
238 | routeId: string,
239 | options: FlatRoutesOptions,
240 | ): boolean {
241 | const nestedDirectoryChar = (options.nestedDirectoryChar as string).replace(
242 | /[.*+\-?^${}()|[\]\\]/g,
243 | '\\$&',
244 | )
245 | const indexRouteRegex = memoizedRegex(
246 | `((^|[.]|[${nestedDirectoryChar}]\\/)(index|_index))(\\/[^\\/]+)?$|(\\/_?index\\/)`,
247 | )
248 | return indexRouteRegex.test(routeId)
249 | }
250 |
251 | export function getRouteInfo(
252 | routeDir: string,
253 | file: string,
254 | options: FlatRoutesOptions,
255 | ) {
256 | let filePath = normalizeSlashes(path.join(routeDir, file))
257 | let routeId = createRouteId(filePath)
258 | let routeIdWithoutRoutes = routeId.slice(routeDir.length + 1)
259 | let index = isIndexRoute(routeIdWithoutRoutes, options)
260 | let routeSegments = getRouteSegments(
261 | routeIdWithoutRoutes,
262 | index,
263 | options.paramPrefixChar,
264 | options.nestedDirectoryChar,
265 | )
266 | let routePath = createRoutePath(routeSegments, index, options)
267 | let routeInfo = {
268 | id: routeId,
269 | path: routePath!,
270 | file: filePath,
271 | name: routeSegments.join('/'),
272 | segments: routeSegments,
273 | index,
274 | }
275 |
276 | return routeInfo
277 | }
278 |
279 | // create full path starting with /
280 | export function createRoutePath(
281 | routeSegments: string[],
282 | index: boolean,
283 | options: FlatRoutesOptions,
284 | ): string | undefined {
285 | let result = ''
286 | let basePath = options.basePath ?? '/'
287 | let paramPrefixChar = options.paramPrefixChar ?? '$'
288 |
289 | if (index) {
290 | // replace index with blank
291 | routeSegments[routeSegments.length - 1] = ''
292 | }
293 | for (let i = 0; i < routeSegments.length; i++) {
294 | let segment = routeSegments[i]
295 | // skip pathless layout segments
296 | if (segment.startsWith('_')) {
297 | continue
298 | }
299 | // remove trailing slash
300 | if (segment.endsWith('_')) {
301 | segment = segment.slice(0, -1)
302 | }
303 |
304 | // remove outer square brackets
305 | if (segment.includes('[') && segment.includes(']')) {
306 | let output = ''
307 | let depth = 0
308 |
309 | for (const char of segment) {
310 | if (char === '[' && depth === 0) {
311 | depth++
312 | } else if (char === ']' && depth > 0) {
313 | depth--
314 | } else {
315 | output += char
316 | }
317 | }
318 |
319 | segment = output
320 | }
321 |
322 | // handle param segments: $ => *, $id => :id
323 | if (segment.startsWith(paramPrefixChar)) {
324 | if (segment === paramPrefixChar) {
325 | result += `/*`
326 | } else {
327 | result += `/:${segment.slice(1)}`
328 | }
329 | // handle optional segments with param: ($segment) => :segment?
330 | } else if (segment.startsWith(`(${paramPrefixChar}`)) {
331 | result += `/:${segment.slice(2, segment.length - 1)}?`
332 | // handle optional segments: (segment) => segment?
333 | } else if (segment.startsWith('(')) {
334 | result += `/${segment.slice(1, segment.length - 1)}?`
335 | } else {
336 | result += `/${segment}`
337 | }
338 | }
339 | if (basePath !== '/') {
340 | result = basePath + result
341 | }
342 |
343 | if (result.endsWith('/')) {
344 | result = result.slice(0, -1)
345 | }
346 |
347 | return result || undefined
348 | }
349 |
350 | function findParentRouteId(
351 | routeInfo: RouteInfo,
352 | nameMap: Map,
353 | ): string | undefined {
354 | let parentName = routeInfo.segments.slice(0, -1).join('/')
355 | while (parentName) {
356 | if (nameMap.has(parentName)) {
357 | return nameMap.get(parentName)!.id
358 | }
359 | parentName = parentName.substring(0, parentName.lastIndexOf('/'))
360 | }
361 | return undefined
362 | }
363 |
364 | export function getRouteSegments(
365 | name: string,
366 | index: boolean,
367 | paramPrefixChar: string = '$',
368 | nestedDirectoryChar: string = '+',
369 | ) {
370 | let routeSegments: string[] = []
371 | let i = 0
372 | let routeSegment = ''
373 | let state = 'START'
374 | let subState = 'NORMAL'
375 | let hasPlus = false
376 |
377 | // name has already been normalized to use / as path separator
378 |
379 | const escapedNestedDirectoryChar = nestedDirectoryChar.replace(
380 | /[.*+\-?^${}()|[\]\\]/g,
381 | '\\$&',
382 | )
383 |
384 | const combinedRegex = new RegExp(`${escapedNestedDirectoryChar}[/\\\\]`, 'g')
385 | const testRegex = new RegExp(`${escapedNestedDirectoryChar}[/\\\\]`)
386 | const replacePattern = `${escapedNestedDirectoryChar}/_\\.`
387 | const replaceRegex = new RegExp(replacePattern)
388 |
389 | // replace `+/_.` with `_+/`
390 | // this supports ability to specify parent folder will not be a layout
391 | // _public+/_.about.tsx => _public_.about.tsx
392 |
393 | if (replaceRegex.test(name)) {
394 | const replaceRegexGlobal = new RegExp(replacePattern, 'g')
395 | name = name.replace(replaceRegexGlobal, `_${nestedDirectoryChar}/`)
396 | }
397 |
398 | // replace `+/` with `.`
399 | // this supports folders for organizing flat-files convention
400 | // _public+/about.tsx => _public.about.tsx
401 | //
402 | if (testRegex.test(name)) {
403 | name = name.replace(combinedRegex, '.')
404 |
405 | hasPlus = true
406 | }
407 |
408 | let hasFolder = /\//.test(name)
409 | // if name has plus folder, but we still have regular folders
410 | // then treat ending route as flat-folders
411 | if (((hasPlus && hasFolder) || !hasPlus) && !name.endsWith('.route')) {
412 | // do not remove segments ending in .route
413 | // since these would be part of the route directory name
414 | // docs/readme.route.tsx => docs/readme
415 | // remove last segment since this should just be the
416 | // route filename and we only want the directory name
417 | // docs/_layout.tsx => docs
418 | let last = name.lastIndexOf('/')
419 | if (last >= 0) {
420 | name = name.substring(0, last)
421 | }
422 | }
423 |
424 | let pushRouteSegment = (routeSegment: string) => {
425 | if (routeSegment) {
426 | routeSegments.push(routeSegment)
427 | }
428 | }
429 |
430 | while (i < name.length) {
431 | let char = name[i]
432 | switch (state) {
433 | case 'START':
434 | // process existing segment
435 | if (
436 | routeSegment.includes(paramPrefixChar) &&
437 | !(
438 | routeSegment.startsWith(paramPrefixChar) ||
439 | routeSegment.startsWith(`(${paramPrefixChar}`)
440 | )
441 | ) {
442 | throw new Error(
443 | `Route params must start with prefix char ${paramPrefixChar}: ${routeSegment}`,
444 | )
445 | }
446 | if (
447 | routeSegment.includes('(') &&
448 | !routeSegment.startsWith('(') &&
449 | !routeSegment.endsWith(')')
450 | ) {
451 | throw new Error(
452 | `Optional routes must start and end with parentheses: ${routeSegment}`,
453 | )
454 | }
455 | pushRouteSegment(routeSegment)
456 | routeSegment = ''
457 | state = 'PATH'
458 | continue // restart without advancing index
459 | case 'PATH':
460 | if (isPathSeparator(char) && subState === 'NORMAL') {
461 | state = 'START'
462 | break
463 | } else if (char === '[') {
464 | subState = 'ESCAPE'
465 | } else if (char === ']') {
466 | subState = 'NORMAL'
467 | }
468 | routeSegment += char
469 | break
470 | }
471 | i++ // advance to next character
472 | }
473 | // process remaining segment
474 | pushRouteSegment(routeSegment)
475 | // strip trailing .route segment
476 | if (routeSegments.at(-1) === 'route') {
477 | routeSegments = routeSegments.slice(0, -1)
478 | }
479 | // if hasPlus, we need to strip the trailing segment if it starts with _
480 | // and route is not an index route
481 | // this is to handle layouts in flat-files
482 | // _public+/_layout.tsx => _public.tsx
483 | // _public+/index.tsx => _public.index.tsx
484 | if (!index && hasPlus && routeSegments.at(-1)?.startsWith('_')) {
485 | routeSegments = routeSegments.slice(0, -1)
486 | }
487 | return routeSegments
488 | }
489 |
490 | const pathSeparatorRegex = /[\/\\.]/
491 |
492 | function isPathSeparator(char: string) {
493 | return pathSeparatorRegex.test(char)
494 | }
495 |
496 | export function defaultVisitFiles(
497 | dir: string,
498 | visitor: (file: string) => void,
499 | baseDir = dir,
500 | ) {
501 | for (let filename of fs.readdirSync(dir)) {
502 | let file = path.resolve(dir, filename)
503 | let stat = fs.statSync(file)
504 |
505 | if (stat.isDirectory()) {
506 | defaultVisitFiles(file, visitor, baseDir)
507 | } else if (stat.isFile()) {
508 | visitor(path.relative(baseDir, file))
509 | }
510 | }
511 | }
512 |
513 | export function createRouteId(file: string) {
514 | return normalizeSlashes(stripFileExtension(file))
515 | }
516 |
517 | export function normalizeSlashes(file: string) {
518 | return file.split(path.win32.sep).join('/')
519 | }
520 |
521 | function stripFileExtension(file: string) {
522 | return file.replace(/\.[a-z0-9]+$/i, '')
523 | }
524 |
525 | const getRouteRegex = (
526 | RegexRequiresNestedDirReplacement: RegExp,
527 | nestedDirectoryChar: string,
528 | ): RegExp => {
529 | nestedDirectoryChar = nestedDirectoryChar.replace(
530 | /[.*+\-?^${}()|[\]\\]/g,
531 | '\\$&',
532 | )
533 |
534 | return new RegExp(
535 | RegexRequiresNestedDirReplacement.source.replace('\\${nestedDirectoryChar}', `[${nestedDirectoryChar}]`),
536 | )
537 | }
538 |
--------------------------------------------------------------------------------
/src/lib.ts:
--------------------------------------------------------------------------------
1 | import { minimatch } from 'minimatch'
2 | import fs from 'node:fs'
3 | import path from 'node:path'
4 | import {
5 | createRouteId,
6 | DefineRouteFunction,
7 | DefineRoutesFunction,
8 | RouteManifest,
9 | } from './routes'
10 |
11 | const paramPrefixChar = '$' as const
12 | const escapeStart = '[' as const
13 | const escapeEnd = ']' as const
14 | const optionalStart = '(' as const
15 | const optionalEnd = ')' as const
16 |
17 | let routeModuleExts = ['.js', '.jsx', '.ts', '.tsx', '.md', '.mdx']
18 |
19 | function isRouteModuleFile(filename: string): boolean {
20 | return routeModuleExts.includes(path.extname(filename))
21 | }
22 |
23 | export type CreateRoutesFromFoldersOptions = {
24 | /**
25 | * The directory where your app lives. Defaults to `app`.
26 | * @default "app"
27 | */
28 | appDirectory?: string
29 | /**
30 | * A list of glob patterns to ignore when looking for route modules.
31 | * Defaults to `[]`.
32 | */
33 | ignoredFilePatterns?: string[]
34 | /**
35 | * The directory where your routes live. Defaults to `routes`.
36 | * This is relative to `appDirectory`.
37 | * @default "routes"
38 | */
39 | routesDirectory?: string
40 | }
41 |
42 | /**
43 | * Defines routes using the filesystem convention in `app/routes`. The rules are:
44 | *
45 | * - Route paths are derived from the file path. A `.` in the filename indicates
46 | * a `/` in the URL (a "nested" URL, but no route nesting). A `$` in the
47 | * filename indicates a dynamic URL segment.
48 | * - Subdirectories are used for nested routes.
49 | *
50 | * For example, a file named `app/routes/gists/$username.tsx` creates a route
51 | * with a path of `gists/:username`.
52 | */
53 | export function createRoutesFromFolders(
54 | defineRoutes: DefineRoutesFunction,
55 | options: CreateRoutesFromFoldersOptions = {},
56 | ): RouteManifest {
57 | let {
58 | appDirectory = 'app',
59 | ignoredFilePatterns = [],
60 | routesDirectory = 'routes',
61 | } = options
62 |
63 | let appRoutesDirectory = path.join(appDirectory, routesDirectory)
64 | if (!fs.existsSync(appRoutesDirectory)) {
65 | throw new Error(`Routes directory not found: ${appRoutesDirectory}`)
66 | }
67 | let files: { [routeId: string]: string } = {}
68 |
69 | // First, find all route modules in app/routes
70 | visitFiles(appRoutesDirectory, file => {
71 | if (
72 | ignoredFilePatterns.length > 0 &&
73 | ignoredFilePatterns.some(pattern => minimatch(file, pattern))
74 | ) {
75 | return
76 | }
77 |
78 | if (isRouteModuleFile(file)) {
79 | let relativePath = path.join(routesDirectory, file)
80 | let routeId = createRouteId(relativePath)
81 | files[routeId] = relativePath
82 | return
83 | }
84 |
85 | throw new Error(
86 | `Invalid route module file: ${path.join(appRoutesDirectory, file)}`,
87 | )
88 | })
89 |
90 | let routeIds = Object.keys(files).sort(byLongestFirst)
91 | let parentRouteIds = getParentRouteIds(routeIds)
92 | let uniqueRoutes = new Map()
93 |
94 | // Then, recurse through all routes using the public defineRoutes() API
95 | function defineNestedRoutes(
96 | defineRoute: DefineRouteFunction,
97 | parentId?: string,
98 | ): void {
99 | let childRouteIds = routeIds.filter(id => {
100 | return parentRouteIds[id] === parentId
101 | })
102 |
103 | for (let routeId of childRouteIds) {
104 | let routePath: string | undefined = createRoutePath(
105 | routeId.slice((parentId || routesDirectory).length + 1),
106 | )
107 |
108 | let isIndexRoute = routeId.endsWith('/index')
109 | let fullPath = createRoutePath(routeId.slice(routesDirectory.length + 1))
110 | let uniqueRouteId = (fullPath || '') + (isIndexRoute ? '?index' : '')
111 | let isPathlessLayoutRoute =
112 | routeId.split('/').pop()?.startsWith('__') === true
113 |
114 | /**
115 | * We do not try to detect path collisions for pathless layout route
116 | * files because, by definition, they create the potential for route
117 | * collisions _at that level in the tree_.
118 | *
119 | * Consider example where a user may want multiple pathless layout routes
120 | * for different subfolders
121 | *
122 | * routes/
123 | * account.tsx
124 | * account/
125 | * __public/
126 | * login.tsx
127 | * perks.tsx
128 | * __private/
129 | * orders.tsx
130 | * profile.tsx
131 | * __public.tsx
132 | * __private.tsx
133 | *
134 | * In order to support both a public and private layout for `/account/*`
135 | * URLs, we are creating a mutually exclusive set of URLs beneath 2
136 | * separate pathless layout routes. In this case, the route paths for
137 | * both account/__public.tsx and account/__private.tsx is the same
138 | * (/account), but we're again not expecting to match at that level.
139 | *
140 | * By only ignoring this check when the final portion of the filename is
141 | * pathless, we will still detect path collisions such as:
142 | *
143 | * routes/parent/__pathless/foo.tsx
144 | * routes/parent/__pathless2/foo.tsx
145 | *
146 | * and
147 | *
148 | * routes/parent/__pathless/index.tsx
149 | * routes/parent/__pathless2/index.tsx
150 | */
151 | if (uniqueRouteId && !isPathlessLayoutRoute) {
152 | if (uniqueRoutes.has(uniqueRouteId)) {
153 | throw new Error(
154 | `Path ${JSON.stringify(fullPath || '/')} defined by route ` +
155 | `${JSON.stringify(routeId)} conflicts with route ` +
156 | `${JSON.stringify(uniqueRoutes.get(uniqueRouteId))}`,
157 | )
158 | } else {
159 | uniqueRoutes.set(uniqueRouteId, routeId)
160 | }
161 | }
162 |
163 | if (isIndexRoute) {
164 | let invalidChildRoutes = routeIds.filter(
165 | id => parentRouteIds[id] === routeId,
166 | )
167 |
168 | if (invalidChildRoutes.length > 0) {
169 | throw new Error(
170 | `Child routes are not allowed in index routes. Please remove child routes of ${routeId}`,
171 | )
172 | }
173 |
174 | defineRoute(routePath, files[routeId], { index: true, id: routeId })
175 | } else {
176 | defineRoute(routePath, files[routeId], { id: routeId }, () => {
177 | defineNestedRoutes(defineRoute, routeId)
178 | })
179 | }
180 | }
181 | }
182 |
183 | return defineRoutes(defineNestedRoutes)
184 | }
185 |
186 | // TODO: Cleanup and write some tests for this function
187 | export function createRoutePath(partialRouteId: string): string | undefined {
188 | let result = ''
189 | let rawSegmentBuffer = ''
190 |
191 | let inEscapeSequence = 0
192 | let inOptionalSegment = 0
193 | let optionalSegmentIndex = null
194 | let skipSegment = false
195 | for (let i = 0; i < partialRouteId.length; i++) {
196 | let char = partialRouteId.charAt(i)
197 | let prevChar = i > 0 ? partialRouteId.charAt(i - 1) : undefined
198 | let nextChar =
199 | i < partialRouteId.length - 1 ? partialRouteId.charAt(i + 1) : undefined
200 |
201 | function isNewEscapeSequence() {
202 | return (
203 | !inEscapeSequence && char === escapeStart && prevChar !== escapeStart
204 | )
205 | }
206 |
207 | function isCloseEscapeSequence() {
208 | return inEscapeSequence && char === escapeEnd && nextChar !== escapeEnd
209 | }
210 |
211 | function isStartOfLayoutSegment() {
212 | return char === '_' && nextChar === '_' && !rawSegmentBuffer
213 | }
214 |
215 | function isNewOptionalSegment() {
216 | return (
217 | char === optionalStart &&
218 | prevChar !== optionalStart &&
219 | (isSegmentSeparator(prevChar) || prevChar === undefined) &&
220 | !inOptionalSegment &&
221 | !inEscapeSequence
222 | )
223 | }
224 |
225 | function isCloseOptionalSegment() {
226 | return (
227 | char === optionalEnd &&
228 | nextChar !== optionalEnd &&
229 | (isSegmentSeparator(nextChar) || nextChar === undefined) &&
230 | inOptionalSegment &&
231 | !inEscapeSequence
232 | )
233 | }
234 |
235 | if (skipSegment) {
236 | if (isSegmentSeparator(char)) {
237 | skipSegment = false
238 | }
239 | continue
240 | }
241 |
242 | if (isNewEscapeSequence()) {
243 | inEscapeSequence++
244 | continue
245 | }
246 |
247 | if (isCloseEscapeSequence()) {
248 | inEscapeSequence--
249 | continue
250 | }
251 |
252 | if (isNewOptionalSegment()) {
253 | inOptionalSegment++
254 | optionalSegmentIndex = result.length
255 | result += optionalStart
256 | continue
257 | }
258 |
259 | if (isCloseOptionalSegment()) {
260 | if (optionalSegmentIndex !== null) {
261 | result =
262 | result.slice(0, optionalSegmentIndex) +
263 | result.slice(optionalSegmentIndex + 1)
264 | }
265 | optionalSegmentIndex = null
266 | inOptionalSegment--
267 | result += '?'
268 | continue
269 | }
270 |
271 | if (inEscapeSequence) {
272 | result += char
273 | continue
274 | }
275 |
276 | if (isSegmentSeparator(char)) {
277 | if (rawSegmentBuffer === 'index' && result.endsWith('index')) {
278 | result = result.replace(/\/?index$/, '')
279 | } else {
280 | result += '/'
281 | }
282 |
283 | rawSegmentBuffer = ''
284 | inOptionalSegment = 0
285 | optionalSegmentIndex = null
286 | continue
287 | }
288 |
289 | if (isStartOfLayoutSegment()) {
290 | skipSegment = true
291 | continue
292 | }
293 |
294 | rawSegmentBuffer += char
295 |
296 | if (char === paramPrefixChar) {
297 | if (nextChar === optionalEnd) {
298 | throw new Error(
299 | `Invalid route path: ${partialRouteId}. Splat route $ is already optional`,
300 | )
301 | }
302 | result += typeof nextChar === 'undefined' ? '*' : ':'
303 | continue
304 | }
305 |
306 | result += char
307 | }
308 |
309 | if (rawSegmentBuffer === 'index' && result.endsWith('index')) {
310 | result = result.replace(/\/?index$/, '')
311 | } else {
312 | result = result.replace(/\/$/, '')
313 | }
314 |
315 | if (rawSegmentBuffer === 'index' && result.endsWith('index?')) {
316 | throw new Error(
317 | `Invalid route path: ${partialRouteId}. Make index route optional by using (index)`,
318 | )
319 | }
320 |
321 | return result || undefined
322 | }
323 |
324 | function isSegmentSeparator(checkChar: string | undefined) {
325 | if (!checkChar) return false
326 | return ['/', '.', path.win32.sep].includes(checkChar)
327 | }
328 |
329 | function getParentRouteIds(routeIds: string[]): Record {
330 | // We could use Array objects directly below, but Map is more performant,
331 | // especially for larger arrays of routeIds,
332 | // due to the faster lookups provided by the Map data structure.
333 | const routeIdMap = new Map();
334 | for (const routeId of routeIds) {
335 | routeIdMap.set(routeId, routeId);
336 | }
337 |
338 | const parentRouteIdMap = new Map();
339 | for (const [childRouteId, _] of routeIdMap) {
340 | let parentRouteId: string | undefined = undefined;
341 | for (const [potentialParentId, _] of routeIdMap) {
342 | if (childRouteId.startsWith(`${potentialParentId}/`)) {
343 | parentRouteId = potentialParentId;
344 | break;
345 | }
346 | }
347 |
348 | parentRouteIdMap.set(childRouteId, parentRouteId);
349 | }
350 |
351 | return Object.fromEntries(parentRouteIdMap);
352 | }
353 |
354 | function byLongestFirst(a: string, b: string): number {
355 | return b.length - a.length
356 | }
357 |
358 | function visitFiles(
359 | dir: string,
360 | visitor: (file: string) => void,
361 | baseDir = dir,
362 | ): void {
363 | for (let filename of fs.readdirSync(dir)) {
364 | let file = path.resolve(dir, filename)
365 | let stat = fs.lstatSync(file)
366 |
367 | if (stat.isDirectory()) {
368 | visitFiles(file, visitor, baseDir)
369 | } else if (stat.isFile()) {
370 | visitor(path.relative(baseDir, file))
371 | }
372 | }
373 | }
374 |
375 | /*
376 | eslint
377 | no-loop-func: "off",
378 | */
379 |
--------------------------------------------------------------------------------
/src/migrate.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs'
2 | import * as path from 'path'
3 | import { type RouteManifest } from './index'
4 | import { createRoutesFromFolders } from './lib'
5 | import { defineRoutes } from './routes'
6 |
7 | export type RoutingConvention = 'flat-files' | 'flat-folders' | 'hybrid'
8 | export type MigrateOptions = {
9 | convention: RoutingConvention
10 | force: boolean
11 | ignoredRouteFiles?: string[]
12 | }
13 |
14 | const pathSepRegex = new RegExp(`\\${path.sep}`, 'g')
15 | const routeExtensions = ['.js', '.jsx', '.ts', '.tsx', '.md', '.mdx']
16 |
17 | export function migrate(
18 | sourceDir: string,
19 | targetDir: string,
20 | options: MigrateOptions = {
21 | convention: 'flat-files',
22 | force: false,
23 | ignoredRouteFiles: undefined,
24 | },
25 | ) {
26 | if (sourceDir.startsWith('./')) {
27 | sourceDir = sourceDir.substring(2)
28 | }
29 | if (targetDir.startsWith('./')) {
30 | targetDir = targetDir.substring(2)
31 | }
32 |
33 | if (!options.ignoredRouteFiles) {
34 | // get remix.config.js
35 | const remixConfigPath = path.join(process.cwd(), 'remix.config.js')
36 | if (fs.existsSync(remixConfigPath)) {
37 | const remixConfig = require(remixConfigPath)
38 | options.ignoredRouteFiles = remixConfig.ignoredRouteFiles
39 | }
40 | }
41 |
42 | console.log(
43 | `🛠️ Migrating to flat-routes using ${options.convention} convention...`,
44 | )
45 | console.log(`🗂️ source: ${sourceDir}`)
46 | console.log(`🗂️ target: ${targetDir}`)
47 | console.log(`🙈ignored files: ${options.ignoredRouteFiles}`)
48 | console.log()
49 |
50 | const routes = createRoutesFromFolders(defineRoutes, {
51 | appDirectory: './',
52 | routesDirectory: sourceDir,
53 | ignoredFilePatterns: options.ignoredRouteFiles,
54 | })
55 |
56 | Object.entries(routes).forEach(([id, route]) => {
57 | let { path: routePath, file, parentId } = route
58 | let extension = path.extname(file)
59 | // skip non-route files if not ignored above
60 | if (!routeExtensions.includes(extension)) {
61 | return
62 | }
63 |
64 | let flat = convertToRoute(
65 | routes,
66 | sourceDir,
67 | id,
68 | parentId!,
69 | routePath!,
70 | !!route.index,
71 | options.convention,
72 | )
73 |
74 | // replace sourceDir with targetDir
75 | flat = path.join(targetDir, flat)
76 |
77 | switch (options.convention) {
78 | case 'flat-folders': {
79 | fs.mkdirSync(flat, { recursive: true })
80 | fs.cpSync(file, path.join(flat, `/route${extension}`), {
81 | force: true,
82 | })
83 | break
84 | }
85 | case 'hybrid': {
86 | fs.mkdirSync(path.dirname(flat), { recursive: true })
87 | const targetFile = `${flat}${extension}`
88 | fs.cpSync(file, targetFile, { force: true })
89 | break
90 | }
91 | case 'flat-files': {
92 | const targetFile = `${flat}${extension}`
93 | fs.cpSync(file, targetFile, { force: true })
94 | break
95 | }
96 | }
97 | })
98 | console.log('🏁 Finished!')
99 | }
100 |
101 | export function convertToRoute(
102 | routes: RouteManifest,
103 | sourceDir: string,
104 | id: string,
105 | parentId: string,
106 | routePath: string,
107 | index: boolean,
108 | convention: RoutingConvention,
109 | ) {
110 | // strip sourceDir from id and parentId
111 | let routeId = id.substring(sourceDir.length + 1)
112 | parentId =
113 | parentId === 'root' ? parentId : parentId.substring(sourceDir.length + 1)
114 |
115 | if (parentId && parentId !== 'root') {
116 | if (convention !== 'hybrid' && routePath?.includes('/')) {
117 | // multi-segment route, so need to fixup parent for flat-routes (trailing _)
118 | // strip parent route from route
119 | let currentPath = routeId.substring(parentId.length + 1)
120 | let routeSegments = getRouteSegments(currentPath)
121 | let dottedSegments = getEscapedDottedRouteSegments(routeSegments[0])
122 | console.log({ routeSegments, dottedSegments })
123 | if (dottedSegments.length > 1) {
124 | const [first, ...rest] = dottedSegments
125 | //rewrite id to use trailing _ for parent
126 | routeId = `${parentId}/${first}_.${rest.join('.')}`
127 | if (routeSegments.length > 1) {
128 | routeSegments.shift()
129 | routeId += `/${routeSegments.join('.')}`
130 | }
131 | } else {
132 | routeId = `${parentId}/${routeSegments.join('.')}`
133 | }
134 | }
135 | }
136 |
137 | if (convention === 'hybrid') {
138 | let flat = routeId
139 | // convert path separators /+ hybrid format
140 | .replace(pathSepRegex, '+/')
141 | // convert single _ to [_] due to conflict with new pathless layout prefix
142 | .replace(/(^|\/|\.)_([^_.])/g, '$1[_]$2')
143 | // convert double __ to single _ for pathless layout prefix
144 | .replace(/(^|\/|\.)__/g, '$1_')
145 | // convert index to _index for index routes
146 | .replace(/(^|\/|\.)index$/, '$1_index')
147 |
148 | // check if route is a parent route
149 | // is so, move to hybrid folder (+) as _layout route
150 | if (Object.values(routes).some(r => r.parentId === id)) {
151 | flat = flat + '+/_layout'
152 | }
153 |
154 | return flat
155 | }
156 | // convert to flat route convention
157 | let flat = routeId
158 | // convert path separators to dots
159 | .replace(pathSepRegex, '.')
160 | // convert single _ to [_] due to conflict with new pathless layout prefix
161 | .replace(/(^|\/|\.)_([^_.])/g, '$1[_]$2')
162 | // convert double __ to single _ for pathless layout prefix
163 | .replace(/(^|\/|\.)__/g, '$1_')
164 | // convert index to _index for index routes
165 | .replace(/(^|\/|\.)index$/, '$1_index')
166 |
167 | return flat
168 | }
169 |
170 | function getRouteSegments(routePath: string) {
171 | return routePath.split('/')
172 | }
173 |
174 | function getEscapedDottedRouteSegments(name: string) {
175 | let routeSegments: string[] = []
176 | let i = 0
177 | let routeSegment = ''
178 | let state = 'START'
179 | let subState = 'NORMAL'
180 |
181 | let pushRouteSegment = (routeSegment: string) => {
182 | if (routeSegment) {
183 | routeSegments.push(routeSegment)
184 | }
185 | }
186 |
187 | while (i < name.length) {
188 | let char = name[i]
189 | switch (state) {
190 | case 'START':
191 | // process existing segment
192 | pushRouteSegment(routeSegment)
193 | routeSegment = ''
194 | state = 'PATH'
195 | continue // restart without advancing index
196 | case 'PATH':
197 | if (char === '.' && subState === 'NORMAL') {
198 | state = 'START'
199 | break
200 | } else if (char === '[') {
201 | subState = 'ESCAPE'
202 | break
203 | } else if (char === ']') {
204 | subState = 'NORMAL'
205 | break
206 | }
207 | routeSegment += char
208 | break
209 | }
210 | i++ // advance to next character
211 | }
212 | // process remaining segment
213 | pushRouteSegment(routeSegment)
214 | return routeSegments
215 | }
216 |
--------------------------------------------------------------------------------
/src/routes.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'path'
2 |
3 | /**
4 | * A route that was created using `defineRoutes` or created conventionally from
5 | * looking at the files on the filesystem.
6 | */
7 | export interface ConfigRoute {
8 | /**
9 | * The path this route uses to match on the URL pathname.
10 | */
11 | path?: string
12 |
13 | /**
14 | * Should be `true` if it is an index route. This disallows child routes.
15 | */
16 | index?: boolean
17 |
18 | /**
19 | * Should be `true` if the `path` is case-sensitive. Defaults to `false`.
20 | */
21 | caseSensitive?: boolean
22 |
23 | /**
24 | * The unique id for this route, named like its `file` but without the
25 | * extension. So `app/routes/gists/$username.jsx` will have an `id` of
26 | * `routes/gists/$username`.
27 | */
28 | id: string
29 |
30 | /**
31 | * The unique `id` for this route's parent route, if there is one.
32 | */
33 | parentId?: string
34 |
35 | /**
36 | * The path to the entry point for this route, relative to
37 | * `config.appDirectory`.
38 | */
39 | file: string
40 | }
41 |
42 | export interface RouteManifest {
43 | [routeId: string]: ConfigRoute
44 | }
45 |
46 | export interface DefineRouteOptions {
47 | /**
48 | * Should be `true` if the route `path` is case-sensitive. Defaults to
49 | * `false`.
50 | */
51 | caseSensitive?: boolean
52 |
53 | /**
54 | * Should be `true` if this is an index route that does not allow child routes.
55 | */
56 | index?: boolean
57 |
58 | /**
59 | * An optional unique id string for this route. Use this if you need to aggregate
60 | * two or more routes with the same route file.
61 | */
62 | id?: string
63 | }
64 |
65 | interface DefineRouteChildren {
66 | (): void
67 | }
68 |
69 | /**
70 | * A function for defining a route that is passed as the argument to the
71 | * `defineRoutes` callback.
72 | *
73 | * Calls to this function are designed to be nested, using the `children`
74 | * callback argument.
75 | *
76 | * defineRoutes(route => {
77 | * route('/', 'pages/layout', () => {
78 | * route('react-router', 'pages/react-router');
79 | * route('reach-ui', 'pages/reach-ui');
80 | * });
81 | * });
82 | */
83 | export interface DefineRouteFunction {
84 | (
85 | /**
86 | * The path this route uses to match the URL pathname.
87 | */
88 | path: string | undefined,
89 |
90 | /**
91 | * The path to the file that exports the React component rendered by this
92 | * route as its default export, relative to the `app` directory.
93 | */
94 | file: string,
95 |
96 | /**
97 | * Options for defining routes, or a function for defining child routes.
98 | */
99 | optionsOrChildren?: DefineRouteOptions | DefineRouteChildren,
100 |
101 | /**
102 | * A function for defining child routes.
103 | */
104 | children?: DefineRouteChildren,
105 | ): void
106 | }
107 |
108 | export type DefineRoutesFunction = typeof defineRoutes
109 |
110 | /**
111 | * A function for defining routes programmatically, instead of using the
112 | * filesystem convention.
113 | */
114 | export function defineRoutes(
115 | callback: (defineRoute: DefineRouteFunction) => void,
116 | ): RouteManifest {
117 | let routes: RouteManifest = Object.create(null)
118 | let parentRoutes: ConfigRoute[] = []
119 | let alreadyReturned = false
120 |
121 | let defineRoute: DefineRouteFunction = (
122 | path,
123 | file,
124 | optionsOrChildren,
125 | children,
126 | ) => {
127 | if (alreadyReturned) {
128 | throw new Error(
129 | 'You tried to define routes asynchronously but started defining ' +
130 | 'routes before the async work was done. Please await all async ' +
131 | 'data before calling `defineRoutes()`',
132 | )
133 | }
134 |
135 | let options: DefineRouteOptions
136 | if (typeof optionsOrChildren === 'function') {
137 | // route(path, file, children)
138 | options = {}
139 | children = optionsOrChildren
140 | } else {
141 | // route(path, file, options, children)
142 | // route(path, file, options)
143 | options = optionsOrChildren || {}
144 | }
145 |
146 | let route: ConfigRoute = {
147 | path: path ? path : undefined,
148 | index: options.index ? true : undefined,
149 | caseSensitive: options.caseSensitive ? true : undefined,
150 | id: options.id || createRouteId(file),
151 | parentId:
152 | parentRoutes.length > 0
153 | ? parentRoutes[parentRoutes.length - 1].id
154 | : 'root',
155 | file,
156 | }
157 |
158 | if (route.id in routes) {
159 | throw new Error(
160 | `Unable to define routes with duplicate route id: "${route.id}"`,
161 | )
162 | }
163 |
164 | routes[route.id] = route
165 |
166 | if (children) {
167 | parentRoutes.push(route)
168 | children()
169 | parentRoutes.pop()
170 | }
171 | }
172 |
173 | callback(defineRoute)
174 |
175 | alreadyReturned = true
176 |
177 | return routes
178 | }
179 |
180 | export function createRouteId(file: string) {
181 | return normalizeSlashes(stripFileExtension(file))
182 | }
183 |
184 | export function normalizeSlashes(file: string) {
185 | return file.split(path.win32.sep).join('/')
186 | }
187 |
188 | function stripFileExtension(file: string) {
189 | return file.replace(/\.[a-z0-9]+$/i, '')
190 | }
191 |
--------------------------------------------------------------------------------
/test/__snapshots__/index.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`define folders for flat-files should define routes for flat-files with folders 1`] = `
4 | {
5 | "routes/_auth+/forgot-password": {
6 | "caseSensitive": undefined,
7 | "file": "routes/_auth+/forgot-password.tsx",
8 | "id": "routes/_auth+/forgot-password",
9 | "index": undefined,
10 | "parentId": "root",
11 | "path": "forgot-password",
12 | },
13 | "routes/_auth+/login": {
14 | "caseSensitive": undefined,
15 | "file": "routes/_auth+/login.tsx",
16 | "id": "routes/_auth+/login",
17 | "index": undefined,
18 | "parentId": "root",
19 | "path": "login",
20 | },
21 | "routes/_public+/_layout": {
22 | "caseSensitive": undefined,
23 | "file": "routes/_public+/_layout.tsx",
24 | "id": "routes/_public+/_layout",
25 | "index": undefined,
26 | "parentId": "root",
27 | "path": undefined,
28 | },
29 | "routes/_public+/about": {
30 | "caseSensitive": undefined,
31 | "file": "routes/_public+/about.tsx",
32 | "id": "routes/_public+/about",
33 | "index": undefined,
34 | "parentId": "routes/_public+/_layout",
35 | "path": "about",
36 | },
37 | "routes/_public+/contact[.jpg]": {
38 | "caseSensitive": undefined,
39 | "file": "routes/_public+/contact[.jpg].tsx",
40 | "id": "routes/_public+/contact[.jpg]",
41 | "index": undefined,
42 | "parentId": "routes/_public+/_layout",
43 | "path": "contact.jpg",
44 | },
45 | "routes/_public+/index": {
46 | "caseSensitive": undefined,
47 | "file": "routes/_public+/index.tsx",
48 | "id": "routes/_public+/index",
49 | "index": true,
50 | "parentId": "routes/_public+/_layout",
51 | "path": undefined,
52 | },
53 | "routes/users+/$userId": {
54 | "caseSensitive": undefined,
55 | "file": "routes/users+/$userId.tsx",
56 | "id": "routes/users+/$userId",
57 | "index": undefined,
58 | "parentId": "routes/users+/route",
59 | "path": ":userId",
60 | },
61 | "routes/users+/$userId_.edit": {
62 | "caseSensitive": undefined,
63 | "file": "routes/users+/$userId_.edit.tsx",
64 | "id": "routes/users+/$userId_.edit",
65 | "index": undefined,
66 | "parentId": "routes/users+/route",
67 | "path": ":userId/edit",
68 | },
69 | "routes/users+/_layout": {
70 | "caseSensitive": undefined,
71 | "file": "routes/users+/_layout.tsx",
72 | "id": "routes/users+/_layout",
73 | "index": undefined,
74 | "parentId": "root",
75 | "path": "users",
76 | },
77 | "routes/users+/route": {
78 | "caseSensitive": undefined,
79 | "file": "routes/users+/route.tsx",
80 | "id": "routes/users+/route",
81 | "index": undefined,
82 | "parentId": "root",
83 | "path": "users",
84 | },
85 | }
86 | `;
87 |
88 | exports[`define folders for flat-files with overridden nested folder character should define routes for flat-files with folders 1`] = `
89 | {
90 | "routes/_auth-/forgot-password": {
91 | "caseSensitive": undefined,
92 | "file": "routes/_auth-/forgot-password.tsx",
93 | "id": "routes/_auth-/forgot-password",
94 | "index": undefined,
95 | "parentId": "root",
96 | "path": "forgot-password",
97 | },
98 | "routes/_auth-/login": {
99 | "caseSensitive": undefined,
100 | "file": "routes/_auth-/login.tsx",
101 | "id": "routes/_auth-/login",
102 | "index": undefined,
103 | "parentId": "root",
104 | "path": "login",
105 | },
106 | "routes/_public-/_layout": {
107 | "caseSensitive": undefined,
108 | "file": "routes/_public-/_layout.tsx",
109 | "id": "routes/_public-/_layout",
110 | "index": undefined,
111 | "parentId": "root",
112 | "path": undefined,
113 | },
114 | "routes/_public-/about": {
115 | "caseSensitive": undefined,
116 | "file": "routes/_public-/about.tsx",
117 | "id": "routes/_public-/about",
118 | "index": undefined,
119 | "parentId": "routes/_public-/_layout",
120 | "path": "about",
121 | },
122 | "routes/_public-/contact[.jpg]": {
123 | "caseSensitive": undefined,
124 | "file": "routes/_public-/contact[.jpg].tsx",
125 | "id": "routes/_public-/contact[.jpg]",
126 | "index": undefined,
127 | "parentId": "routes/_public-/_layout",
128 | "path": "contact.jpg",
129 | },
130 | "routes/_public-/index": {
131 | "caseSensitive": undefined,
132 | "file": "routes/_public-/index.tsx",
133 | "id": "routes/_public-/index",
134 | "index": true,
135 | "parentId": "routes/_public-/_layout",
136 | "path": undefined,
137 | },
138 | "routes/users-/$userId": {
139 | "caseSensitive": undefined,
140 | "file": "routes/users-/$userId.tsx",
141 | "id": "routes/users-/$userId",
142 | "index": undefined,
143 | "parentId": "routes/users-/route",
144 | "path": ":userId",
145 | },
146 | "routes/users-/$userId_.edit": {
147 | "caseSensitive": undefined,
148 | "file": "routes/users-/$userId_.edit.tsx",
149 | "id": "routes/users-/$userId_.edit",
150 | "index": undefined,
151 | "parentId": "routes/users-/route",
152 | "path": ":userId/edit",
153 | },
154 | "routes/users-/_layout": {
155 | "caseSensitive": undefined,
156 | "file": "routes/users-/_layout.tsx",
157 | "id": "routes/users-/_layout",
158 | "index": undefined,
159 | "parentId": "root",
160 | "path": "users",
161 | },
162 | "routes/users-/route": {
163 | "caseSensitive": undefined,
164 | "file": "routes/users-/route.tsx",
165 | "id": "routes/users-/route",
166 | "index": undefined,
167 | "parentId": "root",
168 | "path": "users",
169 | },
170 | }
171 | `;
172 |
173 | exports[`define hybrid routes should define routes for hybrid routes 1`] = `
174 | {
175 | "routes/_index/route": {
176 | "caseSensitive": undefined,
177 | "file": "routes/_index/route.tsx",
178 | "id": "routes/_index/route",
179 | "index": true,
180 | "parentId": "root",
181 | "path": undefined,
182 | },
183 | "routes/_public/_layout": {
184 | "caseSensitive": undefined,
185 | "file": "routes/_public/_layout.tsx",
186 | "id": "routes/_public/_layout",
187 | "index": undefined,
188 | "parentId": "root",
189 | "path": undefined,
190 | },
191 | "routes/_public/about/route": {
192 | "caseSensitive": undefined,
193 | "file": "routes/_public/about/route.tsx",
194 | "id": "routes/_public/about/route",
195 | "index": undefined,
196 | "parentId": "routes/_public/_layout",
197 | "path": "about",
198 | },
199 | "routes/_public/contact[.jpg]/route": {
200 | "caseSensitive": undefined,
201 | "file": "routes/_public/contact[.jpg]/route.tsx",
202 | "id": "routes/_public/contact[.jpg]/route",
203 | "index": undefined,
204 | "parentId": "routes/_public/_layout",
205 | "path": "contact.jpg",
206 | },
207 | "routes/test.$/route": {
208 | "caseSensitive": undefined,
209 | "file": "routes/test.$/route.tsx",
210 | "id": "routes/test.$/route",
211 | "index": undefined,
212 | "parentId": "root",
213 | "path": "test/*",
214 | },
215 | "routes/users/$userId/route": {
216 | "caseSensitive": undefined,
217 | "file": "routes/users/$userId/route.tsx",
218 | "id": "routes/users/$userId/route",
219 | "index": undefined,
220 | "parentId": "routes/users/route/route",
221 | "path": ":userId",
222 | },
223 | "routes/users/$userId_.edit/route": {
224 | "caseSensitive": undefined,
225 | "file": "routes/users/$userId_.edit/route.tsx",
226 | "id": "routes/users/$userId_.edit/route",
227 | "index": undefined,
228 | "parentId": "routes/users/route/route",
229 | "path": ":userId/edit",
230 | },
231 | "routes/users/_layout": {
232 | "caseSensitive": undefined,
233 | "file": "routes/users/_layout.tsx",
234 | "id": "routes/users/_layout",
235 | "index": undefined,
236 | "parentId": "root",
237 | "path": "users",
238 | },
239 | "routes/users/route/route": {
240 | "caseSensitive": undefined,
241 | "file": "routes/users/route/route.tsx",
242 | "id": "routes/users/route/route",
243 | "index": undefined,
244 | "parentId": "root",
245 | "path": "users",
246 | },
247 | }
248 | `;
249 |
250 | exports[`define ignored routes should ignore routes for flat-files 1`] = `
251 | {
252 | "routes/$lang.$ref": {
253 | "caseSensitive": undefined,
254 | "file": "routes/$lang.$ref.tsx",
255 | "id": "routes/$lang.$ref",
256 | "index": undefined,
257 | "parentId": "root",
258 | "path": ":lang/:ref",
259 | },
260 | "routes/$lang.$ref.$": {
261 | "caseSensitive": undefined,
262 | "file": "routes/$lang.$ref.$.tsx",
263 | "id": "routes/$lang.$ref.$",
264 | "index": undefined,
265 | "parentId": "routes/$lang.$ref",
266 | "path": "*",
267 | },
268 | "routes/$lang.$ref._index": {
269 | "caseSensitive": undefined,
270 | "file": "routes/$lang.$ref._index.tsx",
271 | "id": "routes/$lang.$ref._index",
272 | "index": true,
273 | "parentId": "routes/$lang.$ref",
274 | "path": undefined,
275 | },
276 | "routes/_index": {
277 | "caseSensitive": undefined,
278 | "file": "routes/_index.tsx",
279 | "id": "routes/_index",
280 | "index": true,
281 | "parentId": "root",
282 | "path": undefined,
283 | },
284 | "routes/healthcheck": {
285 | "caseSensitive": undefined,
286 | "file": "routes/healthcheck.tsx",
287 | "id": "routes/healthcheck",
288 | "index": undefined,
289 | "parentId": "root",
290 | "path": "healthcheck",
291 | },
292 | }
293 | `;
294 |
295 | exports[`define routes should allow routes to specify different parent routes 1`] = `
296 | {
297 | "routes/parent": {
298 | "caseSensitive": undefined,
299 | "file": "routes/parent.tsx",
300 | "id": "routes/parent",
301 | "index": undefined,
302 | "parentId": "root",
303 | "path": "parent",
304 | },
305 | "routes/parent.some.nested": {
306 | "caseSensitive": undefined,
307 | "file": "routes/parent.some.nested.tsx",
308 | "id": "routes/parent.some.nested",
309 | "index": undefined,
310 | "parentId": "routes/parent",
311 | "path": "some/nested",
312 | },
313 | "routes/parent.some_.nested.page": {
314 | "caseSensitive": undefined,
315 | "file": "routes/parent.some_.nested.page.tsx",
316 | "id": "routes/parent.some_.nested.page",
317 | "index": undefined,
318 | "parentId": "routes/parent",
319 | "path": "some/nested/page",
320 | },
321 | }
322 | `;
323 |
324 | exports[`define routes should correctly nest routes 1`] = `
325 | {
326 | "routes/app.$organizationSlug": {
327 | "caseSensitive": undefined,
328 | "file": "routes/app.$organizationSlug.tsx",
329 | "id": "routes/app.$organizationSlug",
330 | "index": undefined,
331 | "parentId": "root",
332 | "path": "app/:organizationSlug",
333 | },
334 | "routes/app.$organizationSlug.edit": {
335 | "caseSensitive": undefined,
336 | "file": "routes/app.$organizationSlug.edit.tsx",
337 | "id": "routes/app.$organizationSlug.edit",
338 | "index": undefined,
339 | "parentId": "routes/app.$organizationSlug",
340 | "path": "edit",
341 | },
342 | "routes/app.$organizationSlug.projects": {
343 | "caseSensitive": undefined,
344 | "file": "routes/app.$organizationSlug.projects.tsx",
345 | "id": "routes/app.$organizationSlug.projects",
346 | "index": undefined,
347 | "parentId": "routes/app.$organizationSlug",
348 | "path": "projects",
349 | },
350 | "routes/app.$organizationSlug.projects.$projectId": {
351 | "caseSensitive": undefined,
352 | "file": "routes/app.$organizationSlug.projects.$projectId.tsx",
353 | "id": "routes/app.$organizationSlug.projects.$projectId",
354 | "index": undefined,
355 | "parentId": "routes/app.$organizationSlug.projects",
356 | "path": ":projectId",
357 | },
358 | "routes/app.$organizationSlug.projects.$projectId.edit": {
359 | "caseSensitive": undefined,
360 | "file": "routes/app.$organizationSlug.projects.$projectId.edit.tsx",
361 | "id": "routes/app.$organizationSlug.projects.$projectId.edit",
362 | "index": undefined,
363 | "parentId": "routes/app.$organizationSlug.projects.$projectId",
364 | "path": "edit",
365 | },
366 | "routes/app.$organizationSlug.projects.new": {
367 | "caseSensitive": undefined,
368 | "file": "routes/app.$organizationSlug.projects.new.tsx",
369 | "id": "routes/app.$organizationSlug.projects.new",
370 | "index": undefined,
371 | "parentId": "routes/app.$organizationSlug.projects",
372 | "path": "new",
373 | },
374 | }
375 | `;
376 |
377 | exports[`define routes should define routes for complex structure 1`] = `
378 | {
379 | "routes/_auth": {
380 | "caseSensitive": undefined,
381 | "file": "routes/_auth.tsx",
382 | "id": "routes/_auth",
383 | "index": undefined,
384 | "parentId": "root",
385 | "path": undefined,
386 | },
387 | "routes/_auth.forgot-password": {
388 | "caseSensitive": undefined,
389 | "file": "routes/_auth.forgot-password.tsx",
390 | "id": "routes/_auth.forgot-password",
391 | "index": undefined,
392 | "parentId": "routes/_auth",
393 | "path": "forgot-password",
394 | },
395 | "routes/_auth.login": {
396 | "caseSensitive": undefined,
397 | "file": "routes/_auth.login.tsx",
398 | "id": "routes/_auth.login",
399 | "index": undefined,
400 | "parentId": "routes/_auth",
401 | "path": "login",
402 | },
403 | "routes/_auth.reset-password": {
404 | "caseSensitive": undefined,
405 | "file": "routes/_auth.reset-password.tsx",
406 | "id": "routes/_auth.reset-password",
407 | "index": undefined,
408 | "parentId": "routes/_auth",
409 | "path": "reset-password",
410 | },
411 | "routes/_auth.signup": {
412 | "caseSensitive": undefined,
413 | "file": "routes/_auth.signup.tsx",
414 | "id": "routes/_auth.signup",
415 | "index": undefined,
416 | "parentId": "routes/_auth",
417 | "path": "signup",
418 | },
419 | "routes/_landing": {
420 | "caseSensitive": undefined,
421 | "file": "routes/_landing.tsx",
422 | "id": "routes/_landing",
423 | "index": undefined,
424 | "parentId": "root",
425 | "path": undefined,
426 | },
427 | "routes/_landing.about": {
428 | "caseSensitive": undefined,
429 | "file": "routes/_landing.about.tsx",
430 | "id": "routes/_landing.about",
431 | "index": undefined,
432 | "parentId": "routes/_landing",
433 | "path": "about",
434 | },
435 | "routes/_landing.index": {
436 | "caseSensitive": undefined,
437 | "file": "routes/_landing.index.tsx",
438 | "id": "routes/_landing.index",
439 | "index": true,
440 | "parentId": "routes/_landing",
441 | "path": undefined,
442 | },
443 | "routes/app": {
444 | "caseSensitive": undefined,
445 | "file": "routes/app.tsx",
446 | "id": "routes/app",
447 | "index": undefined,
448 | "parentId": "root",
449 | "path": "app",
450 | },
451 | "routes/app.calendar": {
452 | "caseSensitive": undefined,
453 | "file": "routes/app.calendar.tsx",
454 | "id": "routes/app.calendar",
455 | "index": undefined,
456 | "parentId": "routes/app",
457 | "path": "calendar",
458 | },
459 | "routes/app.calendar.$day": {
460 | "caseSensitive": undefined,
461 | "file": "routes/app.calendar.$day.tsx",
462 | "id": "routes/app.calendar.$day",
463 | "index": undefined,
464 | "parentId": "routes/app.calendar",
465 | "path": ":day",
466 | },
467 | "routes/app.calendar.index": {
468 | "caseSensitive": undefined,
469 | "file": "routes/app.calendar.index.tsx",
470 | "id": "routes/app.calendar.index",
471 | "index": true,
472 | "parentId": "routes/app.calendar",
473 | "path": undefined,
474 | },
475 | "routes/app.projects": {
476 | "caseSensitive": undefined,
477 | "file": "routes/app.projects.tsx",
478 | "id": "routes/app.projects",
479 | "index": undefined,
480 | "parentId": "routes/app",
481 | "path": "projects",
482 | },
483 | "routes/app.projects.$id": {
484 | "caseSensitive": undefined,
485 | "file": "routes/app.projects.$id.tsx",
486 | "id": "routes/app.projects.$id",
487 | "index": undefined,
488 | "parentId": "routes/app.projects",
489 | "path": ":id",
490 | },
491 | "routes/app_.projects.$id.roadmap": {
492 | "caseSensitive": undefined,
493 | "file": "routes/app_.projects.$id.roadmap.tsx",
494 | "id": "routes/app_.projects.$id.roadmap",
495 | "index": undefined,
496 | "parentId": "root",
497 | "path": "app/projects/:id/roadmap",
498 | },
499 | "routes/app_.projects.$id.roadmap[.pdf]": {
500 | "caseSensitive": undefined,
501 | "file": "routes/app_.projects.$id.roadmap[.pdf].tsx",
502 | "id": "routes/app_.projects.$id.roadmap[.pdf]",
503 | "index": undefined,
504 | "parentId": "root",
505 | "path": "app/projects/:id/roadmap.pdf",
506 | },
507 | }
508 | `;
509 |
510 | exports[`define routes should define routes for flat-files 1`] = `
511 | {
512 | "routes/$lang.$ref": {
513 | "caseSensitive": undefined,
514 | "file": "routes/$lang.$ref.tsx",
515 | "id": "routes/$lang.$ref",
516 | "index": undefined,
517 | "parentId": "root",
518 | "path": ":lang/:ref",
519 | },
520 | "routes/$lang.$ref.$": {
521 | "caseSensitive": undefined,
522 | "file": "routes/$lang.$ref.$.tsx",
523 | "id": "routes/$lang.$ref.$",
524 | "index": undefined,
525 | "parentId": "routes/$lang.$ref",
526 | "path": "*",
527 | },
528 | "routes/$lang.$ref._index": {
529 | "caseSensitive": undefined,
530 | "file": "routes/$lang.$ref._index.tsx",
531 | "id": "routes/$lang.$ref._index",
532 | "index": true,
533 | "parentId": "routes/$lang.$ref",
534 | "path": undefined,
535 | },
536 | "routes/_index": {
537 | "caseSensitive": undefined,
538 | "file": "routes/_index.tsx",
539 | "id": "routes/_index",
540 | "index": true,
541 | "parentId": "root",
542 | "path": undefined,
543 | },
544 | "routes/healthcheck": {
545 | "caseSensitive": undefined,
546 | "file": "routes/healthcheck.tsx",
547 | "id": "routes/healthcheck",
548 | "index": undefined,
549 | "parentId": "root",
550 | "path": "healthcheck",
551 | },
552 | }
553 | `;
554 |
555 | exports[`define routes should define routes for flat-folders 1`] = `
556 | {
557 | "routes/$lang.$ref.$/route": {
558 | "caseSensitive": undefined,
559 | "file": "routes/$lang.$ref.$/route.tsx",
560 | "id": "routes/$lang.$ref.$/route",
561 | "index": undefined,
562 | "parentId": "routes/$lang.$ref/route",
563 | "path": "*",
564 | },
565 | "routes/$lang.$ref._index/route": {
566 | "caseSensitive": undefined,
567 | "file": "routes/$lang.$ref._index/route.tsx",
568 | "id": "routes/$lang.$ref._index/route",
569 | "index": true,
570 | "parentId": "routes/$lang.$ref/route",
571 | "path": undefined,
572 | },
573 | "routes/$lang.$ref/route": {
574 | "caseSensitive": undefined,
575 | "file": "routes/$lang.$ref/route.tsx",
576 | "id": "routes/$lang.$ref/route",
577 | "index": undefined,
578 | "parentId": "root",
579 | "path": ":lang/:ref",
580 | },
581 | "routes/_index/route": {
582 | "caseSensitive": undefined,
583 | "file": "routes/_index/route.tsx",
584 | "id": "routes/_index/route",
585 | "index": true,
586 | "parentId": "root",
587 | "path": undefined,
588 | },
589 | "routes/healthcheck/route": {
590 | "caseSensitive": undefined,
591 | "file": "routes/healthcheck/route.tsx",
592 | "id": "routes/healthcheck/route",
593 | "index": undefined,
594 | "parentId": "root",
595 | "path": "healthcheck",
596 | },
597 | }
598 | `;
599 |
600 | exports[`define routes should define routes for flat-folders on Windows 1`] = `
601 | {
602 | "routes/$lang.$ref.$/route": {
603 | "caseSensitive": undefined,
604 | "file": "routes/$lang.$ref.$/route.tsx",
605 | "id": "routes/$lang.$ref.$/route",
606 | "index": undefined,
607 | "parentId": "routes/$lang.$ref/route",
608 | "path": "*",
609 | },
610 | "routes/$lang.$ref._index/route": {
611 | "caseSensitive": undefined,
612 | "file": "routes/$lang.$ref._index/route.tsx",
613 | "id": "routes/$lang.$ref._index/route",
614 | "index": true,
615 | "parentId": "routes/$lang.$ref/route",
616 | "path": undefined,
617 | },
618 | "routes/$lang.$ref/route": {
619 | "caseSensitive": undefined,
620 | "file": "routes/$lang.$ref/route.tsx",
621 | "id": "routes/$lang.$ref/route",
622 | "index": undefined,
623 | "parentId": "root",
624 | "path": ":lang/:ref",
625 | },
626 | "routes/_index/route": {
627 | "caseSensitive": undefined,
628 | "file": "routes/_index/route.tsx",
629 | "id": "routes/_index/route",
630 | "index": true,
631 | "parentId": "root",
632 | "path": undefined,
633 | },
634 | "routes/healthcheck/route": {
635 | "caseSensitive": undefined,
636 | "file": "routes/healthcheck/route.tsx",
637 | "id": "routes/healthcheck/route",
638 | "index": undefined,
639 | "parentId": "root",
640 | "path": "healthcheck",
641 | },
642 | }
643 | `;
644 |
645 | exports[`define routes should handle params with trailing underscore 1`] = `
646 | {
647 | "routes/app.$organizationSlug_._projects": {
648 | "caseSensitive": undefined,
649 | "file": "routes/app.$organizationSlug_._projects.tsx",
650 | "id": "routes/app.$organizationSlug_._projects",
651 | "index": undefined,
652 | "parentId": "root",
653 | "path": "app/:organizationSlug",
654 | },
655 | "routes/app.$organizationSlug_._projects.projects.$projectId": {
656 | "caseSensitive": undefined,
657 | "file": "routes/app.$organizationSlug_._projects.projects.$projectId.tsx",
658 | "id": "routes/app.$organizationSlug_._projects.projects.$projectId",
659 | "index": undefined,
660 | "parentId": "routes/app.$organizationSlug_._projects",
661 | "path": "projects/:projectId",
662 | },
663 | "routes/app.$organizationSlug_._projects.projects.$projectId.edit": {
664 | "caseSensitive": undefined,
665 | "file": "routes/app.$organizationSlug_._projects.projects.$projectId.edit.tsx",
666 | "id": "routes/app.$organizationSlug_._projects.projects.$projectId.edit",
667 | "index": undefined,
668 | "parentId": "routes/app.$organizationSlug_._projects.projects.$projectId",
669 | "path": "edit",
670 | },
671 | "routes/app.$organizationSlug_._projects.projects.new": {
672 | "caseSensitive": undefined,
673 | "file": "routes/app.$organizationSlug_._projects.projects.new.tsx",
674 | "id": "routes/app.$organizationSlug_._projects.projects.new",
675 | "index": undefined,
676 | "parentId": "routes/app.$organizationSlug_._projects",
677 | "path": "projects/new",
678 | },
679 | }
680 | `;
681 |
682 | exports[`define routes should ignore non-route files in flat-folders 1`] = `
683 | {
684 | "routes/$lang.$ref._index/route": {
685 | "caseSensitive": undefined,
686 | "file": "routes/$lang.$ref._index/route.tsx",
687 | "id": "routes/$lang.$ref._index/route",
688 | "index": true,
689 | "parentId": "routes/$lang.$ref/_layout",
690 | "path": undefined,
691 | },
692 | "routes/$lang.$ref/_layout": {
693 | "caseSensitive": undefined,
694 | "file": "routes/$lang.$ref/_layout.tsx",
695 | "id": "routes/$lang.$ref/_layout",
696 | "index": undefined,
697 | "parentId": "root",
698 | "path": ":lang/:ref",
699 | },
700 | "routes/_index/route": {
701 | "caseSensitive": undefined,
702 | "file": "routes/_index/route.tsx",
703 | "id": "routes/_index/route",
704 | "index": true,
705 | "parentId": "root",
706 | "path": undefined,
707 | },
708 | "routes/healthcheck/route": {
709 | "caseSensitive": undefined,
710 | "file": "routes/healthcheck/route.tsx",
711 | "id": "routes/healthcheck/route",
712 | "index": undefined,
713 | "parentId": "root",
714 | "path": "healthcheck",
715 | },
716 | }
717 | `;
718 |
719 | exports[`define routes should support markdown routes as flat-files 1`] = `
720 | {
721 | "routes/docs": {
722 | "caseSensitive": undefined,
723 | "file": "routes/docs.tsx",
724 | "id": "routes/docs",
725 | "index": undefined,
726 | "parentId": "root",
727 | "path": "docs",
728 | },
729 | "routes/docs.readme": {
730 | "caseSensitive": undefined,
731 | "file": "routes/docs.readme.md",
732 | "id": "routes/docs.readme",
733 | "index": undefined,
734 | "parentId": "routes/docs",
735 | "path": "readme",
736 | },
737 | }
738 | `;
739 |
740 | exports[`define routes should support markdown routes as flat-folders 1`] = `
741 | {
742 | "routes/docs/_layout": {
743 | "caseSensitive": undefined,
744 | "file": "routes/docs/_layout.tsx",
745 | "id": "routes/docs/_layout",
746 | "index": undefined,
747 | "parentId": "root",
748 | "path": "docs",
749 | },
750 | "routes/docs/readme.route": {
751 | "caseSensitive": undefined,
752 | "file": "routes/docs/readme.route.mdx",
753 | "id": "routes/docs/readme.route",
754 | "index": undefined,
755 | "parentId": "routes/docs/_layout",
756 | "path": "readme",
757 | },
758 | }
759 | `;
760 |
761 | exports[`support routeRegex should accept a dynamic regex 1`] = `
762 | {
763 | "routes/$lang.$ref": {
764 | "caseSensitive": undefined,
765 | "file": "routes/$lang.$ref.tsx",
766 | "id": "routes/$lang.$ref",
767 | "index": undefined,
768 | "parentId": "root",
769 | "path": ":lang/:ref",
770 | },
771 | "routes/$lang.$ref.$": {
772 | "caseSensitive": undefined,
773 | "file": "routes/$lang.$ref.$.tsx",
774 | "id": "routes/$lang.$ref.$",
775 | "index": undefined,
776 | "parentId": "routes/$lang.$ref",
777 | "path": "*",
778 | },
779 | "routes/$lang.$ref._index": {
780 | "caseSensitive": undefined,
781 | "file": "routes/$lang.$ref._index.tsx",
782 | "id": "routes/$lang.$ref._index",
783 | "index": true,
784 | "parentId": "routes/$lang.$ref",
785 | "path": undefined,
786 | },
787 | "routes/_auth+/forgot-password": {
788 | "caseSensitive": undefined,
789 | "file": "routes/_auth+/forgot-password.tsx",
790 | "id": "routes/_auth+/forgot-password",
791 | "index": undefined,
792 | "parentId": "root",
793 | "path": "forgot-password",
794 | },
795 | "routes/_auth+/login": {
796 | "caseSensitive": undefined,
797 | "file": "routes/_auth+/login.tsx",
798 | "id": "routes/_auth+/login",
799 | "index": undefined,
800 | "parentId": "root",
801 | "path": "login",
802 | },
803 | "routes/_index": {
804 | "caseSensitive": undefined,
805 | "file": "routes/_index.tsx",
806 | "id": "routes/_index",
807 | "index": true,
808 | "parentId": "root",
809 | "path": undefined,
810 | },
811 | "routes/_public+/_layout": {
812 | "caseSensitive": undefined,
813 | "file": "routes/_public+/_layout.tsx",
814 | "id": "routes/_public+/_layout",
815 | "index": undefined,
816 | "parentId": "root",
817 | "path": undefined,
818 | },
819 | "routes/_public+/about": {
820 | "caseSensitive": undefined,
821 | "file": "routes/_public+/about.tsx",
822 | "id": "routes/_public+/about",
823 | "index": undefined,
824 | "parentId": "routes/_public+/_layout",
825 | "path": "about",
826 | },
827 | "routes/_public+/contact[.jpg]": {
828 | "caseSensitive": undefined,
829 | "file": "routes/_public+/contact[.jpg].tsx",
830 | "id": "routes/_public+/contact[.jpg]",
831 | "index": undefined,
832 | "parentId": "routes/_public+/_layout",
833 | "path": "contact.jpg",
834 | },
835 | "routes/_public+/index": {
836 | "caseSensitive": undefined,
837 | "file": "routes/_public+/index.tsx",
838 | "id": "routes/_public+/index",
839 | "index": true,
840 | "parentId": "routes/_public+/_layout",
841 | "path": undefined,
842 | },
843 | "routes/healthcheck": {
844 | "caseSensitive": undefined,
845 | "file": "routes/healthcheck.tsx",
846 | "id": "routes/healthcheck",
847 | "index": undefined,
848 | "parentId": "root",
849 | "path": "healthcheck",
850 | },
851 | "routes/users+/$userId": {
852 | "caseSensitive": undefined,
853 | "file": "routes/users+/$userId.tsx",
854 | "id": "routes/users+/$userId",
855 | "index": undefined,
856 | "parentId": "routes/users+/route",
857 | "path": ":userId",
858 | },
859 | "routes/users+/$userId_.edit": {
860 | "caseSensitive": undefined,
861 | "file": "routes/users+/$userId_.edit.tsx",
862 | "id": "routes/users+/$userId_.edit",
863 | "index": undefined,
864 | "parentId": "routes/users+/route",
865 | "path": ":userId/edit",
866 | },
867 | "routes/users+/_layout": {
868 | "caseSensitive": undefined,
869 | "file": "routes/users+/_layout.tsx",
870 | "id": "routes/users+/_layout",
871 | "index": undefined,
872 | "parentId": "root",
873 | "path": "users",
874 | },
875 | "routes/users+/route": {
876 | "caseSensitive": undefined,
877 | "file": "routes/users+/route.tsx",
878 | "id": "routes/users+/route",
879 | "index": undefined,
880 | "parentId": "root",
881 | "path": "users",
882 | },
883 | }
884 | `;
885 |
886 | exports[`support routeRegex should accept a static regex 1`] = `
887 | {
888 | "routes/$lang.$ref": {
889 | "caseSensitive": undefined,
890 | "file": "routes/$lang.$ref.tsx",
891 | "id": "routes/$lang.$ref",
892 | "index": undefined,
893 | "parentId": "root",
894 | "path": ":lang/:ref",
895 | },
896 | "routes/$lang.$ref.$": {
897 | "caseSensitive": undefined,
898 | "file": "routes/$lang.$ref.$.tsx",
899 | "id": "routes/$lang.$ref.$",
900 | "index": undefined,
901 | "parentId": "routes/$lang.$ref",
902 | "path": "*",
903 | },
904 | "routes/$lang.$ref._index": {
905 | "caseSensitive": undefined,
906 | "file": "routes/$lang.$ref._index.tsx",
907 | "id": "routes/$lang.$ref._index",
908 | "index": true,
909 | "parentId": "routes/$lang.$ref",
910 | "path": undefined,
911 | },
912 | "routes/_auth+/forgot-password": {
913 | "caseSensitive": undefined,
914 | "file": "routes/_auth+/forgot-password.tsx",
915 | "id": "routes/_auth+/forgot-password",
916 | "index": undefined,
917 | "parentId": "root",
918 | "path": "forgot-password",
919 | },
920 | "routes/_auth+/login": {
921 | "caseSensitive": undefined,
922 | "file": "routes/_auth+/login.tsx",
923 | "id": "routes/_auth+/login",
924 | "index": undefined,
925 | "parentId": "root",
926 | "path": "login",
927 | },
928 | "routes/_index": {
929 | "caseSensitive": undefined,
930 | "file": "routes/_index.tsx",
931 | "id": "routes/_index",
932 | "index": true,
933 | "parentId": "root",
934 | "path": undefined,
935 | },
936 | "routes/_public+/_layout": {
937 | "caseSensitive": undefined,
938 | "file": "routes/_public+/_layout.tsx",
939 | "id": "routes/_public+/_layout",
940 | "index": undefined,
941 | "parentId": "root",
942 | "path": undefined,
943 | },
944 | "routes/_public+/about": {
945 | "caseSensitive": undefined,
946 | "file": "routes/_public+/about.tsx",
947 | "id": "routes/_public+/about",
948 | "index": undefined,
949 | "parentId": "routes/_public+/_layout",
950 | "path": "about",
951 | },
952 | "routes/_public+/contact[.jpg]": {
953 | "caseSensitive": undefined,
954 | "file": "routes/_public+/contact[.jpg].tsx",
955 | "id": "routes/_public+/contact[.jpg]",
956 | "index": undefined,
957 | "parentId": "routes/_public+/_layout",
958 | "path": "contact.jpg",
959 | },
960 | "routes/_public+/index": {
961 | "caseSensitive": undefined,
962 | "file": "routes/_public+/index.tsx",
963 | "id": "routes/_public+/index",
964 | "index": true,
965 | "parentId": "routes/_public+/_layout",
966 | "path": undefined,
967 | },
968 | "routes/healthcheck": {
969 | "caseSensitive": undefined,
970 | "file": "routes/healthcheck.tsx",
971 | "id": "routes/healthcheck",
972 | "index": undefined,
973 | "parentId": "root",
974 | "path": "healthcheck",
975 | },
976 | "routes/users+/$userId": {
977 | "caseSensitive": undefined,
978 | "file": "routes/users+/$userId.tsx",
979 | "id": "routes/users+/$userId",
980 | "index": undefined,
981 | "parentId": "routes/users+/route",
982 | "path": ":userId",
983 | },
984 | "routes/users+/$userId_.edit": {
985 | "caseSensitive": undefined,
986 | "file": "routes/users+/$userId_.edit.tsx",
987 | "id": "routes/users+/$userId_.edit",
988 | "index": undefined,
989 | "parentId": "routes/users+/route",
990 | "path": ":userId/edit",
991 | },
992 | "routes/users+/_layout": {
993 | "caseSensitive": undefined,
994 | "file": "routes/users+/_layout.tsx",
995 | "id": "routes/users+/_layout",
996 | "index": undefined,
997 | "parentId": "root",
998 | "path": "users",
999 | },
1000 | "routes/users+/route": {
1001 | "caseSensitive": undefined,
1002 | "file": "routes/users+/route.tsx",
1003 | "id": "routes/users+/route",
1004 | "index": undefined,
1005 | "parentId": "root",
1006 | "path": "users",
1007 | },
1008 | }
1009 | `;
1010 |
--------------------------------------------------------------------------------
/test/index.test.ts:
--------------------------------------------------------------------------------
1 | import type { RouteManifest } from '../src/index'
2 | import flatRoutes from '../src/index'
3 | import { defineRoutes } from '../src/routes'
4 |
5 | type ExpectedValues = {
6 | id: string
7 | path: string | undefined
8 | parentId?: string
9 | }
10 |
11 | type RouteMapInfo = {
12 | id: string
13 | path: string
14 | file: string
15 | index?: boolean
16 | children: string[]
17 | }
18 |
19 | describe('define routes', () => {
20 | it('should define routes for flat-files', () => {
21 | const flatFiles = [
22 | '$lang.$ref.tsx',
23 | '$lang.$ref._index.tsx',
24 | '$lang.$ref.$.tsx',
25 | '_index.tsx',
26 | 'healthcheck.tsx',
27 | ]
28 | const routes = flatRoutes('routes', defineRoutes, {
29 | visitFiles: visitFilesFromArray(flatFiles),
30 | })
31 | expect(routes).toMatchSnapshot()
32 | })
33 | it('should define routes for flat-folders', () => {
34 | const flatFolders = [
35 | '$lang.$ref/route.tsx',
36 | '$lang.$ref._index/route.tsx',
37 | '$lang.$ref.$/route.tsx',
38 | '_index/route.tsx',
39 | 'healthcheck/route.tsx',
40 | ]
41 | const routes = flatRoutes('routes', defineRoutes, {
42 | visitFiles: visitFilesFromArray(flatFolders),
43 | })
44 | expect(routes).toMatchSnapshot()
45 | })
46 | it('should define routes for flat-folders on Windows', () => {
47 | const flatFolders = [
48 | '$lang.$ref\\route.tsx',
49 | '$lang.$ref._index\\route.tsx',
50 | '$lang.$ref.$\\route.tsx',
51 | '_index\\route.tsx',
52 | 'healthcheck\\route.tsx',
53 | ]
54 | const routes = flatRoutes('routes', defineRoutes, {
55 | visitFiles: visitFilesFromArray(flatFolders),
56 | })
57 | expect(routes['routes/$lang.$ref._index/route']).toBeDefined()
58 | expect(routes['routes/$lang.$ref._index/route'].parentId).toBe(
59 | 'routes/$lang.$ref/route',
60 | )
61 | expect(routes['routes/$lang.$ref._index/route'].file).toBe(
62 | 'routes/$lang.$ref._index/route.tsx',
63 | )
64 | expect(routes).toMatchSnapshot()
65 | })
66 | it('should define routes for complex structure', () => {
67 | const routeList = [
68 | '_auth.forgot-password.tsx',
69 | '_auth.login.tsx',
70 | '_auth.reset-password.tsx',
71 | '_auth.signup.tsx',
72 | '_auth.tsx',
73 | '_landing.about.tsx',
74 | '_landing.index.tsx',
75 | '_landing.tsx',
76 | 'app.calendar.$day.tsx',
77 | 'app.calendar.index.tsx',
78 | 'app.calendar.tsx',
79 | 'app.projects.$id.tsx',
80 | 'app.projects.tsx',
81 | 'app.tsx',
82 | 'app_.projects.$id.roadmap.tsx',
83 | 'app_.projects.$id.roadmap[.pdf].tsx',
84 | ]
85 | const routes = flatRoutes('routes', defineRoutes, {
86 | visitFiles: visitFilesFromArray(routeList),
87 | })
88 | expect(routes).toMatchSnapshot()
89 | })
90 |
91 | it('should correctly nest routes', () => {
92 | const routeList = [
93 | 'app.$organizationSlug.tsx',
94 | 'app.$organizationSlug.edit.tsx',
95 | 'app.$organizationSlug.projects.tsx',
96 | 'app.$organizationSlug.projects.$projectId.tsx',
97 | 'app.$organizationSlug.projects.$projectId.edit.tsx',
98 | 'app.$organizationSlug.projects.new.tsx',
99 | ]
100 | const routes = flatRoutes('routes', defineRoutes, {
101 | visitFiles: visitFilesFromArray(routeList),
102 | })
103 | expect(routes).toMatchSnapshot()
104 | })
105 | it('should allow routes to specify different parent routes', () => {
106 | const routeList = [
107 | 'parent.tsx',
108 | 'parent.some.nested.tsx',
109 | 'parent.some_.nested.page.tsx',
110 | ]
111 | const routes = flatRoutes('routes', defineRoutes, {
112 | visitFiles: visitFilesFromArray(routeList),
113 | })
114 | expect(routes).toMatchSnapshot()
115 | })
116 | it('should handle params with trailing underscore', () => {
117 | const routeList = [
118 | 'app.$organizationSlug_._projects.tsx',
119 | 'app.$organizationSlug_._projects.projects.new.tsx',
120 | 'app.$organizationSlug_._projects.projects.$projectId.tsx',
121 | 'app.$organizationSlug_._projects.projects.$projectId.edit.tsx',
122 | ]
123 | const routes = flatRoutes('routes', defineRoutes, {
124 | visitFiles: visitFilesFromArray(routeList),
125 | })
126 | expect(routes).toMatchSnapshot()
127 | })
128 |
129 | it('should ignore non-route files in flat-folders', () => {
130 | const flatFolders = [
131 | '$lang.$ref/_layout.tsx',
132 | '$lang.$ref/component.tsx',
133 | '$lang.$ref._index/route.tsx',
134 | '$lang.$ref._index/style.css',
135 | '$lang.$ref.$/model.server.ts',
136 | '_index/route.tsx',
137 | 'healthcheck/route.tsx',
138 | ]
139 | const routes = flatRoutes('routes', defineRoutes, {
140 | visitFiles: visitFilesFromArray(flatFolders),
141 | })
142 | expect(routes).toMatchSnapshot()
143 | })
144 |
145 | it('should support markdown routes as flat-files', () => {
146 | const flatFiles = ['docs.tsx', 'docs.readme.md']
147 | const routes = flatRoutes('routes', defineRoutes, {
148 | visitFiles: visitFilesFromArray(flatFiles),
149 | })
150 | expect(routes).toMatchSnapshot()
151 | })
152 |
153 | it('should support markdown routes as flat-folders', () => {
154 | const flatFolders = ['docs/_layout.tsx', 'docs/readme.route.mdx']
155 | const routes = flatRoutes('routes', defineRoutes, {
156 | visitFiles: visitFilesFromArray(flatFolders),
157 | })
158 | expect(routes).toMatchSnapshot()
159 | })
160 |
161 | it('should not contain unnecessary trailing slash on path', () => {
162 | const routesWithExpectedValues: Record = {
163 | '_login+/_layout.tsx': {
164 | id: 'routes/_login+/_layout',
165 | path: undefined,
166 | parentId: 'root',
167 | },
168 | '_login+/login.tsx': {
169 | id: 'routes/_login+/login',
170 | path: 'login',
171 | parentId: 'routes/_login+/_layout',
172 | },
173 | '_login+/register+/_index.tsx': {
174 | id: 'routes/_login+/register+/_index',
175 | path: 'register',
176 | parentId: 'routes/_login+/_layout',
177 | },
178 | }
179 |
180 | generateFlatRoutesAndVerifyResultWithExpected(routesWithExpectedValues)
181 | })
182 | })
183 |
184 | describe('define ignored routes', () => {
185 | const ignoredRouteFiles = ['**/.*', '**/*.css', '**/*.test.{js,jsx,ts,tsx}']
186 | it('should ignore routes for flat-files', () => {
187 | const flatFiles = [
188 | '$lang.$ref.tsx',
189 | '$lang.$ref._index.tsx',
190 | '$lang.$ref.$.tsx',
191 | '_index.tsx',
192 | 'healthcheck.tsx',
193 | 'style.css',
194 | '_index.test.tsx',
195 | 'styles/style.css',
196 | '__tests__/route.test.tsx',
197 | ]
198 | const routes = flatRoutes('routes', defineRoutes, {
199 | visitFiles: visitFilesFromArray(flatFiles),
200 | ignoredRouteFiles,
201 | })
202 | expect(routes).toMatchSnapshot()
203 | })
204 | })
205 |
206 | describe('define index routes', () => {
207 | it('should generate "correct" id for index routes for flat files', () => {
208 | const flatFiles = [
209 | '$lang.$ref.tsx',
210 | '$lang.$ref._index.tsx',
211 | '$lang.$ref.$.tsx',
212 | '_index.tsx',
213 | ]
214 | const routes = flatRoutes('routes', defineRoutes, {
215 | visitFiles: visitFilesFromArray(flatFiles),
216 | })
217 |
218 | expect(routes['routes/_index'].index).toBe(true)
219 | expect(routes['routes/$lang.$ref._index'].index).toBe(true)
220 | })
221 |
222 | it('should generate "correct" id for index routes for flat folders', () => {
223 | const flatFolders = [
224 | '$lang.$ref/route.tsx',
225 | '$lang.$ref._index/route.tsx',
226 | '$lang.$ref.$/route.tsx',
227 | '_index/route.tsx',
228 | ]
229 | const routes = flatRoutes('routes', defineRoutes, {
230 | visitFiles: visitFilesFromArray(flatFolders),
231 | })
232 | expect(routes['routes/_index/route'].index).toBe(true)
233 | expect(routes['routes/$lang.$ref._index/route'].index).toBe(true)
234 | })
235 | })
236 |
237 | describe('use custom base path', () => {
238 | it('should generate correct routes with base path prefix', () => {
239 | const flatFiles = [
240 | '$lang.$ref.tsx',
241 | '$lang.$ref._index.tsx',
242 | '$lang.$ref.$.tsx',
243 | '_index.tsx',
244 | ]
245 | const routes = flatRoutes('routes', defineRoutes, {
246 | visitFiles: visitFilesFromArray(flatFiles),
247 | basePath: '/myapp',
248 | })
249 | const rootChildren = Object.values(routes).filter(
250 | route => route.parentId === 'root',
251 | )
252 | expect(rootChildren.length).toBeGreaterThan(0)
253 | expect(rootChildren[0].path!.startsWith('myapp/')).toBe(true)
254 | })
255 | })
256 |
257 | describe('use custom param prefix char', () => {
258 | it('should generate correct paths with custom param prefix', () => {
259 | const flatFiles = ['^userId.tsx', '^.tsx']
260 | const routes = flatRoutes('routes', defineRoutes, {
261 | visitFiles: visitFilesFromArray(flatFiles),
262 | paramPrefixChar: '^',
263 | })
264 | expect(routes['routes/^userId']!.path!).toBe(':userId')
265 | expect(routes['routes/^']!.path!).toBe('*')
266 | })
267 | })
268 |
269 | describe('use optional segments', () => {
270 | it('should generate correct paths with optional syntax', () => {
271 | const files = ['parent.(child).tsx']
272 | const routes = flatRoutes('routes', defineRoutes, {
273 | visitFiles: visitFilesFromArray(files),
274 | })
275 | expect(routes['routes/parent.(child)']!.path!).toBe('parent/child?')
276 | })
277 | it('should generate correct paths with folders', () => {
278 | const files = ['_folder+/parent.(child).tsx']
279 | const routes = flatRoutes('routes', defineRoutes, {
280 | visitFiles: visitFilesFromArray(files),
281 | })
282 | expect(routes['routes/_folder+/parent.(child)']!.path!).toBe(
283 | 'parent/child?',
284 | )
285 | })
286 | it('should generate correct paths with folders with overridden nested folder character', () => {
287 | const files = ['_folder-/parent.(child).tsx']
288 | const routes = flatRoutes('routes', defineRoutes, {
289 | visitFiles: visitFilesFromArray(files),
290 | nestedDirectoryChar: '-',
291 | })
292 | expect(routes['routes/_folder-/parent.(child)']!.path!).toBe(
293 | 'parent/child?',
294 | )
295 | })
296 | it('should generate correct paths with optional syntax and dynamic param', () => {
297 | const files = ['parent.($child).tsx']
298 | const routes = flatRoutes('routes', defineRoutes, {
299 | visitFiles: visitFilesFromArray(files),
300 | })
301 | expect(routes['routes/parent.($child)']!.path!).toBe('parent/:child?')
302 | })
303 | })
304 |
305 | describe('define hybrid routes', () => {
306 | it('should define routes for hybrid routes', () => {
307 | const flatFolders = [
308 | '_index/route.tsx',
309 | '_public/_layout.tsx',
310 | '_public/about/route.tsx',
311 | '_public/contact[.jpg]/route.tsx',
312 | 'test.$/route.tsx',
313 | 'users/_layout.tsx',
314 | 'users/users.css',
315 | 'users/route/route.tsx',
316 | 'users/$userId/route.tsx',
317 | 'users/$userId/avatar.png',
318 | 'users/$userId_.edit/route.tsx',
319 | ]
320 | const routes = flatRoutes('routes', defineRoutes, {
321 | visitFiles: visitFilesFromArray(flatFolders),
322 | })
323 | expect(routes).toMatchSnapshot()
324 | })
325 | })
326 |
327 | describe('define folders for flat-files', () => {
328 | it('should define routes for flat-files with folders', () => {
329 | const flatFolders = [
330 | '_auth+/forgot-password.tsx',
331 | '_auth+/login.tsx',
332 | '_public+/_layout.tsx',
333 | '_public+/index.tsx',
334 | '_public+/about.tsx',
335 | '_public+/contact[.jpg].tsx',
336 | 'users+/_layout.tsx',
337 | 'users+/route.tsx',
338 | 'users+/$userId.tsx',
339 | 'users+/$userId_.edit.tsx',
340 | ]
341 | const routes = flatRoutes('routes', defineRoutes, {
342 | visitFiles: visitFilesFromArray(flatFolders),
343 | })
344 | expect(routes).toMatchSnapshot()
345 | })
346 | it('should define routes with flat-files hybrid with parent layout override', () => {
347 | const flatFolders = [
348 | '_index.tsx',
349 | 'faculty+/_layout.tsx',
350 | 'faculty+/index.tsx',
351 | 'faculty+/_.login.tsx',
352 | ]
353 | const routes = flatRoutes('routes', defineRoutes, {
354 | visitFiles: visitFilesFromArray(flatFolders),
355 | })
356 | expect(routes['routes/faculty+/_.login']?.parentId).toBe('root')
357 | })
358 |
359 | it('should define routes for flat-files with folders and flat-folders convention', () => {
360 | const flatFolders = [
361 | '_public+/parent.child/index.tsx',
362 | '_public+/parent.child.grandchild/index.tsx',
363 | ]
364 | const routes = flatRoutes('routes', defineRoutes, {
365 | visitFiles: visitFilesFromArray(flatFolders),
366 | })
367 | expect(routes['routes/_public+/parent.child/index']?.path).toBe(
368 | 'parent/child',
369 | )
370 | expect(
371 | routes['routes/_public+/parent.child.grandchild/index']?.parentId,
372 | ).toBe('routes/_public+/parent.child/index')
373 | expect(routes['routes/_public+/parent.child.grandchild/index']?.path).toBe(
374 | 'grandchild',
375 | )
376 | })
377 | it('should define routes for flat-files with folders on windows', () => {
378 | const flatFolders = [
379 | '_public+\\parent.child.tsx',
380 | '_public+\\parent.child.grandchild.tsx',
381 | ]
382 | const routes = flatRoutes('routes', defineRoutes, {
383 | visitFiles: visitFilesFromArray(flatFolders),
384 | })
385 | expect(routes['routes/_public+/parent.child']?.path).toBe('parent/child')
386 | expect(routes['routes/_public+/parent.child.grandchild']?.parentId).toBe(
387 | 'routes/_public+/parent.child',
388 | )
389 | expect(routes['routes/_public+/parent.child.grandchild']?.path).toBe(
390 | 'grandchild',
391 | )
392 | })
393 | })
394 |
395 | describe('define folders for flat-files with overridden nested folder character', () => {
396 | it('should define routes for flat-files with folders', () => {
397 | const flatFolders = [
398 | '_auth-/forgot-password.tsx',
399 | '_auth-/login.tsx',
400 | '_public-/_layout.tsx',
401 | '_public-/index.tsx',
402 | '_public-/about.tsx',
403 | '_public-/contact[.jpg].tsx',
404 | 'users-/_layout.tsx',
405 | 'users-/route.tsx',
406 | 'users-/$userId.tsx',
407 | 'users-/$userId_.edit.tsx',
408 | ]
409 | const routes = flatRoutes('routes', defineRoutes, {
410 | visitFiles: visitFilesFromArray(flatFolders),
411 | nestedDirectoryChar: '-',
412 | })
413 | expect(routes).toMatchSnapshot()
414 | })
415 | it('should define routes with flat-files hybrid with parent layout override', () => {
416 | const flatFolders = [
417 | '_index.tsx',
418 | 'faculty-/_layout.tsx',
419 | 'faculty-/index.tsx',
420 | 'faculty-/_.login.tsx',
421 | ]
422 | const routes = flatRoutes('routes', defineRoutes, {
423 | visitFiles: visitFilesFromArray(flatFolders),
424 | nestedDirectoryChar: '-',
425 | })
426 | expect(routes['routes/faculty-/_.login']?.parentId).toBe('root')
427 | })
428 |
429 | it('should define routes for flat-files with folders and flat-folders convention', () => {
430 | const flatFolders = [
431 | '_public-/parent.child/index.tsx',
432 | '_public-/parent.child.grandchild/index.tsx',
433 | ]
434 | const routes = flatRoutes('routes', defineRoutes, {
435 | visitFiles: visitFilesFromArray(flatFolders),
436 | nestedDirectoryChar: '-',
437 | })
438 | expect(routes['routes/_public-/parent.child/index']?.path).toBe(
439 | 'parent/child',
440 | )
441 | expect(
442 | routes['routes/_public-/parent.child.grandchild/index']?.parentId,
443 | ).toBe('routes/_public-/parent.child/index')
444 | expect(routes['routes/_public-/parent.child.grandchild/index']?.path).toBe(
445 | 'grandchild',
446 | )
447 | })
448 | it('should define routes for flat-files with folders on windows', () => {
449 | const flatFolders = [
450 | '_public-\\parent.child.tsx',
451 | '_public-\\parent.child.grandchild.tsx',
452 | ]
453 | const routes = flatRoutes('routes', defineRoutes, {
454 | visitFiles: visitFilesFromArray(flatFolders),
455 | nestedDirectoryChar: '-',
456 | })
457 | expect(routes['routes/_public-/parent.child']?.path).toBe('parent/child')
458 | expect(routes['routes/_public-/parent.child.grandchild']?.parentId).toBe(
459 | 'routes/_public-/parent.child',
460 | )
461 | expect(routes['routes/_public-/parent.child.grandchild']?.path).toBe(
462 | 'grandchild',
463 | )
464 | })
465 | })
466 | describe('support routeRegex', () => {
467 | it('should accept a dynamic regex', () => {
468 | const flatFiles = [
469 | '$lang.$ref.tsx',
470 | '$lang.$ref._index.tsx',
471 | '$lang.$ref.$.tsx',
472 | '_index.tsx',
473 | 'healthcheck.tsx',
474 | '_auth+/forgot-password.tsx',
475 | '_auth+/login.tsx',
476 | '_public+/_layout.tsx',
477 | '_public+/index.tsx',
478 | '_public+/about.tsx',
479 | '_public+/contact[.jpg].tsx',
480 | 'users+/_layout.tsx',
481 | 'users+/route.tsx',
482 | 'users+/$userId.tsx',
483 | 'users+/$userId_.edit.tsx',
484 | ]
485 | const routes = flatRoutes('routes', defineRoutes, {
486 | visitFiles: visitFilesFromArray(flatFiles),
487 | routeRegex:
488 | /((\${nestedDirectoryChar}[\/\\][^\/\\:?*]+)|[\/\\]((index|route|layout|page)|(_[^\/\\:?*]+)|([^\/\\:?*]+\.route)))\.(ts|tsx|js|jsx|md|mdx)$$/,
489 | })
490 | expect(routes).toMatchSnapshot()
491 | })
492 |
493 | it('should accept a static regex', () => {
494 | const flatFiles = [
495 | '$lang.$ref.tsx',
496 | '$lang.$ref._index.tsx',
497 | '$lang.$ref.$.tsx',
498 | '_index.tsx',
499 | 'healthcheck.tsx',
500 | '_auth+/forgot-password.tsx',
501 | '_auth+/login.tsx',
502 | '_public+/_layout.tsx',
503 | '_public+/index.tsx',
504 | '_public+/about.tsx',
505 | '_public+/contact[.jpg].tsx',
506 | 'users+/_layout.tsx',
507 | 'users+/route.tsx',
508 | 'users+/$userId.tsx',
509 | 'users+/$userId_.edit.tsx',
510 | ]
511 | const routes = flatRoutes('routes', defineRoutes, {
512 | visitFiles: visitFilesFromArray(flatFiles),
513 | routeRegex:
514 | /(([+][\/\\][^\/\\:?*]+)|[\/\\]((index|route|layout|page)|(_[^\/\\:?*]+)|([^\/\\:?*]+\.route)))\.(ts|tsx|js|jsx|md|mdx)$$/,
515 | })
516 | expect(routes).toMatchSnapshot()
517 | })
518 | })
519 |
520 | describe('is able to escape special characters', () => {
521 | it('should escape underscore', () => {
522 | const routesWithExpectedValues: Record = {
523 | '[__].tsx': {
524 | id: 'routes/[__]',
525 | path: '__',
526 | parentId: 'root',
527 | },
528 | '[_].tsx': {
529 | id: 'routes/[_]',
530 | path: '_',
531 | parentId: 'root',
532 | },
533 | '_layout+/[___].tsx': {
534 | id: 'routes/_layout+/[___]',
535 | path: '___',
536 | parentId: 'root',
537 | },
538 | '_layout+/parent.[__].tsx': {
539 | id: 'routes/_layout+/parent.[__]',
540 | path: 'parent/__',
541 | parentId: 'root',
542 | },
543 | }
544 |
545 | generateFlatRoutesAndVerifyResultWithExpected(routesWithExpectedValues)
546 | })
547 | })
548 |
549 | function dumpRoutes(routes: RouteManifest) {
550 | const routeMap = new Map()
551 | const rootRoute: RouteMapInfo = {
552 | id: 'root',
553 | path: '',
554 | file: 'root.tsx',
555 | index: false,
556 | children: [],
557 | }
558 | routeMap.set('root', rootRoute)
559 | Object.entries(routes).forEach(([name, route]) => {
560 | if (!route.parentId) return
561 | const parent = routeMap.get(route.parentId)
562 | if (parent) {
563 | parent.children.push(name)
564 | }
565 | routeMap.set(name, {
566 | id: name,
567 | path: route.path!,
568 | file: route.file,
569 | index: route.index,
570 | children: [],
571 | })
572 | })
573 | const dump = (route: RouteMapInfo, indent: string) => {
574 | const getPath = (path?: string) => (path ? `path="${path}" ` : '')
575 | const getIndex = (index?: boolean) => (index ? 'index ' : '')
576 | output += `${indent}\n`
579 | if (route.children.length) {
580 | route.children.forEach((childId: string) => {
581 | dump(routeMap.get(childId)!, indent + ' ')
582 | })
583 | output += `${indent}\n`
584 | }
585 | }
586 | let output = '\n'
587 | dump(routeMap.get('root')!, ' ')
588 | output += '\n'
589 | console.log(output)
590 | }
591 |
592 | function visitFilesFromArray(files: string[]) {
593 | return (_dir: string, visitor: (file: string) => void, _baseDir?: string) => {
594 | files.forEach(file => {
595 | visitor(file)
596 | })
597 | }
598 | }
599 |
600 | function generateFlatRoutesAndVerifyResultWithExpected(
601 | routesWithExpectedValues: Record,
602 | ) {
603 | const routesArrayInput = Object.keys(routesWithExpectedValues) as Array<
604 | keyof typeof routesWithExpectedValues
605 | >
606 |
607 | const routes = flatRoutes('routes', defineRoutes, {
608 | visitFiles: visitFilesFromArray(routesArrayInput),
609 | })
610 |
611 | routesArrayInput.forEach(key => {
612 | const route = routesWithExpectedValues[key]
613 |
614 | expect(routes?.[route.id]).toBeDefined()
615 |
616 | expect(routes?.[route.id]?.path).toBe(route.path)
617 |
618 | expect(routes?.[route.id]?.parentId).toBe(route.parentId)
619 | })
620 | }
621 |
--------------------------------------------------------------------------------
/test/migrate.test.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'path'
2 | import { warn } from 'console'
3 |
4 | type RouteConfig = {
5 | file: string
6 | path: string
7 | parentId: string
8 | index: boolean
9 | }
10 |
11 | warn('\x1b[41m', '⤭ migrate.test.ts is incomplete!')
12 | describe('migrate default routes to flat-files', () => {
13 | // route, expected
14 | const routes: [RouteConfig, string][] = [
15 | [{ file: 'index.tsx', path: '', parentId: 'root', index: true }, '_index'],
16 | [
17 | {
18 | file: 'accounts.tsx',
19 | path: 'accounts',
20 | parentId: 'root',
21 | index: false,
22 | },
23 | 'accounts',
24 | ],
25 | [
26 | {
27 | file: 'sales/invoices.tsx',
28 | path: 'invoices',
29 | parentId: 'sales',
30 | index: false,
31 | },
32 | 'sales.invoices',
33 | ],
34 | [
35 | {
36 | file: 'sales/invoices/index.tsx',
37 | path: '',
38 | parentId: 'sales',
39 | index: true,
40 | },
41 | 'sales.invoices._index',
42 | ],
43 | [
44 | {
45 | file: 'sales/invoices/$invoiceId.tsx',
46 | path: ':invoiceId',
47 | parentId: 'sales/invoices',
48 | index: false,
49 | },
50 | 'sales.invoices.$invoiceId',
51 | ],
52 | [
53 | {
54 | file: 'sales/invoices/$invoiceId.edit.tsx',
55 | path: ':invoiceId/edit',
56 | parentId: 'sales/invoices',
57 | index: false,
58 | },
59 | 'sales.invoices.$invoiceId_.edit',
60 | ],
61 | [
62 | { file: '__landing.tsx', path: '', parentId: 'root', index: false },
63 | '_landing',
64 | ],
65 | [
66 | {
67 | file: '__landing/index.tsx',
68 | path: '',
69 | parentId: '__landing',
70 | index: true,
71 | },
72 | '_landing._index',
73 | ],
74 | [
75 | {
76 | file: '__landing/login.tsx',
77 | path: 'login',
78 | parentId: '__landing',
79 | index: false,
80 | },
81 | '_landing.login',
82 | ],
83 | [
84 | {
85 | file: 'app.projects.$id.roadmap.tsx',
86 | path: 'app/projects/:id/roadmap',
87 | parentId: 'root',
88 | index: false,
89 | },
90 | 'app.projects.$id.roadmap',
91 | ],
92 | ]
93 | runTests(routes)
94 | })
95 |
96 | describe('migrate multiple params, no parent', () => {
97 | // route, expected
98 | const routes: [RouteConfig, string][] = [
99 | [
100 | {
101 | file: 'healthcheck.tsx',
102 | path: 'healthcheck',
103 | parentId: 'root',
104 | index: false,
105 | },
106 | 'healthcheck',
107 | ],
108 | [
109 | {
110 | file: '$lang.$ref.tsx',
111 | path: ':lang/:ref',
112 | parentId: 'root',
113 | index: false,
114 | },
115 | '$lang.$ref',
116 | ],
117 | [
118 | {
119 | file: '$lang.$ref/$.tsx',
120 | path: '*',
121 | parentId: '$lang.$ref',
122 | index: false,
123 | },
124 | '$lang.$ref.$',
125 | ],
126 | [
127 | {
128 | file: '$lang.$ref/index.tsx',
129 | path: '',
130 | parentId: '$lang.$ref',
131 | index: true,
132 | },
133 | '$lang.$ref._index',
134 | ],
135 | ]
136 |
137 | runTests(routes)
138 | })
139 |
140 | describe('test single leading _', () => {
141 | // route, expected
142 | const routes: [RouteConfig, string][] = [
143 | [
144 | { file: '_route.tsx', path: '_route', parentId: 'root', index: true },
145 | '[_]route',
146 | ],
147 | ]
148 | runTests(routes)
149 | })
150 |
151 | describe('test pathless layouts', () => {
152 | // route, expected
153 | const routes: [RouteConfig, string][] = [
154 | [
155 | {
156 | file: '__index/resources/onboarding.tsx',
157 | path: 'resources/onboarding',
158 | parentId: '__index',
159 | index: false,
160 | },
161 | '_index.resources.onboarding.tsx',
162 | ],
163 | ]
164 | runTests(routes)
165 | })
166 |
167 | function runTests(routes: [RouteConfig, string][]) {
168 | /* TODO: convertToRoute signature has been changed, see below
169 | test.each(routes)('%s: %s', (route, expected) => {
170 | const {
171 | file,
172 | path: routePath,
173 | parentId,
174 | index,
175 | } = normalizeRoute(route, 'routes')
176 | let extension = path.extname(file)
177 | let name = file.substring(0, file.length - extension.length)
178 |
179 | // TODO: I'll come back to this later
180 | const result = '' //convertToRoute(name, parentId, routePath, index)
181 | expect(result).toEqual('routes.' + expected)
182 | })
183 | */
184 |
185 | it.todo('migrate tests are unfinished!')
186 | }
187 |
188 | function normalizeRoute(route: RouteConfig, basePath: string) {
189 | route.file = path.join(basePath, route.file)
190 | if (route.parentId !== 'root') {
191 | route.parentId = path.join(basePath, route.parentId)
192 | }
193 | return route
194 | }
195 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
4 | "esModuleInterop": true,
5 | "moduleResolution": "Node",
6 | "target": "ES2022",
7 | "module": "ESNext",
8 | "strict": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "skipLibCheck": true,
11 | "declaration": true,
12 | "noEmit": true
13 | },
14 | "exclude": ["node_modules"],
15 | "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"]
16 | }
17 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsup'
2 |
3 | export default defineConfig(() => {
4 | const commonOptions = {
5 | splitting: false,
6 | sourcemap: false,
7 | clean: true,
8 | }
9 |
10 | const indexCommonOptions = {
11 | entry: ['src/index.ts'],
12 | }
13 |
14 | return [{
15 | ...commonOptions,
16 | ...indexCommonOptions,
17 | format: 'esm',
18 | dts: true, // Generate declaration file (.d.ts)
19 | }, {
20 | ...commonOptions,
21 | ...indexCommonOptions,
22 | format: 'cjs', // TODO: consider removing cjs support. Vite expects ESM anyway and v1 remix compiler can use serverDependenciesToBundle option
23 | }, {
24 | ...commonOptions,
25 | entry: ['src/cli.ts'],
26 | format: 'cjs',
27 | }]
28 | })
29 |
--------------------------------------------------------------------------------