feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接 - 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配 - 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成 - middleware 与相关组件适配
This commit is contained in:
192
frontend/lib/urlStyle.ts
Normal file
192
frontend/lib/urlStyle.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user