Files
jiang13-bbs/frontend/lib/urlStyle.ts
freefire 7a44c1f2cf feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
2026-09-27 04:01:42 +08:00

193 lines
7.8 KiB
TypeScript
Raw Permalink 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
* html: /post-123.html /user-5.html /announcement-1.html /p-about.html /leaderboard.html
* discuz: /thread-123.html /space-uid-5.html /notice-1.html /page-about.html /rank.html
*/
export type UrlStyle = "default" | "html" | "discuz";
export type EntityKind = "post" | "user" | "announcement";
/** 全部可反解的实体类型;page 的 id 是 slug(字符串),leaderboard 是固定单页无 id */
export type PageKind = EntityKind | "page" | "leaderboard";
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",
};
/** 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];
}
/** 实体对应风格下的标准/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: "leaderboard" };
// 精确锚定:id 仅纯数字,天然排除 /post/123/edit 这类子路径与伪造路径;
// page 的 slug 捕获保持 URL 原样(encoded),rewrite 落点直接复用
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: /^\/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" },
];
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" },
];
/** 匹配任一伪静态风格路径(不含标准风格);命中返回风格/实体/id;page 的 slug 保持 encoded 原样 */
export function matchPrettyPath(pathname: string): PrettyMatch | null {
for (const p of PRETTY_PATTERNS) {
const m = p.re.exec(pathname);
if (!m) continue;
if (p.kind === "page") return { style: p.style, kind: "page", slug: m[1] };
if (p.kind === "leaderboard") return { style: p.style, kind: "leaderboard" };
return { style: p.style, kind: p.kind, id: Number(m[1]) };
}
return null;
}
/** 匹配标准实体路径;page 的 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") return { kind: "page", slug: m[1] };
if (p.kind === "leaderboard") return { kind: "leaderboard" };
return { kind: p.kind, id: Number(m[1]) };
}
return null;
}
export type StandardMatch =
| { kind: EntityKind; id: number }
| { kind: "page"; slug: string }
| { kind: "leaderboard" };
/**
* 反解任意实体路径(标准 + 全部伪静态风格),供 isActive 前缀判断、面包屑等
* "从路径认实体"的场景使用。page 的 slug 解码为原始文本;数字实体统一 { kind, id }。
*/
export function parseEntityPath(pathname: string): StandardMatch | null {
const standard = matchStandardPath(pathname);
if (standard) {
return standard.kind === "page" ? { kind: "page", 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 === "leaderboard") return { kind: "leaderboard" };
return { kind: pretty.kind, id: pretty.id };
}
/** 解码 URL 段;非法序列(手动输入的坏编码)保持原样 */
function decodeSlug(slug: string): string {
try {
return decodeURIComponent(slug);
} catch {
return slug;
}
}