The root layout was stamping the homepage title and production root onto every page. Routes now supply a page segment through one em-dash template, canonicals follow the request path, and non-production hosts disallow crawlers. Co-authored-by: Cursor <cursoragent@cursor.com>
130 lines
3.4 KiB
TypeScript
130 lines
3.4 KiB
TypeScript
import type { Metadata, MetadataRoute } from "next";
|
|
|
|
/** Production origin used for `metadataBase`, canonical resolution, and sitemap URLs. */
|
|
export const PRODUCTION_SITE_ORIGIN = "https://communityrule.com";
|
|
|
|
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;
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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 `alternates.canonical` from the page path (resolved via `metadataBase`). */
|
|
export function routeMetadata(
|
|
pathname: string,
|
|
metadata: Metadata = {},
|
|
): Metadata {
|
|
return {
|
|
...metadata,
|
|
alternates: {
|
|
...metadata.alternates,
|
|
canonical: canonicalPath(pathname),
|
|
},
|
|
};
|
|
}
|
|
|
|
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}`;
|
|
}
|