UNPKG

rvx

Version:

A signal based rendering library

190 lines (174 loc) 5.16 kB
import { $, Expression, get, watch } from "../core/signals.js"; import { Component } from "../core/types.js"; import { nest, View } from "../core/view.js"; import { ChildRouter } from "./child-router.js"; import { normalize } from "./path.js"; import { ROUTER } from "./router.js"; export interface RouteMatchResult { /** * The normalized matched path. */ path: string; /** * The parameters extracted from the matched path. */ params?: unknown; } export interface RouteMatchFn { /** * Check if this route matches the specified path. * * @param path The path to match against. * @returns The match result or undefined if the path doesn't match. */ (path: string): RouteMatchResult | undefined; } export interface Route { /** * The paths this route matches. */ match?: string | RegExp | RouteMatchFn; } export interface ParentRouteMatch<T extends Route> { /** * The route that has been matched. */ route: T; /** * The normalized matched path. */ path: string; /** * The parameters extracted from the matched path. */ params?: unknown; } export interface RouteMatch<T extends Route> extends ParentRouteMatch<T> { /** * The normalized remaining rest path. */ rest: string; } /** * Find the first matching route. * * @param path The {@link normalize normalized} path to match against. Non normalized paths result in undefined behavior. * @param routes The routes to test in order. * @returns A match or undefined if none of the routes matched. */ export function matchRoute<T extends Route>(path: string, routes: Iterable<T>): RouteMatch<T> | undefined { for (const route of routes) { if (typeof route.match === "string") { const test = route.match === "/" ? "" : route.match; if (test.endsWith("/")) { if (path.startsWith(test) || path === test.slice(0, -1)) { return { route, path: normalize(path.slice(0, test.length - 1)), params: undefined, rest: normalize(path.slice(test.length)), }; } } else if (test === path) { return { route, path, rest: "" }; } } else if (typeof route.match === "function") { const match = route.match(path); if (match !== undefined) { let rest = path; if (path.startsWith(match.path) && (path.length === match.path.length || path[match.path.length] === "/")) { rest = normalize(path.slice(match.path.length)); } return { ...match, route, rest }; } } else if (route.match instanceof RegExp) { const match = route.match.exec(path); if (match !== null) { const matched = normalize(match[0], false); let rest = path; if (path.startsWith(matched) && (path.length === matched.length || path[matched.length] === "/")) { rest = normalize(path.slice(matched.length)); } return { route, path: matched, params: match, rest }; } } else { return { route, path: "", rest: path }; } } } export interface WatchedRoutes<T extends Route> { match: () => ParentRouteMatch<T> | undefined; rest: () => string; } /** * Watch and match routes against an expression. * * @param path The normalized path. * @param routes The routes to watch. * @returns An object with individually watchable route match and the unmatched rest path. */ export function watchRoutes<T extends Route>(path: Expression<string>, routes: Expression<Iterable<T>>): WatchedRoutes<T> { const parent = $<ParentRouteMatch<T> | undefined>(undefined); const rest = $<string>(undefined!); watch(() => matchRoute(get(path), get(routes)), match => { if (match) { if (!parent.value || parent.value.path !== match.path || parent.value.route !== match.route) { parent.value = match; } rest.value = match.rest; } else { parent.value = undefined; rest.value = ""; } }); return { match: () => parent.value, rest: () => rest.value, }; } /** * Props passed to the root component of a {@link ComponentRoute}. */ export interface RouteProps<P = unknown> { /** Matched route parameters. */ params: P; } /** * A route where the content is a component to render. */ export interface ComponentRoute<P = unknown> extends Route { content: Component<RouteProps<P>>; } /** * Match and render routes in the current context. * * A {@link ChildRouter} is injected as a replacement for the current router when rendering route content. */ export function routes(routes: Expression<Iterable<ComponentRoute<any>>>): View { const router = ROUTER.current; if (!router) { // Router is not available in the current context: throw new Error("G3"); } const watched = watchRoutes(() => router.path, routes); return nest(watched.match, match => { if (match) { return ROUTER.inject(new ChildRouter(router, match.path, watched.rest), () => { return match.route.content({ params: match.params }); }); } }); } /** * Match and render routes in the current context. * * A {@link ChildRouter} is injected as a replacement for the current router when rendering route content. */ export function Routes(props: { /** * The routes to match. */ routes: Expression<Iterable<ComponentRoute<any>>>; }): View { return routes(props.routes); }