update
This commit is contained in:
42
shared/ai-errors.js
Normal file
42
shared/ai-errors.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// AI 错误码 —— 主进程与 renderer 端共享的字符串字面值。
|
||||
//
|
||||
// audit fix (CQ-MED-7):之前错误码字面量分散在 main/ai.js#ERR_* 与
|
||||
// src/ai/ai-status.js#AI_ERROR 两处,靠注释提醒同步 —— 实际曾漂移(renderer 旧版
|
||||
// 把 'AI_TIMEOUT' 写成 'ERR_TIMEOUT',导致 ai-controller 收到 main 返回的错误码
|
||||
// 后 aiErrorMessage switch 永不命中、永远走 fallback 'AI 修改失败')。
|
||||
// 现在所有跨进程错误码集中在本文件,main / preload / renderer 三处共用同一份字面值,
|
||||
// 任何一边改了另一边不会失配。
|
||||
//
|
||||
// 加载:
|
||||
// - main/ai.js: const { AI_ERROR } = require('../shared/ai-errors.js');
|
||||
// - preload.js: contextBridge.exposeInMainWorld('api', { aiErrors: { AI_ERROR } })
|
||||
// - renderer: const { AI_ERROR } = window.api.aiErrors;
|
||||
// (fallback 见 src/ai/ai-status.js,jsdom 单测环境走本地副本)
|
||||
//
|
||||
// 命名规则:AI_ERROR.* 的 value 是字符串字面值;调用方比较时用 AI_ERROR.FOO 引用,
|
||||
// 永远不要直接引用字符串字面量。
|
||||
|
||||
'use strict';
|
||||
|
||||
const AI_ERROR = Object.freeze({
|
||||
// ---- 跨进程(main ↔ renderer)----
|
||||
// 主进程 ai.js 抛出 + renderer 端 controller 检测本地未配置时同样抛出。
|
||||
NOT_CONFIGURED: 'AI_NOT_CONFIGURED',
|
||||
// fetch 超时(AbortError + timeout 信号)
|
||||
ERR_TIMEOUT: 'AI_TIMEOUT',
|
||||
// HTTP 4xx/5xx 或非 2xx 响应
|
||||
ERR_PROVIDER: 'AI_PROVIDER_ERROR',
|
||||
// 响应体 JSON 解析失败 / 不符合 OpenAI/Anthropic 协议
|
||||
ERR_FORMAT: 'AI_BAD_RESPONSE',
|
||||
// 用户主动取消(renderer abort signal / controller.cancelled 标志)
|
||||
ERR_CANCELLED: 'AI_CANCELLED',
|
||||
|
||||
// ---- renderer 端辅助判定(main 不会产生这些值,但放一起便于集中引用)----
|
||||
NO_FILE: 'NO_FILE',
|
||||
EDITOR_UNAVAILABLE: 'EDITOR_UNAVAILABLE',
|
||||
IPC_FAILED: 'IPC_FAILED',
|
||||
GENERATION_STALE: 'GENERATION_STALE',
|
||||
FILE_CHANGED: 'FILE_CHANGED',
|
||||
});
|
||||
|
||||
module.exports = { AI_ERROR };
|
||||
111
shared/extension-lists.js
Normal file
111
shared/extension-lists.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// 文件扩展名白名单(main / preload / renderer 三处共用)
|
||||
//
|
||||
// ============================================================================
|
||||
//
|
||||
// 之前一批可编辑扩展名同时维护在两处(具体数量已不准):
|
||||
// - main/file-ops.js 的 EDITABLE_EXTS Set(classifyEntry 入口决定文件
|
||||
// entryType,关系到 sidebar 展示 + 打开提示)
|
||||
// - src/app.js 的 LOCAL_FILE_EXTS 数组(resolveLocalFile 判断 md 内
|
||||
// 链接是否可以跳转到该文件)
|
||||
//
|
||||
// 两边列表一旦漂移,「侧栏显示可编辑 / 点击却提示不支持」或「侧栏灰掉
|
||||
// 但 md 里能点开」的体验割裂就会出现。本文件是单一事实源:
|
||||
//
|
||||
// • EDITABLE_EXTS — 可打开编辑的文件扩展名(小写、不含点)
|
||||
// • MARKDOWN_EXTS — 支持 Markdown 渲染预览的扩展名(EDITABLE_EXTS 子集)
|
||||
//
|
||||
// 加载方式:
|
||||
// • main / preload(CJS):const exts = require('../shared/extension-lists.js')
|
||||
// • renderer:经 preload contextBridge 过桥(window.api.EDITABLE_EXTS),
|
||||
// 因为 renderer 是 ESM + Chromium 原生,不能直接 import CJS。
|
||||
// preload 内对 Set 调 Array.from 后再 exposeInMainWorld,所以 renderer 端
|
||||
// 拿到的是 Array(不是 Set),用 .includes / Array.isArray 判断即可。
|
||||
//
|
||||
// 修改流程:只改本文件一处,main + renderer 两侧自动同步。
|
||||
// ============================================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 可打开编辑的文件扩展名白名单(小写、不含点)。
|
||||
*
|
||||
* 包含 Markdown + 常见纯文本 + 常见编程语言;其它扩展名一律视为 binary
|
||||
* (侧栏仍展示但灰掉、点击提示"不支持的文件类型")。
|
||||
*
|
||||
* 范围决策(2026-08-25 反馈):覆盖 Web 脚本 + 主流语言,避免「写笔记
|
||||
* 时顺手维护个 .py / .sql 也打不开」的体验割裂。编辑器是 CodeMirror
|
||||
* Markdown 6,没有专门的语法高亮(import map 里只配了 markdown /
|
||||
* javascript / css / html),所以 .py / .go / .rs 等虽然可编辑但会
|
||||
* 是纯文本色 —— 后续要做 syntax highlighting 再补 lang-* 包。
|
||||
*
|
||||
* 列表约定:
|
||||
* - 按「用途族」分组:Markdown / 数据 / 配置 / 文档 / Python /
|
||||
* JS-TS / Web 样式 / 组件框架 / JVM / Go / Rust / Ruby / PHP /
|
||||
* Shell / Windows / SQL / C-C++ / C# / Swift / Scala / Lua /
|
||||
* Perl / R / Dart
|
||||
* - 排序方便 review 时一眼看清覆盖了哪些,没别的语义
|
||||
*/
|
||||
const EDITABLE_EXTS = new Set([
|
||||
// Markdown
|
||||
'md', 'markdown',
|
||||
// 常见纯文本
|
||||
'txt', 'text', 'log',
|
||||
// 表格 / 结构化数据
|
||||
'csv', 'tsv', 'json', 'xml', 'yaml', 'yml', 'toml',
|
||||
// 配置 / 环境
|
||||
'ini', 'cfg', 'conf', 'env',
|
||||
'gitconfig', // 隐藏文件 .gitconfig 走 lastIndexOf('.') === 0 路径,需在白名单才能 editable
|
||||
// 文档 / 排版
|
||||
'rst', 'tex',
|
||||
|
||||
// Python
|
||||
'py', 'pyi', 'pyw',
|
||||
// JavaScript / TypeScript(jsx/tsx 让 React 用户也能直接编辑)
|
||||
'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx',
|
||||
// Web:HTML / CSS 衍生
|
||||
'html', 'htm', 'css', 'scss', 'sass', 'less',
|
||||
// 组件框架
|
||||
'vue', 'svelte',
|
||||
// JVM 系
|
||||
'java', 'kt', 'kts',
|
||||
// Go
|
||||
'go',
|
||||
// Rust
|
||||
'rs',
|
||||
// Ruby
|
||||
'rb',
|
||||
// PHP
|
||||
'php',
|
||||
// Shell 系(POSIX 主流)
|
||||
'sh', 'bash', 'zsh',
|
||||
// Windows 脚本
|
||||
'ps1',
|
||||
// SQL
|
||||
'sql',
|
||||
// C / C++
|
||||
'c', 'h', 'cpp', 'hpp', 'cc', 'cxx',
|
||||
// C#
|
||||
'cs',
|
||||
// Swift
|
||||
'swift',
|
||||
// Scala
|
||||
'scala',
|
||||
// Lua
|
||||
'lua',
|
||||
// Perl
|
||||
'pl',
|
||||
// R
|
||||
'r',
|
||||
// Dart
|
||||
'dart',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 支持 Markdown 渲染预览的扩展名(EDITABLE_EXTS 的子集)。
|
||||
*
|
||||
* 仅 .md / .markdown 走 viewer 预览,其它可编辑文件(.txt/.py/.json/...)
|
||||
* 在 viewer 显示「不支持预览」空态,但仍可在编辑器里修改。
|
||||
*/
|
||||
const MARKDOWN_EXTS = new Set(['md', 'markdown']);
|
||||
|
||||
module.exports = { EDITABLE_EXTS, MARKDOWN_EXTS };
|
||||
77
shared/friendly-fs-error.js
Normal file
77
shared/friendly-fs-error.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// 文件系统 errno → 用户能看懂的提示(main + renderer 共享)
|
||||
// ============================================================================
|
||||
//
|
||||
// 单一事实源。解决 Round 3 之前的三份独立实现漂移:
|
||||
// - main/file-ops.js#friendlyWriteError(e) —— 主进程内部,签名 e.code
|
||||
// - src/app.js#friendlyWriteError(code, fallback) —— renderer save 路径
|
||||
// - src/file-ops.js#friendlyFsError(code, fallback) —— renderer 文件 CRUD
|
||||
//
|
||||
// 三份在 EROFS / ENAMETOOLONG / ENOTDIR / ENOTEMPTY 上文案不同 —— 用户看到
|
||||
// 不一致提示。每加一个 errno 都要同步改三处,每处都有人漏改。
|
||||
//
|
||||
// 修法(Round 4 收尾):本文件 CJS export 函数 friendlyFsError(code, fallback),
|
||||
// - main/file-ops.js 用 require('./shared/friendly-fs-error.js'),
|
||||
// 调用时友好FsError(e?.code, e?.message || '写入文件失败')
|
||||
// - preload 用 require + contextBridge 暴露 window.api.friendlyFsError,
|
||||
// renderer 端走 window.api.friendlyFsError(...) 拿到同一份文案
|
||||
//
|
||||
// 加载方式:
|
||||
// - main.js / main/file-ops.js / preload.js (CJS):const { friendlyFsError } = require('...')
|
||||
// - renderer:经 preload contextBridge 过桥(window.api.friendlyFsError)
|
||||
// —— renderer 跑在 Chromium 原生 ESM,不能直接 import CJS
|
||||
//
|
||||
// 【fallback 行为】
|
||||
// - 已知 errno:返回固定中文文案(与 fileOps.friendlyFsError 测试矩阵对齐)
|
||||
// - 未知 errno:返回 fallback(业务码如 PATH_NOT_ALLOWED / SYMLINK_NOT_ALLOWED /
|
||||
// FILE_TOO_LARGE / FILE_CHANGED_EXTERNALLY 等不是 fs errno,由 IPC 调用方
|
||||
// 把 result.message 中文文案塞进 fallback)
|
||||
// - fallback 为空串 / undefined:走兜底「未知错误」,避免空 toast
|
||||
// ============================================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 把后端返回的 errno 翻译成中文用户提示。
|
||||
*
|
||||
* @param {string|null|undefined} code - 原始 errno(EACCES / EPERM / ENOSPC ...)
|
||||
* 或业务码字符串(PATH_NOT_ALLOWED 等),业务码一律走 fallback
|
||||
* @param {string|undefined} fallback - errno 未匹配时使用的回退文案
|
||||
* (主进程一般传 e?.message;renderer 一般传 IPC result.message / result.error)
|
||||
* @returns {string} 中文提示(永不为空 —— 兜底走「未知错误」)
|
||||
*/
|
||||
function friendlyFsError(code, fallback) {
|
||||
switch (code) {
|
||||
case 'EACCES':
|
||||
case 'EPERM':
|
||||
return '文件被占用或没有写入权限(可能是只读文件 / 另一进程独占 / 权限不足)';
|
||||
case 'ENOSPC':
|
||||
return '磁盘空间不足';
|
||||
case 'EROFS':
|
||||
return '只读文件系统,无法写入';
|
||||
case 'EIO':
|
||||
return '磁盘 I/O 错误';
|
||||
case 'EBUSY':
|
||||
return '文件被其他程序占用';
|
||||
case 'ENAMETOOLONG':
|
||||
return '路径过长';
|
||||
case 'ENOTDIR':
|
||||
return '父目录不是目录';
|
||||
case 'EISDIR':
|
||||
return '目标路径是文件夹,无法写入';
|
||||
case 'ENOTEMPTY':
|
||||
return '目标文件夹不为空';
|
||||
// audit fix (Settings P3 / ENOENT mapping):原本 ENOENT 没在 mapping 里,
|
||||
// 走 default → fallback 兜底成「未知错误」。ENOENT 是最常见的 fs errno
|
||||
// 之一(rename 源文件已删 / delete 已被外部删 / 路径打错 / watch 到一半
|
||||
// 文件被替换),用户看到一个空泛"未知错误"会以为 Notes 出 bug。补上。
|
||||
// 措辞区分「文件不存在」与「路径里某一级目录不存在」也覆盖 ENOTDIR
|
||||
// 已有的 case —— ENOENT 统一按"目标路径不存在"处理(精确到「文件还是
|
||||
// 目录」要 main 端自己抛业务码,不该让 errno mapping 揣测)。
|
||||
case 'ENOENT':
|
||||
return '文件或目录不存在(可能已被移动、重命名或删除)';
|
||||
default:
|
||||
return fallback || '未知错误';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { friendlyFsError };
|
||||
604
shared/markdown-diff.js
Normal file
604
shared/markdown-diff.js
Normal file
@@ -0,0 +1,604 @@
|
||||
// 行级 + 词级 Markdown diff —— 用于 AI 修改预览
|
||||
// ============================================================================
|
||||
//
|
||||
// 从参考项目 markdown.guanjihuan.com 的 src/lib/markdownDiff.ts 移植为 CJS,
|
||||
// 以便在 main / preload / renderer / 单元测试之间共用同一份算法。
|
||||
//
|
||||
// 核心思路:
|
||||
// 1. 行级 LCS diff(动态规划,O(n*m) 但有上限保护)找到 add/remove/context 序列
|
||||
// 2. 把连续的 add/remove 序列打包成"region"(一个修改块)
|
||||
// 3. region 内部做词级 diff(同上,tokenize 后 LCS)
|
||||
// 4. applyDiffRegionSafely 用 region 上下文(前后各 2 行)安全定位并替换
|
||||
//
|
||||
// 关键保护:
|
||||
// - MAX_LINE_DIFF_CELLS / MAX_TOKEN_DIFF_CELLS / MAX_TOKEN_DIFF_CHARS
|
||||
// 防止大文档(>1MB)触发指数级内存 / CPU 爆炸
|
||||
// - endsWithNewline 跟踪:拼接回去时保留尾换行,避免与原文件 diff
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* @typedef {Object} DiffSegment
|
||||
* @property {'equal'|'removed'|'added'} type
|
||||
* @property {string} text
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FullDiffRow
|
||||
* @property {string} id
|
||||
* @property {string} [regionId]
|
||||
* @property {'context'|'removed'|'added'} type
|
||||
* @property {number} [oldLineNumber]
|
||||
* @property {number} [newLineNumber]
|
||||
* @property {DiffSegment[]} segments
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AiDiffRegion
|
||||
* @property {string} id
|
||||
* @property {number} oldStart
|
||||
* @property {string[]} oldLines
|
||||
* @property {number} newStart
|
||||
* @property {string[]} newLines
|
||||
* @property {string[]} beforeContext
|
||||
* @property {string[]} afterContext
|
||||
* @property {string} [conflict]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PendingAiDiffProposal
|
||||
* @property {string} id
|
||||
* @property {string} baseContent
|
||||
* @property {string} nextContent
|
||||
* @property {boolean} [isReadOnly]
|
||||
* @property {FullDiffRow[]} rows
|
||||
* @property {AiDiffRegion[]} regions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ComputeFullMarkdownDiffOptions
|
||||
* @property {boolean} [tokenDiff]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DiffOp
|
||||
* @property {'context'|'removed'|'added'} type
|
||||
* @property {string} text
|
||||
* @property {number} [oldLineNumber]
|
||||
* @property {number} [newLineNumber]
|
||||
*/
|
||||
|
||||
const CONTEXT_SIZE = 2;
|
||||
const MAX_LINE_DIFF_CELLS = 200_000;
|
||||
const MAX_TOKEN_DIFF_CHARS = 4_000;
|
||||
const MAX_TOKEN_DIFF_CELLS = 40_000;
|
||||
|
||||
function normalizeContent(content) {
|
||||
// audit fix:Windows Notepad / 某些 PowerShell pipeline 会写 UTF-8 BOM
|
||||
// (U+FEFF) 在文件头。不剥 BOM 会让第一行变成 "\uFEFF# Title",
|
||||
// computeDiffOps 里 oldLines[0] === newLines[0] 永远 false,
|
||||
// 每个 BOM-prefixed 文件都会在 AI diff 面板里把首行当成「被改」渲染,
|
||||
// 即使内容一字未动。仅在文件起始位置剥一次(intra-content 的 BOM 保留)。
|
||||
// \u5BA1\u8BA1\u4FEE\u590D (Round 11 deep-fix P2-1)\uFF1A\u628A BOM \u5265\u9664\u4ECE\u300C\u4EC5\u6587\u4EF6\u5934\u300D\u6269\u5230\u300C\u5168\u6587 BOM \u5B57\u7B26\u300D\u3002
|
||||
// \u65E7\u7248 intra-file BOM\uFF08\u4F8B\u5982\u591A\u6B21 cat \u62FC\u63A5\u65F6\u5076\u5C14\u51FA\u73B0\uFF09\u4F1A\u88AB\u5F53\u6210\u6B63\u6587\u4E00\u90E8\u5206\uFF0C
|
||||
// \u8BA9\u5BF9\u5E94\u884C\u6C38\u4E0D\u7B49\u4E8E oldLines \u91CC\u540C\u6837\u4F4D\u7F6E\u7684\u884C \u2192 \u6574\u884C\u88AB\u8BEF\u5224\u4E3A\u300C\u4FEE\u6539\u300D\u3002
|
||||
// \uFEFF \u5728 Markdown \u91CC\u6CA1\u6709\u4EFB\u4F55\u6709\u610F\u4E49\u7684\u8BED\u4E49\uFF08\u96F6\u5BBD BOM \u6807\u8BB0\uFF09\uFF0C\u5168\u6587\u5265\u662F\u5B89\u5168\u7684\u3002
|
||||
//
|
||||
// \u884C\u5C3E\u89C4\u6574\uFF1A\r\n \u2192 \n\uFF1B**\u4FDD\u7559\u88F8 \r**\uFF08\u65E7 Mac \u98CE\u683C\u5408\u6CD5\u53EF\u542B\uFF09\u3002
|
||||
// \u539F `replace(/\r\n?/g, '\n')` \u4F1A\u541E\u6389\u88F8 \r \u628A\u4E00\u884C\u62C6\u6210\u4E24\u884C\uFF08Round 8 BOM \u4FEE\u590D\u7684\u526F\u4F5C\u7528\uFF09\uFF0C
|
||||
// \u73B0\u5728\u6539\u7528 /\r\n/g \u53EA\u5339\u914D CRLF\u3002
|
||||
return String(content).replace(/\r\n/g, '\n').replace(/\uFEFF/g, '');
|
||||
}
|
||||
|
||||
function splitLines(content) {
|
||||
const normalized = normalizeContent(content);
|
||||
if (normalized.length === 0) return [];
|
||||
// audit fix:「空文件」与「只有一个换行的文件」语义上等价 —— 都是「无内容行」,
|
||||
// 但前者 splitLines 返回 [],后者返回 [""],diff LCS 在这两种输入下会产生
|
||||
// 不同的 ops(added 与 nothing),让 AI diff 偶现「空文件被加了一行」的幽灵。
|
||||
// 统一成 []。
|
||||
const lines = normalized.endsWith('\n')
|
||||
? normalized.slice(0, -1).split('\n')
|
||||
: normalized.split('\n');
|
||||
if (lines.length === 1 && lines[0] === '') return [];
|
||||
return lines;
|
||||
}
|
||||
|
||||
function joinLines(lines, endsWithNewline, eol = '\n') {
|
||||
if (lines.length === 0) return endsWithNewline ? eol : '';
|
||||
return `${lines.join(eol)}${endsWithNewline ? eol : ''}`;
|
||||
}
|
||||
|
||||
function computeDiffOps(oldLines, newLines, onFallback) {
|
||||
let prefixLength = 0;
|
||||
const maxPrefixLength = Math.min(oldLines.length, newLines.length);
|
||||
|
||||
while (prefixLength < maxPrefixLength && oldLines[prefixLength] === newLines[prefixLength]) {
|
||||
prefixLength += 1;
|
||||
}
|
||||
|
||||
let oldSuffixStart = oldLines.length;
|
||||
let newSuffixStart = newLines.length;
|
||||
|
||||
while (
|
||||
oldSuffixStart > prefixLength
|
||||
&& newSuffixStart > prefixLength
|
||||
&& oldLines[oldSuffixStart - 1] === newLines[newSuffixStart - 1]
|
||||
) {
|
||||
oldSuffixStart -= 1;
|
||||
newSuffixStart -= 1;
|
||||
}
|
||||
|
||||
const oldMiddle = oldLines.slice(prefixLength, oldSuffixStart);
|
||||
const newMiddle = newLines.slice(prefixLength, newSuffixStart);
|
||||
/** @type {DiffOp[]} */
|
||||
const ops = [];
|
||||
|
||||
for (let index = 0; index < prefixLength; index += 1) {
|
||||
ops.push({ type: 'context', text: oldLines[index], oldLineNumber: index + 1, newLineNumber: index + 1 });
|
||||
}
|
||||
|
||||
if (oldMiddle.length * newMiddle.length > MAX_LINE_DIFF_CELLS) {
|
||||
// H3 fix (audit):cells 超过 MAX_LINE_DIFF_CELLS 时退化到「整段删 + 整段加」
|
||||
// fallback。原来的实现不告诉调用方 —— UI 看到「整篇被改」会误以为 AI 整篇重写。
|
||||
// onFallback 让 controller toast「diff 过大,已退化为整段替换」。
|
||||
if (typeof onFallback === 'function') {
|
||||
onFallback(`行级 diff 超过 ${MAX_LINE_DIFF_CELLS} cells 上限,已退化为整段替换(${oldMiddle.length} 行 removed + ${newMiddle.length} 行 added)`);
|
||||
}
|
||||
oldMiddle.forEach((text, index) => {
|
||||
ops.push({ type: 'removed', text, oldLineNumber: prefixLength + index + 1 });
|
||||
});
|
||||
newMiddle.forEach((text, index) => {
|
||||
ops.push({ type: 'added', text, newLineNumber: prefixLength + index + 1 });
|
||||
});
|
||||
} else {
|
||||
// LCS 动态规划
|
||||
const dp = Array.from({ length: oldMiddle.length + 1 }, () => new Array(newMiddle.length + 1).fill(0));
|
||||
|
||||
for (let i = oldMiddle.length - 1; i >= 0; i -= 1) {
|
||||
for (let j = newMiddle.length - 1; j >= 0; j -= 1) {
|
||||
dp[i][j] = oldMiddle[i] === newMiddle[j]
|
||||
? dp[i + 1][j + 1] + 1
|
||||
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
|
||||
while (i < oldMiddle.length && j < newMiddle.length) {
|
||||
if (oldMiddle[i] === newMiddle[j]) {
|
||||
ops.push({
|
||||
type: 'context',
|
||||
text: oldMiddle[i],
|
||||
oldLineNumber: prefixLength + i + 1,
|
||||
newLineNumber: prefixLength + j + 1,
|
||||
});
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
ops.push({ type: 'removed', text: oldMiddle[i], oldLineNumber: prefixLength + i + 1 });
|
||||
i += 1;
|
||||
} else {
|
||||
ops.push({ type: 'added', text: newMiddle[j], newLineNumber: prefixLength + j + 1 });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
while (i < oldMiddle.length) {
|
||||
ops.push({ type: 'removed', text: oldMiddle[i], oldLineNumber: prefixLength + i + 1 });
|
||||
i += 1;
|
||||
}
|
||||
|
||||
while (j < newMiddle.length) {
|
||||
ops.push({ type: 'added', text: newMiddle[j], newLineNumber: prefixLength + j + 1 });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = oldSuffixStart; index < oldLines.length; index += 1) {
|
||||
ops.push({
|
||||
type: 'context',
|
||||
text: oldLines[index],
|
||||
oldLineNumber: index + 1,
|
||||
newLineNumber: newSuffixStart + index - oldSuffixStart + 1,
|
||||
});
|
||||
}
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
function findSequence(lines, sequence) {
|
||||
if (sequence.length === 0) return [];
|
||||
|
||||
const matchesAt = (index) => sequence.every((line, offset) => lines[index + offset] === line);
|
||||
|
||||
// fix(audit 2026-08):原版在 preferredIndex 命中时直接 [preferredIndex] 返回,
|
||||
// 跳过扫描全文 → 其它位置的重复匹配被静默忽略。背景:AI 从 base 算 diff 时只
|
||||
// 有 1 个匹配;但用户编辑后可能在其它位置粘了相同行 → 当前文档有 N 个匹配,
|
||||
// preferredIndex hint 仍指向原位置。直接套用 hint 会改错位置(旧位置可能是
|
||||
// 用户新增的副本,而非 AI 原意要改的那一行)。
|
||||
// 修复:总是扫描全文,仅当只有 1 个匹配时才信任 hint 返回单元素数组。
|
||||
// hint 现在在调用方(applyDiffRegionSafely)通过 beforeContext/afterContext 实现,
|
||||
// 见 :547+ region clamp 切片。
|
||||
const matches = [];
|
||||
for (let i = 0; i <= lines.length - sequence.length; i += 1) {
|
||||
if (matchesAt(i)) matches.push(i);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function tokenize(text) {
|
||||
const tokens = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < text.length) {
|
||||
const char = text[index];
|
||||
// 用贪婪匹配,匹配 $...$ 行内数学(Katex 风格的简单 token)
|
||||
const mathMatch = char === '$' ? text.slice(index).match(/^\$[^$]+\$/) : null;
|
||||
const wordMatch = text.slice(index).match(/^[A-Za-z0-9_]+/);
|
||||
const spaceMatch = text.slice(index).match(/^\s+/);
|
||||
|
||||
if (mathMatch) {
|
||||
tokens.push(mathMatch[0]);
|
||||
index += mathMatch[0].length;
|
||||
} else if (spaceMatch) {
|
||||
tokens.push(spaceMatch[0]);
|
||||
index += spaceMatch[0].length;
|
||||
} else if (wordMatch) {
|
||||
tokens.push(wordMatch[0]);
|
||||
index += wordMatch[0].length;
|
||||
} else {
|
||||
tokens.push(char);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function diffTokens(removedText, addedText, onFallback) {
|
||||
if (removedText.length + addedText.length > MAX_TOKEN_DIFF_CHARS) {
|
||||
// H3 fix (audit):token 字符数超过上限 → 退化到行级 fallback。
|
||||
// 通过 onFallback 把警告挂到 computeFullMarkdownDiff 的 warnings 字段。
|
||||
if (typeof onFallback === 'function') {
|
||||
onFallback(`词级 diff 超过 ${MAX_TOKEN_DIFF_CHARS} 字符上限,已退化为行级(${removedText.length}+${addedText.length} chars)`);
|
||||
}
|
||||
return {
|
||||
removedSegments: [{ type: 'removed', text: removedText }],
|
||||
addedSegments: [{ type: 'added', text: addedText }],
|
||||
};
|
||||
}
|
||||
|
||||
const oldTokens = tokenize(removedText);
|
||||
const newTokens = tokenize(addedText);
|
||||
|
||||
if (oldTokens.length * newTokens.length > MAX_TOKEN_DIFF_CELLS) {
|
||||
// H3 fix (audit):token cells 超上限同样退化。
|
||||
if (typeof onFallback === 'function') {
|
||||
onFallback(`词级 diff cells 超过 ${MAX_TOKEN_DIFF_CELLS} 上限,已退化为行级(${oldTokens.length}×${newTokens.length} tokens)`);
|
||||
}
|
||||
return {
|
||||
removedSegments: [{ type: 'removed', text: removedText }],
|
||||
addedSegments: [{ type: 'added', text: addedText }],
|
||||
};
|
||||
}
|
||||
|
||||
const dp = Array.from({ length: oldTokens.length + 1 }, () => new Array(newTokens.length + 1).fill(0));
|
||||
|
||||
for (let i = oldTokens.length - 1; i >= 0; i -= 1) {
|
||||
for (let j = newTokens.length - 1; j >= 0; j -= 1) {
|
||||
dp[i][j] = oldTokens[i] === newTokens[j]
|
||||
? dp[i + 1][j + 1] + 1
|
||||
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {DiffSegment[]} */
|
||||
const removedSegments = [];
|
||||
/** @type {DiffSegment[]} */
|
||||
const addedSegments = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
|
||||
const pushSegment = (segments, type, text) => {
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last.type === type) {
|
||||
last.text += text;
|
||||
} else {
|
||||
segments.push({ type, text });
|
||||
}
|
||||
};
|
||||
|
||||
while (i < oldTokens.length && j < newTokens.length) {
|
||||
if (oldTokens[i] === newTokens[j]) {
|
||||
pushSegment(removedSegments, 'equal', oldTokens[i]);
|
||||
pushSegment(addedSegments, 'equal', newTokens[j]);
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
pushSegment(removedSegments, 'removed', oldTokens[i]);
|
||||
i += 1;
|
||||
} else {
|
||||
pushSegment(addedSegments, 'added', newTokens[j]);
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
while (i < oldTokens.length) {
|
||||
pushSegment(removedSegments, 'removed', oldTokens[i]);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
while (j < newTokens.length) {
|
||||
pushSegment(addedSegments, 'added', newTokens[j]);
|
||||
j += 1;
|
||||
}
|
||||
|
||||
return { removedSegments, addedSegments };
|
||||
}
|
||||
|
||||
function buildContextRows(lines) {
|
||||
return lines.map((text, index) => ({
|
||||
id: `context-${index}`,
|
||||
type: 'context',
|
||||
oldLineNumber: index + 1,
|
||||
newLineNumber: index + 1,
|
||||
segments: [{ type: 'equal', text }],
|
||||
}));
|
||||
}
|
||||
|
||||
function buildChangedRows(regionId, changedOps, options, onFallback) {
|
||||
/** @type {FullDiffRow[]} */
|
||||
const rows = [];
|
||||
const removedOps = changedOps.filter((op) => op.type === 'removed');
|
||||
const addedOps = changedOps.filter((op) => op.type === 'added');
|
||||
const pairCount = Math.min(removedOps.length, addedOps.length);
|
||||
const useTokenDiff = options.tokenDiff !== false;
|
||||
|
||||
for (let i = 0; i < pairCount; i += 1) {
|
||||
const removedOp = removedOps[i];
|
||||
const addedOp = addedOps[i];
|
||||
const { removedSegments, addedSegments } = useTokenDiff
|
||||
? diffTokens(removedOp.text, addedOp.text, onFallback)
|
||||
: {
|
||||
removedSegments: [{ type: 'removed', text: removedOp.text }],
|
||||
addedSegments: [{ type: 'added', text: addedOp.text }],
|
||||
};
|
||||
rows.push({
|
||||
id: `${regionId}-removed-${i}`,
|
||||
regionId,
|
||||
type: 'removed',
|
||||
oldLineNumber: removedOp.oldLineNumber,
|
||||
segments: removedSegments.length > 0 ? removedSegments : [{ type: 'removed', text: removedOp.text }],
|
||||
});
|
||||
rows.push({
|
||||
id: `${regionId}-added-${i}`,
|
||||
regionId,
|
||||
type: 'added',
|
||||
newLineNumber: addedOp.newLineNumber,
|
||||
segments: addedSegments.length > 0 ? addedSegments : [{ type: 'added', text: addedOp.text }],
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = pairCount; i < removedOps.length; i += 1) {
|
||||
rows.push({
|
||||
id: `${regionId}-removed-${i}`,
|
||||
regionId,
|
||||
type: 'removed',
|
||||
oldLineNumber: removedOps[i].oldLineNumber,
|
||||
segments: [{ type: 'removed', text: removedOps[i].text }],
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = pairCount; i < addedOps.length; i += 1) {
|
||||
rows.push({
|
||||
id: `${regionId}-added-${i}`,
|
||||
regionId,
|
||||
type: 'added',
|
||||
newLineNumber: addedOps[i].newLineNumber,
|
||||
segments: [{ type: 'added', text: addedOps[i].text }],
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两段 Markdown 文本的完整 diff。
|
||||
* @param {string} baseContent
|
||||
* @param {string} nextContent
|
||||
* @param {ComputeFullMarkdownDiffOptions} [options]
|
||||
* @returns {{ rows: FullDiffRow[], regions: AiDiffRegion[] }}
|
||||
*/
|
||||
function computeFullMarkdownDiff(baseContent, nextContent, options = {}) {
|
||||
const normalizedBaseContent = normalizeContent(baseContent);
|
||||
const normalizedNextContent = normalizeContent(nextContent);
|
||||
// H3 fix (audit):收集 fallback 警告(行级 cells 超 MAX_LINE_DIFF_CELLS /
|
||||
// 词级超 MAX_TOKEN_DIFF_CHARS 触发整段替换 / 退化为行级时挂警告),
|
||||
// 让 controller toast「diff 过大,已退化为整段替换」/「词级已退化为行级」,
|
||||
// 避免 UI 上看到「整篇被改」以为是 AI 整篇重写。
|
||||
const warnings = [];
|
||||
const emitWarning = (msg) => { warnings.push(msg); };
|
||||
if (normalizedBaseContent === normalizedNextContent) {
|
||||
return { rows: buildContextRows(splitLines(normalizedNextContent)), regions: [] };
|
||||
}
|
||||
|
||||
const oldLines = splitLines(normalizedBaseContent);
|
||||
const newLines = splitLines(normalizedNextContent);
|
||||
const ops = computeDiffOps(oldLines, newLines, emitWarning);
|
||||
/** @type {FullDiffRow[]} */
|
||||
const rows = [];
|
||||
/** @type {AiDiffRegion[]} */
|
||||
const regions = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < ops.length) {
|
||||
const op = ops[index];
|
||||
|
||||
if (op.type === 'context') {
|
||||
rows.push({
|
||||
id: `context-${rows.length}`,
|
||||
type: 'context',
|
||||
oldLineNumber: op.oldLineNumber,
|
||||
newLineNumber: op.newLineNumber,
|
||||
segments: [{ type: 'equal', text: op.text }],
|
||||
});
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const changeStart = index;
|
||||
while (index < ops.length && ops[index].type !== 'context') index += 1;
|
||||
const changedOps = ops.slice(changeStart, index);
|
||||
const oldLinesInRegion = changedOps.filter((item) => item.type === 'removed').map((item) => item.text);
|
||||
const newLinesInRegion = changedOps.filter((item) => item.type === 'added').map((item) => item.text);
|
||||
const beforeContextStart = Math.max(0, changeStart - CONTEXT_SIZE);
|
||||
const afterContextEnd = Math.min(ops.length, index + CONTEXT_SIZE);
|
||||
const beforeContext = ops.slice(beforeContextStart, changeStart).filter((item) => item.type === 'context').map((item) => item.text);
|
||||
const afterContext = ops.slice(index, afterContextEnd).filter((item) => item.type === 'context').map((item) => item.text);
|
||||
const firstOldLine = changedOps.find((item) => item.oldLineNumber !== undefined);
|
||||
const firstNewLine = changedOps.find((item) => item.newLineNumber !== undefined);
|
||||
const firstOldLineNumber = firstOldLine ? firstOldLine.oldLineNumber : undefined;
|
||||
const firstNewLineNumber = firstNewLine ? firstNewLine.newLineNumber : undefined;
|
||||
const regionId = `region-${regions.length + 1}`;
|
||||
|
||||
// C1 fix (audit):若一个 region 完全没有 beforeContext + afterContext,
|
||||
// 「应用此处」无法唯一定位(applyDiffRegionSafely 对纯新增/整篇删除 fallback
|
||||
// 到 currentLines.length===0 才生效,正常文档永远 conflict)。在 UI 显式标 conflict
|
||||
// 引导用户走「应用全部」。这是 AI 返回「整篇替换」或「在空文档插入内容」时的合理退化。
|
||||
const needsWholeDocHint = beforeContext.length === 0 && afterContext.length === 0;
|
||||
|
||||
regions.push({
|
||||
id: regionId,
|
||||
oldStart: firstOldLineNumber !== undefined ? firstOldLineNumber - 1 : Math.max(0, (firstNewLineNumber || 1) - 1),
|
||||
oldLines: oldLinesInRegion,
|
||||
newStart: firstNewLineNumber !== undefined ? firstNewLineNumber - 1 : Math.max(0, (firstOldLineNumber || 1) - 1),
|
||||
newLines: newLinesInRegion,
|
||||
beforeContext,
|
||||
afterContext,
|
||||
...(needsWholeDocHint ? { conflict: '无上下文定位,请使用「应用全部」' } : {}),
|
||||
});
|
||||
rows.push(...buildChangedRows(regionId, changedOps, options, emitWarning));
|
||||
}
|
||||
|
||||
// H3 fix (audit):只有真有 fallback 才带 warnings 字段,避免污染 return shape。
|
||||
return warnings.length > 0 ? { rows, regions, warnings } : { rows, regions };
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 region 安全替换 currentContent 中对应位置的内容。
|
||||
* 找不到唯一匹配(0 个或多个)时返回 { ok:false, reason },供 UI 标红提示。
|
||||
* @param {string} currentContent
|
||||
* @param {AiDiffRegion} region
|
||||
* @returns {{ ok: true, content: string } | { ok: false, reason: string }}
|
||||
*/
|
||||
function applyDiffRegionSafely(currentContent, region) {
|
||||
const original = String(currentContent);
|
||||
const normalized = normalizeContent(original);
|
||||
const currentLines = splitLines(normalized);
|
||||
const endsWithNewline = normalized.endsWith('\n');
|
||||
// 保留原稿行尾:检测是否含 CRLF(含至少一处即视为 CRLF 文件)。
|
||||
// 之前 normalizeContent 把 \r\n → \n 后 joinLines 又硬写 \n,
|
||||
// 会把 Windows 用户的 .md 文件静默改成 LF —— 污染文件格式。
|
||||
const eol = /\r\n/.test(original) ? '\r\n' : '\n';
|
||||
|
||||
if (region.oldLines.length > 0) {
|
||||
// findSequence 现在总是扫描全文(不再走 preferredIndex early-return),
|
||||
// 拿到所有 oldLines 匹配的下标。再用 beforeContext/afterContext 二次过滤,
|
||||
// 既保留 C3 上下文漂移检测,又能在「多个匹配但只有一个上下文一致」时安全应用。
|
||||
const allMatches = findSequence(currentLines, region.oldLines, region.oldStart);
|
||||
if (allMatches.length === 0) {
|
||||
return { ok: false, reason: '无法安全定位该修改位置' };
|
||||
}
|
||||
|
||||
// C3 fix (audit):多匹配时用 context 过滤,定位唯一正确的位置。
|
||||
// 场景:用户复制了相同行到多处(如原 c 后面又粘一个 c),preferredIndex 仍
|
||||
// 命中旧位置,但旧位置的 context 已和 region 算出时不同;其它新位置的
|
||||
// context 同样不对。三个候选都漂移 → "上下文漂移"。
|
||||
// 单匹配且有 context 时也照常校验(防止单匹配也漂移的极端情况)。
|
||||
if (region.beforeContext.length > 0 || region.afterContext.length > 0) {
|
||||
const contextMatches = allMatches.filter((idx) => {
|
||||
const beforeActual = currentLines.slice(
|
||||
Math.max(0, idx - region.beforeContext.length),
|
||||
idx,
|
||||
);
|
||||
const afterActual = currentLines.slice(
|
||||
idx + region.oldLines.length,
|
||||
idx + region.oldLines.length + region.afterContext.length,
|
||||
);
|
||||
return beforeActual.join('\n') === region.beforeContext.join('\n')
|
||||
&& afterActual.join('\n') === region.afterContext.join('\n');
|
||||
});
|
||||
if (contextMatches.length === 0) {
|
||||
// 所有匹配位置的 context 都不对 → drift。
|
||||
return { ok: false, reason: '上下文漂移,无法确定该修改的位置' };
|
||||
}
|
||||
if (contextMatches.length > 1) {
|
||||
// 多处 context 都和 region 一致,但 oldLines 重复了 → 真歧义。
|
||||
return { ok: false, reason: '找到多个相同位置,无法判断应应用到哪一处' };
|
||||
}
|
||||
// 唯一匹配:context 一致的位置就是 base 时的位置,安全应用。
|
||||
const nextLines = currentLines.slice();
|
||||
nextLines.splice(contextMatches[0], region.oldLines.length, ...region.newLines);
|
||||
return { ok: true, content: joinLines(nextLines, endsWithNewline, eol) };
|
||||
}
|
||||
|
||||
// 无 context:要求 oldLines 全局唯一。
|
||||
if (allMatches.length !== 1) {
|
||||
return { ok: false, reason: '找到多个相同位置,无法判断应应用到哪一处' };
|
||||
}
|
||||
const nextLines = currentLines.slice();
|
||||
nextLines.splice(allMatches[0], region.oldLines.length, ...region.newLines);
|
||||
return { ok: true, content: joinLines(nextLines, endsWithNewline, eol) };
|
||||
}
|
||||
|
||||
// 纯新增:尝试用 beforeContext + afterContext 定位插入点
|
||||
const preferredIndex = Math.min(Math.max(region.oldStart, 0), currentLines.length);
|
||||
const beforeMatches = findSequence(currentLines, region.beforeContext);
|
||||
const afterMatches = findSequence(currentLines, region.afterContext);
|
||||
/** @type {number | null} */
|
||||
let insertionIndex = null;
|
||||
|
||||
if (
|
||||
region.beforeContext.length > 0
|
||||
&& region.afterContext.length > 0
|
||||
// clamp 切片端点:preferredIndex - region.beforeContext.length 在文档开头
|
||||
// 可能是负数 → JS 的 slice(-N, 0) 会拿"末尾 N 项"误匹配;显式 clamp 到 0。
|
||||
&& preferredIndex - region.beforeContext.length >= 0
|
||||
&& currentLines.slice(preferredIndex - region.beforeContext.length, preferredIndex).join('\n') === region.beforeContext.join('\n')
|
||||
&& currentLines.slice(preferredIndex, preferredIndex + region.afterContext.length).join('\n') === region.afterContext.join('\n')
|
||||
) {
|
||||
insertionIndex = preferredIndex;
|
||||
} else if (beforeMatches.length === 1 && afterMatches.length === 1) {
|
||||
// P3 fix:before + after 同时各只有一个匹配,但 preferredIndex 错位了。
|
||||
// 此时必须校验 before 末尾 === after 起点(即「这两段上下文在文档里相邻」),
|
||||
// 否则 before 和 after 是两个独立匹配,盲选 before 会插到错位置。
|
||||
const beforeEnd = beforeMatches[0] + region.beforeContext.length;
|
||||
if (beforeEnd === afterMatches[0]) {
|
||||
insertionIndex = beforeEnd;
|
||||
} else {
|
||||
// 上下文冲突:让 UI 走 conflict 分支(标灰、应用按钮 disable)。
|
||||
return { ok: false, reason: '前后上下文位置冲突,无法确定插入点' };
|
||||
}
|
||||
} else if (beforeMatches.length === 1) {
|
||||
insertionIndex = beforeMatches[0] + region.beforeContext.length;
|
||||
} else if (afterMatches.length === 1) {
|
||||
insertionIndex = afterMatches[0];
|
||||
} else if (currentLines.length === 0) {
|
||||
insertionIndex = 0;
|
||||
}
|
||||
|
||||
if (insertionIndex === null) {
|
||||
return { ok: false, reason: '无法安全定位插入位置' };
|
||||
}
|
||||
|
||||
const nextLines = currentLines.slice();
|
||||
nextLines.splice(insertionIndex, 0, ...region.newLines);
|
||||
return { ok: true, content: joinLines(nextLines, endsWithNewline, eol) };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
computeFullMarkdownDiff,
|
||||
applyDiffRegionSafely,
|
||||
};
|
||||
239
shared/render-sanitize.js
Normal file
239
shared/render-sanitize.js
Normal file
@@ -0,0 +1,239 @@
|
||||
// Markdown → 安全 HTML 的核心规则(Stage 8:与 preload.js 共用)
|
||||
//
|
||||
// 职责:
|
||||
// - 定义 ALLOWED_URI_REGEXP(URI scheme 白名单 + 相对路径分支)
|
||||
// - 注册 uponSanitizeAttribute 钩子(统一走 ALLOWED_URI_REGEXP;
|
||||
// 危险协议 javascript:/vbscript:/data:text\/html/file: 在所有标签上都剥)
|
||||
// - 导出 renderMarkdown(markdown, dompurifyInstance) → safeHtml
|
||||
//
|
||||
// 边界:
|
||||
// - 不引用 electron / contextBridge / marked —— 由 preload 注入
|
||||
// - 不导出 DOMPurify 实例(每个进程各自创建,避免泄漏 window)
|
||||
//
|
||||
// preload.js 与 tests/unit/render-sanitize.test.js 都 require 本文件,
|
||||
// 保证规则不会在「实现」与「测试」之间漂移。
|
||||
|
||||
/**
|
||||
* 允许的 URI 协议:
|
||||
* - 前半段:显式 scheme 白名单(http/https/mailto/tel/callto/file/data:image/raster)
|
||||
* —— file: 必须先放行,DOMPurify 才会回调 uponSanitizeAttribute 钩子
|
||||
* —— data:image 限定为栅格格式(png/jpeg/gif/webp/bmp/ico/tiff)—— 显式
|
||||
* 拒绝 svg+xml/svg 等可携带 JS / XML 外部实体的格式。SVG 数据 URL 在
|
||||
* `<a href>` 上点击会导航到 top-level SVG 上下文,现代 Chromium 多半
|
||||
* 拦截脚本执行,但跨浏览器 / 跨版本一致性差。Markdown 用例几乎不需
|
||||
* SVG 内嵌图,raster 已覆盖 99% 场景。
|
||||
* - 后半段:相对路径分支('img/a.png'、'./other.md'、'/abs/x.png'、'../up.md')
|
||||
* —— `(?![/\\]{2})[/\\]|[^a-z/\\]` 覆盖 `#` 锚点 + 单 `/` 或 `\` 开头;
|
||||
* `(?![/\\]{2})` 显式拒绝「协议相对 URL」(`//evil.com/x.png` /
|
||||
* `\\evil.com\x.png`),避免静默导航到外站
|
||||
* —— `[a-z+.-]+(?:[\\/][^a-z]*|$)` 覆盖字母开头的相对路径
|
||||
*
|
||||
* 危险协议 (javascript:/vbscript:/data:text\/html) 走「字母+冒号」分支——因
|
||||
* scheme 不在前列、不以 `#` 开头、不含 `/`,全部不匹配。
|
||||
*
|
||||
* Windows 路径分隔符 `\`:用户在 Windows 上常写 ``,
|
||||
* 浏览器对 URL 会自动把 `\` 规范化成 `/`(Chrome/Firefox 都如此),
|
||||
* 校验放行 `\` 与 `/` 都不会引入新风险 —— 真实加载由 resolveRelativeImages 控。
|
||||
*/
|
||||
const ALLOWED_URI_REGEXP =
|
||||
/^(?:(?:https?|mailto|tel|callto|file):|data:image\/(?:png|jpe?g|gif|webp|bmp|ico|tiff);base64,|#|(?:(?![/\\]{2})[/\\]|[^a-z/\\])|[a-z+.-]+(?:[\\/][^a-z]*|$))/i;
|
||||
|
||||
// data:image 单 URL 字节上限(防 OOM)。10 MB 对正常 AI 输出 + 用户内嵌图
|
||||
// 已经远超合理上限 —— Notes 单文件 5 MB 限制会先卡住写入,所以 10 MB 留余量。
|
||||
const DATA_IMAGE_MAX_URL_LENGTH = 10 * 1024 ** 2;
|
||||
|
||||
/**
|
||||
* 危险 URI scheme —— 在非 IMG 标签(<a href> / <form action> / iframe 等)
|
||||
* 上剥,包括 file:(导航/数据外流通道)。IMG/src 例外:file:/// 在 IMG 上是
|
||||
* Notes 数据目录本地图片路径,需要保留。
|
||||
*
|
||||
* audit fix (C1):钩子里先 trim 再匹配。原正则 `^...javascript:...` 锚定首字符,
|
||||
* 若 DOMPurify 未规范化前导空格 / 控制字符(` ` 等实体),
|
||||
* 像 `<a href=" javascript:alert(1)">` 就能绕过钩子。trim 后再 `^` 匹配,
|
||||
* 同时把 NUL/控制字符一并处理(C0 控制字符 0x00-0x1F + 0x7F)。
|
||||
*/
|
||||
const DANGEROUS_URI_NON_IMG_REGEXP = /^(?:javascript|vbscript|data(?!:image\/)|file):/i;
|
||||
|
||||
function isDangerousUriNonImg(value) {
|
||||
if (typeof value !== 'string') return false;
|
||||
// 剥前导空白 + 控制字符:HTML 实体解码后可能留下的 \x00-\x1F / \x7F / 空格 / 换行。
|
||||
// 用循环 + charCodeAt 比较而非 regex —— ESLint no-control-regex 会拒绝 regex 字面里的控制字符。
|
||||
let i = 0;
|
||||
while (i < value.length) {
|
||||
const code = value.charCodeAt(i);
|
||||
// 空白:\t(9) \n(10) \v(11) \f(12) \r(13) 空格(32) NBSP(160) 等
|
||||
// 控制字符:0-31 与 127
|
||||
if (code <= 32 || code === 127 || code === 160) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return DANGEROUS_URI_NON_IMG_REGEXP.test(value.slice(i));
|
||||
}
|
||||
|
||||
/**
|
||||
* audit fix (Round 9):style 属性里的危险 CSS 模式防御。
|
||||
*
|
||||
* 场景:`<a style="background:url(javascript:alert(1))">` —— 上面
|
||||
* isDangerousUriNonImg 检的是 attrValue **开头**,对「值里嵌套 javascript: URL
|
||||
* 的 style」无能为力。同理 `style="width:expression(alert(1))"`(legacy IE)、
|
||||
* `style="-moz-binding:url(...)"`(legacy Mozilla XBL)、`behavior:url(...)`
|
||||
* (legacy IE HTC)。
|
||||
*
|
||||
* DOMPurify v3 默认对这些模式有部分保护(CSS sanitizer 拒 url(javascript:)),
|
||||
* 但行为跨版本 / 跨浏览器一致性差。本函数做防御性深度扫描:发现任一危险模式
|
||||
* → 整条 style 属性剥掉(保守:宁可错杀不可漏过)。
|
||||
*
|
||||
* 同时扫描 @import(可绕过 background-image 限定的外链资源加载)和
|
||||
* url(javascript:|vbscript:|data:text/html|file:) 等危险 URL scheme。
|
||||
*
|
||||
* 注意:url() 内的空白 / 引号 / 大小写都要容忍 —— 用不区分大小写的 regex,
|
||||
* 容忍 url 关键字后的可选空白。
|
||||
*/
|
||||
const DANGEROUS_STYLE_PATTERNS = [
|
||||
// url(javascript:...) / url("javascript:...") / url('javascript:...')
|
||||
// 容忍可选空白 + 单/双引号包裹 + 大小写
|
||||
/url\s*\(\s*['"]?\s*(?:javascript|vbscript|data\s*:\s*text\s*\/\s*html|file)\s*:/i,
|
||||
// legacy IE CSS expression()
|
||||
/expression\s*\(/i,
|
||||
// legacy IE HTC behavior
|
||||
/\bbehavior\s*:\s*url\s*\(/i,
|
||||
// legacy Mozilla XBL binding
|
||||
/-moz-binding\s*:/i,
|
||||
// CSS @import(外链资源加载 / CSP 绕过)
|
||||
/@import/i,
|
||||
];
|
||||
|
||||
// audit fix (Round 13 / Sec-M):CSS 属性 denylist —— 阻止攻击者把整窗当画布。
|
||||
// 思路:Markdown 的合法 inline style 几乎只用 color / font-size / text-align /
|
||||
// background:url(https://...) 这类纯视觉属性;任何「能改变布局 / 跳出文档流 /
|
||||
// 遮挡 UI / 隐藏元素 / 让用户看不清真实界面」的属性都属于攻击面。
|
||||
//
|
||||
// 受保护列表(每个测试过:
|
||||
// - `<span style="position:fixed;left:0;top:0;width:100vw;height:100vh;z-index:2147483647">`
|
||||
// —— 全屏透明覆盖层,配合外链跳转 = 整窗 UI 欺骗 / 点击劫持
|
||||
// - `<svg><style>.app-shell{display:none}</style></svg>` —— 隐藏 chrome(已
|
||||
// 靠 FORBID_TAGS:['svg'] 拦死,这条是给「未来如果放宽 svg」做兜底)
|
||||
// - `.toolbar,.statusbar{visibility:hidden}` —— 视觉欺骗
|
||||
// - `body::after{content:"会话过期请重新输入 API Key"}` —— 钓鱼覆盖层
|
||||
// - `pointer-events:none` 让 UI 看着可点但透传到下层
|
||||
// - `transform: ...` 在 markdown 里几乎不合法使用,且可绕开父级 contain
|
||||
// - `opacity:0` 让链接看着没东西实际可点
|
||||
//
|
||||
// 注意:\b 词边界要求属性名是独立 token,`font-size` 不会被误命中 `size`。
|
||||
const DANGEROUS_STYLE_PROPS = [
|
||||
'position', 'inset', 'top', 'left', 'right', 'bottom',
|
||||
'z-index', 'zindex',
|
||||
'float', 'clear',
|
||||
'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
|
||||
'margin', 'padding',
|
||||
'transform', 'translate',
|
||||
'visibility', 'opacity',
|
||||
'pointer-events',
|
||||
'content',
|
||||
// display:none 是合法用法(折叠冗余段落)但 XSS 高敏,宁可错杀。
|
||||
// 想要折叠段落用引用 styled 容器而不是内联 style。
|
||||
'display',
|
||||
];
|
||||
|
||||
function isDangerousStyleValue(value) {
|
||||
if (typeof value !== 'string') return false;
|
||||
// style 值的注释 / 字符串里也照样扫 —— 攻击者可借 `/* xxx */` 试图绕过;
|
||||
// 保守策略:任一危险模式出现 → 剥整条 style。
|
||||
for (const pat of DANGEROUS_STYLE_PATTERNS) {
|
||||
if (pat.test(value)) return true;
|
||||
}
|
||||
// 属性名扫描 —— 把 value 按 `;` 拆成 declaration,逐个看属性名是否在
|
||||
// 黑名单。\b 词边界防 `font-size` 误命中 `size`、防 `text-align` 误命中
|
||||
// 不存在的 `align`。兼容 `:property` 与 `: property` 两种写法。
|
||||
for (const decl of value.split(';')) {
|
||||
const m = decl.match(/^\s*([a-zA-Z-]+)\s*:/);
|
||||
if (m && DANGEROUS_STYLE_PROPS.includes(m[1].toLowerCase())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 uponSanitizeAttribute 钩子:
|
||||
* - <img src> 走 ALLOWED_URI_REGEXP 显式校验(统一策略);
|
||||
* file:/// 放行(ALLOWED 已含),javascript:/vbscript:/data:text\/html 等
|
||||
* 未在白名单的 scheme 一律剥
|
||||
* - 其他标签的 href/src/action 见到 javascript:/vbscript:/data:text\/html/file:
|
||||
* → 主动剥
|
||||
*
|
||||
* 之前 <img src> 是 keepAttr=true 全放行,靠 DOMPurify v3 内置兜底处理 javascript:;
|
||||
* vbscript: / data:text\/html 在 img 上不会被 DOMPurify 兜底剥掉,靠「img 不渲染 html」
|
||||
* 的策略假设——跨浏览器 / 跨版本一致性差。现在所有 src/href/action 都按 ALLOWED 过滤,
|
||||
* 单一事实源;同时非 IMG 标签的 file: 仍主动剥(导航 / 数据外流风险)。
|
||||
*
|
||||
* 必须在调用 DOMPurifyInstance.sanitize 之前注册一次(v3 的钩子是实例级,
|
||||
* 重启进程后丢失;不需要 removeHook)。
|
||||
*
|
||||
* @param {object} DOMPurifyInstance - 由 createDOMPurify(window) 创建的实例
|
||||
*/
|
||||
function installHooks(DOMPurifyInstance) {
|
||||
if (!DOMPurifyInstance || typeof DOMPurifyInstance.addHook !== 'function') return;
|
||||
DOMPurifyInstance.addHook('uponSanitizeAttribute', (node, data) => {
|
||||
const isImgSrc = data.attrName === 'src' && node.tagName === 'IMG';
|
||||
// <img src> 走显式白名单 —— ALLOWED_URI_REGEXP 已含 file:/data:image/,未匹配即剥
|
||||
if (isImgSrc) {
|
||||
if (!ALLOWED_URI_REGEXP.test(data.attrValue)) {
|
||||
data.keepAttr = false;
|
||||
return;
|
||||
}
|
||||
// audit fix (Round 13 / Sec-H2):file:// 必须严格 file:///... 三斜杠形式
|
||||
// —— 拒绝 file://host/... 与 file:////host/...。
|
||||
//
|
||||
// 威胁:Windows 上 Chromium 把 `file://ATTACKER-HOST/share/x.png` 解析为
|
||||
// UNC 路径 `\\ATTACKER-HOST\share\x.png` 并交给 SMB 客户端去取 —— 系统
|
||||
// 静默做 NTLMv2 认证,攻击者能直接抓受害者的 domain\user + NTLM response
|
||||
// (CVE-2023-23397 Outlook 那个原语,这里通过 markdown 图片复现)。
|
||||
// 三斜杠 = "无 host + 绝对路径",是浏览器渲染本地图片的唯一合法形式。
|
||||
// 写法:要求 file: 后恰好 3 个 /,且第 4 个字符不是 /(即 `////` UNC 也拒)。
|
||||
if (/^\s*file:/i.test(data.attrValue) && !/^file:\/{3}[^/]/i.test(data.attrValue)) {
|
||||
data.keepAttr = false;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
// audit fix (Sec-M3):data:image 单 URL 上限。恶意 markdown 可嵌 5MB+
|
||||
// base64 图(AI 修改场景下尤其:用户 prompt + content + 嵌入图可叠到
|
||||
// MB 级),DOMPurify v3 内置不会卡 base64 长度 → OOM 风险。
|
||||
// 10 MB base64 ≈ 7.5 MB 二进制,对单张笔记内嵌图已远超合理范围。
|
||||
// 文件绝对路径/file:/https: 不进此分支,行为不变。
|
||||
data.attrValue.length > DATA_IMAGE_MAX_URL_LENGTH
|
||||
&& /^data:image\//i.test(data.attrValue)
|
||||
) {
|
||||
data.keepAttr = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// audit fix (Round 9):style 属性走 isDangerousStyleValue 深度扫描。
|
||||
// 嵌在 url() 里的 javascript:/expression()/@import 等光靠 attrValue
|
||||
// 开头扫描抓不到。这里保守:任一危险模式命中 → 整条 style 剥。
|
||||
if (data.attrName === 'style' && isDangerousStyleValue(data.attrValue)) {
|
||||
data.keepAttr = false;
|
||||
return;
|
||||
}
|
||||
// audit fix (Round 4 P1-5):非 IMG 的 URI 属性也走 ALLOWED 校验剥 data:image/svg+xml。
|
||||
// 限制为 URI 类属性(href / src / action / formaction / xlink:href / cite / longdesc /
|
||||
// poster / usemap),避免误剥 DOMPurify 自动注入的安全属性(如 target=_blank 时
|
||||
// 自动加的 rel="noopener noreferrer")—— 那些值不是 URI,不该过 ALLOWED 校验。
|
||||
const URI_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'xlink:href', 'cite', 'longdesc', 'poster', 'usemap']);
|
||||
const isUriAttr = URI_ATTRS.has(data.attrName);
|
||||
if (isDangerousUriNonImg(data.attrValue)) {
|
||||
data.keepAttr = false;
|
||||
return;
|
||||
}
|
||||
if (isUriAttr && !ALLOWED_URI_REGEXP.test(data.attrValue)) {
|
||||
// 非 IMG 标签也调一次 ALLOWED_URI_REGEXP.test,与 IMG 路径策略对称。
|
||||
// 之前非 IMG 只走 DANGEROUS(负向预查放过 data:image),不二次 ALLOWED
|
||||
// —— 完全依赖 DOMPurify v3 内置 ALLOWED_URI_REGEXP 全局应用兜底。若未来
|
||||
// DOMPurify 配置改动或行为变更,<a href="data:image/svg+xml;base64,...">
|
||||
// 会落地;点击进入 top-level SVG 上下文,跨浏览器脚本执行行为不一致。
|
||||
data.keepAttr = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { ALLOWED_URI_REGEXP, installHooks };
|
||||
566
shared/settings-schema.js
Normal file
566
shared/settings-schema.js
Normal file
@@ -0,0 +1,566 @@
|
||||
// 设置 schema —— 单一事实源
|
||||
// ============================================================================
|
||||
//
|
||||
// 这是 main / preload / renderer 三处设置定义的唯一权威来源。
|
||||
// 之前四处漂移(main.js 的 DEFAULT_CONFIG + save-settings 80 行校验链 + renderer
|
||||
// DEFAULT_SETTINGS + settings-dialog 的选项表)统一收敛到本文件:
|
||||
//
|
||||
// • DEFAULT_SETTINGS — 各键的默认值
|
||||
// • SETTINGS_SCHEMA — 每键的 type / enum / min / max / custom validator
|
||||
// • validateAndSanitize() — 用 schema 校验+clamp+enumeration
|
||||
// • SETTINGS_UI_OPTIONS — 对话框用的枚举列表(readerFontSize / palette / sort)
|
||||
//
|
||||
// 加载方式:
|
||||
// • main.js / preload.js(CJS):const schema = require('../shared/settings-schema.js')
|
||||
// • renderer:经 preload contextBridge 过桥(window.api.settingsSchema / coerceLoadedSettings)
|
||||
// —— renderer 跑在 Chromium 原生 ESM,不能直接 import CJS(无 CJS 互操作),
|
||||
// .cjs / .js 扩展名都不能解决这个问题,文件 *格式* 决定有没有 named export。
|
||||
// 所以 renderer 拿到的始终是经 preload 包装过的纯数据 / 函数,schema 内部
|
||||
// 结构不暴露。
|
||||
//
|
||||
// 新增/修改设置项流程:
|
||||
// 1. 在 DEFAULT_SETTINGS 加默认值
|
||||
// 2. 在 SETTINGS_SCHEMA 加类型/范围/枚举/自定义校验
|
||||
// 3. 若需要在 UI 显示,在 SETTINGS_UI_OPTIONS 加选项
|
||||
// 4. 跑 npm run check 验证 — validateAndSanitize 会用 schema 拒绝非法值
|
||||
// ============================================================================
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 默认值(单一事实源)。所有键都在这里定义,缺一不可。
|
||||
* @type {Readonly<Record<string, *>>}
|
||||
*/
|
||||
const DEFAULT_SETTINGS = Object.freeze({
|
||||
dataDir: null, // null = 用主进程默认目录(home/Notes)
|
||||
theme: 'dark', // 'dark' | 'light'
|
||||
themePalette: 'default', // 调色板 ID(见 SETTINGS_UI_OPTIONS.palettes)
|
||||
alwaysOnTop: false,
|
||||
editorMode: 'split', // 'preview' | 'edit' | 'split' — 默认双栏
|
||||
readerFontSize: 17, // 阅读字号 (px),范围 12..24
|
||||
readerLineHeight: 1.85, // 阅读行距,范围 1.4..2.2
|
||||
fileListSort: 'name', // 'name' | 'mtime-desc'
|
||||
autoSaveDebounceMs: 500, // 自动保存防抖延迟(ms);0 = 关闭;默认 500ms = 「停打后立刻存」
|
||||
splitRatio: 0.5, // 双栏模式左侧占比 0.2..0.8
|
||||
sidebarWidth: null, // 侧栏拖拽后的宽度(px);null = 使用 CSS 默认 --w-sidebar
|
||||
aiWidth: null, // AI 中间面板拖拽后的宽度(px);null = 使用 CSS 默认 --w-ai
|
||||
focusMode: false, // 聚焦模式:隐藏工具栏/侧栏/状态栏;Ctrl+Shift+F 切换
|
||||
// AI 修改功能(用户自填 API Key / BaseURL / Model / System Prompt)
|
||||
// 详见 main/ai.js。空值时 AI 入口点击会提示去设置。
|
||||
aiProvider: 'openai', // 'openai' | 'anthropic' —— 决定 main/ai.js 走哪条协议
|
||||
aiBaseUrl: '', // baseURL:OpenAI 含 /v1(如 https://api.openai.com/v1);Anthropic 不含(如 https://api.anthropic.com)
|
||||
aiApiKey: '', // API Key(敏感数据;只在主进程内存里使用,不写日志)
|
||||
aiModel: '', // 模型名;OpenAI 如 gpt-4o-mini;Anthropic 如 claude-opus-5
|
||||
aiSystemPrompt: '', // 自定义系统提示词;空 = 用 main/ai.js 内置中文 prompt
|
||||
});
|
||||
|
||||
/**
|
||||
* 每个键的校验规则。
|
||||
* type: 'string' | 'number' | 'boolean' | 'enum' | 'nullable-string' | 'nullable-path'
|
||||
* enum?: 仅 enum 类型:允许的字面量数组
|
||||
* min?: number / integer / string(长度)
|
||||
* max?: number / integer / string(长度)
|
||||
* clamp?: number: 是否 clamp 到 [min,max]
|
||||
* integer?:boolean number 是否取整
|
||||
* round?: number: 保留几位小数
|
||||
* choices?:Array<{value, label, hint?}> UI 选项(仅供 dialog 渲染,不参与校验)
|
||||
*
|
||||
* 自定义校验(如 dataDir 必须存在)放在 validateAndSanitize() 里集中处理,
|
||||
* 因为它需要 fs 调用,不能纯声明式表达。
|
||||
*
|
||||
* @type {Readonly<Record<string, object>>}
|
||||
*/
|
||||
const SETTINGS_SCHEMA = Object.freeze({
|
||||
dataDir: {
|
||||
type: 'nullable-path',
|
||||
description: '数据目录;null = 用默认目录',
|
||||
},
|
||||
theme: {
|
||||
type: 'enum',
|
||||
enum: ['dark', 'light'],
|
||||
description: 'UI 主题',
|
||||
},
|
||||
themePalette: {
|
||||
type: 'enum',
|
||||
enum: ['default', 'ocean', 'forest', 'lavender', 'sunset'],
|
||||
description: '调色板',
|
||||
},
|
||||
alwaysOnTop: {
|
||||
type: 'boolean',
|
||||
description: '窗口置顶',
|
||||
},
|
||||
editorMode: {
|
||||
type: 'enum',
|
||||
enum: ['preview', 'edit', 'split'],
|
||||
description: '视图模式',
|
||||
},
|
||||
readerFontSize: {
|
||||
type: 'number',
|
||||
min: 12,
|
||||
max: 24,
|
||||
integer: true,
|
||||
clamp: true,
|
||||
description: '阅读字号 (px)',
|
||||
},
|
||||
readerLineHeight: {
|
||||
type: 'number',
|
||||
min: 1.4,
|
||||
max: 2.2,
|
||||
// fix(audit 2026-08):round:1 会把 1.85 静默四舍五入到 1.9。UI 选项列出
|
||||
// 的是 [1.5, 1.7, 1.85, 2.0] 两位小数,round:2 才能保留用户的选择。
|
||||
round: 2,
|
||||
description: '阅读行距',
|
||||
},
|
||||
fileListSort: {
|
||||
type: 'enum',
|
||||
enum: ['name', 'mtime-desc'],
|
||||
description: '侧栏文件列表排序',
|
||||
},
|
||||
autoSaveDebounceMs: {
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 60000,
|
||||
clamp: true,
|
||||
integer: true,
|
||||
description: '编辑停止后多少毫秒触发自动保存;0 = 关闭',
|
||||
},
|
||||
splitRatio: {
|
||||
type: 'number',
|
||||
min: 0.2,
|
||||
max: 0.8,
|
||||
round: 3,
|
||||
clamp: true,
|
||||
description: '双栏模式左侧占比',
|
||||
},
|
||||
sidebarWidth: {
|
||||
type: 'nullable-number',
|
||||
min: 120,
|
||||
max: 480,
|
||||
integer: true,
|
||||
description: '侧栏宽度 (px);null = 用 CSS 默认',
|
||||
},
|
||||
aiWidth: {
|
||||
type: 'nullable-number',
|
||||
min: 220,
|
||||
max: 720,
|
||||
integer: true,
|
||||
description: 'AI 中间面板宽度 (px);null = 用 CSS 默认 --w-ai (360)',
|
||||
},
|
||||
focusMode: {
|
||||
type: 'boolean',
|
||||
description: '聚焦模式(隐藏工具栏/侧栏/状态栏)',
|
||||
},
|
||||
aiProvider: {
|
||||
type: 'enum',
|
||||
enum: ['openai', 'anthropic'],
|
||||
description: 'AI 服务提供方:openai = /chat/completions;anthropic = /v1/messages',
|
||||
},
|
||||
aiBaseUrl: {
|
||||
type: 'string',
|
||||
// URL 不会超过几 KB,留 4 KB 足够;防止有人塞几 MB 把请求体打爆
|
||||
max: 4_096,
|
||||
// audit fix (#9 shared):格式校验。空串放行(用户主动清空 = 关闭 AI);
|
||||
// 非空必须是可解析的 http(s) URL,避免「abc / www.foo.com / file:///xxx」
|
||||
// 这类带空格 / 漏 scheme / 协议错误的值被静默接受,最后在主进程 fetch 时
|
||||
// 才抛 TypeError,错误信息很难定位到 settings。trim 后用 URL 解析,
|
||||
// 协议限定 http: 或 https:(不接 ftp / file / data 等)。
|
||||
format: 'url-https',
|
||||
description: 'baseURL:OpenAI 含 /v1(如 https://api.openai.com/v1);Anthropic 不含(如 https://api.anthropic.com)',
|
||||
},
|
||||
aiApiKey: {
|
||||
// 密码字段:renderer 通过 IPC 传给主进程,不在 schema 校验链里打印
|
||||
type: 'string',
|
||||
// API key 通常 50~200 字符;留 4 KB 上限足够
|
||||
max: 4_096,
|
||||
description: 'API Key',
|
||||
},
|
||||
aiModel: {
|
||||
type: 'string',
|
||||
max: 256,
|
||||
description: '模型名',
|
||||
},
|
||||
aiSystemPrompt: {
|
||||
type: 'string',
|
||||
// 系统提示词较长是合理的,但单个几十 MB 的 prompt 会拖慢 JSON.stringify
|
||||
// 且让 AI 计费爆炸 —— 200 KB 对应约 5 万中文字,足够绝大多数场景
|
||||
max: 200_000,
|
||||
description: '自定义系统提示词;空 = 用内置默认',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* UI 选项表 —— 仅供对话框渲染使用。
|
||||
* key 与 SETTINGS_SCHEMA 的 enum 对齐,但额外带 label / hint。
|
||||
* @type {Readonly<Record<string, ReadonlyArray<{value: *, label: string, hint?: string}>>>}
|
||||
*/
|
||||
const SETTINGS_UI_OPTIONS = Object.freeze({
|
||||
themePalette: Object.freeze([
|
||||
{ value: 'default', label: '默认' },
|
||||
{ value: 'ocean', label: '海洋' },
|
||||
{ value: 'forest', label: '森林' },
|
||||
{ value: 'lavender', label: '薰衣草' },
|
||||
{ value: 'sunset', label: '夕阳' },
|
||||
]),
|
||||
fileListSort: Object.freeze([
|
||||
{ value: 'name', label: '按名称', hint: 'A → Z,localeCompare(zh-CN)' },
|
||||
{ value: 'mtime-desc', label: '按修改时间', hint: '最近修改排在最前' },
|
||||
]),
|
||||
// 自动保存不再需要枚举选项:debounce 延迟是连续数值(0..60000 ms),
|
||||
// 由 toolbar 按钮直接 toggle 0 ↔ 500,UI 也不再有「选几秒」的下拉。
|
||||
readerFontSize: Object.freeze([14, 15, 17, 19, 22]),
|
||||
readerLineHeight: Object.freeze([1.5, 1.7, 1.85, 2.0]),
|
||||
// 两个选项都标"兼容":突出是「按这个协议实现的兼容 API」而不是特定厂商;
|
||||
// 用户可填任意走该协议的 baseURL(中转、自部署、官方 API 都行)。
|
||||
aiProvider: Object.freeze([
|
||||
{ value: 'openai', label: 'OpenAI 兼容', hint: '/chat/completions · 含 DeepSeek / Moonshot / Azure 等' },
|
||||
{ value: 'anthropic', label: 'Anthropic 兼容', hint: '/v1/messages · Claude 系列 · 含第三方中转' },
|
||||
]),
|
||||
});
|
||||
|
||||
/**
|
||||
* 校验 + sanitize 单个键。
|
||||
* 返回 { ok:true, value } 或 { ok:false, error }。
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {*} raw
|
||||
* @param {object} [opts] - { resolveDir?: async (path) => { ok, error? } }
|
||||
* 注入目录存在性校验(默认走 fs,调用方可传 mock)
|
||||
* @returns {Promise<{ok: true, value: *} | {ok: false, error: string}>}
|
||||
*/
|
||||
async function validateKey(key, raw, opts = {}) {
|
||||
const rule = SETTINGS_SCHEMA[key];
|
||||
if (!rule) {
|
||||
// 未知键直接拒绝(防止 renderer 误传)
|
||||
return { ok: false, error: `未知设置项: ${key}` };
|
||||
}
|
||||
|
||||
// nullable 类型:null 一律放行
|
||||
if ((rule.type === 'nullable-string' || rule.type === 'nullable-path' || rule.type === 'nullable-number') && raw === null) {
|
||||
return { ok: true, value: null };
|
||||
}
|
||||
|
||||
// 类型分发
|
||||
switch (rule.type) {
|
||||
case 'nullable-path': {
|
||||
if (typeof raw !== 'string') return { ok: false, error: `${key} 必须是字符串或 null` };
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { ok: true, value: null };
|
||||
if (opts.resolveDir) {
|
||||
const r = await opts.resolveDir(trimmed);
|
||||
if (!r.ok) return { ok: false, error: r.error };
|
||||
}
|
||||
return { ok: true, value: trimmed };
|
||||
}
|
||||
case 'nullable-string': {
|
||||
if (typeof raw !== 'string') return { ok: false, error: `${key} 必须是字符串或 null` };
|
||||
if (typeof rule.max === 'number' && raw.length > rule.max) {
|
||||
return { ok: false, error: `${key} 过长(超过 ${rule.max} 字符)` };
|
||||
}
|
||||
return { ok: true, value: raw };
|
||||
}
|
||||
case 'string': {
|
||||
if (typeof raw !== 'string') return { ok: false, error: `${key} 必须是字符串` };
|
||||
// P2 fix:trim 首尾空白 —— 用户复制粘贴 AI Key / Model 经常带回车 / 空格,
|
||||
// 不 trim 会让 main/ai.js 把它当字面量放进 Authorization header / 请求体,
|
||||
// 服务端校验「key 不匹配」但错误信息没有「多打了空格」的提示,用户无从下手。
|
||||
// 用户手动改 settings.json 也经常留尾空格。
|
||||
const trimmed = raw.trim();
|
||||
// rule.max 限定字符串长度 —— 防止几 MB 的值把请求体撑爆 / 拖慢序列化。
|
||||
// 用户看到的错误直接说"过长",避免报"非法 JSON"。
|
||||
// (audit fix:之前误写成 opts.max,导致 aiApiKey/aiBaseUrl/aiModel/
|
||||
// aiSystemPrompt 声明的上限从未生效。)
|
||||
if (typeof rule.max === 'number' && trimmed.length > rule.max) {
|
||||
return { ok: false, error: `${key} 过长(超过 ${rule.max} 字符)` };
|
||||
}
|
||||
// audit fix (#9 shared):可选 format 校验。空串放行(清空合法);
|
||||
// 非空按 format 规则走,失败给中文错误。
|
||||
if (trimmed && rule.format === 'url-https') {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
return { ok: false, error: `${key} 不是合法的 URL(需以 http:// 或 https:// 开头)` };
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return { ok: false, error: `${key} 必须是 http(s) URL(当前协议 ${parsed.protocol})` };
|
||||
}
|
||||
}
|
||||
return { ok: true, value: trimmed };
|
||||
}
|
||||
case 'boolean': {
|
||||
if (typeof raw !== 'boolean') return { ok: false, error: `${key} 必须是布尔` };
|
||||
return { ok: true, value: raw };
|
||||
}
|
||||
case 'number': {
|
||||
// audit C3:拒绝 null / undefined / 空串 / 非数字 —— Number('') === 0 会
|
||||
// 让 `readerFontSize: ''` 静默通过 clamp 到 min;这是 bug,不是「合法 0」。
|
||||
// 合法输入:实际数字 或 非空数字字符串。
|
||||
if (raw === null || raw === undefined) {
|
||||
return { ok: false, error: `${key} 必须是数字` };
|
||||
}
|
||||
if (typeof raw === 'string' && raw.trim() === '') {
|
||||
return { ok: false, error: `${key} 必须是数字` };
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return { ok: false, error: `${key} 必须是数字` };
|
||||
let v = n;
|
||||
if (rule.clamp && (v < rule.min || v > rule.max)) {
|
||||
v = Math.min(Math.max(v, rule.min), rule.max);
|
||||
}
|
||||
if (rule.integer) v = Math.round(v);
|
||||
if (typeof rule.round === 'number') {
|
||||
const k = 10 ** rule.round;
|
||||
v = Math.round(v * k) / k;
|
||||
}
|
||||
if (v < rule.min || v > rule.max) {
|
||||
return { ok: false, error: `${key} 超出范围 [${rule.min}, ${rule.max}]` };
|
||||
}
|
||||
return { ok: true, value: v };
|
||||
}
|
||||
case 'nullable-number': {
|
||||
// audit fix (Round 9):与 'number' 分支对齐显式拒绝 null / undefined /
|
||||
// 空串 / 非数字。Number('') === 0 会让 nullable-number 字段把空串静默
|
||||
// 转成 0 再 clamp 到 min —— 与上面 'number' 分支同款 bug。null 是
|
||||
// 合法值(保留为 null,让 UI 显示「未设置」),但空串 / 非数字必须拒绝。
|
||||
//
|
||||
// 审计修复 (Round 11 deep-fix P2-3):undefined 在 nullable 字段应等价于 null。
|
||||
// 旧版异步路径拒绝 undefined → 同步 sanitizeSync 路径却把 undefined 当作 null;
|
||||
// 手改 config.json 时如果字段被删(JSON.stringify 会序列化成 undefined → 字段缺失,
|
||||
// 但 settings-dialog applySetting 走异步路径)会出现 update() reject / load() accept 的不对称。
|
||||
// 现在 undefined 走「视为 null」分支。
|
||||
if (raw === null || raw === undefined) return { ok: true, value: null };
|
||||
if (typeof raw === 'string' && raw.trim() === '') {
|
||||
return { ok: false, error: `${key} 必须是数字或 null` };
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return { ok: false, error: `${key} 必须是数字或 null` };
|
||||
let v = n;
|
||||
if (rule.integer) v = Math.round(v);
|
||||
if (v < rule.min || v > rule.max) {
|
||||
return { ok: false, error: `${key} 超出范围 [${rule.min}, ${rule.max}]` };
|
||||
}
|
||||
return { ok: true, value: v };
|
||||
}
|
||||
case 'enum': {
|
||||
// enum:值在列表内即放行(兼容 string / number 字面量;如 0 也要命中)
|
||||
const allowed = rule.enum.some((e) => e === raw) ||
|
||||
(typeof raw === 'string' && rule.enum.includes(raw));
|
||||
if (!allowed) return { ok: false, error: `${key} 取值非法: ${raw}` };
|
||||
return { ok: true, value: raw };
|
||||
}
|
||||
default:
|
||||
return { ok: false, error: `${key} 类型未定义: ${rule.type}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量校验 + sanitize。
|
||||
* 跳过未在 partial 里出现的键(局部更新)。
|
||||
*
|
||||
* @param {object} partial
|
||||
* @param {object} [opts] - 同 validateKey
|
||||
* @returns {Promise<{ok: true, sanitized: object} | {ok: false, error: string}>}
|
||||
*/
|
||||
async function validateAndSanitize(partial, opts = {}) {
|
||||
if (!partial || typeof partial !== 'object' || Array.isArray(partial)) {
|
||||
return { ok: false, error: '请求体必须是对象' };
|
||||
}
|
||||
/** @type {Record<string, *>} */
|
||||
const sanitized = {};
|
||||
for (const key of Object.keys(partial)) {
|
||||
const r = await validateKey(key, partial[key], opts);
|
||||
if (!r.ok) return r;
|
||||
sanitized[key] = r.value;
|
||||
}
|
||||
return { ok: true, sanitized };
|
||||
}
|
||||
|
||||
/**
|
||||
* 与默认合并 + 校验完整 settings 对象(启动 / 读取配置文件时用)。
|
||||
*
|
||||
* 同步校验 —— 故意不 await fs 检查 dataDir 存在性(load 阶段不阻塞);
|
||||
* dataDir 存在性推迟到 settings-store.update 时再走 validateAndSanitize。
|
||||
* 但同步可校验的部分(enum / 范围 / 类型)必须现在就做,否则手改的
|
||||
* config.json 会把整个 UI 弄坏(audit #8)。
|
||||
*
|
||||
* @param {*} raw
|
||||
* @returns {Record<string, *>}
|
||||
*/
|
||||
function coerceLoadedSettings(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
// 旧版 autoSaveIntervalSec (enum: 0/3/10 秒 轮询) → autoSaveDebounceMs (ms 防抖)
|
||||
// 语义变了:「每隔 X 秒轮询」变成「停打 X ms 后保存」。所有「开启」档位
|
||||
// (旧 3 秒、10 秒)一律映射到新的 500ms 默认值,避免老用户升级后自动保存
|
||||
// 突然变得非常激进(1s 内反复触发)或太迟(10s 才一次)。
|
||||
if ('autoSaveIntervalSec' in raw) {
|
||||
const old = raw.autoSaveIntervalSec;
|
||||
if (!('autoSaveDebounceMs' in raw)) {
|
||||
const offish = old === 0 || old === '0' || old === false;
|
||||
raw.autoSaveDebounceMs = offish ? 0 : 500;
|
||||
}
|
||||
delete raw.autoSaveIntervalSec;
|
||||
}
|
||||
|
||||
/** @type {Record<string, *>} */
|
||||
const out = { ...DEFAULT_SETTINGS };
|
||||
for (const key of Object.keys(DEFAULT_SETTINGS)) {
|
||||
if (!(key in raw)) continue;
|
||||
const sanitized = sanitizeSync(key, raw[key]);
|
||||
// sanitizeSync 返回 undefined 表示「未知键 / 不可修复」,跳过即可
|
||||
if (sanitized !== undefined) out[key] = sanitized;
|
||||
}
|
||||
// Phase N 修复:保留白名单内的 `_`-前缀元数据键(当前只有 `_hasAiKey`)。
|
||||
// 之前只迭代 Object.keys(DEFAULT_SETTINGS) 把未声明的键全 drop —— get-settings
|
||||
// 返回的 `_hasAiKey: !!cfg.aiApiKey` 在 coerce 阶段被吃掉,renderer 永远拿不到
|
||||
// "已配置 API Key" 信号,"显示已填 key" + reveal 流程全失效。
|
||||
//
|
||||
// 防御:必须用白名单而不是"所有下划线前缀键都过"。否则攻击者 / 误用方可用
|
||||
// `_xxx` 形式把任意字段塞进内存 settings(虽然不会写盘,但能在内存里残留)。
|
||||
// 白名单维护成本低(已知元数据键只有少数几个),但放行成本高。
|
||||
const METADATA_KEYS = new Set(['_hasAiKey']);
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (METADATA_KEYS.has(key) && !(key in out)) {
|
||||
out[key] = raw[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步版本的单键 sanitize —— 只做不依赖 IO 的检查。
|
||||
* 与 validateKey 共享规则,但把 fs 检查(nullable-path)推迟到 update 阶段。
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {*} raw
|
||||
* @returns {*} 合法值;不可修复时返回 undefined(调用方应忽略这个键)
|
||||
*/
|
||||
function sanitizeSync(key, raw) {
|
||||
const rule = SETTINGS_SCHEMA[key];
|
||||
if (!rule) return undefined; // 未知键:丢弃
|
||||
|
||||
// nullable:null 合法
|
||||
if ((rule.type === 'nullable-string' || rule.type === 'nullable-path' || rule.type === 'nullable-number') && raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (rule.type) {
|
||||
case 'nullable-path':
|
||||
case 'string': {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
// audit fix (Round 4 P1-1):nullable-path 与 validateKey 异步路径对齐——空串归一为 null。
|
||||
// 之前 sanitizeSync 直接返回 '',coerceLoadedSettings 把磁盘上残留的
|
||||
// `"dataDir": ""` 保留为 '',但 validateKey 异步路径会归一为 null,两条路径
|
||||
// 语义不同步 → 任何依赖 dataDir === null 判断的代码失配(resolveDataDir
|
||||
// 靠 .trim() 兜底不崩但漏检 null 路径)。
|
||||
if (rule.type === 'nullable-path' && raw.trim() === '') return null;
|
||||
// 同步路径(coerceLoadedSettings / sanitizeSync)也要尊重 max —— 用户
|
||||
// 手工改坏 settings.json 时同样不能让几 MB 的字符串进入运行时。
|
||||
if (typeof rule.max === 'number' && raw.length > rule.max) return undefined;
|
||||
// 审计修复 (Round 11 deep-fix P2-3):同步路径也 trim,前后空白不再让 URL 校验
|
||||
// 失败。async validateKey 早就 trim(Round 8 fix),同步路径遗漏导致
|
||||
// load(): 不 trim 直接 new URL(' https://api.example.com ')
|
||||
// update(): trim 后校验
|
||||
// 两次读同一字段返回不同值,UI 看着值变了(实际上是同一字符串的展示差异)。
|
||||
// 注意:trim 后可能变空字符串,与 max > 0 但被 trim 成空的 case 区分;
|
||||
// 这里把 trim 后空串仍走原 length 检查(空串会让 url-https 校验短路,
|
||||
// 但保留 nullable-path 上面已经拦截过的场景)。
|
||||
const trimmed = (rule.type === 'string' || rule.type === 'nullable-string' || rule.type === 'path') ? raw.trim() : raw;
|
||||
if (typeof rule.max === 'number' && trimmed.length > rule.max) return undefined;
|
||||
// fix(audit 2026-08):同步路径也要校验 format。旧版只 validateKey(async 路径)
|
||||
// 校验 format,coerceLoadedSettings 直接放行 → 手改 settings.json 把 aiBaseUrl
|
||||
// 写成 "not-a-url" / "ftp://xxx" 也会被加载,渲染端拿到的值是无效 URL,
|
||||
// 真正 fetch 时才报 TypeError: fetch failed,错误链很难定位到 settings。
|
||||
if (trimmed.length > 0 && rule.format === 'url-https') {
|
||||
// 必须前缀严格是 http:// 或 https://,避免 'http:/missing-slash' 这种
|
||||
// URL 构造器能解析但实际 fetch 行为不一致的 case。
|
||||
if (!/^https?:\/\//i.test(trimmed)) return undefined;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
case 'nullable-string': {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
if (typeof rule.max === 'number' && raw.length > rule.max) return undefined;
|
||||
// fix(audit 2026-08):nullable-string 与 string 一样需要 format 校验,
|
||||
// 否则未来 schema 加 nullable-string + format 字段会静默失效。
|
||||
if (raw.length > 0 && rule.format === 'url-https') {
|
||||
if (!/^https?:\/\//i.test(raw)) return undefined;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
case 'boolean':
|
||||
return typeof raw === 'boolean' ? raw : undefined;
|
||||
case 'number': {
|
||||
// fix(audit 2026-08):拒绝 Boolean 输入。Number(true) === 1,
|
||||
// Number(false) === 0 —— 旧版会让 autoSaveDebounceMs: true 静默变 1ms
|
||||
// (激进到每个 keystroke 都存),splitRatio: false 变 0(无预览面板)。
|
||||
// 合法输入限定为 number 或非空数字字符串。
|
||||
if (raw === null || raw === undefined) return undefined;
|
||||
if (typeof raw !== 'number' && typeof raw !== 'string') return undefined;
|
||||
if (typeof raw === 'string' && raw.trim() === '') return undefined;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
let v = n;
|
||||
if (rule.clamp && (v < rule.min || v > rule.max)) {
|
||||
v = Math.min(Math.max(v, rule.min), rule.max);
|
||||
}
|
||||
if (rule.integer) v = Math.round(v);
|
||||
if (typeof rule.round === 'number') {
|
||||
const k = 10 ** rule.round;
|
||||
v = Math.round(v * k) / k;
|
||||
}
|
||||
// clamp 后还在范围外(例如 raw 是 NaN / Infinity)→ 拒收
|
||||
if (v < rule.min || v > rule.max) return undefined;
|
||||
return v;
|
||||
}
|
||||
case 'nullable-number': {
|
||||
// audit fix (Round 4 P2-3):与 number 分支对称——拒绝 Boolean 输入。
|
||||
// 原版无 typeof 守卫,Number(true) === 1 隐式通过 isFinite;目前 schema
|
||||
// 用 nullable-number 的字段(sidebarWidth / aiWidth)min 检查会拦下 1,
|
||||
// 但语义上与 number 不一致,且未来加更宽 min 范围的字段会绕过。复制上方
|
||||
// number 分支的 typeof 守卫保持两条路径对称。
|
||||
if (raw === null || raw === undefined) return null;
|
||||
if (typeof raw !== 'number' && typeof raw !== 'string') return undefined;
|
||||
if (typeof raw === 'string' && raw.trim() === '') return undefined;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
let v = n;
|
||||
if (rule.integer) v = Math.round(v);
|
||||
if (v < rule.min || v > rule.max) return undefined;
|
||||
return v;
|
||||
}
|
||||
case 'enum': {
|
||||
// 数字 enum 接受 number,字符串 enum 接受 string 字面量
|
||||
const ok = rule.enum.some((e) => e === raw)
|
||||
|| (typeof raw === 'string' && rule.enum.includes(raw));
|
||||
return ok ? raw : undefined;
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_SETTINGS,
|
||||
SETTINGS_SCHEMA,
|
||||
SETTINGS_UI_OPTIONS,
|
||||
validateKey,
|
||||
validateAndSanitize,
|
||||
coerceLoadedSettings,
|
||||
};
|
||||
78
shared/slug.js
Normal file
78
shared/slug.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// Heading slug 的唯一算法(preload + renderer 共用)
|
||||
//
|
||||
// 唯一调用点:把 heading 文本转成 [A-Za-z0-9-]+ 形式的 slug id。
|
||||
// 因为 preload(marked renderer.heading)和 renderer 端都会
|
||||
// 产生「指向同一 DOM 节点」的 id,必须用同一份算法 —— 否则
|
||||
// 文内锚点会因 id 不一致定位失败。
|
||||
//
|
||||
// 设计取舍:
|
||||
// - 保留 \p{L} / \p{N} / \p{M}:CJK(含扩展平面 A/B/...)+ 拉丁扩展字母都能保留
|
||||
// - 删除 markdown 行内 HTML 标签 / 反引号 / 星号 / 下划线 / 波浪号:
|
||||
// 这些「标记符号」不应进 id,否则直接复制渲染出的 id 会得到带引号的字符串
|
||||
// (下划线一并剥离,与 marked 旧默认行为一致;GitHub 是保留的,
|
||||
// 但那会让 `hello_world` / `_em_` 这种 heading 算成同一个 base,撞名重)
|
||||
// - 空白 → `-`;不裁首尾 `-`:避免 `# --foo--` → `foo` 这种出乎用户意料的别名
|
||||
// - 纯符号 / 空白输入返回 `''`:由 `slugifyHeading`(带 `seenSlugs` 的版本)
|
||||
// 兜底成 `'section'`,保证渲染出的 DOM id 非空可点击
|
||||
//
|
||||
// 这份文件被两类消费者使用:
|
||||
// - preload.js(CJS,require):marked renderer 决定 DOM 上的真实 id
|
||||
// - src/outline.js(renderer ESM):源码级镜像一份(renderer 不能 import CJS)
|
||||
// 改算法 = 同步改两边 + 测试 + 检查 outline.test.js 与 slug.test.js 的期望值。
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 把 heading 文本转成「基础 slug」(无重复检测、无空值兜底)。
|
||||
*
|
||||
* 返回空串意味着:原文剥完 HTML + 行内标记后什么都不剩(纯符号 heading)。
|
||||
* outline.js 用这一点判断是否要跳过这个 heading(导航没意义)。
|
||||
* preload.js 的 slugifyHeading 把它当 base,再走 `|| 'section'` 兜底 + 撞名加后缀。
|
||||
*
|
||||
* @param {string} raw
|
||||
* @returns {string}
|
||||
*/
|
||||
function slugifyHeadingBase(raw) {
|
||||
// audit fix (shared-M5):先 normalize('NFC') 把 NFD 字符串(如 macOS
|
||||
// 默认文件系统产出的 café 这种「e + 组合 ́」)合并成预组合字符,
|
||||
// 再做后续 replace。否则 NFD 与 NFC 的同一逻辑 heading 会生成两个不同
|
||||
// DOM id,outline 点击就会跳到错误锚点。Windows / WSL / 云盘同步经常会
|
||||
// 带来混合 normalization,这个守卫保证 slug 只看逻辑字符。
|
||||
return String(raw)
|
||||
.normalize('NFC')
|
||||
.replace(/<[^>]*>/g, '') // 行内 HTML
|
||||
.replace(/[`*_~]/g, '') // 行内标记符号
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
// \p{L} = 任意 Unicode 字母(含中日韩),\p{N} = 数字,\p{M} = 组合记号
|
||||
.replace(/[^\p{L}\p{N}\p{M}\s-]/gu, '')
|
||||
.replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重复检测的 slug 生成器。
|
||||
*
|
||||
* 同名 heading 在文档内会得到 `-1`、`-2`、... 后缀(marked 默认行为)。
|
||||
* 调用方负责在每次「解析整篇文档」前清空 `seenSlugs`,让计数按文档重置。
|
||||
* 空 base 回退 `'section'`,保证 id 始终非空可点击。
|
||||
*
|
||||
* @param {string} raw
|
||||
* @param {Set<string>} seenSlugs - 已被本次解析用过的 slug 集合
|
||||
* @returns {string}
|
||||
*/
|
||||
function slugifyHeading(raw, seenSlugs) {
|
||||
const base = slugifyHeadingBase(raw) || 'section';
|
||||
let slug = base;
|
||||
let i = 1;
|
||||
while (seenSlugs.has(slug)) {
|
||||
slug = `${base}-${i}`;
|
||||
i += 1;
|
||||
}
|
||||
seenSlugs.add(slug);
|
||||
return slug;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
slugifyHeadingBase,
|
||||
slugifyHeading,
|
||||
};
|
||||
Reference in New Issue
Block a user