feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等

This commit is contained in:
2026-10-09 18:53:38 +08:00
parent 5598e1cbb7
commit da72843c70
64 changed files with 7047 additions and 248 deletions

View File

@@ -4987,3 +4987,403 @@ export async function apiAdminSetFriendLinkStatus(
export async function apiAdminDeleteFriendLink(id: number): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/friend-links/${id}`, "DELETE");
}
// ===== 代码片段(广场 + 我的 + 管理) =====
/** 片段可见性 */
export type SnippetVisibility = "public" | "private";
/** 片段广场/列表条目(不含代码全文,preview 为代码前缀) */
export interface SnippetListItem {
id: number;
title: string;
description: string;
language: string;
visibility: SnippetVisibility;
user_id: number;
forked_from_id?: number | null;
copy_count: number;
view_count: number;
comment_count: number;
fork_count: number;
preview: string;
user?: LibraryCreator | null;
created_at: string;
updated_at: string;
}
/** 片段详情(含代码全文与作者/Fork 来源摘要) */
export interface SnippetDetail {
id: number;
title: string;
description: string;
language: string;
code: string;
visibility: SnippetVisibility;
user_id: number;
forked_from_id?: number | null;
copy_count: number;
view_count: number;
comment_count: number;
fork_count: number;
created_at: string;
updated_at: string;
user?: LibraryCreator | null;
forked_from?: { id: number; title: string } | null;
}
/** 片段版本历史条目(编辑前旧值快照) */
export interface SnippetVersionItem {
id: number;
snippet_id: number;
editor_id: number;
old_title: string;
old_language: string;
old_description: string;
old_code: string;
created_at: string;
}
/** 片段评论(楼层树:parent_id 空=主评) */
export interface SnippetCommentItem {
id: number;
snippet_id: number;
user_id: number;
parent_id?: number | null;
root_id?: number | null;
depth: number;
content: string;
floor?: number;
deleted?: boolean;
user?: LibraryCreator | null;
replies?: SnippetCommentItem[];
edited?: boolean;
created_at: string;
updated_at: string;
}
export interface SnippetInput {
title: string;
language: string;
description?: string;
code: string;
visibility: SnippetVisibility;
}
export type SnippetSort = "latest" | "copy" | "comment" | "fork";
/** SSR / 公开:片段广场列表 */
export async function fetchSnippets(opts: {
q?: string;
lang?: string;
sort?: SnippetSort;
page?: number;
size?: number;
cookieHeader?: string;
}): Promise<{ items: SnippetListItem[]; total: number }> {
const params = new URLSearchParams();
if (opts.q) params.set("q", opts.q);
if (opts.lang) params.set("lang", opts.lang);
if (opts.sort) params.set("sort", opts.sort);
params.set("page", String(opts.page ?? 1));
params.set("size", String(opts.size ?? 12));
const res = await fetch(
`${API_BASE}/api/snippets?${params}`,
ssrInit(cookieHeaders(opts.cookieHeader))
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取片段列表失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** SSR / 公开:语言白名单 */
export async function fetchSnippetLanguages(): Promise<{ languages: string[] }> {
const res = await fetch(`${API_BASE}/api/snippets/languages`, ssrInit());
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取语言列表失败");
return { languages: data.languages ?? [] };
}
export interface SnippetLangStat {
language: string;
count: number;
}
export interface SnippetStats {
total_snippets: number;
total_copies: number;
total_views: number;
total_comments: number;
total_forks: number;
contributors: number;
top_languages: SnippetLangStat[];
}
/** SSR / 公开:片段广场聚合统计 */
export async function fetchSnippetStats(): Promise<SnippetStats> {
const res = await fetch(`${API_BASE}/api/snippets/stats`, ssrInit());
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取片段统计失败");
return (data.stats ?? {
total_snippets: 0,
total_copies: 0,
total_views: 0,
total_comments: 0,
total_forks: 0,
contributors: 0,
top_languages: [],
}) as SnippetStats;
}
/** SSR:片段详情(公开或本人/管理可见私有;404/403 返回 null) */
export async function fetchSnippetById(
id: number,
cookieHeader?: string
): Promise<{ snippet: SnippetDetail } | null> {
const res = await fetch(`${API_BASE}/api/snippets/${id}`, ssrInit(cookieHeaders(cookieHeader)));
if (res.status === 404 || res.status === 403) return null;
if (!res.ok) throw new Error("获取片段失败");
return res.json();
}
/** 客户端:片段详情(带登录凭证,私有片段仅 owner/staff 可见) */
export async function apiGetSnippet(id: number): Promise<{ snippet: SnippetDetail } | null> {
const res = await fetchWithRefresh(`/api/snippets/${id}`, { headers: clientHeaders() });
if (res.status === 404 || res.status === 403) return null;
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取片段失败");
return data as { snippet: SnippetDetail };
}
/** SSR / 公开:片段评论(主评分页 + 回复) */
export async function fetchSnippetComments(
id: number,
page = 1,
size = 20,
cookieHeader?: string
): Promise<{ items: SnippetCommentItem[]; total: number }> {
const res = await fetch(
`${API_BASE}/api/snippets/${id}/comments?page=${page}&size=${size}`,
ssrInit(cookieHeaders(cookieHeader))
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取评论失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** SSR / 公开:片段的公开 Fork 列表 */
export async function fetchSnippetForks(
id: number,
page = 1,
size = 20
): Promise<{ items: SnippetListItem[]; total: number }> {
const res = await fetch(`${API_BASE}/api/snippets/${id}/forks?page=${page}&size=${size}`, ssrInit());
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取 Fork 列表失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** 客户端:我的片段(含私有) */
export async function apiMySnippets(
q = "",
page = 1,
size = 12
): Promise<{ items: SnippetListItem[]; total: number }> {
const params = new URLSearchParams({ page: String(page), size: String(size) });
if (q) params.set("q", q);
const res = await fetchWithRefresh(`/api/snippets/mine?${params}`, { headers: clientHeaders() });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取我的片段失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** 客户端:创建片段 */
export async function apiCreateSnippet(input: SnippetInput): Promise<{ snippet: SnippetDetail }> {
const res = await fetchWithRefresh("/api/snippets", {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "创建片段失败");
return data as { snippet: SnippetDetail };
}
/** 客户端:编辑片段(owner/staff;服务端落版本快照) */
export async function apiUpdateSnippet(
id: number,
input: SnippetInput
): Promise<{ snippet: SnippetDetail }> {
const res = await fetchWithRefresh(`/api/snippets/${id}`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "保存片段失败");
return data as { snippet: SnippetDetail };
}
/** 客户端:删除片段(owner/staff) */
export async function apiDeleteSnippet(id: number): Promise<void> {
const res = await fetchWithRefresh(`/api/snippets/${id}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "删除片段失败");
}
}
/** 客户端:Fork 公开片段为自己的私有副本 */
export async function apiForkSnippet(id: number): Promise<{ snippet: SnippetDetail }> {
const res = await fetchWithRefresh(`/api/snippets/${id}/fork`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Fork 失败");
return data as { snippet: SnippetDetail };
}
/** 客户端:复制计数(节流由服务端按用户/IP 控制;失败静默) */
export async function apiRecordSnippetCopy(id: number): Promise<void> {
try {
await fetchWithRefresh(`/api/snippets/${id}/copy`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
});
} catch {
/* 计数失败不影响复制本身 */
}
}
/** 客户端:版本历史(登录可见) */
export async function apiSnippetHistory(
id: number,
page = 1,
size = 20
): Promise<{ items: SnippetVersionItem[]; total: number }> {
const res = await fetchWithRefresh(`/api/snippets/${id}/history?page=${page}&size=${size}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取版本历史失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** 客户端:发表片段评论(parent_id 非空为回复) */
export async function apiCreateSnippetComment(
id: number,
content: string,
parentId?: number
): Promise<{ comment: SnippetCommentItem }> {
const res = await fetchWithRefresh(`/api/snippets/${id}/comments`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ content, parent_id: parentId || undefined }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "评论失败");
return data as { comment: SnippetCommentItem };
}
/** 客户端:编辑片段评论(仅本人) */
export async function apiUpdateSnippetComment(
id: number,
cid: number,
content: string
): Promise<{ comment: SnippetCommentItem }> {
const res = await fetchWithRefresh(`/api/snippets/${id}/comments/${cid}`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ content }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "保存评论失败");
return data as { comment: SnippetCommentItem };
}
/** 客户端:删除片段评论(本人/staff) */
export async function apiDeleteSnippetComment(id: number, cid: number): Promise<void> {
const res = await fetchWithRefresh(`/api/snippets/${id}/comments/${cid}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "删除评论失败");
}
}
export interface SnippetCommentHistoryItem {
id: number;
editor: { id: number; username: string; nickname: string; avatar: string } | null;
old_content: string;
created_at: string;
}
/** 客户端:片段评论编辑历史(登录可见) */
export async function apiSnippetCommentHistory(
id: number,
cid: number,
page = 1,
size = 10
): Promise<{ items: SnippetCommentHistoryItem[]; total: number; page: number; size: number }> {
const params = new URLSearchParams({ page: String(page), size: String(size) });
const res = await fetchWithRefresh(`/api/snippets/${id}/comments/${cid}/history?${params}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取编辑历史失败");
return {
items: data.items ?? [],
total: data.total ?? 0,
page: data.page ?? page,
size: data.size ?? size,
};
}
/** 客户端:片段搜索(嵌入弹窗等场景;公开广场 + 可含我的) */
export async function apiSearchSnippets(opts: {
q?: string;
page?: number;
size?: number;
}): Promise<{ items: SnippetListItem[]; total: number }> {
const params = new URLSearchParams();
if (opts.q) params.set("q", opts.q);
params.set("page", String(opts.page ?? 1));
params.set("size", String(opts.size ?? 10));
const res = await fetchWithRefresh(`/api/snippets?${params}`, { headers: clientHeaders() });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "搜索片段失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** 管理端:片段全量列表 */
export async function apiAdminSnippets(opts: {
q?: string;
visibility?: string;
page?: number;
size?: number;
}): Promise<{ items: SnippetListItem[]; total: number }> {
const params = new URLSearchParams({ page: String(opts.page ?? 1), size: String(opts.size ?? 30) });
if (opts.q) params.set("q", opts.q);
if (opts.visibility) params.set("visibility", opts.visibility);
return apiOperations(`/api/admin/snippets?${params}`);
}
/** 管理端:删除片段(软删 + 原因) */
export async function apiAdminDeleteSnippet(id: number, reason = ""): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/snippets/${id}`, "DELETE", reason ? { reason } : undefined);
}
/** 管理端:强制修改片段可见性(下架为 private / 恢复 public) */
export async function apiAdminSetSnippetVisibility(
id: number,
visibility: SnippetVisibility
): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/snippets/${id}/visibility`, "PUT", { visibility });
}

View File

@@ -27,7 +27,8 @@ export type ContentSegment =
| { type: "timeline"; items: TimelineItem[]; rawBody: string }
| { type: "entries"; items: EntryItem[]; rawBody: string }
| { type: "details"; title: string; body: string }
| { type: "video"; provider: "bilibili" | "youtube"; id: string };
| { type: "video"; provider: "bilibili" | "youtube"; id: string }
| { type: "snippet"; id: number };
function isTimelineClose(line: string): boolean {
return line.trim().toLowerCase() === "[/timeline]";
@@ -57,6 +58,18 @@ export function parseDetailsOpen(trimmed: string): string {
return title || DETAILS_DEFAULT_TITLE;
}
/* ---------- [snippet id=N] 代码片段嵌入(整行单标记) ---------- */
const SNIPPET_LINE_RE = /^\[snippet\s+id=(\d+)\]$/i;
/** 整行 `[snippet id=N]`。解析失败返回 null(该行按普通 Markdown 处理)。 */
export function parseSnippetLine(trimmed: string): number | null {
const m = trimmed.match(SNIPPET_LINE_RE);
if (!m) return null;
const id = Number(m[1]);
return Number.isInteger(id) && id > 0 ? id : null;
}
/* ---------- [bilibili] / [youtube] 视频嵌入(整行单标记) ---------- */
const VIDEO_LINE_RE = /^\[(bilibili|youtube)\]([^\[\]]+)\[\/\1\]$/i;
@@ -122,6 +135,7 @@ export function splitContentSegments(
| { type: "entries"; body: string }
| { type: "details"; title: string; body: string }
| { type: "video"; provider: "bilibili" | "youtube"; id: string }
| { type: "snippet"; id: number }
> = [];
let buf: string[] = [];
let inCode = false;
@@ -228,6 +242,15 @@ export function splitContentSegments(
continue;
}
// [snippet id=N] 整行片段嵌入
const snippetId = parseSnippetLine(trimmed);
if (snippetId != null) {
flushMd();
raw.push({ type: "snippet", id: snippetId });
i++;
continue;
}
// [entries:off] 例外块:body 原样进 md 缓冲(标记行丢弃),
// 不再参与 entries/timeline 拆分;未闭合时整块当普通 Markdown
if (isEntriesOffOpen(trimmed)) {
@@ -273,6 +296,10 @@ export function splitContentSegments(
out.push({ type: "video", provider: part.provider, id: part.id });
continue;
}
if (part.type === "snippet") {
out.push({ type: "snippet", id: part.id });
continue;
}
const hideSegs = splitHideSegments(part.text) as HideSegment[];
for (const s of hideSegs) {
if (s.type === "md") {

View File

@@ -15,8 +15,8 @@ describe("normalizeHeaderNav", () => {
assert.deepEqual(normalizeHeaderNav("home"), DEFAULT_HEADER_NAV);
});
it("默认三项顺序与注册表一致", () => {
assert.equal(DEFAULT_HEADER_NAV.length, 3);
it("默认内置项顺序与注册表一致", () => {
assert.equal(DEFAULT_HEADER_NAV.length, HEADER_NAV_BUILTIN_KEYS.length);
assert.deepEqual(
DEFAULT_HEADER_NAV.map((i) => (i.type === "builtin" ? i.key : "")),
[...HEADER_NAV_BUILTIN_KEYS],

View File

@@ -11,7 +11,7 @@
import { footerURLAllowed, isExternalFooterURL, runeCount } from "./brand.ts";
export const HEADER_NAV_BUILTIN_KEYS = ["home", "leaderboard", "library"] as const;
export const HEADER_NAV_BUILTIN_KEYS = ["home", "leaderboard", "library", "snippets"] as const;
export type HeaderNavBuiltinKey = (typeof HEADER_NAV_BUILTIN_KEYS)[number];
@@ -20,6 +20,7 @@ export const HEADER_NAV_BUILTIN_LABELS: Record<HeaderNavBuiltinKey, string> = {
home: "首页",
leaderboard: "排行榜",
library: "书库",
snippets: "片段",
};
export type HeaderNavBuiltinItem = { type: "builtin"; key: HeaderNavBuiltinKey };

View File

@@ -105,10 +105,11 @@ export interface RtChatMessageData {
};
}
// 群成员关系变更:被踢/群解散/被邀请
// 群成员关系变更:被踢/群解散/被邀请/新成员加入
export interface RtChatMembershipData {
room_id: number;
status: "kicked" | "dissolved" | "invited";
status: "kicked" | "dissolved" | "invited" | "joined";
user_id?: number;
}
// 消息撤回

View File

@@ -0,0 +1,48 @@
/** 片段语言霓虹色:Hero 分布条 / 卡片顶条共用,未收录语言回落主题色 */
const LANG_COLORS: Record<string, string> = {
go: "#00d8ff",
css: "#c792ea",
scss: "#e879c6",
html: "#ff6d4d",
javascript: "#f7df1e",
jsx: "#61dafb",
typescript: "#4e9cff",
tsx: "#4e9cff",
vue: "#41d1a6",
python: "#ffd43b",
rust: "#ff7a59",
java: "#ffb86c",
kotlin: "#c87bff",
swift: "#ff9e64",
dart: "#54c5f8",
c: "#7fd4ff",
cpp: "#7fd4ff",
csharp: "#b78bff",
php: "#a6b1ff",
ruby: "#ff5f56",
lua: "#9c9cff",
r: "#7ea6ff",
sql: "#ff9e64",
graphql: "#ff6bd6",
bash: "#89e051",
shell: "#89e051",
powershell: "#6ea8ff",
dockerfile: "#3ab0ff",
nginx: "#5fd08a",
yaml: "#ff6b81",
toml: "#ffd166",
ini: "#ffd166",
json: "#8bc34a",
jsonc: "#8bc34a",
xml: "#ffab70",
markdown: "#9aa7ff",
regex: "#f78c6c",
diff: "#b0bec5",
makefile: "#ffd166",
prisma: "#5eead4",
plaintext: "#94a3b8",
};
export function snippetLangColor(lang: string): string {
return LANG_COLORS[lang.toLowerCase()] ?? "var(--accent)";
}

View File

@@ -14,6 +14,8 @@ import {
pagePath,
parseEntityPath,
postPath,
snippetPath,
snippetsPath,
userPath,
} from "./urlStyle.ts";
@@ -89,6 +91,16 @@ describe("路径构造器", () => {
assert.equal(entityPath("default", "user", 9), "/u/9");
assert.equal(entityPath("html", "announcement", 9), "/announcement-9.html");
});
it("代码片段三风格(html/discuz 同模板)", () => {
assert.equal(snippetsPath("default"), "/snippets");
assert.equal(snippetsPath("html"), "/snippets.html");
assert.equal(snippetsPath("discuz"), "/snippets.html");
assert.equal(snippetPath("default", 12), "/snippets/12");
assert.equal(snippetPath("html", 12), "/snippet-12.html");
assert.equal(snippetPath("discuz", 12), "/snippet-12.html");
assert.equal(snippetPath("default", 0), "");
assert.equal(snippetPath("html", -1), "");
});
it("评论锚点使用帖内相对楼层号", () => {
assert.equal(commentAnchor(1, true, 147), "#comment-1");
assert.equal(commentAnchor(3, false, 147), "#comment-3-r147");
@@ -111,6 +123,14 @@ describe("matchPrettyPath", () => {
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" });
assert.deepEqual(matchPrettyPath("/snippets.html", "html"), { style: "html", kind: "snippets" });
assert.deepEqual(matchPrettyPath("/snippets.html", "discuz"), { style: "discuz", kind: "snippets" });
assert.deepEqual(matchPrettyPath("/snippet-12.html", "discuz"), { style: "discuz", kind: "snippet", id: 12 });
assert.deepEqual(matchPrettyPath("/snippet-12.html"), { style: "html", kind: "snippet", id: 12 });
assert.equal(matchPrettyPath("/snippet-abc.html"), null);
assert.equal(matchPrettyPath("/snippet-.html"), null);
assert.equal(matchPrettyPath("/snippets-.html"), null);
assert.equal(matchPrettyPath("/snippet-12.htmlx"), null);
});
it("书库两风格同模板:prefer 优先当前风格,缺省首条兜底", () => {
assert.equal(matchPrettyPath("/library.html", "html")?.style, "html");
@@ -187,6 +207,13 @@ describe("matchStandardPath / parseEntityPath", () => {
assert.deepEqual(matchStandardPath("/library/abc/read"), { kind: "libraryRead", slug: "abc" });
assert.equal(matchStandardPath("/library/a/b"), null);
assert.equal(matchStandardPath("/libraryx"), null);
// 片段:列表与详情;new/edit 子路径不得命中详情
assert.deepEqual(matchStandardPath("/snippets"), { kind: "snippets" });
assert.deepEqual(matchStandardPath("/snippets/"), { kind: "snippets" });
assert.deepEqual(matchStandardPath("/snippets/12"), { kind: "snippet", id: 12 });
assert.equal(matchStandardPath("/snippets/new"), null);
assert.equal(matchStandardPath("/snippets/12/edit"), null);
assert.equal(matchStandardPath("/snippetsx"), null);
});
it("parseEntityPath 兼容标准与全部模板", () => {
assert.deepEqual(parseEntityPath("/post/12"), { kind: "post", id: 12 });
@@ -211,4 +238,10 @@ describe("matchStandardPath / parseEntityPath", () => {
assert.deepEqual(parseEntityPath("/library/abc/read"), { kind: "libraryRead", slug: "abc" });
assert.deepEqual(parseEntityPath("/library-abc-read-2.html"), { kind: "libraryRead", slug: "abc" });
});
it("parseEntityPath 兼容片段路径", () => {
assert.deepEqual(parseEntityPath("/snippets"), { kind: "snippets" });
assert.deepEqual(parseEntityPath("/snippets/12"), { kind: "snippet", id: 12 });
assert.deepEqual(parseEntityPath("/snippets.html"), { kind: "snippets" });
assert.deepEqual(parseEntityPath("/snippet-12.html"), { kind: "snippet", id: 12 });
});
});

View File

@@ -7,14 +7,21 @@
* default: /post/123 /u/5 /announcement/1 /p/about /leaderboard /library /library/{slug} /library/{slug}/read?c={书内章号}
* html: /post-123.html /user-5.html /announcement-1.html /p-about.html /leaderboard.html /library.html /library-{slug}.html (章节:/library-{slug}.html?sec={书内章号})
* discuz: /thread-123.html /space-uid-5.html /notice-1.html /page-about.html /rank.html (书库两风格同 html 模板)
* 书库章节走详情路径 + ?sec= 查询参数(章内子目录锚点 #e-{sec}-{章内序号});旧版 -read-{n} 路径
* 不再生成,但正则保留可解析(middleware 改写回标准读页),旧链接不失效。
* 代码片段:default /snippets /snippets/{id};html/discuz 两风格统一 /snippets.html /snippet-{id}.html
*/
export type UrlStyle = "default" | "html" | "discuz";
export type EntityKind = "post" | "user" | "announcement";
/** 全部可反解的实体类型;page/libraryDoc/libraryRead 的 id 是 slug(字符串),leaderboard/library 是固定单页无 id */
export type PageKind = EntityKind | "page" | "leaderboard" | "library" | "libraryDoc" | "libraryRead";
/** 全部可反解的实体类型;page/libraryDoc/libraryRead 的 id 是 slug(字符串),leaderboard/library/snippets 是固定单页无 id */
export type PageKind =
| EntityKind
| "page"
| "leaderboard"
| "library"
| "libraryDoc"
| "libraryRead"
| "snippets"
| "snippet";
export const URL_STYLES: readonly UrlStyle[] = ["default", "html", "discuz"];
@@ -64,6 +71,18 @@ const LIBRARY_TEMPLATES: Record<Exclude<UrlStyle, "default">, { list: string; do
},
};
/** 代码片段伪静态模板:两风格统一(同书库,非 Discuz 原生概念) */
const SNIPPET_TEMPLATES: Record<Exclude<UrlStyle, "default">, { list: string; detail: (id: number) => string }> = {
html: {
list: "/snippets.html",
detail: (id) => `/snippet-${id}.html`,
},
discuz: {
list: "/snippets.html",
detail: (id) => `/snippet-${id}.html`,
},
};
/** id 非法(≤0/非有限数)时返回空串,调用方按异常路径处理 */
function guardId(id: number): number | null {
return Number.isFinite(id) && id > 0 ? id : null;
@@ -155,6 +174,19 @@ export function libraryDocReadPath(style: UrlStyle, slug: string, chapterNo?: nu
return no ? `${base}?c=${no}` : base;
}
/** 片段列表路径:default 标准路径,html/discuz 伪静态(两风格统一模板) */
export function snippetsPath(style: UrlStyle): string {
return style === "default" ? "/snippets" : SNIPPET_TEMPLATES[style].list;
}
/** 片段详情路径;id 非法(≤0/非有限数)时返回空串 */
export function snippetPath(style: UrlStyle, id: number): string {
const n = guardId(id);
if (n == null) return "";
if (style === "default") return `/snippets/${n}`;
return SNIPPET_TEMPLATES[style].detail(n);
}
/** 实体对应风格下的标准/pretty 路径(内部与 middleware 共用) */
export function entityPath(style: UrlStyle, kind: EntityKind, id: number): string {
if (kind === "post") return postPath(style, id);
@@ -173,7 +205,9 @@ export type PrettyMatch =
| { style: Exclude<UrlStyle, "default">; kind: "libraryDoc"; slug: string }
| { style: Exclude<UrlStyle, "default">; kind: "libraryRead"; slug: string; id: number }
| { style: Exclude<UrlStyle, "default">; kind: "library" }
| { style: Exclude<UrlStyle, "default">; kind: "leaderboard" };
| { style: Exclude<UrlStyle, "default">; kind: "leaderboard" }
| { style: Exclude<UrlStyle, "default">; kind: "snippets" }
| { style: Exclude<UrlStyle, "default">; kind: "snippet"; id: number };
// 精确锚定:id 仅纯数字,天然排除 /post/123/edit 这类子路径与伪造路径;
// page/libraryDoc 的 slug 捕获保持 URL 原样(encoded),rewrite 落点直接复用。
@@ -188,6 +222,8 @@ const PRETTY_PATTERNS: { re: RegExp; style: Exclude<UrlStyle, "default">; kind:
{ re: /^\/library\.html$/, style: "html", kind: "library" },
{ re: /^\/library-(.+)-read-(\d+)\.html$/, style: "html", kind: "libraryRead" },
{ re: /^\/library-([^/]+)\.html$/, style: "html", kind: "libraryDoc" },
{ re: /^\/snippets\.html$/, style: "html", kind: "snippets" },
{ re: /^\/snippet-(\d+)\.html$/, style: "html", kind: "snippet" },
{ 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" },
@@ -196,6 +232,8 @@ const PRETTY_PATTERNS: { re: RegExp; style: Exclude<UrlStyle, "default">; kind:
{ re: /^\/library\.html$/, style: "discuz", kind: "library" },
{ re: /^\/library-(.+)-read-(\d+)\.html$/, style: "discuz", kind: "libraryRead" },
{ re: /^\/library-([^/]+)\.html$/, style: "discuz", kind: "libraryDoc" },
{ re: /^\/snippets\.html$/, style: "discuz", kind: "snippets" },
{ re: /^\/snippet-(\d+)\.html$/, style: "discuz", kind: "snippet" },
];
const STANDARD_PATTERNS: { re: RegExp; kind: PageKind }[] = [
@@ -208,6 +246,9 @@ const STANDARD_PATTERNS: { re: RegExp; kind: PageKind }[] = [
{ re: /^\/library\/?$/, kind: "library" },
{ re: /^\/library\/([^/]+)\/read$/, kind: "libraryRead" },
{ re: /^\/library\/([^/]+)$/, kind: "libraryDoc" },
// (\d+)$ 纯数字锚定:/snippets/new 与 /snippets/123/edit 不会命中详情模式
{ re: /^\/snippets\/?$/, kind: "snippets" },
{ re: /^\/snippets\/(\d+)$/, kind: "snippet" },
];
/**
@@ -230,7 +271,7 @@ export function matchPrettyPath(
hit = { style: p.style, kind: p.kind, slug: m[1] };
} else if (p.kind === "libraryRead") {
hit = { style: p.style, kind: p.kind, slug: m[1], id: Number(m[2]) };
} else if (p.kind === "library" || p.kind === "leaderboard") {
} else if (p.kind === "library" || p.kind === "leaderboard" || p.kind === "snippets") {
hit = { style: p.style, kind: p.kind };
} else {
hit = { style: p.style, kind: p.kind, id: Number(m[1]) };
@@ -249,7 +290,7 @@ export function matchStandardPath(pathname: string): StandardMatch | null {
if (p.kind === "page" || p.kind === "libraryDoc" || p.kind === "libraryRead") {
return { kind: p.kind, slug: m[1] };
}
if (p.kind === "library" || p.kind === "leaderboard") return { kind: p.kind };
if (p.kind === "library" || p.kind === "leaderboard" || p.kind === "snippets") return { kind: p.kind };
return { kind: p.kind, id: Number(m[1]) };
}
return null;
@@ -261,7 +302,9 @@ export type StandardMatch =
| { kind: "libraryDoc"; slug: string }
| { kind: "libraryRead"; slug: string }
| { kind: "library" }
| { kind: "leaderboard" };
| { kind: "leaderboard" }
| { kind: "snippets" }
| { kind: "snippet"; id: number };
/**
* 反解任意实体路径(标准 + 全部伪静态风格),供 isActive 前缀判断、面包屑等
@@ -281,6 +324,7 @@ export function parseEntityPath(pathname: string): StandardMatch | null {
if (pretty.kind === "libraryRead") return { kind: "libraryRead", slug: decodeSlug(pretty.slug) };
if (pretty.kind === "leaderboard") return { kind: "leaderboard" };
if (pretty.kind === "library") return { kind: "library" };
if (pretty.kind === "snippets") return { kind: "snippets" };
return { kind: pretty.kind, id: pretty.id };
}