Merge branch 'site-aware-pagehead-and-canonical'

This commit is contained in:
2026-07-23 10:13:23 -04:00
3 changed files with 262 additions and 23 deletions

View File

@@ -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);
});
});

View File

@@ -1,17 +1,31 @@
import { Title, Meta, Link } from "@solidjs/meta"; 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 { // Re-export the pure types + resolver so existing imports
title: string; // (`import { PageHead } from "~/components/PageHead"`) plus any consumer that
description?: string; // wants the meta helper resolve from a single module path.
ogImage?: string; export {
ogTitle?: string; resolvePageHeadMeta,
ogDescription?: string; type PageHeadProps,
canonical?: string; type ResolvedPageHeadMeta
} } from "~/components/page-head-meta";
/** /**
* PageHead component for consistent page metadata across the application. * 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 * @example
* ```tsx * ```tsx
@@ -23,27 +37,25 @@ export interface PageHeadProps {
* ``` * ```
*/ */
export default function PageHead(props: 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 ( return (
<> <>
<Title>{fullTitle()}</Title> <Title>{meta().title}</Title>
{props.description && ( {meta().description && (
<Meta name="description" content={props.description} /> <Meta name="description" content={meta().description} />
)} )}
{props.canonical && <Link rel="canonical" href={props.canonical} />} <Link rel="canonical" href={meta().canonical} />
{/* Open Graph / Social Media Tags */} {/* Open Graph / Social Media Tags */}
{(props.ogTitle || props.title) && ( <Meta property="og:title" content={meta().ogTitle} />
<Meta property="og:title" content={props.ogTitle || props.title} /> {meta().ogDescription && (
<Meta property="og:description" content={meta().ogDescription} />
)} )}
{(props.ogDescription || props.description) && ( <Meta property="og:image" content={meta().ogImage} />
<Meta
property="og:description"
content={props.ogDescription || props.description}
/>
)}
{props.ogImage && <Meta property="og:image" content={props.ogImage} />}
</> </>
); );
} }

View File

@@ -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
};
}