From 9c3fb19aaf9125d8f879de4a4247784ee7f8331d Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Mon, 30 Mar 2026 09:52:39 -0400 Subject: [PATCH] add meta combinator to router --- .changeset/famous-parts-pull.md | 6 ++ apps/docs/src/components/Sidebar.ts | 16 +++- apps/docs/src/routes.ts | 5 ++ packages/platform/src/Platform.ts | 35 +++++++- packages/router/src/Outlet.ts | 14 ++- packages/router/src/Route.test.ts | 132 ++++++++++++++++++++++++++++ packages/router/src/Route.ts | 123 ++++++++++++++++++++++++++ packages/router/src/index.ts | 7 ++ 8 files changed, 334 insertions(+), 4 deletions(-) create mode 100644 .changeset/famous-parts-pull.md diff --git a/.changeset/famous-parts-pull.md b/.changeset/famous-parts-pull.md new file mode 100644 index 00000000..c85b2903 --- /dev/null +++ b/.changeset/famous-parts-pull.md @@ -0,0 +1,6 @@ +--- +"@effex/platform": minor +"@effex/router": minor +--- + +added meta combinator to be able to change title and description of routes diff --git a/apps/docs/src/components/Sidebar.ts b/apps/docs/src/components/Sidebar.ts index ada5e4b6..657f56ec 100644 --- a/apps/docs/src/components/Sidebar.ts +++ b/apps/docs/src/components/Sidebar.ts @@ -1,4 +1,4 @@ -import { NotebookText } from "lucide-static"; +import { ExternalLink, NotebookText } from "lucide-static"; import { $, collect } from "@effex/dom"; import { Link } from "@effex/router"; @@ -80,6 +80,20 @@ export const Sidebar = (props: { readonly sections: readonly DocSection[] }) => ), ), ), + $.a( + { + href: "https://effex-api.pages.dev", + class: + "mt-4 text-sm text-base-content/50 hover:text-primary transition-colors flex items-center gap-2", + }, + collect( + $.span({ + class: "[&_svg]:w-4 [&_svg]:h-4", + innerHTML: ExternalLink, + }), + $.span({}, $.of("API Reference")), + ), + ), ), ), ), diff --git a/apps/docs/src/routes.ts b/apps/docs/src/routes.ts index 8db9274f..04d7ae7f 100644 --- a/apps/docs/src/routes.ts +++ b/apps/docs/src/routes.ts @@ -44,6 +44,7 @@ const HomeRoute = Route.make("/").pipe( }), render: (data) => HomePage(data), }), + Route.meta({ title: "Effex | Reactive UI Built on Effect.ts" }), ); // ─── Doc pages ─────────────────────────────────────────────────────────────── @@ -80,6 +81,10 @@ const DocRoute = Route.make("/docs/*").pipe( toc: data.toc, }), }), + Route.meta(({ data }) => ({ + title: `${data.page.title} | Effex Docs`, + description: data.page.description, + })), ); // ─── Router ────────────────────────────────────────────────────────────────── diff --git a/packages/platform/src/Platform.ts b/packages/platform/src/Platform.ts index 3e51499b..79b21781 100644 --- a/packages/platform/src/Platform.ts +++ b/packages/platform/src/Platform.ts @@ -36,6 +36,7 @@ import { renderToString } from "@effex/dom/server"; import { Navigation, NavigationContext, + resolveMeta, RouteDataContext, RouteDataProvider, type Router as EffexRouter, @@ -126,8 +127,14 @@ export const generateDocument = ( html: string, loaderData: Record, options?: DocumentOptions, + meta?: { title?: string; description?: string }, ): string => { - const title = options?.title ? `${options.title}` : ""; + // Route-level meta overrides document-level options + const titleText = meta?.title ?? options?.title; + const title = titleText ? `${titleText}` : ""; + const description = meta?.description + ? `` + : ""; const styles = (options?.styles ?? []) .map((href) => ``) .join("\n "); @@ -149,6 +156,7 @@ export const generateDocument = ( ${title} + ${description} ${styles} ${head} @@ -451,6 +459,16 @@ export const toHttpRoutes = < ); } + // Resolve route meta (title, description) + const routeMetaResolved = resolveMeta( + route as RouteType, + { + params: rawRouteParams, + searchParams: rawSearchParams, + data: loaderData, + }, + ); + // Embed loader data for hydration const hydrationData: Record = { data: loaderData, @@ -458,7 +476,12 @@ export const toHttpRoutes = < }; return HttpServerResponse.html( - generateDocument(html, hydrationData, options?.document), + generateDocument( + html, + hydrationData, + options?.document, + routeMetaResolved, + ), ); }); @@ -819,6 +842,13 @@ export const buildStaticSite = ( html = yield* render(element).pipe(Effect.provide(ssrLayers)); } + // Resolve route meta + const pageMeta = resolveMeta(page.route, { + params: page.params, + searchParams: {}, + data, + }); + // Wrap in document shell const hydrationData: Record = { data, @@ -828,6 +858,7 @@ export const buildStaticSite = ( html, hydrationData, options.document, + pageMeta, ); return { url: page.url, html: fullHtml } as StaticPage; diff --git a/packages/router/src/Outlet.ts b/packages/router/src/Outlet.ts index 3ea03172..6aad8cd3 100644 --- a/packages/router/src/Outlet.ts +++ b/packages/router/src/Outlet.ts @@ -4,7 +4,7 @@ import { ControlCtx, reconcile } from "@effex/core"; import { $, Element, type AnimationOptions } from "@effex/dom"; import { buildPath, NavigationContext, type Navigation } from "./Navigation.js"; -import type { Route } from "./Route.js"; +import { resolveMeta, type Route } from "./Route.js"; import { RouteDataContext, RouteDataProvider, @@ -147,6 +147,18 @@ const renderRouteWithGuard = ( } } + // Resolve meta (title, description, etc.) and apply to document + if (route._meta) { + const meta = resolveMeta(route, { + params: currentMatch.params, + searchParams: searchParamsObj, + data: routeData.data, + }); + if (typeof document !== "undefined" && meta.title) { + document.title = meta.title; + } + } + // Build the route element with RouteContext and RouteDataContext provided const routeElement = route.render(routeData.data).pipe( Effect.provideService(route.Params, { diff --git a/packages/router/src/Route.test.ts b/packages/router/src/Route.test.ts index 0d09bca0..0ad91df6 100644 --- a/packages/router/src/Route.test.ts +++ b/packages/router/src/Route.test.ts @@ -5,6 +5,7 @@ import { isRoute, matchSegments, parsePath, + resolveMeta, Route, routeSpecificity, type RouteContext, @@ -427,6 +428,137 @@ describe("isRoute", () => { }); }); +describe("Route.meta", () => { + it("stores meta on the route", () => { + const route = Route.make("/").pipe( + Route.render(() => Effect.succeed(document.createElement("div"))), + Route.meta({ title: "Home" }), + ); + + expect(route._meta).toEqual({ title: "Home" }); + }); + + it("defaults to null when not set", () => { + const route = Route.make("/"); + expect(route._meta).toBeNull(); + }); + + describe("resolveMeta", () => { + const args = { params: {}, searchParams: {}, data: undefined }; + + it("resolves static string fields", () => { + const route = Route.make("/").pipe( + Route.render(() => Effect.succeed(document.createElement("div"))), + Route.meta({ title: "Home", description: "Welcome" }), + ); + + const meta = resolveMeta(route, args); + expect(meta).toEqual({ title: "Home", description: "Welcome" }); + }); + + it("resolves function fields with params", () => { + const route = Route.make("/users/:id").pipe( + Route.params(Schema.Struct({ id: Schema.NumberFromString })), + Route.render(() => Effect.succeed(document.createElement("div"))), + Route.meta({ + title: ({ params }) => `User ${(params as { id: number }).id}`, + }), + ); + + const meta = resolveMeta(route, { + params: { id: 42 }, + searchParams: {}, + data: undefined, + }); + expect(meta).toEqual({ title: "User 42" }); + }); + + it("resolves function fields with loader data", () => { + const route = Route.make("/users/:id").pipe( + Route.get( + () => Effect.succeed({ name: "Alice" }), + () => Effect.succeed(document.createElement("div")), + ), + Route.meta({ + title: ({ data }) => `${(data as { name: string }).name}'s Profile`, + description: ({ data }) => + `Profile page for ${(data as { name: string }).name}`, + }), + ); + + const meta = resolveMeta(route, { + params: {}, + searchParams: {}, + data: { name: "Alice" }, + }); + expect(meta).toEqual({ + title: "Alice's Profile", + description: "Profile page for Alice", + }); + }); + + it("resolves a function returning the full meta object", () => { + const route = Route.make("/users/:id").pipe( + Route.get( + () => Effect.succeed({ name: "Bob", bio: "Loves coding" }), + () => Effect.succeed(document.createElement("div")), + ), + Route.meta(({ data }) => ({ + title: `${(data as { name: string }).name}'s Profile`, + description: (data as { bio: string }).bio, + })), + ); + + const meta = resolveMeta(route, { + params: {}, + searchParams: {}, + data: { name: "Bob", bio: "Loves coding" }, + }); + expect(meta).toEqual({ + title: "Bob's Profile", + description: "Loves coding", + }); + }); + + it("supports mixed static and function fields", () => { + const route = Route.make("/docs/:slug").pipe( + Route.render(() => Effect.succeed(document.createElement("div"))), + Route.meta({ + title: ({ params }) => `${(params as { slug: string }).slug} — Docs`, + description: "Documentation page", + }), + ); + + const meta = resolveMeta(route, { + params: { slug: "intro" }, + searchParams: {}, + data: undefined, + }); + expect(meta).toEqual({ + title: "intro — Docs", + description: "Documentation page", + }); + }); + + it("returns empty object when no meta is set", () => { + const route = Route.make("/"); + const meta = resolveMeta(route, args); + expect(meta).toEqual({}); + }); + + it("omits undefined fields from object form", () => { + const route = Route.make("/").pipe( + Route.render(() => Effect.succeed(document.createElement("div"))), + Route.meta({ title: "Home" }), + ); + + const meta = resolveMeta(route, args); + expect(meta).toEqual({ title: "Home" }); + expect("description" in meta).toBe(false); + }); + }); +}); + describe("Route composition", () => { it("allows chaining multiple combinators", () => { const isAuth = Effect.succeed(true); diff --git a/packages/router/src/Route.ts b/packages/router/src/Route.ts index 611feff8..ec1cac97 100644 --- a/packages/router/src/Route.ts +++ b/packages/router/src/Route.ts @@ -188,6 +188,50 @@ export type GuardOptions = >; }; +// ============================================================================= +// Meta Types +// ============================================================================= + +/** + * Resolved meta values for a route. + */ +export interface RouteMeta { + readonly title?: string; + readonly description?: string; +} + +/** + * Arguments passed to meta field functions. + */ +export interface MetaArgs

{ + readonly params: P; + readonly searchParams: SP; + readonly data: D; +} + +/** + * A single meta field — either a static string or a function of route args. + */ +export type MetaField = + | string + | ((args: MetaArgs) => string); + +/** + * Object form of meta input — each field can be a string or function. + */ +export interface MetaObject { + readonly title?: MetaField; + readonly description?: MetaField; +} + +/** + * Input to `Route.meta` — either an object with per-field values, + * or a function that returns the full meta object. + */ +export type MetaInput = + | MetaObject + | ((args: MetaArgs) => RouteMeta); + /** * A handler entry for POST/PUT/DELETE mutations. */ @@ -284,6 +328,11 @@ export interface Route< params: unknown; }) => Effect.Effect; } | null; + /** + * Route meta configuration — set by `Route.meta`. + * Resolved at render time by Outlet (client) or Platform (SSR/SSG). + */ + readonly _meta: MetaInput | null; } // ============================================================================= @@ -363,6 +412,7 @@ export const make = ( _loader: null, _handlers: [], _staticConfig: null, + _meta: null, }); return route; @@ -903,6 +953,7 @@ export const lazy = ( _loader: null, _handlers: [], _staticConfig: null, + _meta: null, // Store the module loader for the router to use _load: load, }); @@ -910,6 +961,76 @@ export const lazy = ( return route; }; +// ============================================================================= +// Meta +// ============================================================================= + +/** + * Add meta (title, description, etc.) to a route. + * + * Accepts an object with per-field values (string or function), + * or a function that returns the full meta object. + * + * @example + * ```ts + * // Static title + * Route.meta({ title: "Home" }) + * + * // Dynamic title from params + * Route.meta({ title: ({ params }) => `User ${params.id}` }) + * + * // Function returning full meta from loader data + * Route.meta(({ data }) => ({ + * title: `${data.user.name}'s Profile`, + * description: data.user.bio, + * })) + * ``` + */ +export const meta = + (input: MetaInput) => + ( + route: Route, + ): Route => { + return Object.assign(Object.create(RouteProto), { + ...route, + _meta: input, + }); + }; + +/** + * Resolve a route's meta config into concrete string values. + */ +export const resolveMeta = ( + route: Route, + args: MetaArgs, +): RouteMeta => { + if (!route._meta) return {}; + + // Function form — call it directly + if (typeof route._meta === "function") { + return route._meta(args); + } + + // Object form — resolve each field + const result: { title?: string; description?: string } = {}; + + if (route._meta.title !== undefined) { + result.title = + typeof route._meta.title === "function" + ? route._meta.title(args) + : route._meta.title; + } + + if (route._meta.description !== undefined) { + result.description = + typeof route._meta.description === "function" + ? route._meta.description(args) + : route._meta.description; + } + + return result; +}; + // ============================================================================= // Utilities // ============================================================================= @@ -962,6 +1083,8 @@ export const Route = { rawParams, withGuard, withAnimation, + meta, + resolveMeta, lazy, isRoute, catchIf, diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index b2d16f68..06a78dfd 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -25,10 +25,17 @@ export { type PathSegment, type AnimationOptions, type GuardOptions, + meta as routeMeta, + resolveMeta, NoRenderError, type RouteParams, type RouteSearchParams, type RouteData, + type RouteMeta, + type MetaArgs, + type MetaInput, + type MetaField, + type MetaObject, } from "./Route.js"; // Router