@riogz/router
Version:
A simple, lightweight, powerful, view-agnostic, modular and extensible router
1,323 lines (1,309 loc) • 140 kB
JavaScript
import transitionPath, { nameToIDs } from '@riogz/router-transition-path';
export { default as transitionPath } from '@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 _recu