feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 消息撤回
|
||||
|
||||
48
frontend/lib/snippetLangColors.ts
Normal file
48
frontend/lib/snippetLangColors.ts
Normal 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)";
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user