From 741a6e26a301a5fde4590e076e4540311ba791a8 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 23 Jul 2026 10:04:26 -0400 Subject: [PATCH] feat(PageHead): make page metadata site-aware with auto-derived canonical URLs Extract pure resolvePageHeadMeta helper that derives title suffix, canonical URL, and OpenGraph fallbacks from the active site config and router pathname. PageHead now reads useSite() + useLocation() to render per-subdomain metadata (e.g. " | Nessa", canonical on nessa.freno.me) instead of hardcoding " | Michael Freno". - Always emit a canonical link (explicit override wins; otherwise https://) - Default og:image to the site ogDefaultImage when not provided - Add unit tests covering all five sites, canonical derivation, and OpenGraph fallback behavior --- src/components/PageHead.test.ts | 157 +++++++++++++++++++++++++++++++ src/components/PageHead.tsx | 58 +++++++----- src/components/page-head-meta.ts | 70 ++++++++++++++ 3 files changed, 262 insertions(+), 23 deletions(-) create mode 100644 src/components/PageHead.test.ts create mode 100644 src/components/page-head-meta.ts diff --git a/src/components/PageHead.test.ts b/src/components/PageHead.test.ts new file mode 100644 index 0000000..ef211c3 --- /dev/null +++ b/src/components/PageHead.test.ts @@ -0,0 +1,157 @@ +/** + * Unit tests for `PageHead` site-aware metadata derivation (task 02). + * + * `resolvePageHeadMeta` is a pure function over (props, site, pathname), so + * these tests mirror the acceptance matrix without a DOM / SolidJS router. + * The render layer (`PageHead` component) is a thin wrapper over this function. + */ +import { describe, it, expect } from "bun:test"; +import { + resolvePageHeadMeta, + type PageHeadProps +} from "~/components/page-head-meta"; +import { SITE_CONFIG, type SiteId } from "~/lib/site-context"; + +const BASE_PROPS: PageHeadProps = { + title: "Blog", + description: "Technical blog posts about web development." +}; + +describe("resolvePageHeadMeta — title suffix per site", () => { + const cases: Array<{ id: SiteId; suffix: string }> = [ + { id: "main", suffix: " | Michael Freno" }, + { id: "nessa", suffix: " | Nessa" }, + { id: "lineage", suffix: " | Life and Lineage" }, + { id: "gaze", suffix: " | Gaze" }, + { id: "inputhalo", suffix: " | InputHalo" } + ]; + + for (const { id, suffix } of cases) { + it(`${id} → title is "${BASE_PROPS.title}${suffix}"`, () => { + const meta = resolvePageHeadMeta( + BASE_PROPS, + SITE_CONFIG[id], + "/blog" + ); + expect(meta.title).toBe(`${BASE_PROPS.title}${suffix}`); + }); + } + + it("main produces 'Home | Michael Freno' for the homepage", () => { + const meta = resolvePageHeadMeta( + { title: "Home" }, + SITE_CONFIG.main, + "/" + ); + expect(meta.title).toBe("Home | Michael Freno"); + }); +}); + +describe("resolvePageHeadMeta — canonical URL derivation", () => { + it("main → canonical starts with https://freno.me", () => { + const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/"); + expect(meta.canonical).toBe("https://freno.me/"); + }); + + it("main blog → https://freno.me/blog", () => { + const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/blog"); + expect(meta.canonical).toBe("https://freno.me/blog"); + }); + + it("nessa → canonical starts with https://nessa.freno.me", () => { + const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/"); + expect(meta.canonical).toBe("https://nessa.freno.me/"); + }); + + it("nessa /contact → https://nessa.freno.me/contact", () => { + const meta = resolvePageHeadMeta( + BASE_PROPS, + SITE_CONFIG.nessa, + "/contact" + ); + expect(meta.canonical).toBe("https://nessa.freno.me/contact"); + }); + + it("lineage → canonical starts with https://lineage.freno.me", () => { + const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.lineage, "/"); + expect(meta.canonical.startsWith("https://lineage.freno.me")).toBe(true); + }); + + it("gaze → canonical starts with https://gaze.freno.me", () => { + const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.gaze, "/"); + expect(meta.canonical.startsWith("https://gaze.freno.me")).toBe(true); + }); + + it("inputhalo → canonical starts with https://inputhalo.freno.me", () => { + const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.inputhalo, "/"); + expect(meta.canonical.startsWith("https://inputhalo.freno.me")).toBe(true); + }); + + it("preserves the full pathname including nested segments + search is NOT included", () => { + // useLocation().pathname excludes the query string; canonical should too. + const meta = resolvePageHeadMeta( + BASE_PROPS, + SITE_CONFIG.main, + "/blog/my-post" + ); + expect(meta.canonical).toBe("https://freno.me/blog/my-post"); + }); + + it("explicit `canonical` prop overrides auto-derivation", () => { + const meta = resolvePageHeadMeta( + { ...BASE_PROPS, canonical: "https://example.com/override" }, + SITE_CONFIG.nessa, + "/contact" + ); + expect(meta.canonical).toBe("https://example.com/override"); + }); +}); + +describe("resolvePageHeadMeta — OpenGraph fallbacks", () => { + it("ogImage defaults to the site's ogDefaultImage when not provided", () => { + const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/"); + expect(meta.ogImage).toBe(SITE_CONFIG.nessa.ogDefaultImage); + }); + + it("explicit ogImage overrides the site default", () => { + const meta = resolvePageHeadMeta( + { ...BASE_PROPS, ogImage: "https://cdn/custom.png" }, + SITE_CONFIG.main, + "/" + ); + expect(meta.ogImage).toBe("https://cdn/custom.png"); + }); + + it("ogTitle falls back to the base title (no suffix)", () => { + const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/"); + expect(meta.ogTitle).toBe("Blog"); + }); + + it("explicit ogTitle overrides the title fallback", () => { + const meta = resolvePageHeadMeta( + { ...BASE_PROPS, ogTitle: "Custom OG Title" }, + SITE_CONFIG.main, + "/" + ); + expect(meta.ogTitle).toBe("Custom OG Title"); + }); + + it("ogDescription falls back to description", () => { + const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/"); + expect(meta.ogDescription).toBe(BASE_PROPS.description); + }); + + it("explicit ogDescription overrides the description fallback", () => { + const meta = resolvePageHeadMeta( + { ...BASE_PROPS, ogDescription: "Custom OG desc" }, + SITE_CONFIG.main, + "/" + ); + expect(meta.ogDescription).toBe("Custom OG desc"); + }); + + it("description is passed through unchanged", () => { + const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/"); + expect(meta.description).toBe(BASE_PROPS.description); + }); +}); diff --git a/src/components/PageHead.tsx b/src/components/PageHead.tsx index 06b85bf..ba4446d 100644 --- a/src/components/PageHead.tsx +++ b/src/components/PageHead.tsx @@ -1,17 +1,31 @@ import { Title, Meta, Link } from "@solidjs/meta"; +import { useLocation } from "@solidjs/router"; +import { useSite } from "~/context/SiteContext"; +import { + resolvePageHeadMeta, + type PageHeadProps +} from "~/components/page-head-meta"; -export interface PageHeadProps { - title: string; - description?: string; - ogImage?: string; - ogTitle?: string; - ogDescription?: string; - canonical?: string; -} +// Re-export the pure types + resolver so existing imports +// (`import { PageHead } from "~/components/PageHead"`) plus any consumer that +// wants the meta helper resolve from a single module path. +export { + resolvePageHeadMeta, + type PageHeadProps, + type ResolvedPageHeadMeta +} from "~/components/page-head-meta"; /** * PageHead component for consistent page metadata across the application. - * Automatically appends " | Michael Freno" to the title. + * + * Site-aware (task 02): reads `useSite()` for the per-site title suffix, + * canonical domain, and default OpenGraph image, so the same component + * renders `" | Michael Freno"` / `" | Nessa"` / … depending on the active + * subdomain. Canonical URLs are auto-derived from the site domain + the + * current router pathname unless an explicit `canonical` override is given. + * + * The actual derivation lives in the pure `resolvePageHeadMeta` helper (see + * `~/components/page-head-meta.ts`) so it can be unit-tested without a DOM. * * @example * ```tsx @@ -23,27 +37,25 @@ export interface PageHeadProps { * ``` */ export default function PageHead(props: PageHeadProps) { - const fullTitle = () => `${props.title} | Michael Freno`; + const site = useSite(); + const location = useLocation(); + + const meta = () => resolvePageHeadMeta(props, site(), location.pathname); return ( <> - {fullTitle()} - {props.description && ( - + {meta().title} + {meta().description && ( + )} - {props.canonical && } + {/* Open Graph / Social Media Tags */} - {(props.ogTitle || props.title) && ( - + + {meta().ogDescription && ( + )} - {(props.ogDescription || props.description) && ( - - )} - {props.ogImage && } + ); } diff --git a/src/components/page-head-meta.ts b/src/components/page-head-meta.ts new file mode 100644 index 0000000..769027b --- /dev/null +++ b/src/components/page-head-meta.ts @@ -0,0 +1,70 @@ +/** + * Pure metadata derivation for `PageHead` (task 02). + * + * Intentionally imports NOTHING from solid-js / @solidjs/router / @solidjs/meta + * so it can be unit-tested in `bun:test` without spinning up the SolidJS + * router + MetaProvider + DOM (which this repo does not configure). The + * `PageHead` component is a thin render layer over this function. + * + * Rules: + * - `title` → `props.title + site.titleSuffix` + * - `canonical` → explicit `props.canonical` override wins; otherwise + * `https://${site.domain}${pathname}`. The pathname is the *browser* path + * (from `useLocation`), which is correct because vercel.json host rewrites + * target internal route prefixes (`/nessa`, `/lineage`, …) while leaving + * the public URL intact — so `nessa.freno.me/contact` reports pathname + * `/contact`, and the canonical is `https://nessa.freno.me/contact`. + * - `ogImage` → explicit `props.ogImage` wins; otherwise `site.ogDefaultImage`. + * - `ogTitle` / `ogDescription` → explicit override wins; otherwise fall + * back to the base title (no suffix) / description (existing behavior). + */ +import type { Site } from "~/lib/site-context"; + +export interface PageHeadProps { + title: string; + description?: string; + ogImage?: string; + ogTitle?: string; + ogDescription?: string; + canonical?: string; +} + +/** + * Fully-resolved metadata computed by {@link resolvePageHeadMeta}. `PageHead` + * renders these verbatim. Kept as an exported type so call sites / tests can + * assert against the exact values without a DOM render. + */ +export interface ResolvedPageHeadMeta { + /** Page title with the active site's `titleSuffix` appended. */ + title: string; + description?: string; + /** Canonical absolute URL for the current route. */ + canonical: string; + /** OpenGraph title (falls back to the page title without suffix). */ + ogTitle: string; + /** OpenGraph description (falls back to `description`). */ + ogDescription?: string; + /** OpenGraph image (defaults to the site's `ogDefaultImage`). */ + ogImage: string; +} + +export function resolvePageHeadMeta( + props: PageHeadProps, + site: Site, + pathname: string +): ResolvedPageHeadMeta { + const title = `${props.title}${site.titleSuffix}`; + const canonical = props.canonical ?? `https://${site.domain}${pathname}`; + const ogTitle = props.ogTitle ?? props.title; + const ogDescription = props.ogDescription ?? props.description; + const ogImage = props.ogImage ?? site.ogDefaultImage; + + return { + title, + description: props.description, + canonical, + ogTitle, + ogDescription, + ogImage + }; +}