├── .github ├── FUNDING.yml ├── dependabot.yml ├── release.yml └── workflows │ ├── bump.yml │ ├── ci.yml │ ├── dependabot.yml │ ├── docs.yml │ └── publish.yml ├── .gitignore ├── .nvmrc ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── biome.json ├── bun.lockb ├── package.json ├── scripts └── exports.ts ├── src ├── __snapshots__ │ └── index.test.ts.snap ├── helpers │ ├── apply-layout.ts │ ├── is-index.test.ts │ ├── is-index.ts │ ├── path-to-id.test.ts │ ├── path-to-id.ts │ ├── remove-index.ts │ └── resolve-extension.ts ├── index.test.ts └── index.ts ├── tsconfig.json └── typedoc.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: sergiodxa 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | reviewers: 9 | - "sergiodxa" 10 | assignees: 11 | - "sergiodxa" 12 | 13 | - package-ecosystem: npm 14 | directory: / 15 | schedule: 16 | interval: "weekly" 17 | reviewers: 18 | - "sergiodxa" 19 | assignees: 20 | - "sergiodxa" 21 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: New Features 4 | labels: 5 | - enhancement 6 | - title: Documentation Changes 7 | labels: 8 | - documentation 9 | - title: Bug Fixes 10 | labels: 11 | - bug 12 | - title: Example 13 | labels: 14 | - example 15 | - title: Deprecations 16 | labels: 17 | - deprecated 18 | - title: Other Changes 19 | labels: 20 | - "*" 21 | -------------------------------------------------------------------------------- /.github/workflows/bump.yml: -------------------------------------------------------------------------------- 1 | name: Bump version 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "Type of version to bump" 8 | required: true 9 | type: choice 10 | options: 11 | - major 12 | - minor 13 | - patch 14 | 15 | jobs: 16 | bump-version: 17 | name: Bump version 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | ssh-key: ${{ secrets.DEPLOY_KEY }} 23 | 24 | - uses: oven-sh/setup-bun@v2 25 | - run: bun install --frozen-lockfile 26 | 27 | - uses: actions/setup-node@v4 28 | with: 29 | node-version: "lts/*" 30 | 31 | - run: | 32 | git config user.name 'Sergio Xalambrí' 33 | git config user.email 'hello@sergiodxa.com' 34 | 35 | - run: npm version ${{ github.event.inputs.version }} 36 | - run: bun run quality:fix 37 | - run: git push origin main --follow-tags 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: oven-sh/setup-bun@v2 12 | - run: bun install --frozen-lockfile 13 | - run: bun run build 14 | 15 | typecheck: 16 | name: Typechecker 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: oven-sh/setup-bun@v2 21 | - run: bun install --frozen-lockfile 22 | - run: bun run typecheck 23 | 24 | quality: 25 | name: Code Quality 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: oven-sh/setup-bun@v2 30 | - run: bun install --frozen-lockfile 31 | - run: bun run quality 32 | 33 | test: 34 | name: Tests 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: oven-sh/setup-bun@v2 39 | - run: bun install --frozen-lockfile 40 | - run: bun test 41 | 42 | exports: 43 | name: Verify Exports 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: oven-sh/setup-bun@v2 48 | - run: bun install --frozen-lockfile 49 | - run: bun run exports 50 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Enable auto-merge for Dependabot PRs 2 | 3 | on: 4 | pull_request: 5 | types: opened 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot: 13 | runs-on: ubuntu-latest 14 | if: ${{ github.actor == 'dependabot[bot]' }} 15 | steps: 16 | - id: metadata 17 | uses: dependabot/fetch-metadata@v2 18 | with: 19 | github-token: "${{ secrets.GITHUB_TOKEN }}" 20 | 21 | - run: gh pr merge --auto --squash "$PR_URL" 22 | env: 23 | PR_URL: ${{github.event.pull_request.html_url}} 24 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 25 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Documentation 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: "docs" 15 | cancel-in-progress: false 16 | 17 | jobs: 18 | deploy: 19 | environment: 20 | name: github-pages 21 | url: ${{ steps.deployment.outputs.page_url }} 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: oven-sh/setup-bun@v2 26 | - run: bun install --frozen-lockfile 27 | - run: bunx typedoc 28 | - uses: actions/configure-pages@v5 29 | - uses: actions/upload-pages-artifact@v3 30 | with: 31 | path: "./docs" 32 | - uses: actions/deploy-pages@v4 33 | id: deployment 34 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish-npm: 9 | name: "Publish to npm" 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: oven-sh/setup-bun@v2 14 | - run: bun install --frozen-lockfile 15 | - run: bun run build 16 | - run: npx exports 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: "lts/*" 21 | registry-url: https://registry.npmjs.org/ 22 | 23 | - run: npm publish --access public 24 | env: 25 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /coverage 3 | /docs 4 | /node_modules -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript]": { 3 | "editor.defaultFormatter": "biomejs.biome" 4 | }, 5 | "[javascriptreact]": { 6 | "editor.defaultFormatter": "biomejs.biome" 7 | }, 8 | "[json]": { 9 | "editor.defaultFormatter": "biomejs.biome" 10 | }, 11 | "[jsonc]": { 12 | "editor.defaultFormatter": "biomejs.biome" 13 | }, 14 | "[typescript]": { 15 | "editor.defaultFormatter": "biomejs.biome" 16 | }, 17 | "[typescriptreact]": { 18 | "editor.defaultFormatter": "biomejs.biome" 19 | }, 20 | "editor.codeActionsOnSave": { 21 | "source.organizeImports.biome": "explicit", 22 | "quickfix.biome": "explicit" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution 2 | 3 | ## Setup 4 | 5 | Run `bun install` to install the dependencies. 6 | 7 | Run the tests with `bun test`. 8 | 9 | Run the code quality checker with `bun run check`. 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sergio Xalambrí 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-define-routes 2 | 3 | A DSL to define Remix routes with code. 4 | 5 | ## Setup 6 | 7 | ```bash 8 | npm add remix-define-routes 9 | ``` 10 | 11 | Create a file called `routes.ts` in your Remix project. 12 | 13 | ```ts 14 | import { defineRoutes } from "remix-define-routes"; 15 | 16 | let authRoutes = defineRoutes(({ layout }) => { 17 | layout("auth", { base: "routes/auth" }, ({ route }) => { 18 | route("index", "routes/auth._index"); 19 | route("register"); 20 | route("login"); 21 | }); 22 | }); 23 | 24 | export default defineRoutes(({ route, extend }) => { 25 | extend(authRoutes); 26 | 27 | route("api/healthcheck"); 28 | route("index", "routes/_index"); 29 | route("admin/:resource"); 30 | }); 31 | ``` 32 | 33 | Then in your `vite.config.ts` import and use it to configure your Remix plugin. 34 | 35 | ```ts 36 | import { vitePlugin as remix } from "@remix-run/dev"; 37 | import { defineConfig } from "vite"; 38 | 39 | import routes from "./config/routes"; 40 | 41 | export default defineConfig({ 42 | plugins: [ 43 | remix({ 44 | ignoredRouteFiles: ["**/*"], 45 | routes: () => routes, 46 | }), 47 | ], 48 | }); 49 | ``` 50 | 51 | And now you can use route routes file to define your application routes. 52 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.7.1/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "correctness": { 11 | "useHookAtTopLevel": "error" 12 | }, 13 | "performance": { 14 | "noBarrelFile": "error", 15 | "noReExportAll": "error" 16 | }, 17 | "style": { 18 | "noDefaultExport": "error", 19 | "noNegationElse": "error", 20 | "useConst": "off", 21 | "useExportType": "off", 22 | "useImportType": "off" 23 | }, 24 | "suspicious": { 25 | "noConsoleLog": "warn", 26 | "noEmptyBlockStatements": "warn", 27 | "noSkippedTests": "error" 28 | } 29 | } 30 | }, 31 | "formatter": { "enabled": true }, 32 | "vcs": { 33 | "enabled": true, 34 | "clientKind": "git", 35 | "defaultBranch": "main", 36 | "useIgnoreFile": true 37 | }, 38 | "overrides": [ 39 | { 40 | "include": ["**/*.md"], 41 | "formatter": { "indentStyle": "tab" } 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergiodxa/remix-define-routes/e91cbb9741b7d7d3a80ba90f62ce3b09359bbf17/bun.lockb -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-define-routes", 3 | "version": "0.0.2", 4 | "description": "A DSL to define Remix routes with code", 5 | "license": "MIT", 6 | "funding": ["https://github.com/sponsors/sergiodxa"], 7 | "author": { 8 | "name": "Sergio Xalambrí", 9 | "email": "hello+oss@sergiodxa.com", 10 | "url": "https://sergiodxa.com" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/sergiodxa/remix-define-routes" 15 | }, 16 | "homepage": "https://sergiodxa.github.io/remix-define-routes", 17 | "bugs": { 18 | "url": "https://github.com/sergiodxa/remix-define-routes/issues" 19 | }, 20 | "scripts": { 21 | "build": "tsc", 22 | "typecheck": "tsc --noEmit", 23 | "quality": "biome check .", 24 | "quality:fix": "biome check . --apply-unsafe", 25 | "exports": "bun run ./scripts/exports.ts" 26 | }, 27 | "sideEffects": false, 28 | "type": "module", 29 | "engines": { 30 | "node": ">=20.0.0" 31 | }, 32 | "files": ["build", "package.json", "README.md"], 33 | "exports": { 34 | ".": "./build/index.js", 35 | "./package.json": "./package.json" 36 | }, 37 | "dependencies": { 38 | "@remix-run/dev": "^2.9.2" 39 | }, 40 | "peerDependencies": {}, 41 | "devDependencies": { 42 | "@arethetypeswrong/cli": "^0.18.1", 43 | "@biomejs/biome": "^1.7.2", 44 | "@types/bun": "^1.1.1", 45 | "citty": "^0.1.6", 46 | "consola": "^3.2.3", 47 | "typedoc": "^0.28.0", 48 | "typedoc-plugin-mdn-links": "^5.0.1", 49 | "typescript": "^5.4.5" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /scripts/exports.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | import { file, spawn } from "bun"; 3 | import { defineCommand, runMain } from "citty"; 4 | import { consola } from "consola"; 5 | 6 | await runMain( 7 | defineCommand({ 8 | meta: { 9 | name: "exports", 10 | description: "Check package.json exports", 11 | }, 12 | 13 | async run() { 14 | let proc = spawn([ 15 | "bunx", 16 | "attw", 17 | "-f", 18 | "table-flipped", 19 | "--no-emoji", 20 | "--no-color", 21 | "--pack", 22 | ]); 23 | 24 | let text = await new Response(proc.stdout).text(); 25 | 26 | let entrypointLines = text 27 | .slice(text.indexOf('"remix-utils/')) 28 | .split("\n") 29 | .filter(Boolean) 30 | .filter((line) => !line.includes("─")) 31 | .map((line) => 32 | line 33 | .replaceAll(/[^\d "()/A-Za-z│-]/g, "") 34 | .replaceAll("90m│39m", "│") 35 | .replaceAll(/^│/g, "") 36 | .replaceAll(/│$/g, ""), 37 | ); 38 | 39 | let pkg = await file("package.json").json(); 40 | let entrypoints = entrypointLines.map((entrypointLine) => { 41 | let [entrypoint, ...resolutionColumns] = entrypointLine.split("│"); 42 | return { 43 | entrypoint: entrypoint.replace(pkg.name, ".").trim(), 44 | esm: resolutionColumns[2].trim(), 45 | bundler: resolutionColumns[3].trim(), 46 | }; 47 | }); 48 | 49 | let entrypointsWithProblems = entrypoints.filter( 50 | (item) => item.esm.includes("fail") || item.bundler.includes("fail"), 51 | ); 52 | 53 | if (entrypointsWithProblems.length > 0) { 54 | consola.error("Entrypoints with problems:"); 55 | process.exit(1); 56 | } else { 57 | consola.success("All entrypoints are valid!"); 58 | } 59 | }, 60 | }), 61 | ); 62 | -------------------------------------------------------------------------------- /src/__snapshots__/index.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Bun Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`defineRoutes tenant routes 1`] = ` 4 | { 5 | "routes/$tenant": { 6 | "file": "tenant/home.tsx", 7 | "id": "routes/$tenant", 8 | "index": false, 9 | "parentId": "routes/$tenant", 10 | "path": "/", 11 | }, 12 | "routes/$tenant.posts.$postId": { 13 | "file": "routes/$tenant.posts.$postId.tsx", 14 | "id": "routes/$tenant.posts.$postId", 15 | "index": false, 16 | "parentId": "routes/$tenant", 17 | "path": "posts/:postId", 18 | }, 19 | "routes/$tenant.users.$userId": { 20 | "file": "routes/$tenant.users.$userId.tsx", 21 | "id": "routes/$tenant.users.$userId", 22 | "index": false, 23 | "parentId": "routes/$tenant", 24 | "path": "users/:userId", 25 | }, 26 | } 27 | `; 28 | 29 | exports[`defineRoutes admin routes 1`] = ` 30 | { 31 | "routes/admin": { 32 | "file": "admin/home.tsx", 33 | "id": "routes/admin", 34 | "index": false, 35 | "parentId": "routes/admin", 36 | "path": "/", 37 | }, 38 | "routes/admin.posts": { 39 | "file": "routes/admin.posts.tsx", 40 | "id": "routes/admin.posts", 41 | "index": false, 42 | "parentId": "routes/admin", 43 | "path": "posts", 44 | }, 45 | "routes/admin.users": { 46 | "file": "routes/admin.users.tsx", 47 | "id": "routes/admin.users", 48 | "index": false, 49 | "parentId": "routes/admin", 50 | "path": "users", 51 | }, 52 | } 53 | `; 54 | 55 | exports[`defineRoutes debug routes 1`] = ` 56 | { 57 | "debug/cache": { 58 | "file": "debug/cache.tsx", 59 | "id": "debug/cache", 60 | "index": false, 61 | "parentId": "root", 62 | "path": "cache", 63 | }, 64 | "debug/email": { 65 | "file": "debug/email.tsx", 66 | "id": "debug/email", 67 | "index": false, 68 | "parentId": "root", 69 | "path": "email", 70 | }, 71 | } 72 | `; 73 | 74 | exports[`defineRoutes full routes 1`] = ` 75 | { 76 | "debug/cache": { 77 | "file": "debug/cache.tsx", 78 | "id": "debug/cache", 79 | "index": false, 80 | "parentId": "root", 81 | "path": "cache", 82 | }, 83 | "debug/email": { 84 | "file": "debug/email.tsx", 85 | "id": "debug/email", 86 | "index": false, 87 | "parentId": "root", 88 | "path": "email", 89 | }, 90 | "routes": { 91 | "file": "routes/home.tsx", 92 | "id": "routes", 93 | "index": false, 94 | "parentId": "root", 95 | "path": "/", 96 | }, 97 | "routes/$tenant": { 98 | "file": "tenant/home.tsx", 99 | "id": "routes/$tenant", 100 | "index": false, 101 | "parentId": "routes/$tenant", 102 | "path": "/", 103 | }, 104 | "routes/$tenant.posts.$postId": { 105 | "file": "routes/$tenant.posts.$postId.tsx", 106 | "id": "routes/$tenant.posts.$postId", 107 | "index": false, 108 | "parentId": "routes/$tenant", 109 | "path": "posts/:postId", 110 | }, 111 | "routes/$tenant.users.$userId": { 112 | "file": "routes/$tenant.users.$userId.tsx", 113 | "id": "routes/$tenant.users.$userId", 114 | "index": false, 115 | "parentId": "routes/$tenant", 116 | "path": "users/:userId", 117 | }, 118 | "routes/*": { 119 | "file": "routes/not-found.tsx", 120 | "id": "routes/*", 121 | "index": false, 122 | "parentId": "root", 123 | "path": "*", 124 | }, 125 | "routes/admin": { 126 | "file": "routes/admin/home.tsx", 127 | "id": "routes/admin", 128 | "index": false, 129 | "parentId": "root", 130 | "path": "admin", 131 | }, 132 | "routes/admin.posts": { 133 | "file": "routes/admin.posts.tsx", 134 | "id": "routes/admin.posts", 135 | "index": false, 136 | "parentId": "routes/admin", 137 | "path": "posts", 138 | }, 139 | "routes/admin.users": { 140 | "file": "routes/admin.users.tsx", 141 | "id": "routes/admin.users", 142 | "index": false, 143 | "parentId": "routes/admin", 144 | "path": "users", 145 | }, 146 | "routes/posts.$postId": { 147 | "file": "routes/posts.$postId.tsx", 148 | "id": "routes/posts.$postId", 149 | "index": false, 150 | "parentId": "root", 151 | "path": "posts/:postId", 152 | }, 153 | "routes/users": { 154 | "file": "routes/users.tsx", 155 | "id": "routes/users", 156 | "index": false, 157 | "parentId": "root", 158 | "path": "users", 159 | }, 160 | "routes/users.$userId": { 161 | "file": "routes/users.$userId._index.tsx", 162 | "id": "routes/users.$userId", 163 | "index": false, 164 | "parentId": "routes/users.$userId", 165 | "path": "/", 166 | }, 167 | "routes/users.$userId.posts": { 168 | "file": "routes/users.$userId.posts.tsx", 169 | "id": "routes/users.$userId.posts", 170 | "index": false, 171 | "parentId": "routes/users.$userId", 172 | "path": "posts", 173 | }, 174 | } 175 | `; 176 | -------------------------------------------------------------------------------- /src/helpers/apply-layout.ts: -------------------------------------------------------------------------------- 1 | import { join } from "node:path"; 2 | 3 | export function applyLayout(layout: string, path: string) { 4 | if (layout === "") return path; 5 | if (path === "/") return layout; 6 | return join(layout, path); 7 | } 8 | -------------------------------------------------------------------------------- /src/helpers/is-index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { isIndex } from "./is-index"; 3 | 4 | test("isIndex returns true", () => { 5 | expect(isIndex("index")).toBe(true); 6 | expect(isIndex("something/index")).toBe(true); 7 | }); 8 | 9 | test("isIndex returns false", () => { 10 | expect(isIndex("something")).toBe(false); 11 | }); 12 | -------------------------------------------------------------------------------- /src/helpers/is-index.ts: -------------------------------------------------------------------------------- 1 | export function isIndex(path: string) { 2 | return path.endsWith("index"); 3 | } 4 | -------------------------------------------------------------------------------- /src/helpers/path-to-id.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | 3 | import { pathToId } from "./path-to-id"; 4 | 5 | describe(pathToId.name, () => { 6 | describe.each(["", "admin", "tenant"])('pathToId with base "%s"', (base) => { 7 | test("index", () => { 8 | expect(pathToId("index", base)).toBe(`${base || "routes"}/_index`); 9 | }); 10 | 11 | test("auth", () => { 12 | expect(pathToId("auth", base)).toBe(`${base || "routes"}/auth`); 13 | }); 14 | 15 | test("auth/index", () => { 16 | expect(pathToId("auth/index", base)).toBe( 17 | `${base || "routes"}/auth._index`, 18 | ); 19 | }); 20 | 21 | test("auth/register", () => { 22 | expect(pathToId("auth/register", base)).toBe( 23 | `${base || "routes"}/auth.register`, 24 | ); 25 | }); 26 | 27 | test(":userId", () => { 28 | expect(pathToId(":userId", base)).toBe(`${base || "routes"}/$userId`); 29 | }); 30 | 31 | test("users/:userId", () => { 32 | expect(pathToId("users/:userId", base)).toBe( 33 | `${base || "routes"}/users.$userId`, 34 | ); 35 | }); 36 | 37 | test("users/:userId/posts", () => { 38 | expect(pathToId("users/:userId/posts", base)).toBe( 39 | `${base || "routes"}/users.$userId.posts`, 40 | ); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/helpers/path-to-id.ts: -------------------------------------------------------------------------------- 1 | import { join } from "node:path"; 2 | 3 | export function pathToId(path: string, base = "routes") { 4 | let result = path 5 | .replaceAll("/", ".") 6 | .replace("index", "_index") 7 | .replaceAll(":", "$"); 8 | if (!base) return join("routes", result); 9 | return join(base, result); 10 | } 11 | -------------------------------------------------------------------------------- /src/helpers/remove-index.ts: -------------------------------------------------------------------------------- 1 | export function removeIndex(path: string) { 2 | if (path.endsWith("index")) return path.slice(0, -6); 3 | if (path.includes("index")) { 4 | throw new Error("Invalid path. Index must be the end of tha path"); 5 | } 6 | return path; 7 | } 8 | -------------------------------------------------------------------------------- /src/helpers/resolve-extension.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from "node:fs"; 2 | import { resolve } from "node:path"; 3 | 4 | export function resolveExtension(file: string) { 5 | if (existsSync(resolve(`./app/${file}/route.tsx`))) { 6 | return `${file}/route.tsx`; 7 | } 8 | 9 | if (existsSync(resolve(`./app/${file}/route.ts`))) return `${file}/route.ts`; 10 | 11 | if (existsSync(resolve(`./app/${file}.tsx`))) return `${file}.tsx`; 12 | 13 | if (existsSync(resolve(`./app/${file}.ts`))) return `${file}.ts`; 14 | 15 | throw new Error(`File ${file} not found`); 16 | } 17 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, mock, test } from "bun:test"; 2 | import { defineRoutes } from "."; 3 | 4 | // Mock node:fs to make existsSync return true always so we don't need the files 5 | // in the test 6 | mock.module("node:fs", () => { 7 | return { 8 | existsSync(path: string) { 9 | if (path.endsWith("/route.tsx") || path.endsWith("/route.ts")) { 10 | return false; 11 | } 12 | return true; 13 | }, 14 | }; 15 | }); 16 | 17 | describe(defineRoutes.name, () => { 18 | let tenantRoutes = defineRoutes(({ layout }) => { 19 | layout(":tenant", { base: "tenant" }, ({ route }) => { 20 | route("/", "tenant/home"); 21 | route("posts/:postId"); 22 | route("users/:userId"); 23 | }); 24 | }); 25 | 26 | let adminRoutes = defineRoutes(({ layout }) => { 27 | // Define the admin scope, move the base folder to app/admin 28 | layout("admin", { base: "admin" }, ({ route }) => { 29 | route("/", { file: "admin/home" }); // Redirect /admin to /admin/users 30 | route("users"); 31 | route("posts"); 32 | }); 33 | }); 34 | 35 | let debugRoutes = defineRoutes(({ base }) => { 36 | // Change the routes folder to app/debug 37 | base("debug", ({ route }) => { 38 | route("email"); 39 | route("cache"); 40 | }); 41 | }); 42 | 43 | let fullRoutes = defineRoutes(({ route, extend }) => { 44 | // Apply routes from other files or packages 45 | extend(tenantRoutes); 46 | extend(adminRoutes); 47 | extend(debugRoutes); 48 | // Overwrites /admin 49 | route("admin", "routes/admin/home"); 50 | // Define / route 51 | route("/", "routes/home"); 52 | route("users"); 53 | route("posts/:postId"); 54 | route("users/:userId", ({ route }) => { 55 | route("/", "routes/users.$userId._index"); 56 | route("posts"); 57 | }); 58 | route("*", "routes/not-found"); 59 | }); 60 | 61 | test("tenant routes", () => { 62 | expect(tenantRoutes).toMatchSnapshot(); 63 | }); 64 | 65 | test("admin routes", () => { 66 | expect(adminRoutes).toMatchSnapshot(); 67 | }); 68 | 69 | test("debug routes", () => { 70 | expect(debugRoutes).toMatchSnapshot(); 71 | }); 72 | 73 | test("full routes", async () => { 74 | expect(fullRoutes).toMatchSnapshot(); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { ResolvedRemixConfig } from "@remix-run/dev"; 2 | 3 | import { applyLayout } from "./helpers/apply-layout.js"; 4 | import { isIndex } from "./helpers/is-index.js"; 5 | import { pathToId } from "./helpers/path-to-id.js"; 6 | import { removeIndex } from "./helpers/remove-index.js"; 7 | import { resolveExtension } from "./helpers/resolve-extension.js"; 8 | 9 | type RouteManifest = ResolvedRemixConfig["routes"]; 10 | 11 | type ConfigRoute = RouteManifest[string]; 12 | 13 | type RouteOptions = { file?: string; base?: string }; 14 | 15 | type RouteCallback = (args: DefineRoutesCallbackArgs) => void; 16 | 17 | type RouteFunctionArgs = [] | [string] | [RouteOptions]; 18 | 19 | type RouteFunctionWithCallbackArgs = 20 | | RouteFunctionArgs 21 | | [RouteCallback] 22 | | [string, RouteCallback] 23 | | [RouteOptions, RouteCallback]; 24 | 25 | type CreateRouteFunctionArgs = { 26 | base?: string; 27 | layout?: string; 28 | parentId?: string; 29 | }; 30 | 31 | function createRouteFunction({ 32 | base = "routes", 33 | layout = "", 34 | parentId = "root", 35 | }: CreateRouteFunctionArgs = {}) { 36 | return function route(path: string, ...args: RouteFunctionArgs): ConfigRoute { 37 | if (args.length === 0) { 38 | let id = pathToId(applyLayout(layout, path), base); 39 | return { 40 | path: removeIndex(path), 41 | id, 42 | file: resolveExtension(id), 43 | index: isIndex(path), 44 | parentId, 45 | }; 46 | } 47 | 48 | if (args.length === 1 && typeof args[0] === "string") { 49 | let [file] = args; 50 | let id = pathToId(applyLayout(layout, path), base); 51 | return { 52 | path: removeIndex(path), 53 | id, 54 | file: resolveExtension(file), 55 | index: isIndex(path), 56 | parentId, 57 | }; 58 | } 59 | 60 | if (args.length === 1 && typeof args[0] === "object") { 61 | let [{ file }] = args; 62 | let id = pathToId(applyLayout(layout, path), base); 63 | return { 64 | path: removeIndex(path), 65 | id, 66 | file: resolveExtension(file || id), 67 | index: isIndex(path), 68 | parentId, 69 | }; 70 | } 71 | 72 | throw new Error("Invalid way to call `route`"); 73 | }; 74 | } 75 | 76 | type LayoutFunctionArgs = 77 | | [RouteCallback] 78 | | [CreateRouteFunctionArgs, RouteCallback] 79 | | [string, RouteCallback] 80 | | [string, CreateRouteFunctionArgs, RouteCallback]; 81 | 82 | interface DefineRoutesCallbackArgs { 83 | /** 84 | * Define a route in the application. Each route has a path, based on the path 85 | * there's an associated file, which can be customized. 86 | * 87 | * Each route can also work as a layout for other nested routes, keeping both 88 | * the URL segment and the UI. 89 | * @example 90 | * route("about") // Renders at /about 91 | * @example 92 | * route("about", "landings/about") 93 | * @example 94 | * route("about", { file: "landings/about" }) 95 | * @example 96 | * route("about", { base: "landings" }) 97 | * @example 98 | * route("about", ({ route }) => { 99 | * route("team") // Renders at /about/team 100 | * }) 101 | * @example 102 | * route("about", { file: "landings/about" }, ({ route }) => { 103 | * route("team") // Renders at /about/team 104 | * }) 105 | */ 106 | route(path: string, ...args: RouteFunctionWithCallbackArgs): void; 107 | base(base: string, callback: RouteCallback): void; 108 | layout(path: string, ...args: LayoutFunctionArgs): void; 109 | extend(manifest: RouteManifest): void; 110 | } 111 | 112 | function createDefineRoutes({ 113 | base = "routes", 114 | layout = "", 115 | parentId = "root", 116 | }: CreateRouteFunctionArgs = {}) { 117 | return function defineRoutes( 118 | callback: (args: DefineRoutesCallbackArgs) => void, 119 | ) { 120 | let routes: RouteManifest = {}; 121 | 122 | let route = createRouteFunction({ base, layout: layout, parentId }); 123 | 124 | callback({ 125 | route(path: string, ...args: RouteFunctionWithCallbackArgs) { 126 | let argsWithoutCallback = args.filter( 127 | (arg) => typeof arg !== "function", 128 | ) as RouteFunctionArgs; 129 | 130 | let config = route(path, ...argsWithoutCallback); 131 | routes[config.id] = { parentId: "root", ...config }; 132 | 133 | if (args.length === 1 && typeof args[0] === "function") { 134 | let [callback] = args; 135 | let defineRoutes = createDefineRoutes({ 136 | base, 137 | layout: config.path, 138 | parentId: config.id, 139 | }); 140 | 141 | Object.assign(routes, defineRoutes(callback)); 142 | } 143 | }, 144 | 145 | base(base, callback) { 146 | let defineRoutes = createDefineRoutes({ base }); 147 | Object.assign(routes, defineRoutes(callback)); 148 | }, 149 | 150 | layout(path, ...args) { 151 | let config: ConfigRoute; 152 | let callback: RouteCallback; 153 | 154 | if (args.length === 1) { 155 | [callback] = args; 156 | config = route(path); 157 | } else if (args.length === 2 && typeof args[0] === "object") { 158 | let [routeArgs, _callback] = args; 159 | callback = _callback; 160 | config = route(path, routeArgs); 161 | } else if (args.length === 2 && typeof args[0] === "string") { 162 | let [file, _callback] = args; 163 | callback = _callback; 164 | config = route(path, { file }); 165 | } else if (args.length === 3) { 166 | let [file, routeArgs, _callback] = args; 167 | callback = _callback; 168 | config = route(path, { ...routeArgs, file }); 169 | } else { 170 | throw new Error("Invalid way to call `layout`."); 171 | } 172 | 173 | routes[config.id] = { parentId: "root", ...config }; 174 | 175 | let defineRoutes = createDefineRoutes({ 176 | base, 177 | ...args, 178 | layout: path, 179 | parentId: config.id, 180 | }); 181 | 182 | Object.assign(routes, defineRoutes(callback)); 183 | }, 184 | 185 | extend(manifest) { 186 | Object.assign(routes, manifest); 187 | }, 188 | }); 189 | 190 | return routes; 191 | }; 192 | } 193 | 194 | export const defineRoutes = createDefineRoutes(); 195 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext"], 4 | "esModuleInterop": true, 5 | "moduleResolution": "NodeNext", 6 | "module": "NodeNext", 7 | "target": "ESNext", 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "declaration": true, 11 | "outDir": "./build" 12 | }, 13 | "exclude": ["node_modules"], 14 | "include": ["src/index.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "includeVersion": true, 4 | "entryPoints": ["./src/index.ts"], 5 | "out": "docs", 6 | "json": "docs/index.json", 7 | "cleanOutputDir": true, 8 | "plugin": ["typedoc-plugin-mdn-links"], 9 | "categorizeByGroup": false 10 | } 11 | --------------------------------------------------------------------------------