From c6999763c030f6056a2a1b58c3554487ec1586ee Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 23 Jul 2026 12:04:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(editor):=20=E4=BF=AE=E5=A4=8D=20tiptap-mark?= =?UTF-8?q?down=20=E8=BF=87=E5=BA=A6=E8=BD=AC=E4=B9=89=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=8D=9F=E5=9D=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @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,编码只会产生 > 损坏) - 同步修补 src/dist ESM/dist CJS 三处 - 8 个往返单测覆盖词内下划线/行内>/字面星号/真实强调/多轮稳定 --- libs/patches/@tiptap__markdown@3.27.3.patch | 249 ++++++++++++++++++ libs/pnpm-lock.yaml | 7 +- libs/pnpm-workspace.yaml | 2 + .../src/__tests__/markdown-escape.test.ts | 114 ++++++++ 4 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 libs/patches/@tiptap__markdown@3.27.3.patch create mode 100644 libs/tiptap-editor/src/__tests__/markdown-escape.test.ts diff --git a/libs/patches/@tiptap__markdown@3.27.3.patch b/libs/patches/@tiptap__markdown@3.27.3.patch new file mode 100644 index 0000000..8e71bcc --- /dev/null +++ b/libs/patches/@tiptap__markdown@3.27.3.patch @@ -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, "&").replace(/ 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, "&").replace(/ 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 ++ * `>` that re-imports as literal `>` text — the `>` corruption. ++ * `<` and `&` still need encoding to avoid being parsed as tags/entities. ++ */ ++function encodeLimitedHtmlEntities(text: string): string { ++ return text.replace(/&/g, '&').replace(/` 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 `>` that re-imports as literal text — the root cause of ++ * the `>` 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( diff --git a/libs/pnpm-lock.yaml b/libs/pnpm-lock.yaml index 4e2285a..7169815 100644 --- a/libs/pnpm-lock.yaml +++ b/libs/pnpm-lock.yaml @@ -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 diff --git a/libs/pnpm-workspace.yaml b/libs/pnpm-workspace.yaml index a2dc071..d14191d 100644 --- a/libs/pnpm-workspace.yaml +++ b/libs/pnpm-workspace.yaml @@ -1,2 +1,4 @@ packages: - '*' +patchedDependencies: + '@tiptap/markdown@3.27.3': patches/@tiptap__markdown@3.27.3.patch diff --git a/libs/tiptap-editor/src/__tests__/markdown-escape.test.ts b/libs/tiptap-editor/src/__tests__/markdown-escape.test.ts new file mode 100644 index 0000000..0d9ebc9 --- /dev/null +++ b/libs/tiptap-editor/src/__tests__/markdown-escape.test.ts @@ -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)、 + * 行内大于号(> → >)、以及被 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('行内大于号不被编码为 >:> 入口提示', () => { + // 块级 > 是 blockquote,这里用行内(非行首)的 > 验证。 + editor.commands.setContent('见 `foo > bar` 或 输入 > 入口提示', { contentType: 'markdown' }); + const md = editor.getMarkdown(); + expect(md).toContain('入口提示'); + // 修复前:encodeHtmlEntities 把 > 编码为 > + expect(md).not.toContain('>'); + }); + + 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('>'); + }); +});