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,编码只会产生 > 损坏) - 同步修补 src/dist ESM/dist CJS 三处 - 8 个往返单测覆盖词内下划线/行内>/字面星号/真实强调/多轮稳定
This commit is contained in:
parent
5e5583fe20
commit
c6999763c0
249
libs/patches/@tiptap__markdown@3.27.3.patch
Normal file
249
libs/patches/@tiptap__markdown@3.27.3.patch
Normal 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, "&").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(
|
||||
7
libs/pnpm-lock.yaml
generated
7
libs/pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -1,2 +1,4 @@
|
||||
packages:
|
||||
- '*'
|
||||
patchedDependencies:
|
||||
'@tiptap/markdown@3.27.3': patches/@tiptap__markdown@3.27.3.patch
|
||||
|
||||
114
libs/tiptap-editor/src/__tests__/markdown-escape.test.ts
Normal file
114
libs/tiptap-editor/src/__tests__/markdown-escape.test.ts
Normal 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)、
|
||||
* 行内大于号(> → >)、以及被 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('>');
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user