Files
community-rule/lib/siteMetadata.ts
T
adilalloandCursor 751f2e9343 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>
2026-09-29 15:41:27 -06:00

270 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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",
"communityrule.info",
"www.communityrule.info",
]);
export const NO_INDEX_ROBOTS = { index: false, follow: false } as const;
/** Unlisted (guest-claim) rule pages stay shareable but must not be indexed. */
export function robotsForPublishedRule(isListed: boolean):
| {
index: false;
follow: false;
}
| undefined {
return isListed ? undefined : NO_INDEX_ROBOTS;
}
function hostnameFromHostHeader(host: string | null | undefined): string {
if (!host) return "";
return host.split(",")[0]?.trim().split(":")[0]?.toLowerCase() ?? "";
}
function isLoopbackHost(hostname: string): boolean {
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "0.0.0.0" ||
hostname === "::1"
);
}
/**
* Prefer the public hostname Cloudron's proxy forwards. Standalone binds
* `0.0.0.0`, which is not a crawlable host — fall back to `CLOUDRON_APP_ORIGIN`.
*/
export function resolveRequestHost(
forwardedHost: string | null,
host: string | null,
): string | null {
if (forwardedHost) {
const first = forwardedHost.split(",")[0]?.trim();
if (first) return first;
}
if (host) {
const hostname = hostnameFromHostHeader(host);
if (!isLoopbackHost(hostname)) return host;
}
const origin = process.env.CLOUDRON_APP_ORIGIN?.trim();
if (origin) {
try {
return new URL(origin).host;
} catch {
return host;
}
}
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.
*/
function isIndexableHost(host: string | null | undefined): boolean {
const override = process.env.SITE_INDEXING?.trim().toLowerCase();
if (override === "true") return true;
if (override === "false") return false;
return INDEXABLE_HOSTS.has(hostnameFromHostHeader(host));
}
function canonicalPath(pathname: string): string {
const trimmed = pathname.trim();
if (trimmed === "" || trimmed === "/") return "/";
const withLeading = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
const withoutQuery = withoutQueryOrHash(withLeading);
if (withoutQuery.length > 1 && withoutQuery.endsWith("/")) {
return withoutQuery.slice(0, -1);
}
return withoutQuery;
}
function withoutQueryOrHash(pathname: string): string {
const q = pathname.indexOf("?");
const h = pathname.indexOf("#");
let end = pathname.length;
if (q !== -1) end = Math.min(end, q);
if (h !== -1) end = Math.min(end, h);
return pathname.slice(0, end) || "/";
}
/**
* 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: path,
},
openGraph: mergedOpenGraph,
twitter: {
card: "summary_large_image",
images: derivedTwitterImages,
...twitter,
},
};
}
export function buildRobots(
host: string | null | undefined,
): MetadataRoute.Robots {
if (!isIndexableHost(host)) {
return {
rules: {
userAgent: "*",
disallow: "/",
},
};
}
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: `${PRODUCTION_SITE_ORIGIN}/sitemap.xml`,
};
}
export function sitemapUrl(pathname: string): string {
const path = canonicalPath(pathname);
if (path === "/") return PRODUCTION_SITE_ORIGIN;
return `${PRODUCTION_SITE_ORIGIN}${path}`;
}