103 lines
3.4 KiB
TypeScript
103 lines
3.4 KiB
TypeScript
import { getRoute, ROUTE_RUNTIME_CONTRACT } from "../../features/installed-feature-contracts.ts";
|
|
import { ROUTE_CODECS } from "../../features/installed-feature-runtimes.tsx";
|
|
import type {
|
|
ParsedRouteInput,
|
|
RouteId,
|
|
RouteInputResult,
|
|
} from "./route-contract.ts";
|
|
|
|
export type { ParsedRouteInput, RouteId, RouteInputResult };
|
|
|
|
function codecById(codecId: string) {
|
|
const codec = ROUTE_CODECS[codecId as keyof typeof ROUTE_CODECS];
|
|
if (!codec) throw new TypeError(`Unregistered route codec: ${codecId}`);
|
|
return codec;
|
|
}
|
|
|
|
export function parseRouteInput(
|
|
routeId: RouteId,
|
|
rawParams: Readonly<Record<string, string | undefined>>,
|
|
rawSearch: URLSearchParams,
|
|
): RouteInputResult {
|
|
const runtime = ROUTE_RUNTIME_CONTRACT[routeId];
|
|
const params = codecById(runtime.paramsCodec).safeParse(rawParams);
|
|
if (!params.success) {
|
|
return { success: false, code: "ROUTE_PARAMS_INVALID" };
|
|
}
|
|
const search = codecById(runtime.searchCodec).safeParse(
|
|
searchRecord(rawSearch),
|
|
);
|
|
if (!search.success) {
|
|
return { success: false, code: "ROUTE_SEARCH_INVALID" };
|
|
}
|
|
const parsedParams: Record<string, unknown> = { ...params.data };
|
|
const parsedSearch: Record<string, unknown> = { ...search.data };
|
|
return {
|
|
success: true,
|
|
data: Object.freeze({
|
|
routeId,
|
|
params: Object.freeze(parsedParams),
|
|
search: Object.freeze(parsedSearch),
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function buildRouteUrl(
|
|
routeId: RouteId,
|
|
input: Readonly<{
|
|
params?: Readonly<Record<string, unknown>>;
|
|
search?: Readonly<Record<string, unknown>>;
|
|
}> = {},
|
|
): string {
|
|
const definition = getRoute(routeId);
|
|
if (definition.path === "*") {
|
|
throw new TypeError("The not-found route cannot build a canonical URL");
|
|
}
|
|
const runtime = ROUTE_RUNTIME_CONTRACT[routeId];
|
|
const params = codecById(runtime.paramsCodec).parse(input.params ?? {});
|
|
const search = codecById(runtime.searchCodec).parse(input.search ?? {});
|
|
const parsedParams: Record<string, unknown> = { ...params };
|
|
const parsedSearch: Record<string, unknown> = { ...search };
|
|
let path = definition.path;
|
|
path = path.replace(
|
|
/:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g,
|
|
(
|
|
_token: string,
|
|
colonName: string | undefined,
|
|
braceName: string | undefined,
|
|
) => {
|
|
const name = colonName ?? braceName ?? "";
|
|
const value = parsedParams[name];
|
|
if (typeof value !== "string" && typeof value !== "number") {
|
|
throw new TypeError(`Missing route path parameter: ${name}`);
|
|
}
|
|
return encodeURIComponent(String(value));
|
|
},
|
|
);
|
|
const query = new URLSearchParams();
|
|
for (const key of Object.keys(parsedSearch).sort((left, right) =>
|
|
left.localeCompare(right),
|
|
)) {
|
|
const value = parsedSearch[key];
|
|
if (value === undefined || value === null) continue;
|
|
for (const item of Array.isArray(value) ? value : [value]) {
|
|
query.append(key, String(item));
|
|
}
|
|
}
|
|
const serialized = query.toString();
|
|
return serialized ? `${path}?${serialized}` : path;
|
|
}
|
|
|
|
function searchRecord(
|
|
search: URLSearchParams,
|
|
): Readonly<Record<string, string | readonly string[]>> {
|
|
const result: Record<string, string | readonly string[]> = {};
|
|
for (const key of [...new Set(search.keys())].sort((left, right) =>
|
|
left.localeCompare(right),
|
|
)) {
|
|
const values = search.getAll(key);
|
|
result[key] = values.length === 1 ? values[0] : values;
|
|
}
|
|
return result;
|
|
}
|