UNPKG

@riogz/router

Version:

A simple, lightweight, powerful, view-agnostic, modular and extensible router

1,326 lines (1,310 loc) 140 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var transitionPath = require('@riogz/router-transition-path'); /** * Default router configuration options. * * These defaults provide sensible behavior for most use cases: * - Standard trailing slash handling * - Default query parameter processing * - Automatic cleanup of unused route guards * - Strong route matching for better performance * - Path rewriting on successful matches * - Case-sensitive route matching disabled * - Default URL parameter encoding */ const defaultOptions$1 = { trailingSlashMode: 'default', queryParamsMode: 'default', strictTrailingSlash: false, autoCleanUp: true, allowNotFound: false, strongMatching: true, rewritePathOnMatch: true, caseSensitive: false, urlParamsEncoding: 'default' }; /** * Enhances a router with configuration options management. * * This module provides functionality to: * - Set default router options * - Override specific options * - Retrieve current configuration * - Update options at runtime * * Options control various aspects of router behavior including: * - URL parsing and generation * - Route matching behavior * - Query parameter handling * - Trailing slash processing * - Case sensitivity * - Cleanup behavior * * @template Dependencies - Type of dependencies available in the router * @param options - Partial options object to override defaults * @returns Function that enhances a router with options management * * @example * ```typescript * const router = createRouter(routes, { * trailingSlashMode: 'never', * caseSensitive: true, * defaultRoute: 'home', * defaultParams: { lang: 'en' } * }) * * // Update options at runtime * router.setOption('allowNotFound', true) * * // Get current options * const currentOptions = router.getOptions() * ``` */ function withOptions(options) { return (router) => { const routerOptions = Object.assign(Object.assign({}, defaultOptions$1), options); /** * Get the current router options configuration. * * @returns Complete options object with all settings * * @example * ```typescript * const options = router.getOptions() * console.log('Trailing slash mode:', options.trailingSlashMode) * console.log('Default route:', options.defaultRoute) * ``` */ router.getOptions = () => routerOptions; /** * Set a specific router option value. * * @param option - Name of the option to set * @param value - New value for the option * @returns Router instance for chaining * * @example * ```typescript * router * .setOption('caseSensitive', true) * .setOption('allowNotFound', true) * .setOption('defaultRoute', 'dashboard') * ``` */ router.setOption = (option, value) => { routerOptions[option] = value; return router; }; return router; }; } const makeOptions = (opts = {}) => ({ arrayFormat: opts.arrayFormat || 'none', booleanFormat: opts.booleanFormat || 'none', nullFormat: opts.nullFormat || 'default' }); const encodeValue = (value) => encodeURIComponent(value); const decodeValue = (value) => decodeURIComponent(value.replace(/\+/g, ' ')); const encodeBoolean = (name, value, opts) => { if (opts.booleanFormat === 'empty-true' && value) { return name; } let encodedValue; if (opts.booleanFormat === 'unicode') { encodedValue = value ? '✓' : '✗'; } else { encodedValue = value.toString(); } return `${name}=${encodedValue}`; }; const encodeNull = (name, opts) => { if (opts.nullFormat === 'hidden') { return ''; } if (opts.nullFormat === 'string') { return `${name}=null`; } return name; }; const getNameEncoder = (opts) => { if (opts.arrayFormat === 'index') { return (name, index) => `${name}[${index}]`; } if (opts.arrayFormat === 'brackets') { return (name) => `${name}[]`; } return (name) => name; }; const encodeArray = (name, arr, opts) => { const encodeName = getNameEncoder(opts); return arr .map((val, index) => `${encodeName(name, index)}=${encodeValue(val)}`) .join('&'); }; const encode = (name, value, opts) => { const encodedName = encodeValue(name); if (value === null) { return encodeNull(encodedName, opts); } if (typeof value === 'boolean') { return encodeBoolean(encodedName, value, opts); } if (Array.isArray(value)) { return encodeArray(encodedName, value, opts); } return `${encodedName}=${encodeValue(value)}`; }; const decode = (value, opts) => { if (value === undefined) { return opts.booleanFormat === 'empty-true' ? true : null; } if (opts.booleanFormat === 'string') { if (value === 'true') { return true; } if (value === 'false') { return false; } } if (opts.nullFormat === 'string') { if (value === 'null') { return null; } } const decodedValue = decodeValue(value); if (opts.booleanFormat === 'unicode') { if (decodedValue === '✓') { return true; } if (decodedValue === '✗') { return false; } } return decodedValue; }; const getSearch$1 = (path) => { const pos = path.indexOf('?'); if (pos === -1) { return path; } return path.slice(pos + 1); }; const isSerialisable = (val) => val !== undefined; const parseName = (name) => { const bracketPosition = name.indexOf('['); const hasBrackets = bracketPosition !== -1; return { hasBrackets, name: hasBrackets ? name.slice(0, bracketPosition) : name }; }; /** * Parse a querystring and return an object of parameters */ const parse = (path, opts) => { const options = makeOptions(opts); return getSearch$1(path) .split('&') .reduce((params, param) => { const [rawName, value] = param.split('='); const { hasBrackets, name } = parseName(rawName); const decodedName = decodeValue(name); const currentValue = params[name]; const decodedValue = decode(value, options); if (currentValue === undefined) { params[decodedName] = hasBrackets ? [decodedValue] : decodedValue; } else { params[decodedName] = (Array.isArray(currentValue) ? currentValue : [currentValue]).concat(decodedValue); } return params; }, {}); }; /** * Build a querystring from an object of parameters */ const build = (params, opts) => { const options = makeOptions(opts); return Object.keys(params) .filter(paramName => isSerialisable(params[paramName])) .map(paramName => encode(paramName, params[paramName], options)) .filter(Boolean) .join('&'); }; /** * Remove a list of parameters from a querystring */ const omit = (path, paramsToOmit, opts) => { const options = makeOptions(opts); const searchPart = getSearch$1(path); if (searchPart === '') { return { querystring: '', removedParams: {} }; } const [kept, removed] = path.split('&').reduce(([left, right], chunk) => { const rawName = chunk.split('=')[0]; const { name } = parseName(rawName); return paramsToOmit.indexOf(name) === -1 ? [left.concat(chunk), right] : [left, right.concat(chunk)]; }, [[], []]); return { querystring: kept.join('&'), removedParams: parse(removed.join('&'), options) }; }; /** * We encode using encodeURIComponent but we want to * preserver certain characters which are commonly used * (sub delimiters and ':') * * https://www.ietf.org/rfc/rfc3986.txt * * reserved = gen-delims / sub-delims * * gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@" * * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" */ const excludeSubDelimiters = /[^!$'()*+,;|:]/g; const encodeURIComponentExcludingSubDelims = (segment) => { try { return segment.replace(excludeSubDelimiters, match => { try { return encodeURIComponent(match); } catch (error) { // If encodeURIComponent fails (e.g., with malformed Unicode), // return the original character to avoid breaking the entire operation return match; } }); } catch (error) { // If the entire operation fails, fall back to basic encoding // that preserves Unicode characters try { return encodeURI(segment); } catch (fallbackError) { // Last resort: return the original segment return segment; } } }; const encodingMethods = { default: encodeURIComponentExcludingSubDelims, uri: encodeURI, uriComponent: encodeURIComponent, none: val => val, legacy: encodeURI }; const decodingMethods = { default: decodeURIComponent, uri: decodeURI, uriComponent: decodeURIComponent, none: val => val, legacy: decodeURIComponent }; const encodeParam = (param, encoding, isSpatParam) => { const encoder = encodingMethods[encoding] || encodeURIComponentExcludingSubDelims; if (isSpatParam) { return String(param) .split('/') .map(encoder) .join('/'); } return encoder(String(param)); }; const decodeParam = (param, encoding) => (decodingMethods[encoding] || decodeURIComponent)(param); const defaultOrConstrained = (match) => '(' + (match ? match.replace(/(^<|>$)/g, '') : "[a-zA-Z0-9-_.~%':|=+\\*@$]+") + ')'; const rules = [ { name: 'url-parameter', pattern: /^:([a-zA-Z0-9-_]*[a-zA-Z0-9]{1})(<(.+?)>)?/, regex: (match) => new RegExp(defaultOrConstrained(match[2])) }, { name: 'url-parameter-splat', pattern: /^\*([a-zA-Z0-9-_]*[a-zA-Z0-9]{1})/, regex: /([^?]*)/ }, { name: 'url-parameter-matrix', pattern: /^;([a-zA-Z0-9-_]*[a-zA-Z0-9]{1})(<(.+?)>)?/, regex: (match) => new RegExp(';' + match[1] + '=' + defaultOrConstrained(match[2])) }, { name: 'query-parameter', pattern: /^(?:\?|&)(?::)?([a-zA-Z0-9-_]*[a-zA-Z0-9]{1})/ }, { name: 'delimiter', pattern: /^(\/|\?)/, regex: (match) => new RegExp('\\' + match[0]) }, { name: 'sub-delimiter', pattern: /^(!|&|-|_|\.|;)/, regex: (match) => new RegExp(match[0]) }, { name: 'fragment', pattern: /^([0-9a-zA-Z]+)/, regex: (match) => new RegExp(match[0]) } ]; const tokenise = (str, tokens = []) => { // Look for a matching rule const matched = rules.some(rule => { const match = str.match(rule.pattern); if (!match) { return false; } tokens.push({ type: rule.name, match: match[0], val: match.slice(1, 2), otherVal: match.slice(2), regex: rule.regex instanceof Function ? rule.regex(match) : rule.regex }); if (match[0].length < str.length) { tokens = tokenise(str.substr(match[0].length), tokens); } return true; }); // If no rules matched, throw an error (possible malformed path) if (!matched) { throw new Error(`Could not parse path '${str}'`); } return tokens; }; const exists = (val) => val !== undefined && val !== null; const optTrailingSlash = (source, strictTrailingSlash) => { if (strictTrailingSlash) { return source; } if (source === '\\/') { return source; } return source.replace(/\\\/$/, '') + '(?:\\/)?'; }; const upToDelimiter = (source, delimiter) => { if (!delimiter) { return source; } return /(\/)$/.test(source) ? source : source + '(\\/|\\?|\\.|;|$)'; }; const appendQueryParam = (params, param, val = '') => { const existingVal = params[param]; if (existingVal === undefined) { params[param] = val; } else { params[param] = Array.isArray(existingVal) ? existingVal.concat(val) : [existingVal, val]; } return params; }; const defaultOptions = { urlParamsEncoding: 'default' }; class Path { static createPath(path, options) { return new Path(path, options); } constructor(path, options) { if (!path) { throw new Error('Missing path in Path constructor'); } this.path = path; this.options = Object.assign(Object.assign({}, defaultOptions), options); this.tokens = tokenise(path); this.hasUrlParams = this.tokens.filter(t => /^url-parameter/.test(t.type)).length > 0; this.hasSpatParam = this.tokens.filter(t => /splat$/.test(t.type)).length > 0; this.hasMatrixParams = this.tokens.filter(t => /matrix$/.test(t.type)).length > 0; this.hasQueryParams = this.tokens.filter(t => /^query-parameter/.test(t.type)).length > 0; // Extract named parameters from tokens this.spatParams = this.getParams('url-parameter-splat'); this.urlParams = this.getParams(/^url-parameter/); // Query params this.queryParams = this.getParams('query-parameter'); // All params this.params = this.urlParams.concat(this.queryParams); // Check if hasQueryParams // Regular expressions for url part only (full and partial match) this.source = this.tokens .filter(t => t.regex !== undefined) .map(t => t.regex.source) .join(''); } isQueryParam(name) { return this.queryParams.indexOf(name) !== -1; } isSpatParam(name) { return this.spatParams.indexOf(name) !== -1; } test(path, opts) { const options = Object.assign(Object.assign({ caseSensitive: false, strictTrailingSlash: false }, this.options), opts); // trailingSlash: falsy => non optional, truthy => optional const source = optTrailingSlash(this.source, options.strictTrailingSlash); // Check if exact match const match = this.urlTest(path, source + (this.hasQueryParams ? '(\\?.*$|$)' : '$'), options.caseSensitive, options.urlParamsEncoding); // If no match, or no query params, no need to go further if (!match || !this.hasQueryParams) { return match; } // Extract query params const queryParams = parse(path, options.queryParams); const unexpectedQueryParams = Object.keys(queryParams).filter(p => !this.isQueryParam(p)); if (unexpectedQueryParams.length === 0) { // Extend url match Object.keys(queryParams).forEach( // @ts-ignore: Dynamic property assignment needed for query params p => (match[p] = queryParams[p])); return match; } return null; } partialTest(path, opts) { const options = Object.assign(Object.assign({ caseSensitive: false, delimited: true }, this.options), opts); // Check if partial match (start of given path matches regex) // trailingSlash: falsy => non optional, truthy => optional const source = upToDelimiter(this.source, options.delimited); const match = this.urlTest(path, source, options.caseSensitive, options.urlParamsEncoding); if (!match) { return match; } if (!this.hasQueryParams) { return match; } const queryParams = parse(path, options.queryParams); Object.keys(queryParams) .filter(p => this.isQueryParam(p)) .forEach(p => appendQueryParam(match, p, queryParams[p])); return match; } build(params = {}, opts) { const options = Object.assign(Object.assign({ ignoreConstraints: false, ignoreSearch: false, queryParams: {} }, this.options), opts); const encodedUrlParams = Object.keys(params) .filter(p => !this.isQueryParam(p)) .reduce((acc, key) => { if (!exists(params[key])) { return acc; } const val = params[key]; const isSpatParam = this.isSpatParam(key); if (typeof val === 'boolean') { acc[key] = val; } else if (Array.isArray(val)) { acc[key] = val.map(v => encodeParam(v, options.urlParamsEncoding, isSpatParam)); } else { acc[key] = encodeParam(val, options.urlParamsEncoding, isSpatParam); } return acc; }, {}); // Check all params are provided (not search parameters which are optional) if (this.urlParams.some(p => !exists(params[p]))) { const missingParameters = this.urlParams.filter(p => !exists(params[p])); throw new Error("Cannot build path: '" + this.path + "' requires missing parameters { " + missingParameters.join(', ') + ' }'); } // Check constraints if (!options.ignoreConstraints) { const constraintsPassed = this.tokens .filter(t => /^url-parameter/.test(t.type) && !/-splat$/.test(t.type)) .every(t => new RegExp('^' + defaultOrConstrained(t.otherVal[0]) + '$').test(encodedUrlParams[t.val])); if (!constraintsPassed) { throw new Error(`Some parameters of '${this.path}' are of invalid format`); } } const base = this.tokens .filter(t => /^query-parameter/.test(t.type) === false) .map(t => { if (t.type === 'url-parameter-matrix') { return `;${t.val}=${encodedUrlParams[t.val[0]]}`; } return /^url-parameter/.test(t.type) ? encodedUrlParams[t.val[0]] : t.match; }) .join(''); if (options.ignoreSearch) { return base; } const searchParams = this.queryParams .filter(p => Object.keys(params).indexOf(p) !== -1) .reduce((sparams, paramName) => { sparams[paramName] = params[paramName]; return sparams; }, {}); const searchPart = build(searchParams, options.queryParams); return searchPart ? base + '?' + searchPart : base; } getParams(type) { const predicate = type instanceof RegExp ? (t) => type.test(t.type) : (t) => t.type === type; return this.tokens.filter(predicate).map(t => t.val[0]); } urlTest(path, source, caseSensitive, urlParamsEncoding) { const regex = new RegExp('^' + source, caseSensitive ? '' : 'i'); const match = path.match(regex); if (!match) { return null; } else if (!this.urlParams.length) { return {}; } // Reduce named params to key-value pairs return match .slice(1, this.urlParams.length + 1) .reduce((params, m, i) => { params[this.urlParams[i]] = decodeParam(m, urlParamsEncoding); return params; }, {}); } } const getMetaFromSegments = (segments) => { let accName = ''; return segments.reduce((meta, segment) => { var _a, _b, _c, _d; const urlParams = (_b = (_a = segment.parser) === null || _a === void 0 ? void 0 : _a.urlParams.reduce((params, p) => { params[p] = 'url'; return params; }, {})) !== null && _b !== void 0 ? _b : {}; const allParams = (_d = (_c = segment.parser) === null || _c === void 0 ? void 0 : _c.queryParams.reduce((params, p) => { params[p] = 'query'; return params; }, urlParams)) !== null && _d !== void 0 ? _d : {}; if (segment.name !== undefined) { accName = accName ? accName + '.' + segment.name : segment.name; meta[accName] = allParams; } return meta; }, {}); }; const buildStateFromMatch = (match) => { if (!match || !match.segments || !match.segments.length) { return null; } const name = match.segments .map(segment => segment.name) .filter(name => name) .join('.'); const params = match.params; return { name, params, meta: getMetaFromSegments(match.segments) }; }; const buildPathFromSegments = (segments, params = {}, options = {}) => { const { queryParamsMode = 'default', trailingSlashMode = 'default' } = options; const searchParams = []; const nonSearchParams = []; for (const segment of segments) { const { parser } = segment; if (parser) { searchParams.push(...parser.queryParams); nonSearchParams.push(...parser.urlParams); nonSearchParams.push(...parser.spatParams); } } if (queryParamsMode === 'loose') { const extraParams = Object.keys(params).reduce((acc, p) => searchParams.indexOf(p) === -1 && nonSearchParams.indexOf(p) === -1 ? acc.concat(p) : acc, []); searchParams.push(...extraParams); } const searchParamsObject = searchParams.reduce((acc, paramName) => { if (Object.keys(params).indexOf(paramName) !== -1) { acc[paramName] = params[paramName]; } return acc; }, {}); const searchPart = build(searchParamsObject, options.queryParams); const path = segments .reduce((path, segment) => { var _a, _b; const segmentPath = (_b = (_a = segment.parser) === null || _a === void 0 ? void 0 : _a.build(params, { ignoreSearch: true, queryParams: options.queryParams, urlParamsEncoding: options.urlParamsEncoding })) !== null && _b !== void 0 ? _b : ''; return segment.absolute ? segmentPath : path + segmentPath; }, '') // remove repeated slashes .replace(/\/\/{1,}/g, '/'); let finalPath = path; if (trailingSlashMode === 'always') { finalPath = /\/$/.test(path) ? path : `${path}/`; } else if (trailingSlashMode === 'never' && path !== '/') { finalPath = /\/$/.test(path) ? path.slice(0, -1) : path; } return finalPath + (searchPart ? '?' + searchPart : ''); }; const getPathFromSegments = (segments) => segments ? segments.map(segment => segment.path).join('') : null; const getPath = (path) => path.split('?')[0]; const getSearch = (path) => path.split('?')[1] || ''; const matchChildren = (nodes, pathSegment, currentMatch, options = {}, consumedBefore) => { const { queryParamsMode = 'default', strictTrailingSlash = false, strongMatching = true, caseSensitive = false } = options; const isRoot = nodes.length === 1 && nodes[0].name === ''; // for (child of node.children) { for (const child of nodes) { // Partially match path let match = null; let remainingPath; let segment = pathSegment; if (consumedBefore === '/' && child.path === '/') { // when we encounter repeating slashes we add the slash // back to the URL to make it de facto pathless segment = '/' + pathSegment; } if (!child.children.length) { match = child.parser.test(segment, { caseSensitive, strictTrailingSlash, queryParams: options.queryParams, urlParamsEncoding: options.urlParamsEncoding }); } if (!match) { match = child.parser.partialTest(segment, { delimited: strongMatching, caseSensitive, queryParams: options.queryParams, urlParamsEncoding: options.urlParamsEncoding }); } if (match) { // Remove consumed segment from path let consumedPath = child.parser.build(match, { ignoreSearch: true, urlParamsEncoding: options.urlParamsEncoding }); if (!strictTrailingSlash && !child.children.length) { consumedPath = consumedPath.replace(/\/$/, ''); } // Can't create a regexp from the path because it might contain a // regexp character. if (segment.toLowerCase().indexOf(consumedPath.toLowerCase()) === 0) { remainingPath = segment.slice(consumedPath.length); } else { remainingPath = segment; } if (!strictTrailingSlash && !child.children.length) { remainingPath = remainingPath.replace(/^\/\?/, '?'); } const { querystring } = omit(getSearch(segment.replace(consumedPath, '')), child.parser.queryParams, options.queryParams); remainingPath = getPath(remainingPath) + (querystring ? `?${querystring}` : ''); if (!strictTrailingSlash && !isRoot && remainingPath === '/' && !/\/$/.test(consumedPath)) { remainingPath = ''; } currentMatch.segments.push(child); Object.keys(match).forEach(param => (currentMatch.params[param] = match[param])); if (!isRoot && !remainingPath.length) { // fully matched return currentMatch; } if (!isRoot && queryParamsMode !== 'strict' && remainingPath.indexOf('?') === 0) { // unmatched queryParams in non strict mode const remainingQueryParams = parse(remainingPath.slice(1), options.queryParams); Object.keys(remainingQueryParams).forEach(name => (currentMatch.params[name] = remainingQueryParams[name])); return currentMatch; } // Continue matching on non absolute children const children = child.getNonAbsoluteChildren(); // If no children to match against but unmatched path left if (!children.length) { return null; } // Else: remaining path and children return matchChildren(children, remainingPath, currentMatch, options, consumedPath); } } return null; }; function sortChildren(children) { const originalChildren = children.slice(0); return children.sort(sortPredicate(originalChildren)); } const sortPredicate = (originalChildren) => (left, right) => { var _a, _b, _c, _d, _e, _f; const leftPath = left.path .replace(/<.*?>/g, '') .split('?')[0] .replace(/(.+)\/$/, '$1'); const rightPath = right.path .replace(/<.*?>/g, '') .split('?')[0] .replace(/(.+)\/$/, '$1'); // '/' last if (leftPath === '/') { return 1; } if (rightPath === '/') { return -1; } // Spat params last if ((_a = left.parser) === null || _a === void 0 ? void 0 : _a.hasSpatParam) { return 1; } if ((_b = right.parser) === null || _b === void 0 ? void 0 : _b.hasSpatParam) { return -1; } // No spat, number of segments (less segments last) const leftSegments = (leftPath.match(/\//g) || []).length; const rightSegments = (rightPath.match(/\//g) || []).length; if (leftSegments < rightSegments) { return 1; } if (leftSegments > rightSegments) { return -1; } // Same number of segments, number of URL params ascending const leftParamsCount = (_d = (_c = left.parser) === null || _c === void 0 ? void 0 : _c.urlParams.length) !== null && _d !== void 0 ? _d : 0; const rightParamsCount = (_f = (_e = right.parser) === null || _e === void 0 ? void 0 : _e.urlParams.length) !== null && _f !== void 0 ? _f : 0; if (leftParamsCount < rightParamsCount) { return -1; } if (leftParamsCount > rightParamsCount) { return 1; } // Same number of segments and params, last segment length descending const leftParamLength = (leftPath.split('/').slice(-1)[0] || '').length; const rightParamLength = (rightPath.split('/').slice(-1)[0] || '').length; if (leftParamLength < rightParamLength) { return 1; } if (leftParamLength > rightParamLength) { return -1; } // Same last segment length, preserve definition order. Note that we // cannot just return 0, as sort is not guaranteed to be a stable sort. return originalChildren.indexOf(left) - originalChildren.indexOf(right); }; class RouteNode { constructor(name = '', path = '', childRoutes = [], options = {}) { this.name = name; this.absolute = /^~/.test(path); this.path = this.absolute ? path.slice(1) : path; this.parser = this.path ? new Path(this.path) : null; this.children = []; this.parent = options.parent; this.checkParents(); this.add(childRoutes, options.onAdd, options.finalSort ? false : options.sort !== false); if (options.finalSort) { this.sortDescendants(); } return this; } getParentSegments(segments = []) { return this.parent && this.parent.parser ? this.parent.getParentSegments(segments.concat(this.parent)) : segments.reverse(); } setParent(parent) { this.parent = parent; this.checkParents(); } setPath(path = '') { this.path = path; this.parser = path ? new Path(path) : null; } add(route, cb, sort = true) { if (route === undefined || route === null) { return this; } if (route instanceof Array) { route.forEach(r => this.add(r, cb, sort)); return this; } if (!(route instanceof RouteNode) && !(route instanceof Object)) { throw new Error('RouteNode.add() expects routes to be an Object or an instance of RouteNode.'); } else if (route instanceof RouteNode) { route.setParent(this); this.addRouteNode(route, sort); } else { if (!route.name || !route.path) { throw new Error('RouteNode.add() expects routes to have a name and a path defined.'); } const routeNode = new RouteNode(route.name, route.path, [], { finalSort: false, onAdd: cb, parent: this, sort }); for (const key in route) { if (Object.prototype.hasOwnProperty.call(route, key)) { if (!['name', 'path', 'children'].includes(key)) { routeNode[key] = route[key]; } } } if (route.children && route.children.length > 0) { routeNode.add(route.children, cb, sort); } const fullName = routeNode .getParentSegments([routeNode]) .map(_ => _.name) .join('.'); if (cb) { cb(Object.assign(Object.assign({}, route), { name: fullName })); } this.addRouteNode(routeNode, sort); } return this; } addNode(name, path) { this.add(new RouteNode(name, path)); return this; } /** * Removes a direct child RouteNode by its name. * If the name is a composite (e.g., 'parent.child'), it will attempt to remove 'parent' from this node's children. * * @param name The name of the child node to remove. * @returns True if the child node was found and removed, false otherwise. */ removeNode(name) { const targetName = name.split('.')[0]; const initialChildrenCount = this.children.length; this.children = this.children.filter(child => child.name !== targetName); return this.children.length < initialChildrenCount; } getPath(routeName) { const segmentsByName = this.getSegmentsByName(routeName); return segmentsByName ? getPathFromSegments(segmentsByName) : null; } getNonAbsoluteChildren() { return this.children.filter(child => !child.absolute); } sortChildren() { if (this.children.length) { sortChildren(this.children); } } sortDescendants() { this.sortChildren(); this.children.forEach(child => child.sortDescendants()); } /** * Creates a deep clone of this RouteNode and all its children. * The cloned node will have the same structure but be completely independent. * * @returns A new RouteNode instance that is a deep copy of this node */ clone() { const clonedNode = new RouteNode(this.name, this.absolute ? `~${this.path}` : this.path, [], // Children will be added by reconstructing from childRoutes if necessary, or handled by subsequent .add calls { finalSort: false } // Ensure sort is not prematurely done if children are added later ); // Copy other custom properties from `this` to `clonedNode` for (const key in this) { if (Object.prototype.hasOwnProperty.call(this, key)) { if (!['name', 'path', 'children', 'parent', 'absolute', 'parser'].includes(key)) { clonedNode[key] = this[key]; } } } // After custom props are copied, now add children definitions (if any) to the clone // This ensures that if children themselves have custom props, they are handled by the standard `add` process if (this.children.length > 0) { this.children.forEach(child => { clonedNode.add(child.clone(), undefined, false); // Add a CLONE of the child }); } // If the original node had finalSort true in its options, we might want to call sortDescendants // For now, the caller of clone or subsequent operations should handle final sorting if needed. // Example: if clone is used in `addRouteNode`, the `sort` param there will handle it for the parent. return clonedNode; } /** * Helper method to recursively convert a RouteNode to a RouteDefinition * @private */ convertNodeToDefinition(node) { const definition = { name: node.name, path: node.absolute ? `~${node.path}` : node.path }; if (node.children.length > 0) { definition.children = node.children.map(child => this.convertNodeToDefinition(child)); } return definition; } buildPath(routeName, params = {}, options = {}) { const segments = this.getSegmentsByName(routeName); if (!segments) { throw new Error(`[route-node][buildPath] '${routeName}' is not defined`); } return buildPathFromSegments(segments, params, options); } buildState(name, params = {}) { const segments = this.getSegmentsByName(name); if (!segments || !segments.length) { return null; } return { name, params, meta: getMetaFromSegments(segments) }; } matchPath(path, options = {}) { if (path === '' && !options.strictTrailingSlash) { path = '/'; } const match = this.getSegmentsMatchingPath(path, options); if (!match) { return null; } const matchedSegments = match.segments; if (matchedSegments[0].absolute) { const firstSegmentParams = matchedSegments[0].getParentSegments(); matchedSegments.reverse(); matchedSegments.push(...firstSegmentParams); matchedSegments.reverse(); } const lastSegment = matchedSegments[matchedSegments.length - 1]; const lastSegmentSlashChild = lastSegment.findSlashChild(); if (lastSegmentSlashChild) { matchedSegments.push(lastSegmentSlashChild); } return buildStateFromMatch(match); } _updateWith(sourceNode, sortChildrenFlag) { // 1. Update path (with sibling conflict check) // Update path only if it's different, and check for conflicts if (this.path !== sourceNode.path) { if (this.parent) { // Path conflict check is relevant if there's a parent with other children const conflictingSibling = this.parent.children.find(sibling => sibling !== this && sibling.path === sourceNode.path); if (conflictingSibling) { throw new Error(`Path "${sourceNode.path}" for route "${this.name}" conflicts with existing sibling route "${conflictingSibling.name}".`); } } this.setPath(sourceNode.path); } // 2. Update other relevant properties from sourceNode to `this`. // We iterate over all keys in sourceNode. Properties like 'name', 'children', 'parent', // 'absolute', and 'parser' are handled by other parts of the logic or are intrinsic // to the node's identity and structure, so we skip them here. // The 'path' is handled above with setPath. for (const key in sourceNode) { if (Object.prototype.hasOwnProperty.call(sourceNode, key)) { if (!['name', 'path', 'children', 'parent', 'absolute', 'parser'].includes(key)) { // Directly assign the value from sourceNode to this node. // This allows custom properties from RouteDefinition to be transferred. this[key] = sourceNode[key]; } } } // 3. Update/add child nodes. // Child nodes from `sourceNode` are used to update/add to the child nodes of `this`. // Existing child nodes in `this` that are not mentioned by name in `sourceNode.children` will remain. if (sourceNode.children && sourceNode.children.length > 0) { sourceNode.children.forEach(childFromSource => { // Call addRouteNode on `this` (the node being updated) for each child from the source. // `addRouteNode` will handle updating an existing child or adding a new one. const childClone = childFromSource.clone(); // Clone the child from source // Ensure the clone has no parent initially, so it can be correctly parented by `this` // if added as a new node, or its properties used for update if it matches an existing child of `this`. // The clone() method already ensures the new node has no parent. this.addRouteNode(childClone, false); // Defer sorting until the end }); } if (sortChildrenFlag) { this.sortChildren(); } } addRouteNode(route, sort = true) { var _a; const names = route.name.split('.'); if (names.length === 1) { // Find an existing child node with the same name const existingChild = this.children.find(child => child.name === route.name); if (existingChild) { // === UPDATE EXISTING CHILD NODE === existingChild._updateWith(route, sort); // Use the helper method to update } else { // === ADD NEW CHILD NODE === // Check for duplicate path only among other existing child nodes if (this.children.some(child => child.path === route.path)) { throw new Error(`Path "${route.path}" is already defined in another route node ("${((_a = this.children.find(c => c.path === route.path)) === null || _a === void 0 ? void 0 : _a.name) || 'unknown'}") at this level.`); } route.setParent(this); // Ensure parent is set before adding this.children.push(route); if (sort) { this.sortChildren(); } } } else { const parentName = names.slice(0, -1).join('.'); const childSimpleName = names[names.length - 1]; const segments = this.getSegmentsByName(parentName); if (segments && segments.length > 0) { const directParentNode = segments[segments.length - 1]; // Create a clone from `route` to work with. // The original `route` (argument) will remain untouched. const nodeToProcess = route.clone(); nodeToProcess.name = childSimpleName; // Set the simple name for the clone. // Children of this clone already have simple names relative to it (due to how clone works). // Now pass this prepared clone to the public `add` method of the parent. // The public `add` will call setParent and then addRouteNode (this same method) on `directParentNode`. // Since `nodeToProcess.name` is now simple, it will fall into the first branch (if names.length === 1). directParentNode.add(nodeToProcess, undefined, sort); // The name of the original `route` (argument) was not changed. // The name of `nodeToProcess` was changed, but it's a local copy. } else { throw new Error(`Could not add route named '${names.join('.')}', parent segment '${parentName}' is missing.`); } } return this; } checkParents() { if (this.absolute && this.hasParentsParams()) { throw new Error('[RouteNode] A RouteNode with an abolute path cannot have parents with route parameters'); } } hasParentsParams() { if (this.parent && this.parent.parser) { const parser = this.parent.parser; const hasParams = parser.hasUrlParams || parser.hasSpatParam || parser.hasMatrixParams || parser.hasQueryParams; return hasParams || this.parent.hasParentsParams(); } return false; } findAbsoluteChildren() { return this.children.reduce((absoluteChildren, child) => absoluteChildren .concat(child.absolute ? child : []) .concat(child.findAbsoluteChildren()), []); } findSlashChild() { const slashChildren = this.getNonAbsoluteChildren().filter(child => child.parser && /^\/(\?|$)/.test(child.parser.path)); return slashChildren[0]; } getSegmentsByName(routeName) { const findSegmentByName = (name, routes) => { const filteredRoutes = routes.filter(r => r.name === name); return filteredRoutes.length ? filteredRoutes[0] : undefined; }; const segments = []; let routes = this.parser ? [this] : this.children; const names = (this.parser ? [''] : []).concat(routeName.split('.')); const matched = names.every(name => { const segment = findSegmentByName(name, routes); if (segment) { routes = segment.children; segments.push(segment); return true; } return false; }); return matched ? segments : null; } getSegmentsMatchingPath(path, options) { const topLevelNodes = this.parser ? [this] : this.children; const startingNodes = topLevelNodes.reduce((nodes, node) => nodes.concat(node, node.findAbsoluteChildren()), []); const currentMatch = { segments: [], params: {} }; const finalMatch = matchChildren(startingNodes, path, currentMatch, options); if (finalMatch && finalMatch.segments.length === 1 && finalMatch.segments[0].name === '') { return null; } return finalMatch; } } /** * Error codes used throughout the router for consistent error handling * * @example * ```typescript * import { errorCodes } from '@riogz/router' * * if (error.code === errorCodes.ROUTE_NOT_FOUND) { * // Handle route not found error * } * ``` */ const errorCodes = { ROUTER_NOT_STARTED: 'NOT_STARTED', NO_START_PATH_OR_STATE: 'NO_START_PATH_OR_STATE', ROUTER_ALREADY_STARTED: 'ALREADY_STARTED', ROUTE_NOT_FOUND: 'ROUTE_NOT_FOUND', SAME_STATES: 'SAME_STATES', CANNOT_DEACTIVATE: 'CANNOT_DEACTIVATE', CANNOT_ACTIVATE: 'CANNOT_ACTIVATE', TRANSITION_ERR: 'TRANSITION_ERR', TRANSITION_CANCELLED: 'CANCELLED' }; /** * Constants used for router events and special route identifiers * * @example * ```typescript * import { constants } from '@riogz/router' * * router.addEventListener(constants.TRANSITION_SUCCESS, (state) => { * console.log('Navigation successful:', state) * }) * ``` */ const constants = { UNKNOWN_ROUTE: '@@router/UNKNOWN_ROUTE', ROUTER_START: '$start', ROUTER_STOP: '$stop', TRANSITION_START: '$$start', TRANSITION_CANCEL: '$$cancel', TRANSITION_SUCCESS: '$$success', TRANSITION_ERROR: '$$error' }; /** * Enhances a router with route management capabilities. * * This module provides functionality for: * - Route definition and management * - Route tree construction and navigation * - Path building and matching * - Route forwarding and redirection * - Active route checking * - Parameter encoding/decoding * - Default parameter handling * * Routes are organized in a hierarchical tree structure where: * - Parent routes can have child routes (nested routing) * - Route names use dot notation (e.g., 'app.users.profile') * - Parameters can be passed between routes * - Guards and lifecycle hooks can be attached to routes * * @template Dependencies - Type of dependencies available to route handlers * @param routes - Array of route definitions or RouteNode instance * @returns Function that enhances a router with route management * * @example * ```typescript * const routes = [ * { name: 'home', path: '/' }, * { name: 'users', path: '/users', children: [ * { name: 'list', path: '' }, * { name: 'detail', path: '/:id' } * ]}, * { name: 'about', path: '/about' } * ] * * const router = createRouter(routes) * * // Navigate to routes * router.navigate('users.detail', { id: '123' }) * * // Check if route is active * if (router.isActive('users')) { * console.log('Users section is active') * } * ``` */ function withRoutes(routes) { return (router) => { /** * Set up route forwarding from one route to another. * * @param fromRoute - Source route name * @param toRoute - Target route name * @returns Router instance for chaining * * @example * ```typescript * // Redirect old route to new route * router.forward('old-users', 'users') * ``` */ router.forward = (fromRoute, toRoute) => { router.config.forwardMap[fromRoute] = toRoute; return router; }; const rootNode = routes instanceof RouteNode ? routes : new RouteNode('', '', routes, { onAdd: onRouteAddedInternal }); /** * Callback function called when a route is added to the route tree. * * This function automatically registers route-specific handlers: * - Route guards (canActivate, canDeactivate) * - Route forwarding * - Parameter encoding/decoding * - Default parameters * - Lifecycle hooks * - Browser title handlers * * @param route - Route definition that was added */ function onRouteAddedInternal(route) { if (route.canActivate) router.canActivate(route.name, route.canActivate); if (route.canDeactivate) router.canDeactivate(route.name, route.canDeactivate); if (route.forwardTo) router.forward(route.name, route.forwardTo); // Handle redirectToFirstAllowNode if (route.redirectToFirstAllowNode) { router.config.redirectToFirstAllowNodeMap = router.config.redirectToFirstAllowNodeMap || {}; router.config.redirectToFirstAllowNodeMap[route.name] = true; } if (route.decodeParams) router.config.decoders[route.name] = route.decodeParams; if (route.encodeParams) router.config.encoders[route.name] = route.encodeParams; if (route.defaultParams) router.config.defaultParams[route.name] = route.defaultParams; // Register new lifecycle hooks if (route.onEnterNode) router.registerOnEnterNode(route.name, route.onEnterNode); if (route.onExitNode) router.registerOnExitNode(route.name, route.onExitNode); if (route.onNodeInActiveChain) router.registerOnNodeInActiveChain(route.name, route.onNodeInActiveChain); if (route.browserTitle) router.registerBrowserTitle(route.name, route.browserTitle); } /** * Helper function to recursively clear handlers associated with a route name. */ function _recursiveCle