From e44998850d89b94f3014c9b0ed1353f1548bf0d4 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 3 Oct 2025 14:01:37 -0400 Subject: [PATCH 1/2] feat: loader function now accepts params --- package.json | 2 +- src/__tests__/router.test.ts | 61 ++++++++++++++++++++++++++++++++++++ src/router.ts | 12 +++---- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 030190f..898ac5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@olliethedev/yar", - "version": "1.0.9", + "version": "1.1.0", "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 c236aa6..0b19f4f 100644 --- a/src/__tests__/router.test.ts +++ b/src/__tests__/router.test.ts @@ -932,4 +932,65 @@ describe("Edge cases", () => { expect(authRoutes).toHaveLength(1); expect(authRoutes[0][0]).toBe("user"); }); + + it("should infer loader function types correctly - no params", async () => { + const route = createRoute("/loader-no-params", () => ({ + PageComponent: MockComponent, + loader: () => ({ data: "static" }), + })); + + const router = createRouter({ route }); + const match = router.getRoute("/loader-no-params"); + + expect(match?.loader).toBeDefined(); + if (match?.loader) { + const data = await match.loader(); + expect(data).toEqual({ data: "static" }); + } + }); + + it("should infer loader function types correctly - with params", async () => { + const route = createRoute("/loader-custom", () => ({ + PageComponent: MockComponent, + loader: (userId: string, options?: { cache: boolean }) => + Promise.resolve({ + userId, + cached: options?.cache ?? false, + }), + })); + + const router = createRouter({ route }); + const match = router.getRoute("/loader-custom"); + + expect(match?.loader).toBeDefined(); + if (match?.loader) { + const data = await match.loader("user-123", { cache: true }); + expect(data.userId).toBe("user-123"); + expect(data.cached).toBe(true); + } + }); + + it("should support loader with AbortSignal parameter", async () => { + const route = createRoute("/loader-signal", () => ({ + PageComponent: MockComponent, + loader: async (signal?: AbortSignal) => { + // Simulate async operation that can be aborted + await new Promise((resolve) => setTimeout(resolve, 10)); + if (signal?.aborted) { + throw new Error("Aborted"); + } + return { data: "loaded" }; + }, + })); + + const router = createRouter({ route }); + const match = router.getRoute("/loader-signal"); + + expect(match?.loader).toBeDefined(); + if (match?.loader) { + const controller = new AbortController(); + const data = await match.loader(controller.signal); + expect(data).toEqual({ data: "loaded" }); + } + }); }); diff --git a/src/router.ts b/src/router.ts index 74ba83c..e9e43be 100644 --- a/src/router.ts +++ b/src/router.ts @@ -19,14 +19,14 @@ type MetaReturnType = MetaArray | Promise; type HandlerReturn< ComponentProps, - LoaderData, + LoaderFn extends (...args: any[]) => any | Promise = () => any, ExtraFn extends (...args: any[]) => any | Promise = () => any, MetaFn extends (...args: any[]) => MetaReturnType = () => MetaArray, > = { PageComponent?: ComponentType; LoadingComponent?: ComponentType; ErrorComponent?: ComponentType; - loader?: () => LoaderData | Promise; + loader?: LoaderFn; meta?: MetaFn; extra?: ExtraFn; }; @@ -36,8 +36,8 @@ type HandlerReturn< * * @template Path - The route path string, which may include dynamic segments (e.g., "/users/:id") * @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 LoaderFn - The type of the loader 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 @@ -55,7 +55,7 @@ type HandlerReturn< * "/user/:id", * ({ params, query }) => ({ * PageComponent: UserPage, - * loader: () => fetchUser(params.id), + * loader: (signal?: AbortSignal) => fetchUser(params.id, signal), * meta: (data) => [{ name: "title", content: `User ${data.name}` }] * }), * { query: z.object({ tab: z.string().optional() }) }, @@ -66,8 +66,8 @@ type HandlerReturn< export function createRoute< Path extends string, Options extends RouteOptions, - LoaderData, ComponentProps, + LoaderFn extends (...args: any[]) => any | Promise = () => any, ExtraFn extends (...args: any[]) => any | Promise = () => any, MetaFn extends (...args: any[]) => MetaReturnType = () => MetaArray, Meta extends RouteMeta = RouteMeta, @@ -76,7 +76,7 @@ export function createRoute< handler: (context: { params: InferParam; query: InferQuery | undefined; - }) => HandlerReturn, + }) => HandlerReturn, options?: Options, meta?: Meta, ) { From 1a2fabfde23906a81d8572b87b77b6f86ca80bd2 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 3 Oct 2025 14:12:08 -0400 Subject: [PATCH 2/2] docs: update --- README.md | 279 +++++++++++++++++++++--------------------------------- 1 file changed, 106 insertions(+), 173 deletions(-) diff --git a/README.md b/README.md index d8bf2c1..383601b 100644 --- a/README.md +++ b/README.md @@ -1,218 +1,151 @@ # @olliethedev/yar (Yet Another Router) -## About +A simple, type-safe router for React that works with any framework. -`@olliethedev/yar` is a simple and pluggable router for modern react frameworks. It is designed to be used as a part of any modern react framework. +## Why use this? - -## Why another router? - -- **Composable**: Routes can be exported from npm packages and combined -- **Framework Flexibility**: Not tied to any specific React framework -- **Simple API**: Just two functions to learn: `createRoute` and `createRouter` -- **Type Safety First**: Unlike many routers, `@olliethedev/yar` provides complete type inference for path parameters and validated query parameters -- **Validation Built-in**: No need to manually validate query parameters - use your favorite schema library +- ✨ **Super Simple** - Only 2 functions: `createRoute` and `createRouter` +- 🔒 **Type Safe** - TypeScript knows your route params automatically +- 🎯 **Flexible** - Works with any React framework +- ✅ **Validated** - Built-in query parameter validation ## Installation ```bash +npm install @olliethedev/yar +# or pnpm add @olliethedev/yar ``` -## API Reference - -### `createRoute(path, handler, options?)` - -Creates a type-safe route definition. - -**Parameters:** -- `path` (string): Route pattern with optional parameters (e.g., `/user/:id`) -- `handler` (function): Function receiving `{ params, query }` and returning: - - `PageComponent?`: Optional React component to render - - `LoadingComponent?`: Optional loading component to show while data loads - - `ErrorComponent?`: Optional error component to show on errors - - `loader?`: Optional async function to load data - - `meta?`: Optional function to generate meta tags (receives loader data) - - `extra?`: Optional field for additional static data (e.g., breadcrumbs, auth requirements, layout config) -- `options?` (object): Optional configuration - - `query`: Standard Schema for query parameter validation - -**Returns:** Route handler function with `path` and `options` properties - -### `createRouter(routes, config?)` - -Creates a router instance from route definitions. - -**Parameters:** -- `routes` (object): Map of route names to route handlers -- `config?` (object): Optional router configuration - - `routerContext?`: Shared context accessible to all routes - -**Returns:** Router object with: -- `routes`: Original routes object -- `getRoute(path, queryParams?)`: Function to match and execute a route - +## Quick Start -## Usage +Here's one complete example showing all features: ```tsx import { createRoute, createRouter } from "@olliethedev/yar"; -import PageA from "@/components/page-a"; -import PageB from "@/components/page-b"; -import { z } from "zod"; - -const pageARoute = createRoute( - "/page-a", - () => ({ - PageComponent: PageA, - meta: () => [ - { name: "title", content: "Page A!" }, - { name: "description", content: "Page A Description" }, - ], - }) +import { z } from "zod"; // or any Standard Schema library +import HomePage from "./pages/home"; +import BlogPostPage from "./pages/blog-post"; + +// 1️⃣ Simple route - just a page +const homeRoute = createRoute( + "/", + () => ({ + PageComponent: HomePage, + meta: () => [{ name: "title", content: "Home" }], + }), + undefined, + { isStatic: true } // 🏷️ Optional: tag routes for filtering for SSG environments ); -const pageBRoute = createRoute( - "/page-b/:id", - (context) => { - const loader = () => dataForPageB(context.params, context.query?.test || "NONE"); - return { - PageComponent: PageB, - loader, - meta: (data?: string) => [ - { name: "title", content: "Page B" }, - { name: "description", content: "Page B Description:" + data }, - { property: "og:title", content: "Page B" }, - { property: "og:description", content: "Page B Description" }, - ], - }; +// 2️⃣ Dynamic route - with params, validation, and data loading +const blogRoute = createRoute( + "/blog/:slug", // :slug becomes available as params.slug + ({ params, query }) => ({ + PageComponent: BlogPostPage, + + // 📦 Load data (can use AbortSignal, options, etc.) + loader: async (signal?: AbortSignal) => { + const res = await fetch(`/api/posts/${params.slug}`, { signal }); + return res.json(); }, - { - query: z.object({ - test: z.string(), - }), - } + + // 📄 Generate meta tags (can use loader data) + meta: (post) => [ + { name: "title", content: post.title }, + { name: "description", content: post.excerpt }, + ], + + // 🎨 Extra data for anything (breadcrumbs, layout, etc.) + extra: () => ({ + breadcrumbs: ["Home", "Blog", params.slug], + layout: "blog", + }), + }), + { + // ✅ Validate query parameters + query: z.object({ + preview: z.boolean().optional(), + }), + }, + { isStatic: false, requiresAuth: false } // 🏷️ Route tags ); -const routes = { - pageA: pageARoute, - pageB: pageBRoute, -} as const; - -export const AppRouter = () => createRouter(routes); - -const dataForPageB = async (params: Record, test: string): Promise => { - return "Computed data: " + params.id + " Test:" + test; -}; - -``` - -## Complete Example: Handling Routes - -Here's a framework-agnostic example showing how to use the router to handle incoming requests: - -```tsx -import { AppRouter } from "./router"; +// 3️⃣ Create router +const router = createRouter({ + home: homeRoute, + blog: blogRoute, +}); -async function handleRequest(pathname: string, queryParams: Record) { - const route = AppRouter().getRoute(pathname, queryParams); +// 4️⃣ Use the router in your app +async function handleRequest(url: string) { + const route = router.getRoute(url); if (!route) { - return { status: 404, html: "

404 - Page Not Found

" }; + return ; } - const { PageComponent, LoadingComponent, ErrorComponent, params, loader, meta, extra } = route; - const data = loader ? await loader() : undefined; - const metaTags = meta ? meta(data) : []; - - return { - status: 200, - metaTags, - element: PageComponent ? : null, - extra, // Additional static data available for the route - }; + // Everything is typed! TypeScript knows the types of all params + const data = route.loader ? await route.loader() : null; + const metaTags = route.meta ? route.meta(data) : []; + const extras = route.extra ? route.extra() : null; + + return ( + + ); } -handleRequest("/page-a", {}); -handleRequest("/page-b/123", { test: "hello" }); +// 5️⃣ Filter routes without running handlers (great for SSG!) +const staticRoutes = Object.values(router.routes) + .filter(route => route.meta?.isStatic); ``` -### Extracting Metadata - -```tsx -async function extractMetadata(pathname: string, queryParams: Record) { - const route = AppRouter().getRoute(pathname, queryParams); - - if (!route || !route.meta) { - return { title: "My Site", description: "" }; - } - - const data = route.loader ? await route.loader() : undefined; - const metaTags = route.meta(data); +## What You Need to Know - const findBy = (predicate: (m: React.JSX.IntrinsicElements["meta"]) => boolean) => - metaTags.find((tag) => tag && predicate(tag)); +### `createRoute(path, handler, options?, routeMeta?)` - const getContent = (m?: React.JSX.IntrinsicElements["meta"]) => - (m && "content" in m ? m.content : undefined) as string | undefined; +Creates a route. The handler returns: +- **`PageComponent`** - Your React component +- **`loader()`** - Load data (can accept any params like `AbortSignal`) +- **`meta()`** - Generate SEO tags (can accept any params) +- **`extra()`** - Any extra data you need (can accept any params) - const titleFromName = getContent(findBy((m) => m.name === "title")); - const titleFromOg = getContent(findBy((m) => m.property === "og:title")); - const descriptionFromName = getContent(findBy((m) => m.name === "description")); - const descriptionFromOg = getContent(findBy((m) => m.property === "og:description")); +The 4th parameter `routeMeta` lets you tag routes for filtering (e.g., `{ isStatic: true }`). - return { - title: titleFromName ?? titleFromOg ?? "My Site", - description: descriptionFromName ?? descriptionFromOg ?? "", - openGraph: { - title: titleFromOg ?? titleFromName, - description: descriptionFromOg ?? descriptionFromName, - }, - }; -} -``` +### `createRouter(routes)` -### Using the `extra` Field +Combines your routes. Returns: +- **`routes`** - All your routes +- **`getRoute(path, query?)`** - Match a URL and get the route -The `extra` field allows you to attach additional static data to routes, such as breadcrumbs, authentication requirements, or layout configurations: +### Key Features +**🎯 Path Parameters** ```tsx -const adminRoute = createRoute( - "/admin/users", - () => ({ - PageComponent: AdminUsersPage, - extra: { - breadcrumbs: ["Home", "Admin", "Users"], - requiresAuth: true, - permissions: ["admin:users:read"], - layout: "admin", - }, - }) -); - -// Later, access the extra data: -const route = router.getRoute("/admin/users"); -if (route?.extra?.requiresAuth) { - // Check authentication -} +"/blog/:slug" → params.slug is automatically typed ``` -### Data-Only Routes +**✅ Query Validation** +```tsx +query: z.object({ sort: z.string() }) +``` -Since `PageComponent` is optional, you can create data-only routes for API endpoints or data fetching: +**🏷️ Route Tags** +```tsx +{ isStatic: true, requiresAuth: false } +// Filter without running handlers - perfect for finding static routes in SSG environments, or for filtering routes that require authentication. +``` +**🎨 Flexible Functions** ```tsx -const apiRoute = createRoute( - "/api/data/:id", - ({ params }) => ({ - loader: async () => { - const response = await fetch(`https://api.example.com/data/${params.id}`); - return response.json(); - }, - extra: { type: "api", version: "v1" }, - }) -); +loader: (signal) => fetch(url, { signal }) +meta: (data) => [{ name: "title", content: data.title }] +extra: (userId) => ({ breadcrumbs: [...], userId }) ``` +Perfect for prefetching data and generating meta tags in SSR environments, or for adding extra data to your routes. ## Contributing @@ -220,4 +153,4 @@ Contributions are welcome! Please feel free to submit a Pull Request. ## License -MIT © [olliethedev](https://github.com/olliethedev) \ No newline at end of file +MIT \ No newline at end of file