import type { Metadata } from "next"; /** * 公开站点 origin(访客浏览器地址栏),与 Go CORS 的 SITE_URL 同源。 * 不使用 BACKEND_URL / NEXT_PUBLIC_API_URL(那是 Go API,可能是容器内网)。 */ const TRACKING_KEYS = new Set([ "gclid", "gbraid", "wbraid", "dclid", "fbclid", "msclkid", "twclid", "ttclid", "yclid", "_ga", "_gl", "mc_cid", "mc_eid", ]); export type QueryValue = string | number | undefined | null; export type QueryBag = Record; export type FeedSort = "latest" | "new" | "recommended"; function envOriginRaw(): string { return (process.env.SITE_URL || "").trim(); } /** 解析为无尾斜杠的 http(s) origin;路径/账号/内部服务名视为非法 */ export function parseSiteOrigin(raw: string | undefined | null): string | null { const s = raw?.trim() ?? ""; if (!s) return null; let u: URL; try { u = new URL(s); } catch { return null; } if (u.protocol !== "http:" && u.protocol !== "https:") return null; if (u.username || u.password) return null; const host = u.hostname.toLowerCase(); if (!host || host === "api" || host.endsWith(".internal")) return null; const path = u.pathname.replace(/\/+$/, ""); if (path !== "") return null; return `${u.protocol}//${u.host}`; } /** * 运行时公开 origin。 * 开发态未配置时用 http://localhost:3000;生产态缺失或非法则返回 null(不回退 localhost)。 */ export function resolveSiteOrigin(): string | null { const parsed = parseSiteOrigin(envOriginRaw()); if (parsed) return parsed; if (process.env.NODE_ENV !== "production") return "http://localhost:3000"; return null; } export function metadataBaseURL(): URL | undefined { const origin = resolveSiteOrigin(); return origin ? new URL(`${origin}/`) : undefined; } export function isTrackingParam(key: string): boolean { const k = key.toLowerCase(); return k.startsWith("utm_") || TRACKING_KEYS.has(k); } /** 路径规范化:保证以 / 开头,除首页外去掉尾斜杠 */ export function canonicalPath(pathname: string, query?: QueryBag): string { let path = pathname.trim() || "/"; if (!path.startsWith("/")) path = `/${path}`; if (path.length > 1) path = path.replace(/\/+$/, ""); const params = new URLSearchParams(); if (query) { const keys = Object.keys(query).sort(); for (const key of keys) { if (isTrackingParam(key)) continue; const raw = query[key]; if (raw == null || raw === "") continue; params.set(key, String(raw)); } } const qs = params.toString(); return qs ? `${path}?${qs}` : path; } export function absoluteCanonical(pathname: string, query?: QueryBag): string | undefined { const origin = resolveSiteOrigin(); if (!origin) return undefined; return `${origin}${canonicalPath(pathname, query)}`; } /** 写入 generateMetadata 的 alternates.canonical;origin 不可用时不加字段 */ export function canonicalFields(pathname: string, query?: QueryBag): Pick { const href = absoluteCanonical(pathname, query); if (!href) return {}; return { alternates: { canonical: href } }; } /** 页码:非法或 ≤1 视为首页(省略 page) */ export function canonicalPage(raw: string | undefined): number | undefined { const n = parseInt(raw || "1", 10); const page = Number.isFinite(n) ? Math.max(1, n) : 1; return page > 1 ? page : undefined; } /** 板块 id:≤0 或非法则省略 */ export function canonicalBoard(raw: string | undefined): number | undefined { const n = parseInt(raw || "0", 10); if (!Number.isFinite(n) || n <= 0) return undefined; return n; } export function defaultFeedSort(allowComments: boolean): FeedSort { return allowComments ? "latest" : "new"; } /** 与首页 page.tsx 的排序解析一致 */ export function normalizeFeedSort(v: string | undefined, allowComments: boolean): FeedSort { if (v === "recommended") return "recommended"; if (v === "new") return "new"; if (!allowComments) return "new"; if (v === "latest") return "latest"; return "latest"; } /** * 首页 query 归一化:保留 q / 非默认 sort / 有效 board / page>1; * 搜索时 board 不参与列表查询,故省略。 */ export function homeCanonicalQuery( sp: { q?: string; page?: string; sort?: string; board?: string }, allowComments: boolean ): QueryBag { const q = sp.q?.trim() || ""; const bag: QueryBag = {}; if (q) bag.q = q; const page = canonicalPage(sp.page); if (page) bag.page = page; const sort = normalizeFeedSort(sp.sort, allowComments); if (sort !== defaultFeedSort(allowComments)) bag.sort = sort; if (!q) { const board = canonicalBoard(sp.board); if (board) bag.board = board; } return bag; }