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

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