├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── package.json ├── pnpm-lock.yaml ├── rollup.config.js ├── src ├── index.ts ├── pathParserRanker.ts └── pathTokenizer.ts ├── tsconfig.json └── types.d.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest] 14 | 15 | runs-on: ${{ matrix.os }} 16 | 17 | # Steps represent a sequence of tasks that will be executed as part of the job 18 | steps: 19 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 20 | - uses: actions/checkout@v2 21 | 22 | - uses: actions/setup-node@v1 23 | with: 24 | node-version: 14.x 25 | 26 | - name: Cache ~/.pnpm-store 27 | uses: actions/cache@v2 28 | env: 29 | cache-name: cache-pnpm-store 30 | with: 31 | path: ~/.pnpm-store 32 | key: ${{ runner.os }}-${{ matrix.node-version }}-build-${{ env.cache-name }}-${{ hashFiles('**/pnpm-lock.yaml') }} 33 | restore-keys: | 34 | ${{ runner.os }}-${{ matrix.node-version }}-build-${{ env.cache-name }}- 35 | ${{ runner.os }}-${{ matrix.node-version }}-build- 36 | ${{ runner.os }}- 37 | - name: Install pnpm 38 | run: npm i -g pnpm 39 | 40 | - name: Install deps 41 | run: pnpm i 42 | 43 | # Runs a set of commands using the runners shell 44 | - name: Build and Test 45 | run: npm run test 46 | 47 | - name: Release 48 | run: pnpx semantic-release --branches main 49 | env: 50 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | *.log -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | "@egoist/prettier-config" 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Eduardo San Martin Morote (https://github.com/posva), EGOIST (https://github.com/egoist) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @egoist/path-parser 2 | 3 | This module is entirely extracted from [vue-router](https://github.com/vuejs/vue-router-next), it's basically a light-weight version of [path-to-regexp](https://github.com/pillarjs/path-to-regexp) with path ranking support. 4 | 5 | ## Install 6 | 7 | ```bash 8 | npm i @egoist/path-parser 9 | ``` 10 | 11 | ## Usage 12 | 13 | Create a path parser: 14 | 15 | ```ts 16 | import { createParser } from '@egoist/path-parser' 17 | 18 | const parser = createParser('/user/:user') 19 | 20 | parser.parse('/user/egoist') 21 | //=> { user: 'egoist' } 22 | // `null` if not matched 23 | ``` 24 | 25 | Sort paths by ranking: 26 | 27 | ```ts 28 | import { comparePathParserScore, createParser } from '@egoist/path-parser' 29 | 30 | const paths = ['/:user', '/about'] 31 | 32 | paths.sort((a, b) => { 33 | return comparePathParserScore(createParser(a), createParser(b)) 34 | }) 35 | //=> [ '/about', '/:user' ] 36 | ``` 37 | 38 | ## Credits 39 | 40 | The code is extracted from [vue-router](https://github.com/vuejs/vue-router-next), all credits to its author [@posva](https://github.com/posva). The code might differ from the upstream in the future. 41 | 42 | ## License 43 | 44 | MIT © [EGOIST](https://github.com/sponsors/egoist) 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@egoist/path-parser", 3 | "description": "A robust and light-weight path-to-regexp alternative", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "version": "0.0.0", 8 | "sideEffects": false, 9 | "main": "./dist/index.js", 10 | "module": "./dist/index.mjs", 11 | "exports": { 12 | "require": "./dist/index.js", 13 | "import": "./dist/index.mjs" 14 | }, 15 | "types": "./dist/index.d.ts", 16 | "files": [ 17 | "dist" 18 | ], 19 | "scripts": { 20 | "test": "echo lol", 21 | "build": "rollup -c", 22 | "prepublishOnly": "npm run build" 23 | }, 24 | "devDependencies": { 25 | "@egoist/prettier-config": "^0.1.0", 26 | "esbuild": "^0.9.2", 27 | "prettier": "^2.2.1", 28 | "rollup": "^2.41.3", 29 | "rollup-plugin-dts": "^3.0.1", 30 | "rollup-plugin-esbuild": "^3.0.2", 31 | "typescript": "^4.2.3" 32 | }, 33 | "license": "MIT", 34 | "author": { 35 | "name": "Eduardo San Martin Morote", 36 | "url": "https://github.com/posva" 37 | }, 38 | "contributors": [ 39 | { 40 | "name": "EGOIST", 41 | "url": "https://github.com/egoist" 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | devDependencies: 2 | '@egoist/prettier-config': 0.1.0 3 | esbuild: 0.9.2 4 | prettier: 2.2.1 5 | rollup: 2.41.3 6 | rollup-plugin-dts: 3.0.1_rollup@2.41.3+typescript@4.2.3 7 | rollup-plugin-esbuild: 3.0.2_esbuild@0.9.2+rollup@2.41.3 8 | typescript: 4.2.3 9 | lockfileVersion: 5.2 10 | packages: 11 | /@babel/code-frame/7.12.13: 12 | dependencies: 13 | '@babel/highlight': 7.13.10 14 | dev: true 15 | optional: true 16 | resolution: 17 | integrity: sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g== 18 | /@babel/helper-validator-identifier/7.12.11: 19 | dev: true 20 | optional: true 21 | resolution: 22 | integrity: sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw== 23 | /@babel/highlight/7.13.10: 24 | dependencies: 25 | '@babel/helper-validator-identifier': 7.12.11 26 | chalk: 2.4.2 27 | js-tokens: 4.0.0 28 | dev: true 29 | optional: true 30 | resolution: 31 | integrity: sha512-5aPpe5XQPzflQrFwL1/QoeHkP2MsA4JCntcXHRhEsdsfPVkvPi2w7Qix4iV7t5S/oC9OodGrggd8aco1g3SZFg== 32 | /@egoist/prettier-config/0.1.0: 33 | dev: true 34 | resolution: 35 | integrity: sha512-BYeixvJcTY/jOgawCgFOBxYBPP0e1WQPiyZw98BsUEjSI5SbdAguRKodqcyT50wie+HGk1EuhGUzC3lfrKL4Vg== 36 | /@rollup/pluginutils/4.1.0_rollup@2.41.3: 37 | dependencies: 38 | estree-walker: 2.0.2 39 | picomatch: 2.2.2 40 | rollup: 2.41.3 41 | dev: true 42 | engines: 43 | node: '>= 8.0.0' 44 | peerDependencies: 45 | rollup: ^1.20.0||^2.0.0 46 | resolution: 47 | integrity: sha512-TrBhfJkFxA+ER+ew2U2/fHbebhLT/l/2pRk0hfj9KusXUuRXd2v0R58AfaZK9VXDQ4TogOSEmICVrQAA3zFnHQ== 48 | /ansi-styles/3.2.1: 49 | dependencies: 50 | color-convert: 1.9.3 51 | dev: true 52 | engines: 53 | node: '>=4' 54 | optional: true 55 | resolution: 56 | integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== 57 | /chalk/2.4.2: 58 | dependencies: 59 | ansi-styles: 3.2.1 60 | escape-string-regexp: 1.0.5 61 | supports-color: 5.5.0 62 | dev: true 63 | engines: 64 | node: '>=4' 65 | optional: true 66 | resolution: 67 | integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== 68 | /color-convert/1.9.3: 69 | dependencies: 70 | color-name: 1.1.3 71 | dev: true 72 | optional: true 73 | resolution: 74 | integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== 75 | /color-name/1.1.3: 76 | dev: true 77 | optional: true 78 | resolution: 79 | integrity: sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= 80 | /esbuild/0.9.2: 81 | dev: true 82 | hasBin: true 83 | requiresBuild: true 84 | resolution: 85 | integrity: sha512-xE3oOILjnmN8PSjkG3lT9NBbd1DbxNqolJ5qNyrLhDWsFef3yTp/KTQz1C/x7BYFKbtrr9foYtKA6KA1zuNAUQ== 86 | /escape-string-regexp/1.0.5: 87 | dev: true 88 | engines: 89 | node: '>=0.8.0' 90 | optional: true 91 | resolution: 92 | integrity: sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= 93 | /estree-walker/2.0.2: 94 | dev: true 95 | resolution: 96 | integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== 97 | /fsevents/2.3.2: 98 | dev: true 99 | engines: 100 | node: ^8.16.0 || ^10.6.0 || >=11.0.0 101 | optional: true 102 | os: 103 | - darwin 104 | resolution: 105 | integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== 106 | /has-flag/3.0.0: 107 | dev: true 108 | engines: 109 | node: '>=4' 110 | optional: true 111 | resolution: 112 | integrity: sha1-tdRU3CGZriJWmfNGfloH87lVuv0= 113 | /joycon/3.0.0: 114 | dev: true 115 | engines: 116 | node: '>=12' 117 | resolution: 118 | integrity: sha512-liltsn9SO+IuOHnVb1EgW6HBaHb6rUsMzkLKnGV7izz8UrXBpa3bsMSa2J8pbXo3B4Ebk6Wm2Izs4Dgbl2EGPw== 119 | /js-tokens/4.0.0: 120 | dev: true 121 | optional: true 122 | resolution: 123 | integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== 124 | /jsonc-parser/3.0.0: 125 | dev: true 126 | resolution: 127 | integrity: sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA== 128 | /magic-string/0.25.7: 129 | dependencies: 130 | sourcemap-codec: 1.4.8 131 | dev: true 132 | resolution: 133 | integrity: sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA== 134 | /picomatch/2.2.2: 135 | dev: true 136 | engines: 137 | node: '>=8.6' 138 | resolution: 139 | integrity: sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== 140 | /prettier/2.2.1: 141 | dev: true 142 | engines: 143 | node: '>=10.13.0' 144 | hasBin: true 145 | resolution: 146 | integrity: sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q== 147 | /rollup-plugin-dts/3.0.1_rollup@2.41.3+typescript@4.2.3: 148 | dependencies: 149 | magic-string: 0.25.7 150 | rollup: 2.41.3 151 | typescript: 4.2.3 152 | dev: true 153 | engines: 154 | node: '>=12' 155 | optionalDependencies: 156 | '@babel/code-frame': 7.12.13 157 | peerDependencies: 158 | rollup: ^2.40.0 159 | typescript: ^4.2.3 160 | resolution: 161 | integrity: sha512-sdTsd0tEIV1b5Bio1k4Ei3N4/7jbwcVRdlYotGYdJOKR59JH7DzqKTSCbfaKPzuAcKTp7k317z2BzYJ3bkhDTw== 162 | /rollup-plugin-esbuild/3.0.2_esbuild@0.9.2+rollup@2.41.3: 163 | dependencies: 164 | '@rollup/pluginutils': 4.1.0_rollup@2.41.3 165 | esbuild: 0.9.2 166 | joycon: 3.0.0 167 | jsonc-parser: 3.0.0 168 | dev: true 169 | engines: 170 | node: '>=12' 171 | peerDependencies: 172 | esbuild: '>=0.9.0' 173 | rollup: '*' 174 | resolution: 175 | integrity: sha512-uq+oBCeLXF1m6g9V0qpqbPbgyq24aXBKF474BvqgxfNmTP6FZ+oVk5/pCWQ/2rfSNJs4IimNU/k0q8xMaa0iCA== 176 | /rollup/2.41.3: 177 | dev: true 178 | engines: 179 | node: '>=10.0.0' 180 | hasBin: true 181 | optionalDependencies: 182 | fsevents: 2.3.2 183 | resolution: 184 | integrity: sha512-swrSUfX3UK7LGd5exBJNUC7kykdxemUTRuyO9hUFJsmQUsUovHcki9vl5MAWFbB6oI47HpeZHtbmuzdm1SRUZw== 185 | /sourcemap-codec/1.4.8: 186 | dev: true 187 | resolution: 188 | integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== 189 | /supports-color/5.5.0: 190 | dependencies: 191 | has-flag: 3.0.0 192 | dev: true 193 | engines: 194 | node: '>=4' 195 | optional: true 196 | resolution: 197 | integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== 198 | /typescript/4.2.3: 199 | dev: true 200 | engines: 201 | node: '>=4.2.0' 202 | hasBin: true 203 | resolution: 204 | integrity: sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw== 205 | specifiers: 206 | '@egoist/prettier-config': ^0.1.0 207 | esbuild: ^0.9.2 208 | prettier: ^2.2.1 209 | rollup: ^2.41.3 210 | rollup-plugin-dts: ^3.0.1 211 | rollup-plugin-esbuild: ^3.0.2 212 | typescript: ^4.2.3 213 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import esbuild from 'rollup-plugin-esbuild' 2 | import dts from 'rollup-plugin-dts' 3 | 4 | /** @type {import('rollup').Plugin} */ 5 | const replace = { 6 | transform(code) { 7 | return code.replace( 8 | /\b__DEV__\b/g, 9 | `process.env.NODE_ENV === 'development'`, 10 | ) 11 | }, 12 | } 13 | 14 | /** @type {import('rollup').RollupOptions} */ 15 | const jsConfig = { 16 | input: './src/index.ts', 17 | plugins: [esbuild({}), replace], 18 | output: [ 19 | { file: 'dist/index.mjs', format: 'esm' }, 20 | { file: 'dist/index.js', format: 'cjs' }, 21 | ], 22 | } 23 | 24 | /** @type {import('rollup').RollupOptions} */ 25 | const dtsConfig = { 26 | input: './src/index.ts', 27 | plugins: [dts()], 28 | output: { 29 | dir: 'dist', 30 | format: 'esm', 31 | }, 32 | } 33 | 34 | export default [jsConfig, dtsConfig] 35 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { tokenizePath } from './pathTokenizer' 2 | import { tokensToParser } from './pathParserRanker' 3 | import type { PathParserOptions } from './pathParserRanker' 4 | 5 | export { comparePathParserScore } from './pathParserRanker' 6 | 7 | export type { PathParser } from './pathParserRanker' 8 | 9 | export { tokenizePath, tokensToParser, PathParserOptions } 10 | 11 | export const createParser = (path: string, options?: PathParserOptions) => { 12 | return tokensToParser(tokenizePath(path), options) 13 | } 14 | -------------------------------------------------------------------------------- /src/pathParserRanker.ts: -------------------------------------------------------------------------------- 1 | // Forked from https://github.com/vuejs/vue-router-next/blob/65816e13747de372a43ea8f7ac6bf121e44dd8e1/src/matcher/pathParserRanker.ts#L1 2 | import { Token, TokenType } from './pathTokenizer' 3 | 4 | export type PathParams = Record 5 | 6 | /** 7 | * A param in a url like `/users/:id` 8 | */ 9 | interface PathParserParamKey { 10 | name: string 11 | repeatable: boolean 12 | optional: boolean 13 | } 14 | 15 | export interface PathParser { 16 | /** 17 | * The regexp used to match a url 18 | */ 19 | re: RegExp 20 | /** 21 | * The score of the parser 22 | */ 23 | score: Array 24 | /** 25 | * Keys that appeared in the path 26 | */ 27 | keys: PathParserParamKey[] 28 | /** 29 | * Parses a url and returns the matched params or nul if it doesn't match. An 30 | * optional param that isn't preset will be an empty string. A repeatable 31 | * param will be an array if there is at least one value. 32 | * 33 | * @param path - url to parse 34 | * @returns a Params object, empty if there are no params. `null` if there is 35 | * no match 36 | */ 37 | parse(path: string): PathParams | null 38 | /** 39 | * Creates a string version of the url 40 | * 41 | * @param params - object of params 42 | * @returns a url 43 | */ 44 | stringify(params: PathParams): string 45 | } 46 | 47 | /** 48 | * @internal 49 | */ 50 | export interface _PathParserOptions { 51 | /** 52 | * Makes the RegExp case sensitive. Defaults to false 53 | */ 54 | sensitive?: boolean 55 | /** 56 | * Should we disallow a trailing slash. Defaults to false 57 | */ 58 | strict?: boolean 59 | /** 60 | * Should the RegExp match from the beginning by prepending a `^` to it. Defaults to true 61 | * @internal 62 | */ 63 | start?: boolean 64 | /** 65 | * Should the RegExp match until the end by appending a `$` to it. Defaults to true 66 | */ 67 | end?: boolean 68 | } 69 | 70 | export type PathParserOptions = Pick< 71 | _PathParserOptions, 72 | 'end' | 'sensitive' | 'strict' 73 | > 74 | 75 | // default pattern for a param: non greedy everything but / 76 | const BASE_PARAM_PATTERN = '[^/]+?' 77 | 78 | const BASE_PATH_PARSER_OPTIONS: Required<_PathParserOptions> = { 79 | sensitive: false, 80 | strict: false, 81 | start: true, 82 | end: true, 83 | } 84 | 85 | // Scoring values used in tokensToParser 86 | const enum PathScore { 87 | _multiplier = 10, 88 | Root = 9 * _multiplier, // just / 89 | Segment = 4 * _multiplier, // /a-segment 90 | SubSegment = 3 * _multiplier, // /multiple-:things-in-one-:segment 91 | Static = 4 * _multiplier, // /static 92 | Dynamic = 2 * _multiplier, // /:someId 93 | BonusCustomRegExp = 1 * _multiplier, // /:someId(\\d+) 94 | BonusWildcard = -4 * _multiplier - BonusCustomRegExp, // /:namedWildcard(.*) we remove the bonus added by the custom regexp 95 | BonusRepeatable = -2 * _multiplier, // /:w+ or /:w* 96 | BonusOptional = -0.8 * _multiplier, // /:w? or /:w* 97 | // these two have to be under 0.1 so a strict /:page is still lower than /:a-:b 98 | BonusStrict = 0.07 * _multiplier, // when options strict: true is passed, as the regex omits \/? 99 | BonusCaseSensitive = 0.025 * _multiplier, // when options strict: true is passed, as the regex omits \/? 100 | } 101 | 102 | // Special Regex characters that must be escaped in static tokens 103 | const REGEX_CHARS_RE = /[.+*?^${}()[\]/\\]/g 104 | 105 | /** 106 | * Creates a path parser from an array of Segments (a segment is an array of Tokens) 107 | * 108 | * @param segments - array of segments returned by tokenizePath 109 | * @param extraOptions - optional options for the regexp 110 | * @returns a PathParser 111 | */ 112 | export function tokensToParser( 113 | segments: Array, 114 | extraOptions?: _PathParserOptions, 115 | ): PathParser { 116 | const options = Object.assign({}, BASE_PATH_PARSER_OPTIONS, extraOptions) 117 | 118 | // the amount of scores is the same as the length of segments except for the root segment "/" 119 | let score: Array = [] 120 | // the regexp as a string 121 | let pattern = options.start ? '^' : '' 122 | // extracted keys 123 | const keys: PathParserParamKey[] = [] 124 | 125 | for (const segment of segments) { 126 | // the root segment needs special treatment 127 | const segmentScores: number[] = segment.length ? [] : [PathScore.Root] 128 | 129 | // allow trailing slash 130 | if (options.strict && !segment.length) pattern += '/' 131 | for (let tokenIndex = 0; tokenIndex < segment.length; tokenIndex++) { 132 | const token = segment[tokenIndex] 133 | // resets the score if we are inside a sub segment /:a-other-:b 134 | let subSegmentScore: number = 135 | PathScore.Segment + 136 | (options.sensitive ? PathScore.BonusCaseSensitive : 0) 137 | 138 | if (token.type === TokenType.Static) { 139 | // prepend the slash if we are starting a new segment 140 | if (!tokenIndex) pattern += '/' 141 | pattern += token.value.replace(REGEX_CHARS_RE, '\\$&') 142 | subSegmentScore += PathScore.Static 143 | } else if (token.type === TokenType.Param) { 144 | const { value, repeatable, optional, regexp } = token 145 | keys.push({ 146 | name: value, 147 | repeatable, 148 | optional, 149 | }) 150 | const re = regexp ? regexp : BASE_PARAM_PATTERN 151 | // the user provided a custom regexp /:id(\\d+) 152 | if (re !== BASE_PARAM_PATTERN) { 153 | subSegmentScore += PathScore.BonusCustomRegExp 154 | // make sure the regexp is valid before using it 155 | try { 156 | new RegExp(`(${re})`) 157 | } catch (err) { 158 | throw new Error( 159 | `Invalid custom RegExp for param "${value}" (${re}): ` + 160 | err.message, 161 | ) 162 | } 163 | } 164 | 165 | // when we repeat we must take care of the repeating leading slash 166 | let subPattern = repeatable ? `((?:${re})(?:/(?:${re}))*)` : `(${re})` 167 | 168 | // prepend the slash if we are starting a new segment 169 | if (!tokenIndex) 170 | subPattern = 171 | // avoid an optional / if there are more segments e.g. /:p?-static 172 | // or /:p?-:p2 173 | optional && segment.length < 2 174 | ? `(?:/${subPattern})` 175 | : '/' + subPattern 176 | if (optional) subPattern += '?' 177 | 178 | pattern += subPattern 179 | 180 | subSegmentScore += PathScore.Dynamic 181 | if (optional) subSegmentScore += PathScore.BonusOptional 182 | if (repeatable) subSegmentScore += PathScore.BonusRepeatable 183 | if (re === '.*') subSegmentScore += PathScore.BonusWildcard 184 | } 185 | 186 | segmentScores.push(subSegmentScore) 187 | } 188 | 189 | // an empty array like /home/ -> [[{home}], []] 190 | // if (!segment.length) pattern += '/' 191 | 192 | score.push(segmentScores) 193 | } 194 | 195 | // only apply the strict bonus to the last score 196 | if (options.strict && options.end) { 197 | const i = score.length - 1 198 | score[i][score[i].length - 1] += PathScore.BonusStrict 199 | } 200 | 201 | // TODO: dev only warn double trailing slash 202 | if (!options.strict) pattern += '/?' 203 | 204 | if (options.end) pattern += '$' 205 | // allow paths like /dynamic to only match dynamic or dynamic/... but not dynamic_something_else 206 | else if (options.strict) pattern += '(?:/|$)' 207 | 208 | const re = new RegExp(pattern, options.sensitive ? '' : 'i') 209 | 210 | function parse(path: string): PathParams | null { 211 | const match = path.match(re) 212 | const params: PathParams = {} 213 | 214 | if (!match) return null 215 | 216 | for (let i = 1; i < match.length; i++) { 217 | const value: string = match[i] || '' 218 | const key = keys[i - 1] 219 | params[key.name] = value && key.repeatable ? value.split('/') : value 220 | } 221 | 222 | return params 223 | } 224 | 225 | function stringify(params: PathParams): string { 226 | let path = '' 227 | // for optional parameters to allow to be empty 228 | let avoidDuplicatedSlash: boolean = false 229 | for (const segment of segments) { 230 | if (!avoidDuplicatedSlash || !path.endsWith('/')) path += '/' 231 | avoidDuplicatedSlash = false 232 | 233 | for (const token of segment) { 234 | if (token.type === TokenType.Static) { 235 | path += token.value 236 | } else if (token.type === TokenType.Param) { 237 | const { value, repeatable, optional } = token 238 | const param: string | string[] = value in params ? params[value] : '' 239 | 240 | if (Array.isArray(param) && !repeatable) 241 | throw new Error( 242 | `Provided param "${value}" is an array but it is not repeatable (* or + modifiers)`, 243 | ) 244 | const text: string = Array.isArray(param) ? param.join('/') : param 245 | if (!text) { 246 | if (optional) { 247 | // if we have more than one optional param like /:a?-static we 248 | // don't need to care about the optional param 249 | if (segment.length < 2) { 250 | // remove the last slash as we could be at the end 251 | if (path.endsWith('/')) path = path.slice(0, -1) 252 | // do not append a slash on the next iteration 253 | else avoidDuplicatedSlash = true 254 | } 255 | } else throw new Error(`Missing required param "${value}"`) 256 | } 257 | path += text 258 | } 259 | } 260 | } 261 | 262 | return path 263 | } 264 | 265 | return { 266 | re, 267 | score, 268 | keys, 269 | parse, 270 | stringify, 271 | } 272 | } 273 | 274 | /** 275 | * Compares an array of numbers as used in PathParser.score and returns a 276 | * number. This function can be used to `sort` an array 277 | * @param a - first array of numbers 278 | * @param b - second array of numbers 279 | * @returns 0 if both are equal, < 0 if a should be sorted first, > 0 if b 280 | * should be sorted first 281 | */ 282 | function compareScoreArray(a: number[], b: number[]): number { 283 | let i = 0 284 | while (i < a.length && i < b.length) { 285 | const diff = b[i] - a[i] 286 | // only keep going if diff === 0 287 | if (diff) return diff 288 | 289 | i++ 290 | } 291 | 292 | // if the last subsegment was Static, the shorter segments should be sorted first 293 | // otherwise sort the longest segment first 294 | if (a.length < b.length) { 295 | return a.length === 1 && a[0] === PathScore.Static + PathScore.Segment 296 | ? -1 297 | : 1 298 | } else if (a.length > b.length) { 299 | return b.length === 1 && b[0] === PathScore.Static + PathScore.Segment 300 | ? 1 301 | : -1 302 | } 303 | 304 | return 0 305 | } 306 | 307 | /** 308 | * Compare function that can be used with `sort` to sort an array of PathParser 309 | * @param a - first PathParser 310 | * @param b - second PathParser 311 | * @returns 0 if both are equal, < 0 if a should be sorted first, > 0 if b 312 | */ 313 | export function comparePathParserScore(a: PathParser, b: PathParser): number { 314 | let i = 0 315 | const aScore = a.score 316 | const bScore = b.score 317 | while (i < aScore.length && i < bScore.length) { 318 | const comp = compareScoreArray(aScore[i], bScore[i]) 319 | // do not return if both are equal 320 | if (comp) return comp 321 | 322 | i++ 323 | } 324 | 325 | // if a and b share the same score entries but b has more, sort b first 326 | return bScore.length - aScore.length 327 | // this is the ternary version 328 | // return aScore.length < bScore.length 329 | // ? 1 330 | // : aScore.length > bScore.length 331 | // ? -1 332 | // : 0 333 | } 334 | -------------------------------------------------------------------------------- /src/pathTokenizer.ts: -------------------------------------------------------------------------------- 1 | // Forked from https://github.com/vuejs/vue-router-next/blob/65816e13747de372a43ea8f7ac6bf121e44dd8e1/src/matcher/pathTokenizer.ts 2 | 3 | export const enum TokenType { 4 | Static, 5 | Param, 6 | Group, 7 | } 8 | 9 | const enum TokenizerState { 10 | Static, 11 | Param, 12 | ParamRegExp, // custom re for a param 13 | ParamRegExpEnd, // check if there is any ? + * 14 | EscapeNext, 15 | } 16 | 17 | interface TokenStatic { 18 | type: TokenType.Static 19 | value: string 20 | } 21 | 22 | interface TokenParam { 23 | type: TokenType.Param 24 | regexp?: string 25 | value: string 26 | optional: boolean 27 | repeatable: boolean 28 | } 29 | 30 | interface TokenGroup { 31 | type: TokenType.Group 32 | value: Exclude[] 33 | } 34 | 35 | export type Token = TokenStatic | TokenParam | TokenGroup 36 | 37 | const ROOT_TOKEN: Token = { 38 | type: TokenType.Static, 39 | value: '', 40 | } 41 | 42 | const VALID_PARAM_RE = /[a-zA-Z0-9_]/ 43 | // After some profiling, the cache seems to be unnecessary because tokenizePath 44 | // (the slowest part of adding a route) is very fast 45 | 46 | // const tokenCache = new Map() 47 | 48 | export function tokenizePath(path: string): Array { 49 | if (!path) return [[]] 50 | if (path === '/') return [[ROOT_TOKEN]] 51 | if (!path.startsWith('/')) { 52 | throw new Error( 53 | __DEV__ 54 | ? `Route paths should start with a "/": "${path}" should be "/${path}".` 55 | : `Invalid path "${path}"`, 56 | ) 57 | } 58 | 59 | // if (tokenCache.has(path)) return tokenCache.get(path)! 60 | 61 | function crash(message: string) { 62 | throw new Error(`ERR (${state})/"${buffer}": ${message}`) 63 | } 64 | 65 | let state: TokenizerState = TokenizerState.Static 66 | let previousState: TokenizerState = state 67 | const tokens: Array = [] 68 | // the segment will always be valid because we get into the initial state 69 | // with the leading / 70 | let segment!: Token[] 71 | 72 | function finalizeSegment() { 73 | if (segment) tokens.push(segment) 74 | segment = [] 75 | } 76 | 77 | // index on the path 78 | let i = 0 79 | // char at index 80 | let char: string 81 | // buffer of the value read 82 | let buffer: string = '' 83 | // custom regexp for a param 84 | let customRe: string = '' 85 | 86 | function consumeBuffer() { 87 | if (!buffer) return 88 | 89 | if (state === TokenizerState.Static) { 90 | segment.push({ 91 | type: TokenType.Static, 92 | value: buffer, 93 | }) 94 | } else if ( 95 | state === TokenizerState.Param || 96 | state === TokenizerState.ParamRegExp || 97 | state === TokenizerState.ParamRegExpEnd 98 | ) { 99 | if (segment.length > 1 && (char === '*' || char === '+')) 100 | crash( 101 | `A repeatable param (${buffer}) must be alone in its segment. eg: '/:ids+.`, 102 | ) 103 | segment.push({ 104 | type: TokenType.Param, 105 | value: buffer, 106 | regexp: customRe, 107 | repeatable: char === '*' || char === '+', 108 | optional: char === '*' || char === '?', 109 | }) 110 | } else { 111 | crash('Invalid state to consume buffer') 112 | } 113 | buffer = '' 114 | } 115 | 116 | function addCharToBuffer() { 117 | buffer += char 118 | } 119 | 120 | while (i < path.length) { 121 | char = path[i++] 122 | 123 | if (char === '\\' && state !== TokenizerState.ParamRegExp) { 124 | previousState = state 125 | state = TokenizerState.EscapeNext 126 | continue 127 | } 128 | 129 | switch (state) { 130 | case TokenizerState.Static: 131 | if (char === '/') { 132 | if (buffer) { 133 | consumeBuffer() 134 | } 135 | finalizeSegment() 136 | } else if (char === ':') { 137 | consumeBuffer() 138 | state = TokenizerState.Param 139 | } else { 140 | addCharToBuffer() 141 | } 142 | break 143 | 144 | case TokenizerState.EscapeNext: 145 | addCharToBuffer() 146 | state = previousState 147 | break 148 | 149 | case TokenizerState.Param: 150 | if (char === '(') { 151 | state = TokenizerState.ParamRegExp 152 | } else if (VALID_PARAM_RE.test(char)) { 153 | addCharToBuffer() 154 | } else { 155 | consumeBuffer() 156 | state = TokenizerState.Static 157 | // go back one character if we were not modifying 158 | if (char !== '*' && char !== '?' && char !== '+') i-- 159 | } 160 | break 161 | 162 | case TokenizerState.ParamRegExp: 163 | // TODO: is it worth handling nested regexp? like :p(?:prefix_([^/]+)_suffix) 164 | // it already works by escaping the closing ) 165 | // https://paths.esm.dev/?p=AAMeJbiAwQEcDKbAoAAkP60PG2R6QAvgNaA6AFACM2ABuQBB# 166 | // is this really something people need since you can also write 167 | // /prefix_:p()_suffix 168 | if (char === ')') { 169 | // handle the escaped ) 170 | if (customRe[customRe.length - 1] == '\\') 171 | customRe = customRe.slice(0, -1) + char 172 | else state = TokenizerState.ParamRegExpEnd 173 | } else { 174 | customRe += char 175 | } 176 | break 177 | 178 | case TokenizerState.ParamRegExpEnd: 179 | // same as finalizing a param 180 | consumeBuffer() 181 | state = TokenizerState.Static 182 | // go back one character if we were not modifying 183 | if (char !== '*' && char !== '?' && char !== '+') i-- 184 | customRe = '' 185 | break 186 | 187 | default: 188 | crash('Unknown state') 189 | break 190 | } 191 | } 192 | 193 | if (state === TokenizerState.ParamRegExp) 194 | crash(`Unfinished custom RegExp for param "${buffer}"`) 195 | 196 | consumeBuffer() 197 | finalizeSegment() 198 | 199 | // tokenCache.set(path, tokens) 200 | 201 | return tokens 202 | } 203 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "esnext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 44 | 45 | /* Module Resolution Options */ 46 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | 67 | /* Advanced Options */ 68 | "skipLibCheck": true /* Skip type checking of declaration files. */, 69 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | declare const __DEV__: boolean 2 | --------------------------------------------------------------------------------