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:
1 parent
47625b1c82
commit
751f2e9343
22 files changed
+340
-16
No files matched your search
@@ -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
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user