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,79 @@
// 构建后处理:把 next/font 生成的 Noto Sans SC 字体声明(6 字重 × ~101 个
// unicode-range 切片 ≈ 606 条 @font-face,压缩后 ~200 KiB)从渲染阻塞的 CSS
// chunk 中抽到 public/fonts/noto-sans-sc.css,由 app/layout.tsx 注入的脚本异步
// 加载。next/font 机制原样保留:woff2 仍在 /_next/static/media/(immutable),
// 度量校正 fallback 面与 Jakarta/JetBrains 声明仍留在阻塞 CSS。
//
// 挂载点:package.json 的 postbuild(构建后必跑)与 predev(有上次构建产物时
// 让 dev 也有字体;无产物则静默跳过)。
//
// 用法:node scripts/extract-font-css.mjs
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const staticDir = join(root, ".next", "static");
const outDir = join(root, "public", "fonts");
const outFile = join(outDir, "noto-sans-sc.css");
// 递归收集 .next/static 下所有产物 CSS(chunks 与 css 目录都扫,与 bundler 无关)
function collectCssFiles(dir) {
if (!existsSync(dir)) return [];
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) out.push(...collectCssFiles(p));
else if (entry.isFile() && entry.name.endsWith(".css")) out.push(p);
}
return out;
}
// @font-face 内无嵌套花括号(压缩产物),逐块提取
const FACE_RE = /@font-face\{[^{}]*\}/g;
// font-family 值精确等于 "Noto Sans SC"(产物中可能不带引号);
// "Noto Sans SC Fallback" 因值不等而自然排除,必须留在阻塞 CSS 兜度量
function isNotoFace(block) {
const m = block.match(/font-family\s*:\s*([^;}]+)/);
if (!m) return false;
return m[1].trim().replace(/^["']|["']$/g, "") === "Noto Sans SC";
}
const cssFiles = collectCssFiles(staticDir);
let extracted = [];
const touched = new Map(); // file -> 重写后的内容
for (const file of cssFiles) {
const content = readFileSync(file, "utf8");
const faces = content.match(FACE_RE);
if (!faces) continue;
const noto = faces.filter(isNotoFace);
if (noto.length === 0) continue;
// 抽出块里的相对 URL(../media/)改为绝对路径:CSS 将从 /fonts/ 引用
extracted.push(...noto.map((b) => b.replaceAll("../media/", "/_next/static/media/")));
let next = content;
for (const b of noto) next = next.replace(b, "");
touched.set(file, next);
}
if (extracted.length === 0) {
if (existsSync(outFile) || !existsSync(staticDir)) {
// 已抽取过 / 上游产物无字体声明(无构建产物时的 predev 场景):静默跳过
console.log("[extract-font-css] 无可抽取的 Noto Sans SC 声明,跳过");
process.exit(0);
}
// 有构建产物却扫不到字体声明:bundler 行为可能变化,必须响亮失败
console.error(
"[extract-font-css] 构建产物中未找到 Noto Sans SC @font-face,字体加载机制可能已变化,请人工检查 .next/static 下的 CSS"
);
process.exit(1);
}
mkdirSync(outDir, { recursive: true });
writeFileSync(outFile, extracted.join("\n") + "\n");
for (const [file, content] of touched) writeFileSync(file, content);
console.log(
`[extract-font-css] 已抽出 ${extracted.length} 条 @font-face → public/fonts/noto-sans-sc.css(涉及 ${touched.size} 个 chunk)`
);