├── .circleci └── config.yml ├── .editorconfig ├── .eslintrc.json ├── .github ├── CONTRIBUTING.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .node-version ├── .prettierrc.yml ├── .vscode └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── common-types.ts ├── index.spec.ts ├── index.ts ├── page-mover.spec.ts ├── page-mover.ts ├── query-merger.spec.ts ├── query-merger.ts ├── route-creator.spec.ts └── route-creator.ts └── tsconfig.json /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # JavaScript CircleCI 2.0 configuration file. 2 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details. 3 | version: 2.1 4 | jobs: 5 | node_v12: 6 | working_directory: "~/repo" 7 | docker: 8 | - image: "node:12.11" 9 | steps: 10 | - "checkout" 11 | - restore_cache: 12 | key: "node-v12.11-node-modules-{{ checksum \"package.json\" }}" 13 | - run: 14 | name: "Install packages" 15 | command: "npm ci" 16 | - save_cache: 17 | key: "node-v12.11-node-modules-{{ checksum \"package.json\" }}" 18 | paths: 19 | - "node_modules" 20 | - run: 21 | name: "Execute linters" 22 | command: "npm run lint" 23 | - run: 24 | name: "Execute tests" 25 | command: "npm test && npm run coverage" 26 | - run: 27 | name: "Build" 28 | command: "npm run build" 29 | node_v10: 30 | working_directory: "~/repo" 31 | docker: 32 | - image: "node:10.15" 33 | steps: 34 | - "checkout" 35 | - restore_cache: 36 | key: "node-v10.15-node-modules-{{ checksum \"package.json\" }}" 37 | - run: 38 | name: "Install packages" 39 | command: "npm ci" 40 | - save_cache: 41 | key: "node-v10.15-node-modules-{{ checksum \"package.json\" }}" 42 | paths: 43 | - "node_modules" 44 | - run: 45 | name: "Execute linters" 46 | command: "npm run lint" 47 | - run: 48 | name: "Execute tests" 49 | command: "npm test" 50 | - run: 51 | name: "Build" 52 | command: "npm run build" 53 | workflows: 54 | version: 2 55 | build: 56 | jobs: 57 | - "node_v12" 58 | - "node_v10" 59 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { "browser": true, "node": true, "es6": true }, 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:prettier/recommended", 6 | "plugin:@typescript-eslint/recommended" 7 | ], 8 | "plugins": [ 9 | "@typescript-eslint" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "sourceType": "module" 14 | }, 15 | "rules": { 16 | "@typescript-eslint/no-explicit-any": "off", 17 | "@typescript-eslint/no-unused-vars": [ 18 | "error", 19 | { "argsIgnorePattern": "^_" } 20 | ], 21 | "@typescript-eslint/explicit-function-return-type": "off", 22 | "@typescript-eslint/no-non-null-assertion": "off", 23 | "@typescript-eslint/consistent-type-definitions": ["error", "type"], 24 | "arrow-body-style": ["error", "as-needed"], 25 | "sort-imports": ["error", { 26 | "ignoreCase": true, 27 | "ignoreDeclarationSort": true 28 | }], 29 | "no-undef": "off" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | ## Language 3 | Git branch names and commit messages, and GitHub pull request should be written in English in order to be readable for 4 | developers around the world. 5 | 6 | 7 | ## Git branch flow 8 | We adhere GitHub Flow to develop this project. Anything in the `master` branch is deployable. To work on something new, create 9 | a descriptively named branch off of master, also add a prefix `feature/` to its name. 10 | A branch name should be started with verb and the most terse and lucid possible. 11 | 12 | ```bash 13 | # Example 14 | feature/implement-xxx 15 | feature/support-xxx-for-xxx 16 | feature/fix-xxx-bugs 17 | ``` 18 | 19 | For more details, see [GitHub Flow – Scott Chacon](http://scottchacon.com/2011/08/31/github-flow.html). 20 | 21 | 22 | ## Git commit messages convention 23 | Follow the following format for Git commit messages. 24 | 25 | ```bash 26 | # Format 27 | [] 28 | 29 | - 30 | - 31 | - 32 | ``` 33 | 34 | ### `` 35 | One commit should have only one purpose, so you should add the following commit type to beginning of line 1 of the commit 36 | message. 37 | 38 | | TYPE | USE CASE | COMMENTS | 39 | |:---------|:----------------------------------------------------------|:------------------------------------------------------------------------------| 40 | | `Add` | Implement functions/Add files/Support new platform | | 41 | | `Change` | Change current spec | Use this type when breaking changes are happened, otherwise DO NOT use. | 42 | | `Fix` | Fix bugs | Use this type when fix bugs, otherwise DO NOT use. | 43 | | `Modify` | Modify wording | Use this type when breaking changes are not happened and fix other than bugs. | 44 | | `Clean` | Refactor some codes/Rename classes, methods, or variables | | 45 | | `Remove` | Remove unneeded files or libraries | | 46 | | `Update` | Update dependencies or this project version | | 47 | 48 | ```bash 49 | # Example 50 | [Add] Implement sign up system 51 | [Clean] Rename XXXClass to YYYClass 52 | 53 | # BAD 54 | [Add]Implement sign up system 55 | Implement sign up system 56 | [ADD] Implement sign up system 57 | [add] Implement sign up system 58 | Add Implement sign up system 59 | ``` 60 | 61 | ### `` 62 | `` is a sumamry of changes, do not exceed 50 characters including a commit type. Do not include period `.` because 63 | a summary should be expressed one sentence. Also start with upper case. 64 | 65 | ```bash 66 | # Example 67 | [Add] Implement sign up system 68 | [Clean] Rename XXXClass to YYYClass 69 | 70 | # BAD 71 | [Add] implement sign up system 72 | [Add] Implement sign up system. Because ... 73 | ``` 74 | 75 | ### `` (Optional) 76 | `` is a description what was changed in the commit. Start with upper case and write one description line by line, 77 | also do not include period `.` . 78 | 79 | ```bash 80 | # Example 81 | [Add] Implement sign up system 82 | 83 | - Add sign up pages 84 | - Add sign up form styles 85 | 86 | # BAD 87 | [Add] Implement sign up system 88 | - Add sign up pages 89 | - Add sign up form styles 90 | 91 | # BAD 92 | [Add] Implement sign up system 93 | 94 | - Add sign up pages. 95 | - Add sign up form styles. 96 | 97 | # BAD 98 | [Add] Implement sign up system 99 | 100 | - add sign up pages 101 | - add sign up form styles 102 | ``` 103 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # New Features 2 | - Add ... 3 | - Implement to ... 4 | - Enable ... 5 | 6 | 7 | # Changes and Fixes 8 | - Change ... 9 | - Fix ... 10 | - Modify ... 11 | 12 | 13 | # Refactors 14 | - Clean ... 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------------------------------------------------------ 2 | # Temp Files 3 | # ------------------------------------------------------------------------------------------------------------------------------ 4 | # Generated temporary files by OS or editors. 5 | *~ 6 | *# 7 | *.bak 8 | *.tmproj 9 | .buildpath 10 | .project 11 | .settings 12 | .idea 13 | .tmp 14 | .netbeans 15 | nbproject 16 | 17 | 18 | # ------------------------------------------------------------------------------------------------------------------------------ 19 | # Dynamicaly Generated Files 20 | # ------------------------------------------------------------------------------------------------------------------------------ 21 | # Hidden files created by OS. 22 | Thumbs.db 23 | desktop.ini 24 | .DS_Store 25 | .DS_STORE 26 | 27 | # Generated files created by Node.js. 28 | node_modules 29 | npm-debug.log 30 | 31 | # Generated files created by bundler. 32 | lib 33 | 34 | # Generated files created by tests. 35 | coverage 36 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 10.15.3 2 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | # Specify the line length that the printer will wrap on. 2 | printWidth: 128 3 | 4 | # Specify the number of spaces per indentation-level. 5 | tabWidth: 2 6 | 7 | # Indent lines with tabs instead of spaces. 8 | useTabs: false 9 | 10 | # Print semicolons at the ends of statements. 11 | semi: true 12 | 13 | # Use single quotes instead of double quotes. 14 | singleQuote: false 15 | 16 | # Change when properties in objects are quoted. 17 | quoteProps: "as-needed" 18 | 19 | # Use single quotes instead of double quotes in JSX. 20 | # jsxSingleQuote: false 21 | 22 | # Print trailing commas wherever possible when multi-line (A single-line array, for example, never gets trailing commas). 23 | trailingComma: "all" 24 | 25 | # Print spaces between brackets in object literals. 26 | bracketSpacing: true 27 | 28 | # Put the `>` of a multi-line JSX element at the end of the last line instead of being alone on the next line 29 | # (does not apply to self closing elements). 30 | # jsxBracketSameLine: false 31 | 32 | # Include parentheses around a sole arrow function parameter. 33 | arrowParens: "always" 34 | 35 | # Format only a segment of a file. 36 | # These two options can be used to format code starting and ending at a given character offset (inclusive and exclusive, 37 | # respectively). 38 | # rangeStart: 0 39 | # rangeEnd: "Infinity" 40 | # Specify which parser to use. 41 | # Both the babylon and flow parsers support the same set of JavaScript features (including Flow). 42 | # Prettier automatically infers the parser from the input file path, so you shouldn't have to change this setting. 43 | # parser: "typescript" 44 | # Specify the input filepath. This will be used to do parser inference. 45 | filepath: "none" 46 | 47 | # Prettier can restrict itself to only format files that contain a special comment, called a pragma, at the top of the file. 48 | # This is very useful when gradually transitioning large, unformatted codebases to prettier. 49 | requirePragma: false 50 | 51 | # Prettier can insert a special @format marker at the top of files specifying that the file has been formatted with prettier. 52 | # This works well when used in tandem with the --require-pragma option. If there is already a docblock at the top of the file 53 | # then this option will add a newline to it with the @format marker. 54 | insertPragma: false 55 | 56 | # By default, Prettier will wrap markdown text as-is since some services use a linebreak-sensitive renderer, 57 | # e.g. GitHub comment and BitBucket. In some cases you may want to rely on editor/viewer soft wrapping instead, so this option 58 | # allows you to opt out with "never". 59 | proseWrap: "preserve" 60 | 61 | # Specify the global whitespace sensitivity for HTML files, see whitespace-sensitive formatting for more info. 62 | # https://prettier.io/blog/2018/11/07/1.15.0.html#whitespace-sensitive-formatting 63 | htmlWhitespaceSensitivity: "strict" 64 | 65 | # For historical reasons, there exist two commonly used flavors of line endings in text files. 66 | endOfLine: "lf" 67 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | // --------------------------------------------------------------------------------------------------------------------------- 4 | // Editor 5 | // --------------------------------------------------------------------------------------------------------------------------- 6 | // Inserts snippets when their prefix matches. 7 | // Works best when 'quickSuggestions' aren't enabled. 8 | "editor.tabCompletion": "on", 9 | // Columns at which to show vertical rulers. 10 | "editor.rulers": [ 11 | 128 12 | ], 13 | 14 | // --------------------------------------------------------------------------------------------------------------------------- 15 | // Files 16 | // --------------------------------------------------------------------------------------------------------------------------- 17 | // Configures glob patterns for excluding files and folders. 18 | "files.exclude": { 19 | "**/node_modules": true 20 | }, 21 | 22 | // Configures glob patterns of file paths to exclude from file watching. 23 | // Changing this setting requires a restart. When you experience Code consuming 24 | // lots of cpu time on startup, you can exclude large folders to reduce the initial load. 25 | "files.watcherExclude": { 26 | "**/.git/objects/**": true, 27 | "**/node_modules/**": true, 28 | "lib/**": true 29 | }, 30 | 31 | // Specifies the folder path containing the tsserver and lib*.d.ts files to use. 32 | "typescript.tsdk": "node_modules/typescript/lib", 33 | 34 | 35 | // --------------------------------------------------------------------------------------------------------------------------- 36 | // Language Setings 37 | // --------------------------------------------------------------------------------------------------------------------------- 38 | "[javascript]": { 39 | "editor.formatOnSave": true 40 | }, 41 | "[typescript]": { 42 | "editor.formatOnSave": true 43 | }, 44 | "[typescriptreact]": { 45 | "editor.formatOnSave": true 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ## 1.0.2 (2019-12-07) 3 | - Fix parameters and query parameteres checking when giving falsy values such as zero #8 - [@jagaapple](https://github.com/jagaapple) 4 | - Update development environment #7 - [@jagaapple](https://github.com/jagaapple) 5 | 6 | ## 1.0.1 (2019-10-09) 7 | - Fix `createRoute` function parse #5 - [@jagaapple](https://github.com/jagaapple) 8 | 9 | ## 1.0.0 (2019-10-09) 10 | - Initial public release - [@jagaapple](https://github.com/jagaapple) 11 | 12 | ## 0.0.1 (2019-10-02) 13 | - Initial private release - [@jagaapple](https://github.com/jagaapple) 14 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | ## Our Pledge 3 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making 4 | participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, 5 | disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or 6 | sexual identity and orientation. 7 | 8 | 9 | ## Our Standards 10 | Examples of behavior that contributes to creating a positive environment include: 11 | 12 | - Using welcoming and inclusive language 13 | - Being respectful of differing viewpoints and experiences 14 | - Gracefully accepting constructive criticism 15 | - Focusing on what is best for the community 16 | - Showing empathy towards other community members 17 | 18 | Examples of unacceptable behavior by participants include: 19 | 20 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 21 | - Trolling, insulting/derogatory comments, and personal or political attacks 22 | - Public or private harassment 23 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 24 | - Other conduct which could reasonably be considered inappropriate in a professional setting 25 | 26 | 27 | ## Our Responsibilities 28 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and 29 | fair corrective action in response to any instances of unacceptable behavior. 30 | 31 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, 32 | and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for 33 | other behaviors that they deem inappropriate, threatening, offensive, or harmful. 34 | 35 | 36 | ## Scope 37 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or 38 | its community. Examples of representing a project or community include using an official project e-mail address, posting via an 39 | official social media account, or acting as an appointed representative at an online or offline event. Representation of a 40 | project may be further defined and clarified by project maintainers. 41 | 42 | 43 | ## Enforcement 44 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at 45 | `jagaapple+github@uniboar.com`. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and 46 | appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an 47 | incident. Further details of specific enforcement policies may be posted separately. 48 | 49 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions 50 | as determined by other members of the project's leadership. 51 | 52 | 53 | ## Attribution 54 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 55 | available at [http://contributor-covenant.org/version/1/4][version] 56 | 57 | [homepage]: http://contributor-covenant.org 58 | [version]: http://contributor-covenant.org/version/1/4/ 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2019 Jaga Apple 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files 6 | (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 13 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 14 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 15 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

next-typed-routes

2 | 3 |

🔜 Type safe route utilities for Next.js. 🔙

4 | 5 | ```ts 6 | // /routes.ts 7 | import { createRoute } from "next-typed-routes"; 8 | 9 | export const routes = { 10 | // For `/pages/index.tsx` 11 | top: createRoute("/"), 12 | 13 | // For `/pages/users/index.tsx` 14 | users: createRoute("/users"), 15 | // For `/pages/users/[userId].tsx` 16 | usersDetail: (userId: number) => createRoute("/users/[userId]", { userId }), 17 | }; 18 | ``` 19 | ```tsx 20 | // /pages/index.tsx 21 | import Link from "next/link"; 22 | import { routes } from "../routes.ts"; 23 | 24 | const targetUserId = 5; 25 | 26 | const Page () => ( 27 | 28 | Go to the user page (id: {targetUserId}) 29 | 30 | ); 31 | 32 | ... 33 | Page.getInitialProps = async ({ res }) => { 34 | // Redirect to `/users/123&limit=30` . 35 | // This works fine on client-side and server-side. 36 | movePage(routes.usersDetail(123), { res, queryParameters: { limit: 30 } }) 37 | }; 38 | ``` 39 | 40 |
41 | npm 42 | CircleCI 43 | 44 | license 45 | @jagaapple_tech 46 |
47 | 48 | ## Table of Contents 49 | 50 | 51 | 52 | - [Table of Contents](#table-of-contents) 53 | - [Features](#features) 54 | - [Motivation](#motivation) 55 | - [Quick Start](#quick-start) 56 | - [Requirements](#requirements) 57 | - [Installation](#installation) 58 | - [Usage](#usage) 59 | - [Routes](#routes) 60 | - [Arguments of `createRoute`](#arguments-of-createroute) 61 | - [Key names](#key-names) 62 | - [Page Mover](#page-mover) 63 | - [API](#api) 64 | - [`createRoute(path, parameters, queryParameters): { href: string, as: string }`](#createroutepath-parameters-queryparameters--href-string-as-string-) 65 | - [`createPageMover(baseURI, Router): PageMover`](#createpagemoverbaseuri-router-pagemover) 66 | - [`movePage(path, options): void`](#movepagepath-options-void) 67 | - [Contributing to next-typed-routes](#contributing-to-next-typed-routes) 68 | - [License](#license) 69 | 70 | 71 | 72 | 73 | ## Features 74 | 75 | | FEATURES | WHAT YOU CAN DO | 76 | |------------------------------------|----------------------------------------------------------| 77 | | ❤️ **Designed for Next.js** | You can use Next.js routing system without custom server | 78 | | 🌐 **Build for Universal** | Ready for Universal JavaScript | 79 | | 📄 **Write once, Manage one file** | All you need is write routes to one file | 80 | | 🎩 **Type Safe** | You can use with TypeScript | 81 | 82 | ### Motivation 83 | Next.js 9 is the first version to support dynamic routing without any middleware. It is so useful and easy to use, and it supports 84 | dynamic parameters such as `/users/123` . 85 | 86 | However the dynamic parameters do not support to be referred type safely. So when we rename a dynamic parameter, we should search 87 | codes which use the parameter in `` component and others and replace them with a new parameter name. Also it is thought 88 | that developers may forget to specify required dynamic parameters when creating links. 89 | 90 | next-typed-routes provides some APIs to resolve this issues. You can manage all routes with dynamic parameters in one file and 91 | you can create links type safely. 92 | 93 | 94 | ## Quick Start 95 | ### Requirements 96 | - npm or Yarn 97 | - Node.js 10.0.0 or higher 98 | - **Next.js 9.0.0 or higher** 99 | 100 | 101 | ### Installation 102 | ```bash 103 | $ npm install next-typed-routes 104 | ``` 105 | 106 | If you are using Yarn, use the following command. 107 | 108 | ```bash 109 | $ yarn add --dev next-typed-routes 110 | ``` 111 | 112 | 113 | ## Usage 114 | ### Routes 115 | ```ts 116 | import { createRoute } from "next-typed-routes"; 117 | 118 | export const routes = { 119 | // For `/pages/index.tsx` 120 | top: createRoute("/"), 121 | 122 | // For `/pages/users/index.tsx` 123 | users: createRoute("/users"), 124 | // For `/pages/users/[userId].tsx` 125 | usersDetail: (userId: number) => createRoute("/users/[userId]", { userId }), 126 | }; 127 | ``` 128 | 129 | Firstly, you need to define routes using next-typed-routes. 130 | 131 | `createRoute` function exported from next-typed-routes returns an object for `` component props, which has `href` and `as` 132 | properties. 133 | So when you manage values created by `createRoute`, you can get `` component props via the keys like the following. 134 | 135 | ```tsx 136 | 137 | Go to top. 138 | 139 | 140 | // Or 141 | 142 | Go to top. 143 | 144 | ``` 145 | 146 | #### Arguments of `createRoute` 147 | Currently, `createRoute` accepts three parameters. 148 | 149 | ```tsx 150 | 151 | 152 | 153 | ``` 154 | 155 | - `path: string` 156 | - Required. 157 | - A page of Next.js. This is a path of files in `/pages` in general. 158 | - `parameters: { [key: string]: number | string | undefined | null }` 159 | - Optional, default is `{}` . 160 | - You can give dynamic parameters as object. 161 | - `queryParameters: { [key: string]: number | string | boolean | null | undefined }` 162 | - Optional, default is `{}` . 163 | - You can give query string as object. 164 | 165 | 166 | #### Key names 167 | ```ts 168 | import { createRoute } from "next-typed-routes"; 169 | 170 | export const routes = { 171 | // For `/pages/index.tsx` 172 | "/": createRoute("/"), 173 | 174 | // For `/pages/users/index.tsx` 175 | "/users": createRoute("/users"), 176 | // For `/pages/users/[userId].tsx` 177 | "/users/[userId]": (userId: number) => createRoute("/users/[userId]", { userId }), 178 | }; 179 | ``` 180 | 181 | You can freely name keys of `routes` , but I recommend you to adopt the same with page file path for key names in order to reduce 182 | the thinking time to name them. 183 | 184 | ### Page Mover 185 | ```ts 186 | import Router from "next/router"; 187 | import { createPageMover } from "next-typed-routes"; 188 | 189 | const movePage = createPageMover("https://you-project.example.com", Router); 190 | 191 | movePage("/about"); 192 | movePage(createRoute("/")); 193 | ``` 194 | 195 | next-typed-routes provides a function to reidrect to a specific page using `createRoute` . This works fine on cliet-side (web browsers) 196 | and server-side. 197 | 198 | If you want to support server-side, you must give `res` object from `getInitialProps` arguments. 199 | 200 | ```ts 201 | Component.getInitialProps = async ({ res }) => { 202 | movePage("/about", { res }); 203 | }; 204 | ``` 205 | 206 | 207 | ## API 208 | ### `createRoute(path, parameters, queryParameters): { href: string, as: string }` 209 | ```ts 210 | createRoute("/") 211 | createRoute("/users", undefined, { limit: 10 }) 212 | createRoute("/users/[userId]/items/[itemId]", { userId: 1, itemId: 2 }) 213 | ``` 214 | 215 | Returns an object for `` component props. 216 | 217 | - `path: string` 218 | - Required 219 | - A page of Next.js. This is a path of files in `/pages` in general 220 | - `parameters: { [key: string]: number | string | undefined | null }` 221 | - Optional, default is `{}` 222 | - You can give dynamic parameters as object 223 | - `queryParameters: { [key: string]: number | string | boolean | undefined | null }` 224 | - Optional, default is `{}` 225 | - You can give query string as object 226 | 227 | ### `createPageMover(baseURI, Router): PageMover` 228 | ```ts 229 | createPageMover("https://you-project.example.com", Router); 230 | createPageMover(new URL("https://you-project.example.com"), Router); 231 | ``` 232 | 233 | Returns a function to redirect URL. 234 | 235 | - `baseURI: string | URL` 236 | - Required 237 | - Your project base URI 238 | - `Router: NextRouter` 239 | - Required 240 | - Give Router object from `next/router` in your project 241 | 242 | ### `movePage(path, options): void` 243 | ```ts 244 | movePage("/about"); 245 | movePage(createRoute("/about")); 246 | 247 | movePage("/about", { res, statusCode: 301 }); 248 | movePage("/about", { res, queryParameters: { limit: 30 } }); 249 | ``` 250 | 251 | This function is created from `createPageMover` . 252 | 253 | - `path: string | { href: string, as: string }` 254 | - Required 255 | - A destination path 256 | - It is possible to give a return value from `createRoute` 257 | - `options: Options` 258 | - Optional, default is `{}` 259 | - `res: ServerResponse` 260 | - Required if you want to support server-side redirect 261 | - A sever response object for server-side 262 | - It is possible to get a context object which contains this from Next.js `getInitialProps` arguments 263 | - `queryParameters: { [key: string]: number | string | boolean | undefined | null }` 264 | - An object for query string 265 | - If `path` already has query string, it will be merged with `queryParameters` 266 | - `statusCode: 301 | 302` 267 | - A status code for server-side 268 | 269 | 270 | ## Contributing to next-typed-routes 271 | Bug reports and pull requests are welcome on GitHub at 272 | [https://github.com/jagaapple/next-typed-routes](https://github.com/jagaapple/next-typed-routes). 273 | This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the 274 | [Contributor Covenant](http://contributor-covenant.org) code of conduct. 275 | 276 | Please read [Contributing Guidelines](./.github/CONTRIBUTING.md) before development and contributing. 277 | 278 | 279 | ## License 280 | The library is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 281 | 282 | Copyright 2019 Jaga Apple. All rights reserved. 283 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/88/kfhvhbw51qlgmj4jxjpb34vw0000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | collectCoverage: true, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: null, 25 | 26 | // The directory where Jest should output its coverage files 27 | coverageDirectory: "coverage", 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | coverageReporters: [ 36 | "html", 37 | // "json", 38 | "text", 39 | "lcov", 40 | // "clover" 41 | ], 42 | 43 | // An object that configures minimum threshold enforcement for coverage results 44 | // coverageThreshold: null, 45 | 46 | // A path to a custom dependency extractor 47 | // dependencyExtractor: null, 48 | 49 | // Make calling deprecated APIs throw helpful error messages 50 | // errorOnDeprecated: false, 51 | 52 | // Force coverage collection from ignored files using an array of glob patterns 53 | // forceCoverageMatch: [], 54 | 55 | // A path to a module which exports an async function that is triggered once before all test suites 56 | // globalSetup: null, 57 | 58 | // A path to a module which exports an async function that is triggered once after all test suites 59 | // globalTeardown: null, 60 | 61 | // A set of global variables that need to be available in all test environments 62 | // globals: {}, 63 | 64 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 65 | // maxWorkers: "50%", 66 | 67 | // An array of directory names to be searched recursively up from the requiring module's location 68 | // moduleDirectories: [ 69 | // "node_modules" 70 | // ], 71 | 72 | // An array of file extensions your modules use 73 | // moduleFileExtensions: [ 74 | // "js", 75 | // "json", 76 | // "jsx", 77 | // "ts", 78 | // "tsx", 79 | // "node" 80 | // ], 81 | 82 | // A map from regular expressions to module names that allow to stub out resources with a single module 83 | // moduleNameMapper: {}, 84 | 85 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 86 | // modulePathIgnorePatterns: [], 87 | 88 | // Activates notifications for test results 89 | // notify: false, 90 | 91 | // An enum that specifies notification mode. Requires { notify: true } 92 | // notifyMode: "failure-change", 93 | 94 | // A preset that is used as a base for Jest's configuration 95 | preset: "ts-jest", 96 | 97 | // Run tests from one or more projects 98 | // projects: null, 99 | 100 | // Use this configuration option to add custom reporters to Jest 101 | // reporters: undefined, 102 | 103 | // Automatically reset mock state between every test 104 | // resetMocks: false, 105 | 106 | // Reset the module registry before running each individual test 107 | // resetModules: false, 108 | 109 | // A path to a custom resolver 110 | // resolver: null, 111 | 112 | // Automatically restore mock state between every test 113 | // restoreMocks: false, 114 | 115 | // The root directory that Jest should scan for tests and modules within 116 | // rootDir: null, 117 | 118 | // A list of paths to directories that Jest should use to search for files in 119 | // roots: [ 120 | // "" 121 | // ], 122 | 123 | // Allows you to use a custom runner instead of Jest's default test runner 124 | // runner: "jest-runner", 125 | 126 | // The paths to modules that run some code to configure or set up the testing environment before each test 127 | setupFiles: ["jest-plugin-context/setup"], 128 | 129 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 130 | // setupFilesAfterEnv: [], 131 | 132 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 133 | // snapshotSerializers: [], 134 | 135 | // The test environment that will be used for testing 136 | testEnvironment: "node", 137 | 138 | // Options that will be passed to the testEnvironment 139 | // testEnvironmentOptions: {}, 140 | 141 | // Adds a location field to test results 142 | // testLocationInResults: false, 143 | 144 | // The glob patterns Jest uses to detect test files 145 | testMatch: [ 146 | // "**/__tests__/**/*.[jt]s?(x)", 147 | // "**/?(*.)+(spec|test).[tj]s?(x)", 148 | "**/src/**/?(*.)+(spec|test).[tj]s?(x)", 149 | ], 150 | 151 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 152 | // testPathIgnorePatterns: [ 153 | // "/node_modules/" 154 | // ], 155 | 156 | // The regexp pattern or array of patterns that Jest uses to detect test files 157 | // testRegex: [], 158 | 159 | // This option allows the use of a custom results processor 160 | // testResultsProcessor: null, 161 | 162 | // This option allows use of a custom test runner 163 | // testRunner: "jasmine2", 164 | 165 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 166 | // testURL: "http://localhost", 167 | 168 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 169 | // timers: "real", 170 | 171 | // A map from regular expressions to paths to transformers 172 | // transform: null, 173 | 174 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 175 | // transformIgnorePatterns: [ 176 | // "/node_modules/" 177 | // ], 178 | 179 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 180 | // unmockedModulePathPatterns: undefined, 181 | 182 | // Indicates whether each individual test should be reported during the run 183 | // verbose: null, 184 | 185 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 186 | // watchPathIgnorePatterns: [], 187 | 188 | // Whether to use watchman for file crawling 189 | // watchman: true, 190 | }; 191 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-typed-routes", 3 | "version": "1.0.2", 4 | "description": "Type safe route utilities for Next.js.", 5 | "keywords": [ 6 | "next.js", 7 | "routes", 8 | "router", 9 | "routing", 10 | "redirect", 11 | "typescript" 12 | ], 13 | "homepage": "https://github.com/jagaapple/next-typed-routes", 14 | "bugs": "https://github.com/jagaapple/next-typed-routes/issues", 15 | "license": "MIT", 16 | "author": "Jaga Apple", 17 | "contributors": [], 18 | "files": [ 19 | "lib", 20 | "CHANGELOG.md", 21 | "CODE_OF_CONDUCT.md", 22 | "LICENSE", 23 | "package.json", 24 | "README.md" 25 | ], 26 | "main": "lib/index.js", 27 | "bin": "", 28 | "man": "", 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/jagaapple/next-typed-routes.git" 32 | }, 33 | "scripts": { 34 | "prebuild": "rm -rf ./lib", 35 | "build": "tsc", 36 | "lint": "eslint ./src/**/*.ts", 37 | "fix": "eslint --fix ./src/**/*.ts", 38 | "prepublishOnly": "npm run build", 39 | "test": "jest --coverage", 40 | "coverage": "codecov", 41 | "clean": "rm -rf ./lib ./coverage" 42 | }, 43 | "config": {}, 44 | "dependencies": { 45 | "query-string": "^6.9.0" 46 | }, 47 | "devDependencies": { 48 | "@types/jest": "^24.0.23", 49 | "@types/jest-plugin-context": "^2.9.2", 50 | "@types/node": "^12.7.11", 51 | "@typescript-eslint/eslint-plugin": "^2.10.0", 52 | "@typescript-eslint/parser": "^2.10.0", 53 | "codecov": "^3.6.1", 54 | "eslint": "^6.7.2", 55 | "eslint-config-prettier": "^6.7.0", 56 | "eslint-plugin-prettier": "^3.1.1", 57 | "jest": "^24.9.0", 58 | "jest-plugin-context": "^2.9.0", 59 | "prettier": "^1.19.1", 60 | "ts-jest": "^24.2.0", 61 | "ts-node": "^8.5.4", 62 | "typescript": "^3.7.3" 63 | }, 64 | "peerDependencies": {}, 65 | "engines": { 66 | "node": ">=10.0.0" 67 | }, 68 | "engineStrict": false, 69 | "preferGlobal": true, 70 | "private": false 71 | } 72 | -------------------------------------------------------------------------------- /src/common-types.ts: -------------------------------------------------------------------------------- 1 | export type QueryParameters = Record; 2 | -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRoute } from "./index"; 2 | 3 | describe("createRoute", () => { 4 | it("should export `createRoute` function", () => { 5 | expect(createRoute).not.toBeUndefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./page-mover"; 2 | export * from "./route-creator"; 3 | -------------------------------------------------------------------------------- /src/page-mover.spec.ts: -------------------------------------------------------------------------------- 1 | import { ServerResponse } from "http"; 2 | 3 | import { createPageMover } from "./page-mover"; 4 | 5 | describe("createPageMover", () => { 6 | const dummyBaseURL = new URL("https://example.com"); 7 | let dummyRouter: Parameters[1]; 8 | let routerPushSpy: jest.Mock, Parameters>; 9 | let resWriteHeadSpy: jest.Mock>; 10 | let resEndSpy: jest.Mock; 11 | let dummyRes: ServerResponse; 12 | 13 | beforeEach(() => { 14 | routerPushSpy = jest.fn((_, __?, ___?) => Promise.resolve(true)); 15 | dummyRouter = { push: routerPushSpy }; 16 | 17 | resWriteHeadSpy = jest.fn((_, __?) => undefined); 18 | resEndSpy = jest.fn(() => undefined); 19 | dummyRes = { writeHead: resWriteHeadSpy, end: resEndSpy } as any; 20 | }); 21 | 22 | context("when specifying a path as string,", () => { 23 | const dummyPath = "/foo/bar"; 24 | 25 | context("without a res object,", () => { 26 | it("should call `Router.push` with the path", () => { 27 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 28 | moveToPage(dummyPath); 29 | 30 | expect(routerPushSpy).toBeCalledWith(dummyPath, dummyPath); 31 | expect(routerPushSpy).toBeCalledTimes(1); 32 | }); 33 | 34 | it("should not use a res object", () => { 35 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 36 | moveToPage(dummyPath); 37 | 38 | expect(resWriteHeadSpy).not.toBeCalled(); 39 | expect(resEndSpy).not.toBeCalled(); 40 | }); 41 | 42 | context("specifying `options.queryParameters`,", () => { 43 | context("a path does not have query string,", () => { 44 | it("should call `Router.push` with the parameters as query string", () => { 45 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 46 | moveToPage(dummyPath, { queryParameters: { a: 1, b: "2" } }); 47 | 48 | expect(routerPushSpy).toBeCalledWith(`${dummyPath}?a=1&b=2`, `${dummyPath}?a=1&b=2`); 49 | expect(routerPushSpy).toBeCalledTimes(1); 50 | }); 51 | }); 52 | 53 | context("a path has query string,", () => { 54 | it("should call `Router.push` with merge query string", () => { 55 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 56 | moveToPage(`${dummyPath}?x=1&y=2`, { queryParameters: { a: 1, b: "2" } }); 57 | 58 | expect(routerPushSpy).toBeCalledWith(`${dummyPath}?a=1&b=2&x=1&y=2`, `${dummyPath}?a=1&b=2&x=1&y=2`); 59 | expect(routerPushSpy).toBeCalledTimes(1); 60 | }); 61 | }); 62 | }); 63 | }); 64 | 65 | context("with a res object,", () => { 66 | it("should not use `Router`", () => { 67 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 68 | moveToPage(dummyPath, { res: dummyRes }); 69 | 70 | expect(routerPushSpy).not.toBeCalled(); 71 | }); 72 | 73 | it("should call `res.writeHead` with the string as object", () => { 74 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 75 | moveToPage(dummyPath, { res: dummyRes }); 76 | 77 | expect(resWriteHeadSpy).toBeCalledWith(302, { Location: new URL(dummyPath, dummyBaseURL).toString() }); 78 | expect(resWriteHeadSpy).toBeCalledTimes(1); 79 | }); 80 | 81 | it("should call `res.end`", () => { 82 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 83 | moveToPage(dummyPath, { res: dummyRes }); 84 | 85 | expect(resEndSpy).toBeCalledTimes(1); 86 | }); 87 | 88 | context("specifying `options.statusCode`,", () => { 89 | it("should call `res.writeHead` with the string and the status code", () => { 90 | const statusCode = 301; 91 | 92 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 93 | moveToPage(dummyPath, { statusCode, res: dummyRes }); 94 | 95 | expect(resWriteHeadSpy).toBeCalledWith(statusCode, { Location: new URL(dummyPath, dummyBaseURL).toString() }); 96 | expect(resWriteHeadSpy).toBeCalledTimes(1); 97 | }); 98 | }); 99 | 100 | context("specifying `options.queryParameters`,", () => { 101 | context("a path does not have query string,", () => { 102 | it("should call `res.writeHead` with the parameters as query string", () => { 103 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 104 | moveToPage(dummyPath, { res: dummyRes, queryParameters: { a: 1, b: "2" } }); 105 | 106 | const url = new URL(`${dummyPath}?a=1&b=2`, dummyBaseURL); 107 | expect(resWriteHeadSpy).toBeCalledWith(302, { Location: url.toString() }); 108 | expect(resWriteHeadSpy).toBeCalledTimes(1); 109 | }); 110 | }); 111 | 112 | context("a path has query string,", () => { 113 | it("should call `res.writeHead` with merge query string", () => { 114 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 115 | moveToPage(`${dummyPath}?x=1&y=2`, { res: dummyRes, queryParameters: { a: 1, b: "2" } }); 116 | 117 | const url = new URL(`${dummyPath}?a=1&b=2&x=1&y=2`, dummyBaseURL); 118 | expect(resWriteHeadSpy).toBeCalledWith(302, { Location: url.toString() }); 119 | expect(resWriteHeadSpy).toBeCalledTimes(1); 120 | }); 121 | }); 122 | }); 123 | }); 124 | }); 125 | 126 | context("when specifying a path as returned value from `createRoute`,", () => { 127 | const dummyPath = { href: "/users/[userId]", as: "/users/123" }; 128 | 129 | context("without a res object,", () => { 130 | it("should call `Router.push` with the path's `href` and `as`", () => { 131 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 132 | moveToPage(dummyPath); 133 | 134 | expect(routerPushSpy).toBeCalledWith(dummyPath.href, dummyPath.as); 135 | expect(routerPushSpy).toBeCalledTimes(1); 136 | }); 137 | 138 | it("should not use a res object", () => { 139 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 140 | moveToPage(dummyPath); 141 | 142 | expect(resWriteHeadSpy).not.toBeCalled(); 143 | expect(resEndSpy).not.toBeCalled(); 144 | }); 145 | 146 | context("specifying `options.queryParameters`,", () => { 147 | context("a path does not have query string,", () => { 148 | it("should call `Router.push` with the parameters as query string", () => { 149 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 150 | moveToPage(dummyPath, { queryParameters: { a: 1, b: "2" } }); 151 | 152 | expect(routerPushSpy).toBeCalledWith(`${dummyPath.href}?a=1&b=2`, `${dummyPath.as}?a=1&b=2`); 153 | expect(routerPushSpy).toBeCalledTimes(1); 154 | }); 155 | }); 156 | 157 | context("a path has query string,", () => { 158 | it("should call `Router.push` with merge query string", () => { 159 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 160 | const path = { href: "/users/[userId]?limit=30", as: "/users/123?limit=30" }; 161 | moveToPage(path, { queryParameters: { a: 1, b: "2" } }); 162 | 163 | expect(routerPushSpy).toBeCalledWith("/users/[userId]?a=1&b=2&limit=30", "/users/123?a=1&b=2&limit=30"); 164 | expect(routerPushSpy).toBeCalledTimes(1); 165 | }); 166 | }); 167 | }); 168 | }); 169 | 170 | context("with a res object,", () => { 171 | it("should not use `Router`", () => { 172 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 173 | moveToPage(dummyPath, { res: dummyRes }); 174 | 175 | expect(routerPushSpy).not.toBeCalled(); 176 | }); 177 | 178 | it("should call `res.writeHead` with the path's `as`", () => { 179 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 180 | moveToPage(dummyPath, { res: dummyRes }); 181 | 182 | expect(resWriteHeadSpy).toBeCalledWith(302, { Location: new URL(dummyPath.as, dummyBaseURL).toString() }); 183 | expect(resWriteHeadSpy).toBeCalledTimes(1); 184 | }); 185 | 186 | it("should call `res.end`", () => { 187 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 188 | moveToPage(dummyPath, { res: dummyRes }); 189 | 190 | expect(resEndSpy).toBeCalledTimes(1); 191 | }); 192 | 193 | context("specifying `options.statusCode`,", () => { 194 | it("should call `res.writeHead` with the string and the status code", () => { 195 | const statusCode = 301; 196 | 197 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 198 | moveToPage(dummyPath, { statusCode, res: dummyRes }); 199 | 200 | expect(resWriteHeadSpy).toBeCalledWith(statusCode, { Location: new URL(dummyPath.as, dummyBaseURL).toString() }); 201 | expect(resWriteHeadSpy).toBeCalledTimes(1); 202 | }); 203 | }); 204 | 205 | context("specifying `options.queryParameters`,", () => { 206 | context("a path does not have query string,", () => { 207 | it("should call `res.writeHead` with the parameters as query string", () => { 208 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 209 | moveToPage(dummyPath, { res: dummyRes, queryParameters: { a: 1, b: "2" } }); 210 | 211 | const url = new URL(`${dummyPath.as}?a=1&b=2`, dummyBaseURL); 212 | expect(resWriteHeadSpy).toBeCalledWith(302, { Location: url.toString() }); 213 | expect(resWriteHeadSpy).toBeCalledTimes(1); 214 | }); 215 | }); 216 | 217 | context("a path has query string,", () => { 218 | it("should call `res.writeHead` with merge query string", () => { 219 | const moveToPage = createPageMover(dummyBaseURL, dummyRouter); 220 | const path = { href: "/users/[userId]?limit=30", as: "/users/123?limit=30" }; 221 | moveToPage(path, { res: dummyRes, queryParameters: { a: 1, b: "2" } }); 222 | 223 | const url = new URL("/users/123?a=1&b=2&limit=30", dummyBaseURL); 224 | expect(resWriteHeadSpy).toBeCalledWith(302, { Location: url.toString() }); 225 | expect(resWriteHeadSpy).toBeCalledTimes(1); 226 | }); 227 | }); 228 | }); 229 | }); 230 | }); 231 | }); 232 | -------------------------------------------------------------------------------- /src/page-mover.ts: -------------------------------------------------------------------------------- 1 | import { ServerResponse } from "http"; 2 | 3 | import { QueryParameters } from "./common-types"; 4 | import { mergeQueryString } from "./query-merger"; 5 | import { createRoute } from "./route-creator"; 6 | 7 | type Router = { 8 | push: (url: string, as?: string, options?: {}) => Promise; 9 | }; 10 | 11 | type Options = Partial<{ 12 | /** 13 | * A sever response object for server-side. 14 | * It is possible to get a context object which contains this from Next.js `getInitialProps` arguments. 15 | */ 16 | res: ServerResponse; 17 | /** An object for query string. */ 18 | queryParameters: QueryParameters; 19 | /** A status code for server-side */ 20 | statusCode: 301 | 302; 21 | }>; 22 | 23 | export const createPageMover = ( 24 | /** A base URI of project. */ 25 | baseURI: string | URL, 26 | /** Next.js built-in router object. */ 27 | Router: Router, 28 | ) => ( 29 | /** A destination absolute path or return value from `createRoute` function. */ 30 | path: string | ReturnType, 31 | /** 32 | * Options. 33 | * @default {} 34 | */ 35 | options: Options = {}, 36 | ) => { 37 | const uri = typeof path === "string" ? path : path.href; 38 | const alias = typeof path === "string" ? path : path.as; 39 | 40 | const res = options.res; 41 | if (res == undefined) { 42 | // For client. 43 | if (options.queryParameters == undefined) return Router.push(uri, alias); 44 | 45 | const pageURL = new URL(uri, baseURI); 46 | const aliasURL = new URL(alias, baseURI); 47 | const pageQueryString = mergeQueryString(pageURL, options.queryParameters); 48 | const aliasQueryString = mergeQueryString(aliasURL, options.queryParameters); 49 | 50 | return Router.push(`${pageURL.pathname}?${pageQueryString}`, `${aliasURL.pathname}?${aliasQueryString}`); 51 | } 52 | 53 | // For server. 54 | const aliasURL = new URL(alias, baseURI); 55 | const queryString = mergeQueryString(aliasURL, options.queryParameters ?? {}); 56 | const url = new URL(queryString.length > 0 ? `${aliasURL.pathname}?${queryString}` : alias, baseURI); 57 | res.writeHead(options.statusCode ?? 302, { Location: url.toString() }); 58 | res.end(); 59 | }; 60 | -------------------------------------------------------------------------------- /src/query-merger.spec.ts: -------------------------------------------------------------------------------- 1 | import { mergeQueryString } from "./query-merger"; 2 | 3 | describe("mergeQueryString", () => { 4 | it("should merge query strings", () => { 5 | expect(mergeQueryString(new URL("https://foo.example.com/"), {})).toEqual(""); 6 | expect(mergeQueryString(new URL("https://foo.example.com/"), { a: 1, b: "2" })).toEqual("a=1&b=2"); 7 | 8 | expect(mergeQueryString(new URL("https://foo.example.com/bar?x=1&y=2"), {})).toEqual("x=1&y=2"); 9 | expect(mergeQueryString(new URL("https://foo.example.com/bar?x=1&y=2"), { a: 1, b: "2" })).toEqual("a=1&b=2&x=1&y=2"); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/query-merger.ts: -------------------------------------------------------------------------------- 1 | import * as queryString from "query-string"; 2 | 3 | import { QueryParameters } from "./common-types"; 4 | 5 | export const mergeQueryString = (url: URL, queryParameters: QueryParameters) => 6 | queryString.stringify({ 7 | ...queryString.parse(url.search), 8 | ...queryParameters, 9 | }); 10 | -------------------------------------------------------------------------------- /src/route-creator.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRoute } from "./route-creator"; 2 | 3 | describe("createRoute", () => { 4 | it("should append one leading slash", () => { 5 | expect(createRoute("example")).toEqual({ href: "/example", as: "/example" }); 6 | expect(createRoute("/example")).toEqual({ href: "/example", as: "/example" }); 7 | expect(createRoute("//example")).toEqual({ href: "/example", as: "/example" }); 8 | }); 9 | 10 | context("when parameters are not required,", () => { 11 | it('should return "href" and "as" and they are the same value', () => { 12 | expect(createRoute("/example")).toEqual({ href: "/example", as: "/example" }); 13 | expect(createRoute("/example/example")).toEqual({ href: "/example/example", as: "/example/example" }); 14 | 15 | expect(createRoute("/example", { foo: "dummy1", bar: "dummy2" })).toEqual({ href: "/example", as: "/example" }); 16 | expect(createRoute("/example/example", { foo: "dummy1", bar: "dummy2" })).toEqual({ 17 | href: "/example/example", 18 | as: "/example/example", 19 | }); 20 | }); 21 | }); 22 | 23 | context("when parameters are required and fills them,", () => { 24 | it('should return "href" and completed URI as "as"', () => { 25 | expect(createRoute("/tags/[tagName]", { tagName: "dummy-name" })).toEqual({ 26 | href: "/tags/[tagName]", 27 | as: "/tags/dummy-name", 28 | }); 29 | expect(createRoute("/posts/[postId]", { postId: 123 })).toEqual({ href: "/posts/[postId]", as: "/posts/123" }); 30 | expect(createRoute("/posts/[postId]/[commentId]", { postId: 123, commentId: 456 })).toEqual({ 31 | href: "/posts/[postId]/[commentId]", 32 | as: "/posts/123/456", 33 | }); 34 | }); 35 | }); 36 | 37 | context("when parameters are required and lacks them,", () => { 38 | it('should return "href" and incompleted URI as "as"', () => { 39 | expect(createRoute("/tags/[tagName]")).toEqual({ href: "/tags/[tagName]", as: "/tags/[tagName]" }); 40 | expect(createRoute("/posts/[postId]", { dummy: 123 })).toEqual({ href: "/posts/[postId]", as: "/posts/[postId]" }); 41 | expect(createRoute("/posts/[postId]/[commentId]", { postId: 123 })).toEqual({ 42 | href: "/posts/[postId]/[commentId]", 43 | as: "/posts/123/[commentId]", 44 | }); 45 | }); 46 | }); 47 | 48 | context("when specifying query parameters,", () => { 49 | it("should parse them as query string", () => { 50 | const queryParameters = { a: 1, b: "2" }; 51 | 52 | expect(createRoute("/example/example", undefined, queryParameters)).toEqual({ 53 | href: "/example/example?a=1&b=2", 54 | as: "/example/example?a=1&b=2", 55 | }); 56 | expect(createRoute("/tags/[tagName]", undefined, queryParameters)).toEqual({ 57 | href: "/tags/[tagName]?a=1&b=2", 58 | as: "/tags/[tagName]?a=1&b=2", 59 | }); 60 | expect(createRoute("/posts/[postId]", { dummy: 123 }, queryParameters)).toEqual({ 61 | href: "/posts/[postId]?a=1&b=2", 62 | as: "/posts/[postId]?a=1&b=2", 63 | }); 64 | expect(createRoute("/posts/[postId]/[commentId]", { postId: 123 }, queryParameters)).toEqual({ 65 | href: "/posts/[postId]/[commentId]?a=1&b=2", 66 | as: "/posts/123/[commentId]?a=1&b=2", 67 | }); 68 | }); 69 | }); 70 | 71 | context("when specifying query parameters which contain undefined values", () => { 72 | it("should not contain the parameters", () => { 73 | const queryParameters = { a: 1, b: undefined, c: "2" }; 74 | 75 | expect(createRoute("/example/example", undefined, queryParameters)).toEqual({ 76 | href: "/example/example?a=1&c=2", 77 | as: "/example/example?a=1&c=2", 78 | }); 79 | expect(createRoute("/tags/[tagName]", undefined, queryParameters)).toEqual({ 80 | href: "/tags/[tagName]?a=1&c=2", 81 | as: "/tags/[tagName]?a=1&c=2", 82 | }); 83 | expect(createRoute("/posts/[postId]", { dummy: 123 }, queryParameters)).toEqual({ 84 | href: "/posts/[postId]?a=1&c=2", 85 | as: "/posts/[postId]?a=1&c=2", 86 | }); 87 | expect(createRoute("/posts/[postId]/[commentId]", { postId: 123 }, queryParameters)).toEqual({ 88 | href: "/posts/[postId]/[commentId]?a=1&c=2", 89 | as: "/posts/123/[commentId]?a=1&c=2", 90 | }); 91 | }); 92 | }); 93 | 94 | context("when specifying query parameters which contain null values", () => { 95 | it("should contain only keys of the parameters", () => { 96 | const queryParameters = { a: 1, b: null, c: "2" }; 97 | 98 | expect(createRoute("/example/example", undefined, queryParameters)).toEqual({ 99 | href: "/example/example?a=1&b&c=2", 100 | as: "/example/example?a=1&b&c=2", 101 | }); 102 | expect(createRoute("/tags/[tagName]", undefined, queryParameters)).toEqual({ 103 | href: "/tags/[tagName]?a=1&b&c=2", 104 | as: "/tags/[tagName]?a=1&b&c=2", 105 | }); 106 | expect(createRoute("/posts/[postId]", { dummy: 123 }, queryParameters)).toEqual({ 107 | href: "/posts/[postId]?a=1&b&c=2", 108 | as: "/posts/[postId]?a=1&b&c=2", 109 | }); 110 | expect(createRoute("/posts/[postId]/[commentId]", { postId: 123 }, queryParameters)).toEqual({ 111 | href: "/posts/[postId]/[commentId]?a=1&b&c=2", 112 | as: "/posts/123/[commentId]?a=1&b&c=2", 113 | }); 114 | }); 115 | }); 116 | 117 | context("when specifying query parameters which contain boolean values", () => { 118 | it("should contain the parameters as string", () => { 119 | const queryParameters = { a: 1, b: false, c: "2", d: true }; 120 | 121 | expect(createRoute("/example/example", undefined, queryParameters)).toEqual({ 122 | href: "/example/example?a=1&b=false&c=2&d=true", 123 | as: "/example/example?a=1&b=false&c=2&d=true", 124 | }); 125 | expect(createRoute("/tags/[tagName]", undefined, queryParameters)).toEqual({ 126 | href: "/tags/[tagName]?a=1&b=false&c=2&d=true", 127 | as: "/tags/[tagName]?a=1&b=false&c=2&d=true", 128 | }); 129 | expect(createRoute("/posts/[postId]", { dummy: 123 }, queryParameters)).toEqual({ 130 | href: "/posts/[postId]?a=1&b=false&c=2&d=true", 131 | as: "/posts/[postId]?a=1&b=false&c=2&d=true", 132 | }); 133 | expect(createRoute("/posts/[postId]/[commentId]", { postId: 123 }, queryParameters)).toEqual({ 134 | href: "/posts/[postId]/[commentId]?a=1&b=false&c=2&d=true", 135 | as: "/posts/123/[commentId]?a=1&b=false&c=2&d=true", 136 | }); 137 | }); 138 | }); 139 | 140 | context("when parameters or specifying query parameters contain zero", () => { 141 | it("should contain zero as the parameters", () => { 142 | const queryParameters = { a: 0 }; 143 | 144 | expect(createRoute("/example/example", undefined, queryParameters)).toEqual({ 145 | href: "/example/example?a=0", 146 | as: "/example/example?a=0", 147 | }); 148 | expect(createRoute("/posts/[postId]", { postId: 0 }, queryParameters)).toEqual({ 149 | href: "/posts/[postId]?a=0", 150 | as: "/posts/0?a=0", 151 | }); 152 | expect(createRoute("/posts/[postId]/[commentId]", { postId: 0, commentId: 0 }, queryParameters)).toEqual({ 153 | href: "/posts/[postId]/[commentId]?a=0", 154 | as: "/posts/0/0?a=0", 155 | }); 156 | }); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /src/route-creator.ts: -------------------------------------------------------------------------------- 1 | import * as queryString from "query-string"; 2 | 3 | import { QueryParameters } from "./common-types"; 4 | 5 | export const createRoute = ( 6 | /** A page of Next.js. This is a path of files in `/pages` in general. */ 7 | path: string, 8 | /** 9 | * An object for dynamic parameters. 10 | * @default {} 11 | */ 12 | parameters: Record = {}, 13 | /** 14 | * An object for query string. 15 | * @default {} 16 | */ 17 | queryParameters: QueryParameters = {}, 18 | ) => { 19 | const pagePath = path.replace(/^\/*/, "/"); 20 | const completedURISegments: string[] = []; 21 | 22 | const segments = pagePath.split("/"); 23 | segments.forEach((segment: string) => { 24 | const newSegment = (() => { 25 | const matches = segment.match(/\[(.*?)\]/); 26 | if (matches == undefined) return segment; 27 | 28 | const parameterName = matches[1]; 29 | 30 | return parameters[parameterName] ?? segment; 31 | })(); 32 | 33 | completedURISegments.push(newSegment.toString()); 34 | }); 35 | 36 | const queries = queryString.stringify(queryParameters); 37 | const querySeparator = queries && "?"; 38 | 39 | const pagePathWithQueries = pagePath + querySeparator + queries; 40 | const completedURI = completedURISegments.join("/") + querySeparator + queries; 41 | 42 | return { href: pagePathWithQueries, as: completedURI }; 43 | }; 44 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "allowJs": false, 5 | "allowSyntheticDefaultImports": true, 6 | "baseUrl": "./", 7 | "declaration": true, 8 | "lib": [ 9 | "es2020", 10 | "dom" 11 | ], 12 | "module": "commonjs", 13 | "moduleResolution": "node", 14 | "outDir": "./lib", 15 | "rootDir": "./src", 16 | "removeComments": false, 17 | "sourceMap": false, 18 | "skipLibCheck": true, 19 | "strict": true, 20 | "target": "es2015" 21 | }, 22 | "include": [ 23 | "./src/**/*" 24 | ], 25 | "exclude": [ 26 | "node_modules", 27 | "./src/**/*.spec.ts" 28 | ] 29 | } 30 | --------------------------------------------------------------------------------