Files
community-rule/lib/siteMetadata.ts
T
adilalloandCursor c180e79c38 Give every public route its own description, and keep canonicals and structured data on the host that serves the page.
Decorative images on the marketing pages stay quiet, and every dialog close button is named Close.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 11:04:52 -07:00

316 lines
8.4 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. Sitemap URLs and prerendered canonicals use this when
* no request host and no `CLOUDRON_APP_ORIGIN` are available.
*/
export const PRODUCTION_SITE_ORIGIN = "https://communityrule.info";
/** 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));
}
/** Origin baked into prerendered pages: app origin when set, otherwise production. */
export function configuredSiteOrigin(): string {
return originFromHost(null);
}
/** Absolute URL for canonicals and JSON-LD. Same origin as `metadataBase`. */
export function absoluteSiteUrl(
pathname: string,
origin: string = configuredSiteOrigin(),
): string {
const path = canonicalPath(pathname);
if (path === "/") return origin;
return `${origin}${path}`;
}
/** WebSite block. No SearchAction — the site has no search page. */
export function websiteJsonLd(origin: string) {
return {
"@context": "https://schema.org" as const,
"@type": "WebSite" as const,
name: "CommunityRule",
url: origin,
};
}
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 pageTitle = typeof metadata.title === "string" ? metadata.title : undefined;
const pageDescription =
typeof metadata.description === "string" ? metadata.description : undefined;
const mergedOpenGraph = {
images: defaultImages,
...(pageTitle ? { title: pageTitle } : {}),
...(pageDescription ? { description: pageDescription } : {}),
...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" as const,
...(pageTitle ? { title: pageTitle } : {}),
...(pageDescription ? { description: pageDescription } : {}),
images: derivedTwitterImages,
...twitter,
},
};
}
/**
* Link unfurlers. Staging stays out of search indexes, and these agents can
* still fetch the page so a shared link can show its image.
*/
const UNFURL_USER_AGENTS = [
"Twitterbot",
"facebookexternalhit",
"Slackbot",
"Discordbot",
"LinkedInBot",
"TelegramBot",
"WhatsApp",
"Bluesky",
] as const;
export function buildRobots(
host: string | null | undefined,
): MetadataRoute.Robots {
if (!isIndexableHost(host)) {
return {
rules: [
{ userAgent: "*", disallow: "/" },
{ userAgent: [...UNFURL_USER_AGENTS], allow: "/" },
],
};
}
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: `${PRODUCTION_SITE_ORIGIN}/sitemap.xml`,
};
}
export function sitemapUrl(pathname: string): string {
return absoluteSiteUrl(pathname, PRODUCTION_SITE_ORIGIN);
}