feat: 站点访问统计与可配置侧边栏组件

- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
2026-09-24 18:53:45 +08:00
parent 3e55b5d230
commit 7b327bc8cc
60 changed files with 6539 additions and 572 deletions

View File

@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { mergeWidgetCode, parseMixedWidgetCode, scopeWidgetCss } from "./widgetCode.ts";
describe("parseMixedWidgetCode", () => {
it("拆分混写的 style/script 段", () => {
const r = parseMixedWidgetCode(
`<div class="a">hi</div>\n<style>.a { color: red; }</style>\n<script>console.log(1);</script>`
);
assert.equal(r.html, `<div class="a">hi</div>`);
assert.equal(r.css, `.a { color: red; }`);
assert.equal(r.js, `console.log(1);`);
});
it("多段 style/script 按序拼接", () => {
const r = parseMixedWidgetCode(`<style>.a{}</style><style>.b{}</style><p>x</p><script>a();</script><script>b();</script>`);
assert.equal(r.css, ".a{}\n\n.b{}");
assert.equal(r.js, "a();\n\nb();");
assert.equal(r.html, "<p>x</p>");
});
it("纯 HTML 不误伤", () => {
const r = parseMixedWidgetCode(`<p>hello</p>`);
assert.deepEqual(r, { html: "<p>hello</p>", css: "", js: "" });
});
it("外链与非 JS type 的 script 留在 html", () => {
const code = `<script src="https://x/y.js"></script><script type="application/json">{"a":1}</script><script>z();</script>`;
const r = parseMixedWidgetCode(code);
assert.equal(r.js, "z();");
assert.equal(
r.html,
`<script src="https://x/y.js"></script><script type="application/json">{"a":1}</script>`
);
});
});
describe("mergeWidgetCode", () => {
it("合并后再解析可还原三字段", () => {
const parts = { html: "<b>go</b>", css: "b { font-size: 18px; }", js: "x();" };
assert.deepEqual(parseMixedWidgetCode(mergeWidgetCode(parts)), parts);
});
it("空段不产生标签", () => {
assert.equal(mergeWidgetCode({ html: "hi", css: "", js: "" }), "hi");
});
});
describe("scopeWidgetCss", () => {
const p = ".j13-widget-7";
it("普通规则加前缀", () => {
assert.equal(scopeWidgetCss(".a { color: red; }", 7), `${p} .a { color: red; }`);
});
it("选择器列表逐个前缀,括号内逗号不分割", () => {
assert.equal(
scopeWidgetCss(".a, .b {}", 7),
`${p} .a, ${p} .b {}`
);
assert.equal(
scopeWidgetCss(':is(.a, .b) { color: red }', 7),
`${p} :is(.a, .b) { color: red }`
);
});
it("root/body 选择器替换为容器本身", () => {
assert.equal(scopeWidgetCss(":root { --x: 1; }", 7), `${p} { --x: 1; }`);
assert.equal(scopeWidgetCss("body { margin: 0 }", 7), `${p} { margin: 0 }`);
assert.equal(scopeWidgetCss("body .card {}", 7), `${p} .card {}`);
});
it("容器自身伪类紧贴前缀", () => {
assert.equal(scopeWidgetCss(":hover { opacity: .8 }", 7), `${p}:hover { opacity: .8 }`);
});
it("@media 内层前缀化,头部原样", () => {
assert.equal(
scopeWidgetCss("@media (min-width: 10px) { .a {} }", 7),
`@media (min-width: 10px) { ${p} .a {} }`
);
});
it("@keyframes 原样保留", () => {
const css = "@keyframes spin { from { opacity: 0 } to { opacity: 1 } }";
assert.equal(scopeWidgetCss(css, 7), css);
});
it("@import 语句原样保留", () => {
assert.equal(
scopeWidgetCss('@import url("a.css"); .a {}', 7),
'@import url("a.css"); .j13-widget-7 .a {}'
);
});
it("注释与字符串内的花括号不干扰配对", () => {
assert.equal(
scopeWidgetCss("/* { nope } */ .a::after { content: \"{\" }", 7),
`/* { nope } */ ${p} .a::after { content: "{" }`
);
});
});