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://<site.domain><pathname>) - Default og:image to the site ogDefaultImage when not provided - Add unit tests covering all five sites, canonical derivation, and OpenGraph fallback behavior
This commit is contained in:
157
src/components/PageHead.test.ts
Normal file
157
src/components/PageHead.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user