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() {