/** * 详情页伪静态风格的单一事实源: * 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, Record 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 string> = { post: (id) => `/post/${id}`, user: (id) => `/u/${id}`, announcement: (id) => `/announcement/${id}`, }; const PAGE_TEMPLATES: Record, (slug: string) => string> = { html: (slug) => `/p-${slug}.html`, discuz: (slug) => `/page-${slug}.html`, }; const LEADERBOARD_TEMPLATES: Record, string> = { html: "/leaderboard.html", discuz: "/rank.html", }; /** 书库伪静态模板:两风格统一(书库非 Discuz 原生概念);章节在详情路径上追加 ?sec= */ const LIBRARY_TEMPLATES: Record, { 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); } /** * 评论锚点:使用评论在所属帖内的相对楼层号,而非全站评论 ID。 * 主楼 → #comment-{floor};楼中楼 → #comment-{floor}-r{commentId}(楼层负责定位/分页/展开, * r 段精确定位具体回复)。楼层号无效时返回空串,调用方应回退为不带锚点的帖子路径。 */ export function commentAnchor(floor: number, isRoot: boolean, commentId?: number): string { if (!(Number.isFinite(floor) && floor > 0)) return ""; return isRoot || !commentId ? `#comment-${floor}` : `#comment-${floor}-r${commentId}`; } /** 用户主页路径;?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; kind: EntityKind; id: number } | { style: Exclude; kind: "page"; slug: string } | { style: Exclude; kind: "libraryDoc"; slug: string } | { style: Exclude; kind: "libraryRead"; slug: string; id: number } | { style: Exclude; kind: "library" } | { style: Exclude; 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; 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 ): 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; } }