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}`; }