@aurelia/route-recognizer
Version:
[](https://opensource.org/licenses/MIT) [](http://www.typescriptlang.org/) [ • 29.5 kB
JavaScript
import { isArray } from '@aurelia/kernel';
function isIHandler(value) {
return value != null
&& typeof value === 'object'
&& isArray(value.path);
}
class Parameter {
constructor(name, isOptional, isStar, pattern) {
this.name = name;
this.isOptional = isOptional;
this.isStar = isStar;
this.pattern = pattern;
}
satisfiesPattern(value) {
if (this.pattern === null)
return true;
this.pattern.lastIndex = 0;
return this.pattern.test(value);
}
}
class ConfigurableRoute {
constructor(path, caseSensitive, handler) {
this.path = path;
this.caseSensitive = caseSensitive;
this.handler = handler;
}
}
class Endpoint {
get residualEndpoint() { return this._residualEndpoint; }
/** @internal */
set residualEndpoint(endpoint) {
if (this._residualEndpoint !== null)
throw new Error('Residual endpoint is already set');
this._residualEndpoint = endpoint;
}
constructor(route, params) {
this.route = route;
this.params = params;
this._residualEndpoint = null;
}
equalsOrResidual(other) {
return other != null && this === other || this._residualEndpoint === other;
}
}
class RecognizedRoute {
constructor(endpoint, path, params) {
this.endpoint = endpoint;
/** @internal */
this._firstNonEmptyPath = null;
const $params = Object.create(null);
for (const key in params) {
const value = params[key];
$params[key] = value != null ? decodeURIComponent(value) : value;
}
this.params = Object.freeze($params);
const residue = this.params[RESIDUE];
if ((residue?.length ?? 0) > 0 && path.endsWith(residue)) {
path = path.slice(0, -residue.length);
}
path = path.startsWith('/') ? path.slice(1) : path;
path = path.endsWith('/') ? path.slice(0, -1) : path;
this.path = path;
}
/** @internal */
_getFirstNonEmptyPath() {
let path = this._firstNonEmptyPath;
if (path != null)
return path;
path = this.path;
if (path.length !== 0)
return this._firstNonEmptyPath = path;
const handler = this.endpoint.route.handler;
path = isIHandler(handler) ? handler.path.find(p => p.length > 0) ?? null : null;
if (path !== null)
return this._firstNonEmptyPath = path;
throw new Error(`No non-empty path found`);
}
}
class Candidate {
constructor(
/** @internal */ chars, states, skippedStates, result) {
this.chars = chars;
this.states = states;
this.skippedStates = skippedStates;
this.result = result;
this.childRoutes = [];
this.recognizedResult = null;
this.isConstrained = false;
this.satisfiesConstraints = null;
this.head = states[states.length - 1];
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
this.endpoint = this.head?.endpoint;
}
advance(ch) {
const { chars, states, skippedStates, result } = this;
let stateToAdd = null;
let matchCount = 0;
const state = states[states.length - 1];
function $process(nextState, skippedState) {
if (nextState.isMatch(ch)) {
if (++matchCount === 1) {
stateToAdd = nextState;
}
else {
result.add(new Candidate(chars.concat(ch), states.concat(nextState), skippedState === null ? skippedStates : skippedStates.concat(skippedState), result));
}
}
if (state.segment === null && nextState.isOptional && nextState.nextStates !== null) {
if (nextState.nextStates.length > 1) {
throw createError(`${nextState.nextStates.length} nextStates`);
}
const separator = nextState.nextStates[0];
if (!separator.isSeparator) {
throw createError(`Not a separator`);
}
if (separator.nextStates !== null) {
for (const $nextState of separator.nextStates) {
$process($nextState, nextState);
}
}
}
}
if (state.isDynamic) {
$process(state, null);
}
if (state.nextStates !== null) {
for (const nextState of state.nextStates) {
$process(nextState, null);
}
}
if (stateToAdd !== null) {
states.push(this.head = stateToAdd);
chars.push(ch);
this.isConstrained = this.isConstrained
|| stateToAdd.isDynamic
&& stateToAdd.segment.isConstrained;
if (stateToAdd.endpoint !== null) {
this.endpoint = stateToAdd.endpoint;
}
}
if (matchCount === 0) {
result.remove(this);
}
}
/** @internal */
_finalize() {
function collectSkippedStates(skippedStates, state) {
const nextStates = state.nextStates;
if (nextStates !== null) {
if (nextStates.length === 1 && nextStates[0].segment === null) {
collectSkippedStates(skippedStates, nextStates[0]);
}
else {
for (const nextState of nextStates) {
if (nextState.isOptional && nextState.endpoint !== null) {
skippedStates.push(nextState);
if (nextState.nextStates !== null) {
for (const $nextState of nextState.nextStates) {
collectSkippedStates(skippedStates, $nextState);
}
}
break;
}
}
}
}
}
collectSkippedStates(this.skippedStates, this.head);
if (!this.isConstrained)
return true;
this._getRoutes();
return this.satisfiesConstraints;
}
/** @internal */
_getRoutes() {
let result = this.recognizedResult;
if (result != null)
return result;
const { states, chars } = this;
this.satisfiesConstraints = true;
const routes = [];
let curentEndpointRequirements = null;
for (let i = states.length - 1, ii = 0; i >= ii; --i) {
const state = states[i];
// should create the parent route if
// - the state has an endpoint, and
// - the endpoint handler is different from the current endpoint handler, and
// - all the requirements of the current endpoint have been fulfilled
const createNewRoute = state.endpoint !== null
&& (curentEndpointRequirements === null
|| curentEndpointRequirements.isDifferentEndpoint(state.endpoint)
&& curentEndpointRequirements.isFulfilled());
if (createNewRoute) {
if (curentEndpointRequirements !== null) {
routes.unshift(curentEndpointRequirements.toRecognizedRoute());
}
curentEndpointRequirements = new EndpointRequirement(state);
}
if (curentEndpointRequirements === null)
continue;
this.satisfiesConstraints = this.satisfiesConstraints && curentEndpointRequirements.consume(state, chars[i], states[i - 1]);
}
if (curentEndpointRequirements !== null && curentEndpointRequirements.isDifferentRecognizedRoute(routes[0])) {
routes.unshift(curentEndpointRequirements.toRecognizedRoute());
}
if (routes.length > 1 && routes[0].path === '')
routes.shift();
if (this.satisfiesConstraints) {
this.recognizedResult = result = [routes, this.head];
}
return result;
}
/**
* Compares this candidate to another candidate to determine the correct sorting order.
*
* This algorithm is different from `sortSolutions` in v1's route-recognizer in that it compares
* the candidates segment-by-segment, rather than merely comparing the cumulative of segment types
*
* This resolves v1's ambiguity in situations like `/foo/:id/bar` vs. `/foo/bar/:id`, which had the
* same sorting value because they both consist of two static segments and one dynamic segment.
*
* With this algorithm, `/foo/bar/:id` would always be sorted first because the second segment is different,
* and static wins over dynamic.
*
* ### NOTE
* This algorithm violates some of the invariants of v1's algorithm,
* but those invariants were arguably not very sound to begin with. Example:
*
* `/foo/*path/bar/baz` vs. `/foo/bar/*path1/*path2`
* - in v1, the first would win because that match has fewer stars
* - in v2, the second will win because there is a bigger static match at the start of the pattern
*
* The algorithm should be more logical and easier to reason about in v2, but it's important to be aware of
* subtle difference like this which might surprise some users who happened to rely on this behavior from v1,
* intentionally or unintentionally.
*
* @param b - The candidate to compare this to.
* Parameter name is `b` because the method should be used like so: `states.sort((a, b) => a.compareTo(b))`.
* This will bring the candidate with the highest score to the first position of the array.
*/
compareTo(b) {
const statesA = this.states;
const statesB = b.states;
for (let iA = 0, iB = 0, ii = Math.max(statesA.length, statesB.length); iA < ii; ++iA) {
let stateA = statesA[iA];
if (stateA === void 0) {
return 1;
}
let stateB = statesB[iB];
if (stateB === void 0) {
return -1;
}
let segmentA = stateA.segment;
let segmentB = stateB.segment;
if (segmentA === null) {
if (segmentB === null) {
++iB;
continue;
}
if ((stateA = statesA[++iA]) === void 0) {
return 1;
}
segmentA = stateA.segment;
}
else if (segmentB === null) {
if ((stateB = statesB[++iB]) === void 0) {
return -1;
}
segmentB = stateB.segment;
}
if (segmentA.kind < segmentB.kind) {
return 1;
}
if (segmentA.kind > segmentB.kind) {
return -1;
}
++iB;
}
const skippedStatesA = this.skippedStates;
const skippedStatesB = b.skippedStates;
const skippedStatesALen = skippedStatesA.length;
const skippedStatesBLen = skippedStatesB.length;
if (skippedStatesALen < skippedStatesBLen) {
return 1;
}
if (skippedStatesALen > skippedStatesBLen) {
return -1;
}
for (let i = 0; i < skippedStatesALen; ++i) {
const skippedStateA = skippedStatesA[i];
const skippedStateB = skippedStatesB[i];
if (skippedStateA.length < skippedStateB.length) {
return 1;
}
if (skippedStateA.length > skippedStateB.length) {
return -1;
}
}
// This should only be possible with a single pattern with multiple consecutive star segments.
// TODO: probably want to warn or even throw here, but leave it be for now.
return 0;
}
}
/** @internal */
class EndpointRequirement {
/** @internal */
constructor(state) {
/** @internal */ this._parameters = Object.create(null);
/** @internal */ this._staticSegements = [];
/** @internal */ this._path = '';
if (state.endpoint == null)
throw new Error('Invalid state; endpoint is required.');
const endpoint = this._endpoint = state.endpoint;
for (const param of endpoint.params) {
this._parameters[param.name] = [void 0, !param.isOptional, false];
}
for (const part of endpoint.route.path.split('/').filter(p => isNotEmpty(p) && !p.startsWith(':') && !p.startsWith('*'))) {
this._staticSegements.push([part, '', false]);
}
}
/** @internal */
consume(state, char, previousState) {
this._path = char + this._path;
if (state.isDynamic) {
const segment = state.segment;
const name = segment.name;
const parameter = this._parameters[name];
if (parameter[0] === void 0) {
parameter[0] = char;
if (parameter[1])
parameter[2] = true;
}
else {
parameter[0] = char + parameter[0];
}
// check for constraint if this state's segment is constrained
// and the state is the last dynamic state in a series of dynamic states.
// null fallback is used, as a star segment can also be a dynamic segment, but without a pattern.
const checkConstraint = state.isConstrained
&& !Object.is(previousState?.segment, segment);
if (!checkConstraint)
return true;
return state.satisfiesConstraint(parameter[0]);
}
if (this._staticSegements.length > 0 && char !== '' && char !== '/') {
const lastIncompleteStaticSegment = this._staticSegements.findLast(s => !s[2]);
if (lastIncompleteStaticSegment == null)
throw createError(`Unexpected state`); // unlikely to happen; but safe-guarding
lastIncompleteStaticSegment[1] = char + lastIncompleteStaticSegment[1];
lastIncompleteStaticSegment[2] = state.segment.caseSensitive
? lastIncompleteStaticSegment[0] === lastIncompleteStaticSegment[1]
: lastIncompleteStaticSegment[0].toLowerCase() === lastIncompleteStaticSegment[1].toLowerCase();
}
return true;
}
/** @internal */
isFulfilled() {
for (const [, isRequired, isFulfilled] of Object.values(this._parameters)) {
if (isRequired && !isFulfilled)
return false;
}
return this._staticSegements.length === 0 || this._staticSegements[0][2];
}
/** @internal */
toRecognizedRoute() {
const params = Object.create(null);
for (const key in this._parameters) {
params[key] = this._parameters[key][0];
}
return new RecognizedRoute(this._endpoint, this._path, params);
}
/** @internal */
isDifferentRecognizedRoute(value) {
return this.isDifferentEndpoint(value?.endpoint);
}
/** @internal */
isDifferentEndpoint(value) {
return value == null || this._endpoint.route.handler !== value.route.handler;
}
}
function hasEndpoint(candidate) {
return candidate.head.endpoint !== null;
}
function compareChains(a, b) {
return a.compareTo(b);
}
class RecognizeResult {
get isEmpty() {
return this.candidates.length === 0;
}
constructor(rootState) {
this.candidates = [];
this.candidates = [new Candidate([''], [rootState], [], this)];
}
getSolution() {
const candidates = this.candidates.filter(x => hasEndpoint(x) && x._finalize());
if (candidates.length === 0) {
return null;
}
candidates.sort(compareChains);
return candidates[0];
}
add(candidate) {
this.candidates.push(candidate);
}
remove(candidate) {
this.candidates.splice(this.candidates.indexOf(candidate), 1);
}
advance(ch) {
const candidates = this.candidates.slice();
for (const candidate of candidates) {
candidate.advance(ch);
}
}
}
/**
* Reserved parameter name that's used when registering a route with residual star segment (catch-all).
*/
const RESIDUE = '$$residue';
const routeParameterPattern = /^:(?<name>[^?\s{}]+)(?:\{\{(?<constraint>.+)\}\})?(?<optional>\?)?$/g;
class RouteRecognizer {
constructor() {
this.rootState = new State(null, null, '');
this.cache = new Map();
this.endpointLookup = new Map();
}
add(routeOrRoutes, addResidue = false, parentPath = null) {
let params;
let endpoint;
if (routeOrRoutes instanceof Array) {
for (const route of routeOrRoutes) {
endpoint = this.$add(route, false, parentPath);
params = endpoint.params;
// add residue iff the last parameter is not a star segment.
if (!addResidue || (params[params.length - 1]?.isStar ?? false))
continue;
endpoint.residualEndpoint = this.$add({ ...route, path: `${route.path}/*${RESIDUE}` }, true, parentPath);
}
}
else {
endpoint = this.$add(routeOrRoutes, false, parentPath);
params = endpoint.params;
// add residue iff the last parameter is not a star segment.
if (addResidue && !(params[params.length - 1]?.isStar ?? false)) {
endpoint.residualEndpoint = this.$add({ ...routeOrRoutes, path: `${routeOrRoutes.path}/*${RESIDUE}` }, true, parentPath);
}
}
// Clear the cache whenever there are state changes, because the recognizeResults could be arbitrarily different as a result
this.cache.clear();
}
$add(route, addResidue, parentPath) {
const path = parentPath === null ? route.path : `${parentPath}/${route.path}`;
const lookup = this.endpointLookup;
if (lookup.has(path))
throw createError(`Cannot add duplicate path '${path}'.`);
const $route = new ConfigurableRoute(route.path, route.caseSensitive === true, route.handler);
// Normalize leading, trailing and double slashes by ignoring empty segments
const parts = path === '' ? [''] : path.split('/').filter(isNotEmpty);
const params = [];
let state = this.rootState;
const numParentParts = parentPath === null ? 0 : parentPath.split('/').filter(isNotEmpty).length;
const numParts = parts.length;
for (let i = 0; i < numParts; ++i) {
const part = parts[i];
// Each segment always begins with a slash, so we represent this with a non-segment state
state = state.append(null, '/');
switch (part.charAt(0)) {
case ':': { // route parameter
routeParameterPattern.lastIndex = 0;
const match = routeParameterPattern.exec(part);
const { name, optional } = match?.groups ?? {};
const isOptional = optional === '?';
if (name === RESIDUE)
throw new Error(`Invalid parameter name; usage of the reserved parameter name '${RESIDUE}' is used.`);
const constraint = match?.groups?.constraint;
const pattern = constraint != null ? new RegExp(constraint) : null;
if (i >= numParentParts) {
params.push(new Parameter(name, isOptional, false, pattern));
}
state = new DynamicSegment(name, isOptional, pattern).appendTo(state);
break;
}
case '*': { // dynamic route
const name = part.slice(1);
let kind;
if (name === RESIDUE) {
if (!addResidue)
throw new Error(`Invalid parameter name; usage of the reserved parameter name '${RESIDUE}' is used.`);
kind = 1 /* SegmentKind.residue */;
}
else {
kind = 2 /* SegmentKind.star */;
}
if (i >= numParentParts) {
params.push(new Parameter(name, true, true, null));
}
state = new StarSegment(name, kind).appendTo(state);
break;
}
default: { // standard path route
state = new StaticSegment(part, $route.caseSensitive).appendTo(state);
break;
}
}
}
const endpoint = new Endpoint($route, params);
state.setEndpoint(endpoint);
lookup.set(path, endpoint);
return endpoint;
}
recognize(path, relativeTo = null) {
const cache = this.cache;
let result;
if (relativeTo == null) {
result = cache.get(path);
if (result !== void 0)
return result?.[0] ?? null;
cache.set(path, result = this.$recognize(path, this.rootState));
return result?.[0] ?? null;
}
// check for cached result first
const parentPath = relativeTo.map(curr => curr._getFirstNonEmptyPath()).join('/');
const combinedPath = combinePaths(parentPath, path);
result = cache.get(combinedPath);
if (result !== void 0)
return result === null ? null : result[0].slice(relativeTo.length);
// no cached result, try recognizing parent first
let parentResult = cache.get(parentPath);
if (parentResult === null)
return null;
if (parentResult === void 0) {
parentResult = this.$recognize(parentPath, this.rootState);
if (parentResult === null)
return null;
}
const relativeResult = this.$recognize(path, parentResult[1]);
if (relativeResult === null)
return null;
let routes = relativeResult[0];
if (routes.length > 1 && routes[0].path === '')
routes = routes.slice(1);
cache.set(combinedPath, [relativeTo.concat(routes), relativeResult[1]]);
return routes;
function combinePaths(parentPath, childPath) {
if (parentPath === '')
return childPath;
if (childPath === '')
return parentPath;
if (parentPath.endsWith('/'))
return childPath.startsWith('/')
? parentPath + childPath.slice(1)
: parentPath + childPath;
return childPath.startsWith('/')
? parentPath + childPath
: `${parentPath}/${childPath}`;
}
}
$recognize(path, relativeToState) {
if (!path.startsWith('/')) {
path = `/${path}`;
}
if (path.length > 1 && path.endsWith('/')) {
path = path.slice(0, -1);
}
const result = new RecognizeResult(relativeToState);
for (let i = 0, ii = path.length; i < ii; ++i) {
const ch = path.charAt(i);
result.advance(ch);
if (result.isEmpty) {
return null;
}
}
const candidate = result.getSolution();
if (candidate === null) {
return null;
}
return candidate._getRoutes();
}
getEndpoint(path) {
return this.endpointLookup.get(path) ?? null;
}
}
class State {
constructor(prevState, segment, value) {
this.prevState = prevState;
this.segment = segment;
this.value = value;
this.nextStates = null;
this.endpoint = null;
this.isConstrained = false;
switch (segment?.kind) {
case 3 /* SegmentKind.dynamic */:
this.length = prevState.length + 1;
this.isSeparator = false;
this.isDynamic = true;
this.isOptional = segment.optional;
this.isConstrained = segment.isConstrained;
break;
case 2 /* SegmentKind.star */:
case 1 /* SegmentKind.residue */:
this.length = prevState.length + 1;
this.isSeparator = false;
this.isDynamic = true;
this.isOptional = false;
break;
case 4 /* SegmentKind.static */:
this.length = prevState.length + 1;
this.isSeparator = false;
this.isDynamic = false;
this.isOptional = false;
break;
case undefined:
this.length = prevState === null ? 0 : prevState.length;
this.isSeparator = true;
this.isDynamic = false;
this.isOptional = false;
break;
}
}
append(segment, value) {
let state;
let nextStates = this.nextStates;
if (nextStates === null) {
state = void 0;
nextStates = this.nextStates = [];
}
else if (segment === null) {
state = nextStates.find(s => s.value === value);
}
else {
state = nextStates.find(s => s.segment?.equals(segment));
}
if (state === void 0) {
nextStates.push(state = new State(this, segment, value));
}
return state;
}
setEndpoint(endpoint) {
if (this.endpoint !== null) {
throw createError(`Cannot add ambiguous route. The pattern '${endpoint.route.path}' clashes with '${this.endpoint.route.path}'`);
}
this.endpoint = endpoint;
if (this.isOptional) {
this.prevState.setEndpoint(endpoint);
if (this.prevState.isSeparator && this.prevState.prevState !== null) {
this.prevState.prevState.setEndpoint(endpoint);
}
}
}
isMatch(ch) {
const segment = this.segment;
switch (segment?.kind) {
case 3 /* SegmentKind.dynamic */:
return !this.value.includes(ch);
case 2 /* SegmentKind.star */:
case 1 /* SegmentKind.residue */:
return true;
case 4 /* SegmentKind.static */:
case undefined:
// segment separators (slashes) are non-segments. We could say return ch === '/' as well, technically.
return this.value.includes(ch);
}
}
satisfiesConstraint(value) {
return this.isConstrained
? this.segment.satisfiesPattern(value)
: true;
}
}
function isNotEmpty(segment) {
return segment.length > 0;
}
class StaticSegment {
get kind() { return 4 /* SegmentKind.static */; }
constructor(value, caseSensitive) {
this.value = value;
this.caseSensitive = caseSensitive;
}
appendTo(state) {
const { value, value: { length } } = this;
if (this.caseSensitive) {
for (let i = 0; i < length; ++i) {
state = state.append(
/* segment */ this,
/* value */ value.charAt(i));
}
}
else {
for (let i = 0; i < length; ++i) {
const ch = value.charAt(i);
state = state.append(
/* segment */ this,
/* value */ ch.toUpperCase() + ch.toLowerCase());
}
}
return state;
}
equals(b) {
return (b.kind === 4 /* SegmentKind.static */ &&
b.caseSensitive === this.caseSensitive &&
b.value === this.value);
}
}
class DynamicSegment {
get kind() { return 3 /* SegmentKind.dynamic */; }
constructor(name, optional, pattern) {
this.name = name;
this.optional = optional;
this.pattern = pattern;
if (pattern === void 0)
throw new Error(`Pattern is undefined`);
this.isConstrained = pattern !== null;
}
appendTo(state) {
state = state.append(
/* segment */ this,
/* value */ '/');
return state;
}
equals(b) {
return (b.kind === 3 /* SegmentKind.dynamic */ &&
b.optional === this.optional &&
b.name === this.name);
}
satisfiesPattern(value) {
if (this.pattern === null)
return true;
this.pattern.lastIndex = 0;
return this.pattern.test(value);
}
}
class StarSegment {
constructor(name, kind) {
this.name = name;
this.kind = kind;
}
appendTo(state) {
state = state.append(
/* segment */ this,
/* value */ '');
return state;
}
equals(b) {
return ((b.kind === 2 /* SegmentKind.star */ || b.kind === 1 /* SegmentKind.residue */) &&
b.name === this.name);
}
}
const createError = (msg) => new Error(msg);
export { ConfigurableRoute, Endpoint, Parameter, RESIDUE, RecognizedRoute, RouteRecognizer };
//# sourceMappingURL=index.dev.mjs.map