feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等

新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
This commit is contained in:
2026-10-01 03:06:05 +08:00
parent 7fbd6ba699
commit ff2ab286fb
141 changed files with 7527 additions and 1407 deletions

View File

@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { markdownToSearchText, searchBook, type BookChapterIndex } from "./bookSearch.ts";
describe("markdownToSearchText", () => {
it("条目卡指令保留值、剥离标记符号", () => {
const md = `[entries]
## 安全气囊
@tags 汽车 | 安全 | 主:被动
@summary 气囊需配合安全带使用。
备注 :: 详见召回公告
@source NHTSA 报告 | https://a.gov/x
[/entries]`;
const text = markdownToSearchText(md);
assert.ok(text.includes("安全气囊"));
assert.ok(text.includes("汽车"));
assert.ok(text.includes("主:被动"));
assert.ok(text.includes("气囊需配合安全带使用。"));
assert.ok(text.includes("备注 详见召回公告"));
assert.ok(text.includes("NHTSA 报告"));
assert.ok(!text.includes("@tags"));
assert.ok(!text.includes("@summary"));
assert.ok(!text.includes("@source"));
assert.ok(!text.includes("::"));
assert.ok(!text.includes("[entries"));
assert.ok(!text.includes("https://a.gov/x"));
});
it("锁定的 hide 块整体不可搜,未锁块保留正文", () => {
const md = `公开段落内容。
[hide login]
登录后可见的秘密
[/hide]
[hide points=10 locked]
积分秘密内容
[/hide]`;
const text = markdownToSearchText(md);
assert.ok(text.includes("公开段落内容"));
assert.ok(text.includes("登录后可见的秘密"));
assert.ok(!text.includes("积分秘密内容"));
});
it("剥离代码围栏与行内 markdown 语法但保留代码与锚文本", () => {
const md = "见 [官网说明](https://x.com) 与 `const x = 1`,\n\n```ts\ncodeInside();\n```";
const text = markdownToSearchText(md);
assert.ok(text.includes("官网说明"));
assert.ok(!text.includes("https://x.com"));
assert.ok(text.includes("const x = 1"));
assert.ok(text.includes("codeInside();"));
assert.ok(!text.includes("```"));
});
});
describe("searchBook", () => {
const chapters: BookChapterIndex[] = [
{ no: 1, title: "第一章", plain: "今天天气很好,适合出门散步。路边有一朵花。" },
{ no: 2, title: "第二章", plain: "第二章没有关键词。" },
{ no: 3, title: "第三章", plain: "再次提到天气,天气真的不错。" },
];
it("按章聚合命中数与片段,大小写不敏感", () => {
const hits = searchBook(chapters, "天气");
assert.equal(hits.length, 2);
assert.deepEqual(
hits.map((h) => [h.no, h.count]),
[
[1, 1],
[3, 2],
]
);
assert.equal(hits[1].snippets[0].match, "天气");
assert.ok(hits[1].snippets[0].after.includes(",天气真的"));
});
it("片段最多生成 SNIPPET_LIMIT 个,但 count 为真实总数", () => {
const cs: BookChapterIndex[] = [{ no: 1, title: "t", plain: "天气 天气 天气 天气" }];
const hits = searchBook(cs, "天气");
assert.equal(hits[0].count, 4);
assert.equal(hits[0].snippets.length, 3);
assert.deepEqual(hits[0].snippets.map((s) => s.ordinal), [0, 1, 2]);
});
it("空关键词返回空", () => {
assert.deepEqual(searchBook(chapters, " "), []);
});
it("无命中返回空数组", () => {
assert.deepEqual(searchBook(chapters, "航天飞机"), []);
});
});