feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等

新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
This commit is contained in:
2026-10-01 03:06:05 +08:00
parent 7fbd6ba699
commit ff2ab286fb
141 changed files with 7527 additions and 1407 deletions

View File

@@ -82,6 +82,8 @@ export interface User {
board_ids?: number[];
// 站点消息管理(站长授予;站长/超管固有监管不必依赖此字段)
can_manage_messages?: boolean;
// 生效权限码(角色默认 ∪ 账号级渠道授予;仅 /api/me 返回,用于后台入口显隐)
perms?: string[];
// 作为群管理员的群数量(/me;用于后台消息入口)
chat_admin_room_count?: number;
// 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息)
@@ -284,13 +286,26 @@ export interface LibrarySectionItem {
updated_at: string;
}
/** 书库条目创建者摘要(已注销用户时接口省略该字段) */
export interface LibraryCreator {
id: number;
username: string;
nickname: string;
avatar: string;
}
/** 书库条目(详情/管理端,含文件与章节) */
export interface LibraryDoc {
id: number;
slug: string;
title: string;
author: string;
creator_id: number;
creator?: LibraryCreator | null;
description: string;
cover_url: string;
cover_width: number;
cover_height: number;
published: boolean;
sort_order: number;
entries_auto?: boolean; // 全章节条目卡模式(章节内容免写 [entries] 壳)
@@ -301,11 +316,14 @@ export interface LibraryDoc {
sections: LibrarySectionItem[];
}
/** 书库公开目录条目(不含 description 全文) */
/** 书库公开目录条目(description 为 markdown 原文,卡片内按纯文本截断展示) */
export interface LibraryDocListItem {
id: number;
slug: string;
title: string;
author: string;
description: string;
creator?: LibraryCreator | null;
cover_url: string;
sort_order: number;
file_count: number;
@@ -317,8 +335,11 @@ export interface LibraryDocListItem {
export interface LibraryInput {
slug: string;
title: string;
author?: string;
description?: string;
cover_url?: string;
cover_width?: number;
cover_height?: number;
published: boolean;
sort_order?: number;
entries_auto?: boolean;
@@ -492,6 +513,8 @@ export interface Post {
export interface AcceptedAnswer {
id: number;
floor: number;
/** false = 楼中楼回复,锚点需 #comment-{floor}-r{id} */
is_root: boolean;
content: string;
created_at: string;
deleted?: boolean;
@@ -683,6 +706,10 @@ export interface UserCommentItem {
post_id: number;
post_title: string;
content: string;
/** 所属帖内相对楼层号(0 = 评论已不可见) */
floor: number;
/** 主楼/楼中楼:楼中楼锚点需 #comment-{floor}-r{id} */
is_root: boolean;
created_at: string;
}
@@ -728,6 +755,10 @@ export interface NotificationItem {
content: string;
is_read: boolean;
created_at: string;
/** 关联评论在所属帖内的相对楼层号(0/缺省 = 评论已彻底删除) */
comment_floor?: number;
/** 关联评论是否主楼;楼中楼锚点需 #comment-{floor}-r{comment_id} */
comment_is_root?: boolean;
actor: User;
post?: { id: number; title: string };
room?: { id: number; name: string } | null;
@@ -1407,18 +1438,6 @@ export async function fetchComments(
return res.json();
}
// 评论定位:返回楼层号(与楼层分页口径一致),0 = 不存在。
// 通知/主页评论深链 #comment-{id} 跨页时用它换到正确页码。
export async function fetchCommentLocation(
postId: string | number,
commentId: number
): Promise<number> {
const res = await fetch(`${API_BASE}/api/posts/${postId}/comments/${commentId}`);
if (!res.ok) return 0;
const data = (await res.json()) as { floor?: number };
return data.floor || 0;
}
export async function fetchUserProfile(
id: string,
page = 1,
@@ -2331,6 +2350,8 @@ export interface MediaAttachment {
id: number;
url: string;
kind: "avatar" | "image";
/** 图片来源:post=帖子插图,library_cover=书籍封面,library_content=书籍正文 */
source?: string;
size: number;
width: number;
height: number;
@@ -2354,7 +2375,8 @@ export async function apiUploadAvatar(
// 上传帖子插图(JPEG / PNG / WebP / GIF,≤5MB;服务端自动转存 WebP,GIF 除外)
export async function apiUploadPostImage(
file: File | Blob,
filename = "image.jpg"
filename = "image.jpg",
source?: string
): Promise<{ url?: string; attachment?: MediaAttachment; error?: string }> {
const form = new FormData();
const name =
@@ -2362,6 +2384,7 @@ export async function apiUploadPostImage(
? file.name
: filename;
form.append("file", file, name);
if (source) form.append("source", source);
const res = await fetchWithRefresh("/api/upload/image", {
method: "POST",
headers: clientHeaders(),
@@ -2965,6 +2988,48 @@ export async function apiAdminDeleteLibraryFile(fid: number): Promise<void> {
}
}
/** 书库导入报告(POST /api/admin/library/import) */
export interface LibraryImportItem {
slug: string;
title: string;
action: "created" | "overwritten" | string;
sections: number;
files: number;
}
export interface LibraryImportFailure {
slug: string;
title: string;
/** conflict=slug 被在用条目占用(可改 overwrite 重导);deleted=被软删条目占用;invalid/failed */
reason: "conflict" | "deleted" | "invalid" | "failed" | string;
error: string;
}
export interface LibraryImportReport {
mode: string;
total: number;
imported: LibraryImportItem[];
failed: LibraryImportFailure[];
}
/** 上传书库导出 ZIP 恢复书籍;mode=create(冲突跳过)/ overwrite(覆盖同名) */
export async function apiAdminImportLibraryZip(
file: File,
mode: "create" | "overwrite"
): Promise<LibraryImportReport> {
const form = new FormData();
form.append("file", file, file.name);
form.append("mode", mode);
const res = await fetchWithRefresh(`/api/admin/library/import`, {
method: "POST",
headers: clientHeaders(),
body: form,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "导入失败");
return (data as { report: LibraryImportReport }).report;
}
// ===== 书库章节(两级树)=====
export interface LibrarySectionInput {
@@ -3067,6 +3132,8 @@ export interface AdminUser {
board_ids: number[]; // 板块管理员的授权板块
banned: boolean;
can_manage_messages?: boolean;
// 账号级渠道权限(公告/单页/书库/广告;角色固有之外的增量授予)
perm_overrides?: string[];
points?: number;
total_points?: number;
level?: number;
@@ -3210,6 +3277,33 @@ export async function apiAdminSetUserMessages(
return data as { user: AdminUser };
}
// 读取账号级渠道权限(超管/站长;前台快捷菜单的授权弹窗回显用)
export async function apiAdminGetUserPerms(
id: number
): Promise<{ perm_overrides: string[] }> {
const res = await fetchWithRefresh(`/api/admin/users/${id}/perms`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "渠道权限加载失败");
return data as { perm_overrides: string[] };
}
// 超管/站长授予/撤销账号级渠道权限(公告/单页/书库/广告)
export async function apiAdminSetUserPerms(
id: number,
perms: string[]
): Promise<{ user: AdminUser }> {
const res = await fetchWithRefresh(`/api/admin/users/${id}/perms`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ perms }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "渠道权限更新失败");
return data as { user: AdminUser };
}
// 管理员手动调整用户积分(加分计累计可升级;扣分仅扣余额不降级),理由必填
export interface AdminAdjustPointsResult {
balance: number;
@@ -3547,6 +3641,9 @@ export interface AdminAuditCommentItem {
deleted: boolean;
created_at: string;
deleted_at?: string | null;
/** 所属帖内相对楼层号(0 = 评论已不可见) */
floor: number;
is_root: boolean;
}
export interface AdminAuditMessageItem {
@@ -3788,8 +3885,10 @@ export async function apiBackgroundFromMedia(
return url;
}
/** 把本人媒体库图片复制一份作书籍封面素材,返回副本 URL(与原图解耦) */
export async function apiLibraryCoverFromMedia(attachmentId: number): Promise<string> {
/** 把本人媒体库图片复制一份作书籍封面素材,返回副本 URL 与尺寸(与原图解耦) */
export async function apiLibraryCoverFromMedia(
attachmentId: number
): Promise<{ url: string; width: number; height: number }> {
const res = await fetchWithRefresh("/api/admin/library/cover/from-media", {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
@@ -3799,7 +3898,8 @@ export async function apiLibraryCoverFromMedia(attachmentId: number): Promise<st
if (!res.ok) throw new Error(data.error || "选用媒体库图片失败");
const url = String(data.url || "");
if (!url) throw new Error("选用媒体库图片失败");
return url;
const att = (data.attachment || {}) as { width?: number; height?: number };
return { url, width: Number(att.width) || 0, height: Number(att.height) || 0 };
}
// ===== 板块(客户端,供角色授权弹窗勾选) =====
@@ -3877,6 +3977,9 @@ export interface AdminContentComment {
deleted: boolean;
deleted_at?: string;
created_at: string;
/** 所属帖内相对楼层号(0 = 评论已不可见) */
floor: number;
is_root: boolean;
board: Board;
user: User;
}
@@ -4089,6 +4192,7 @@ export interface ChatRoomMember {
role: "owner" | "admin" | "member";
last_read_message_id: number;
muted: boolean;
muted_until?: string | null;
created_at: string;
updated_at: string;
user: User;
@@ -4106,6 +4210,8 @@ export interface ChatMessage {
recalled_at?: string | null;
recalled_by?: number;
sender: User;
/** 系统提示消息(如禁言通知):sender_id=0,居中灰字渲染 */
is_system?: boolean;
/** 撤回操作者(已撤回时后端 Preload;本人撤回时通常等于 sender) */
recaller?: User | null;
}
@@ -4376,23 +4482,24 @@ export async function apiKickChatMember(
return data as { ok: boolean };
}
// 客户端:禁言/解禁
// 客户端:禁言/解禁;durationMinutes 可选:0/缺省=永久,>0=指定分钟数
export async function apiMuteChatMember(
roomId: number,
userId: number,
muted: boolean
): Promise<{ ok: boolean; muted: boolean }> {
muted: boolean,
durationMinutes?: number
): Promise<{ ok: boolean; muted: boolean; duration_minutes?: number }> {
const res = await fetchWithRefresh(
`/api/chat/rooms/${roomId}/members/${userId}/mute`,
{
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ muted }),
body: JSON.stringify({ muted, duration_minutes: durationMinutes ?? 0 }),
}
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "禁言操作失败");
return data as { ok: boolean; muted: boolean };
return data as { ok: boolean; muted: boolean; duration_minutes?: number };
}
// 客户端:站长任命/撤销群管理员

View File

@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { markdownToSearchText, searchBook, type BookChapterIndex } from "./bookSearch.ts";
describe("markdownToSearchText", () => {
it("条目卡指令保留值、剥离标记符号", () => {
const md = `[entries]
## 安全气囊
@tags 汽车 | 安全 | 主:被动
@summary 气囊需配合安全带使用。
备注 :: 详见召回公告
@source NHTSA 报告 | https://a.gov/x
[/entries]`;
const text = markdownToSearchText(md);
assert.ok(text.includes("安全气囊"));
assert.ok(text.includes("汽车"));
assert.ok(text.includes("主:被动"));
assert.ok(text.includes("气囊需配合安全带使用。"));
assert.ok(text.includes("备注 详见召回公告"));
assert.ok(text.includes("NHTSA 报告"));
assert.ok(!text.includes("@tags"));
assert.ok(!text.includes("@summary"));
assert.ok(!text.includes("@source"));
assert.ok(!text.includes("::"));
assert.ok(!text.includes("[entries"));
assert.ok(!text.includes("https://a.gov/x"));
});
it("锁定的 hide 块整体不可搜,未锁块保留正文", () => {
const md = `公开段落内容。
[hide login]
登录后可见的秘密
[/hide]
[hide points=10 locked]
积分秘密内容
[/hide]`;
const text = markdownToSearchText(md);
assert.ok(text.includes("公开段落内容"));
assert.ok(text.includes("登录后可见的秘密"));
assert.ok(!text.includes("积分秘密内容"));
});
it("剥离代码围栏与行内 markdown 语法但保留代码与锚文本", () => {
const md = "见 [官网说明](https://x.com) 与 `const x = 1`,\n\n```ts\ncodeInside();\n```";
const text = markdownToSearchText(md);
assert.ok(text.includes("官网说明"));
assert.ok(!text.includes("https://x.com"));
assert.ok(text.includes("const x = 1"));
assert.ok(text.includes("codeInside();"));
assert.ok(!text.includes("```"));
});
});
describe("searchBook", () => {
const chapters: BookChapterIndex[] = [
{ no: 1, title: "第一章", plain: "今天天气很好,适合出门散步。路边有一朵花。" },
{ no: 2, title: "第二章", plain: "第二章没有关键词。" },
{ no: 3, title: "第三章", plain: "再次提到天气,天气真的不错。" },
];
it("按章聚合命中数与片段,大小写不敏感", () => {
const hits = searchBook(chapters, "天气");
assert.equal(hits.length, 2);
assert.deepEqual(
hits.map((h) => [h.no, h.count]),
[
[1, 1],
[3, 2],
]
);
assert.equal(hits[1].snippets[0].match, "天气");
assert.ok(hits[1].snippets[0].after.includes(",天气真的"));
});
it("片段最多生成 SNIPPET_LIMIT 个,但 count 为真实总数", () => {
const cs: BookChapterIndex[] = [{ no: 1, title: "t", plain: "天气 天气 天气 天气" }];
const hits = searchBook(cs, "天气");
assert.equal(hits[0].count, 4);
assert.equal(hits[0].snippets.length, 3);
assert.deepEqual(hits[0].snippets.map((s) => s.ordinal), [0, 1, 2]);
});
it("空关键词返回空", () => {
assert.deepEqual(searchBook(chapters, " "), []);
});
it("无命中返回空数组", () => {
assert.deepEqual(searchBook(chapters, "航天飞机"), []);
});
});

147
frontend/lib/bookSearch.ts Normal file
View File

@@ -0,0 +1,147 @@
/**
* 书库全书搜索:章节 markdown → 检索纯文本 + 关键词匹配/片段生成。
* 纯函数、无 DOM 依赖,供阅读页搜索面板(client)与单测共用。
*/
import { publicMarkdownForSeo } from "./hideBlocks.ts";
import { parseEntrySource } from "./entryBlocks.ts";
/** 传给客户端的章节索引:no 为书内章序号(1 基,与 ?c=/ ?sec= 一致) */
export interface BookChapterIndex {
no: number;
title: string;
/** markdown 经剥离后的可见纯文本 */
plain: string;
}
export interface BookSnippet {
/** 命中前缀文(约 SNIPPET_CONTEXT 字) */
before: string;
/** 命中原文(保留原始大小写) */
match: string;
/** 命中后缀文 */
after: string;
/** 该片段是本章内第几个命中(0 基,与 DOM 高亮顺序对应) */
ordinal: number;
}
export interface BookChapterHit {
no: number;
title: string;
/** 本章命中总数 */
count: number;
/** 前若干个命中的上下文片段 */
snippets: BookSnippet[];
}
export const SNIPPET_CONTEXT = 28;
export const SNIPPET_LIMIT = 3;
const TAGS_RE = /^@tags\s+(.+)$/i;
const SUMMARY_RE = /^@summary(?:\s+(.*))?$/i;
const SOURCE_RE = /^@source\s+(.+)$/i;
const FIELD_RE = /^([^:@\n]{1,40}?)\s*::\s*(.*)$/;
/** [entries] / [entries:off] / [timeline] 等块壳标记行(hide 已由 SEO 逻辑处理) */
const DIRECTIVE_SHELL_RE = /^\[\/?(?:entries(?::off)?|timeline)\]$/i;
/**
* 章节 markdown → 检索用纯文本。
* 目标:页面可见的文字基本都能被搜到,且不命中标记符号。
* - 锁定的 [hide] 块整体剔除,未锁块保留正文(复用 SEO 公开文本逻辑)
* - 条目卡指令保留「值」:@tags 留标签、字段::值 留「字段 值」、@summary/@source 留文本
* - 块壳标记、代码围栏、图片/链接语法、HTML 标签、强调符号一律剥离
*/
export function markdownToSearchText(md: string): string {
if (!md) return "";
// 统一换行;剔除锁块(未锁 hide 仅留正文,标记行已不在)
const lines = publicMarkdownForSeo(md).replace(/\r\n?/g, "\n").split("\n");
const kept: string[] = [];
let inCode = false;
for (const raw of lines) {
const t = raw.trim();
if (t.startsWith("```")) {
inCode = !inCode;
continue; // 围栏本身去掉,代码内容保留可搜
}
if (inCode) {
kept.push(raw);
continue;
}
if (!t) continue;
if (DIRECTIVE_SHELL_RE.test(t)) continue;
const tags = t.match(TAGS_RE);
if (tags) {
kept.push(tags[1].replace(/[||]/g, " "));
continue;
}
const summary = t.match(SUMMARY_RE);
if (summary) {
if (summary[1]) kept.push(summary[1]);
continue;
}
const source = t.match(SOURCE_RE);
if (source) {
// 仅保留来源文本,丢弃尾部 URL
kept.push(parseEntrySource(source[1]).text);
continue;
}
if (/^@plain$/i.test(t)) continue;
const field = t.match(FIELD_RE);
if (field) {
kept.push(`${field[1]} ${field[2]}`);
continue;
}
kept.push(raw);
}
return kept
.join("\n")
.replace(/<!--[\s\S]*?-->/g, " ") // HTML 注释(如 <!-- git: ... -->)
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") // 图片 → alt
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // 链接 → 锚文本
.replace(/<\/?[a-z][^>]*>/gi, " ") // 内联 HTML 标签
.replace(/^#{1,6}\s+/gm, "") // 标题井号
.replace(/^\s*>+\s?/gm, "") // 引用前缀
.replace(/^\s*[-+*]\s+/gm, "") // 无序列表点
.replace(/^\s*\d+[.)]\s+/gm, "") // 有序列表序号
.replace(/[`*_~]+/g, "") // 行内代码/强调/删除线
.replace(/\|/g, " ") // 表格竖线
.replace(/\s+/g, " ")
.trim();
}
/** 全书关键词匹配:大小写不敏感子串,命中按章聚合,仅前 SNIPPET_LIMIT 个命中生成片段 */
export function searchBook(chapters: BookChapterIndex[], query: string): BookChapterHit[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const hits: BookChapterHit[] = [];
for (const ch of chapters) {
const hay = ch.plain.toLowerCase();
const snippets: BookSnippet[] = [];
let count = 0;
let from = 0;
for (;;) {
const idx = hay.indexOf(q, from);
if (idx < 0) break;
if (snippets.length < SNIPPET_LIMIT) {
snippets.push({
ordinal: count,
before: ch.plain.slice(Math.max(0, idx - SNIPPET_CONTEXT), idx),
match: ch.plain.slice(idx, idx + q.length),
after: ch.plain.slice(idx + q.length, idx + q.length + SNIPPET_CONTEXT),
});
}
count++;
from = idx + q.length;
}
if (count > 0) hits.push({ no: ch.no, title: ch.title, count, snippets });
}
return hits;
}
/** 全书命中总数 */
export function countBookHits(chapters: BookChapterIndex[], query: string): number {
return searchBook(chapters, query).reduce((acc, h) => acc + h.count, 0);
}

View File

@@ -33,16 +33,18 @@ describe("questionJsonLd", () => {
createdAt: "2026-09-01T00:00:00Z",
author: { id: 3, name: "甲" },
postId: 9,
accepted: { id: 4, content: "用索引", created_at: "2026-09-02T00:00:00Z", author: { id: 5, name: "乙" } },
accepted: { id: 4, floor: 1, content: "用索引", created_at: "2026-09-02T00:00:00Z", author: { id: 5, name: "乙" } },
answers: [
{ id: 4, content: "用索引", created_at: "2026-09-02T00:00:00Z", author: { id: 5, name: "乙" } },
{ id: 6, content: "先 EXPLAIN", created_at: "2026-09-03T00:00:00Z" },
{ id: 4, floor: 1, content: "用索引", created_at: "2026-09-02T00:00:00Z", author: { id: 5, name: "乙" } },
{ id: 6, floor: 2, content: "先 EXPLAIN", created_at: "2026-09-03T00:00:00Z" },
],
});
assert.ok(data);
const q = data.mainEntity as { acceptedAnswer: { text: string }; suggestedAnswer: { text: string }[] };
const q = data.mainEntity as { acceptedAnswer: { text: string; url: string }; suggestedAnswer: { text: string; url: string }[] };
assert.equal(q.acceptedAnswer.text, "用索引");
assert.equal(q.acceptedAnswer.url, "https://bbs.example.com/post/9#comment-1");
assert.deepEqual(q.suggestedAnswer.map((a) => a.text), ["先 EXPLAIN"]);
assert.equal(q.suggestedAnswer[0].url, "https://bbs.example.com/post/9#comment-2");
assert.equal(data["@type"], "QAPage");
});
});
@@ -57,11 +59,13 @@ describe("discussionJsonLd", () => {
author: { id: 1, name: "丙" },
postId: 8,
pageUrl: "https://bbs.example.com/post/8?page=2",
comments: [{ id: 2, content: "可见楼层", created_at: "2026-09-01T01:00:00Z" }],
comments: [{ id: 2, floor: 21, content: "可见楼层", created_at: "2026-09-01T01:00:00Z" }],
});
assert.equal(data?.url, "https://bbs.example.com/post/8");
const page = data?.mainEntityOfPage as { url: string };
assert.equal(page.url, "https://bbs.example.com/post/8?page=2");
assert.equal((data?.comment as { text: string }[])[0].text, "可见楼层");
const firstComment = (data?.comment as { text: string; url: string }[])[0];
assert.equal(firstComment.text, "可见楼层");
assert.equal(firstComment.url, "https://bbs.example.com/post/8#comment-21");
});
});

View File

@@ -1,5 +1,5 @@
import { absoluteCanonical, resolveSiteOrigin, type QueryBag } from "./siteUrl.ts";
import { postPath, userPath, type UrlStyle } from "./urlStyle.ts";
import { postPath, userPath, commentAnchor, type UrlStyle } from "./urlStyle.ts";
/** 防止用户正文里的 </script> 提前闭合 JSON-LD 脚本 */
export function serializeJsonLd(data: unknown): string {
@@ -88,13 +88,17 @@ export function listingJsonLd(input: {
export type JsonLdVisibleComment = {
id: number;
/** 帖内相对楼层号;评论 URL 锚点使用它而非全局评论 ID */
floor: number;
/** 楼中楼回复需 -r{id} 精确锚定;楼层评论可缺省(默认 true) */
is_root?: boolean;
content: string;
created_at: string;
author?: JsonLdPerson;
};
function commentUrl(entityUrl: string, id: number): string {
return `${entityUrl}#comment-${id}`;
function commentUrl(entityUrl: string, c: JsonLdVisibleComment): string {
return `${entityUrl}${commentAnchor(c.floor, c.is_root ?? true, c.id)}`;
}
/** 问答帖:一问多答。只收录当前页可见楼层,不把折叠回复或未展示评论写进去。 */
@@ -124,7 +128,7 @@ export function questionJsonLd(input: {
"@type": "Answer" as const,
text: a.content,
dateCreated: a.created_at,
url: commentUrl(entityUrl, a.id),
url: commentUrl(entityUrl, a),
...(a.author ? { author: personNode(a.author, urlStyle) } : {}),
});
return {
@@ -208,7 +212,7 @@ export function discussionJsonLd(input: {
"@type": "Comment",
text: c.content,
dateCreated: c.created_at,
url: commentUrl(entityUrl, c.id),
url: commentUrl(entityUrl, c),
...(c.author ? { author: personNode(c.author, urlStyle) } : {}),
})),
}

View File

@@ -20,6 +20,7 @@ export const RT_PRESENCE_UPDATE = "presence:update";
export const RT_CHAT_MESSAGE = "chat:message";
export const RT_CHAT_MEMBERSHIP = "chat:membership";
export const RT_CHAT_RECALLED = "chat:message_recalled";
export const RT_CHAT_MEMBER_MUTED = "chat:member_muted";
export const RT_CHAT_UNREAD = "chat:unread";
export const RT_NOTIFICATION_NEW = "notification:new";
export const RT_FEED_CHANGED = "feed:changed";
@@ -125,6 +126,18 @@ export interface RtChatRecalledData {
};
}
// 成员被禁言/解禁
export interface RtChatMemberMutedData {
room_id: number;
user_id: number;
muted: boolean;
muted_until?: string | null;
duration_minutes?: number;
target_name?: string;
operator_id: number;
operator_name: string;
}
// 未读增量(推送给未在房内订阅的成员,用于 Header / 会话列表角标)
// delta 通常为 +1(新消息);撤回未读消息时为 -1,此时不含 preview,不得当新消息处理
export interface RtChatUnreadData {

View File

@@ -0,0 +1,52 @@
// 自适应图片:统一生成后端变体地址(GET /api/img?u=&w=)。
// 宽度白名单与后端 backend/service/image_variants.go 同值,约 1.5x 步进。
// 源内容不可变(随机哈希文件名),变体可被浏览器/CDN 长期 immutable 缓存。
/** 宽度白名单(48~1920) */
export const VARIANT_WIDTHS = [48, 96, 160, 256, 384, 512, 768, 1080, 1536, 1920] as const;
/**
* pickVariantWidth 选「≥ 需求」的最小白名单档(保证清晰度);
* 需求超过最大档时回落最大档(源图不够大时后端会原样透传,不会放大)。
*/
export function pickVariantWidth(cssPx: number, dpr = 1): number {
const need = Math.max(1, Math.round(cssPx * dpr));
for (const w of VARIANT_WIDTHS) {
if (w >= need) return w;
}
return VARIANT_WIDTHS[VARIANT_WIDTHS.length - 1];
}
/** variantURL 指定宽度的变体地址 */
export function variantURL(src: string, w: number): string {
return `/api/img?u=${encodeURIComponent(src)}&w=${w}`;
}
/** isOwnImage 是否本站图片(/uploads 本地文件或 /api/media 对象),只有本站图能走变体端点 */
export function isOwnImage(src: string): boolean {
return src.startsWith("/uploads/") || src.startsWith("/api/media/");
}
/**
* densitySrcSet 固定尺寸位的密度 srcset(如头像、固定广告位):
* 返回 "url 1x, url 2x";不同 DPR 选中同档时自动去重。
*/
export function densitySrcSet(src: string, cssPx: number, dprs: number[] = [1, 2]): string {
const seen = new Set<number>();
const parts: string[] = [];
for (const dpr of dprs) {
const w = pickVariantWidth(cssPx, dpr);
if (seen.has(w)) continue;
seen.add(w);
parts.push(`${variantURL(src, w)} ${dpr}x`);
}
return parts.join(", ");
}
/**
* widthSrcSet 可变宽度位的宽度 srcset(如封面、正文图),配合 sizes 使用。
* widths 请直接传白名单档位。
*/
export function widthSrcSet(src: string, widths: readonly number[]): string {
return widths.map((w) => `${variantURL(src, w)} ${w}w`).join(", ");
}

View File

@@ -44,6 +44,38 @@ export function isOwner(role?: string | null): boolean {
return role === ROLES.OWNER;
}
// 后台功能点权限码(与后端 service.Perm* 一致)。
// 渠道类权限可由超管/站长账号级授予任意账号;/me 下发的 perms 为
// 角色默认 ∪ 账号授予的生效集合,前端只做显隐判定,最终权限以后端为准。
export const PERMS = {
ANNOUNCEMENTS: "announcements",
PAGES: "pages",
LIBRARY: "library",
ADS: "ads",
MESSAGES: "messages",
} as const;
export type PermKey = (typeof PERMS)[keyof typeof PERMS];
/** 可账号级授予的渠道权限(与后端 GrantablePerms 一致) */
export const CHANNEL_PERMS = [
PERMS.ANNOUNCEMENTS,
PERMS.PAGES,
PERMS.LIBRARY,
PERMS.ADS,
] as const;
/** 渠道权限展示元信息(角色与账号弹窗/后台导航共用) */
export const PERM_META: Record<
(typeof CHANNEL_PERMS)[number],
{ label: string; desc: string }
> = {
[PERMS.ANNOUNCEMENTS]: { label: "公告管理", desc: "发布、编辑、置顶与删除站点公告" },
[PERMS.PAGES]: { label: "单页管理", desc: "创建与维护站点单页(/p/:slug)" },
[PERMS.LIBRARY]: { label: "书库管理", desc: "管理书库条目、章节与文件" },
[PERMS.ADS]: { label: "广告与赞助", desc: "管理广告位与赞助配置、审批投放" },
};
/** 站长/超管:群聊监管旁路(可见全部群含私聊、撤回任意消息) */
export function canOverseeChat(role?: string | null): boolean {
return role === ROLES.OWNER || role === ROLES.SUPER_ADMIN;
@@ -59,10 +91,23 @@ export interface RoleUserLike {
role?: string | null;
board_ids?: number[] | null;
can_manage_messages?: boolean | null;
/** 生效权限码(/me 下发;角色默认 ∪ 账号授予) */
perms?: string[] | null;
/** 是否至少管理一个群(/me 可带;后台入口用) */
chat_admin_room_count?: number | null;
}
/** 是否拥有某后台功能点(依据 /me 下发的生效权限码;最终以后端校验为准) */
export function hasPerm(user: RoleUserLike | null | undefined, perm: string): boolean {
if (!user) return false;
return (user.perms ?? []).includes(perm);
}
/** 是否拥有任一渠道权限(后台准入判定用) */
export function hasAnyChannelPerm(user: RoleUserLike | null | undefined): boolean {
return CHANNEL_PERMS.some((p) => hasPerm(user, p));
}
/**
* 能否管理指定板块的内容(帖子/评论的编辑删除审核):
* 管理员及以上不限板块;板块管理员仅限被授权板块

View File

@@ -6,7 +6,6 @@ import {
canonicalPath,
homeCanonicalQuery,
isTrackingParam,
isWideShellPath,
parseSiteOrigin,
resolveSiteOrigin,
} from "./siteUrl.ts";
@@ -117,23 +116,3 @@ describe("homeCanonicalQuery", () => {
});
});
describe("isWideShellPath(书库页放宽档位)", () => {
it("覆盖三种伪静态风格的书库路径", () => {
assert.equal(isWideShellPath("/library"), true);
assert.equal(isWideShellPath("/library/abc"), true);
assert.equal(isWideShellPath("/library/abc/read"), true);
assert.equal(isWideShellPath("/library.html"), true);
assert.equal(isWideShellPath("/library-abc.html"), true);
assert.equal(isWideShellPath("/library-abc.html?sec=2"), true);
assert.equal(isWideShellPath("/library-abc-read-2.html"), true);
});
it("非书库路径不放宽", () => {
assert.equal(isWideShellPath("/"), false);
assert.equal(isWideShellPath("/post-1.html"), false);
assert.equal(isWideShellPath("/p-about.html"), false);
assert.equal(isWideShellPath("/u/5"), false);
assert.equal(isWideShellPath("/admin/library"), false);
assert.equal(isWideShellPath("/my-library.html"), false);
assert.equal(isWideShellPath(null), false);
});
});

View File

@@ -115,21 +115,6 @@ export function canonicalBoard(raw: string | undefined): number | undefined {
return n;
}
/** 书库页路径:命中时公开站三件套(Header/main/Footer)放宽到 1680px,
* 读页三栏与条目卡等内容更宽裕;其余页面保持 1440px。
* 需覆盖三种伪静态风格:/library[/...](default)、/library.html 与 /library-{slug}.html(html/discuz,
* 新章节地址 = 详情路径 + ?sec=,pathname 与详情一致) */
export function isWideShellPath(pathname: string | null | undefined): boolean {
if (!pathname) return false;
const p = pathname.split("?")[0];
return (
p === "/library" ||
p.startsWith("/library/") ||
p === "/library.html" ||
/^\/library-.*\.html$/.test(p)
);
}
export function defaultFeedSort(allowComments: boolean): FeedSort {
return allowComments ? "latest" : "new";
}

View File

@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
announcementPath,
commentAnchor,
entityPath,
leaderboardPath,
libraryDocPath,
@@ -88,6 +89,15 @@ describe("路径构造器", () => {
assert.equal(entityPath("default", "user", 9), "/u/9");
assert.equal(entityPath("html", "announcement", 9), "/announcement-9.html");
});
it("评论锚点使用帖内相对楼层号", () => {
assert.equal(commentAnchor(1, true, 147), "#comment-1");
assert.equal(commentAnchor(3, false, 147), "#comment-3-r147");
// 楼中楼缺评论 id 时退化为楼层锚点
assert.equal(commentAnchor(3, false), "#comment-3");
// 楼层无效(评论已不可见)返回空串,由调用方回落纯帖子路径
assert.equal(commentAnchor(0, true, 147), "");
assert.equal(commentAnchor(Number.NaN, false, 147), "");
});
});
describe("matchPrettyPath", () => {

View File

@@ -76,6 +76,16 @@ export function postPath(style: UrlStyle, id: number): string {
return style === "default" ? STANDARD_TEMPLATES.post(n) : PRETTY_TEMPLATES[style].post(n);
}
/**
* 评论锚点:使用评论在所属帖内的相对楼层号,而非全站评论 ID。
* 主楼 → #comment-{floor};楼中楼 → #comment-{floor}-r{commentId}(楼层负责定位/分页/展开,
* r 段精确定位具体回复)。楼层号无效时返回空串,调用方应回退为不带锚点的帖子路径。
*/
export function commentAnchor(floor: number, isRoot: boolean, commentId?: number): string {
if (!(Number.isFinite(floor) && floor > 0)) return "";
return isRoot || !commentId ? `#comment-${floor}` : `#comment-${floor}-r${commentId}`;
}
/** 用户主页路径;?tab= 等查询参数由调用方拼接 */
export function userPath(style: UrlStyle, id: number): string {
const n = guardId(id);

View File

@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { mergeWidgetCode, parseMixedWidgetCode, scopeWidgetCss } from "./widgetCode.ts";
import { mergeWidgetCode, parseMixedWidgetCode } from "./widgetCode.ts";
describe("parseMixedWidgetCode", () => {
it("拆分混写的 style/script 段", () => {
@@ -45,58 +45,3 @@ describe("mergeWidgetCode", () => {
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: "{" }`
);
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1,218 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { lintWidgetCode, type WidgetLintIssue } from "./widgetLint.ts";
const codes = (issues: WidgetLintIssue[]) => issues.map((i) => i.code);
describe("lintWidgetCode - JS 全局查找", () => {
it("document 上的六种查找 API 全部报 error", () => {
const js = [
'document.getElementById("a");',
'document.querySelector(".a");',
'document.querySelectorAll(".a");',
'document.getElementsByClassName("a");',
'document.getElementsByTagName("div");',
'document.getElementsByName("a");',
].join("\n");
const issues = lintWidgetCode({ js });
assert.equal(issues.length, 6);
assert.ok(issues.every((i) => i.level === "error" && i.code === "no-global-dom-lookup"));
assert.deepEqual(
issues.map((i) => i.line),
[1, 2, 3, 4, 5, 6]
);
});
it("window.document 与 ownerDocument 上的同款查找同样命中", () => {
const issues = lintWidgetCode({
js: 'window.document.querySelector(".x");\nroot.ownerDocument.getElementById("y");',
});
assert.equal(issues.length, 2);
assert.ok(issues.every((i) => i.code === "no-global-dom-lookup"));
});
it("从 root 出发的查找不报错", () => {
const issues = lintWidgetCode({
js: [
'root.querySelector(".a");',
'root.querySelectorAll(".a");',
'root.getElementById("a");',
'el.getElementsByClassName("a");',
].join("\n"),
});
assert.equal(issues.length, 0);
});
it("注释、字符串、正则字面量中的关键词不误报", () => {
const js = `
// document.getElementById("x")
/* document.querySelector(".y") */
const s1 = "document.getElementById('z')";
const s2 = 'document.querySelector(".z")';
const re = /document\\.getElementById/;
const tpl = \`document.getElementsByName("t")\`;
root.querySelector(".real");
`;
const issues = lintWidgetCode({ js });
assert.deepEqual(codes(issues), []);
});
it("行列号按原文定位", () => {
const js = "\n\n document.getElementById('x');";
const [issue] = lintWidgetCode({ js });
assert.equal(issue.line, 3);
assert.equal(issue.column, 3);
});
});
describe("lintWidgetCode - setInterval", () => {
it("只有 setInterval、全文无 clearInterval 时报 warn", () => {
const issues = lintWidgetCode({
js: "const t = setInterval(fn, 100);",
});
assert.equal(issues.length, 1);
assert.equal(issues[0].level, "warn");
assert.equal(issues[0].code, "warn-set-interval");
assert.equal(issues[0].line, 1);
});
it("setInterval 与 clearInterval 配对出现时不报", () => {
assert.deepEqual(
lintWidgetCode({ js: "const t = setInterval(fn, 100);\nclearInterval(t);" }),
[]
);
});
it("safeInterval 封装(cleanup 内清理)不报", () => {
const js = [
"function safeInterval(fn, ms) {",
" var id = setInterval(fn, ms);",
" registerCleanup(function () { clearInterval(id); });",
" return id;",
"}",
"safeInterval(render, 1000);",
].join("\n");
assert.deepEqual(lintWidgetCode({ js }), []);
});
it("clearInterval 只写在注释或字符串里不算配对,仍报 warn", () => {
const issues = lintWidgetCode({
js: 'const t = setInterval(f, 100);\n// clearInterval(t);\nconst s = "clearInterval(t)";',
});
assert.equal(issues.length, 1);
assert.equal(issues[0].code, "warn-set-interval");
assert.equal(issues[0].line, 1);
});
it("window.setInterval 同样命中;注释里的 setInterval 不报", () => {
const issues = lintWidgetCode({
js: "window.setInterval(f, 50);\n// setInterval(f, 50);",
});
assert.equal(issues.length, 1);
assert.equal(issues[0].code, "warn-set-interval");
});
it("多个无清理的 setInterval 逐处提醒", () => {
const issues = lintWidgetCode({
js: "setInterval(a, 10);\nsetInterval(b, 20);",
});
assert.deepEqual(
issues.map((i) => i.line),
[1, 2]
);
});
});
describe("lintWidgetCode - CSS position:fixed", () => {
it("position: fixed 报 error,兼容空格与 !important", () => {
const css = ".a { position: fixed; }\n.b { position:fixed !important; }\n.c { position: absolute; }";
const issues = lintWidgetCode({ css });
assert.equal(issues.length, 2);
assert.ok(issues.every((i) => i.code === "no-fixed-position" && i.level === "error"));
assert.deepEqual(
issues.map((i) => i.line),
[1, 2]
);
});
it("@media 内层规则同样检查", () => {
const issues = lintWidgetCode({ css: "@media (min-width: 100px) { .m { position: fixed; } }" });
assert.equal(issues.length, 1);
assert.equal(issues[0].code, "no-fixed-position");
});
it("注释与 content 字符串不造成误报", () => {
const css = `/* position: fixed */\n.a::after { content: "position: fixed"; color: red; }`;
assert.deepEqual(codes(lintWidgetCode({ css })), []);
});
});
describe("lintWidgetCode - CSS 全局选择器", () => {
it("body / html / * 开头的规则报 error", () => {
const issues = lintWidgetCode({
css: "body { margin: 0; }\nhtml.dark .a { color: #000; }\n* { box-sizing: border-box; }",
});
assert.equal(issues.length, 3);
assert.ok(issues.every((i) => i.code === "no-global-selector" && i.level === "error"));
});
it("选择器列表逐个判定,.body 类名与后代通配不误伤", () => {
const issues = lintWidgetCode({
css: ".body, body .card, .a *, html, *.x {}",
});
// 命中:body .card、html、*.x;.body 与 .a * 不命中
assert.equal(issues.length, 3);
});
it("@media 内层选择器同样检查", () => {
const issues = lintWidgetCode({ css: "@media (min-width: 1px) { body { color: red; } }" });
assert.equal(issues.length, 1);
assert.equal(issues[0].code, "no-global-selector");
});
});
describe("lintWidgetCode - transform: scale", () => {
it("transform 含 scale() 报 warn,支持厂商前缀与嵌套", () => {
const issues = lintWidgetCode({
css: ".a { transform: scale(1.2); }\n@media (min-width: 1px) { .b { -webkit-transform: scale(0.8); } }\n.c { transform: translateX(2px); }",
});
assert.equal(issues.length, 2);
assert.ok(issues.every((i) => i.code === "warn-scale-transform" && i.level === "warn"));
});
it("@keyframes 里的 scale 不报", () => {
const css = "@keyframes spin { from { transform: scale(0.9); } to { transform: scale(1); } }";
assert.deepEqual(codes(lintWidgetCode({ css })), []);
});
});
describe("lintWidgetCode - 综合", () => {
it("空输入与干净代码返回空列表", () => {
assert.deepEqual(lintWidgetCode({}), []);
assert.deepEqual(
lintWidgetCode({
html: '<div class="box"></div>',
css: ".box { color: red; transform: rotate(45deg); }",
js: 'root.querySelector(".box").textContent = "hi";',
}),
[]
);
});
it("css 问题排在 js 问题之前,error 与 warn 各自分级", () => {
const issues = lintWidgetCode({
css: "body { position: fixed; }\n.a { transform: scale(2); }",
js: "setInterval(f, 10);",
});
assert.deepEqual(codes(issues), [
"no-global-selector",
"no-fixed-position",
"warn-scale-transform",
"warn-set-interval",
]);
assert.deepEqual(
issues.map((i) => i.level),
["error", "error", "warn", "warn"]
);
});
});

378
frontend/lib/widgetLint.ts Normal file
View File

@@ -0,0 +1,378 @@
// 自定义侧栏工具保存前静态扫描:纯字符串启发式检查,不依赖 AST / 第三方库。
// 扫描时先做「等长掩码」——注释、字符串、正则字面量被替换为空格(换行保留),
// 因此写在注释/字符串里的关键词不会误报,行列号仍按原文计算。
//
// error(命中禁止保存):
// JS document / ownerDocument 上的全局元素查找 API
// CSS position: fixed
// CSS 以 body / html / * 开头的全局选择器
// warn(提醒确认后可保存):
// JS setInterval((仅当整份代码没有任何 clearInterval 调用时才报;
// 出现 clearInterval 即视为作者已自行管理定时器生命周期,避免对规范写法的误导性提醒)
// CSS transform ... scale(
//
// 已知取舍:HTML 内联事件属性(onclick="" 等)本轮不扫描;模板字符串 ${} 内的代码不扫描。
export type WidgetLintField = "js" | "css" | "html";
export type WidgetLintLevel = "error" | "warn";
export interface WidgetLintIssue {
level: WidgetLintLevel;
field: WidgetLintField;
/** 机器可读规则码 */
code: string;
/** 1-based 行号(按原始文本) */
line: number;
/** 1-based 列号(按原始文本) */
column: number;
message: string;
}
export interface WidgetLintInput {
html?: string;
css?: string;
js?: string;
}
/* ─────────────────────────── 通用工具 ─────────────────────────── */
/** 把 [0, n) 内的字符替换为空格,换行保留(保证掩码与原文等长、行列号一致) */
function blankRange(mask: string[], from: number, to: number, src: string): void {
for (let k = from; k < to && k < mask.length; k++) {
mask[k] = src[k] === "\n" ? "\n" : " ";
}
}
interface LineMap {
lineStarts: number[];
}
function buildLineMap(src: string): LineMap {
const lineStarts = [0];
for (let i = 0; i < src.length; i++) {
if (src[i] === "\n") lineStarts.push(i + 1);
}
return { lineStarts };
}
function locate(map: LineMap, index: number): { line: number; column: number } {
const { lineStarts } = map;
// 二分:最后一个 <= index 的行首
let lo = 0;
let hi = lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (lineStarts[mid] <= index) lo = mid;
else hi = mid - 1;
}
return { line: lo + 1, column: index - lineStarts[lo] + 1 };
}
/* ─────────────────────────── JS 掩码 ─────────────────────────── */
const REGEX_PREV_PUNCT = new Set(["(", ",", "=", ":", "[", "!", "&", "|", "?", "{", ";", "+", "-", "*", "%", "^", "~", "<", ">"]);
const REGEX_PREV_KEYWORDS = new Set([
"return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "case", "do", "else", "yield", "await",
]);
// 判断 slashIndex 处的 / 更可能是正则起始还是除号
function regexAllowedBefore(src: string, slashIndex: number): boolean {
let j = slashIndex - 1;
while (j >= 0 && src[j] !== "\n" && /\s/.test(src[j])) j--;
if (j < 0 || src[j] === "\n") return true;
if (REGEX_PREV_PUNCT.has(src[j])) return true;
let k = j;
while (k >= 0 && /[\w$]/.test(src[k])) k--;
const word = src.slice(k + 1, j + 1);
return REGEX_PREV_KEYWORDS.has(word);
}
/** 等长掩码 JS:注释、字符串、正则字面量 → 空格(换行保留) */
export function maskJavaScript(src: string): string {
const mask = src.split("");
const n = src.length;
let i = 0;
while (i < n) {
const ch = src[i];
const next = src[i + 1];
if (ch === "/" && next === "/") {
let j = i + 2;
while (j < n && src[j] !== "\n") j++;
blankRange(mask, i, j, src);
i = j;
continue;
}
if (ch === "/" && next === "*") {
const close = src.indexOf("*/", i + 2);
const stop = close === -1 ? n : close + 2;
blankRange(mask, i, stop, src);
i = stop;
continue;
}
if (ch === '"' || ch === "'" || ch === "`") {
const quote = ch;
let j = i + 1;
while (j < n) {
if (src[j] === "\\") j += 2;
else if (src[j] === quote) { j++; break; }
else j++;
}
// 模板字符串 ${} 内的表达式不解析(接受该处的漏报)
blankRange(mask, i, Math.min(j, n), src);
i = j;
continue;
}
if (ch === "/" && regexAllowedBefore(src, i)) {
let j = i + 1;
let inClass = false;
while (j < n) {
const c = src[j];
if (c === "\\") j += 2;
else if (c === "\n") break; // 正则不能跨行,容错按除法放弃掩码
else if (c === "[") inClass = true;
else if (c === "]") inClass = false;
else if (c === "/" && !inClass) { j++; break; }
else j++;
}
while (j < n && /[\w$]/.test(src[j])) j++; // flags
blankRange(mask, i, j, src);
i = j;
continue;
}
i++;
}
return mask.join("");
}
/* ─────────────────────────── CSS 掩码与解析 ─────────────────────────── */
/** 等长掩码 CSS:块注释与字符串 → 空格(换行保留) */
export function maskCss(src: string): string {
const mask = src.split("");
const n = src.length;
let i = 0;
while (i < n) {
if (src[i] === "/" && src[i + 1] === "*") {
const close = src.indexOf("*/", i + 2);
const stop = close === -1 ? n : close + 2;
blankRange(mask, i, stop, src);
i = stop;
continue;
}
if (src[i] === '"' || src[i] === "'") {
const quote = src[i];
let j = i + 1;
while (j < n) {
if (src[j] === "\\") j += 2;
else if (src[j] === quote) { j++; break; }
else j++;
}
blankRange(mask, i, Math.min(j, n), src);
i = j;
continue;
}
i++;
}
return mask.join("");
}
/** 从 openBrace 后一位起找匹配的 }(掩码文本,引号/注释已是空格) */
function matchBrace(src: string, from: number, to: number): number {
let depth = 1;
for (let i = from; i < to; i++) {
if (src[i] === "{") depth++;
else if (src[i] === "}") {
depth--;
if (depth === 0) return i;
}
}
return to;
}
/** 选择器按顶层逗号切分,保留相对偏移;忽略 () / [] 内的逗号 */
function splitSelectors(head: string): { sel: string; offset: number }[] {
const out: { sel: string; offset: number }[] = [];
let depth = 0;
let start = 0;
for (let i = 0; i < head.length; i++) {
const c = head[i];
if (c === "(" || c === "[") depth++;
else if (c === ")" || c === "]") depth = Math.max(0, depth - 1);
else if (c === "," && depth === 0) {
out.push({ sel: head.slice(start, i), offset: start });
start = i + 1;
}
}
out.push({ sel: head.slice(start), offset: start });
return out;
}
const NESTING_AT = new Set(["@media", "@supports", "@container", "@layer"]);
/* ─────────────────────────── 规则实现 ─────────────────────────── */
const LOOKUP_METHODS = "getElementById|querySelectorAll|querySelector|getElementsByClassName|getElementsByTagName|getElementsByName";
const GLOBAL_LOOKUP_RE = new RegExp(`\\bdocument\\s*\\.\\s*(?:${LOOKUP_METHODS})\\s*\\(`, "g");
const OWNER_LOOKUP_RE = new RegExp(`\\.ownerDocument\\s*\\.\\s*(?:${LOOKUP_METHODS})\\s*\\(`, "g");
const SET_INTERVAL_RE = /\bsetInterval\s*\(/g;
const CLEAR_INTERVAL_RE = /\bclearInterval\s*\(/;
function lintJs(js: string, issues: WidgetLintIssue[]): void {
const map = buildLineMap(js);
const masked = maskJavaScript(js);
const pushAt = (
level: WidgetLintLevel,
code: string,
index: number,
message: string
) => {
const { line, column } = locate(map, index);
issues.push({ level, field: "js", code, line, column, message });
};
for (const re of [GLOBAL_LOOKUP_RE, OWNER_LOOKUP_RE]) {
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(masked)) !== null) {
pushAt(
"error",
"no-global-dom-lookup",
m.index,
"禁止从 document/ownerDocument 全局查找元素:同页多个同款工具会互相取错元素。请改用 root.querySelector / root.getElementById 等从 root 出发的查找。"
);
}
}
// 代码里已出现 clearInterval 调用(含 safeInterval 等封装内部、自终止定时器),
// 视为作者已处理清理,不再提醒;完全没有清理痕迹时才逐处 warn。
if (!CLEAR_INTERVAL_RE.test(masked)) {
SET_INTERVAL_RE.lastIndex = 0;
let im: RegExpExecArray | null;
while ((im = SET_INTERVAL_RE.exec(masked)) !== null) {
pushAt(
"warn",
"warn-set-interval",
im.index,
"setInterval 未发现配对的 clearInterval:请保存返回的 id,并在 registerCleanup 中清理,否则离开页面后定时器仍在执行。"
);
}
}
}
function lintCss(css: string, issues: WidgetLintIssue[]): void {
const map = buildLineMap(css);
const src = maskCss(css);
const n = src.length;
const pushAt = (
level: WidgetLintLevel,
code: string,
index: number,
message: string
) => {
const { line, column } = locate(map, index);
issues.push({ level, field: "css", code, line, column, message });
};
const checkSelectors = (head: string, headStart: number) => {
for (const part of splitSelectors(head)) {
const sel = part.sel.trim();
if (!sel) continue;
const abs = headStart + part.offset + (part.sel.length - part.sel.trimStart().length);
if (sel[0] === "*") {
pushAt(
"error",
"no-global-selector",
abs,
"禁止以 * 开头的全局选择器。Shadow DOM 内样式天然隔离,请直接写具体类名。"
);
continue;
}
const gm = sel.match(/^(html|body)\b/i);
if (gm) {
pushAt(
"error",
"no-global-selector",
abs,
`禁止以 ${gm[1].toLowerCase()} 开头的全局选择器:在 Shadow DOM 内它匹配不到任何元素。请改用 .tool-root(CSS 变量也定义在 .tool-root 上)。`
);
}
}
};
const checkDeclarations = (body: string, bodyStart: number) => {
let cursor = 0;
for (const decl of body.split(";")) {
const rel = cursor;
cursor += decl.length + 1;
const colon = decl.indexOf(":");
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim().toLowerCase();
const val = decl.slice(colon + 1).trim();
if (!prop) continue;
const propAbs = bodyStart + rel + decl.indexOf(prop);
if (prop === "position" && /^fixed\b/i.test(val)) {
pushAt(
"error",
"no-fixed-position",
propAbs,
"禁止 position: fixed:会脱离工具容器、盖住全站。请改用 absolute(相对 .tool-root 定位)。"
);
}
if ((prop === "transform" || prop.endsWith("-transform")) && /\bscale\s*\(/i.test(val)) {
pushAt(
"warn",
"warn-scale-transform",
propAbs,
"transform: scale() 可能溢出容器引出版块滚动条;.tool-root 已设 overflow:hidden 裁切,请确认动画不依赖溢出区域可见。"
);
}
}
};
// 递归下降解析规则块;@keyframes 内部整块跳过(动画里的 transform:scale 是正常用法)
const parse = (from: number, to: number) => {
let i = from;
while (i < to) {
const headStart = i;
let j = i;
while (j < to && src[j] !== "{" && src[j] !== "}" && src[j] !== ";") j++;
const head = src.slice(i, j);
if (j >= to) break;
if (src[j] === ";") {
i = j + 1;
continue;
}
if (src[j] === "}") {
i = j + 1; // 容错:多余闭括号
continue;
}
const close = matchBrace(src, j + 1, to);
const headTrim = head.trim();
if (headTrim.startsWith("@")) {
const at = headTrim.split(/[\s(]/)[0].toLowerCase();
if (NESTING_AT.has(at)) parse(j + 1, close);
// @keyframes / @font-face / 其他 at 块:整体跳过
} else {
checkSelectors(head, headStart);
checkDeclarations(src.slice(j + 1, close), j + 1);
}
i = close + 1;
}
};
parse(0, n);
}
/**
* 保存前静态扫描。返回问题列表(按字段、源码位置排序);
* 有 error 时调用方应阻止保存,仅有 warn 时需用户二次确认。
*/
export function lintWidgetCode(input: WidgetLintInput): WidgetLintIssue[] {
const issues: WidgetLintIssue[] = [];
// html 本轮不产出规则;css 问题排在 js 之前
if (input.css) lintCss(input.css, issues);
if (input.js) lintJs(input.js, issues);
return issues;
}

View File

@@ -0,0 +1,179 @@
// 自定义侧栏工具的 Shadow DOM 运行时(仅浏览器端,由 CustomWidgetBlock 的 effect 调用):
// - 每个工具实例独占一个 shadow root:用户 CSS 注入 shadow 内,天然不外泄;
// - 用户 HTML 挂在 .tool-root 下,用户 JS 以函数形式手动执行,
// 通过参数拿到 (root, shadow, toolId, registerCleanup),DOM 查找一律从 root 出发;
// - mount / unmount 幂等:同一 host 复用 shadow root、先清空再注入,
// 兼容 React StrictMode 双调用、依赖变更重挂与同页多个同款工具实例。
export interface WidgetSource {
id: number;
html: string;
css: string;
js: string;
}
/** 用户脚本通过 registerCleanup 注册的卸载回调(停定时器、移除监听等) */
export type WidgetCleanup = () => void;
/** 传给用户脚本的运行时上下文 */
export interface WidgetRuntimeContext {
/** 工具根节点:所有 querySelector / getElementById 都应从这里出发 */
root: HTMLElement;
shadow: ShadowRoot;
toolId: number;
registerCleanup: (fn: WidgetCleanup) => void;
}
export interface WidgetHandle {
unmount: () => void;
}
// shadow 边界内没有 Tailwind preflight,这里补一份最小 reset:
// 只做「盒模型一致 + 外边距归零 + 字体颜色继承」,视觉仍完全交给用户 CSS。
// CSS 变量(var(--accent) 等)沿 DOM 继承,可自动穿过 shadow 边界,无需在此声明。
const BASE_CSS = `
:host {
display: block;
min-width: 0;
}
.tool-root {
overflow: hidden;
min-width: 0;
}
.tool-root,
.tool-root *,
.tool-root *::before,
.tool-root *::after {
box-sizing: border-box;
}
.tool-root blockquote,
.tool-root dl,
.tool-root dd,
.tool-root h1,
.tool-root h2,
.tool-root h3,
.tool-root h4,
.tool-root h5,
.tool-root h6,
.tool-root hr,
.tool-root figure,
.tool-root p,
.tool-root pre {
margin: 0;
}
.tool-root button,
.tool-root input,
.tool-root select,
.tool-root textarea {
font: inherit;
letter-spacing: inherit;
color: inherit;
}
.tool-root button {
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
}
.tool-root img,
.tool-root svg,
.tool-root video,
.tool-root canvas {
display: block;
max-width: 100%;
}
`.trim();
function reportError(toolId: number, phase: string, err: unknown): void {
// 用户代码出错不静默:统一带工具 id 前缀,便于在控制台定位
console.error(`[自定义工具#${toolId}] ${phase}失败:`, err);
}
/**
* 把工具挂载到 host:attachShadow → 注入基础/用户样式 → 放 .tool-root → 执行用户 JS。
* 返回的 unmount 幂等,重复调用只有第一次生效。
*/
export function mountWidget(host: HTMLElement, source: WidgetSource): WidgetHandle {
const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" });
// 幂等重挂(StrictMode / 依赖变更):清掉上一次的全部内容后重建
shadow.replaceChildren();
const baseStyle = document.createElement("style");
baseStyle.dataset.widgetBase = "";
baseStyle.textContent = BASE_CSS;
shadow.appendChild(baseStyle);
if (source.css.trim()) {
const userStyle = document.createElement("style");
userStyle.dataset.widgetStyle = String(source.id);
// shadow 内天然隔离,用户 CSS 原样注入、不再加选择器前缀
userStyle.textContent = source.css;
shadow.appendChild(userStyle);
}
const root = document.createElement("div");
root.className = "tool-root";
root.dataset.tool = String(source.id);
if (source.html.trim()) root.innerHTML = source.html;
shadow.appendChild(root);
const cleanups: WidgetCleanup[] = [];
let disposed = false;
const context: WidgetRuntimeContext = {
root,
shadow,
toolId: source.id,
registerCleanup(fn) {
if (disposed) {
// 卸载后注册没有意义,直接丢弃并提示,避免用户误以为还会执行
try {
fn();
} catch (err) {
reportError(source.id, "卸载后注册的 cleanup 执行", err);
}
return;
}
if (typeof fn === "function") cleanups.push(fn);
},
};
if (source.js.trim()) {
try {
// 站长可信内容:函数构造器手动执行,参数即工具运行时上下文
const fn = new Function(
"root",
"shadow",
"toolId",
"registerCleanup",
`"use strict";\n${source.js}`
) as (
root: HTMLElement,
shadow: ShadowRoot,
toolId: number,
registerCleanup: WidgetRuntimeContext["registerCleanup"]
) => unknown;
fn(context.root, context.shadow, context.toolId, context.registerCleanup);
} catch (err) {
reportError(source.id, "脚本执行", err);
}
}
return {
unmount() {
if (disposed) return;
disposed = true;
// 后注册的先执行,符合多数资源(监听/定时器)的释放直觉
for (let i = cleanups.length - 1; i >= 0; i--) {
try {
cleanups[i]();
} catch (err) {
reportError(source.id, "cleanup 执行", err);
}
}
cleanups.length = 0;
shadow.replaceChildren();
},
};
}