diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..0669846 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,47 @@ +FROM archlinux:latest + +# Sync package database and install base tooling +# docker CLI (no daemon) lets `docker compose` talk to the host Podman socket +RUN pacman -Syu --noconfirm \ + && pacman -S --noconfirm --needed \ + base-devel \ + bash \ + ca-certificates \ + curl \ + docker \ + docker-compose \ + git \ + github-cli \ + make \ + openssh \ + procps-ng \ + sudo \ + unzip \ + && pacman -Scc --noconfirm + +# Create a non-root user matching the host UID/GID (1000/1000 on SteamOS) +ARG USERNAME=dev +ARG USER_UID=1000 +ARG USER_GID=1000 +RUN groupadd --gid $USER_GID $USERNAME \ + && useradd --uid $USER_UID --gid $USER_GID -m -s /bin/bash $USERNAME \ + && echo "$USERNAME ALL=(root) NOPASSWD:ALL" > /etc/sudoers.d/$USERNAME \ + && chmod 0440 /etc/sudoers.d/$USERNAME + +USER $USERNAME +WORKDIR /home/$USERNAME + +# Install mise (runtime version manager) +RUN curl https://mise.run | sh +ENV PATH="/home/$USERNAME/.local/bin:$PATH" + +# Node 20 matches the repo's CI workflows; pnpm 10.14.0 matches the +# "packageManager" field in package.json (activated via corepack). +RUN mise use --global node@20 \ + && mise exec -- corepack enable \ + && mise exec -- corepack prepare pnpm@10.14.0 --activate + +RUN echo 'eval "$(mise activate bash)"' >> ~/.bashrc \ + && echo 'eval "$(mise activate bash)"' >> ~/.profile + +ENV PATH="/home/$USERNAME/.local/share/mise/shims:$PATH" diff --git a/.devcontainer/linux-podman/devcontainer.json b/.devcontainer/linux-podman/devcontainer.json new file mode 100644 index 0000000..4ead60e --- /dev/null +++ b/.devcontainer/linux-podman/devcontainer.json @@ -0,0 +1,87 @@ +{ + // ─── To reuse this in another project ──────────────────────────────────────── + // 1. Copy .devcontainer/ into the new repo + // 2. Update "name" and "postCreateCommand" + // 3. Everything else (Dockerfile, mounts, runArgs) is project-agnostic + // ───────────────────────────────────────────────────────────────────────────── + "name": "yar", + "build": { + "dockerfile": "../Dockerfile", + "args": { + "USERNAME": "dev", + "USER_UID": "1000", + "USER_GID": "1000" + } + }, + + // Podman-specific flags only — no volume paths here (those are in "mounts") + // --userns=keep-id: rootless Podman maps your UID into the container so file ownership matches + // --network=host: shares host network stack (localhost = host) + "runArgs": [ + "--userns=keep-id", + "--network=host" + ], + + // ${localEnv:HOME} → /home/ on any Linux machine + // ${localEnv:XDG_RUNTIME_DIR} → /run/user/, where the Podman socket lives + "mounts": [ + // Podman socket → docker CLI inside the container talks to host Podman (no daemon needed). + // Mounted at the SAME path as on the host so docker-out-of-docker path forwarding works. + { + "source": "${localEnv:XDG_RUNTIME_DIR}/podman/podman.sock", + "target": "${localEnv:XDG_RUNTIME_DIR}/podman/podman.sock", + "type": "bind" + }, + // pnpm content-addressable store → packages survive image rebuilds + { + "source": "${localEnv:HOME}/.local/share/pnpm", + "target": "/home/dev/.local/share/pnpm", + "type": "bind" + }, + // SSH keys (read-only) → git push/pull over SSH works inside the container + { + "source": "${localEnv:HOME}/.ssh", + "target": "/home/dev/.ssh", + "type": "bind", + "readonly": true + }, + // gh CLI auth token → survives container rebuilds without re-authenticating + { + "source": "${localEnv:HOME}/.config/gh", + "target": "/home/dev/.config/gh", + "type": "bind" + } + ], + + "containerEnv": { + // Point at the socket's real host path (mounted 1:1 above) so any path the + // CLI forwards to sibling containers resolves identically on the host. + "DOCKER_HOST": "unix://${localEnv:XDG_RUNTIME_DIR}/podman/podman.sock" + }, + + // Mount the workspace at its REAL host path (not /workspaces/...) so any + // project-relative paths forwarded to sibling containers resolve on the HOST. + "workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind,consistency=cached", + "workspaceFolder": "${localWorkspaceFolder}", + + // reshim: regenerates mise shims to point to this container's mise binary + // store-dir: pins pnpm store so it never falls back to a project-local .pnpm-store/ + "postCreateCommand": "mise reshim && pnpm config set store-dir /home/dev/.local/share/pnpm/store && pnpm install", + + // NOTE: No "forwardPorts" here on purpose. This container runs with + // --network=host (see runArgs), so it already shares the host's network + // namespace and any dev server is directly reachable on the host. + + "customizations": { + "vscode": { + "extensions": [ + "biomejs.biome" + ], + "settings": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome", + "terminal.integrated.defaultProfile.linux": "bash" + } + } + } +} diff --git a/README.md b/README.md index 816a6c3..9fe357b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A simple, type-safe router for React that works with any framework. ## Why use this? -- ✨ **Super Simple** - Only 2 functions: `createRoute` and `createRouter` +- ✨ **Super Simple** - A tiny API: `createRoute` and `createRouter` (plus optional `defineRoute`/`defineRoutes` sugar) - 🔒 **Type Safe** - TypeScript knows your route params automatically - 🎯 **Flexible** - Works with any React framework - ✅ **Validated** - Built-in query parameter validation @@ -103,6 +103,44 @@ const staticRoutes = Object.values(router.routes) .filter(route => route.meta?.isStatic); ``` +## Declarative Routes (less boilerplate) + +If you don't need custom closures per route, `defineRoute` and `defineRoutes` remove the handler wiring entirely. Components receive the route context (`params`, `query`) as props, and `loader`/`meta`/`extra` receive it as their first argument: + +```tsx +import { defineRoute, defineRoutes, createRouter } from "@btst/yar"; + +// Single route +const postRoute = defineRoute("/blog/:slug", { + page: BlogPostPage, // rendered with { params, query } as props + loading: Spinner, + error: ErrorPage, + loader: (ctx, signal?: AbortSignal) => + fetch(`/api/posts/${ctx.params.slug}`, { signal }).then((r) => r.json()), + meta: (ctx, post) => [{ name: "title", content: post?.title ?? ctx.params.slug }], + extra: (ctx) => ({ breadcrumbs: ["Home", "Blog", ctx.params.slug] }), +}); + +// Many routes at once, with optional page overrides +const routes = defineRoutes( + { + home: defineRoute("/", { page: HomePage }, undefined, { isStatic: true }), + post: defineRoute("/blog/:slug", { + page: BlogPostPage, + loader: (ctx) => fetchPost(ctx.params.slug), + meta: (ctx) => [{ name: "title", content: ctx.params.slug }], + }), + }, + { pages: { post: CustomPostPage } } // swap a page component per key +); + +const router = createRouter(routes); +``` + +Overridden pages still receive the route context (`params`, `query`) as props. `defineRoutes` also accepts plain `createRoute` routes; overriding those swaps the `PageComponent` as-is. + +`defineRoute` returns a regular route, so it composes freely with `createRoute` routes in the same `createRouter` call. Use `createRoute` when you need full control over the handler closure; use `defineRoute`/`defineRoutes` for the common declarative case. + ## What You Need to Know ### `createRoute(path, handler, options?, routeMeta?)` diff --git a/package.json b/package.json index 6c7baf8..e371521 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@btst/yar", - "version": "1.2.0", + "version": "1.3.0", "packageManager": "pnpm@10.14.0", "description": "Pluggable router for modern react frameworks", "type": "module", @@ -44,13 +44,15 @@ "@biomejs/biome": "2.2.4", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", + "react": "^19.1.1", "tsup": "^8.5.0", "typescript": "^5.9.2", "vitest": "^3.2.4" }, "peerDependencies": { "@types/react": "^19.1.16", - "@types/react-dom": "^19.1.9" + "@types/react-dom": "^19.1.9", + "react": "^18.0.0 || ^19.0.0" }, "exports": { ".": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43c80be..e22af55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@types/react-dom': specifier: ^19.1.9 version: 19.1.9(@types/react@19.1.16) + react: + specifier: ^19.1.1 + version: 19.2.7 tsup: specifier: ^8.5.0 version: 8.5.0(postcss@8.5.6)(typescript@5.9.2) @@ -686,6 +689,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -1417,6 +1424,8 @@ snapshots: punycode@2.3.1: {} + react@19.2.7: {} + readdirp@4.1.2: {} resolve-from@5.0.0: {} diff --git a/src/__tests__/define.test.ts b/src/__tests__/define.test.ts new file mode 100644 index 0000000..b8f7315 --- /dev/null +++ b/src/__tests__/define.test.ts @@ -0,0 +1,327 @@ +import type { ComponentType, ReactElement } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { defineRoute, defineRoutes } from "../define"; +import { createRoute, createRouter } from "../router"; +import { createFailingSchema, createObjectSchema } from "./test-helpers"; + +// Mock components for testing +const MockPage: ComponentType> = () => null; +const MockLoading: ComponentType> = () => null; +const MockError: ComponentType> = () => null; +const OverridePage: ComponentType> = () => null; + +// Bound components are wrappers that render the original component with the +// route context injected as props. Invoking the wrapper returns the element, +// which we can inspect without a DOM. +function renderBound( + Component: ComponentType> | undefined, + props: Record = {}, +): ReactElement> | null { + if (!Component) return null; + return ( + Component as ( + props: Record, + ) => ReactElement> + )(props); +} + +describe("defineRoute", () => { + it("should create a route with a simple path", () => { + const route = defineRoute("/home", { page: MockPage }); + + expect(route.path).toBe("/home"); + expect(route.options).toBeUndefined(); + + const result = route(); + expect(result.PageComponent).toBeDefined(); + expect(result.LoadingComponent).toBeUndefined(); + expect(result.ErrorComponent).toBeUndefined(); + expect(result.loader).toBeUndefined(); + expect(result.meta).toBeUndefined(); + expect(result.extra).toBeUndefined(); + }); + + it("should inject params and query into the page component as props", () => { + const route = defineRoute("/user/:id", { page: MockPage }); + + const result = route({ params: { id: "123" } }); + const element = renderBound(result.PageComponent); + + expect(element?.type).toBe(MockPage); + expect(element?.props.params).toEqual({ id: "123" }); + expect(element?.props.query).toBeUndefined(); + }); + + it("should merge render-time props with the injected context", () => { + const route = defineRoute("/user/:id", { page: MockPage }); + + const result = route({ params: { id: "123" } }); + const element = renderBound(result.PageComponent, { highlighted: true }); + + expect(element?.props.params).toEqual({ id: "123" }); + expect(element?.props.highlighted).toBe(true); + }); + + it("should bind loading and error components to the context", () => { + const route = defineRoute("/user/:id", { + page: MockPage, + loading: MockLoading, + error: MockError, + }); + + const result = route({ params: { id: "7" } }); + + const loading = renderBound(result.LoadingComponent); + expect(loading?.type).toBe(MockLoading); + expect(loading?.props.params).toEqual({ id: "7" }); + + const error = renderBound(result.ErrorComponent); + expect(error?.type).toBe(MockError); + expect(error?.props.params).toEqual({ id: "7" }); + }); + + it("should bind the loader to the context", async () => { + const loader = vi.fn( + (ctx: { params: { slug: string } }) => `Post ${ctx.params.slug}`, + ); + const route = defineRoute("/blog/:slug", { page: MockPage, loader }); + + const result = route({ params: { slug: "hello" } }); + const data = await result.loader?.(); + + expect(data).toBe("Post hello"); + expect(loader).toHaveBeenCalledWith({ + params: { slug: "hello" }, + query: undefined, + }); + }); + + it("should support async loaders", async () => { + const route = defineRoute("/blog/:slug", { + page: MockPage, + loader: async (ctx) => ({ title: `Post ${ctx.params.slug}` }), + }); + + const result = route({ params: { slug: "async" } }); + const data = await result.loader?.(); + + expect(data).toEqual({ title: "Post async" }); + }); + + it("should forward extra loader arguments after the context", async () => { + const loader = vi.fn( + (ctx: { params: { slug: string } }, signal?: AbortSignal) => ({ + slug: ctx.params.slug, + aborted: signal?.aborted ?? null, + }), + ); + const route = defineRoute("/blog/:slug", { page: MockPage, loader }); + + const result = route({ params: { slug: "sig" } }); + const controller = new AbortController(); + const data = await result.loader?.(controller.signal); + + expect(data).toEqual({ slug: "sig", aborted: false }); + expect(loader).toHaveBeenCalledWith( + { params: { slug: "sig" }, query: undefined }, + controller.signal, + ); + }); + + it("should bind meta to the context and forward extra arguments", async () => { + const route = defineRoute("/blog/:slug", { + page: MockPage, + loader: (ctx) => ({ title: `Post ${ctx.params.slug}` }), + meta: (ctx, data?: { title: string }) => [ + { name: "title", content: data?.title ?? ctx.params.slug }, + ], + }); + + const result = route({ params: { slug: "seo" } }); + + // Without loader data, falls back to context + expect(result.meta?.()).toEqual([{ name: "title", content: "seo" }]); + + // With loader data passed through (the common SSR pattern) + const data = await result.loader?.(); + expect(result.meta?.(data)).toEqual([ + { name: "title", content: "Post seo" }, + ]); + }); + + it("should support async meta", async () => { + const route = defineRoute("/about", { + page: MockPage, + meta: async () => [{ name: "title", content: "About" }], + }); + + const result = route(); + await expect(result.meta?.()).resolves.toEqual([ + { name: "title", content: "About" }, + ]); + }); + + it("should bind extra to the context", () => { + const route = defineRoute("/blog/:slug", { + page: MockPage, + extra: (ctx) => ({ breadcrumb: ctx.params.slug }), + }); + + const result = route({ params: { slug: "crumb" } }); + expect(result.extra?.()).toEqual({ breadcrumb: "crumb" }); + }); + + it("should validate query parameters via options", () => { + const querySchema = createObjectSchema<{ search: string }>({ + search: (val) => typeof val === "string", + }); + const route = defineRoute( + "/search", + { + page: MockPage, + loader: (ctx) => ctx.query?.search ?? "no query", + }, + { query: querySchema }, + ); + + expect(route.options).toEqual({ query: querySchema }); + + const result = route({ query: { search: "test" } }); + expect(result.loader?.()).toBe("test"); + }); + + it("should pass undefined query when validation fails", () => { + const route = defineRoute( + "/search", + { + page: MockPage, + loader: (ctx) => ctx.query ?? null, + }, + { query: createFailingSchema() }, + ); + + const result = route({ query: { search: "bad" } }); + expect(result.loader?.()).toBeNull(); + }); + + it("should attach route-level metadata", () => { + const route = defineRoute("/", { page: MockPage }, undefined, { + isStatic: true, + }); + + expect(route.meta).toEqual({ isStatic: true }); + }); + + it("should expose the original definition on the route", () => { + const def = { page: MockPage }; + const route = defineRoute("/home", def); + + expect(route.def).toBe(def); + }); +}); + +describe("defineRoutes", () => { + it("should return the routes record unchanged without overrides", () => { + const home = defineRoute("/", { page: MockPage }); + const post = defineRoute("/blog/:slug", { page: MockPage }); + const routes = defineRoutes({ home, post }); + + expect(routes.home).toBe(home); + expect(routes.post).toBe(post); + }); + + it("should work with createRouter and expose routeKey and params", async () => { + const routes = defineRoutes({ + home: defineRoute("/", { page: MockPage }), + post: defineRoute("/blog/:slug", { + page: MockPage, + loader: (ctx) => `Post ${ctx.params.slug}`, + }), + }); + const router = createRouter(routes); + + const match = router.getRoute("/blog/hello"); + expect(match).not.toBeNull(); + expect(match?.routeKey).toBe("post"); + expect(match?.params).toEqual({ slug: "hello" }); + expect(await match?.loader?.()).toBe("Post hello"); + + const element = renderBound(match?.PageComponent); + expect(element?.type).toBe(MockPage); + expect(element?.props.params).toEqual({ slug: "hello" }); + }); + + it("should apply per-key page overrides", () => { + const routes = defineRoutes( + { + home: defineRoute("/", { page: MockPage }), + post: defineRoute("/blog/:slug", { page: MockPage }), + }, + { pages: { post: OverridePage } }, + ); + const router = createRouter(routes); + + const homeElement = renderBound(router.getRoute("/")?.PageComponent); + expect(homeElement?.type).toBe(MockPage); + + const postMatch = router.getRoute("/blog/x"); + const postElement = renderBound(postMatch?.PageComponent); + expect(postElement?.type).toBe(OverridePage); + // Overridden components still receive the route context as props + expect(postElement?.props.params).toEqual({ slug: "x" }); + }); + + it("should preserve loader, meta, extra, options, and routeMeta when overriding", () => { + const querySchema = createObjectSchema<{ q: string }>({ + q: (val) => typeof val === "string", + }); + const routes = defineRoutes( + { + search: defineRoute( + "/search/:term", + { + page: MockPage, + loading: MockLoading, + error: MockError, + loader: (ctx) => `Results for ${ctx.params.term}`, + meta: (ctx) => [{ name: "title", content: ctx.params.term }], + extra: (ctx) => ({ crumb: ctx.params.term }), + }, + { query: querySchema }, + { requiresAuth: true }, + ), + }, + { pages: { search: OverridePage } }, + ); + + expect(routes.search.options).toEqual({ query: querySchema }); + expect(routes.search.meta).toEqual({ requiresAuth: true }); + + const router = createRouter(routes); + const match = router.getRoute("/search/yar"); + expect(renderBound(match?.PageComponent)?.type).toBe(OverridePage); + expect(renderBound(match?.LoadingComponent)?.type).toBe(MockLoading); + expect(renderBound(match?.ErrorComponent)?.type).toBe(MockError); + expect(match?.loader?.()).toBe("Results for yar"); + expect(match?.meta?.()).toEqual([{ name: "title", content: "yar" }]); + expect(match?.extra?.()).toEqual({ crumb: "yar" }); + }); + + it("should override the page of plain createRoute routes as-is", () => { + const routes = defineRoutes( + { + legacy: createRoute("/legacy/:id", ({ params }) => ({ + PageComponent: MockPage, + loader: () => `Legacy ${params.id}`, + })), + }, + { pages: { legacy: OverridePage } }, + ); + const router = createRouter(routes); + + const match = router.getRoute("/legacy/9"); + expect(match?.PageComponent).toBe(OverridePage); + expect(match?.loader?.()).toBe("Legacy 9"); + expect(match?.params).toEqual({ id: "9" }); + }); +}); diff --git a/src/define.ts b/src/define.ts new file mode 100644 index 0000000..3c3a1ab --- /dev/null +++ b/src/define.ts @@ -0,0 +1,221 @@ +// biome-ignore-all lint/suspicious/noExplicitAny: complex types +import { type ComponentType, createElement } from "react"; +import { createRoute } from "./router"; +import type { + InferParam, + InferQuery, + Route, + RouteMeta, + RouteOptions, +} from "./types"; + +type MetaArray = Array; +type MetaReturnType = MetaArray | Promise; + +/** + * The context object passed to declarative route functions (`loader`, `meta`, + * `extra`) and injected as props into `page`, `loading`, and `error` components. + */ +export type RouteContext< + Path extends string, + Options extends RouteOptions = RouteOptions, +> = { + params: InferParam; + query: InferQuery | undefined; +}; + +/** + * Declarative route definition accepted by `defineRoute`. + */ +export type RouteDef< + Path extends string, + Options extends RouteOptions = RouteOptions, +> = { + /** Page component. Receives the route context (`params`, `query`) as props. */ + page?: ComponentType>; + /** Loading component. Receives the route context as props. */ + loading?: ComponentType>; + /** Error component. Receives the route context as props. */ + error?: ComponentType>; + /** Data loader. Receives the route context first; extra args are caller-defined. */ + loader?: (ctx: RouteContext, ...args: any[]) => any; + /** Meta tag generator. Receives the route context first; extra args are caller-defined. */ + meta?: (ctx: RouteContext, ...args: any[]) => MetaReturnType; + /** Extra data generator. Receives the route context first; extra args are caller-defined. */ + extra?: (ctx: RouteContext, ...args: any[]) => any; +}; + +// Drops the bound context argument from a context-first function type. +type BoundFn = F extends (ctx: any, ...args: infer A) => infer R + ? (...args: A) => R + : undefined; + +type DefinedHandlerReturn> = { + PageComponent: ComponentType | undefined; + LoadingComponent: ComponentType | undefined; + ErrorComponent: ComponentType | undefined; + loader: BoundFn; + meta: BoundFn; + extra: BoundFn; +}; + +/** + * The route produced by `defineRoute`: a regular yar `Route` whose handler + * returns context-bound components and functions, with the original + * declarative definition attached as `def` (used by `defineRoutes` to apply + * page overrides). + */ +export type DefinedRoute< + Path extends string, + D extends RouteDef, + Meta extends RouteMeta = RouteMeta, +> = Route< + Path, + RouteOptions, + (inputCtx?: any) => DefinedHandlerReturn, + Meta +> & { + def: D; +}; + +function bindComponent( + Component: ComponentType, + context: Record, +): ComponentType { + const Bound = (props: Record) => + createElement(Component, { ...context, ...props }); + Bound.displayName = `Bound(${Component.displayName || Component.name || "Component"})`; + return Bound; +} + +function bindFn any>( + fn: F, + context: Ctx, +): BoundFn { + return ((...args: any[]) => fn(context, ...args)) as BoundFn; +} + +/** + * Declarative variant of `createRoute` that removes the handler-closure + * boilerplate. Components receive the route context (`params`, `query`) as + * props, and `loader`/`meta`/`extra` receive it as their first argument — + * no manual wiring per route. + * + * @param {Path} path - The route path pattern with optional dynamic segments + * @param {RouteDef} def - Declarative definition: `page`, `loading`, `error`, `loader`, `meta`, `extra` + * @param {Options} [options] - Optional configuration including query parameter validation schema + * @param {Meta} [routeMeta] - Optional route-level metadata for filtering without executing the handler + * + * @example + * ```tsx + * const post = defineRoute("/blog/:slug", { + * page: PostPage, // receives { params, query } as props + * loader: (ctx, signal?: AbortSignal) => fetchPost(ctx.params.slug, signal), + * meta: (ctx) => [{ name: "title", content: `Post ${ctx.params.slug}` }], + * }); + * ``` + */ +export function defineRoute< + Path extends string, + Options extends RouteOptions, + const Def extends RouteDef, + Meta extends RouteMeta = RouteMeta, +>( + path: Path, + def: Def, + options?: Options, + routeMeta?: Meta, +): DefinedRoute { + const route = createRoute( + path, + ({ params, query }) => { + const context = { params, query } as RouteContext; + const componentContext = context as Record; + return { + PageComponent: def.page + ? bindComponent(def.page, componentContext) + : undefined, + LoadingComponent: def.loading + ? bindComponent(def.loading, componentContext) + : undefined, + ErrorComponent: def.error + ? bindComponent(def.error, componentContext) + : undefined, + loader: def.loader ? bindFn(def.loader, context) : undefined, + meta: def.meta ? bindFn(def.meta, context) : undefined, + extra: def.extra ? bindFn(def.extra, context) : undefined, + } as DefinedHandlerReturn; + }, + options, + routeMeta, + ); + (route as any).def = def; + return route as unknown as DefinedRoute; +} + +/** + * Groups routes into a record compatible with `createRouter`, with optional + * per-key page component overrides. + * + * Overrides on routes created with `defineRoute` are rebuilt so the override + * component still receives the route context (`params`, `query`) as props. + * Overrides on plain `createRoute` routes replace the `PageComponent` as-is. + * + * @param {Record} routes - Record of routes (from `defineRoute` or `createRoute`) + * @param {Object} [shared] - Shared configuration + * @param {Object} [shared.pages] - Per-key page component overrides + * + * @example + * ```tsx + * const routes = defineRoutes( + * { + * home: defineRoute("/", { page: HomePage }), + * post: defineRoute("/blog/:slug", { + * page: PostPage, + * loader: (ctx) => fetchPost(ctx.params.slug), + * meta: (ctx) => [{ name: "title", content: ctx.params.slug }], + * }), + * }, + * { pages: { post: CustomPostPage } }, + * ); + * + * const router = createRouter(routes); + * ``` + */ +export function defineRoutes< + T extends Record }>, +>( + routes: T, + shared?: { + pages?: { [K in keyof T]?: ComponentType }; + }, +): T { + if (!shared?.pages) { + return routes; + } + const result: Record = {}; + for (const key of Object.keys(routes)) { + const route = routes[key] as Route & { def?: RouteDef }; + const override = shared.pages[key]; + if (!override) { + result[key] = route; + } else if (route.def) { + result[key] = defineRoute( + route.path, + { ...route.def, page: override }, + route.options, + route.meta, + ) as unknown as Route; + } else { + const wrapped = (inputCtx?: any) => ({ + ...route(inputCtx), + PageComponent: override, + }); + wrapped.path = route.path; + wrapped.options = route.options; + wrapped.meta = route.meta; + result[key] = wrapped as unknown as Route; + } + } + return result as T; +} diff --git a/src/index.ts b/src/index.ts index c9ec447..703ad5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ +export * from "./define"; export * from "./router"; export * from "./types";