feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user