Files
jiang13-bbs/frontend/lib/widgetCode.ts
freefire 7b327bc8cc feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
2026-09-24 18:53:45 +08:00

213 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 自定义侧栏工具代码处理:
// - 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;
}