feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接 - 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配 - 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成 - middleware 与相关组件适配
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
type FooterLinksAlign,
|
||||
} from "./brand";
|
||||
import { DEFAULT_LEVEL_DEFS, LEVEL_COLOR_KEYS, MAX_LEVEL_COUNT, type LevelDef } from "./levels";
|
||||
import { normalizeUrlStyle, type UrlStyle } from "./urlStyle";
|
||||
import { CSRF_COOKIE, hasAuthCookieHint } from "./cookies";
|
||||
import { publishPublicMetaSnapshot } from "./publicMetaSnapshot";
|
||||
import { emitForceLogout } from "./userEvents";
|
||||
@@ -1009,6 +1010,8 @@ export interface PublicSettings {
|
||||
levels: LevelDef[];
|
||||
/** 等级特效动画总开关(流光动画);缺省开 */
|
||||
levels_fx: boolean;
|
||||
/** 详情页伪静态风格;缺省 default(标准 /post/123) */
|
||||
url_style: UrlStyle;
|
||||
}
|
||||
|
||||
/** 与后端 DefaultAttachmentExts 保持同步 */
|
||||
@@ -1073,6 +1076,7 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
||||
necro_reply_penalty: 0,
|
||||
levels: [...DEFAULT_LEVEL_DEFS],
|
||||
levels_fx: true,
|
||||
url_style: "default",
|
||||
};
|
||||
|
||||
function normalizeAttachmentExts(raw: unknown): string[] {
|
||||
@@ -1160,6 +1164,7 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
|
||||
bg_admin_mode: typeof data?.bg_admin_mode === "string" ? data.bg_admin_mode : "cover",
|
||||
...normalizePointsSettings(data),
|
||||
...normalizeLockSettings(data),
|
||||
url_style: normalizeUrlStyle(data?.url_style),
|
||||
levels: normalizeLevels(data?.levels),
|
||||
levels_fx: data?.levels_fx !== false,
|
||||
};
|
||||
@@ -3361,6 +3366,7 @@ export type UpdateSiteSettingsBody = {
|
||||
footer_links?: FooterLink[];
|
||||
allow_register?: boolean;
|
||||
allow_comments?: boolean;
|
||||
url_style?: UrlStyle;
|
||||
comments_require_login?: boolean;
|
||||
allow_messages?: boolean;
|
||||
post_cooldown_hours?: number;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { absoluteCanonical, resolveSiteOrigin, type QueryBag } from "./siteUrl.ts";
|
||||
import { postPath, userPath, type UrlStyle } from "./urlStyle.ts";
|
||||
|
||||
/** 防止用户正文里的 </script> 提前闭合 JSON-LD 脚本 */
|
||||
export function serializeJsonLd(data: unknown): string {
|
||||
@@ -15,8 +16,8 @@ export type JsonLdPerson = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
function personNode(person: JsonLdPerson) {
|
||||
const url = person.id ? absoluteCanonical(`/u/${person.id}`) : undefined;
|
||||
function personNode(person: JsonLdPerson, urlStyle: UrlStyle = "default") {
|
||||
const url = person.id ? absoluteCanonical(userPath(urlStyle, person.id)) : undefined;
|
||||
return {
|
||||
"@type": "Person" as const,
|
||||
name: person.name,
|
||||
@@ -52,7 +53,9 @@ export function listingJsonLd(input: {
|
||||
items: { id: number; name: string }[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
urlStyle?: UrlStyle;
|
||||
}): Record<string, unknown> | null {
|
||||
const urlStyle = input.urlStyle ?? "default";
|
||||
const site = websiteNode(input.siteName, input.siteDescription);
|
||||
const pageUrl = absoluteCanonical(input.path, input.query);
|
||||
const origin = resolveSiteOrigin();
|
||||
@@ -75,7 +78,7 @@ export function listingJsonLd(input: {
|
||||
"@type": "ListItem",
|
||||
position: (input.page - 1) * input.pageSize + i + 1,
|
||||
name: item.name,
|
||||
url: `${origin}/post/${item.id}`,
|
||||
url: `${origin}${postPath(urlStyle, item.id)}`,
|
||||
})),
|
||||
},
|
||||
},
|
||||
@@ -105,8 +108,10 @@ export function questionJsonLd(input: {
|
||||
pageUrl?: string;
|
||||
accepted?: JsonLdVisibleComment | null;
|
||||
answers: JsonLdVisibleComment[];
|
||||
urlStyle?: UrlStyle;
|
||||
}): Record<string, unknown> | null {
|
||||
const entityUrl = absoluteCanonical(`/post/${input.postId}`);
|
||||
const urlStyle = input.urlStyle ?? "default";
|
||||
const entityUrl = absoluteCanonical(postPath(urlStyle, input.postId));
|
||||
const origin = resolveSiteOrigin();
|
||||
const site = websiteId();
|
||||
if (!entityUrl || !origin || !site) return null;
|
||||
@@ -120,7 +125,7 @@ export function questionJsonLd(input: {
|
||||
text: a.content,
|
||||
dateCreated: a.created_at,
|
||||
url: commentUrl(entityUrl, a.id),
|
||||
...(a.author ? { author: personNode(a.author) } : {}),
|
||||
...(a.author ? { author: personNode(a.author, urlStyle) } : {}),
|
||||
});
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
@@ -137,7 +142,7 @@ export function questionJsonLd(input: {
|
||||
...(input.text ? { text: input.text } : {}),
|
||||
dateCreated: input.createdAt,
|
||||
...(input.updatedAt ? { dateModified: input.updatedAt } : {}),
|
||||
author: personNode(input.author),
|
||||
author: personNode(input.author, urlStyle),
|
||||
...(input.accepted && input.accepted.content.trim()
|
||||
? { acceptedAnswer: answerNode(input.accepted) }
|
||||
: {}),
|
||||
@@ -158,8 +163,10 @@ export function discussionJsonLd(input: {
|
||||
commentCount?: number;
|
||||
viewCount?: number;
|
||||
comments: JsonLdVisibleComment[];
|
||||
urlStyle?: UrlStyle;
|
||||
}): Record<string, unknown> | null {
|
||||
const entityUrl = absoluteCanonical(`/post/${input.postId}`);
|
||||
const urlStyle = input.urlStyle ?? "default";
|
||||
const entityUrl = absoluteCanonical(postPath(urlStyle, input.postId));
|
||||
const site = websiteId();
|
||||
if (!entityUrl || !site) return null;
|
||||
const pageUrl = input.pageUrl || entityUrl;
|
||||
@@ -174,7 +181,7 @@ export function discussionJsonLd(input: {
|
||||
datePublished: input.createdAt,
|
||||
...(input.updatedAt ? { dateModified: input.updatedAt } : {}),
|
||||
inLanguage: "zh-CN",
|
||||
author: personNode(input.author),
|
||||
author: personNode(input.author, urlStyle),
|
||||
isPartOf: { "@id": site },
|
||||
...(pageUrl !== entityUrl
|
||||
? {
|
||||
@@ -202,7 +209,7 @@ export function discussionJsonLd(input: {
|
||||
text: c.content,
|
||||
dateCreated: c.created_at,
|
||||
url: commentUrl(entityUrl, c.id),
|
||||
...(c.author ? { author: personNode(c.author) } : {}),
|
||||
...(c.author ? { author: personNode(c.author, urlStyle) } : {}),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
@@ -215,8 +222,9 @@ export function profileJsonLd(input: {
|
||||
username: string;
|
||||
signature?: string;
|
||||
pageUrl?: string;
|
||||
urlStyle?: UrlStyle;
|
||||
}): Record<string, unknown> | null {
|
||||
const personUrl = absoluteCanonical(`/u/${input.id}`);
|
||||
const personUrl = absoluteCanonical(userPath(input.urlStyle ?? "default", input.id));
|
||||
if (!personUrl) return null;
|
||||
const pageUrl = input.pageUrl || personUrl;
|
||||
return {
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface RtSettingsData {
|
||||
footer_links?: { label: string; url: string; new_tab: boolean; align?: string }[];
|
||||
allow_register?: boolean;
|
||||
allow_comments?: boolean;
|
||||
url_style?: string;
|
||||
comments_require_login?: boolean;
|
||||
allow_messages?: boolean;
|
||||
post_cooldown_hours?: number;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/** 单页公开路径。前台路由是 /p/[slug],列表、复制和线上查看必须用同一条。 */
|
||||
export function sitePagePublicPath(slug: string): string {
|
||||
const clean = slug.trim();
|
||||
if (!clean) return "/p/";
|
||||
return `/p/${encodeURIComponent(clean)}`;
|
||||
import { pagePath, type UrlStyle } from "./urlStyle.ts";
|
||||
|
||||
/** 单页公开路径。前台路由是 /p/[slug](伪静态风格跟随站点设置),列表、复制和线上查看必须用同一条。 */
|
||||
export function sitePagePublicPath(slug: string, style: UrlStyle = "default"): string {
|
||||
return pagePath(style, slug);
|
||||
}
|
||||
|
||||
/** 用当前页面的 origin 拼绝对地址,不写死域名。origin 为空时只返回路径。 */
|
||||
export function sitePagePublicURL(origin: string, slug: string): string {
|
||||
const path = sitePagePublicPath(slug);
|
||||
export function sitePagePublicURL(origin: string, slug: string, style: UrlStyle = "default"): string {
|
||||
const path = sitePagePublicPath(slug, style);
|
||||
const base = origin.replace(/\/$/, "");
|
||||
return base ? `${base}${path}` : path;
|
||||
}
|
||||
|
||||
128
frontend/lib/urlStyle.test.ts
Normal file
128
frontend/lib/urlStyle.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
announcementPath,
|
||||
entityPath,
|
||||
leaderboardPath,
|
||||
matchPrettyPath,
|
||||
matchStandardPath,
|
||||
normalizeUrlStyle,
|
||||
pagePath,
|
||||
parseEntityPath,
|
||||
postPath,
|
||||
userPath,
|
||||
} from "./urlStyle.ts";
|
||||
|
||||
describe("normalizeUrlStyle", () => {
|
||||
it("合法值与容错", () => {
|
||||
assert.equal(normalizeUrlStyle("default"), "default");
|
||||
assert.equal(normalizeUrlStyle(" HTML "), "html");
|
||||
assert.equal(normalizeUrlStyle("DISCUZ"), "discuz");
|
||||
assert.equal(normalizeUrlStyle("pretty"), "default");
|
||||
assert.equal(normalizeUrlStyle(undefined), "default");
|
||||
assert.equal(normalizeUrlStyle(42), "default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("路径构造器", () => {
|
||||
it("三风格帖子路径", () => {
|
||||
assert.equal(postPath("default", 123), "/post/123");
|
||||
assert.equal(postPath("html", 123), "/post-123.html");
|
||||
assert.equal(postPath("discuz", 123), "/thread-123.html");
|
||||
});
|
||||
it("三风格用户路径", () => {
|
||||
assert.equal(userPath("default", 5), "/u/5");
|
||||
assert.equal(userPath("html", 5), "/user-5.html");
|
||||
assert.equal(userPath("discuz", 5), "/space-uid-5.html");
|
||||
});
|
||||
it("三风格公告路径", () => {
|
||||
assert.equal(announcementPath("default", 1), "/announcement/1");
|
||||
assert.equal(announcementPath("html", 1), "/announcement-1.html");
|
||||
assert.equal(announcementPath("discuz", 1), "/notice-1.html");
|
||||
});
|
||||
it("非法 id 返回空串", () => {
|
||||
assert.equal(postPath("default", 0), "");
|
||||
assert.equal(postPath("html", -1), "");
|
||||
assert.equal(userPath("discuz", Number.NaN), "");
|
||||
});
|
||||
it("自建页三风格并编码 slug", () => {
|
||||
assert.equal(pagePath("default", "about"), "/p/about");
|
||||
assert.equal(pagePath("html", "about"), "/p-about.html");
|
||||
assert.equal(pagePath("discuz", "使用指南"), `/page-${encodeURIComponent("使用指南")}.html`);
|
||||
assert.equal(pagePath("html", "使用指南"), `/p-${encodeURIComponent("使用指南")}.html`);
|
||||
assert.equal(pagePath("default", " "), "/p/");
|
||||
});
|
||||
it("排行榜三风格", () => {
|
||||
assert.equal(leaderboardPath("default"), "/leaderboard");
|
||||
assert.equal(leaderboardPath("html"), "/leaderboard.html");
|
||||
assert.equal(leaderboardPath("discuz"), "/rank.html");
|
||||
});
|
||||
it("entityPath 与标准路径", () => {
|
||||
assert.equal(entityPath("discuz", "post", 9), "/thread-9.html");
|
||||
assert.equal(entityPath("default", "user", 9), "/u/9");
|
||||
assert.equal(entityPath("html", "announcement", 9), "/announcement-9.html");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchPrettyPath", () => {
|
||||
it("命中各风格模板", () => {
|
||||
assert.deepEqual(matchPrettyPath("/post-12.html"), { style: "html", kind: "post", id: 12 });
|
||||
assert.deepEqual(matchPrettyPath("/user-3.html"), { style: "html", kind: "user", id: 3 });
|
||||
assert.deepEqual(matchPrettyPath("/announcement-7.html"), { style: "html", kind: "announcement", id: 7 });
|
||||
assert.deepEqual(matchPrettyPath("/thread-12.html"), { style: "discuz", kind: "post", id: 12 });
|
||||
assert.deepEqual(matchPrettyPath("/space-uid-3.html"), { style: "discuz", kind: "user", id: 3 });
|
||||
assert.deepEqual(matchPrettyPath("/notice-7.html"), { style: "discuz", kind: "announcement", id: 7 });
|
||||
assert.deepEqual(matchPrettyPath("/p-about.html"), { style: "html", kind: "page", slug: "about" });
|
||||
assert.deepEqual(matchPrettyPath("/leaderboard.html"), { style: "html", kind: "leaderboard" });
|
||||
assert.deepEqual(matchPrettyPath("/rank.html"), { style: "discuz", kind: "leaderboard" });
|
||||
});
|
||||
it("page slug 保持 encoded 原样", () => {
|
||||
const enc = encodeURIComponent("使用指南");
|
||||
assert.deepEqual(matchPrettyPath(`/page-${enc}.html`), { style: "discuz", kind: "page", slug: enc });
|
||||
assert.deepEqual(matchPrettyPath(`/p-${enc}.html`), { style: "html", kind: "page", slug: enc });
|
||||
});
|
||||
it("排除子路径与伪造路径", () => {
|
||||
assert.equal(matchPrettyPath("/post/12"), null);
|
||||
assert.equal(matchPrettyPath("/post/12/edit"), null);
|
||||
assert.equal(matchPrettyPath("/post-abc.html"), null);
|
||||
assert.equal(matchPrettyPath("/thread-12.htmlx"), null);
|
||||
assert.equal(matchPrettyPath("/thread-12.html/edit"), null);
|
||||
assert.equal(matchPrettyPath("/thread--12.html"), null);
|
||||
assert.equal(matchPrettyPath("/post.html"), null);
|
||||
assert.equal(matchPrettyPath("/p-.html"), null);
|
||||
assert.equal(matchPrettyPath("/leaderboardx.html"), null);
|
||||
assert.equal(matchPrettyPath("/rank.htmlx"), null);
|
||||
assert.equal(matchPrettyPath("/rank"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchStandardPath / parseEntityPath", () => {
|
||||
it("标准路径精确匹配", () => {
|
||||
assert.deepEqual(matchStandardPath("/post/12"), { kind: "post", id: 12 });
|
||||
assert.deepEqual(matchStandardPath("/u/5"), { kind: "user", id: 5 });
|
||||
assert.deepEqual(matchStandardPath("/announcement/7"), { kind: "announcement", id: 7 });
|
||||
assert.deepEqual(matchStandardPath("/p/about"), { kind: "page", slug: "about" });
|
||||
assert.deepEqual(matchStandardPath("/leaderboard"), { kind: "leaderboard" });
|
||||
assert.deepEqual(matchStandardPath("/leaderboard/"), { kind: "leaderboard" });
|
||||
assert.equal(matchStandardPath("/post/12/edit"), null);
|
||||
assert.equal(matchStandardPath("/post/abc"), null);
|
||||
assert.equal(matchStandardPath("/u/5/followers"), null);
|
||||
assert.equal(matchStandardPath("/p/"), null);
|
||||
assert.equal(matchStandardPath("/p/a/b"), null);
|
||||
});
|
||||
it("parseEntityPath 兼容标准与全部模板", () => {
|
||||
assert.deepEqual(parseEntityPath("/post/12"), { kind: "post", id: 12 });
|
||||
assert.deepEqual(parseEntityPath("/thread-12.html"), { kind: "post", id: 12 });
|
||||
assert.deepEqual(parseEntityPath("/post-12.html"), { kind: "post", id: 12 });
|
||||
assert.deepEqual(parseEntityPath("/space-uid-5.html"), { kind: "user", id: 5 });
|
||||
assert.deepEqual(parseEntityPath("/p/about"), { kind: "page", slug: "about" });
|
||||
assert.deepEqual(parseEntityPath("/p-about.html"), { kind: "page", slug: "about" });
|
||||
assert.deepEqual(parseEntityPath(`/page-${encodeURIComponent("使用指南")}.html`), {
|
||||
kind: "page",
|
||||
slug: "使用指南",
|
||||
});
|
||||
assert.deepEqual(parseEntityPath("/rank.html"), { kind: "leaderboard" });
|
||||
assert.equal(parseEntityPath("/chat/5"), null);
|
||||
assert.equal(parseEntityPath("/"), null);
|
||||
});
|
||||
});
|
||||
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