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

@@ -1269,17 +1269,257 @@ export async function apiAdminAnalytics(range: "7d" | "30d" = "7d"): Promise<Ana
return res.json();
}
export async function apiTelemetryPageView(): Promise<void> {
export async function apiTelemetryPageView(path: string, ref: string): Promise<void> {
try {
await fetchWithRefresh("/api/telemetry/pageview", {
method: "POST",
headers: clientHeaders(),
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ path, ref }),
});
} catch {
/* 埋点失败不影响浏览 */
}
}
// ===== 访问统计(管理端,仅超管/站长) =====
export interface VisitDayPoint {
date: string;
pv: number;
uv: number;
}
export interface VisitNamedCount {
key: string;
label: string;
count: number;
}
export interface VisitPageRow {
path: string;
pv: number;
uv: number;
share: number;
}
export interface VisitOverview {
range_days: number;
trend: VisitDayPoint[];
today_pv: number;
today_uv: number;
yesterday_pv: number;
yesterday_uv: number;
logged_in_pv: number;
devices: VisitNamedCount[];
sources: VisitNamedCount[];
top_pages: VisitPageRow[];
}
export interface VisitMinutePoint {
minute: string;
pv: number;
}
export interface VisitEventItem {
id: number;
created_at: string;
kind: string;
path: string;
ref_host: string;
ref_kind: string;
user_id: number;
username: string;
ip: string;
device: string;
os: string;
browser: string;
bot: string;
status: number;
}
export interface VisitRealtime {
minutes: VisitMinutePoint[];
pv_5min: number;
pv_30min: number;
online: number;
top_pages: VisitNamedCount[];
recent: VisitEventItem[];
}
export interface VisitSourcesKindDay {
date: string;
items: VisitNamedCount[];
}
export interface VisitSources {
range_days: number;
kinds: VisitNamedCount[];
search_hosts: VisitNamedCount[];
external: VisitNamedCount[];
trend: VisitSourcesKindDay[];
}
export interface VisitPages {
range_days: number;
total: number;
items: VisitPageRow[];
}
export interface VisitVisitors {
range_days: number;
devices: VisitNamedCount[];
oses: VisitNamedCount[];
browsers: VisitNamedCount[];
logged_in_pv: number;
guest_pv: number;
new_visitors: number;
returning: number;
hourly: VisitNamedCount[];
}
export interface VisitSecurity {
range_days: number;
bots: VisitNamedCount[];
bot_trend: VisitDayPoint[];
probe_status: VisitNamedCount[];
probe_ips: VisitNamedCount[];
login_failed: number;
failed_users: number;
}
export interface VisitEventsQuery {
kind?: string;
bot?: string;
path?: string;
ip?: string;
start?: string;
end?: string;
page?: number;
size?: number;
}
export interface VisitEventsResult {
total: number;
items: VisitEventItem[];
}
export interface VisitUsage {
rows: number;
bytes: number;
}
export interface VisitSettingsPayload {
enabled: boolean;
retention_days: number;
usage: VisitUsage;
dropped: number;
}
// SSR 首拉:透传 cookie
async function fetchVisitStats<T>(path: string, cookieHeader?: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, ssrInit(cookieHeaders(cookieHeader)));
if (!res.ok) throw new Error("failed");
return res.json();
}
// 客户端拉取:带 CSRF + 自动续期
async function apiVisitStats<T>(path: string, init?: RequestInit): Promise<T> {
const headers = init?.body
? clientHeaders({ "Content-Type": "application/json" })
: clientHeaders();
const res = await fetchWithRefresh(path, {
method: init?.method ?? "GET",
...init,
headers,
});
if (!res.ok) throw new Error("failed");
return res.json();
}
export function fetchStatsOverview(cookieHeader?: string, range = "7d") {
return fetchVisitStats<VisitOverview>(`/api/admin/stats/overview?range=${range}`, cookieHeader);
}
export function apiStatsOverview(range = "7d") {
return apiVisitStats<VisitOverview>(`/api/admin/stats/overview?range=${range}`);
}
export function fetchStatsRealtime(cookieHeader?: string) {
return fetchVisitStats<VisitRealtime>("/api/admin/stats/realtime", cookieHeader);
}
export function apiStatsRealtime() {
return apiVisitStats<VisitRealtime>("/api/admin/stats/realtime");
}
export function fetchStatsSources(cookieHeader?: string, range = "7d") {
return fetchVisitStats<VisitSources>(`/api/admin/stats/sources?range=${range}`, cookieHeader);
}
export function apiStatsSources(range = "7d") {
return apiVisitStats<VisitSources>(`/api/admin/stats/sources?range=${range}`);
}
export function fetchStatsPages(cookieHeader?: string, range = "7d", q = "", page = 1, size = 20) {
return fetchVisitStats<VisitPages>(
`/api/admin/stats/pages?range=${range}&q=${encodeURIComponent(q)}&page=${page}&size=${size}`,
cookieHeader
);
}
export function apiStatsPages(range = "7d", q = "", page = 1, size = 20) {
return apiVisitStats<VisitPages>(
`/api/admin/stats/pages?range=${range}&q=${encodeURIComponent(q)}&page=${page}&size=${size}`
);
}
export function fetchStatsVisitors(cookieHeader?: string, range = "7d") {
return fetchVisitStats<VisitVisitors>(`/api/admin/stats/visitors?range=${range}`, cookieHeader);
}
export function apiStatsVisitors(range = "7d") {
return apiVisitStats<VisitVisitors>(`/api/admin/stats/visitors?range=${range}`);
}
export function fetchStatsSecurity(cookieHeader?: string, range = "7d") {
return fetchVisitStats<VisitSecurity>(`/api/admin/stats/security?range=${range}`, cookieHeader);
}
export function apiStatsSecurity(range = "7d") {
return apiVisitStats<VisitSecurity>(`/api/admin/stats/security?range=${range}`);
}
export function fetchStatsEvents(cookieHeader?: string, query: VisitEventsQuery = {}) {
const p = new URLSearchParams();
if (query.kind) p.set("kind", query.kind);
if (query.bot) p.set("bot", query.bot);
if (query.path) p.set("path", query.path);
if (query.ip) p.set("ip", query.ip);
if (query.start) p.set("start", query.start);
if (query.end) p.set("end", query.end);
p.set("page", String(query.page ?? 1));
p.set("size", String(query.size ?? 50));
return fetchVisitStats<VisitEventsResult>(`/api/admin/stats/events?${p.toString()}`, cookieHeader);
}
export function apiStatsEvents(query: VisitEventsQuery = {}) {
const p = new URLSearchParams();
if (query.kind) p.set("kind", query.kind);
if (query.bot) p.set("bot", query.bot);
if (query.path) p.set("path", query.path);
if (query.ip) p.set("ip", query.ip);
if (query.start) p.set("start", query.start);
if (query.end) p.set("end", query.end);
p.set("page", String(query.page ?? 1));
p.set("size", String(query.size ?? 50));
return apiVisitStats<VisitEventsResult>(`/api/admin/stats/events?${p.toString()}`);
}
export function fetchStatsSettings(cookieHeader?: string) {
return fetchVisitStats<VisitSettingsPayload>("/api/admin/stats/settings", cookieHeader);
}
export function apiStatsSettings() {
return apiVisitStats<VisitSettingsPayload>("/api/admin/stats/settings");
}
export function apiStatsSaveSettings(enabled: boolean, retentionDays: number) {
return apiVisitStats<VisitSettingsPayload>("/api/admin/stats/settings", {
method: "PUT",
body: JSON.stringify({ enabled, retention_days: retentionDays }),
});
}
export function apiStatsPurge(days: number) {
return apiVisitStats<{ ok: boolean }>("/api/admin/stats/purge", {
method: "POST",
body: JSON.stringify({ days }),
});
}
export type AdminChatRoomType = "group" | "direct";
export interface AdminChatParticipant {
@@ -3451,3 +3691,68 @@ export async function fetchAdminSponsorsConfig(cookie?: string): Promise<Sponsor
if (!res.ok) throw new Error("加载赞助商配置失败");
return res.json();
}
// ===== 侧栏管理(布局配置 + 自定义 HTML 工具) =====
import type {
SidebarConfig,
SidebarPublicPayload,
SidebarWidgetRecord,
} from "@/lib/sidebarMeta";
export type {
SidebarConfig,
SidebarScope,
SidebarScopeConfig,
SidebarBlockRef,
SidebarPublicPayload,
SidebarWidgetPublic,
SidebarWidgetRecord,
} from "@/lib/sidebarMeta";
/** SSR 公开侧栏载荷;失败由调用方回落默认布局 */
export async function fetchSidebar(cookieHeader?: string): Promise<SidebarPublicPayload> {
const res = await fetch(`${API_BASE}/api/sidebar`, ssrInit(cookieHeaders(cookieHeader)));
if (!res.ok) throw new Error("获取侧栏配置失败");
return res.json();
}
/** SSR 管理端侧栏配置(config + 全量工具含停用);失败由调用方提示 */
export async function fetchAdminSidebar(
cookie?: string
): Promise<{ config: SidebarConfig; widgets: SidebarWidgetRecord[] }> {
const res = await fetch(`${API_BASE}/api/admin/sidebar`, {
...ssrInit(cookie ? { Cookie: cookie } : undefined),
credentials: "include",
});
if (!res.ok) throw new Error("加载侧栏配置失败");
return res.json();
}
export async function apiAdminGetSidebar(): Promise<{
config: SidebarConfig;
widgets: SidebarWidgetRecord[];
}> {
return apiOperations("/api/admin/sidebar");
}
export async function apiAdminSaveSidebarConfig(config: SidebarConfig): Promise<{ config: SidebarConfig }> {
return apiOperations("/api/admin/sidebar/config", "PUT", { config });
}
export async function apiAdminCreateSidebarWidget(
body: Pick<SidebarWidgetRecord, "name" | "html" | "css" | "js">
): Promise<{ widget: SidebarWidgetRecord }> {
return apiOperations("/api/admin/sidebar/widgets", "POST", body);
}
export async function apiAdminUpdateSidebarWidget(
id: number,
body: Pick<SidebarWidgetRecord, "name" | "html" | "css" | "js" | "enabled">
): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/sidebar/widgets/${id}`, "PUT", body);
}
export async function apiAdminDeleteSidebarWidget(id: number): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/sidebar/widgets/${id}`, "DELETE");
}

179
frontend/lib/sidebarMeta.ts Normal file
View File

@@ -0,0 +1,179 @@
// 侧栏管理:内置块元数据与默认布局(管理页与前台注册表共用,勿在此引入组件)
export type SidebarScope = "home" | "board" | "post";
export interface SidebarBlockRef {
key?: string;
widget?: number;
}
export interface SidebarScopeConfig {
left?: SidebarBlockRef[] | null;
right?: SidebarBlockRef[] | null;
}
export interface SidebarConfig {
version: number;
scopes: Partial<Record<SidebarScope, SidebarScopeConfig>>;
}
export interface SidebarWidgetPublic {
id: number;
name: string;
html: string;
css: string;
js: string;
}
export interface SidebarPublicPayload {
config: SidebarConfig;
widgets: SidebarWidgetPublic[];
}
export interface SidebarWidgetRecord {
id: number;
name: string;
html: string;
css: string;
js: string;
enabled: boolean;
created_at: string;
updated_at: string;
}
// 每侧最多放置块数(与后端 SidebarMaxBlocksPerSide 一致)
export const SIDEBAR_MAX_BLOCKS_PER_SIDE = 24;
export const SIDEBAR_SCOPE_LABELS: Record<SidebarScope, string> = {
home: "首页",
board: "板块页",
post: "帖子详情",
};
export const SIDEBAR_SIDE_LABELS: Record<"left" | "right", string> = {
left: "左栏",
right: "右栏",
};
// 各页面范围可用的内置块(key → 展示名),与后端 sidebarBuiltinKeys 保持一致
export const SIDEBAR_WIDGET_META: Record<SidebarScope, Record<string, string>> = {
home: {
board_nav: "板块目录",
pulse: "站点数据",
announcements: "站点公告",
ads: "自助推广",
sponsors: "赞助商",
hot: "热门讨论",
new_users: "最新注册",
active_users: "活跃成员",
friend_links: "友情链接",
about_site: "关于本站",
},
board: {
board_moderators: "板块管理员",
board_intro: "板块概况",
board_pulse: "板块数据",
board_hot: "本板热门",
board_active: "活跃成员",
},
post: {
post_board: "所属板块",
post_author: "作者名片",
post_author_posts: "作者其他帖",
post_toc: "文章目录",
},
};
// 内置块 icon(字符串引用,渲染方 import lucide-react 时映射)
// 保持轻量:不在 sidebarMeta 里引入组件
export const SIDEBAR_BLOCK_ICON: Record<string, string> = {
board_nav: "layout-grid",
pulse: "bar-chart-3",
announcements: "megaphone",
ads: "circle-dollar-sign",
sponsors: "handshake",
hot: "flame",
new_users: "user-plus",
active_users: "users",
friend_links: "link2",
about_site: "info",
board_moderators: "shield-check",
board_intro: "file-text",
board_pulse: "bar-chart-3",
board_hot: "flame",
board_active: "users",
post_board: "layers",
post_author: "user",
post_author_posts: "pen-line",
post_toc: "list",
};
// 默认布局(= 历史硬编码渲染;后端 DefaultSidebarConfig 的前端镜像)
export function defaultSidebarConfig(): SidebarConfig {
const refs = (keys: string[]): SidebarBlockRef[] => keys.map((key) => ({ key }));
return {
version: 1,
scopes: {
home: {
left: refs(["board_nav"]),
right: refs([
"pulse",
"announcements",
"ads",
"sponsors",
"hot",
"new_users",
"active_users",
"friend_links",
"about_site",
]),
},
board: {
left: [],
right: refs(["board_moderators", "board_intro", "board_pulse", "board_hot", "board_active"]),
},
post: {
left: refs(["post_board", "post_author", "post_author_posts"]),
right: refs(["post_toc"]),
},
},
};
}
// 按页面范围解析左右两列的块列表:
// - 未配置(nil)的侧回落默认布局,显式空数组 = 站长清空该侧;
// - board 范围左栏跟随 home.left(板块态仅管理右栏)。
export function sidebarScopeLists(
config: SidebarConfig,
scope: SidebarScope
): { left: SidebarBlockRef[]; right: SidebarBlockRef[] } {
const def = defaultSidebarConfig().scopes;
if (scope === "board") {
return {
left: config.scopes.home?.left ?? def.home?.left ?? [],
right: config.scopes.board?.right ?? def.board?.right ?? [],
};
}
return {
left: config.scopes[scope]?.left ?? def[scope]?.left ?? [],
right: config.scopes[scope]?.right ?? def[scope]?.right ?? [],
};
}
// 块的展示名:内置取 META,自定义取备注名;未知返回 null(渲染时跳过)
export function sidebarBlockLabel(
ref: SidebarBlockRef,
widgets: { id: number; name: string }[]
): { label: string; custom: boolean } | null {
if (ref.widget) {
const w = widgets.find((x) => x.id === ref.widget);
return w ? { label: w.name, custom: true } : null;
}
if (ref.key) {
for (const scope of Object.keys(SIDEBAR_WIDGET_META) as SidebarScope[]) {
const label = SIDEBAR_WIDGET_META[scope][ref.key];
if (label) return { label, custom: false };
}
}
return null;
}

91
frontend/lib/widgetApi.ts Normal file
View File

@@ -0,0 +1,91 @@
// 自定义侧栏工具的站点开放接口:
// - CSS:工具内直接使用站点主题变量(var(--accent) 等),随明暗主题与
// 后台自定义主题色自动变化(CSS 变量沿 DOM 继承,无需任何 JS);
// - JS:window.J13 提供当前用户、主题读取/监听、站点概览懒加载。
// 本模块只在浏览器端(CustomWidgetBlock 的 effect 内)执行。
import type { OverviewResponse, User } from "@/lib/api";
export interface WidgetViewer {
id: number;
username: string;
nickname: string;
role: string;
avatar: string | null;
}
export interface WidgetApi {
version: 1;
/** 当前登录用户(未登录为 null);每次工具挂载时刷新 */
user: WidgetViewer | null;
theme: {
get: () => "light" | "dark";
/** 监听明暗切换,返回取消监听函数 */
onChange: (cb: (theme: "light" | "dark") => void) => () => void;
};
/** 站点公开概览统计(结果模块级缓存,多个工具共享一次请求) */
getOverview: () => Promise<OverviewResponse | null>;
}
declare global {
interface Window {
J13?: WidgetApi;
}
}
function toViewer(user: User | null | undefined): WidgetViewer | null {
if (!user) return null;
return {
id: user.id,
username: user.username,
nickname: user.nickname,
role: user.role,
avatar: user.avatar || null,
};
}
// 概览请求全站只发一次(无论多少个工具、多少次挂载)
let overviewPromise: Promise<OverviewResponse | null> | null = null;
function loadOverview(): Promise<OverviewResponse | null> {
if (!overviewPromise) {
overviewPromise = fetch("/api/overview", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.catch(() => null);
}
return overviewPromise;
}
/**
* 幂等初始化 window.J13 并刷新当前用户信息。
* 必须在注入站长 JS 之前调用;多个工具共享同一个实例。
*/
export function ensureWidgetApi(viewer: User | null | undefined): WidgetApi {
const root = document.documentElement;
const readTheme = (): "light" | "dark" =>
root.classList.contains("dark") ? "dark" : "light";
if (!window.J13) {
const listeners = new Set<(theme: "light" | "dark") => void>();
// 主题切换 = html 元素 .dark 类的增删,观察其变化广播给工具
const observer = new MutationObserver(() => {
const theme = readTheme();
listeners.forEach((cb) => cb(theme));
});
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
window.J13 = {
version: 1,
user: null,
theme: {
get: readTheme,
onChange(cb) {
listeners.add(cb);
return () => listeners.delete(cb);
},
},
getOverview: loadOverview,
};
}
window.J13.user = toViewer(viewer);
return window.J13;
}

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: "{" }`
);
});
});

212
frontend/lib/widgetCode.ts Normal file
View File

@@ -0,0 +1,212 @@
// 自定义侧栏工具代码处理:
// - 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;
}