新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
285 lines
13 KiB
TypeScript
285 lines
13 KiB
TypeScript
/**
|
||
* 详情页伪静态风格的单一事实源:
|
||
* middleware(改写/301)、URL 构造器、后台设置、测试共用本模块。
|
||
* 风格枚举与后端 service.UrlStyles 保持一致:default / html / discuz。
|
||
*
|
||
* 各风格模板(自建页 slug、排行榜与书库也参与;slug 非 ASCII 时按 encodeURIComponent 编码):
|
||
* default: /post/123 /u/5 /announcement/1 /p/about /leaderboard /library /library/{slug} /library/{slug}/read?c={书内章号}
|
||
* html: /post-123.html /user-5.html /announcement-1.html /p-about.html /leaderboard.html /library.html /library-{slug}.html (章节:/library-{slug}.html?sec={书内章号})
|
||
* discuz: /thread-123.html /space-uid-5.html /notice-1.html /page-about.html /rank.html (书库两风格同 html 模板)
|
||
* 书库章节走详情路径 + ?sec= 查询参数(章内子目录锚点 #e-{sec}-{章内序号});旧版 -read-{n} 路径
|
||
* 不再生成,但正则保留可解析(middleware 改写回标准读页),旧链接不失效。
|
||
*/
|
||
|
||
export type UrlStyle = "default" | "html" | "discuz";
|
||
export type EntityKind = "post" | "user" | "announcement";
|
||
/** 全部可反解的实体类型;page/libraryDoc/libraryRead 的 id 是 slug(字符串),leaderboard/library 是固定单页无 id */
|
||
export type PageKind = EntityKind | "page" | "leaderboard" | "library" | "libraryDoc" | "libraryRead";
|
||
|
||
export const URL_STYLES: readonly UrlStyle[] = ["default", "html", "discuz"];
|
||
|
||
export function normalizeUrlStyle(v: unknown): UrlStyle {
|
||
const s = String(v ?? "").trim().toLowerCase();
|
||
return (URL_STYLES as readonly string[]).includes(s) ? (s as UrlStyle) : "default";
|
||
}
|
||
|
||
const PRETTY_TEMPLATES: Record<Exclude<UrlStyle, "default">, Record<EntityKind, (id: number) => string>> = {
|
||
html: {
|
||
post: (id) => `/post-${id}.html`,
|
||
user: (id) => `/user-${id}.html`,
|
||
announcement: (id) => `/announcement-${id}.html`,
|
||
},
|
||
discuz: {
|
||
post: (id) => `/thread-${id}.html`,
|
||
user: (id) => `/space-uid-${id}.html`,
|
||
announcement: (id) => `/notice-${id}.html`,
|
||
},
|
||
};
|
||
|
||
const STANDARD_TEMPLATES: Record<EntityKind, (id: number) => string> = {
|
||
post: (id) => `/post/${id}`,
|
||
user: (id) => `/u/${id}`,
|
||
announcement: (id) => `/announcement/${id}`,
|
||
};
|
||
|
||
const PAGE_TEMPLATES: Record<Exclude<UrlStyle, "default">, (slug: string) => string> = {
|
||
html: (slug) => `/p-${slug}.html`,
|
||
discuz: (slug) => `/page-${slug}.html`,
|
||
};
|
||
|
||
const LEADERBOARD_TEMPLATES: Record<Exclude<UrlStyle, "default">, string> = {
|
||
html: "/leaderboard.html",
|
||
discuz: "/rank.html",
|
||
};
|
||
|
||
/** 书库伪静态模板:两风格统一(书库非 Discuz 原生概念);章节在详情路径上追加 ?sec= */
|
||
const LIBRARY_TEMPLATES: Record<Exclude<UrlStyle, "default">, { list: string; doc: (slug: string) => string }> = {
|
||
html: {
|
||
list: "/library.html",
|
||
doc: (slug) => `/library-${slug}.html`,
|
||
},
|
||
discuz: {
|
||
list: "/library.html",
|
||
doc: (slug) => `/library-${slug}.html`,
|
||
},
|
||
};
|
||
|
||
/** id 非法(≤0/非有限数)时返回空串,调用方按异常路径处理 */
|
||
function guardId(id: number): number | null {
|
||
return Number.isFinite(id) && id > 0 ? id : null;
|
||
}
|
||
|
||
/** 帖子详情路径;default 与 pretty 风格共用 */
|
||
export function postPath(style: UrlStyle, id: number): string {
|
||
const n = guardId(id);
|
||
if (n == null) return "";
|
||
return style === "default" ? STANDARD_TEMPLATES.post(n) : PRETTY_TEMPLATES[style].post(n);
|
||
}
|
||
|
||
/** 用户主页路径;?tab= 等查询参数由调用方拼接 */
|
||
export function userPath(style: UrlStyle, id: number): string {
|
||
const n = guardId(id);
|
||
if (n == null) return "";
|
||
return style === "default" ? STANDARD_TEMPLATES.user(n) : PRETTY_TEMPLATES[style].user(n);
|
||
}
|
||
|
||
/** 公告详情路径 */
|
||
export function announcementPath(style: UrlStyle, id: number): string {
|
||
const n = guardId(id);
|
||
if (n == null) return "";
|
||
return style === "default" ? STANDARD_TEMPLATES.announcement(n) : PRETTY_TEMPLATES[style].announcement(n);
|
||
}
|
||
|
||
/** 自建页路径:slug 走当前风格,非 ASCII 由 encodeURIComponent 编码 */
|
||
export function pagePath(style: UrlStyle, slug: string): string {
|
||
const enc = encodeURIComponent(slug.trim());
|
||
if (!enc) return "/p/";
|
||
return joinPagePath(style, enc);
|
||
}
|
||
|
||
/** 自建页路径(slug 已是 URL 编码形态,如来自路径反解);内部/ middleware 共用 */
|
||
export function pagePathFromSlug(style: UrlStyle, encodedSlug: string): string {
|
||
return joinPagePath(style, encodedSlug);
|
||
}
|
||
|
||
function joinPagePath(style: UrlStyle, enc: string): string {
|
||
return style === "default" ? `/p/${enc}` : PAGE_TEMPLATES[style](enc);
|
||
}
|
||
|
||
/** 排行榜路径(固定单页) */
|
||
export function leaderboardPath(style: UrlStyle): string {
|
||
return style === "default" ? "/leaderboard" : LEADERBOARD_TEMPLATES[style];
|
||
}
|
||
|
||
/** 书库列表路径:default 标准路径,html/discuz 伪静态(两风格统一模板) */
|
||
export function libraryPath(style: UrlStyle): string {
|
||
return style === "default" ? "/library" : LIBRARY_TEMPLATES[style].list;
|
||
}
|
||
|
||
/** 书库条目详情路径 */
|
||
export function libraryDocPath(style: UrlStyle, slug: string): string {
|
||
return libraryDocPathFromSlug(style, encodeURIComponent(slug.trim()));
|
||
}
|
||
|
||
/** 书库条目详情路径(slug 已是 URL 编码形态);middleware 301 复用,避免二次编码 */
|
||
export function libraryDocPathFromSlug(style: UrlStyle, encodedSlug: string): string {
|
||
if (style === "default") {
|
||
return encodedSlug ? `/library/${encodedSlug}` : "/library";
|
||
}
|
||
return encodedSlug ? LIBRARY_TEMPLATES[style].doc(encodedSlug) : libraryPath(style);
|
||
}
|
||
|
||
/** 书库阅读页路径。
|
||
* chapterNo 为书内章序号(1 起,非章节全局 id):
|
||
* default → /library/{slug}/read?c={n};html/discuz → /library-{slug}.html?sec={n}(缺省第一章)。
|
||
* 章内子目录锚点为 #e-{sec}-{章内序号}(见读页),如 ?sec=1#e-1-1 */
|
||
export function libraryDocReadPath(style: UrlStyle, slug: string, chapterNo?: number): string {
|
||
const enc = encodeURIComponent(slug.trim());
|
||
if (style !== "default") {
|
||
if (!enc) return libraryPath(style);
|
||
const no = guardId(chapterNo ?? 0) ?? 1;
|
||
return `${LIBRARY_TEMPLATES[style].doc(enc)}?sec=${no}`;
|
||
}
|
||
const base = enc ? `/library/${enc}/read` : "/library";
|
||
const no = guardId(chapterNo ?? 0);
|
||
return no ? `${base}?c=${no}` : base;
|
||
}
|
||
|
||
/** 实体对应风格下的标准/pretty 路径(内部与 middleware 共用) */
|
||
export function entityPath(style: UrlStyle, kind: EntityKind, id: number): string {
|
||
if (kind === "post") return postPath(style, id);
|
||
if (kind === "user") return userPath(style, id);
|
||
return announcementPath(style, id);
|
||
}
|
||
|
||
/** 标准内部路由路径(middleware rewrite 的落点) */
|
||
export function standardEntityPath(kind: EntityKind, id: number): string {
|
||
return STANDARD_TEMPLATES[kind](id);
|
||
}
|
||
|
||
export type PrettyMatch =
|
||
| { style: Exclude<UrlStyle, "default">; kind: EntityKind; id: number }
|
||
| { style: Exclude<UrlStyle, "default">; kind: "page"; slug: string }
|
||
| { style: Exclude<UrlStyle, "default">; kind: "libraryDoc"; slug: string }
|
||
| { style: Exclude<UrlStyle, "default">; kind: "libraryRead"; slug: string; id: number }
|
||
| { style: Exclude<UrlStyle, "default">; kind: "library" }
|
||
| { style: Exclude<UrlStyle, "default">; kind: "leaderboard" };
|
||
|
||
// 精确锚定:id 仅纯数字,天然排除 /post/123/edit 这类子路径与伪造路径;
|
||
// page/libraryDoc 的 slug 捕获保持 URL 原样(encoded),rewrite 落点直接复用。
|
||
// 书库两风格同模板:html 条目在前作首条兜底,middleware 按 prefer 优先取当前风格;
|
||
// 旧版章节模板 -read-{n} 仍须排在详情模板之前(否则会被 libraryDoc 的 [^/]+ 吞掉 -read-2),仅用于旧链接解析
|
||
const PRETTY_PATTERNS: { re: RegExp; style: Exclude<UrlStyle, "default">; kind: PageKind }[] = [
|
||
{ re: /^\/post-(\d+)\.html$/, style: "html", kind: "post" },
|
||
{ re: /^\/user-(\d+)\.html$/, style: "html", kind: "user" },
|
||
{ re: /^\/announcement-(\d+)\.html$/, style: "html", kind: "announcement" },
|
||
{ re: /^\/p-([^/]+)\.html$/, style: "html", kind: "page" },
|
||
{ re: /^\/leaderboard\.html$/, style: "html", kind: "leaderboard" },
|
||
{ re: /^\/library\.html$/, style: "html", kind: "library" },
|
||
{ re: /^\/library-(.+)-read-(\d+)\.html$/, style: "html", kind: "libraryRead" },
|
||
{ re: /^\/library-([^/]+)\.html$/, style: "html", kind: "libraryDoc" },
|
||
{ re: /^\/thread-(\d+)\.html$/, style: "discuz", kind: "post" },
|
||
{ re: /^\/space-uid-(\d+)\.html$/, style: "discuz", kind: "user" },
|
||
{ re: /^\/notice-(\d+)\.html$/, style: "discuz", kind: "announcement" },
|
||
{ re: /^\/page-([^/]+)\.html$/, style: "discuz", kind: "page" },
|
||
{ re: /^\/rank\.html$/, style: "discuz", kind: "leaderboard" },
|
||
{ re: /^\/library\.html$/, style: "discuz", kind: "library" },
|
||
{ re: /^\/library-(.+)-read-(\d+)\.html$/, style: "discuz", kind: "libraryRead" },
|
||
{ re: /^\/library-([^/]+)\.html$/, style: "discuz", kind: "libraryDoc" },
|
||
];
|
||
|
||
const STANDARD_PATTERNS: { re: RegExp; kind: PageKind }[] = [
|
||
{ re: /^\/post\/(\d+)$/, kind: "post" },
|
||
{ re: /^\/u\/(\d+)$/, kind: "user" },
|
||
{ re: /^\/announcement\/(\d+)$/, kind: "announcement" },
|
||
{ re: /^\/p\/([^/]+)$/, kind: "page" },
|
||
{ re: /^\/leaderboard\/?$/, kind: "leaderboard" },
|
||
// ([^/]+)$ 尾部锚定:/library/{slug}/read 不会命中详情模式(读页单独识别,?c= 走 query)
|
||
{ re: /^\/library\/?$/, kind: "library" },
|
||
{ re: /^\/library\/([^/]+)\/read$/, kind: "libraryRead" },
|
||
{ re: /^\/library\/([^/]+)$/, kind: "libraryDoc" },
|
||
];
|
||
|
||
/**
|
||
* 匹配任一伪静态风格路径(不含标准风格);命中返回风格/实体/id;
|
||
* page/libraryDoc 的 slug 保持 encoded 原样。
|
||
* prefer:同一模板存在多风格条目时优先返回该风格的匹配(middleware 传入当前
|
||
* 风格,避免书库两风格同模板被判成另一风格 → 301 目标与自身相同而死循环);
|
||
* 缺省取首条命中。
|
||
*/
|
||
export function matchPrettyPath(
|
||
pathname: string,
|
||
prefer?: Exclude<UrlStyle, "default">
|
||
): PrettyMatch | null {
|
||
let fallback: PrettyMatch | null = null;
|
||
for (const p of PRETTY_PATTERNS) {
|
||
const m = p.re.exec(pathname);
|
||
if (!m) continue;
|
||
let hit: PrettyMatch;
|
||
if (p.kind === "page" || p.kind === "libraryDoc") {
|
||
hit = { style: p.style, kind: p.kind, slug: m[1] };
|
||
} else if (p.kind === "libraryRead") {
|
||
hit = { style: p.style, kind: p.kind, slug: m[1], id: Number(m[2]) };
|
||
} else if (p.kind === "library" || p.kind === "leaderboard") {
|
||
hit = { style: p.style, kind: p.kind };
|
||
} else {
|
||
hit = { style: p.style, kind: p.kind, id: Number(m[1]) };
|
||
}
|
||
if (!fallback) fallback = hit;
|
||
if (prefer && p.style === prefer) return hit;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
/** 匹配标准实体路径;page/libraryDoc/libraryRead 的 slug 保持 encoded 原样(与 URL 一致,rewrite 直接复用) */
|
||
export function matchStandardPath(pathname: string): StandardMatch | null {
|
||
for (const p of STANDARD_PATTERNS) {
|
||
const m = p.re.exec(pathname);
|
||
if (!m) continue;
|
||
if (p.kind === "page" || p.kind === "libraryDoc" || p.kind === "libraryRead") {
|
||
return { kind: p.kind, slug: m[1] };
|
||
}
|
||
if (p.kind === "library" || p.kind === "leaderboard") return { kind: p.kind };
|
||
return { kind: p.kind, id: Number(m[1]) };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export type StandardMatch =
|
||
| { kind: EntityKind; id: number }
|
||
| { kind: "page"; slug: string }
|
||
| { kind: "libraryDoc"; slug: string }
|
||
| { kind: "libraryRead"; slug: string }
|
||
| { kind: "library" }
|
||
| { kind: "leaderboard" };
|
||
|
||
/**
|
||
* 反解任意实体路径(标准 + 全部伪静态风格),供 isActive 前缀判断、面包屑等
|
||
* "从路径认实体"的场景使用。page/libraryDoc 的 slug 解码为原始文本;数字实体统一 { kind, id }。
|
||
*/
|
||
export function parseEntityPath(pathname: string): StandardMatch | null {
|
||
const standard = matchStandardPath(pathname);
|
||
if (standard) {
|
||
return standard.kind === "page" || standard.kind === "libraryDoc" || standard.kind === "libraryRead"
|
||
? { kind: standard.kind, slug: decodeSlug(standard.slug) }
|
||
: standard;
|
||
}
|
||
const pretty = matchPrettyPath(pathname);
|
||
if (!pretty) return null;
|
||
if (pretty.kind === "page") return { kind: "page", slug: decodeSlug(pretty.slug) };
|
||
if (pretty.kind === "libraryDoc") return { kind: "libraryDoc", slug: decodeSlug(pretty.slug) };
|
||
if (pretty.kind === "libraryRead") return { kind: "libraryRead", slug: decodeSlug(pretty.slug) };
|
||
if (pretty.kind === "leaderboard") return { kind: "leaderboard" };
|
||
if (pretty.kind === "library") return { kind: "library" };
|
||
return { kind: pretty.kind, id: pretty.id };
|
||
}
|
||
|
||
/** 解码 URL 段;非法序列(手动输入的坏编码)保持原样 */
|
||
function decodeSlug(slug: string): string {
|
||
try {
|
||
return decodeURIComponent(slug);
|
||
} catch {
|
||
return slug;
|
||
}
|
||
}
|