Merge branch 'site-aware-pagehead-and-canonical'
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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} />}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
70
src/components/page-head-meta.ts
Normal file
70
src/components/page-head-meta.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user