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

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