├── .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 | }
--------------------------------------------------------------------------------