177 lines
4.5 KiB
TypeScript
177 lines
4.5 KiB
TypeScript
import type { HousekeepingDomainId } from "../../migration/types";
|
|
import type { CanonicalHousekeepingHref } from "../contracts";
|
|
import type { HousekeepingRegistry } from "../registry";
|
|
|
|
export interface HousekeepingRouteMatch {
|
|
routeId: string;
|
|
domain: HousekeepingDomainId;
|
|
params: Readonly<Record<string, string>>;
|
|
canonicalHref: CanonicalHousekeepingHref;
|
|
}
|
|
|
|
interface ParsedCanonicalPath {
|
|
segments: readonly string[];
|
|
}
|
|
|
|
interface RoutePatternSegment {
|
|
literal: string | null;
|
|
parameterName: string | null;
|
|
}
|
|
|
|
interface RouteCandidate {
|
|
routeId: string;
|
|
domain: HousekeepingDomainId;
|
|
patternSegments: readonly RoutePatternSegment[];
|
|
}
|
|
|
|
export function matchHousekeepingRoute(
|
|
registry: HousekeepingRegistry,
|
|
canonicalPath: string,
|
|
): HousekeepingRouteMatch | null {
|
|
const path = parseCanonicalPath(canonicalPath);
|
|
if (!path) return null;
|
|
|
|
const candidates = registry.domains
|
|
.flatMap((domain) =>
|
|
domain.routes.flatMap((route) => {
|
|
if (!routeBelongsToDomain(route.href, domain.canonicalHref)) return [];
|
|
|
|
const patternSegments = parseRoutePattern(route.href);
|
|
return patternSegments
|
|
? [{ routeId: route.id, domain: domain.id, patternSegments }]
|
|
: [];
|
|
}),
|
|
)
|
|
.sort(compareSpecificity);
|
|
|
|
for (const candidate of candidates) {
|
|
const params = matchSegments(candidate, path);
|
|
if (!params) continue;
|
|
|
|
return {
|
|
routeId: candidate.routeId,
|
|
domain: candidate.domain,
|
|
params: Object.freeze(params),
|
|
canonicalHref: canonicalPath as CanonicalHousekeepingHref,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseCanonicalPath(canonicalPath: string): ParsedCanonicalPath | null {
|
|
if (
|
|
canonicalPath !== canonicalPath.trim() ||
|
|
!canonicalPath.startsWith("/ase") ||
|
|
canonicalPath.includes("?") ||
|
|
canonicalPath.includes("#") ||
|
|
canonicalPath.includes("\\") ||
|
|
canonicalPath.endsWith("/")
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const rawSegments = canonicalPath.slice(1).split("/");
|
|
if (rawSegments[0] !== "ase" || rawSegments.some((segment) => !segment)) {
|
|
return null;
|
|
}
|
|
|
|
const segments: string[] = [];
|
|
for (const segment of rawSegments) {
|
|
const decoded = decodeCanonicalSegment(segment);
|
|
if (decoded === null) return null;
|
|
segments.push(decoded);
|
|
}
|
|
|
|
return { segments };
|
|
}
|
|
|
|
function parseRoutePattern(
|
|
routeHref: CanonicalHousekeepingHref,
|
|
): readonly RoutePatternSegment[] | null {
|
|
const segments: RoutePatternSegment[] = [];
|
|
|
|
for (const segment of routeHref.slice(1).split("/")) {
|
|
if (segment.startsWith(":")) {
|
|
const parameterName = segment.slice(1);
|
|
if (!parameterName) return null;
|
|
segments.push({ literal: null, parameterName });
|
|
continue;
|
|
}
|
|
|
|
const literal = decodeCanonicalSegment(segment);
|
|
if (literal === null) return null;
|
|
segments.push({ literal, parameterName: null });
|
|
}
|
|
|
|
return segments;
|
|
}
|
|
|
|
function decodeCanonicalSegment(segment: string): string | null {
|
|
let decoded: string;
|
|
try {
|
|
decoded = decodeURIComponent(segment);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
return !decoded ||
|
|
decoded === "." ||
|
|
decoded === ".." ||
|
|
decoded.includes("/") ||
|
|
decoded.includes("\\")
|
|
? null
|
|
: decoded;
|
|
}
|
|
|
|
function compareSpecificity(
|
|
left: RouteCandidate,
|
|
right: RouteCandidate,
|
|
): number {
|
|
const sharedLength = Math.min(
|
|
left.patternSegments.length,
|
|
right.patternSegments.length,
|
|
);
|
|
|
|
for (let index = 0; index < sharedLength; index += 1) {
|
|
const leftDynamic = left.patternSegments[index]?.parameterName !== null;
|
|
const rightDynamic = right.patternSegments[index]?.parameterName !== null;
|
|
if (leftDynamic === rightDynamic) continue;
|
|
return leftDynamic ? 1 : -1;
|
|
}
|
|
|
|
return left.patternSegments.length - right.patternSegments.length;
|
|
}
|
|
|
|
function routeBelongsToDomain(
|
|
routeHref: CanonicalHousekeepingHref,
|
|
domainHref: CanonicalHousekeepingHref,
|
|
): boolean {
|
|
return (
|
|
routeHref === domainHref ||
|
|
(domainHref !== "/ase" && routeHref.startsWith(`${domainHref}/`)) ||
|
|
(domainHref === "/ase" && routeHref === "/ase")
|
|
);
|
|
}
|
|
|
|
function matchSegments(
|
|
candidate: RouteCandidate,
|
|
path: ParsedCanonicalPath,
|
|
): Record<string, string> | null {
|
|
if (candidate.patternSegments.length !== path.segments.length) return null;
|
|
|
|
const params: Record<string, string> = {};
|
|
for (const [index, patternSegment] of candidate.patternSegments.entries()) {
|
|
const pathSegment = path.segments[index] ?? "";
|
|
if (patternSegment.parameterName === null) {
|
|
if (patternSegment.literal !== pathSegment) return null;
|
|
continue;
|
|
}
|
|
|
|
if (patternSegment.parameterName in params) return null;
|
|
params[patternSegment.parameterName] = pathSegment;
|
|
}
|
|
|
|
return params;
|
|
}
|