import { UrlParserError } from "./errors/UrlParserError"; import { type CodecMap, type CodecsToRecord, type PathLike, type RouteParams, safeKeys, } from "./helpers/common"; import type { Codec } from "./Codecs"; /** * Recursively creates a union of string literals from a {@link PathLike} * string xtracting the path variables sections. I.e. anything that starts with * `:` on the path. * * @param P the {@link PathLike} string */ type PathVarsCapture

= P extends `${string}/:${infer P1}/${string}:${infer P2}` ? P1 | PathVarsCapture<`/:${P2}`> : P extends `${string}/:${infer P3}/${string}` ? P3 : P extends `${string}/:${infer P4}` ? P4 : never; /** * Conditional constraint for path variables based on the result of capturing * the literals on the {@link PathLike} string. * * @param P the {@link PathLike} string */ type PathVars

= PathVarsCapture

extends never ? Record : Record, Codec>; /** * Conditionally create the `makeUrl` function based on the codec map of path * variables and query parameters. * * @param V the path vars codec record * @param Q the query param codec record */ type MakeUrl< V extends CodecMap, Q extends CodecMap, > = keyof V extends never ? { /** * Creates a raw string URL for the route using the provided * parameters. * * @param params the parameters used to build the route * @returns the built URL of the route */ makeUrl(params?: RouteParams): string; } : { /** * Creates a raw string URL for the route using the provided * parameters. * * @param params the parameters used to build the route * @returns the built URL of the route */ makeUrl(params: RouteParams): string; }; /** * A `Routeway` route instance. It may contain more keys with other subroutes. * * @param P the {@link PathLike} string * @param V the path vars codec record * @param Q the query param codec record * @param S the record of `Routeway` subroutes */ export type Routeway< P extends PathLike = PathLike, V extends CodecMap = Record>, Q extends CodecMap = Record>, S extends Record = Record, > = MakeUrl & { /** * Convenience method that returns the configuration of the route. * * @returns an object with all the configuration of the route */ $config(): { /** * A record of the path variables configuration. The key refers to the name * of the path variable and the value is the specific codec for the * variable. */ pathVars: V; /** * A record of the query parameters configuration. The key refers to the * name of the query parameters and the value is the specific codec for the * parameter. */ queryParams: Q; /** * The template of this route segment. Differently from the `.template()` * method, this property does not contain the template of the full path, * but only of the specific route. */ segment: P; /** * A record of the nested `Routeway` instances of the route (if any). */ subRoutes: S; }; /** * Parse a raw URL to get the path variables and query parameters from it. * * @param uri the raw URL to parse the params from * @returns an object with the parsed path variables and query parameters */ parseUrl(uri: string): { pathVars: CodecsToRecord; queryParams: Partial>; }; /** * Creates the complete template of the route. Useful when working with other * routing libraries that need the context of the path with its variables. * * @returns the route template */ template(): string; } & S; type DefinedSubRoutes>> = B extends RoutewaysBuilder ? M extends Record ? { [K in keyof M]: GetDefinedRoute } : never : never; type GetDefinedRoute = S extends Routeway ? Routeway }> : never; type ResultSubRoutes>, V extends Record> = B extends RoutewaysBuilder ? M extends Record ? { [K in keyof M]: GetResultRoute; } : never : never; type GetResultRoute> = S extends Routeway ? Routeway< G, { [K in keyof (V1 & V2)]: (V1 & V2)[K]; }, Q, { [K in keyof SR]: GetResultRoute; } > : never; /** * Conditionally create a route configuratiion based on the `path` property * string. If the path contains path variables, the `pathVars` property is * required and it must be defined with path variables names as its keys. * * @param N the name of the route * @param P the {@link PathLike} string * @param V the path vars codec record * @param Q the query param codec record */ export type PathConfig< N extends string, P extends PathLike, V extends PathVars

, Q extends CodecMap, > = PathVarsCapture

extends never ? { name: N; path: P; queryParams?: Q; } : { name: N; path: P; pathVars: V; queryParams?: Q; }; /** * Conditionally create a route configuratiion based on the `path` property * string. If the path contains path variables, the `pathVars` property is * required and it must be defined with path variables names as its keys. * Aditionally, this configuration requires a `subRoutes` property. * * @param N the name of the route * @param P the {@link PathLike} string * @param V the path vars codec record * @param Q the query param codec record * @param S the `RoutewaysBuilder` for the subroutes */ export type NestConfig< N extends string, P extends PathLike, V extends PathVars

, Q extends CodecMap, S extends RoutewaysBuilder>, > = PathVarsCapture

extends never ? { name: N; path: P; queryParams?: Q; subRoutes: S; } : { name: N; path: P; pathVars: V; queryParams?: Q; subRoutes: S; }; /** * The Routeways builder API. */ export class RoutewaysBuilder> { private readonly routes: M; public constructor(routes: M) { this.routes = routes; this.path = this.path.bind(this); this.nest = this.nest.bind(this); this.build = this.build.bind(this); } /** * Create a single path on the route under construction. Single paths do not * allow nesting and can be considered the latest point of a branch in the * router. * * If you need to nest routes use {@link RoutewaysBuilder.nest() .nest(..)} * instead. * * @param config a configuration object for the route * @returns the Routeways instance to continue building */ public path< N extends string, P extends PathLike, V extends Record, Codec>, Q extends CodecMap, >( config: PathConfig, ): RoutewaysBuilder<{ [K in keyof M]: M[K]; } & { [K in N]: Routeway; }> { const { name, path, pathVars, queryParams = { } as Q } = "pathVars" in config ? config : { ...config, pathVars: { } as V }; return new RoutewaysBuilder({ ...this.routes, [name]: { $config: () => ({ pathVars, queryParams, segment: path, subRoutes: { }, }), makeUrl: () => path, parseUrl: () => ({ pathVars: { } as CodecsToRecord, queryParams: { } as Partial>, }), template: () => path, }, }); } /** * Create a path on the route under construction that allows creating nested * routes under it. The `subRoutes` are required in this method, if you need * to create a terminal route, use {@link RoutewaysBuilder.path() .path(..)} * instead. * * @param config a configuration object for the route * @returns the Routeways instance to continue building */ public nest< N extends string, P extends PathLike, V extends PathVars

, Q extends CodecMap, S extends RoutewaysBuilder>, >( config: NestConfig, ): RoutewaysBuilder<{ [K in keyof M]: M[K] } & { [K in N]: Routeway> }> { const { name, path, pathVars, queryParams = { } as Q, subRoutes } = "pathVars" in config ? config : { ...config, pathVars: { } as V }; const subRouteRecord = subRoutes.routes as ResultSubRoutes; const newRoute: Routeway> = { $config: () => ({ pathVars, queryParams, segment: path, subRoutes: subRouteRecord, }), makeUrl: () => "", parseUrl: () => ({ pathVars: { } as CodecsToRecord, queryParams: { } as Partial>, }), template: () => path, ...subRouteRecord, }; return new RoutewaysBuilder({ ...this.routes, [name]: newRoute, }); } /** * Builds the routes defined by the API and returns a `Routeways` instance * shaped by the names of the paths. * * @returns the built `Routeways` instance */ public build(): M { return safeKeys(this.routes).reduce((acc, key) => { const route = this.routes[key]; if (route !== undefined) { return { ...acc, [key]: injectParentData(route), }; } return acc; }, { } as M); } } function injectParentData< R extends Routeway, S extends Record = Record, V extends CodecMap = Record>, Q extends CodecMap = Record>, >( route: R, path = "", pathVars: V = { } as V, ): R { const routeConfig = route.$config(); const fullPath = `${path}${route.template()}`; const allPathVars = { ...pathVars, ...routeConfig.pathVars }; return safeKeys(route) .reduce((acc, routeName) => { const subRoute = route[routeName]; return { ...acc, $config: () => ({ ...routeConfig, pathVars: allPathVars, }), makeUrl: (params?: RouteParams): string => { if (params === undefined) { return fullPath; } const queryKeys = safeKeys(routeConfig.queryParams).filter(key => safeKeys(params).includes(key)); const queryParams = queryKeys.reduce((search, key) => { const codec = routeConfig.queryParams[key]; const paramValue = params[key]; if (codec !== undefined && paramValue !== undefined) { const encodedValue = codec.encode(paramValue, key); const joinChar: string = search === "?" ? "" : "&"; return encodedValue.includes(`${key}=`) ? `${search}${joinChar}${encodeURI(encodedValue)}` : `${search}${joinChar}${key}=${encodeURIComponent(encodedValue)}`; } return search; }, "?"); const baseUrl = safeKeys(allPathVars) .reduce((url, key) => { const paramValue = params[key]; const codec = allPathVars[key]; return codec !== undefined ? url.replaceAll(`:${String(key)}`, codec.encode(paramValue)) : url; }, fullPath); return `${baseUrl}${queryKeys.length > 0 ? queryParams : ""}`; }, parseUrl: uri => { const url = new URL(uri.startsWith("http") ? uri : `http://localhost${uri}`); const pathnameChunks = url.pathname.split("/"); const templateChunks = fullPath.split("/"); const allChuncksMatch = templateChunks.every((chunck, i) => pathnameChunks[i] === chunck || chunck.startsWith(":"), ); if (pathnameChunks.length === templateChunks.length && allChuncksMatch) { return { pathVars: safeKeys(allPathVars) .reduce((params, key) => { const templateIndex = templateChunks.indexOf(`:${String(key)}`); const pathVar = pathnameChunks[templateIndex]; const codec = allPathVars[key]; return codec !== undefined && pathVar !== undefined ? { ...params, [key]: codec.decode(pathVar) } : params; }, { } as V), queryParams: safeKeys(routeConfig.queryParams) .reduce((params, key) => { const codec = routeConfig.queryParams[key]; const first = url.searchParams.get(`${key}`); const { search } = url; if (first && codec) { return { ...params, [key]: codec.decode(first, { key, search }), }; } return params; }, { } as Q), }; } throw new UrlParserError(`Unable to parse "${uri}". The url does not match the template "${fullPath}"`); }, [routeName]: isRouteway(subRoute) ? injectParentData(subRoute, fullPath, allPathVars) : subRoute, template: () => fullPath, }; }, { } as R); } function isRouteway< V extends CodecMap, Q extends CodecMap, S extends Record, >(value: unknown): value is Routeway { return typeof value !== "function"; }