├── .changeset ├── README.md └── config.json ├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── src └── index.ts ├── test └── index.test.ts ├── tsconfig.json └── yarn.lock /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/master/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "linked": [], 6 | "access": "public", 7 | "baseBranch": "master", 8 | "updateInternalDependencies": "patch", 9 | "ignore": [] 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Begin CI... 9 | uses: actions/checkout@v2 10 | 11 | - name: Use Node 12 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12.x 15 | 16 | - name: Use cached node_modules 17 | uses: actions/cache@v1 18 | with: 19 | path: node_modules 20 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | nodeModules- 23 | 24 | - name: Install dependencies 25 | run: yarn install --frozen-lockfile 26 | env: 27 | CI: true 28 | 29 | - name: Lint 30 | run: yarn lint 31 | env: 32 | CI: true 33 | 34 | - name: Test 35 | run: yarn test --ci --coverage --maxWorkers=2 36 | env: 37 | CI: true 38 | 39 | - name: Build 40 | run: yarn build 41 | env: 42 | CI: true 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | coverage -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # make-route-map 2 | 3 | ## 1.0.1 4 | ### Patch Changes 5 | 6 | - 787e539: Added repository field in package.json 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Matt Pocock 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # make-route-map 2 | 3 | Routing in web apps can be a subtle, but persistent source of bugs. You think you've updated every reference to a route you're changing, and _BAM_. You've caused a bug in some unrelated part of your app. 4 | 5 | Keep your routes in a single, type-safe source of truth with a routeMap. 6 | 7 | We've been using it at [Yozobi](http://yozobi.com/) in production apps for some time now, and it's saved us a lot of headaches. 8 | 9 | ## makeRouteMap 10 | 11 | ### Simple 12 | 13 | ```js 14 | import { makeRouteMap } from 'make-route-map'; 15 | 16 | const routeMap = makeRouteMap({ 17 | users: { 18 | path: '/users', 19 | }, 20 | admin: { 21 | path: '/admin', 22 | }, 23 | }); 24 | 25 | console.log(routeMap.admin()); 26 | // '/admin' 27 | 28 | console.log(routeMap.users()); 29 | // '/users' 30 | ``` 31 | 32 | ### Path Params and Search 33 | 34 | ```js 35 | import { makeRouteMap } from 'make-route-map'; 36 | 37 | const routeMap = makeRouteMap({ 38 | editUser: { 39 | path: '/users/:id/edit', 40 | params: { 41 | id: true, 42 | }, 43 | }, 44 | auth: { 45 | path: '/auth', 46 | search: { 47 | desiredUsername: true, 48 | }, 49 | }, 50 | }); 51 | 52 | console.log(routeMap.editUser({ params: { id: '240' } })); 53 | // '/users/240/edit' 54 | 55 | console.log(routeMap.auth({ search: { desiredUsername: 'mattpocock' } })); 56 | // '/auth?desiredUsername=mattpocock' 57 | ``` 58 | 59 | ## makeNavigate 60 | 61 | `makeNavigate` gives you a type-safe way of navigating around your app. 62 | 63 | ```js 64 | import { makeRouteMap, makeNavigate } from 'make-route-map'; 65 | 66 | const routeMap = makeRouteMap({ 67 | editUser: { 68 | path: '/users/:id/edit', 69 | params: { 70 | id: true, 71 | }, 72 | }, 73 | auth: { 74 | path: '/auth', 75 | search: { 76 | desiredUsername: true, 77 | }, 78 | }, 79 | }); 80 | 81 | // This could be replaced with any history.push implementation 82 | const goToRoute = route => { 83 | window.location.href = route; 84 | }; 85 | 86 | const navigate = makeNavigate(routeMap, goToRoute); 87 | 88 | // This would take the user to '/users/240/edit' 89 | navigate.editUser({ 90 | params: { 91 | id: '240', 92 | }, 93 | }); 94 | ``` 95 | 96 | ### useNavigate in React 97 | 98 | In React, this can be combined with a hook to make a simple `useNavigate` hook. This example uses `react-router-dom`. 99 | 100 | ```js 101 | import { makeRouteMap, makeNavigate } from 'make-route-map'; 102 | import { useHistory } from 'react-router-dom'; 103 | 104 | const routeMap = makeRouteMap({ 105 | root: { 106 | path: '/', 107 | }, 108 | }); 109 | 110 | const useNavigate = () => { 111 | const history = useHistory(); 112 | const navigate = makeNavigate(routeMap, history.push); 113 | return navigate; 114 | }; 115 | 116 | const Button = () => { 117 | const navigate = useNavigate(); 118 | return ; 119 | }; 120 | ``` 121 | 122 | ## Options 123 | 124 | `make-route-map` is at an early stage, so I'm keen to hear what you need to make this work for you. 125 | 126 | ### paramMatcher 127 | 128 | Helps for when your path params don't match the default `:id` pattern. 129 | 130 | ```js 131 | import { makeRouteMap } from 'make-route-map'; 132 | 133 | const routeMap = makeRouteMap( 134 | { 135 | editUser: { 136 | path: '/users/$id/edit', 137 | params: { 138 | id: true, 139 | }, 140 | }, 141 | }, 142 | { 143 | paramMatcher: param => new RegExp(`\\$${param}`), 144 | } 145 | ); 146 | 147 | console.log( 148 | routeMap.editUser({ 149 | params: { 150 | id: '240', 151 | }, 152 | }) 153 | ); 154 | // '/users/240/edit' 155 | ``` 156 | 157 | ```js 158 | import { makeRouteMap } from 'make-route-map'; 159 | 160 | const routeMap = makeRouteMap({ 161 | editUser: { 162 | path: '/users/:id/edit', 163 | params: { 164 | id: true, 165 | }, 166 | }, 167 | }); 168 | 169 | import { makeNavigate } from 'make-route-map'; 170 | import { useHistory } from 'react-router-dom'; 171 | 172 | const useNavigate = () => { 173 | const history = useHistory(); 174 | const navigate = makeNavigate(routeMap, history.push); 175 | return navigate; 176 | }; 177 | 178 | const Button = () => { 179 | const navigate = useNavigate(); 180 | return ( 181 | 184 | ); 185 | }; 186 | ``` 187 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.1", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "engines": { 11 | "node": ">=10" 12 | }, 13 | "scripts": { 14 | "start": "tsdx watch", 15 | "build": "tsdx build", 16 | "test": "tsdx test", 17 | "lint": "tsdx lint", 18 | "prepare": "tsdx build" 19 | }, 20 | "peerDependencies": {}, 21 | "husky": { 22 | "hooks": { 23 | "pre-commit": "tsdx lint" 24 | } 25 | }, 26 | "jest": { 27 | "collectCoverage": true, 28 | "coverageThreshold": { 29 | "global": { 30 | "branches": 100, 31 | "functions": 100, 32 | "lines": 100, 33 | "statements": 100 34 | } 35 | } 36 | }, 37 | "prettier": { 38 | "printWidth": 80, 39 | "semi": true, 40 | "singleQuote": true, 41 | "trailingComma": "es5" 42 | }, 43 | "name": "make-route-map", 44 | "author": "Matt Pocock", 45 | "module": "dist/make-route-map.esm.js", 46 | "repository": "https://github.com/mattpocock/make-route-map", 47 | "devDependencies": { 48 | "@changesets/cli": "^2.9.2", 49 | "husky": "^4.2.5", 50 | "tsdx": "^0.13.2", 51 | "tslib": "^2.0.0", 52 | "typescript": "^3.9.7" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type RoutesType = { 2 | [name: string]: { 3 | path: string; 4 | // Params contained in this path's URL 5 | params?: { 6 | [paramName: string]: true; 7 | }; 8 | search?: { 9 | // Is this field required or not? 10 | [paramName: string]: boolean; 11 | }; 12 | }; 13 | }; 14 | 15 | export type RoutesReturn = { 16 | [K in keyof R]: (params?: { 17 | params?: { [PK in keyof R[K]['params']]: string | number }; 18 | search?: { [PK in keyof R[K]['search']]?: string | number }; 19 | }) => string; 20 | }; 21 | 22 | export type UseNavigateReturn = { 23 | [K in keyof RoutesReturn]: ( 24 | params?: Parameters[K]>[0] 25 | ) => void; 26 | }; 27 | 28 | export interface MakeRouteMapOptions { 29 | /** 30 | * By default, we match path parameters using the `:id` pattern. 31 | * You can change this by passing an alternative regex from 32 | * the result of this function. 33 | */ 34 | paramMatcher?: (paramName: string) => RegExp; 35 | } 36 | 37 | /** 38 | * Use this function to create a single source of truth 39 | * for all routes in your app 40 | */ 41 | export const makeRouteMap = ( 42 | routes: R, 43 | options?: MakeRouteMapOptions 44 | ): RoutesReturn => { 45 | let obj: Partial> = {}; 46 | Object.entries(routes).forEach(([_key, { path }]) => { 47 | const key: keyof R = _key; 48 | 49 | const func: RoutesReturn[typeof key] = (routeInfo?: { 50 | params?: { 51 | [paramName: string]: string | number; 52 | }; 53 | search?: { 54 | [paramName: string]: string | number | undefined; 55 | }; 56 | }) => { 57 | let newPath = String(path); 58 | // If params, add the new path to the object 59 | if (routeInfo?.params) { 60 | Object.entries(routeInfo.params).forEach(([paramName, value]) => { 61 | newPath = newPath.replace( 62 | options?.paramMatcher?.(paramName) || new RegExp(':' + paramName), 63 | String(value) 64 | ); 65 | }); 66 | } 67 | if (!routeInfo?.search) { 68 | return newPath; 69 | } else { 70 | return `${newPath}?${new URLSearchParams( 71 | routeInfo.search as any 72 | ).toString()}`; 73 | } 74 | }; 75 | 76 | obj[key] = func; 77 | }); 78 | return obj as RoutesReturn; 79 | }; 80 | 81 | /** 82 | * Creates a navigate function which you can use to 83 | * navigate type-safely between all routes in your app 84 | */ 85 | export const makeNavigate = ( 86 | routeMap: RoutesReturn, 87 | goToRoute: (route: string) => void 88 | ): UseNavigateReturn => { 89 | const toReturn: Partial> = {}; 90 | Object.keys(routeMap).forEach(_routeName => { 91 | const routeName: keyof UseNavigateReturn = _routeName; 92 | toReturn[routeName] = (params: any) => { 93 | goToRoute(routeMap[routeName](params)); 94 | }; 95 | }); 96 | return toReturn as UseNavigateReturn; 97 | }; 98 | 99 | export default makeRouteMap; 100 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { makeRouteMap, makeNavigate } from '../src'; 2 | 3 | describe('makeRouteMap', () => { 4 | it('Should create simple routes as expected', () => { 5 | const routeMap = makeRouteMap({ 6 | index: { 7 | path: '/', 8 | }, 9 | admin: { 10 | path: '/admin', 11 | }, 12 | }); 13 | 14 | expect(routeMap.index()).toEqual('/'); 15 | expect(routeMap.admin()).toEqual('/admin'); 16 | }); 17 | it('Should handle path params', () => { 18 | const routeMap = makeRouteMap({ 19 | editUser: { 20 | path: '/users/:id/edit', 21 | params: { 22 | id: true, 23 | }, 24 | }, 25 | }); 26 | expect( 27 | routeMap.editUser({ 28 | params: { 29 | id: '240', 30 | }, 31 | }) 32 | ).toEqual('/users/240/edit'); 33 | }); 34 | it('Should handle path params with a custom matcher', () => { 35 | const routeMap = makeRouteMap( 36 | { 37 | editUser: { 38 | path: '/users/$id/edit', 39 | params: { 40 | id: true, 41 | }, 42 | }, 43 | }, 44 | { 45 | paramMatcher: param => new RegExp(`\\$${param}`), 46 | } 47 | ); 48 | expect( 49 | routeMap.editUser({ 50 | params: { 51 | id: '240', 52 | }, 53 | }) 54 | ).toEqual('/users/240/edit'); 55 | }); 56 | it('Should handle search parameters', () => { 57 | const routeMap = makeRouteMap({ 58 | auth: { 59 | path: '/', 60 | search: { 61 | redirectPath: true, 62 | }, 63 | }, 64 | }); 65 | 66 | expect( 67 | routeMap.auth({ 68 | search: { 69 | redirectPath: '/somewhere-else', 70 | }, 71 | }) 72 | ).toEqual(`/?redirectPath=%2Fsomewhere-else`); 73 | }); 74 | }); 75 | 76 | describe('makeNavigate', () => { 77 | it('Should call the navigate function with the routeMap you provide', () => { 78 | const pushToHistory = jest.fn(); 79 | const routeMap = makeRouteMap({ 80 | editUser: { 81 | path: '/users/:id/edit', 82 | params: { 83 | id: true, 84 | }, 85 | }, 86 | }); 87 | 88 | const navigate = makeNavigate(routeMap, pushToHistory); 89 | 90 | navigate.editUser({ 91 | params: { 92 | id: '240', 93 | }, 94 | }); 95 | 96 | expect(pushToHistory).toHaveBeenCalledWith(`/users/240/edit`); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "importHelpers": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "rootDir": "./src", 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "moduleResolution": "node", 16 | "baseUrl": "./", 17 | "paths": { 18 | "*": ["src/*", "node_modules/*"] 19 | }, 20 | "jsx": "react", 21 | "esModuleInterop": true 22 | } 23 | } 24 | --------------------------------------------------------------------------------