├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky └── commit-msg ├── .npmrc ├── .nvmrc ├── .prettierrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── commitlint.config.js ├── nodemon.json ├── package.json ├── src ├── index.ts ├── run.ts ├── types.ts └── utils │ ├── __test__ │ ├── convertPagePathToRoutePath.test.ts │ ├── convertRoutePathToPagePath.test.ts │ ├── createPageFilesIfNotExist.test.ts │ ├── deleteEmptyDirectoriesWithinRoutes.test.ts │ ├── deletePageFilesIfRouteMissing.test.ts │ ├── findPageFiles.test.ts │ └── findRouteFiles.test.ts │ ├── convertPagePathToRoutePath.ts │ ├── convertRoutePathToPagePath.ts │ ├── createPageFilesIfNotExists.ts │ ├── deleteEmptyDirectoriesWithinRoutes.ts │ ├── deletePageFilesIfRouteMissing.ts │ ├── findPageFiles.ts │ ├── findRouteFiles.ts │ └── watchDirectory.ts ├── tsconfig.json ├── unbuild.config.ts ├── vitest.config.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_size = 2 9 | indent_style = space 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint-config-unjs"], 3 | "rules": { 4 | "no-useless-constructor": "off", 5 | "unicorn/filename-case": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | ci: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: 16 19 | cache: 'yarn' 20 | - run: yarn install --frozen-lockfile 21 | - run: yarn lint 22 | - run: yarn build 23 | - run: yarn vitest run --coverage 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # dependencies 3 | /node_modules 4 | /.pnp 5 | .pnp.js 6 | 7 | # testing 8 | /coverage 9 | 10 | # production 11 | /dist 12 | 13 | # misc 14 | .DS_Store 15 | *.pem 16 | 17 | # debug 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # local env files 23 | .env*.local 24 | 25 | # typescript 26 | *.tsbuildinfo 27 | next-env.d.ts 28 | 29 | # other 30 | .eslintcache 31 | 32 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn commit-msg 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | shrinkwrap=false -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.15.1 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "semi": false, 4 | "singleQuote": true, 5 | "bracketSpacing": true, 6 | "arrowParens": "always", 7 | "bracketSameLine": true, 8 | "trailingComma": "es5" 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["unflatten"] 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.1.6 4 | 5 | [compare changes](https://github.com/ifyio/next-flat-routes/compare/v0.1.5...v0.1.6) 6 | 7 | ### 🩹 Fixes 8 | 9 | - Only delete empty folders within (.routes) folders ([26cea0f](https://github.com/ifyio/next-flat-routes/commit/26cea0f)) 10 | 11 | ### ❤️ Contributors 12 | 13 | - Ifeanyi Isitor 14 | 15 | ## v0.1.5 16 | 17 | [compare changes](https://github.com/ifyio/next-flat-routes/compare/v0.1.4...v0.1.5) 18 | 19 | ### 📖 Documentation 20 | 21 | - Tweak docs ([1b192f3](https://github.com/ifyio/next-flat-routes/commit/1b192f3)) 22 | 23 | ### 🏡 Chore 24 | 25 | - Rename to next-flat-routes ([ede2528](https://github.com/ifyio/next-flat-routes/commit/ede2528)) 26 | 27 | ### ❤️ Contributors 28 | 29 | - Ifeanyi Isitor 30 | 31 | ## v0.1.4 32 | 33 | [compare changes](https://github.com/ifyio/next-flat-routes/compare/v0.1.3...v0.1.4) 34 | 35 | ### 🩹 Fixes 36 | 37 | - Fix typo in docs ([f43d27f](https://github.com/ifyio/next-flat-routes/commit/f43d27f)) 38 | 39 | ### 🏡 Chore 40 | 41 | - Rename to n-route and update readme ([eef61cc](https://github.com/ifyio/next-flat-routes/commit/eef61cc)) 42 | 43 | ### ❤️ Contributors 44 | 45 | - Ifeanyi Isitor 46 | 47 | ## v0.1.3 48 | 49 | [compare changes](https://github.com/ifyio/next-flat-routes/compare/v0.1.2...v0.1.3) 50 | 51 | ### 🚀 Enhancements 52 | 53 | - Add support for `.ts` extension ([00dc28c](https://github.com/ifyio/next-flat-routes/commit/00dc28c)) 54 | 55 | ### 📖 Documentation 56 | 57 | - Tidy up readme docs ([e738881](https://github.com/ifyio/next-flat-routes/commit/e738881)) 58 | 59 | ### 🏡 Chore 60 | 61 | - Add keywords to package.json ([40baca9](https://github.com/ifyio/next-flat-routes/commit/40baca9)) 62 | 63 | ### ❤️ Contributors 64 | 65 | - Ifeanyi Isitor 66 | 67 | ## v0.1.2 68 | 69 | [compare changes](https://github.com/ifyio/next-flat-routes/compare/v0.1.1...v0.1.2) 70 | 71 | ### 🩹 Fixes 72 | 73 | - Output error when when run with the wrong node version ([825b78d](https://github.com/ifyio/next-flat-routes/commit/825b78d)) 74 | 75 | ### 📖 Documentation 76 | 77 | - Update readme to include the correct way to run the command ([c119d2f](https://github.com/ifyio/next-flat-routes/commit/c119d2f)) 78 | 79 | ### 🏡 Chore 80 | 81 | - Add the repository details to the package.json file ([7e7b373](https://github.com/ifyio/next-flat-routes/commit/7e7b373)) 82 | - Log out changes to route files ([d8d94a6](https://github.com/ifyio/next-flat-routes/commit/d8d94a6)) 83 | 84 | ### ❤️ Contributors 85 | 86 | - Ifeanyi Isitor 87 | 88 | ## v0.1.1 89 | 90 | ### 🚀 Enhancements 91 | 92 | - Implement initial functionality (21cd868) 93 | 94 | ### 🏡 Chore 95 | 96 | - Initial code (1862aad) 97 | - Update the project description (10ba04c) 98 | - Setup conventional comit linting (c373248) 99 | - Integrate changelogen (fcf3e9a) 100 | 101 | ### ❤️ Contributors 102 | 103 | - Ifeanyi Isitor 104 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ifeanyi Isitor 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 | # next-flat-routes 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![Github Actions][github-actions-src]][github-actions-href] 5 | 6 | > Enabling flat routes for Next.js 7 | 8 | ## Table of Contents 9 | 10 | - [Introduction](#introduction) 11 | - [Usage](#usage) 12 | - [About flat routes](#about-flat-routes) 13 | - [Index route files](#index-route-files) 14 | - [Supported file extensions](#supported-file-extensions) 15 | - [License](#license) 16 | 17 | --- 18 | 19 | ## Introduction 20 | 21 | With the introduction of [Next.js 13][nextjs], a new folder-based routing mechanism was unveiled. While this approach offers powerful and flexible routing capabilities, it brings with it the challenge of managing deeply nested route files. In large projects with a myriad of routes, locating a specific route or deciphering the intricate structure of the application becomes increasingly complex. 22 | 23 | Enter `next-flat-routes`. 24 | 25 | Designed specifically for Next.js 13, `next-flat-routes` is a CLI tool that allows developers to work with a flat route file structure that is easier to manage and understand. With `next-flat-routes` your routes can be structured like this: 26 | 27 | ``` 28 | app/ 29 | |-- shop/ 30 | |-- routes/ 31 | |-- basket.(page).tsx 32 | |-- product.(page).tsx 33 | |-- product.[id].(page).tsx 34 | 35 | ``` 36 | 37 | ... and `next-flat-routes` will ensure that these routes are transformed into the nested format that Next.js expects. 38 | 39 | ``` 40 | app/ 41 | |-- shop/ 42 | |-- (.routes)/ 43 | | |-- basket/ 44 | | | |-- page.tsx 45 | | |-- product/ 46 | | |-- page.tsx 47 | | |-- [id]/ 48 | | |-- page.tsx 49 | |-- routes/ 50 | |-- basket.(page).tsx 51 | |-- product.(page).tsx 52 | |-- product.[id].(page).tsx 53 | 54 | ``` 55 | 56 | ## Usage 57 | 58 | To start using the `next-flat-routes`, run the following command in the Next.js project root: 59 | 60 | ```sh 61 | npx next-flat-routes@latest 62 | ``` 63 | 64 | This will initiate the `next-flat-routes` CLI in watch mode. 65 | 66 | Then add flat route files within any `/routes/` folder located within the `app` directory. As you add, rename, or remove these flat route files, the equivalent nested route file will be generated or updated within a parallel `/(.routes)/` directory. 67 | 68 | > Note: The `/(.routes)/` directory should be considered as "private", similar to the `.next` directory that Next.js uses for its build output. Files within this directory are auto-generated and should not be manually edited. 69 | 70 | ## About flat routes 71 | 72 | Flat routes can be created for `page.tsx`, `layout.tsx`, `loading.tsx`, `error.tsx` and `route.tsx` files. All that is required is for their flat route equivalent filenames to end with `.(page).tsx`, `.(layout).tsx`, `.(loading).tsx`, `.(error).tsx` and `.(route).tsx` . 73 | 74 | Example: 75 | 76 | ``` 77 | /app/shop/routes/basket.(page).tsx 78 | /app/shop/routes/product.(layout).tsx 79 | /app/shop/routes/product.[productId].(page).tsx 80 | /app/admin/routes/settings.(page).tsx 81 | ``` 82 | 83 | Additionally, each route segment should be delimited by a period (.), as seen in the example above. 84 | 85 | ## Index route files 86 | 87 | For flat index route files, there's no need to prefix the route filename. For instance, the admin homepage will be: 88 | 89 | ``` 90 | /app/admin/routes/(page).tsx 91 | /app/admin/routes/(error).tsx 92 | /app/admin/routes/(layout).tsx 93 | /app/admin/routes/(loading).tsx 94 | ``` 95 | 96 | ## Supported file extensions 97 | 98 | `next-flat-routes` supports `.ts`, `.tsx`, `.jsx`, and `.js` file extensions for flat route files. 99 | 100 | ## License 101 | 102 | [MIT](./LICENSE) 103 | 104 | 105 | 106 | [npm-version-src]: https://img.shields.io/npm/v/next-flat-routes?style=flat-square 107 | [npm-version-href]: https://npmjs.com/package/next-flat-routes 108 | [github-actions-src]: https://img.shields.io/github/actions/workflow/status/ifyio/next-flat-routes/ci.yml?style=flat-square 109 | [github-actions-href]: https://github.com/ifyio/next-flat-routes/actions?query=workflow%3Aci 110 | [nextjs]: https://nextjs.org 111 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { extends: ['@commitlint/config-conventional'] } 2 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["."], 3 | "ext": "ts", 4 | "ignore": ["node_modules", "dist"], 5 | "exec": "pnpm run build" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-flat-routes", 3 | "version": "0.1.6", 4 | "description": "A CLI tool to simplify Next.js routing by allowing developers to work with a flat route structure", 5 | "keywords": [ 6 | "Next.js", 7 | "routing", 8 | "CLI", 9 | "flat routes", 10 | "flat next.js routes", 11 | "route structure" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/ifyio/next-flat-routes.git" 16 | }, 17 | "bugs": { 18 | "url": "https://github.com/ifyio/next-flat-routes/issues" 19 | }, 20 | "homepage": "https://github.com/ifyio/next-flat-routes#readme", 21 | "type": "module", 22 | "module": "./dist/index.mjs", 23 | "exports": { 24 | ".": { 25 | "types": "./dist/index.d.ts", 26 | "import": "./dist/index.mjs" 27 | }, 28 | "./package.json": "./package.json" 29 | }, 30 | "types": "./dist/index.d.ts", 31 | "files": [ 32 | "dist" 33 | ], 34 | "scripts": { 35 | "dev": "nodemon", 36 | "build": "unbuild", 37 | "lint": "eslint --cache --ext .ts,.js,.mjs,.cjs . && prettier -c src", 38 | "lint:fix": "eslint --cache --ext .ts,.js,.mjs,.cjs . --fix && prettier -c src -w", 39 | "prepack": "yarn build", 40 | "test": "vitest", 41 | "prepare": "husky install", 42 | "commit-msg": "commitlint --edit $1", 43 | "release": "vitest run && changelogen --release && git push --follow-tags && npm publish" 44 | }, 45 | "author": "Ifeanyi Isitor", 46 | "license": "MIT", 47 | "dependencies": { 48 | "chalk": "^5.3.0", 49 | "esm": "^3.2.25", 50 | "lru-cache": "^10.0.1", 51 | "meow": "^12.0.1", 52 | "reflect-metadata": "^0.1.13", 53 | "semver": "^7.5.4", 54 | "unbuild": "^1.2.1", 55 | "yallist": "^4.0.0" 56 | }, 57 | "devDependencies": { 58 | "@commitlint/cli": "^17.7.1", 59 | "@commitlint/config-conventional": "^17.7.0", 60 | "@types/esm": "^3.2.0", 61 | "@types/mock-fs": "^4.13.1", 62 | "@types/node": "^20.4.8", 63 | "@vitest/coverage-v8": "^0.34.2", 64 | "changelogen": "^0.5.4", 65 | "eslint": "^8.46.0", 66 | "eslint-config-unjs": "^0.2.1", 67 | "husky": "^8.0.3", 68 | "mock-fs": "^5.2.0", 69 | "nodemon": "^3.0.1", 70 | "prettier": "^3.0.1", 71 | "typescript": "^5.1.6", 72 | "vitest": "^0.34.1" 73 | }, 74 | "bin": { 75 | "next-flat-routes": "./dist/index.mjs" 76 | }, 77 | "engines": { 78 | "node": ">=16.0.0" 79 | } 80 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import semver from 'semver' 4 | import chalk from 'chalk' 5 | 6 | const requiredNodeVersion = '16.0.0' 7 | 8 | if (!semver.satisfies(process.version, `>=${requiredNodeVersion}`)) { 9 | console.error( 10 | chalk.red( 11 | `Error: This tool requires Node.js version ${requiredNodeVersion} or newer. You are using ${process.version}. Please update your Node.js version and try again.` 12 | ) 13 | ) 14 | process.exit(1) // eslint-disable-line unicorn/no-process-exit 15 | } 16 | 17 | // If version check passes, dynamically import the main CLI 18 | import('./run') 19 | -------------------------------------------------------------------------------- /src/run.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import meow from 'meow' 4 | import chalk from 'chalk' 5 | import { findPageFiles } from './utils/findPageFiles' 6 | import { findRouteFiles } from './utils/findRouteFiles' 7 | import { watchDirectory } from './utils/watchDirectory' 8 | import { createPageFilesIfNotExist } from './utils/createPageFilesIfNotExists' 9 | import { deleteEmptyDirectoriesWithinRoutes } from './utils/deleteEmptyDirectoriesWithinRoutes' 10 | import { deletePageFilesIfRouteMissing } from './utils/deletePageFilesIfRouteMissing' 11 | 12 | meow( 13 | ` 14 | Usage 15 | $ npx next-flat-routes 16 | 17 | Description 18 | Convert flat Next.js 13 route files to the nested structure 19 | 20 | How it works 21 | The CLI will watch for any flat route files located within '/routes/' folders located anywhere within the 'app' directory of your Next.js project. 22 | It will then generate the nested equivalent in a parallel '/(.routes)/' folder. 23 | 24 | Options 25 | --help Show help 26 | --version Show version 27 | 28 | Note 29 | Do not manually modify or delete files in the '/(.routes)/' directory, as they are auto-generated. 30 | 31 | `, 32 | { 33 | importMeta: import.meta, 34 | } 35 | ) 36 | 37 | function run() { 38 | const currentDirectory = process.cwd() 39 | 40 | unflatten(currentDirectory) 41 | 42 | watchDirectory(currentDirectory, () => { 43 | unflatten(currentDirectory) 44 | }) 45 | 46 | console.log(chalk.blue('\n➜ Listening for changes to routes...\n')) 47 | } 48 | 49 | function unflatten(dir: string) { 50 | generatePageFiles(dir) 51 | removeUnlinkedPageFiles(dir) 52 | deleteEmptyDirectoriesWithinRoutes(dir) 53 | } 54 | 55 | function generatePageFiles(dir: string) { 56 | const routeFiles = findRouteFiles(dir) 57 | createPageFilesIfNotExist(routeFiles) 58 | } 59 | 60 | function removeUnlinkedPageFiles(dir: string) { 61 | const pageFiles = findPageFiles(dir) 62 | deletePageFilesIfRouteMissing(pageFiles) 63 | } 64 | 65 | run() 66 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type RouteFile = { 2 | routePath: string 3 | pagePath: string 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/__test__/convertPagePathToRoutePath.test.ts: -------------------------------------------------------------------------------- 1 | import { convertPagePathToRoutePath } from '../convertPagePathToRoutePath' 2 | 3 | describe('convertPagePathToRoutePath', () => { 4 | it.each([ 5 | // Index routes 6 | { 7 | pagePath: '/somePath/(.routes)/page.tsx', 8 | routePath: '/somePath/routes/(page).tsx', 9 | }, 10 | { 11 | pagePath: '/somePath/(.routes)/layout.jsx', 12 | routePath: '/somePath/routes/(layout).jsx', 13 | }, 14 | { 15 | pagePath: '/somePath/(.routes)/loading.tsx', 16 | routePath: '/somePath/routes/(loading).tsx', 17 | }, 18 | { 19 | pagePath: '/somePath/(.routes)/error.js', 20 | routePath: '/somePath/routes/(error).js', 21 | }, 22 | { 23 | pagePath: '/somePath/(.routes)/route.ts', 24 | routePath: '/somePath/routes/(route).ts', 25 | }, 26 | // Nested routes 27 | { 28 | pagePath: '/somePath/(.routes)/foo/[...any]/[id]/baz/page.tsx', 29 | routePath: '/somePath/routes/foo.[...any].[id].baz.(page).tsx', 30 | }, 31 | { 32 | pagePath: '/somePath/(.routes)/foo/[...any]/[id]/baz/layout.tsx', 33 | routePath: '/somePath/routes/foo.[...any].[id].baz.(layout).tsx', 34 | }, 35 | { 36 | pagePath: '/somePath/(.routes)/foo/[...any]/[id]/baz/loading.js', 37 | routePath: '/somePath/routes/foo.[...any].[id].baz.(loading).js', 38 | }, 39 | { 40 | pagePath: '/somePath/(.routes)/foo/[...any]/[id]/baz/error.tsx', 41 | routePath: '/somePath/routes/foo.[...any].[id].baz.(error).tsx', 42 | }, 43 | { 44 | pagePath: '/somePath/(.routes)/foo/[...any]/[id]/baz/route.tsx', 45 | routePath: '/somePath/routes/foo.[...any].[id].baz.(route).tsx', 46 | }, 47 | ])(`converts $pagePath to $routePath`, ({ pagePath, routePath }) => { 48 | expect(convertPagePathToRoutePath(pagePath)).toBe(routePath) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/utils/__test__/convertRoutePathToPagePath.test.ts: -------------------------------------------------------------------------------- 1 | import { convertRoutePathToPagePath } from '../convertRoutePathToPagePath' 2 | 3 | describe('convertRoutePathToPagePath', () => { 4 | it.each([ 5 | // Index routes 6 | { 7 | routePath: '/somePath/routes/(page).tsx', 8 | pagePath: '/somePath/(.routes)/page.tsx', 9 | }, 10 | { 11 | routePath: '/somePath/routes/(layout).jsx', 12 | pagePath: '/somePath/(.routes)/layout.jsx', 13 | }, 14 | { 15 | routePath: '/somePath/routes/(loading).tsx', 16 | pagePath: '/somePath/(.routes)/loading.tsx', 17 | }, 18 | { 19 | routePath: '/somePath/routes/(error).js', 20 | pagePath: '/somePath/(.routes)/error.js', 21 | }, 22 | { 23 | routePath: '/somePath/routes/(route).ts', 24 | pagePath: '/somePath/(.routes)/route.ts', 25 | }, 26 | // Nested routes 27 | { 28 | routePath: '/somePath/routes/foo.[...any].[id].baz.(page).tsx', 29 | pagePath: '/somePath/(.routes)/foo/[...any]/[id]/baz/page.tsx', 30 | }, 31 | { 32 | routePath: '/somePath/routes/foo.[...any].[id].baz.(layout).js', 33 | pagePath: '/somePath/(.routes)/foo/[...any]/[id]/baz/layout.js', 34 | }, 35 | { 36 | routePath: '/somePath/routes/foo.[...any].[id].baz.(loading).tsx', 37 | pagePath: '/somePath/(.routes)/foo/[...any]/[id]/baz/loading.tsx', 38 | }, 39 | { 40 | routePath: '/somePath/routes/foo.[...any].[id].baz.(error).tsx', 41 | pagePath: '/somePath/(.routes)/foo/[...any]/[id]/baz/error.tsx', 42 | }, 43 | { 44 | routePath: '/somePath/routes/foo.[...any].[id].baz.(route).tsx', 45 | pagePath: '/somePath/(.routes)/foo/[...any]/[id]/baz/route.tsx', 46 | }, 47 | ])('converts $routePath to $pagePath', ({ routePath, pagePath }) => { 48 | const output = convertRoutePathToPagePath(routePath) 49 | 50 | expect(output).toBe(pagePath) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /src/utils/__test__/createPageFilesIfNotExist.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import mockFs from 'mock-fs' 3 | import { createPageFilesIfNotExist } from '../createPageFilesIfNotExists' 4 | 5 | describe('createPageFilesIfNotExist', () => { 6 | beforeEach(() => { 7 | // Set up mock file system before each test 8 | mockFs({ 9 | 'app/shop/routes/': { 10 | 'basket.(page).tsx': 'mock content', 11 | 'product.(page).jsx': 'mock content', 12 | 'checkout.(page).js': 'mock content', 13 | }, 14 | }) 15 | }) 16 | 17 | afterEach(() => { 18 | // Restore the file system after each test 19 | mockFs.restore() 20 | }) 21 | 22 | it.each([ 23 | [ 24 | 'tsx', 25 | 'basket', 26 | "export * from '../../routes/basket.(page)';\n" + 27 | "export { default } from '../../routes/basket.(page)';\n", 28 | ], 29 | [ 30 | 'jsx', 31 | 'product', 32 | "export * from '../../routes/product.(page)';\n" + 33 | "export { default } from '../../routes/product.(page)';\n", 34 | ], 35 | [ 36 | 'js', 37 | 'checkout', 38 | "export * from '../../routes/checkout.(page)';\n" + 39 | "export { default } from '../../routes/checkout.(page)';\n", 40 | ], 41 | ])( 42 | 'should create the page file for .%s extension if it does not exist', 43 | (extension, fileName, expectedContent) => { 44 | const mockRouteFiles = [ 45 | { 46 | routePath: `app/shop/routes/${fileName}.(page).${extension}`, 47 | pagePath: `app/shop/(.routes)/${fileName}/page.${extension}`, 48 | }, 49 | ] 50 | 51 | createPageFilesIfNotExist(mockRouteFiles) 52 | 53 | // Use the regular fs module to read the file 54 | const actualContent = fs.readFileSync(mockRouteFiles[0].pagePath, 'utf8') 55 | 56 | expect(actualContent).toBe(expectedContent) 57 | } 58 | ) 59 | }) 60 | -------------------------------------------------------------------------------- /src/utils/__test__/deleteEmptyDirectoriesWithinRoutes.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import mockFs from 'mock-fs' 3 | import { deleteEmptyDirectoriesWithinRoutes } from '../deleteEmptyDirectoriesWithinRoutes' 4 | 5 | describe('deleteEmptyDirectoriesWithinRoutes', () => { 6 | beforeAll(() => { 7 | // Mock the file system based on the structure you provide 8 | mockFs({ 9 | '/test-dir': { 10 | foo: { 11 | '(.routes)': { 12 | 'empty-dir': {}, 13 | }, 14 | }, 15 | 'dir-with-file': { 16 | 'some-file.txt': 'content here', 17 | }, 18 | routes: { 19 | 'empty-subdir': {}, 20 | }, 21 | '(.routes)': { 22 | 'empty-subdir': { 23 | foo: {}, 24 | }, 25 | 'another-empty': { 26 | 'yet-another-empty': {}, 27 | }, 28 | 'dir-with-file': { 29 | 'some-file.txt': 'content here', 30 | }, 31 | }, 32 | }, 33 | }) 34 | }) 35 | 36 | afterAll(() => { 37 | mockFs.restore() 38 | }) 39 | 40 | it('should delete only the right empty directories', () => { 41 | deleteEmptyDirectoriesWithinRoutes('/test-dir') 42 | 43 | // Check if the directories are correctly deleted or retained 44 | expect(fs.existsSync('/test-dir/foo/(.routes)')).toBeTruthy() // Empty (.routes) folder should not be deleted 45 | expect(fs.existsSync('/test-dir/foo/(.routes)/empty-dir')).toBeFalsy() // Empty and within (.routes), so it should be deleted 46 | expect(fs.existsSync('/test-dir/dir-with-file')).toBeTruthy() 47 | expect(fs.existsSync('/test-dir/routes/empty-subdir')).toBeTruthy() // Not within (.routes), so it should remain 48 | expect(fs.existsSync('/test-dir/(.routes)/empty-subdir')).toBeFalsy() // Empty and within (.routes), so it should be deleted 49 | expect( 50 | fs.existsSync('/test-dir/(.routes)/another-empty/yet-another-empty') 51 | ).toBeFalsy() // Empty and within nested (.routes), so it should be deleted 52 | expect(fs.existsSync('/test-dir/(.routes)/dir-with-file')).toBeTruthy() 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /src/utils/__test__/deletePageFilesIfRouteMissing.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import mockFs from 'mock-fs' 3 | import { deletePageFilesIfRouteMissing } from '../deletePageFilesIfRouteMissing' 4 | 5 | describe('deletePageFilesIfRouteMissing', () => { 6 | afterEach(() => { 7 | // Restore the real filesystem after each test 8 | mockFs.restore() 9 | }) 10 | 11 | it('should delete page files and their parent directories if their corresponding route files are missing', () => { 12 | // Set up a mocked filesystem 13 | mockFs({ 14 | '/mocked/path/(.routes)': { 15 | 'page.tsx': 'content here', 16 | nestedDir: { 17 | 'test3.page.tsx': 'content here', 18 | deeperDir: { 19 | 'test4.page.tsx': 'content here', 20 | }, 21 | }, 22 | }, 23 | '/mocked/path/routes': { 24 | '(page).tsx': 'content here', 25 | }, 26 | }) 27 | 28 | const routeFiles = [ 29 | { 30 | routePath: '/mocked/path/routes/(page).tsx', 31 | pagePath: '/mocked/path/(.routes)/page.tsx', 32 | }, 33 | { 34 | routePath: '/mocked/path/routes/nestedDir/test3.(page).tsx', 35 | pagePath: '/mocked/path/(.routes)/nestedDir/test3.page.tsx', 36 | }, 37 | { 38 | routePath: '/mocked/path/routes/nestedDir/deeperDir/test4.(page).tsx', 39 | pagePath: '/mocked/path/(.routes)/nestedDir/deeperDir/test4.page.tsx', 40 | }, 41 | ] 42 | 43 | deletePageFilesIfRouteMissing(routeFiles) 44 | 45 | // Check if the files were deleted as expected 46 | expect(() => fs.statSync('/mocked/path/(.routes)/page.tsx')).not.toThrow() 47 | expect(() => 48 | fs.statSync('/mocked/path/(.routes)/nestedDir/test3.page.tsx') 49 | ).toThrow() 50 | expect(() => 51 | fs.statSync('/mocked/path/(.routes)/nestedDir/deeperDir/test4.page.tsx') 52 | ).toThrow() 53 | 54 | // Check if directories were deleted 55 | expect(() => fs.statSync('/mocked/path/(.routes)/nestedDir')).toThrow() // deeperDir should be deleted 56 | 57 | // Check if the files that shouldn't be deleted are still there 58 | expect(() => fs.statSync('/mocked/path/routes/(page).tsx')).not.toThrow() 59 | }) 60 | 61 | it('should not delete the (.routes) directory even if it becomes empty', () => { 62 | // Set up a mocked filesystem with only one page file inside (.routes) 63 | mockFs({ 64 | '/mocked/path/(.routes)': { 65 | foo: { 66 | 'page.tsx': 'content here', 67 | }, 68 | }, 69 | }) 70 | 71 | const routeFiles = [ 72 | { 73 | routePath: '/mocked/path/routes/foo.(page).tsx', 74 | pagePath: '/mocked/path/(.routes)/foo/page.tsx', 75 | }, 76 | ] 77 | 78 | deletePageFilesIfRouteMissing(routeFiles) 79 | 80 | // Check if the file was deleted as expected 81 | expect(() => fs.statSync('/mocked/path/(.routes)/foo/page.tsx')).toThrow() 82 | 83 | // Check that the (.routes) directory is still there 84 | expect(() => fs.statSync('/mocked/path/(.routes)')).not.toThrow() 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /src/utils/__test__/findPageFiles.test.ts: -------------------------------------------------------------------------------- 1 | import mockFs from 'mock-fs' 2 | import { convertPagePathToRoutePath } from '../convertPagePathToRoutePath' 3 | import { findPageFiles } from '../findPageFiles' 4 | 5 | describe('findPageFiles', () => { 6 | afterEach(() => { 7 | // Restore the real filesystem after each test 8 | mockFs.restore() 9 | }) 10 | 11 | it('should return the expected RouteFile objects', () => { 12 | // Set up a mocked filesystem 13 | mockFs({ 14 | '/mocked/path/(.routes)': { 15 | // Index routes 16 | 'page.tsx': 'content here', 17 | 'layout.tsx': 'content here', 18 | 'loading.jsx': 'content here', 19 | 'error.tsx': 'content here', 20 | 'route.ts': 'content here', 21 | // Nested routes 22 | 'test1.page.tsx': 'content here', 23 | 'test2.layout.tsx': 'content here', 24 | 'test2.error.tsx': 'content here', 25 | 'test2.route.js': 'content here', 26 | 'test2.foo': { 27 | '[...any]': { 28 | '[id]': { 29 | baz: { 30 | 'loading.tsx': 'content here', 31 | }, 32 | }, 33 | }, 34 | }, 35 | subDir: { 36 | 'test3.error.tsx': 'content here', 37 | }, 38 | }, 39 | }) 40 | 41 | const expected = [ 42 | // Index routes 43 | { 44 | routePath: convertPagePathToRoutePath( 45 | '/mocked/path/(.routes)/error.tsx' 46 | ), 47 | pagePath: '/mocked/path/(.routes)/error.tsx', 48 | }, 49 | { 50 | routePath: convertPagePathToRoutePath( 51 | '/mocked/path/(.routes)/layout.tsx' 52 | ), 53 | pagePath: '/mocked/path/(.routes)/layout.tsx', 54 | }, 55 | { 56 | routePath: convertPagePathToRoutePath( 57 | '/mocked/path/(.routes)/loading.jsx' 58 | ), 59 | pagePath: '/mocked/path/(.routes)/loading.jsx', 60 | }, 61 | { 62 | routePath: convertPagePathToRoutePath( 63 | '/mocked/path/(.routes)/page.tsx' 64 | ), 65 | pagePath: '/mocked/path/(.routes)/page.tsx', 66 | }, 67 | { 68 | routePath: convertPagePathToRoutePath( 69 | '/mocked/path/(.routes)/route.ts' 70 | ), 71 | pagePath: '/mocked/path/(.routes)/route.ts', 72 | }, 73 | // Nested routes 74 | { 75 | routePath: convertPagePathToRoutePath( 76 | '/mocked/path/(.routes)/subDir/test3.error.tsx' 77 | ), 78 | pagePath: '/mocked/path/(.routes)/subDir/test3.error.tsx', 79 | }, 80 | { 81 | routePath: convertPagePathToRoutePath( 82 | '/mocked/path/(.routes)/test1.page.tsx' 83 | ), 84 | pagePath: '/mocked/path/(.routes)/test1.page.tsx', 85 | }, 86 | { 87 | routePath: convertPagePathToRoutePath( 88 | '/mocked/path/(.routes)/test2.error.tsx' 89 | ), 90 | pagePath: '/mocked/path/(.routes)/test2.error.tsx', 91 | }, 92 | { 93 | routePath: convertPagePathToRoutePath( 94 | '/mocked/path/(.routes)/test2.layout.tsx' 95 | ), 96 | pagePath: '/mocked/path/(.routes)/test2.layout.tsx', 97 | }, 98 | { 99 | routePath: convertPagePathToRoutePath( 100 | '/mocked/path/(.routes)/test2.route.js' 101 | ), 102 | pagePath: '/mocked/path/(.routes)/test2.route.js', 103 | }, 104 | { 105 | routePath: convertPagePathToRoutePath( 106 | '/mocked/path/(.routes)/test2.foo/[...any]/[id]/baz/loading.tsx' 107 | ), 108 | pagePath: 109 | '/mocked/path/(.routes)/test2.foo/[...any]/[id]/baz/loading.tsx', 110 | }, 111 | ] 112 | 113 | const result = findPageFiles('/mocked/path') 114 | expect(result).toEqual(expect.arrayContaining(expected)) 115 | expect(expected).toEqual(expect.arrayContaining(result)) 116 | }) 117 | }) 118 | -------------------------------------------------------------------------------- /src/utils/__test__/findRouteFiles.test.ts: -------------------------------------------------------------------------------- 1 | import mockFs from 'mock-fs' 2 | import { convertRoutePathToPagePath } from '../convertRoutePathToPagePath' 3 | import { findRouteFiles } from '../findRouteFiles' 4 | 5 | describe('findRouteFiles', () => { 6 | afterEach(() => { 7 | // Restore the real filesystem after each test 8 | mockFs.restore() 9 | }) 10 | 11 | it('should return the expected RouteFile objects', () => { 12 | // Set up a mocked filesystem 13 | mockFs({ 14 | '/mocked/path/routes': { 15 | // Index routes 16 | '(page).js': '', 17 | '(layout).tsx': '', 18 | '(loading).jsx': '', 19 | '(error).tsx': '', 20 | '(route).ts': '', 21 | // Nested routes 22 | 'test1.(page).tsx': '', 23 | 'test2.(layout).tsx': '', 24 | 'test2.foo.[...any].[id].bar.(loading).tsx': '', 25 | 'test2.(error).tsx': '', 26 | 'test2.(route).tsx': '', 27 | 28 | subDir: { 29 | 'test3.(error).tsx': '', 30 | }, 31 | }, 32 | }) 33 | 34 | const expected = [ 35 | // Index routes 36 | { 37 | pagePath: '/mocked/path/(.routes)/error.tsx', 38 | routePath: '/mocked/path/routes/(error).tsx', 39 | }, 40 | { 41 | pagePath: '/mocked/path/(.routes)/layout.tsx', 42 | routePath: '/mocked/path/routes/(layout).tsx', 43 | }, 44 | { 45 | pagePath: '/mocked/path/(.routes)/loading.jsx', 46 | routePath: '/mocked/path/routes/(loading).jsx', 47 | }, 48 | { 49 | pagePath: '/mocked/path/(.routes)/page.js', 50 | routePath: '/mocked/path/routes/(page).js', 51 | }, 52 | { 53 | pagePath: '/mocked/path/(.routes)/route.ts', 54 | routePath: '/mocked/path/routes/(route).ts', 55 | }, 56 | // Nested routes 57 | { 58 | routePath: '/mocked/path/routes/subDir/test3.(error).tsx', 59 | pagePath: convertRoutePathToPagePath( 60 | '/mocked/path/routes/subDir/test3.(error).tsx' 61 | ), 62 | }, 63 | { 64 | routePath: '/mocked/path/routes/test1.(page).tsx', 65 | pagePath: convertRoutePathToPagePath( 66 | '/mocked/path/routes/test1.(page).tsx' 67 | ), 68 | }, 69 | { 70 | routePath: '/mocked/path/routes/test2.(error).tsx', 71 | pagePath: convertRoutePathToPagePath( 72 | '/mocked/path/routes/test2.(error).tsx' 73 | ), 74 | }, 75 | { 76 | routePath: '/mocked/path/routes/test2.(layout).tsx', 77 | pagePath: convertRoutePathToPagePath( 78 | '/mocked/path/routes/test2.(layout).tsx' 79 | ), 80 | }, 81 | { 82 | routePath: 83 | '/mocked/path/routes/test2.foo.[...any].[id].bar.(loading).tsx', 84 | pagePath: convertRoutePathToPagePath( 85 | '/mocked/path/routes/test2.foo.[...any].[id].bar.(loading).tsx' 86 | ), 87 | }, 88 | { 89 | routePath: '/mocked/path/routes/test2.(route).tsx', 90 | pagePath: convertRoutePathToPagePath( 91 | '/mocked/path/routes/test2.(route).tsx' 92 | ), 93 | }, 94 | ] 95 | 96 | const result = findRouteFiles('/mocked/path') 97 | expect(result).toEqual(expect.arrayContaining(expected)) 98 | expect(expected).toEqual(expect.arrayContaining(result)) 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /src/utils/convertPagePathToRoutePath.ts: -------------------------------------------------------------------------------- 1 | export function convertPagePathToRoutePath(pagePath: string): string { 2 | const [beforeRoutes, afterRoutes] = pagePath.split('/(.routes)/') 3 | return [ 4 | beforeRoutes, 5 | '/routes/', 6 | afterRoutes 7 | // Replace / with . if not followed by ] or [ 8 | .replace(/\/(?![^[]*])(? void 7 | ): void { 8 | fs.watch(directory, { recursive: true }, (eventType, filename) => { 9 | if (filename && eventType === 'rename' && !filename.includes('(.routes)')) { 10 | const changedFilePath = path.join(directory, filename) 11 | callback(changedFilePath) 12 | } 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "module": "ESNext", 5 | "target": "ESNext", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "skipLibCheck": true, 11 | "experimentalDecorators": true, 12 | "types": [ 13 | "vitest/importMeta", 14 | "vitest/globals" 15 | ] 16 | }, 17 | "include": ["src"] 18 | } 19 | -------------------------------------------------------------------------------- /unbuild.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'unbuild' 2 | 3 | export default defineBuildConfig({ 4 | externals: ['semver', 'lru-cache', 'yallist'], 5 | }) 6 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true 6 | } 7 | }); 8 | --------------------------------------------------------------------------------