@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 个往返单测覆盖词内下划线/行内>/字面星号/真实强调/多轮稳定
250 lines
11 KiB
Diff
250 lines
11 KiB
Diff
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(/</g, "<");
|
||
+}
|
||
|
||
// 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, "&").replace(/</g, "<");
|
||
+}
|
||
|
||
// 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
|
||
+ * `>` 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(/</g, '<')
|
||
+}
|
||
+
|
||
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 `>` 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(
|