Files
jiang13-bbs/frontend/lib/siteUrl.ts
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

152 lines
4.7 KiB
TypeScript
Raw 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;
}
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;
}