├── .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 | [![All Contributors](https://img.shields.io/badge/all_contributors-13-orange.svg?style=flat-square)](#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 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 |
Kiliman
Kiliman

💻 📖
Ryan Florence
Ryan Florence

📖
Brandon Pittman
Brandon Pittman

📖 💻
Mehdi Achour
Mehdi Achour

📖
Fidel González
Fidel González

📖
Andrew Haines
Andrew Haines

💻
Wonu Lee
Wonu Lee

💻
Markus Wolf
Markus Wolf

💻
Sarat Chandra Balla
Sarat Chandra Balla

💻
Brooks Lybrand
Brooks Lybrand

📖
Steven Lahmann
Steven Lahmann

💻
Mikk Pokk
Mikk Pokk

💻 📖
Brandon
Brandon

💻 📖
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 | --------------------------------------------------------------------------------