新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
152 lines
4.7 KiB
TypeScript
152 lines
4.7 KiB
TypeScript
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;
|
||
}
|