feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
// 客户端:站长任命/撤销群管理员
|
||||
|
||||
92
frontend/lib/bookSearch.test.ts
Normal file
92
frontend/lib/bookSearch.test.ts
Normal 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
147
frontend/lib/bookSearch.ts
Normal 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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) } : {}),
|
||||
})),
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
52
frontend/lib/responsiveImage.ts
Normal file
52
frontend/lib/responsiveImage.ts
Normal 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(", ");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 能否管理指定板块的内容(帖子/评论的编辑删除审核):
|
||||
* 管理员及以上不限板块;板块管理员仅限被授权板块
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: "{" }`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
218
frontend/lib/widgetLint.test.ts
Normal file
218
frontend/lib/widgetLint.test.ts
Normal 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
378
frontend/lib/widgetLint.ts
Normal 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;
|
||||
}
|
||||
179
frontend/lib/widgetRuntime.ts
Normal file
179
frontend/lib/widgetRuntime.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user