diff --git a/public/nook/og.png b/public/nook/og.png new file mode 100644 index 0000000..2c308ce Binary files /dev/null and b/public/nook/og.png differ diff --git a/src/components/PageHead.test.ts b/src/components/PageHead.test.ts index 0dd39f1..c9f6855 100644 --- a/src/components/PageHead.test.ts +++ b/src/components/PageHead.test.ts @@ -132,12 +132,17 @@ describe("resolvePageHeadMeta — canonical URL derivation", () => { }); describe("resolvePageHeadMeta — OpenGraph fallbacks", () => { - it("ogImage defaults to the site's ogDefaultImage when not provided", () => { + it("ogImage defaults to the site's ogDefaultImage, resolved to an absolute URL", () => { const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/"); - expect(meta.ogImage).toBe(SITE_CONFIG.nessa.ogDefaultImage); + expect(meta.ogImage).toBe("https://nessa.freno.me/nessa/og-default.png"); }); - it("explicit ogImage overrides the site default", () => { + it("the nook default resolves to the absolute og.png URL", () => { + const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nook, "/"); + expect(meta.ogImage).toBe("https://nook.freno.me/nook/og.png"); + }); + + it("explicit absolute ogImage passes through unchanged", () => { const meta = resolvePageHeadMeta( { ...BASE_PROPS, ogImage: "https://cdn/custom.png" }, SITE_CONFIG.main, @@ -146,6 +151,15 @@ describe("resolvePageHeadMeta — OpenGraph fallbacks", () => { expect(meta.ogImage).toBe("https://cdn/custom.png"); }); + it("explicit site-relative ogImage is resolved against the site domain", () => { + const meta = resolvePageHeadMeta( + { ...BASE_PROPS, ogImage: "/og/custom.png" }, + SITE_CONFIG.gaze, + "/" + ); + expect(meta.ogImage).toBe("https://gaze.freno.me/og/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"); diff --git a/src/components/PageHead.tsx b/src/components/PageHead.tsx index 5a050b5..5ff623d 100644 --- a/src/components/PageHead.tsx +++ b/src/components/PageHead.tsx @@ -56,6 +56,8 @@ export default function PageHead(props: PageHeadProps) { )} + {/* X/Twitter large-image card; falls back to the og:* tags above. */} + ); } diff --git a/src/components/page-head-meta.ts b/src/components/page-head-meta.ts index 4990130..a824a73 100644 --- a/src/components/page-head-meta.ts +++ b/src/components/page-head-meta.ts @@ -16,6 +16,9 @@ * prefix-strip below also handles any legacy prefixed form, so the canonical * is always the public URL. * - `ogImage` → explicit `props.ogImage` wins; otherwise `site.ogDefaultImage`. + * Either way it is resolved to an **absolute** URL against the site's + * domain — OG consumers (X, Slack, Discord, iMessage) require an absolute + * URL, and a site-relative path in `ogDefaultImage` would 404 otherwise. * - `ogTitle` / `ogDescription` → explicit override wins; otherwise fall * back to the base title (no suffix) / description (existing behavior). */ @@ -49,6 +52,20 @@ export interface ResolvedPageHeadMeta { ogImage: string; } +/** + * Resolve an OpenGraph image reference to an absolute URL. + * + * OG consumers (X, Slack, Discord, LinkedIn, iMessage) require an absolute + * URL; `ogDefaultImage` values are site-relative paths (`/nook/og.png`), so + * they are joined to the site origin here. Already-absolute references + * (`https:`, `data:`, protocol-relative `//`) pass through untouched. + */ +function absolutizeOgImage(image: string, domain: string): string { + if (image.startsWith("//")) return `https:${image}`; + if (/^[a-z][a-z0-9+.-]*:/i.test(image)) return image; + return `https://${domain}${image.startsWith("/") ? "" : "/"}${image}`; +} + export function resolvePageHeadMeta( props: PageHeadProps, site: Site, @@ -71,7 +88,10 @@ export function resolvePageHeadMeta( const canonical = props.canonical ?? `https://${site.domain}${publicPath}`; const ogTitle = props.ogTitle ?? props.title; const ogDescription = props.ogDescription ?? props.description; - const ogImage = props.ogImage ?? site.ogDefaultImage; + const ogImage = absolutizeOgImage( + props.ogImage ?? site.ogDefaultImage, + site.domain + ); return { title, diff --git a/src/lib/site-context.ts b/src/lib/site-context.ts index 1a894bd..1480c3a 100644 --- a/src/lib/site-context.ts +++ b/src/lib/site-context.ts @@ -132,7 +132,7 @@ export const SITE_CONFIG: Record = { titleSuffix: " | The Nook", brandColor: "#4C9FBC", brandColorDark: "#4C9FBC", - ogDefaultImage: "/nook/og-default.png", + ogDefaultImage: "/nook/og.png", faviconPath: "/nook/favicon/favicon.ico" } }; diff --git a/src/routes/nessa/meta.test.ts b/src/routes/nessa/meta.test.ts index 0b4b42e..e04c898 100644 --- a/src/routes/nessa/meta.test.ts +++ b/src/routes/nessa/meta.test.ts @@ -40,13 +40,13 @@ describe("Nessa landing page — PageHead metadata", () => { expect(meta.canonical).toBe("https://nessa.freno.me/"); }); - it("ogImage falls back to the nessa site default", () => { + it("ogImage falls back to the nessa site default, resolved absolute", () => { const meta = resolvePageHeadMeta( NESSA_LANDING_META, SITE_CONFIG.nessa, "/" ); - expect(meta.ogImage).toBe(SITE_CONFIG.nessa.ogDefaultImage); + expect(meta.ogImage).toBe("https://nessa.freno.me/nessa/og-default.png"); }); it("ogTitle uses the explicit marketing copy, not the bare title", () => { diff --git a/src/routes/nook/index.tsx b/src/routes/nook/index.tsx index d774865..87c4a50 100644 --- a/src/routes/nook/index.tsx +++ b/src/routes/nook/index.tsx @@ -57,7 +57,7 @@ export default function NookLanding() {