diff --git a/.gitignore b/.gitignore index c789a61..5def1a2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ node_modules/** test/url-pattern.js **.DS_Store npm-debug.log +dist +package-lock.json +.nyc_output +coverage diff --git a/.travis.yml b/.travis.yml index dc308e7..78dfd38 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,37 +1,27 @@ -language: node_js -node_js: - - "0.12" - - "iojs-3" - - "4" - - "5" -script: npm run $NPM_COMMAND -sudo: false +matrix: + include: + - language: node_js + node_js: "12" + script: + - npm audit + - npm run compile + - npm run lint + - npm run coverage + - npm run bundle + - node parcel-bundle-test.js + - language: node_js + node_js: "10.15" + script: + - npm test + - language: minimal + name: "deno" + script: + - curl -fsSL https://deno.land/x/install/install.sh | sh + - export PATH="$HOME/.deno/bin:$PATH" + - deno run deno-test.ts env: global: # SAUCE_USERNAME - secure: "Js6Pr7dJfvAKY5JuuuEJSrDvoBCrnjTjISMCPBmH0CkGwtGR7J2mCvaLcXQ9RO3zrSasdj8Rb6gBmrIgk1fQbp/NpwQVwMUPH4J+dhbwTHIrrIHVtxt6q8cPx43RJqjE6qN+G1MA/Y4IVbgAzjJPnzu6A6v7E/FzSFbpNilv2i4=" # SAUCE_ACCESS_KEY - secure: "idJFmSy6EyMNO9UoxUx0wG83G/w8H1Sh1fG5lWodAdV01/Ft0j3KQo/zelENBx7zMWf+iqdWOhL4rBLIIkaajHbmvkMYDzhFXK4GIZmd1HnV4MZCunipscMsEbtQU+uTY/I3fersnIz74aTuj3SKlFW4jVNgvc8fawijBtTbuhU=" - matrix: - - NPM_COMMAND=test - - NPM_COMMAND=test-with-coverage - - NPM_COMMAND=test-in-browsers -matrix: - exclude: - # don't test in browsers more than once (already done with node 5) - - node_js: "0.12" - env: NPM_COMMAND=test-in-browsers - - node_js: "iojs-3" - env: NPM_COMMAND=test-in-browsers - - node_js: "4" - env: NPM_COMMAND=test-in-browsers - # don't collect code coverage more than once (already done with node 5) - - node_js: "0.12" - env: NPM_COMMAND=test-with-coverage - - node_js: "iojs-3" - env: NPM_COMMAND=test-with-coverage - - node_js: "4" - env: NPM_COMMAND=test-with-coverage - # already tested with coverage (with node 5). no need to test again without - - node_js: "5" - env: NPM_COMMAND=test diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ce5cd..06d02ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,3 +82,10 @@ non breaking non breaking messages on errors thrown on invalid patterns have changed slightly. + +#### 2.0 + +- UrlPattern now uses typescript instead of coffeescript +- renamed `UrlPattern.newParser` to `UrlPattern.newUrlPatternParser` +- renamed `UrlPattern.escapeStringForRegex` to `UrlPattern.escapeStringForRegex` +- `UrlPattern.match` now returns `undefined` (previously `null`) if there's no match diff --git a/README.md b/README.md index 8cd7832..192c61c 100644 --- a/README.md +++ b/README.md @@ -12,147 +12,155 @@ turn strings into data or data into strings.** > This is a great little library -- thanks! > [michael](https://github.com/snd/url-pattern/pull/7) -[make pattern:](#make-pattern-from-string) -``` javascript -var pattern = new UrlPattern('/api/users(/:id)'); +[make a pattern:](#make-pattern-from-string) +```typescript +> const pattern = new UrlPattern("/api/users(/:id)"); ``` -[match pattern against string and extract values:](#match-pattern-against-string) -``` javascript -pattern.match('/api/users/10'); // {id: '10'} -pattern.match('/api/users'); // {} -pattern.match('/api/products/5'); // null +[match a pattern against a string and extract values:](#match-pattern-against-string) +```typescript +> pattern.match("/api/users/10"); +{id: "10"} + +> pattern.match("/api/users"); +{} + +> pattern.match("/api/products/5"); +undefined ``` -[generate string from pattern and values:](#stringify-patterns) -``` javascript -pattern.stringify() // '/api/users' -pattern.stringify({id: 20}) // '/api/users/20' +[generate a string from a pattern and values:](#stringify-patterns) +```typescript +> pattern.stringify() +"/api/users" + +> pattern.stringify({id: 20}) +"/api/users/20" ``` -- continuously tested in Node.js (0.12, 4.2.3 and 5.3) and all relevant browsers: - [![Sauce Test Status](https://saucelabs.com/browser-matrix/urlpattern.svg)](https://saucelabs.com/u/urlpattern) -- [tiny single file with just under 500 lines of simple, readable, maintainable code](src/url-pattern.coffee) +prefer a different syntax? [customize it:](#customize-the-pattern-syntax) +```typescript +> const pattern = new UrlPattern("/api/users/{id}", { + segmentNameEndChar: "}", + segmentNameStartChar: "{", +} + +> pattern.match("/api/users/5") +{id: "5"} +``` + +- very fast matching as each pattern is compiled into a regex +- [tiny source of around 500 lines of simple, readable typescript](src/) +- widely used [![Downloads per Month](https://img.shields.io/npm/dm/url-pattern.svg?style=flat)](https://www.npmjs.org/package/url-pattern) +- zero dependencies +- [parser](src/parser.ts) implemented using simple, precise, reusable [parser combinators](src/parsercombinators.ts) +- continuously tested in Node.js (10.15 (LTS), 12) and all relevant browsers - [huge test suite](test) - passing [![Build Status](https://travis-ci.org/snd/url-pattern.svg?branch=master)](https://travis-ci.org/snd/url-pattern/branches) + [![Build Status](https://travis-ci.org/snd/url-pattern.svg?branch=master)](https://travis-ci.org/snd/url-pattern/branches) with [![codecov.io](http://codecov.io/github/snd/url-pattern/coverage.svg?branch=master)](http://codecov.io/github/snd/url-pattern?branch=master) code coverage -- widely used [![Downloads per Month](https://img.shields.io/npm/dm/url-pattern.svg?style=flat)](https://www.npmjs.org/package/url-pattern) -- supports CommonJS, [AMD](http://requirejs.org/docs/whyamd.html) and browser globals - - `require('url-pattern')` - - use [lib/url-pattern.js](lib/url-pattern.js) in the browser - - sets the global variable `UrlPattern` when neither CommonJS nor [AMD](http://requirejs.org/docs/whyamd.html) are available. -- very fast matching as each pattern is compiled into a regex exactly once -- zero dependencies -- [customizable](#customize-the-pattern-syntax) +- [customizable pattern syntax](#customize-the-pattern-syntax) - [frequently asked questions](#frequently-asked-questions) -- npm package: `npm install url-pattern` -- bower package: `bower install url-pattern` -- pattern parser implemented using simple, combosable, testable [parser combinators](https://en.wikipedia.org/wiki/Parser_combinator) -- [typescript typings](index.d.ts) -[check out **passage** if you are looking for simple composable routing that builds on top of url-pattern](https://github.com/snd/passage) +## a more complex example showing the power of url-pattern -``` -npm install url-pattern -``` +``` typescript +> const pattern = new UrlPattern("(http(s)\\://)(:subdomain.):domain.:tld(\\::port)(/*:path)") -``` -bower install url-pattern -``` +> pattern.match("google.de"); +{domain: "google", tld: "de"} -```javascript -> var UrlPattern = require('url-pattern'); -``` +> pattern.match("https://www.google.com"); +{subdomain: "www", domain: "google", tld: "com"} -``` javascript -> var pattern = new UrlPattern('/v:major(.:minor)/*'); +> pattern.match("http://mail.google.com/mail"); +{subdomain: "mail", domain: "google", tld: "com", path: "mail"} -> pattern.match('/v1.2/'); -{major: '1', minor: '2', _: ''} +> pattern.match("http://mail.google.com:80/mail/inbox"); +{subdomain: "mail", domain: "google", tld: "com", port: "80", path: "mail/inbox"} -> pattern.match('/v2/users'); -{major: '2', _: 'users'} - -> pattern.match('/v/'); -null +> pattern.match("google"); +undefined ``` -``` javascript -> var pattern = new UrlPattern('(http(s)\\://)(:subdomain.):domain.:tld(\\::port)(/*)') -> pattern.match('google.de'); -{domain: 'google', tld: 'de'} +## install -> pattern.match('https://www.google.com'); -{subdomain: 'www', domain: 'google', tld: 'com'} +``` +npm install url-pattern +``` +and +```typescript +> import UrlPattern from "url-pattern"; +``` +or +```typescript +> const UrlPattern = require("url-pattern").default; +``` -> pattern.match('http://mail.google.com/mail'); -{subdomain: 'mail', domain: 'google', tld: 'com', _: 'mail'} +## works with [deno](https://deno.land/): -> pattern.match('http://mail.google.com:80/mail'); -{subdomain: 'mail', domain: 'google', tld: 'com', port: '80', _: 'mail'} +**stable** latest release: +```typescript +import UrlPattern from "https://raw.githubusercontent.com/snd/url-pattern/2.0.0/src/url-pattern.ts"; +``` -> pattern.match('google'); -null +**bleeding edge** master: +```typescript +import UrlPattern from "https://raw.githubusercontent.com/snd/url-pattern/master/src/url-pattern.ts"; ``` -## make pattern from string +## reference -```javascript -> var pattern = new UrlPattern('/api/users/:id'); +### make pattern from string + +```typescript +> const pattern = new UrlPattern("/api/users/:id"); ``` -a `pattern` is immutable after construction. +a `UrlPattern` is immutable after construction. none of its methods changes its state. that makes it easier to reason about. -## match pattern against string +### match pattern against string match returns the extracted segments: -```javascript -> pattern.match('/api/users/10'); -{id: '10'} +```typescript +> pattern.match("/api/users/10"); +{id: "10"} ``` -or `null` if there was no match: +or `undefined` if there was no match: -``` javascript -> pattern.match('/api/products/5'); -null +```typescript +> pattern.match("/api/products/5"); +undefined ``` patterns are compiled into regexes which makes `.match()` superfast. -## named segments +### named segments `:id` (in the example above) is a named segment: a named segment starts with `:` followed by the **name**. -the **name** must be at least one character in the regex character set `a-zA-Z0-9`. +the **name** must be at least one character in the regex character set `a-zA-Z0-9_`. when matching, a named segment consumes all characters in the regex character set -`a-zA-Z0-9-_~ %`. +`a-zA-Z0-9-_~ %`. a named segment match stops at `/`, `.`, ... but not at `_`, `-`, ` `, `%`... [you can change these character sets. click here to see how.](#customize-the-pattern-syntax) -if a named segment **name** occurs more than once in the pattern string, -then the multiple results are stored in an array on the returned object: +names must be unique. a **name** may not appear twice in a pattern. -```javascript -> var pattern = new UrlPattern('/api/users/:ids/posts/:ids'); -> pattern.match('/api/users/10/posts/5'); -{ids: ['10', '5']} -``` - -## optional segments, wildcards and escaping +### optional segments, wildcards and escaping to make part of a pattern optional just wrap it in `(` and `)`: -```javascript -> var pattern = new UrlPattern( - '(http(s)\\://)(:subdomain.):domain.:tld(/*)' +```typescript +> const pattern = new UrlPattern( + "(http(s)\\://)(:subdomain.):domain.:tld(/*:path)" ); ``` @@ -162,89 +170,94 @@ url-pattern. optional named segments are stored in the corresponding property only if they are present in the source string: -```javascript -> pattern.match('google.de'); -{domain: 'google', tld: 'de'} +```typescript +> pattern.match("google.de"); +{domain: "google", tld: "de"} ``` -```javascript -> pattern.match('https://www.google.com'); -{subdomain: 'www', domain: 'google', tld: 'com'} +```typescript +> pattern.match("https://www.google.com"); +{subdomain: "www", domain: "google", tld: "com"} ``` -`*` in patterns are wildcards and match anything. -wildcard matches are collected in the `_` property: +`:*path` in the pattern above is a named wildcard with the name `path`. +named wildcards match anything. that makes them different from named segments which +only match characters inside the `options.segmentNameCharset` (default: `a-zA-Z0-9_-`). -```javascript -> pattern.match('http://mail.google.com/mail'); -{subdomain: 'mail', domain: 'google', tld: 'com', _: 'mail'} +```typescript +> pattern.match("http://mail.google.com/mail/inbox"); +{subdomain: "mail", domain: "google", tld: "com", path: "mail/inbox"} ``` -if there is only one wildcard then `_` contains the matching string. -otherwise `_` contains an array of matching strings. - -[look at the tests for additional examples of `.match`](test/match-fixtures.coffee) +there are also -## make pattern from regex +unnamed wildcards are not collected. -```javascript -> var pattern = new UrlPattern(/^\/api\/(.*)$/); +```typescript +> const pattern = new UrlPattern('/search/*:term'); +> pattern.match('/search/fruit'); +{term: 'fruit'} ``` -if the pattern was created from a regex an array of the captured groups is returned on a match: +[look at the tests for additional examples of `.match`](test/match-fixtures.ts) + +### make pattern from regex + +```typescript +> const pattern = new UrlPattern(/^\/api\/(.*)$/, ["path"]); -```javascript -> pattern.match('/api/users'); -['users'] +> pattern.match("/api/users"); +{path: "users"} -> pattern.match('/apiii/test'); -null +> pattern.match("/apiii/test"); +undefined ``` when making a pattern from a regex -you can pass an array of keys as the second argument. +you have to pass an array of keys as the second argument. returns objects on match with each key mapped to a captured value: -```javascript -> var pattern = new UrlPattern( +```typescript +> const pattern = new UrlPattern( /^\/api\/([^\/]+)(?:\/(\d+))?$/, - ['resource', 'id'] + ["resource", "id"] ); -> pattern.match('/api/users'); -{resource: 'users'} +> pattern.match("/api/users"); +{resource: "users"} -> pattern.match('/api/users/5'); -{resource: 'users', id: '5'} +> pattern.match("/api/users/5"); +{resource: "users", id: "5"} -> pattern.match('/api/users/foo'); -null +> pattern.match("/api/users/foo"); +undefined ``` -## stringify patterns +### stringify patterns -```javascript -> var pattern = new UrlPattern('/api/users/:id'); +```typescript +> const pattern = new UrlPattern("/api/users/:id"); > pattern.stringify({id: 10}) -'/api/users/10' +"/api/users/10" ``` optional segments are only included in the output if they contain named segments and/or wildcards and values for those are provided: -```javascript -> var pattern = new UrlPattern('/api/users(/:id)'); +```typescript +> const pattern = new UrlPattern("/api/users(/:id)"); > pattern.stringify() -'/api/users' +"/api/users" > pattern.stringify({id: 10}) -'/api/users/10' +"/api/users/10" ``` -wildcards (key = `_`), deeply nested optional groups and multiple value arrays should stringify as expected. +named wildcards and deeply nested optional groups should stringify as expected. +TODO an error is thrown if a value that is not in an optional group is not provided. an error is thrown if an optional segment contains multiple @@ -252,73 +265,82 @@ params and not all of them are provided. *one provided value for an optional segment makes all values in that optional segment required.* -[look at the tests for additional examples of `.stringify`](test/stringify-fixtures.coffee) +TODO +anonymous wildcards are ignored. -## customize the pattern syntax +[look at the tests for additional examples of `.stringify`](test/stringify-fixtures.ts) + +### customize the pattern syntax finally we can completely change pattern-parsing and regex-compilation to suit our needs: -```javascript -> var options = {}; +```typescript +> let options = {}; ``` let's change the char used for escaping (default `\\`): -```javascript -> options.escapeChar = '!'; +```typescript +> options.escapeChar = "!"; ``` let's change the char used to start a named segment (default `:`): -```javascript -> options.segmentNameStartChar = '$'; +```typescript +> options.segmentNameStartChar = "{"; +``` + +let's add a char required at the end of a named segment (default nothing): + +```typescript +> options.segmentNameEndChar = "}"; ``` -let's change the set of chars allowed in named segment names (default `a-zA-Z0-9`) -to also include `_` and `-`: +let's change the set of chars allowed in named segment names (default `a-zA-Z0-9_`) +to also include `-`: -```javascript -> options.segmentNameCharset = 'a-zA-Z0-9_-'; +```typescript +> options.segmentNameCharset = "a-zA-Z0-9_-"; ``` let's change the set of chars allowed in named segment values (default `a-zA-Z0-9-_~ %`) to not allow non-alphanumeric chars: -```javascript -> options.segmentValueCharset = 'a-zA-Z0-9'; +```typescript +> options.segmentValueCharset = "a-zA-Z0-9"; ``` let's change the chars used to surround an optional segment (default `(` and `)`): -```javascript -> options.optionalSegmentStartChar = '['; -> options.optionalSegmentEndChar = ']'; +```typescript +> options.optionalSegmentStartChar = "<"; +> options.optionalSegmentEndChar = ">"; ``` let's change the char used to denote a wildcard (default `*`): -```javascript -> options.wildcardChar = '?'; +```typescript +> options.wildcardChar = "#"; ``` pass options as the second argument to the constructor: -```javascript -> var pattern = new UrlPattern( - '[http[s]!://][$sub_domain.]$domain.$toplevel-domain[/?]', +```typescript +> const pattern = new UrlPattern( + "!://><{sub_domain}.>{domain}.{toplevel-domain}", options ); ``` then match: -```javascript -> pattern.match('http://mail.google.com/mail'); +```typescript +> pattern.match("http://mail.google.com/mail"); { - sub_domain: 'mail', - domain: 'google', - 'toplevel-domain': 'com', - _: 'mail' + sub_domain: "mail", + domain: "google", + "toplevel-domain": "com", + path: "mail" } ``` diff --git a/deno-test.ts b/deno-test.ts new file mode 100644 index 0000000..cca8a52 --- /dev/null +++ b/deno-test.ts @@ -0,0 +1,8 @@ +import UrlPattern from "./src/url-pattern.ts"; +import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + +const pattern = new UrlPattern("/api/users/:id"); + +assertEquals(pattern.match("/api/users/5"), {id: "5"}); +assertEquals(pattern.stringify({id: 10}), "/api/users/10"); +console.log("OK"); diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index 347cdd1..0000000 --- a/index.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -interface UrlPatternOptions { - escapeChar?: string; - segmentNameStartChar?: string; - segmentValueCharset?: string; - segmentNameCharset?: string; - optionalSegmentStartChar?: string; - optionalSegmentEndChar?: string; - wildcardChar?: string; -} - -declare class UrlPattern { - constructor(pattern: string, options?: UrlPatternOptions); - constructor(pattern: RegExp, groupNames?: string[]); - - match(url: string): any; - stringify(values?: any): string; -} - -declare module UrlPattern { } - -export = UrlPattern; diff --git a/lib/url-pattern.js b/lib/url-pattern.js deleted file mode 100644 index 0d635c2..0000000 --- a/lib/url-pattern.js +++ /dev/null @@ -1,436 +0,0 @@ -// Generated by CoffeeScript 1.10.0 -var slice = [].slice; - -(function(root, factory) { - if (('function' === typeof define) && (define.amd != null)) { - return define([], factory); - } else if (typeof exports !== "undefined" && exports !== null) { - return module.exports = factory(); - } else { - return root.UrlPattern = factory(); - } -})(this, function() { - var P, UrlPattern, astNodeContainsSegmentsForProvidedParams, astNodeToNames, astNodeToRegexString, baseAstNodeToRegexString, concatMap, defaultOptions, escapeForRegex, getParam, keysAndValuesToObject, newParser, regexGroupCount, stringConcatMap, stringify; - escapeForRegex = function(string) { - return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); - }; - concatMap = function(array, f) { - var i, length, results; - results = []; - i = -1; - length = array.length; - while (++i < length) { - results = results.concat(f(array[i])); - } - return results; - }; - stringConcatMap = function(array, f) { - var i, length, result; - result = ''; - i = -1; - length = array.length; - while (++i < length) { - result += f(array[i]); - } - return result; - }; - regexGroupCount = function(regex) { - return (new RegExp(regex.toString() + '|')).exec('').length - 1; - }; - keysAndValuesToObject = function(keys, values) { - var i, key, length, object, value; - object = {}; - i = -1; - length = keys.length; - while (++i < length) { - key = keys[i]; - value = values[i]; - if (value == null) { - continue; - } - if (object[key] != null) { - if (!Array.isArray(object[key])) { - object[key] = [object[key]]; - } - object[key].push(value); - } else { - object[key] = value; - } - } - return object; - }; - P = {}; - P.Result = function(value, rest) { - this.value = value; - this.rest = rest; - }; - P.Tagged = function(tag, value) { - this.tag = tag; - this.value = value; - }; - P.tag = function(tag, parser) { - return function(input) { - var result, tagged; - result = parser(input); - if (result == null) { - return; - } - tagged = new P.Tagged(tag, result.value); - return new P.Result(tagged, result.rest); - }; - }; - P.regex = function(regex) { - return function(input) { - var matches, result; - matches = regex.exec(input); - if (matches == null) { - return; - } - result = matches[0]; - return new P.Result(result, input.slice(result.length)); - }; - }; - P.sequence = function() { - var parsers; - parsers = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return function(input) { - var i, length, parser, rest, result, values; - i = -1; - length = parsers.length; - values = []; - rest = input; - while (++i < length) { - parser = parsers[i]; - result = parser(rest); - if (result == null) { - return; - } - values.push(result.value); - rest = result.rest; - } - return new P.Result(values, rest); - }; - }; - P.pick = function() { - var indexes, parsers; - indexes = arguments[0], parsers = 2 <= arguments.length ? slice.call(arguments, 1) : []; - return function(input) { - var array, result; - result = P.sequence.apply(P, parsers)(input); - if (result == null) { - return; - } - array = result.value; - result.value = array[indexes]; - return result; - }; - }; - P.string = function(string) { - var length; - length = string.length; - return function(input) { - if (input.slice(0, length) === string) { - return new P.Result(string, input.slice(length)); - } - }; - }; - P.lazy = function(fn) { - var cached; - cached = null; - return function(input) { - if (cached == null) { - cached = fn(); - } - return cached(input); - }; - }; - P.baseMany = function(parser, end, stringResult, atLeastOneResultRequired, input) { - var endResult, parserResult, rest, results; - rest = input; - results = stringResult ? '' : []; - while (true) { - if (end != null) { - endResult = end(rest); - if (endResult != null) { - break; - } - } - parserResult = parser(rest); - if (parserResult == null) { - break; - } - if (stringResult) { - results += parserResult.value; - } else { - results.push(parserResult.value); - } - rest = parserResult.rest; - } - if (atLeastOneResultRequired && results.length === 0) { - return; - } - return new P.Result(results, rest); - }; - P.many1 = function(parser) { - return function(input) { - return P.baseMany(parser, null, false, true, input); - }; - }; - P.concatMany1Till = function(parser, end) { - return function(input) { - return P.baseMany(parser, end, true, true, input); - }; - }; - P.firstChoice = function() { - var parsers; - parsers = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return function(input) { - var i, length, parser, result; - i = -1; - length = parsers.length; - while (++i < length) { - parser = parsers[i]; - result = parser(input); - if (result != null) { - return result; - } - } - }; - }; - newParser = function(options) { - var U; - U = {}; - U.wildcard = P.tag('wildcard', P.string(options.wildcardChar)); - U.optional = P.tag('optional', P.pick(1, P.string(options.optionalSegmentStartChar), P.lazy(function() { - return U.pattern; - }), P.string(options.optionalSegmentEndChar))); - U.name = P.regex(new RegExp("^[" + options.segmentNameCharset + "]+")); - U.named = P.tag('named', P.pick(1, P.string(options.segmentNameStartChar), P.lazy(function() { - return U.name; - }))); - U.escapedChar = P.pick(1, P.string(options.escapeChar), P.regex(/^./)); - U["static"] = P.tag('static', P.concatMany1Till(P.firstChoice(P.lazy(function() { - return U.escapedChar; - }), P.regex(/^./)), P.firstChoice(P.string(options.segmentNameStartChar), P.string(options.optionalSegmentStartChar), P.string(options.optionalSegmentEndChar), U.wildcard))); - U.token = P.lazy(function() { - return P.firstChoice(U.wildcard, U.optional, U.named, U["static"]); - }); - U.pattern = P.many1(P.lazy(function() { - return U.token; - })); - return U; - }; - defaultOptions = { - escapeChar: '\\', - segmentNameStartChar: ':', - segmentValueCharset: 'a-zA-Z0-9-_~ %', - segmentNameCharset: 'a-zA-Z0-9', - optionalSegmentStartChar: '(', - optionalSegmentEndChar: ')', - wildcardChar: '*' - }; - baseAstNodeToRegexString = function(astNode, segmentValueCharset) { - if (Array.isArray(astNode)) { - return stringConcatMap(astNode, function(node) { - return baseAstNodeToRegexString(node, segmentValueCharset); - }); - } - switch (astNode.tag) { - case 'wildcard': - return '(.*?)'; - case 'named': - return "([" + segmentValueCharset + "]+)"; - case 'static': - return escapeForRegex(astNode.value); - case 'optional': - return '(?:' + baseAstNodeToRegexString(astNode.value, segmentValueCharset) + ')?'; - } - }; - astNodeToRegexString = function(astNode, segmentValueCharset) { - if (segmentValueCharset == null) { - segmentValueCharset = defaultOptions.segmentValueCharset; - } - return '^' + baseAstNodeToRegexString(astNode, segmentValueCharset) + '$'; - }; - astNodeToNames = function(astNode) { - if (Array.isArray(astNode)) { - return concatMap(astNode, astNodeToNames); - } - switch (astNode.tag) { - case 'wildcard': - return ['_']; - case 'named': - return [astNode.value]; - case 'static': - return []; - case 'optional': - return astNodeToNames(astNode.value); - } - }; - getParam = function(params, key, nextIndexes, sideEffects) { - var index, maxIndex, result, value; - if (sideEffects == null) { - sideEffects = false; - } - value = params[key]; - if (value == null) { - if (sideEffects) { - throw new Error("no values provided for key `" + key + "`"); - } else { - return; - } - } - index = nextIndexes[key] || 0; - maxIndex = Array.isArray(value) ? value.length - 1 : 0; - if (index > maxIndex) { - if (sideEffects) { - throw new Error("too few values provided for key `" + key + "`"); - } else { - return; - } - } - result = Array.isArray(value) ? value[index] : value; - if (sideEffects) { - nextIndexes[key] = index + 1; - } - return result; - }; - astNodeContainsSegmentsForProvidedParams = function(astNode, params, nextIndexes) { - var i, length; - if (Array.isArray(astNode)) { - i = -1; - length = astNode.length; - while (++i < length) { - if (astNodeContainsSegmentsForProvidedParams(astNode[i], params, nextIndexes)) { - return true; - } - } - return false; - } - switch (astNode.tag) { - case 'wildcard': - return getParam(params, '_', nextIndexes, false) != null; - case 'named': - return getParam(params, astNode.value, nextIndexes, false) != null; - case 'static': - return false; - case 'optional': - return astNodeContainsSegmentsForProvidedParams(astNode.value, params, nextIndexes); - } - }; - stringify = function(astNode, params, nextIndexes) { - if (Array.isArray(astNode)) { - return stringConcatMap(astNode, function(node) { - return stringify(node, params, nextIndexes); - }); - } - switch (astNode.tag) { - case 'wildcard': - return getParam(params, '_', nextIndexes, true); - case 'named': - return getParam(params, astNode.value, nextIndexes, true); - case 'static': - return astNode.value; - case 'optional': - if (astNodeContainsSegmentsForProvidedParams(astNode.value, params, nextIndexes)) { - return stringify(astNode.value, params, nextIndexes); - } else { - return ''; - } - } - }; - UrlPattern = function(arg1, arg2) { - var groupCount, options, parsed, parser, withoutWhitespace; - if (arg1 instanceof UrlPattern) { - this.isRegex = arg1.isRegex; - this.regex = arg1.regex; - this.ast = arg1.ast; - this.names = arg1.names; - return; - } - this.isRegex = arg1 instanceof RegExp; - if (!(('string' === typeof arg1) || this.isRegex)) { - throw new TypeError('argument must be a regex or a string'); - } - if (this.isRegex) { - this.regex = arg1; - if (arg2 != null) { - if (!Array.isArray(arg2)) { - throw new Error('if first argument is a regex the second argument may be an array of group names but you provided something else'); - } - groupCount = regexGroupCount(this.regex); - if (arg2.length !== groupCount) { - throw new Error("regex contains " + groupCount + " groups but array of group names contains " + arg2.length); - } - this.names = arg2; - } - return; - } - if (arg1 === '') { - throw new Error('argument must not be the empty string'); - } - withoutWhitespace = arg1.replace(/\s+/g, ''); - if (withoutWhitespace !== arg1) { - throw new Error('argument must not contain whitespace'); - } - options = { - escapeChar: (arg2 != null ? arg2.escapeChar : void 0) || defaultOptions.escapeChar, - segmentNameStartChar: (arg2 != null ? arg2.segmentNameStartChar : void 0) || defaultOptions.segmentNameStartChar, - segmentNameCharset: (arg2 != null ? arg2.segmentNameCharset : void 0) || defaultOptions.segmentNameCharset, - segmentValueCharset: (arg2 != null ? arg2.segmentValueCharset : void 0) || defaultOptions.segmentValueCharset, - optionalSegmentStartChar: (arg2 != null ? arg2.optionalSegmentStartChar : void 0) || defaultOptions.optionalSegmentStartChar, - optionalSegmentEndChar: (arg2 != null ? arg2.optionalSegmentEndChar : void 0) || defaultOptions.optionalSegmentEndChar, - wildcardChar: (arg2 != null ? arg2.wildcardChar : void 0) || defaultOptions.wildcardChar - }; - parser = newParser(options); - parsed = parser.pattern(arg1); - if (parsed == null) { - throw new Error("couldn't parse pattern"); - } - if (parsed.rest !== '') { - throw new Error("could only partially parse pattern"); - } - this.ast = parsed.value; - this.regex = new RegExp(astNodeToRegexString(this.ast, options.segmentValueCharset)); - this.names = astNodeToNames(this.ast); - }; - UrlPattern.prototype.match = function(url) { - var groups, match; - match = this.regex.exec(url); - if (match == null) { - return null; - } - groups = match.slice(1); - if (this.names) { - return keysAndValuesToObject(this.names, groups); - } else { - return groups; - } - }; - UrlPattern.prototype.stringify = function(params) { - if (params == null) { - params = {}; - } - if (this.isRegex) { - throw new Error("can't stringify patterns generated from a regex"); - } - if (params !== Object(params)) { - throw new Error("argument must be an object or undefined"); - } - return stringify(this.ast, params, {}); - }; - UrlPattern.escapeForRegex = escapeForRegex; - UrlPattern.concatMap = concatMap; - UrlPattern.stringConcatMap = stringConcatMap; - UrlPattern.regexGroupCount = regexGroupCount; - UrlPattern.keysAndValuesToObject = keysAndValuesToObject; - UrlPattern.P = P; - UrlPattern.newParser = newParser; - UrlPattern.defaultOptions = defaultOptions; - UrlPattern.astNodeToRegexString = astNodeToRegexString; - UrlPattern.astNodeToNames = astNodeToNames; - UrlPattern.getParam = getParam; - UrlPattern.astNodeContainsSegmentsForProvidedParams = astNodeContainsSegmentsForProvidedParams; - UrlPattern.stringify = stringify; - return UrlPattern; -}); diff --git a/package.json b/package.json index 7cb6ee7..37d829f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "url-pattern", - "version": "1.0.3", + "version": "2.0.0", "description": "easier than regex string matching patterns for urls and other strings. turn strings into data or data into strings.", "keywords": [ "url", @@ -30,6 +30,11 @@ "processing" ], "homepage": "http://github.com/snd/url-pattern", + "bugs": { + "url": "http://github.com/snd/url-pattern/issues", + "email": "kruemaxi@gmail.com" + }, + "license": "MIT", "author": { "name": "Maximilian Krüger", "email": "kruemaxi@gmail.com", @@ -58,37 +63,55 @@ "url": "https://github.com/caasi" } ], - "bugs": { - "url": "http://github.com/snd/url-pattern/issues", - "email": "kruemaxi@gmail.com" - }, + "files": [ + "src", + "dist" + ], + "main": "dist/url-pattern.js", + "browser": "dist/url-pattern.js", + "jsdelivr": "dist/url-pattern.js", + "types": "dist/url-pattern.d.ts", "repository": { "type": "git", "url": "git://github.com/snd/url-pattern.git" }, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - }, - "dependencies": {}, - "devDependencies": { - "codecov.io": "0.1.6", - "coffee-script": "1.10.0", - "coffeeify": "2.0.1", - "coffeetape": "1.0.1", - "istanbul": "0.4.1", - "tape": "4.2.2", - "zuul": "3.8.0" - }, - "main": "lib/url-pattern", "scripts": { - "compile": "coffee --bare --compile --output lib src", + "compile": "tsc", + "doc": "typedoc --out doc", "prepublish": "npm run compile", - "pretest": "npm run compile", - "test": "coffeetape test/*", - "test-with-coverage": "istanbul cover coffeetape test/* && cat ./coverage/coverage.json | ./node_modules/codecov.io/bin/codecov.io.js", - "test-in-browsers": "zuul test/*", + "lint": "tslint --project .", + "test": "tape -r ts-node/register test/*.ts", + "node": "ts-node", + "coverage": "rm -r .nyc_output || true && rm -r coverage || true && nyc npm test", + "bundle": "parcel build src/url-pattern.ts", + "test-in-browsers": "karma start test/*", "test-zuul-local": "zuul --local 8080 test/*" }, - "typings": "index.d.ts" + "dependencies": {}, + "devDependencies": { + "@types/tape": "^4.2.33", + "karma": "^4.1.0", + "karma-sauce-launcher": "^2.0.2", + "karma-typescript": "^4.0.0", + "nyc": "^14.1.0", + "parcel-bundler": "^1.12.3", + "tape": "^4.10.1", + "ts-node": "^8.1.0", + "tslint": "^5.16.0", + "typescript": "^3.4.5" + }, + "sideEffects": false, + "nyc": { + "include": [ + "src/*.ts" + ], + "extension": [ + ".ts" + ], + "reporter": [ + "json", + "html" + ], + "all": true + } } diff --git a/parcel-bundle-test.js b/parcel-bundle-test.js new file mode 100644 index 0000000..425fcec --- /dev/null +++ b/parcel-bundle-test.js @@ -0,0 +1,10 @@ +// tests that the bundle generated by parcel via `npm run bundle` works + +const assert = require("assert"); +const UrlPattern = require("./dist/url-pattern.js").default; + +const pattern = new UrlPattern("/api/users/:id"); + +assert.deepEqual(pattern.match("/api/users/5"), {id: "5"}); +assert.deepEqual(pattern.stringify({id: 10}), "/api/users/10"); +console.log("OK"); diff --git a/src/ast-helpers.ts b/src/ast-helpers.ts new file mode 100644 index 0000000..f98d6b2 --- /dev/null +++ b/src/ast-helpers.ts @@ -0,0 +1,159 @@ +/** + * functions that work on ASTs returned from the url-pattern parser + * within the `parser` module. + */ + +import { + Ast, +// @ts-ignore +} from "./parser-combinators.ts"; + +import { + escapeStringForRegex, +// @ts-ignore +} from "./helpers.ts"; + +/** + * converts an array of AST nodes `nodes` representing a parsed url-pattern into + * a string representing the regex which matches that url-pattern. + */ +function astToRegexString(nodes: Array>, segmentValueCharset: string): string { + let result = ""; + + for (const node of nodes) { + switch (node.tag) { + case "wildcard": + // ? = lazy + result += ".*?"; + continue; + case "namedWildcard": + // ? = lazy + result += "(.*?)"; + continue; + case "namedSegment": + result += `([${ segmentValueCharset }]+)`; + continue; + case "staticContent": + result += escapeStringForRegex(node.value); + continue; + case "optionalSegment": + result += `(?:${ astToRegexString(node.value, segmentValueCharset) })?`; + continue; + default: + throw new Error(`unknown tag \`${ node.tag }\``); + } + } + + return result; +} + +/** + * converts the root `astNode` of a parsed url-pattern into + * a string representing the regex that matches the url-pattern. + */ +export function astRootToRegexString(nodes: Array>, segmentValueCharset: string) { + return `^${ astToRegexString(nodes, segmentValueCharset) }$`; +} + +/** + * returns the names of any named segments and named wildcards contained + * in the url-pattern represented by the given AST `nodes` in order. + */ +export function astToNames(nodes: Array>): string[] { + const result: string[] = []; + + for (const node of nodes) { + switch (node.tag) { + case "wildcard": + case "staticContent": + continue; + case "namedWildcard": + case "namedSegment": + result.push(node.value); + continue; + case "optionalSegment": + // recurse into the optional segment + // optional segments values are always arrays + result.push(...astToNames(node.value)); + continue; + default: + throw new Error(`unknown tag \`${ node.tag }\``); + } + } + + return result; +} + +/** + * returns whether the given `astNode` contains + * any segments that + * based on this information optional segments are included or not. + */ +function astContainsAnySegmentsForParams( + nodes: Array>, + params: { [index: string]: any }, +): boolean { + for (const node of nodes) { + switch (node.tag) { + case "staticContent": + case "wildcard": + continue; + case "namedWildcard": + case "namedSegment": + if (params[node.value] != null) { + return true; + } + continue; + case "optionalSegment": + if (astContainsAnySegmentsForParams(node.value, params)) { + return false; + } + continue; + default: + throw new Error(`unknown tag \`${ node.tag }\``); + } + } + return false; +} + +/** + * turn an url-pattern AST and a mapping of `namesToValues` into a string + */ +export function stringify( + nodes: Array>, + namesToValues: { [index: string]: any }, +): string { + let result = ""; + + for (const node of nodes) { + switch (node.tag) { + case "wildcard": + continue; + case "namedWildcard": + case "namedSegment": + const value = namesToValues[node.value]; + if (value == null) { + throw new Error(`no value provided for name \`${ node.value }\``); + } + result += value; + continue; + case "staticContent": + result += node.value; + continue; + case "optionalSegment": + // only add optional segments if values are present. + // optional segments are only included if values are provided + // for all names (of named segments) within the optional segment + if (astContainsAnySegmentsForParams(node.value, namesToValues)) { + // recurse into the optional segment + // optional segments values are always arrays + result += stringify(node.value, namesToValues); + } + continue; + default: + throw new Error(`unknown tag \`${ node.tag }\``); + } + } + + return result; +} diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..85854e0 --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,40 @@ +/** + * escapes a string for insertion into a regular expression. + * source: http://stackoverflow.com/a/3561711 + */ +export function escapeStringForRegex(str: string): string { + return str.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"); +} + +/** + * returns the number of groups in the `regex`. + * source: http://stackoverflow.com/a/16047223 + */ +export function regexGroupCount(regex: RegExp): number { + // add a "|" to the end of the regex meaning logical OR. + const testingRegex = new RegExp(regex.toString() + "|"); + // executing the regex on an empty string matches the empty right side of the "|" (OR). + const matches: any = testingRegex.exec(""); + // `matches` is never null here as the regex always matches. + // the matches array contains an element for every group in the `regex`. + // thus we detect the number of groups in the regex. + return matches.length - 1; +} + +/** + * returns the index of the first duplicate element in `elements` + * or `-1` if there are no duplicates. + */ +export function indexOfDuplicateElement(elements: T[]): number { + const knownElements: Set = new Set(); + + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + if (knownElements.has(element)) { + return i; + } + knownElements.add(element); + } + + return -1; +} diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 0000000..3d29460 --- /dev/null +++ b/src/options.ts @@ -0,0 +1,31 @@ +export interface IUserInputOptions { + escapeChar?: string; + segmentNameStartChar?: string; + segmentNameEndChar?: string; + segmentValueCharset?: string; + segmentNameCharset?: string; + optionalSegmentStartChar?: string; + optionalSegmentEndChar?: string; + wildcardChar?: string; +} + +export interface IOptions { + escapeChar: string; + segmentNameStartChar: string; + segmentNameEndChar?: string; + segmentValueCharset: string; + segmentNameCharset: string; + optionalSegmentStartChar: string; + optionalSegmentEndChar: string; + wildcardChar: string; +} + +export const defaultOptions: IOptions = { + escapeChar: "\\", + optionalSegmentEndChar: ")", + optionalSegmentStartChar: "(", + segmentNameCharset: "a-zA-Z0-9_", + segmentNameStartChar: ":", + segmentValueCharset: "a-zA-Z0-9-_~ %", + wildcardChar: "*", +}; diff --git a/src/parser-combinators.ts b/src/parser-combinators.ts new file mode 100644 index 0000000..f1095b9 --- /dev/null +++ b/src/parser-combinators.ts @@ -0,0 +1,208 @@ +/** + * generic parser combinators used to build the url pattern parser (module `parser`) + */ + +/** + * parse result + */ +export class Result { + /* parsed value */ + public readonly value: Value; + /* unparsed rest */ + public readonly rest: string; + constructor(value: Value, rest: string) { + this.value = value; + this.rest = rest; + } +} + +/** + * a parser is a function that takes a string and returns a `Result` + * containing a parsed `Result.value` and the rest of the string `Result.rest` + */ +export type Parser = (str: string) => Result | undefined; + +/* + * returns a parser that consumes `str` exactly + */ +export function newStringParser(str: string): Parser { + const { length } = str; + return (input: string) => { + if (input.slice(0, length) === str) { + return new Result(str, input.slice(length)); + } + }; +} + +/** + * returns a parser that consumes everything matched by `regexp` + */ +export function newRegexParser(regexp: RegExp): Parser { + return (input: string) => { + const matches = regexp.exec(input); + if (matches == null) { + return; + } + const result = matches[0]; + return new Result(result, input.slice(result.length)); + }; +} + +/** + * node in the AST (abstract syntax tree) + */ +export class Ast { + public readonly tag: string; + public readonly value: Value; + constructor(tag: string, value: Value) { + this.tag = tag; + this.value = value; + } +} + +/** + * transforms a `parser` into a parser that returns an Ast node + */ +export function newAst(tag: string, parser: Parser): Parser> { + return (input: string) => { + const result = parser(input); + if (result == null) { + return; + } + const ast = new Ast(tag, result.value); + return new Result(ast, result.rest); + }; +} + +/* + * takes many `parsers`. + * returns a new parser that runs + * all `parsers` in sequence and returns an array of their results + */ +export function newSequenceParser(...parsers: Array>): Parser { + return (input: string) => { + let rest = input; + const values: any[] = []; + for (const parser of parsers) { + const result = parser(rest); + if (result == null) { + return; + } + values.push(result.value); + rest = result.rest; + } + return new Result(values, rest); + }; +} + +/* + * takes an `index` and many `parsers` + * + * takes a sequence of parser and only returns the result + * returned by the `index`th parser + */ +export function newPickNthParser(index: number, ...parsers: Array>): Parser { + const parser = newSequenceParser(...parsers); + return (input: string) => { + const result = parser(input); + if (result == null) { + return; + } + return new Result(result.value[index], result.rest); + }; +} + +/* + * for parsers that each depend on one another (cyclic dependencies) + * postpone lookup to when they both exist. + */ +export function newLazyParser(getParser: () => Parser): Parser { + let cachedParser: Parser | null = null; + return (input: string) => { + if (cachedParser == null) { + cachedParser = getParser(); + } + return cachedParser(input); + }; +} + +/** + * takes a `parser` and returns a parser that parses + * many occurences of the parser + * returns the results collected in an array. + */ +export function newAtLeastOneParser(parser: Parser): Parser { + return (input: string) => { + let rest = input; + const results: T[] = []; + while (true) { + const parserResult = parser(rest); + if (parserResult == null) { + break; + } + results.push(parserResult.value); + rest = parserResult.rest; + } + + if (results.length === 0) { + return; + } + + return new Result(results, rest); + }; +} + +/** + * takes a `parser` returning strings. + * returns a parser that parses + * at least one occurence of `parser` and concatenates the results. + * stops parsing whenever `endParser` matches and ignores the `endParser` result. + */ +export function newConcatAtLeastOneUntilParser(parser: Parser, endParser: Parser): Parser { + return (input: string) => { + let hasAtLeastOneMatch = false; + let rest = input; + let result = ""; + + while (true) { + if (endParser != null) { + if (endParser(rest) != null) { + break; + } + } + + const parserResult = parser(rest); + if (parserResult == null) { + break; + } + + hasAtLeastOneMatch = true; + result += parserResult.value; + rest = parserResult.rest; + } + + if (!hasAtLeastOneMatch) { + return; + } + + return new Result(result, rest); + }; +} + +/** + * takes many `parsers`. + * returns a new parser that tries all `parsers` in order + * and stops and returns as soon as a parser returns a non-null result. + */ +// TODO any +export function newEitherParser(...parsers: Array>): Parser { + return (input: string) => { + for (const parser of parsers) { + const result = parser(input); + if (result != null) { + return result; + } + } + return; + }; +} diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..91cdfa5 --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,114 @@ +/* + * the url pattern parser + */ + +import { + Ast, + newAst, + newAtLeastOneParser, + newConcatAtLeastOneUntilParser, + newEitherParser, + newLazyParser, + newPickNthParser, + newRegexParser, + newStringParser, + Parser, +// @ts-ignore +} from "./parser-combinators.ts"; + +import { + IOptions, +// @ts-ignore +} from "./options.ts"; + +export function newEscapedCharParser(options: IOptions): Parser> { + return newPickNthParser(1, newStringParser(options.escapeChar), newRegexParser(/^./)); +} + +export function newWildcardParser(options: IOptions): Parser> { + return newAst("wildcard", newStringParser(options.wildcardChar)); +} + +/* + * parses just the segment name in a named segment + */ +export function newSegmentNameParser(options: IOptions): Parser { + return newRegexParser(new RegExp(`^[${ options.segmentNameCharset }]+`)); +} + +export function newNamedSegmentParser(options: IOptions): Parser> { + const parseSegmentName = newSegmentNameParser(options); + if (options.segmentNameEndChar == null) { + return newAst("namedSegment", newPickNthParser(1, + newStringParser(options.segmentNameStartChar), + parseSegmentName)); + } else { + return newAst("namedSegment", newPickNthParser(1, + newStringParser(options.segmentNameStartChar), + parseSegmentName, + newStringParser(options.segmentNameEndChar))); + } +} + +export function newNamedWildcardParser(options: IOptions): Parser> { + if (options.segmentNameEndChar == null) { + return newAst("namedWildcard", newPickNthParser(2, + newStringParser(options.wildcardChar), + newStringParser(options.segmentNameStartChar), + newSegmentNameParser(options), + )); + } else { + return newAst("namedWildcard", newPickNthParser(2, + newStringParser(options.wildcardChar), + newStringParser(options.segmentNameStartChar), + newSegmentNameParser(options), + newStringParser(options.segmentNameEndChar), + )); + } +} + +export function newStaticContentParser(options: IOptions): Parser> { + const parseUntil = newEitherParser( + newStringParser(options.segmentNameStartChar), + newStringParser(options.optionalSegmentStartChar), + newStringParser(options.optionalSegmentEndChar), + newWildcardParser(options), + newNamedWildcardParser(options), + ); + return newAst("staticContent", newConcatAtLeastOneUntilParser(newEitherParser( + newEscapedCharParser(options), + newRegexParser(/^./)), + // parse any normal or escaped char until the following matches: + parseUntil, + )); +} + +/* + * + */ +export function newUrlPatternParser(options: IOptions): Parser>> { + let parsePattern: Parser = (input: string) => { + throw new Error(` + this is just a temporary placeholder + to make a circular dependency work. + if you see this error it's a bug. + `); + }; + + const parseOptionalSegment = newAst("optionalSegment", newPickNthParser(1, + newStringParser(options.optionalSegmentStartChar), + newLazyParser(() => parsePattern), + newStringParser(options.optionalSegmentEndChar))); + + const parseToken = newEitherParser( + newNamedWildcardParser(options), + newWildcardParser(options), + parseOptionalSegment, + newNamedSegmentParser(options), + newStaticContentParser(options), + ); + + parsePattern = newAtLeastOneParser(parseToken); + + return parsePattern; +} diff --git a/src/url-pattern.coffee b/src/url-pattern.coffee deleted file mode 100644 index 10b6904..0000000 --- a/src/url-pattern.coffee +++ /dev/null @@ -1,444 +0,0 @@ -((root, factory) -> - # AMD - if ('function' is typeof define) and define.amd? - define([], factory) - # CommonJS - else if exports? - module.exports = factory() - # browser globals - else - root.UrlPattern = factory() -)(this, -> - -################################################################################ -# helpers - - # source: http://stackoverflow.com/a/3561711 - escapeForRegex = (string) -> - string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') - - concatMap = (array, f) -> - results = [] - i = -1 - length = array.length - while ++i < length - results = results.concat f(array[i]) - return results - - stringConcatMap = (array, f) -> - result = '' - i = -1 - length = array.length - while ++i < length - result += f(array[i]) - return result - - # source: http://stackoverflow.com/a/16047223 - regexGroupCount = (regex) -> - (new RegExp(regex.toString() + '|')).exec('').length - 1 - - keysAndValuesToObject = (keys, values) -> - object = {} - i = -1 - length = keys.length - while ++i < length - key = keys[i] - value = values[i] - unless value? - continue - # key already encountered - if object[key]? - # capture multiple values for same key in an array - unless Array.isArray object[key] - object[key] = [object[key]] - object[key].push value - else - object[key] = value - return object - -################################################################################ -# parser combinators -# subset copied from -# https://github.com/snd/pcom/blob/master/src/pcom.coffee -# (where they are tested !) -# to keep this at zero dependencies and small filesize - - P = {} - - P.Result = (value, rest) -> - this.value = value - this.rest = rest - return - - P.Tagged = (tag, value) -> - this.tag = tag - this.value = value - return - - P.tag = (tag, parser) -> - (input) -> - result = parser input - unless result? - return - tagged = new P.Tagged tag, result.value - return new P.Result tagged, result.rest - - P.regex = (regex) -> - # unless regex instanceof RegExp - # throw new Error 'argument must be instanceof RegExp' - (input) -> - matches = regex.exec input - unless matches? - return - result = matches[0] - return new P.Result result, input.slice(result.length) - - P.sequence = (parsers...) -> - (input) -> - i = -1 - length = parsers.length - values = [] - rest = input - while ++i < length - parser = parsers[i] - # unless 'function' is typeof parser - # throw new Error "parser passed at index `#{i}` into `sequence` is not of type `function` but of type `#{typeof parser}`" - result = parser rest - unless result? - return - values.push result.value - rest = result.rest - return new P.Result values, rest - - P.pick = (indexes, parsers...) -> - (input) -> - result = P.sequence(parsers...)(input) - unless result? - return - array = result.value - result.value = array[indexes] - # unless Array.isArray indexes - # result.value = array[indexes] - # else - # result.value = [] - # indexes.forEach (i) -> - # result.value.push array[i] - return result - - P.string = (string) -> - length = string.length - # if length is 0 - # throw new Error '`string` must not be blank' - (input) -> - if input.slice(0, length) is string - return new P.Result string, input.slice(length) - - P.lazy = (fn) -> - cached = null - (input) -> - unless cached? - cached = fn() - return cached input - - P.baseMany = (parser, end, stringResult, atLeastOneResultRequired, input) -> - rest = input - results = if stringResult then '' else [] - while true - if end? - endResult = end rest - if endResult? - break - parserResult = parser rest - unless parserResult? - break - if stringResult - results += parserResult.value - else - results.push parserResult.value - rest = parserResult.rest - - if atLeastOneResultRequired and results.length is 0 - return - - return new P.Result results, rest - - P.many1 = (parser) -> - (input) -> - P.baseMany parser, null, false, true, input - - P.concatMany1Till = (parser, end) -> - (input) -> - P.baseMany parser, end, true, true, input - - P.firstChoice = (parsers...) -> - (input) -> - i = -1 - length = parsers.length - while ++i < length - parser = parsers[i] - # unless 'function' is typeof parser - # throw new Error "parser passed at index `#{i}` into `firstChoice` is not of type `function` but of type `#{typeof parser}`" - result = parser input - if result? - return result - return - -################################################################################ -# url pattern parser -# copied from -# https://github.com/snd/pcom/blob/master/src/url-pattern-example.coffee - - newParser = (options) -> - U = {} - - U.wildcard = P.tag 'wildcard', P.string(options.wildcardChar) - - U.optional = P.tag( - 'optional' - P.pick(1, - P.string(options.optionalSegmentStartChar) - P.lazy(-> U.pattern) - P.string(options.optionalSegmentEndChar) - ) - ) - - U.name = P.regex new RegExp "^[#{options.segmentNameCharset}]+" - - U.named = P.tag( - 'named', - P.pick(1, - P.string(options.segmentNameStartChar) - P.lazy(-> U.name) - ) - ) - - U.escapedChar = P.pick(1, - P.string(options.escapeChar) - P.regex(/^./) - ) - - U.static = P.tag( - 'static' - P.concatMany1Till( - P.firstChoice( - P.lazy(-> U.escapedChar) - P.regex(/^./) - ) - P.firstChoice( - P.string(options.segmentNameStartChar) - P.string(options.optionalSegmentStartChar) - P.string(options.optionalSegmentEndChar) - U.wildcard - ) - ) - ) - - U.token = P.lazy -> - P.firstChoice( - U.wildcard - U.optional - U.named - U.static - ) - - U.pattern = P.many1 P.lazy(-> U.token) - - return U - -################################################################################ -# options - - defaultOptions = - escapeChar: '\\' - segmentNameStartChar: ':' - segmentValueCharset: 'a-zA-Z0-9-_~ %' - segmentNameCharset: 'a-zA-Z0-9' - optionalSegmentStartChar: '(' - optionalSegmentEndChar: ')' - wildcardChar: '*' - -################################################################################ -# functions that further process ASTs returned as `.value` in parser results - - baseAstNodeToRegexString = (astNode, segmentValueCharset) -> - if Array.isArray astNode - return stringConcatMap astNode, (node) -> - baseAstNodeToRegexString(node, segmentValueCharset) - - switch astNode.tag - when 'wildcard' then '(.*?)' - when 'named' then "([#{segmentValueCharset}]+)" - when 'static' then escapeForRegex(astNode.value) - when 'optional' - '(?:' + baseAstNodeToRegexString(astNode.value, segmentValueCharset) + ')?' - - astNodeToRegexString = (astNode, segmentValueCharset = defaultOptions.segmentValueCharset) -> - '^' + baseAstNodeToRegexString(astNode, segmentValueCharset) + '$' - - astNodeToNames = (astNode) -> - if Array.isArray astNode - return concatMap astNode, astNodeToNames - - switch astNode.tag - when 'wildcard' then ['_'] - when 'named' then [astNode.value] - when 'static' then [] - when 'optional' then astNodeToNames(astNode.value) - - getParam = (params, key, nextIndexes, sideEffects = false) -> - value = params[key] - unless value? - if sideEffects - throw new Error "no values provided for key `#{key}`" - else - return - index = nextIndexes[key] or 0 - maxIndex = if Array.isArray value then value.length - 1 else 0 - if index > maxIndex - if sideEffects - throw new Error "too few values provided for key `#{key}`" - else - return - - result = if Array.isArray value then value[index] else value - - if sideEffects - nextIndexes[key] = index + 1 - - return result - - astNodeContainsSegmentsForProvidedParams = (astNode, params, nextIndexes) -> - if Array.isArray astNode - i = -1 - length = astNode.length - while ++i < length - if astNodeContainsSegmentsForProvidedParams astNode[i], params, nextIndexes - return true - return false - - switch astNode.tag - when 'wildcard' then getParam(params, '_', nextIndexes, false)? - when 'named' then getParam(params, astNode.value, nextIndexes, false)? - when 'static' then false - when 'optional' - astNodeContainsSegmentsForProvidedParams astNode.value, params, nextIndexes - - stringify = (astNode, params, nextIndexes) -> - if Array.isArray astNode - return stringConcatMap astNode, (node) -> - stringify node, params, nextIndexes - - switch astNode.tag - when 'wildcard' then getParam params, '_', nextIndexes, true - when 'named' then getParam params, astNode.value, nextIndexes, true - when 'static' then astNode.value - when 'optional' - if astNodeContainsSegmentsForProvidedParams astNode.value, params, nextIndexes - stringify astNode.value, params, nextIndexes - else - '' - -################################################################################ -# UrlPattern - - UrlPattern = (arg1, arg2) -> - # self awareness - if arg1 instanceof UrlPattern - @isRegex = arg1.isRegex - @regex = arg1.regex - @ast = arg1.ast - @names = arg1.names - return - - @isRegex = arg1 instanceof RegExp - - unless ('string' is typeof arg1) or @isRegex - throw new TypeError 'argument must be a regex or a string' - - # regex - - if @isRegex - @regex = arg1 - if arg2? - unless Array.isArray arg2 - throw new Error 'if first argument is a regex the second argument may be an array of group names but you provided something else' - groupCount = regexGroupCount @regex - unless arg2.length is groupCount - throw new Error "regex contains #{groupCount} groups but array of group names contains #{arg2.length}" - @names = arg2 - return - - # string pattern - - if arg1 is '' - throw new Error 'argument must not be the empty string' - withoutWhitespace = arg1.replace(/\s+/g, '') - unless withoutWhitespace is arg1 - throw new Error 'argument must not contain whitespace' - - options = - escapeChar: arg2?.escapeChar or defaultOptions.escapeChar - segmentNameStartChar: arg2?.segmentNameStartChar or defaultOptions.segmentNameStartChar - segmentNameCharset: arg2?.segmentNameCharset or defaultOptions.segmentNameCharset - segmentValueCharset: arg2?.segmentValueCharset or defaultOptions.segmentValueCharset - optionalSegmentStartChar: arg2?.optionalSegmentStartChar or defaultOptions.optionalSegmentStartChar - optionalSegmentEndChar: arg2?.optionalSegmentEndChar or defaultOptions.optionalSegmentEndChar - wildcardChar: arg2?.wildcardChar or defaultOptions.wildcardChar - - parser = newParser options - parsed = parser.pattern arg1 - unless parsed? - # TODO better error message - throw new Error "couldn't parse pattern" - if parsed.rest isnt '' - # TODO better error message - throw new Error "could only partially parse pattern" - @ast = parsed.value - - @regex = new RegExp astNodeToRegexString @ast, options.segmentValueCharset - @names = astNodeToNames @ast - - return - - UrlPattern.prototype.match = (url) -> - match = @regex.exec url - unless match? - return null - - groups = match.slice(1) - if @names - keysAndValuesToObject @names, groups - else - groups - - UrlPattern.prototype.stringify = (params = {}) -> - if @isRegex - throw new Error "can't stringify patterns generated from a regex" - unless params is Object(params) - throw new Error "argument must be an object or undefined" - stringify @ast, params, {} - -################################################################################ -# exports - - # helpers - UrlPattern.escapeForRegex = escapeForRegex - UrlPattern.concatMap = concatMap - UrlPattern.stringConcatMap = stringConcatMap - UrlPattern.regexGroupCount = regexGroupCount - UrlPattern.keysAndValuesToObject = keysAndValuesToObject - - # parsers - UrlPattern.P = P - UrlPattern.newParser = newParser - UrlPattern.defaultOptions = defaultOptions - - # ast - UrlPattern.astNodeToRegexString = astNodeToRegexString - UrlPattern.astNodeToNames = astNodeToNames - UrlPattern.getParam = getParam - UrlPattern.astNodeContainsSegmentsForProvidedParams = astNodeContainsSegmentsForProvidedParams - UrlPattern.stringify = stringify - - return UrlPattern -) diff --git a/src/url-pattern.ts b/src/url-pattern.ts new file mode 100644 index 0000000..c47f84d --- /dev/null +++ b/src/url-pattern.ts @@ -0,0 +1,153 @@ +import { + indexOfDuplicateElement, + regexGroupCount, +// @ts-ignore +} from "./helpers.ts"; + +import { + Ast, +// @ts-ignore +} from "./parser-combinators.ts"; + +import { + defaultOptions, + IUserInputOptions, +// @ts-ignore +} from "./options.ts"; + +import { + newUrlPatternParser, +// @ts-ignore +} from "./parser.ts"; + +import { + astRootToRegexString, + astToNames, + stringify, +// @ts-ignore +} from "./ast-helpers.ts"; + +export default class UrlPattern { + public readonly isRegex: boolean; + public readonly regex: RegExp; + public readonly ast?: Array>; + public readonly names: string[]; + + constructor(pattern: string, options?: IUserInputOptions); + constructor(pattern: RegExp, groupNames?: string[]); + constructor(pattern: UrlPattern); + + constructor(pattern: string | RegExp | UrlPattern, optionsOrGroupNames?: IUserInputOptions | string[]) { + // self awareness + if (pattern instanceof UrlPattern) { + this.isRegex = pattern.isRegex; + this.regex = pattern.regex; + this.ast = pattern.ast; + this.names = pattern.names; + return; + } + + this.isRegex = pattern instanceof RegExp; + + if ("string" !== typeof pattern && !this.isRegex) { + throw new TypeError("first argument must be a RegExp, a string or an instance of UrlPattern"); + } + + // handle regex pattern and return early + if (pattern instanceof RegExp) { + this.regex = pattern; + if (optionsOrGroupNames == null || !Array.isArray(optionsOrGroupNames)) { + throw new TypeError([ + "if first argument is a RegExp the second argument", + "must be an Array of group names", + ].join(" ")); + } + const groupCount = regexGroupCount(this.regex); + if (optionsOrGroupNames.length !== groupCount) { + throw new Error([ + `regex contains ${ groupCount } groups`, + `but array of group names contains ${ optionsOrGroupNames.length }`, + ].join(" ")); + } + this.names = optionsOrGroupNames; + const regexNameIndex = indexOfDuplicateElement(this.names); + if (regexNameIndex !== -1) { + throw new Error( + `duplicate group name "${ this.names[regexNameIndex] }". group names must be unique`, + ); + } + return; + } + + // everything following only concerns string patterns + + if (pattern === "") { + throw new Error("first argument must not be the empty string"); + } + const patternWithoutWhitespace = pattern.replace(/\s+/g, ""); + if (patternWithoutWhitespace !== pattern) { + throw new Error("first argument must not contain whitespace"); + } + + if (Array.isArray(optionsOrGroupNames)) { + throw new Error("if first argument is a string second argument must be an options object or undefined"); + } + + const options = Object.assign({}, defaultOptions, optionsOrGroupNames); + + const parser = newUrlPatternParser(options); + const parsed = parser(pattern); + if (parsed == null) { + throw new Error("couldn't parse pattern"); + } + if (parsed.rest.length !== 0) { + const failureIndex = pattern.length - parsed.rest.length; + throw new Error([ + `could only partially parse pattern.`, + `failure at character ${ failureIndex + 1} in pattern:`, + pattern, + " ".repeat(failureIndex) + "^ parsing failed here", + ].join("\n")); + } + const ast = parsed.value; + this.ast = ast; + + this.regex = new RegExp(astRootToRegexString(ast, options.segmentValueCharset)); + this.names = astToNames(ast); + const index = indexOfDuplicateElement(this.names); + if (index !== -1) { + throw new Error( + `duplicate name "${ this.names[index] }" in pattern. names must be unique`, + ); + } + } + + public match(url: string): { [index: string]: string } | undefined { + const match = this.regex.exec(url); + if (match == null) { + return; + } + + const groups = match.slice(1); + const mergedNamesAndGroups: { [index: string]: string } = {}; + for (let i = 0; i < this.names.length; i++) { + if (groups[i] != null) { + mergedNamesAndGroups[this.names[i]] = groups[i]; + } + } + return mergedNamesAndGroups; + } + + public stringify(params?: object): string { + if (params == null) { + params = {}; + } + if (this.ast == null) { + throw new Error("can't stringify patterns generated from a regex"); + } + if (params !== Object(params)) { + throw new Error("argument must be an object or undefined"); + } + return stringify(this.ast, params); + } +} diff --git a/test/ast-helpers.ts b/test/ast-helpers.ts new file mode 100644 index 0000000..d510f8e --- /dev/null +++ b/test/ast-helpers.ts @@ -0,0 +1,95 @@ +/* tslint:disable:no-shadowed-variable */ +import * as tape from "tape"; + +import { + newUrlPatternParser, +// @ts-ignore +} from "../src/parser.ts"; + +import { + astRootToRegexString, + astToNames, +// @ts-ignore +} from "../src/ast-helpers.ts"; + +import { + defaultOptions, +// @ts-ignore +} from "../src/options.ts"; + +const parse: any = newUrlPatternParser(defaultOptions); + +// test both functions in one go as they are related +// and extract data from the same input +tape("astRootToRegexString and astToNames", (t: tape.Test) => { + t.test("just static alphanumeric", (t: tape.Test) => { + const parsed = parse("user42"); + t.equal(astRootToRegexString(parsed.value, defaultOptions.segmentValueCharset), + "^user42$"); + t.deepEqual(astToNames(parsed.value), []); + t.end(); + }); + + t.test("just static escaped", (t: tape.Test) => { + const parsed = parse("/api/v1/users"); + t.equal(astRootToRegexString(parsed.value, defaultOptions.segmentValueCharset), "^\\/api\\/v1\\/users$"); + t.deepEqual(astToNames(parsed.value), []); + t.end(); + }); + + t.test("just single char variable", (t: tape.Test) => { + const parsed = parse(":a"); + t.equal(astRootToRegexString(parsed.value, defaultOptions.segmentValueCharset), "^([a-zA-Z0-9-_~ %]+)$"); + t.deepEqual(astToNames(parsed.value), ["a"]); + t.end(); + }); + + t.test("just variable", (t: tape.Test) => { + const parsed = parse(":variable"); + t.equal(astRootToRegexString(parsed.value, defaultOptions.segmentValueCharset), "^([a-zA-Z0-9-_~ %]+)$"); + t.deepEqual(astToNames(parsed.value), ["variable"]); + t.end(); + }); + + t.test("just wildcard", (t: tape.Test) => { + const parsed = parse("*"); + t.equal(astRootToRegexString(parsed.value, defaultOptions.segmentValueCharset), "^.*?$"); + t.deepEqual(astToNames(parsed.value), []); + t.end(); + }); + + t.test("just named wildcard", (t: tape.Test) => { + const parsed = parse("*:variable"); + t.equal(astRootToRegexString(parsed.value, defaultOptions.segmentValueCharset), "^(.*?)$"); + t.deepEqual(astToNames(parsed.value), ["variable"]); + t.end(); + }); + + t.test("just optional static", (t: tape.Test) => { + const parsed = parse("(foo)"); + t.equal(astRootToRegexString(parsed.value, defaultOptions.segmentValueCharset), "^(?:foo)?$"); + t.deepEqual(astToNames(parsed.value), []); + t.end(); + }); + + t.test("just optional variable", (t: tape.Test) => { + const parsed = parse("(:foo)"); + t.equal(astRootToRegexString(parsed.value, defaultOptions.segmentValueCharset), "^(?:([a-zA-Z0-9-_~ %]+))?$"); + t.deepEqual(astToNames(parsed.value), ["foo"]); + t.end(); + }); + + t.test("just optional wildcard", (t: tape.Test) => { + const parsed = parse("(*)"); + t.equal(astRootToRegexString(parsed.value, defaultOptions.segmentValueCharset), "^(?:.*?)?$"); + t.deepEqual(astToNames(parsed.value), []); + t.end(); + }); + + t.test("just optional named wildcard", (t: tape.Test) => { + const parsed = parse("(*:variable)"); + t.equal(astRootToRegexString(parsed.value, defaultOptions.segmentValueCharset), "^(?:(.*?))?$"); + t.deepEqual(astToNames(parsed.value), ["variable"]); + t.end(); + }); +}); diff --git a/test/ast.coffee b/test/ast.coffee deleted file mode 100644 index 7aa6f30..0000000 --- a/test/ast.coffee +++ /dev/null @@ -1,199 +0,0 @@ -test = require 'tape' -UrlPattern = require '../lib/url-pattern' - -{ - astNodeToRegexString - astNodeToNames - getParam -} = UrlPattern - -parse = UrlPattern.newParser(UrlPattern.defaultOptions).pattern - -test 'astNodeToRegexString and astNodeToNames', (t) -> - t.test 'just static alphanumeric', (t) -> - parsed = parse 'user42' - t.equal astNodeToRegexString(parsed.value), '^user42$' - t.deepEqual astNodeToNames(parsed.value), [] - t.end() - - t.test 'just static escaped', (t) -> - parsed = parse '/api/v1/users' - t.equal astNodeToRegexString(parsed.value), '^\\/api\\/v1\\/users$' - t.deepEqual astNodeToNames(parsed.value), [] - t.end() - - t.test 'just single char variable', (t) -> - parsed = parse ':a' - t.equal astNodeToRegexString(parsed.value), '^([a-zA-Z0-9-_~ %]+)$' - t.deepEqual astNodeToNames(parsed.value), ['a'] - t.end() - - t.test 'just variable', (t) -> - parsed = parse ':variable' - t.equal astNodeToRegexString(parsed.value), '^([a-zA-Z0-9-_~ %]+)$' - t.deepEqual astNodeToNames(parsed.value), ['variable'] - t.end() - - t.test 'just wildcard', (t) -> - parsed = parse '*' - t.equal astNodeToRegexString(parsed.value), '^(.*?)$' - t.deepEqual astNodeToNames(parsed.value), ['_'] - t.end() - - t.test 'just optional static', (t) -> - parsed = parse '(foo)' - t.equal astNodeToRegexString(parsed.value), '^(?:foo)?$' - t.deepEqual astNodeToNames(parsed.value), [] - t.end() - - t.test 'just optional variable', (t) -> - parsed = parse '(:foo)' - t.equal astNodeToRegexString(parsed.value), '^(?:([a-zA-Z0-9-_~ %]+))?$' - t.deepEqual astNodeToNames(parsed.value), ['foo'] - t.end() - - t.test 'just optional wildcard', (t) -> - parsed = parse '(*)' - t.equal astNodeToRegexString(parsed.value), '^(?:(.*?))?$' - t.deepEqual astNodeToNames(parsed.value), ['_'] - t.end() - -test 'getParam', (t) -> - t.test 'no side effects', (t) -> - next = {} - t.equal undefined, getParam {}, 'one', next - t.deepEqual next, {} - - # value - - next = {} - t.equal 1, getParam {one: 1}, 'one', next - t.deepEqual next, {} - - next = {one: 0} - t.equal 1, getParam {one: 1}, 'one', next - t.deepEqual next, {one: 0} - - next = {one: 1} - t.equal undefined, getParam {one: 1}, 'one', next - t.deepEqual next, {one: 1} - - next = {one: 2} - t.equal undefined, getParam {one: 1}, 'one', next - t.deepEqual next, {one: 2} - - # array - - next = {} - t.equal 1, getParam {one: [1]}, 'one', next - t.deepEqual next, {} - - next = {one: 0} - t.equal 1, getParam {one: [1]}, 'one', next - t.deepEqual next, {one: 0} - - next = {one: 1} - t.equal undefined, getParam {one: [1]}, 'one', next - t.deepEqual next, {one: 1} - - next = {one: 2} - t.equal undefined, getParam {one: [1]}, 'one', next - t.deepEqual next, {one: 2} - - next = {one: 0} - t.equal 1, getParam {one: [1, 2, 3]}, 'one', next - t.deepEqual next, {one: 0} - - next = {one: 1} - t.equal 2, getParam {one: [1, 2, 3]}, 'one', next - t.deepEqual next, {one: 1} - - next = {one: 2} - t.equal 3, getParam {one: [1, 2, 3]}, 'one', next - t.deepEqual next, {one: 2} - - next = {one: 3} - t.equal undefined, getParam {one: [1, 2, 3]}, 'one', next - t.deepEqual next, {one: 3} - - t.end() - - t.test 'side effects', (t) -> - next = {} - t.equal 1, getParam {one: 1}, 'one', next, true - t.deepEqual next, {one: 1} - - next = {one: 0} - t.equal 1, getParam {one: 1}, 'one', next, true - t.deepEqual next, {one: 1} - - # array - - next = {} - t.equal 1, getParam {one: [1]}, 'one', next, true - t.deepEqual next, {one: 1} - - next = {one: 0} - t.equal 1, getParam {one: [1]}, 'one', next, true - t.deepEqual next, {one: 1} - - next = {one: 0} - t.equal 1, getParam {one: [1, 2, 3]}, 'one', next, true - t.deepEqual next, {one: 1} - - next = {one: 1} - t.equal 2, getParam {one: [1, 2, 3]}, 'one', next, true - t.deepEqual next, {one: 2} - - next = {one: 2} - t.equal 3, getParam {one: [1, 2, 3]}, 'one', next, true - t.deepEqual next, {one: 3} - - t.end() - - t.test 'side effects errors', (t) -> - t.plan 2 * 6 - - next = {} - try - getParam {}, 'one', next, true - catch e - t.equal e.message, "no values provided for key `one`" - t.deepEqual next, {} - - next = {one: 1} - try - getParam {one: 1}, 'one', next, true - catch e - t.equal e.message, "too few values provided for key `one`" - t.deepEqual next, {one: 1} - - next = {one: 2} - try - getParam {one: 2}, 'one', next, true - catch e - t.equal e.message, "too few values provided for key `one`" - t.deepEqual next, {one: 2} - - next = {one: 1} - try - getParam {one: [1]}, 'one', next, true - catch e - t.equal e.message, "too few values provided for key `one`" - t.deepEqual next, {one: 1} - - next = {one: 2} - try - getParam {one: [1]}, 'one', next, true - catch e - t.equal e.message, "too few values provided for key `one`" - t.deepEqual next, {one: 2} - - next = {one: 3} - try - getParam {one: [1, 2, 3]}, 'one', next, true - catch e - t.equal e.message, "too few values provided for key `one`" - t.deepEqual next, {one: 3} - - t.end() diff --git a/test/errors.coffee b/test/errors.coffee deleted file mode 100644 index 12186de..0000000 --- a/test/errors.coffee +++ /dev/null @@ -1,107 +0,0 @@ -test = require 'tape' -UrlPattern = require '../lib/url-pattern' - -test 'invalid argument', (t) -> - UrlPattern - t.plan 5 - try - new UrlPattern() - catch e - t.equal e.message, "argument must be a regex or a string" - try - new UrlPattern(5) - catch e - t.equal e.message, "argument must be a regex or a string" - try - new UrlPattern '' - catch e - t.equal e.message, "argument must not be the empty string" - try - new UrlPattern ' ' - catch e - t.equal e.message, "argument must not contain whitespace" - try - new UrlPattern ' fo o' - catch e - t.equal e.message, "argument must not contain whitespace" - t.end() - -test 'invalid variable name in pattern', (t) -> - UrlPattern - t.plan 3 - try - new UrlPattern ':' - catch e - t.equal e.message, "couldn't parse pattern" - try - new UrlPattern ':.' - catch e - t.equal e.message, "couldn't parse pattern" - try - new UrlPattern 'foo:.' - catch e - # 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 - t.equal e.message, "could only partially parse pattern" - t.end() - -test 'too many closing parentheses', (t) -> - t.plan 2 - try - new UrlPattern ')' - catch e - # TODO did not plan ) at 0 - t.equal e.message, "couldn't parse pattern" - try - new UrlPattern '((foo)))bar' - catch e - # TODO did not plan ) at 7 - t.equal e.message, "could only partially parse pattern" - t.end() - -test 'unclosed parentheses', (t) -> - t.plan 2 - try - new UrlPattern '(' - catch e - # TODO unclosed parentheses at 1 - t.equal e.message, "couldn't parse pattern" - try - new UrlPattern '(((foo)bar(boo)far)' - catch e - # TODO unclosed parentheses at 19 - t.equal e.message, "couldn't parse pattern" - t.end() - -test 'regex names', (t) -> - t.plan 3 - try - new UrlPattern /x/, 5 - catch e - 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' - try - new UrlPattern /(((foo)bar(boo))far)/, [] - catch e - t.equal e.message, "regex contains 4 groups but array of group names contains 0" - try - new UrlPattern /(((foo)bar(boo))far)/, ['a', 'b'] - catch e - t.equal e.message, "regex contains 4 groups but array of group names contains 2" - t.end() - -test 'stringify regex', (t) -> - t.plan 1 - pattern = new UrlPattern /x/ - try - pattern.stringify() - catch e - t.equal e.message, "can't stringify patterns generated from a regex" - t.end() - -test 'stringify argument', (t) -> - t.plan 1 - pattern = new UrlPattern 'foo' - try - pattern.stringify(5) - catch e - t.equal e.message, "argument must be an object or undefined" - t.end() diff --git a/test/errors.ts b/test/errors.ts new file mode 100644 index 0000000..b4ee30a --- /dev/null +++ b/test/errors.ts @@ -0,0 +1,163 @@ +/* tslint:disable:no-unused-expression */ +import * as tape from "tape"; + +// @ts-ignore +import UrlPattern from "../src/url-pattern.ts"; + +const UntypedUrlPattern: any = UrlPattern; + +tape("invalid argument", (t: tape.Test) => { + t.plan(6); + + try { + new UntypedUrlPattern(); + } catch (error) { + t.equal(error.message, "first argument must be a RegExp, a string or an instance of UrlPattern"); + } + try { + new UntypedUrlPattern(5); + } catch (error) { + t.equal(error.message, "first argument must be a RegExp, a string or an instance of UrlPattern"); + } + try { + new UrlPattern(""); + } catch (error) { + t.equal(error.message, "first argument must not be the empty string"); + } + try { + new UrlPattern(" "); + } catch (error) { + t.equal(error.message, "first argument must not contain whitespace"); + } + try { + new UrlPattern(" fo o"); + } catch (error) { + t.equal(error.message, "first argument must not contain whitespace"); + } + try { + const str: any = "foo"; + new UrlPattern(str, []); + } catch (error) { + t.equal(error.message, "if first argument is a string second argument must be an options object or undefined"); + } + t.end(); +}); + +tape("invalid variable name in pattern", (t: tape.Test) => { + t.plan(3); + try { + new UrlPattern(":"); + } catch (error) { + t.equal(error.message, "couldn't parse pattern"); + } + try { + new UrlPattern(":."); + } catch (error) { + t.equal(error.message, "couldn't parse pattern"); + } + try { + new UrlPattern("foo:."); + } catch (error) { + t.equal(error.message, [ + "could only partially parse pattern.", + "failure at character 4 in pattern:", + "foo:.", + " ^ parsing failed here", + ].join("\n")); + } + t.end(); +}); + +tape("duplicate variable name in pattern", (t: tape.Test) => { + t.plan(1); + try { + new UrlPattern(":a/:a"); + } catch (error) { + t.equal(error.message, "duplicate name \"a\" in pattern. names must be unique"); + } + t.end(); +}); + +tape("too many closing parentheses", (t: tape.Test) => { + t.plan(2); + try { + new UrlPattern(")"); + } catch (error) { + t.equal(error.message, "couldn't parse pattern"); + } + try { + new UrlPattern("((foo)))bar"); + } catch (error) { + t.equal(error.message, [ + "could only partially parse pattern.", + "failure at character 8 in pattern:", + "((foo)))bar", + " ^ parsing failed here", + ].join("\n")); + } + t.end(); +}); + +tape("unclosed parentheses", (t: tape.Test) => { + t.plan(2); + try { + new UrlPattern("("); + } catch (error) { + t.equal(error.message, "couldn't parse pattern"); + } + try { + new UrlPattern("(((foo)bar(boo)far)"); + } catch (error) { + t.equal(error.message, "couldn't parse pattern"); + } + t.end(); +}); + +tape("regex names", (t: tape.Test) => { + t.plan(4); + try { + new UntypedUrlPattern(/x/, 5); + } catch (error) { + t.equal(error.message, + "if first argument is a RegExp the second argument must be an Array of group names", + ); + } + try { + new UrlPattern(/(((foo)bar(boo))far)/, []); + } catch (error) { + t.equal(error.message, "regex contains 4 groups but array of group names contains 0"); + } + try { + new UrlPattern(/(((foo)bar(boo))far)/, ["a", "b"]); + } catch (error) { + t.equal(error.message, "regex contains 4 groups but array of group names contains 2"); + } + try { + new UrlPattern(/(\d).(\d).(\d)/, ["a", "b", "a"]); + } catch (error) { + t.equal(error.message, "duplicate group name \"a\". group names must be unique"); + } + t.end(); +}); + +tape("stringify regex", (t: tape.Test) => { + t.plan(1); + const pattern = new UrlPattern(/x/, []); + try { + pattern.stringify(); + } catch (error) { + t.equal(error.message, "can't stringify patterns generated from a regex"); + } + t.end(); +}); + +tape("stringify argument", (t: tape.Test) => { + t.plan(1); + const pattern = new UntypedUrlPattern("foo"); + try { + pattern.stringify(5); + } catch (error) { + t.equal(error.message, "argument must be an object or undefined"); + } + t.end(); +}); diff --git a/test/helpers.coffee b/test/helpers.coffee deleted file mode 100644 index c10e727..0000000 --- a/test/helpers.coffee +++ /dev/null @@ -1,138 +0,0 @@ -test = require 'tape' -{ - escapeForRegex - concatMap - stringConcatMap - regexGroupCount - keysAndValuesToObject -} = require '../lib/url-pattern' - -test 'escapeForRegex', (t) -> - expected = '\\[\\-\\/\\\\\\^\\$\\*\\+\\?\\.\\(\\)\\|\\[\\]\\{\\}\\]' - actual = escapeForRegex('[-\/\\^$*+?.()|[\]{}]') - t.equal expected, actual - - t.equal escapeForRegex('a$98kdjf(kdj)'), 'a\\$98kdjf\\(kdj\\)' - t.equal 'a', escapeForRegex 'a' - t.equal '!', escapeForRegex '!' - t.equal '\\.', escapeForRegex '.' - t.equal '\\/', escapeForRegex '/' - t.equal '\\-', escapeForRegex '-' - t.equal '\\-', escapeForRegex '-' - t.equal '\\[', escapeForRegex '[' - t.equal '\\]', escapeForRegex ']' - t.equal '\\(', escapeForRegex '(' - t.equal '\\)', escapeForRegex ')' - t.end() - -test 'concatMap', (t) -> - t.deepEqual [], concatMap [], -> - t.deepEqual [1], concatMap [1], (x) -> [x] - t.deepEqual [1, 1, 1, 2, 2, 2, 3, 3, 3], concatMap [1, 2, 3], (x) -> [x, x, x] - t.end() - -test 'stringConcatMap', (t) -> - t.equal '', stringConcatMap [], -> - t.equal '1', stringConcatMap [1], (x) -> x - t.equal '123', stringConcatMap [1, 2, 3], (x) -> x - t.equal '1a2a3a', stringConcatMap [1, 2, 3], (x) -> x + 'a' - t.end() - -test 'regexGroupCount', (t) -> - t.equal 0, regexGroupCount /foo/ - t.equal 1, regexGroupCount /(foo)/ - t.equal 2, regexGroupCount /((foo))/ - t.equal 2, regexGroupCount /(fo(o))/ - t.equal 2, regexGroupCount /f(o)(o)/ - t.equal 2, regexGroupCount /f(o)o()/ - t.equal 5, regexGroupCount /f(o)o()()(())/ - t.end() - -test 'keysAndValuesToObject', (t) -> - t.deepEqual( - keysAndValuesToObject( - [] - [] - ) - {} - ) - t.deepEqual( - keysAndValuesToObject( - ['one'] - [1] - ) - { - one: 1 - } - ) - t.deepEqual( - keysAndValuesToObject( - ['one', 'two'] - [1] - ) - { - one: 1 - } - ) - t.deepEqual( - keysAndValuesToObject( - ['one', 'two', 'two'] - [1, 2, 3] - ) - { - one: 1 - two: [2, 3] - } - ) - t.deepEqual( - keysAndValuesToObject( - ['one', 'two', 'two', 'two'] - [1, 2, 3, null] - ) - { - one: 1 - two: [2, 3] - } - ) - t.deepEqual( - keysAndValuesToObject( - ['one', 'two', 'two', 'two'] - [1, 2, 3, 4] - ) - { - one: 1 - two: [2, 3, 4] - } - ) - t.deepEqual( - keysAndValuesToObject( - ['one', 'two', 'two', 'two', 'three'] - [1, 2, 3, 4, undefined] - ) - { - one: 1 - two: [2, 3, 4] - } - ) - t.deepEqual( - keysAndValuesToObject( - ['one', 'two', 'two', 'two', 'three'] - [1, 2, 3, 4, 5] - ) - { - one: 1 - two: [2, 3, 4] - three: 5 - } - ) - t.deepEqual( - keysAndValuesToObject( - ['one', 'two', 'two', 'two', 'three'] - [null, 2, 3, 4, 5] - ) - { - two: [2, 3, 4] - three: 5 - } - ) - t.end() diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..74609c2 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,49 @@ +import * as tape from "tape"; + +import { + escapeStringForRegex, + indexOfDuplicateElement, + regexGroupCount, +// @ts-ignore +} from "../src/helpers.ts"; + +tape("escapeStringForRegex", (t: tape.Test) => { + const expected = "\\[\\-\\/\\\\\\^\\$\\*\\+\\?\\.\\(\\)\\|\\[\\]\\{\\}\\]"; + const actual = escapeStringForRegex("[-\/\\^$*+?.()|[\]{}]"); + t.equal(expected, actual); + + t.equal(escapeStringForRegex("a$98kdjf(kdj)"), "a\\$98kdjf\\(kdj\\)"); + t.equal("a", escapeStringForRegex("a")); + t.equal("!", escapeStringForRegex("!")); + t.equal("\\.", escapeStringForRegex(".")); + t.equal("\\/", escapeStringForRegex("/")); + t.equal("\\-", escapeStringForRegex("-")); + t.equal("\\-", escapeStringForRegex("-")); + t.equal("\\[", escapeStringForRegex("[")); + t.equal("\\]", escapeStringForRegex("]")); + t.equal("\\(", escapeStringForRegex("(")); + t.equal("\\)", escapeStringForRegex(")")); + t.end(); +}); + +tape("regexGroupCount", (t: tape.Test) => { + t.equal(0, regexGroupCount(/foo/)); + t.equal(1, regexGroupCount(/(foo)/)); + t.equal(2, regexGroupCount(/((foo))/)); + t.equal(2, regexGroupCount(/(fo(o))/)); + t.equal(2, regexGroupCount(/f(o)(o)/)); + t.equal(2, regexGroupCount(/f(o)o()/)); + t.equal(5, regexGroupCount(/f(o)o()()(())/)); + t.end(); +}); + +tape("indexOfDuplicateElement", (t: tape.Test) => { + t.equal(-1, indexOfDuplicateElement([])); + t.equal(-1, indexOfDuplicateElement([1, 2, 3, 4, 5])); + t.equal(1, indexOfDuplicateElement([1, 1, 3, 4, 5])); + t.equal(2, indexOfDuplicateElement([1, 2, 1, 4, 5])); + t.equal(3, indexOfDuplicateElement([1, 2, 3, 2, 5])); + t.equal(-1, indexOfDuplicateElement(["a", "b", "c"])); + t.equal(2, indexOfDuplicateElement(["a", "b", "a"])); + t.end(); +}); diff --git a/test/match-fixtures.coffee b/test/match-fixtures.coffee deleted file mode 100644 index ca4d5ec..0000000 --- a/test/match-fixtures.coffee +++ /dev/null @@ -1,249 +0,0 @@ -test = require 'tape' -UrlPattern = require '../lib/url-pattern' - -test 'match', (t) -> - pattern = new UrlPattern '/foo' - t.deepEqual pattern.match('/foo'), {} - - pattern = new UrlPattern '.foo' - t.deepEqual pattern.match('.foo'), {} - - pattern = new UrlPattern '/foo' - t.equals pattern.match('/foobar'), null - - pattern = new UrlPattern '.foo' - t.equals pattern.match('.foobar'), null - - pattern = new UrlPattern '/foo' - t.equals pattern.match('/bar/foo'), null - - pattern = new UrlPattern '.foo' - t.equals pattern.match('.bar.foo'), null - - pattern = new UrlPattern /foo/ - t.deepEqual pattern.match('foo'), [] - - pattern = new UrlPattern /\/foo\/(.*)/ - t.deepEqual pattern.match('/foo/bar'), ['bar'] - - pattern = new UrlPattern /\/foo\/(.*)/ - t.deepEqual pattern.match('/foo/'), [''] - - pattern = new UrlPattern '/user/:userId/task/:taskId' - t.deepEqual pattern.match('/user/10/task/52'), - userId: '10' - taskId: '52' - - pattern = new UrlPattern '.user.:userId.task.:taskId' - t.deepEqual pattern.match('.user.10.task.52'), - userId: '10' - taskId: '52' - - pattern = new UrlPattern '*/user/:userId' - t.deepEqual pattern.match('/school/10/user/10'), - _: '/school/10', - userId: '10' - - pattern = new UrlPattern '*-user-:userId' - t.deepEqual pattern.match('-school-10-user-10'), - _: '-school-10' - userId: '10' - - pattern = new UrlPattern '/admin*' - t.deepEqual pattern.match('/admin/school/10/user/10'), - _: '/school/10/user/10' - - pattern = new UrlPattern '#admin*' - t.deepEqual pattern.match('#admin#school#10#user#10'), - _: '#school#10#user#10' - - pattern = new UrlPattern '/admin/*/user/:userId' - t.deepEqual pattern.match('/admin/school/10/user/10'), - _: 'school/10', - userId: '10' - - pattern = new UrlPattern '$admin$*$user$:userId' - t.deepEqual pattern.match('$admin$school$10$user$10'), - _: 'school$10' - userId: '10' - - pattern = new UrlPattern '/admin/*/user/*/tail' - t.deepEqual pattern.match('/admin/school/10/user/10/12/tail'), - _: ['school/10', '10/12'] - - pattern = new UrlPattern '$admin$*$user$*$tail' - t.deepEqual pattern.match('$admin$school$10$user$10$12$tail'), - _: ['school$10', '10$12'] - - pattern = new UrlPattern '/admin/*/user/:id/*/tail' - t.deepEqual pattern.match('/admin/school/10/user/10/12/13/tail'), - _: ['school/10', '12/13'] - id: '10' - - pattern = new UrlPattern '^admin^*^user^:id^*^tail' - t.deepEqual pattern.match('^admin^school^10^user^10^12^13^tail'), - _: ['school^10', '12^13'] - id: '10' - - pattern = new UrlPattern '/*/admin(/:path)' - t.deepEqual pattern.match('/admin/admin/admin'), - _: 'admin' - path: 'admin' - - pattern = new UrlPattern '(/)' - t.deepEqual pattern.match(''), {} - t.deepEqual pattern.match('/'), {} - - pattern = new UrlPattern '/admin(/foo)/bar' - t.deepEqual pattern.match('/admin/foo/bar'), {} - t.deepEqual pattern.match('/admin/bar'), {} - - pattern = new UrlPattern '/admin(/:foo)/bar' - t.deepEqual pattern.match('/admin/baz/bar'), - foo: 'baz' - t.deepEqual pattern.match('/admin/bar'), {} - - pattern = new UrlPattern '/admin/(*/)foo' - t.deepEqual pattern.match('/admin/foo'), {} - t.deepEqual pattern.match('/admin/baz/bar/biff/foo'), - _: 'baz/bar/biff' - - pattern = new UrlPattern '/v:major.:minor/*' - t.deepEqual pattern.match('/v1.2/resource/'), - _: 'resource/' - major: '1' - minor: '2' - - pattern = new UrlPattern '/v:v.:v/*' - t.deepEqual pattern.match('/v1.2/resource/'), - _: 'resource/' - v: ['1', '2'] - - pattern = new UrlPattern '/:foo_bar' - t.equal pattern.match('/_bar'), null - t.deepEqual pattern.match('/a_bar'), - foo: 'a' - t.deepEqual pattern.match('/a__bar'), - foo: 'a_' - t.deepEqual pattern.match('/a-b-c-d__bar'), - foo: 'a-b-c-d_' - t.deepEqual pattern.match('/a b%c-d__bar'), - foo: 'a b%c-d_' - - pattern = new UrlPattern '((((a)b)c)d)' - t.deepEqual pattern.match(''), {} - t.equal pattern.match('a'), null - t.equal pattern.match('ab'), null - t.equal pattern.match('abc'), null - t.deepEqual pattern.match('abcd'), {} - t.deepEqual pattern.match('bcd'), {} - t.deepEqual pattern.match('cd'), {} - t.deepEqual pattern.match('d'), {} - - pattern = new UrlPattern '/user/:range' - t.deepEqual pattern.match('/user/10-20'), - range: '10-20' - - pattern = new UrlPattern '/user/:range' - t.deepEqual pattern.match('/user/10_20'), - range: '10_20' - - pattern = new UrlPattern '/user/:range' - t.deepEqual pattern.match('/user/10 20'), - range: '10 20' - - pattern = new UrlPattern '/user/:range' - t.deepEqual pattern.match('/user/10%20'), - range: '10%20' - - pattern = new UrlPattern '/vvv:version/*' - t.equal null, pattern.match('/vvv/resource') - t.deepEqual pattern.match('/vvv1/resource'), - _: 'resource' - version: '1' - t.equal null, pattern.match('/vvv1.1/resource') - - pattern = new UrlPattern '/api/users/:id', - segmentValueCharset: 'a-zA-Z0-9-_~ %.@' - t.deepEqual pattern.match('/api/users/someuser@example.com'), - id: 'someuser@example.com' - - pattern = new UrlPattern '/api/users?username=:username', - segmentValueCharset: 'a-zA-Z0-9-_~ %.@' - t.deepEqual pattern.match('/api/users?username=someone@example.com'), - username: 'someone@example.com' - - pattern = new UrlPattern '/api/users?param1=:param1¶m2=:param2' - t.deepEqual pattern.match('/api/users?param1=foo¶m2=bar'), - param1: 'foo' - param2: 'bar' - - pattern = new UrlPattern ':scheme\\://:host(\\::port)', - segmentValueCharset: 'a-zA-Z0-9-_~ %.' - t.deepEqual pattern.match('ftp://ftp.example.com'), - scheme: 'ftp' - host: 'ftp.example.com' - t.deepEqual pattern.match('ftp://ftp.example.com:8080'), - scheme: 'ftp' - host: 'ftp.example.com' - port: '8080' - t.deepEqual pattern.match('https://example.com:80'), - scheme: 'https' - host: 'example.com' - port: '80' - - pattern = new UrlPattern ':scheme\\://:host(\\::port)(/api(/:resource(/:id)))', - segmentValueCharset: 'a-zA-Z0-9-_~ %.@' - t.deepEqual pattern.match('https://sss.www.localhost.com'), - scheme: 'https' - host: 'sss.www.localhost.com' - t.deepEqual pattern.match('https://sss.www.localhost.com:8080'), - scheme: 'https' - host: 'sss.www.localhost.com' - port: '8080' - t.deepEqual pattern.match('https://sss.www.localhost.com/api'), - scheme: 'https' - host: 'sss.www.localhost.com' - t.deepEqual pattern.match('https://sss.www.localhost.com/api/security'), - scheme: 'https' - host: 'sss.www.localhost.com' - resource: 'security' - t.deepEqual pattern.match('https://sss.www.localhost.com/api/security/bob@example.com'), - scheme: 'https' - host: 'sss.www.localhost.com' - resource: 'security' - id: 'bob@example.com' - - 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]?)$/ - pattern = new UrlPattern regex - t.equal null, pattern.match('10.10.10.10') - t.equal null, pattern.match('ip/10.10.10.10') - t.equal null, pattern.match('/ip/10.10.10.') - t.equal null, pattern.match('/ip/10.') - t.equal null, pattern.match('/ip/') - t.deepEqual pattern.match('/ip/10.10.10.10'), ['10', '10', '10', '10'] - t.deepEqual pattern.match('/ip/127.0.0.1'), ['127', '0', '0', '1'] - - 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]?))$/ - pattern = new UrlPattern regex - t.equal null, pattern.match('10.10.10.10') - t.equal null, pattern.match('ip/10.10.10.10') - t.equal null, pattern.match('/ip/10.10.10.') - t.equal null, pattern.match('/ip/10.') - t.equal null, pattern.match('/ip/') - t.deepEqual pattern.match('/ip/10.10.10.10'), ['10.10.10.10'] - t.deepEqual pattern.match('/ip/127.0.0.1'), ['127.0.0.1'] - - 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]?))$/ - pattern = new UrlPattern regex, ['ip'] - t.equal null, pattern.match('10.10.10.10') - t.equal null, pattern.match('ip/10.10.10.10') - t.equal null, pattern.match('/ip/10.10.10.') - t.equal null, pattern.match('/ip/10.') - t.equal null, pattern.match('/ip/') - t.deepEqual pattern.match('/ip/10.10.10.10'), - ip: '10.10.10.10' - t.deepEqual pattern.match('/ip/127.0.0.1'), - ip: '127.0.0.1' - - t.end() diff --git a/test/match-fixtures.ts b/test/match-fixtures.ts new file mode 100644 index 0000000..8478810 --- /dev/null +++ b/test/match-fixtures.ts @@ -0,0 +1,339 @@ +// tests to ensure that there are no regressions in matching functionality +// +// tslint:disable:max-line-length +import * as tape from "tape"; + +// @ts-ignore +import UrlPattern from "../src/url-pattern.ts"; + +tape("match", (t: tape.Test) => { + let pattern = new UrlPattern("/foo"); + t.deepEqual(pattern.match("/foo"), {}); + + pattern = new UrlPattern(".foo"); + t.deepEqual(pattern.match(".foo"), {}); + + pattern = new UrlPattern("/foo"); + t.equals(pattern.match("/foobar"), undefined); + + pattern = new UrlPattern(".foo"); + t.equals(pattern.match(".foobar"), undefined); + + pattern = new UrlPattern("/foo"); + t.equals(pattern.match("/bar/foo"), undefined); + + pattern = new UrlPattern(".foo"); + t.equals(pattern.match(".bar.foo"), undefined); + + pattern = new UrlPattern(/foo/, []); + t.deepEqual(pattern.match("foo"), []); + + pattern = new UrlPattern(/\/foo\/(.*)/, ["path"]); + t.deepEqual(pattern.match("/foo/bar"), {path: "bar"}); + t.deepEqual(pattern.match("/foo/"), {path: ""}); + + pattern = new UrlPattern("/user/:userId/task/:taskId"); + t.deepEqual(pattern.match("/user/10/task/52"), { + taskId: "52", + userId: "10", + }); + + pattern = new UrlPattern(".user.:userId.task.:taskId"); + t.deepEqual(pattern.match(".user.10.task.52"), { + taskId: "52", + userId: "10", + }); + + pattern = new UrlPattern("*/user/:userId"); + t.deepEqual(pattern.match("/school/10/user/10"), { + userId: "10", + }); + + pattern = new UrlPattern("*:prefix/user/:userId"); + t.deepEqual(pattern.match("/school/10/user/10"), { + prefix: "/school/10", + userId: "10", + }); + + pattern = new UrlPattern("*-user-:userId"); + t.deepEqual(pattern.match("-school-10-user-10"), { + userId: "10", + }); + + pattern = new UrlPattern("*:prefix-user-:userId"); + t.deepEqual(pattern.match("-school-10-user-10"), { + prefix: "-school-10", + userId: "10", + }); + + pattern = new UrlPattern("/admin*"); + t.deepEqual(pattern.match("/admin/school/10/user/10"), {}); + + pattern = new UrlPattern("/admin*:suffix"); + t.deepEqual(pattern.match("/admin/school/10/user/10"), { + suffix: "/school/10/user/10", + }); + t.deepEqual(pattern.match("/admin"), { + suffix: "", + }); + + pattern = new UrlPattern("/admin(*:suffix)"); + t.deepEqual(pattern.match("/admin/school/10/user/10"), { + suffix: "/school/10/user/10", + }); + t.deepEqual(pattern.match("/admin"), {}); + + pattern = new UrlPattern("#admin*"); + t.deepEqual(pattern.match("#admin#school#10#user#10"), {}); + + pattern = new UrlPattern("#admin*:suffix"); + t.deepEqual(pattern.match("#admin#school#10#user#10"), { + suffix: "#school#10#user#10", + }); + + pattern = new UrlPattern("/admin/*/user/:userId"); + t.deepEqual(pattern.match("/admin/school/10/user/10"), { + userId: "10", + }); + + pattern = new UrlPattern("/admin/*:infix/user/:userId"); + t.deepEqual(pattern.match("/admin/school/10/user/10"), { + infix: "school/10", + userId: "10", + }); + + pattern = new UrlPattern("$admin$*$user$:userId"); + t.deepEqual(pattern.match("$admin$school$10$user$10"), { + userId: "10", + }); + + pattern = new UrlPattern("$admin$*:infix$user$:userId"); + t.deepEqual(pattern.match("$admin$school$10$user$10"), { + infix: "school$10", + userId: "10", + }); + + pattern = new UrlPattern("/admin/*/user/*/tail"); + t.deepEqual(pattern.match("/admin/school/10/user/10/12/tail"), {}); + + pattern = new UrlPattern("/admin/*:infix1/user/*:infix2/tail"); + t.deepEqual(pattern.match("/admin/school/10/user/10/12/tail"), { + infix1: "school/10", + infix2: "10/12", + }); + + pattern = new UrlPattern("/admin/*/user/:id/*/tail"); + t.deepEqual(pattern.match("/admin/school/10/user/10/12/13/tail"), { + id: "10", + }); + + pattern = new UrlPattern("/admin/*:infix1/user/:id/*:infix2/tail"); + t.deepEqual(pattern.match("/admin/school/10/user/10/12/13/tail"), { + id: "10", + infix1: "school/10", + infix2: "12/13", + }); + + pattern = new UrlPattern("/*/admin(/:path)"); + t.deepEqual(pattern.match("/admin/admin/admin"), { + path: "admin", + }); + + pattern = new UrlPattern("/*:infix/admin(/:path)"); + t.deepEqual(pattern.match("/admin/admin/admin"), { + infix: "admin", + path: "admin", + }); + + pattern = new UrlPattern("(/)"); + t.deepEqual(pattern.match(""), {}); + t.deepEqual(pattern.match("/"), {}); + + pattern = new UrlPattern("/admin(/foo)/bar"); + t.deepEqual(pattern.match("/admin/foo/bar"), {}); + t.deepEqual(pattern.match("/admin/bar"), {}); + + pattern = new UrlPattern("/admin(/:foo)/bar"); + t.deepEqual(pattern.match("/admin/baz/bar"), + {foo: "baz"}); + t.deepEqual(pattern.match("/admin/bar"), {}); + + pattern = new UrlPattern("/admin/(*/)foo"); + t.deepEqual(pattern.match("/admin/foo"), {}); + t.deepEqual(pattern.match("/admin/baz/bar/biff/foo"), {}); + + pattern = new UrlPattern("/admin/(*:infix/)foo"); + t.deepEqual(pattern.match("/admin/foo"), {}); + t.deepEqual(pattern.match("/admin/baz/bar/biff/foo"), { + infix: "baz/bar/biff", + }); + + pattern = new UrlPattern("/v:major.:minor/*"); + t.deepEqual(pattern.match("/v1.2/resource/"), { + major: "1", + minor: "2", + }); + + pattern = new UrlPattern("/v:major.:minor/*:suffix"); + t.deepEqual(pattern.match("/v1.2/resource/"), { + major: "1", + minor: "2", + suffix: "resource/", + }); + + pattern = new UrlPattern("/v:minor.:major/*"); + t.deepEqual(pattern.match("/v1.2/resource/"), { + major: "2", + minor: "1", + }); + + pattern = new UrlPattern("/v:minor.:major/*:suffix"); + t.deepEqual(pattern.match("/v1.2/resource/"), { + major: "2", + minor: "1", + suffix: "resource/", + }); + + pattern = new UrlPattern("/:foo_bar"); + t.deepEqual(pattern.match("/_bar"), + {foo_bar: "_bar"}); + t.deepEqual(pattern.match("/a_bar"), + {foo_bar: "a_bar"}); + t.deepEqual(pattern.match("/a__bar"), + {foo_bar: "a__bar"}); + t.deepEqual(pattern.match("/a-b-c-d__bar"), + {foo_bar: "a-b-c-d__bar"}); + t.deepEqual(pattern.match("/a b%c-d__bar"), + {foo_bar: "a b%c-d__bar"}); + + pattern = new UrlPattern("((((a)b)c)d)"); + t.deepEqual(pattern.match(""), {}); + t.equal(pattern.match("a"), undefined); + t.equal(pattern.match("ab"), undefined); + t.equal(pattern.match("abc"), undefined); + t.deepEqual(pattern.match("abcd"), {}); + t.deepEqual(pattern.match("bcd"), {}); + t.deepEqual(pattern.match("cd"), {}); + t.deepEqual(pattern.match("d"), {}); + + pattern = new UrlPattern("/user/:range"); + t.deepEqual(pattern.match("/user/10-20"), + {range: "10-20"}); + + pattern = new UrlPattern("/user/:range"); + t.deepEqual(pattern.match("/user/10_20"), + {range: "10_20"}); + + pattern = new UrlPattern("/user/:range"); + t.deepEqual(pattern.match("/user/10 20"), + {range: "10 20"}); + + pattern = new UrlPattern("/user/:range"); + t.deepEqual(pattern.match("/user/10%20"), + {range: "10%20"}); + + pattern = new UrlPattern("/vvv:version/*"); + t.equal(undefined, pattern.match("/vvv/resource")); + t.deepEqual(pattern.match("/vvv1/resource"), { + version: "1", + }); + t.equal(undefined, pattern.match("/vvv1.1/resource")); + + pattern = new UrlPattern("/vvv:version/*:suffix"); + t.equal(undefined, pattern.match("/vvv/resource")); + t.deepEqual(pattern.match("/vvv1/resource"), { + suffix: "resource", + version: "1", + }); + t.equal(undefined, pattern.match("/vvv1.1/resource")); + + pattern = new UrlPattern("/api/users/:id", + {segmentValueCharset: "a-zA-Z0-9-_~ %.@"}); + t.deepEqual(pattern.match("/api/users/someuser@example.com"), + {id: "someuser@example.com"}); + + pattern = new UrlPattern("/api/users?username=:username", + {segmentValueCharset: "a-zA-Z0-9-_~ %.@"}); + t.deepEqual(pattern.match("/api/users?username=someone@example.com"), + {username: "someone@example.com"}); + + pattern = new UrlPattern("/api/users?param1=:param1¶m2=:param2"); + t.deepEqual(pattern.match("/api/users?param1=foo¶m2=bar"), { + param1: "foo", + param2: "bar", + }); + + pattern = new UrlPattern(":scheme\\://:host(\\::port)", + {segmentValueCharset: "a-zA-Z0-9-_~ %."}); + t.deepEqual(pattern.match("ftp://ftp.example.com"), { + host: "ftp.example.com", + scheme: "ftp", + }); + t.deepEqual(pattern.match("ftp://ftp.example.com:8080"), { + host: "ftp.example.com", + port: "8080", + scheme: "ftp", + }); + t.deepEqual(pattern.match("https://example.com:80"), { + host: "example.com", + port: "80", + scheme: "https", + }); + + pattern = new UrlPattern(":scheme\\://:host(\\::port)(/api(/:resource(/:id)))", + {segmentValueCharset: "a-zA-Z0-9-_~ %.@"}); + t.deepEqual(pattern.match("https://sss.www.localhost.com"), { + host: "sss.www.localhost.com", + scheme: "https", + }); + t.deepEqual(pattern.match("https://sss.www.localhost.com:8080"), { + host: "sss.www.localhost.com", + port: "8080", + scheme: "https", + }); + t.deepEqual(pattern.match("https://sss.www.localhost.com/api"), { + host: "sss.www.localhost.com", + scheme: "https", + }); + t.deepEqual(pattern.match("https://sss.www.localhost.com/api/security"), { + host: "sss.www.localhost.com", + resource: "security", + scheme: "https", + }); + t.deepEqual(pattern.match("https://sss.www.localhost.com/api/security/bob@example.com"), { + host: "sss.www.localhost.com", + id: "bob@example.com", + resource: "security", + scheme: "https", + }); + + const 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]?))$/; + pattern = new UrlPattern(regex, ["ip"]); + t.equal(undefined, pattern.match("10.10.10.10")); + t.equal(undefined, pattern.match("ip/10.10.10.10")); + t.equal(undefined, pattern.match("/ip/10.10.10.")); + t.equal(undefined, pattern.match("/ip/10.")); + t.equal(undefined, pattern.match("/ip/")); + t.deepEqual(pattern.match("/ip/10.10.10.10"), + {ip: "10.10.10.10"}); + t.deepEqual(pattern.match("/ip/127.0.0.1"), + {ip: "127.0.0.1"}); + + pattern = new UrlPattern("https\\://translate.google.com/translate?sl=auto&tl=:targetLanguage&u=:url", { + segmentValueCharset: "a-zA-Z0-9-_~ %.", + }); + t.deepEqual(pattern.match("https://translate.google.com/translate?sl=auto&tl=es&u=yahoo.com"), { + targetLanguage: "es", + url: "yahoo.com", + }); + + pattern = new UrlPattern("https\\://translate.google.com/translate?sl=auto&tl=:target_language&u=:url", { + segmentValueCharset: "a-zA-Z0-9-_~ %.", + }); + t.deepEqual(pattern.match("https://translate.google.com/translate?sl=auto&tl=es&u=yahoo.com"), { + target_language: "es", + url: "yahoo.com", + }); + + t.end(); +}); diff --git a/test/misc.coffee b/test/misc.coffee deleted file mode 100644 index 14550ac..0000000 --- a/test/misc.coffee +++ /dev/null @@ -1,30 +0,0 @@ -test = require 'tape' -UrlPattern = require '../lib/url-pattern' - -test 'instance of UrlPattern is handled correctly as constructor argument', (t) -> - pattern = new UrlPattern '/user/:userId/task/:taskId' - copy = new UrlPattern pattern - t.deepEqual copy.match('/user/10/task/52'), - userId: '10' - taskId: '52' - t.end() - -test 'match full stops in segment values', (t) -> - options = - segmentValueCharset: 'a-zA-Z0-9-_ %.' - pattern = new UrlPattern '/api/v1/user/:id/', options - t.deepEqual pattern.match('/api/v1/user/test.name/'), - id: 'test.name' - t.end() - -test 'regex group names', (t) -> - pattern = new UrlPattern /^\/api\/([a-zA-Z0-9-_~ %]+)(?:\/(\d+))?$/, ['resource', 'id'] - t.deepEqual pattern.match('/api/users'), - resource: 'users' - t.equal pattern.match('/apiii/users'), null - t.deepEqual pattern.match('/api/users/foo'), null - t.deepEqual pattern.match('/api/users/10'), - resource: 'users' - id: '10' - t.deepEqual pattern.match('/api/projects/10/'), null - t.end() diff --git a/test/misc.ts b/test/misc.ts new file mode 100644 index 0000000..54b4105 --- /dev/null +++ b/test/misc.ts @@ -0,0 +1,40 @@ +import * as tape from "tape"; + +// @ts-ignore +import UrlPattern from "../src/url-pattern.ts"; + +tape("instance of UrlPattern is handled correctly as constructor argument", (t: tape.Test) => { + const pattern = new UrlPattern("/user/:userId/task/:taskId"); + const copy = new UrlPattern(pattern); + t.deepEqual(copy.match("/user/10/task/52"), { + taskId: "52", + userId: "10", + }, + ); + t.end(); +}); + +tape("match full stops in segment values", (t: tape.Test) => { + const options = { + segmentValueCharset: "a-zA-Z0-9-_ %.", + }; + const pattern = new UrlPattern("/api/v1/user/:id/", options); + t.deepEqual(pattern.match("/api/v1/user/test.name/"), + {id: "test.name"}); + t.end(); +}); + +tape("regex group names", (t: tape.Test) => { + const pattern = new UrlPattern(/^\/api\/([a-zA-Z0-9-_~ %]+)(?:\/(\d+))?$/, ["resource", "id"]); + t.deepEqual(pattern.match("/api/users"), + {resource: "users"}); + t.equal(pattern.match("/apiii/users"), undefined); + t.deepEqual(pattern.match("/api/users/foo"), undefined); + t.deepEqual(pattern.match("/api/users/10"), { + id: "10", + resource: "users", + }, + ); + t.deepEqual(pattern.match("/api/projects/10/"), undefined); + t.end(); +}); diff --git a/test/parser-combinators.ts b/test/parser-combinators.ts new file mode 100644 index 0000000..ad7082d --- /dev/null +++ b/test/parser-combinators.ts @@ -0,0 +1,41 @@ +import * as tape from "tape"; + +import { + newRegexParser, + newStringParser, +// @ts-ignore +} from "../src/parser-combinators.ts"; + +tape("newStringParser", (t: tape.Test) => { + const parse = newStringParser("foo"); + t.deepEqual(parse("foo"), { + rest: "", + value: "foo", + }); + t.deepEqual(parse("foobar"), { + rest: "bar", + value: "foo", + }); + t.equal(parse("bar"), undefined); + t.equal(parse(""), undefined); + t.end(); +}); + +tape("newRegexParser", (t: tape.Test) => { + const parse = newRegexParser(/^[a-zA-Z0-9]+/); + t.deepEqual(parse("foobar"), { + rest: "", + value: "foobar", + }); + t.equal(parse("_aa"), undefined); + t.deepEqual(parse("a"), { + rest: "", + value: "a", + }); + t.deepEqual(parse("foo90$bar"), { + rest: "$bar", + value: "foo90", + }); + t.equal(parse(""), undefined); + t.end(); +}); diff --git a/test/parser.coffee b/test/parser.coffee deleted file mode 100644 index e7d5c74..0000000 --- a/test/parser.coffee +++ /dev/null @@ -1,426 +0,0 @@ -# taken from -# https://github.com/snd/pcom/blob/master/t/url-pattern-example.coffee - -test = require 'tape' - -UrlPattern = require '../lib/url-pattern' -U = UrlPattern.newParser(UrlPattern.defaultOptions) -parse = U.pattern - -test 'wildcard', (t) -> - t.deepEqual U.wildcard('*'), - value: - tag: 'wildcard' - value: '*' - rest: '' - t.deepEqual U.wildcard('*/'), - value: - tag: 'wildcard' - value: '*' - rest: '/' - t.equal U.wildcard(' *'), undefined - t.equal U.wildcard('()'), undefined - t.equal U.wildcard('foo(100)'), undefined - t.equal U.wildcard('(100foo)'), undefined - t.equal U.wildcard('(foo100)'), undefined - t.equal U.wildcard('(foobar)'), undefined - t.equal U.wildcard('foobar'), undefined - t.equal U.wildcard('_aa'), undefined - t.equal U.wildcard('$foobar'), undefined - t.equal U.wildcard('$'), undefined - t.equal U.wildcard(''), undefined - t.end() - -test 'named', (t) -> - t.deepEqual U.named(':a'), - value: - tag: 'named' - value: 'a' - rest: '' - t.deepEqual U.named(':ab96c'), - value: - tag: 'named' - value: 'ab96c' - rest: '' - t.deepEqual U.named(':ab96c.'), - value: - tag: 'named' - value: 'ab96c' - rest: '.' - t.deepEqual U.named(':96c-:ab'), - value: - tag: 'named' - value: '96c' - rest: '-:ab' - t.equal U.named(':'), undefined - t.equal U.named(''), undefined - t.equal U.named('a'), undefined - t.equal U.named('abc'), undefined - t.end() - -test 'static', (t) -> - t.deepEqual U.static('a'), - value: - tag: 'static' - value: 'a' - rest: '' - t.deepEqual U.static('abc:d'), - value: - tag: 'static' - value: 'abc' - rest: ':d' - t.equal U.static(':ab96c'), undefined - t.equal U.static(':'), undefined - t.equal U.static('('), undefined - t.equal U.static(')'), undefined - t.equal U.static('*'), undefined - t.equal U.static(''), undefined - t.end() - - -test 'fixtures', (t) -> - t.equal parse(''), undefined - t.equal parse('('), undefined - t.equal parse(')'), undefined - t.equal parse('()'), undefined - t.equal parse(':'), undefined - t.equal parse('((foo)'), undefined - t.equal parse('(((foo)bar(boo)far)'), undefined - - t.deepEqual parse('(foo))'), - rest: ')' - value: [ - {tag: 'optional', value: [{tag: 'static', value: 'foo'}]} - ] - - t.deepEqual parse('((foo)))bar'), - rest: ')bar' - value: [ - { - tag: 'optional' - value: [ - {tag: 'optional', value: [{tag: 'static', value: 'foo'}]} - ] - } - ] - - - t.deepEqual parse('foo:*'), - rest: ':*' - value: [ - {tag: 'static', value: 'foo'} - ] - - t.deepEqual parse(':foo:bar'), - rest: '' - value: [ - {tag: 'named', value: 'foo'} - {tag: 'named', value: 'bar'} - ] - - t.deepEqual parse('a'), - rest: '' - value: [ - {tag: 'static', value: 'a'} - ] - t.deepEqual parse('user42'), - rest: '' - value: [ - {tag: 'static', value: 'user42'} - ] - t.deepEqual parse(':a'), - rest: '' - value: [ - {tag: 'named', value: 'a'} - ] - t.deepEqual parse('*'), - rest: '' - value: [ - {tag: 'wildcard', value: '*'} - ] - t.deepEqual parse('(foo)'), - rest: '' - value: [ - {tag: 'optional', value: [{tag: 'static', value: 'foo'}]} - ] - t.deepEqual parse('(:foo)'), - rest: '' - value: [ - {tag: 'optional', value: [{tag: 'named', value: 'foo'}]} - ] - t.deepEqual parse('(*)'), - rest: '' - value: [ - {tag: 'optional', value: [{tag: 'wildcard', value: '*'}]} - ] - - - t.deepEqual parse('/api/users/:id'), - rest: '' - value: [ - {tag: 'static', value: '/api/users/'} - {tag: 'named', value: 'id'} - ] - t.deepEqual parse('/v:major(.:minor)/*'), - rest: '' - value: [ - {tag: 'static', value: '/v'} - {tag: 'named', value: 'major'} - { - tag: 'optional' - value: [ - {tag: 'static', value: '.'} - {tag: 'named', value: 'minor'} - ] - } - {tag: 'static', value: '/'} - {tag: 'wildcard', value: '*'} - ] - t.deepEqual parse('(http(s)\\://)(:subdomain.):domain.:tld(/*)'), - rest: '' - value: [ - { - tag: 'optional' - value: [ - {tag: 'static', value: 'http'} - { - tag: 'optional' - value: [ - {tag: 'static', value: 's'} - ] - } - {tag: 'static', value: '://'} - ] - } - { - tag: 'optional' - value: [ - {tag: 'named', value: 'subdomain'} - {tag: 'static', value: '.'} - ] - } - {tag: 'named', value: 'domain'} - {tag: 'static', value: '.'} - {tag: 'named', value: 'tld'} - { - tag: 'optional' - value: [ - {tag: 'static', value: '/'} - {tag: 'wildcard', value: '*'} - ] - } - ] - t.deepEqual parse('/api/users/:ids/posts/:ids'), - rest: '' - value: [ - {tag: 'static', value: '/api/users/'} - {tag: 'named', value: 'ids'} - {tag: 'static', value: '/posts/'} - {tag: 'named', value: 'ids'} - ] - - t.deepEqual parse('/user/:userId/task/:taskId'), - rest: '' - value: [ - {tag: 'static', value: '/user/'} - {tag: 'named', value: 'userId'} - {tag: 'static', value: '/task/'} - {tag: 'named', value: 'taskId'} - ] - - t.deepEqual parse('.user.:userId.task.:taskId'), - rest: '' - value: [ - {tag: 'static', value: '.user.'} - {tag: 'named', value: 'userId'} - {tag: 'static', value: '.task.'} - {tag: 'named', value: 'taskId'} - ] - - t.deepEqual parse('*/user/:userId'), - rest: '' - value: [ - {tag: 'wildcard', value: '*'} - {tag: 'static', value: '/user/'} - {tag: 'named', value: 'userId'} - ] - - t.deepEqual parse('*-user-:userId'), - rest: '' - value: [ - {tag: 'wildcard', value: '*'} - {tag: 'static', value: '-user-'} - {tag: 'named', value: 'userId'} - ] - - t.deepEqual parse('/admin*'), - rest: '' - value: [ - {tag: 'static', value: '/admin'} - {tag: 'wildcard', value: '*'} - ] - - t.deepEqual parse('#admin*'), - rest: '' - value: [ - {tag: 'static', value: '#admin'} - {tag: 'wildcard', value: '*'} - ] - - t.deepEqual parse('/admin/*/user/:userId'), - rest: '' - value: [ - {tag: 'static', value: '/admin/'} - {tag: 'wildcard', value: '*'} - {tag: 'static', value: '/user/'} - {tag: 'named', value: 'userId'} - ] - - t.deepEqual parse('$admin$*$user$:userId'), - rest: '' - value: [ - {tag: 'static', value: '$admin$'} - {tag: 'wildcard', value: '*'} - {tag: 'static', value: '$user$'} - {tag: 'named', value: 'userId'} - ] - - t.deepEqual parse('/admin/*/user/*/tail'), - rest: '' - value: [ - {tag: 'static', value: '/admin/'} - {tag: 'wildcard', value: '*'} - {tag: 'static', value: '/user/'} - {tag: 'wildcard', value: '*'} - {tag: 'static', value: '/tail'} - ] - - t.deepEqual parse('/admin/*/user/:id/*/tail'), - rest: '' - value: [ - {tag: 'static', value: '/admin/'} - {tag: 'wildcard', value: '*'} - {tag: 'static', value: '/user/'} - {tag: 'named', value: 'id'} - {tag: 'static', value: '/'} - {tag: 'wildcard', value: '*'} - {tag: 'static', value: '/tail'} - ] - - t.deepEqual parse('^admin^*^user^:id^*^tail'), - rest: '' - value: [ - {tag: 'static', value: '^admin^'} - {tag: 'wildcard', value: '*'} - {tag: 'static', value: '^user^'} - {tag: 'named', value: 'id'} - {tag: 'static', value: '^'} - {tag: 'wildcard', value: '*'} - {tag: 'static', value: '^tail'} - ] - - t.deepEqual parse('/*/admin(/:path)'), - rest: '' - value: [ - {tag: 'static', value: '/'} - {tag: 'wildcard', value: '*'} - {tag: 'static', value: '/admin'} - {tag: 'optional', value: [ - {tag: 'static', value: '/'} - {tag: 'named', value: 'path'} - ]} - ] - - t.deepEqual parse('/'), - rest: '' - value: [ - {tag: 'static', value: '/'} - ] - - t.deepEqual parse('(/)'), - rest: '' - value: [ - {tag: 'optional', value: [ - {tag: 'static', value: '/'} - ]} - ] - - t.deepEqual parse('/admin(/:foo)/bar'), - rest: '' - value: [ - {tag: 'static', value: '/admin'} - {tag: 'optional', value: [ - {tag: 'static', value: '/'} - {tag: 'named', value: 'foo'} - ]} - {tag: 'static', value: '/bar'} - ] - - t.deepEqual parse('/admin(*/)foo'), - rest: '' - value: [ - {tag: 'static', value: '/admin'} - {tag: 'optional', value: [ - {tag: 'wildcard', value: '*'} - {tag: 'static', value: '/'} - ]} - {tag: 'static', value: 'foo'} - ] - - t.deepEqual parse('/v:major.:minor/*'), - rest: '' - value: [ - {tag: 'static', value: '/v'} - {tag: 'named', value: 'major'} - {tag: 'static', value: '.'} - {tag: 'named', value: 'minor'} - {tag: 'static', value: '/'} - {tag: 'wildcard', value: '*'} - ] - - t.deepEqual parse('/v:v.:v/*'), - rest: '' - value: [ - {tag: 'static', value: '/v'} - {tag: 'named', value: 'v'} - {tag: 'static', value: '.'} - {tag: 'named', value: 'v'} - {tag: 'static', value: '/'} - {tag: 'wildcard', value: '*'} - ] - - t.deepEqual parse('/:foo_bar'), - rest: '' - value: [ - {tag: 'static', value: '/'} - {tag: 'named', value: 'foo'} - {tag: 'static', value: '_bar'} - ] - - t.deepEqual parse('((((a)b)c)d)'), - rest: '' - value: [ - {tag: 'optional', value: [ - {tag: 'optional', value: [ - {tag: 'optional', value: [ - {tag: 'optional', value: [ - {tag: 'static', value: 'a'} - ]} - {tag: 'static', value: 'b'} - ]} - {tag: 'static', value: 'c'} - ]} - {tag: 'static', value: 'd'} - ]} - ] - - t.deepEqual parse('/vvv:version/*'), - rest: '' - value: [ - {tag: 'static', value: '/vvv'} - {tag: 'named', value: 'version'} - {tag: 'static', value: '/'} - {tag: 'wildcard', value: '*'} - ] - - t.end() diff --git a/test/parser.ts b/test/parser.ts new file mode 100644 index 0000000..23359ab --- /dev/null +++ b/test/parser.ts @@ -0,0 +1,477 @@ +import * as tape from "tape"; + +import { + newNamedSegmentParser, + newNamedWildcardParser, + newStaticContentParser, + newUrlPatternParser, +// @ts-ignore +} from "../src/parser.ts"; + +import { + defaultOptions, +// @ts-ignore +} from "../src/options.ts"; + +const parse = newUrlPatternParser(defaultOptions); +const parseNamedSegment = newNamedSegmentParser(defaultOptions); +const parseStaticContent = newStaticContentParser(defaultOptions); +const parseNamedWildcard = newNamedWildcardParser(defaultOptions); + +tape("namedSegment", (t: tape.Test) => { + t.deepEqual(parseNamedSegment(":a"), { + rest: "", + value: { + tag: "namedSegment", + value: "a", + }, + }, + ); + t.deepEqual(parseNamedSegment(":ab96c"), { + rest: "", + value: { + tag: "namedSegment", + value: "ab96c", + }, + }, + ); + t.deepEqual(parseNamedSegment(":ab96c."), { + rest: ".", + value: { + tag: "namedSegment", + value: "ab96c", + }, + }, + ); + t.deepEqual(parseNamedSegment(":96c-:ab"), { + rest: "-:ab", + value: { + tag: "namedSegment", + value: "96c", + }, + }, + ); + t.equal(parseNamedSegment(":"), undefined); + t.equal(parseNamedSegment(""), undefined); + t.equal(parseNamedSegment("a"), undefined); + t.equal(parseNamedSegment("abc"), undefined); + t.end(); +}); + +tape("static", (t: tape.Test) => { + t.deepEqual(parseStaticContent("a"), { + rest: "", + value: { + tag: "staticContent", + value: "a", + }, + }, + ); + t.deepEqual(parseStaticContent("abc:d"), { + rest: ":d", + value: { + tag: "staticContent", + value: "abc", + }, + }, + ); + t.equal(parseStaticContent(":ab96c"), undefined); + t.equal(parseStaticContent(":"), undefined); + t.equal(parseStaticContent("("), undefined); + t.equal(parseStaticContent(")"), undefined); + t.equal(parseStaticContent("*"), undefined); + t.equal(parseStaticContent(""), undefined); + t.end(); +}); + +tape("namedWildcard", (t: tape.Test) => { + t.deepEqual(parseNamedWildcard("*:a"), { + rest: "", + value: { + tag: "namedWildcard", + value: "a", + }, + }); + t.end(); +}); + +tape("fixtures", (t: tape.Test) => { + t.equal(parse(""), undefined); + t.equal(parse("("), undefined); + t.equal(parse(")"), undefined); + t.equal(parse("()"), undefined); + t.equal(parse(":"), undefined); + t.equal(parse("((foo)"), undefined); + t.equal(parse("(((foo)bar(boo)far)"), undefined); + + t.deepEqual(parse("(foo))"), { + rest: ")", + value: [ + {tag: "optionalSegment", value: [{tag: "staticContent", value: "foo"}]}, + ], + }); + + t.deepEqual(parse("((foo)))bar"), { + rest: ")bar", + value: [ + { + tag: "optionalSegment", + value: [ + {tag: "optionalSegment", value: [{tag: "staticContent", value: "foo"}]}, + ], + }, + ], + }); + + t.deepEqual(parse("foo:*"), { + rest: ":*", + value: [ + {tag: "staticContent", value: "foo"}, + ], + }); + + t.deepEqual(parse(":foo:bar"), { + rest: "", + value: [ + {tag: "namedSegment", value: "foo"}, + {tag: "namedSegment", value: "bar"}, + ], + }); + + t.deepEqual(parse("a"), { + rest: "", + value: [ + {tag: "staticContent", value: "a"}, + ], + }); + t.deepEqual(parse("user42"), { + rest: "", + value: [ + {tag: "staticContent", value: "user42"}, + ], + }); + t.deepEqual(parse(":a"), { + rest: "", + value: [ + {tag: "namedSegment", value: "a"}, + ], + }); + t.deepEqual(parse("*"), { + rest: "", + value: [ + {tag: "wildcard", value: "*"}, + ], + }); + t.deepEqual(parse("(foo)"), { + rest: "", + value: [ + {tag: "optionalSegment", value: [{tag: "staticContent", value: "foo"}]}, + ], + }); + t.deepEqual(parse("(:foo)"), { + rest: "", + value: [ + {tag: "optionalSegment", value: [{tag: "namedSegment", value: "foo"}]}, + ], + }); + t.deepEqual(parse("(*)"), { + rest: "", + value: [ + {tag: "optionalSegment", value: [{tag: "wildcard", value: "*"}]}, + ], + }); + + t.deepEqual(parse("/api/users/:id"), { + rest: "", + value: [ + {tag: "staticContent", value: "/api/users/"}, + {tag: "namedSegment", value: "id"}, + ], + }); + t.deepEqual(parse("/v:major(.:minor)/*"), { + rest: "", + value: [ + {tag: "staticContent", value: "/v"}, + {tag: "namedSegment", value: "major"}, + { + tag: "optionalSegment", + value: [ + {tag: "staticContent", value: "."}, + {tag: "namedSegment", value: "minor"}, + ], + }, + {tag: "staticContent", value: "/"}, + {tag: "wildcard", value: "*"}, + ], + }); + t.deepEqual(parse("(http(s)\\://)(:subdomain.):domain.:tld(/*)"), { + rest: "", + value: [ + { + tag: "optionalSegment", + value: [ + {tag: "staticContent", value: "http"}, + { + tag: "optionalSegment", + value: [ + {tag: "staticContent", value: "s"}, + ], + }, + {tag: "staticContent", value: "://"}, + ], + }, + { + tag: "optionalSegment", + value: [ + {tag: "namedSegment", value: "subdomain"}, + {tag: "staticContent", value: "."}, + ], + }, + {tag: "namedSegment", value: "domain"}, + {tag: "staticContent", value: "."}, + {tag: "namedSegment", value: "tld"}, + { + tag: "optionalSegment", + value: [ + {tag: "staticContent", value: "/"}, + {tag: "wildcard", value: "*"}, + ], + }, + ], + }); + t.deepEqual(parse("/api/users/:ids/posts/:ids"), { + rest: "", + value: [ + {tag: "staticContent", value: "/api/users/"}, + {tag: "namedSegment", value: "ids"}, + {tag: "staticContent", value: "/posts/"}, + {tag: "namedSegment", value: "ids"}, + ], + }); + + t.deepEqual(parse("/user/:userId/task/:taskId"), { + rest: "", + value: [ + {tag: "staticContent", value: "/user/"}, + {tag: "namedSegment", value: "userId"}, + {tag: "staticContent", value: "/task/"}, + {tag: "namedSegment", value: "taskId"}, + ], + }); + + t.deepEqual(parse(".user.:userId.task.:taskId"), { + rest: "", + value: [ + {tag: "staticContent", value: ".user."}, + {tag: "namedSegment", value: "userId"}, + {tag: "staticContent", value: ".task."}, + {tag: "namedSegment", value: "taskId"}, + ], + }); + + t.deepEqual(parse("*/user/:userId"), { + rest: "", + value: [ + {tag: "wildcard", value: "*"}, + {tag: "staticContent", value: "/user/"}, + {tag: "namedSegment", value: "userId"}, + ], + }); + + t.deepEqual(parse("*-user-:userId"), { + rest: "", + value: [ + {tag: "wildcard", value: "*"}, + {tag: "staticContent", value: "-user-"}, + {tag: "namedSegment", value: "userId"}, + ], + }); + + t.deepEqual(parse("/admin*"), { + rest: "", + value: [ + {tag: "staticContent", value: "/admin"}, + {tag: "wildcard", value: "*"}, + ], + }); + + t.deepEqual(parse("#admin*"), { + rest: "", + value: [ + {tag: "staticContent", value: "#admin"}, + {tag: "wildcard", value: "*"}, + ], + }); + + t.deepEqual(parse("/admin/*/user/:userId"), { + rest: "", + value: [ + {tag: "staticContent", value: "/admin/"}, + {tag: "wildcard", value: "*"}, + {tag: "staticContent", value: "/user/"}, + {tag: "namedSegment", value: "userId"}, + ], + }); + + t.deepEqual(parse("$admin$*$user$:userId"), { + rest: "", + value: [ + {tag: "staticContent", value: "$admin$"}, + {tag: "wildcard", value: "*"}, + {tag: "staticContent", value: "$user$"}, + {tag: "namedSegment", value: "userId"}, + ], + }); + + t.deepEqual(parse("/admin/*/user/*/tail"), { + rest: "", + value: [ + {tag: "staticContent", value: "/admin/"}, + {tag: "wildcard", value: "*"}, + {tag: "staticContent", value: "/user/"}, + {tag: "wildcard", value: "*"}, + {tag: "staticContent", value: "/tail"}, + ], + }); + + t.deepEqual(parse("/admin/*/user/:id/*/tail"), { + rest: "", + value: [ + {tag: "staticContent", value: "/admin/"}, + {tag: "wildcard", value: "*"}, + {tag: "staticContent", value: "/user/"}, + {tag: "namedSegment", value: "id"}, + {tag: "staticContent", value: "/"}, + {tag: "wildcard", value: "*"}, + {tag: "staticContent", value: "/tail"}, + ], + }); + + t.deepEqual(parse("^admin^*^user^:id^*^tail"), { + rest: "", + value: [ + {tag: "staticContent", value: "^admin^"}, + {tag: "wildcard", value: "*"}, + {tag: "staticContent", value: "^user^"}, + {tag: "namedSegment", value: "id"}, + {tag: "staticContent", value: "^"}, + {tag: "wildcard", value: "*"}, + {tag: "staticContent", value: "^tail"}, + ], + }); + + t.deepEqual(parse("/*/admin(/:path)"), { + rest: "", + value: [ + {tag: "staticContent", value: "/"}, + {tag: "wildcard", value: "*"}, + {tag: "staticContent", value: "/admin"}, + {tag: "optionalSegment", value: [ + {tag: "staticContent", value: "/"}, + {tag: "namedSegment", value: "path"}, + ]}, + ], + }); + + t.deepEqual(parse("/"), { + rest: "", + value: [ + {tag: "staticContent", value: "/"}, + ], + }); + + t.deepEqual(parse("(/)"), { + rest: "", + value: [ + {tag: "optionalSegment", value: [ + {tag: "staticContent", value: "/"}, + ]}, + ], + }); + + t.deepEqual(parse("/admin(/:foo)/bar"), { + rest: "", + value: [ + {tag: "staticContent", value: "/admin"}, + {tag: "optionalSegment", value: [ + {tag: "staticContent", value: "/"}, + {tag: "namedSegment", value: "foo"}, + ]}, + {tag: "staticContent", value: "/bar"}, + ], + }); + + t.deepEqual(parse("/admin(*/)foo"), { + rest: "", + value: [ + {tag: "staticContent", value: "/admin"}, + {tag: "optionalSegment", value: [ + {tag: "wildcard", value: "*"}, + {tag: "staticContent", value: "/"}, + ]}, + {tag: "staticContent", value: "foo"}, + ], + }); + + t.deepEqual(parse("/v:major.:minor/*"), { + rest: "", + value: [ + {tag: "staticContent", value: "/v"}, + {tag: "namedSegment", value: "major"}, + {tag: "staticContent", value: "."}, + {tag: "namedSegment", value: "minor"}, + {tag: "staticContent", value: "/"}, + {tag: "wildcard", value: "*"}, + ], + }); + + t.deepEqual(parse("/v:v.:v/*"), { + rest: "", + value: [ + {tag: "staticContent", value: "/v"}, + {tag: "namedSegment", value: "v"}, + {tag: "staticContent", value: "."}, + {tag: "namedSegment", value: "v"}, + {tag: "staticContent", value: "/"}, + {tag: "wildcard", value: "*"}, + ], + }); + + t.deepEqual(parse("/:foo_bar"), { + rest: "", + value: [ + {tag: "staticContent", value: "/"}, + {tag: "namedSegment", value: "foo_bar"}, + ], + }); + + t.deepEqual(parse("((((a)b)c)d)"), { + rest: "", + value: [ + {tag: "optionalSegment", value: [ + {tag: "optionalSegment", value: [ + {tag: "optionalSegment", value: [ + {tag: "optionalSegment", value: [ + {tag: "staticContent", value: "a"}, + ]}, + {tag: "staticContent", value: "b"}, + ]}, + {tag: "staticContent", value: "c"}, + ]}, + {tag: "staticContent", value: "d"}, + ]}, + ], + }); + + t.deepEqual(parse("/vvv:version/*"), { + rest: "", + value: [ + {tag: "staticContent", value: "/vvv"}, + {tag: "namedSegment", value: "version"}, + {tag: "staticContent", value: "/"}, + {tag: "wildcard", value: "*"}, + ], + }); + + t.end(); +}); diff --git a/test/readme.coffee b/test/readme.coffee deleted file mode 100644 index e3861c1..0000000 --- a/test/readme.coffee +++ /dev/null @@ -1,121 +0,0 @@ -test = require 'tape' -UrlPattern = require '../lib/url-pattern' - -test 'simple', (t) -> - pattern = new UrlPattern('/api/users/:id') - t.deepEqual pattern.match('/api/users/10'), {id: '10'} - t.equal pattern.match('/api/products/5'), null - t.end() - -test 'api versioning', (t) -> - pattern = new UrlPattern('/v:major(.:minor)/*') - t.deepEqual pattern.match('/v1.2/'), {major: '1', minor: '2', _: ''} - t.deepEqual pattern.match('/v2/users'), {major: '2', _: 'users'} - t.equal pattern.match('/v/'), null - t.end() - -test 'domain', (t) -> - pattern = new UrlPattern('(http(s)\\://)(:subdomain.):domain.:tld(\\::port)(/*)') - t.deepEqual pattern.match('google.de'), - domain: 'google' - tld: 'de' - t.deepEqual pattern.match('https://www.google.com'), - subdomain: 'www' - domain: 'google' - tld: 'com' - t.deepEqual pattern.match('http://mail.google.com/mail'), - subdomain: 'mail' - domain: 'google' - tld: 'com' - _: 'mail' - t.deepEqual pattern.match('http://mail.google.com:80/mail'), - subdomain: 'mail' - domain: 'google' - tld: 'com' - port: '80' - _: 'mail' - t.equal pattern.match('google'), null - - t.deepEqual pattern.match('www.google.com'), - subdomain: 'www' - domain: 'google' - tld: 'com' - t.equal pattern.match('httpp://mail.google.com/mail'), null - t.deepEqual pattern.match('google.de/search'), - domain: 'google' - tld: 'de' - _: 'search' - - t.end() - -test 'named segment occurs more than once', (t) -> - pattern = new UrlPattern('/api/users/:ids/posts/:ids') - t.deepEqual pattern.match('/api/users/10/posts/5'), {ids: ['10', '5']} - t.end() - -test 'regex', (t) -> - pattern = new UrlPattern(/^\/api\/(.*)$/) - t.deepEqual pattern.match('/api/users'), ['users'] - t.equal pattern.match('/apiii/users'), null - t.end() - -test 'regex group names', (t) -> - pattern = new UrlPattern(/^\/api\/([^\/]+)(?:\/(\d+))?$/, ['resource', 'id']) - t.deepEqual pattern.match('/api/users'), - resource: 'users' - t.equal pattern.match('/api/users/'), null - t.deepEqual pattern.match('/api/users/5'), - resource: 'users' - id: '5' - t.equal pattern.match('/api/users/foo'), null - t.end() - -test 'stringify', (t) -> - pattern = new UrlPattern('/api/users/:id') - t.equal '/api/users/10', pattern.stringify(id: 10) - - pattern = new UrlPattern('/api/users(/:id)') - t.equal '/api/users', pattern.stringify() - t.equal '/api/users/10', pattern.stringify(id: 10) - - t.end() - -test 'customization', (t) -> - options = - escapeChar: '!' - segmentNameStartChar: '$' - segmentNameCharset: 'a-zA-Z0-9_-' - segmentValueCharset: 'a-zA-Z0-9' - optionalSegmentStartChar: '[' - optionalSegmentEndChar: ']' - wildcardChar: '?' - - pattern = new UrlPattern( - '[http[s]!://][$sub_domain.]$domain.$toplevel-domain[/?]' - options - ) - - t.deepEqual pattern.match('google.de'), - domain: 'google' - 'toplevel-domain': 'de' - t.deepEqual pattern.match('http://mail.google.com/mail'), - sub_domain: 'mail' - domain: 'google' - 'toplevel-domain': 'com' - _: 'mail' - t.equal pattern.match('http://mail.this-should-not-match.com/mail'), null - t.equal pattern.match('google'), null - t.deepEqual pattern.match('www.google.com'), - sub_domain: 'www' - domain: 'google' - 'toplevel-domain': 'com' - t.deepEqual pattern.match('https://www.google.com'), - sub_domain: 'www' - domain: 'google' - 'toplevel-domain': 'com' - t.equal pattern.match('httpp://mail.google.com/mail'), null - t.deepEqual pattern.match('google.de/search'), - domain: 'google' - 'toplevel-domain': 'de' - _: 'search' - t.end() diff --git a/test/readme.ts b/test/readme.ts new file mode 100644 index 0000000..649346c --- /dev/null +++ b/test/readme.ts @@ -0,0 +1,140 @@ +// tests for all the examples in the readme + +import * as tape from "tape"; + +// @ts-ignore +import UrlPattern from "../src/url-pattern.ts"; + +tape("match a pattern against a string and extract values", (t: tape.Test) => { + const pattern = new UrlPattern("/api/users(/:id)"); + t.deepEqual(pattern.match("/api/users/10"), {id: "10"}); + t.deepEqual(pattern.match("/api/users"), {}); + t.equal(pattern.match("/api/products/5"), undefined); + t.end(); +}); + +tape("generate a string from a pattern and values", (t: tape.Test) => { + const pattern = new UrlPattern("/api/users(/:id)"); + t.equal(pattern.stringify(), "/api/users"); + t.equal(pattern.stringify({id: 20}), "/api/users/20"); + t.end(); +}); + +tape("prefer a different syntax. customize it", (t: tape.Test) => { + const options = { + segmentNameEndChar: "}", + segmentNameStartChar: "{", + }; + + const pattern = new UrlPattern( + "/api/users/{id}", + options, + ); + + t.deepEqual(pattern.match("/api/users/5"), { + id: "5", + }); + t.end(); +}); + +tape("domain example", (t: tape.Test) => { + const pattern = new UrlPattern("(http(s)\\://)(:subdomain.):domain.:tld(\\::port)(/*:path)"); + t.deepEqual(pattern.match("google.de"), { + domain: "google", + tld: "de", + }); + t.deepEqual(pattern.match("https://www.google.com"), { + domain: "google", + subdomain: "www", + tld: "com", + }); + t.deepEqual(pattern.match("http://mail.google.com:80/mail/inbox"), { + domain: "google", + path: "mail/inbox", + port: "80", + subdomain: "mail", + tld: "com", + }); + t.equal(pattern.match("google"), undefined); + + t.end(); +}); + +tape("regex example", (t: tape.Test) => { + const pattern = new UrlPattern(/^\/api\/(.*)$/, ["path"]); + t.deepEqual(pattern.match("/api/users"), {path: "users"}); + t.equal(pattern.match("/apiii/users"), undefined); + t.end(); +}); + +tape("regex group names", (t: tape.Test) => { + const pattern = new UrlPattern(/^\/api\/([^\/]+)(?:\/(\d+))?$/, ["resource", "id"]); + t.deepEqual(pattern.match("/api/users"), + {resource: "users"}); + t.equal(pattern.match("/api/users/"), undefined); + t.deepEqual(pattern.match("/api/users/5"), { + id: "5", + resource: "users", + }, + ); + t.equal(pattern.match("/api/users/foo"), undefined); + t.end(); +}); + +tape("stringify", (t: tape.Test) => { + let pattern = new UrlPattern("/api/users/:id"); + t.equal("/api/users/10", pattern.stringify({id: 10})); + + pattern = new UrlPattern("/api/users(/:id)"); + t.equal("/api/users", pattern.stringify()); + t.equal("/api/users/10", pattern.stringify({id: 10})); + + t.end(); +}); + +tape("customization", (t: tape.Test) => { + const options = { + escapeChar: "!", + optionalSegmentEndChar: "]", + optionalSegmentStartChar: "[", + segmentNameCharset: "a-zA-Z0-9_-", + segmentNameStartChar: "$", + segmentValueCharset: "a-zA-Z0-9", + wildcardChar: "?", + }; + + const pattern = new UrlPattern( + "[http[s]!://][$sub_domain.]$domain.$toplevel-domain[/?$path]", + options, + ); + + t.deepEqual(pattern.match("google.de"), { + "domain": "google", + "toplevel-domain": "de", + }); + t.deepEqual(pattern.match("http://mail.google.com/mail"), { + "domain": "google", + "path": "mail", + "sub_domain": "mail", + "toplevel-domain": "com", + }); + t.equal(pattern.match("http://mail.this-should-not-match.com/mail"), undefined); + t.equal(pattern.match("google"), undefined); + t.deepEqual(pattern.match("www.google.com"), { + "domain": "google", + "sub_domain": "www", + "toplevel-domain": "com", + }); + t.deepEqual(pattern.match("https://www.google.com"), { + "domain": "google", + "sub_domain": "www", + "toplevel-domain": "com", + }); + t.equal(pattern.match("httpp://mail.google.com/mail"), undefined); + t.deepEqual(pattern.match("google.de/search"), { + "domain": "google", + "path": "search", + "toplevel-domain": "de", + }); + t.end(); +}); diff --git a/test/stringify-fixtures.coffee b/test/stringify-fixtures.coffee deleted file mode 100644 index cd69bfe..0000000 --- a/test/stringify-fixtures.coffee +++ /dev/null @@ -1,162 +0,0 @@ -test = require 'tape' -UrlPattern = require '../lib/url-pattern' - -test 'stringify', (t) -> - pattern = new UrlPattern '/foo' - t.equal '/foo', pattern.stringify() - - pattern = new UrlPattern '/user/:userId/task/:taskId' - t.equal '/user/10/task/52', pattern.stringify - userId: '10' - taskId: '52' - - pattern = new UrlPattern '/user/:userId/task/:taskId' - t.equal '/user/10/task/52', pattern.stringify - userId: '10' - taskId: '52' - ignored: 'ignored' - - pattern = new UrlPattern '.user.:userId.task.:taskId' - t.equal '.user.10.task.52', pattern.stringify - userId: '10' - taskId: '52' - - pattern = new UrlPattern '*/user/:userId' - t.equal '/school/10/user/10', pattern.stringify - _: '/school/10', - userId: '10' - - pattern = new UrlPattern '*-user-:userId' - t.equal '-school-10-user-10', pattern.stringify - _: '-school-10' - userId: '10' - - pattern = new UrlPattern '/admin*' - t.equal '/admin/school/10/user/10', pattern.stringify - _: '/school/10/user/10' - - pattern = new UrlPattern '/admin/*/user/*/tail' - t.equal '/admin/school/10/user/10/12/tail', pattern.stringify - _: ['school/10', '10/12'] - - pattern = new UrlPattern '/admin/*/user/:id/*/tail' - t.equal '/admin/school/10/user/10/12/13/tail', pattern.stringify - _: ['school/10', '12/13'] - id: '10' - - pattern = new UrlPattern '/*/admin(/:path)' - t.equal '/foo/admin/baz', pattern.stringify - _: 'foo' - path: 'baz' - t.equal '/foo/admin', pattern.stringify - _: 'foo' - - pattern = new UrlPattern '(/)' - t.equal '', pattern.stringify() - - pattern = new UrlPattern '/admin(/foo)/bar' - t.equal '/admin/bar', pattern.stringify() - - pattern = new UrlPattern '/admin(/:foo)/bar' - t.equal '/admin/bar', pattern.stringify() - t.equal '/admin/baz/bar', pattern.stringify - foo: 'baz' - - pattern = new UrlPattern '/admin/(*/)foo' - t.equal '/admin/foo', pattern.stringify() - t.equal '/admin/baz/bar/biff/foo', pattern.stringify - _: 'baz/bar/biff' - - pattern = new UrlPattern '/v:major.:minor/*' - t.equal '/v1.2/resource/', pattern.stringify - _: 'resource/' - major: '1' - minor: '2' - - pattern = new UrlPattern '/v:v.:v/*' - t.equal '/v1.2/resource/', pattern.stringify - _: 'resource/' - v: ['1', '2'] - - pattern = new UrlPattern '/:foo_bar' - t.equal '/a_bar', pattern.stringify - foo: 'a' - t.equal '/a__bar', pattern.stringify - foo: 'a_' - t.equal '/a-b-c-d__bar', pattern.stringify - foo: 'a-b-c-d_' - t.equal '/a b%c-d__bar', pattern.stringify - foo: 'a b%c-d_' - - pattern = new UrlPattern '((((a)b)c)d)' - t.equal '', pattern.stringify() - - pattern = new UrlPattern '(:a-)1-:b(-2-:c-3-:d(-4-*-:a))' - t.equal '1-B', pattern.stringify - b: 'B' - t.equal 'A-1-B', pattern.stringify - a: 'A' - b: 'B' - t.equal 'A-1-B', pattern.stringify - a: 'A' - b: 'B' - t.equal 'A-1-B-2-C-3-D', pattern.stringify - a: 'A' - b: 'B' - c: 'C' - d: 'D' - t.equal 'A-1-B-2-C-3-D-4-E-F', pattern.stringify - a: ['A', 'F'] - b: 'B' - c: 'C' - d: 'D' - _: 'E' - - pattern = new UrlPattern '/user/:range' - t.equal '/user/10-20', pattern.stringify - range: '10-20' - - t.end() - -test 'stringify errors', (t) -> - t.plan 5 - - pattern = new UrlPattern '(:a-)1-:b(-2-:c-3-:d(-4-*-:a))' - - try - pattern.stringify() - catch e - t.equal e.message, "no values provided for key `b`" - try - pattern.stringify - a: 'A' - b: 'B' - c: 'C' - catch e - t.equal e.message, "no values provided for key `d`" - try - pattern.stringify - a: 'A' - b: 'B' - d: 'D' - catch e - t.equal e.message, "no values provided for key `c`" - try - pattern.stringify - a: 'A' - b: 'B' - c: 'C' - d: 'D' - _: 'E' - catch e - t.equal e.message, "too few values provided for key `a`" - try - pattern.stringify - a: ['A', 'F'] - b: 'B' - c: 'C' - d: 'D' - catch e - t.equal e.message, "no values provided for key `_`" - - t.end() diff --git a/test/stringify-fixtures.ts b/test/stringify-fixtures.ts new file mode 100644 index 0000000..e794a73 --- /dev/null +++ b/test/stringify-fixtures.ts @@ -0,0 +1,209 @@ +// tests to ensure that there are no regressions in stringify functionality + +import * as tape from "tape"; + +// @ts-ignore +import UrlPattern from "../src/url-pattern.ts"; + +tape("stringify", (t: tape.Test) => { + let pattern = new UrlPattern("/foo"); + t.equal("/foo", pattern.stringify()); + + pattern = new UrlPattern("/user/:userId/task/:taskId"); + t.equal("/user/10/task/52", pattern.stringify({ + taskId: "52", + userId: "10", + })); + + pattern = new UrlPattern("/user/:userId/task/:taskId"); + t.equal("/user/10/task/52", pattern.stringify({ + ignored: "ignored", + taskId: "52", + userId: "10", + })); + + pattern = new UrlPattern(".user.:userId.task.:taskId"); + t.equal(".user.10.task.52", pattern.stringify({ + taskId: "52", + userId: "10", + })); + + pattern = new UrlPattern("*/user/:userId"); + t.equal("/user/10", pattern.stringify({ + userId: "10", + })); + + pattern = new UrlPattern("*:prefix/user/:userId"); + t.equal("/school/10/user/10", pattern.stringify({ + prefix: "/school/10", + userId: "10", + })); + + pattern = new UrlPattern("*-user-:userId"); + t.equal("-user-10", pattern.stringify({ + userId: "10", + })); + + pattern = new UrlPattern("*:prefix-user-:userId"); + t.equal("-school-10-user-10", pattern.stringify({ + prefix: "-school-10", + userId: "10", + })); + + pattern = new UrlPattern("/admin*"); + t.equal("/admin", pattern.stringify({})); + + pattern = new UrlPattern("/admin*:suffix"); + t.equal("/admin/school/10/user/10", pattern.stringify({ + suffix: "/school/10/user/10", + })); + + pattern = new UrlPattern("/admin/*/user/*/tail"); + t.equal("/admin//user//tail", pattern.stringify({})); + + pattern = new UrlPattern("/admin/*:infix1/user/*:infix2/tail"); + t.equal("/admin/school/10/user/10/12/tail", pattern.stringify({ + infix1: "school/10", + infix2: "10/12", + })); + + pattern = new UrlPattern("/admin/*/user/:id/*/tail"); + t.equal("/admin//user/10//tail", pattern.stringify({ + id: "10", + })); + + pattern = new UrlPattern("/admin/*:infix1/user/:id/*:infix2/tail"); + t.equal("/admin/school/10/user/10/12/13/tail", pattern.stringify({ + id: "10", + infix1: "school/10", + infix2: "12/13", + })); + + pattern = new UrlPattern("/*/admin(/:path)"); + t.equal("//admin/baz", pattern.stringify({ + path: "baz", + })); + t.equal("//admin", pattern.stringify({})); + + pattern = new UrlPattern("/*:infix/admin(/:path)"); + t.equal("/foo/admin/baz", pattern.stringify({ + infix: "foo", + path: "baz", + })); + t.equal("/foo/admin", pattern.stringify({ infix: "foo" })); + + pattern = new UrlPattern("(/)"); + t.equal("", pattern.stringify()); + + pattern = new UrlPattern("/admin(/foo)/bar"); + t.equal("/admin/bar", pattern.stringify()); + + pattern = new UrlPattern("/admin(/:foo)/bar"); + t.equal("/admin/bar", pattern.stringify()); + t.equal("/admin/baz/bar", pattern.stringify({ foo: "baz" })); + +// pattern = new UrlPattern("/admin/(*/)foo"); +// t.equal("/admin/foo", pattern.stringify()); +// t.equal("/admin/baz/bar/biff/foo", pattern.stringify({ _: "baz/bar/biff" })); +// +// pattern = new UrlPattern("/v:major.:minor/*"); +// t.equal("/v1.2/resource/", pattern.stringify({ +// _: "resource/", +// major: "1", +// minor: "2", +// })); + + pattern = new UrlPattern("/v:major.:minor/*"); + t.equal("/v1.2/", pattern.stringify({ + major: "1", + minor: "2", + })); + + pattern = new UrlPattern("/v:major.:minor/*:suffix"); + t.equal("/v1.2/", pattern.stringify({ + major: "1", + minor: "2", + suffix: "", + })); + + pattern = new UrlPattern("/v:major.:minor/*:suffix"); + t.equal("/v1.2/resource/", pattern.stringify({ + major: "1", + minor: "2", + suffix: "resource/", + })); + + pattern = new UrlPattern("/:foo_bar"); + t.equal("/a_bar", pattern.stringify({ foo_bar: "a_bar" })); + t.equal("/a__bar", pattern.stringify({ foo_bar: "a__bar" })); + t.equal("/a-b-c-d__bar", pattern.stringify({ foo_bar: "a-b-c-d__bar" })); + t.equal("/a b%c-d__bar", pattern.stringify({ foo_bar: "a b%c-d__bar" })); + + pattern = new UrlPattern("((((a)b)c)d)"); + t.equal("", pattern.stringify()); + +// pattern = new UrlPattern("(:a-)1-:b(-2-:c-3-:d(-4-*-:a))"); +// t.equal("1-B", pattern.stringify({ b: "B" })); +// t.equal("A-1-B", pattern.stringify({ +// a: "A", +// b: "B", +// })); +// t.equal("A-1-B", pattern.stringify({ +// a: "A", +// b: "B", +// })); +// t.equal("A-1-B-2-C-3-D", pattern.stringify({ +// a: "A", +// b: "B", +// c: "C", +// d: "D", +// })); +// t.equal("A-1-B-2-C-3-D-4-E-F", pattern.stringify({ +// _: "E", +// a: ["A", "F"], +// b: "B", +// c: "C", +// d: "D", +// })); + + pattern = new UrlPattern("/user/:range"); + t.equal("/user/10-20", pattern.stringify({ range: "10-20" })); + + t.end(); +}); + +tape("stringify errors", (t: tape.Test) => { + let e; + t.plan(3); + + const pattern = new UrlPattern("(:a-)1-:b(-2-:c-3-:d(-4-*-:e))"); + + try { + pattern.stringify(); + } catch (error) { + e = error; + t.equal(e.message, "no value provided for name `b`"); + } + try { + pattern.stringify({ + a: "A", + b: "B", + c: "C", + }); + } catch (error1) { + e = error1; + t.equal(e.message, "no value provided for name `d`"); + } + try { + pattern.stringify({ + a: "A", + b: "B", + d: "D", + }); + } catch (error2) { + e = error2; + t.equal(e.message, "no value provided for name `c`"); + } + + t.end(); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..21870b4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES6", + "strict": true, + "noUnusedLocals": true, + "removeComments": true, + "preserveConstEnums": true, + "sourceMap": true, + "allowUnreachableCode": false, + "declaration": true, + "outDir": "dist" + }, + "include": [ + "src/*.ts" + ] +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..9740cb3 --- /dev/null +++ b/tslint.json @@ -0,0 +1,11 @@ +{ + "defaultSeverity": "error", + "extends": [ + "tslint:recommended" + ], + "jsRules": {}, + "rules": { + "max-classes-per-file": [true, 5] + }, + "rulesDirectory": [] +}