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