feat: 友链/书库/移动端导航/排行榜等多模块功能

新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
2026-09-30 16:30:07 +08:00
parent e487bc2e92
commit 4f3f3a265c
105 changed files with 12371 additions and 852 deletions

View File

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

View File

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

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

View File

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

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

View File

@@ -1,7 +0,0 @@
// 友情链接(站点级静态配置)。无条目时侧栏不渲染该区块。
export interface FriendLink {
name: string;
href: string;
}
export const FRIEND_LINKS: FriendLink[] = [];

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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:")
);

View File

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

View File

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

View File

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