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

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;
}

View 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
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;
}
}