Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/famous-parts-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@effex/platform": minor
"@effex/router": minor
---

added meta combinator to be able to change title and description of routes
16 changes: 15 additions & 1 deletion apps/docs/src/components/Sidebar.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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")),
),
),
),
),
),
Expand Down
5 changes: 5 additions & 0 deletions apps/docs/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────────────────────────
Expand Down
35 changes: 33 additions & 2 deletions packages/platform/src/Platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { renderToString } from "@effex/dom/server";
import {
Navigation,
NavigationContext,
resolveMeta,
RouteDataContext,
RouteDataProvider,
type Router as EffexRouter,
Expand Down Expand Up @@ -126,8 +127,14 @@ export const generateDocument = (
html: string,
loaderData: Record<string, unknown>,
options?: DocumentOptions,
meta?: { title?: string; description?: string },
): string => {
const title = options?.title ? `<title>${options.title}</title>` : "";
// Route-level meta overrides document-level options
const titleText = meta?.title ?? options?.title;
const title = titleText ? `<title>${titleText}</title>` : "";
const description = meta?.description
? `<meta name="description" content="${meta.description.replace(/"/g, "&quot;")}">`
: "";
const styles = (options?.styles ?? [])
.map((href) => `<link rel="stylesheet" href="${href}">`)
.join("\n ");
Expand All @@ -149,6 +156,7 @@ export const generateDocument = (
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
${title}
${description}
${styles}
${head}
</head>
Expand Down Expand Up @@ -451,14 +459,29 @@ export const toHttpRoutes = <
);
}

// Resolve route meta (title, description)
const routeMetaResolved = resolveMeta(
route as RouteType<string, unknown, unknown, unknown, unknown, unknown>,
{
params: rawRouteParams,
searchParams: rawSearchParams,
data: loaderData,
},
);

// Embed loader data for hydration
const hydrationData: Record<string, unknown> = {
data: loaderData,
actions: actionPaths,
};

return HttpServerResponse.html(
generateDocument(html, hydrationData, options?.document),
generateDocument(
html,
hydrationData,
options?.document,
routeMetaResolved,
),
);
});

Expand Down Expand Up @@ -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<string, unknown> = {
data,
Expand All @@ -828,6 +858,7 @@ export const buildStaticSite = (
html,
hydrationData,
options.document,
pageMeta,
);

return { url: page.url, html: fullHtml } as StaticPage;
Expand Down
14 changes: 13 additions & 1 deletion packages/router/src/Outlet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -147,6 +147,18 @@ const renderRouteWithGuard = <E, R>(
}
}

// 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, {
Expand Down
132 changes: 132 additions & 0 deletions packages/router/src/Route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isRoute,
matchSegments,
parsePath,
resolveMeta,
Route,
routeSpecificity,
type RouteContext,
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading