From e6956f9bc64ee65382c6b12b45226c25c4454153 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 3 Oct 2025 09:44:11 -0400 Subject: [PATCH] feat: expand exta and add route level meta --- package.json | 2 +- src/__tests__/router.test.ts | 165 +++++++++++++++++++++++++++++++++-- src/router.ts | 43 +++++---- src/types.ts | 4 + 4 files changed, 185 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index da622bc..030190f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@olliethedev/yar", - "version": "1.0.8", + "version": "1.0.9", "packageManager": "pnpm@10.14.0", "description": "Pluggable router for modern react frameworks", "type": "module", diff --git a/src/__tests__/router.test.ts b/src/__tests__/router.test.ts index 55007aa..c236aa6 100644 --- a/src/__tests__/router.test.ts +++ b/src/__tests__/router.test.ts @@ -182,12 +182,14 @@ describe("createRoute", () => { const route = createRoute("/with-extra", () => ({ PageComponent: MockComponent, - extra: extraData, + extra: () => extraData, })); const result = route(); expect(result.PageComponent).toBe(MockComponent); - expect(result.extra).toEqual(extraData); + if (result.extra) { + expect(result.extra()).toEqual(extraData); + } }); it("should support typed extra field", () => { @@ -196,11 +198,13 @@ describe("createRoute", () => { const route = createRoute("/typed-extra", () => ({ PageComponent: MockComponent, - extra: extraData, + extra: () => extraData, })); const result = route(); - expect(result.extra).toEqual(extraData); + if (result.extra) { + expect(result.extra()).toEqual(extraData); + } }); }); @@ -493,7 +497,7 @@ describe("createRouter", () => { const routes = { admin: createRoute("/admin", () => ({ PageComponent: MockComponent, - extra: extraData, + extra: () => extraData, })), }; @@ -501,7 +505,9 @@ describe("createRouter", () => { const match = router.getRoute("/admin"); expect(match).toBeDefined(); - expect(match?.extra).toEqual(extraData); + if (match?.extra) { + expect(match.extra()).toEqual(extraData); + } }); }); @@ -779,4 +785,151 @@ describe("Edge cases", () => { ]); } }); + + it("should infer extra function types correctly - no params", () => { + const route = createRoute("/extra-no-params", () => ({ + PageComponent: MockComponent, + extra: () => ({ analytics: "tracking-id-123" }), + })); + + const router = createRouter({ route }); + const match = router.getRoute("/extra-no-params"); + + expect(match?.extra).toBeDefined(); + if (match?.extra) { + const extraData = match.extra(); + expect(extraData).toEqual({ analytics: "tracking-id-123" }); + } + }); + + it("should infer extra function types correctly - with params", () => { + const route = createRoute("/extra-custom", () => ({ + PageComponent: MockComponent, + extra: (userId: string, sessionId: number) => ({ + tracking: { userId, sessionId }, + timestamp: Date.now(), + }), + })); + + const router = createRouter({ route }); + const match = router.getRoute("/extra-custom"); + + expect(match?.extra).toBeDefined(); + if (match?.extra) { + const extraData = match.extra("user-123", 456); + expect(extraData.tracking).toEqual({ + userId: "user-123", + sessionId: 456, + }); + expect(extraData.timestamp).toBeGreaterThan(0); + } + }); + + it("should support async extra functions", async () => { + const route = createRoute("/extra-async", () => ({ + PageComponent: MockComponent, + extra: async (apiKey: string) => { + // Simulate async operation + await new Promise((resolve) => setTimeout(resolve, 10)); + return { + apiKey, + validated: true, + timestamp: Date.now(), + }; + }, + })); + + const router = createRouter({ route }); + const match = router.getRoute("/extra-async"); + + expect(match?.extra).toBeDefined(); + if (match?.extra) { + const extraData = await match.extra("secret-key"); + expect(extraData.apiKey).toBe("secret-key"); + expect(extraData.validated).toBe(true); + expect(extraData.timestamp).toBeGreaterThan(0); + } + }); + + // Type safety test - this DOES cause a compile error now! ✅ + it("should enforce meta shape at compile time", () => { + const route = createRoute("/invalid-meta", () => ({ + PageComponent: MockComponent, + // @ts-expect-error - This correctly fails: invalid meta shape + meta: () => [{ invalid: "property", notAllowed: true }], + })); + + expect(route).toBeDefined(); + }); + + it("should support route-level metadata", () => { + const homeRoute = createRoute( + "/", + () => ({ + PageComponent: MockComponent, + }), + undefined, + { isStatic: true, category: "public" }, + ); + + const userRoute = createRoute( + "/user/:id", + () => ({ + PageComponent: MockComponent, + }), + undefined, + { isStatic: false, category: "user", requiresAuth: true }, + ); + + expect(homeRoute.meta).toEqual({ isStatic: true, category: "public" }); + expect(userRoute.meta).toEqual({ + isStatic: false, + category: "user", + requiresAuth: true, + }); + }); + + it("should allow filtering routes by metadata", () => { + const routes = { + home: createRoute( + "/", + () => ({ PageComponent: MockComponent }), + undefined, + { isStatic: true }, + ), + about: createRoute( + "/about", + () => ({ PageComponent: MockComponent }), + undefined, + { isStatic: true }, + ), + user: createRoute( + "/user/:id", + () => ({ PageComponent: MockComponent }), + undefined, + { isStatic: false, requiresAuth: true }, + ), + }; + + const router = createRouter(routes); + + // Filter static routes without calling handlers + const staticRoutes = Object.entries(router.routes).filter( + ([_, route]) => route.meta?.isStatic === true, + ); + + expect(staticRoutes).toHaveLength(2); + expect(staticRoutes.map(([name]) => name)).toEqual(["home", "about"]); + + // Filter routes requiring auth + const authRoutes = Object.entries(router.routes).filter( + ([_, route]) => + route.meta && + "requiresAuth" in route.meta && + route.meta.requiresAuth === true, + ); + + expect(authRoutes).toHaveLength(1); + expect(authRoutes[0][0]).toBe("user"); + }); }); diff --git a/src/router.ts b/src/router.ts index d736f0b..74ba83c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -8,28 +8,27 @@ import type { InferQuery, InputContext, Route, + RouteMeta, RouteOptions, RouterConfig, } from "./types"; +// Helper type to validate meta function return type +type MetaArray = Array; +type MetaReturnType = MetaArray | Promise; + type HandlerReturn< ComponentProps, LoaderData, - Extra = unknown, - MetaFn extends ( - ...args: any[] - ) => - | Array - | Promise> = () => - | Array - | Promise>, + ExtraFn extends (...args: any[]) => any | Promise = () => any, + MetaFn extends (...args: any[]) => MetaReturnType = () => MetaArray, > = { PageComponent?: ComponentType; LoadingComponent?: ComponentType; ErrorComponent?: ComponentType; loader?: () => LoaderData | Promise; meta?: MetaFn; - extra?: Extra; + extra?: ExtraFn; }; /** @@ -39,14 +38,16 @@ type HandlerReturn< * @template Options - Route options including query parameter validation schema * @template LoaderData - The type of data returned by the optional loader function * @template ComponentProps - The props type for the React component - * @template Extra - The type of extra data returned by the handler - * @template MetaFn - The type of the meta function + * @template ExtraFn - The type of the extra function + * @template MetaFn - The type of the meta function (must return valid React meta elements) + * @template Meta - The type of route-level metadata * * @param {Path} path - The route path pattern with optional dynamic segments * @param {Function} handler - Handler function that receives route context (params, query) and returns component, loader, and meta configuration * @param {Options} [options] - Optional configuration including query parameter validation schema + * @param {Meta} [meta] - Optional route-level metadata for filtering/categorization without executing the handler * - * @returns {Function} A route handler function with path and options attached + * @returns {Function} A route handler function with path, options, and meta attached * * @example * ```ts @@ -57,7 +58,8 @@ type HandlerReturn< * loader: () => fetchUser(params.id), * meta: (data) => [{ name: "title", content: `User ${data.name}` }] * }), - * { query: z.object({ tab: z.string().optional() }) } + * { query: z.object({ tab: z.string().optional() }) }, + * { isStatic: false, requiresAuth: true } * ); * ``` */ @@ -66,21 +68,17 @@ export function createRoute< Options extends RouteOptions, LoaderData, ComponentProps, - Extra = unknown, - MetaFn extends ( - ...args: any[] - ) => - | Array - | Promise> = () => - | Array - | Promise>, + ExtraFn extends (...args: any[]) => any | Promise = () => any, + MetaFn extends (...args: any[]) => MetaReturnType = () => MetaArray, + Meta extends RouteMeta = RouteMeta, >( path: Path, handler: (context: { params: InferParam; query: InferQuery | undefined; - }) => HandlerReturn, + }) => HandlerReturn, options?: Options, + meta?: Meta, ) { type Context = InputContext; const internalHandler = ( @@ -97,6 +95,7 @@ export function createRoute< }; internalHandler.options = options; internalHandler.path = path; + internalHandler.meta = meta; return internalHandler; } diff --git a/src/types.ts b/src/types.ts index 3927394..d9c6fef 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,13 +9,17 @@ export interface RouteOptions { query?: StandardSchemaV1; } +export type RouteMeta = Record; + export type Route< Path extends string = string, Options extends RouteOptions = RouteOptions, Handler extends (inputCtx: any) => any = (inputCtx: any) => any, + Meta extends RouteMeta = RouteMeta, > = Handler & { options?: Options; path: Path; + meta?: Meta; }; export type InputContext<