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>
270 lines
6.9 KiB
TypeScript
270 lines
6.9 KiB
TypeScript
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}`;
|
||
}
|