Files
EpicNext-Cms/src/features/housekeeping/foundation/routing/match-route.ts
T

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;
}