UNPKG

@aurelia/route-recognizer

Version:

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/) [![CircleCI](https://circleci.com/

754 lines (750 loc) • 29.5 kB
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