├── .gitignore ├── .npmignore ├── LICENSE.md ├── README.md ├── package.json ├── src ├── index.ts ├── types.ts └── utils.ts ├── tsconfig.build.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | package-lock.json 4 | test/* 5 | dist/* 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | package-lock.json 4 | test/* 5 | !dist/* -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 D3VL LTD 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix Keep It Super Simple (KISS) Routes 2 | 3 | > [!WARNING] 4 | > This package is in active development, API changes are no longer expected. 5 | > We're getting close to 1.0.0, we now have this in production on a few sites. 6 | 7 | The goal of this package is to provide a simple way to define routes in Remix using a structured file system. 8 | The routing method in Remix is _OK_, but has many nuances and arbitrary rules that make it difficult to onboard new developers, leaves file/folder names littered with `_`,`[`,`+` amongst other special characters with little meaning unless you know the rules and odd nuances. 9 | 10 | ## 🤷‍♂️ Why? 11 | Frustration with a flat folder routing system, a project with 1000's of routes is not fun to open in VSCode, the sidebar becomes unmanageably long, scrolling up and down becomes tedious very quickly. 12 | 13 | We want to be able to define our routes in a way that makes intuitive sense, maps to the web in a logical predictable way, and is easy to keep well organized across teams. 14 | 15 | ## 💡 Concepts 16 | 17 | - Routes are defined and nested using folders, very similar to how you'd layout HTML files on an nginx server. 18 | - `_layout` files wrap all routes downstream, these need an `` to render the child routes. 19 | - `_index` files are the default file for a folder, eg: `/users/_index.tsx` would become `/users`. 20 | - Variables are denoted using `$` in the file path, eg: `/users/$id/edit.tsx` would become `/users/123/edit` 21 | - You can replace folders with a "virtual" folder using a `.` in the filename, eg: `/users.$id.edit.tsx` would become `/users/123/edit`. 22 | - You can escape special characters in the file path using `[]`, eg: `/make-[$$$]-fast-online.tsx` would become `/make-$$$-fast-online` 23 | - Files and folders prefixed with an `_` become invisible, allowing for folder organization without affecting the route path eg: `/_legal-pages/privacy-policy.tsx` would become `/privacy-policy` 24 | - Files not ending in `.jsx`, `.tsx`, `.js`, `.ts` are ignored, allowing you to keep assets and other files in the same folder as your routes. 25 | 26 | ## 🔮 Example 27 | 28 | ### 📂 File System 29 | ``` 30 | ├── _index.jsx 31 | ├── _layout.jsx 32 | ├── users 33 | │ ├── _index.jsx 34 | │ ├── _layout.jsx 35 | │ ├── $id 36 | │ │ ├── _index.jsx 37 | │ │ ├── _layout.jsx 38 | │ │ └── edit.jsx 39 | | └── $id.view.jsx 40 | └── _legal-pages 41 | └── privacy-policy.jsx 42 | ``` 43 | 44 | ### 🧬 Routes Generated 45 | ``` 46 | /_index.jsx -> / 47 | /users/_index.jsx -> /users 48 | /users/$id/_index.jsx -> /users/$id 49 | /users/$id/edit.jsx -> /users/$id/edit 50 | /users/$id.view.jsx -> /users/$id/view 51 | /_legal-pages/privacy-policy.jsx -> /privacy-policy 52 | ``` 53 | ✨ See how super simple that is! 54 | 55 | ## 🔨 Usage 56 | 57 | ### 🚀 Install the package: 58 | `npm install -D remix-kiss-routes` 59 | 60 | ### 💿 Remix Config 61 | ```js 62 | // remix.config.js 63 | import { kissRoutes } from 'remix-kiss-routes' 64 | // ---- OR ---- // 65 | const { kissRoutes } = require('remix-kiss-routes') 66 | 67 | 68 | /** @type {import('@remix-run/dev').AppConfig} */ 69 | module.exports = { 70 | ignoredRouteFiles: ["**/*"], 71 | routes: defineRoutes => { 72 | return kissRoutes(defineRoutes) 73 | // or kissRoutes(defineRoutes, RemixKissRoutesOptions) 74 | }, 75 | } 76 | ``` 77 | 78 | Parameters: 79 | ```js 80 | const RemixKissRoutesOptions = { 81 | app: './app', // where your root.jsx file is located 82 | routes: 'routes', // where your routes are located relative to app 83 | caseSensitive: false, // whether or not to use case sensitive routes 84 | variableCharacter: '$', // the character to denote a variable in the route path 85 | pathlessCharacter: '_', // the character to make a file or folder pathless (invisible) 86 | delimiterCharacter: '.', // used for virtual folders, internally replaced with '/' 87 | layoutFileName: '_layout', // the name of the layout file 88 | indexFileName: '_index', // the name of the index file 89 | } 90 | ``` 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-kiss-routes", 3 | "version": "0.1.4", 4 | "description": "Build cleanly structured routes for Remix using folders.", 5 | "main": "dist/index.js", 6 | "files": [ 7 | "dist/**/*.js", 8 | "dist/**/*.d.ts", 9 | "README.md" 10 | ], 11 | "sideEffects": false, 12 | "scripts": { 13 | "build": "tsc --project tsconfig.build.json --module CommonJS --outDir ./dist" 14 | }, 15 | "keywords": [ 16 | "remix", 17 | "remix-routes", 18 | "file-based-routes" 19 | ], 20 | "author": { 21 | "name": "D3VL LTD", 22 | "email": "opensource@d3vl.com", 23 | "url": "https://d3vl.com/" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/d3vl/remix-kiss-routes.git" 28 | }, 29 | "license": "MIT", 30 | "devDependencies": { 31 | "@remix-run/dev": "^2.0.0", 32 | "@types/node": "^17.0.21", 33 | "esbuild": "^0.14.36", 34 | "esbuild-register": "^3.3.2", 35 | "ts-node": "^10.9.1", 36 | "tslib": "^2.3.1", 37 | "typescript": "^5.1.0" 38 | }, 39 | "peerDependencies": { 40 | "@remix-run/dev": "^2.0.0" 41 | } 42 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // concepts 2 | // _layout.tsx files define the layout of the page, they should contain a component where child routes are rendered. 3 | // index.tsx files are dedicated for index routes 4 | // $ is used to define dynamic routes, for example /blog/$slug will match /blog/hello-world and /blog/another-post 5 | // _ prefix is used to squish out files and folder, i.e. make it invisible to the route, for example /_legal-pages/privacy-policy will match /privacy-policy 6 | // . is used to define virtual folders, for example /users.$id.edit will match /users/123/edit 7 | // [] is used to escape special characters, for example /make-[$$$]-fast-online will match /make-$$$-fast-online 8 | // files not ending in .tsx, .jsx, .ts, .js are ignored, allowing you to keep assets and other files in the same folder as your routes. 9 | 10 | import { DefineRouteFunction } from "@remix-run/dev/dist/config/routes" 11 | import { adoptRoutes, getCollisionHash, getRouteId, getRoutePath, getRouteSegments, recursivelyFindFiles } from "./utils" 12 | import { InternalConfigRoute, RemixKissRoutesOptions, SegmentInfo } from "./types" 13 | import path from "path" 14 | 15 | const defaultOptions: RemixKissRoutesOptions = { 16 | app: './app', 17 | routes: 'routes', 18 | caseSensitive: false, 19 | variableCharacter: '$', 20 | pathlessCharacter: '_', 21 | delimiterCharacter: '.', 22 | layoutFileName: '_layout', 23 | indexFileName: '_index', 24 | } 25 | 26 | export type DefineRoutesFunction = ( 27 | callback: (route: DefineRouteFunction) => void, 28 | ) => any 29 | 30 | const routeModuleExts = ['.js', '.jsx', '.ts', '.tsx', '.md', '.mdx'] 31 | const serverRegex = /\.server\.(ts|tsx|js|jsx|md|mdx)$/ 32 | 33 | export default function kissRoutes(defineRoutes: DefineRoutesFunction, userOptions?: RemixKissRoutesOptions): void { 34 | 35 | const options = { ...defaultOptions, ...userOptions } as RemixKissRoutesOptions; 36 | const files = recursivelyFindFiles(path.join(options.app, options.routes)) 37 | .map(filePath => path.relative(options.app, filePath)) // make paths relative to app 38 | .filter(filePath => filePath.match(serverRegex) === null) // remove server files 39 | .filter(filePath => routeModuleExts.includes(path.extname(filePath))); // remove non-route files 40 | 41 | const configRoutes = new Map(); 42 | 43 | for (const file of files) { 44 | const routeSegments = getRouteSegments(file, options); 45 | const lastSegment = routeSegments[routeSegments.length - 1]; 46 | const isIndex = lastSegment?.value === options.indexFileName; 47 | const isLayout = lastSegment?.value === options.layoutFileName; 48 | const collisionHash = getCollisionHash(Array.from(routeSegments)); 49 | 50 | // check for collisions 51 | if (configRoutes.has(collisionHash)) { 52 | throw new Error(`Conflicting route found: ${file} <-> ${configRoutes.get(collisionHash)?.file}`); 53 | } 54 | 55 | configRoutes.set(collisionHash, { 56 | path: isLayout ? undefined : getRoutePath(Array.from(routeSegments), options).replace(/^\//, ''), 57 | index: isIndex, 58 | caseSensitive: options.caseSensitive, 59 | id: getRouteId(Array.from(routeSegments)), 60 | parentId: undefined, 61 | file, 62 | // custom 63 | isLayout, 64 | segments: Array.from(routeSegments), 65 | collisionHash, 66 | } as InternalConfigRoute); 67 | } 68 | 69 | const adopted = adoptRoutes(Array.from(configRoutes.values())); 70 | 71 | const doDefineRoutes = (defineRoute: DefineRouteFunction, parentId?: string) => { 72 | const parent = adopted.find(route => route.id === parentId) ?? { 73 | id: 'root', 74 | path: undefined, 75 | parentId: undefined, 76 | collisionHash: 'root', 77 | segments: [] as SegmentInfo[], 78 | isLayout: false, 79 | } as InternalConfigRoute; 80 | 81 | const routes = adopted.filter(route => route.parentId === parent.id); 82 | 83 | for (const route of routes) { 84 | const relativePath = parent ? route?.path?.slice((parent?.path ?? '').length) : route.path ?? route.path; 85 | 86 | defineRoute(relativePath, route.file, { 87 | caseSensitive: route.caseSensitive, 88 | index: route.index, 89 | }, () => { 90 | doDefineRoutes(defineRoute, route.id); 91 | }) 92 | } 93 | } 94 | 95 | return defineRoutes(doDefineRoutes) 96 | } 97 | 98 | export { kissRoutes } -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ConfigRoute } from "@remix-run/dev/dist/config/routes" 2 | 3 | export enum SegmentType { 4 | PLAIN = 'plain', 5 | PARAM = 'param', 6 | IGNORE = 'ignore', 7 | DELIMITER = 'delimiter', 8 | HIDDEN = 'hidden', 9 | SPLAT = 'splat', 10 | } 11 | 12 | export enum parserState { 13 | START, 14 | IN_PLAIN, 15 | IN_PARAM, 16 | IN_IGNORE, 17 | END 18 | } 19 | 20 | export enum parserChar { 21 | PARAM = '$', 22 | SEPARATOR = '/', 23 | IGNORE_START = '[', 24 | IGNORE_END = ']', 25 | DOT = '.', 26 | PATHLESS = '_', 27 | } 28 | 29 | export type SegmentInfo = { 30 | value: string, 31 | type: SegmentType 32 | } 33 | 34 | 35 | export type RemixKissRoutesOptions = { 36 | app: string 37 | routes: string 38 | caseSensitive?: boolean 39 | variableCharacter?: string 40 | pathlessCharacter?: string 41 | delimiterCharacter?: string 42 | layoutFileName?: string 43 | indexFileName?: string 44 | } 45 | 46 | export type InternalConfigRoute = { 47 | isLayout: boolean 48 | collisionHash: string 49 | segments: SegmentInfo[] 50 | } & ConfigRoute 51 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | import { InternalConfigRoute, RemixKissRoutesOptions, SegmentInfo, SegmentType, parserChar, parserState } from './types' 4 | 5 | const pathIdVariableHolder = '%%VARIABLE%%' 6 | 7 | export function recursivelyFindFiles( 8 | dir: string, 9 | maxDepth?: number | 10, 10 | depth?: number | 0, 11 | ): string[] { 12 | 13 | if (depth === undefined) depth = 0 14 | if (maxDepth === undefined) maxDepth = 10 15 | 16 | if (depth >= maxDepth) return [] 17 | 18 | const files = fs.readdirSync(dir) 19 | const foundFiles: string[] = [] 20 | 21 | for (let file of files) { 22 | const filePath = path.join(dir, file) 23 | 24 | if (fs.statSync(filePath).isDirectory()) { 25 | foundFiles.push(...recursivelyFindFiles(filePath, maxDepth, depth + 1)) 26 | } else { 27 | foundFiles.push(filePath) 28 | } 29 | } 30 | 31 | return foundFiles; 32 | } 33 | 34 | 35 | export const getRouteSegments = (_path: string, options: RemixKissRoutesOptions): SegmentInfo[] => { 36 | 37 | // check if path contains pathIdVariableHolder if so,throw error 38 | if (_path.includes(pathIdVariableHolder)) throw new Error(`Path ${_path} contains ${pathIdVariableHolder} which is a reserved variable holder`) 39 | 40 | // strip extension from path 41 | const { ext } = path.parse(_path) 42 | _path = _path.slice(0, _path.length - ext.length) 43 | 44 | // create a list for our segments 45 | const segments: SegmentInfo[] = []; 46 | 47 | // initialize state and segment 48 | let STATE = parserState.START; 49 | let SEGMENT = { 50 | value: '', 51 | type: SegmentType.PLAIN 52 | } as SegmentInfo; 53 | 54 | // save the current segment to the list and reset the segment 55 | const commitSegment = (newType?: SegmentType) => { 56 | // if SEGMENT.type = HIDDEN but the value is options.indexFileName or options.layoutFileName, then we need to set the type to PLAIN 57 | if (SEGMENT.type === SegmentType.HIDDEN && (SEGMENT.value === options.indexFileName || SEGMENT.value === options.layoutFileName)) SEGMENT.type = SegmentType.PLAIN; 58 | 59 | // if SEGMENT.type = PARAM but the value is empty, then we need to set the type to SPLAT 60 | if (SEGMENT.value === '' && SEGMENT.type === SegmentType.PARAM) SEGMENT.type = SegmentType.SPLAT; 61 | 62 | if (SEGMENT.value !== '' || SEGMENT.type === SegmentType.SPLAT) { 63 | segments.push(SEGMENT); 64 | } 65 | 66 | SEGMENT = { 67 | value: '', 68 | type: newType || SegmentType.PLAIN 69 | } as SegmentInfo; 70 | STATE = parserState.IN_PLAIN; 71 | } 72 | 73 | // add a delimiter to the list 74 | const addDelimiter = (value: string) => { 75 | // if length of segments is 0, then we can skip this 76 | if (segments.length === 0) return; 77 | // if last item was delimiter of the same value, then we can skip this 78 | if (segments.length > 0 && segments[segments.length - 1].value === value && segments[segments.length - 1].type === SegmentType.DELIMITER) return; 79 | segments.push({ 80 | value, 81 | type: SegmentType.DELIMITER 82 | } as SegmentInfo) 83 | } 84 | 85 | for (const char of _path) { 86 | 87 | if (char === ':') throw new Error('Cannot use ":" in path ' + _path); 88 | if (char === '?') throw new Error('Cannot use "?" in path ' + _path); 89 | if (char === '*') throw new Error('Cannot use "*" in path ' + _path); 90 | 91 | 92 | if (char === path.sep || (char === (options.delimiterCharacter ?? parserChar.DOT) && STATE !== parserState.IN_IGNORE)) { 93 | // this is a path separator, if we're still at the START state, then we can skip this 94 | if (STATE === parserState.START) continue; 95 | 96 | commitSegment() 97 | addDelimiter(char) 98 | continue; 99 | } 100 | 101 | // if this is the first character of the segment, the character is an _ and we're in a PLAIN state then we are entering a hidden segment 102 | if ((char === options.pathlessCharacter ?? parserChar.PATHLESS) && SEGMENT.value === '' && STATE === parserState.IN_PLAIN) { 103 | SEGMENT.type = SegmentType.HIDDEN; 104 | STATE = parserState.IN_IGNORE; 105 | } 106 | 107 | if (char === parserChar.IGNORE_END && STATE === parserState.IN_IGNORE) { 108 | // we're at the end of an optional segment, so we can push the current segment and reset the state 109 | commitSegment() 110 | STATE = parserState.IN_PLAIN; 111 | continue; 112 | } 113 | 114 | if (STATE === parserState.IN_IGNORE) { 115 | // we're in an optional segment, so we can just add the char to the current segment 116 | SEGMENT.value += char; 117 | continue; 118 | } 119 | 120 | if (char === parserChar.IGNORE_START) { 121 | STATE = parserState.IN_IGNORE; 122 | continue; 123 | } 124 | 125 | if (STATE === parserState.IN_PARAM) { 126 | // check this char to see if it's a valid param char, a-z, A-Z, 0-9 127 | if (char.match(/[a-zA-Z0-9_]/)) { 128 | SEGMENT.value += char; 129 | continue; 130 | } 131 | 132 | // if it's not a valid param char, then we need to push the current segment and reset the state 133 | commitSegment() 134 | } 135 | 136 | 137 | if (char === (options.variableCharacter ?? parserChar.PARAM)) { 138 | // flush the current segment 139 | commitSegment() 140 | 141 | SEGMENT.type = SegmentType.PARAM; 142 | STATE = parserState.IN_PARAM; 143 | 144 | continue; 145 | } 146 | 147 | STATE = parserState.IN_PLAIN; 148 | SEGMENT.value += char; 149 | } 150 | 151 | // push the last segment 152 | commitSegment() 153 | 154 | return segments; 155 | } 156 | 157 | 158 | export const getCollisionHash = (segments: SegmentInfo[]): string => { 159 | return segments 160 | .map(segment => { 161 | if (segment.type === SegmentType.PARAM) return pathIdVariableHolder 162 | if (segment.type === SegmentType.SPLAT) return '%%SPLAT%%' 163 | if (segment.type === SegmentType.DELIMITER) return undefined 164 | if (segment.type === SegmentType.HIDDEN) return undefined 165 | return segment.value 166 | }) 167 | .filter(Boolean) 168 | .join('/') 169 | } 170 | 171 | export const getRouteId = (segments: SegmentInfo[]): string => { 172 | return segments 173 | // .filter((segment, index, self) => !(segment.type === SegmentType.DELIMITER && self[index - 1]?.type === SegmentType.HIDDEN)) // remove delimiters that follow hidden segments 174 | .map(segment => { 175 | if (segment.type === SegmentType.PARAM || segment.type === SegmentType.SPLAT) return '$' + segment.value 176 | if (segment.type === SegmentType.DELIMITER) return '/' 177 | return segment.value 178 | }) 179 | .filter(Boolean) 180 | .join('') 181 | } 182 | 183 | export const getRoutePath = (segments: SegmentInfo[], options: RemixKissRoutesOptions): string => { 184 | // if first element matches options.routes, remove it 185 | if (options.routes.startsWith(segments[0]?.value)) segments.shift() 186 | 187 | // if last element is index, remove it 188 | if (segments[segments.length - 1]?.value === options.indexFileName) segments.pop() 189 | 190 | // if last element is _layout, remove it 191 | if (segments[segments.length - 1]?.value === options.layoutFileName) segments.pop() 192 | 193 | // if there's 2 delimiters in a row, or a delimiter followed by a hidden, remove it 194 | segments = segments.filter((segment, index, self) => { 195 | if (segment.type === SegmentType.DELIMITER && self[index - 1]?.type === SegmentType.DELIMITER) return false 196 | if (segment.type === SegmentType.DELIMITER && self[index - 1]?.type === SegmentType.HIDDEN) return false 197 | return true 198 | }) 199 | 200 | if (segments.length === 0) return '' 201 | 202 | return segments.map(segment => { 203 | if (segment.type === SegmentType.PARAM) return ':' + segment.value 204 | if (segment.type === SegmentType.SPLAT) return '*' 205 | if (segment.type === SegmentType.DELIMITER) return '/' 206 | if (segment.type === SegmentType.HIDDEN) return undefined 207 | return segment.value 208 | }).filter(Boolean).join('') 209 | } 210 | 211 | export const adoptRoutes = (routes: InternalConfigRoute[]): InternalConfigRoute[] => { 212 | 213 | for (const route of routes) { 214 | let segments = Array.from(route.segments); 215 | while (!route.parentId) { 216 | const layoutId = getRouteId([ 217 | ...segments, 218 | { value: '_layout', type: SegmentType.PLAIN } as SegmentInfo 219 | ]) 220 | route.parentId = routes.find(r => ( 221 | r.id === layoutId && 222 | r.id !== route.id 223 | ))?.id; 224 | 225 | if (segments.length === 0 && !route.parentId) { 226 | route.parentId = 'root'; 227 | } 228 | 229 | segments.pop(); 230 | } 231 | } 232 | 233 | return routes; 234 | } -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false 5 | }, 6 | "include": [ 7 | "src/**/*" 8 | ], 9 | "exclude": [ 10 | "**/*.test.ts" 11 | ] 12 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | }, 7 | "compilerOptions": { 8 | "lib": [ 9 | "DOM", 10 | "DOM.Iterable", 11 | "ES2019" 12 | ], 13 | "esModuleInterop": true, 14 | "moduleResolution": "Node", 15 | "target": "ES2019", 16 | "module": "ES2020", 17 | "strict": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "skipLibCheck": true, 20 | "declaration": true, 21 | "noEmit": true 22 | }, 23 | "exclude": [ 24 | "node_modules" 25 | ], 26 | "include": [ 27 | "src/**/*.ts", 28 | "src/**/*.tsx" 29 | ] 30 | } --------------------------------------------------------------------------------