diff --git a/packages/editor/src/core/markdown/Markdown.test.ts b/packages/editor/src/core/markdown/Markdown.test.ts index 29a7f9e6e..b22c3f285 100644 --- a/packages/editor/src/core/markdown/Markdown.test.ts +++ b/packages/editor/src/core/markdown/Markdown.test.ts @@ -11,7 +11,7 @@ import type {Parser} from '../types/parser'; import type {SerializerNodeToken} from '../types/serializer'; import {MarkdownParser} from './MarkdownParser'; -import {MarkdownSerializer} from './MarkdownSerializer'; +import {MarkdownSerializer, MarkdownSerializerState} from './MarkdownSerializer'; const {schema} = builder; schema.nodes['hard_break'].spec.isBreak = true; @@ -214,4 +214,28 @@ describe('markdown', () => { ), ); }); + + it('escapes exclamation mark before image syntax', () => { + same('hello !\\[alt\\](path/to/image)', doc(p('hello ![alt](path/to/image)'))); + }); + + it('escapes exclamation mark before non-escaped bracket in text()', () => { + // Directly tests the fix: when text() is called with escape=false + // and content starts with [, a preceding ! must become \! + const state = new MarkdownSerializerState({}, {}, {}); + state.out = 'hello!'; + state.text('[link](url)', false); + expect(state.out).toBe('hello\\![link](url)'); + }); + + it('does not escape underscore between word characters', () => { + same('foo_bar', doc(p('foo_bar'))); + same('a_b_c', doc(p('a_b_c'))); + }); + + it('escapes underscore at word boundaries', () => { + same('\\_leading', doc(p('_leading'))); + same('trailing\\_', doc(p('trailing_'))); + same('space \\_ space', doc(p('space _ space'))); + }); }); diff --git a/packages/editor/src/core/markdown/MarkdownSerializer.ts b/packages/editor/src/core/markdown/MarkdownSerializer.ts index 50575eefd..d80a258ca 100644 --- a/packages/editor/src/core/markdown/MarkdownSerializer.ts +++ b/packages/editor/src/core/markdown/MarkdownSerializer.ts @@ -228,6 +228,9 @@ export class MarkdownSerializerState { for (let i = 0; i < lines.length; i++) { const startOfLine = this.atBlank() || this.closed; this.write(); + // Escape ! before [ to prevent being parsed as image syntax + if (escape === false && lines[i][0] === '[' && /(^|[^\\])!$/.test(this.out)) + this.out = this.out.slice(0, this.out.length - 1) + '\\!'; let text = lines[i]; if (escape !== false && this.options.escape !== false) text = this.esc(text, startOfLine as any) if (this.escapeWhitespace) text = this.escWhitespace(text); @@ -380,7 +383,7 @@ export class MarkdownSerializerState { // have special meaning only at the start of the line. esc(str: string, startOfLine = false) { // eslint-disable-next-line no-useless-escape - const defaultEsc = /[`\^+*\\\|~\[\]\{\}<>\$_]/g; + const defaultEsc = /[`\^+*\\\|~\[\]\{\}<>\$]/g; const extraChars = this.escapeCharacters?.length ? this.escapeCharacters.map(c => '\\' + c).join('') : ''; const escRegexp = this.options?.commonEscape || // Compose the escape regexp from default, options, and extra characters @@ -389,6 +392,12 @@ export class MarkdownSerializerState { const startOfLineEscRegexp = this.options?.startOfLineEscape || /^[:#\-*+>]/; str = str.replace(escRegexp, '\\$&'); + // Smart underscore: don't escape _ between word characters (e.g. foo_bar) + str = str.replace(/_/g, (m, i) => + i > 0 && i + 1 < str.length && /\w/.test(str[i - 1]) && /\w/.test(str[i + 1]) + ? m + : '\\' + m + ); if (startOfLine) str = str.replace(startOfLineEscRegexp, '\\$&').replace(/^(\s*\d+)\./, '$1\\.'); return str; }