'use strict'; /** * @description * Default options to be passed to the router */ const defaultRouterOptions = { /** * @description * Default encoding function to use, this is the encodeUriComponent function by default * * @param decodedValue value to be encoded * @returns encoded value */ parameterValueEncoder: (decodedValue) => encodeURIComponent(decodedValue), /** * @description * Default decoding function to use, this is the decodeURIComponent function by default * * @param encodedValue value to be decoded * @returns decoded value */ parameterValueDecoder: (encodedValue) => decodeURIComponent(encodedValue), /** * Use `{` and `}` as a default for matching placeholders in the route templates. */ parameterPlaceholderRE: /\{(.*?)\}/gu, /** * Assume a maximum parameter value length of 20 */ maximumParameterValueLength: 20, }; function findCommonPrefixLength(stringLeft, stringRight) { const length = Math.min(stringLeft.length, stringRight.length); let index; for (index = 0; index < length; index++) { const charLeft = stringLeft.charAt(index); const charRight = stringRight.charAt(index); if (charLeft !== charRight) { break; } } return index; } /** * @description * This interface represents a node in the tree structure that holds all the node * for the routes */ class RouteNode { anchor; hasParameter; routeKey; children; constructor( /** * @description * suffix that comes after the parameter value (if any!) of the path */ anchor = "", /** * @description * does this node have a parameter value */ hasParameter = false, /** * @description * key that identifies the route, if this is a leaf node for the route */ routeKey = null, /** * @description * children that represent the rest of the path that needs to be matched */ children = new Array()) { this.anchor = anchor; this.hasParameter = hasParameter; this.routeKey = routeKey; this.children = children; } addChild(childNode) { this.children.push(childNode); } removeChild(childNode) { const childIndex = this.children.indexOf(childNode); this.children.splice(childIndex, 1); } countChildren() { return this.children.length; } insert(routeKey, templatePairs) { const routeParameterNames = templatePairs .map(([, parameterName]) => parameterName) .filter((parameterName) => parameterName); // eslint-disable-next-line @typescript-eslint/no-this-alias let currentNode = this; for (let index = 0; index < templatePairs.length; index++) { const [anchor, parameterName] = templatePairs[index]; const hasParameter = parameterName != null; const [commonPrefixLength, childNode] = currentNode.findSimilarChild(anchor, hasParameter); currentNode = currentNode.merge(childNode, anchor, hasParameter, index === templatePairs.length - 1 ? routeKey : null, routeParameterNames, commonPrefixLength); } return currentNode; } parse(path, maximumParameterValueLength) { const parameterValues = new Array(); if (this.hasParameter) { // we are matching a parameter value! If the path's length is 0, there is no match, because a parameter value should have at least length 1 if (path.length === 0) { return [null, []]; } // look for the anchor in the path (note: indexOf is probably the most expensive operation!) If the anchor is empty, match the remainder of the path const index = this.anchor.length === 0 ? path.length : path .substring(0, maximumParameterValueLength + this.anchor.length) .indexOf(this.anchor); if (index < 0) { return [null, []]; } // get the parameter value const value = path.substring(0, index); // remove the matches part from the path path = path.substring(index + this.anchor.length); // add value to parameters parameterValues.push(value); } else { // if this node does not represent a parameter we expect the path to start with the `anchor` if (!path.startsWith(this.anchor)) { // this node does not match the path return [null, []]; } // we successfully matches the node to the path, now remove the matched part from the path path = path.substring(this.anchor.length); } for (const childNode of this.children) { // find a route in every child node const [childRouteKey, childParameterValues] = childNode.parse(path, maximumParameterValueLength); // if a child node is matched, return that node instead of the current! So child nodes are matched first! if (childRouteKey != null) { return [childRouteKey, [...parameterValues, ...childParameterValues]]; } } // if the node had a route name and there is no path left to match against then we found a route if (this.routeKey != null && path.length === 0) { return [this.routeKey, parameterValues]; } // we did not found a route :-( return [null, []]; } merge(childNode, anchor, hasParameter, routeKey, routeParameterNames, commonPrefixLength) { if (childNode == null) { return this.mergeNew(anchor, hasParameter, routeKey); } const commonPrefix = childNode.anchor.substring(0, commonPrefixLength); if (childNode.anchor === anchor) { return this.mergeJoin(childNode, routeKey); } else if (childNode.anchor === commonPrefix) { return this.mergeAddToChild(childNode, anchor, hasParameter, routeKey, routeParameterNames, commonPrefixLength); } else if (anchor === commonPrefix) { return this.mergeAddToNew(childNode, anchor, hasParameter, routeKey, routeParameterNames, commonPrefixLength); } else { return this.mergeIntermediate(childNode, anchor, hasParameter, routeKey, routeParameterNames, commonPrefixLength); } } mergeNew(anchor, hasParameter, routeKey) { const newNode = new RouteNode(anchor, hasParameter, routeKey); this.addChild(newNode); this.children.sort((a, b) => a.compare(b)); return newNode; } mergeJoin(childNode, routeKey) { if (childNode.routeKey != null && routeKey != null) { throw new Error("ambiguous route"); } if (childNode.routeKey == null) { childNode.routeKey = routeKey; } return childNode; } mergeIntermediate(childNode, anchor, hasParameter, routeKey, routeParameterNames, commonPrefixLength) { this.removeChild(childNode); const newNode = new RouteNode(anchor.substring(commonPrefixLength), false, routeKey); childNode.anchor = childNode.anchor.substring(commonPrefixLength); childNode.hasParameter = false; const intermediateNode = new RouteNode(anchor.substring(0, commonPrefixLength), hasParameter); intermediateNode.addChild(childNode); intermediateNode.addChild(newNode); this.addChild(intermediateNode); this.children.sort((a, b) => a.compare(b)); intermediateNode.children.sort((a, b) => a.compare(b)); return newNode; } mergeAddToChild(childNode, anchor, hasParameter, routeKey, routeParameterNames, commonPrefixLength) { anchor = anchor.substring(commonPrefixLength); hasParameter = false; const [commonPrefixLength2, childNode2] = childNode.findSimilarChild(anchor, hasParameter); return childNode.merge(childNode2, anchor, hasParameter, routeKey, routeParameterNames, commonPrefixLength2); } mergeAddToNew(childNode, anchor, hasParameter, routeKey, routeParameterNames, commonPrefixLength) { const newNode = new RouteNode(anchor, hasParameter, routeKey); this.addChild(newNode); this.removeChild(childNode); childNode.anchor = childNode.anchor.substring(commonPrefixLength); childNode.hasParameter = false; newNode.addChild(childNode); this.children.sort((a, b) => a.compare(b)); newNode.children.sort((a, b) => a.compare(b)); return newNode; } findSimilarChild(anchor, hasParameter) { for (const childNode of this.children) { if (childNode.hasParameter !== hasParameter) { continue; } const commonPrefixLength = findCommonPrefixLength(anchor, childNode.anchor); if (commonPrefixLength === 0) { continue; } return [commonPrefixLength, childNode]; } return [0, null]; } compare(other) { if (this.anchor.length < other.anchor.length) return 1; if (this.anchor.length > other.anchor.length) return -1; if (!this.hasParameter && other.hasParameter) return -1; if (this.hasParameter && !other.hasParameter) return 1; if (this.anchor < other.anchor) return -1; if (this.anchor > other.anchor) return 1; return 0; } toJSON() { const json = { anchor: this.anchor, hasParameter: this.hasParameter, routeKey: this.routeKey, children: this.children.map((child) => child.toJSON()), }; return json; } static fromJSON(json) { const node = new RouteNode(json.anchor, json.hasParameter, json.routeKey, json.children.map((child) => RouteNode.fromJSON(child))); return node; } } /** * Take a route template and chops is in pieces! The first piece is a literal part of * the template. Then the name of a placeholder. Then a literal parts of the template again. * The first and the last elements are always literal strings taken from the template, * therefore the number of elements in the resulting iterable is always uneven! * * @param routeTemplate template to chop up * @param parameterPlaceholderRE regular expression to use when searching for parameter placeholders * @returns Iterable of strings, always an uneven number of elements. */ function* parseTemplateParts(routeTemplate, parameterPlaceholderRE) { if (!parameterPlaceholderRE.global) { throw new Error("regular expression needs to be global"); } let match; let offsetIndex = 0; while ((match = parameterPlaceholderRE.exec(routeTemplate)) != null) { yield routeTemplate.substring(offsetIndex, parameterPlaceholderRE.lastIndex - match[0].length); yield match[1]; offsetIndex = parameterPlaceholderRE.lastIndex; } yield routeTemplate.substring(offsetIndex); } function* parseTemplatePairs(routeTemplate, parameterPlaceholderRE) { const parts = parseTemplateParts(routeTemplate, parameterPlaceholderRE); let index = 0; let parameter = null; for (const part of parts) { if (index % 2 === 0) { yield [part, parameter]; } else { parameter = part; } index++; } } exports.RouterMode = void 0; (function (RouterMode) { RouterMode[RouterMode["Client"] = 2] = "Client"; RouterMode[RouterMode["Server"] = 4] = "Server"; RouterMode[RouterMode["Bidirectional"] = 6] = "Bidirectional"; })(exports.RouterMode || (exports.RouterMode = {})); /** * @description * This is the actual router that contains all routes and does the actual routing * * @example * ```typescript * const router = new Router(); * * router.insertRoute("all-products", "/product/all"); * router.insertRoute("product-detail", "/product/{id}"); * * // And now we can parse routes! * * { * const [routeKey, routeParameters] = router.parseRoute("/not-found"); * assert.equal(routeKey, null); * assert.deepEqual(routeParameters, {}); * } * * { * const [routeKey, routeParameters] = router.parseRoute("/product/all"); * assert.equal(routeKey, "all-products"); * assert.deepEqual(routeParameters, {}); * } * * { * const [routeKey, routeParameters] = router.parseRoute("/product/1"); * assert.equal(routeKey, "product-detail"); * assert.deepEqual(routeParameters, { id: "1" }); * } * * // And we can stringify routes * * { * const path = router.stringifyRoute( * "all-products", * }); * assert.equal(path, "/product/all"); * } * * { * const path = router.stringifyRoute( * "product-detail", * { id: "2" }, * ); * assert.equal(path, "/product/2"); * } * ``` */ class Router { mode; constructor(options = {}, mode = exports.RouterMode.Bidirectional) { this.mode = mode; this.options = { ...defaultRouterOptions, ...options, }; } options; rootNode = new RouteNode(); templatePairs = new Map(); /** * @description * Adds a new route * * @param routeKey name of the route * @param routeTemplate template for the route, als defines parameters */ insertRoute(routeKey, routeTemplate) { const templatePairs = [ ...parseTemplatePairs(routeTemplate, this.options.parameterPlaceholderRE), ]; if ((this.mode & exports.RouterMode.Client) > 0) { this.templatePairs.set(routeKey, templatePairs); } if ((this.mode & exports.RouterMode.Server) > 0) { this.rootNode.insert(routeKey, templatePairs); } return this; } /** * @description * Match the path against a known routes and parse the parameters in it * * @param path path to match * @returns tuple with the route name or null if no route found. Then the parameters */ parseRoute(path) { if ((this.mode & exports.RouterMode.Server) === 0) { throw new TypeError("Router needs to be in server mode to parse"); } const parameters = {}; const [routeKey, parameterValues] = this.rootNode.parse(path, this.options.maximumParameterValueLength); if (routeKey == null) { return [null, {}]; } const templatePairs = this.templatePairs.get(routeKey); if (templatePairs == null) { // this never happens return [null, {}]; } for (let index = 0; index < parameterValues.length; index++) { const [, parameterName] = templatePairs[index + 1]; if (parameterName == null) { // this never happens return [null, {}]; } const parameterValue = parameterValues[index]; parameters[parameterName] = this.options.parameterValueDecoder(parameterValue); } return [routeKey, parameters]; } /** * @description * Convert a route to a path string. * * @param routeKey route to stringify * @param routeParameters parameters to include in the path * @returns string representing the route or null if the route is not found by name */ stringifyRoute(routeKey, routeParameters = {}) { if ((this.mode & exports.RouterMode.Client) === 0) { throw new TypeError("Router needs to be in client mode to stringify"); } let result = ""; const templatePairs = this.templatePairs.get(routeKey); if (templatePairs == null) { return null; } for (let index = 0; index < templatePairs.length; index++) { const [parameterAnchor, parameterName] = templatePairs[index]; if (parameterName != null) { const parameterValue = routeParameters[parameterName]; result += this.options.parameterValueEncoder(parameterValue); } result += parameterAnchor; } return result; } saveToJson(mode = this.mode) { const rootNode = (this.mode & mode & exports.RouterMode.Server) > 0 ? this.rootNode.toJSON() : undefined; const templatePairs = (this.mode & mode & exports.RouterMode.Client) > 0 ? [...this.templatePairs] : undefined; return { rootNode, templatePairs, }; } loadFromJson(json) { this.mode = exports.RouterMode.Bidirectional; if (json.rootNode == null) { this.mode &= ~exports.RouterMode.Server; } else { this.rootNode = RouteNode.fromJSON(json.rootNode); } if (json.templatePairs == null) { this.mode &= ~exports.RouterMode.Client; } else { this.templatePairs = new Map(json.templatePairs); } return this; } } exports.Router = Router; exports.defaultRouterOptions = defaultRouterOptions; //# sourceMappingURL=main.cjs.map