fix(editor): 修复 tiptap-markdown 过度转义导致显示损坏

@tiptap/markdown 的 escapeMarkdownSyntax 对每个非代码文本节点的 */_/>
无差别转义,导致 content_html→content\_html、>→>、字面星号被反复
转义累积,前台表格/加粗/引用块显示为裸 markdown 文本。转义幂等且自增强
(已转义的 \* 被 marked 解析为 escape token→再转义→粘住),只有源码模式
保存原文才能清除。

pnpm patch 持久化修补上游包(提交进 libs/patches/,pnpm install 自动应用):
- escapeMarkdownSyntax 改为 CommonMark 风向感知:只在真正构成强调开/闭
  边界的位置转义 */_(一侧 space/punct、另一侧 other),词内位置不转义
- encodeHtmlEntities 降级为 encodeLimitedHtmlEntities:只编码 < 和 &,
  移除 > (文本节点内的 > 不构成 blockquote,编码只会产生 &gt; 损坏)
- 同步修补 src/dist ESM/dist CJS 三处
- 8 个往返单测覆盖词内下划线/行内>/字面星号/真实强调/多轮稳定
This commit is contained in:
xfy 2026-07-23 12:04:25 +08:00
parent 5e5583fe20
commit c6999763c0
4 changed files with 370 additions and 2 deletions

View File

@ -0,0 +1,249 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 617292de240bd244e51ab3b0690b8ece3353637e..c5421ddadc6aea87d7ba5f46f04d61ca36aa7834 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -274,6 +274,9 @@ function htmlContainsUnrecognizedTag(html, schemaTags) {
return !schemaTags.has(tagName);
});
}
+function encodeLimitedHtmlEntities(text) {
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;");
+}
// src/MarkdownManager.ts
var MarkdownManager = class {
@@ -1049,29 +1052,40 @@ var MarkdownManager = class {
return { type: "text", text };
}
/**
- * Encode HTML entities in text unless the node is inside a code context
- * (code mark or code-block parent) where literal characters should be preserved.
- * Also backslash-escape markdown-significant characters in non-code text to
- * prevent them from being misinterpreted as formatting delimiters.
+ * Encode HTML entities + backslash-escape markdown-significant chars.
+ * Patch: CommonMark direction-aware escape for * and _ (only escape
+ * emphasis-boundary positions, not intra-word); drop > from HTML encoding.
*/
encodeTextForMarkdown(text, node, parentNode) {
const isInsideCode = (parentNode == null ? void 0 : parentNode.type) != null && this.codeTypes.has(parentNode.type) || (node.marks || []).some((m) => this.codeTypes.has(typeof m === "string" ? m : m.type));
if (isInsideCode) {
return text;
}
- return this.escapeMarkdownSyntax(_core.encodeHtmlEntities.call(void 0, text));
+ return this.escapeMarkdownSyntax(encodeLimitedHtmlEntities(text));
}
- /**
- * Backslash-escape characters that have special meaning in markdown inline
- * syntax. This prevents literal characters in text nodes from being
- * misinterpreted as formatting delimiters when the output is parsed again.
- *
- * The set covers the most common inline markdown syntax characters.
- * Characters inside code blocks/code marks are skipped by the caller
- * (`encodeTextForMarkdown`) via the existing `isInsideCode` guard.
- */
escapeMarkdownSyntax(text) {
- return text.replace(/([\\`*_[\]~])/g, "\\$1");
+ const punct = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~';
+ let result = "";
+ for (let i = 0; i < text.length; i++) {
+ const ch = text[i];
+ if (ch === "*" || ch === "_") {
+ const prev = i > 0 ? text[i - 1] : "";
+ const next = i < text.length - 1 ? text[i + 1] : "";
+ const leftSpace = prev === "" || /\s/.test(prev);
+ const leftPunct = punct.includes(prev);
+ const leftOther = !leftSpace && !leftPunct;
+ const rightSpace = next === "" || /\s/.test(next);
+ const rightPunct = punct.includes(next);
+ const rightOther = !rightSpace && !rightPunct;
+ const isBoundary = ((leftSpace || leftPunct) && rightOther) || ((rightSpace || rightPunct) && leftOther);
+ result += isBoundary ? "\\" + ch : ch;
+ } else if (ch === "`" || ch === "[" || ch === "]" || ch === "~" || ch === "\\") {
+ result += "\\" + ch;
+ } else {
+ result += ch;
+ }
+ }
+ return result;
}
renderNodeToMarkdown(node, parentNode, index = 0, level = 0, meta = {}) {
var _a;
diff --git a/dist/index.js b/dist/index.js
index 18088ba76ee44bcddc94fdf30502928249516f10..e57cf65f9ffabca7728de5a39637d309720394c5 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -9,7 +9,6 @@ import {
attrsEqual as attrsEqual2,
callOrReturn,
decodeHtmlEntities,
- encodeHtmlEntities,
flattenExtensions,
generateJSON,
getExtensionField,
@@ -274,6 +273,9 @@ function htmlContainsUnrecognizedTag(html, schemaTags) {
return !schemaTags.has(tagName);
});
}
+function encodeLimitedHtmlEntities(text) {
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;");
+}
// src/MarkdownManager.ts
var MarkdownManager = class {
@@ -1049,29 +1051,40 @@ var MarkdownManager = class {
return { type: "text", text };
}
/**
- * Encode HTML entities in text unless the node is inside a code context
- * (code mark or code-block parent) where literal characters should be preserved.
- * Also backslash-escape markdown-significant characters in non-code text to
- * prevent them from being misinterpreted as formatting delimiters.
+ * Encode HTML entities + backslash-escape markdown-significant chars.
+ * Patch: CommonMark direction-aware escape for * and _ (only escape
+ * emphasis-boundary positions, not intra-word); drop > from HTML encoding.
*/
encodeTextForMarkdown(text, node, parentNode) {
const isInsideCode = (parentNode == null ? void 0 : parentNode.type) != null && this.codeTypes.has(parentNode.type) || (node.marks || []).some((m) => this.codeTypes.has(typeof m === "string" ? m : m.type));
if (isInsideCode) {
return text;
}
- return this.escapeMarkdownSyntax(encodeHtmlEntities(text));
+ return this.escapeMarkdownSyntax(encodeLimitedHtmlEntities(text));
}
- /**
- * Backslash-escape characters that have special meaning in markdown inline
- * syntax. This prevents literal characters in text nodes from being
- * misinterpreted as formatting delimiters when the output is parsed again.
- *
- * The set covers the most common inline markdown syntax characters.
- * Characters inside code blocks/code marks are skipped by the caller
- * (`encodeTextForMarkdown`) via the existing `isInsideCode` guard.
- */
escapeMarkdownSyntax(text) {
- return text.replace(/([\\`*_[\]~])/g, "\\$1");
+ const punct = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~';
+ let result = "";
+ for (let i = 0; i < text.length; i++) {
+ const ch = text[i];
+ if (ch === "*" || ch === "_") {
+ const prev = i > 0 ? text[i - 1] : "";
+ const next = i < text.length - 1 ? text[i + 1] : "";
+ const leftSpace = prev === "" || /\s/.test(prev);
+ const leftPunct = punct.includes(prev);
+ const leftOther = !leftSpace && !leftPunct;
+ const rightSpace = next === "" || /\s/.test(next);
+ const rightPunct = punct.includes(next);
+ const rightOther = !rightSpace && !rightPunct;
+ const isBoundary = ((leftSpace || leftPunct) && rightOther) || ((rightSpace || rightPunct) && leftOther);
+ result += isBoundary ? "\\" + ch : ch;
+ } else if (ch === "`" || ch === "[" || ch === "]" || ch === "~" || ch === "\\") {
+ result += "\\" + ch;
+ } else {
+ result += ch;
+ }
+ }
+ return result;
}
renderNodeToMarkdown(node, parentNode, index = 0, level = 0, meta = {}) {
var _a;
diff --git a/src/MarkdownManager.ts b/src/MarkdownManager.ts
index 5d6256f921567f53268827cc549bb83b77b3130d..146514f78f857951eebb1f776756b381cc5fc90b 100644
--- a/src/MarkdownManager.ts
+++ b/src/MarkdownManager.ts
@@ -13,7 +13,6 @@ import {
attrsEqual,
callOrReturn,
decodeHtmlEntities,
- encodeHtmlEntities,
flattenExtensions,
generateJSON,
getExtensionField,
@@ -34,6 +33,19 @@ import {
} from './utils.js'
import { htmlContainsUnrecognizedTag } from './utils/htmlTagDetection.js'
+/**
+ * Encode only `<` and `&` as HTML entities. Patch replacement for the
+ * `encodeHtmlEntities` from `@tiptap/core`, which also encodes `>`.
+ *
+ * `>` inside a text node is never a blockquote start (block tokenizer consumes
+ * line-leading `>` before text nodes exist), so encoding it only produces
+ * `&gt;` that re-imports as literal `&gt;` text — the `&gt;` corruption.
+ * `<` and `&` still need encoding to avoid being parsed as tags/entities.
+ */
+function encodeLimitedHtmlEntities(text: string): string {
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;')
+}
+
export class MarkdownManager {
private markedInstance: typeof marked
private activeParseLexer: Lexer | null = null
@@ -1087,6 +1099,12 @@ export class MarkdownManager {
* (code mark or code-block parent) where literal characters should be preserved.
* Also backslash-escape markdown-significant characters in non-code text to
* prevent them from being misinterpreted as formatting delimiters.
+ *
+ * Patch: drop `>` from HTML entity encoding (only encode `<` and `&`).
+ * A `>` inside a text node never starts a blockquote (line-start `>` is
+ * consumed by the block tokenizer before reaching text nodes), so encoding
+ * it only produces `&gt;` that re-imports as literal text — the root cause of
+ * the `&gt;` corruption.
*/
private encodeTextForMarkdown(text: string, node: JSONContent, parentNode?: JSONContent): string {
const isInsideCode =
@@ -1097,7 +1115,7 @@ export class MarkdownManager {
return text
}
- return this.escapeMarkdownSyntax(encodeHtmlEntities(text))
+ return this.escapeMarkdownSyntax(encodeLimitedHtmlEntities(text))
}
/**
@@ -1105,12 +1123,44 @@ export class MarkdownManager {
* syntax. This prevents literal characters in text nodes from being
* misinterpreted as formatting delimiters when the output is parsed again.
*
- * The set covers the most common inline markdown syntax characters.
+ * Patch: CommonMark direction-aware escaping for `*` and `_`. Instead of
+ * blindly escaping every `*`/`_` (which corrupts intra-word underscores like
+ * `content_html` → `content\_html`), only escape positions that would be
+ * consumed as emphasis open/close boundaries by a CommonMark parser. An
+ * emphasis boundary requires one flank to be {start, whitespace, punctuation}
+ * and the other to be a non-space non-punct char (letters / CJK / digits).
+ * Intra-word `*`/`_` (both flanks "other") is left alone.
+ * Structural characters (` ` [ ] ~ \) stay fully escaped — they are never
+ * emphasis boundaries but are structurally significant.
* Characters inside code blocks/code marks are skipped by the caller
* (`encodeTextForMarkdown`) via the existing `isInsideCode` guard.
*/
private escapeMarkdownSyntax(text: string): string {
- return text.replace(/([\\`*_[\]~])/g, '\\$1')
+ // CommonMark 标点集CJK/字母/数字属 "other"。
+ const punct = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'
+ let result = ''
+ for (let i = 0; i < text.length; i++) {
+ const ch = text[i]
+ if (ch === '*' || ch === '_') {
+ const prev = i > 0 ? text[i - 1] : ''
+ const next = i < text.length - 1 ? text[i + 1] : ''
+ const leftSpace = prev === '' || /\s/.test(prev)
+ const leftPunct = punct.includes(prev)
+ const leftOther = !leftSpace && !leftPunct
+ const rightSpace = next === '' || /\s/.test(next)
+ const rightPunct = punct.includes(next)
+ const rightOther = !rightSpace && !rightPunct
+ // 可作开边界:(left∈{start,space,punct}) 且 (right∈other)
+ // 可作闭边界:(right∈{start,space,punct}) 且 (left∈other)
+ const isBoundary = ((leftSpace || leftPunct) && rightOther) || ((rightSpace || rightPunct) && leftOther)
+ result += isBoundary ? '\\' + ch : ch
+ } else if (ch === '`' || ch === '[' || ch === ']' || ch === '~' || ch === '\\') {
+ result += '\\' + ch
+ } else {
+ result += ch
+ }
+ }
+ return result
}
renderNodeToMarkdown(

7
libs/pnpm-lock.yaml generated
View File

@ -4,6 +4,9 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
patchedDependencies:
'@tiptap/markdown@3.27.3': 982043a3137900a6f90697c62a2c026a44c215725d951656385fd2c3cbe0e907
importers:
.:
@ -111,7 +114,7 @@ importers:
version: 3.27.3(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)
'@tiptap/markdown':
specifier: ^3.27.3
version: 3.27.3(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)
version: 3.27.3(patch_hash=982043a3137900a6f90697c62a2c026a44c215725d951656385fd2c3cbe0e907)(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)
'@tiptap/pm':
specifier: ^3.27.3
version: 3.27.3
@ -1898,7 +1901,7 @@ snapshots:
'@tiptap/core': 3.27.3(@tiptap/pm@3.27.3)
'@tiptap/pm': 3.27.3
'@tiptap/markdown@3.27.3(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)':
'@tiptap/markdown@3.27.3(patch_hash=982043a3137900a6f90697c62a2c026a44c215725d951656385fd2c3cbe0e907)(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)':
dependencies:
'@tiptap/core': 3.27.3(@tiptap/pm@3.27.3)
'@tiptap/pm': 3.27.3

View File

@ -1,2 +1,4 @@
packages:
- '*'
patchedDependencies:
'@tiptap/markdown@3.27.3': patches/@tiptap__markdown@3.27.3.patch

View File

@ -0,0 +1,114 @@
// @vitest-environment happy-dom
import { Editor } from '@tiptap/core';
import { Markdown } from '@tiptap/markdown';
import StarterKit from '@tiptap/starter-kit';
import { beforeEach, describe, expect, it } from 'vitest';
/**
* Markdown (Fix 1:pnpm patch @tiptap/markdown)
*
* :@tiptap/markdown escapeMarkdownSyntax **
* `*`/`_`/`>` ,线(content_html content\_html)
* (> &gt;) marked
*
* 修补:CommonMark / `*`/`_`,
* ( other);`>` HTML
*
* (setContent markdown getMarkdown),
* MarkdownManager.serialize ()
*/
function makeEditor() {
return new Editor({
element: document.body,
extensions: [StarterKit.configure({ heading: { levels: [1, 2, 3] } }), Markdown],
content: '',
});
}
describe('markdown 转义往返 - 富文本模式不过度转义', () => {
let editor: Editor;
beforeEach(() => {
editor = makeEditor();
});
it('词内下划线不被转义:content_html 保持原样', () => {
editor.commands.setContent('字段 content_html 是渲染产物', { contentType: 'markdown' });
const md = editor.getMarkdown();
// 修复前:escapeMarkdownSyntax 把每个 _ 转义 → content\_html
expect(md).toContain('content_html');
expect(md).not.toContain('content\\_html');
});
it('行内大于号不被编码为 &gt;:> 入口提示', () => {
// 块级 > 是 blockquote,这里用行内(非行首)的 > 验证。
editor.commands.setContent('见 `foo > bar` 或 输入 > 入口提示', { contentType: 'markdown' });
const md = editor.getMarkdown();
expect(md).toContain('入口提示');
// 修复前:encodeHtmlEntities 把 > 编码为 &gt;
expect(md).not.toContain('&gt;');
});
it('字面星号 run 安全转义且往返不累积:用法**说明', () => {
// 词内 ** (CJK 两侧) 在无配对闭界时被 marked 保留为字面文本节点。
// 序列化时转义为 \*\* 是安全表示(\* 在 re-import 时解码回字面 *,不累积)。
// 关键契约:多轮往返稳定,不出现 \\\* 这类双重转义累积。
editor.commands.setContent('这是 用法**说明 文本', { contentType: 'markdown' });
const md1 = editor.getMarkdown();
// 第二轮
editor.commands.setContent(md1, { contentType: 'markdown' });
const md2 = editor.getMarkdown();
expect(md2).toBe(md1);
// 不应出现四反斜杠以上的双重转义累积。
expect(md2).not.toMatch(/\\\\{2,}\*/);
expect(md2).toContain('说明');
});
it('删除线 ~~~~ 结构性转义保留', () => {
// ~ 是结构性字符(strikethrough),应维持转义避免误判。
editor.commands.setContent('这是 ~~删除~~ 文本', { contentType: 'markdown' });
const md = editor.getMarkdown();
// ~~ 在词边界(空格两侧)会被 marked 识别为删除线;转义后序列化为 \~\~
expect(md).toContain('删除');
});
it('真实强调 **bold** 作为 strong 往返保留', () => {
editor.commands.setContent('这是 **加粗** 文本', { contentType: 'markdown' });
const md = editor.getMarkdown();
// 真实强调(两侧空格 + other)应作为 strong mark 识别并原样序列化。
expect(md).toContain('**加粗**');
});
it('字面反斜杠星号裸文本 \\* 不被双重转义', () => {
// 裸 \* 在 import 时被 marked 解析为 escape token → 字面 *。
// serialize 时这个字面 * 若处于边界位置会被转义一次(单层),不应变 \\*。
editor.commands.setContent('字面星号 \\* 在这里', { contentType: 'markdown' });
const md = editor.getMarkdown();
// 关键:不应出现四反斜杠(\\\\)这种双重转义累积。
expect(md).not.toContain('\\\\\\*');
});
it('行内代码反引号结构性转义保留', () => {
editor.commands.setContent('用 `code` 标记', { contentType: 'markdown' });
const md = editor.getMarkdown();
expect(md).toContain('`code`');
expect(md).toContain('标记');
});
it('多轮往返稳定:序列化 → 再解析 → 序列化不累积转义', () => {
const source = '字段 content_html 与 **加粗** 和 > 符号';
editor.commands.setContent(source, { contentType: 'markdown' });
const md1 = editor.getMarkdown();
editor.commands.setContent(md1, { contentType: 'markdown' });
const md2 = editor.getMarkdown();
editor.commands.setContent(md2, { contentType: 'markdown' });
const md3 = editor.getMarkdown();
// 关键契约:多轮不累积(content\_html → content\\\_html → ...)。
expect(md2).toBe(md1);
expect(md3).toBe(md1);
expect(md3).toContain('content_html');
expect(md3).not.toContain('&gt;');
});
});