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

147
frontend/lib/bookSearch.ts Normal file
View File

@@ -0,0 +1,147 @@
/**
* 书库全书搜索:章节 markdown → 检索纯文本 + 关键词匹配/片段生成。
* 纯函数、无 DOM 依赖,供阅读页搜索面板(client)与单测共用。
*/
import { publicMarkdownForSeo } from "./hideBlocks.ts";
import { parseEntrySource } from "./entryBlocks.ts";
/** 传给客户端的章节索引:no 为书内章序号(1 基,与 ?c=/ ?sec= 一致) */
export interface BookChapterIndex {
no: number;
title: string;
/** markdown 经剥离后的可见纯文本 */
plain: string;
}
export interface BookSnippet {
/** 命中前缀文(约 SNIPPET_CONTEXT 字) */
before: string;
/** 命中原文(保留原始大小写) */
match: string;
/** 命中后缀文 */
after: string;
/** 该片段是本章内第几个命中(0 基,与 DOM 高亮顺序对应) */
ordinal: number;
}
export interface BookChapterHit {
no: number;
title: string;
/** 本章命中总数 */
count: number;
/** 前若干个命中的上下文片段 */
snippets: BookSnippet[];
}
export const SNIPPET_CONTEXT = 28;
export const SNIPPET_LIMIT = 3;
const TAGS_RE = /^@tags\s+(.+)$/i;
const SUMMARY_RE = /^@summary(?:\s+(.*))?$/i;
const SOURCE_RE = /^@source\s+(.+)$/i;
const FIELD_RE = /^([^:@\n]{1,40}?)\s*::\s*(.*)$/;
/** [entries] / [entries:off] / [timeline] 等块壳标记行(hide 已由 SEO 逻辑处理) */
const DIRECTIVE_SHELL_RE = /^\[\/?(?:entries(?::off)?|timeline)\]$/i;
/**
* 章节 markdown → 检索用纯文本。
* 目标:页面可见的文字基本都能被搜到,且不命中标记符号。
* - 锁定的 [hide] 块整体剔除,未锁块保留正文(复用 SEO 公开文本逻辑)
* - 条目卡指令保留「值」:@tags 留标签、字段::值 留「字段 值」、@summary/@source 留文本
* - 块壳标记、代码围栏、图片/链接语法、HTML 标签、强调符号一律剥离
*/
export function markdownToSearchText(md: string): string {
if (!md) return "";
// 统一换行;剔除锁块(未锁 hide 仅留正文,标记行已不在)
const lines = publicMarkdownForSeo(md).replace(/\r\n?/g, "\n").split("\n");
const kept: string[] = [];
let inCode = false;
for (const raw of lines) {
const t = raw.trim();
if (t.startsWith("```")) {
inCode = !inCode;
continue; // 围栏本身去掉,代码内容保留可搜
}
if (inCode) {
kept.push(raw);
continue;
}
if (!t) continue;
if (DIRECTIVE_SHELL_RE.test(t)) continue;
const tags = t.match(TAGS_RE);
if (tags) {
kept.push(tags[1].replace(/[||]/g, " "));
continue;
}
const summary = t.match(SUMMARY_RE);
if (summary) {
if (summary[1]) kept.push(summary[1]);
continue;
}
const source = t.match(SOURCE_RE);
if (source) {
// 仅保留来源文本,丢弃尾部 URL
kept.push(parseEntrySource(source[1]).text);
continue;
}
if (/^@plain$/i.test(t)) continue;
const field = t.match(FIELD_RE);
if (field) {
kept.push(`${field[1]} ${field[2]}`);
continue;
}
kept.push(raw);
}
return kept
.join("\n")
.replace(/<!--[\s\S]*?-->/g, " ") // HTML 注释(如 <!-- git: ... -->)
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") // 图片 → alt
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // 链接 → 锚文本
.replace(/<\/?[a-z][^>]*>/gi, " ") // 内联 HTML 标签
.replace(/^#{1,6}\s+/gm, "") // 标题井号
.replace(/^\s*>+\s?/gm, "") // 引用前缀
.replace(/^\s*[-+*]\s+/gm, "") // 无序列表点
.replace(/^\s*\d+[.)]\s+/gm, "") // 有序列表序号
.replace(/[`*_~]+/g, "") // 行内代码/强调/删除线
.replace(/\|/g, " ") // 表格竖线
.replace(/\s+/g, " ")
.trim();
}
/** 全书关键词匹配:大小写不敏感子串,命中按章聚合,仅前 SNIPPET_LIMIT 个命中生成片段 */
export function searchBook(chapters: BookChapterIndex[], query: string): BookChapterHit[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const hits: BookChapterHit[] = [];
for (const ch of chapters) {
const hay = ch.plain.toLowerCase();
const snippets: BookSnippet[] = [];
let count = 0;
let from = 0;
for (;;) {
const idx = hay.indexOf(q, from);
if (idx < 0) break;
if (snippets.length < SNIPPET_LIMIT) {
snippets.push({
ordinal: count,
before: ch.plain.slice(Math.max(0, idx - SNIPPET_CONTEXT), idx),
match: ch.plain.slice(idx, idx + q.length),
after: ch.plain.slice(idx + q.length, idx + q.length + SNIPPET_CONTEXT),
});
}
count++;
from = idx + q.length;
}
if (count > 0) hits.push({ no: ch.no, title: ch.title, count, snippets });
}
return hits;
}
/** 全书命中总数 */
export function countBookHits(chapters: BookChapterIndex[], query: string): number {
return searchBook(chapters, query).reduce((acc, h) => acc + h.count, 0);
}