├── package.json ├── examples ├── hash-route.html ├── hash-switch.html ├── pathname-route.html └── pathname-switch.html ├── LICENSE ├── hash.js ├── README.md ├── pathname.js ├── index.js └── vendor └── path-to-regexp └── index.js /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@aaronshaf/html-router", 3 | "description": "Drop-in router", 4 | "files": [ 5 | "index.js", 6 | "pathname.js", 7 | "hash.js", 8 | "vendor/path-to-regexp/index.js" 9 | ], 10 | "version": "1.1.2", 11 | "main": "index.js", 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/aaronshaf/html-router.git" 15 | }, 16 | "author": "Aaron Shafovaloff ", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/aaronshaf/html-router/issues" 20 | }, 21 | "homepage": "https://github.com/aaronshaf/html-router#readme" 22 | } 23 | -------------------------------------------------------------------------------- /examples/hash-route.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | html-router 5 | 6 | 11 | 12 | 13 | 14 | 15 | 16 |

Standalone hash routes

17 | 18 | 23 | 24 | 25 | 28 | 29 | 30 | 31 | 34 | 35 | 36 | 37 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /examples/hash-switch.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | html-router 5 | 6 | 11 | 12 | 13 | 14 | 15 | 16 |

Match first hash route

17 | 18 | 23 | 24 | 25 | 28 | 29 | 32 | 33 | 36 | 37 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /examples/pathname-route.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | html-router 5 | 6 | 11 | 12 | 13 | 14 | 15 | 16 |

Standalone pathname routes

17 | 18 | 32 | 33 | 34 |

Home

35 |
36 | 37 | 38 |

Foo

39 |
40 | 41 | 42 |

Bar

43 |
44 | 45 | 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Aaron Shafovaloff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/pathname-switch.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | html-router 5 | 6 | 11 | 12 | 13 | 14 | 15 | 16 |

Match first pathname route

17 | 18 | 32 | 33 | 34 | 37 | 38 | 41 | 42 | 45 | 46 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /hash.js: -------------------------------------------------------------------------------- 1 | import { Switch, Route } from "./index.js"; 2 | 3 | export class HashSwitch extends Switch { 4 | constructor() { 5 | super(); 6 | this.handleHashChange = this.handleHashChange.bind(this); 7 | this.handleHashChange(); 8 | } 9 | 10 | handleHashChange() { 11 | const path = location.hash.slice(1); 12 | this.updateMatch(path); 13 | } 14 | 15 | connectedCallback() { 16 | window.addEventListener("hashchange", this.handleHashChange); 17 | } 18 | 19 | disconnectedCallback() { 20 | window.removeEventListener("hashchange", this.handleHashChange); 21 | } 22 | } 23 | 24 | if (window.customElements.get("hash-switch") == null) { 25 | window.HashSwitch = HashSwitch; 26 | window.customElements.define("hash-switch", HashSwitch); 27 | } 28 | 29 | export class HashRoute extends Route { 30 | constructor() { 31 | super(); 32 | this.handleHashChange = this.handleHashChange.bind(this); 33 | this.handleHashChange(); 34 | } 35 | 36 | handleHashChange() { 37 | const path = location.hash.slice(1); 38 | this.updateMatch(path); 39 | } 40 | 41 | connectedCallback() { 42 | window.addEventListener("hashchange", this.handleHashChange); 43 | } 44 | 45 | disconnectedCallback() { 46 | window.removeEventListener("hashchange", this.handleHashChange); 47 | } 48 | } 49 | 50 | if (window.customElements.get("hash-route") == null) { 51 | window.HashRoute = HashRoute; 52 | window.customElements.define("hash-route", HashRoute); 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Drop-in router. 2 | 3 | ## Usage 4 | 5 | ### Hash router 6 | 7 | ```html 8 | 9 | ``` 10 | 11 | #### Render first match ([example](https://aaronshaf.github.io/html-router/examples/hash-switch.html)) 12 | 13 | ```html 14 | 19 | ``` 20 | 21 | ```html 22 | 23 | 26 | 27 | 30 | 31 | 34 | 35 | ``` 36 | 37 | #### Standalone routes ([example](https://aaronshaf.github.io/html-router/examples/hash-route.html)) 38 | 39 | ```html 40 | 45 | ``` 46 | 47 | ```html 48 | 49 |

Home

50 |
51 | 52 | 53 |

Foo

54 |
55 | 56 | 57 |

Bar

58 |
59 | ``` 60 | 61 | ### Pathname router 62 | 63 | ```html 64 | 65 | ``` 66 | 67 | #### Render first match ([example](https://aaronshaf.github.io/html-router/examples/pathname-switch.html)) 68 | 69 | ```html 70 | 81 | ``` 82 | 83 | ```html 84 | 85 | 88 | 89 | 92 | 93 | 96 | 97 | ``` 98 | 99 | #### Standalone routes ([example](https://aaronshaf.github.io/html-router/examples/pathname-route.html)) 100 | 101 | ```html 102 | 110 | ``` 111 | 112 | ```html 113 | 114 |

Foo

115 |
116 | 117 | 118 |

Bar

119 |
120 | ``` 121 | 122 | ## Access match params 123 | 124 | Custom elements that are children of a route or switch can access match data at `this.match`. 125 | 126 | ## Prevent flash of undefined content 127 | 128 | ```html 129 | 135 | ``` 136 | 137 | ## Web components polyfill 138 | 139 | ```html 140 | 141 | ``` 142 | 143 | ## See also 144 | 145 | * [element-router](https://github.com/filipbech/element-router) 146 | -------------------------------------------------------------------------------- /pathname.js: -------------------------------------------------------------------------------- 1 | import { Switch, Route } from "./index.js"; 2 | 3 | let isPushStatedObserved = false; 4 | 5 | // https://stackoverflow.com/a/25673911 6 | export const listenToPushState = () => { 7 | const _wr = type => { 8 | const orig = history[type]; 9 | return function() { 10 | const rv = orig.apply(this, arguments); 11 | const e = new Event(type); 12 | e.arguments = arguments; 13 | window.dispatchEvent(e); 14 | return rv; 15 | }; 16 | }; 17 | (history.pushState = _wr("pushState")), 18 | (history.replaceState = _wr("replaceState")); 19 | isPushStatedObserved = true; 20 | }; 21 | 22 | export class PathnameSwitch extends Switch { 23 | constructor() { 24 | super(); 25 | this.handleStateChange = this.handleStateChange.bind(this); 26 | this.handleStateChange(); 27 | } 28 | 29 | handleStateChange() { 30 | this.updateMatch(location.pathname); 31 | } 32 | 33 | connectedCallback() { 34 | if (isPushStatedObserved === false) { 35 | listenToPushState(); 36 | } 37 | 38 | window.addEventListener("popstate", this.handleStateChange); 39 | window.addEventListener("pushState", this.handleStateChange); 40 | } 41 | 42 | disconnectedCallback() { 43 | window.removeEventListener("popstate", this.handleStateChange); 44 | window.removeEventListener("pushState", this.handleStateChange); 45 | } 46 | } 47 | 48 | if (window.customElements.get("pathname-switch") == null) { 49 | window.PathnameSwitch = PathnameSwitch; 50 | window.customElements.define("pathname-switch", PathnameSwitch); 51 | } 52 | 53 | class PushStateLink extends HTMLElement { 54 | constructor() { 55 | super(); 56 | this.handleClick = this.handleClick.bind(this); 57 | } 58 | 59 | handleClick(event) { 60 | if (event.metaKey) { 61 | return true; 62 | } 63 | event.preventDefault(); 64 | const href = this.a.getAttribute("href"); 65 | window.history.pushState({}, "", href); 66 | } 67 | 68 | connectedCallback() { 69 | this.a = this.querySelector("a"); 70 | this.a.addEventListener("click", this.handleClick); 71 | } 72 | 73 | disconnectedCallback() { 74 | this.a.removeEventListener("click", this.handleClick); 75 | } 76 | } 77 | 78 | if (window.customElements.get("pushstate-link") == null) { 79 | window.PushStateLink = PushStateLink; 80 | window.customElements.define("pushstate-link", PushStateLink); 81 | } 82 | 83 | export class PathnameRoute extends Route { 84 | constructor() { 85 | super(); 86 | this.handleStateChange = this.handleStateChange.bind(this); 87 | this.handleStateChange(); 88 | } 89 | 90 | handleStateChange() { 91 | this.updateMatch(location.pathname); 92 | } 93 | 94 | connectedCallback() { 95 | if (isPushStatedObserved === false) { 96 | listenToPushState(); 97 | } 98 | 99 | window.addEventListener("popstate", this.handleStateChange); 100 | window.addEventListener("pushState", this.handleStateChange); 101 | } 102 | 103 | disconnectedCallback() { 104 | window.removeEventListener("popstate", this.handleStateChange); 105 | window.removeEventListener("pushState", this.handleStateChange); 106 | } 107 | } 108 | 109 | if (window.customElements.get("pathname-route") == null) { 110 | window.HashRoute = PathnameRoute; 111 | window.customElements.define("pathname-route", PathnameRoute); 112 | } 113 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import pathToRegexp from "./vendor/path-to-regexp/index.js"; 2 | 3 | const isElement = node => node.nodeType === Node.ELEMENT_NODE; 4 | 5 | const isCustomElement = node => 6 | node.tagName != null && node.tagName.includes("-"); 7 | 8 | const isTemplateElement = node => 9 | node.tagName != null && node.tagName.toUpperCase() === "TEMPLATE"; 10 | 11 | const isRouteNode = node => node.hasAttribute("data-path"); 12 | 13 | export class Switch extends HTMLElement { 14 | constructor() { 15 | super(); 16 | const shadowRoot = this.attachShadow({ mode: "open" }); 17 | const node = document.createElement("slot"); 18 | node.setAttribute("name", "matched"); 19 | shadowRoot.appendChild(node); 20 | this.lastPathname = null; 21 | this.importedNodes = []; 22 | this.updateMatch = this.updateMatch.bind(this); 23 | } 24 | 25 | updateMatch(pathname) { 26 | if (pathname === "") { 27 | pathname = "/"; 28 | } 29 | let matchFound = false; 30 | 31 | const routeNodes = Array.from(this.childNodes) 32 | .filter(isElement) 33 | .filter(isRouteNode); 34 | 35 | if (pathname !== this.lastPathname) { 36 | this.importedNodes.forEach(node => node.remove()); 37 | this.importedNodes = []; 38 | } 39 | 40 | routeNodes.forEach(async node => { 41 | const path = node.dataset.path; 42 | const keys = []; 43 | const re = pathToRegexp(path, keys); 44 | const match = re.exec(pathname); 45 | const params = getParams(keys, match); 46 | 47 | if (match != null && matchFound === false) { 48 | matchFound = true; 49 | 50 | if (isTemplateElement(node) && pathname !== this.lastPathname) { 51 | const clone = document.importNode(node.content, true); 52 | Array.from(clone.children).forEach(node => 53 | node.setAttribute("slot", "matched") 54 | ); 55 | this.importedNodes.push(...clone.children); 56 | insertAfter(clone, node); 57 | } 58 | 59 | await Promise.all( 60 | Array.from(node.childNodes) 61 | .concat(this.importedNodes) 62 | .filter(isCustomElement) 63 | .map(async node => { 64 | await customElements.whenDefined(node.tagName.toLowerCase()); 65 | node.match = { 66 | url: pathname, 67 | path, 68 | params 69 | }; 70 | }) 71 | ); 72 | if (isTemplateElement(node) == false) { 73 | node.setAttribute("slot", "matched"); 74 | } 75 | } else { 76 | node.removeAttribute("slot"); 77 | } 78 | }); 79 | 80 | this.lastPathname = pathname; 81 | } 82 | } 83 | 84 | export class Route extends HTMLElement { 85 | constructor() { 86 | super(); 87 | this.importedNodes = []; 88 | const shadowRoot = this.attachShadow({ mode: "open" }); 89 | const node = document.createElement("slot"); 90 | node.setAttribute("name", "unmatched"); 91 | shadowRoot.appendChild(node); 92 | this.updateMatch = this.updateMatch.bind(this); 93 | this.isMatched = false; 94 | } 95 | 96 | async updateMatch(pathname) { 97 | if (pathname === "") { 98 | pathname = "/"; 99 | } 100 | 101 | const path = this.getAttribute("path"); 102 | const keys = []; 103 | const re = pathToRegexp(path, keys); 104 | const match = re.exec(pathname); 105 | const params = getParams(keys, match); 106 | const isMatched = match != null; 107 | if (this.isMatched === isMatched) { 108 | return; 109 | } 110 | this.isMatched = isMatched; 111 | 112 | if (this.isMatched) { 113 | Array.from(this.childNodes) 114 | .filter(isTemplateElement) 115 | .forEach(templateNode => { 116 | const clone = document.importNode(templateNode.content, true); 117 | this.importedNodes.push(...clone.children); 118 | insertAfter(clone, templateNode); 119 | }); 120 | 121 | await Promise.all( 122 | Array.from(this.childNodes) 123 | .concat(this.importedNodes) 124 | .filter(isCustomElement) 125 | .map(async node => { 126 | await customElements.whenDefined(node.tagName.toLowerCase()); 127 | node.match = { 128 | url: pathname, 129 | path, 130 | params 131 | }; 132 | }) 133 | ); 134 | this.shadowRoot.firstChild.removeAttribute("name"); 135 | } else { 136 | this.importedNodes.forEach(node => node.remove()); 137 | this.importedNodes = []; 138 | this.shadowRoot.firstChild.setAttribute("name", "unmatched"); 139 | } 140 | } 141 | } 142 | 143 | function getParams(keys, match) { 144 | return match == null 145 | ? {} 146 | : match.slice(1).reduce((params, value, index) => { 147 | params[keys[index].name] = value; 148 | return params; 149 | }, {}); 150 | } 151 | 152 | function insertAfter(node, antecedent) { 153 | return antecedent.parentNode.insertBefore(node, antecedent.nextSibling); 154 | } 155 | -------------------------------------------------------------------------------- /vendor/path-to-regexp/index.js: -------------------------------------------------------------------------------- 1 | // https://github.com/synaptiko/path-to-regexp/blob/d24a1ab484859468a36e03c9f99b70c971e23c1a/index.mjs 2 | // License MIT 3 | 4 | /** 5 | * Default configs. 6 | */ 7 | const DEFAULT_DELIMITER = "/"; 8 | const DEFAULT_DELIMITERS = "./"; 9 | 10 | /** 11 | * The main path matching regexp utility. 12 | * 13 | * @type {RegExp} 14 | */ 15 | const PATH_REGEXP = new RegExp( 16 | [ 17 | // Match escaped characters that would otherwise appear in future matches. 18 | // This allows the user to escape special characters that won't transform. 19 | "(\\\\.)", 20 | // Match Express-style parameters and un-named parameters with a prefix 21 | // and optional suffixes. Matches appear as: 22 | // 23 | // "/:test(\\d+)?" => ["/", "test", "\d+", undefined, "?"] 24 | // "/route(\\d+)" => [undefined, undefined, undefined, "\d+", undefined] 25 | "(?:\\:(\\w+)(?:\\(((?:\\\\.|[^\\\\()])+)\\))?|\\(((?:\\\\.|[^\\\\()])+)\\))([+*?])?" 26 | ].join("|"), 27 | "g" 28 | ); 29 | 30 | /** 31 | * Parse a string for the raw tokens. 32 | * 33 | * @param {string} str 34 | * @param {Object=} options 35 | * @return {!Array} 36 | */ 37 | export function parse(str, options) { 38 | const tokens = []; 39 | let key = 0; 40 | let index = 0; 41 | let path = ""; 42 | const defaultDelimiter = (options && options.delimiter) || DEFAULT_DELIMITER; 43 | const delimiters = (options && options.delimiters) || DEFAULT_DELIMITERS; 44 | let pathEscaped = false; 45 | let res; 46 | 47 | while ((res = PATH_REGEXP.exec(str)) !== null) { 48 | const m = res[0]; 49 | const escaped = res[1]; 50 | const offset = res.index; 51 | path += str.slice(index, offset); 52 | index = offset + m.length; 53 | 54 | // Ignore already escaped sequences. 55 | if (escaped) { 56 | path += escaped[1]; 57 | pathEscaped = true; 58 | continue; 59 | } 60 | 61 | let prev = ""; 62 | const next = str[index]; 63 | const name = res[2]; 64 | const capture = res[3]; 65 | const group = res[4]; 66 | const modifier = res[5]; 67 | 68 | if (!pathEscaped && path.length) { 69 | const k = path.length - 1; 70 | 71 | if (delimiters.indexOf(path[k]) > -1) { 72 | prev = path[k]; 73 | path = path.slice(0, k); 74 | } 75 | } 76 | 77 | // Push the current path onto the tokens. 78 | if (path) { 79 | tokens.push(path); 80 | path = ""; 81 | pathEscaped = false; 82 | } 83 | 84 | const partial = prev !== "" && next !== undefined && next !== prev; 85 | const repeat = modifier === "+" || modifier === "*"; 86 | const optional = modifier === "?" || modifier === "*"; 87 | const delimiter = prev || defaultDelimiter; 88 | const pattern = capture || group; 89 | 90 | tokens.push({ 91 | name: name || key++, 92 | prefix: prev, 93 | delimiter, 94 | optional, 95 | repeat, 96 | partial, 97 | pattern: pattern 98 | ? escapeGroup(pattern) 99 | : "[^" + escapeString(delimiter) + "]+?" 100 | }); 101 | } 102 | 103 | // Push any remaining characters. 104 | if (path || index < str.length) { 105 | tokens.push(path + str.substr(index)); 106 | } 107 | 108 | return tokens; 109 | } 110 | 111 | /** 112 | * Compile a string to a template function for the path. 113 | * 114 | * @param {string} str 115 | * @param {Object=} options 116 | * @return {!function(Object=, Object=)} 117 | */ 118 | export function compile(str, options) { 119 | return tokensToFunction(parse(str, options)); 120 | } 121 | 122 | /** 123 | * Expose a method for transforming tokens into the path function. 124 | */ 125 | export function tokensToFunction(tokens) { 126 | // Compile all the tokens into regexps. 127 | const matches = new Array(tokens.length); 128 | 129 | // Compile all the patterns before compilation. 130 | for (let i = 0; i < tokens.length; i++) { 131 | if (typeof tokens[i] === "object") { 132 | matches[i] = new RegExp("^(?:" + tokens[i].pattern + ")$"); 133 | } 134 | } 135 | 136 | return function(data, options) { 137 | let path = ""; 138 | const encode = (options && options.encode) || encodeURIComponent; 139 | 140 | for (let i = 0; i < tokens.length; i++) { 141 | const token = tokens[i]; 142 | 143 | if (typeof token === "string") { 144 | path += token; 145 | continue; 146 | } 147 | 148 | const value = data ? data[token.name] : undefined; 149 | let segment; 150 | 151 | if (Array.isArray(value)) { 152 | if (!token.repeat) { 153 | throw new TypeError( 154 | 'Expected "' + token.name + '" to not repeat, but got array' 155 | ); 156 | } 157 | 158 | if (value.length === 0) { 159 | if (token.optional) continue; 160 | 161 | throw new TypeError('Expected "' + token.name + '" to not be empty'); 162 | } 163 | 164 | for (let j = 0; j < value.length; j++) { 165 | segment = encode(value[j]); 166 | 167 | if (!matches[i].test(segment)) { 168 | throw new TypeError( 169 | 'Expected all "' + 170 | token.name + 171 | '" to match "' + 172 | token.pattern + 173 | '"' 174 | ); 175 | } 176 | 177 | path += (j === 0 ? token.prefix : token.delimiter) + segment; 178 | } 179 | 180 | continue; 181 | } 182 | 183 | if ( 184 | typeof value === "string" || 185 | typeof value === "number" || 186 | typeof value === "boolean" 187 | ) { 188 | segment = encode(String(value)); 189 | 190 | if (!matches[i].test(segment)) { 191 | throw new TypeError( 192 | 'Expected "' + 193 | token.name + 194 | '" to match "' + 195 | token.pattern + 196 | '", but got "' + 197 | segment + 198 | '"' 199 | ); 200 | } 201 | 202 | path += token.prefix + segment; 203 | continue; 204 | } 205 | 206 | if (token.optional) { 207 | // Prepend partial segment prefixes. 208 | if (token.partial) path += token.prefix; 209 | 210 | continue; 211 | } 212 | 213 | throw new TypeError( 214 | 'Expected "' + 215 | token.name + 216 | '" to be ' + 217 | (token.repeat ? "an array" : "a string") 218 | ); 219 | } 220 | 221 | return path; 222 | }; 223 | } 224 | 225 | /** 226 | * Escape a regular expression string. 227 | * 228 | * @param {string} str 229 | * @return {string} 230 | */ 231 | function escapeString(str) { 232 | return str.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1"); 233 | } 234 | 235 | /** 236 | * Escape the capturing group by escaping special characters and meaning. 237 | * 238 | * @param {string} group 239 | * @return {string} 240 | */ 241 | function escapeGroup(group) { 242 | return group.replace(/([=!:$/()])/g, "\\$1"); 243 | } 244 | 245 | /** 246 | * Get the flags for a regexp from the options. 247 | * 248 | * @param {Object} options 249 | * @return {string} 250 | */ 251 | function flags(options) { 252 | return options && options.sensitive ? "" : "i"; 253 | } 254 | 255 | /** 256 | * Pull out keys from a regexp. 257 | * 258 | * @param {!RegExp} path 259 | * @param {Array=} keys 260 | * @return {!RegExp} 261 | */ 262 | function regexpToRegexp(path, keys) { 263 | if (!keys) return path; 264 | 265 | // Use a negative lookahead to match only capturing groups. 266 | const groups = path.source.match(/\((?!\?)/g); 267 | 268 | if (groups) { 269 | for (let i = 0; i < groups.length; i++) { 270 | keys.push({ 271 | name: i, 272 | prefix: null, 273 | delimiter: null, 274 | optional: false, 275 | repeat: false, 276 | partial: false, 277 | pattern: null 278 | }); 279 | } 280 | } 281 | 282 | return path; 283 | } 284 | 285 | /** 286 | * Transform an array into a regexp. 287 | * 288 | * @param {!Array} path 289 | * @param {Array=} keys 290 | * @param {Object=} options 291 | * @return {!RegExp} 292 | */ 293 | function arrayToRegexp(path, keys, options) { 294 | const parts = []; 295 | 296 | for (let i = 0; i < path.length; i++) { 297 | parts.push(pathToRegexp(path[i], keys, options).source); 298 | } 299 | 300 | return new RegExp("(?:" + parts.join("|") + ")", flags(options)); 301 | } 302 | 303 | /** 304 | * Create a path regexp from string input. 305 | * 306 | * @param {string} path 307 | * @param {Array=} keys 308 | * @param {Object=} options 309 | * @return {!RegExp} 310 | */ 311 | function stringToRegexp(path, keys, options) { 312 | return tokensToRegExp(parse(path, options), keys, options); 313 | } 314 | 315 | /** 316 | * Expose a function for taking tokens and returning a RegExp. 317 | * 318 | * @param {!Array} tokens 319 | * @param {Array=} keys 320 | * @param {Object=} options 321 | * @return {!RegExp} 322 | */ 323 | export function tokensToRegExp(tokens, keys, options) { 324 | options = options || {}; 325 | 326 | const strict = options.strict; 327 | const end = options.end !== false; 328 | const delimiter = escapeString(options.delimiter || DEFAULT_DELIMITER); 329 | const delimiters = options.delimiters || DEFAULT_DELIMITERS; 330 | const endsWith = [] 331 | .concat(options.endsWith || []) 332 | .map(escapeString) 333 | .concat("$") 334 | .join("|"); 335 | let route = ""; 336 | let isEndDelimited = false; 337 | 338 | // Iterate over the tokens and create our regexp string. 339 | for (let i = 0; i < tokens.length; i++) { 340 | const token = tokens[i]; 341 | 342 | if (typeof token === "string") { 343 | route += escapeString(token); 344 | isEndDelimited = 345 | i === tokens.length - 1 && 346 | delimiters.indexOf(token[token.length - 1]) > -1; 347 | } else { 348 | const prefix = escapeString(token.prefix); 349 | const capture = token.repeat 350 | ? "(?:" + 351 | token.pattern + 352 | ")(?:" + 353 | prefix + 354 | "(?:" + 355 | token.pattern + 356 | "))*" 357 | : token.pattern; 358 | 359 | if (keys) keys.push(token); 360 | 361 | if (token.optional) { 362 | if (token.partial) { 363 | route += prefix + "(" + capture + ")?"; 364 | } else { 365 | route += "(?:" + prefix + "(" + capture + "))?"; 366 | } 367 | } else { 368 | route += prefix + "(" + capture + ")"; 369 | } 370 | } 371 | } 372 | 373 | if (end) { 374 | if (!strict) route += "(?:" + delimiter + ")?"; 375 | 376 | route += endsWith === "$" ? "$" : "(?=" + endsWith + ")"; 377 | } else { 378 | if (!strict) route += "(?:" + delimiter + "(?=" + endsWith + "))?"; 379 | if (!isEndDelimited) route += "(?=" + delimiter + "|" + endsWith + ")"; 380 | } 381 | 382 | return new RegExp("^" + route, flags(options)); 383 | } 384 | 385 | /** 386 | * Normalize the given path string, returning a regular expression. 387 | * 388 | * An empty array can be passed in for the keys, which will hold the 389 | * placeholder key descriptions. For example, using `/user/:id`, `keys` will 390 | * contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`. 391 | * 392 | * @param {(string|RegExp|Array)} path 393 | * @param {Array=} keys 394 | * @param {Object=} options 395 | * @return {!RegExp} 396 | */ 397 | export default function pathToRegexp(path, keys, options) { 398 | if (path instanceof RegExp) { 399 | return regexpToRegexp(path, keys); 400 | } 401 | 402 | if (Array.isArray(path)) { 403 | return arrayToRegexp(/** @type {!Array} */ (path), keys, options); 404 | } 405 | 406 | return stringToRegexp(/** @type {string} */ (path), keys, options); 407 | } 408 | --------------------------------------------------------------------------------