feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
@@ -14,6 +14,8 @@ import {
|
||||
} from "./brand";
|
||||
import { DEFAULT_LEVEL_DEFS, LEVEL_COLOR_KEYS, MAX_LEVEL_COUNT, type LevelDef } from "./levels";
|
||||
import { normalizeUrlStyle, type UrlStyle } from "./urlStyle";
|
||||
import { DEFAULT_MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "./mobileTabs";
|
||||
import { DEFAULT_HEADER_NAV, normalizeHeaderNav, type HeaderNavItem } from "./headerNav";
|
||||
import { CSRF_COOKIE, hasAuthCookieHint } from "./cookies";
|
||||
import { publishPublicMetaSnapshot } from "./publicMetaSnapshot";
|
||||
import { emitForceLogout } from "./userEvents";
|
||||
@@ -243,6 +245,7 @@ export interface SitePage {
|
||||
allow_comments: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted?: boolean; // 软删标记(仅管理端列表)
|
||||
}
|
||||
|
||||
export interface SitePageInput {
|
||||
@@ -255,6 +258,72 @@ export interface SitePageInput {
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
/** 书库文件(epub/pdf/html 等;StoredName 不出后端) */
|
||||
export interface LibraryFileItem {
|
||||
id: number;
|
||||
doc_id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
ext: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
download_count: number;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 书库章节(两级树:parent_id 空=章,非空=节;正文 markdown) */
|
||||
export interface LibrarySectionItem {
|
||||
id: number;
|
||||
doc_id: number;
|
||||
parent_id: number | null;
|
||||
title: string;
|
||||
content: string;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 书库条目(详情/管理端,含文件与章节) */
|
||||
export interface LibraryDoc {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
cover_url: string;
|
||||
published: boolean;
|
||||
sort_order: number;
|
||||
entries_auto?: boolean; // 全章节条目卡模式(章节内容免写 [entries] 壳)
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted?: boolean; // 软删标记(仅管理端列表)
|
||||
files: LibraryFileItem[];
|
||||
sections: LibrarySectionItem[];
|
||||
}
|
||||
|
||||
/** 书库公开目录条目(不含 description 全文) */
|
||||
export interface LibraryDocListItem {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
cover_url: string;
|
||||
sort_order: number;
|
||||
file_count: number;
|
||||
files: LibraryFileItem[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LibraryInput {
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
cover_url?: string;
|
||||
published: boolean;
|
||||
sort_order?: number;
|
||||
entries_auto?: boolean;
|
||||
}
|
||||
|
||||
export interface NewUser {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -592,7 +661,7 @@ export interface UserProfile {
|
||||
badges?: UserBadgeView[];
|
||||
/** 访客(登录态)与该用户的关注关系;本人访问恒 false */
|
||||
is_following?: boolean;
|
||||
/** 仅本人访问时下发的打码邮箱(j***@163.com) */
|
||||
/** 仅本人访问时下发的邮箱明文(账号信息展示与资料表单回填) */
|
||||
email?: string;
|
||||
};
|
||||
stats: {
|
||||
@@ -1012,6 +1081,10 @@ export interface PublicSettings {
|
||||
levels_fx: boolean;
|
||||
/** 详情页伪静态风格;缺省 default(标准 /post/123) */
|
||||
url_style: UrlStyle;
|
||||
/** 移动端底栏显示的 tab(固定顺序子集);空/非法回落默认全量 */
|
||||
mobile_tabs: MobileTabKey[];
|
||||
/** 桌面端 Header 主导航(数组顺序即展示顺序);空数组=全部隐藏;缺省=内置三项 */
|
||||
header_nav: HeaderNavItem[];
|
||||
}
|
||||
|
||||
/** 与后端 DefaultAttachmentExts 保持同步 */
|
||||
@@ -1077,6 +1150,8 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
||||
levels: [...DEFAULT_LEVEL_DEFS],
|
||||
levels_fx: true,
|
||||
url_style: "default",
|
||||
mobile_tabs: [...DEFAULT_MOBILE_TABS],
|
||||
header_nav: DEFAULT_HEADER_NAV.map((item) => ({ ...item })),
|
||||
};
|
||||
|
||||
function normalizeAttachmentExts(raw: unknown): string[] {
|
||||
@@ -1167,6 +1242,9 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
|
||||
url_style: normalizeUrlStyle(data?.url_style),
|
||||
levels: normalizeLevels(data?.levels),
|
||||
levels_fx: data?.levels_fx !== false,
|
||||
mobile_tabs: normalizeMobileTabs(data?.mobile_tabs),
|
||||
// undefined(字段缺失)回落默认;[] 为空配置(全部隐藏),语义在 normalize 内区分
|
||||
header_nav: normalizeHeaderNav(data?.header_nav),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2686,6 +2764,19 @@ export async function apiTimelineFromGit(input: {
|
||||
return data as { items: TimelineGitItem[]; warning?: string; error?: string };
|
||||
}
|
||||
|
||||
export async function apiTimelineFromReleases(input: {
|
||||
urls: string[];
|
||||
}): Promise<{ items: TimelineGitItem[]; warning?: string; error?: string }> {
|
||||
const res = await fetchWithRefresh("/api/timeline/from-releases", {
|
||||
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 { items: TimelineGitItem[]; warning?: string; error?: string };
|
||||
}
|
||||
|
||||
/** SSR / 公开:已发布单页目录 */
|
||||
export async function fetchPublishedSitePages(): Promise<{ pages: SitePageSummary[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/pages`, ssrInit());
|
||||
@@ -2753,6 +2844,215 @@ export async function apiAdminDeleteSitePage(id: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 彻底删除单页(仅限已软删;删除后 slug 释放可复用) */
|
||||
export async function apiAdminPurgeSitePage(id: number): Promise<void> {
|
||||
const res = await fetchWithRefresh(`/api/admin/pages/${id}/purge`, {
|
||||
method: "DELETE",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "彻底删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 书库(公开 + 管理) =====
|
||||
|
||||
/** SSR / 公开:已发布书库目录 */
|
||||
export async function fetchLibraryList(): Promise<{ docs: LibraryDocListItem[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/library`, ssrInit());
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取书库失败");
|
||||
return { docs: data.docs ?? [] };
|
||||
}
|
||||
|
||||
/** SSR / 公开:书库条目详情(404 返 null) */
|
||||
export async function fetchLibraryDocBySlug(slug: string): Promise<{ doc: LibraryDoc } | null> {
|
||||
const res = await fetch(`${API_BASE}/api/library/${encodeURIComponent(slug)}`, ssrInit());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error("获取条目失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** SSR:管理端书库目录(含未发布) */
|
||||
export async function fetchAdminLibraryDocs(
|
||||
cookieHeader?: string
|
||||
): Promise<{ docs: LibraryDoc[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/admin/library/docs`, ssrInit(cookieHeaders(cookieHeader)));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取书库失败");
|
||||
return data as { docs: LibraryDoc[] };
|
||||
}
|
||||
|
||||
export async function apiAdminListLibraryDocs(): Promise<{ docs: LibraryDoc[] }> {
|
||||
const res = await fetchWithRefresh("/api/admin/library/docs", { headers: clientHeaders() });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取书库失败");
|
||||
return data as { docs: LibraryDoc[] };
|
||||
}
|
||||
|
||||
async function saveLibraryDoc(
|
||||
method: "POST" | "PUT",
|
||||
url: string,
|
||||
input: LibraryInput
|
||||
): Promise<{ doc: LibraryDoc }> {
|
||||
const res = await fetchWithRefresh(url, {
|
||||
method,
|
||||
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 { doc: LibraryDoc };
|
||||
}
|
||||
|
||||
export function apiAdminCreateLibraryDoc(input: LibraryInput) {
|
||||
return saveLibraryDoc("POST", "/api/admin/library/docs", input);
|
||||
}
|
||||
|
||||
export function apiAdminUpdateLibraryDoc(id: number, input: LibraryInput) {
|
||||
return saveLibraryDoc("PUT", `/api/admin/library/docs/${id}`, input);
|
||||
}
|
||||
|
||||
export async function apiAdminDeleteLibraryDoc(id: number): Promise<void> {
|
||||
const res = await fetchWithRefresh(`/api/admin/library/docs/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "删除条目失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** 彻底删除条目(仅限已软删;清除章节/文件行与磁盘文件,slug 释放可复用) */
|
||||
export async function apiAdminPurgeLibraryDoc(id: number): Promise<void> {
|
||||
const res = await fetchWithRefresh(`/api/admin/library/docs/${id}/purge`, {
|
||||
method: "DELETE",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "彻底删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** 上传文件到书库条目(multipart;扩展名/大小由后端白名单校验) */
|
||||
export async function apiAdminUploadLibraryFile(
|
||||
docId: number,
|
||||
file: File
|
||||
): Promise<{ file: LibraryFileItem; error?: string }> {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const res = await fetchWithRefresh(`/api/admin/library/docs/${docId}/files`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) return { file: null as unknown as LibraryFileItem, error: data.error || "上传失败" };
|
||||
return data as { file: LibraryFileItem };
|
||||
}
|
||||
|
||||
export async function apiAdminDeleteLibraryFile(fid: number): Promise<void> {
|
||||
const res = await fetchWithRefresh(`/api/admin/library/files/${fid}`, {
|
||||
method: "DELETE",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "删除文件失败");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 书库章节(两级树)=====
|
||||
|
||||
export interface LibrarySectionInput {
|
||||
parent_id?: number | null;
|
||||
title: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
/** 批量导入的单个 md 文件 */
|
||||
export interface LibraryImportFileInput {
|
||||
filename: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** 导入 md 拆章入参:多文件按传入顺序依次拆章;split_level h1/h2/none 对每个文件生效 */
|
||||
export interface LibraryImportMdInput {
|
||||
files: LibraryImportFileInput[];
|
||||
split_level: "h1" | "h2" | "none";
|
||||
}
|
||||
|
||||
export async function apiAdminCreateLibrarySection(
|
||||
docId: number,
|
||||
input: LibrarySectionInput
|
||||
): Promise<{ section: LibrarySectionItem }> {
|
||||
const res = await fetchWithRefresh(`/api/admin/library/docs/${docId}/sections`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "创建章节失败");
|
||||
return data as { section: LibrarySectionItem };
|
||||
}
|
||||
|
||||
export async function apiAdminUpdateLibrarySection(
|
||||
sid: number,
|
||||
input: Pick<LibrarySectionInput, "title" | "content">
|
||||
): Promise<{ section: LibrarySectionItem }> {
|
||||
const res = await fetchWithRefresh(`/api/admin/library/sections/${sid}`, {
|
||||
method: "PUT",
|
||||
headers: clientHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "保存章节失败");
|
||||
return data as { section: LibrarySectionItem };
|
||||
}
|
||||
|
||||
export async function apiAdminDeleteLibrarySection(sid: number): Promise<void> {
|
||||
const res = await fetchWithRefresh(`/api/admin/library/sections/${sid}`, {
|
||||
method: "DELETE",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "删除章节失败");
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiAdminMoveLibrarySection(
|
||||
sid: number,
|
||||
direction: "up" | "down"
|
||||
): Promise<void> {
|
||||
const res = await fetchWithRefresh(`/api/admin/library/sections/${sid}/move`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
body: JSON.stringify({ direction }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "移动章节失败");
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiAdminImportLibraryMarkdownBatch(
|
||||
docId: number,
|
||||
input: LibraryImportMdInput
|
||||
): Promise<{ created: number; sections: LibrarySectionItem[] }> {
|
||||
const res = await fetchWithRefresh(`/api/admin/library/docs/${docId}/import-md-batch`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "导入失败");
|
||||
return data as { created: number; sections: LibrarySectionItem[] };
|
||||
}
|
||||
|
||||
// ===== 用户管理(管理员) =====
|
||||
|
||||
// 后台用户列表项:email/最近活跃/登录 IP 仅管理员接口返回
|
||||
@@ -3367,6 +3667,10 @@ export type UpdateSiteSettingsBody = {
|
||||
allow_register?: boolean;
|
||||
allow_comments?: boolean;
|
||||
url_style?: UrlStyle;
|
||||
/** 移动端底栏显示的 tab(固定顺序子集,整体替换保存;空数组会被后端拒绝) */
|
||||
mobile_tabs?: MobileTabKey[];
|
||||
/** 桌面端 Header 主导航(整体替换保存;空数组表示全部隐藏) */
|
||||
header_nav?: HeaderNavItem[];
|
||||
comments_require_login?: boolean;
|
||||
allow_messages?: boolean;
|
||||
post_cooldown_hours?: number;
|
||||
@@ -3484,6 +3788,20 @@ export async function apiBackgroundFromMedia(
|
||||
return url;
|
||||
}
|
||||
|
||||
/** 把本人媒体库图片复制一份作书籍封面素材,返回副本 URL(与原图解耦) */
|
||||
export async function apiLibraryCoverFromMedia(attachmentId: number): Promise<string> {
|
||||
const res = await fetchWithRefresh("/api/admin/library/cover/from-media", {
|
||||
method: "POST",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ attachment_id: attachmentId }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "选用媒体库图片失败");
|
||||
const url = String(data.url || "");
|
||||
if (!url) throw new Error("选用媒体库图片失败");
|
||||
return url;
|
||||
}
|
||||
|
||||
// ===== 板块(客户端,供角色授权弹窗勾选) =====
|
||||
|
||||
export async function apiListBoards(): Promise<{ boards: Board[] }> {
|
||||
@@ -4443,3 +4761,101 @@ export async function apiAdminUpdateSidebarWidget(
|
||||
export async function apiAdminDeleteSidebarWidget(id: number): Promise<{ ok: boolean }> {
|
||||
return apiOperations(`/api/admin/sidebar/widgets/${id}`, "DELETE");
|
||||
}
|
||||
|
||||
/* ---------- 友情链接(申请审批制) ---------- */
|
||||
|
||||
export type FriendLinkStatus = "pending" | "approved" | "rejected";
|
||||
|
||||
export interface FriendLink {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
logo_url?: string;
|
||||
category?: string;
|
||||
reciprocal_url?: string;
|
||||
sort_order: number;
|
||||
status: FriendLinkStatus;
|
||||
applicant_user_id?: number | null;
|
||||
reject_reason?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface FriendLinkApplyInput {
|
||||
name: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
logo_url?: string;
|
||||
category?: string;
|
||||
reciprocal_url?: string;
|
||||
}
|
||||
|
||||
export interface FriendLinkAdminInput {
|
||||
name: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
logo_url?: string;
|
||||
category?: string;
|
||||
reciprocal_url?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
/** SSR 公开友情链接列表(仅 approved) */
|
||||
export async function fetchFriendLinks(cookie?: string): Promise<{ links: FriendLink[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/friend-links`, ssrInit(cookieHeaders(cookie)));
|
||||
if (!res.ok) throw new Error("获取友情链接失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 用户提交友情链接申请(登录) */
|
||||
export async function apiApplyFriendLink(body: FriendLinkApplyInput): Promise<{ link: FriendLink }> {
|
||||
return apiOperations("/api/friend-links/apply", "POST", body);
|
||||
}
|
||||
|
||||
/** 我的申请记录(含待审/拒绝与拒绝理由) */
|
||||
export async function apiMyFriendLinks(): Promise<{ links: FriendLink[] }> {
|
||||
return apiOperations("/api/friend-links/mine");
|
||||
}
|
||||
|
||||
/** 申请人修改自己的申请;已通过/已驳回的修改后回退待审 */
|
||||
export async function apiUpdateMyFriendLink(
|
||||
id: number,
|
||||
body: FriendLinkApplyInput
|
||||
): Promise<{ ok: boolean }> {
|
||||
return apiOperations(`/api/friend-links/${id}`, "PUT", body);
|
||||
}
|
||||
|
||||
/** SSR 管理端全量列表(含 pending/rejected) */
|
||||
export async function fetchAdminFriendLinks(cookie?: string): Promise<{ links: FriendLink[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/admin/friend-links`, {
|
||||
...ssrInit(cookie ? { Cookie: cookie } : undefined),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("加载友情链接失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiAdminListFriendLinks(): Promise<{ links: FriendLink[] }> {
|
||||
return apiOperations("/api/admin/friend-links");
|
||||
}
|
||||
|
||||
export async function apiAdminCreateFriendLink(body: FriendLinkAdminInput): Promise<{ link: FriendLink }> {
|
||||
return apiOperations("/api/admin/friend-links", "POST", body);
|
||||
}
|
||||
|
||||
export async function apiAdminUpdateFriendLink(id: number, body: FriendLinkAdminInput): Promise<{ ok: boolean }> {
|
||||
return apiOperations(`/api/admin/friend-links/${id}`, "PUT", body);
|
||||
}
|
||||
|
||||
export async function apiAdminSetFriendLinkStatus(
|
||||
id: number,
|
||||
status: FriendLinkStatus,
|
||||
reject_reason?: string
|
||||
): Promise<{ ok: boolean }> {
|
||||
return apiOperations(`/api/admin/friend-links/${id}/status`, "POST", reject_reason ? { status, reject_reason } : { status });
|
||||
}
|
||||
|
||||
export async function apiAdminDeleteFriendLink(id: number): Promise<{ ok: boolean }> {
|
||||
return apiOperations(`/api/admin/friend-links/${id}`, "DELETE");
|
||||
}
|
||||
|
||||
@@ -107,6 +107,14 @@ export function normalizeBrandURL(slot: BrandSlot, raw: unknown): string {
|
||||
return re.test(u) ? u : "";
|
||||
}
|
||||
|
||||
/** 品牌图固定对外地址(与 Go service/brand.go 的 BrandAliasPath* 对齐):
|
||||
* 友链等外部引用此地址,站点换图后地址不变 */
|
||||
export function brandAliasPath(slot: BrandSlot): string {
|
||||
if (slot === "logo_light") return "/api/brand/logo-light";
|
||||
if (slot === "logo_dark") return "/api/brand/logo-dark";
|
||||
return "/api/brand/favicon";
|
||||
}
|
||||
|
||||
export function normalizeKeywords(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
11
frontend/lib/coverColor.ts
Normal file
11
frontend/lib/coverColor.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 书名 → 稳定色相(0-359):djb2 哈希取模。
|
||||
* 无封面文本封面的配色来源——同一本书在任何页面、任何刷新下颜色一致,
|
||||
* 不使用 Math.random()(那会导致每次刷新变色)。
|
||||
* 用法:元素挂 .j13-text-cover 类并内联 --cover-h,取色规则见 globals.css。
|
||||
*/
|
||||
export function coverHue(title: string): number {
|
||||
let h = 5381;
|
||||
for (let i = 0; i < title.length; i++) h = (h * 33) ^ title.charCodeAt(i);
|
||||
return (h >>> 0) % 360;
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
/** 统一拆分正文中的 [hide] / [timeline] 指令块(忽略代码围栏) */
|
||||
/** 统一拆分正文中的 [hide] / [timeline] / [entries] 指令块(忽略代码围栏) */
|
||||
|
||||
import {
|
||||
splitHideSegments,
|
||||
type HideKind,
|
||||
type HideSegment,
|
||||
} from "@/lib/hideBlocks";
|
||||
import { parseTimelineBody, type TimelineItem } from "@/lib/timelineBlocks";
|
||||
} from "./hideBlocks.ts";
|
||||
import { parseTimelineBody, type TimelineItem } from "./timelineBlocks.ts";
|
||||
import {
|
||||
isEntriesClose,
|
||||
isEntriesOffOpen,
|
||||
isEntriesOpen,
|
||||
parseEntriesBody,
|
||||
wrapImplicitEntries,
|
||||
type EntryItem,
|
||||
} from "./entryBlocks.ts";
|
||||
|
||||
export type ContentSegment =
|
||||
| { type: "md"; text: string }
|
||||
@@ -16,7 +24,8 @@ export type ContentSegment =
|
||||
locked: boolean;
|
||||
body: string;
|
||||
}
|
||||
| { type: "timeline"; items: TimelineItem[]; rawBody: string };
|
||||
| { type: "timeline"; items: TimelineItem[]; rawBody: string }
|
||||
| { type: "entries"; items: EntryItem[]; rawBody: string };
|
||||
|
||||
function isTimelineClose(line: string): boolean {
|
||||
return line.trim().toLowerCase() === "[/timeline]";
|
||||
@@ -29,11 +38,23 @@ function isTimelineOpen(trimmed: string): boolean {
|
||||
/**
|
||||
* 先拆时间线,再对普通 md/hide 段做 hide 拆分。
|
||||
* 时间线与 hide 互不嵌套;解析失败时整段当普通 Markdown。
|
||||
* opts.entriesAuto:书级「全章节条目卡模式」开关——开启时按 wrapImplicitEntries
|
||||
* 把「## 标题 + @tags/@summary/@source/字段::值」写法包上 [entries] 壳再走统一拆分;
|
||||
* 关闭(含帖子/评论等非书场景)时均按普通 Markdown 渲染,条目卡只能用显式 [entries] 块。
|
||||
* [entries:off] 块内始终保持普通 Markdown。
|
||||
*/
|
||||
export function splitContentSegments(content: string): ContentSegment[] {
|
||||
export function splitContentSegments(
|
||||
content: string,
|
||||
opts?: { entriesAuto?: boolean }
|
||||
): ContentSegment[] {
|
||||
if (!content) return [{ type: "md", text: "" }];
|
||||
if (opts?.entriesAuto) {
|
||||
content = wrapImplicitEntries(content);
|
||||
}
|
||||
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const raw: Array<{ type: "md"; text: string } | { type: "timeline"; body: string }> = [];
|
||||
const raw: Array<
|
||||
{ type: "md"; text: string } | { type: "timeline"; body: string } | { type: "entries"; body: string }
|
||||
> = [];
|
||||
let buf: string[] = [];
|
||||
let inCode = false;
|
||||
let i = 0;
|
||||
@@ -44,6 +65,54 @@ export function splitContentSegments(content: string): ContentSegment[] {
|
||||
buf = [];
|
||||
};
|
||||
|
||||
/** 从 i 行的开标签扫到匹配闭标签;忽略代码围栏,嵌套开标签视为失败(返回 null,调用方按 md 处理) */
|
||||
const scanBlock = (
|
||||
isOpen: (t: string) => boolean,
|
||||
isClose: (t: string) => boolean
|
||||
): { body: string; end: number } | null => {
|
||||
const bodyLines: string[] = [];
|
||||
let j = i + 1;
|
||||
let innerCode = false;
|
||||
while (j < lines.length) {
|
||||
const inner = lines[j].trim();
|
||||
if (inner.startsWith("```")) {
|
||||
innerCode = !innerCode;
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (innerCode) {
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (isOpen(inner)) return null;
|
||||
if (isClose(inner)) return { body: bodyLines.join("\n"), end: j };
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** 开闭标签块(timeline/entries)公共处理:失败按 md,成功推进 i */
|
||||
const takeBlock = (
|
||||
line: string,
|
||||
isOpen: (t: string) => boolean,
|
||||
isClose: (t: string) => boolean,
|
||||
type: "timeline" | "entries"
|
||||
): boolean => {
|
||||
const hit = scanBlock(isOpen, isClose);
|
||||
if (!hit) {
|
||||
buf.push(line);
|
||||
i++;
|
||||
return true;
|
||||
}
|
||||
flushMd();
|
||||
raw.push({ type, body: hit.body });
|
||||
i = hit.end + 1;
|
||||
return true;
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (trimmed.startsWith("```")) {
|
||||
@@ -59,45 +128,26 @@ export function splitContentSegments(content: string): ContentSegment[] {
|
||||
}
|
||||
|
||||
if (isTimelineOpen(trimmed)) {
|
||||
flushMd();
|
||||
const bodyLines: string[] = [];
|
||||
let j = i + 1;
|
||||
let innerCode = false;
|
||||
let found = false;
|
||||
while (j < lines.length) {
|
||||
const inner = lines[j].trim();
|
||||
if (inner.startsWith("```")) {
|
||||
innerCode = !innerCode;
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (innerCode) {
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (isTimelineOpen(inner)) {
|
||||
// 嵌套视为失败:整段当 md
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
if (isTimelineClose(inner)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
if (!found) {
|
||||
takeBlock(lines[i], isTimelineOpen, isTimelineClose, "timeline");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isEntriesOpen(trimmed)) {
|
||||
takeBlock(lines[i], isEntriesOpen, isEntriesClose, "entries");
|
||||
continue;
|
||||
}
|
||||
|
||||
// [entries:off] 例外块:body 原样进 md 缓冲(标记行丢弃),
|
||||
// 不再参与 entries/timeline 拆分;未闭合时整块当普通 Markdown
|
||||
if (isEntriesOffOpen(trimmed)) {
|
||||
const hit = scanBlock(isEntriesOffOpen, isEntriesClose);
|
||||
if (!hit) {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
raw.push({ type: "timeline", body: bodyLines.join("\n") });
|
||||
i = j + 1;
|
||||
buf.push(hit.body);
|
||||
i = hit.end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -116,6 +166,14 @@ export function splitContentSegments(content: string): ContentSegment[] {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (part.type === "entries") {
|
||||
out.push({
|
||||
type: "entries",
|
||||
items: parseEntriesBody(part.body),
|
||||
rawBody: part.body,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const hideSegs = splitHideSegments(part.text) as HideSegment[];
|
||||
for (const s of hideSegs) {
|
||||
if (s.type === "md") {
|
||||
|
||||
351
frontend/lib/entryBlocks.test.ts
Normal file
351
frontend/lib/entryBlocks.test.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
parseEntriesBody,
|
||||
parseEntrySource,
|
||||
serializeEntriesBlock,
|
||||
wrapImplicitEntries,
|
||||
} from "./entryBlocks.ts";
|
||||
import { splitContentSegments } from "./directiveBlocks.ts";
|
||||
import { buildTocFromSegments, extractToc } from "./toc.ts";
|
||||
|
||||
describe("parseEntrySource", () => {
|
||||
it("最后一个 | 后为 http(s) 时拆分文本与链接", () => {
|
||||
assert.deepEqual(parseEntrySource("NHTSA (2024). Report | https://a.gov/x"), {
|
||||
text: "NHTSA (2024). Report",
|
||||
url: "https://a.gov/x",
|
||||
});
|
||||
});
|
||||
it("无链接或非 http 后缀时整行作为文本", () => {
|
||||
assert.deepEqual(parseEntrySource("WHO 口径"), { text: "WHO 口径" });
|
||||
assert.deepEqual(parseEntrySource("标题 | 备注说明"), { text: "标题 | 备注说明" });
|
||||
});
|
||||
it("兼容全角竖线", () => {
|
||||
assert.deepEqual(parseEntrySource("来源|https://a.gov/x"), {
|
||||
text: "来源",
|
||||
url: "https://a.gov/x",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseEntriesBody", () => {
|
||||
it("完整条目:标题/标签/摘要/字段/来源", () => {
|
||||
const items = parseEntriesBody(
|
||||
[
|
||||
"## 系安全带,前排后排都系",
|
||||
"@tags 性价比:极高 | 证据:A级 | 不花钱",
|
||||
"@summary 坐前排系上安全带,概率大约降一半。",
|
||||
"成本 :: 不花钱。每次上车多花 2 秒。",
|
||||
"收益 :: NHTSA 估计致命伤风险下降 45%。",
|
||||
"@source NHTSA (2024). Report | https://a.gov/x",
|
||||
"@source WHO (2025). Fact sheet",
|
||||
].join("\n")
|
||||
);
|
||||
assert.equal(items.length, 1);
|
||||
assert.deepEqual(items[0], {
|
||||
title: "系安全带,前排后排都系",
|
||||
tags: ["性价比:极高", "证据:A级", "不花钱"],
|
||||
summary: "坐前排系上安全带,概率大约降一半。",
|
||||
fields: [
|
||||
{ label: "成本", value: "不花钱。每次上车多花 2 秒。" },
|
||||
{ label: "收益", value: "NHTSA 估计致命伤风险下降 45%。" },
|
||||
],
|
||||
sources: [
|
||||
{ text: "NHTSA (2024). Report", url: "https://a.gov/x" },
|
||||
{ text: "WHO (2025). Fact sheet" },
|
||||
],
|
||||
});
|
||||
});
|
||||
it("多条目按标题切分;标题后裸文本计入摘要", () => {
|
||||
const items = parseEntriesBody(
|
||||
[
|
||||
"## 甲",
|
||||
"第一段摘要。",
|
||||
"第二行摘要。",
|
||||
"成本 :: 10 元。",
|
||||
"## 乙",
|
||||
"@tags 快",
|
||||
].join("\n")
|
||||
);
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(items[0].title, "甲");
|
||||
assert.equal(items[0].summary, "第一段摘要。\n第二行摘要。");
|
||||
assert.deepEqual(items[0].fields, [{ label: "成本", value: "10 元。" }]);
|
||||
assert.equal(items[1].title, "乙");
|
||||
assert.deepEqual(items[1].tags, ["快"]);
|
||||
});
|
||||
it("字段值多行追加到最后一个字段;无条目的内容丢弃", () => {
|
||||
const items = parseEntriesBody(
|
||||
[
|
||||
"块首无标题行应丢弃",
|
||||
"## 甲",
|
||||
"成本 :: 第一行",
|
||||
"续行仍属成本",
|
||||
"@tags x",
|
||||
].join("\n")
|
||||
);
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].fields[0].value, "第一行\n续行仍属成本");
|
||||
});
|
||||
it("空块返回空数组", () => {
|
||||
assert.deepEqual(parseEntriesBody(""), []);
|
||||
});
|
||||
it("@plain 标记无标题头,序列化往返保留", () => {
|
||||
const items = parseEntriesBody(["## 甲", "@plain", "@tags x", "成本 :: 1"].join("\n"));
|
||||
assert.equal(items[0].plain, true);
|
||||
const round = parseEntriesBody(
|
||||
serializeEntriesBlock(items).slice("[entries]\n".length, -"\n[/entries]".length)
|
||||
);
|
||||
assert.equal(round[0].plain, true);
|
||||
const normal = parseEntriesBody(["## 甲", "@tags x"].join("\n"));
|
||||
assert.equal(normal[0].plain, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeEntriesBlock", () => {
|
||||
it("往返:序列化后可重新解析", () => {
|
||||
const src = parseEntriesBody(
|
||||
[
|
||||
"## 系安全带",
|
||||
"@tags 性价比:极高 | 不花钱",
|
||||
"@summary 概率降一半。",
|
||||
"成本 :: 不花钱。",
|
||||
"@source NHTSA | https://a.gov/x",
|
||||
].join("\n")
|
||||
);
|
||||
const round = parseEntriesBody(serializeEntriesBlock(src).slice("[entries]\n".length, -"\n[/entries]".length));
|
||||
assert.deepEqual(round, src);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitContentSegments 集成 [entries]", () => {
|
||||
it("条目块单独成段,前后 md 保留", () => {
|
||||
const segs = splitContentSegments(
|
||||
["前文", "", "[entries]", "## 甲", "@tags x", "[/entries]", "", "后文"].join("\n")
|
||||
);
|
||||
assert.deepEqual(
|
||||
segs.map((s) => s.type),
|
||||
["md", "entries", "md"]
|
||||
);
|
||||
if (segs[1].type === "entries") {
|
||||
assert.equal(segs[1].items[0].title, "甲");
|
||||
} else {
|
||||
assert.fail("第二段应为 entries");
|
||||
}
|
||||
});
|
||||
it("代码围栏内的块标记与未闭合块按普通 md 处理", () => {
|
||||
const fenced = splitContentSegments(["```", "[entries]", "```"].join("\n"));
|
||||
assert.deepEqual(fenced.map((s) => s.type), ["md"]);
|
||||
const unclosed = splitContentSegments(["[entries]", "## 甲"].join("\n"));
|
||||
assert.deepEqual(unclosed.map((s) => s.type), ["md"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractToc 跳过 entries 块", () => {
|
||||
it("块内标题不进目录,块外锚点不错位", () => {
|
||||
const toc = extractToc(
|
||||
["## 外部一", "[entries]", "## 卡片标题", "[/entries]", "### 外部二"].join("\n")
|
||||
);
|
||||
assert.deepEqual(
|
||||
toc.map((t) => `${t.level}:${t.text}:${t.id}`),
|
||||
["2:外部一:h-0", "3:外部二:h-1"]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapImplicitEntries(书级条目卡模式)", () => {
|
||||
it("标题 + 指令行自动包壳;普通小节与正文不受影响", () => {
|
||||
const src = [
|
||||
"# 第一章",
|
||||
"",
|
||||
"## 说明小节",
|
||||
"这是普通段落,不是条目。",
|
||||
"",
|
||||
"## 系安全带",
|
||||
"@tags 性价比:极高",
|
||||
"@summary 概率降一半。",
|
||||
"成本 :: 不花钱。",
|
||||
"",
|
||||
"普通正文段落,出现在条目之后。",
|
||||
].join("\n");
|
||||
const wrapped = wrapImplicitEntries(src);
|
||||
const segs = splitContentSegments(wrapped, { entriesAuto: false });
|
||||
assert.deepEqual(
|
||||
segs.map((s) => s.type),
|
||||
["md", "entries", "md"]
|
||||
);
|
||||
if (segs[1].type === "entries") {
|
||||
assert.equal(segs[1].items.length, 1);
|
||||
assert.equal(segs[1].items[0].title, "系安全带");
|
||||
assert.deepEqual(segs[1].items[0].tags, ["性价比:极高"]);
|
||||
assert.deepEqual(segs[1].items[0].fields, [{ label: "成本", value: "不花钱。" }]);
|
||||
} else {
|
||||
assert.fail("隐式条目应解析为 entries 段");
|
||||
}
|
||||
// 普通小节标题与条目后正文都按普通 md 渲染
|
||||
if (segs[0].type !== "md" || segs[2].type !== "md") assert.fail("前后应为 md 段");
|
||||
assert.ok(segs[0].text.includes("## 说明小节"));
|
||||
assert.ok(segs[2].text.includes("普通正文段落"));
|
||||
});
|
||||
|
||||
it("空行后仍是指令行时条目继续;空行后普通文本则条目收尾", () => {
|
||||
const src = [
|
||||
"## 甲",
|
||||
"@tags a",
|
||||
"",
|
||||
"备注 :: 续字段。",
|
||||
"",
|
||||
"收尾后的普通段落。",
|
||||
].join("\n");
|
||||
const items = parseEntriesBody(
|
||||
wrapImplicitEntries(src)
|
||||
.split("[entries]\n")[1]
|
||||
?.split("\n[/entries]")[0] ?? ""
|
||||
);
|
||||
assert.equal(items.length, 1);
|
||||
assert.deepEqual(items[0].tags, ["a"]);
|
||||
assert.deepEqual(items[0].fields, [{ label: "备注", value: "续字段。" }]);
|
||||
});
|
||||
|
||||
it("显式 [entries] 块与 [entries:off]、时间线块原样跳过", () => {
|
||||
const src = [
|
||||
"[entries]",
|
||||
"## 显式条目",
|
||||
"@tags x",
|
||||
"[/entries]",
|
||||
"",
|
||||
"[entries:off]",
|
||||
"## off 内标题",
|
||||
"@tags 不应识别为条目",
|
||||
"[/entries]",
|
||||
"",
|
||||
"## 隐式条目",
|
||||
"@summary 摘要。",
|
||||
].join("\n");
|
||||
const segs = splitContentSegments(src, { entriesAuto: true });
|
||||
assert.deepEqual(
|
||||
segs.map((s) => s.type),
|
||||
["entries", "md", "entries"]
|
||||
);
|
||||
if (segs[0].type === "entries") assert.equal(segs[0].items[0].title, "显式条目");
|
||||
else assert.fail("显式块应保持 entries");
|
||||
// off 段内容原样按普通 md 渲染(含 off 内字面标记行)
|
||||
if (segs[1].type !== "md") assert.fail("off 段应为 md");
|
||||
assert.ok(segs[1].text.includes("## off 内标题"));
|
||||
assert.ok(segs[1].text.includes("@tags 不应识别为条目"));
|
||||
if (segs[2].type === "entries") assert.equal(segs[2].items[0].title, "隐式条目");
|
||||
else assert.fail("off 之后的条目应正常识别");
|
||||
});
|
||||
|
||||
it("幂等:包壳结果重复执行不变", () => {
|
||||
const src = [
|
||||
"## 系安全带",
|
||||
"@tags 钱=0",
|
||||
"成本 :: 不花钱。",
|
||||
"",
|
||||
"## 隐式条目",
|
||||
"@tags x",
|
||||
].join("\n");
|
||||
const once = wrapImplicitEntries(src);
|
||||
assert.equal(wrapImplicitEntries(once), once);
|
||||
});
|
||||
|
||||
it("条目标题进卡后目录不再收录,块外标题锚点不漂移", () => {
|
||||
const src = [
|
||||
"## 前章",
|
||||
"",
|
||||
"### 系安全带",
|
||||
"@summary 降一半。",
|
||||
"成本 :: 不花钱。",
|
||||
"",
|
||||
"### 后节",
|
||||
].join("\n");
|
||||
const toc = extractToc(wrapImplicitEntries(src));
|
||||
assert.deepEqual(
|
||||
toc.map((t) => `${t.level}:${t.text}`),
|
||||
["2:前章", "3:后节"]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTocFromSegments(条目卡模式目录)", () => {
|
||||
it("md 标题与条目标题按渲染顺序编入目录并分配锚点", () => {
|
||||
const src = [
|
||||
"开头说明。",
|
||||
"",
|
||||
"## 系安全带",
|
||||
"@tags 钱=0",
|
||||
"成本 :: 不花钱。",
|
||||
"",
|
||||
"### 小节标题",
|
||||
].join("\n");
|
||||
const segs = splitContentSegments(wrapImplicitEntries(src));
|
||||
let n = 0;
|
||||
const { items, mdHeadIds, entriesTitleIds } = buildTocFromSegments(segs, () => `e-1-${++n}`);
|
||||
assert.deepEqual(
|
||||
items.map((t) => `${t.level}:${t.text}`),
|
||||
["2:系安全带", "3:小节标题"]
|
||||
);
|
||||
assert.deepEqual(mdHeadIds, ["e-1-2"]);
|
||||
assert.equal(entriesTitleIds[0], undefined);
|
||||
assert.deepEqual(entriesTitleIds[1], ["e-1-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("注释标签写法不再识别(已收敛为一套 @ 指令写法)", () => {
|
||||
const USER_SAMPLE = [
|
||||
"<!-- 成本标签: 钱=0 时间=少 -->",
|
||||
"- 成本:不花钱。",
|
||||
"- 说人话:死亡风险降一半。",
|
||||
].join("\n");
|
||||
|
||||
it("entriesAuto 开启时注释标签 + 字段列表仍保持普通 Markdown", () => {
|
||||
const segs = splitContentSegments(USER_SAMPLE, { entriesAuto: true });
|
||||
assert.deepEqual(segs.map((s) => s.type), ["md"]);
|
||||
if (segs[0].type !== "md") assert.fail("应为 md 段");
|
||||
assert.ok(segs[0].text.includes("- 成本:不花钱。"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("端到端:@ 指令写法(安全带条目)", () => {
|
||||
it("标题/@tags/@summary/字段/多来源完整成卡", () => {
|
||||
const src = [
|
||||
"### 系安全带,前排后排都系",
|
||||
"@tags 钱=0 | 时间=少 | 毅力=否 | 收益=大 | 口径=死亡率 | 证据:A",
|
||||
"@summary 坐前排系上安全带,出车祸时被撞死的概率大约降一半。",
|
||||
"成本 :: 不花钱。每次上车多花 2 秒。",
|
||||
"收益 :: NHTSA 估计致命伤风险下降 45%。",
|
||||
"备注 :: 后排也要系。",
|
||||
"@source NHTSA (2024). Occupant Protection (DOT HS 813 573) | https://crashstats.nhtsa.dot.gov/Api/Public/ViewPublication/813573",
|
||||
"@source WHO (2025). Road traffic injuries fact sheet | https://www.who.int/news-room/fact-sheets/detail/road-traffic-injuries",
|
||||
].join("\n");
|
||||
const segs = splitContentSegments(src, { entriesAuto: true });
|
||||
assert.deepEqual(segs.map((s) => s.type), ["entries"]);
|
||||
const it = segs[0].items[0];
|
||||
assert.equal(it.title, "系安全带,前排后排都系");
|
||||
assert.equal(it.summary, "坐前排系上安全带,出车祸时被撞死的概率大约降一半。");
|
||||
assert.deepEqual(it.tags, [
|
||||
"钱=0",
|
||||
"时间=少",
|
||||
"毅力=否",
|
||||
"收益=大",
|
||||
"口径=死亡率",
|
||||
"证据:A",
|
||||
]);
|
||||
assert.deepEqual(it.fields, [
|
||||
{ label: "成本", value: "不花钱。每次上车多花 2 秒。" },
|
||||
{ label: "收益", value: "NHTSA 估计致命伤风险下降 45%。" },
|
||||
{ label: "备注", value: "后排也要系。" },
|
||||
]);
|
||||
assert.deepEqual(it.sources, [
|
||||
{
|
||||
text: "NHTSA (2024). Occupant Protection (DOT HS 813 573)",
|
||||
url: "https://crashstats.nhtsa.dot.gov/Api/Public/ViewPublication/813573",
|
||||
},
|
||||
{
|
||||
text: "WHO (2025). Road traffic injuries fact sheet",
|
||||
url: "https://www.who.int/news-room/fact-sheets/detail/road-traffic-injuries",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
298
frontend/lib/entryBlocks.ts
Normal file
298
frontend/lib/entryBlocks.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/** 正文 [entries]…[/entries] 条目卡块解析:
|
||||
* 通用「结构化小节」渲染约定——字段名不限定,任何书籍可定义自己的字段。
|
||||
* 块内语法:
|
||||
* ## 条目标题
|
||||
* @tags 标签一 | 主:次 | 标签三 → 标签 chips(| 或 | 分隔,"主:次" 渲染主次两段)
|
||||
* @summary 一句话结论 → 高亮摘要块(可多行;标题后的裸文本也计入摘要)
|
||||
* 字段名 :: 内容 → 灰色标签行(label 任意,可多行)
|
||||
* @source 来源文本 | https://… → 折叠来源列表(url 可省)
|
||||
*/
|
||||
|
||||
export interface EntrySource {
|
||||
text: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface EntryField {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface EntryItem {
|
||||
title: string;
|
||||
tags: string[];
|
||||
summary?: string;
|
||||
fields: EntryField[];
|
||||
sources: EntrySource[];
|
||||
/** 无标题头(注释标签卡等场景):不渲染编号与标题行 */
|
||||
plain?: boolean;
|
||||
}
|
||||
|
||||
const HEADING_RE = /^#{1,4}\s+(.+)$/;
|
||||
/** 条目标题自带序号前缀("1."、"12、"、"(3)"、"(4)"):卡片不再叠加自动编号 */
|
||||
export const ENTRY_TITLE_NUM_RE = /^(?:[((]\d+[))]|\d+\s*[.、))])/;
|
||||
const TAGS_RE = /^@tags\s+(.+)$/i;
|
||||
const SUMMARY_RE = /^@summary(?:\s+(.*))?$/i;
|
||||
const SOURCE_RE = /^@source\s+(.+)$/i;
|
||||
const PLAIN_RE = /^@plain$/i;
|
||||
const FIELD_RE = /^([^:@\n]{1,40}?)\s*::\s*(.*)$/;
|
||||
|
||||
/** 来源行:"文本 | url",取最后一个 | 且后段为 http(s) 才拆分;文本/链接兼容全角竖线 */
|
||||
export function parseEntrySource(raw: string): EntrySource {
|
||||
const idx = Math.max(raw.lastIndexOf("|"), raw.lastIndexOf("|"));
|
||||
if (idx > 0) {
|
||||
const url = raw.slice(idx + 1).trim();
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
return { text: raw.slice(0, idx).trim(), url };
|
||||
}
|
||||
}
|
||||
return { text: raw.trim() };
|
||||
}
|
||||
|
||||
export function parseEntriesBody(body: string): EntryItem[] {
|
||||
const lines = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const items: EntryItem[] = [];
|
||||
let cur: EntryItem | null = null;
|
||||
/** 普通文本行的归宿:摘要 / 最后一个字段(缺省时并入摘要) */
|
||||
let target: { kind: "summary" } | { kind: "field"; index: number } | null = null;
|
||||
|
||||
const flush = () => {
|
||||
if (cur) items.push(cur);
|
||||
cur = null;
|
||||
target = null;
|
||||
};
|
||||
|
||||
const appendSummary = (text: string) => {
|
||||
if (!cur) return;
|
||||
cur.summary = cur.summary ? `${cur.summary}\n${text}` : text;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
|
||||
const hm = t.match(HEADING_RE);
|
||||
if (hm) {
|
||||
flush();
|
||||
cur = { title: hm[1].trim(), tags: [], fields: [], sources: [] };
|
||||
target = null;
|
||||
continue;
|
||||
}
|
||||
if (!cur) continue;
|
||||
|
||||
const tm = t.match(TAGS_RE);
|
||||
if (tm) {
|
||||
cur.tags.push(...tm[1].split(/[||]/).map((s) => s.trim()).filter(Boolean));
|
||||
target = null;
|
||||
continue;
|
||||
}
|
||||
const sm = t.match(SUMMARY_RE);
|
||||
if (sm) {
|
||||
const rest = (sm[1] ?? "").trim();
|
||||
if (rest) appendSummary(rest);
|
||||
target = { kind: "summary" };
|
||||
continue;
|
||||
}
|
||||
const scm = t.match(SOURCE_RE);
|
||||
if (scm) {
|
||||
cur.sources.push(parseEntrySource(scm[1]));
|
||||
target = null;
|
||||
continue;
|
||||
}
|
||||
if (PLAIN_RE.test(t)) {
|
||||
cur.plain = true;
|
||||
target = null;
|
||||
continue;
|
||||
}
|
||||
const fm = t.match(FIELD_RE);
|
||||
if (fm) {
|
||||
cur.fields.push({ label: fm[1].trim(), value: fm[2] });
|
||||
target = { kind: "field", index: cur.fields.length - 1 };
|
||||
continue;
|
||||
}
|
||||
// 普通行:追加到开放容器;无容器时视为摘要(标题后直接跟正文段落)
|
||||
if (target?.kind === "field") {
|
||||
const f = cur.fields[target.index];
|
||||
f.value = `${f.value}\n${t}`;
|
||||
} else {
|
||||
appendSummary(t);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 序列化回块文本(编辑器面板复用);selected=false 的条目丢弃,与时间线一致 */
|
||||
export function serializeEntriesBlock(items: (EntryItem & { selected?: boolean })[]): string {
|
||||
const parts = items
|
||||
.filter((it) => it.selected !== false)
|
||||
.map((it) => {
|
||||
const lines = [`## ${it.title.trim() || "未命名"}`];
|
||||
if (it.plain) lines.push("@plain");
|
||||
if (it.tags.length > 0) lines.push(`@tags ${it.tags.join(" | ")}`);
|
||||
if (it.summary?.trim()) lines.push(`@summary ${it.summary.trim()}`);
|
||||
for (const f of it.fields) {
|
||||
if (!f.label.trim() && !f.value.trim()) continue;
|
||||
lines.push(`${f.label.trim() || "备注"} :: ${f.value.trim()}`);
|
||||
}
|
||||
for (const s of it.sources) {
|
||||
if (!s.text.trim()) continue;
|
||||
lines.push(`@source ${s.text.trim()}${s.url?.trim() ? ` | ${s.url.trim()}` : ""}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
});
|
||||
return `[entries]\n${parts.join("\n\n")}\n[/entries]`;
|
||||
}
|
||||
|
||||
export const ENTRIES_OPEN = "[entries]";
|
||||
export const ENTRIES_CLOSE = "[/entries]";
|
||||
|
||||
/** [entries:off] 例外块:书级条目卡模式开启时,块内恢复普通 Markdown 渲染 */
|
||||
export const ENTRIES_OFF_OPEN = "[entries:off]";
|
||||
|
||||
export function isEntriesOpen(line: string): boolean {
|
||||
return line.trim().toLowerCase() === ENTRIES_OPEN;
|
||||
}
|
||||
|
||||
export function isEntriesClose(line: string): boolean {
|
||||
return line.trim().toLowerCase() === ENTRIES_CLOSE;
|
||||
}
|
||||
|
||||
export function isEntriesOffOpen(line: string): boolean {
|
||||
return line.trim().toLowerCase() === ENTRIES_OFF_OPEN;
|
||||
}
|
||||
|
||||
/** 条目指令行:@tags / @summary / @source / @plain / 字段名 :: 内容(隐式模式识别条目特征用) */
|
||||
export function isEntrySignalLine(t: string): boolean {
|
||||
return (
|
||||
TAGS_RE.test(t) || SUMMARY_RE.test(t) || SOURCE_RE.test(t) || PLAIN_RE.test(t) || FIELD_RE.test(t)
|
||||
);
|
||||
}
|
||||
|
||||
/** 已知指令块标记行(隐式模式扫描时原样跳过,不当作条目内容) */
|
||||
const DIRECTIVE_LINE_RE = /^\[(\/)?(entries(?::off)?|timeline|hide[^\]]*)\]$/i;
|
||||
|
||||
export function isDirectiveLine(t: string): boolean {
|
||||
return DIRECTIVE_LINE_RE.test(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐式条目模式:把「## 标题 + 条目指令行」的区间自动包上 [entries] 壳。
|
||||
* 规则:
|
||||
* - 标题行(1-4 级)与其后到空行/结构标记前的段落里出现任一条目指令行 → 确认为条目;
|
||||
* 否则视为普通小节标题,原样输出
|
||||
* - 条目体持续收集:指令行后的普通文本行归入条目(与显式块语义一致);
|
||||
* 空行后若仍是指令行则继续,否则条目在空行处收尾
|
||||
* - 代码围栏与已知指令块([entries]/[entries:off]/[timeline]/[hide…])原样跳过
|
||||
*/
|
||||
export function wrapImplicitEntries(src: string): string {
|
||||
const lines = src.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const out: string[] = [];
|
||||
let inCode = false;
|
||||
let i = 0;
|
||||
|
||||
/** 从 i 行的开标记找匹配闭标记,返回闭标记行下标;找不到返回 -1 */
|
||||
const findClose = (start: number, isClose: (t: string) => boolean): number => {
|
||||
let innerCode = false;
|
||||
for (let j = start; j < lines.length; j++) {
|
||||
const t = lines[j].trim();
|
||||
if (t.startsWith("```")) innerCode = !innerCode;
|
||||
if (!innerCode && isClose(t)) return j;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
const t = lines[i].trim();
|
||||
if (t.startsWith("```")) {
|
||||
inCode = !inCode;
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (inCode) {
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// 显式指令块整段原样([entries] 本身无需再包;off/timeline/hide 内容不参与隐式识别)
|
||||
if (isEntriesOpen(t) || isEntriesOffOpen(t) || isDirectiveLine(t)) {
|
||||
const close = findClose(
|
||||
i + 1,
|
||||
isEntriesOffOpen(t)
|
||||
? isEntriesClose
|
||||
: isEntriesOpen(t)
|
||||
? isEntriesClose
|
||||
: t.toLowerCase() === "[timeline]"
|
||||
? (x) => x.toLowerCase() === "[/timeline]"
|
||||
: (x) => x.toLowerCase().startsWith("[/hide")
|
||||
);
|
||||
if (close >= 0) {
|
||||
for (; i <= close; i++) out.push(lines[i]);
|
||||
} else {
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (HEADING_RE.test(t)) {
|
||||
// 前导段:标题行后到空行/结构标记前的所有行
|
||||
const seg: string[] = [];
|
||||
let k = i + 1;
|
||||
while (k < lines.length) {
|
||||
const tk = lines[k].trim();
|
||||
if (!tk || tk.startsWith("```") || isDirectiveLine(tk) || HEADING_RE.test(tk)) break;
|
||||
seg.push(lines[k]);
|
||||
k++;
|
||||
}
|
||||
if (!seg.some(isEntrySignalLine)) {
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// 条目确认:包壳输出标题 + 前导段,再继续吃条目体
|
||||
out.push(ENTRIES_OPEN);
|
||||
out.push(lines[i]);
|
||||
out.push(...seg);
|
||||
i = k;
|
||||
while (i < lines.length) {
|
||||
const t2 = lines[i].trim();
|
||||
if (!t2) {
|
||||
// 空行后仍是指令行 → 保留空行继续收集;否则条目到此收尾
|
||||
let p = i;
|
||||
while (p < lines.length && !lines[p].trim()) p++;
|
||||
if (p < lines.length && isEntrySignalLine(lines[p].trim())) {
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (t2.startsWith("```") || isDirectiveLine(t2) || HEADING_RE.test(t2)) break;
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
out.push(ENTRIES_CLOSE);
|
||||
continue;
|
||||
}
|
||||
out.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/** 编辑器「条目卡」插入模板:字段名(字段名::内容)按书籍内容自定义 */
|
||||
export const ENTRIES_TEMPLATE = `[entries]
|
||||
## 条目标题
|
||||
@tags 标签 | 主:次
|
||||
@summary 一句话结论。
|
||||
字段名 :: 内容。
|
||||
@source 来源标题 | https://example.com
|
||||
[/entries]`;
|
||||
|
||||
/** 编辑器「条目卡」插入模板(隐式模式):书籍开启全章节条目卡后免写外壳 */
|
||||
export const ENTRIES_TEMPLATE_AUTO = `## 条目标题
|
||||
@tags 标签 | 主:次
|
||||
@summary 一句话结论。
|
||||
字段名 :: 内容。
|
||||
@source 来源标题 | https://example.com`;
|
||||
@@ -1,7 +0,0 @@
|
||||
// 友情链接(站点级静态配置)。无条目时侧栏不渲染该区块。
|
||||
export interface FriendLink {
|
||||
name: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export const FRIEND_LINKS: FriendLink[] = [];
|
||||
78
frontend/lib/headerNav.test.ts
Normal file
78
frontend/lib/headerNav.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
DEFAULT_HEADER_NAV,
|
||||
HEADER_NAV_BUILTIN_KEYS,
|
||||
isHeaderNavCustom,
|
||||
normalizeHeaderNav,
|
||||
type HeaderNavItem,
|
||||
} from "./headerNav.ts";
|
||||
|
||||
describe("normalizeHeaderNav", () => {
|
||||
it("缺行/非法输入回落内置默认三项", () => {
|
||||
assert.deepEqual(normalizeHeaderNav(undefined), DEFAULT_HEADER_NAV);
|
||||
assert.deepEqual(normalizeHeaderNav(null), DEFAULT_HEADER_NAV);
|
||||
assert.deepEqual(normalizeHeaderNav("home"), DEFAULT_HEADER_NAV);
|
||||
});
|
||||
|
||||
it("默认三项顺序与注册表一致", () => {
|
||||
assert.equal(DEFAULT_HEADER_NAV.length, 3);
|
||||
assert.deepEqual(
|
||||
DEFAULT_HEADER_NAV.map((i) => (i.type === "builtin" ? i.key : "")),
|
||||
[...HEADER_NAV_BUILTIN_KEYS],
|
||||
);
|
||||
});
|
||||
|
||||
it("显式空数组表示全部隐藏,不回落默认", () => {
|
||||
assert.deepEqual(normalizeHeaderNav([]), []);
|
||||
});
|
||||
|
||||
it("自定义顺序与内容原样保留", () => {
|
||||
const items: HeaderNavItem[] = [
|
||||
{ type: "builtin", key: "library" },
|
||||
{ type: "custom", label: "关于", url: "/p/about", new_tab: false },
|
||||
{ type: "builtin", key: "home" },
|
||||
{ type: "custom", label: "官网", url: "https://example.com", new_tab: true },
|
||||
];
|
||||
assert.deepEqual(normalizeHeaderNav(items), items);
|
||||
});
|
||||
|
||||
it("内置 key 去空白/小写容错、重复去重", () => {
|
||||
assert.deepEqual(
|
||||
normalizeHeaderNav([
|
||||
{ type: "builtin", key: " HOME " },
|
||||
{ type: "builtin", key: "home" },
|
||||
{ type: "builtin", key: "library" },
|
||||
]),
|
||||
[
|
||||
{ type: "builtin", key: "home" },
|
||||
{ type: "builtin", key: "library" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("未知内置 key 与非法自定义项被剔除", () => {
|
||||
assert.deepEqual(
|
||||
normalizeHeaderNav([
|
||||
{ type: "builtin", key: "nope" },
|
||||
{ type: "custom", label: "", url: "/x" },
|
||||
{ type: "custom", label: "坏地址", url: "javascript:alert(1)" },
|
||||
{ type: "custom", label: "相对路径", url: "relative" },
|
||||
{ type: "weird", key: "home" },
|
||||
{ type: "custom", label: "合法", url: "/links" },
|
||||
]),
|
||||
[{ type: "custom", label: "合法", url: "/links", new_tab: false }],
|
||||
);
|
||||
});
|
||||
|
||||
it("自定义外链 new_tab 缺省为 true,站内为 false", () => {
|
||||
const got = normalizeHeaderNav([
|
||||
{ type: "custom", label: "外链", url: "https://example.com" },
|
||||
{ type: "custom", label: "站内", url: "/p/about" },
|
||||
]);
|
||||
assert.equal(got.length, 2);
|
||||
assert.ok(got.every(isHeaderNavCustom));
|
||||
assert.equal((got[0] as { new_tab: boolean }).new_tab, true);
|
||||
assert.equal((got[1] as { new_tab: boolean }).new_tab, false);
|
||||
});
|
||||
});
|
||||
88
frontend/lib/headerNav.ts
Normal file
88
frontend/lib/headerNav.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 桌面端 Header 主导航配置的单一事实源:
|
||||
* 与后端 service.HeaderNavBuiltinKeys 保持一致;Header 渲染、后台管理面板、公共设置 normalize 共用。
|
||||
*
|
||||
* 配置为有序数组,数组顺序即展示顺序:
|
||||
* - builtin 只存 key,文案/图标/地址/高亮由前端注册表派生(地址随伪静态风格变化);
|
||||
* - custom 存文案 + 地址(站内路径或 http(s) 外链)。
|
||||
* 缺行/非法输入回落内置默认三项;显式空数组表示全部隐藏(前台不渲染导航)。
|
||||
* 后续新增内置页面:在本注册表与后端白名单各加一项,后台自动出现可选项。
|
||||
*/
|
||||
|
||||
import { footerURLAllowed, isExternalFooterURL, runeCount } from "./brand.ts";
|
||||
|
||||
export const HEADER_NAV_BUILTIN_KEYS = ["home", "leaderboard", "library"] as const;
|
||||
|
||||
export type HeaderNavBuiltinKey = (typeof HEADER_NAV_BUILTIN_KEYS)[number];
|
||||
|
||||
/** 内置菜单默认文案;后台面板用同一文案 */
|
||||
export const HEADER_NAV_BUILTIN_LABELS: Record<HeaderNavBuiltinKey, string> = {
|
||||
home: "首页",
|
||||
leaderboard: "排行榜",
|
||||
library: "书库",
|
||||
};
|
||||
|
||||
export type HeaderNavBuiltinItem = { type: "builtin"; key: HeaderNavBuiltinKey };
|
||||
export type HeaderNavCustomItem = {
|
||||
type: "custom";
|
||||
label: string;
|
||||
url: string;
|
||||
new_tab: boolean;
|
||||
};
|
||||
export type HeaderNavItem = HeaderNavBuiltinItem | HeaderNavCustomItem;
|
||||
|
||||
export const HEADER_NAV_LIMITS = {
|
||||
items: 8,
|
||||
label: 16,
|
||||
url: 200,
|
||||
} as const;
|
||||
|
||||
/** 内置默认三项(缺行时回落) */
|
||||
export const DEFAULT_HEADER_NAV: HeaderNavItem[] = HEADER_NAV_BUILTIN_KEYS.map((key) => ({
|
||||
type: "builtin" as const,
|
||||
key,
|
||||
}));
|
||||
|
||||
export function isHeaderNavCustom(item: HeaderNavItem): item is HeaderNavCustomItem {
|
||||
return item.type === "custom";
|
||||
}
|
||||
|
||||
/**
|
||||
* 宽容解析服务端配置:未知内置 key/重复内置/非法自定义项跳过;
|
||||
* 入参非数组回落默认;空数组原样返回(全部隐藏)。
|
||||
*/
|
||||
export function normalizeHeaderNav(raw: unknown): HeaderNavItem[] {
|
||||
if (!Array.isArray(raw)) return DEFAULT_HEADER_NAV.map((item) => ({ ...item }));
|
||||
const out: HeaderNavItem[] = [];
|
||||
const seenBuiltin = new Set<string>();
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const rec = entry as Record<string, unknown>;
|
||||
const type = String(rec.type ?? "").trim();
|
||||
if (type === "builtin") {
|
||||
const key = String(rec.key ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!(HEADER_NAV_BUILTIN_KEYS as readonly string[]).includes(key) || seenBuiltin.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenBuiltin.add(key);
|
||||
out.push({ type: "builtin", key: key as HeaderNavBuiltinKey });
|
||||
} else if (type === "custom") {
|
||||
const label = String(rec.label ?? "").trim();
|
||||
const url = String(rec.url ?? "").trim();
|
||||
if (!label || runeCount(label) > HEADER_NAV_LIMITS.label) continue;
|
||||
if (!footerURLAllowed(url)) continue;
|
||||
const omitted = rec.new_tab === undefined || rec.new_tab === null;
|
||||
const newTab = omitted ? isExternalFooterURL(url) : rec.new_tab !== false;
|
||||
out.push({ type: "custom", label, url, new_tab: newTab });
|
||||
}
|
||||
if (out.length >= HEADER_NAV_LIMITS.items) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 自定义菜单是否为外链(外链渲染 target=_blank) */
|
||||
export function isHeaderNavExternal(url: string): boolean {
|
||||
return isExternalFooterURL(url.trim());
|
||||
}
|
||||
39
frontend/lib/librarySections.ts
Normal file
39
frontend/lib/librarySections.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { LibrarySectionItem } from "@/lib/api";
|
||||
|
||||
/** 章 → 其下小节(保持平铺顺序;顺序沿用后端 sort_order) */
|
||||
export function groupSections(sections: LibrarySectionItem[]) {
|
||||
const groups: { chapter: LibrarySectionItem; subs: LibrarySectionItem[] }[] = [];
|
||||
for (const s of sections) {
|
||||
if (s.parent_id == null) {
|
||||
groups.push({ chapter: s, subs: [] });
|
||||
} else {
|
||||
groups[groups.length - 1]?.subs.push(s);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export interface ChapterNav {
|
||||
/** 当前章下标(0 起) */
|
||||
index: number;
|
||||
/** 章总数 */
|
||||
total: number;
|
||||
prev: LibrarySectionItem | null;
|
||||
next: LibrarySectionItem | null;
|
||||
}
|
||||
|
||||
/** 当前章在章节序列中的位置与相邻章(找不到回落第一章) */
|
||||
export function chapterNav(
|
||||
groups: { chapter: LibrarySectionItem }[],
|
||||
currentId: number | null
|
||||
): ChapterNav {
|
||||
const total = groups.length;
|
||||
let index = groups.findIndex((g) => g.chapter.id === currentId);
|
||||
if (index < 0) index = 0;
|
||||
return {
|
||||
index,
|
||||
total,
|
||||
prev: index > 0 ? groups[index - 1].chapter : null,
|
||||
next: index < total - 1 ? groups[index + 1].chapter : null,
|
||||
};
|
||||
}
|
||||
30
frontend/lib/mobileTabs.test.ts
Normal file
30
frontend/lib/mobileTabs.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { DEFAULT_MOBILE_TABS, MOBILE_TABS, normalizeMobileTabs } from "./mobileTabs.ts";
|
||||
|
||||
describe("normalizeMobileTabs", () => {
|
||||
it("合法子集原样保留", () => {
|
||||
assert.deepEqual(normalizeMobileTabs(["home", "me"]), ["home", "me"]);
|
||||
assert.deepEqual(normalizeMobileTabs(["messages"]), ["messages"]);
|
||||
});
|
||||
it("去重与去空白", () => {
|
||||
assert.deepEqual(normalizeMobileTabs(["home", "home", "", " ", "me"]), ["home", "me"]);
|
||||
});
|
||||
it("大小写与首尾空格容错", () => {
|
||||
assert.deepEqual(normalizeMobileTabs([" HOME ", "Me"]), ["home", "me"]);
|
||||
});
|
||||
it("非法元素被剔除", () => {
|
||||
assert.deepEqual(normalizeMobileTabs(["home", "trophy", 42, null]), ["home"]);
|
||||
});
|
||||
it("空数组/非法输入回落默认全量", () => {
|
||||
assert.deepEqual(normalizeMobileTabs([]), DEFAULT_MOBILE_TABS);
|
||||
assert.deepEqual(normalizeMobileTabs(["nope"]), DEFAULT_MOBILE_TABS);
|
||||
assert.deepEqual(normalizeMobileTabs(undefined), DEFAULT_MOBILE_TABS);
|
||||
assert.deepEqual(normalizeMobileTabs("home"), DEFAULT_MOBILE_TABS);
|
||||
assert.deepEqual(normalizeMobileTabs(null), DEFAULT_MOBILE_TABS);
|
||||
});
|
||||
it("默认全量与固定顺序一致", () => {
|
||||
assert.equal(DEFAULT_MOBILE_TABS.length, 5);
|
||||
assert.deepEqual(DEFAULT_MOBILE_TABS, [...MOBILE_TABS]);
|
||||
});
|
||||
});
|
||||
29
frontend/lib/mobileTabs.ts
Normal file
29
frontend/lib/mobileTabs.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 移动端底栏配置的单一事实源:
|
||||
* 与后端 service.MobileTabKeys 保持一致;管理面板、MobileTabBar、公共设置 normalize 共用。
|
||||
* 配置为有序数组,数组顺序即前台底栏展示顺序;配置为空/非法回落默认全量。
|
||||
*/
|
||||
|
||||
export const MOBILE_TABS = ["home", "library", "compose", "messages", "me"] as const;
|
||||
|
||||
export type MobileTabKey = (typeof MOBILE_TABS)[number];
|
||||
|
||||
/** 底栏默认全量配置 */
|
||||
export const DEFAULT_MOBILE_TABS: MobileTabKey[] = [...MOBILE_TABS];
|
||||
|
||||
/** 配置子集解析:去空白、去重、只留白名单内元素;结果为空视为非法,回落默认全量 */
|
||||
export function normalizeMobileTabs(raw: unknown): MobileTabKey[] {
|
||||
if (!Array.isArray(raw)) return [...DEFAULT_MOBILE_TABS];
|
||||
const seen = new Set<string>();
|
||||
const out: MobileTabKey[] = [];
|
||||
for (const item of raw) {
|
||||
const key = String(item ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!key || seen.has(key)) continue;
|
||||
if (!(MOBILE_TABS as readonly string[]).includes(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(key as MobileTabKey);
|
||||
}
|
||||
return out.length > 0 ? out : [...DEFAULT_MOBILE_TABS];
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
// - HTTP 仅作回前台 / 进页校准;实时靠 WS(通知、待审、主题、群聊未读)。
|
||||
|
||||
import { apiMe } from "./api";
|
||||
import type { HeaderNavItem } from "./headerNav";
|
||||
|
||||
export const RT_HELLO = "hello";
|
||||
export const RT_PONG = "pong";
|
||||
@@ -62,6 +63,8 @@ export interface RtSettingsData {
|
||||
allow_register?: boolean;
|
||||
allow_comments?: boolean;
|
||||
url_style?: string;
|
||||
mobile_tabs?: string[];
|
||||
header_nav?: HeaderNavItem[];
|
||||
comments_require_login?: boolean;
|
||||
allow_messages?: boolean;
|
||||
post_cooldown_hours?: number;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
canonicalPath,
|
||||
homeCanonicalQuery,
|
||||
isTrackingParam,
|
||||
isWideShellPath,
|
||||
parseSiteOrigin,
|
||||
resolveSiteOrigin,
|
||||
} from "./siteUrl.ts";
|
||||
@@ -115,3 +116,24 @@ describe("homeCanonicalQuery", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWideShellPath(书库页放宽档位)", () => {
|
||||
it("覆盖三种伪静态风格的书库路径", () => {
|
||||
assert.equal(isWideShellPath("/library"), true);
|
||||
assert.equal(isWideShellPath("/library/abc"), true);
|
||||
assert.equal(isWideShellPath("/library/abc/read"), true);
|
||||
assert.equal(isWideShellPath("/library.html"), true);
|
||||
assert.equal(isWideShellPath("/library-abc.html"), true);
|
||||
assert.equal(isWideShellPath("/library-abc.html?sec=2"), true);
|
||||
assert.equal(isWideShellPath("/library-abc-read-2.html"), true);
|
||||
});
|
||||
it("非书库路径不放宽", () => {
|
||||
assert.equal(isWideShellPath("/"), false);
|
||||
assert.equal(isWideShellPath("/post-1.html"), false);
|
||||
assert.equal(isWideShellPath("/p-about.html"), false);
|
||||
assert.equal(isWideShellPath("/u/5"), false);
|
||||
assert.equal(isWideShellPath("/admin/library"), false);
|
||||
assert.equal(isWideShellPath("/my-library.html"), false);
|
||||
assert.equal(isWideShellPath(null), false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,6 +115,21 @@ export function canonicalBoard(raw: string | undefined): number | undefined {
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 书库页路径:命中时公开站三件套(Header/main/Footer)放宽到 1680px,
|
||||
* 读页三栏与条目卡等内容更宽裕;其余页面保持 1440px。
|
||||
* 需覆盖三种伪静态风格:/library[/...](default)、/library.html 与 /library-{slug}.html(html/discuz,
|
||||
* 新章节地址 = 详情路径 + ?sec=,pathname 与详情一致) */
|
||||
export function isWideShellPath(pathname: string | null | undefined): boolean {
|
||||
if (!pathname) return false;
|
||||
const p = pathname.split("?")[0];
|
||||
return (
|
||||
p === "/library" ||
|
||||
p.startsWith("/library/") ||
|
||||
p === "/library.html" ||
|
||||
/^\/library-.*\.html$/.test(p)
|
||||
);
|
||||
}
|
||||
|
||||
export function defaultFeedSort(allowComments: boolean): FeedSort {
|
||||
return allowComments ? "latest" : "new";
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ function isDirectiveLine(t: string): boolean {
|
||||
return (
|
||||
lower === "[/hide]" ||
|
||||
lower === "[/timeline]" ||
|
||||
lower === "[/entries]" ||
|
||||
lower === "[timeline]" ||
|
||||
lower === "[entries]" ||
|
||||
lower.startsWith("[hide") ||
|
||||
t.startsWith("<!-- git:")
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TocItem } from "@/components/PostToc";
|
||||
import type { ContentSegment } from "@/lib/directiveBlocks";
|
||||
|
||||
const TIMELINE_DAY_RE = /^##\s+(\d{4}-\d{2}-\d{2})\s+(.+)$/;
|
||||
const HEADING_RE = /^(#{2,3})\s+(.+?)\s*#*\s*$/;
|
||||
@@ -14,13 +15,14 @@ export function timelineDayId(date: string): string {
|
||||
* 从正文提取目录:
|
||||
* - 块外 ## / ### → h-{n}(供 headingRenderer 按序挂 id)
|
||||
* - [timeline] 内按日期去重 → tl-YYYY-MM-DD(日分组锚点)
|
||||
* - 代码围栏 / [hide] 块内标题不计入
|
||||
* - 代码围栏 / [hide] / [entries] 块内标题不计入
|
||||
*/
|
||||
export function extractToc(content: string): TocItem[] {
|
||||
const items: TocItem[] = [];
|
||||
let inCode = false;
|
||||
let inTimeline = false;
|
||||
let inHide = false;
|
||||
let inEntries = false;
|
||||
let headingCount = 0;
|
||||
/** 当前 timeline 块内出现过的日期(保持文件顺序,收块时再按日倒序发出) */
|
||||
let timelineDates: string[] = [];
|
||||
@@ -65,6 +67,17 @@ export function extractToc(content: string): TocItem[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inHide && !inTimeline && !inEntries && lower === "[entries]") {
|
||||
inEntries = true;
|
||||
continue;
|
||||
}
|
||||
if (inEntries && lower === "[/entries]") {
|
||||
inEntries = false;
|
||||
continue;
|
||||
}
|
||||
// 条目卡块内标题不进目录(渲染时也不挂 h-{n} 锚点,避免错位)
|
||||
if (inEntries) continue;
|
||||
|
||||
if (!inHide && HIDE_OPEN_RE.test(line)) {
|
||||
inHide = true;
|
||||
continue;
|
||||
@@ -95,6 +108,60 @@ export function markdownHeadingToc(toc: TocItem[]): TocItem[] {
|
||||
return toc.filter((t) => /^h-\d+$/.test(t.id));
|
||||
}
|
||||
|
||||
/** buildTocFromSegments 的产物:目录项 + 供正文挂锚点的三组 id */
|
||||
export interface SegmentsToc {
|
||||
/** 按渲染顺序的目录项(md 标题 / 条目标题 / 时间线日),id 已由 nextAnchor 分配 */
|
||||
items: TocItem[];
|
||||
/** md 段标题 id 序列(供 headingComponentsFor 按序挂到 h2/h3) */
|
||||
mdHeadIds: string[];
|
||||
/** 按段索引组织的条目标题 id(传给 MdEntries 挂到卡内标题) */
|
||||
entriesTitleIds: (string[] | undefined)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于「渲染分段」构建章内目录(条目卡模式下的目录来源):
|
||||
* - md 段标题 → h2/h3 级目录项(顺序与 headingRenderer 一致)
|
||||
* - entries 段条目标题 → 二级目录项(与卡内标题共享锚点,目录可跳转)
|
||||
* - timeline 段 → 日分组锚点(与 extractToc 同为日倒序);hide 段内容不进目录
|
||||
* nextAnchor 与阅读页章内锚点计数器共享,保证目录、正文、卡内标题同一份 id
|
||||
*/
|
||||
export function buildTocFromSegments(
|
||||
segs: ContentSegment[],
|
||||
nextAnchor: () => string
|
||||
): SegmentsToc {
|
||||
const items: TocItem[] = [];
|
||||
const mdHeadIds: string[] = [];
|
||||
const entriesTitleIds: (string[] | undefined)[] = [];
|
||||
|
||||
for (let si = 0; si < segs.length; si++) {
|
||||
const seg = segs[si];
|
||||
if (seg.type === "md") {
|
||||
for (const raw of seg.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")) {
|
||||
const m = HEADING_RE.exec(raw.trim());
|
||||
if (!m) continue;
|
||||
const id = nextAnchor();
|
||||
items.push({ level: m[1].length as 2 | 3, text: m[2], id });
|
||||
mdHeadIds.push(id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (seg.type === "entries") {
|
||||
const ids = seg.items.map(() => nextAnchor());
|
||||
seg.items.forEach((it, i) => items.push({ level: 2, text: it.title, id: ids[i] }));
|
||||
entriesTitleIds[si] = ids;
|
||||
continue;
|
||||
}
|
||||
if (seg.type === "timeline") {
|
||||
const dates = [...new Set(seg.items.map((t) => t.date))].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
|
||||
for (const date of dates) {
|
||||
items.push({ level: 2, text: date, id: timelineDayId(date) });
|
||||
}
|
||||
}
|
||||
// hide 段内容(未解锁/未登录不可见)不进目录
|
||||
}
|
||||
return { items, mdHeadIds, entriesTitleIds };
|
||||
}
|
||||
|
||||
/** 粗略阅读时长:约 400 字/分钟,至少 1 分钟 */
|
||||
export function estimateReadingMinutes(text: string, fallbackLen = 0): number {
|
||||
const len = text.trim().length > 0 ? text.trim().length : fallbackLen;
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
announcementPath,
|
||||
entityPath,
|
||||
leaderboardPath,
|
||||
libraryDocPath,
|
||||
libraryDocReadPath,
|
||||
libraryPath,
|
||||
matchPrettyPath,
|
||||
matchStandardPath,
|
||||
normalizeUrlStyle,
|
||||
@@ -57,6 +60,29 @@ describe("路径构造器", () => {
|
||||
assert.equal(leaderboardPath("html"), "/leaderboard.html");
|
||||
assert.equal(leaderboardPath("discuz"), "/rank.html");
|
||||
});
|
||||
it("书库三风格(html/discuz 同模板;章节 = 详情路径 + ?sec=)", () => {
|
||||
assert.equal(libraryPath("default"), "/library");
|
||||
assert.equal(libraryPath("html"), "/library.html");
|
||||
assert.equal(libraryPath("discuz"), "/library.html");
|
||||
assert.equal(libraryDocPath("default", "abc"), "/library/abc");
|
||||
assert.equal(libraryDocPath("html", "abc"), "/library-abc.html");
|
||||
assert.equal(libraryDocPath("discuz", "abc"), "/library-abc.html");
|
||||
assert.equal(
|
||||
libraryDocPath("html", "使用指南"),
|
||||
`/library-${encodeURIComponent("使用指南")}.html`
|
||||
);
|
||||
assert.equal(libraryDocPath("default", " "), "/library");
|
||||
assert.equal(libraryDocPath("html", " "), "/library.html");
|
||||
// 阅读页:chapterNo 为书内章序号(1 起),缺省表示第一章
|
||||
assert.equal(libraryDocReadPath("default", "abc"), "/library/abc/read");
|
||||
assert.equal(libraryDocReadPath("default", "abc", 5), "/library/abc/read?c=5");
|
||||
assert.equal(libraryDocReadPath("html", "abc"), "/library-abc.html?sec=1");
|
||||
assert.equal(libraryDocReadPath("discuz", "abc", 5), "/library-abc.html?sec=5");
|
||||
assert.equal(
|
||||
libraryDocReadPath("html", "使用指南", 2),
|
||||
`/library-${encodeURIComponent("使用指南")}.html?sec=2`
|
||||
);
|
||||
});
|
||||
it("entityPath 与标准路径", () => {
|
||||
assert.equal(entityPath("discuz", "post", 9), "/thread-9.html");
|
||||
assert.equal(entityPath("default", "user", 9), "/u/9");
|
||||
@@ -76,6 +102,41 @@ describe("matchPrettyPath", () => {
|
||||
assert.deepEqual(matchPrettyPath("/leaderboard.html"), { style: "html", kind: "leaderboard" });
|
||||
assert.deepEqual(matchPrettyPath("/rank.html"), { style: "discuz", kind: "leaderboard" });
|
||||
});
|
||||
it("书库两风格同模板:prefer 优先当前风格,缺省首条兜底", () => {
|
||||
assert.equal(matchPrettyPath("/library.html", "html")?.style, "html");
|
||||
assert.equal(matchPrettyPath("/library.html", "discuz")?.style, "discuz");
|
||||
assert.equal(matchPrettyPath("/library.html")?.style, "html");
|
||||
assert.deepEqual(matchPrettyPath("/library-x.html", "discuz"), {
|
||||
style: "discuz",
|
||||
kind: "libraryDoc",
|
||||
slug: "x",
|
||||
});
|
||||
assert.deepEqual(matchPrettyPath("/library-x.html"), { style: "html", kind: "libraryDoc", slug: "x" });
|
||||
});
|
||||
it("旧版章节伪静态 -read-{n} 仍可解析(不再生成)", () => {
|
||||
assert.deepEqual(matchPrettyPath("/library-x-read-2.html", "discuz"), {
|
||||
style: "discuz",
|
||||
kind: "libraryRead",
|
||||
slug: "x",
|
||||
id: 2,
|
||||
});
|
||||
assert.deepEqual(matchPrettyPath("/library-x-read-2.html"), {
|
||||
style: "html",
|
||||
kind: "libraryRead",
|
||||
slug: "x",
|
||||
id: 2,
|
||||
});
|
||||
// 详情模板不再吞掉 -read-{n};纯详情仍命中 libraryDoc
|
||||
assert.deepEqual(matchPrettyPath("/library-x.html"), { style: "html", kind: "libraryDoc", slug: "x" });
|
||||
assert.equal(matchPrettyPath("/library-x-read-2.htmlx"), null);
|
||||
assert.equal(matchPrettyPath("/library-x-read-2.html/edit"), null);
|
||||
});
|
||||
it("书库 pretty 排除子路径与伪造路径", () => {
|
||||
assert.equal(matchPrettyPath("/library-x.htmlx"), null);
|
||||
assert.equal(matchPrettyPath("/library-x.html/edit"), null);
|
||||
assert.equal(matchPrettyPath("/library-.html"), null);
|
||||
assert.equal(matchPrettyPath("/library.html/x"), null);
|
||||
});
|
||||
it("page slug 保持 encoded 原样", () => {
|
||||
const enc = encodeURIComponent("使用指南");
|
||||
assert.deepEqual(matchPrettyPath(`/page-${enc}.html`), { style: "discuz", kind: "page", slug: enc });
|
||||
@@ -104,11 +165,18 @@ describe("matchStandardPath / parseEntityPath", () => {
|
||||
assert.deepEqual(matchStandardPath("/p/about"), { kind: "page", slug: "about" });
|
||||
assert.deepEqual(matchStandardPath("/leaderboard"), { kind: "leaderboard" });
|
||||
assert.deepEqual(matchStandardPath("/leaderboard/"), { kind: "leaderboard" });
|
||||
assert.deepEqual(matchStandardPath("/library"), { kind: "library" });
|
||||
assert.deepEqual(matchStandardPath("/library/"), { kind: "library" });
|
||||
assert.deepEqual(matchStandardPath("/library/abc"), { kind: "libraryDoc", slug: "abc" });
|
||||
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);
|
||||
// 读页单独识别(?c= 走 query),不得被详情模式误伤
|
||||
assert.deepEqual(matchStandardPath("/library/abc/read"), { kind: "libraryRead", slug: "abc" });
|
||||
assert.equal(matchStandardPath("/library/a/b"), null);
|
||||
assert.equal(matchStandardPath("/libraryx"), null);
|
||||
});
|
||||
it("parseEntityPath 兼容标准与全部模板", () => {
|
||||
assert.deepEqual(parseEntityPath("/post/12"), { kind: "post", id: 12 });
|
||||
@@ -125,4 +193,12 @@ describe("matchStandardPath / parseEntityPath", () => {
|
||||
assert.equal(parseEntityPath("/chat/5"), null);
|
||||
assert.equal(parseEntityPath("/"), null);
|
||||
});
|
||||
it("parseEntityPath 兼容书库路径并解码 slug", () => {
|
||||
const enc = encodeURIComponent("三体");
|
||||
assert.deepEqual(parseEntityPath(`/library/${enc}`), { kind: "libraryDoc", slug: "三体" });
|
||||
assert.deepEqual(parseEntityPath(`/library-${enc}.html`), { kind: "libraryDoc", slug: "三体" });
|
||||
assert.deepEqual(parseEntityPath("/library.html"), { kind: "library" });
|
||||
assert.deepEqual(parseEntityPath("/library/abc/read"), { kind: "libraryRead", slug: "abc" });
|
||||
assert.deepEqual(parseEntityPath("/library-abc-read-2.html"), { kind: "libraryRead", slug: "abc" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,16 +3,18 @@
|
||||
* 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
|
||||
* 各风格模板(自建页 slug、排行榜与书库也参与;slug 非 ASCII 时按 encodeURIComponent 编码):
|
||||
* 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 改写回标准读页),旧链接不失效。
|
||||
*/
|
||||
|
||||
export type UrlStyle = "default" | "html" | "discuz";
|
||||
export type EntityKind = "post" | "user" | "announcement";
|
||||
/** 全部可反解的实体类型;page 的 id 是 slug(字符串),leaderboard 是固定单页无 id */
|
||||
export type PageKind = EntityKind | "page" | "leaderboard";
|
||||
/** 全部可反解的实体类型;page/libraryDoc/libraryRead 的 id 是 slug(字符串),leaderboard/library 是固定单页无 id */
|
||||
export type PageKind = EntityKind | "page" | "leaderboard" | "library" | "libraryDoc" | "libraryRead";
|
||||
|
||||
export const URL_STYLES: readonly UrlStyle[] = ["default", "html", "discuz"];
|
||||
|
||||
@@ -50,6 +52,18 @@ const LEADERBOARD_TEMPLATES: Record<Exclude<UrlStyle, "default">, string> = {
|
||||
discuz: "/rank.html",
|
||||
};
|
||||
|
||||
/** 书库伪静态模板:两风格统一(书库非 Discuz 原生概念);章节在详情路径上追加 ?sec= */
|
||||
const LIBRARY_TEMPLATES: Record<Exclude<UrlStyle, "default">, { list: string; doc: (slug: string) => string }> = {
|
||||
html: {
|
||||
list: "/library.html",
|
||||
doc: (slug) => `/library-${slug}.html`,
|
||||
},
|
||||
discuz: {
|
||||
list: "/library.html",
|
||||
doc: (slug) => `/library-${slug}.html`,
|
||||
},
|
||||
};
|
||||
|
||||
/** id 非法(≤0/非有限数)时返回空串,调用方按异常路径处理 */
|
||||
function guardId(id: number): number | null {
|
||||
return Number.isFinite(id) && id > 0 ? id : null;
|
||||
@@ -97,6 +111,40 @@ export function leaderboardPath(style: UrlStyle): string {
|
||||
return style === "default" ? "/leaderboard" : LEADERBOARD_TEMPLATES[style];
|
||||
}
|
||||
|
||||
/** 书库列表路径:default 标准路径,html/discuz 伪静态(两风格统一模板) */
|
||||
export function libraryPath(style: UrlStyle): string {
|
||||
return style === "default" ? "/library" : LIBRARY_TEMPLATES[style].list;
|
||||
}
|
||||
|
||||
/** 书库条目详情路径 */
|
||||
export function libraryDocPath(style: UrlStyle, slug: string): string {
|
||||
return libraryDocPathFromSlug(style, encodeURIComponent(slug.trim()));
|
||||
}
|
||||
|
||||
/** 书库条目详情路径(slug 已是 URL 编码形态);middleware 301 复用,避免二次编码 */
|
||||
export function libraryDocPathFromSlug(style: UrlStyle, encodedSlug: string): string {
|
||||
if (style === "default") {
|
||||
return encodedSlug ? `/library/${encodedSlug}` : "/library";
|
||||
}
|
||||
return encodedSlug ? LIBRARY_TEMPLATES[style].doc(encodedSlug) : libraryPath(style);
|
||||
}
|
||||
|
||||
/** 书库阅读页路径。
|
||||
* chapterNo 为书内章序号(1 起,非章节全局 id):
|
||||
* default → /library/{slug}/read?c={n};html/discuz → /library-{slug}.html?sec={n}(缺省第一章)。
|
||||
* 章内子目录锚点为 #e-{sec}-{章内序号}(见读页),如 ?sec=1#e-1-1 */
|
||||
export function libraryDocReadPath(style: UrlStyle, slug: string, chapterNo?: number): string {
|
||||
const enc = encodeURIComponent(slug.trim());
|
||||
if (style !== "default") {
|
||||
if (!enc) return libraryPath(style);
|
||||
const no = guardId(chapterNo ?? 0) ?? 1;
|
||||
return `${LIBRARY_TEMPLATES[style].doc(enc)}?sec=${no}`;
|
||||
}
|
||||
const base = enc ? `/library/${enc}/read` : "/library";
|
||||
const no = guardId(chapterNo ?? 0);
|
||||
return no ? `${base}?c=${no}` : base;
|
||||
}
|
||||
|
||||
/** 实体对应风格下的标准/pretty 路径(内部与 middleware 共用) */
|
||||
export function entityPath(style: UrlStyle, kind: EntityKind, id: number): string {
|
||||
if (kind === "post") return postPath(style, id);
|
||||
@@ -112,21 +160,32 @@ export function standardEntityPath(kind: EntityKind, id: number): string {
|
||||
export type PrettyMatch =
|
||||
| { style: Exclude<UrlStyle, "default">; kind: EntityKind; id: number }
|
||||
| { style: Exclude<UrlStyle, "default">; kind: "page"; slug: string }
|
||||
| { 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" };
|
||||
|
||||
// 精确锚定:id 仅纯数字,天然排除 /post/123/edit 这类子路径与伪造路径;
|
||||
// page 的 slug 捕获保持 URL 原样(encoded),rewrite 落点直接复用
|
||||
// page/libraryDoc 的 slug 捕获保持 URL 原样(encoded),rewrite 落点直接复用。
|
||||
// 书库两风格同模板:html 条目在前作首条兜底,middleware 按 prefer 优先取当前风格;
|
||||
// 旧版章节模板 -read-{n} 仍须排在详情模板之前(否则会被 libraryDoc 的 [^/]+ 吞掉 -read-2),仅用于旧链接解析
|
||||
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: /^\/library\.html$/, style: "html", kind: "library" },
|
||||
{ re: /^\/library-(.+)-read-(\d+)\.html$/, style: "html", kind: "libraryRead" },
|
||||
{ re: /^\/library-([^/]+)\.html$/, style: "html", kind: "libraryDoc" },
|
||||
{ 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" },
|
||||
{ re: /^\/library\.html$/, style: "discuz", kind: "library" },
|
||||
{ re: /^\/library-(.+)-read-(\d+)\.html$/, style: "discuz", kind: "libraryRead" },
|
||||
{ re: /^\/library-([^/]+)\.html$/, style: "discuz", kind: "libraryDoc" },
|
||||
];
|
||||
|
||||
const STANDARD_PATTERNS: { re: RegExp; kind: PageKind }[] = [
|
||||
@@ -135,27 +194,52 @@ const STANDARD_PATTERNS: { re: RegExp; kind: PageKind }[] = [
|
||||
{ re: /^\/announcement\/(\d+)$/, kind: "announcement" },
|
||||
{ re: /^\/p\/([^/]+)$/, kind: "page" },
|
||||
{ re: /^\/leaderboard\/?$/, kind: "leaderboard" },
|
||||
// ([^/]+)$ 尾部锚定:/library/{slug}/read 不会命中详情模式(读页单独识别,?c= 走 query)
|
||||
{ re: /^\/library\/?$/, kind: "library" },
|
||||
{ re: /^\/library\/([^/]+)\/read$/, kind: "libraryRead" },
|
||||
{ re: /^\/library\/([^/]+)$/, kind: "libraryDoc" },
|
||||
];
|
||||
|
||||
/** 匹配任一伪静态风格路径(不含标准风格);命中返回风格/实体/id;page 的 slug 保持 encoded 原样 */
|
||||
export function matchPrettyPath(pathname: string): PrettyMatch | null {
|
||||
/**
|
||||
* 匹配任一伪静态风格路径(不含标准风格);命中返回风格/实体/id;
|
||||
* page/libraryDoc 的 slug 保持 encoded 原样。
|
||||
* prefer:同一模板存在多风格条目时优先返回该风格的匹配(middleware 传入当前
|
||||
* 风格,避免书库两风格同模板被判成另一风格 → 301 目标与自身相同而死循环);
|
||||
* 缺省取首条命中。
|
||||
*/
|
||||
export function matchPrettyPath(
|
||||
pathname: string,
|
||||
prefer?: Exclude<UrlStyle, "default">
|
||||
): PrettyMatch | null {
|
||||
let fallback: PrettyMatch | null = 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]) };
|
||||
let hit: PrettyMatch;
|
||||
if (p.kind === "page" || p.kind === "libraryDoc") {
|
||||
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") {
|
||||
hit = { style: p.style, kind: p.kind };
|
||||
} else {
|
||||
hit = { style: p.style, kind: p.kind, id: Number(m[1]) };
|
||||
}
|
||||
if (!fallback) fallback = hit;
|
||||
if (prefer && p.style === prefer) return hit;
|
||||
}
|
||||
return null;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** 匹配标准实体路径;page 的 slug 保持 encoded 原样(与 URL 一致,rewrite 直接复用) */
|
||||
/** 匹配标准实体路径;page/libraryDoc/libraryRead 的 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" };
|
||||
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 };
|
||||
return { kind: p.kind, id: Number(m[1]) };
|
||||
}
|
||||
return null;
|
||||
@@ -164,21 +248,29 @@ export function matchStandardPath(pathname: string): StandardMatch | null {
|
||||
export type StandardMatch =
|
||||
| { kind: EntityKind; id: number }
|
||||
| { kind: "page"; slug: string }
|
||||
| { kind: "libraryDoc"; slug: string }
|
||||
| { kind: "libraryRead"; slug: string }
|
||||
| { kind: "library" }
|
||||
| { kind: "leaderboard" };
|
||||
|
||||
/**
|
||||
* 反解任意实体路径(标准 + 全部伪静态风格),供 isActive 前缀判断、面包屑等
|
||||
* "从路径认实体"的场景使用。page 的 slug 解码为原始文本;数字实体统一 { kind, id }。
|
||||
* "从路径认实体"的场景使用。page/libraryDoc 的 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;
|
||||
return standard.kind === "page" || standard.kind === "libraryDoc" || standard.kind === "libraryRead"
|
||||
? { kind: standard.kind, 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 === "libraryDoc") return { kind: "libraryDoc", slug: decodeSlug(pretty.slug) };
|
||||
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" };
|
||||
return { kind: pretty.kind, id: pretty.id };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user