Resolve share URLs from the instance host and ship 1200×630 Open Graph images.

Staging no longer advertises production canonicals, and Slack/social previews get a default hero card with template and article overrides.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilalloandCursor committed 2026-09-29 15:41:27 -06:00
1 parent 47625b1c82
commit 751f2e9343
22 files changed
+340 -16

No files matched your search

+9
View File
@@ -153,6 +153,11 @@ export function contentBlogHorizontalPath(slug: string): string {
return `/content/blog/${slug}-horizontal.svg`;
}
/** 1200×630 social preview rasterized from the article's section banner. */
export function contentBlogOgPath(slug: string): string {
return `/content/blog/${slug}-og.png`;
}
/** Wide banner background for ContentBanner at md+ (Figma Section, 1920×672). */
export function contentBlogSectionPath(slug: string): string {
return `/content/blog/${slug}-section.svg`;
@@ -250,6 +255,10 @@ export const ASSETS = {
// Marketing
HERO_IMAGE: "assets/marketing/hero-image.png",
/** Default 1200×630 Open Graph image (hero crop). */
OG_IMAGE: "assets/marketing/og-image.png",
/** Templates-index 1200×630 Open Graph image. */
OG_IMAGE_TEMPLATES: "assets/marketing/og-image-templates.png",
// Top nav union ornaments
UNION_XSM: "assets/shapes/union-xsm.svg",
+133 -3
View File
@@ -1,8 +1,18 @@
import type { Metadata, MetadataRoute } from "next";
import {
ASSETS,
CONTENT_CATALOG_SLUG_ORDER,
contentBlogOgPath,
getAssetPath,
} from "./assetUtils";
import metadataMessages from "../messages/en/metadata.json";
/** Production origin used for `metadataBase`, canonical resolution, and sitemap URLs. */
/** Production origin used for sitemap URLs and as the metadata-host fallback. */
export const PRODUCTION_SITE_ORIGIN = "https://communityrule.com";
/** Open Graph / Twitter large-card raster size. */
const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const;
const INDEXABLE_HOSTS = new Set([
"communityrule.com",
"www.communityrule.com",
@@ -66,6 +76,95 @@ export function resolveRequestHost(
return host;
}
/**
* Absolute origin for canonical / `og:url` resolution. Uses the public
* hostname of this instance (`CLOUDRON_APP_ORIGIN`, the same source
* `resolveRequestHost` uses when the process binds to a loopback address)
* so staging shares do not advertise production.
*/
export function originFromHost(host: string | null | undefined): string {
const fromEnv = (): string | undefined => {
const origin = process.env.CLOUDRON_APP_ORIGIN?.trim();
if (!origin) return undefined;
try {
return new URL(origin).origin;
} catch {
return undefined;
}
};
if (!host) {
return fromEnv() ?? PRODUCTION_SITE_ORIGIN;
}
const hostname = hostnameFromHostHeader(host);
if (!hostname || isLoopbackHost(hostname)) {
if (hostname === "localhost") {
const port = host.includes(":")
? host.slice(host.indexOf(":") + 1).split("/")[0]
: "";
return port ? `http://localhost:${port}` : "http://localhost:3000";
}
return fromEnv() ?? PRODUCTION_SITE_ORIGIN;
}
return `https://${hostname}`;
}
export function metadataBaseUrl(): URL {
return new URL(originFromHost(null));
}
type ShareImage = {
url: string;
width: number;
height: number;
alt: string;
};
function shareImage(url: string, alt: string): ShareImage {
return {
url,
width: OG_IMAGE_SIZE.width,
height: OG_IMAGE_SIZE.height,
alt,
};
}
export function defaultShareImages(): ShareImage[] {
return [
shareImage(
getAssetPath(ASSETS.OG_IMAGE),
metadataMessages.shareImage.defaultAlt,
),
];
}
export function templatesShareImages(): ShareImage[] {
return [
shareImage(
getAssetPath(ASSETS.OG_IMAGE_TEMPLATES),
metadataMessages.shareImage.templatesAlt,
),
];
}
export function articleShareImages(
slug: string,
alt: string,
): ShareImage[] | undefined {
if (
!(CONTENT_CATALOG_SLUG_ORDER as readonly string[]).includes(slug)
) {
return undefined;
}
return [shareImage(contentBlogOgPath(slug), alt)];
}
function twitterImageUrls(images: ShareImage[]): string[] {
return images.map((image) => image.url);
}
/**
* Staging, restore-drill hosts, and local dev are not indexed. Override with
* `SITE_INDEXING=true` / `false` when the hostname check is not enough.
@@ -97,16 +196,47 @@ function withoutQueryOrHash(pathname: string): string {
return pathname.slice(0, end) || "/";
}
/** Merge `alternates.canonical` from the page path (resolved via `metadataBase`). */
/**
* Merge path canonical + `og:url` (resolved against `metadataBase`) and a
* default 1200×630 share image. Callers override `openGraph.images` /
* `twitter.images` for templates and articles.
*/
export function routeMetadata(
pathname: string,
metadata: Metadata = {},
): Metadata {
const path = canonicalPath(pathname);
const defaultImages = defaultShareImages();
const openGraph = metadata.openGraph ?? {};
const twitter = metadata.twitter ?? {};
const mergedOpenGraph = {
images: defaultImages,
...openGraph,
url: openGraph.url ?? path,
};
const ogImages = mergedOpenGraph.images;
const derivedTwitterImages = Array.isArray(ogImages)
? ogImages
.map((image) => {
if (typeof image === "string") return image;
if (image instanceof URL) return image.href;
const url = image.url;
return typeof url === "string" ? url : url.href;
})
.filter((url) => url.length > 0)
: twitterImageUrls(defaultImages);
return {
...metadata,
alternates: {
...metadata.alternates,
canonical: canonicalPath(pathname),
canonical: path,
},
openGraph: mergedOpenGraph,
twitter: {
card: "summary_large_image",
images: derivedTwitterImages,
...twitter,
},
};
}