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