- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
213 lines
7.1 KiB
TypeScript
213 lines
7.1 KiB
TypeScript
// 自定义侧栏工具代码处理:
|
||
// - parseMixedWidgetCode / mergeWidgetCode:「混写一框」(内联 <style>/<script>)与分栏三字段互转;
|
||
// 三字段存储结构不变,拆分仅发生在管理端提交/切换视图时。
|
||
// - scopeWidgetCss:渲染时把工具 CSS 限定在其容器(.j13-widget-{id})内,
|
||
// 逐条规则加前缀,避免污染页面其他区域。
|
||
|
||
export interface WidgetCodeParts {
|
||
html: string;
|
||
css: string;
|
||
js: string;
|
||
}
|
||
|
||
const STYLE_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
|
||
const SCRIPT_RE = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
||
const TYPE_RE = /\btype\s*=\s*(?:"([^"]*)"|'([^']*)')/i;
|
||
|
||
const JS_TYPES = new Set(["", "text/javascript", "application/javascript", "module"]);
|
||
|
||
function isNonJsScript(attrs: string): boolean {
|
||
const m = attrs.match(TYPE_RE);
|
||
if (!m) return false;
|
||
return !JS_TYPES.has((m[1] ?? m[2] ?? "").toLowerCase());
|
||
}
|
||
|
||
/** 混写代码 → 三字段:<style>/<script> 段被摘出,剩余全部视为 HTML */
|
||
export function parseMixedWidgetCode(code: string): WidgetCodeParts {
|
||
const cssParts: string[] = [];
|
||
const jsParts: string[] = [];
|
||
let html = code.replace(STYLE_RE, (_m, body: string) => {
|
||
cssParts.push(body);
|
||
return "";
|
||
});
|
||
html = html.replace(SCRIPT_RE, (m: string, attrs: string, body: string) => {
|
||
// 外链脚本、模板/数据脚本(type 非 JS)原样留在 HTML 中
|
||
if (/\bsrc\s*=/i.test(attrs) || isNonJsScript(attrs)) return m;
|
||
jsParts.push(body);
|
||
return "";
|
||
});
|
||
return {
|
||
html: html.trim(),
|
||
css: cssParts.join("\n\n").trim(),
|
||
js: jsParts.join("\n\n").trim(),
|
||
};
|
||
}
|
||
|
||
/** 三字段 → 混写视图:HTML 在前,CSS/JS 分别以标准标签包裹 */
|
||
export function mergeWidgetCode(parts: WidgetCodeParts): string {
|
||
const out: string[] = [];
|
||
if (parts.html.trim()) out.push(parts.html.trim());
|
||
if (parts.css.trim()) out.push(`<style>\n${parts.css.trim()}\n</style>`);
|
||
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;
|
||
}
|