feat: 可配置站点 URL 风格

- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
This commit is contained in:
2026-09-27 04:01:42 +08:00
parent 1d862a0dd3
commit 7a44c1f2cf
64 changed files with 1183 additions and 200 deletions

192
frontend/lib/urlStyle.ts Normal file
View File

@@ -0,0 +1,192 @@
/**
* 详情页伪静态风格的单一事实源:
* 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;
}
}