├── .gitignore ├── .travis.yml ├── .zuul.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── bower.json ├── contributing.md ├── index.d.ts ├── lib └── url-pattern.js ├── package.json ├── src └── url-pattern.coffee └── test ├── ast.coffee ├── errors.coffee ├── helpers.coffee ├── match-fixtures.coffee ├── misc.coffee ├── parser.coffee ├── readme.coffee └── stringify-fixtures.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/** 2 | test/url-pattern.js 3 | **.DS_Store 4 | npm-debug.log 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | - "iojs-3" 5 | - "4" 6 | - "5" 7 | script: npm run $NPM_COMMAND 8 | sudo: false 9 | env: 10 | global: 11 | # SAUCE_USERNAME 12 | - secure: "Js6Pr7dJfvAKY5JuuuEJSrDvoBCrnjTjISMCPBmH0CkGwtGR7J2mCvaLcXQ9RO3zrSasdj8Rb6gBmrIgk1fQbp/NpwQVwMUPH4J+dhbwTHIrrIHVtxt6q8cPx43RJqjE6qN+G1MA/Y4IVbgAzjJPnzu6A6v7E/FzSFbpNilv2i4=" 13 | # SAUCE_ACCESS_KEY 14 | - secure: "idJFmSy6EyMNO9UoxUx0wG83G/w8H1Sh1fG5lWodAdV01/Ft0j3KQo/zelENBx7zMWf+iqdWOhL4rBLIIkaajHbmvkMYDzhFXK4GIZmd1HnV4MZCunipscMsEbtQU+uTY/I3fersnIz74aTuj3SKlFW4jVNgvc8fawijBtTbuhU=" 15 | matrix: 16 | - NPM_COMMAND=test 17 | - NPM_COMMAND=test-with-coverage 18 | - NPM_COMMAND=test-in-browsers 19 | matrix: 20 | exclude: 21 | # don't test in browsers more than once (already done with node 5) 22 | - node_js: "0.12" 23 | env: NPM_COMMAND=test-in-browsers 24 | - node_js: "iojs-3" 25 | env: NPM_COMMAND=test-in-browsers 26 | - node_js: "4" 27 | env: NPM_COMMAND=test-in-browsers 28 | # don't collect code coverage more than once (already done with node 5) 29 | - node_js: "0.12" 30 | env: NPM_COMMAND=test-with-coverage 31 | - node_js: "iojs-3" 32 | env: NPM_COMMAND=test-with-coverage 33 | - node_js: "4" 34 | env: NPM_COMMAND=test-with-coverage 35 | # already tested with coverage (with node 5). no need to test again without 36 | - node_js: "5" 37 | env: NPM_COMMAND=test 38 | -------------------------------------------------------------------------------- /.zuul.yml: -------------------------------------------------------------------------------- 1 | ui: tape 2 | browsers: 3 | - name: ie 4 | version: [9, 10, 11] 5 | - name: chrome 6 | version: latest 7 | - name: firefox 8 | version: latest 9 | - name: opera 10 | version: [11, 12] 11 | - name: safari 12 | version: latest 13 | # - name: iphone 14 | # version: latest 15 | # - name: android 16 | # version: latest 17 | browserify: 18 | - transform: coffeeify 19 | browser_retries: 0 20 | # 3 minutes 21 | browser_output_timeout: 120000 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # changelog 2 | 3 | #### 0.7 4 | 5 | instead of 6 | 7 | ``` javascript 8 | var urlPattern = require('url-pattern'); 9 | var pattern = urlPattern.newPattern('/example'); 10 | ``` 11 | 12 | now use 13 | 14 | ``` javascript 15 | var Pattern = require('url-pattern'); 16 | var pattern = new Pattern('/example'); 17 | ``` 18 | 19 | #### 0.8 20 | 21 | single wildcard matches are now saved directly as a 22 | string on the `_` property and not as an array with 1 element: 23 | 24 | ``` javascript 25 | > var pattern = new Pattern('/api/*'); 26 | > pattern.match('/api/users/5') 27 | {_: 'users/5'} 28 | ``` 29 | 30 | if named segments occur more than once the results are collected in an array. 31 | 32 | parsing of named segment names (`:foo`) and named segment values now 33 | stops at the next non-alphanumeric character. 34 | it is no longer needed to declare separators other than `/` explicitely. 35 | it was previously necessary to use the second argument to `new UrlPattern` to 36 | override the default separator `/`. 37 | the second argument is now ignored. 38 | mixing of separators is now possible (`/` and `.` in this example): 39 | 40 | ``` javascript 41 | > var pattern = new UrlPattern('/v:major(.:minor)/*'); 42 | 43 | > pattern.match('/v1.2/'); 44 | {major: '1', minor: '2', _: ''} 45 | 46 | > pattern.match('/v2/users'); 47 | {major: '2', _: 'users'} 48 | 49 | > pattern.match('/v/'); 50 | null 51 | ``` 52 | 53 | #### 0.9 54 | 55 | named segments now also match `-`, `_`, ` ` and `%`. 56 | 57 | `\\` can now be used to escape characters. 58 | 59 | [made all special chars and charsets used in parsing configurable.](https://github.com/snd/url-pattern#customize-the-pattern-syntax) 60 | 61 | added [bower.json](bower.json) and registered with bower as `url-pattern`. 62 | 63 | #### 0.10 64 | 65 | [issue 15](https://github.com/snd/url-pattern/issues/15): 66 | named segments now also match the `~` character. 67 | **this will break your code if you relied on the fact that named segments 68 | stop matching at `~` !** 69 | [you can customize the parsing to go back to the old behaviour](https://github.com/snd/url-pattern#customize-the-pattern-syntax) 70 | 71 | the way the parser is customized has changed. 72 | **this will break your code if you customized the parser !** 73 | [read me](https://github.com/snd/url-pattern#customize-the-pattern-syntax) 74 | updating your code is very easy. 75 | 76 | [issue 14](https://github.com/snd/url-pattern/issues/14): 77 | [read me](https://github.com/snd/url-pattern#make-pattern-from-regex) 78 | non breaking 79 | 80 | [issue 11](https://github.com/snd/url-pattern/issues/11): 81 | [read me](https://github.com/snd/url-pattern#stringify-patterns) 82 | non breaking 83 | 84 | messages on errors thrown on invalid patterns have changed slightly. 85 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. 6 | 7 | Examples of unacceptable behavior by participants include: 8 | 9 | * The use of sexualized language or imagery 10 | * Personal attacks 11 | * Trolling or insulting/derogatory comments 12 | * Public or private harassment 13 | * Publishing other's private information, such as physical or electronic addresses, without explicit permission 14 | * Other unethical or unprofessional conduct. 15 | 16 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. 17 | 18 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 19 | 20 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 21 | 22 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.2.0, available at [http://contributor-covenant.org/version/1/2/0/](http://contributor-covenant.org/version/1/2/0/) 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2015 by Maximilian Krüger 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all 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 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # url-pattern 2 | 3 | [![NPM Package](https://img.shields.io/npm/v/url-pattern.svg?style=flat)](https://www.npmjs.org/package/url-pattern) 4 | [![Build Status](https://travis-ci.org/snd/url-pattern.svg?branch=master)](https://travis-ci.org/snd/url-pattern/branches) 5 | [![Sauce Test Status](https://saucelabs.com/buildstatus/urlpattern)](https://saucelabs.com/u/urlpattern) 6 | [![codecov.io](http://codecov.io/github/snd/url-pattern/coverage.svg?branch=master)](http://codecov.io/github/snd/url-pattern?branch=master) 7 | [![Downloads per Month](https://img.shields.io/npm/dm/url-pattern.svg?style=flat)](https://www.npmjs.org/package/url-pattern) 8 | 9 | **easier than regex string matching patterns for urls and other strings. 10 | turn strings into data or data into strings.** 11 | 12 | > This is a great little library -- thanks! 13 | > [michael](https://github.com/snd/url-pattern/pull/7) 14 | 15 | [make pattern:](#make-pattern-from-string) 16 | ``` javascript 17 | var pattern = new UrlPattern('/api/users(/:id)'); 18 | ``` 19 | 20 | [match pattern against string and extract values:](#match-pattern-against-string) 21 | ``` javascript 22 | pattern.match('/api/users/10'); // {id: '10'} 23 | pattern.match('/api/users'); // {} 24 | pattern.match('/api/products/5'); // null 25 | ``` 26 | 27 | [generate string from pattern and values:](#stringify-patterns) 28 | ``` javascript 29 | pattern.stringify() // '/api/users' 30 | pattern.stringify({id: 20}) // '/api/users/20' 31 | ``` 32 | 33 | - continuously tested in Node.js (0.12, 4.2.3 and 5.3) and all relevant browsers: 34 | [![Sauce Test Status](https://saucelabs.com/browser-matrix/urlpattern.svg)](https://saucelabs.com/u/urlpattern) 35 | - [tiny single file with just under 500 lines of simple, readable, maintainable code](src/url-pattern.coffee) 36 | - [huge test suite](test) 37 | passing [![Build Status](https://travis-ci.org/snd/url-pattern.svg?branch=master)](https://travis-ci.org/snd/url-pattern/branches) 38 | with [![codecov.io](http://codecov.io/github/snd/url-pattern/coverage.svg?branch=master)](http://codecov.io/github/snd/url-pattern?branch=master) 39 | code coverage 40 | - widely used [![Downloads per Month](https://img.shields.io/npm/dm/url-pattern.svg?style=flat)](https://www.npmjs.org/package/url-pattern) 41 | - supports CommonJS, [AMD](http://requirejs.org/docs/whyamd.html) and browser globals 42 | - `require('url-pattern')` 43 | - use [lib/url-pattern.js](lib/url-pattern.js) in the browser 44 | - sets the global variable `UrlPattern` when neither CommonJS nor [AMD](http://requirejs.org/docs/whyamd.html) are available. 45 | - very fast matching as each pattern is compiled into a regex exactly once 46 | - zero dependencies 47 | - [customizable](#customize-the-pattern-syntax) 48 | - [frequently asked questions](#frequently-asked-questions) 49 | - npm package: `npm install url-pattern` 50 | - bower package: `bower install url-pattern` 51 | - pattern parser implemented using simple, combosable, testable [parser combinators](https://en.wikipedia.org/wiki/Parser_combinator) 52 | - [typescript typings](index.d.ts) 53 | 54 | [check out **passage** if you are looking for simple composable routing that builds on top of url-pattern](https://github.com/snd/passage) 55 | 56 | ``` 57 | npm install url-pattern 58 | ``` 59 | 60 | ``` 61 | bower install url-pattern 62 | ``` 63 | 64 | ```javascript 65 | > var UrlPattern = require('url-pattern'); 66 | ``` 67 | 68 | ``` javascript 69 | > var pattern = new UrlPattern('/v:major(.:minor)/*'); 70 | 71 | > pattern.match('/v1.2/'); 72 | {major: '1', minor: '2', _: ''} 73 | 74 | > pattern.match('/v2/users'); 75 | {major: '2', _: 'users'} 76 | 77 | > pattern.match('/v/'); 78 | null 79 | ``` 80 | ``` javascript 81 | > var pattern = new UrlPattern('(http(s)\\://)(:subdomain.):domain.:tld(\\::port)(/*)') 82 | 83 | > pattern.match('google.de'); 84 | {domain: 'google', tld: 'de'} 85 | 86 | > pattern.match('https://www.google.com'); 87 | {subdomain: 'www', domain: 'google', tld: 'com'} 88 | 89 | > pattern.match('http://mail.google.com/mail'); 90 | {subdomain: 'mail', domain: 'google', tld: 'com', _: 'mail'} 91 | 92 | > pattern.match('http://mail.google.com:80/mail'); 93 | {subdomain: 'mail', domain: 'google', tld: 'com', port: '80', _: 'mail'} 94 | 95 | > pattern.match('google'); 96 | null 97 | ``` 98 | 99 | ## make pattern from string 100 | 101 | ```javascript 102 | > var pattern = new UrlPattern('/api/users/:id'); 103 | ``` 104 | 105 | a `pattern` is immutable after construction. 106 | none of its methods changes its state. 107 | that makes it easier to reason about. 108 | 109 | ## match pattern against string 110 | 111 | match returns the extracted segments: 112 | 113 | ```javascript 114 | > pattern.match('/api/users/10'); 115 | {id: '10'} 116 | ``` 117 | 118 | or `null` if there was no match: 119 | 120 | ``` javascript 121 | > pattern.match('/api/products/5'); 122 | null 123 | ``` 124 | 125 | patterns are compiled into regexes which makes `.match()` superfast. 126 | 127 | ## named segments 128 | 129 | `:id` (in the example above) is a named segment: 130 | 131 | a named segment starts with `:` followed by the **name**. 132 | the **name** must be at least one character in the regex character set `a-zA-Z0-9`. 133 | 134 | when matching, a named segment consumes all characters in the regex character set 135 | `a-zA-Z0-9-_~ %`. 136 | a named segment match stops at `/`, `.`, ... but not at `_`, `-`, ` `, `%`... 137 | 138 | [you can change these character sets. click here to see how.](#customize-the-pattern-syntax) 139 | 140 | if a named segment **name** occurs more than once in the pattern string, 141 | then the multiple results are stored in an array on the returned object: 142 | 143 | ```javascript 144 | > var pattern = new UrlPattern('/api/users/:ids/posts/:ids'); 145 | > pattern.match('/api/users/10/posts/5'); 146 | {ids: ['10', '5']} 147 | ``` 148 | 149 | ## optional segments, wildcards and escaping 150 | 151 | to make part of a pattern optional just wrap it in `(` and `)`: 152 | 153 | ```javascript 154 | > var pattern = new UrlPattern( 155 | '(http(s)\\://)(:subdomain.):domain.:tld(/*)' 156 | ); 157 | ``` 158 | 159 | note that `\\` escapes the `:` in `http(s)\\://`. 160 | you can use `\\` to escape `(`, `)`, `:` and `*` which have special meaning within 161 | url-pattern. 162 | 163 | optional named segments are stored in the corresponding property only if they are present in the source string: 164 | 165 | ```javascript 166 | > pattern.match('google.de'); 167 | {domain: 'google', tld: 'de'} 168 | ``` 169 | 170 | ```javascript 171 | > pattern.match('https://www.google.com'); 172 | {subdomain: 'www', domain: 'google', tld: 'com'} 173 | ``` 174 | 175 | `*` in patterns are wildcards and match anything. 176 | wildcard matches are collected in the `_` property: 177 | 178 | ```javascript 179 | > pattern.match('http://mail.google.com/mail'); 180 | {subdomain: 'mail', domain: 'google', tld: 'com', _: 'mail'} 181 | ``` 182 | 183 | if there is only one wildcard then `_` contains the matching string. 184 | otherwise `_` contains an array of matching strings. 185 | 186 | [look at the tests for additional examples of `.match`](test/match-fixtures.coffee) 187 | 188 | ## make pattern from regex 189 | 190 | ```javascript 191 | > var pattern = new UrlPattern(/^\/api\/(.*)$/); 192 | ``` 193 | 194 | if the pattern was created from a regex an array of the captured groups is returned on a match: 195 | 196 | ```javascript 197 | > pattern.match('/api/users'); 198 | ['users'] 199 | 200 | > pattern.match('/apiii/test'); 201 | null 202 | ``` 203 | 204 | when making a pattern from a regex 205 | you can pass an array of keys as the second argument. 206 | returns objects on match with each key mapped to a captured value: 207 | 208 | ```javascript 209 | > var pattern = new UrlPattern( 210 | /^\/api\/([^\/]+)(?:\/(\d+))?$/, 211 | ['resource', 'id'] 212 | ); 213 | 214 | > pattern.match('/api/users'); 215 | {resource: 'users'} 216 | 217 | > pattern.match('/api/users/5'); 218 | {resource: 'users', id: '5'} 219 | 220 | > pattern.match('/api/users/foo'); 221 | null 222 | ``` 223 | 224 | ## stringify patterns 225 | 226 | ```javascript 227 | > var pattern = new UrlPattern('/api/users/:id'); 228 | 229 | > pattern.stringify({id: 10}) 230 | '/api/users/10' 231 | ``` 232 | 233 | optional segments are only included in the output if they contain named segments 234 | and/or wildcards and values for those are provided: 235 | 236 | ```javascript 237 | > var pattern = new UrlPattern('/api/users(/:id)'); 238 | 239 | > pattern.stringify() 240 | '/api/users' 241 | 242 | > pattern.stringify({id: 10}) 243 | '/api/users/10' 244 | ``` 245 | 246 | wildcards (key = `_`), deeply nested optional groups and multiple value arrays should stringify as expected. 247 | 248 | an error is thrown if a value that is not in an optional group is not provided. 249 | 250 | an error is thrown if an optional segment contains multiple 251 | params and not all of them are provided. 252 | *one provided value for an optional segment 253 | makes all values in that optional segment required.* 254 | 255 | [look at the tests for additional examples of `.stringify`](test/stringify-fixtures.coffee) 256 | 257 | ## customize the pattern syntax 258 | 259 | finally we can completely change pattern-parsing and regex-compilation to suit our needs: 260 | 261 | ```javascript 262 | > var options = {}; 263 | ``` 264 | 265 | let's change the char used for escaping (default `\\`): 266 | 267 | ```javascript 268 | > options.escapeChar = '!'; 269 | ``` 270 | 271 | let's change the char used to start a named segment (default `:`): 272 | 273 | ```javascript 274 | > options.segmentNameStartChar = '$'; 275 | ``` 276 | 277 | let's change the set of chars allowed in named segment names (default `a-zA-Z0-9`) 278 | to also include `_` and `-`: 279 | 280 | ```javascript 281 | > options.segmentNameCharset = 'a-zA-Z0-9_-'; 282 | ``` 283 | 284 | let's change the set of chars allowed in named segment values 285 | (default `a-zA-Z0-9-_~ %`) to not allow non-alphanumeric chars: 286 | 287 | ```javascript 288 | > options.segmentValueCharset = 'a-zA-Z0-9'; 289 | ``` 290 | 291 | let's change the chars used to surround an optional segment (default `(` and `)`): 292 | 293 | ```javascript 294 | > options.optionalSegmentStartChar = '['; 295 | > options.optionalSegmentEndChar = ']'; 296 | ``` 297 | 298 | let's change the char used to denote a wildcard (default `*`): 299 | 300 | ```javascript 301 | > options.wildcardChar = '?'; 302 | ``` 303 | 304 | pass options as the second argument to the constructor: 305 | 306 | ```javascript 307 | > var pattern = new UrlPattern( 308 | '[http[s]!://][$sub_domain.]$domain.$toplevel-domain[/?]', 309 | options 310 | ); 311 | ``` 312 | 313 | then match: 314 | 315 | ```javascript 316 | > pattern.match('http://mail.google.com/mail'); 317 | { 318 | sub_domain: 'mail', 319 | domain: 'google', 320 | 'toplevel-domain': 'com', 321 | _: 'mail' 322 | } 323 | ``` 324 | 325 | ## frequently asked questions 326 | 327 | ### how do i match the query part of an URL ? 328 | 329 | the query part of an URL has very different semantics than the rest. 330 | url-pattern is not well suited for parsing the query part. 331 | 332 | there are good existing libraries for parsing the query part of an URL. 333 | https://github.com/hapijs/qs is an example. 334 | in the interest of keeping things simple and focused 335 | i see no reason to add special support 336 | for parsing the query part to url-pattern. 337 | 338 | i recommend splitting the URL at `?`, using url-pattern 339 | to parse the first part (scheme, host, port, path) 340 | and using https://github.com/hapijs/qs to parse the last part (query). 341 | 342 | ### how do i match an IP ? 343 | 344 | you can't exactly match IPs with url-pattern so you have to 345 | fall back to regexes and pass in a regex object. 346 | 347 | [here's how you do it](https://github.com/snd/url-pattern/blob/c8e0a943bb62e6feeca2d2595da4e22782e617ed/test/match-fixtures.coffee#L237) 348 | 349 | ## [contributing](contributing.md) 350 | 351 | ## [license: MIT](LICENSE) 352 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "url-pattern", 3 | "homepage": "https://github.com/snd/url-pattern", 4 | "authors": [ 5 | "Maximilian Krüger " 6 | ], 7 | "description": "easier than regex string matching patterns for urls and other strings. turn strings into data or data into strings.", 8 | "keywords": [ 9 | "url", 10 | "string", 11 | "matching", 12 | "pattern", 13 | "matching", 14 | "routing", 15 | "route", 16 | "regex", 17 | "match", 18 | "segment", 19 | "parsing", 20 | "parser", 21 | "parse", 22 | "combinator", 23 | "combinators", 24 | "custom", 25 | "customizable", 26 | "filepath", 27 | "path", 28 | "domain", 29 | "separator", 30 | "stringify", 31 | "generate", 32 | "text", 33 | "processing" 34 | ], 35 | "main": "lib/url-pattern.js", 36 | "moduleType": [ 37 | "amd", 38 | "globals", 39 | "node" 40 | ], 41 | "license": "MIT", 42 | "ignore": [ 43 | "**/.*", 44 | "node_modules", 45 | "bower_components", 46 | "test", 47 | "tests" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # contributing 2 | 3 | **bugfixes, issues and discussion are always welcome. 4 | kindly [ask](https://github.com/snd/url-pattern/issues/new) before implementing new features.** 5 | 6 | i will happily merge pull requests that fix bugs with reasonable code. 7 | 8 | i will only merge pull requests that modify/add functionality 9 | if the changes align with my goals for this package, 10 | are well written, documented and tested. 11 | 12 | **communicate !** 13 | [write an issue](https://github.com/snd/url-pattern/issues/new) to start a discussion before writing code that may or may not get merged. 14 | 15 | please also read the [frequently asked questions](https://github.com/snd/url-pattern#frequently-asked-questions) before filing an issue. 16 | 17 | [This project adheres to the Contributor Covenant 1.2](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to kruemaxi@gmail.com. 18 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | interface UrlPatternOptions { 2 | escapeChar?: string; 3 | segmentNameStartChar?: string; 4 | segmentValueCharset?: string; 5 | segmentNameCharset?: string; 6 | optionalSegmentStartChar?: string; 7 | optionalSegmentEndChar?: string; 8 | wildcardChar?: string; 9 | } 10 | 11 | declare class UrlPattern { 12 | constructor(pattern: string, options?: UrlPatternOptions); 13 | constructor(pattern: RegExp, groupNames?: string[]); 14 | 15 | match(url: string): any; 16 | stringify(values?: any): string; 17 | } 18 | 19 | declare module UrlPattern { } 20 | 21 | export = UrlPattern; 22 | -------------------------------------------------------------------------------- /lib/url-pattern.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.10.0 2 | var slice = [].slice; 3 | 4 | (function(root, factory) { 5 | if (('function' === typeof define) && (define.amd != null)) { 6 | return define([], factory); 7 | } else if (typeof exports !== "undefined" && exports !== null) { 8 | return module.exports = factory(); 9 | } else { 10 | return root.UrlPattern = factory(); 11 | } 12 | })(this, function() { 13 | var P, UrlPattern, astNodeContainsSegmentsForProvidedParams, astNodeToNames, astNodeToRegexString, baseAstNodeToRegexString, concatMap, defaultOptions, escapeForRegex, getParam, keysAndValuesToObject, newParser, regexGroupCount, stringConcatMap, stringify; 14 | escapeForRegex = function(string) { 15 | return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); 16 | }; 17 | concatMap = function(array, f) { 18 | var i, length, results; 19 | results = []; 20 | i = -1; 21 | length = array.length; 22 | while (++i < length) { 23 | results = results.concat(f(array[i])); 24 | } 25 | return results; 26 | }; 27 | stringConcatMap = function(array, f) { 28 | var i, length, result; 29 | result = ''; 30 | i = -1; 31 | length = array.length; 32 | while (++i < length) { 33 | result += f(array[i]); 34 | } 35 | return result; 36 | }; 37 | regexGroupCount = function(regex) { 38 | return (new RegExp(regex.toString() + '|')).exec('').length - 1; 39 | }; 40 | keysAndValuesToObject = function(keys, values) { 41 | var i, key, length, object, value; 42 | object = {}; 43 | i = -1; 44 | length = keys.length; 45 | while (++i < length) { 46 | key = keys[i]; 47 | value = values[i]; 48 | if (value == null) { 49 | continue; 50 | } 51 | if (object[key] != null) { 52 | if (!Array.isArray(object[key])) { 53 | object[key] = [object[key]]; 54 | } 55 | object[key].push(value); 56 | } else { 57 | object[key] = value; 58 | } 59 | } 60 | return object; 61 | }; 62 | P = {}; 63 | P.Result = function(value, rest) { 64 | this.value = value; 65 | this.rest = rest; 66 | }; 67 | P.Tagged = function(tag, value) { 68 | this.tag = tag; 69 | this.value = value; 70 | }; 71 | P.tag = function(tag, parser) { 72 | return function(input) { 73 | var result, tagged; 74 | result = parser(input); 75 | if (result == null) { 76 | return; 77 | } 78 | tagged = new P.Tagged(tag, result.value); 79 | return new P.Result(tagged, result.rest); 80 | }; 81 | }; 82 | P.regex = function(regex) { 83 | return function(input) { 84 | var matches, result; 85 | matches = regex.exec(input); 86 | if (matches == null) { 87 | return; 88 | } 89 | result = matches[0]; 90 | return new P.Result(result, input.slice(result.length)); 91 | }; 92 | }; 93 | P.sequence = function() { 94 | var parsers; 95 | parsers = 1 <= arguments.length ? slice.call(arguments, 0) : []; 96 | return function(input) { 97 | var i, length, parser, rest, result, values; 98 | i = -1; 99 | length = parsers.length; 100 | values = []; 101 | rest = input; 102 | while (++i < length) { 103 | parser = parsers[i]; 104 | result = parser(rest); 105 | if (result == null) { 106 | return; 107 | } 108 | values.push(result.value); 109 | rest = result.rest; 110 | } 111 | return new P.Result(values, rest); 112 | }; 113 | }; 114 | P.pick = function() { 115 | var indexes, parsers; 116 | indexes = arguments[0], parsers = 2 <= arguments.length ? slice.call(arguments, 1) : []; 117 | return function(input) { 118 | var array, result; 119 | result = P.sequence.apply(P, parsers)(input); 120 | if (result == null) { 121 | return; 122 | } 123 | array = result.value; 124 | result.value = array[indexes]; 125 | return result; 126 | }; 127 | }; 128 | P.string = function(string) { 129 | var length; 130 | length = string.length; 131 | return function(input) { 132 | if (input.slice(0, length) === string) { 133 | return new P.Result(string, input.slice(length)); 134 | } 135 | }; 136 | }; 137 | P.lazy = function(fn) { 138 | var cached; 139 | cached = null; 140 | return function(input) { 141 | if (cached == null) { 142 | cached = fn(); 143 | } 144 | return cached(input); 145 | }; 146 | }; 147 | P.baseMany = function(parser, end, stringResult, atLeastOneResultRequired, input) { 148 | var endResult, parserResult, rest, results; 149 | rest = input; 150 | results = stringResult ? '' : []; 151 | while (true) { 152 | if (end != null) { 153 | endResult = end(rest); 154 | if (endResult != null) { 155 | break; 156 | } 157 | } 158 | parserResult = parser(rest); 159 | if (parserResult == null) { 160 | break; 161 | } 162 | if (stringResult) { 163 | results += parserResult.value; 164 | } else { 165 | results.push(parserResult.value); 166 | } 167 | rest = parserResult.rest; 168 | } 169 | if (atLeastOneResultRequired && results.length === 0) { 170 | return; 171 | } 172 | return new P.Result(results, rest); 173 | }; 174 | P.many1 = function(parser) { 175 | return function(input) { 176 | return P.baseMany(parser, null, false, true, input); 177 | }; 178 | }; 179 | P.concatMany1Till = function(parser, end) { 180 | return function(input) { 181 | return P.baseMany(parser, end, true, true, input); 182 | }; 183 | }; 184 | P.firstChoice = function() { 185 | var parsers; 186 | parsers = 1 <= arguments.length ? slice.call(arguments, 0) : []; 187 | return function(input) { 188 | var i, length, parser, result; 189 | i = -1; 190 | length = parsers.length; 191 | while (++i < length) { 192 | parser = parsers[i]; 193 | result = parser(input); 194 | if (result != null) { 195 | return result; 196 | } 197 | } 198 | }; 199 | }; 200 | newParser = function(options) { 201 | var U; 202 | U = {}; 203 | U.wildcard = P.tag('wildcard', P.string(options.wildcardChar)); 204 | U.optional = P.tag('optional', P.pick(1, P.string(options.optionalSegmentStartChar), P.lazy(function() { 205 | return U.pattern; 206 | }), P.string(options.optionalSegmentEndChar))); 207 | U.name = P.regex(new RegExp("^[" + options.segmentNameCharset + "]+")); 208 | U.named = P.tag('named', P.pick(1, P.string(options.segmentNameStartChar), P.lazy(function() { 209 | return U.name; 210 | }))); 211 | U.escapedChar = P.pick(1, P.string(options.escapeChar), P.regex(/^./)); 212 | U["static"] = P.tag('static', P.concatMany1Till(P.firstChoice(P.lazy(function() { 213 | return U.escapedChar; 214 | }), P.regex(/^./)), P.firstChoice(P.string(options.segmentNameStartChar), P.string(options.optionalSegmentStartChar), P.string(options.optionalSegmentEndChar), U.wildcard))); 215 | U.token = P.lazy(function() { 216 | return P.firstChoice(U.wildcard, U.optional, U.named, U["static"]); 217 | }); 218 | U.pattern = P.many1(P.lazy(function() { 219 | return U.token; 220 | })); 221 | return U; 222 | }; 223 | defaultOptions = { 224 | escapeChar: '\\', 225 | segmentNameStartChar: ':', 226 | segmentValueCharset: 'a-zA-Z0-9-_~ %', 227 | segmentNameCharset: 'a-zA-Z0-9', 228 | optionalSegmentStartChar: '(', 229 | optionalSegmentEndChar: ')', 230 | wildcardChar: '*' 231 | }; 232 | baseAstNodeToRegexString = function(astNode, segmentValueCharset) { 233 | if (Array.isArray(astNode)) { 234 | return stringConcatMap(astNode, function(node) { 235 | return baseAstNodeToRegexString(node, segmentValueCharset); 236 | }); 237 | } 238 | switch (astNode.tag) { 239 | case 'wildcard': 240 | return '(.*?)'; 241 | case 'named': 242 | return "([" + segmentValueCharset + "]+)"; 243 | case 'static': 244 | return escapeForRegex(astNode.value); 245 | case 'optional': 246 | return '(?:' + baseAstNodeToRegexString(astNode.value, segmentValueCharset) + ')?'; 247 | } 248 | }; 249 | astNodeToRegexString = function(astNode, segmentValueCharset) { 250 | if (segmentValueCharset == null) { 251 | segmentValueCharset = defaultOptions.segmentValueCharset; 252 | } 253 | return '^' + baseAstNodeToRegexString(astNode, segmentValueCharset) + '$'; 254 | }; 255 | astNodeToNames = function(astNode) { 256 | if (Array.isArray(astNode)) { 257 | return concatMap(astNode, astNodeToNames); 258 | } 259 | switch (astNode.tag) { 260 | case 'wildcard': 261 | return ['_']; 262 | case 'named': 263 | return [astNode.value]; 264 | case 'static': 265 | return []; 266 | case 'optional': 267 | return astNodeToNames(astNode.value); 268 | } 269 | }; 270 | getParam = function(params, key, nextIndexes, sideEffects) { 271 | var index, maxIndex, result, value; 272 | if (sideEffects == null) { 273 | sideEffects = false; 274 | } 275 | value = params[key]; 276 | if (value == null) { 277 | if (sideEffects) { 278 | throw new Error("no values provided for key `" + key + "`"); 279 | } else { 280 | return; 281 | } 282 | } 283 | index = nextIndexes[key] || 0; 284 | maxIndex = Array.isArray(value) ? value.length - 1 : 0; 285 | if (index > maxIndex) { 286 | if (sideEffects) { 287 | throw new Error("too few values provided for key `" + key + "`"); 288 | } else { 289 | return; 290 | } 291 | } 292 | result = Array.isArray(value) ? value[index] : value; 293 | if (sideEffects) { 294 | nextIndexes[key] = index + 1; 295 | } 296 | return result; 297 | }; 298 | astNodeContainsSegmentsForProvidedParams = function(astNode, params, nextIndexes) { 299 | var i, length; 300 | if (Array.isArray(astNode)) { 301 | i = -1; 302 | length = astNode.length; 303 | while (++i < length) { 304 | if (astNodeContainsSegmentsForProvidedParams(astNode[i], params, nextIndexes)) { 305 | return true; 306 | } 307 | } 308 | return false; 309 | } 310 | switch (astNode.tag) { 311 | case 'wildcard': 312 | return getParam(params, '_', nextIndexes, false) != null; 313 | case 'named': 314 | return getParam(params, astNode.value, nextIndexes, false) != null; 315 | case 'static': 316 | return false; 317 | case 'optional': 318 | return astNodeContainsSegmentsForProvidedParams(astNode.value, params, nextIndexes); 319 | } 320 | }; 321 | stringify = function(astNode, params, nextIndexes) { 322 | if (Array.isArray(astNode)) { 323 | return stringConcatMap(astNode, function(node) { 324 | return stringify(node, params, nextIndexes); 325 | }); 326 | } 327 | switch (astNode.tag) { 328 | case 'wildcard': 329 | return getParam(params, '_', nextIndexes, true); 330 | case 'named': 331 | return getParam(params, astNode.value, nextIndexes, true); 332 | case 'static': 333 | return astNode.value; 334 | case 'optional': 335 | if (astNodeContainsSegmentsForProvidedParams(astNode.value, params, nextIndexes)) { 336 | return stringify(astNode.value, params, nextIndexes); 337 | } else { 338 | return ''; 339 | } 340 | } 341 | }; 342 | UrlPattern = function(arg1, arg2) { 343 | var groupCount, options, parsed, parser, withoutWhitespace; 344 | if (arg1 instanceof UrlPattern) { 345 | this.isRegex = arg1.isRegex; 346 | this.regex = arg1.regex; 347 | this.ast = arg1.ast; 348 | this.names = arg1.names; 349 | return; 350 | } 351 | this.isRegex = arg1 instanceof RegExp; 352 | if (!(('string' === typeof arg1) || this.isRegex)) { 353 | throw new TypeError('argument must be a regex or a string'); 354 | } 355 | if (this.isRegex) { 356 | this.regex = arg1; 357 | if (arg2 != null) { 358 | if (!Array.isArray(arg2)) { 359 | throw new Error('if first argument is a regex the second argument may be an array of group names but you provided something else'); 360 | } 361 | groupCount = regexGroupCount(this.regex); 362 | if (arg2.length !== groupCount) { 363 | throw new Error("regex contains " + groupCount + " groups but array of group names contains " + arg2.length); 364 | } 365 | this.names = arg2; 366 | } 367 | return; 368 | } 369 | if (arg1 === '') { 370 | throw new Error('argument must not be the empty string'); 371 | } 372 | withoutWhitespace = arg1.replace(/\s+/g, ''); 373 | if (withoutWhitespace !== arg1) { 374 | throw new Error('argument must not contain whitespace'); 375 | } 376 | options = { 377 | escapeChar: (arg2 != null ? arg2.escapeChar : void 0) || defaultOptions.escapeChar, 378 | segmentNameStartChar: (arg2 != null ? arg2.segmentNameStartChar : void 0) || defaultOptions.segmentNameStartChar, 379 | segmentNameCharset: (arg2 != null ? arg2.segmentNameCharset : void 0) || defaultOptions.segmentNameCharset, 380 | segmentValueCharset: (arg2 != null ? arg2.segmentValueCharset : void 0) || defaultOptions.segmentValueCharset, 381 | optionalSegmentStartChar: (arg2 != null ? arg2.optionalSegmentStartChar : void 0) || defaultOptions.optionalSegmentStartChar, 382 | optionalSegmentEndChar: (arg2 != null ? arg2.optionalSegmentEndChar : void 0) || defaultOptions.optionalSegmentEndChar, 383 | wildcardChar: (arg2 != null ? arg2.wildcardChar : void 0) || defaultOptions.wildcardChar 384 | }; 385 | parser = newParser(options); 386 | parsed = parser.pattern(arg1); 387 | if (parsed == null) { 388 | throw new Error("couldn't parse pattern"); 389 | } 390 | if (parsed.rest !== '') { 391 | throw new Error("could only partially parse pattern"); 392 | } 393 | this.ast = parsed.value; 394 | this.regex = new RegExp(astNodeToRegexString(this.ast, options.segmentValueCharset)); 395 | this.names = astNodeToNames(this.ast); 396 | }; 397 | UrlPattern.prototype.match = function(url) { 398 | var groups, match; 399 | match = this.regex.exec(url); 400 | if (match == null) { 401 | return null; 402 | } 403 | groups = match.slice(1); 404 | if (this.names) { 405 | return keysAndValuesToObject(this.names, groups); 406 | } else { 407 | return groups; 408 | } 409 | }; 410 | UrlPattern.prototype.stringify = function(params) { 411 | if (params == null) { 412 | params = {}; 413 | } 414 | if (this.isRegex) { 415 | throw new Error("can't stringify patterns generated from a regex"); 416 | } 417 | if (params !== Object(params)) { 418 | throw new Error("argument must be an object or undefined"); 419 | } 420 | return stringify(this.ast, params, {}); 421 | }; 422 | UrlPattern.escapeForRegex = escapeForRegex; 423 | UrlPattern.concatMap = concatMap; 424 | UrlPattern.stringConcatMap = stringConcatMap; 425 | UrlPattern.regexGroupCount = regexGroupCount; 426 | UrlPattern.keysAndValuesToObject = keysAndValuesToObject; 427 | UrlPattern.P = P; 428 | UrlPattern.newParser = newParser; 429 | UrlPattern.defaultOptions = defaultOptions; 430 | UrlPattern.astNodeToRegexString = astNodeToRegexString; 431 | UrlPattern.astNodeToNames = astNodeToNames; 432 | UrlPattern.getParam = getParam; 433 | UrlPattern.astNodeContainsSegmentsForProvidedParams = astNodeContainsSegmentsForProvidedParams; 434 | UrlPattern.stringify = stringify; 435 | return UrlPattern; 436 | }); 437 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "url-pattern", 3 | "version": "1.0.3", 4 | "description": "easier than regex string matching patterns for urls and other strings. turn strings into data or data into strings.", 5 | "keywords": [ 6 | "url", 7 | "string", 8 | "matching", 9 | "pattern", 10 | "matching", 11 | "routing", 12 | "route", 13 | "regex", 14 | "match", 15 | "segment", 16 | "parsing", 17 | "parser", 18 | "parse", 19 | "combinator", 20 | "combinators", 21 | "custom", 22 | "customizable", 23 | "filepath", 24 | "path", 25 | "domain", 26 | "separator", 27 | "stringify", 28 | "generate", 29 | "text", 30 | "processing" 31 | ], 32 | "homepage": "http://github.com/snd/url-pattern", 33 | "author": { 34 | "name": "Maximilian Krüger", 35 | "email": "kruemaxi@gmail.com", 36 | "url": "http://github.com/snd" 37 | }, 38 | "contributors": [ 39 | { 40 | "name": "Andrey Popp", 41 | "email": "8mayday@gmail.com", 42 | "url": "https://github.com/andreypopp" 43 | }, 44 | { 45 | "name": "Samuel Reed", 46 | "url": "https://github.com/STRML" 47 | }, 48 | { 49 | "name": "Michael Trotter", 50 | "url": "https://github.com/spicydonuts" 51 | }, 52 | { 53 | "name": "Kate Hudson", 54 | "url": "https://github.com/k88hudson" 55 | }, 56 | { 57 | "name": "caasi Huang", 58 | "url": "https://github.com/caasi" 59 | } 60 | ], 61 | "bugs": { 62 | "url": "http://github.com/snd/url-pattern/issues", 63 | "email": "kruemaxi@gmail.com" 64 | }, 65 | "repository": { 66 | "type": "git", 67 | "url": "git://github.com/snd/url-pattern.git" 68 | }, 69 | "license": "MIT", 70 | "engines": { 71 | "node": ">=0.12.0" 72 | }, 73 | "dependencies": {}, 74 | "devDependencies": { 75 | "codecov.io": "0.1.6", 76 | "coffee-script": "1.10.0", 77 | "coffeeify": "2.0.1", 78 | "coffeetape": "1.0.1", 79 | "istanbul": "0.4.1", 80 | "tape": "4.2.2", 81 | "zuul": "3.8.0" 82 | }, 83 | "main": "lib/url-pattern", 84 | "scripts": { 85 | "compile": "coffee --bare --compile --output lib src", 86 | "prepublish": "npm run compile", 87 | "pretest": "npm run compile", 88 | "test": "coffeetape test/*", 89 | "test-with-coverage": "istanbul cover coffeetape test/* && cat ./coverage/coverage.json | ./node_modules/codecov.io/bin/codecov.io.js", 90 | "test-in-browsers": "zuul test/*", 91 | "test-zuul-local": "zuul --local 8080 test/*" 92 | }, 93 | "typings": "index.d.ts" 94 | } 95 | -------------------------------------------------------------------------------- /src/url-pattern.coffee: -------------------------------------------------------------------------------- 1 | ((root, factory) -> 2 | # AMD 3 | if ('function' is typeof define) and define.amd? 4 | define([], factory) 5 | # CommonJS 6 | else if exports? 7 | module.exports = factory() 8 | # browser globals 9 | else 10 | root.UrlPattern = factory() 11 | )(this, -> 12 | 13 | ################################################################################ 14 | # helpers 15 | 16 | # source: http://stackoverflow.com/a/3561711 17 | escapeForRegex = (string) -> 18 | string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') 19 | 20 | concatMap = (array, f) -> 21 | results = [] 22 | i = -1 23 | length = array.length 24 | while ++i < length 25 | results = results.concat f(array[i]) 26 | return results 27 | 28 | stringConcatMap = (array, f) -> 29 | result = '' 30 | i = -1 31 | length = array.length 32 | while ++i < length 33 | result += f(array[i]) 34 | return result 35 | 36 | # source: http://stackoverflow.com/a/16047223 37 | regexGroupCount = (regex) -> 38 | (new RegExp(regex.toString() + '|')).exec('').length - 1 39 | 40 | keysAndValuesToObject = (keys, values) -> 41 | object = {} 42 | i = -1 43 | length = keys.length 44 | while ++i < length 45 | key = keys[i] 46 | value = values[i] 47 | unless value? 48 | continue 49 | # key already encountered 50 | if object[key]? 51 | # capture multiple values for same key in an array 52 | unless Array.isArray object[key] 53 | object[key] = [object[key]] 54 | object[key].push value 55 | else 56 | object[key] = value 57 | return object 58 | 59 | ################################################################################ 60 | # parser combinators 61 | # subset copied from 62 | # https://github.com/snd/pcom/blob/master/src/pcom.coffee 63 | # (where they are tested !) 64 | # to keep this at zero dependencies and small filesize 65 | 66 | P = {} 67 | 68 | P.Result = (value, rest) -> 69 | this.value = value 70 | this.rest = rest 71 | return 72 | 73 | P.Tagged = (tag, value) -> 74 | this.tag = tag 75 | this.value = value 76 | return 77 | 78 | P.tag = (tag, parser) -> 79 | (input) -> 80 | result = parser input 81 | unless result? 82 | return 83 | tagged = new P.Tagged tag, result.value 84 | return new P.Result tagged, result.rest 85 | 86 | P.regex = (regex) -> 87 | # unless regex instanceof RegExp 88 | # throw new Error 'argument must be instanceof RegExp' 89 | (input) -> 90 | matches = regex.exec input 91 | unless matches? 92 | return 93 | result = matches[0] 94 | return new P.Result result, input.slice(result.length) 95 | 96 | P.sequence = (parsers...) -> 97 | (input) -> 98 | i = -1 99 | length = parsers.length 100 | values = [] 101 | rest = input 102 | while ++i < length 103 | parser = parsers[i] 104 | # unless 'function' is typeof parser 105 | # throw new Error "parser passed at index `#{i}` into `sequence` is not of type `function` but of type `#{typeof parser}`" 106 | result = parser rest 107 | unless result? 108 | return 109 | values.push result.value 110 | rest = result.rest 111 | return new P.Result values, rest 112 | 113 | P.pick = (indexes, parsers...) -> 114 | (input) -> 115 | result = P.sequence(parsers...)(input) 116 | unless result? 117 | return 118 | array = result.value 119 | result.value = array[indexes] 120 | # unless Array.isArray indexes 121 | # result.value = array[indexes] 122 | # else 123 | # result.value = [] 124 | # indexes.forEach (i) -> 125 | # result.value.push array[i] 126 | return result 127 | 128 | P.string = (string) -> 129 | length = string.length 130 | # if length is 0 131 | # throw new Error '`string` must not be blank' 132 | (input) -> 133 | if input.slice(0, length) is string 134 | return new P.Result string, input.slice(length) 135 | 136 | P.lazy = (fn) -> 137 | cached = null 138 | (input) -> 139 | unless cached? 140 | cached = fn() 141 | return cached input 142 | 143 | P.baseMany = (parser, end, stringResult, atLeastOneResultRequired, input) -> 144 | rest = input 145 | results = if stringResult then '' else [] 146 | while true 147 | if end? 148 | endResult = end rest 149 | if endResult? 150 | break 151 | parserResult = parser rest 152 | unless parserResult? 153 | break 154 | if stringResult 155 | results += parserResult.value 156 | else 157 | results.push parserResult.value 158 | rest = parserResult.rest 159 | 160 | if atLeastOneResultRequired and results.length is 0 161 | return 162 | 163 | return new P.Result results, rest 164 | 165 | P.many1 = (parser) -> 166 | (input) -> 167 | P.baseMany parser, null, false, true, input 168 | 169 | P.concatMany1Till = (parser, end) -> 170 | (input) -> 171 | P.baseMany parser, end, true, true, input 172 | 173 | P.firstChoice = (parsers...) -> 174 | (input) -> 175 | i = -1 176 | length = parsers.length 177 | while ++i < length 178 | parser = parsers[i] 179 | # unless 'function' is typeof parser 180 | # throw new Error "parser passed at index `#{i}` into `firstChoice` is not of type `function` but of type `#{typeof parser}`" 181 | result = parser input 182 | if result? 183 | return result 184 | return 185 | 186 | ################################################################################ 187 | # url pattern parser 188 | # copied from 189 | # https://github.com/snd/pcom/blob/master/src/url-pattern-example.coffee 190 | 191 | newParser = (options) -> 192 | U = {} 193 | 194 | U.wildcard = P.tag 'wildcard', P.string(options.wildcardChar) 195 | 196 | U.optional = P.tag( 197 | 'optional' 198 | P.pick(1, 199 | P.string(options.optionalSegmentStartChar) 200 | P.lazy(-> U.pattern) 201 | P.string(options.optionalSegmentEndChar) 202 | ) 203 | ) 204 | 205 | U.name = P.regex new RegExp "^[#{options.segmentNameCharset}]+" 206 | 207 | U.named = P.tag( 208 | 'named', 209 | P.pick(1, 210 | P.string(options.segmentNameStartChar) 211 | P.lazy(-> U.name) 212 | ) 213 | ) 214 | 215 | U.escapedChar = P.pick(1, 216 | P.string(options.escapeChar) 217 | P.regex(/^./) 218 | ) 219 | 220 | U.static = P.tag( 221 | 'static' 222 | P.concatMany1Till( 223 | P.firstChoice( 224 | P.lazy(-> U.escapedChar) 225 | P.regex(/^./) 226 | ) 227 | P.firstChoice( 228 | P.string(options.segmentNameStartChar) 229 | P.string(options.optionalSegmentStartChar) 230 | P.string(options.optionalSegmentEndChar) 231 | U.wildcard 232 | ) 233 | ) 234 | ) 235 | 236 | U.token = P.lazy -> 237 | P.firstChoice( 238 | U.wildcard 239 | U.optional 240 | U.named 241 | U.static 242 | ) 243 | 244 | U.pattern = P.many1 P.lazy(-> U.token) 245 | 246 | return U 247 | 248 | ################################################################################ 249 | # options 250 | 251 | defaultOptions = 252 | escapeChar: '\\' 253 | segmentNameStartChar: ':' 254 | segmentValueCharset: 'a-zA-Z0-9-_~ %' 255 | segmentNameCharset: 'a-zA-Z0-9' 256 | optionalSegmentStartChar: '(' 257 | optionalSegmentEndChar: ')' 258 | wildcardChar: '*' 259 | 260 | ################################################################################ 261 | # functions that further process ASTs returned as `.value` in parser results 262 | 263 | baseAstNodeToRegexString = (astNode, segmentValueCharset) -> 264 | if Array.isArray astNode 265 | return stringConcatMap astNode, (node) -> 266 | baseAstNodeToRegexString(node, segmentValueCharset) 267 | 268 | switch astNode.tag 269 | when 'wildcard' then '(.*?)' 270 | when 'named' then "([#{segmentValueCharset}]+)" 271 | when 'static' then escapeForRegex(astNode.value) 272 | when 'optional' 273 | '(?:' + baseAstNodeToRegexString(astNode.value, segmentValueCharset) + ')?' 274 | 275 | astNodeToRegexString = (astNode, segmentValueCharset = defaultOptions.segmentValueCharset) -> 276 | '^' + baseAstNodeToRegexString(astNode, segmentValueCharset) + '$' 277 | 278 | astNodeToNames = (astNode) -> 279 | if Array.isArray astNode 280 | return concatMap astNode, astNodeToNames 281 | 282 | switch astNode.tag 283 | when 'wildcard' then ['_'] 284 | when 'named' then [astNode.value] 285 | when 'static' then [] 286 | when 'optional' then astNodeToNames(astNode.value) 287 | 288 | getParam = (params, key, nextIndexes, sideEffects = false) -> 289 | value = params[key] 290 | unless value? 291 | if sideEffects 292 | throw new Error "no values provided for key `#{key}`" 293 | else 294 | return 295 | index = nextIndexes[key] or 0 296 | maxIndex = if Array.isArray value then value.length - 1 else 0 297 | if index > maxIndex 298 | if sideEffects 299 | throw new Error "too few values provided for key `#{key}`" 300 | else 301 | return 302 | 303 | result = if Array.isArray value then value[index] else value 304 | 305 | if sideEffects 306 | nextIndexes[key] = index + 1 307 | 308 | return result 309 | 310 | astNodeContainsSegmentsForProvidedParams = (astNode, params, nextIndexes) -> 311 | if Array.isArray astNode 312 | i = -1 313 | length = astNode.length 314 | while ++i < length 315 | if astNodeContainsSegmentsForProvidedParams astNode[i], params, nextIndexes 316 | return true 317 | return false 318 | 319 | switch astNode.tag 320 | when 'wildcard' then getParam(params, '_', nextIndexes, false)? 321 | when 'named' then getParam(params, astNode.value, nextIndexes, false)? 322 | when 'static' then false 323 | when 'optional' 324 | astNodeContainsSegmentsForProvidedParams astNode.value, params, nextIndexes 325 | 326 | stringify = (astNode, params, nextIndexes) -> 327 | if Array.isArray astNode 328 | return stringConcatMap astNode, (node) -> 329 | stringify node, params, nextIndexes 330 | 331 | switch astNode.tag 332 | when 'wildcard' then getParam params, '_', nextIndexes, true 333 | when 'named' then getParam params, astNode.value, nextIndexes, true 334 | when 'static' then astNode.value 335 | when 'optional' 336 | if astNodeContainsSegmentsForProvidedParams astNode.value, params, nextIndexes 337 | stringify astNode.value, params, nextIndexes 338 | else 339 | '' 340 | 341 | ################################################################################ 342 | # UrlPattern 343 | 344 | UrlPattern = (arg1, arg2) -> 345 | # self awareness 346 | if arg1 instanceof UrlPattern 347 | @isRegex = arg1.isRegex 348 | @regex = arg1.regex 349 | @ast = arg1.ast 350 | @names = arg1.names 351 | return 352 | 353 | @isRegex = arg1 instanceof RegExp 354 | 355 | unless ('string' is typeof arg1) or @isRegex 356 | throw new TypeError 'argument must be a regex or a string' 357 | 358 | # regex 359 | 360 | if @isRegex 361 | @regex = arg1 362 | if arg2? 363 | unless Array.isArray arg2 364 | throw new Error 'if first argument is a regex the second argument may be an array of group names but you provided something else' 365 | groupCount = regexGroupCount @regex 366 | unless arg2.length is groupCount 367 | throw new Error "regex contains #{groupCount} groups but array of group names contains #{arg2.length}" 368 | @names = arg2 369 | return 370 | 371 | # string pattern 372 | 373 | if arg1 is '' 374 | throw new Error 'argument must not be the empty string' 375 | withoutWhitespace = arg1.replace(/\s+/g, '') 376 | unless withoutWhitespace is arg1 377 | throw new Error 'argument must not contain whitespace' 378 | 379 | options = 380 | escapeChar: arg2?.escapeChar or defaultOptions.escapeChar 381 | segmentNameStartChar: arg2?.segmentNameStartChar or defaultOptions.segmentNameStartChar 382 | segmentNameCharset: arg2?.segmentNameCharset or defaultOptions.segmentNameCharset 383 | segmentValueCharset: arg2?.segmentValueCharset or defaultOptions.segmentValueCharset 384 | optionalSegmentStartChar: arg2?.optionalSegmentStartChar or defaultOptions.optionalSegmentStartChar 385 | optionalSegmentEndChar: arg2?.optionalSegmentEndChar or defaultOptions.optionalSegmentEndChar 386 | wildcardChar: arg2?.wildcardChar or defaultOptions.wildcardChar 387 | 388 | parser = newParser options 389 | parsed = parser.pattern arg1 390 | unless parsed? 391 | # TODO better error message 392 | throw new Error "couldn't parse pattern" 393 | if parsed.rest isnt '' 394 | # TODO better error message 395 | throw new Error "could only partially parse pattern" 396 | @ast = parsed.value 397 | 398 | @regex = new RegExp astNodeToRegexString @ast, options.segmentValueCharset 399 | @names = astNodeToNames @ast 400 | 401 | return 402 | 403 | UrlPattern.prototype.match = (url) -> 404 | match = @regex.exec url 405 | unless match? 406 | return null 407 | 408 | groups = match.slice(1) 409 | if @names 410 | keysAndValuesToObject @names, groups 411 | else 412 | groups 413 | 414 | UrlPattern.prototype.stringify = (params = {}) -> 415 | if @isRegex 416 | throw new Error "can't stringify patterns generated from a regex" 417 | unless params is Object(params) 418 | throw new Error "argument must be an object or undefined" 419 | stringify @ast, params, {} 420 | 421 | ################################################################################ 422 | # exports 423 | 424 | # helpers 425 | UrlPattern.escapeForRegex = escapeForRegex 426 | UrlPattern.concatMap = concatMap 427 | UrlPattern.stringConcatMap = stringConcatMap 428 | UrlPattern.regexGroupCount = regexGroupCount 429 | UrlPattern.keysAndValuesToObject = keysAndValuesToObject 430 | 431 | # parsers 432 | UrlPattern.P = P 433 | UrlPattern.newParser = newParser 434 | UrlPattern.defaultOptions = defaultOptions 435 | 436 | # ast 437 | UrlPattern.astNodeToRegexString = astNodeToRegexString 438 | UrlPattern.astNodeToNames = astNodeToNames 439 | UrlPattern.getParam = getParam 440 | UrlPattern.astNodeContainsSegmentsForProvidedParams = astNodeContainsSegmentsForProvidedParams 441 | UrlPattern.stringify = stringify 442 | 443 | return UrlPattern 444 | ) 445 | -------------------------------------------------------------------------------- /test/ast.coffee: -------------------------------------------------------------------------------- 1 | test = require 'tape' 2 | UrlPattern = require '../lib/url-pattern' 3 | 4 | { 5 | astNodeToRegexString 6 | astNodeToNames 7 | getParam 8 | } = UrlPattern 9 | 10 | parse = UrlPattern.newParser(UrlPattern.defaultOptions).pattern 11 | 12 | test 'astNodeToRegexString and astNodeToNames', (t) -> 13 | t.test 'just static alphanumeric', (t) -> 14 | parsed = parse 'user42' 15 | t.equal astNodeToRegexString(parsed.value), '^user42$' 16 | t.deepEqual astNodeToNames(parsed.value), [] 17 | t.end() 18 | 19 | t.test 'just static escaped', (t) -> 20 | parsed = parse '/api/v1/users' 21 | t.equal astNodeToRegexString(parsed.value), '^\\/api\\/v1\\/users$' 22 | t.deepEqual astNodeToNames(parsed.value), [] 23 | t.end() 24 | 25 | t.test 'just single char variable', (t) -> 26 | parsed = parse ':a' 27 | t.equal astNodeToRegexString(parsed.value), '^([a-zA-Z0-9-_~ %]+)$' 28 | t.deepEqual astNodeToNames(parsed.value), ['a'] 29 | t.end() 30 | 31 | t.test 'just variable', (t) -> 32 | parsed = parse ':variable' 33 | t.equal astNodeToRegexString(parsed.value), '^([a-zA-Z0-9-_~ %]+)$' 34 | t.deepEqual astNodeToNames(parsed.value), ['variable'] 35 | t.end() 36 | 37 | t.test 'just wildcard', (t) -> 38 | parsed = parse '*' 39 | t.equal astNodeToRegexString(parsed.value), '^(.*?)$' 40 | t.deepEqual astNodeToNames(parsed.value), ['_'] 41 | t.end() 42 | 43 | t.test 'just optional static', (t) -> 44 | parsed = parse '(foo)' 45 | t.equal astNodeToRegexString(parsed.value), '^(?:foo)?$' 46 | t.deepEqual astNodeToNames(parsed.value), [] 47 | t.end() 48 | 49 | t.test 'just optional variable', (t) -> 50 | parsed = parse '(:foo)' 51 | t.equal astNodeToRegexString(parsed.value), '^(?:([a-zA-Z0-9-_~ %]+))?$' 52 | t.deepEqual astNodeToNames(parsed.value), ['foo'] 53 | t.end() 54 | 55 | t.test 'just optional wildcard', (t) -> 56 | parsed = parse '(*)' 57 | t.equal astNodeToRegexString(parsed.value), '^(?:(.*?))?$' 58 | t.deepEqual astNodeToNames(parsed.value), ['_'] 59 | t.end() 60 | 61 | test 'getParam', (t) -> 62 | t.test 'no side effects', (t) -> 63 | next = {} 64 | t.equal undefined, getParam {}, 'one', next 65 | t.deepEqual next, {} 66 | 67 | # value 68 | 69 | next = {} 70 | t.equal 1, getParam {one: 1}, 'one', next 71 | t.deepEqual next, {} 72 | 73 | next = {one: 0} 74 | t.equal 1, getParam {one: 1}, 'one', next 75 | t.deepEqual next, {one: 0} 76 | 77 | next = {one: 1} 78 | t.equal undefined, getParam {one: 1}, 'one', next 79 | t.deepEqual next, {one: 1} 80 | 81 | next = {one: 2} 82 | t.equal undefined, getParam {one: 1}, 'one', next 83 | t.deepEqual next, {one: 2} 84 | 85 | # array 86 | 87 | next = {} 88 | t.equal 1, getParam {one: [1]}, 'one', next 89 | t.deepEqual next, {} 90 | 91 | next = {one: 0} 92 | t.equal 1, getParam {one: [1]}, 'one', next 93 | t.deepEqual next, {one: 0} 94 | 95 | next = {one: 1} 96 | t.equal undefined, getParam {one: [1]}, 'one', next 97 | t.deepEqual next, {one: 1} 98 | 99 | next = {one: 2} 100 | t.equal undefined, getParam {one: [1]}, 'one', next 101 | t.deepEqual next, {one: 2} 102 | 103 | next = {one: 0} 104 | t.equal 1, getParam {one: [1, 2, 3]}, 'one', next 105 | t.deepEqual next, {one: 0} 106 | 107 | next = {one: 1} 108 | t.equal 2, getParam {one: [1, 2, 3]}, 'one', next 109 | t.deepEqual next, {one: 1} 110 | 111 | next = {one: 2} 112 | t.equal 3, getParam {one: [1, 2, 3]}, 'one', next 113 | t.deepEqual next, {one: 2} 114 | 115 | next = {one: 3} 116 | t.equal undefined, getParam {one: [1, 2, 3]}, 'one', next 117 | t.deepEqual next, {one: 3} 118 | 119 | t.end() 120 | 121 | t.test 'side effects', (t) -> 122 | next = {} 123 | t.equal 1, getParam {one: 1}, 'one', next, true 124 | t.deepEqual next, {one: 1} 125 | 126 | next = {one: 0} 127 | t.equal 1, getParam {one: 1}, 'one', next, true 128 | t.deepEqual next, {one: 1} 129 | 130 | # array 131 | 132 | next = {} 133 | t.equal 1, getParam {one: [1]}, 'one', next, true 134 | t.deepEqual next, {one: 1} 135 | 136 | next = {one: 0} 137 | t.equal 1, getParam {one: [1]}, 'one', next, true 138 | t.deepEqual next, {one: 1} 139 | 140 | next = {one: 0} 141 | t.equal 1, getParam {one: [1, 2, 3]}, 'one', next, true 142 | t.deepEqual next, {one: 1} 143 | 144 | next = {one: 1} 145 | t.equal 2, getParam {one: [1, 2, 3]}, 'one', next, true 146 | t.deepEqual next, {one: 2} 147 | 148 | next = {one: 2} 149 | t.equal 3, getParam {one: [1, 2, 3]}, 'one', next, true 150 | t.deepEqual next, {one: 3} 151 | 152 | t.end() 153 | 154 | t.test 'side effects errors', (t) -> 155 | t.plan 2 * 6 156 | 157 | next = {} 158 | try 159 | getParam {}, 'one', next, true 160 | catch e 161 | t.equal e.message, "no values provided for key `one`" 162 | t.deepEqual next, {} 163 | 164 | next = {one: 1} 165 | try 166 | getParam {one: 1}, 'one', next, true 167 | catch e 168 | t.equal e.message, "too few values provided for key `one`" 169 | t.deepEqual next, {one: 1} 170 | 171 | next = {one: 2} 172 | try 173 | getParam {one: 2}, 'one', next, true 174 | catch e 175 | t.equal e.message, "too few values provided for key `one`" 176 | t.deepEqual next, {one: 2} 177 | 178 | next = {one: 1} 179 | try 180 | getParam {one: [1]}, 'one', next, true 181 | catch e 182 | t.equal e.message, "too few values provided for key `one`" 183 | t.deepEqual next, {one: 1} 184 | 185 | next = {one: 2} 186 | try 187 | getParam {one: [1]}, 'one', next, true 188 | catch e 189 | t.equal e.message, "too few values provided for key `one`" 190 | t.deepEqual next, {one: 2} 191 | 192 | next = {one: 3} 193 | try 194 | getParam {one: [1, 2, 3]}, 'one', next, true 195 | catch e 196 | t.equal e.message, "too few values provided for key `one`" 197 | t.deepEqual next, {one: 3} 198 | 199 | t.end() 200 | -------------------------------------------------------------------------------- /test/errors.coffee: -------------------------------------------------------------------------------- 1 | test = require 'tape' 2 | UrlPattern = require '../lib/url-pattern' 3 | 4 | test 'invalid argument', (t) -> 5 | UrlPattern 6 | t.plan 5 7 | try 8 | new UrlPattern() 9 | catch e 10 | t.equal e.message, "argument must be a regex or a string" 11 | try 12 | new UrlPattern(5) 13 | catch e 14 | t.equal e.message, "argument must be a regex or a string" 15 | try 16 | new UrlPattern '' 17 | catch e 18 | t.equal e.message, "argument must not be the empty string" 19 | try 20 | new UrlPattern ' ' 21 | catch e 22 | t.equal e.message, "argument must not contain whitespace" 23 | try 24 | new UrlPattern ' fo o' 25 | catch e 26 | t.equal e.message, "argument must not contain whitespace" 27 | t.end() 28 | 29 | test 'invalid variable name in pattern', (t) -> 30 | UrlPattern 31 | t.plan 3 32 | try 33 | new UrlPattern ':' 34 | catch e 35 | t.equal e.message, "couldn't parse pattern" 36 | try 37 | new UrlPattern ':.' 38 | catch e 39 | t.equal e.message, "couldn't parse pattern" 40 | try 41 | new UrlPattern 'foo:.' 42 | catch e 43 | # TODO `:` must be followed by the name of the named segment consisting of at least one character in character set `a-zA-Z0-9` at 4 44 | t.equal e.message, "could only partially parse pattern" 45 | t.end() 46 | 47 | test 'too many closing parentheses', (t) -> 48 | t.plan 2 49 | try 50 | new UrlPattern ')' 51 | catch e 52 | # TODO did not plan ) at 0 53 | t.equal e.message, "couldn't parse pattern" 54 | try 55 | new UrlPattern '((foo)))bar' 56 | catch e 57 | # TODO did not plan ) at 7 58 | t.equal e.message, "could only partially parse pattern" 59 | t.end() 60 | 61 | test 'unclosed parentheses', (t) -> 62 | t.plan 2 63 | try 64 | new UrlPattern '(' 65 | catch e 66 | # TODO unclosed parentheses at 1 67 | t.equal e.message, "couldn't parse pattern" 68 | try 69 | new UrlPattern '(((foo)bar(boo)far)' 70 | catch e 71 | # TODO unclosed parentheses at 19 72 | t.equal e.message, "couldn't parse pattern" 73 | t.end() 74 | 75 | test 'regex names', (t) -> 76 | t.plan 3 77 | try 78 | new UrlPattern /x/, 5 79 | catch e 80 | t.equal e.message, 'if first argument is a regex the second argument may be an array of group names but you provided something else' 81 | try 82 | new UrlPattern /(((foo)bar(boo))far)/, [] 83 | catch e 84 | t.equal e.message, "regex contains 4 groups but array of group names contains 0" 85 | try 86 | new UrlPattern /(((foo)bar(boo))far)/, ['a', 'b'] 87 | catch e 88 | t.equal e.message, "regex contains 4 groups but array of group names contains 2" 89 | t.end() 90 | 91 | test 'stringify regex', (t) -> 92 | t.plan 1 93 | pattern = new UrlPattern /x/ 94 | try 95 | pattern.stringify() 96 | catch e 97 | t.equal e.message, "can't stringify patterns generated from a regex" 98 | t.end() 99 | 100 | test 'stringify argument', (t) -> 101 | t.plan 1 102 | pattern = new UrlPattern 'foo' 103 | try 104 | pattern.stringify(5) 105 | catch e 106 | t.equal e.message, "argument must be an object or undefined" 107 | t.end() 108 | -------------------------------------------------------------------------------- /test/helpers.coffee: -------------------------------------------------------------------------------- 1 | test = require 'tape' 2 | { 3 | escapeForRegex 4 | concatMap 5 | stringConcatMap 6 | regexGroupCount 7 | keysAndValuesToObject 8 | } = require '../lib/url-pattern' 9 | 10 | test 'escapeForRegex', (t) -> 11 | expected = '\\[\\-\\/\\\\\\^\\$\\*\\+\\?\\.\\(\\)\\|\\[\\]\\{\\}\\]' 12 | actual = escapeForRegex('[-\/\\^$*+?.()|[\]{}]') 13 | t.equal expected, actual 14 | 15 | t.equal escapeForRegex('a$98kdjf(kdj)'), 'a\\$98kdjf\\(kdj\\)' 16 | t.equal 'a', escapeForRegex 'a' 17 | t.equal '!', escapeForRegex '!' 18 | t.equal '\\.', escapeForRegex '.' 19 | t.equal '\\/', escapeForRegex '/' 20 | t.equal '\\-', escapeForRegex '-' 21 | t.equal '\\-', escapeForRegex '-' 22 | t.equal '\\[', escapeForRegex '[' 23 | t.equal '\\]', escapeForRegex ']' 24 | t.equal '\\(', escapeForRegex '(' 25 | t.equal '\\)', escapeForRegex ')' 26 | t.end() 27 | 28 | test 'concatMap', (t) -> 29 | t.deepEqual [], concatMap [], -> 30 | t.deepEqual [1], concatMap [1], (x) -> [x] 31 | t.deepEqual [1, 1, 1, 2, 2, 2, 3, 3, 3], concatMap [1, 2, 3], (x) -> [x, x, x] 32 | t.end() 33 | 34 | test 'stringConcatMap', (t) -> 35 | t.equal '', stringConcatMap [], -> 36 | t.equal '1', stringConcatMap [1], (x) -> x 37 | t.equal '123', stringConcatMap [1, 2, 3], (x) -> x 38 | t.equal '1a2a3a', stringConcatMap [1, 2, 3], (x) -> x + 'a' 39 | t.end() 40 | 41 | test 'regexGroupCount', (t) -> 42 | t.equal 0, regexGroupCount /foo/ 43 | t.equal 1, regexGroupCount /(foo)/ 44 | t.equal 2, regexGroupCount /((foo))/ 45 | t.equal 2, regexGroupCount /(fo(o))/ 46 | t.equal 2, regexGroupCount /f(o)(o)/ 47 | t.equal 2, regexGroupCount /f(o)o()/ 48 | t.equal 5, regexGroupCount /f(o)o()()(())/ 49 | t.end() 50 | 51 | test 'keysAndValuesToObject', (t) -> 52 | t.deepEqual( 53 | keysAndValuesToObject( 54 | [] 55 | [] 56 | ) 57 | {} 58 | ) 59 | t.deepEqual( 60 | keysAndValuesToObject( 61 | ['one'] 62 | [1] 63 | ) 64 | { 65 | one: 1 66 | } 67 | ) 68 | t.deepEqual( 69 | keysAndValuesToObject( 70 | ['one', 'two'] 71 | [1] 72 | ) 73 | { 74 | one: 1 75 | } 76 | ) 77 | t.deepEqual( 78 | keysAndValuesToObject( 79 | ['one', 'two', 'two'] 80 | [1, 2, 3] 81 | ) 82 | { 83 | one: 1 84 | two: [2, 3] 85 | } 86 | ) 87 | t.deepEqual( 88 | keysAndValuesToObject( 89 | ['one', 'two', 'two', 'two'] 90 | [1, 2, 3, null] 91 | ) 92 | { 93 | one: 1 94 | two: [2, 3] 95 | } 96 | ) 97 | t.deepEqual( 98 | keysAndValuesToObject( 99 | ['one', 'two', 'two', 'two'] 100 | [1, 2, 3, 4] 101 | ) 102 | { 103 | one: 1 104 | two: [2, 3, 4] 105 | } 106 | ) 107 | t.deepEqual( 108 | keysAndValuesToObject( 109 | ['one', 'two', 'two', 'two', 'three'] 110 | [1, 2, 3, 4, undefined] 111 | ) 112 | { 113 | one: 1 114 | two: [2, 3, 4] 115 | } 116 | ) 117 | t.deepEqual( 118 | keysAndValuesToObject( 119 | ['one', 'two', 'two', 'two', 'three'] 120 | [1, 2, 3, 4, 5] 121 | ) 122 | { 123 | one: 1 124 | two: [2, 3, 4] 125 | three: 5 126 | } 127 | ) 128 | t.deepEqual( 129 | keysAndValuesToObject( 130 | ['one', 'two', 'two', 'two', 'three'] 131 | [null, 2, 3, 4, 5] 132 | ) 133 | { 134 | two: [2, 3, 4] 135 | three: 5 136 | } 137 | ) 138 | t.end() 139 | -------------------------------------------------------------------------------- /test/match-fixtures.coffee: -------------------------------------------------------------------------------- 1 | test = require 'tape' 2 | UrlPattern = require '../lib/url-pattern' 3 | 4 | test 'match', (t) -> 5 | pattern = new UrlPattern '/foo' 6 | t.deepEqual pattern.match('/foo'), {} 7 | 8 | pattern = new UrlPattern '.foo' 9 | t.deepEqual pattern.match('.foo'), {} 10 | 11 | pattern = new UrlPattern '/foo' 12 | t.equals pattern.match('/foobar'), null 13 | 14 | pattern = new UrlPattern '.foo' 15 | t.equals pattern.match('.foobar'), null 16 | 17 | pattern = new UrlPattern '/foo' 18 | t.equals pattern.match('/bar/foo'), null 19 | 20 | pattern = new UrlPattern '.foo' 21 | t.equals pattern.match('.bar.foo'), null 22 | 23 | pattern = new UrlPattern /foo/ 24 | t.deepEqual pattern.match('foo'), [] 25 | 26 | pattern = new UrlPattern /\/foo\/(.*)/ 27 | t.deepEqual pattern.match('/foo/bar'), ['bar'] 28 | 29 | pattern = new UrlPattern /\/foo\/(.*)/ 30 | t.deepEqual pattern.match('/foo/'), [''] 31 | 32 | pattern = new UrlPattern '/user/:userId/task/:taskId' 33 | t.deepEqual pattern.match('/user/10/task/52'), 34 | userId: '10' 35 | taskId: '52' 36 | 37 | pattern = new UrlPattern '.user.:userId.task.:taskId' 38 | t.deepEqual pattern.match('.user.10.task.52'), 39 | userId: '10' 40 | taskId: '52' 41 | 42 | pattern = new UrlPattern '*/user/:userId' 43 | t.deepEqual pattern.match('/school/10/user/10'), 44 | _: '/school/10', 45 | userId: '10' 46 | 47 | pattern = new UrlPattern '*-user-:userId' 48 | t.deepEqual pattern.match('-school-10-user-10'), 49 | _: '-school-10' 50 | userId: '10' 51 | 52 | pattern = new UrlPattern '/admin*' 53 | t.deepEqual pattern.match('/admin/school/10/user/10'), 54 | _: '/school/10/user/10' 55 | 56 | pattern = new UrlPattern '#admin*' 57 | t.deepEqual pattern.match('#admin#school#10#user#10'), 58 | _: '#school#10#user#10' 59 | 60 | pattern = new UrlPattern '/admin/*/user/:userId' 61 | t.deepEqual pattern.match('/admin/school/10/user/10'), 62 | _: 'school/10', 63 | userId: '10' 64 | 65 | pattern = new UrlPattern '$admin$*$user$:userId' 66 | t.deepEqual pattern.match('$admin$school$10$user$10'), 67 | _: 'school$10' 68 | userId: '10' 69 | 70 | pattern = new UrlPattern '/admin/*/user/*/tail' 71 | t.deepEqual pattern.match('/admin/school/10/user/10/12/tail'), 72 | _: ['school/10', '10/12'] 73 | 74 | pattern = new UrlPattern '$admin$*$user$*$tail' 75 | t.deepEqual pattern.match('$admin$school$10$user$10$12$tail'), 76 | _: ['school$10', '10$12'] 77 | 78 | pattern = new UrlPattern '/admin/*/user/:id/*/tail' 79 | t.deepEqual pattern.match('/admin/school/10/user/10/12/13/tail'), 80 | _: ['school/10', '12/13'] 81 | id: '10' 82 | 83 | pattern = new UrlPattern '^admin^*^user^:id^*^tail' 84 | t.deepEqual pattern.match('^admin^school^10^user^10^12^13^tail'), 85 | _: ['school^10', '12^13'] 86 | id: '10' 87 | 88 | pattern = new UrlPattern '/*/admin(/:path)' 89 | t.deepEqual pattern.match('/admin/admin/admin'), 90 | _: 'admin' 91 | path: 'admin' 92 | 93 | pattern = new UrlPattern '(/)' 94 | t.deepEqual pattern.match(''), {} 95 | t.deepEqual pattern.match('/'), {} 96 | 97 | pattern = new UrlPattern '/admin(/foo)/bar' 98 | t.deepEqual pattern.match('/admin/foo/bar'), {} 99 | t.deepEqual pattern.match('/admin/bar'), {} 100 | 101 | pattern = new UrlPattern '/admin(/:foo)/bar' 102 | t.deepEqual pattern.match('/admin/baz/bar'), 103 | foo: 'baz' 104 | t.deepEqual pattern.match('/admin/bar'), {} 105 | 106 | pattern = new UrlPattern '/admin/(*/)foo' 107 | t.deepEqual pattern.match('/admin/foo'), {} 108 | t.deepEqual pattern.match('/admin/baz/bar/biff/foo'), 109 | _: 'baz/bar/biff' 110 | 111 | pattern = new UrlPattern '/v:major.:minor/*' 112 | t.deepEqual pattern.match('/v1.2/resource/'), 113 | _: 'resource/' 114 | major: '1' 115 | minor: '2' 116 | 117 | pattern = new UrlPattern '/v:v.:v/*' 118 | t.deepEqual pattern.match('/v1.2/resource/'), 119 | _: 'resource/' 120 | v: ['1', '2'] 121 | 122 | pattern = new UrlPattern '/:foo_bar' 123 | t.equal pattern.match('/_bar'), null 124 | t.deepEqual pattern.match('/a_bar'), 125 | foo: 'a' 126 | t.deepEqual pattern.match('/a__bar'), 127 | foo: 'a_' 128 | t.deepEqual pattern.match('/a-b-c-d__bar'), 129 | foo: 'a-b-c-d_' 130 | t.deepEqual pattern.match('/a b%c-d__bar'), 131 | foo: 'a b%c-d_' 132 | 133 | pattern = new UrlPattern '((((a)b)c)d)' 134 | t.deepEqual pattern.match(''), {} 135 | t.equal pattern.match('a'), null 136 | t.equal pattern.match('ab'), null 137 | t.equal pattern.match('abc'), null 138 | t.deepEqual pattern.match('abcd'), {} 139 | t.deepEqual pattern.match('bcd'), {} 140 | t.deepEqual pattern.match('cd'), {} 141 | t.deepEqual pattern.match('d'), {} 142 | 143 | pattern = new UrlPattern '/user/:range' 144 | t.deepEqual pattern.match('/user/10-20'), 145 | range: '10-20' 146 | 147 | pattern = new UrlPattern '/user/:range' 148 | t.deepEqual pattern.match('/user/10_20'), 149 | range: '10_20' 150 | 151 | pattern = new UrlPattern '/user/:range' 152 | t.deepEqual pattern.match('/user/10 20'), 153 | range: '10 20' 154 | 155 | pattern = new UrlPattern '/user/:range' 156 | t.deepEqual pattern.match('/user/10%20'), 157 | range: '10%20' 158 | 159 | pattern = new UrlPattern '/vvv:version/*' 160 | t.equal null, pattern.match('/vvv/resource') 161 | t.deepEqual pattern.match('/vvv1/resource'), 162 | _: 'resource' 163 | version: '1' 164 | t.equal null, pattern.match('/vvv1.1/resource') 165 | 166 | pattern = new UrlPattern '/api/users/:id', 167 | segmentValueCharset: 'a-zA-Z0-9-_~ %.@' 168 | t.deepEqual pattern.match('/api/users/someuser@example.com'), 169 | id: 'someuser@example.com' 170 | 171 | pattern = new UrlPattern '/api/users?username=:username', 172 | segmentValueCharset: 'a-zA-Z0-9-_~ %.@' 173 | t.deepEqual pattern.match('/api/users?username=someone@example.com'), 174 | username: 'someone@example.com' 175 | 176 | pattern = new UrlPattern '/api/users?param1=:param1¶m2=:param2' 177 | t.deepEqual pattern.match('/api/users?param1=foo¶m2=bar'), 178 | param1: 'foo' 179 | param2: 'bar' 180 | 181 | pattern = new UrlPattern ':scheme\\://:host(\\::port)', 182 | segmentValueCharset: 'a-zA-Z0-9-_~ %.' 183 | t.deepEqual pattern.match('ftp://ftp.example.com'), 184 | scheme: 'ftp' 185 | host: 'ftp.example.com' 186 | t.deepEqual pattern.match('ftp://ftp.example.com:8080'), 187 | scheme: 'ftp' 188 | host: 'ftp.example.com' 189 | port: '8080' 190 | t.deepEqual pattern.match('https://example.com:80'), 191 | scheme: 'https' 192 | host: 'example.com' 193 | port: '80' 194 | 195 | pattern = new UrlPattern ':scheme\\://:host(\\::port)(/api(/:resource(/:id)))', 196 | segmentValueCharset: 'a-zA-Z0-9-_~ %.@' 197 | t.deepEqual pattern.match('https://sss.www.localhost.com'), 198 | scheme: 'https' 199 | host: 'sss.www.localhost.com' 200 | t.deepEqual pattern.match('https://sss.www.localhost.com:8080'), 201 | scheme: 'https' 202 | host: 'sss.www.localhost.com' 203 | port: '8080' 204 | t.deepEqual pattern.match('https://sss.www.localhost.com/api'), 205 | scheme: 'https' 206 | host: 'sss.www.localhost.com' 207 | t.deepEqual pattern.match('https://sss.www.localhost.com/api/security'), 208 | scheme: 'https' 209 | host: 'sss.www.localhost.com' 210 | resource: 'security' 211 | t.deepEqual pattern.match('https://sss.www.localhost.com/api/security/bob@example.com'), 212 | scheme: 'https' 213 | host: 'sss.www.localhost.com' 214 | resource: 'security' 215 | id: 'bob@example.com' 216 | 217 | regex = /\/ip\/(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/ 218 | pattern = new UrlPattern regex 219 | t.equal null, pattern.match('10.10.10.10') 220 | t.equal null, pattern.match('ip/10.10.10.10') 221 | t.equal null, pattern.match('/ip/10.10.10.') 222 | t.equal null, pattern.match('/ip/10.') 223 | t.equal null, pattern.match('/ip/') 224 | t.deepEqual pattern.match('/ip/10.10.10.10'), ['10', '10', '10', '10'] 225 | t.deepEqual pattern.match('/ip/127.0.0.1'), ['127', '0', '0', '1'] 226 | 227 | regex = /\/ip\/((?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))$/ 228 | pattern = new UrlPattern regex 229 | t.equal null, pattern.match('10.10.10.10') 230 | t.equal null, pattern.match('ip/10.10.10.10') 231 | t.equal null, pattern.match('/ip/10.10.10.') 232 | t.equal null, pattern.match('/ip/10.') 233 | t.equal null, pattern.match('/ip/') 234 | t.deepEqual pattern.match('/ip/10.10.10.10'), ['10.10.10.10'] 235 | t.deepEqual pattern.match('/ip/127.0.0.1'), ['127.0.0.1'] 236 | 237 | regex = /\/ip\/((?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))$/ 238 | pattern = new UrlPattern regex, ['ip'] 239 | t.equal null, pattern.match('10.10.10.10') 240 | t.equal null, pattern.match('ip/10.10.10.10') 241 | t.equal null, pattern.match('/ip/10.10.10.') 242 | t.equal null, pattern.match('/ip/10.') 243 | t.equal null, pattern.match('/ip/') 244 | t.deepEqual pattern.match('/ip/10.10.10.10'), 245 | ip: '10.10.10.10' 246 | t.deepEqual pattern.match('/ip/127.0.0.1'), 247 | ip: '127.0.0.1' 248 | 249 | t.end() 250 | -------------------------------------------------------------------------------- /test/misc.coffee: -------------------------------------------------------------------------------- 1 | test = require 'tape' 2 | UrlPattern = require '../lib/url-pattern' 3 | 4 | test 'instance of UrlPattern is handled correctly as constructor argument', (t) -> 5 | pattern = new UrlPattern '/user/:userId/task/:taskId' 6 | copy = new UrlPattern pattern 7 | t.deepEqual copy.match('/user/10/task/52'), 8 | userId: '10' 9 | taskId: '52' 10 | t.end() 11 | 12 | test 'match full stops in segment values', (t) -> 13 | options = 14 | segmentValueCharset: 'a-zA-Z0-9-_ %.' 15 | pattern = new UrlPattern '/api/v1/user/:id/', options 16 | t.deepEqual pattern.match('/api/v1/user/test.name/'), 17 | id: 'test.name' 18 | t.end() 19 | 20 | test 'regex group names', (t) -> 21 | pattern = new UrlPattern /^\/api\/([a-zA-Z0-9-_~ %]+)(?:\/(\d+))?$/, ['resource', 'id'] 22 | t.deepEqual pattern.match('/api/users'), 23 | resource: 'users' 24 | t.equal pattern.match('/apiii/users'), null 25 | t.deepEqual pattern.match('/api/users/foo'), null 26 | t.deepEqual pattern.match('/api/users/10'), 27 | resource: 'users' 28 | id: '10' 29 | t.deepEqual pattern.match('/api/projects/10/'), null 30 | t.end() 31 | -------------------------------------------------------------------------------- /test/parser.coffee: -------------------------------------------------------------------------------- 1 | # taken from 2 | # https://github.com/snd/pcom/blob/master/t/url-pattern-example.coffee 3 | 4 | test = require 'tape' 5 | 6 | UrlPattern = require '../lib/url-pattern' 7 | U = UrlPattern.newParser(UrlPattern.defaultOptions) 8 | parse = U.pattern 9 | 10 | test 'wildcard', (t) -> 11 | t.deepEqual U.wildcard('*'), 12 | value: 13 | tag: 'wildcard' 14 | value: '*' 15 | rest: '' 16 | t.deepEqual U.wildcard('*/'), 17 | value: 18 | tag: 'wildcard' 19 | value: '*' 20 | rest: '/' 21 | t.equal U.wildcard(' *'), undefined 22 | t.equal U.wildcard('()'), undefined 23 | t.equal U.wildcard('foo(100)'), undefined 24 | t.equal U.wildcard('(100foo)'), undefined 25 | t.equal U.wildcard('(foo100)'), undefined 26 | t.equal U.wildcard('(foobar)'), undefined 27 | t.equal U.wildcard('foobar'), undefined 28 | t.equal U.wildcard('_aa'), undefined 29 | t.equal U.wildcard('$foobar'), undefined 30 | t.equal U.wildcard('$'), undefined 31 | t.equal U.wildcard(''), undefined 32 | t.end() 33 | 34 | test 'named', (t) -> 35 | t.deepEqual U.named(':a'), 36 | value: 37 | tag: 'named' 38 | value: 'a' 39 | rest: '' 40 | t.deepEqual U.named(':ab96c'), 41 | value: 42 | tag: 'named' 43 | value: 'ab96c' 44 | rest: '' 45 | t.deepEqual U.named(':ab96c.'), 46 | value: 47 | tag: 'named' 48 | value: 'ab96c' 49 | rest: '.' 50 | t.deepEqual U.named(':96c-:ab'), 51 | value: 52 | tag: 'named' 53 | value: '96c' 54 | rest: '-:ab' 55 | t.equal U.named(':'), undefined 56 | t.equal U.named(''), undefined 57 | t.equal U.named('a'), undefined 58 | t.equal U.named('abc'), undefined 59 | t.end() 60 | 61 | test 'static', (t) -> 62 | t.deepEqual U.static('a'), 63 | value: 64 | tag: 'static' 65 | value: 'a' 66 | rest: '' 67 | t.deepEqual U.static('abc:d'), 68 | value: 69 | tag: 'static' 70 | value: 'abc' 71 | rest: ':d' 72 | t.equal U.static(':ab96c'), undefined 73 | t.equal U.static(':'), undefined 74 | t.equal U.static('('), undefined 75 | t.equal U.static(')'), undefined 76 | t.equal U.static('*'), undefined 77 | t.equal U.static(''), undefined 78 | t.end() 79 | 80 | 81 | test 'fixtures', (t) -> 82 | t.equal parse(''), undefined 83 | t.equal parse('('), undefined 84 | t.equal parse(')'), undefined 85 | t.equal parse('()'), undefined 86 | t.equal parse(':'), undefined 87 | t.equal parse('((foo)'), undefined 88 | t.equal parse('(((foo)bar(boo)far)'), undefined 89 | 90 | t.deepEqual parse('(foo))'), 91 | rest: ')' 92 | value: [ 93 | {tag: 'optional', value: [{tag: 'static', value: 'foo'}]} 94 | ] 95 | 96 | t.deepEqual parse('((foo)))bar'), 97 | rest: ')bar' 98 | value: [ 99 | { 100 | tag: 'optional' 101 | value: [ 102 | {tag: 'optional', value: [{tag: 'static', value: 'foo'}]} 103 | ] 104 | } 105 | ] 106 | 107 | 108 | t.deepEqual parse('foo:*'), 109 | rest: ':*' 110 | value: [ 111 | {tag: 'static', value: 'foo'} 112 | ] 113 | 114 | t.deepEqual parse(':foo:bar'), 115 | rest: '' 116 | value: [ 117 | {tag: 'named', value: 'foo'} 118 | {tag: 'named', value: 'bar'} 119 | ] 120 | 121 | t.deepEqual parse('a'), 122 | rest: '' 123 | value: [ 124 | {tag: 'static', value: 'a'} 125 | ] 126 | t.deepEqual parse('user42'), 127 | rest: '' 128 | value: [ 129 | {tag: 'static', value: 'user42'} 130 | ] 131 | t.deepEqual parse(':a'), 132 | rest: '' 133 | value: [ 134 | {tag: 'named', value: 'a'} 135 | ] 136 | t.deepEqual parse('*'), 137 | rest: '' 138 | value: [ 139 | {tag: 'wildcard', value: '*'} 140 | ] 141 | t.deepEqual parse('(foo)'), 142 | rest: '' 143 | value: [ 144 | {tag: 'optional', value: [{tag: 'static', value: 'foo'}]} 145 | ] 146 | t.deepEqual parse('(:foo)'), 147 | rest: '' 148 | value: [ 149 | {tag: 'optional', value: [{tag: 'named', value: 'foo'}]} 150 | ] 151 | t.deepEqual parse('(*)'), 152 | rest: '' 153 | value: [ 154 | {tag: 'optional', value: [{tag: 'wildcard', value: '*'}]} 155 | ] 156 | 157 | 158 | t.deepEqual parse('/api/users/:id'), 159 | rest: '' 160 | value: [ 161 | {tag: 'static', value: '/api/users/'} 162 | {tag: 'named', value: 'id'} 163 | ] 164 | t.deepEqual parse('/v:major(.:minor)/*'), 165 | rest: '' 166 | value: [ 167 | {tag: 'static', value: '/v'} 168 | {tag: 'named', value: 'major'} 169 | { 170 | tag: 'optional' 171 | value: [ 172 | {tag: 'static', value: '.'} 173 | {tag: 'named', value: 'minor'} 174 | ] 175 | } 176 | {tag: 'static', value: '/'} 177 | {tag: 'wildcard', value: '*'} 178 | ] 179 | t.deepEqual parse('(http(s)\\://)(:subdomain.):domain.:tld(/*)'), 180 | rest: '' 181 | value: [ 182 | { 183 | tag: 'optional' 184 | value: [ 185 | {tag: 'static', value: 'http'} 186 | { 187 | tag: 'optional' 188 | value: [ 189 | {tag: 'static', value: 's'} 190 | ] 191 | } 192 | {tag: 'static', value: '://'} 193 | ] 194 | } 195 | { 196 | tag: 'optional' 197 | value: [ 198 | {tag: 'named', value: 'subdomain'} 199 | {tag: 'static', value: '.'} 200 | ] 201 | } 202 | {tag: 'named', value: 'domain'} 203 | {tag: 'static', value: '.'} 204 | {tag: 'named', value: 'tld'} 205 | { 206 | tag: 'optional' 207 | value: [ 208 | {tag: 'static', value: '/'} 209 | {tag: 'wildcard', value: '*'} 210 | ] 211 | } 212 | ] 213 | t.deepEqual parse('/api/users/:ids/posts/:ids'), 214 | rest: '' 215 | value: [ 216 | {tag: 'static', value: '/api/users/'} 217 | {tag: 'named', value: 'ids'} 218 | {tag: 'static', value: '/posts/'} 219 | {tag: 'named', value: 'ids'} 220 | ] 221 | 222 | t.deepEqual parse('/user/:userId/task/:taskId'), 223 | rest: '' 224 | value: [ 225 | {tag: 'static', value: '/user/'} 226 | {tag: 'named', value: 'userId'} 227 | {tag: 'static', value: '/task/'} 228 | {tag: 'named', value: 'taskId'} 229 | ] 230 | 231 | t.deepEqual parse('.user.:userId.task.:taskId'), 232 | rest: '' 233 | value: [ 234 | {tag: 'static', value: '.user.'} 235 | {tag: 'named', value: 'userId'} 236 | {tag: 'static', value: '.task.'} 237 | {tag: 'named', value: 'taskId'} 238 | ] 239 | 240 | t.deepEqual parse('*/user/:userId'), 241 | rest: '' 242 | value: [ 243 | {tag: 'wildcard', value: '*'} 244 | {tag: 'static', value: '/user/'} 245 | {tag: 'named', value: 'userId'} 246 | ] 247 | 248 | t.deepEqual parse('*-user-:userId'), 249 | rest: '' 250 | value: [ 251 | {tag: 'wildcard', value: '*'} 252 | {tag: 'static', value: '-user-'} 253 | {tag: 'named', value: 'userId'} 254 | ] 255 | 256 | t.deepEqual parse('/admin*'), 257 | rest: '' 258 | value: [ 259 | {tag: 'static', value: '/admin'} 260 | {tag: 'wildcard', value: '*'} 261 | ] 262 | 263 | t.deepEqual parse('#admin*'), 264 | rest: '' 265 | value: [ 266 | {tag: 'static', value: '#admin'} 267 | {tag: 'wildcard', value: '*'} 268 | ] 269 | 270 | t.deepEqual parse('/admin/*/user/:userId'), 271 | rest: '' 272 | value: [ 273 | {tag: 'static', value: '/admin/'} 274 | {tag: 'wildcard', value: '*'} 275 | {tag: 'static', value: '/user/'} 276 | {tag: 'named', value: 'userId'} 277 | ] 278 | 279 | t.deepEqual parse('$admin$*$user$:userId'), 280 | rest: '' 281 | value: [ 282 | {tag: 'static', value: '$admin$'} 283 | {tag: 'wildcard', value: '*'} 284 | {tag: 'static', value: '$user$'} 285 | {tag: 'named', value: 'userId'} 286 | ] 287 | 288 | t.deepEqual parse('/admin/*/user/*/tail'), 289 | rest: '' 290 | value: [ 291 | {tag: 'static', value: '/admin/'} 292 | {tag: 'wildcard', value: '*'} 293 | {tag: 'static', value: '/user/'} 294 | {tag: 'wildcard', value: '*'} 295 | {tag: 'static', value: '/tail'} 296 | ] 297 | 298 | t.deepEqual parse('/admin/*/user/:id/*/tail'), 299 | rest: '' 300 | value: [ 301 | {tag: 'static', value: '/admin/'} 302 | {tag: 'wildcard', value: '*'} 303 | {tag: 'static', value: '/user/'} 304 | {tag: 'named', value: 'id'} 305 | {tag: 'static', value: '/'} 306 | {tag: 'wildcard', value: '*'} 307 | {tag: 'static', value: '/tail'} 308 | ] 309 | 310 | t.deepEqual parse('^admin^*^user^:id^*^tail'), 311 | rest: '' 312 | value: [ 313 | {tag: 'static', value: '^admin^'} 314 | {tag: 'wildcard', value: '*'} 315 | {tag: 'static', value: '^user^'} 316 | {tag: 'named', value: 'id'} 317 | {tag: 'static', value: '^'} 318 | {tag: 'wildcard', value: '*'} 319 | {tag: 'static', value: '^tail'} 320 | ] 321 | 322 | t.deepEqual parse('/*/admin(/:path)'), 323 | rest: '' 324 | value: [ 325 | {tag: 'static', value: '/'} 326 | {tag: 'wildcard', value: '*'} 327 | {tag: 'static', value: '/admin'} 328 | {tag: 'optional', value: [ 329 | {tag: 'static', value: '/'} 330 | {tag: 'named', value: 'path'} 331 | ]} 332 | ] 333 | 334 | t.deepEqual parse('/'), 335 | rest: '' 336 | value: [ 337 | {tag: 'static', value: '/'} 338 | ] 339 | 340 | t.deepEqual parse('(/)'), 341 | rest: '' 342 | value: [ 343 | {tag: 'optional', value: [ 344 | {tag: 'static', value: '/'} 345 | ]} 346 | ] 347 | 348 | t.deepEqual parse('/admin(/:foo)/bar'), 349 | rest: '' 350 | value: [ 351 | {tag: 'static', value: '/admin'} 352 | {tag: 'optional', value: [ 353 | {tag: 'static', value: '/'} 354 | {tag: 'named', value: 'foo'} 355 | ]} 356 | {tag: 'static', value: '/bar'} 357 | ] 358 | 359 | t.deepEqual parse('/admin(*/)foo'), 360 | rest: '' 361 | value: [ 362 | {tag: 'static', value: '/admin'} 363 | {tag: 'optional', value: [ 364 | {tag: 'wildcard', value: '*'} 365 | {tag: 'static', value: '/'} 366 | ]} 367 | {tag: 'static', value: 'foo'} 368 | ] 369 | 370 | t.deepEqual parse('/v:major.:minor/*'), 371 | rest: '' 372 | value: [ 373 | {tag: 'static', value: '/v'} 374 | {tag: 'named', value: 'major'} 375 | {tag: 'static', value: '.'} 376 | {tag: 'named', value: 'minor'} 377 | {tag: 'static', value: '/'} 378 | {tag: 'wildcard', value: '*'} 379 | ] 380 | 381 | t.deepEqual parse('/v:v.:v/*'), 382 | rest: '' 383 | value: [ 384 | {tag: 'static', value: '/v'} 385 | {tag: 'named', value: 'v'} 386 | {tag: 'static', value: '.'} 387 | {tag: 'named', value: 'v'} 388 | {tag: 'static', value: '/'} 389 | {tag: 'wildcard', value: '*'} 390 | ] 391 | 392 | t.deepEqual parse('/:foo_bar'), 393 | rest: '' 394 | value: [ 395 | {tag: 'static', value: '/'} 396 | {tag: 'named', value: 'foo'} 397 | {tag: 'static', value: '_bar'} 398 | ] 399 | 400 | t.deepEqual parse('((((a)b)c)d)'), 401 | rest: '' 402 | value: [ 403 | {tag: 'optional', value: [ 404 | {tag: 'optional', value: [ 405 | {tag: 'optional', value: [ 406 | {tag: 'optional', value: [ 407 | {tag: 'static', value: 'a'} 408 | ]} 409 | {tag: 'static', value: 'b'} 410 | ]} 411 | {tag: 'static', value: 'c'} 412 | ]} 413 | {tag: 'static', value: 'd'} 414 | ]} 415 | ] 416 | 417 | t.deepEqual parse('/vvv:version/*'), 418 | rest: '' 419 | value: [ 420 | {tag: 'static', value: '/vvv'} 421 | {tag: 'named', value: 'version'} 422 | {tag: 'static', value: '/'} 423 | {tag: 'wildcard', value: '*'} 424 | ] 425 | 426 | t.end() 427 | -------------------------------------------------------------------------------- /test/readme.coffee: -------------------------------------------------------------------------------- 1 | test = require 'tape' 2 | UrlPattern = require '../lib/url-pattern' 3 | 4 | test 'simple', (t) -> 5 | pattern = new UrlPattern('/api/users/:id') 6 | t.deepEqual pattern.match('/api/users/10'), {id: '10'} 7 | t.equal pattern.match('/api/products/5'), null 8 | t.end() 9 | 10 | test 'api versioning', (t) -> 11 | pattern = new UrlPattern('/v:major(.:minor)/*') 12 | t.deepEqual pattern.match('/v1.2/'), {major: '1', minor: '2', _: ''} 13 | t.deepEqual pattern.match('/v2/users'), {major: '2', _: 'users'} 14 | t.equal pattern.match('/v/'), null 15 | t.end() 16 | 17 | test 'domain', (t) -> 18 | pattern = new UrlPattern('(http(s)\\://)(:subdomain.):domain.:tld(\\::port)(/*)') 19 | t.deepEqual pattern.match('google.de'), 20 | domain: 'google' 21 | tld: 'de' 22 | t.deepEqual pattern.match('https://www.google.com'), 23 | subdomain: 'www' 24 | domain: 'google' 25 | tld: 'com' 26 | t.deepEqual pattern.match('http://mail.google.com/mail'), 27 | subdomain: 'mail' 28 | domain: 'google' 29 | tld: 'com' 30 | _: 'mail' 31 | t.deepEqual pattern.match('http://mail.google.com:80/mail'), 32 | subdomain: 'mail' 33 | domain: 'google' 34 | tld: 'com' 35 | port: '80' 36 | _: 'mail' 37 | t.equal pattern.match('google'), null 38 | 39 | t.deepEqual pattern.match('www.google.com'), 40 | subdomain: 'www' 41 | domain: 'google' 42 | tld: 'com' 43 | t.equal pattern.match('httpp://mail.google.com/mail'), null 44 | t.deepEqual pattern.match('google.de/search'), 45 | domain: 'google' 46 | tld: 'de' 47 | _: 'search' 48 | 49 | t.end() 50 | 51 | test 'named segment occurs more than once', (t) -> 52 | pattern = new UrlPattern('/api/users/:ids/posts/:ids') 53 | t.deepEqual pattern.match('/api/users/10/posts/5'), {ids: ['10', '5']} 54 | t.end() 55 | 56 | test 'regex', (t) -> 57 | pattern = new UrlPattern(/^\/api\/(.*)$/) 58 | t.deepEqual pattern.match('/api/users'), ['users'] 59 | t.equal pattern.match('/apiii/users'), null 60 | t.end() 61 | 62 | test 'regex group names', (t) -> 63 | pattern = new UrlPattern(/^\/api\/([^\/]+)(?:\/(\d+))?$/, ['resource', 'id']) 64 | t.deepEqual pattern.match('/api/users'), 65 | resource: 'users' 66 | t.equal pattern.match('/api/users/'), null 67 | t.deepEqual pattern.match('/api/users/5'), 68 | resource: 'users' 69 | id: '5' 70 | t.equal pattern.match('/api/users/foo'), null 71 | t.end() 72 | 73 | test 'stringify', (t) -> 74 | pattern = new UrlPattern('/api/users/:id') 75 | t.equal '/api/users/10', pattern.stringify(id: 10) 76 | 77 | pattern = new UrlPattern('/api/users(/:id)') 78 | t.equal '/api/users', pattern.stringify() 79 | t.equal '/api/users/10', pattern.stringify(id: 10) 80 | 81 | t.end() 82 | 83 | test 'customization', (t) -> 84 | options = 85 | escapeChar: '!' 86 | segmentNameStartChar: '$' 87 | segmentNameCharset: 'a-zA-Z0-9_-' 88 | segmentValueCharset: 'a-zA-Z0-9' 89 | optionalSegmentStartChar: '[' 90 | optionalSegmentEndChar: ']' 91 | wildcardChar: '?' 92 | 93 | pattern = new UrlPattern( 94 | '[http[s]!://][$sub_domain.]$domain.$toplevel-domain[/?]' 95 | options 96 | ) 97 | 98 | t.deepEqual pattern.match('google.de'), 99 | domain: 'google' 100 | 'toplevel-domain': 'de' 101 | t.deepEqual pattern.match('http://mail.google.com/mail'), 102 | sub_domain: 'mail' 103 | domain: 'google' 104 | 'toplevel-domain': 'com' 105 | _: 'mail' 106 | t.equal pattern.match('http://mail.this-should-not-match.com/mail'), null 107 | t.equal pattern.match('google'), null 108 | t.deepEqual pattern.match('www.google.com'), 109 | sub_domain: 'www' 110 | domain: 'google' 111 | 'toplevel-domain': 'com' 112 | t.deepEqual pattern.match('https://www.google.com'), 113 | sub_domain: 'www' 114 | domain: 'google' 115 | 'toplevel-domain': 'com' 116 | t.equal pattern.match('httpp://mail.google.com/mail'), null 117 | t.deepEqual pattern.match('google.de/search'), 118 | domain: 'google' 119 | 'toplevel-domain': 'de' 120 | _: 'search' 121 | t.end() 122 | -------------------------------------------------------------------------------- /test/stringify-fixtures.coffee: -------------------------------------------------------------------------------- 1 | test = require 'tape' 2 | UrlPattern = require '../lib/url-pattern' 3 | 4 | test 'stringify', (t) -> 5 | pattern = new UrlPattern '/foo' 6 | t.equal '/foo', pattern.stringify() 7 | 8 | pattern = new UrlPattern '/user/:userId/task/:taskId' 9 | t.equal '/user/10/task/52', pattern.stringify 10 | userId: '10' 11 | taskId: '52' 12 | 13 | pattern = new UrlPattern '/user/:userId/task/:taskId' 14 | t.equal '/user/10/task/52', pattern.stringify 15 | userId: '10' 16 | taskId: '52' 17 | ignored: 'ignored' 18 | 19 | pattern = new UrlPattern '.user.:userId.task.:taskId' 20 | t.equal '.user.10.task.52', pattern.stringify 21 | userId: '10' 22 | taskId: '52' 23 | 24 | pattern = new UrlPattern '*/user/:userId' 25 | t.equal '/school/10/user/10', pattern.stringify 26 | _: '/school/10', 27 | userId: '10' 28 | 29 | pattern = new UrlPattern '*-user-:userId' 30 | t.equal '-school-10-user-10', pattern.stringify 31 | _: '-school-10' 32 | userId: '10' 33 | 34 | pattern = new UrlPattern '/admin*' 35 | t.equal '/admin/school/10/user/10', pattern.stringify 36 | _: '/school/10/user/10' 37 | 38 | pattern = new UrlPattern '/admin/*/user/*/tail' 39 | t.equal '/admin/school/10/user/10/12/tail', pattern.stringify 40 | _: ['school/10', '10/12'] 41 | 42 | pattern = new UrlPattern '/admin/*/user/:id/*/tail' 43 | t.equal '/admin/school/10/user/10/12/13/tail', pattern.stringify 44 | _: ['school/10', '12/13'] 45 | id: '10' 46 | 47 | pattern = new UrlPattern '/*/admin(/:path)' 48 | t.equal '/foo/admin/baz', pattern.stringify 49 | _: 'foo' 50 | path: 'baz' 51 | t.equal '/foo/admin', pattern.stringify 52 | _: 'foo' 53 | 54 | pattern = new UrlPattern '(/)' 55 | t.equal '', pattern.stringify() 56 | 57 | pattern = new UrlPattern '/admin(/foo)/bar' 58 | t.equal '/admin/bar', pattern.stringify() 59 | 60 | pattern = new UrlPattern '/admin(/:foo)/bar' 61 | t.equal '/admin/bar', pattern.stringify() 62 | t.equal '/admin/baz/bar', pattern.stringify 63 | foo: 'baz' 64 | 65 | pattern = new UrlPattern '/admin/(*/)foo' 66 | t.equal '/admin/foo', pattern.stringify() 67 | t.equal '/admin/baz/bar/biff/foo', pattern.stringify 68 | _: 'baz/bar/biff' 69 | 70 | pattern = new UrlPattern '/v:major.:minor/*' 71 | t.equal '/v1.2/resource/', pattern.stringify 72 | _: 'resource/' 73 | major: '1' 74 | minor: '2' 75 | 76 | pattern = new UrlPattern '/v:v.:v/*' 77 | t.equal '/v1.2/resource/', pattern.stringify 78 | _: 'resource/' 79 | v: ['1', '2'] 80 | 81 | pattern = new UrlPattern '/:foo_bar' 82 | t.equal '/a_bar', pattern.stringify 83 | foo: 'a' 84 | t.equal '/a__bar', pattern.stringify 85 | foo: 'a_' 86 | t.equal '/a-b-c-d__bar', pattern.stringify 87 | foo: 'a-b-c-d_' 88 | t.equal '/a b%c-d__bar', pattern.stringify 89 | foo: 'a b%c-d_' 90 | 91 | pattern = new UrlPattern '((((a)b)c)d)' 92 | t.equal '', pattern.stringify() 93 | 94 | pattern = new UrlPattern '(:a-)1-:b(-2-:c-3-:d(-4-*-:a))' 95 | t.equal '1-B', pattern.stringify 96 | b: 'B' 97 | t.equal 'A-1-B', pattern.stringify 98 | a: 'A' 99 | b: 'B' 100 | t.equal 'A-1-B', pattern.stringify 101 | a: 'A' 102 | b: 'B' 103 | t.equal 'A-1-B-2-C-3-D', pattern.stringify 104 | a: 'A' 105 | b: 'B' 106 | c: 'C' 107 | d: 'D' 108 | t.equal 'A-1-B-2-C-3-D-4-E-F', pattern.stringify 109 | a: ['A', 'F'] 110 | b: 'B' 111 | c: 'C' 112 | d: 'D' 113 | _: 'E' 114 | 115 | pattern = new UrlPattern '/user/:range' 116 | t.equal '/user/10-20', pattern.stringify 117 | range: '10-20' 118 | 119 | t.end() 120 | 121 | test 'stringify errors', (t) -> 122 | t.plan 5 123 | 124 | pattern = new UrlPattern '(:a-)1-:b(-2-:c-3-:d(-4-*-:a))' 125 | 126 | try 127 | pattern.stringify() 128 | catch e 129 | t.equal e.message, "no values provided for key `b`" 130 | try 131 | pattern.stringify 132 | a: 'A' 133 | b: 'B' 134 | c: 'C' 135 | catch e 136 | t.equal e.message, "no values provided for key `d`" 137 | try 138 | pattern.stringify 139 | a: 'A' 140 | b: 'B' 141 | d: 'D' 142 | catch e 143 | t.equal e.message, "no values provided for key `c`" 144 | try 145 | pattern.stringify 146 | a: 'A' 147 | b: 'B' 148 | c: 'C' 149 | d: 'D' 150 | _: 'E' 151 | catch e 152 | t.equal e.message, "too few values provided for key `a`" 153 | try 154 | pattern.stringify 155 | a: ['A', 'F'] 156 | b: 'B' 157 | c: 'C' 158 | d: 'D' 159 | catch e 160 | t.equal e.message, "no values provided for key `_`" 161 | 162 | t.end() 163 | --------------------------------------------------------------------------------