Files
jiang13-bbs/frontend/lib/siteUrl.ts
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

167 lines
5.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, QueryValue>;
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<Metadata, "alternates"> {
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;
}
/** 书库页路径:命中时公开站三件套(Header/main/Footer)放宽到 1680px,
* 读页三栏与条目卡等内容更宽裕;其余页面保持 1440px。
* 需覆盖三种伪静态风格:/library[/...](default)、/library.html 与 /library-{slug}.html(html/discuz,
* 新章节地址 = 详情路径 + ?sec=,pathname 与详情一致) */
export function isWideShellPath(pathname: string | null | undefined): boolean {
if (!pathname) return false;
const p = pathname.split("?")[0];
return (
p === "/library" ||
p.startsWith("/library/") ||
p === "/library.html" ||
/^\/library-.*\.html$/.test(p)
);
}
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;
}