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

@@ -1,8 +1,7 @@
// 自定义侧栏工具代码处理:
// - parseMixedWidgetCode / mergeWidgetCode:「混写一框」(内联 <style>/<script>)与分栏三字段互转;
// 三字段存储结构不变,拆分仅发生在管理端提交/切换视图时。
// - scopeWidgetCss:渲染时把工具 CSS 限定在其容器(.j13-widget-{id})内,
// 逐条规则加前缀,避免污染页面其他区域。
// parseMixedWidgetCode / mergeWidgetCode:「混写一框」(内联 <style>/<script>)与分栏三字段互转;
// 三字段存储结构不变,拆分仅发生在管理端提交/切换视图时。
// 工具的样式隔离由渲染端 Shadow DOM 负责(见 widgetRuntime.ts),本模块不再做 CSS 作用域改写。
export interface WidgetCodeParts {
html: string;
@@ -51,162 +50,3 @@ export function mergeWidgetCode(parts: WidgetCodeParts): string {
if (parts.js.trim()) out.push(`<script>\n${parts.js.trim()}\n</script>`);
return out.join("\n\n");
}
/* ─────────────────────────── CSS 作用域 ─────────────────────────── */
// 按 , 分割选择器列表,忽略 () / [] 与引号内的逗号(:is(a,b)、[attr="a,b"])
function splitSelectorList(sel: string): string[] {
const out: string[] = [];
let depth = 0;
let quote = "";
let start = 0;
for (let i = 0; i < sel.length; i++) {
const ch = sel[i];
if (quote) {
if (ch === "\\") i++;
else if (ch === quote) quote = "";
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === "(" || ch === "[") depth++;
else if (ch === ")" || ch === "]") depth = Math.max(0, depth - 1);
else if (ch === "," && depth === 0) {
out.push(sel.slice(start, i));
start = i + 1;
}
}
out.push(sel.slice(start));
return out;
}
// 单个选择器加前缀:
// - :root / :scope / html / body 开头 → 视作容器本身(变量定义在容器上,板块内可用);
// - 以伪类/伪元素(:)开头 → 紧贴容器(容器自身状态,如 .j13-widget-1:hover);
// - 其余 → 后代选择器。
function scopeOneSelector(sel: string, prefix: string): string | null {
const s = sel.trim();
if (!s) return null;
const rootLike = s.match(/^(?::root\b|:scope\b|html\b|body\b)([\s\S]*)$/);
if (rootLike) {
const rest = rootLike[1].replace(/^\s+/, "");
return rest ? `${prefix} ${rest}` : prefix;
}
if (s.startsWith(":")) {
// 功能型伪类开头(:is( / :where( / :has( / :not( )→ 按容器后代处理;
// 简单伪类/伪元素(:hover、::before…)→ 紧贴容器(容器自身状态)。
return /^::?[a-zA-Z-]+\(/.test(s) ? `${prefix} ${s}` : prefix + s;
}
return `${prefix} ${s}`;
}
/**
* 把 CSS 限定到容器内:
* - 普通规则:选择器逐个加 `.j13-widget-{id}` 前缀;
* - @media/@supports/@container/@layer 块:头部原样,内部规则递归前缀化;
* - @keyframes/@font-face/@property 等:整块原样(内部是帧/描述符,不是选择器);
* - 注释、引号内容(如 content:"{")不影响括号配对。
* 已知取舍:同名 @keyframes 在多个工具间仍是全局的,命名请自带区分。
*/
export function scopeWidgetCss(css: string, id: number): string {
const prefix = `.j13-widget-${id}`;
const n = css.length;
let out = "";
let i = 0;
// 从 from 起读一个平衡的 {...} 块(引号与转义内的括号不计入深度)
const readBlock = (from: number): { body: string; end: number } => {
let depth = 0;
let quote = "";
for (let j = from; j < n; j++) {
const ch = css[j];
if (quote) {
if (ch === "\\") j++;
else if (ch === quote) quote = "";
continue;
}
if (ch === '"' || ch === "'") quote = ch;
else if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) return { body: css.slice(from, j + 1), end: j + 1 };
}
}
return { body: css.slice(from), end: n };
};
while (i < n) {
if (css.startsWith("/*", i)) {
const close = css.indexOf("*/", i + 2);
const stop = close === -1 ? n : close + 2;
out += css.slice(i, stop);
i = stop;
continue;
}
if (css[i] === "}") {
// 多余闭括号容错,原样透传
out += css[i];
i++;
continue;
}
// 读取选择器/语句头直到 { ; } 或结尾(引号内不截断)
let j = i;
let quote = "";
for (; j < n; j++) {
const c = css[j];
if (quote) {
if (c === "\\") j++;
else if (c === quote) quote = "";
continue;
}
if (c === '"' || c === "'") {
quote = c;
continue;
}
if (c === "{" || c === ";" || c === "}") break;
}
const head = css.slice(i, j);
if (j >= n) {
out += head;
break;
}
if (css[j] === ";") {
// @import / @charset / @layer a, b; 等语句原样保留
out += head + ";";
i = j + 1;
continue;
}
if (css[j] === "}") {
out += head;
i = j + 1;
continue;
}
const { body, end } = readBlock(j);
const trimmedHead = head.trim();
if (trimmedHead.startsWith("@")) {
const at = trimmedHead.split(/[\s(]/)[0].toLowerCase();
if (at === "@media" || at === "@supports" || at === "@container" || at === "@layer") {
const inner = body.slice(1, -1);
out += head + "{" + scopeWidgetCss(inner, id) + "}";
} else {
out += head + body;
}
} else {
const scoped = splitSelectorList(head)
.map((s) => scopeOneSelector(s, prefix))
.filter((s): s is string => s !== null);
if (scoped.length) {
// 保留选择器前后的原始空白,尽量不改变站长代码的排版
const lead = head.match(/^\s*/)?.[0] ?? "";
const trail = head.match(/\s*$/)?.[0] ?? "";
out += lead + scoped.join(", ") + trail + body;
} else {
out += head + body;
}
}
i = end;
}
return out;
}