Files
jiang13-bbs/frontend/lib/urlStyle.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

285 lines
13 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.
/**
* 详情页伪静态风格的单一事实源:
* 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;
}
}