├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .vscode ├── launch.json └── tasks.json ├── LICENSE ├── README.md ├── bench ├── benches │ ├── add.js │ ├── end-to-end.js │ ├── generate.js │ ├── handlers-for.js │ ├── normalize.js │ └── recognize.js └── index.js ├── config └── environment.js ├── ember-cli-build.js ├── lib ├── route-recognizer.ts └── route-recognizer │ ├── dsl.ts │ ├── normalizer.ts │ └── util.ts ├── package.json ├── server └── index.js ├── testem.js ├── tests ├── index.html ├── normalizer-tests.ts ├── recognizer-tests.ts └── router-tests.ts ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | plugins: ["@typescript-eslint", "import", "simple-import-sort", "prettier"], 5 | extends: [ 6 | "eslint:recommended", 7 | "plugin:import/errors", 8 | "plugin:import/warnings", 9 | "plugin:prettier/recommended", 10 | "prettier", 11 | "prettier/@typescript-eslint" 12 | ], 13 | rules: { 14 | "sort-imports": "off", 15 | "import/order": "off", 16 | "import/no-extraneous-dependencies": "error", 17 | "import/no-unassigned-import": "error", 18 | "import/no-duplicates": "error", 19 | "import/no-unresolved": "off", 20 | "simple-import-sort/sort": "error" 21 | }, 22 | ignorePatterns: ["dist/", "node_modules/", "DEBUG/", "*.d.ts"], 23 | overrides: [ 24 | { 25 | files: ["lib/**/*.ts", "tests/*.ts"], 26 | parserOptions: { 27 | project: "./tsconfig.json", 28 | tsconfigRootDir: __dirname, 29 | sourceType: "module" 30 | }, 31 | extends: [ 32 | "plugin:@typescript-eslint/eslint-recommended", 33 | "plugin:@typescript-eslint/recommended", 34 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 35 | ], 36 | rules: { 37 | "@typescript-eslint/no-use-before-define": [ 38 | "error", 39 | { functions: false } 40 | ], 41 | "@typescript-eslint/prefer-includes": "off", 42 | "@typescript-eslint/explicit-function-return-type": [ 43 | "error", 44 | { allowExpressions: true } 45 | ] 46 | } 47 | } 48 | ] 49 | }; 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: {} 8 | 9 | concurrency: 10 | group: ci-${{ github.head_ref || github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | test: 15 | strategy: 16 | matrix: 17 | node: [12, 14, 16] 18 | yarn-args: ["--frozen-lockfile"] 19 | include: 20 | - node: 17 21 | yarn-args: --no-lockfile 22 | name: 'Tests - Node ${{ matrix.node }}' 23 | env: 24 | CI: true 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v2 28 | - name: Install node 29 | uses: actions/setup-node@v2 30 | with: 31 | node-version: ${{ matrix.node }} 32 | cache: yarn 33 | - name: Install Dependencies 34 | run: yarn install 35 | - name: Lint JS 36 | run: yarn lint 37 | - name: Run Test 38 | run: yarn test 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp/ 2 | bower_components/ 3 | dist/ 4 | /.bundle 5 | .bundle/ 6 | bundle 7 | bin/ 8 | /node_modules 9 | /dist/tests 10 | /DEBUG/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tmp/ 2 | bower_components/ 3 | node_modules/ 4 | /.bundle 5 | ember-cli-build.js 6 | /testem.json 7 | /tests/ 8 | /lib/ 9 | /bench/ 10 | /dist/tests/ 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Launch Chrome against localhost, with sourcemaps", 6 | "type": "chrome", 7 | "request": "launch", 8 | "diagnosticLogging": true, 9 | "url": "http://localhost:4200/tests/", 10 | "sourceMaps": true, 11 | "webRoot": "${workspaceRoot}/dist", 12 | "sourceMapPathOverrides": { 13 | "route-recognizer.ts": "${workspaceRoot}/lib/route-recognizer.ts", 14 | "route-recognizer/*": "${workspaceRoot}/lib/route-recognizer/*", 15 | "tests/*": "${workspaceRoot}/tests/*" 16 | } 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "command": "npm", 6 | "tasks": [ 7 | { 8 | "label": "install", 9 | "type": "shell", 10 | "args": [ 11 | "install" 12 | ], 13 | "problemMatcher": [] 14 | }, 15 | { 16 | "label": "update", 17 | "type": "shell", 18 | "args": [ 19 | "update" 20 | ], 21 | "problemMatcher": [] 22 | }, 23 | { 24 | "label": "build", 25 | "type": "shell", 26 | "args": [ 27 | "run", 28 | "tsc" 29 | ], 30 | "problemMatcher": "$tsc", 31 | "group": { 32 | "_id": "build", 33 | "isDefault": false 34 | } 35 | }, 36 | { 37 | "label": "test", 38 | "type": "shell", 39 | "args": [ 40 | "run", 41 | "test" 42 | ], 43 | "problemMatcher": [], 44 | "group": { 45 | "_id": "test", 46 | "isDefault": false 47 | } 48 | } 49 | ] 50 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Yehuda Katz and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/tildeio/route-recognizer.svg)](https://travis-ci.org/tildeio/route-recognizer) 2 | 3 | # About 4 | `route-recognizer` is a lightweight JavaScript library (under 4kB gzipped!) that 5 | can be used as the recognizer for a more comprehensive router system 6 | (such as [`router.js`](https://github.com/tildeio/router.js)). 7 | 8 | In keeping with the Unix philosophy, it is a modular library that does one 9 | thing and does it well. 10 | 11 | # Usage 12 | 13 | Create a new router: 14 | 15 | ```javascript 16 | var router = new RouteRecognizer(); 17 | ``` 18 | 19 | Add a simple new route description: 20 | 21 | ```javascript 22 | router.add([{ path: "/posts", handler: handler }]); 23 | ``` 24 | 25 | Every route can optionally have a name: 26 | ```javascript 27 | router.add([{ path: "/posts", handler: handler }], { as: "routeName"}); 28 | ``` 29 | 30 | The handler is an opaque object with no specific meaning to 31 | `route-recognizer`. A module using `route-recognizer` could 32 | use functions or other objects with domain-specific semantics 33 | for what to do with the handler. 34 | 35 | A route description can have handlers at various points along 36 | the path: 37 | 38 | ```javascript 39 | router.add([ 40 | { path: "/admin", handler: admin }, 41 | { path: "/posts", handler: posts } 42 | ]); 43 | ``` 44 | 45 | Recognizing a route will return a list of the handlers and 46 | their associated parameters: 47 | 48 | ```javascript 49 | var result = router.recognize("/admin/posts"); 50 | result === [ 51 | { handler: admin, params: {} }, 52 | { handler: posts, params: {} } 53 | ]; 54 | ``` 55 | 56 | Dynamic segments: 57 | 58 | ```javascript 59 | router.add([ 60 | { path: "/posts/:id", handler: posts }, 61 | { path: "/comments", handler: comments } 62 | ]); 63 | 64 | result = router.recognize("/posts/1/comments"); 65 | result === [ 66 | { handler: posts, params: { id: "1" } }, 67 | { handler: comments, params: {} } 68 | ]; 69 | ``` 70 | 71 | A dynamic segment matches any character but `/`. 72 | 73 | Star segments: 74 | 75 | ```javascript 76 | router.add([{ path: "/pages/*path", handler: page }]); 77 | 78 | result = router.recognize("/pages/hello/world"); 79 | result === [{ handler: page, params: { path: "hello/world" } }]; 80 | ``` 81 | 82 | # Sorting 83 | 84 | If multiple routes all match a path, `route-recognizer` 85 | will pick the one with the fewest dynamic segments: 86 | 87 | ```javascript 88 | router.add([{ path: "/posts/edit", handler: editPost }]); 89 | router.add([{ path: "/posts/:id", handler: showPost }]); 90 | router.add([{ path: "/posts/new", handler: newPost }]); 91 | 92 | var result1 = router.recognize("/posts/edit"); 93 | result1 === [{ handler: editPost, params: {} }]; 94 | 95 | var result2 = router.recognize("/posts/1"); 96 | result2 === [{ handler: showPost, params: { id: "1" } }]; 97 | 98 | var result3 = router.recognize("/posts/new"); 99 | result3 === [{ handler: newPost, params: {} }]; 100 | ``` 101 | 102 | As you can see, this has the expected result. Explicit 103 | static paths match more closely than dynamic paths. 104 | 105 | This is also true when comparing star segments and other 106 | dynamic segments. The recognizer will prefer fewer star 107 | segments and prefer using them for less of the match (and, 108 | consequently, using dynamic and static segments for more 109 | of the match). 110 | 111 | # Building / Running Tests 112 | 113 | This project uses Ember CLI and Broccoli for building and testing. 114 | 115 | ## Getting Started 116 | 117 | Run the following commands to get going: 118 | 119 | ```bash 120 | npm install 121 | ``` 122 | 123 | ## Running Tests 124 | 125 | Run the following: 126 | 127 | ``` 128 | npm start 129 | ``` 130 | 131 | At this point you can navigate to the url specified in the Testem UI (usually 132 | http://localhost:7357/). As you change the project the tests will rerun. 133 | 134 | ## Building 135 | 136 | ``` 137 | npm run build 138 | ``` 139 | -------------------------------------------------------------------------------- /bench/benches/add.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | var RouteRecognizer = require("../../dist/route-recognizer"); 3 | 4 | var router = new RouteRecognizer(); 5 | function add() { 6 | var i = 1000; 7 | 8 | while (i--) { 9 | router.add([{ path: "/foo/" + i, handler: { handler: i } }]); 10 | } 11 | } 12 | 13 | module.exports = { 14 | name: "Add", 15 | fn: function() { 16 | add(); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /bench/benches/end-to-end.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | var RouteRecognizer = require("../../dist/route-recognizer"); 3 | 4 | module.exports = { 5 | name: "End-to-end", 6 | fn: function() { 7 | var router = new RouteRecognizer(); 8 | 9 | router.map(function(match) { 10 | var i = 1000; 11 | while (i--) { 12 | match("/posts/" + i).to("showPost" + i); 13 | } 14 | }); 15 | 16 | // Look up time is constant 17 | router.recognize("/posts/1"); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /bench/benches/generate.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | var RouteRecognizer = require("../../dist/route-recognizer"); 3 | 4 | var router = new RouteRecognizer(); 5 | var i = 1000; 6 | 7 | while (i--) { 8 | router.add([{ path: "/posts/:id", handler: {} }], { as: "post" + i }); 9 | } 10 | 11 | module.exports = { 12 | name: "Generate", 13 | fn: function() { 14 | router.generate("post1", { id: 1 }); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /bench/benches/handlers-for.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | var RouteRecognizer = require("../../dist/route-recognizer"); 3 | 4 | var router = new RouteRecognizer(); 5 | var i = 1000; 6 | 7 | while (i--) { 8 | router.add([{ path: "/foo/" + i, handler: { handler: i } }], { 9 | as: "foo" + i 10 | }); 11 | } 12 | 13 | module.exports = { 14 | name: "Handlers For", 15 | fn: function() { 16 | router.handlersFor("foo1"); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /bench/benches/normalize.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | var RouteRecognizer = require("../../dist/route-recognizer"); 3 | var Normalizer = RouteRecognizer.Normalizer; 4 | 5 | var paths = { 6 | complex: 7 | "/foo/" + 8 | encodeURIComponent("http://example.com/index.html?foo=bar&baz=faz#hashtag"), 9 | simple: "/post/123", 10 | medium: "/abc%3Adef" 11 | }; 12 | 13 | module.exports = [ 14 | { 15 | name: "Normalize Complex", 16 | fn: function() { 17 | Normalizer.normalizePath(paths.complex); 18 | } 19 | }, 20 | { 21 | name: "Normalize Simple", 22 | fn: function() { 23 | Normalizer.normalizePath(paths.simple); 24 | } 25 | }, 26 | { 27 | name: "Normalize Medium", 28 | fn: function() { 29 | Normalizer.normalizePath(paths.medium); 30 | } 31 | } 32 | ]; 33 | -------------------------------------------------------------------------------- /bench/benches/recognize.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | var RouteRecognizer = require("../../dist/route-recognizer"); 3 | 4 | var router = new RouteRecognizer(); 5 | var i = 1000; 6 | 7 | while (i--) { 8 | router.add([{ path: "/foo/" + i, handler: { handler: i } }]); 9 | } 10 | 11 | module.exports = { 12 | name: "Recognize", 13 | fn: function() { 14 | router.recognize("/foo/1"); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /bench/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | var glob = require("glob"); 3 | var path = require("path"); 4 | var bench = require("do-you-even-bench"); 5 | 6 | var suites = []; 7 | glob.sync("./bench/benches/*.js").forEach(function(file) { 8 | var exported = require(path.resolve(file)); 9 | if (Array.isArray(exported)) { 10 | suites = suites.concat(exported); 11 | } else { 12 | suites.push(exported); 13 | } 14 | }); 15 | 16 | bench(suites); 17 | -------------------------------------------------------------------------------- /config/environment.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | module.exports = function() { 4 | return { 5 | rootURL: "/dist" 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /ember-cli-build.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | const path = require("path"); 3 | const Rollup = require("broccoli-rollup"); 4 | const funnel = require("broccoli-funnel"); 5 | const merge = require("broccoli-merge-trees"); 6 | const replace = require("broccoli-string-replace"); 7 | const typescript = require("broccoli-typescript-compiler").default; 8 | const sourcemaps = require("rollup-plugin-sourcemaps"); 9 | const BroccoliPlugin = require("broccoli-plugin"); 10 | const fs = require("fs"); 11 | const TreeSync = require("tree-sync"); 12 | const glob = require("glob"); 13 | 14 | class JsImportFix extends BroccoliPlugin { 15 | constructor(input) { 16 | super([input], { 17 | persistentOutputFlag: true, 18 | needsCacheFlag: false 19 | }); 20 | } 21 | 22 | build() { 23 | const inputPath = this.inputPaths[0]; 24 | const outputPath = this.outputPath; 25 | if (this.treeSync === undefined) { 26 | this.treeSync = new TreeSync(inputPath, outputPath); 27 | } 28 | this.treeSync.sync(); 29 | glob.sync("**/*.js", { cwd: outputPath }).forEach(js => { 30 | const file = path.join(outputPath, js); 31 | let src = fs.readFileSync(file, "utf8"); 32 | src = src.replace(/(^import[^'"]+['"]\.[^'"]+)(['"])/gm, "$1.js$2"); 33 | fs.writeFileSync(file, src); 34 | }); 35 | } 36 | } 37 | 38 | const debugTree = require("broccoli-debug").buildDebugCallback( 39 | "route-recognizer" 40 | ); 41 | 42 | module.exports = function() { 43 | const libTs = replace( 44 | funnel("lib", { 45 | include: ["**/*.ts"], 46 | destDir: "lib" 47 | }), 48 | { 49 | files: ["lib/route-recognizer.ts"], 50 | pattern: { 51 | match: /VERSION_STRING_PLACEHOLDER/g, 52 | replacement: require("./package").version 53 | } 54 | } 55 | ); 56 | 57 | const testsTs = funnel("tests", { 58 | include: ["**/*.ts"], 59 | destDir: "tests" 60 | }); 61 | 62 | const ts = debugTree(merge([libTs, testsTs]), "ts"); 63 | 64 | const js = debugTree( 65 | new JsImportFix( 66 | typescript(ts, { 67 | annotation: "compile route-recognizer.ts", 68 | buildPath: "", 69 | workingPath: __dirname 70 | }) 71 | ), 72 | "compiled" 73 | ); 74 | 75 | // rollup needs the source for sourcesContent 76 | // and we need sourcesContent since broccoli 77 | // does not serve source 78 | const jsAndTs = merge([js, ts]); 79 | 80 | const rollup = debugTree( 81 | new Rollup(jsAndTs, { 82 | annotation: "route-recognizer.js", 83 | rollup: { 84 | input: "dist/lib/route-recognizer.js", 85 | plugins: [sourcemaps()], 86 | output: [ 87 | { 88 | file: "dist/route-recognizer.es.js", 89 | format: "es", 90 | sourcemap: true 91 | }, 92 | { 93 | file: "dist/route-recognizer.js", 94 | format: "umd", 95 | sourcemap: true, 96 | "amd.id": "route-recognizer", 97 | name: "RouteRecognizer" 98 | } 99 | ] 100 | } 101 | }), 102 | "rollup" 103 | ); 104 | 105 | const dist = funnel(merge([js, rollup]), { 106 | getDestinationPath(relative) { 107 | if (relative.startsWith("dist/")) { 108 | return relative.slice(5); 109 | } 110 | return relative; 111 | } 112 | }); 113 | 114 | return merge( 115 | [ 116 | dist, 117 | debugTree( 118 | funnel("tests", { 119 | annotation: "tests/index.html", 120 | files: ["index.html"], 121 | destDir: "tests" 122 | }), 123 | "testsIndex" 124 | ), 125 | debugTree( 126 | funnel(path.dirname(require.resolve("qunit")), { 127 | annotation: "tests/qunit.{js,css}", 128 | files: ["qunit.css", "qunit.js"], 129 | destDir: "tests" 130 | }), 131 | "qunit" 132 | ) 133 | ], 134 | { 135 | annotation: "dist" 136 | } 137 | ); 138 | }; 139 | -------------------------------------------------------------------------------- /lib/route-recognizer.ts: -------------------------------------------------------------------------------- 1 | import map, { Delegate, MatchCallback, Route } from "./route-recognizer/dsl"; 2 | import { 3 | encodePathSegment, 4 | normalizePath, 5 | normalizeSegment 6 | } from "./route-recognizer/normalizer"; 7 | import { createMap } from "./route-recognizer/util"; 8 | export { Delegate, MatchCallback } from "./route-recognizer/dsl"; 9 | 10 | const enum CHARS { 11 | ANY = -1, 12 | STAR = 42, 13 | SLASH = 47, 14 | COLON = 58 15 | } 16 | 17 | const escapeRegex = /(\/|\.|\*|\+|\?|\||\(|\)|\[|\]|\{|\}|\\)/g; 18 | 19 | const isArray = Array.isArray; 20 | // eslint-disable-next-line @typescript-eslint/unbound-method 21 | const hasOwnProperty = Object.prototype.hasOwnProperty; 22 | 23 | function getParam(params: Params | null | undefined, key: string): string { 24 | if (typeof params !== "object" || params === null) { 25 | throw new Error( 26 | "You must pass an object as the second argument to `generate`." 27 | ); 28 | } 29 | 30 | if (!hasOwnProperty.call(params, key)) { 31 | throw new Error("You must provide param `" + key + "` to `generate`."); 32 | } 33 | 34 | const value = params[key]; 35 | const str = typeof value === "string" ? value : "" + value; 36 | if (str.length === 0) { 37 | throw new Error("You must provide a param `" + key + "`."); 38 | } 39 | return str; 40 | } 41 | 42 | const enum SegmentType { 43 | Static = 0, 44 | Dynamic = 1, 45 | Star = 2, 46 | Epsilon = 4 47 | } 48 | 49 | const enum SegmentFlags { 50 | Static = SegmentType.Static, 51 | Dynamic = SegmentType.Dynamic, 52 | Star = SegmentType.Star, 53 | Epsilon = SegmentType.Epsilon, 54 | Named = Dynamic | Star, 55 | Decoded = Dynamic, 56 | Counted = Static | Dynamic | Star 57 | } 58 | 59 | type Counted = SegmentType.Static | SegmentType.Dynamic | SegmentType.Star; 60 | 61 | const eachChar: (( 62 | segment: Segment, 63 | currentState: State 64 | ) => State)[] = []; 65 | eachChar[SegmentType.Static] = function( 66 | segment: Segment, 67 | currentState: State 68 | ) { 69 | let state = currentState; 70 | const value = segment.value; 71 | for (let i = 0; i < value.length; i++) { 72 | const ch = value.charCodeAt(i); 73 | state = state.put(ch, false, false); 74 | } 75 | return state; 76 | }; 77 | eachChar[SegmentType.Dynamic] = function( 78 | _: Segment, 79 | currentState: State 80 | ) { 81 | return currentState.put(CHARS.SLASH, true, true); 82 | }; 83 | eachChar[SegmentType.Star] = function( 84 | _: Segment, 85 | currentState: State 86 | ) { 87 | return currentState.put(CHARS.ANY, false, true); 88 | }; 89 | eachChar[SegmentType.Epsilon] = function( 90 | _: Segment, 91 | currentState: State 92 | ) { 93 | return currentState; 94 | }; 95 | 96 | const regex: ((segment: Segment) => string)[] = []; 97 | regex[SegmentType.Static] = function(segment: Segment) { 98 | return segment.value.replace(escapeRegex, "\\$1"); 99 | }; 100 | regex[SegmentType.Dynamic] = function() { 101 | return "([^/]+)"; 102 | }; 103 | regex[SegmentType.Star] = function() { 104 | return "(.+)"; 105 | }; 106 | regex[SegmentType.Epsilon] = function() { 107 | return ""; 108 | }; 109 | 110 | const generate: (( 111 | segment: Segment, 112 | params?: Params | null, 113 | shouldEncode?: boolean 114 | ) => string)[] = []; 115 | generate[SegmentType.Static] = function(segment: Segment) { 116 | return segment.value; 117 | }; 118 | generate[SegmentType.Dynamic] = function( 119 | segment: Segment, 120 | params?: Params | null, 121 | shouldEncode?: boolean 122 | ) { 123 | const value = getParam(params, segment.value); 124 | if (shouldEncode) { 125 | return encodePathSegment(value); 126 | } else { 127 | return value; 128 | } 129 | }; 130 | generate[SegmentType.Star] = function( 131 | segment: Segment, 132 | params?: Params | null 133 | ) { 134 | return getParam(params, segment.value); 135 | }; 136 | generate[SegmentType.Epsilon] = function() { 137 | return ""; 138 | }; 139 | 140 | // A Segment represents a segment in the original route description. 141 | // Each Segment type provides an `eachChar` and `regex` method. 142 | // 143 | // The `eachChar` method invokes the callback with one or more character 144 | // specifications. A character specification consumes one or more input 145 | // characters. 146 | // 147 | // The `regex` method returns a regex fragment for the segment. If the 148 | // segment is a dynamic of star segment, the regex fragment also includes 149 | // a capture. 150 | // 151 | // A character specification contains: 152 | // 153 | // * `validChars`: a String with a list of all valid characters, or 154 | // * `invalidChars`: a String with a list of all invalid characters 155 | // * `repeat`: true if the character specification can repeat 156 | interface Segment { 157 | type: SegmentType; 158 | value: string; 159 | } 160 | 161 | export interface Params { 162 | [key: string]: unknown; 163 | [key: number]: unknown; 164 | queryParams?: { 165 | [key: string]: unknown; 166 | [key: number]: unknown; 167 | } | null; 168 | } 169 | 170 | interface ParsedHandler { 171 | names: string[]; 172 | shouldDecodes: boolean[]; 173 | } 174 | 175 | const EmptyObject = Object.freeze({}); 176 | type EmptyObject = typeof EmptyObject; 177 | 178 | const EmptyArray = Object.freeze([]) as ReadonlyArray; 179 | type EmptyArray = typeof EmptyArray; 180 | 181 | // The `names` will be populated with the paramter name for each dynamic/star 182 | // segment. `shouldDecodes` will be populated with a boolean for each dyanamic/star 183 | // segment, indicating whether it should be decoded during recognition. 184 | function parse( 185 | segments: Segment[], 186 | route: string, 187 | types: [number, number, number] 188 | ): ParsedHandler { 189 | // normalize route as not starting with a "/". Recognition will 190 | // also normalize. 191 | if (route.length > 0 && route.charCodeAt(0) === CHARS.SLASH) { 192 | route = route.substr(1); 193 | } 194 | 195 | const parts = route.split("/"); 196 | let names: undefined | string[] = undefined; 197 | let shouldDecodes: undefined | boolean[] = undefined; 198 | 199 | for (let i = 0; i < parts.length; i++) { 200 | let part = parts[i]; 201 | let type: SegmentType = 0; 202 | 203 | if (part === "") { 204 | type = SegmentType.Epsilon; 205 | } else if (part.charCodeAt(0) === CHARS.COLON) { 206 | type = SegmentType.Dynamic; 207 | } else if (part.charCodeAt(0) === CHARS.STAR) { 208 | type = SegmentType.Star; 209 | } else { 210 | type = SegmentType.Static; 211 | } 212 | 213 | if (type & SegmentFlags.Named) { 214 | part = part.slice(1); 215 | names = names || []; 216 | names.push(part); 217 | 218 | shouldDecodes = shouldDecodes || []; 219 | shouldDecodes.push((type & SegmentFlags.Decoded) !== 0); 220 | } 221 | 222 | if (type & SegmentFlags.Counted) { 223 | types[type as Counted]++; 224 | } 225 | 226 | segments.push({ 227 | type, 228 | value: normalizeSegment(part) 229 | }); 230 | } 231 | 232 | return { 233 | names: names || EmptyArray, 234 | shouldDecodes: shouldDecodes || EmptyArray 235 | } as ParsedHandler; 236 | } 237 | 238 | function isEqualCharSpec( 239 | spec: CharSpec, 240 | char: number, 241 | negate: boolean 242 | ): boolean { 243 | return spec.char === char && spec.negate === negate; 244 | } 245 | 246 | interface Handler { 247 | handler: THandler; 248 | names: string[]; 249 | shouldDecodes: boolean[]; 250 | } 251 | 252 | // A State has a character specification and (`charSpec`) and a list of possible 253 | // subsequent states (`nextStates`). 254 | // 255 | // If a State is an accepting state, it will also have several additional 256 | // properties: 257 | // 258 | // * `regex`: A regular expression that is used to extract parameters from paths 259 | // that reached this accepting state. 260 | // * `handlers`: Information on how to convert the list of captures into calls 261 | // to registered handlers with the specified parameters 262 | // * `types`: How many static, dynamic or star segments in this route. Used to 263 | // decide which route to use if multiple registered routes match a path. 264 | // 265 | // Currently, State is implemented naively by looping over `nextStates` and 266 | // comparing a character specification against a character. A more efficient 267 | // implementation would use a hash of keys pointing at one or more next states. 268 | class State implements CharSpec { 269 | states: State[]; 270 | id: number; 271 | negate: boolean; 272 | char: number; 273 | nextStates: number[] | number | null; 274 | pattern: string; 275 | _regex: RegExp | undefined; 276 | handlers: Handler[] | undefined; 277 | types: [number, number, number] | undefined; 278 | 279 | constructor( 280 | states: State[], 281 | id: number, 282 | char: number, 283 | negate: boolean, 284 | repeat: boolean 285 | ) { 286 | this.states = states; 287 | this.id = id; 288 | this.char = char; 289 | this.negate = negate; 290 | this.nextStates = repeat ? id : null; 291 | this.pattern = ""; 292 | this._regex = undefined; 293 | this.handlers = undefined; 294 | this.types = undefined; 295 | } 296 | 297 | regex(): RegExp { 298 | if (!this._regex) { 299 | this._regex = new RegExp(this.pattern); 300 | } 301 | return this._regex; 302 | } 303 | 304 | get(char: number, negate: boolean): State | void { 305 | const nextStates = this.nextStates; 306 | if (nextStates === null) return; 307 | if (isArray(nextStates)) { 308 | for (let i = 0; i < nextStates.length; i++) { 309 | const child = this.states[nextStates[i]]; 310 | if (isEqualCharSpec(child, char, negate)) { 311 | return child; 312 | } 313 | } 314 | } else { 315 | const child = this.states[nextStates]; 316 | if (isEqualCharSpec(child, char, negate)) { 317 | return child; 318 | } 319 | } 320 | } 321 | 322 | put(char: number, negate: boolean, repeat: boolean): State { 323 | let state: State | void; 324 | 325 | // If the character specification already exists in a child of the current 326 | // state, just return that state. 327 | if ((state = this.get(char, negate))) { 328 | return state; 329 | } 330 | 331 | // Make a new state for the character spec 332 | const states = this.states; 333 | state = new State(states, states.length, char, negate, repeat); 334 | states[states.length] = state; 335 | 336 | // Insert the new state as a child of the current state 337 | if (this.nextStates == null) { 338 | this.nextStates = state.id; 339 | } else if (isArray(this.nextStates)) { 340 | this.nextStates.push(state.id); 341 | } else { 342 | this.nextStates = [this.nextStates, state.id]; 343 | } 344 | 345 | // Return the new state 346 | return state; 347 | } 348 | 349 | // Find a list of child states matching the next character 350 | match(ch: number): State[] { 351 | const nextStates = this.nextStates; 352 | if (!nextStates) return []; 353 | 354 | const returned: State[] = []; 355 | if (isArray(nextStates)) { 356 | for (let i = 0; i < nextStates.length; i++) { 357 | const child = this.states[nextStates[i]]; 358 | 359 | if (isMatch(child, ch)) { 360 | returned.push(child); 361 | } 362 | } 363 | } else { 364 | const child = this.states[nextStates]; 365 | if (isMatch(child, ch)) { 366 | returned.push(child); 367 | } 368 | } 369 | return returned; 370 | } 371 | } 372 | 373 | function isMatch(spec: CharSpec, char: number): boolean { 374 | return spec.negate 375 | ? spec.char !== char && spec.char !== CHARS.ANY 376 | : spec.char === char || spec.char === CHARS.ANY; 377 | } 378 | 379 | // This is a somewhat naive strategy, but should work in a lot of cases 380 | // A better strategy would properly resolve /posts/:id/new and /posts/edit/:id. 381 | // 382 | // This strategy generally prefers more static and less dynamic matching. 383 | // Specifically, it 384 | // 385 | // * prefers fewer stars to more, then 386 | // * prefers using stars for less of the match to more, then 387 | // * prefers fewer dynamic segments to more, then 388 | // * prefers more static segments to more 389 | function sortSolutions(states: State[]): State[] { 390 | return states.sort(function(a, b) { 391 | const [astatics, adynamics, astars] = a.types || [0, 0, 0]; 392 | const [bstatics, bdynamics, bstars] = b.types || [0, 0, 0]; 393 | if (astars !== bstars) { 394 | return astars - bstars; 395 | } 396 | 397 | if (astars) { 398 | if (astatics !== bstatics) { 399 | return bstatics - astatics; 400 | } 401 | if (adynamics !== bdynamics) { 402 | return bdynamics - adynamics; 403 | } 404 | } 405 | 406 | if (adynamics !== bdynamics) { 407 | return adynamics - bdynamics; 408 | } 409 | if (astatics !== bstatics) { 410 | return bstatics - astatics; 411 | } 412 | 413 | return 0; 414 | }); 415 | } 416 | 417 | function recognizeChar( 418 | states: State[], 419 | ch: number 420 | ): State[] { 421 | let nextStates: State[] = []; 422 | 423 | for (let i = 0, l = states.length; i < l; i++) { 424 | const state = states[i]; 425 | 426 | nextStates = nextStates.concat(state.match(ch)); 427 | } 428 | 429 | return nextStates; 430 | } 431 | 432 | export interface QueryParams { 433 | [param: string]: string[] | string | null | undefined; 434 | } 435 | 436 | export interface Result { 437 | handler: THandler; 438 | params: Params; 439 | isDynamic: boolean; 440 | } 441 | 442 | export interface Results extends ArrayLike> { 443 | queryParams: QueryParams; 444 | slice(start?: number, end?: number): Result[]; 445 | splice( 446 | start: number, 447 | deleteCount: number, 448 | ...items: Result[] 449 | ): Result[]; 450 | push(...results: Result[]): number; 451 | } 452 | 453 | class RecognizeResults implements Results { 454 | queryParams: QueryParams; 455 | length = 0; 456 | [index: number]: Result; 457 | splice!: ( 458 | start: number, 459 | deleteCount: number, 460 | ...items: Result[] 461 | ) => Result[]; 462 | slice!: (start?: number, end?: number) => Result[]; 463 | push!: (...results: Result[]) => number; 464 | 465 | constructor(queryParams?: QueryParams) { 466 | this.queryParams = queryParams || {}; 467 | } 468 | } 469 | 470 | // eslint-disable-next-line @typescript-eslint/unbound-method 471 | RecognizeResults.prototype.splice = Array.prototype.splice; 472 | // eslint-disable-next-line @typescript-eslint/unbound-method 473 | RecognizeResults.prototype.slice = Array.prototype.slice; 474 | // eslint-disable-next-line @typescript-eslint/unbound-method 475 | RecognizeResults.prototype.push = Array.prototype.push; 476 | 477 | function findHandler( 478 | state: State, 479 | originalPath: string, 480 | queryParams: QueryParams, 481 | shouldDecode: boolean 482 | ): Results { 483 | const handlers = state.handlers; 484 | const regex: RegExp = state.regex(); 485 | if (!regex || !handlers) throw new Error("state not initialized"); 486 | const captures: RegExpMatchArray | null = regex.exec(originalPath); 487 | let currentCapture = 1; 488 | const result = new RecognizeResults(queryParams); 489 | 490 | result.length = handlers.length; 491 | 492 | for (let i = 0; i < handlers.length; i++) { 493 | const handler = handlers[i]; 494 | const names = handler.names; 495 | const shouldDecodes = handler.shouldDecodes; 496 | let params: Params = EmptyObject; 497 | 498 | let isDynamic = false; 499 | 500 | if (names !== (EmptyArray as string[]) && shouldDecodes !== EmptyArray) { 501 | for (let j = 0; j < names.length; j++) { 502 | isDynamic = true; 503 | const name = names[j]; 504 | const capture = captures && captures[currentCapture++]; 505 | 506 | if (params === EmptyObject) { 507 | params = {}; 508 | } 509 | 510 | if (shouldDecode && shouldDecodes[j]) { 511 | params[name] = capture && decodeURIComponent(capture); 512 | } else { 513 | params[name] = capture; 514 | } 515 | } 516 | } 517 | 518 | result[i] = { 519 | handler: handler.handler, 520 | params, 521 | isDynamic 522 | }; 523 | } 524 | 525 | return result; 526 | } 527 | 528 | function decodeQueryParamPart(part: string): string { 529 | // http://www.w3.org/TR/html401/interact/forms.html#h-17.13.4.1 530 | part = part.replace(/\+/gm, "%20"); 531 | let result; 532 | try { 533 | result = decodeURIComponent(part); 534 | } catch (error) { 535 | result = ""; 536 | } 537 | return result; 538 | } 539 | 540 | interface NamedRoute { 541 | segments: Segment[]; 542 | handlers: Handler[]; 543 | } 544 | 545 | class RouteRecognizer { 546 | private rootState: State; 547 | private names: { 548 | [name: string]: NamedRoute | undefined; 549 | } = createMap>(); 550 | map!: ( 551 | context: MatchCallback, 552 | addCallback?: (router: this, routes: Route[]) => void 553 | ) => void; 554 | delegate: Delegate | undefined; 555 | 556 | constructor() { 557 | const states: State[] = []; 558 | const state = new State(states, 0, CHARS.ANY, true, false); 559 | states[0] = state; 560 | this.rootState = state; 561 | } 562 | 563 | static VERSION = "VERSION_STRING_PLACEHOLDER"; 564 | // Set to false to opt-out of encoding and decoding path segments. 565 | // See https://github.com/tildeio/route-recognizer/pull/55 566 | static ENCODE_AND_DECODE_PATH_SEGMENTS = true; 567 | static Normalizer = { 568 | normalizeSegment, 569 | normalizePath, 570 | encodePathSegment 571 | }; 572 | 573 | add(routes: Route[], options?: { as: string }): void { 574 | let currentState = this.rootState; 575 | let pattern = "^"; 576 | const types: [number, number, number] = [0, 0, 0]; 577 | const handlers: Handler[] = new Array(routes.length); 578 | const allSegments: Segment[] = []; 579 | 580 | let isEmpty = true; 581 | let j = 0; 582 | for (let i = 0; i < routes.length; i++) { 583 | const route = routes[i]; 584 | const { names, shouldDecodes } = parse(allSegments, route.path, types); 585 | 586 | // preserve j so it points to the start of newly added segments 587 | for (; j < allSegments.length; j++) { 588 | const segment = allSegments[j]; 589 | 590 | if (segment.type === SegmentType.Epsilon) { 591 | continue; 592 | } 593 | 594 | isEmpty = false; 595 | 596 | // Add a "/" for the new segment 597 | currentState = currentState.put(CHARS.SLASH, false, false); 598 | pattern += "/"; 599 | 600 | // Add a representation of the segment to the NFA and regex 601 | currentState = eachChar[segment.type](segment, currentState); 602 | pattern += regex[segment.type](segment); 603 | } 604 | handlers[i] = { 605 | handler: route.handler, 606 | names, 607 | shouldDecodes 608 | }; 609 | } 610 | 611 | if (isEmpty) { 612 | currentState = currentState.put(CHARS.SLASH, false, false); 613 | pattern += "/"; 614 | } 615 | 616 | currentState.handlers = handlers; 617 | currentState.pattern = pattern + "$"; 618 | currentState.types = types; 619 | 620 | let name: string | undefined; 621 | if (typeof options === "object" && options !== null && options.as) { 622 | name = options.as; 623 | } 624 | 625 | if (name) { 626 | // if (this.names[name]) { 627 | // throw new Error("You may not add a duplicate route named `" + name + "`."); 628 | // } 629 | 630 | this.names[name] = { 631 | segments: allSegments, 632 | handlers 633 | }; 634 | } 635 | } 636 | 637 | handlersFor(name: string): Handler[] { 638 | const route = this.names[name]; 639 | 640 | if (!route) { 641 | throw new Error("There is no route named " + name); 642 | } 643 | 644 | const result: Handler[] = new Array(route.handlers.length); 645 | 646 | for (let i = 0; i < route.handlers.length; i++) { 647 | const handler = route.handlers[i]; 648 | result[i] = handler; 649 | } 650 | 651 | return result; 652 | } 653 | 654 | hasRoute(name: string): boolean { 655 | return !!this.names[name]; 656 | } 657 | 658 | generate(name: string, params?: Params | null): string { 659 | const shouldEncode = RouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS; 660 | const route = this.names[name]; 661 | let output = ""; 662 | if (!route) { 663 | throw new Error("There is no route named " + name); 664 | } 665 | 666 | const segments: Segment[] = route.segments; 667 | 668 | for (let i = 0; i < segments.length; i++) { 669 | const segment: Segment = segments[i]; 670 | 671 | if (segment.type === SegmentType.Epsilon) { 672 | continue; 673 | } 674 | 675 | output += "/"; 676 | output += generate[segment.type](segment, params, shouldEncode); 677 | } 678 | 679 | if (!output.startsWith("/")) { 680 | output = "/" + output; 681 | } 682 | 683 | if (params && params.queryParams) { 684 | output += this.generateQueryString(params.queryParams); 685 | } 686 | 687 | return output; 688 | } 689 | 690 | generateQueryString(params: Params): string { 691 | const pairs: string[] = []; 692 | const keys: string[] = Object.keys(params); 693 | keys.sort(); 694 | for (let i = 0; i < keys.length; i++) { 695 | const key = keys[i]; 696 | const value = params[key]; 697 | if (value == null) { 698 | continue; 699 | } 700 | let pair = encodeURIComponent(key); 701 | if (isArray(value)) { 702 | for (let j = 0; j < value.length; j++) { 703 | const arrayPair = key + "[]" + "=" + encodeURIComponent(value[j]); 704 | pairs.push(arrayPair); 705 | } 706 | } else { 707 | pair += "=" + encodeURIComponent(value as string); 708 | pairs.push(pair); 709 | } 710 | } 711 | 712 | if (pairs.length === 0) { 713 | return ""; 714 | } 715 | 716 | return "?" + pairs.join("&"); 717 | } 718 | 719 | parseQueryString(queryString: string): QueryParams { 720 | const pairs = queryString.split("&"); 721 | const queryParams: QueryParams = {}; 722 | for (let i = 0; i < pairs.length; i++) { 723 | const pair = pairs[i].split("="); 724 | let key = decodeQueryParamPart(pair[0]); 725 | const keyLength = key.length; 726 | let isArray = false; 727 | let value: string; 728 | if (pair.length === 1) { 729 | value = "true"; 730 | } else { 731 | // Handle arrays 732 | if (keyLength > 2 && key.endsWith("[]")) { 733 | isArray = true; 734 | key = key.slice(0, keyLength - 2); 735 | if (!queryParams[key]) { 736 | queryParams[key] = []; 737 | } 738 | } 739 | value = pair[1] ? decodeQueryParamPart(pair[1]) : ""; 740 | } 741 | if (isArray) { 742 | (queryParams[key] as string[]).push(value); 743 | } else { 744 | queryParams[key] = value; 745 | } 746 | } 747 | return queryParams; 748 | } 749 | 750 | recognize(path: string): Results | undefined { 751 | const shouldNormalize = RouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS; 752 | let results: Results | undefined; 753 | let states: State[] = [this.rootState]; 754 | let queryParams = {}; 755 | let isSlashDropped = false; 756 | const hashStart = path.indexOf("#"); 757 | if (hashStart !== -1) { 758 | path = path.substr(0, hashStart); 759 | } 760 | 761 | const queryStart = path.indexOf("?"); 762 | if (queryStart !== -1) { 763 | const queryString = path.substr(queryStart + 1, path.length); 764 | path = path.substr(0, queryStart); 765 | queryParams = this.parseQueryString(queryString); 766 | } 767 | 768 | if (!path.startsWith("/")) { 769 | path = "/" + path; 770 | } 771 | let originalPath = path; 772 | 773 | if (shouldNormalize) { 774 | path = normalizePath(path); 775 | } else { 776 | path = decodeURI(path); 777 | originalPath = decodeURI(originalPath); 778 | } 779 | 780 | const pathLen = path.length; 781 | if (pathLen > 1 && path.charAt(pathLen - 1) === "/") { 782 | path = path.substr(0, pathLen - 1); 783 | originalPath = originalPath.substr(0, originalPath.length - 1); 784 | isSlashDropped = true; 785 | } 786 | 787 | for (let i = 0; i < path.length; i++) { 788 | states = recognizeChar(states, path.charCodeAt(i)); 789 | if (!states.length) { 790 | break; 791 | } 792 | } 793 | 794 | const solutions: State[] = []; 795 | for (let i = 0; i < states.length; i++) { 796 | if (states[i].handlers) { 797 | solutions.push(states[i]); 798 | } 799 | } 800 | 801 | states = sortSolutions(solutions); 802 | 803 | const state = solutions[0]; 804 | 805 | if (state && state.handlers) { 806 | // if a trailing slash was dropped and a star segment is the last segment 807 | // specified, put the trailing slash back 808 | if (isSlashDropped && state.char === CHARS.ANY) { 809 | originalPath = originalPath + "/"; 810 | } 811 | results = findHandler(state, originalPath, queryParams, shouldNormalize); 812 | } 813 | 814 | return results; 815 | } 816 | } 817 | 818 | RouteRecognizer.prototype.map = map; 819 | 820 | export default RouteRecognizer; 821 | 822 | interface CharSpec { 823 | negate: boolean; 824 | char: number; 825 | } 826 | -------------------------------------------------------------------------------- /lib/route-recognizer/dsl.ts: -------------------------------------------------------------------------------- 1 | import { createMap } from "./util"; 2 | 3 | export interface Delegate { 4 | contextEntered?(context: THandler, route: MatchDSL): void; 5 | willAddRoute?(context: THandler | undefined, route: THandler): THandler; 6 | } 7 | 8 | export interface Route { 9 | path: string; 10 | handler: THandler; 11 | queryParams?: string[]; 12 | } 13 | 14 | export interface RouteRecognizer { 15 | delegate: Delegate | undefined; 16 | add(routes: Route[]): void; 17 | } 18 | 19 | export type MatchCallback = (match: MatchDSL) => void; 20 | 21 | export interface MatchDSL { 22 | (path: string): ToDSL; 23 | (path: string, callback: MatchCallback): void; 24 | } 25 | 26 | export interface ToDSL { 27 | to(name: THandler, callback?: MatchCallback): void; 28 | } 29 | 30 | class Target implements ToDSL { 31 | path: string; 32 | matcher: Matcher; 33 | delegate: Delegate | undefined; 34 | 35 | constructor( 36 | path: string, 37 | matcher: Matcher, 38 | delegate: Delegate | undefined 39 | ) { 40 | this.path = path; 41 | this.matcher = matcher; 42 | this.delegate = delegate; 43 | } 44 | 45 | to(target: THandler, callback: MatchCallback): void { 46 | const delegate = this.delegate; 47 | 48 | if (delegate && delegate.willAddRoute) { 49 | target = delegate.willAddRoute(this.matcher.target, target); 50 | } 51 | 52 | this.matcher.add(this.path, target); 53 | 54 | if (callback) { 55 | if (callback.length === 0) { 56 | throw new Error( 57 | "You must have an argument in the function passed to `to`" 58 | ); 59 | } 60 | this.matcher.addChild(this.path, target, callback, this.delegate); 61 | } 62 | } 63 | } 64 | 65 | export class Matcher { 66 | routes: { 67 | [path: string]: THandler | undefined; 68 | }; 69 | children: { 70 | [path: string]: Matcher | undefined; 71 | }; 72 | target: THandler | undefined; 73 | 74 | constructor(target?: THandler) { 75 | this.routes = createMap(); 76 | this.children = createMap>(); 77 | this.target = target; 78 | } 79 | 80 | add(path: string, target: THandler): void { 81 | this.routes[path] = target; 82 | } 83 | 84 | addChild( 85 | path: string, 86 | target: THandler, 87 | callback: MatchCallback, 88 | delegate: Delegate | undefined 89 | ): void { 90 | const matcher = new Matcher(target); 91 | this.children[path] = matcher; 92 | 93 | const match = generateMatch(path, matcher, delegate); 94 | 95 | if (delegate && delegate.contextEntered) { 96 | delegate.contextEntered(target, match); 97 | } 98 | 99 | callback(match); 100 | } 101 | } 102 | 103 | function generateMatch( 104 | startingPath: string, 105 | matcher: Matcher, 106 | delegate: Delegate | undefined 107 | ): MatchDSL { 108 | function match(path: string): ToDSL; 109 | function match(path: string, callback: MatchCallback): void; 110 | function match( 111 | path: string, 112 | callback?: MatchCallback 113 | ): ToDSL | void { 114 | const fullPath = startingPath + path; 115 | if (callback) { 116 | callback(generateMatch(fullPath, matcher, delegate)); 117 | } else { 118 | return new Target(fullPath, matcher, delegate); 119 | } 120 | } 121 | return match; 122 | } 123 | 124 | function addRoute( 125 | routeArray: Route[], 126 | path: string, 127 | handler: THandler 128 | ): void { 129 | let len = 0; 130 | for (let i = 0; i < routeArray.length; i++) { 131 | len += routeArray[i].path.length; 132 | } 133 | 134 | path = path.substr(len); 135 | const route = { path, handler }; 136 | routeArray.push(route); 137 | } 138 | 139 | function eachRoute( 140 | baseRoute: Route[], 141 | matcher: Matcher, 142 | callback: (this: TThis, routes: Route[]) => void, 143 | binding: TThis 144 | ): void { 145 | const routes = matcher.routes; 146 | const paths = Object.keys(routes); 147 | for (let i = 0; i < paths.length; i++) { 148 | const path = paths[i]; 149 | const routeArray = baseRoute.slice(); 150 | addRoute(routeArray, path, routes[path]); 151 | const nested = matcher.children[path]; 152 | if (nested) { 153 | eachRoute(routeArray, nested, callback, binding); 154 | } else { 155 | callback.call(binding, routeArray); 156 | } 157 | } 158 | } 159 | 160 | export default function map< 161 | TRouteRecognizer extends RouteRecognizer, 162 | THandler 163 | >( 164 | this: TRouteRecognizer, 165 | callback: MatchCallback, 166 | addRouteCallback?: ( 167 | routeRecognizer: TRouteRecognizer, 168 | routes: Route[] 169 | ) => void 170 | ): void { 171 | const matcher = new Matcher(); 172 | 173 | callback(generateMatch("", matcher, this.delegate)); 174 | 175 | eachRoute( 176 | [], 177 | matcher, 178 | function(routes: Route[]) { 179 | if (addRouteCallback) { 180 | addRouteCallback(this, routes); 181 | } else { 182 | this.add(routes); 183 | } 184 | }, 185 | this 186 | ); 187 | } 188 | -------------------------------------------------------------------------------- /lib/route-recognizer/normalizer.ts: -------------------------------------------------------------------------------- 1 | // Normalizes percent-encoded values in `path` to upper-case and decodes percent-encoded 2 | // values that are not reserved (i.e., unicode characters, emoji, etc). The reserved 3 | // chars are "/" and "%". 4 | // Safe to call multiple times on the same path. 5 | export function normalizePath(path: string): string { 6 | return path 7 | .split("/") 8 | .map(normalizeSegment) 9 | .join("/"); 10 | } 11 | 12 | // We want to ensure the characters "%" and "/" remain in percent-encoded 13 | // form when normalizing paths, so replace them with their encoded form after 14 | // decoding the rest of the path 15 | const SEGMENT_RESERVED_CHARS = /%|\//g; 16 | export function normalizeSegment(segment: string): string { 17 | if (segment.length < 3 || segment.indexOf("%") === -1) return segment; 18 | return decodeURIComponent(segment).replace( 19 | SEGMENT_RESERVED_CHARS, 20 | encodeURIComponent 21 | ); 22 | } 23 | 24 | // We do not want to encode these characters when generating dynamic path segments 25 | // See https://tools.ietf.org/html/rfc3986#section-3.3 26 | // sub-delims: "!", "$", "&", "'", "(", ")", "*", "+", ",", ";", "=" 27 | // others allowed by RFC 3986: ":", "@" 28 | // 29 | // First encode the entire path segment, then decode any of the encoded special chars. 30 | // 31 | // The chars "!", "'", "(", ")", "*" do not get changed by `encodeURIComponent`, 32 | // so the possible encoded chars are: 33 | // ['%24', '%26', '%2B', '%2C', '%3B', '%3D', '%3A', '%40']. 34 | const PATH_SEGMENT_ENCODINGS = /%(?:2(?:4|6|B|C)|3(?:B|D|A)|40)/g; 35 | 36 | export function encodePathSegment(str: string): string { 37 | return encodeURIComponent(str).replace( 38 | PATH_SEGMENT_ENCODINGS, 39 | decodeURIComponent 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /lib/route-recognizer/util.ts: -------------------------------------------------------------------------------- 1 | const createObject = Object.create; 2 | 3 | export interface MapLike { 4 | [key: string]: T | undefined; 5 | } 6 | 7 | export function createMap(): MapLike { 8 | const map: MapLike = createObject(null); 9 | map["__"] = undefined; 10 | delete map["__"]; 11 | return map; 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "route-recognizer", 3 | "version": "0.3.4", 4 | "description": "A lightweight JavaScript library that matches paths against registered routes.", 5 | "homepage": "https://github.com/tildeio/route-recognizer", 6 | "bugs": { 7 | "url": "https://github.com/tildeio/route-recognizer/issues" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/tildeio/route-recognizer.git" 12 | }, 13 | "license": "MIT", 14 | "author": "Yehuda Katz", 15 | "files": [ 16 | "dist/*", 17 | "!dist/tests" 18 | ], 19 | "main": "dist/route-recognizer.js", 20 | "module": "dist/route-recognizer.es.js", 21 | "types": "dist/route-recognizer.d.ts", 22 | "scripts": { 23 | "bench": "ember build && node ./bench/index.js", 24 | "build": "ember build", 25 | "lintfix": "eslint --ext --fix .js,.ts .", 26 | "lint": "eslint --ext .js,.ts .", 27 | "prepublish": "ember build --environment=production", 28 | "start": "ember test --server", 29 | "test": "ember test", 30 | "tsc": "tsc" 31 | }, 32 | "devDependencies": { 33 | "@types/node": "^13.7.0", 34 | "@types/qunit": "^2.9.0", 35 | "@typescript-eslint/eslint-plugin": "^2.10.0", 36 | "@typescript-eslint/parser": "^2.10.0", 37 | "broccoli-debug": "^0.6.5", 38 | "broccoli-funnel": "^2", 39 | "broccoli-merge-trees": "^4.1.0", 40 | "broccoli-plugin": "^4.0.1", 41 | "broccoli-rollup": "^4.1.1", 42 | "broccoli-string-replace": "^0.1.1", 43 | "broccoli-typescript-compiler": "^4.2.0", 44 | "ember-cli": "^3.15.2", 45 | "eslint": "^6.7.2", 46 | "eslint-config-prettier": "^6.7.0", 47 | "eslint-plugin-import": "^2.18.2", 48 | "eslint-plugin-prettier": "^3.1.1", 49 | "eslint-plugin-simple-import-sort": "^5.0.0", 50 | "express": "^4.17.1", 51 | "glob": "^7.1.6", 52 | "prettier": "^1.19.1", 53 | "qunit": "^2.9.3", 54 | "rollup": "^1.31.0", 55 | "rollup-plugin-sourcemaps": "^0.5.0", 56 | "tree-sync": "^2.0.0", 57 | "typescript": "^3.5.3" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | const express = require("express"); 3 | const path = require("path"); 4 | module.exports = function(app) { 5 | app.use("/lib", express.static(path.join(__dirname, "..", "lib"))); 6 | app.get("/", (req, res) => res.redirect("/dist/tests/")); 7 | }; 8 | -------------------------------------------------------------------------------- /testem.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | test_page: "tests/index.html?hidepassed", 4 | disable_watching: true, 5 | launch_in_ci: ["Chrome"], 6 | launch_in_dev: ["Chrome"], 7 | browser_args: { 8 | Chrome: { 9 | ci: [ 10 | // --no-sandbox is needed when running Chrome inside a container 11 | process.env.CI ? "--no-sandbox" : null, 12 | "--headless", 13 | "--disable-dev-shm-usage", 14 | "--mute-audio", 15 | "--remote-debugging-port=0", 16 | "--window-size=1440,900" 17 | ].filter(Boolean) 18 | } 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | QUnit Example 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/normalizer-tests.ts: -------------------------------------------------------------------------------- 1 | /* globals QUnit */ 2 | 3 | import RouteRecognizer from "../lib/route-recognizer"; 4 | 5 | const Normalizer = RouteRecognizer.Normalizer; 6 | 7 | QUnit.module("Normalization"); 8 | 9 | const expectations = [ 10 | { 11 | paths: ["/foo/bar"], 12 | normalized: "/foo/bar" 13 | }, 14 | { 15 | paths: ["/foo%3Abar", "/foo%3abar"], 16 | normalized: "/foo:bar" 17 | }, 18 | { 19 | paths: ["/foo%2fbar", "/foo%2Fbar"], 20 | normalized: "/foo%2Fbar" 21 | }, 22 | { 23 | paths: ["/café", "/caf%C3%A9", "/caf%c3%a9"], 24 | normalized: "/café" 25 | }, 26 | { 27 | paths: ["/abc%25def"], 28 | normalized: "/abc%25def" 29 | }, 30 | { 31 | paths: [ 32 | "/" + 33 | encodeURIComponent( 34 | "http://example.com/index.html?foo=100%&baz=boo#hash" 35 | ) 36 | ], 37 | normalized: "/http:%2F%2Fexample.com%2Findex.html?foo=100%25&baz=boo#hash" 38 | }, 39 | { 40 | paths: ["/%25%25%25%25"], 41 | normalized: "/%25%25%25%25" 42 | }, 43 | { 44 | paths: ["/%25%25%25%25%3A%3a%2F%2f%2f"], 45 | normalized: "/%25%25%25%25::%2F%2F%2F" 46 | } 47 | ]; 48 | 49 | expectations.forEach(expectation => { 50 | const { paths, normalized } = expectation; 51 | paths.forEach(function(path) { 52 | QUnit.test( 53 | "the path '" + path + "' is normalized to '" + normalized + "'", 54 | (assert: Assert) => { 55 | assert.equal(Normalizer.normalizePath(path), normalized); 56 | } 57 | ); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /tests/recognizer-tests.ts: -------------------------------------------------------------------------------- 1 | /* globals QUnit */ 2 | import RouteRecognizer, { 3 | QueryParams, 4 | Result, 5 | Results 6 | } from "../lib/route-recognizer"; 7 | 8 | QUnit.module("Route Recognition"); 9 | 10 | function queryParams( 11 | results: Results | undefined 12 | ): QueryParams | undefined { 13 | return results && results.queryParams; 14 | } 15 | 16 | function resultsMatch( 17 | assert: Assert, 18 | actual: Results | undefined, 19 | expected: Result[], 20 | queryParams?: QueryParams 21 | ): void { 22 | assert.deepEqual(actual && actual.slice(), expected); 23 | if (queryParams) { 24 | assert.deepEqual(actual && actual.queryParams, queryParams); 25 | } 26 | } 27 | 28 | QUnit.test("A simple route recognizes", (assert: Assert) => { 29 | const handler = ""; 30 | const router = new RouteRecognizer(); 31 | router.add([{ path: "/foo/bar", handler }]); 32 | 33 | resultsMatch(assert, router.recognize("/foo/bar"), [ 34 | { handler, params: {}, isDynamic: false } 35 | ]); 36 | assert.equal(router.recognize("/foo/baz"), null); 37 | }); 38 | 39 | const slashStaticExpectations = [ 40 | { 41 | // leading only, trailing only, both, neither 42 | routes: ["/foo/bar", "foo/bar/", "/foo/bar/", "foo/bar"], 43 | matches: ["/foo/bar", "foo/bar/", "/foo/bar/", "foo/bar"] 44 | } 45 | ]; 46 | 47 | const nonAsciiStaticExpectations = [ 48 | { 49 | // UTF8 50 | routes: ["/foö/bär", "/fo%C3%B6/b%C3%A4r"], 51 | matches: ["/foö/bär", "/fo%C3%B6/b%C3%A4r", "fo%c3%b6/b%c3%a4r"] 52 | }, 53 | { 54 | // emoji 55 | routes: ["/foo/😜", "/foo/%F0%9F%98%9C"], 56 | matches: ["/foo/😜", "/foo/%F0%9F%98%9C"] 57 | } 58 | ]; 59 | 60 | // ascii chars that are not reserved but sometimes encoded 61 | const unencodedCharStaticExpectations = [ 62 | { 63 | // unencoded space 64 | routes: ["/foo /bar"], 65 | matches: ["/foo /bar", "/foo%20/bar"] 66 | }, 67 | { 68 | // unencoded [ 69 | routes: ["/foo[/bar"], 70 | matches: ["/foo[/bar", "/foo%5B/bar", "/foo%5b/bar"] 71 | } 72 | ]; 73 | 74 | // Tests for routes that include percent-encoded 75 | // reserved and unreserved characters. 76 | const encodedCharStaticExpectations = [ 77 | { 78 | // reserved char ":" in significant place, both cases 79 | routes: ["/foo/%3Abar", "/foo/%3abar"], 80 | matches: ["/foo/%3Abar", "/foo/%3abar", "/foo/:bar"] 81 | }, 82 | { 83 | // reserved char ":" in non-significant place 84 | routes: ["/foo/b%3Aar", "/foo/b%3aar"], 85 | matches: ["/foo/b:ar", "/foo/b%3aar", "/foo/b%3Aar"] 86 | }, 87 | { 88 | // reserved char "*" in significant place 89 | routes: ["/foo/%2Abar", "/foo/%2abar"], 90 | matches: ["/foo/*bar", "/foo/%2Abar", "/foo/%2abar"] 91 | }, 92 | { 93 | // reserved char "*" in non-significant place 94 | routes: ["/foo/b%2Aar", "/foo/b%2aar"], 95 | matches: ["/foo/b*ar", "/foo/b%2Aar", "/foo/b%2aar"] 96 | }, 97 | { 98 | // space: " " 99 | routes: ["/foo%20/bar"], 100 | matches: ["/foo /bar", "/foo%20/bar"] 101 | }, 102 | { 103 | // reserved char "/" 104 | routes: ["/foo/ba%2Fr", "/foo/ba%2fr"], 105 | matches: ["/foo/ba%2Fr", "/foo/ba%2fr"], 106 | nonmatches: ["/foo/ba/r"] 107 | }, 108 | { 109 | // reserved char "%" 110 | routes: ["/foo/ba%25r"], 111 | matches: ["/foo/ba%25r"] 112 | // nonmatches: ["/foo/ba%r"] // malformed URI 113 | }, 114 | { 115 | // reserved char "?" 116 | routes: ["/foo/ba%3Fr"], 117 | matches: ["/foo/ba%3Fr", "/foo/ba%3fr"], 118 | nonmatches: ["/foo/ba?r"] 119 | }, 120 | { 121 | // reserved char "#" in route segment 122 | routes: ["/foo/ba%23r"], 123 | matches: ["/foo/ba%23r"], 124 | nonmatches: ["/foo/ba#r"] // "#" not valid to include in path when unencoded 125 | } 126 | ]; 127 | 128 | const staticExpectations: { 129 | routes: string[]; 130 | matches: string[]; 131 | nonmatches?: string[]; 132 | }[] = [ 133 | ...slashStaticExpectations, 134 | ...nonAsciiStaticExpectations, 135 | ...unencodedCharStaticExpectations, 136 | ...encodedCharStaticExpectations 137 | ]; 138 | 139 | staticExpectations.forEach(function(expectation) { 140 | const { routes, matches } = expectation; 141 | const nonmatches = expectation.nonmatches || []; 142 | 143 | routes.forEach(function(route) { 144 | matches.forEach(function(match) { 145 | QUnit.test( 146 | "Static route '" + route + "' recognizes path '" + match + "'", 147 | (assert: Assert) => { 148 | const handler = {}; 149 | const router = new RouteRecognizer<{}>(); 150 | router.add([{ path: route, handler: handler }]); 151 | resultsMatch(assert, router.recognize(match), [ 152 | { handler: handler, params: {}, isDynamic: false } 153 | ]); 154 | } 155 | ); 156 | }); 157 | 158 | if (nonmatches.length) { 159 | nonmatches.forEach(function(nonmatch) { 160 | QUnit.test( 161 | "Static route '" + 162 | route + 163 | "' does not recognize path '" + 164 | nonmatch + 165 | "'", 166 | (assert: Assert) => { 167 | const handler = {}; 168 | const router = new RouteRecognizer<{}>(); 169 | router.add([{ path: route, handler: handler }]); 170 | assert.equal(router.recognize(nonmatch), null); 171 | } 172 | ); 173 | }); 174 | } 175 | }); 176 | }); 177 | 178 | QUnit.test( 179 | "Escaping works for path length with trailing slashes.", 180 | (assert: Assert) => { 181 | const handler = {}; 182 | const router = new RouteRecognizer<{}>(); 183 | router.add([{ path: "/foo/:query", handler }]); 184 | 185 | resultsMatch(assert, router.recognize("/foo/%e8%81%8c%e4%bd%8d"), [ 186 | { handler: handler, params: { query: "职位" }, isDynamic: true } 187 | ]); 188 | resultsMatch(assert, router.recognize("/foo/%e8%81%8c%e4%bd%8d/"), [ 189 | { handler: handler, params: { query: "职位" }, isDynamic: true } 190 | ]); 191 | } 192 | ); 193 | 194 | QUnit.test("A simple route with query params recognizes", (assert: Assert) => { 195 | const handler = {}; 196 | const router = new RouteRecognizer<{}>(); 197 | router.add([{ path: "/foo/bar", handler }]); 198 | 199 | resultsMatch( 200 | assert, 201 | router.recognize("/foo/bar?sort=date&other=something"), 202 | [{ handler: handler, params: {}, isDynamic: false }], 203 | { sort: "date", other: "something" } 204 | ); 205 | resultsMatch( 206 | assert, 207 | router.recognize("/foo/bar?other=something"), 208 | [{ handler: handler, params: {}, isDynamic: false }], 209 | { other: "something" } 210 | ); 211 | }); 212 | 213 | QUnit.test("False query params = 'false'", (assert: Assert) => { 214 | const handler = {}; 215 | const router = new RouteRecognizer<{}>(); 216 | router.add([{ path: "/foo/bar", handler }]); 217 | 218 | assert.deepEqual(queryParams(router.recognize("/foo/bar?show=false")), { 219 | show: "false" 220 | }); 221 | assert.deepEqual( 222 | queryParams(router.recognize("/foo/bar?show=false&other=something")), 223 | { show: "false", other: "something" } 224 | ); 225 | }); 226 | 227 | QUnit.test("True query params = 'true'", (assert: Assert) => { 228 | const handler = {}; 229 | const router = new RouteRecognizer<{}>(); 230 | router.add([{ path: "/foo/bar", handler }]); 231 | 232 | assert.deepEqual(queryParams(router.recognize("/foo/bar?show=true")), { 233 | show: "true" 234 | }); 235 | assert.deepEqual( 236 | queryParams(router.recognize("/foo/bar?show=true&other=something")), 237 | { show: "true", other: "something" } 238 | ); 239 | }); 240 | 241 | QUnit.test("Query params without '='", (assert: Assert) => { 242 | const handler = {}; 243 | const router = new RouteRecognizer<{}>(); 244 | router.add([{ path: "/foo/bar", handler }]); 245 | 246 | assert.deepEqual(queryParams(router.recognize("/foo/bar?show")), { 247 | show: "true" 248 | }); 249 | assert.deepEqual(queryParams(router.recognize("/foo/bar?show&hide")), { 250 | show: "true", 251 | hide: "true" 252 | }); 253 | }); 254 | 255 | QUnit.test( 256 | "Query params with = and without value are empty string", 257 | (assert: Assert) => { 258 | const handler = {}; 259 | const router = new RouteRecognizer<{}>(); 260 | router.add([{ path: "/foo/bar", handler }]); 261 | 262 | assert.deepEqual(queryParams(router.recognize("/foo/bar?search=")), { 263 | search: "" 264 | }); 265 | assert.deepEqual( 266 | queryParams(router.recognize("/foo/bar?search=&other=something")), 267 | { search: "", other: "something" } 268 | ); 269 | } 270 | ); 271 | 272 | QUnit.test( 273 | "A simple route with multiple query params recognizes", 274 | (assert: Assert) => { 275 | const handler = {}; 276 | const router = new RouteRecognizer<{}>(); 277 | router.add([ 278 | { 279 | path: "/foo/bar", 280 | handler, 281 | queryParams: ["sort", "direction", "category"] 282 | } 283 | ]); 284 | 285 | assert.deepEqual( 286 | queryParams(router.recognize("/foo/bar?sort=date&other=something")), 287 | { sort: "date", other: "something" } 288 | ); 289 | assert.deepEqual( 290 | queryParams( 291 | router.recognize("/foo/bar?sort=date&other=something&direction=asc") 292 | ), 293 | { sort: "date", direction: "asc", other: "something" } 294 | ); 295 | assert.deepEqual( 296 | queryParams( 297 | router.recognize( 298 | "/foo/bar?sort=date&other=something&direction=asc&category=awesome" 299 | ) 300 | ), 301 | { 302 | sort: "date", 303 | direction: "asc", 304 | category: "awesome", 305 | other: "something" 306 | } 307 | ); 308 | assert.deepEqual( 309 | queryParams(router.recognize("/foo/bar?other=something")), 310 | { other: "something" } 311 | ); 312 | } 313 | ); 314 | 315 | QUnit.test( 316 | "A simple route with query params with encoding recognizes", 317 | (assert: Assert) => { 318 | const handler = {}; 319 | const router = new RouteRecognizer<{}>(); 320 | router.add([{ path: "/foo/bar", handler }]); 321 | 322 | assert.deepEqual( 323 | queryParams(router.recognize("/foo/bar?other=something%20100%25")), 324 | { other: "something 100%" } 325 | ); 326 | } 327 | ); 328 | 329 | QUnit.test( 330 | "A route with query params with pluses for spaces instead of %20 recognizes", 331 | (assert: Assert) => { 332 | const handler = {}; 333 | const router = new RouteRecognizer<{}>(); 334 | router.add([{ path: "/foo/bar", handler }]); 335 | 336 | assert.deepEqual( 337 | queryParams(router.recognize("/foo/bar?++one+two=three+four+five++")), 338 | { " one two": "three four five " } 339 | ); 340 | } 341 | ); 342 | 343 | QUnit.test("A `/` route recognizes", (assert: Assert) => { 344 | const handler = {}; 345 | const router = new RouteRecognizer<{}>(); 346 | router.add([{ path: "/", handler }]); 347 | 348 | resultsMatch(assert, router.recognize("/"), [ 349 | { handler: handler, params: {}, isDynamic: false } 350 | ]); 351 | }); 352 | 353 | QUnit.test("A `/` route with query params recognizes", (assert: Assert) => { 354 | const handler = {}; 355 | const router = new RouteRecognizer<{}>(); 356 | router.add([{ path: "/", handler }]); 357 | 358 | resultsMatch( 359 | assert, 360 | router.recognize("/?lemon=jello"), 361 | [{ handler: handler, params: {}, isDynamic: false }], 362 | { lemon: "jello" } 363 | ); 364 | }); 365 | 366 | QUnit.test("A dynamic route recognizes", (assert: Assert) => { 367 | const handler = {}; 368 | const router = new RouteRecognizer<{}>(); 369 | router.add([{ path: "/foo/:bar", handler }]); 370 | 371 | resultsMatch(assert, router.recognize("/foo/bar"), [ 372 | { handler: handler, params: { bar: "bar" }, isDynamic: true } 373 | ]); 374 | resultsMatch(assert, router.recognize("/foo/1"), [ 375 | { handler: handler, params: { bar: "1" }, isDynamic: true } 376 | ]); 377 | assert.equal(router.recognize("/zoo/baz"), null); 378 | }); 379 | 380 | const nonAsciiDynamicExpectations = [ 381 | { 382 | paths: ["/foo/café", "/foo/caf%C3%A9", "/foo/caf%c3%a9"], 383 | match: "café", 384 | unencodedMatches: ["café", "café", "café"] 385 | }, 386 | { 387 | paths: ["/foo/😜", "/foo/%F0%9F%98%9C"], 388 | match: "😜", 389 | unencodedMatches: ["😜", "😜"] 390 | } 391 | ]; 392 | 393 | const encodedCharDynamicExpectations = [ 394 | { 395 | // encoded "/", upper and lower 396 | paths: ["/foo/ba%2Fr", "/foo/ba%2fr"], 397 | match: "ba/r", 398 | unencodedMatches: ["ba%2Fr", "ba%2fr"] 399 | }, 400 | { 401 | // encoded "#" 402 | paths: ["/foo/ba%23r"], 403 | match: "ba#r", 404 | unencodedMatches: ["ba%23r"] 405 | }, 406 | { 407 | // ":" 408 | paths: ["/foo/%3Abar", "/foo/%3abar", "/foo/:bar"], 409 | match: ":bar", 410 | unencodedMatches: ["%3Abar", "%3abar", ":bar"] 411 | }, 412 | { 413 | // encoded "?" 414 | paths: ["/foo/ba%3Fr", "/foo/ba%3fr"], 415 | match: "ba?r", 416 | unencodedMatches: ["ba%3Fr", "ba%3fr"] 417 | }, 418 | { 419 | // space 420 | paths: ["/foo/ba%20r", "/foo/ba r"], 421 | match: "ba r", 422 | // decodeURI changes "%20" -> " " 423 | unencodedMatches: ["ba r", "ba r"] 424 | }, 425 | { 426 | // "+" 427 | paths: ["/foo/ba%2Br", "/foo/ba%2br", "/foo/ba+r"], 428 | match: "ba+r", 429 | unencodedMatches: ["ba%2Br", "ba%2br", "ba+r"] 430 | }, 431 | { 432 | // encoded % 433 | paths: ["/foo/ba%25r"], 434 | match: "ba%r", 435 | unencodedMatches: ["ba%r"] 436 | }, 437 | { 438 | // many encoded % 439 | paths: ["/foo/ba%25%25r%3A%25"], 440 | match: "ba%%r:%", 441 | unencodedMatches: ["ba%%r%3A%"] 442 | }, 443 | { 444 | // doubly-encoded % 445 | paths: ["/foo/ba%2525r"], 446 | match: "ba%25r", 447 | unencodedMatches: ["ba%25r"] 448 | }, 449 | { 450 | // doubly-encoded parameter 451 | paths: [ 452 | "/foo/" + 453 | encodeURIComponent( 454 | "http://example.com/post/" + 455 | encodeURIComponent("http://other-url.com") 456 | ) 457 | ], 458 | match: 459 | "http://example.com/post/" + encodeURIComponent("http://other-url.com"), 460 | unencodedMatches: [ 461 | encodeURIComponent("http://example.com/post/http://other-url.com") 462 | ] 463 | } 464 | ]; 465 | 466 | const dynamicExpectations: { 467 | paths: string[]; 468 | match: string; 469 | unencodedMatches: string[]; 470 | }[] = [...nonAsciiDynamicExpectations, ...encodedCharDynamicExpectations]; 471 | 472 | dynamicExpectations.forEach(expectation => { 473 | const route = "/foo/:bar"; 474 | const { paths, match, unencodedMatches } = expectation; 475 | 476 | paths.forEach(function(path, index) { 477 | const unencodedMatch = unencodedMatches[index]; 478 | 479 | QUnit.test( 480 | "Single-segment dynamic route '" + 481 | route + 482 | "' recognizes path '" + 483 | path + 484 | "'", 485 | (assert: Assert) => { 486 | const handler = {}; 487 | const router = new RouteRecognizer<{}>(); 488 | router.add([{ path: route, handler }]); 489 | resultsMatch(assert, router.recognize(path), [ 490 | { handler: handler, params: { bar: match }, isDynamic: true } 491 | ]); 492 | } 493 | ); 494 | 495 | QUnit.test( 496 | "When RouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS is false, single-segment dynamic route '" + 497 | route + 498 | "' recognizes path '" + 499 | path + 500 | "'", 501 | (assert: Assert) => { 502 | RouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS = false; 503 | 504 | const handler = {}; 505 | const router = new RouteRecognizer<{}>(); 506 | router.add([{ path: route, handler }]); 507 | resultsMatch(assert, router.recognize(path), [ 508 | { handler: handler, params: { bar: unencodedMatch }, isDynamic: true } 509 | ]); 510 | 511 | RouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS = true; 512 | } 513 | ); 514 | }); 515 | }); 516 | 517 | const multiSegmentDynamicExpectations = [ 518 | { 519 | paths: ["/foo%20/bar/baz%20", "/foo /bar/baz "], 520 | match: { foo: "foo ", baz: "baz " }, 521 | // " " is not a reserved uri character, so "%20" gets normalized to " " 522 | // see http://www.ecma-international.org/ecma-262/6.0/#sec-uri-syntax-and-semantics 523 | unencodedMatches: [ 524 | { foo: "foo ", baz: "baz " }, 525 | { foo: "foo ", baz: "baz " } 526 | ] 527 | }, 528 | { 529 | paths: ["/fo%25o/bar/ba%25z"], 530 | match: { foo: "fo%o", baz: "ba%z" }, 531 | unencodedMatches: [{ foo: "fo%o", baz: "ba%z" }] 532 | }, 533 | { 534 | paths: ["/%3Afoo/bar/:baz%3a"], 535 | match: { foo: ":foo", baz: ":baz:" }, 536 | // ":" is a reserved uri character, so "%3A" does not get normalized to ":" 537 | unencodedMatches: [{ foo: "%3Afoo", baz: ":baz%3a" }] 538 | }, 539 | { 540 | paths: [ 541 | encodeURIComponent("http://example.com/some_url.html?abc=foo") + 542 | "/bar/" + 543 | encodeURIComponent("http://example2.com/other.html#hash=bar") 544 | ], 545 | match: { 546 | foo: "http://example.com/some_url.html?abc=foo", 547 | baz: "http://example2.com/other.html#hash=bar" 548 | }, 549 | unencodedMatches: [ 550 | { 551 | foo: decodeURI( 552 | encodeURIComponent("http://example.com/some_url.html?abc=foo") 553 | ), 554 | baz: decodeURI( 555 | encodeURIComponent("http://example2.com/other.html#hash=bar") 556 | ) 557 | } 558 | ] 559 | }, 560 | { 561 | paths: ["/föo/bar/bäz", "/f%c3%b6o/bar/b%c3%a4z", "/f%C3%B6o/bar/b%C3%A4z"], 562 | match: { foo: "föo", baz: "bäz" }, 563 | unencodedMatches: [ 564 | { foo: "föo", baz: "bäz" }, 565 | { foo: "föo", baz: "bäz" }, 566 | { foo: "föo", baz: "bäz" } 567 | ] 568 | } 569 | ]; 570 | 571 | multiSegmentDynamicExpectations.forEach(expectation => { 572 | const route = "/:foo/bar/:baz"; 573 | const { paths, match, unencodedMatches } = expectation; 574 | 575 | paths.forEach((path, index) => { 576 | const unencodedMatch = unencodedMatches[index]; 577 | 578 | QUnit.test( 579 | "Multi-segment dynamic route '" + 580 | route + 581 | "' recognizes path '" + 582 | path + 583 | "'", 584 | (assert: Assert) => { 585 | const handler = {}; 586 | const router = new RouteRecognizer<{}>(); 587 | router.add([{ path: route, handler }]); 588 | 589 | resultsMatch(assert, router.recognize(path), [ 590 | { handler: handler, params: match, isDynamic: true } 591 | ]); 592 | } 593 | ); 594 | 595 | QUnit.test( 596 | "When RouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS is false, multi-segment dynamic route '" + 597 | route + 598 | "' recognizes path '" + 599 | path + 600 | "'", 601 | (assert: Assert) => { 602 | RouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS = false; 603 | 604 | const handler = {}; 605 | const router = new RouteRecognizer<{}>(); 606 | router.add([{ path: route, handler }]); 607 | 608 | resultsMatch(assert, router.recognize(path), [ 609 | { handler: handler, params: unencodedMatch, isDynamic: true } 610 | ]); 611 | 612 | RouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS = true; 613 | } 614 | ); 615 | }); 616 | }); 617 | 618 | QUnit.test( 619 | "A dynamic route with unicode match parameters recognizes", 620 | (assert: Assert) => { 621 | const handler = {}; 622 | const router = new RouteRecognizer<{}>(); 623 | router.add([{ path: "/:föo/bar/:bäz", handler }]); 624 | const path = "/foo/bar/baz"; 625 | 626 | const expectedParams = { föo: "foo", bäz: "baz" }; 627 | resultsMatch(assert, router.recognize(path), [ 628 | { handler: handler, params: expectedParams, isDynamic: true } 629 | ]); 630 | } 631 | ); 632 | 633 | const starSimpleExpectations = [ 634 | // encoded % is left encoded 635 | "ba%25r", 636 | 637 | // encoded / is left encoded 638 | "ba%2Fr", 639 | 640 | // multiple segments 641 | "bar/baz/blah", 642 | 643 | // trailing slash 644 | "bar/baz/blah/", 645 | 646 | // unencoded url 647 | "http://example.com/abc_def.html", 648 | 649 | // encoded url 650 | encodeURIComponent("http://example.com/abc_%def.html") 651 | ]; 652 | 653 | starSimpleExpectations.forEach(function(value) { 654 | const route = "/foo/*bar"; 655 | const path = "/foo/" + value; 656 | 657 | QUnit.test( 658 | "Star segment glob route '" + route + "' recognizes path '" + path + "'", 659 | (assert: Assert) => { 660 | const handler = {}; 661 | const router = new RouteRecognizer<{}>(); 662 | router.add([{ path: route, handler }]); 663 | resultsMatch(assert, router.recognize(path), [ 664 | { handler: handler, params: { bar: value }, isDynamic: true } 665 | ]); 666 | } 667 | ); 668 | }); 669 | 670 | const starComplexExpectations = [ 671 | { 672 | path: "/b%25ar/baz", 673 | params: ["b%25ar", "baz"] 674 | }, 675 | { 676 | path: "a/b/c/baz", 677 | params: ["a/b/c", "baz"] 678 | }, 679 | { 680 | path: "a%2Fb%2fc/baz", 681 | params: ["a%2Fb%2fc", "baz"] 682 | }, 683 | { 684 | path: encodeURIComponent("http://example.com") + "/baz", 685 | params: [encodeURIComponent("http://example.com"), "baz"] 686 | } 687 | ]; 688 | 689 | starComplexExpectations.forEach(function(expectation) { 690 | const route = "/*prefix/:suffix"; 691 | const path = expectation.path; 692 | const params = { 693 | prefix: expectation.params[0], 694 | suffix: expectation.params[1] 695 | }; 696 | 697 | QUnit.test( 698 | "Complex star segment glob route '" + 699 | route + 700 | "' recognizes path '" + 701 | path + 702 | "'", 703 | (assert: Assert) => { 704 | const router = new RouteRecognizer<{}>(); 705 | const handler = {}; 706 | router.add([{ path: route, handler }]); 707 | 708 | resultsMatch(assert, router.recognize(path), [ 709 | { handler: handler, params: params, isDynamic: true } 710 | ]); 711 | } 712 | ); 713 | }); 714 | 715 | QUnit.test("Multiple routes recognize", (assert: Assert) => { 716 | const handler1 = { handler: 1 }; 717 | const handler2 = { handler: 2 }; 718 | const router = new RouteRecognizer<{ handler: number }>(); 719 | 720 | router.add([{ path: "/foo/:bar", handler: handler1 }]); 721 | router.add([{ path: "/bar/:baz", handler: handler2 }]); 722 | 723 | resultsMatch(assert, router.recognize("/foo/bar"), [ 724 | { handler: handler1, params: { bar: "bar" }, isDynamic: true } 725 | ]); 726 | resultsMatch(assert, router.recognize("/bar/1"), [ 727 | { handler: handler2, params: { baz: "1" }, isDynamic: true } 728 | ]); 729 | }); 730 | 731 | QUnit.test("query params ignore the URI malformed error", (assert: Assert) => { 732 | const handler1 = { handler: 1 }; 733 | const router = new RouteRecognizer<{ handler: number }>(); 734 | 735 | router.add([{ path: "/foo", handler: handler1 }]); 736 | 737 | assert.deepEqual(queryParams(router.recognize("/foo?a=1%")), { a: "" }); 738 | }); 739 | 740 | QUnit.test( 741 | "Multiple routes with overlapping query params recognize", 742 | (assert: Assert) => { 743 | const handler1 = { handler: 1 }; 744 | const handler2 = { handler: 2 }; 745 | const router = new RouteRecognizer<{ handler: number }>(); 746 | 747 | router.add([{ path: "/foo", handler: handler1 }]); 748 | router.add([{ path: "/bar", handler: handler2 }]); 749 | 750 | assert.deepEqual(queryParams(router.recognize("/foo")), {}); 751 | assert.deepEqual(queryParams(router.recognize("/foo?a=1")), { a: "1" }); 752 | assert.deepEqual(queryParams(router.recognize("/foo?a=1&b=2")), { 753 | a: "1", 754 | b: "2" 755 | }); 756 | assert.deepEqual(queryParams(router.recognize("/foo?a=1&b=2&c=3")), { 757 | a: "1", 758 | b: "2", 759 | c: "3" 760 | }); 761 | assert.deepEqual(queryParams(router.recognize("/foo?b=2&c=3")), { 762 | b: "2", 763 | c: "3" 764 | }); 765 | assert.deepEqual(queryParams(router.recognize("/foo?c=3")), { c: "3" }); 766 | assert.deepEqual(queryParams(router.recognize("/foo?a=1&c=3")), { 767 | a: "1", 768 | c: "3" 769 | }); 770 | 771 | assert.deepEqual(queryParams(router.recognize("/bar")), {}); 772 | assert.deepEqual(queryParams(router.recognize("/bar?a=1")), { a: "1" }); 773 | assert.deepEqual(queryParams(router.recognize("/bar?a=1&b=2")), { 774 | a: "1", 775 | b: "2" 776 | }); 777 | assert.deepEqual(queryParams(router.recognize("/bar?a=1&b=2&c=3")), { 778 | a: "1", 779 | b: "2", 780 | c: "3" 781 | }); 782 | assert.deepEqual(queryParams(router.recognize("/bar?b=2&c=3")), { 783 | b: "2", 784 | c: "3" 785 | }); 786 | assert.deepEqual(queryParams(router.recognize("/bar?c=3")), { c: "3" }); 787 | assert.deepEqual(queryParams(router.recognize("/bar?a=1&c=3")), { 788 | a: "1", 789 | c: "3" 790 | }); 791 | } 792 | ); 793 | 794 | QUnit.test("Deserialize query param array", (assert: Assert) => { 795 | const handler = {}; 796 | const router = new RouteRecognizer<{}>(); 797 | router.add([{ path: "/foo/bar", handler }]); 798 | 799 | const results = router.recognize("/foo/bar?foo[]=1&foo[]=2"); 800 | const p = results && results.queryParams; 801 | assert.ok(p && Array.isArray(p["foo"]), "foo is an Array"); 802 | assert.deepEqual(p, { foo: ["1", "2"] }); 803 | }); 804 | 805 | QUnit.test( 806 | "Array query params do not conflict with controller namespaced query params", 807 | (assert: Assert) => { 808 | const handler = {}; 809 | const router = new RouteRecognizer<{}>(); 810 | router.add([{ path: "/foo/bar", handler }]); 811 | 812 | const p = queryParams( 813 | router.recognize("/foo/bar?foo[bar][]=1&foo[bar][]=2&baz=barf") 814 | ); 815 | assert.ok(p && Array.isArray(p["foo[bar]"]), "foo[bar] is an Array"); 816 | assert.deepEqual(p, { "foo[bar]": ["1", "2"], baz: "barf" }); 817 | } 818 | ); 819 | 820 | QUnit.test("Multiple `/` routes recognize", (assert: Assert) => { 821 | const handler1 = { handler: 1 }; 822 | const handler2 = { handler: 2 }; 823 | const router = new RouteRecognizer<{}>(); 824 | 825 | router.add([ 826 | { path: "/", handler: handler1 }, 827 | { path: "/", handler: handler2 } 828 | ]); 829 | resultsMatch(assert, router.recognize("/"), [ 830 | { handler: handler1, params: {}, isDynamic: false }, 831 | { handler: handler2, params: {}, isDynamic: false } 832 | ]); 833 | }); 834 | 835 | QUnit.test("Overlapping routes recognize", (assert: Assert) => { 836 | const handler1 = { handler: 1 }; 837 | const handler2 = { handler: 2 }; 838 | const router = new RouteRecognizer<{}>(); 839 | 840 | router.add([{ path: "/foo/:baz", handler: handler2 }]); 841 | router.add([{ path: "/foo/bar/:bar", handler: handler1 }]); 842 | 843 | resultsMatch(assert, router.recognize("/foo/bar/1"), [ 844 | { handler: handler1, params: { bar: "1" }, isDynamic: true } 845 | ]); 846 | resultsMatch(assert, router.recognize("/foo/1"), [ 847 | { handler: handler2, params: { baz: "1" }, isDynamic: true } 848 | ]); 849 | }); 850 | 851 | QUnit.test("Overlapping star routes recognize", (assert: Assert) => { 852 | const handler1 = { handler: 1 }; 853 | const handler2 = { handler: 2 }; 854 | const router = new RouteRecognizer<{}>(); 855 | 856 | router.add([{ path: "/foo/*bar", handler: handler2 }]); 857 | router.add([{ path: "/*foo", handler: handler1 }]); 858 | 859 | resultsMatch(assert, router.recognize("/foo/1"), [ 860 | { handler: handler2, params: { bar: "1" }, isDynamic: true } 861 | ]); 862 | resultsMatch(assert, router.recognize("/1"), [ 863 | { handler: handler1, params: { foo: "1" }, isDynamic: true } 864 | ]); 865 | }); 866 | 867 | QUnit.test("Prefers single dynamic segments over stars", (assert: Assert) => { 868 | const handler1 = { handler: 1 }; 869 | const handler2 = { handler: 2 }; 870 | const router = new RouteRecognizer<{}>(); 871 | 872 | router.add([{ path: "/foo/*star", handler: handler1 }]); 873 | router.add([{ path: "/foo/*star/:dynamic", handler: handler2 }]); 874 | 875 | resultsMatch(assert, router.recognize("/foo/1"), [ 876 | { handler: handler1, params: { star: "1" }, isDynamic: true } 877 | ]); 878 | resultsMatch(assert, router.recognize("/foo/suffix"), [ 879 | { handler: handler1, params: { star: "suffix" }, isDynamic: true } 880 | ]); 881 | resultsMatch(assert, router.recognize("/foo/bar/suffix"), [ 882 | { 883 | handler: handler2, 884 | params: { star: "bar", dynamic: "suffix" }, 885 | isDynamic: true 886 | } 887 | ]); 888 | }); 889 | 890 | QUnit.test( 891 | "Handle star routes last when there are trailing `/` routes.", 892 | (assert: Assert) => { 893 | const handler1 = { handler: 1 }; 894 | const handler2 = { handler: 2 }; 895 | const handler3 = { handler: 3 }; 896 | const handlerWildcard = { handler: 4 }; 897 | const router = new RouteRecognizer<{ handler: number }>(); 898 | 899 | router.add([{ path: "/foo/:dynamic", handler: handler1 }]); 900 | router.add([ 901 | { path: "/foo/:dynamic", handler: handler1 }, 902 | { path: "/baz/:dynamic", handler: handler2 }, 903 | { path: "/", handler: handler3 } 904 | ]); 905 | router.add([ 906 | { path: "/foo/:dynamic", handler: handler1 }, 907 | { path: "/*wildcard", handler: handlerWildcard } 908 | ]); 909 | 910 | resultsMatch(assert, router.recognize("/foo/r3/baz/w10"), [ 911 | { handler: handler1, params: { dynamic: "r3" }, isDynamic: true }, 912 | { handler: handler2, params: { dynamic: "w10" }, isDynamic: true }, 913 | { handler: handler3, params: {}, isDynamic: false } 914 | ]); 915 | } 916 | ); 917 | 918 | QUnit.test( 919 | "Handle `/` before globs when the route is empty.", 920 | (assert: Assert) => { 921 | const handler1 = { handler: 1 }; 922 | const handler2 = { handler: 2 }; 923 | const router = new RouteRecognizer<{ handler: number }>(); 924 | 925 | router.add([{ path: "/", handler: handler1 }]); 926 | router.add([{ path: "/*notFound", handler: handler2 }]); 927 | 928 | resultsMatch(assert, router.recognize("/"), [ 929 | { handler: handler1, params: {}, isDynamic: false } 930 | ]); 931 | 932 | resultsMatch(assert, router.recognize("/hello"), [ 933 | { handler: handler2, params: { notFound: "hello" }, isDynamic: true } 934 | ]); 935 | } 936 | ); 937 | 938 | QUnit.test("Routes with trailing `/` recognize", (assert: Assert) => { 939 | const handler = {}; 940 | const router = new RouteRecognizer<{}>(); 941 | 942 | router.add([{ path: "/foo/bar", handler }]); 943 | resultsMatch(assert, router.recognize("/foo/bar/"), [ 944 | { handler: handler, params: {}, isDynamic: false } 945 | ]); 946 | }); 947 | 948 | QUnit.test("Nested routes recognize", (assert: Assert) => { 949 | const handler1 = { handler: 1 }; 950 | const handler2 = { handler: 2 }; 951 | 952 | const router = new RouteRecognizer<{ handler: number }>(); 953 | router.add( 954 | [ 955 | { path: "/foo/:bar", handler: handler1 }, 956 | { path: "/baz/:bat", handler: handler2 } 957 | ], 958 | { as: "foo" } 959 | ); 960 | 961 | resultsMatch(assert, router.recognize("/foo/1/baz/2"), [ 962 | { handler: handler1, params: { bar: "1" }, isDynamic: true }, 963 | { handler: handler2, params: { bat: "2" }, isDynamic: true } 964 | ]); 965 | 966 | assert.equal(router.hasRoute("foo"), true); 967 | assert.equal(router.hasRoute("bar"), false); 968 | }); 969 | 970 | QUnit.test("Nested epsilon routes recognize.", (assert: Assert) => { 971 | const router = new RouteRecognizer(); 972 | router.add([ 973 | { path: "/", handler: "application" }, 974 | { path: "/", handler: "test1" }, 975 | { path: "/test2", handler: "test1.test2" } 976 | ]); 977 | router.add([ 978 | { path: "/", handler: "application" }, 979 | { path: "/", handler: "test1" }, 980 | { path: "/", handler: "test1.index" } 981 | ]); 982 | router.add([ 983 | { path: "/", handler: "application" }, 984 | { path: "/", handler: "test1" }, 985 | { path: "/", handler: "test1.index" } 986 | ]); 987 | router.add( 988 | [ 989 | { path: "/", handler: "application" }, 990 | { path: "/:param", handler: "misc" } 991 | ], 992 | { as: "misc" } 993 | ); 994 | 995 | resultsMatch(assert, router.recognize("/test2"), [ 996 | { handler: "application", isDynamic: false, params: {} }, 997 | { handler: "test1", isDynamic: false, params: {} }, 998 | { handler: "test1.test2", isDynamic: false, params: {} } 999 | ]); 1000 | }); 1001 | 1002 | QUnit.test("Nested routes with query params recognize", (assert: Assert) => { 1003 | const handler1 = { handler: 1 }; 1004 | const handler2 = { handler: 2 }; 1005 | 1006 | const router = new RouteRecognizer<{ handler: number }>(); 1007 | router.add( 1008 | [ 1009 | { path: "/foo/:bar", handler: handler1, queryParams: ["a", "b"] }, 1010 | { path: "/baz/:bat", handler: handler2, queryParams: ["b", "c"] } 1011 | ], 1012 | { as: "foo" } 1013 | ); 1014 | 1015 | resultsMatch( 1016 | assert, 1017 | router.recognize("/foo/4/baz/5?a=1"), 1018 | [ 1019 | { handler: handler1, params: { bar: "4" }, isDynamic: true }, 1020 | { handler: handler2, params: { bat: "5" }, isDynamic: true } 1021 | ], 1022 | { a: "1" } 1023 | ); 1024 | resultsMatch( 1025 | assert, 1026 | router.recognize("/foo/4/baz/5?a=1&b=2"), 1027 | [ 1028 | { handler: handler1, params: { bar: "4" }, isDynamic: true }, 1029 | { handler: handler2, params: { bat: "5" }, isDynamic: true } 1030 | ], 1031 | { a: "1", b: "2" } 1032 | ); 1033 | resultsMatch( 1034 | assert, 1035 | router.recognize("/foo/4/baz/5?a=1&b=2&c=3"), 1036 | [ 1037 | { handler: handler1, params: { bar: "4" }, isDynamic: true }, 1038 | { handler: handler2, params: { bat: "5" }, isDynamic: true } 1039 | ], 1040 | { a: "1", b: "2", c: "3" } 1041 | ); 1042 | resultsMatch( 1043 | assert, 1044 | router.recognize("/foo/4/baz/5?b=2&c=3"), 1045 | [ 1046 | { handler: handler1, params: { bar: "4" }, isDynamic: true }, 1047 | { handler: handler2, params: { bat: "5" }, isDynamic: true } 1048 | ], 1049 | { b: "2", c: "3" } 1050 | ); 1051 | resultsMatch( 1052 | assert, 1053 | router.recognize("/foo/4/baz/5?c=3"), 1054 | [ 1055 | { handler: handler1, params: { bar: "4" }, isDynamic: true }, 1056 | { handler: handler2, params: { bat: "5" }, isDynamic: true } 1057 | ], 1058 | { c: "3" } 1059 | ); 1060 | resultsMatch( 1061 | assert, 1062 | router.recognize("/foo/4/baz/5?a=1&c=3"), 1063 | [ 1064 | { handler: handler1, params: { bar: "4" }, isDynamic: true }, 1065 | { handler: handler2, params: { bat: "5" }, isDynamic: true } 1066 | ], 1067 | { a: "1", c: "3" } 1068 | ); 1069 | 1070 | assert.equal(router.hasRoute("foo"), true); 1071 | assert.equal(router.hasRoute("bar"), false); 1072 | }); 1073 | 1074 | QUnit.test( 1075 | "If there are multiple matches, the route with the least dynamic segments wins", 1076 | (assert: Assert) => { 1077 | const handler1 = { handler: 1 }; 1078 | const handler2 = { handler: 2 }; 1079 | const handler3 = { handler: 3 }; 1080 | 1081 | const router = new RouteRecognizer<{ handler: number }>(); 1082 | router.add([{ path: "/posts/new", handler: handler1 }]); 1083 | router.add([{ path: "/posts/:id", handler: handler2 }]); 1084 | router.add([{ path: "/posts/edit", handler: handler3 }]); 1085 | 1086 | resultsMatch(assert, router.recognize("/posts/new"), [ 1087 | { handler: handler1, params: {}, isDynamic: false } 1088 | ]); 1089 | resultsMatch(assert, router.recognize("/posts/1"), [ 1090 | { handler: handler2, params: { id: "1" }, isDynamic: true } 1091 | ]); 1092 | resultsMatch(assert, router.recognize("/posts/edit"), [ 1093 | { handler: handler3, params: {}, isDynamic: false } 1094 | ]); 1095 | } 1096 | ); 1097 | 1098 | QUnit.test("Empty paths", (assert: Assert) => { 1099 | const handler1 = { handler: 1 }; 1100 | const handler2 = { handler: 2 }; 1101 | const handler3 = { handler: 3 }; 1102 | const handler4 = { handler: 4 }; 1103 | 1104 | const router = new RouteRecognizer<{ handler: number }>(); 1105 | router.add([ 1106 | { path: "/foo", handler: handler1 }, 1107 | { path: "/", handler: handler2 }, 1108 | { path: "/bar", handler: handler3 } 1109 | ]); 1110 | router.add([ 1111 | { path: "/foo", handler: handler1 }, 1112 | { path: "/", handler: handler2 }, 1113 | { path: "/baz", handler: handler4 } 1114 | ]); 1115 | 1116 | resultsMatch(assert, router.recognize("/foo/bar"), [ 1117 | { handler: handler1, params: {}, isDynamic: false }, 1118 | { handler: handler2, params: {}, isDynamic: false }, 1119 | { handler: handler3, params: {}, isDynamic: false } 1120 | ]); 1121 | resultsMatch(assert, router.recognize("/foo/baz"), [ 1122 | { handler: handler1, params: {}, isDynamic: false }, 1123 | { handler: handler2, params: {}, isDynamic: false }, 1124 | { handler: handler4, params: {}, isDynamic: false } 1125 | ]); 1126 | }); 1127 | 1128 | QUnit.test( 1129 | "Repeated empty segments don't confuse the recognizer", 1130 | (assert: Assert) => { 1131 | const handler1 = { handler: 1 }, 1132 | handler2 = { handler: 2 }, 1133 | handler3 = { handler: 3 }, 1134 | handler4 = { handler: 4 }; 1135 | 1136 | const router = new RouteRecognizer<{ handler: number }>(); 1137 | router.add([ 1138 | { path: "/", handler: handler1 }, 1139 | { path: "/", handler: handler2 }, 1140 | { path: "/", handler: handler3 } 1141 | ]); 1142 | router.add([ 1143 | { path: "/", handler: handler1 }, 1144 | { path: "/", handler: handler2 }, 1145 | { path: "/foo", handler: handler4 } 1146 | ]); 1147 | 1148 | resultsMatch(assert, router.recognize("/"), [ 1149 | { handler: handler1, params: {}, isDynamic: false }, 1150 | { handler: handler2, params: {}, isDynamic: false }, 1151 | { handler: handler3, params: {}, isDynamic: false } 1152 | ]); 1153 | resultsMatch(assert, router.recognize(""), [ 1154 | { handler: handler1, params: {}, isDynamic: false }, 1155 | { handler: handler2, params: {}, isDynamic: false }, 1156 | { handler: handler3, params: {}, isDynamic: false } 1157 | ]); 1158 | resultsMatch(assert, router.recognize("/foo"), [ 1159 | { handler: handler1, params: {}, isDynamic: false }, 1160 | { handler: handler2, params: {}, isDynamic: false }, 1161 | { handler: handler4, params: {}, isDynamic: false } 1162 | ]); 1163 | resultsMatch(assert, router.recognize("foo"), [ 1164 | { handler: handler1, params: {}, isDynamic: false }, 1165 | { handler: handler2, params: {}, isDynamic: false }, 1166 | { handler: handler4, params: {}, isDynamic: false } 1167 | ]); 1168 | } 1169 | ); 1170 | 1171 | // BUG - https://github.com/emberjs/ember.js/issues/2559 1172 | QUnit.test( 1173 | "Dynamic routes without leading `/` and single length param are recognized", 1174 | (assert: Assert) => { 1175 | const handler = {}; 1176 | const router = new RouteRecognizer<{}>(); 1177 | 1178 | router.add([{ path: "/foo/:id", handler }]); 1179 | resultsMatch(assert, router.recognize("foo/1"), [ 1180 | { handler, params: { id: "1" }, isDynamic: true } 1181 | ]); 1182 | } 1183 | ); 1184 | 1185 | QUnit.module("Route Generation", hooks => { 1186 | let router: RouteRecognizer; 1187 | let handlers: unknown[]; 1188 | 1189 | hooks.beforeEach(() => { 1190 | router = new RouteRecognizer(); 1191 | 1192 | handlers = [{}, {}, {}, {}, {}, {}, {}]; 1193 | 1194 | router.add([{ path: "/", handler: {} }], { as: "index" }); 1195 | router.add([{ path: "/posts/:id", handler: handlers[0] }], { as: "post" }); 1196 | router.add([{ path: "/posts", handler: handlers[1] }], { as: "posts" }); 1197 | router.add( 1198 | [ 1199 | { path: "/posts", handler: handlers[1] }, 1200 | { path: "/", handler: handlers[4] } 1201 | ], 1202 | { as: "postIndex" } 1203 | ); 1204 | router.add([{ path: "/posts/new", handler: handlers[2] }], { 1205 | as: "new_post" 1206 | }); 1207 | router.add([{ path: "/posts/:id/edit", handler: handlers[3] }], { 1208 | as: "edit_post" 1209 | }); 1210 | router.add( 1211 | [ 1212 | { path: "/foo/:bar", handler: handlers[4] }, 1213 | { path: "/baz/:bat", handler: handlers[5] } 1214 | ], 1215 | { as: "foo" } 1216 | ); 1217 | router.add([{ path: "/*catchall", handler: handlers[5] }], { 1218 | as: "catchall" 1219 | }); 1220 | }); 1221 | 1222 | QUnit.test("Generation works", (assert: Assert) => { 1223 | assert.equal(router.generate("index"), "/"); 1224 | assert.equal(router.generate("post", { id: 1 }), "/posts/1"); 1225 | assert.equal(router.generate("posts"), "/posts"); 1226 | assert.equal(router.generate("new_post"), "/posts/new"); 1227 | assert.equal(router.generate("edit_post", { id: 1 }), "/posts/1/edit"); 1228 | assert.equal(router.generate("postIndex"), "/posts"); 1229 | assert.equal(router.generate("catchall", { catchall: "foo" }), "/foo"); 1230 | }); 1231 | 1232 | const encodedCharGenerationExpectations = [ 1233 | { 1234 | route: "post", 1235 | params: { id: "abc/def" }, 1236 | expected: "/posts/abc%2Fdef", 1237 | expectedUnencoded: "/posts/abc/def" 1238 | }, 1239 | { 1240 | route: "post", 1241 | params: { id: "abc%def" }, 1242 | expected: "/posts/abc%25def", 1243 | expectedUnencoded: "/posts/abc%def" 1244 | }, 1245 | { 1246 | route: "post", 1247 | params: { id: "abc def" }, 1248 | expected: "/posts/abc%20def", 1249 | expectedUnencoded: "/posts/abc def" 1250 | }, 1251 | { 1252 | route: "post", 1253 | params: { id: "café" }, 1254 | expected: "/posts/caf%C3%A9", 1255 | expectedUnencoded: "/posts/café" 1256 | }, 1257 | { 1258 | route: "edit_post", 1259 | params: { id: "abc/def" }, 1260 | expected: "/posts/abc%2Fdef/edit", 1261 | expectedUnencoded: "/posts/abc/def/edit" 1262 | }, 1263 | { 1264 | route: "edit_post", 1265 | params: { id: "abc%def" }, 1266 | expected: "/posts/abc%25def/edit", 1267 | expectedUnencoded: "/posts/abc%def/edit" 1268 | }, 1269 | { 1270 | route: "edit_post", 1271 | params: { id: "café" }, 1272 | expected: "/posts/caf%C3%A9/edit", 1273 | expectedUnencoded: "/posts/café/edit" 1274 | } 1275 | ]; 1276 | 1277 | encodedCharGenerationExpectations.forEach(function(expectation) { 1278 | const route = expectation.route; 1279 | const params = expectation.params; 1280 | const expected = expectation.expected; 1281 | const expectedUnencoded = expectation.expectedUnencoded; 1282 | 1283 | QUnit.test( 1284 | "Encodes dynamic segment value for route '" + 1285 | route + 1286 | "' with params " + 1287 | JSON.stringify(params), 1288 | (assert: Assert) => { 1289 | assert.equal(router.generate(route, params), expected); 1290 | } 1291 | ); 1292 | 1293 | QUnit.test( 1294 | "When RouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS is false, does not encode dynamic segment for route '" + 1295 | route + 1296 | "' with params " + 1297 | JSON.stringify(params), 1298 | (assert: Assert) => { 1299 | RouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS = false; 1300 | assert.equal(router.generate(route, params), expectedUnencoded); 1301 | RouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS = true; 1302 | } 1303 | ); 1304 | }); 1305 | 1306 | QUnit.test( 1307 | "Generating a dynamic segment with unreserved chars does not encode them", 1308 | (assert: Assert) => { 1309 | // See: https://tools.ietf.org/html/rfc3986#section-2.3 1310 | const unreservedChars = ["a", "0", "-", ".", "_", "~"]; 1311 | unreservedChars.forEach(function(char) { 1312 | const route = "post"; 1313 | const params = { id: char }; 1314 | const expected = "/posts/" + char; 1315 | 1316 | assert.equal( 1317 | router.generate(route, params), 1318 | expected, 1319 | "Unreserved char '" + char + "' is not encoded" 1320 | ); 1321 | }); 1322 | } 1323 | ); 1324 | 1325 | QUnit.test( 1326 | "Generating a dynamic segment with sub-delims or ':' or '@' does not encode them", 1327 | (assert: Assert) => { 1328 | // See https://tools.ietf.org/html/rfc3986#section-2.2 1329 | const subDelims = ["!", "$", "&", "'", "(", ")", "*", "+", ",", ";", "="]; 1330 | const others = [":", "@"]; 1331 | 1332 | const chars = subDelims.concat(others); 1333 | 1334 | chars.forEach(function(char) { 1335 | const route = "post"; 1336 | const params = { id: char }; 1337 | const expected = "/posts/" + char; 1338 | 1339 | assert.equal( 1340 | router.generate(route, params), 1341 | expected, 1342 | "Char '" + char + "' is not encoded when generating dynamic segment" 1343 | ); 1344 | }); 1345 | } 1346 | ); 1347 | 1348 | QUnit.test( 1349 | "Generating a dynamic segment with general delimiters (except ':' and '@') encodes them", 1350 | (assert: Assert) => { 1351 | // See https://tools.ietf.org/html/rfc3986#section-2.2 1352 | const genDelims = [":", "/", "?", "#", "[", "]", "@"]; 1353 | const exclude = [":", "@"]; 1354 | const chars = genDelims.filter(function(ch) { 1355 | return !exclude.includes(ch); 1356 | }); 1357 | 1358 | chars.forEach(function(char) { 1359 | const route = "post"; 1360 | const params = { id: char }; 1361 | const encoded = encodeURIComponent(char); 1362 | assert.ok( 1363 | char !== encoded, 1364 | "precond - encoded '" + char + "' is different ('" + encoded + "')" 1365 | ); 1366 | const expected = "/posts/" + encoded; 1367 | 1368 | assert.equal( 1369 | router.generate(route, params), 1370 | expected, 1371 | "Char '" + 1372 | char + 1373 | "' is encoded to '" + 1374 | encoded + 1375 | "' when generating dynamic segment" 1376 | ); 1377 | }); 1378 | } 1379 | ); 1380 | 1381 | QUnit.test( 1382 | "Generating a dynamic segment with miscellaneous other values encodes correctly", 1383 | (assert: Assert) => { 1384 | const expectations = [ 1385 | { 1386 | // "/" 1387 | id: "abc/def", 1388 | expected: "abc%2Fdef" 1389 | }, 1390 | { 1391 | // percent 1392 | id: "abc%def", 1393 | expected: "abc%25def" 1394 | }, 1395 | { 1396 | // all sub-delims 1397 | id: "!$&'()*+,;=", 1398 | expected: "!$&'()*+,;=" 1399 | }, 1400 | { 1401 | // mix of unreserved and sub-delims 1402 | id: "@abc!def$", 1403 | expected: "@abc!def$" 1404 | }, 1405 | { 1406 | // mix of chars that should and should not be encoded 1407 | id: "abc?def!ghi#jkl", 1408 | expected: "abc%3Fdef!ghi%23jkl" 1409 | }, 1410 | { 1411 | // non-string value should get coerced to string 1412 | id: 1, 1413 | expected: "1" 1414 | } 1415 | ]; 1416 | 1417 | const route = "post"; 1418 | expectations.forEach(function(expectation) { 1419 | const params = { id: expectation.id }; 1420 | const expected = "/posts/" + expectation.expected; 1421 | 1422 | assert.equal( 1423 | router.generate(route, params), 1424 | expected, 1425 | "id '" + params.id + "' is generated correctly" 1426 | ); 1427 | }); 1428 | } 1429 | ); 1430 | 1431 | const globGenerationValues = [ 1432 | "abc/def", 1433 | "abc%2Fdef", 1434 | "abc def", 1435 | "abc%20def", 1436 | "abc%25def", 1437 | "café", 1438 | "caf%C3%A9", 1439 | "/leading-slash", 1440 | "leading-slash/", 1441 | "http://example.com/abc.html?foo=bar", 1442 | encodeURIComponent("http://example.com/abc.html?foo=bar") 1443 | ]; 1444 | 1445 | globGenerationValues.forEach(value => { 1446 | QUnit.test( 1447 | "Generating a star segment glob route with param '" + 1448 | value + 1449 | "' passes value through without modification", 1450 | (assert: Assert) => { 1451 | assert.equal( 1452 | router.generate("catchall", { catchall: value }), 1453 | "/" + value 1454 | ); 1455 | } 1456 | ); 1457 | }); 1458 | 1459 | QUnit.test( 1460 | "Throws when generating dynamic routes with an empty string", 1461 | (assert: Assert) => { 1462 | const router = new RouteRecognizer(); 1463 | router.add( 1464 | [ 1465 | { path: "/posts", handler: "posts" }, 1466 | { path: "/*secret/create", handler: "create" } 1467 | ], 1468 | { as: "create" } 1469 | ); 1470 | router.add( 1471 | [ 1472 | { path: "/posts", handler: "posts" }, 1473 | { path: "/:secret/edit", handler: "edit" } 1474 | ], 1475 | { as: "edit" } 1476 | ); 1477 | 1478 | assert.throws(() => { 1479 | router.generate("create", { secret: "" }); 1480 | }, /You must provide a param `secret`./); 1481 | assert.throws(() => { 1482 | router.generate("edit", { secret: "" }); 1483 | }, /You must provide a param `secret`./); 1484 | } 1485 | ); 1486 | 1487 | QUnit.test( 1488 | "Fails reasonably when bad params passed to dynamic segment", 1489 | (assert: Assert) => { 1490 | const router = new RouteRecognizer(); 1491 | router.add( 1492 | [ 1493 | { path: "/posts", handler: "posts" }, 1494 | { path: "/*secret/create", handler: "create" } 1495 | ], 1496 | { as: "create" } 1497 | ); 1498 | router.add( 1499 | [ 1500 | { path: "/posts", handler: "posts" }, 1501 | { path: "/:secret/edit", handler: "edit" } 1502 | ], 1503 | { as: "edit" } 1504 | ); 1505 | 1506 | assert.throws( 1507 | function() { 1508 | router.generate("edit"); 1509 | }, 1510 | /You must pass an object as the second argument to `generate`./, 1511 | "No argument passed." 1512 | ); 1513 | 1514 | assert.throws( 1515 | function() { 1516 | router.generate("edit", false as never); 1517 | }, 1518 | /You must pass an object as the second argument to `generate`./, 1519 | "Boolean passed." 1520 | ); 1521 | 1522 | assert.throws( 1523 | function() { 1524 | router.generate("edit", null); 1525 | }, 1526 | /You must pass an object as the second argument to `generate`./, 1527 | "`null` passed." 1528 | ); 1529 | 1530 | assert.throws( 1531 | function() { 1532 | router.generate("edit", "123" as never); 1533 | }, 1534 | /You must pass an object as the second argument to `generate`./, 1535 | "String passed." 1536 | ); 1537 | 1538 | assert.throws( 1539 | function() { 1540 | router.generate("edit", new String("foo") as never); 1541 | }, 1542 | /You must provide param `secret` to `generate`./, 1543 | "`new String()` passed." 1544 | ); 1545 | 1546 | assert.throws( 1547 | function() { 1548 | router.generate("edit", [] as never); 1549 | }, 1550 | /You must provide param `secret` to `generate`./, 1551 | "Array passed." 1552 | ); 1553 | 1554 | assert.throws( 1555 | function() { 1556 | router.generate("edit", {}); 1557 | }, 1558 | /You must provide param `secret` to `generate`./, 1559 | "Object without own property passed." 1560 | ); 1561 | 1562 | assert.throws( 1563 | function() { 1564 | router.generate("create"); 1565 | }, 1566 | /You must pass an object as the second argument to `generate`./, 1567 | "No argument passed." 1568 | ); 1569 | 1570 | assert.throws( 1571 | function() { 1572 | router.generate("create", false as never); 1573 | }, 1574 | /You must pass an object as the second argument to `generate`./, 1575 | "Boolean passed." 1576 | ); 1577 | 1578 | assert.throws( 1579 | function() { 1580 | router.generate("create", null); 1581 | }, 1582 | /You must pass an object as the second argument to `generate`./, 1583 | "`null` passed." 1584 | ); 1585 | 1586 | assert.throws( 1587 | function() { 1588 | router.generate("create", "123" as never); 1589 | }, 1590 | /You must pass an object as the second argument to `generate`./, 1591 | "String passed." 1592 | ); 1593 | 1594 | assert.throws( 1595 | function() { 1596 | router.generate("create", new String("foo") as never); 1597 | }, 1598 | /You must provide param `secret` to `generate`./, 1599 | "`new String()` passed." 1600 | ); 1601 | 1602 | assert.throws( 1603 | function() { 1604 | router.generate("create", [] as never); 1605 | }, 1606 | /You must provide param `secret` to `generate`./, 1607 | "Array passed." 1608 | ); 1609 | 1610 | assert.throws( 1611 | function() { 1612 | router.generate("create", {}); 1613 | }, 1614 | /You must provide param `secret` to `generate`./, 1615 | "Object without own property passed." 1616 | ); 1617 | } 1618 | ); 1619 | 1620 | // QUnit.test("Prevents duplicate additions of the same named route.", (assert: Assert) => { 1621 | // let router = new RouteRecognizer(); 1622 | // router.add([{ path: "/posts/:id/foo", handler: "post" }], { as: "post" }); 1623 | 1624 | // assert.throws(function() { 1625 | // router.add([{ path: "/posts/:id", handler: "post" }], { as: "post" }); 1626 | // }, /You may not add a duplicate route named `post`./, "Attempting to clobber an existing route."); 1627 | // }); 1628 | 1629 | QUnit.test( 1630 | "Parsing and generation results into the same input string", 1631 | (assert: Assert) => { 1632 | const query = "filter%20data=date"; 1633 | assert.equal( 1634 | router.generateQueryString(router.parseQueryString(query)), 1635 | "?" + query 1636 | ); 1637 | } 1638 | ); 1639 | 1640 | QUnit.test("Generation works with query params", (assert: Assert) => { 1641 | assert.equal( 1642 | router.generate("index", { queryParams: { filter: "date" } }), 1643 | "/?filter=date" 1644 | ); 1645 | assert.equal( 1646 | router.generate("index", { queryParams: { filter: true } }), 1647 | "/?filter=true" 1648 | ); 1649 | assert.equal( 1650 | router.generate("posts", { queryParams: { sort: "title" } }), 1651 | "/posts?sort=title" 1652 | ); 1653 | assert.equal( 1654 | router.generate("edit_post", { 1655 | id: 1, 1656 | queryParams: { format: "markdown" } 1657 | }), 1658 | "/posts/1/edit?format=markdown" 1659 | ); 1660 | assert.equal( 1661 | router.generate("edit_post", { id: 1, queryParams: { editor: "ace" } }), 1662 | "/posts/1/edit?editor=ace" 1663 | ); 1664 | assert.equal( 1665 | router.generate("edit_post", { 1666 | id: 1, 1667 | queryParams: { format: "markdown", editor: "ace" } 1668 | }), 1669 | "/posts/1/edit?editor=ace&format=markdown" 1670 | ); 1671 | assert.equal( 1672 | router.generate("edit_post", { 1673 | id: 1, 1674 | queryParams: { format: "markdown", editor: "ace" } 1675 | }), 1676 | "/posts/1/edit?editor=ace&format=markdown" 1677 | ); 1678 | assert.equal( 1679 | router.generate("edit_post", { 1680 | id: 1, 1681 | queryParams: { format: true, editor: "ace" } 1682 | }), 1683 | "/posts/1/edit?editor=ace&format=true" 1684 | ); 1685 | assert.equal( 1686 | router.generate("edit_post", { 1687 | id: 1, 1688 | queryParams: { format: "markdown", editor: true } 1689 | }), 1690 | "/posts/1/edit?editor=true&format=markdown" 1691 | ); 1692 | assert.equal( 1693 | router.generate("foo", { bar: 9, bat: 10, queryParams: { a: 1 } }), 1694 | "/foo/9/baz/10?a=1" 1695 | ); 1696 | assert.equal( 1697 | router.generate("foo", { bar: 9, bat: 10, queryParams: { b: 2 } }), 1698 | "/foo/9/baz/10?b=2" 1699 | ); 1700 | assert.equal( 1701 | router.generate("foo", { bar: 9, bat: 10, queryParams: { a: 1, b: 2 } }), 1702 | "/foo/9/baz/10?a=1&b=2" 1703 | ); 1704 | assert.equal( 1705 | router.generate("index", { 1706 | queryParams: { filter: "date", sort: false } 1707 | }), 1708 | "/?filter=date&sort=false" 1709 | ); 1710 | assert.equal( 1711 | router.generate("index", { queryParams: { filter: "date", sort: null } }), 1712 | "/?filter=date" 1713 | ); 1714 | assert.equal( 1715 | router.generate("index", { 1716 | queryParams: { filter: "date", sort: undefined } 1717 | }), 1718 | "/?filter=date" 1719 | ); 1720 | assert.equal( 1721 | router.generate("index", { queryParams: { filter: "date", sort: 0 } }), 1722 | "/?filter=date&sort=0" 1723 | ); 1724 | }); 1725 | 1726 | QUnit.test("Generation works with array query params", (assert: Assert) => { 1727 | assert.equal( 1728 | router.generate("index", { queryParams: { foo: [1, 2, 3] } }), 1729 | "/?foo[]=1&foo[]=2&foo[]=3" 1730 | ); 1731 | }); 1732 | 1733 | QUnit.test( 1734 | "Generation works with controller namespaced array query params", 1735 | (assert: Assert) => { 1736 | assert.equal( 1737 | router.generate("posts", { queryParams: { "foo[bar]": [1, 2, 3] } }), 1738 | "/posts?foo[bar][]=1&foo[bar][]=2&foo[bar][]=3" 1739 | ); 1740 | } 1741 | ); 1742 | 1743 | QUnit.test( 1744 | "Empty query params don't have an extra question mark", 1745 | (assert: Assert) => { 1746 | assert.equal(router.generate("index", { queryParams: {} }), "/"); 1747 | assert.equal(router.generate("index", { queryParams: null }), "/"); 1748 | assert.equal(router.generate("posts", { queryParams: {} }), "/posts"); 1749 | assert.equal(router.generate("posts", { queryParams: null }), "/posts"); 1750 | assert.equal( 1751 | router.generate("posts", { queryParams: { foo: null } }), 1752 | "/posts" 1753 | ); 1754 | assert.equal( 1755 | router.generate("posts", { queryParams: { foo: undefined } }), 1756 | "/posts" 1757 | ); 1758 | } 1759 | ); 1760 | 1761 | QUnit.test("Generating an invalid named route raises", (assert: Assert) => { 1762 | assert.throws(function() { 1763 | router.generate("nope"); 1764 | }, /There is no route named nope/); 1765 | }); 1766 | 1767 | QUnit.test("Getting the handlers for a named route", (assert: Assert) => { 1768 | assert.deepEqual(router.handlersFor("post"), [ 1769 | { handler: handlers[0], names: ["id"], shouldDecodes: [true] } 1770 | ]); 1771 | assert.deepEqual(router.handlersFor("posts"), [ 1772 | { handler: handlers[1], names: [], shouldDecodes: [] } 1773 | ]); 1774 | assert.deepEqual(router.handlersFor("new_post"), [ 1775 | { handler: handlers[2], names: [], shouldDecodes: [] } 1776 | ]); 1777 | assert.deepEqual(router.handlersFor("edit_post"), [ 1778 | { handler: handlers[3], names: ["id"], shouldDecodes: [true] } 1779 | ]); 1780 | assert.deepEqual(router.handlersFor("catchall"), [ 1781 | { handler: handlers[5], names: ["catchall"], shouldDecodes: [false] } 1782 | ]); 1783 | }); 1784 | 1785 | QUnit.test( 1786 | "Getting a handler for an invalid named route raises", 1787 | (assert: Assert) => { 1788 | assert.throws(function() { 1789 | router.handlersFor("nope"); 1790 | }, /There is no route named nope/); 1791 | } 1792 | ); 1793 | 1794 | QUnit.test( 1795 | "Matches the route with the longer static prefix", 1796 | (assert: Assert) => { 1797 | const handler1 = { handler: 1 }; 1798 | const handler2 = { handler: 2 }; 1799 | const router = new RouteRecognizer<{ handler: number }>(); 1800 | 1801 | router.add([ 1802 | { path: "/static", handler: handler2 }, 1803 | { path: "/", handler: handler2 } 1804 | ]); 1805 | router.add([ 1806 | { path: "/:dynamic", handler: handler1 }, 1807 | { path: "/", handler: handler1 } 1808 | ]); 1809 | 1810 | resultsMatch(assert, router.recognize("/static"), [ 1811 | { handler: handler2, params: {}, isDynamic: false }, 1812 | { handler: handler2, params: {}, isDynamic: false } 1813 | ]); 1814 | } 1815 | ); 1816 | 1817 | // Re: https://github.com/emberjs/ember.js/issues/13960 1818 | QUnit.test( 1819 | "Matches the route with the longer static prefix with nesting", 1820 | (assert: Assert) => { 1821 | const handler1 = { handler: 1 }; 1822 | const handler2 = { handler: 2 }; 1823 | const handler3 = { handler: 3 }; 1824 | const router = new RouteRecognizer<{ handler: number }>(); 1825 | 1826 | router.add([ 1827 | { path: "/", handler: handler1 } /* application route */, 1828 | { path: "/", handler: handler1 } /* posts route */, 1829 | { path: ":post_id", handler: handler1 } 1830 | ]); 1831 | router.add([ 1832 | { path: "/", handler: handler3 } /* application route */, 1833 | { path: "/team", handler: handler3 }, 1834 | { path: ":user_slug", handler: handler3 } 1835 | ]); 1836 | router.add([ 1837 | { path: "/", handler: handler2 } /* application route */, 1838 | { path: "/team", handler: handler2 }, 1839 | { path: "/", handler: handler2 } /* index route */ 1840 | ]); 1841 | 1842 | resultsMatch(assert, router.recognize("/5"), [ 1843 | { handler: handler1, params: {}, isDynamic: false }, 1844 | { handler: handler1, params: {}, isDynamic: false }, 1845 | // eslint-disable-next-line @typescript-eslint/camelcase 1846 | { handler: handler1, params: { post_id: "5" }, isDynamic: true } 1847 | ]); 1848 | 1849 | resultsMatch(assert, router.recognize("/team"), [ 1850 | { handler: handler2, params: {}, isDynamic: false }, 1851 | { handler: handler2, params: {}, isDynamic: false }, 1852 | { handler: handler2, params: {}, isDynamic: false } 1853 | ]); 1854 | 1855 | resultsMatch(assert, router.recognize("/team/eww_slugs"), [ 1856 | { handler: handler3, params: {}, isDynamic: false }, 1857 | { handler: handler3, params: {}, isDynamic: false }, 1858 | { 1859 | handler: handler3, 1860 | // eslint-disable-next-line @typescript-eslint/camelcase 1861 | params: { user_slug: "eww_slugs" }, 1862 | isDynamic: true 1863 | } 1864 | ]); 1865 | } 1866 | ); 1867 | }); 1868 | -------------------------------------------------------------------------------- /tests/router-tests.ts: -------------------------------------------------------------------------------- 1 | /* globals QUnit */ 2 | import RouteRecognizer, { 3 | QueryParams, 4 | Result, 5 | Results 6 | } from "../lib/route-recognizer"; 7 | 8 | let router: RouteRecognizer; 9 | 10 | function resultsMatch( 11 | assert: Assert, 12 | results: Results | undefined, 13 | array: Result[], 14 | queryParams?: QueryParams 15 | ): void { 16 | assert.deepEqual(results && results.slice(), array); 17 | if (queryParams) { 18 | assert.deepEqual(results && results.queryParams, queryParams); 19 | } 20 | } 21 | 22 | function matchesRoute( 23 | assert: Assert, 24 | path: string, 25 | expected: Result[], 26 | queryParams?: QueryParams 27 | ): void { 28 | const actual = router.recognize(path); 29 | resultsMatch(assert, actual, expected, queryParams); 30 | } 31 | 32 | QUnit.module("The match DSL", hooks => { 33 | hooks.beforeEach(() => { 34 | router = new RouteRecognizer(); 35 | }); 36 | 37 | QUnit.test("supports multiple calls to match", assert => { 38 | router.map(function(match) { 39 | match("/posts/new").to("newPost"); 40 | match("/posts/:id").to("showPost"); 41 | match("/posts/edit").to("editPost"); 42 | }); 43 | 44 | matchesRoute(assert, "/posts/new", [ 45 | { handler: "newPost", params: {}, isDynamic: false } 46 | ]); 47 | matchesRoute(assert, "/posts/1", [ 48 | { handler: "showPost", params: { id: "1" }, isDynamic: true } 49 | ]); 50 | matchesRoute(assert, "/posts/edit", [ 51 | { handler: "editPost", params: {}, isDynamic: false } 52 | ]); 53 | }); 54 | 55 | QUnit.test( 56 | "supports multiple calls to match with query params", 57 | (assert: Assert) => { 58 | router.map(function(match) { 59 | match("/posts/new").to("newPost"); 60 | match("/posts/:id").to("showPost"); 61 | match("/posts/edit").to("editPost"); 62 | }); 63 | 64 | matchesRoute( 65 | assert, 66 | "/posts/new?foo=1&bar=2", 67 | [{ handler: "newPost", params: {}, isDynamic: false }], 68 | { foo: "1", bar: "2" } 69 | ); 70 | matchesRoute( 71 | assert, 72 | "/posts/1?baz=3", 73 | [{ handler: "showPost", params: { id: "1" }, isDynamic: true }], 74 | { baz: "3" } 75 | ); 76 | matchesRoute( 77 | assert, 78 | "/posts/edit", 79 | [{ handler: "editPost", params: {}, isDynamic: false }], 80 | {} 81 | ); 82 | } 83 | ); 84 | 85 | QUnit.test("supports nested match", (assert: Assert) => { 86 | router.map(function(match) { 87 | match("/posts", function(match) { 88 | match("/new").to("newPost"); 89 | match("/:id").to("showPost"); 90 | match("/edit").to("editPost"); 91 | }); 92 | }); 93 | 94 | matchesRoute(assert, "/posts/new", [ 95 | { handler: "newPost", params: {}, isDynamic: false } 96 | ]); 97 | matchesRoute(assert, "/posts/1", [ 98 | { handler: "showPost", params: { id: "1" }, isDynamic: true } 99 | ]); 100 | matchesRoute(assert, "/posts/edit", [ 101 | { handler: "editPost", params: {}, isDynamic: false } 102 | ]); 103 | }); 104 | 105 | QUnit.test( 106 | "support nested dynamic routes and star route", 107 | (assert: Assert) => { 108 | router.map(function(match) { 109 | match("/:routeId").to("routeId", function(match) { 110 | match("/").to("routeId.index"); 111 | match("/:subRouteId").to("subRouteId"); 112 | }); 113 | match("/*wildcard").to("wildcard"); 114 | }); 115 | 116 | // fails because it incorrectly matches the wildcard route 117 | matchesRoute(assert, "/abc", [ 118 | { handler: "routeId", params: { routeId: "abc" }, isDynamic: true }, 119 | { handler: "routeId.index", params: {}, isDynamic: false } 120 | ]); 121 | 122 | // passes 123 | matchesRoute(assert, "/abc/def", [ 124 | { handler: "routeId", params: { routeId: "abc" }, isDynamic: true }, 125 | { 126 | handler: "subRouteId", 127 | params: { subRouteId: "def" }, 128 | isDynamic: true 129 | } 130 | ]); 131 | 132 | // fails because no route is recognized 133 | matchesRoute(assert, "/abc/def/ghi", [ 134 | { 135 | handler: "wildcard", 136 | params: { wildcard: "abc/def/ghi" }, 137 | isDynamic: true 138 | } 139 | ]); 140 | } 141 | ); 142 | 143 | QUnit.test("supports nested match with query params", (assert: Assert) => { 144 | router.map(function(match) { 145 | match("/posts", function(match) { 146 | match("/new").to("newPost"); 147 | match("/:id").to("showPost"); 148 | match("/edit").to("editPost"); 149 | }); 150 | }); 151 | 152 | matchesRoute( 153 | assert, 154 | "/posts/new?foo=1&bar=2", 155 | [{ handler: "newPost", params: {}, isDynamic: false }], 156 | { foo: "1", bar: "2" } 157 | ); 158 | matchesRoute( 159 | assert, 160 | "/posts/1?baz=3", 161 | [{ handler: "showPost", params: { id: "1" }, isDynamic: true }], 162 | { baz: "3" } 163 | ); 164 | matchesRoute( 165 | assert, 166 | "/posts/edit", 167 | [{ handler: "editPost", params: {}, isDynamic: false }], 168 | {} 169 | ); 170 | }); 171 | 172 | QUnit.test( 173 | "not passing a function with `match` as a parameter raises", 174 | (assert: Assert) => { 175 | assert.throws(function() { 176 | router.map(function(match) { 177 | match("/posts").to("posts", () => void 0); 178 | }); 179 | }); 180 | } 181 | ); 182 | 183 | QUnit.test("supports nested handlers", (assert: Assert) => { 184 | router.map(function(match) { 185 | match("/posts").to("posts", function(match) { 186 | match("/new").to("newPost"); 187 | match("/:id").to("showPost"); 188 | match("/edit").to("editPost"); 189 | }); 190 | }); 191 | 192 | matchesRoute(assert, "/posts/new", [ 193 | { handler: "posts", params: {}, isDynamic: false }, 194 | { handler: "newPost", params: {}, isDynamic: false } 195 | ]); 196 | matchesRoute(assert, "/posts/1", [ 197 | { handler: "posts", params: {}, isDynamic: false }, 198 | { handler: "showPost", params: { id: "1" }, isDynamic: true } 199 | ]); 200 | matchesRoute(assert, "/posts/edit", [ 201 | { handler: "posts", params: {}, isDynamic: false }, 202 | { handler: "editPost", params: {}, isDynamic: false } 203 | ]); 204 | }); 205 | 206 | QUnit.test("supports deeply nested handlers", (assert: Assert) => { 207 | router.map(function(match) { 208 | match("/posts").to("posts", function(match) { 209 | match("/new").to("newPost"); 210 | match("/:id").to("showPost", function(match) { 211 | match("/index").to("postIndex"); 212 | match("/comments").to("postComments"); 213 | }); 214 | match("/edit").to("editPost"); 215 | }); 216 | }); 217 | 218 | matchesRoute(assert, "/posts/new", [ 219 | { handler: "posts", params: {}, isDynamic: false }, 220 | { handler: "newPost", params: {}, isDynamic: false } 221 | ]); 222 | matchesRoute(assert, "/posts/1/index", [ 223 | { handler: "posts", params: {}, isDynamic: false }, 224 | { handler: "showPost", params: { id: "1" }, isDynamic: true }, 225 | { handler: "postIndex", params: {}, isDynamic: false } 226 | ]); 227 | matchesRoute(assert, "/posts/1/comments", [ 228 | { handler: "posts", params: {}, isDynamic: false }, 229 | { handler: "showPost", params: { id: "1" }, isDynamic: true }, 230 | { handler: "postComments", params: {}, isDynamic: false } 231 | ]); 232 | matchesRoute(assert, "/posts/ne/comments", [ 233 | { handler: "posts", params: {}, isDynamic: false }, 234 | { handler: "showPost", params: { id: "ne" }, isDynamic: true }, 235 | { handler: "postComments", params: {}, isDynamic: false } 236 | ]); 237 | matchesRoute(assert, "/posts/edit", [ 238 | { handler: "posts", params: {}, isDynamic: false }, 239 | { handler: "editPost", params: {}, isDynamic: false } 240 | ]); 241 | }); 242 | 243 | QUnit.test("supports index-style routes", (assert: Assert) => { 244 | router.map(function(match) { 245 | match("/posts").to("posts", function(match) { 246 | match("/new").to("newPost"); 247 | match("/:id").to("showPost", function(match) { 248 | match("/").to("postIndex"); 249 | match("/comments").to("postComments"); 250 | }); 251 | match("/edit").to("editPost"); 252 | }); 253 | }); 254 | 255 | matchesRoute(assert, "/posts/new", [ 256 | { handler: "posts", params: {}, isDynamic: false }, 257 | { handler: "newPost", params: {}, isDynamic: false } 258 | ]); 259 | matchesRoute(assert, "/posts/1", [ 260 | { handler: "posts", params: {}, isDynamic: false }, 261 | { handler: "showPost", params: { id: "1" }, isDynamic: true }, 262 | { handler: "postIndex", params: {}, isDynamic: false } 263 | ]); 264 | matchesRoute(assert, "/posts/1/comments", [ 265 | { handler: "posts", params: {}, isDynamic: false }, 266 | { handler: "showPost", params: { id: "1" }, isDynamic: true }, 267 | { handler: "postComments", params: {}, isDynamic: false } 268 | ]); 269 | matchesRoute(assert, "/posts/edit", [ 270 | { handler: "posts", params: {}, isDynamic: false }, 271 | { handler: "editPost", params: {}, isDynamic: false } 272 | ]); 273 | }); 274 | 275 | QUnit.test("supports single `/` routes", (assert: Assert) => { 276 | router.map(function(match) { 277 | match("/").to("posts"); 278 | }); 279 | 280 | matchesRoute(assert, "/", [ 281 | { handler: "posts", params: {}, isDynamic: false } 282 | ]); 283 | }); 284 | 285 | QUnit.test("supports star routes", (assert: Assert) => { 286 | router.map(function(match) { 287 | match("/").to("posts"); 288 | match("/*everything").to("404"); 289 | }); 290 | 291 | // randomly generated strings 292 | [ 293 | "w6PCXxJn20PCSievuP", 294 | "v2y0gaByxHjHYJw0pVT1TeqbEJLllVq-3", 295 | "DFCR4rm7XMbT6CPZq-d8AU7k", 296 | "d3vYEg1AoYaPlM9QbOAxEK6u/H_S-PYH1aYtt" 297 | ].forEach(function(r) { 298 | matchesRoute(assert, "/" + r, [ 299 | { handler: "404", params: { everything: r }, isDynamic: true } 300 | ]); 301 | }); 302 | }); 303 | 304 | QUnit.test("star route does not swallow trailing `/`", (assert: Assert) => { 305 | router.map(function(match) { 306 | match("/").to("posts"); 307 | match("/*everything").to("glob"); 308 | }); 309 | 310 | const r = "folder1/folder2/folder3/"; 311 | matchesRoute(assert, "/" + r, [ 312 | { handler: "glob", params: { everything: r }, isDynamic: true } 313 | ]); 314 | }); 315 | 316 | QUnit.test("support star route before other segment", (assert: Assert) => { 317 | router.map(function(match) { 318 | match("/*everything/:extra").to("glob"); 319 | }); 320 | 321 | [ 322 | "folder1/folder2/folder3//the-extra-stuff/", 323 | "folder1/folder2/folder3//the-extra-stuff" 324 | ].forEach(function(r) { 325 | matchesRoute(assert, "/" + r, [ 326 | { 327 | handler: "glob", 328 | params: { 329 | everything: "folder1/folder2/folder3/", 330 | extra: "the-extra-stuff" 331 | }, 332 | isDynamic: true 333 | } 334 | ]); 335 | }); 336 | }); 337 | 338 | QUnit.test("support nested star route", (assert: Assert) => { 339 | router.map(function(match) { 340 | match("/*everything").to("glob", function(match) { 341 | match("/:extra").to("extra"); 342 | }); 343 | }); 344 | 345 | [ 346 | "folder1/folder2/folder3//the-extra-stuff/", 347 | "folder1/folder2/folder3//the-extra-stuff" 348 | ].forEach(function(r) { 349 | matchesRoute(assert, "/" + r, [ 350 | { 351 | handler: "glob", 352 | params: { everything: "folder1/folder2/folder3/" }, 353 | isDynamic: true 354 | }, 355 | { 356 | handler: "extra", 357 | params: { extra: "the-extra-stuff" }, 358 | isDynamic: true 359 | } 360 | ]); 361 | }); 362 | }); 363 | 364 | QUnit.test( 365 | "calls a delegate whenever a new context is entered", 366 | (assert: Assert) => { 367 | const passedArguments: string[] = []; 368 | 369 | router.delegate = { 370 | contextEntered: function(name, match) { 371 | assert.ok(match instanceof Function, "The match is a function"); 372 | match("/").to("index"); 373 | passedArguments.push(name); 374 | } 375 | }; 376 | 377 | router.map(function(match) { 378 | match("/").to("application", function(match) { 379 | match("/posts").to("posts", function(match) { 380 | match("/:post_id").to("post"); 381 | }); 382 | }); 383 | }); 384 | 385 | assert.deepEqual( 386 | passedArguments, 387 | ["application", "posts"], 388 | "The entered contexts were passed to contextEntered" 389 | ); 390 | 391 | matchesRoute(assert, "/posts", [ 392 | { handler: "application", params: {}, isDynamic: false }, 393 | { handler: "posts", params: {}, isDynamic: false }, 394 | { handler: "index", params: {}, isDynamic: false } 395 | ]); 396 | } 397 | ); 398 | 399 | QUnit.test("delegate can change added routes", (assert: Assert) => { 400 | router.delegate = { 401 | willAddRoute: function(context, route) { 402 | if (!context) { 403 | return route; 404 | } 405 | context = context.split(".").slice(-1)[0]; 406 | return context + "." + route; 407 | }, 408 | 409 | // Test that both delegates work together 410 | contextEntered: function(_, match) { 411 | match("/").to("index"); 412 | } 413 | }; 414 | 415 | router.map(function(match) { 416 | match("/").to("application", function(match) { 417 | match("/posts").to("posts", function(match) { 418 | match("/:post_id").to("post"); 419 | }); 420 | }); 421 | }); 422 | 423 | matchesRoute(assert, "/posts", [ 424 | { handler: "application", params: {}, isDynamic: false }, 425 | { handler: "application.posts", params: {}, isDynamic: false }, 426 | { handler: "posts.index", params: {}, isDynamic: false } 427 | ]); 428 | matchesRoute(assert, "/posts/1", [ 429 | { handler: "application", params: {}, isDynamic: false }, 430 | { handler: "application.posts", params: {}, isDynamic: false }, 431 | // eslint-disable-next-line @typescript-eslint/camelcase 432 | { handler: "posts.post", params: { post_id: "1" }, isDynamic: true } 433 | ]); 434 | }); 435 | 436 | QUnit.test("supports add-route callback", (assert: Assert) => { 437 | const invocations: string[] = []; 438 | 439 | router.map( 440 | function(match) { 441 | match("/").to("application", function(match) { 442 | match("/loading").to("loading"); 443 | match("/_unused_dummy_error_path_route_application/:error").to( 444 | "error" 445 | ); 446 | match("/lobby").to("lobby", function(match) { 447 | match("/loading").to("lobby.loading"); 448 | match("/_unused_dummy_error_path_route_lobby/:error").to( 449 | "lobby.error" 450 | ); 451 | match(":lobby_id").to("lobby.index"); 452 | match("/list").to("lobby.list"); 453 | }); 454 | match("/").to("index"); 455 | }); 456 | }, 457 | function(router, route) { 458 | invocations.push(route.map(e => e.handler).join(".")); 459 | router.add(route); 460 | } 461 | ); 462 | 463 | const expected = [ 464 | "application.loading", 465 | "application.error", 466 | "application.lobby.lobby.loading", 467 | "application.lobby.lobby.error", 468 | "application.lobby.lobby.index", 469 | "application.lobby.lobby.list", 470 | "application.index" 471 | ]; 472 | 473 | assert.deepEqual( 474 | expected, 475 | invocations, 476 | "invokes for the correct set of routes" 477 | ); 478 | matchesRoute(assert, "/lobby/loading", [ 479 | { handler: "application", params: {}, isDynamic: false }, 480 | { handler: "lobby", params: {}, isDynamic: false }, 481 | { handler: "lobby.loading", params: {}, isDynamic: false } 482 | ]); 483 | }); 484 | }); 485 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2015", 4 | "moduleResolution": "node", 5 | "target": "es2015", 6 | "declaration": true, 7 | "strict": true, 8 | "outDir": "dist", 9 | "sourceMap": true 10 | }, 11 | "include": ["lib/route-recognizer.ts", "tests/*.ts"] 12 | } 13 | --------------------------------------------------------------------------------