From f77ad951980690113276679cdadda3276223c075 Mon Sep 17 00:00:00 2001 From: Petar Enchev <299433253+haripetrov-stack@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:56:26 +0300 Subject: [PATCH 01/70] [lexical-markdown] Bug Fix: Treat Unicode punctuation and symbols as punctuation in emphasis flanking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Markdown import decides whether a `*`, `_` or `__` run can open or close emphasis by looking at the characters on either side of it. `PUNCTUATION` and `PUNCTUATION_OR_SPACE` in `packages/lexical-markdown/src/utils.ts` list only ASCII characters, so an en dash, a curly quote or a currency sign next to a run counts as a letter. Before this change: - `See –**[here](https://example.com)** now.` imports as plain text. The next export escapes the asterisks: ``See –\*\*[here](https://example.com)\*\* now.`` - `See “**[here](https://example.com)**” now.` has the same problem. After this change both import as a bold link. The exporter writes the bold inside the link, so the first one exports as `See –[**here**](https://example.com) now.` CommonMark treats every character in the Unicode P (punctuation) and S (symbol) general categories as punctuation ([Unicode punctuation character](https://spec.commonmark.org/0.31.2/#unicode-punctuation-character), spec 0.31.2). Both constants now use `\p{P}\p{S}` with the `u` flag. - `PUNCTUATION` is read by `isFlanking` and `canEmphasis` in `importTextFormatTransformer.ts`. - `PUNCTUATION_OR_SPACE` is read by the typing shortcuts for underscore formats in `MarkdownShortcuts.ts`, so typing `_here_` after an en dash now formats the same way importing it does. Every ASCII punctuation character is in P or S, so ASCII results do not change, with one exception. `PUNCTUATION` did not contain the backslash (U+005C, category Po) because of the escaped `]` in the class. CommonMark counts it, and the new class includes it. #9407 and #9414 fix only that character, so this change overlaps with them on the `PUNCTUATION` line. Not changed: characters outside the Basic Multilingual Plane, such as most emoji, are still read one UTF-16 code unit at a time and still count as letters. Reported in sveltia/sveltia-cms#1107. ## Test plan Command: `pnpm exec vitest run --project unit packages/lexical-markdown` ### Before `main` at 4f385cd with only the new tests added: ``` Test Files 2 failed | 6 passed (8) Tests 12 failed | 702 passed (714) ``` The 12 failures are 8 imports, 2 exports and 2 typed shortcuts. Three of them: ``` FAIL LexicalMarkdown.test.ts > emphasis next to Unicode punctuation and symbols > imports "See –**[here](https://example.com)** now." AssertionError: expected [ [ 'See –**', 'plain' ], …(2) ] to deeply equal [ [ 'See –', 'plain' ], …(2) ] FAIL LexicalMarkdown.test.ts > emphasis next to Unicode punctuation and symbols > exports "See –**[here](https://example.com)** now." without escaped asterisks AssertionError: expected 'See –\*\*[here](https://example.com)\…' to be 'See –[**here**](https://example.com) …' FAIL MarkdownTransformers.test.ts > UNDERSCORE_AFTER_UNICODE_PUNCTUATION > typing "See –_here_" formats the word between the underscores AssertionError: expected [ 'See –_here_' ] to deeply equal [ 'See –', 'here' ] ``` The other 8 new tests are controls and pass before and after: ASCII `-` and a space before `**`, `a**b**`, `**a**b`, `snake_case_word`, a Latin and a Cyrillic letter before `**`, and a letter before a typed `_`. ### After ``` Test Files 8 passed (8) Tests 714 passed (714) ``` `prettier --check` and `eslint` are clean on the three changed files. Not run: the e2e suite, Flow and the repo-wide `tsc`. --- .../__tests__/unit/LexicalMarkdown.test.ts | 185 ++++++++++++++++++ .../unit/MarkdownTransformers.test.ts | 45 +++++ packages/lexical-markdown/src/utils.ts | 8 +- 3 files changed, 236 insertions(+), 2 deletions(-) diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 80bb9545a9b..28d26ebbce0 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -4149,6 +4149,191 @@ describe('link title round trip', () => { } }); +describe('emphasis next to Unicode punctuation and symbols', () => { + // CommonMark counts every character in the Unicode P (punctuation) and S + // (symbol) general categories as punctuation when it decides whether a + // delimiter run is left- or right-flanking, not only the ASCII ones. + // https://spec.commonmark.org/0.31.2/#unicode-punctuation-character + type Run = [text: string, format: string]; + + const EN_DASH = '\u2013'; // Pd + const LEFT_DOUBLE_QUOTE = '\u201C'; // Pi + const RIGHT_DOUBLE_QUOTE = '\u201D'; // Pf + const LOW_DOUBLE_QUOTE = '\u201E'; // Ps + const EURO_SIGN = '\u20AC'; // Sc + const LATIN_E_ACUTE = '\u00E9'; // Ll, a letter + const CYRILLIC_BE = '\u0431'; // Ll, a letter + const HERE_LINK = '[here](https://example.com)'; + + function importRuns(md: string): Run[] { + const editor = createTestEditor(); + editor.update(() => $convertFromMarkdownString(md, TRANSFORMERS), { + discrete: true, + }); + return editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map((node): Run => { + const formats = (['bold', 'italic'] as const).filter(format => + node.hasFormat(format), + ); + return [node.getTextContent(), formats.join(' ') || 'plain']; + }), + ); + } + + // Each expected result matches what micromark, a CommonMark parser, produces + // for the same input. + const CASES: [md: string, runs: Run[]][] = [ + [ + `See ${EN_DASH}**${HERE_LINK}** now.`, + [ + [`See ${EN_DASH}`, 'plain'], + ['here', 'bold'], + [' now.', 'plain'], + ], + ], + [ + `See ${LEFT_DOUBLE_QUOTE}**${HERE_LINK}**${RIGHT_DOUBLE_QUOTE} now.`, + [ + [`See ${LEFT_DOUBLE_QUOTE}`, 'plain'], + ['here', 'bold'], + [`${RIGHT_DOUBLE_QUOTE} now.`, 'plain'], + ], + ], + [ + `See ${LOW_DOUBLE_QUOTE}**${HERE_LINK}**${LEFT_DOUBLE_QUOTE} now.`, + [ + [`See ${LOW_DOUBLE_QUOTE}`, 'plain'], + ['here', 'bold'], + [`${LEFT_DOUBLE_QUOTE} now.`, 'plain'], + ], + ], + [ + `See ${EURO_SIGN}**${HERE_LINK}** now.`, + [ + [`See ${EURO_SIGN}`, 'plain'], + ['here', 'bold'], + [' now.', 'plain'], + ], + ], + [ + // Only the closing run touches a Unicode punctuation character. + `See **${HERE_LINK}**${EN_DASH}now.`, + [ + ['See ', 'plain'], + ['here', 'bold'], + [`${EN_DASH}now.`, 'plain'], + ], + ], + [ + `See ${EN_DASH}*${HERE_LINK}* now.`, + [ + [`See ${EN_DASH}`, 'plain'], + ['here', 'italic'], + [' now.', 'plain'], + ], + ], + [ + `See ${EN_DASH}_${HERE_LINK}_ now.`, + [ + [`See ${EN_DASH}`, 'plain'], + ['here', 'italic'], + [' now.', 'plain'], + ], + ], + [ + `See ${EN_DASH}__${HERE_LINK}__ now.`, + [ + [`See ${EN_DASH}`, 'plain'], + ['here', 'bold'], + [' now.', 'plain'], + ], + ], + // Unchanged: ASCII punctuation and whitespace still work. + [ + `See -**${HERE_LINK}** now.`, + [ + ['See -', 'plain'], + ['here', 'bold'], + [' now.', 'plain'], + ], + ], + [ + `See **${HERE_LINK}** now.`, + [ + ['See ', 'plain'], + ['here', 'bold'], + [' now.', 'plain'], + ], + ], + // Unchanged: a delimiter run inside a word. + [ + 'a**b**', + [ + ['a', 'plain'], + ['b', 'bold'], + ], + ], + [ + '**a**b', + [ + ['a', 'bold'], + ['b', 'plain'], + ], + ], + ['snake_case_word', [['snake_case_word', 'plain']]], + // Unchanged: a letter is not punctuation, whatever the script. + [ + `${LATIN_E_ACUTE}**${HERE_LINK}**`, + [ + [`${LATIN_E_ACUTE}**`, 'plain'], + ['here', 'plain'], + ['**', 'plain'], + ], + ], + [ + `${CYRILLIC_BE}**${HERE_LINK}**`, + [ + [`${CYRILLIC_BE}**`, 'plain'], + ['here', 'plain'], + ['**', 'plain'], + ], + ], + ]; + + for (const [md, runs] of CASES) { + it(`imports ${JSON.stringify(md)}`, () => { + expect(importRuns(md)).toEqual(runs); + }); + } + + // Importing the bold link as text used to leave the asterisks in the text, + // so the export escaped them. The exporter writes the bold inside the link. + const EXPORT_CASES: [md: string, exported: string][] = [ + [ + `See ${EN_DASH}**${HERE_LINK}** now.`, + `See ${EN_DASH}[**here**](https://example.com) now.`, + ], + [ + `See ${LEFT_DOUBLE_QUOTE}**${HERE_LINK}**${RIGHT_DOUBLE_QUOTE} now.`, + `See ${LEFT_DOUBLE_QUOTE}[**here**](https://example.com)${RIGHT_DOUBLE_QUOTE} now.`, + ], + ]; + + for (const [md, exported] of EXPORT_CASES) { + it(`exports ${JSON.stringify(md)} without escaped asterisks`, () => { + const editor = createTestEditor(); + editor.update(() => $convertFromMarkdownString(md, TRANSFORMERS), { + discrete: true, + }); + expect( + editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), + ).toBe(exported); + }); + } +}); + describe('decimal character references that name no character', () => { // `String.fromCodePoint` throws on a number past U+10FFFF, so one of these // in the text or a link used to abort the whole import. CommonMark reads at diff --git a/packages/lexical-markdown/src/__tests__/unit/MarkdownTransformers.test.ts b/packages/lexical-markdown/src/__tests__/unit/MarkdownTransformers.test.ts index e8ffe2bdd05..8fc0ca064a2 100644 --- a/packages/lexical-markdown/src/__tests__/unit/MarkdownTransformers.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/MarkdownTransformers.test.ts @@ -262,6 +262,51 @@ describe('CODE_SPAN_PRECEDENCE', () => { }); }); +describe('UNDERSCORE_AFTER_UNICODE_PUNCTUATION', () => { + // Underscore formats do not apply inside a word, so the character before the + // opening tag has to be whitespace or punctuation. CommonMark counts every + // Unicode punctuation (P) and symbol (S) character as punctuation, not only + // the ASCII ones. + // https://spec.commonmark.org/0.31.2/#unicode-punctuation-character + const EN_DASH = '\u2013'; + const LEFT_DOUBLE_QUOTE = '\u201C'; + + test.each([ + [`See ${EN_DASH}_here_`, `See ${EN_DASH}`], + [`See ${LEFT_DOUBLE_QUOTE}_here_`, `See ${LEFT_DOUBLE_QUOTE}`], + ])('typing %j formats the word between the underscores', (typed, before) => { + using editor = buildEditorFromExtensions([MarkdownShortcutTestExtension]); + typeMarkdown(editor, typed); + editor.read(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + assert($isParagraphNode(paragraph), 'Root child must be a paragraph'); + const textNodes = paragraph.getChildren().filter($isTextNode); + expect(textNodes.map(node => node.getTextContent())).toEqual([ + before, + 'here', + ]); + expect(textNodes.map(node => node.hasFormat('italic'))).toEqual([ + false, + true, + ]); + }); + }); + + test('typing an underscore format right after a letter still does not format it', () => { + using editor = buildEditorFromExtensions([MarkdownShortcutTestExtension]); + typeMarkdown(editor, 'See \u00E9_here_'); + editor.read(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + assert($isParagraphNode(paragraph), 'Root child must be a paragraph'); + const textNodes = paragraph.getChildren().filter($isTextNode); + expect(textNodes.map(node => node.getTextContent())).toEqual([ + 'See \u00E9_here_', + ]); + expect(textNodes[0].hasFormat('italic')).toBe(false); + }); + }); +}); + describe('WRAPPING_PRESERVES_FORMAT', () => { test('**...** around already-bold text preserves bold', () => { // https://github.com/facebook/lexical/issues/8727 diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index a0b11843013..1f6b85a8e30 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -56,9 +56,13 @@ export function transformersByType(transformers: Transformer[]): Readonly<{ }; } -export const PUNCTUATION_OR_SPACE = /[!-/:-@[-`{-~\s]/; +// CommonMark counts every character in the Unicode P (punctuation) and S +// (symbol) general categories as punctuation, which includes all of the ASCII +// punctuation characters. Delimiter flanking depends on it. +// https://spec.commonmark.org/0.31.2/#unicode-punctuation-character +export const PUNCTUATION_OR_SPACE = /[\p{P}\p{S}\s]/u; export const WHITESPACE = /\s/; -export const PUNCTUATION = /[!"#$%&'()*+,\-./:;<=>?@[\]^_`{|}~]/; +export const PUNCTUATION = /[\p{P}\p{S}]/u; const MARKDOWN_EMPTY_LINE_REG_EXP = /^\s{0,3}$/; From 7c011374d6fcf16321ac6cedcf89296b59722327 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 06:33:03 +0000 Subject: [PATCH 02/70] [lexical-markdown] Bug Fix: Keep exported markers flanking beside punctuation, and read astral characters whole ## Description With Unicode punctuation (and the backslash) counted as punctuation on import, the exporter's intraword markers no longer round-trip when the formatted text starts or ends with punctuation next to a letter: plain `a` then bold `*` exported as `a**\***`, which CommonMark (and now the importer) reads as literal asterisks. Seven MarkdownEscaping tests from #9481 failed after merging main. The exporter now writes the letter beside such a marker as a numeric character reference (`a**\***`), whose `&` or `;` is punctuation, so the marker flanks. Flanking checks in import and the typing shortcuts also read the characters around a marker by code point, so an emoji (category So) outside the Basic Multilingual Plane counts as punctuation. ## Changeset `.changeset/markdown-unicode-punctuation-flanking.md` ## Test plan `./node_modules/.bin/vitest run --project unit packages/lexical-markdown` ### Before ``` Test Files 1 failed | 8 passed (9) Tests 7 failed | 844 passed (851) ``` (`MarkdownEscaping.test.ts > preserves existing formatted markers after plain text` and `preserves formatted backslashes next to plain text`, e.g. `expected 'a*****' to be 'a*'`.) ### After ``` Test Files 9 passed (9) Tests 860 passed (860) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 9 ++ .../lexical-markdown/src/MarkdownExport.ts | 122 +++++++++++++++++- .../lexical-markdown/src/MarkdownShortcuts.ts | 15 ++- .../__tests__/unit/LexicalMarkdown.test.ts | 102 +++++++++++++++ .../unit/MarkdownTransformers.test.ts | 2 + .../src/importTextFormatTransformer.ts | 10 +- packages/lexical-markdown/src/utils.ts | 18 +++ 7 files changed, 269 insertions(+), 9 deletions(-) create mode 100644 .changeset/markdown-unicode-punctuation-flanking.md diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md new file mode 100644 index 00000000000..f8483b046a4 --- /dev/null +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -0,0 +1,9 @@ +--- +'@lexical/markdown': patch +--- + +[fix] Treat Unicode punctuation and symbols as punctuation when deciding whether `*`, `_` and `~~` markers open or close formatting + +Markdown import and the typing shortcuts now follow CommonMark here: an en dash, a curly quote, a currency sign or an emoji next to a marker counts as punctuation, not as a letter, so `See –**[here](https://example.com)**` imports as a bold link. The backslash, which was missing from the ASCII list, counts too. + +Verify: export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through any CommonMark reader. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 09cbaf259c5..6c652139e9e 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -33,7 +33,14 @@ import { type TextMatchTransformer, type Transformer, } from './MarkdownTransformers'; -import {isEmptyParagraph, transformersByType} from './utils'; +import { + codePointAt, + codePointBefore, + isEmptyParagraph, + PUNCTUATION, + transformersByType, + WHITESPACE, +} from './utils'; /** * Renders string from markdown. The selection is moved to the start after the operation. @@ -626,12 +633,123 @@ function $exportTextFormat( closingTagsBefore + leadingSpace + openingTags + - trimmedOutput + + (isCode + ? trimmedOutput + : $guardWordEdges( + node, + trimmedOutput, + leadingSpace === '', + trailingSpace === '', + textTransformers, + selection, + )) + closingTagsAfter + trailingSpace ); } +/** + * A marker between a word character and punctuation can only open or close + * on the punctuation side (CommonMark flanking), so plain `a` before a bold + * `*` cannot go out as `a**\***`. A word character of `node`'s text that + * meets such a marker goes out as a numeric reference instead, whose `&` or + * `;` is punctuation: `a**\***`. + */ +function $guardWordEdges( + node: TextNode, + output: string, + startsAtEdge: boolean, + endsAtEdge: boolean, + textTransformers: TextFormatTransformer[], + selection: BaseSelection | null, +): string { + const prevNode = startsAtEdge + ? $exportedSibling(node, true, selection) + : null; + const nextNode = endsAtEdge ? $exportedSibling(node, false, selection) : null; + const first = codePointAt(output, 0); + const last = codePointBefore(output, output.length); + // A tag that `prevNode` has and `node` lacks closes between them. + const encodeFirst = + prevNode !== null && + isWordChar(first) && + hasTagMissingFrom(prevNode, node, textTransformers) && + PUNCTUATION.test($edgeChar(prevNode, selection, true)); + // A tag that `nextNode` has and `node` lacks opens between them. + const encodeLast = + nextNode !== null && + isWordChar(last) && + hasTagMissingFrom(nextNode, node, textTransformers) && + PUNCTUATION.test($edgeChar(nextNode, selection, false)); + let start = ''; + let end = ''; + let body = output; + if (encodeFirst) { + start = toCharacterReference(first!); + body = body.slice(first!.length); + } + if (encodeLast && body !== '') { + end = toCharacterReference(last!); + body = body.slice(0, -last!.length); + } + return start + body + end; +} + +function $exportedSibling( + node: TextNode, + backward: boolean, + selection: BaseSelection | null, +): TextNode | null { + const sibling = getTextSibling(node, backward); + return sibling && (!selection || sibling.isSelected(selection)) + ? sibling + : null; +} + +/** + * The exported character of `node` at its edge beside a tag, or `''` when + * `node` puts no tag there. + */ +function $edgeChar( + node: TextNode, + selection: BaseSelection | null, + atEnd: boolean, +): string { + const text = $exportedText(node, selection); + // Text that cannot hold a tag has no tag at its edges. + if (!canHoldTag(text)) { + return ''; + } + // Inline code goes out inside a backtick fence. + if (node.hasFormat('code')) { + return '`'; + } + return ( + (atEnd ? codePointBefore(text, text.length) : codePointAt(text, 0)) || '' + ); +} + +function hasTagMissingFrom( + node: TextNode, + other: TextNode, + textTransformers: TextFormatTransformer[], +): boolean { + return textTransformers.some( + ({format: [format]}) => + format !== 'code' && node.hasFormat(format) && !other.hasFormat(format), + ); +} + +function isWordChar(char: string | undefined): boolean { + return ( + char !== undefined && !WHITESPACE.test(char) && !PUNCTUATION.test(char) + ); +} + +function toCharacterReference(char: string): string { + return `&#${char.codePointAt(0)};`; +} + function getTextSibling(node: TextNode, backward: boolean): TextNode | null { const sibling = backward ? node.getPreviousSibling() : node.getNextSibling(); diff --git a/packages/lexical-markdown/src/MarkdownShortcuts.ts b/packages/lexical-markdown/src/MarkdownShortcuts.ts index 916c2ac926a..5d44bd98fb5 100644 --- a/packages/lexical-markdown/src/MarkdownShortcuts.ts +++ b/packages/lexical-markdown/src/MarkdownShortcuts.ts @@ -42,7 +42,13 @@ import { type Transformer, TRANSFORMERS, } from './MarkdownTransformers'; -import {indexBy, PUNCTUATION_OR_SPACE, transformersByType} from './utils'; +import { + codePointAt, + codePointBefore, + indexBy, + PUNCTUATION_OR_SPACE, + transformersByType, +} from './utils'; function runElementTransformers( parentNode: ElementNode, @@ -248,7 +254,7 @@ function $runTextFormatTransformers( } // Some tags can not be used within words, hence should have newline/space/punctuation after it - const afterCloseTagChar = textContent[closeTagEndIndex + 1]; + const afterCloseTagChar = codePointAt(textContent, closeTagEndIndex + 1); if ( matcher.intraword === false && @@ -313,7 +319,10 @@ function $runTextFormatTransformers( } // Some tags can not be used within words, hence should have newline/space/punctuation before it - const beforeOpenTagChar = prevOpenNodeText[openTagStartIndex - 1]; + const beforeOpenTagChar = codePointBefore( + prevOpenNodeText, + openTagStartIndex, + ); if ( matcher.intraword === false && diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 2388d840718..e8b7d2d675f 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -4800,6 +4800,108 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ).toBe(exported); }); } + + // Characters outside the Basic Multilingual Plane are read whole: an emoji + // is a symbol (So), so it is punctuation too. markdown-it agrees; micromark + // reads UTF-16 code units here. + const GRINNING_FACE = '\u{1F600}'; + it.each([ + [`a**${GRINNING_FACE}**`, [[`a**${GRINNING_FACE}**`, 'plain']]], + [`**${GRINNING_FACE}**a`, [[`**${GRINNING_FACE}**a`, 'plain']]], + [ + `a **${GRINNING_FACE}**`, + [ + ['a ', 'plain'], + [GRINNING_FACE, 'bold'], + ], + ], + ] as [string, Run[]][])('imports %j', (md, runs) => { + expect(importRuns(md)).toEqual(runs); + }); + + // A tag between a word character and punctuation cannot open or close, so + // the word character goes out as a numeric reference, whose `&` or `;` is + // punctuation. + it.each([ + [ + [ + ['foo', []], + [`${LEFT_DOUBLE_QUOTE}bar${RIGHT_DOUBLE_QUOTE}`, ['bold']], + ['baz', []], + ], + `foo**${LEFT_DOUBLE_QUOTE}bar${RIGHT_DOUBLE_QUOTE}**baz`, + ], + [ + [ + ['a', []], + [GRINNING_FACE, ['italic']], + ['b', []], + ], + `a*${GRINNING_FACE}*b`, + ], + [ + [ + ['a', []], + ['x', ['bold', 'code']], + ['b', []], + ], + 'a**`x`**b', + ], + [ + [ + ['x.', ['bold']], + ['b', []], + ['(y)', ['bold']], + ], + '**x.**b**(y)**', + ], + // A word character beside a word character needs no reference. + [ + [ + ['a', []], + ['b', ['bold']], + ['c', []], + ], + 'a**b**c', + ], + ] as [[string, TextFormatType[]][], string][])( + 'exports and reimports %j as %j', + (runs, exported) => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + ...runs.map(([text, formats]) => { + const node = $createTextNode(text); + formats.forEach(format => node.toggleFormat(format)); + return node; + }), + ), + ), + {discrete: true}, + ); + const markdown = editor.read('latest', () => + $convertToMarkdownString(TRANSFORMERS), + ); + expect(markdown).toBe(exported); + editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS), { + discrete: true, + }); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [ + node.getTextContent(), + (['bold', 'italic', 'code'] as const).filter(format => + node.hasFormat(format), + ), + ]), + ), + ).toEqual(runs); + }, + ); }); describe('decimal character references that name no character', () => { diff --git a/packages/lexical-markdown/src/__tests__/unit/MarkdownTransformers.test.ts b/packages/lexical-markdown/src/__tests__/unit/MarkdownTransformers.test.ts index 098a93f9c7a..dc89bd36bc3 100644 --- a/packages/lexical-markdown/src/__tests__/unit/MarkdownTransformers.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/MarkdownTransformers.test.ts @@ -299,6 +299,8 @@ describe('UNDERSCORE_AFTER_UNICODE_PUNCTUATION', () => { test.each([ [`See ${EN_DASH}_here_`, `See ${EN_DASH}`], [`See ${LEFT_DOUBLE_QUOTE}_here_`, `See ${LEFT_DOUBLE_QUOTE}`], + // An emoji (So) outside the Basic Multilingual Plane, read whole. + ['See \u{1F600}_here_', 'See \u{1F600}'], ])('typing %j formats the word between the underscores', (typed, before) => { using editor = buildEditorFromExtensions([MarkdownShortcutTestExtension]); typeMarkdown(editor, typed); diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index 7c159bc3010..8e121c09f59 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -10,7 +10,7 @@ import type {TextFormatTransformersIndex} from './MarkdownImport'; import type {TextFormatTransformer} from './MarkdownTransformers'; import type {TextNode} from 'lexical'; -import {PUNCTUATION, WHITESPACE} from './utils'; +import {codePointAt, codePointBefore, PUNCTUATION, WHITESPACE} from './utils'; interface Delimiter { index: number; @@ -396,7 +396,9 @@ function canEmphasis( if (!isFlanking(text, index, length, !isOpen)) { return true; } - const adjacentChar = isOpen ? text[index - 1] : text[index + length]; + const adjacentChar = isOpen + ? codePointBefore(text, index) + : codePointAt(text, index + length); return adjacentChar !== undefined && PUNCTUATION.test(adjacentChar); } return true; @@ -408,8 +410,8 @@ function isFlanking( length: number, isLeft: boolean, ): boolean { - const charBefore = text[index - 1]; - const charAfter = text[index + length]; + const charBefore = codePointBefore(text, index); + const charAfter = codePointAt(text, index + length); const [primary, secondary] = isLeft ? [charAfter, charBefore] diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index bfdef4d0d04..c550e001d18 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -64,6 +64,24 @@ export const PUNCTUATION_OR_SPACE = /[\p{P}\p{S}\s]/u; export const WHITESPACE = /\s/; export const PUNCTUATION = /[\p{P}\p{S}]/u; +/** The code point that starts at `index`, if any, whole when it is astral. */ +export function codePointAt(text: string, index: number): string | undefined { + const codePoint = text.codePointAt(index); + return codePoint === undefined ? undefined : String.fromCodePoint(codePoint); +} + +/** The code point that ends just before `index`, if any. */ +export function codePointBefore( + text: string, + index: number, +): string | undefined { + if (index < 1) { + return undefined; + } + const pair = index >= 2 ? text.slice(index - 2, index) : ''; + return /^[\uD800-\uDBFF][\uDC00-\uDFFF]$/.test(pair) ? pair : text[index - 1]; +} + const MARKDOWN_EMPTY_LINE_REG_EXP = /^\s{0,3}$/; export function isEmptyParagraph(node: LexicalNode): boolean { From 2e362325ac982df917b195352e8f74fd5b0734bf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 06:48:53 +0000 Subject: [PATCH 03/70] [lexical-markdown] Bug Fix: Decide export flanking from the emitted markers, and use CommonMark's Unicode whitespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The previous fix guessed where markers fall from neighbouring text nodes' formats, so it missed a tag closed and reopened around an outer tag's end (`***b****“y”*` lost the italic on “y”), text inside an inline element without a transformer, and inline decorators, and it fired where an unclosable tag wrote nothing. `$exportTextFormat` now writes each marker between sentinel noncharacters (U+FDD0 to U+FDD2, escaped as references when they appear in text), and the export resolves them once on the joined output: for each delimiter run, it looks at the characters a reader will see on either side and writes the word character beside a run that could not otherwise open or close as a numeric reference. Markers of different characters count as separate runs, as in CommonMark. `WHITESPACE` and `PUNCTUATION_OR_SPACE` now use CommonMark's Unicode whitespace (Zs plus tab, line feed, form feed and carriage return) instead of JavaScript's `\s`, which also counts U+FEFF and others. ## Test plan `./node_modules/.bin/vitest run --project unit packages/lexical-markdown` ### Before `***b****“y”*` export case: `expected '***b****“y”*' to be '***b****“y”*'` (markdown-it renders the first with literal asterisks around “y”). ### After ``` Test Files 9 passed (9) Tests 865 passed (865) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 6 +- .../lexical-markdown/src/MarkdownExport.ts | 211 ++++++++---------- .../lexical-markdown/src/MarkdownShortcuts.ts | 8 +- .../__tests__/unit/LexicalMarkdown.test.ts | 53 ++++- .../src/importTextFormatTransformer.ts | 10 +- packages/lexical-markdown/src/utils.ts | 28 ++- 6 files changed, 175 insertions(+), 141 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index f8483b046a4..b7d18202353 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -2,8 +2,8 @@ '@lexical/markdown': patch --- -[fix] Treat Unicode punctuation and symbols as punctuation when deciding whether `*`, `_` and `~~` markers open or close formatting +[fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_` and `~~` markers open or close formatting -Markdown import and the typing shortcuts now follow CommonMark here: an en dash, a curly quote, a currency sign or an emoji next to a marker counts as punctuation, not as a letter, so `See –**[here](https://example.com)**` imports as a bold link. The backslash, which was missing from the ASCII list, counts too. +Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or an emoji next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. The backslash, which was missing from the ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+FEFF no longer counts as whitespace. -Verify: export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through any CommonMark reader. +Verify: export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 6c652139e9e..b2c6f491b57 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -34,10 +34,11 @@ import { type Transformer, } from './MarkdownTransformers'; import { - codePointAt, - codePointBefore, + characterAt, + characterBefore, isEmptyParagraph, PUNCTUATION, + toCharacterReference, transformersByType, WHITESPACE, } from './utils'; @@ -94,7 +95,7 @@ export function createMarkdownExport( } // Ensure consecutive groups of texts are at least \n\n apart while each empty paragraph render as a newline. // Eg. ["hello", "", "", "hi", "\nworld"] -> "hello\n\n\nhi\n\nworld" - return output.join('\n'); + return resolveMarkers(output.join('\n')); }; } @@ -160,7 +161,7 @@ export function createSelectionMarkdownExport( previousExported = child; } } - return output.join('\n'); + return resolveMarkers(output.join('\n')); }; } @@ -526,12 +527,18 @@ function $exportTextFormat( // Default mode guards numeric references; named references keep legacy output. // Numeric escapes avoid changing emphasis flanking beside formatting. // Guard ambiguous '<' and '&' at node ends for constructs split across nodes. + // Marker sentinels in the text go out as references, so that only the + // markers this export writes read as markers. output = shouldPreserveNewLines - ? output.replace(/([*_`~])/g, '\\$1') - : output.replace(/([[\]*_`~\\]|<(?=\S|$)|&(?=#|$))/g, character => - /[*_`~\\]/.test(character) - ? `\\${character}` - : `&#${character.charCodeAt(0)};`, + ? output.replace(/([*_`~])|[\uFDD0-\uFDD2]/g, (character, marker) => + marker ? `\\${marker}` : toCharacterReference(character), + ) + : output.replace( + /([[\]*_`~\\]|<(?=\S|$)|&(?=#|$)|[\uFDD0-\uFDD2])/g, + character => + /[*_`~\\]/.test(character) + ? `\\${character}` + : toCharacterReference(character), ); } @@ -569,7 +576,7 @@ function $exportTextFormat( !unclosedTags.find(element => element.tag === tag) ) { unclosedTags.push({answers: new Map(), format, tag}); - openingTags += tag; + openingTags += OPENER + tag + MARKER_END; } } } @@ -600,9 +607,9 @@ function $exportTextFormat( if (!nodeHasFormat) { // Handles cases where the tag has not been closed before, e.g. if the previous node // was a text match transformer that did not account for closing tags of the next node (e.g. a link) - closingTagsBefore += unclosedTag.tag; + closingTagsBefore += CLOSER + unclosedTag.tag + MARKER_END; } else { - closingTagsAfter += unclosedTag.tag; + closingTagsAfter += CLOSER + unclosedTag.tag + MARKER_END; } } // Mutate the original array to remove the closed tag @@ -633,121 +640,93 @@ function $exportTextFormat( closingTagsBefore + leadingSpace + openingTags + - (isCode - ? trimmedOutput - : $guardWordEdges( - node, - trimmedOutput, - leadingSpace === '', - trailingSpace === '', - textTransformers, - selection, - )) + + trimmedOutput + closingTagsAfter + trailingSpace ); } -/** - * A marker between a word character and punctuation can only open or close - * on the punctuation side (CommonMark flanking), so plain `a` before a bold - * `*` cannot go out as `a**\***`. A word character of `node`'s text that - * meets such a marker goes out as a numeric reference instead, whose `&` or - * `;` is punctuation: `a**\***`. - */ -function $guardWordEdges( - node: TextNode, - output: string, - startsAtEdge: boolean, - endsAtEdge: boolean, - textTransformers: TextFormatTransformer[], - selection: BaseSelection | null, -): string { - const prevNode = startsAtEdge - ? $exportedSibling(node, true, selection) - : null; - const nextNode = endsAtEdge ? $exportedSibling(node, false, selection) : null; - const first = codePointAt(output, 0); - const last = codePointBefore(output, output.length); - // A tag that `prevNode` has and `node` lacks closes between them. - const encodeFirst = - prevNode !== null && - isWordChar(first) && - hasTagMissingFrom(prevNode, node, textTransformers) && - PUNCTUATION.test($edgeChar(prevNode, selection, true)); - // A tag that `nextNode` has and `node` lacks opens between them. - const encodeLast = - nextNode !== null && - isWordChar(last) && - hasTagMissingFrom(nextNode, node, textTransformers) && - PUNCTUATION.test($edgeChar(nextNode, selection, false)); - let start = ''; - let end = ''; - let body = output; - if (encodeFirst) { - start = toCharacterReference(first!); - body = body.slice(first!.length); - } - if (encodeLast && body !== '') { - end = toCharacterReference(last!); - body = body.slice(0, -last!.length); - } - return start + body + end; -} - -function $exportedSibling( - node: TextNode, - backward: boolean, - selection: BaseSelection | null, -): TextNode | null { - const sibling = getTextSibling(node, backward); - return sibling && (!selection || sibling.isSelected(selection)) - ? sibling - : null; +// Format markers are written between sentinels, noncharacters that Unicode +// keeps for internal use, until the whole export is joined, so that +// `resolveMarkers` sees each run of markers beside the characters a reader +// will see, whichever node, element or transformer wrote them. +const OPENER = '\uFDD0'; +const CLOSER = '\uFDD1'; +const MARKER_END = '\uFDD2'; +const MARKER_RUN = /((?:[\uFDD0\uFDD1][^\uFDD0-\uFDD2]*\uFDD2)+)/; +const MARKER = /([\uFDD0\uFDD1])([^\uFDD0-\uFDD2]*)\uFDD2/g; + +function isWordCharacter(character: string | undefined): boolean { + return ( + character !== undefined && + !WHITESPACE.test(character) && + !PUNCTUATION.test(character) + ); } /** - * The exported character of `node` at its edge beside a tag, or `''` when - * `node` puts no tag there. + * Removes the marker sentinels. A delimiter run between a word character and + * punctuation can only open or close on the punctuation side (CommonMark + * flanking), so plain `a` before a bold `*` cannot go out as `a**\***`. The + * word character beside such a run goes out as a numeric reference instead, + * whose `&` or `;` is punctuation: `a**\***`. */ -function $edgeChar( - node: TextNode, - selection: BaseSelection | null, - atEnd: boolean, -): string { - const text = $exportedText(node, selection); - // Text that cannot hold a tag has no tag at its edges. - if (!canHoldTag(text)) { - return ''; +function resolveMarkers(markdown: string): string { + // Text and marker runs alternate, starting and ending with text. + const parts = markdown.split(MARKER_RUN); + const encodeStart: boolean[] = []; + const encodeEnd: boolean[] = []; + for (let i = 1; i < parts.length; i += 2) { + // Markers of one character form one delimiter run; only the first and + // the last of them touch text. + const runs: {closes: boolean; markers: string; opens: boolean}[] = []; + for (const [, kind, tag] of parts[i].matchAll(MARKER)) { + let run = runs[runs.length - 1]; + if (!run || run.markers[0] !== tag[0]) { + run = {closes: false, markers: '', opens: false}; + runs.push(run); + } + run.markers += tag; + run.opens ||= kind === OPENER; + run.closes ||= kind === CLOSER; + } + const markers = runs.map(run => run.markers); + const first = runs[0]; + const last = runs[runs.length - 1]; + const before = characterBefore(parts[i - 1], parts[i - 1].length); + const after = characterAt(parts[i + 1], 0); + const afterFirst = runs.length > 1 ? markers[1][0] : after; + const beforeLast = + runs.length > 1 ? markers[runs.length - 2].slice(-1) : before; + encodeEnd[i - 1] = + first.opens && + isWordCharacter(before) && + afterFirst !== undefined && + PUNCTUATION.test(afterFirst); + encodeStart[i + 1] = + last.closes && + isWordCharacter(after) && + beforeLast !== undefined && + PUNCTUATION.test(beforeLast); + parts[i] = markers.join(''); } - // Inline code goes out inside a backtick fence. - if (node.hasFormat('code')) { - return '`'; + for (let i = 0; i < parts.length; i += 2) { + let text = parts[i]; + let start = ''; + let end = ''; + const first = characterAt(text, 0); + if (encodeStart[i] && first !== undefined) { + start = toCharacterReference(first); + text = text.slice(first.length); + } + const last = characterBefore(text, text.length); + if (encodeEnd[i] && last !== undefined) { + end = toCharacterReference(last); + text = text.slice(0, -last.length); + } + parts[i] = start + text + end; } - return ( - (atEnd ? codePointBefore(text, text.length) : codePointAt(text, 0)) || '' - ); -} - -function hasTagMissingFrom( - node: TextNode, - other: TextNode, - textTransformers: TextFormatTransformer[], -): boolean { - return textTransformers.some( - ({format: [format]}) => - format !== 'code' && node.hasFormat(format) && !other.hasFormat(format), - ); -} - -function isWordChar(char: string | undefined): boolean { - return ( - char !== undefined && !WHITESPACE.test(char) && !PUNCTUATION.test(char) - ); -} - -function toCharacterReference(char: string): string { - return `&#${char.codePointAt(0)};`; + return parts.join(''); } function getTextSibling(node: TextNode, backward: boolean): TextNode | null { diff --git a/packages/lexical-markdown/src/MarkdownShortcuts.ts b/packages/lexical-markdown/src/MarkdownShortcuts.ts index 5d44bd98fb5..74c77c7afec 100644 --- a/packages/lexical-markdown/src/MarkdownShortcuts.ts +++ b/packages/lexical-markdown/src/MarkdownShortcuts.ts @@ -43,8 +43,8 @@ import { TRANSFORMERS, } from './MarkdownTransformers'; import { - codePointAt, - codePointBefore, + characterAt, + characterBefore, indexBy, PUNCTUATION_OR_SPACE, transformersByType, @@ -254,7 +254,7 @@ function $runTextFormatTransformers( } // Some tags can not be used within words, hence should have newline/space/punctuation after it - const afterCloseTagChar = codePointAt(textContent, closeTagEndIndex + 1); + const afterCloseTagChar = characterAt(textContent, closeTagEndIndex + 1); if ( matcher.intraword === false && @@ -319,7 +319,7 @@ function $runTextFormatTransformers( } // Some tags can not be used within words, hence should have newline/space/punctuation before it - const beforeOpenTagChar = codePointBefore( + const beforeOpenTagChar = characterBefore( prevOpenNodeText, openTagStartIndex, ); diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index e8b7d2d675f..58326f1a7be 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -4815,6 +4815,12 @@ describe('emphasis next to Unicode punctuation and symbols', () => { [GRINNING_FACE, 'bold'], ], ], + // U+FEFF is a format character (Cf), not CommonMark whitespace. + // markdown-it agrees; micromark uses JavaScript's `\s`. + [ + `x\uFEFF**${LEFT_DOUBLE_QUOTE}a${RIGHT_DOUBLE_QUOTE}**`, + [[`x\uFEFF**${LEFT_DOUBLE_QUOTE}a${RIGHT_DOUBLE_QUOTE}**`, 'plain']], + ], ] as [string, Run[]][])('imports %j', (md, runs) => { expect(importRuns(md)).toEqual(runs); }); @@ -4855,6 +4861,24 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ], '**x.**b**(y)**', ], + // Italic closes and reopens around the bold's end. + [ + [ + ['b', ['bold', 'italic']], + [`${LEFT_DOUBLE_QUOTE}y${RIGHT_DOUBLE_QUOTE}`, ['italic']], + ], + '***b****“y”*', + ], + // Markers of different characters are separate delimiter runs. + [ + [ + ['a', ['bold']], + ['!', ['strikethrough']], + ], + '**a**~~!~~', + ], + // The sentinels that mark markers during export stay text. + [[['a\uFDD0**\uFDD2b', ['bold']]], '**a﷐\\*\\*﷒b**'], // A word character beside a word character needs no reference. [ [ @@ -4894,14 +4918,39 @@ describe('emphasis next to Unicode punctuation and symbols', () => { .getAllTextNodes() .map(node => [ node.getTextContent(), - (['bold', 'italic', 'code'] as const).filter(format => - node.hasFormat(format), + (['bold', 'italic', 'strikethrough', 'code'] as const).filter( + format => node.hasFormat(format), ), ]), ), ).toEqual(runs); }, ); + + it('guards word characters across an inline element without a transformer', () => { + const transformers = TRANSFORMERS.filter( + transformer => transformer !== LINK, + ); + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('foo'), + $createLinkNode('https://example.com').append( + $createTextNode( + `${LEFT_DOUBLE_QUOTE}bar${RIGHT_DOUBLE_QUOTE}`, + ).toggleFormat('bold'), + ), + $createTextNode('baz'), + ), + ), + {discrete: true}, + ); + expect( + editor.read('latest', () => $convertToMarkdownString(transformers)), + ).toBe(`foo**${LEFT_DOUBLE_QUOTE}bar${RIGHT_DOUBLE_QUOTE}**baz`); + }); }); describe('decimal character references that name no character', () => { diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index 8e121c09f59..f99c9723641 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -10,7 +10,7 @@ import type {TextFormatTransformersIndex} from './MarkdownImport'; import type {TextFormatTransformer} from './MarkdownTransformers'; import type {TextNode} from 'lexical'; -import {codePointAt, codePointBefore, PUNCTUATION, WHITESPACE} from './utils'; +import {characterAt, characterBefore, PUNCTUATION, WHITESPACE} from './utils'; interface Delimiter { index: number; @@ -397,8 +397,8 @@ function canEmphasis( return true; } const adjacentChar = isOpen - ? codePointBefore(text, index) - : codePointAt(text, index + length); + ? characterBefore(text, index) + : characterAt(text, index + length); return adjacentChar !== undefined && PUNCTUATION.test(adjacentChar); } return true; @@ -410,8 +410,8 @@ function isFlanking( length: number, isLeft: boolean, ): boolean { - const charBefore = codePointBefore(text, index); - const charAfter = codePointAt(text, index + length); + const charBefore = characterBefore(text, index); + const charAfter = characterAt(text, index + length); const [primary, secondary] = isLeft ? [charAfter, charBefore] diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index c550e001d18..8c4c8df778a 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -60,26 +60,32 @@ export function transformersByType(transformers: Transformer[]): Readonly<{ // (symbol) general categories as punctuation, which includes all of the ASCII // punctuation characters. Delimiter flanking depends on it. // https://spec.commonmark.org/0.31.2/#unicode-punctuation-character -export const PUNCTUATION_OR_SPACE = /[\p{P}\p{S}\s]/u; -export const WHITESPACE = /\s/; +// CommonMark's Unicode whitespace is Zs plus tab, line feed, form feed and +// carriage return; JavaScript's `\s` adds others, such as U+FEFF. +// https://spec.commonmark.org/0.31.2/#unicode-whitespace-character +export const PUNCTUATION_OR_SPACE = /[\p{P}\p{S}\p{Zs}\t\n\f\r]/u; +export const WHITESPACE = /[\p{Zs}\t\n\f\r]/u; export const PUNCTUATION = /[\p{P}\p{S}]/u; -/** The code point that starts at `index`, if any, whole when it is astral. */ -export function codePointAt(text: string, index: number): string | undefined { +/** The character (code point) that starts at `index`, if any. */ +export function characterAt(text: string, index: number): string | undefined { const codePoint = text.codePointAt(index); return codePoint === undefined ? undefined : String.fromCodePoint(codePoint); } -/** The code point that ends just before `index`, if any. */ -export function codePointBefore( +/** The character (code point) that ends just before `index`, if any. */ +export function characterBefore( text: string, index: number, ): string | undefined { - if (index < 1) { - return undefined; - } - const pair = index >= 2 ? text.slice(index - 2, index) : ''; - return /^[\uD800-\uDBFF][\uDC00-\uDFFF]$/.test(pair) ? pair : text[index - 1]; + const codePoint = index >= 2 ? text.codePointAt(index - 2) : undefined; + return codePoint !== undefined && codePoint > 0xffff + ? String.fromCodePoint(codePoint) + : text[index - 1]; +} + +export function toCharacterReference(character: string): string { + return `&#${character.codePointAt(0)};`; } const MARKDOWN_EMPTY_LINE_REG_EXP = /^\s{0,3}$/; From 3ce37b6559ef6b791a0490e5f3ff52cd215215d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 07:04:19 +0000 Subject: [PATCH 04/70] [lexical-markdown] Bug Fix: Keep export markers out of band while deciding flanking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The previous commit marked each format marker with sentinel noncharacters in the exported string. Those leaked into the strings that `exportChildren` and `exportFormat` hand to custom transformers, collided with the same characters in code spans, URLs and decorator text, and let the guard rewrite characters that a transformer wrote. `$exportTextFormat` and the child exporters now return pieces (escaped text, raw output, openers and closers), and `joinPieces` turns them into Markdown wherever a string leaves the exporter: the transformer callbacks and the element exports. Only text this export escaped is written as a reference. When newlines are preserved, a raw backslash before the referenced character goes out as `\` so that it does not escape the reference. Unpaired surrogates are left alone, since their references read back as U+FFFD. `escapeLinkPart` shares `toCharacterReference`. ## Test plan `./node_modules/.bin/vitest run --project unit packages/lexical-markdown` ### Before The new preserve-newlines case exported `x\b**“y”**`, which re-imports as `xb`. ### After ``` Test Files 9 passed (9) Tests 866 passed (866) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 369 +++++++++++------- .../src/MarkdownTransformers.ts | 9 +- .../__tests__/unit/LexicalMarkdown.test.ts | 60 ++- 3 files changed, 293 insertions(+), 145 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index b2c6f491b57..067438bd829 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -38,9 +38,9 @@ import { characterBefore, isEmptyParagraph, PUNCTUATION, + PUNCTUATION_OR_SPACE, toCharacterReference, transformersByType, - WHITESPACE, } from './utils'; /** @@ -95,7 +95,7 @@ export function createMarkdownExport( } // Ensure consecutive groups of texts are at least \n\n apart while each empty paragraph render as a newline. // Eg. ["hello", "", "", "hi", "\nworld"] -> "hello\n\n\nhi\n\nworld" - return resolveMarkers(output.join('\n')); + return output.join('\n'); }; } @@ -161,7 +161,7 @@ export function createSelectionMarkdownExport( previousExported = child; } } - return resolveMarkers(output.join('\n')); + return output.join('\n'); }; } @@ -183,13 +183,15 @@ function $processNodeForSelection( const result = transformer.export( node, node_ => - $exportChildrenForSelection( - node_, - selection, - textFormatTransformers, - textMatchTransformers, - shouldPreserveNewLines, - ).markdown, + joinPieces( + $exportChildrenForSelection( + node_, + selection, + textFormatTransformers, + textMatchTransformers, + shouldPreserveNewLines, + ).pieces, + ), selection, ); @@ -222,7 +224,7 @@ function $processNodeForSelection( shouldPreserveNewLines, ); return { - markdown: childResult.markdown, + markdown: joinPieces(childResult.pieces), shouldInclude: shouldInclude || childResult.shouldInclude, }; } else if ($isDecoratorNode(node)) { @@ -247,8 +249,8 @@ function $exportChildrenForSelection( shouldPreserveNewLines: boolean, unclosedTags?: OpenTag[], unclosableTags?: OpenTag[], -): {shouldInclude: boolean; markdown: string} { - const output = []; +): {shouldInclude: boolean; pieces: MarkdownPiece[]} { + const output: MarkdownPiece[] = []; const children = node.getChildren(); let anyChildIncluded = false; @@ -271,24 +273,28 @@ function $exportChildrenForSelection( const result = transformer.export( child, parentNode => - $exportChildrenForSelection( - parentNode, - selection, - textFormatTransformers, - textMatchTransformers, - shouldPreserveNewLines, - unclosedTags, - [...unclosableTags, ...unclosedTags], - ).markdown, + joinPieces( + $exportChildrenForSelection( + parentNode, + selection, + textFormatTransformers, + textMatchTransformers, + shouldPreserveNewLines, + unclosedTags, + [...unclosableTags, ...unclosedTags], + ).pieces, + ), textNode => - $exportTextFormat( - textNode, - $exportedText(textNode, selection), - textFormatTransformers, - unclosedTags, - unclosableTags, - shouldPreserveNewLines, - selection, + joinPieces( + $exportTextFormat( + textNode, + $exportedText(textNode, selection), + textFormatTransformers, + unclosedTags, + unclosableTags, + shouldPreserveNewLines, + selection, + ), ), ); @@ -303,7 +309,7 @@ function $exportChildrenForSelection( childIncluded = true; } if (childIncluded) { - output.push(result); + output.push(rawPiece(result)); anyChildIncluded = true; } continue mainLoop; @@ -312,12 +318,13 @@ function $exportChildrenForSelection( if ($isLineBreakNode(child)) { if (childIncluded) { - output.push($exportLineBreak(child)); + output.push(rawPiece($exportLineBreak(child))); anyChildIncluded = true; } } else if ($isTextNode(child)) { if (childIncluded) { - output.push( + appendPieces( + output, $exportTextFormat( child, $exportedText(child, selection), @@ -351,18 +358,18 @@ function $exportChildrenForSelection( } if (childIncluded || childResult.shouldInclude) { - output.push(childResult.markdown); + appendPieces(output, childResult.pieces); anyChildIncluded = true; } } else if ($isDecoratorNode(child)) { if (childIncluded) { - output.push(child.getTextContent()); + output.push(rawPiece(child.getTextContent())); anyChildIncluded = true; } } } - return {markdown: output.join(''), shouldInclude: anyChildIncluded}; + return {pieces: output, shouldInclude: anyChildIncluded}; } function $exportTopLevelElements( @@ -416,7 +423,27 @@ function $exportChildren( unclosableTags?: OpenTag[], shouldPreserveNewLines: boolean = false, ): string { - const output = []; + return joinPieces( + $exportChildPieces( + node, + textTransformersIndex, + textMatchTransformers, + unclosedTags, + unclosableTags, + shouldPreserveNewLines, + ), + ); +} + +function $exportChildPieces( + node: ElementNode, + textTransformersIndex: TextFormatTransformer[], + textMatchTransformers: TextMatchTransformer[], + unclosedTags?: OpenTag[], + unclosableTags?: OpenTag[], + shouldPreserveNewLines: boolean = false, +): MarkdownPiece[] { + const output: MarkdownPiece[] = []; const children = node.getChildren(); // keep track of unclosed tags from the very beginning if (!unclosedTags) { @@ -449,27 +476,30 @@ function $exportChildren( shouldPreserveNewLines, ), (textNode, textContent) => - $exportTextFormat( - textNode, - textContent, - textTransformersIndex, - unclosedTags, - unclosableTags, - shouldPreserveNewLines, - null, + joinPieces( + $exportTextFormat( + textNode, + textContent, + textTransformersIndex, + unclosedTags, + unclosableTags, + shouldPreserveNewLines, + null, + ), ), ); if (result != null) { - output.push(result); + output.push(rawPiece(result)); continue mainLoop; } } if ($isLineBreakNode(child)) { - output.push($exportLineBreak(child)); + output.push(rawPiece($exportLineBreak(child))); } else if ($isTextNode(child)) { - output.push( + appendPieces( + output, $exportTextFormat( child, child.getTextContent(), @@ -482,8 +512,9 @@ function $exportChildren( ); } else if ($isElementNode(child)) { // empty paragraph returns "" - output.push( - $exportChildren( + appendPieces( + output, + $exportChildPieces( child, textTransformersIndex, textMatchTransformers, @@ -493,11 +524,11 @@ function $exportChildren( ), ); } else if ($isDecoratorNode(child)) { - output.push(child.getTextContent()); + output.push(rawPiece(child.getTextContent())); } } - return output.join(''); + return output; } function $exportLineBreak(node: LineBreakNode): string { @@ -514,7 +545,7 @@ function $exportTextFormat( shouldPreserveNewLines: boolean, // the selection being exported, which ends the text a tag can extend over selection: BaseSelection | null, -): string { +): MarkdownPiece[] { // This function handles the case of a string looking like this: " foo " // Where it would be invalid markdown to generate: "** foo **" // so the tags go inside the whitespace instead: " **foo** ". @@ -527,28 +558,22 @@ function $exportTextFormat( // Default mode guards numeric references; named references keep legacy output. // Numeric escapes avoid changing emphasis flanking beside formatting. // Guard ambiguous '<' and '&' at node ends for constructs split across nodes. - // Marker sentinels in the text go out as references, so that only the - // markers this export writes read as markers. output = shouldPreserveNewLines - ? output.replace(/([*_`~])|[\uFDD0-\uFDD2]/g, (character, marker) => - marker ? `\\${marker}` : toCharacterReference(character), - ) - : output.replace( - /([[\]*_`~\\]|<(?=\S|$)|&(?=#|$)|[\uFDD0-\uFDD2])/g, - character => - /[*_`~\\]/.test(character) - ? `\\${character}` - : toCharacterReference(character), + ? output.replace(/([*_`~])/g, '\\$1') + : output.replace(/([[\]*_`~\\]|<(?=\S|$)|&(?=#|$))/g, character => + /[*_`~\\]/.test(character) + ? `\\${character}` + : toCharacterReference(character), ); } const holdsTag = canHoldTag(textContent); // the opening tags to be added to the result - let openingTags = ''; + const openingTags: MarkdownPiece[] = []; // the closing tags to be added to the result - let closingTagsBefore = ''; - let closingTagsAfter = ''; + const closingTagsBefore: MarkdownPiece[] = []; + const closingTagsAfter: MarkdownPiece[] = []; const prevNode = getTextSibling(node, true); @@ -576,7 +601,7 @@ function $exportTextFormat( !unclosedTags.find(element => element.tag === tag) ) { unclosedTags.push({answers: new Map(), format, tag}); - openingTags += OPENER + tag + MARKER_END; + openingTags.push({kind: 'opener', value: tag}); } } } @@ -607,9 +632,9 @@ function $exportTextFormat( if (!nodeHasFormat) { // Handles cases where the tag has not been closed before, e.g. if the previous node // was a text match transformer that did not account for closing tags of the next node (e.g. a link) - closingTagsBefore += CLOSER + unclosedTag.tag + MARKER_END; + closingTagsBefore.push({kind: 'closer', value: unclosedTag.tag}); } else { - closingTagsAfter += CLOSER + unclosedTag.tag + MARKER_END; + closingTagsAfter.push({kind: 'closer', value: unclosedTag.tag}); } } // Mutate the original array to remove the closed tag @@ -625,7 +650,7 @@ function $exportTextFormat( } else if (!holdsTag) { // If the node is entirely whitespace, we don't apply opening/closing tags // around it, but it must still output any closing tags. - return closingTagsBefore + closingTagsAfter + output; + return [...closingTagsBefore, ...closingTagsAfter, textPiece(output)]; } // Flanking Compliance: CommonMark flanking rules require formatting tags to be @@ -636,97 +661,159 @@ function $exportTextFormat( const trimmedOutput = trimmedStart.trimEnd(); const leadingSpace = output.slice(0, output.length - trimmedStart.length); const trailingSpace = trimmedStart.slice(trimmedOutput.length); + return [ + ...closingTagsBefore, + textPiece(leadingSpace), + ...openingTags, + // Inline code is a literal span: a character reference would show as is. + isCode ? rawPiece(trimmedOutput) : textPiece(trimmedOutput), + ...closingTagsAfter, + textPiece(trailingSpace), + ]; +} + +/** + * Exported inline Markdown, with the format markers kept apart from the text + * until `joinPieces` can see the characters on both sides of each delimiter + * run. `text` is text this export escaped, which a character reference can + * stand in for; `raw` is anything else, such as code spans, line breaks and + * the output of transformers and decorators. + */ +interface MarkdownPiece { + kind: 'text' | 'raw' | 'opener' | 'closer'; + value: string; +} + +function textPiece(value: string): MarkdownPiece { + return {kind: 'text', value}; +} + +function rawPiece(value: string): MarkdownPiece { + return {kind: 'raw', value}; +} + +function appendPieces(output: MarkdownPiece[], pieces: MarkdownPiece[]) { + for (const piece of pieces) { + output.push(piece); + } +} + +function isMarker(piece: MarkdownPiece | undefined): boolean { return ( - closingTagsBefore + - leadingSpace + - openingTags + - trimmedOutput + - closingTagsAfter + - trailingSpace + piece !== undefined && (piece.kind === 'opener' || piece.kind === 'closer') ); } -// Format markers are written between sentinels, noncharacters that Unicode -// keeps for internal use, until the whole export is joined, so that -// `resolveMarkers` sees each run of markers beside the characters a reader -// will see, whichever node, element or transformer wrote them. -const OPENER = '\uFDD0'; -const CLOSER = '\uFDD1'; -const MARKER_END = '\uFDD2'; -const MARKER_RUN = /((?:[\uFDD0\uFDD1][^\uFDD0-\uFDD2]*\uFDD2)+)/; -const MARKER = /([\uFDD0\uFDD1])([^\uFDD0-\uFDD2]*)\uFDD2/g; - +/** Whether a character reference can stand in for `character` beside a marker. */ function isWordCharacter(character: string | undefined): boolean { return ( character !== undefined && - !WHITESPACE.test(character) && - !PUNCTUATION.test(character) + !PUNCTUATION_OR_SPACE.test(character) && + // An unpaired surrogate reads back from a reference as U+FFFD. + !/\p{Cs}/u.test(character) ); } /** - * Removes the marker sentinels. A delimiter run between a word character and - * punctuation can only open or close on the punctuation side (CommonMark - * flanking), so plain `a` before a bold `*` cannot go out as `a**\***`. The - * word character beside such a run goes out as a numeric reference instead, - * whose `&` or `;` is punctuation: `a**\***`. + * Joins exported pieces into Markdown. A delimiter run between a word + * character and punctuation can only open or close on the punctuation side + * (CommonMark flanking), so plain `a` before a bold `*` cannot go out as + * `a**\***`. The word character beside such a run goes out as a numeric + * reference instead, whose `&` or `;` is punctuation: `a**\***`. */ -function resolveMarkers(markdown: string): string { - // Text and marker runs alternate, starting and ending with text. - const parts = markdown.split(MARKER_RUN); - const encodeStart: boolean[] = []; - const encodeEnd: boolean[] = []; - for (let i = 1; i < parts.length; i += 2) { - // Markers of one character form one delimiter run; only the first and - // the last of them touch text. - const runs: {closes: boolean; markers: string; opens: boolean}[] = []; - for (const [, kind, tag] of parts[i].matchAll(MARKER)) { +function joinPieces(pieces: MarkdownPiece[]): string { + // Drop empty pieces and merge adjacent text, so that each run of markers + // sits between the pieces whose characters a reader sees beside it. + const merged: MarkdownPiece[] = []; + for (const piece of pieces) { + const last = merged[merged.length - 1]; + if (piece.value === '') { + continue; + } else if (last && !isMarker(piece) && last.kind === piece.kind) { + merged[merged.length - 1] = { + kind: last.kind, + value: last.value + piece.value, + }; + } else { + merged.push(piece); + } + } + const encodeStart = new Set(); + const encodeEnd = new Set(); + for (let start = 0; start < merged.length; start++) { + if (!isMarker(merged[start])) { + continue; + } + let end = start; + while (isMarker(merged[end + 1])) { + end++; + } + // Markers of one character form one delimiter run, and only the first + // and the last run touch text. + const runs: {closes: boolean; opens: boolean; value: string}[] = []; + for (let i = start; i <= end; i++) { + const {kind, value} = merged[i]; let run = runs[runs.length - 1]; - if (!run || run.markers[0] !== tag[0]) { - run = {closes: false, markers: '', opens: false}; + if (!run || run.value[0] !== value[0]) { + run = {closes: false, opens: false, value: ''}; runs.push(run); } - run.markers += tag; - run.opens ||= kind === OPENER; - run.closes ||= kind === CLOSER; + run.value += value; + run.opens ||= kind === 'opener'; + run.closes ||= kind === 'closer'; } - const markers = runs.map(run => run.markers); - const first = runs[0]; - const last = runs[runs.length - 1]; - const before = characterBefore(parts[i - 1], parts[i - 1].length); - const after = characterAt(parts[i + 1], 0); - const afterFirst = runs.length > 1 ? markers[1][0] : after; + const firstOpens = runs[0].opens; + const lastCloses = runs[runs.length - 1].closes; + const before = merged[start - 1]; + const after = merged[end + 1]; + const charBefore = + before && characterBefore(before.value, before.value.length); + const charAfter = after && characterAt(after.value, 0); + const afterFirst = runs.length > 1 ? runs[1].value[0] : charAfter; const beforeLast = - runs.length > 1 ? markers[runs.length - 2].slice(-1) : before; - encodeEnd[i - 1] = - first.opens && - isWordCharacter(before) && - afterFirst !== undefined && - PUNCTUATION.test(afterFirst); - encodeStart[i + 1] = - last.closes && - isWordCharacter(after) && - beforeLast !== undefined && - PUNCTUATION.test(beforeLast); - parts[i] = markers.join(''); + runs.length > 1 ? runs[runs.length - 2].value.slice(-1) : charBefore; + if ( + before !== undefined && + before.kind === 'text' && + firstOpens && + isWordCharacter(charBefore) && + PUNCTUATION.test(afterFirst ?? '') + ) { + encodeEnd.add(before); + } + if ( + after !== undefined && + after.kind === 'text' && + lastCloses && + isWordCharacter(charAfter) && + PUNCTUATION.test(beforeLast ?? '') + ) { + encodeStart.add(after); + } + start = end; } - for (let i = 0; i < parts.length; i += 2) { - let text = parts[i]; - let start = ''; - let end = ''; - const first = characterAt(text, 0); - if (encodeStart[i] && first !== undefined) { - start = toCharacterReference(first); - text = text.slice(first.length); + let markdown = ''; + for (const piece of merged) { + let {value} = piece; + if (encodeStart.has(piece)) { + const first = characterAt(value, 0)!; + value = toCharacterReference(first) + value.slice(first.length); } - const last = characterBefore(text, text.length); - if (encodeEnd[i] && last !== undefined) { - end = toCharacterReference(last); - text = text.slice(0, -last.length); + // A reference already written for the start counts as punctuation. + const last = characterBefore(value, value.length)!; + if (encodeEnd.has(piece) && isWordCharacter(last)) { + let body = value.slice(0, -last.length); + // A backslash left unescaped (when preserving newlines) would escape + // the reference's `&`, so it goes out as a reference too. + const backslashes = body.length - body.replace(/\\+$/, '').length; + if (backslashes % 2 === 1) { + body = body.slice(0, -1) + toCharacterReference('\\'); + } + value = body + toCharacterReference(last); } - parts[i] = start + text + end; + markdown += value; } - return parts.join(''); + return markdown; } function getTextSibling(node: TextNode, backward: boolean): TextNode | null { diff --git a/packages/lexical-markdown/src/MarkdownTransformers.ts b/packages/lexical-markdown/src/MarkdownTransformers.ts index 0fc245c81b5..ccc5becd5fa 100644 --- a/packages/lexical-markdown/src/MarkdownTransformers.ts +++ b/packages/lexical-markdown/src/MarkdownTransformers.ts @@ -50,7 +50,12 @@ import { } from 'lexical'; import {scanCodeSpans} from './importTextFormatTransformer'; -import {isEmptyParagraph, isEscaped, unescapeText} from './utils'; +import { + isEmptyParagraph, + isEscaped, + toCharacterReference, + unescapeText, +} from './utils'; export type Transformer = | ElementTransformer @@ -1190,7 +1195,7 @@ function escapeLinkPart(value: string, syntax: RegExp): string { .replace(/&(?=#\d+;|#[Xx][\dA-Fa-f]+;|[A-Za-z][\dA-Za-z]*;)/g, '&') .replace(syntax, '\\$&') .replace(/`/g, '\\`') - .replace(/[\r\n]/g, lineEnding => `&#${lineEnding.charCodeAt(0)};`); + .replace(/[\r\n]/g, toCharacterReference); } // Mask escaped/code brackets without changing source offsets, then find an inner diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 58326f1a7be..4a6b0dcb262 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -4877,8 +4877,6 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ], '**a**~~!~~', ], - // The sentinels that mark markers during export stay text. - [[['a\uFDD0**\uFDD2b', ['bold']]], '**a﷐\\*\\*﷒b**'], // A word character beside a word character needs no reference. [ [ @@ -4927,6 +4925,64 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it('guards a word character after a raw backslash when preserving newlines', () => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('x\\b'), + $createTextNode( + `${LEFT_DOUBLE_QUOTE}y${RIGHT_DOUBLE_QUOTE}`, + ).toggleFormat('bold'), + ), + ), + {discrete: true}, + ); + const markdown = editor.read('latest', () => + $convertToMarkdownString(TRANSFORMERS, undefined, true), + ); + expect(markdown).toBe( + `x\b**${LEFT_DOUBLE_QUOTE}y${RIGHT_DOUBLE_QUOTE}**`, + ); + editor.update( + () => $convertFromMarkdownString(markdown, TRANSFORMERS, undefined, true), + {discrete: true}, + ); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.hasFormat('bold')]), + ), + ).toEqual([ + ['x\\b', false], + [`${LEFT_DOUBLE_QUOTE}y${RIGHT_DOUBLE_QUOTE}`, true], + ]); + }); + + it('guards word characters in a selection export', () => { + const editor = createTestEditor(); + editor.update( + () => { + const bold = $createTextNode( + `${LEFT_DOUBLE_QUOTE}bar${RIGHT_DOUBLE_QUOTE}`, + ).toggleFormat('bold'); + const after = $createTextNode('baz'); + $getRoot().append( + $createParagraphNode().append($createTextNode('foo'), bold, after), + ); + bold.select(0, 0).focus.set(after.getKey(), 2, 'text'); + }, + {discrete: true}, + ); + expect( + editor.read('latest', () => + $convertSelectionToMarkdownString(TRANSFORMERS, $getSelection()), + ), + ).toBe(`**${LEFT_DOUBLE_QUOTE}bar${RIGHT_DOUBLE_QUOTE}**ba`); + }); + it('guards word characters across an inline element without a transformer', () => { const transformers = TRANSFORMERS.filter( transformer => transformer !== LINK, From 9fcef26f7dd13c7e39102d1afe84b5122d7c02bf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 07:14:20 +0000 Subject: [PATCH 05/70] [lexical-markdown] Bug Fix: Re-decide a marker run when a reference changes its neighbour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `joinPieces` decided each marker run against the characters before any reference was written. A one-character piece written as a reference for one run reads as punctuation at both ends, which could stop the run on its other side from opening or closing (`x**a***“b”*` lost the bold). Runs beside such a piece are now decided again, through a worklist so each piece is revisited at most twice and the join stays linear. Decorator text now counts as text a reference can stand in for, U+0000 is left alone like unpaired surrogates (both read back as U+FFFD), and the changeset no longer implies every emoji counts as punctuation. ## Test plan `./node_modules/.bin/vitest run --project unit packages/lexical-markdown` ### Before `[plain 'x', bold 'a', italic '“b”']` exported as `x**a***“b”*`, which re-imports with the bold lost. ### After ``` Test Files 9 passed (9) Tests 869 passed (869) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 4 +- .../lexical-markdown/src/MarkdownExport.ts | 156 ++++++++++++------ .../__tests__/unit/LexicalMarkdown.test.ts | 26 +++ 3 files changed, 132 insertions(+), 54 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index b7d18202353..2c7df9b9df0 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -4,6 +4,6 @@ [fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_` and `~~` markers open or close formatting -Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or an emoji next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. The backslash, which was missing from the ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+FEFF no longer counts as whitespace. +Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. The backslash, which was missing from the ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+FEFF no longer counts as whitespace. -Verify: export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. +Verify: export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 067438bd829..0b1caeebca9 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -363,7 +363,7 @@ function $exportChildrenForSelection( } } else if ($isDecoratorNode(child)) { if (childIncluded) { - output.push(rawPiece(child.getTextContent())); + output.push(textPiece(child.getTextContent())); anyChildIncluded = true; } } @@ -524,7 +524,7 @@ function $exportChildPieces( ), ); } else if ($isDecoratorNode(child)) { - output.push(rawPiece(child.getTextContent())); + output.push(textPiece(child.getTextContent())); } } @@ -675,9 +675,10 @@ function $exportTextFormat( /** * Exported inline Markdown, with the format markers kept apart from the text * until `joinPieces` can see the characters on both sides of each delimiter - * run. `text` is text this export escaped, which a character reference can - * stand in for; `raw` is anything else, such as code spans, line breaks and - * the output of transformers and decorators. + * run. A character reference can stand in for a character of `text`, which + * is text and decorator content; `raw` is anything else, such as code spans, + * line breaks and the output of transformers, whose syntax a reference could + * break. */ interface MarkdownPiece { kind: 'text' | 'raw' | 'opener' | 'closer'; @@ -704,16 +705,32 @@ function isMarker(piece: MarkdownPiece | undefined): boolean { ); } +// U+0000 and unpaired surrogates read back from a reference as U+FFFD. +const UNREFERENCEABLE = /[\0\p{Cs}]/u; + /** Whether a character reference can stand in for `character` beside a marker. */ function isWordCharacter(character: string | undefined): boolean { return ( character !== undefined && !PUNCTUATION_OR_SPACE.test(character) && - // An unpaired surrogate reads back from a reference as U+FFFD. - !/\p{Cs}/u.test(character) + !UNREFERENCEABLE.test(character) ); } +/** Adjacent markers, between the pieces at `before` and `after`. */ +interface MarkerRun { + before: number; + after: number; + /** Whether the first delimiter run opens a tag. */ + opens: boolean; + /** Whether the last delimiter run closes a tag. */ + closes: boolean; + /** The marker character after the first delimiter run, if any. */ + afterFirst: string | undefined; + /** The marker character before the last delimiter run, if any. */ + beforeLast: string | undefined; +} + /** * Joins exported pieces into Markdown. A delimiter run between a word * character and punctuation can only open or close on the punctuation side @@ -738,8 +755,10 @@ function joinPieces(pieces: MarkdownPiece[]): string { merged.push(piece); } } - const encodeStart = new Set(); - const encodeEnd = new Set(); + const runs: MarkerRun[] = []; + // The run that follows and the run that precedes each piece. + const runAfter: number[] = []; + const runBefore: number[] = []; for (let start = 0; start < merged.length; start++) { if (!isMarker(merged[start])) { continue; @@ -749,67 +768,100 @@ function joinPieces(pieces: MarkdownPiece[]): string { end++; } // Markers of one character form one delimiter run, and only the first - // and the last run touch text. - const runs: {closes: boolean; opens: boolean; value: string}[] = []; + // and the last delimiter run touch text. + const groups: {closes: boolean; opens: boolean; value: string}[] = []; for (let i = start; i <= end; i++) { const {kind, value} = merged[i]; - let run = runs[runs.length - 1]; - if (!run || run.value[0] !== value[0]) { - run = {closes: false, opens: false, value: ''}; - runs.push(run); + let group = groups[groups.length - 1]; + if (!group || group.value[0] !== value[0]) { + group = {closes: false, opens: false, value: ''}; + groups.push(group); } - run.value += value; - run.opens ||= kind === 'opener'; - run.closes ||= kind === 'closer'; + group.value += value; + group.opens ||= kind === 'opener'; + group.closes ||= kind === 'closer'; } - const firstOpens = runs[0].opens; - const lastCloses = runs[runs.length - 1].closes; - const before = merged[start - 1]; - const after = merged[end + 1]; - const charBefore = - before && characterBefore(before.value, before.value.length); - const charAfter = after && characterAt(after.value, 0); - const afterFirst = runs.length > 1 ? runs[1].value[0] : charAfter; - const beforeLast = - runs.length > 1 ? runs[runs.length - 2].value.slice(-1) : charBefore; + const last = groups.length - 1; + runAfter[start - 1] = runBefore[end + 1] = runs.length; + runs.push({ + after: end + 1, + afterFirst: last > 0 ? groups[1].value[0] : undefined, + before: start - 1, + beforeLast: last > 0 ? groups[last - 1].value.slice(-1) : undefined, + closes: groups[last].closes, + opens: groups[0].opens, + }); + start = end; + } + const encodeStart: boolean[] = []; + const encodeEnd: boolean[] = []; + const isText = (index: number) => + index >= 0 && index < merged.length && merged[index].kind === 'text'; + // A one-character piece written as a reference reads as punctuation at + // both ends: `a`. + const isEncoded = (index: number) => + (encodeStart[index] || encodeEnd[index]) && + characterAt(merged[index].value, 0)!.length === merged[index].value.length; + const startChar = (index: number) => + index >= merged.length + ? undefined + : encodeStart[index] || isEncoded(index) + ? '&' + : characterAt(merged[index].value, 0); + const endChar = (index: number) => + index < 0 + ? undefined + : encodeEnd[index] || isEncoded(index) + ? ';' + : characterBefore(merged[index].value, merged[index].value.length); + // Writing a one-character piece as a reference changes what the run on its + // other side sees, so that run is decided again. Each piece changes at most + // twice, so this stays linear. + const pending = runs.map((_run, index) => index); + while (pending.length > 0) { + const {before, after, opens, closes, afterFirst, beforeLast} = + runs[pending.pop()!]; if ( - before !== undefined && - before.kind === 'text' && - firstOpens && - isWordCharacter(charBefore) && - PUNCTUATION.test(afterFirst ?? '') + opens && + isText(before) && + isWordCharacter(endChar(before)) && + PUNCTUATION.test(afterFirst || startChar(after) || '') ) { - encodeEnd.add(before); + encodeEnd[before] = true; + if (isEncoded(before) && runBefore[before] !== undefined) { + pending.push(runBefore[before]); + } } if ( - after !== undefined && - after.kind === 'text' && - lastCloses && - isWordCharacter(charAfter) && - PUNCTUATION.test(beforeLast ?? '') + closes && + isText(after) && + isWordCharacter(startChar(after)) && + PUNCTUATION.test(beforeLast || endChar(before) || '') ) { - encodeStart.add(after); + encodeStart[after] = true; + if (isEncoded(after) && runAfter[after] !== undefined) { + pending.push(runAfter[after]); + } } - start = end; } let markdown = ''; - for (const piece of merged) { - let {value} = piece; - if (encodeStart.has(piece)) { + for (let index = 0; index < merged.length; index++) { + let {value} = merged[index]; + if (encodeStart[index]) { const first = characterAt(value, 0)!; - value = toCharacterReference(first) + value.slice(first.length); + markdown += toCharacterReference(first); + value = value.slice(first.length); } - // A reference already written for the start counts as punctuation. - const last = characterBefore(value, value.length)!; - if (encodeEnd.has(piece) && isWordCharacter(last)) { - let body = value.slice(0, -last.length); + if (encodeEnd[index] && value !== '') { + const last = characterBefore(value, value.length)!; + value = value.slice(0, -last.length); // A backslash left unescaped (when preserving newlines) would escape // the reference's `&`, so it goes out as a reference too. - const backslashes = body.length - body.replace(/\\+$/, '').length; + const backslashes = value.length - value.replace(/\\+$/, '').length; if (backslashes % 2 === 1) { - body = body.slice(0, -1) + toCharacterReference('\\'); + value = value.slice(0, -1) + toCharacterReference('\\'); } - value = body + toCharacterReference(last); + value += toCharacterReference(last); } markdown += value; } diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 4a6b0dcb262..e79c72b8e38 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -4877,6 +4877,32 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ], '**a**~~!~~', ], + // A one-character reference reads as punctuation on its other side too, + // so the run there is decided again. + [ + [ + ['x', []], + ['a', ['bold']], + [`${LEFT_DOUBLE_QUOTE}b${RIGHT_DOUBLE_QUOTE}`, ['italic']], + ], + 'x**a***“b”*', + ], + [ + [ + [`${LEFT_DOUBLE_QUOTE}a${RIGHT_DOUBLE_QUOTE}`, ['bold', 'italic']], + ['b', ['italic']], + ['c', []], + ], + '***“a”****b*c', + ], + [ + [ + ['x', []], + ['a', ['bold']], + [`${LEFT_DOUBLE_QUOTE}b${RIGHT_DOUBLE_QUOTE}`, ['bold', 'italic']], + ], + 'x**a*“b”***', + ], // A word character beside a word character needs no reference. [ [ From d3cca13cafbbbc5772554275acc5b5e4dfa865f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 07:27:15 +0000 Subject: [PATCH 06/70] [lexical-markdown] Bug Fix: Place export tags by CommonMark whitespace, and note the import change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Export placed tags inside JavaScript whitespace (`trimStart`/`trimEnd`, `/\S/`) while import now uses CommonMark's Unicode whitespace, so a bold `x` went out as `**x**`. Export now scans for the same whitespace (still linear in long runs). U+0000 can be written as a reference again, since CommonMark reads a raw one as U+FFFD too; the backslash check reuses `isEscaped`; `joinPieces` keeps dense arrays and computes each piece's length check once. The changeset's Verify paragraph now covers Markdown that used to import as bold beside Unicode punctuation. ## Test plan `./node_modules/.bin/vitest run --project unit packages/lexical-markdown` ### After ``` Test Files 9 passed (9) Tests 870 passed (870) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 2 +- .../lexical-markdown/src/MarkdownExport.ts | 57 ++++++++++++------- .../__tests__/unit/LexicalMarkdown.test.ts | 2 + packages/lexical-markdown/src/utils.ts | 1 + 4 files changed, 40 insertions(+), 22 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 2c7df9b9df0..75ff4070602 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -6,4 +6,4 @@ Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. The backslash, which was missing from the ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+FEFF no longer counts as whitespace. -Verify: export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. +Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. Lexical exported this shape for such formatting before, so check stored Markdown that has it. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 0b1caeebca9..2805f8bd519 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -37,10 +37,13 @@ import { characterAt, characterBefore, isEmptyParagraph, + isEscaped, + NOT_WHITESPACE, PUNCTUATION, PUNCTUATION_OR_SPACE, toCharacterReference, transformersByType, + WHITESPACE, } from './utils'; /** @@ -656,11 +659,16 @@ function $exportTextFormat( // Flanking Compliance: CommonMark flanking rules require formatting tags to be // adjacent to non-whitespace characters, so openingTags and closingTagsAfter // are placed INSIDE the whitespace boundaries! - // (Trimming rather than a regex keeps this linear in long whitespace runs.) - const trimmedStart = output.trimStart(); - const trimmedOutput = trimmedStart.trimEnd(); - const leadingSpace = output.slice(0, output.length - trimmedStart.length); - const trailingSpace = trimmedStart.slice(trimmedOutput.length); + // (Scanning rather than a trailing regex keeps this linear in long + // whitespace runs.) + const contentStart = output.search(NOT_WHITESPACE); + let contentEnd = output.length; + while (WHITESPACE.test(output[contentEnd - 1])) { + contentEnd--; + } + const leadingSpace = output.slice(0, contentStart); + const trimmedOutput = output.slice(contentStart, contentEnd); + const trailingSpace = output.slice(contentEnd); return [ ...closingTagsBefore, textPiece(leadingSpace), @@ -705,15 +713,16 @@ function isMarker(piece: MarkdownPiece | undefined): boolean { ); } -// U+0000 and unpaired surrogates read back from a reference as U+FFFD. -const UNREFERENCEABLE = /[\0\p{Cs}]/u; +// An unpaired surrogate reads back from a reference as U+FFFD. (U+0000 does +// too, but CommonMark reads a raw one as U+FFFD as well.) +const UNPAIRED_SURROGATE = /\p{Cs}/u; /** Whether a character reference can stand in for `character` beside a marker. */ function isWordCharacter(character: string | undefined): boolean { return ( character !== undefined && !PUNCTUATION_OR_SPACE.test(character) && - !UNREFERENCEABLE.test(character) + !UNPAIRED_SURROGATE.test(character) ); } @@ -756,9 +765,9 @@ function joinPieces(pieces: MarkdownPiece[]): string { } } const runs: MarkerRun[] = []; - // The run that follows and the run that precedes each piece. - const runAfter: number[] = []; - const runBefore: number[] = []; + // The run that follows and the run that precedes each piece, or -1. + const runAfter: number[] = new Array(merged.length).fill(-1); + const runBefore: number[] = new Array(merged.length).fill(-1); for (let start = 0; start < merged.length; start++) { if (!isMarker(merged[start])) { continue; @@ -782,7 +791,12 @@ function joinPieces(pieces: MarkdownPiece[]): string { group.closes ||= kind === 'closer'; } const last = groups.length - 1; - runAfter[start - 1] = runBefore[end + 1] = runs.length; + if (start > 0) { + runAfter[start - 1] = runs.length; + } + if (end + 1 < merged.length) { + runBefore[end + 1] = runs.length; + } runs.push({ after: end + 1, afterFirst: last > 0 ? groups[1].value[0] : undefined, @@ -793,15 +807,17 @@ function joinPieces(pieces: MarkdownPiece[]): string { }); start = end; } - const encodeStart: boolean[] = []; - const encodeEnd: boolean[] = []; + const encodeStart: boolean[] = new Array(merged.length).fill(false); + const encodeEnd: boolean[] = new Array(merged.length).fill(false); + const isOneCharacter = merged.map( + ({value}) => characterAt(value, 0)!.length === value.length, + ); const isText = (index: number) => index >= 0 && index < merged.length && merged[index].kind === 'text'; // A one-character piece written as a reference reads as punctuation at // both ends: `a`. const isEncoded = (index: number) => - (encodeStart[index] || encodeEnd[index]) && - characterAt(merged[index].value, 0)!.length === merged[index].value.length; + (encodeStart[index] || encodeEnd[index]) && isOneCharacter[index]; const startChar = (index: number) => index >= merged.length ? undefined @@ -828,7 +844,7 @@ function joinPieces(pieces: MarkdownPiece[]): string { PUNCTUATION.test(afterFirst || startChar(after) || '') ) { encodeEnd[before] = true; - if (isEncoded(before) && runBefore[before] !== undefined) { + if (isEncoded(before) && runBefore[before] !== -1) { pending.push(runBefore[before]); } } @@ -839,7 +855,7 @@ function joinPieces(pieces: MarkdownPiece[]): string { PUNCTUATION.test(beforeLast || endChar(before) || '') ) { encodeStart[after] = true; - if (isEncoded(after) && runAfter[after] !== undefined) { + if (isEncoded(after) && runAfter[after] !== -1) { pending.push(runAfter[after]); } } @@ -857,8 +873,7 @@ function joinPieces(pieces: MarkdownPiece[]): string { value = value.slice(0, -last.length); // A backslash left unescaped (when preserving newlines) would escape // the reference's `&`, so it goes out as a reference too. - const backslashes = value.length - value.replace(/\\+$/, '').length; - if (backslashes % 2 === 1) { + if (isEscaped(value, value.length)) { value = value.slice(0, -1) + toCharacterReference('\\'); } value += toCharacterReference(last); @@ -948,5 +963,5 @@ function hasFormat( * keep tags off whitespace. */ function canHoldTag(text: string): boolean { - return /\S/.test(text); + return NOT_WHITESPACE.test(text); } diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index e79c72b8e38..ca5d1bbd734 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -4903,6 +4903,8 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ], 'x**a*“b”***', ], + // Tags go inside CommonMark whitespace only, so U+FEFF keeps its bold. + [[['\uFEFFx', ['bold']]], '**\uFEFFx**'], // A word character beside a word character needs no reference. [ [ diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index 8c4c8df778a..28f64717253 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -65,6 +65,7 @@ export function transformersByType(transformers: Transformer[]): Readonly<{ // https://spec.commonmark.org/0.31.2/#unicode-whitespace-character export const PUNCTUATION_OR_SPACE = /[\p{P}\p{S}\p{Zs}\t\n\f\r]/u; export const WHITESPACE = /[\p{Zs}\t\n\f\r]/u; +export const NOT_WHITESPACE = /[^\p{Zs}\t\n\f\r]/u; export const PUNCTUATION = /[\p{P}\p{S}]/u; /** The character (code point) that starts at `index`, if any. */ From 84fed4adb3888712ef6a61d138770d0ca3d431e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 07:42:27 +0000 Subject: [PATCH 07/70] [lexical-markdown] Bug Fix: Share the flanking rule between import and export, and guard raw backslashes before markers ## Description Export now decides whether a reference is needed with the same `isFlankedBy` rule that import's `isFlanking` uses, instead of a copy of it. When newlines are preserved, a raw backslash right before a marker went out as is and escaped it (`a\**b**` lost the bold); it now goes out as `\`. `joinPieces` skips its setup when there are no markers, and the trailing-whitespace scan has an explicit bound. ## Test plan `./node_modules/.bin/vitest run --project unit packages/lexical-markdown` ### After ``` Test Files 9 passed (9) Tests 871 passed (871) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 31 ++++++++++++++---- .../__tests__/unit/LexicalMarkdown.test.ts | 32 +++++++++++++++++++ .../src/importTextFormatTransformer.ts | 21 +++--------- packages/lexical-markdown/src/utils.ts | 21 ++++++++++++ 4 files changed, 82 insertions(+), 23 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 2805f8bd519..a832bd097b1 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -38,8 +38,8 @@ import { characterBefore, isEmptyParagraph, isEscaped, + isFlankedBy, NOT_WHITESPACE, - PUNCTUATION, PUNCTUATION_OR_SPACE, toCharacterReference, transformersByType, @@ -663,7 +663,7 @@ function $exportTextFormat( // whitespace runs.) const contentStart = output.search(NOT_WHITESPACE); let contentEnd = output.length; - while (WHITESPACE.test(output[contentEnd - 1])) { + while (contentEnd > contentStart && WHITESPACE.test(output[contentEnd - 1])) { contentEnd--; } const leadingSpace = output.slice(0, contentStart); @@ -748,6 +748,9 @@ interface MarkerRun { * reference instead, whose `&` or `;` is punctuation: `a**\***`. */ function joinPieces(pieces: MarkdownPiece[]): string { + if (!pieces.some(isMarker)) { + return pieces.map(piece => piece.value).join(''); + } // Drop empty pieces and merge adjacent text, so that each run of markers // sits between the pieces whose characters a reader sees beside it. const merged: MarkdownPiece[] = []; @@ -837,11 +840,16 @@ function joinPieces(pieces: MarkdownPiece[]): string { while (pending.length > 0) { const {before, after, opens, closes, afterFirst, beforeLast} = runs[pending.pop()!]; + // A reference helps only where the run would flank if the character + // beside it were punctuation. + const charBefore = endChar(before); + const charAfter = startChar(after); if ( opens && isText(before) && - isWordCharacter(endChar(before)) && - PUNCTUATION.test(afterFirst || startChar(after) || '') + isWordCharacter(charBefore) && + !isFlankedBy(afterFirst || charAfter, charBefore) && + isFlankedBy(afterFirst || charAfter, ';') ) { encodeEnd[before] = true; if (isEncoded(before) && runBefore[before] !== -1) { @@ -851,8 +859,9 @@ function joinPieces(pieces: MarkdownPiece[]): string { if ( closes && isText(after) && - isWordCharacter(startChar(after)) && - PUNCTUATION.test(beforeLast || endChar(before) || '') + isWordCharacter(charAfter) && + !isFlankedBy(beforeLast || charBefore, charAfter) && + isFlankedBy(beforeLast || charBefore, '&') ) { encodeStart[after] = true; if (isEncoded(after) && runAfter[after] !== -1) { @@ -868,6 +877,16 @@ function joinPieces(pieces: MarkdownPiece[]): string { markdown += toCharacterReference(first); value = value.slice(first.length); } + if ( + merged[index].kind === 'text' && + isMarker(merged[index + 1]) && + !encodeEnd[index] && + isEscaped(value, value.length) + ) { + // A backslash left unescaped (when preserving newlines) would escape + // the marker after it, so it goes out as a reference. + value = value.slice(0, -1) + toCharacterReference('\\'); + } if (encodeEnd[index] && value !== '') { const last = characterBefore(value, value.length)!; value = value.slice(0, -last.length); diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index ca5d1bbd734..40c0bad0da6 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -4989,6 +4989,38 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ]); }); + it('keeps a raw backslash from escaping a marker when preserving newlines', () => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('a\\'), + $createTextNode('b').toggleFormat('bold'), + ), + ), + {discrete: true}, + ); + const markdown = editor.read('latest', () => + $convertToMarkdownString(TRANSFORMERS, undefined, true), + ); + expect(markdown).toBe('a\**b**'); + editor.update( + () => $convertFromMarkdownString(markdown, TRANSFORMERS, undefined, true), + {discrete: true}, + ); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.hasFormat('bold')]), + ), + ).toEqual([ + ['a\\', false], + ['b', true], + ]); + }); + it('guards word characters in a selection export', () => { const editor = createTestEditor(); editor.update( diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index f99c9723641..b2e51053edc 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -10,7 +10,7 @@ import type {TextFormatTransformersIndex} from './MarkdownImport'; import type {TextFormatTransformer} from './MarkdownTransformers'; import type {TextNode} from 'lexical'; -import {characterAt, characterBefore, PUNCTUATION, WHITESPACE} from './utils'; +import {characterAt, characterBefore, isFlankedBy, PUNCTUATION} from './utils'; interface Delimiter { index: number; @@ -412,22 +412,9 @@ function isFlanking( ): boolean { const charBefore = characterBefore(text, index); const charAfter = characterAt(text, index + length); - - const [primary, secondary] = isLeft - ? [charAfter, charBefore] - : [charBefore, charAfter]; - - if (primary === undefined || WHITESPACE.test(primary)) { - return false; - } - if (!PUNCTUATION.test(primary)) { - return true; - } - return ( - secondary === undefined || - WHITESPACE.test(secondary) || - PUNCTUATION.test(secondary) - ); + return isLeft + ? isFlankedBy(charAfter, charBefore) + : isFlankedBy(charBefore, charAfter); } export function importTextFormatTransformer( diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index 28f64717253..5e3e0081288 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -66,6 +66,27 @@ export function transformersByType(transformers: Transformer[]): Readonly<{ export const PUNCTUATION_OR_SPACE = /[\p{P}\p{S}\p{Zs}\t\n\f\r]/u; export const WHITESPACE = /[\p{Zs}\t\n\f\r]/u; export const NOT_WHITESPACE = /[^\p{Zs}\t\n\f\r]/u; + +/** + * Whether a delimiter run with `primary` on the side it faces (after it for + * left-flanking, before it for right-flanking) and `secondary` on the other + * side is flanking. `undefined` is the edge of the line. + * https://spec.commonmark.org/0.31.2/#left-flanking-delimiter-run + */ +export function isFlankedBy( + primary: string | undefined, + secondary: string | undefined, +): boolean { + if (primary === undefined || WHITESPACE.test(primary)) { + return false; + } + return ( + !PUNCTUATION.test(primary) || + secondary === undefined || + WHITESPACE.test(secondary) || + PUNCTUATION.test(secondary) + ); +} export const PUNCTUATION = /[\p{P}\p{S}]/u; /** The character (code point) that starts at `index`, if any. */ From 892eb70b0c70890e7030becd56914de51ff008db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 07:57:55 +0000 Subject: [PATCH 08/70] [lexical-markdown] Bug Fix: Keep export tags inside JavaScript whitespace, and guard raw backslashes before any markup ## Description The previous commit placed export tags by CommonMark whitespace only, so a tag could land beside U+000B, which markdown-it counts as whitespace (`**\vx\v**` lost the bold there). Export goes back to trimming JavaScript whitespace, which holds CommonMark's and the characters some readers add; import keeps the spec's set. When newlines are preserved, a trailing raw backslash before any following markup (a marker, a reference, a code span, a link) now goes out as `\`, in one place, and the reference analysis runs only when there are markers. ## Test plan `./node_modules/.bin/vitest run --project unit packages/lexical-markdown` ### After ``` Test Files 9 passed (9) Tests 871 passed (871) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 101 +++++++++--------- .../__tests__/unit/LexicalMarkdown.test.ts | 67 ++++++------ packages/lexical-markdown/src/utils.ts | 11 +- 3 files changed, 93 insertions(+), 86 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index a832bd097b1..03caed23c62 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -39,11 +39,9 @@ import { isEmptyParagraph, isEscaped, isFlankedBy, - NOT_WHITESPACE, PUNCTUATION_OR_SPACE, toCharacterReference, transformersByType, - WHITESPACE, } from './utils'; /** @@ -659,16 +657,13 @@ function $exportTextFormat( // Flanking Compliance: CommonMark flanking rules require formatting tags to be // adjacent to non-whitespace characters, so openingTags and closingTagsAfter // are placed INSIDE the whitespace boundaries! - // (Scanning rather than a trailing regex keeps this linear in long - // whitespace runs.) - const contentStart = output.search(NOT_WHITESPACE); - let contentEnd = output.length; - while (contentEnd > contentStart && WHITESPACE.test(output[contentEnd - 1])) { - contentEnd--; - } - const leadingSpace = output.slice(0, contentStart); - const trimmedOutput = output.slice(contentStart, contentEnd); - const trailingSpace = output.slice(contentEnd); + // Tags stay inside JavaScript whitespace, which holds CommonMark's and the + // other characters some readers count as whitespace. + // (Trimming rather than a regex keeps this linear in long whitespace runs.) + const trimmedStart = output.trimStart(); + const trimmedOutput = trimmedStart.trimEnd(); + const leadingSpace = output.slice(0, output.length - trimmedStart.length); + const trailingSpace = trimmedStart.slice(trimmedOutput.length); return [ ...closingTagsBefore, textPiece(leadingSpace), @@ -748,9 +743,6 @@ interface MarkerRun { * reference instead, whose `&` or `;` is punctuation: `a**\***`. */ function joinPieces(pieces: MarkdownPiece[]): string { - if (!pieces.some(isMarker)) { - return pieces.map(piece => piece.value).join(''); - } // Drop empty pieces and merge adjacent text, so that each run of markers // sits between the pieces whose characters a reader sees beside it. const merged: MarkdownPiece[] = []; @@ -767,6 +759,50 @@ function joinPieces(pieces: MarkdownPiece[]): string { merged.push(piece); } } + const encodeStart: boolean[] = []; + const encodeEnd: boolean[] = []; + if (merged.some(isMarker)) { + decideReferences(merged, encodeStart, encodeEnd); + } + let markdown = ''; + for (let index = 0; index < merged.length; index++) { + const {kind} = merged[index]; + let {value} = merged[index]; + let start = ''; + let end = ''; + if (encodeStart[index]) { + const first = characterAt(value, 0)!; + start = toCharacterReference(first); + value = value.slice(first.length); + } + if (encodeEnd[index] && value !== '') { + const last = characterBefore(value, value.length)!; + end = toCharacterReference(last); + value = value.slice(0, -last.length); + } + // A backslash left unescaped (when preserving newlines) would escape what + // follows it: a reference's `&`, a marker or raw output. + if ( + kind === 'text' && + (end !== '' || index + 1 < merged.length) && + isEscaped(value, value.length) + ) { + value = value.slice(0, -1) + toCharacterReference('\\'); + } + markdown += start + value + end; + } + return markdown; +} + +/** + * Marks the text pieces whose first or last character has to go out as a + * reference for the marker run beside it to open or close. + */ +function decideReferences( + merged: MarkdownPiece[], + encodeStart: boolean[], + encodeEnd: boolean[], +) { const runs: MarkerRun[] = []; // The run that follows and the run that precedes each piece, or -1. const runAfter: number[] = new Array(merged.length).fill(-1); @@ -810,8 +846,6 @@ function joinPieces(pieces: MarkdownPiece[]): string { }); start = end; } - const encodeStart: boolean[] = new Array(merged.length).fill(false); - const encodeEnd: boolean[] = new Array(merged.length).fill(false); const isOneCharacter = merged.map( ({value}) => characterAt(value, 0)!.length === value.length, ); @@ -869,37 +903,6 @@ function joinPieces(pieces: MarkdownPiece[]): string { } } } - let markdown = ''; - for (let index = 0; index < merged.length; index++) { - let {value} = merged[index]; - if (encodeStart[index]) { - const first = characterAt(value, 0)!; - markdown += toCharacterReference(first); - value = value.slice(first.length); - } - if ( - merged[index].kind === 'text' && - isMarker(merged[index + 1]) && - !encodeEnd[index] && - isEscaped(value, value.length) - ) { - // A backslash left unescaped (when preserving newlines) would escape - // the marker after it, so it goes out as a reference. - value = value.slice(0, -1) + toCharacterReference('\\'); - } - if (encodeEnd[index] && value !== '') { - const last = characterBefore(value, value.length)!; - value = value.slice(0, -last.length); - // A backslash left unescaped (when preserving newlines) would escape - // the reference's `&`, so it goes out as a reference too. - if (isEscaped(value, value.length)) { - value = value.slice(0, -1) + toCharacterReference('\\'); - } - value += toCharacterReference(last); - } - markdown += value; - } - return markdown; } function getTextSibling(node: TextNode, backward: boolean): TextNode | null { @@ -982,5 +985,5 @@ function hasFormat( * keep tags off whitespace. */ function canHoldTag(text: string): boolean { - return NOT_WHITESPACE.test(text); + return /\S/.test(text); } diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 40c0bad0da6..f572417ef9e 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -4903,8 +4903,6 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ], 'x**a*“b”***', ], - // Tags go inside CommonMark whitespace only, so U+FEFF keeps its bold. - [[['\uFEFFx', ['bold']]], '**\uFEFFx**'], // A word character beside a word character needs no reference. [ [ @@ -4989,37 +4987,44 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ]); }); - it('keeps a raw backslash from escaping a marker when preserving newlines', () => { - const editor = createTestEditor(); - editor.update( - () => - $getRoot().append( - $createParagraphNode().append( - $createTextNode('a\\'), - $createTextNode('b').toggleFormat('bold'), + it.each([ + ['bold', 'a\**b**'], + ['code', 'a\`b`'], + ] as [TextFormatType, string][])( + 'keeps a raw backslash from escaping %s markup when preserving newlines', + (format, exported) => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('a\\'), + $createTextNode('b').toggleFormat(format), + ), ), + {discrete: true}, + ); + const markdown = editor.read('latest', () => + $convertToMarkdownString(TRANSFORMERS, undefined, true), + ); + expect(markdown).toBe(exported); + editor.update( + () => + $convertFromMarkdownString(markdown, TRANSFORMERS, undefined, true), + {discrete: true}, + ); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.hasFormat(format)]), ), - {discrete: true}, - ); - const markdown = editor.read('latest', () => - $convertToMarkdownString(TRANSFORMERS, undefined, true), - ); - expect(markdown).toBe('a\**b**'); - editor.update( - () => $convertFromMarkdownString(markdown, TRANSFORMERS, undefined, true), - {discrete: true}, - ); - expect( - editor.read('latest', () => - $getRoot() - .getAllTextNodes() - .map(node => [node.getTextContent(), node.hasFormat('bold')]), - ), - ).toEqual([ - ['a\\', false], - ['b', true], - ]); - }); + ).toEqual([ + ['a\\', false], + ['b', true], + ]); + }, + ); it('guards word characters in a selection export', () => { const editor = createTestEditor(); diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index 5e3e0081288..ea9e1932693 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -58,14 +58,14 @@ export function transformersByType(transformers: Transformer[]): Readonly<{ // CommonMark counts every character in the Unicode P (punctuation) and S // (symbol) general categories as punctuation, which includes all of the ASCII -// punctuation characters. Delimiter flanking depends on it. +// punctuation characters. Its Unicode whitespace is Zs plus tab, line feed, +// form feed and carriage return; JavaScript's `\s` adds others, such as +// U+FEFF. Delimiter flanking depends on both. // https://spec.commonmark.org/0.31.2/#unicode-punctuation-character -// CommonMark's Unicode whitespace is Zs plus tab, line feed, form feed and -// carriage return; JavaScript's `\s` adds others, such as U+FEFF. // https://spec.commonmark.org/0.31.2/#unicode-whitespace-character -export const PUNCTUATION_OR_SPACE = /[\p{P}\p{S}\p{Zs}\t\n\f\r]/u; +export const PUNCTUATION = /[\p{P}\p{S}]/u; export const WHITESPACE = /[\p{Zs}\t\n\f\r]/u; -export const NOT_WHITESPACE = /[^\p{Zs}\t\n\f\r]/u; +export const PUNCTUATION_OR_SPACE = /[\p{P}\p{S}\p{Zs}\t\n\f\r]/u; /** * Whether a delimiter run with `primary` on the side it faces (after it for @@ -87,7 +87,6 @@ export function isFlankedBy( PUNCTUATION.test(secondary) ); } -export const PUNCTUATION = /[\p{P}\p{S}]/u; /** The character (code point) that starts at `index`, if any. */ export function characterAt(text: string, index: number): string | undefined { From 77e11a29f57d10756e9e85094671eef23fb512e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 08:17:03 +0000 Subject: [PATCH 09/70] [lexical-markdown] Bug Fix: Write no reference that only trades one lost format for another MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description A reference for a one-character piece makes both of its edges punctuation, which the run on its other side may need repaired with another reference, and so on along a chain of one-character pieces. When that chain ended at raw output (a transformer's `@x`) the last run could not be repaired, so the reference only swapped which format was lost. `decideReferences` now walks the chain first and writes nothing when it is blocked; answers are remembered per piece and direction, so each piece is walked at most twice. U+0000 is left alone again (Lexical's importer keeps a raw one but reads `�` as U+FFFD), the preserve-newlines backslash guard is limited to what `joinPieces` writes (markers and references), and the changeset's Verify paragraph mentions it. ## Test plan `./node_modules/.bin/vitest run --project unit packages/lexical-markdown` ### Before ``` × writes no reference that would break a run beside raw output Tests 1 failed | 695 skipped (696) ``` ### After ``` Test Files 9 passed (9) Tests 872 passed (872) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 2 +- .../lexical-markdown/src/MarkdownExport.ts | 79 ++++++++++++++++--- .../__tests__/unit/LexicalMarkdown.test.ts | 48 ++++++++++- 3 files changed, 113 insertions(+), 16 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 75ff4070602..7e2b8667504 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -6,4 +6,4 @@ Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. The backslash, which was missing from the ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+FEFF no longer counts as whitespace. -Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. Lexical exported this shape for such formatting before, so check stored Markdown that has it. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. +Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. Lexical exported this shape for such formatting before, so check stored Markdown that has it. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. With `shouldPreserveNewLines`, a raw backslash that ends text right before a marker or such a reference now goes out as `\`, so that it does not escape it. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 03caed23c62..04bedfdeefc 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -708,16 +708,15 @@ function isMarker(piece: MarkdownPiece | undefined): boolean { ); } -// An unpaired surrogate reads back from a reference as U+FFFD. (U+0000 does -// too, but CommonMark reads a raw one as U+FFFD as well.) -const UNPAIRED_SURROGATE = /\p{Cs}/u; +// U+0000 and unpaired surrogates read back from a reference as U+FFFD. +const UNREFERENCEABLE = /[\0\p{Cs}]/u; /** Whether a character reference can stand in for `character` beside a marker. */ function isWordCharacter(character: string | undefined): boolean { return ( character !== undefined && !PUNCTUATION_OR_SPACE.test(character) && - !UNPAIRED_SURROGATE.test(character) + !UNREFERENCEABLE.test(character) ); } @@ -780,11 +779,11 @@ function joinPieces(pieces: MarkdownPiece[]): string { end = toCharacterReference(last); value = value.slice(0, -last.length); } - // A backslash left unescaped (when preserving newlines) would escape what - // follows it: a reference's `&`, a marker or raw output. + // A backslash left unescaped (when preserving newlines) would escape the + // reference or marker written after it. if ( kind === 'text' && - (end !== '' || index + 1 < merged.length) && + (end !== '' || isMarker(merged[index + 1])) && isEscaped(value, value.length) ) { value = value.slice(0, -1) + toCharacterReference('\\'); @@ -846,15 +845,14 @@ function decideReferences( }); start = end; } - const isOneCharacter = merged.map( - ({value}) => characterAt(value, 0)!.length === value.length, - ); const isText = (index: number) => index >= 0 && index < merged.length && merged[index].kind === 'text'; // A one-character piece written as a reference reads as punctuation at // both ends: `a`. + const isOneCharacter = (index: number) => + characterAt(merged[index].value, 0)!.length === merged[index].value.length; const isEncoded = (index: number) => - (encodeStart[index] || encodeEnd[index]) && isOneCharacter[index]; + (encodeStart[index] || encodeEnd[index]) && isOneCharacter(index); const startChar = (index: number) => index >= merged.length ? undefined @@ -867,6 +865,59 @@ function decideReferences( : encodeEnd[index] || isEncoded(index) ? ';' : characterBefore(merged[index].value, merged[index].value.length); + // Writing a one-character piece as a reference makes its other edge + // punctuation too. That can stop the delimiter run touching that edge from + // flanking unless the piece past it is written as a reference as well, and + // so on along a chain of one-character pieces. Where the chain ends at + // something that cannot be (raw output, say), writing the first reference + // would only trade one lost format for another, so it is not written. + // References only turn letters into punctuation, which never stops a run + // from flanking, so an answer found for a piece holds from then on, and + // each piece is walked at most twice (once in each direction). + const blocked: boolean[][] = [[], []]; + const spreads: boolean[][] = [[], []]; + const canSpread = (index: number, leftward: boolean): boolean => { + const side = leftward ? 0 : 1; + const visited: number[] = []; + let result = true; + for (let piece = index; ; ) { + if (blocked[side][piece] || spreads[side][piece]) { + result = !blocked[side][piece]; + break; + } + visited.push(piece); + const runIndex = (leftward ? runBefore : runAfter)[piece]; + if (runIndex === -1) { + break; + } + const run = runs[runIndex]; + // Only a run of a single delimiter character touches both pieces. + const isAffected = leftward + ? run.opens && run.afterFirst === undefined + : run.closes && run.beforeLast === undefined; + const next = leftward ? run.before : run.after; + const char = leftward ? endChar(next) : startChar(next); + if (!isAffected || isFlankedBy('&', char)) { + break; + } + if (!isText(next) || !isWordCharacter(char)) { + result = false; + break; + } + if (!isOneCharacter(next)) { + break; + } + piece = next; + } + for (const piece of visited) { + (result ? spreads : blocked)[side][piece] = true; + } + return result; + }; + const isFree = (index: number, leftward: boolean) => + !isOneCharacter(index) || + (leftward ? encodeStart[index] : encodeEnd[index]) || + canSpread(index, leftward); // Writing a one-character piece as a reference changes what the run on its // other side sees, so that run is decided again. Each piece changes at most // twice, so this stays linear. @@ -883,7 +934,8 @@ function decideReferences( isText(before) && isWordCharacter(charBefore) && !isFlankedBy(afterFirst || charAfter, charBefore) && - isFlankedBy(afterFirst || charAfter, ';') + isFlankedBy(afterFirst || charAfter, ';') && + isFree(before, true) ) { encodeEnd[before] = true; if (isEncoded(before) && runBefore[before] !== -1) { @@ -895,7 +947,8 @@ function decideReferences( isText(after) && isWordCharacter(charAfter) && !isFlankedBy(beforeLast || charBefore, charAfter) && - isFlankedBy(beforeLast || charBefore, '&') + isFlankedBy(beforeLast || charBefore, '&') && + isFree(after, false) ) { encodeStart[after] = true; if (isEncoded(after) && runAfter[after] !== -1) { diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index f572417ef9e..5908f46acb3 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -4989,9 +4989,9 @@ describe('emphasis next to Unicode punctuation and symbols', () => { it.each([ ['bold', 'a\**b**'], - ['code', 'a\`b`'], + ['italic', 'a\*b*'], ] as [TextFormatType, string][])( - 'keeps a raw backslash from escaping %s markup when preserving newlines', + 'keeps a raw backslash from escaping a %s marker when preserving newlines', (format, exported) => { const editor = createTestEditor(); editor.update( @@ -5026,6 +5026,50 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it('writes no reference that would break a run beside raw output', () => { + // Writing `a` as a reference for the italic would leave the bold's `**` + // between the raw `x` and the reference's `&`, unable to open. + const MENTION: TextMatchTransformer = { + dependencies: [], + export: node => + $isTextNode(node) && node.getTextContent() === '@x' ? '@x' : null, + regExp: /@x$/, + replace: () => {}, + type: 'text-match', + }; + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('@x'), + $createTextNode('a').toggleFormat('bold'), + $createTextNode(`${LEFT_DOUBLE_QUOTE}y${RIGHT_DOUBLE_QUOTE}`) + .toggleFormat('bold') + .toggleFormat('italic'), + ), + ), + {discrete: true}, + ); + const markdown = editor.read('latest', () => + $convertToMarkdownString([MENTION, ...TRANSFORMERS]), + ); + expect(markdown).not.toContain('a'); + editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS), { + discrete: true, + }); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .some( + node => + node.getTextContent().startsWith('a') && node.hasFormat('bold'), + ), + ), + ).toBe(true); + }); + it('guards word characters in a selection export', () => { const editor = createTestEditor(); editor.update( From b5ae663c8e58eb604c5b2d3ac6ff6521137572c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 08:29:25 +0000 Subject: [PATCH 10/70] [lexical-markdown] Refactor: Simplify the reference decision's bookkeeping ## Description `decideReferences` keeps one character per delimiter group instead of joined marker strings, decodes each piece's edge characters once, and remembers chain answers in one tri-state table per direction. The comment on that cache now says how an answer can go stale (only toward writing fewer references), and `appendPieces` says why it loops. ## Test plan `./node_modules/.bin/vitest run --project unit packages/lexical-markdown` ### After ``` Test Files 9 passed (9) Tests 872 passed (872) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 04bedfdeefc..1617f77748d 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -696,6 +696,7 @@ function rawPiece(value: string): MarkdownPiece { return {kind: 'raw', value}; } +// A loop rather than `push(...pieces)`, whose argument count is limited. function appendPieces(output: MarkdownPiece[], pieces: MarkdownPiece[]) { for (const piece of pieces) { output.push(piece); @@ -816,15 +817,14 @@ function decideReferences( } // Markers of one character form one delimiter run, and only the first // and the last delimiter run touch text. - const groups: {closes: boolean; opens: boolean; value: string}[] = []; + const groups: {char: string; closes: boolean; opens: boolean}[] = []; for (let i = start; i <= end; i++) { const {kind, value} = merged[i]; let group = groups[groups.length - 1]; - if (!group || group.value[0] !== value[0]) { - group = {closes: false, opens: false, value: ''}; + if (!group || group.char !== value[0]) { + group = {char: value[0], closes: false, opens: false}; groups.push(group); } - group.value += value; group.opens ||= kind === 'opener'; group.closes ||= kind === 'closer'; } @@ -837,9 +837,9 @@ function decideReferences( } runs.push({ after: end + 1, - afterFirst: last > 0 ? groups[1].value[0] : undefined, + afterFirst: last > 0 ? groups[1].char : undefined, before: start - 1, - beforeLast: last > 0 ? groups[last - 1].value.slice(-1) : undefined, + beforeLast: last > 0 ? groups[last - 1].char : undefined, closes: groups[last].closes, opens: groups[0].opens, }); @@ -849,8 +849,12 @@ function decideReferences( index >= 0 && index < merged.length && merged[index].kind === 'text'; // A one-character piece written as a reference reads as punctuation at // both ends: `a`. + const firstChars = merged.map(({value}) => characterAt(value, 0)!); + const lastChars = merged.map( + ({value}) => characterBefore(value, value.length)!, + ); const isOneCharacter = (index: number) => - characterAt(merged[index].value, 0)!.length === merged[index].value.length; + firstChars[index].length === merged[index].value.length; const isEncoded = (index: number) => (encodeStart[index] || encodeEnd[index]) && isOneCharacter(index); const startChar = (index: number) => @@ -858,31 +862,31 @@ function decideReferences( ? undefined : encodeStart[index] || isEncoded(index) ? '&' - : characterAt(merged[index].value, 0); + : firstChars[index]; const endChar = (index: number) => index < 0 ? undefined : encodeEnd[index] || isEncoded(index) ? ';' - : characterBefore(merged[index].value, merged[index].value.length); + : lastChars[index]; // Writing a one-character piece as a reference makes its other edge // punctuation too. That can stop the delimiter run touching that edge from // flanking unless the piece past it is written as a reference as well, and // so on along a chain of one-character pieces. Where the chain ends at // something that cannot be (raw output, say), writing the first reference // would only trade one lost format for another, so it is not written. - // References only turn letters into punctuation, which never stops a run - // from flanking, so an answer found for a piece holds from then on, and - // each piece is walked at most twice (once in each direction). - const blocked: boolean[][] = [[], []]; - const spreads: boolean[][] = [[], []]; + // Each piece's answer is remembered for each direction, so each piece is + // walked at most twice. Raw output and unreferenceable characters stay + // what they are, and other references only make edges punctuation, so a + // remembered answer can only go stale toward writing fewer references. + const spreads: (boolean | undefined)[][] = [[], []]; const canSpread = (index: number, leftward: boolean): boolean => { const side = leftward ? 0 : 1; const visited: number[] = []; let result = true; for (let piece = index; ; ) { - if (blocked[side][piece] || spreads[side][piece]) { - result = !blocked[side][piece]; + if (spreads[side][piece] !== undefined) { + result = spreads[side][piece]!; break; } visited.push(piece); @@ -910,7 +914,7 @@ function decideReferences( piece = next; } for (const piece of visited) { - (result ? spreads : blocked)[side][piece] = true; + spreads[side][piece] = result; } return result; }; From 112522eac6641a9414d124515f2180fe2bb05ae3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 08:46:04 +0000 Subject: [PATCH 11/70] [lexical-markdown] Bug Fix: Leave control characters and noncharacters raw beside markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description A reference for a control character (U+0001 to U+001F, U+007F to U+009F) or a noncharacter reads back as U+FFFD (or `…` for `…`) in markdown-it and HTML decoders, so the export no longer writes one for them; they stay raw as on main. `canEmphasis` reads the characters around a delimiter once for both its open and close answers, and `isPunctuationOrSpace` replaces the third hand-written character class. ## Test plan `./node_modules/.bin/vitest run --project unit packages/lexical-markdown` ### After ``` Test Files 9 passed (9) Tests 872 passed (872) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 10 +++-- .../lexical-markdown/src/MarkdownShortcuts.ts | 6 +-- .../src/importTextFormatTransformer.ts | 43 ++++++------------- packages/lexical-markdown/src/utils.ts | 5 ++- 4 files changed, 25 insertions(+), 39 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 1617f77748d..1ab59f7c4f5 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -39,7 +39,7 @@ import { isEmptyParagraph, isEscaped, isFlankedBy, - PUNCTUATION_OR_SPACE, + isPunctuationOrSpace, toCharacterReference, transformersByType, } from './utils'; @@ -709,14 +709,16 @@ function isMarker(piece: MarkdownPiece | undefined): boolean { ); } -// U+0000 and unpaired surrogates read back from a reference as U+FFFD. -const UNREFERENCEABLE = /[\0\p{Cs}]/u; +// Characters that a reference does not stand in for everywhere: CommonMark +// readers and HTML decode references to control characters, unpaired +// surrogates and noncharacters as U+FFFD (or remap them). +const UNREFERENCEABLE = /[\p{Cc}\p{Cs}\p{Noncharacter_Code_Point}]/u; /** Whether a character reference can stand in for `character` beside a marker. */ function isWordCharacter(character: string | undefined): boolean { return ( character !== undefined && - !PUNCTUATION_OR_SPACE.test(character) && + !isPunctuationOrSpace(character) && !UNREFERENCEABLE.test(character) ); } diff --git a/packages/lexical-markdown/src/MarkdownShortcuts.ts b/packages/lexical-markdown/src/MarkdownShortcuts.ts index 74c77c7afec..89d3063285a 100644 --- a/packages/lexical-markdown/src/MarkdownShortcuts.ts +++ b/packages/lexical-markdown/src/MarkdownShortcuts.ts @@ -46,7 +46,7 @@ import { characterAt, characterBefore, indexBy, - PUNCTUATION_OR_SPACE, + isPunctuationOrSpace, transformersByType, } from './utils'; @@ -259,7 +259,7 @@ function $runTextFormatTransformers( if ( matcher.intraword === false && afterCloseTagChar && - !PUNCTUATION_OR_SPACE.test(afterCloseTagChar) + !isPunctuationOrSpace(afterCloseTagChar) ) { continue; } @@ -327,7 +327,7 @@ function $runTextFormatTransformers( if ( matcher.intraword === false && beforeOpenTagChar && - !PUNCTUATION_OR_SPACE.test(beforeOpenTagChar) + !isPunctuationOrSpace(beforeOpenTagChar) ) { continue; } diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index b2e51053edc..099c20f579a 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -240,8 +240,7 @@ function scanDelimiters( len++; } - const canOpen = canEmphasis(char, text, i, len, true); - const canClose = canEmphasis(char, text, i, len, false); + const {canOpen, canClose} = canEmphasis(char, text, i, len); if (canOpen || canClose) { delimiters.push({ @@ -384,37 +383,19 @@ function canEmphasis( text: string, index: number, length: number, - isOpen: boolean, -): boolean { - if (!isFlanking(text, index, length, isOpen)) { - return false; - } - if (char === '*') { - return true; - } - if (char === '_') { - if (!isFlanking(text, index, length, !isOpen)) { - return true; - } - const adjacentChar = isOpen - ? characterBefore(text, index) - : characterAt(text, index + length); - return adjacentChar !== undefined && PUNCTUATION.test(adjacentChar); - } - return true; -} - -function isFlanking( - text: string, - index: number, - length: number, - isLeft: boolean, -): boolean { +): {canOpen: boolean; canClose: boolean} { const charBefore = characterBefore(text, index); const charAfter = characterAt(text, index + length); - return isLeft - ? isFlankedBy(charAfter, charBefore) - : isFlankedBy(charBefore, charAfter); + const isLeft = isFlankedBy(charAfter, charBefore); + const isRight = isFlankedBy(charBefore, charAfter); + if (char !== '_') { + return {canClose: isRight, canOpen: isLeft}; + } + // `_` opens or closes inside a word only beside punctuation. + return { + canClose: isRight && (!isLeft || PUNCTUATION.test(charAfter || '')), + canOpen: isLeft && (!isRight || PUNCTUATION.test(charBefore || '')), + }; } export function importTextFormatTransformer( diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index ea9e1932693..03c9cfe1352 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -65,7 +65,6 @@ export function transformersByType(transformers: Transformer[]): Readonly<{ // https://spec.commonmark.org/0.31.2/#unicode-whitespace-character export const PUNCTUATION = /[\p{P}\p{S}]/u; export const WHITESPACE = /[\p{Zs}\t\n\f\r]/u; -export const PUNCTUATION_OR_SPACE = /[\p{P}\p{S}\p{Zs}\t\n\f\r]/u; /** * Whether a delimiter run with `primary` on the side it faces (after it for @@ -88,6 +87,10 @@ export function isFlankedBy( ); } +export function isPunctuationOrSpace(character: string): boolean { + return PUNCTUATION.test(character) || WHITESPACE.test(character); +} + /** The character (code point) that starts at `index`, if any. */ export function characterAt(text: string, index: number): string | undefined { const codePoint = text.codePointAt(index); From 00533af0ad4d19011264545a65dc0debb7e9ca93 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 09:00:37 +0000 Subject: [PATCH 12/70] [lexical-markdown] Bug Fix: Write no reference inside a bare URL, host, email or mention ## Description A reference for the letter at the edge of `www.example.com`, `me@example.com` or `https://example.com/a` changed what an autolinking reader links to (`www.example.com` links to `www.example.co`). The export now writes none inside such a word, so only the format beside it is lost, as before. `canSpread` models the reference's closing `;` when walking rightward, and the changeset says the raw-backslash guard also covers decorator text. ## Test plan `./node_modules/.bin/vitest run --project unit packages/lexical-markdown` ### After ``` Test Files 9 passed (9) Tests 875 passed (875) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 2 +- .../lexical-markdown/src/MarkdownExport.ts | 44 +++++++++++++++---- .../__tests__/unit/LexicalMarkdown.test.ts | 22 ++++++++++ 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 7e2b8667504..1c8669a2281 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -6,4 +6,4 @@ Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. The backslash, which was missing from the ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+FEFF no longer counts as whitespace. -Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. Lexical exported this shape for such formatting before, so check stored Markdown that has it. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. With `shouldPreserveNewLines`, a raw backslash that ends text right before a marker or such a reference now goes out as `\`, so that it does not escape it. +Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. Lexical exported this shape for such formatting before, so check stored Markdown that has it. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it. No reference is written inside a bare URL, `www.` host, email or mention, so formatting right beside one is still lost there. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 1ab59f7c4f5..95e5504472e 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -847,8 +847,38 @@ function decideReferences( }); start = end; } - const isText = (index: number) => - index >= 0 && index < merged.length && merged[index].kind === 'text'; + // Whether the word at the end (or start) of the piece at `index` is a bare + // URL, `www.` host, email or mention. A reference inside one would change + // what an autolinking reader links to, so the format is lost there instead, + // as before. + const linkish: boolean[][] = [[], []]; + const isLinkish = (index: number, atEnd: boolean): boolean => { + const known = linkish[atEnd ? 1 : 0]; + if (known[index] === undefined) { + const {value} = merged[index]; + let start = atEnd ? value.length : 0; + let end = start; + if (atEnd) { + while (start > 0 && /\S/.test(value[start - 1])) { + start--; + } + } else { + while (end < value.length && /\S/.test(value[end])) { + end++; + } + } + known[index] = /:\/\/|www\.|@/i.test(value.slice(start, end)); + } + return known[index]; + }; + // Whether the last (or first) character of the piece at `index` can go out + // as a reference. + const canReference = (index: number, atEnd: boolean): boolean => + index >= 0 && + index < merged.length && + merged[index].kind === 'text' && + isWordCharacter(atEnd ? endChar(index) : startChar(index)) && + !isLinkish(index, atEnd); // A one-character piece written as a reference reads as punctuation at // both ends: `a`. const firstChars = merged.map(({value}) => characterAt(value, 0)!); @@ -903,10 +933,10 @@ function decideReferences( : run.closes && run.beforeLast === undefined; const next = leftward ? run.before : run.after; const char = leftward ? endChar(next) : startChar(next); - if (!isAffected || isFlankedBy('&', char)) { + if (!isAffected || isFlankedBy(leftward ? '&' : ';', char)) { break; } - if (!isText(next) || !isWordCharacter(char)) { + if (!canReference(next, leftward)) { result = false; break; } @@ -937,8 +967,7 @@ function decideReferences( const charAfter = startChar(after); if ( opens && - isText(before) && - isWordCharacter(charBefore) && + canReference(before, true) && !isFlankedBy(afterFirst || charAfter, charBefore) && isFlankedBy(afterFirst || charAfter, ';') && isFree(before, true) @@ -950,8 +979,7 @@ function decideReferences( } if ( closes && - isText(after) && - isWordCharacter(charAfter) && + canReference(after, false) && !isFlankedBy(beforeLast || charBefore, charAfter) && isFlankedBy(beforeLast || charBefore, '&') && isFree(after, false) diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 5908f46acb3..2e2afe378a1 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5070,6 +5070,28 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ).toBe(true); }); + it.each(['www.example.com', 'me@example.com', 'https://example.com/a'])( + 'writes no reference inside %j, which readers may autolink', + link => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode(`see ${link}`), + $createTextNode( + `${LEFT_DOUBLE_QUOTE}x${RIGHT_DOUBLE_QUOTE}`, + ).toggleFormat('bold'), + ), + ), + {discrete: true}, + ); + expect( + editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), + ).toMatch(new RegExp(`^see ${link.replace(/\./g, '\\.')}\\*\\*`)); + }, + ); + it('guards word characters in a selection export', () => { const editor = createTestEditor(); editor.update( From 04177c996daa7b374da6e4b9f65086e5d49350dd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 09:33:30 +0000 Subject: [PATCH 13/70] [lexical-markdown] Refactor: Cut the export overhead of deciding references ## Description The export kept every marker and text piece as its own object, decoded edge characters with `String.fromCodePoint`, ran Unicode property regular expressions for ASCII characters and scanned words for autolinks before cheaper checks. Exporting a 40,000-node paragraph took 112 ms (best of 25) against 40 ms on main with heavy formatting beside punctuation, and 39 ms against 28 ms for ordinary prose. Pieces are now two parallel arrays, markers go back to concatenated tag strings, `characterAt`/`characterBefore` slice only for surrogate pairs, flanking classifies ASCII without a regular expression, the autolink check skips pieces with no `://`, `www.` or `@`, and the bookkeeping uses typed arrays. With the same probe: 63 ms (main 40 ms) and 38 ms (main 28 ms). `decideReferences` returns its results, and `isPunctuationOrSpace` shares the classifier. The changeset also lists U+000B, U+2028 and U+2029 with U+FEFF as characters that no longer count as whitespace beside a marker. ## Test plan `./node_modules/.bin/vitest run --project unit packages/lexical-markdown` ### After ``` Test Files 9 passed (9) Tests 875 passed (875) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 2 +- .../lexical-markdown/src/MarkdownExport.ts | 357 ++++++++++-------- packages/lexical-markdown/src/utils.ts | 63 +++- 3 files changed, 244 insertions(+), 178 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 1c8669a2281..f5895f93da8 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -4,6 +4,6 @@ [fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_` and `~~` markers open or close formatting -Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. The backslash, which was missing from the ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+FEFF no longer counts as whitespace. +Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. The backslash, which was missing from the ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. Lexical exported this shape for such formatting before, so check stored Markdown that has it. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it. No reference is written inside a bare URL, `www.` host, email or mention, so formatting right beside one is still lost there. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 95e5504472e..ca6156f6c41 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -250,8 +250,8 @@ function $exportChildrenForSelection( shouldPreserveNewLines: boolean, unclosedTags?: OpenTag[], unclosableTags?: OpenTag[], -): {shouldInclude: boolean; pieces: MarkdownPiece[]} { - const output: MarkdownPiece[] = []; +): {shouldInclude: boolean; pieces: MarkdownPieces} { + const output = createPieces(); const children = node.getChildren(); let anyChildIncluded = false; @@ -310,7 +310,7 @@ function $exportChildrenForSelection( childIncluded = true; } if (childIncluded) { - output.push(rawPiece(result)); + pushPiece(output, RAW, result); anyChildIncluded = true; } continue mainLoop; @@ -319,22 +319,20 @@ function $exportChildrenForSelection( if ($isLineBreakNode(child)) { if (childIncluded) { - output.push(rawPiece($exportLineBreak(child))); + pushPiece(output, RAW, $exportLineBreak(child)); anyChildIncluded = true; } } else if ($isTextNode(child)) { if (childIncluded) { - appendPieces( + $exportTextFormat( + child, + $exportedText(child, selection), + textFormatTransformers, + unclosedTags, + unclosableTags, + shouldPreserveNewLines, + selection, output, - $exportTextFormat( - child, - $exportedText(child, selection), - textFormatTransformers, - unclosedTags, - unclosableTags, - shouldPreserveNewLines, - selection, - ), ); anyChildIncluded = true; } @@ -364,7 +362,7 @@ function $exportChildrenForSelection( } } else if ($isDecoratorNode(child)) { if (childIncluded) { - output.push(textPiece(child.getTextContent())); + pushPiece(output, TEXT, child.getTextContent()); anyChildIncluded = true; } } @@ -443,8 +441,8 @@ function $exportChildPieces( unclosedTags?: OpenTag[], unclosableTags?: OpenTag[], shouldPreserveNewLines: boolean = false, -): MarkdownPiece[] { - const output: MarkdownPiece[] = []; +): MarkdownPieces { + const output = createPieces(); const children = node.getChildren(); // keep track of unclosed tags from the very beginning if (!unclosedTags) { @@ -491,25 +489,23 @@ function $exportChildPieces( ); if (result != null) { - output.push(rawPiece(result)); + pushPiece(output, RAW, result); continue mainLoop; } } if ($isLineBreakNode(child)) { - output.push(rawPiece($exportLineBreak(child))); + pushPiece(output, RAW, $exportLineBreak(child)); } else if ($isTextNode(child)) { - appendPieces( + $exportTextFormat( + child, + child.getTextContent(), + textTransformersIndex, + unclosedTags, + unclosableTags, + shouldPreserveNewLines, + null, output, - $exportTextFormat( - child, - child.getTextContent(), - textTransformersIndex, - unclosedTags, - unclosableTags, - shouldPreserveNewLines, - null, - ), ); } else if ($isElementNode(child)) { // empty paragraph returns "" @@ -525,7 +521,7 @@ function $exportChildPieces( ), ); } else if ($isDecoratorNode(child)) { - output.push(textPiece(child.getTextContent())); + pushPiece(output, TEXT, child.getTextContent()); } } @@ -546,7 +542,9 @@ function $exportTextFormat( shouldPreserveNewLines: boolean, // the selection being exported, which ends the text a tag can extend over selection: BaseSelection | null, -): MarkdownPiece[] { + // where the exported pieces go + pieces: MarkdownPieces = createPieces(), +): MarkdownPieces { // This function handles the case of a string looking like this: " foo " // Where it would be invalid markdown to generate: "** foo **" // so the tags go inside the whitespace instead: " **foo** ". @@ -571,10 +569,10 @@ function $exportTextFormat( const holdsTag = canHoldTag(textContent); // the opening tags to be added to the result - const openingTags: MarkdownPiece[] = []; + let openingTags = ''; // the closing tags to be added to the result - const closingTagsBefore: MarkdownPiece[] = []; - const closingTagsAfter: MarkdownPiece[] = []; + let closingTagsBefore = ''; + let closingTagsAfter = ''; const prevNode = getTextSibling(node, true); @@ -602,7 +600,7 @@ function $exportTextFormat( !unclosedTags.find(element => element.tag === tag) ) { unclosedTags.push({answers: new Map(), format, tag}); - openingTags.push({kind: 'opener', value: tag}); + openingTags += tag; } } } @@ -633,9 +631,9 @@ function $exportTextFormat( if (!nodeHasFormat) { // Handles cases where the tag has not been closed before, e.g. if the previous node // was a text match transformer that did not account for closing tags of the next node (e.g. a link) - closingTagsBefore.push({kind: 'closer', value: unclosedTag.tag}); + closingTagsBefore += unclosedTag.tag; } else { - closingTagsAfter.push({kind: 'closer', value: unclosedTag.tag}); + closingTagsAfter += unclosedTag.tag; } } // Mutate the original array to remove the closed tag @@ -651,7 +649,9 @@ function $exportTextFormat( } else if (!holdsTag) { // If the node is entirely whitespace, we don't apply opening/closing tags // around it, but it must still output any closing tags. - return [...closingTagsBefore, ...closingTagsAfter, textPiece(output)]; + pushPiece(pieces, CLOSER, closingTagsBefore + closingTagsAfter); + pushPiece(pieces, TEXT, output); + return pieces; } // Flanking Compliance: CommonMark flanking rules require formatting tags to be @@ -664,51 +664,61 @@ function $exportTextFormat( const trimmedOutput = trimmedStart.trimEnd(); const leadingSpace = output.slice(0, output.length - trimmedStart.length); const trailingSpace = trimmedStart.slice(trimmedOutput.length); - return [ - ...closingTagsBefore, - textPiece(leadingSpace), - ...openingTags, - // Inline code is a literal span: a character reference would show as is. - isCode ? rawPiece(trimmedOutput) : textPiece(trimmedOutput), - ...closingTagsAfter, - textPiece(trailingSpace), - ]; + pushPiece(pieces, CLOSER, closingTagsBefore); + pushPiece(pieces, TEXT, leadingSpace); + pushPiece(pieces, OPENER, openingTags); + // Inline code is a literal span: a character reference would show as is. + pushPiece(pieces, isCode ? RAW : TEXT, trimmedOutput); + pushPiece(pieces, CLOSER, closingTagsAfter); + pushPiece(pieces, TEXT, trailingSpace); + return pieces; } /** * Exported inline Markdown, with the format markers kept apart from the text * until `joinPieces` can see the characters on both sides of each delimiter - * run. A character reference can stand in for a character of `text`, which - * is text and decorator content; `raw` is anything else, such as code spans, - * line breaks and the output of transformers, whose syntax a reference could - * break. + * run: `values[i]` is a piece of the output and `kinds[i]` what it is. A + * character reference can stand in for a character of `TEXT`, which is text + * and decorator content; `RAW` is anything else, such as code spans, line + * breaks and the output of transformers, whose syntax a reference could + * break. `OPENER` and `CLOSER` are format markers. */ -interface MarkdownPiece { - kind: 'text' | 'raw' | 'opener' | 'closer'; - value: string; +interface MarkdownPieces { + kinds: number[]; + values: string[]; } -function textPiece(value: string): MarkdownPiece { - return {kind: 'text', value}; +const TEXT = 0; +const RAW = 1; +const OPENER = 2; +const CLOSER = 3; + +function createPieces(): MarkdownPieces { + return {kinds: [], values: []}; } -function rawPiece(value: string): MarkdownPiece { - return {kind: 'raw', value}; +function pushPiece(pieces: MarkdownPieces, kind: number, value: string) { + if (value !== '') { + pieces.kinds.push(kind); + pieces.values.push(value); + } } -// A loop rather than `push(...pieces)`, whose argument count is limited. -function appendPieces(output: MarkdownPiece[], pieces: MarkdownPiece[]) { - for (const piece of pieces) { - output.push(piece); +function appendPieces(output: MarkdownPieces, pieces: MarkdownPieces) { + for (let i = 0; i < pieces.kinds.length; i++) { + output.kinds.push(pieces.kinds[i]); + output.values.push(pieces.values[i]); } } -function isMarker(piece: MarkdownPiece | undefined): boolean { - return ( - piece !== undefined && (piece.kind === 'opener' || piece.kind === 'closer') - ); +function isMarker(kind: number | undefined): boolean { + return kind === OPENER || kind === CLOSER; } +const SPACE = /\s/; +// A bare URL, `www.` host, email or mention, which a reader may autolink. +const LINKISH = /:\/\/|www\.|@/i; + // Characters that a reference does not stand in for everywhere: CommonMark // readers and HTML decode references to control characters, unpaired // surrogates and noncharacters as U+FFFD (or remap them). @@ -744,32 +754,28 @@ interface MarkerRun { * `a**\***`. The word character beside such a run goes out as a numeric * reference instead, whose `&` or `;` is punctuation: `a**\***`. */ -function joinPieces(pieces: MarkdownPiece[]): string { - // Drop empty pieces and merge adjacent text, so that each run of markers - // sits between the pieces whose characters a reader sees beside it. - const merged: MarkdownPiece[] = []; - for (const piece of pieces) { - const last = merged[merged.length - 1]; - if (piece.value === '') { - continue; - } else if (last && !isMarker(piece) && last.kind === piece.kind) { - merged[merged.length - 1] = { - kind: last.kind, - value: last.value + piece.value, - }; +function joinPieces(pieces: MarkdownPieces): string { + if (!pieces.kinds.some(isMarker)) { + return pieces.values.join(''); + } + // Merge adjacent text (and adjacent raw output), so that each run of + // markers sits between the pieces whose characters a reader sees beside it. + const kinds: number[] = []; + const values: string[] = []; + for (let i = 0; i < pieces.kinds.length; i++) { + const kind = pieces.kinds[i]; + const last = kinds.length - 1; + if (last >= 0 && !isMarker(kind) && kinds[last] === kind) { + values[last] += pieces.values[i]; } else { - merged.push(piece); + kinds.push(kind); + values.push(pieces.values[i]); } } - const encodeStart: boolean[] = []; - const encodeEnd: boolean[] = []; - if (merged.some(isMarker)) { - decideReferences(merged, encodeStart, encodeEnd); - } + const {encodeStart, encodeEnd} = decideReferences(kinds, values); let markdown = ''; - for (let index = 0; index < merged.length; index++) { - const {kind} = merged[index]; - let {value} = merged[index]; + for (let index = 0; index < kinds.length; index++) { + let value = values[index]; let start = ''; let end = ''; if (encodeStart[index]) { @@ -782,11 +788,11 @@ function joinPieces(pieces: MarkdownPiece[]): string { end = toCharacterReference(last); value = value.slice(0, -last.length); } - // A backslash left unescaped (when preserving newlines) would escape the - // reference or marker written after it. + // A backslash left unescaped (when preserving newlines, or in decorator + // text) would escape the reference or marker written after it. if ( - kind === 'text' && - (end !== '' || isMarker(merged[index + 1])) && + kinds[index] === TEXT && + (end !== '' || isMarker(kinds[index + 1])) && isEscaped(value, value.length) ) { value = value.slice(0, -1) + toCharacterReference('\\'); @@ -801,106 +807,127 @@ function joinPieces(pieces: MarkdownPiece[]): string { * reference for the marker run beside it to open or close. */ function decideReferences( - merged: MarkdownPiece[], - encodeStart: boolean[], - encodeEnd: boolean[], -) { + kinds: number[], + values: string[], +): {encodeStart: Uint8Array; encodeEnd: Uint8Array} { + const count = kinds.length; + const encodeStart = new Uint8Array(count); + const encodeEnd = new Uint8Array(count); const runs: MarkerRun[] = []; // The run that follows and the run that precedes each piece, or -1. - const runAfter: number[] = new Array(merged.length).fill(-1); - const runBefore: number[] = new Array(merged.length).fill(-1); - for (let start = 0; start < merged.length; start++) { - if (!isMarker(merged[start])) { + const runAfter = new Int32Array(count).fill(-1); + const runBefore = new Int32Array(count).fill(-1); + for (let start = 0; start < count; start++) { + if (!isMarker(kinds[start])) { continue; } let end = start; - while (isMarker(merged[end + 1])) { + while (isMarker(kinds[end + 1])) { end++; } // Markers of one character form one delimiter run, and only the first // and the last delimiter run touch text. - const groups: {char: string; closes: boolean; opens: boolean}[] = []; - for (let i = start; i <= end; i++) { - const {kind, value} = merged[i]; - let group = groups[groups.length - 1]; - if (!group || group.char !== value[0]) { - group = {char: value[0], closes: false, opens: false}; - groups.push(group); + const firstMarker = values[start][0]; + let opens = false; + let afterFirst: string | undefined; + for (let i = start; i <= end && afterFirst === undefined; i++) { + const value = values[i]; + if (value[0] !== firstMarker) { + afterFirst = value[0]; + break; + } + opens ||= kinds[i] === OPENER; + for (let c = 1; c < value.length; c++) { + if (value[c] !== firstMarker) { + afterFirst = value[c]; + break; + } + } + } + const lastMarker = values[end][values[end].length - 1]; + let closes = false; + let beforeLast: string | undefined; + for (let i = end; i >= start && beforeLast === undefined; i--) { + const value = values[i]; + if (value[value.length - 1] !== lastMarker) { + beforeLast = value[value.length - 1]; + break; + } + closes ||= kinds[i] === CLOSER; + for (let c = value.length - 2; c >= 0; c--) { + if (value[c] !== lastMarker) { + beforeLast = value[c]; + break; + } } - group.opens ||= kind === 'opener'; - group.closes ||= kind === 'closer'; } - const last = groups.length - 1; if (start > 0) { runAfter[start - 1] = runs.length; } - if (end + 1 < merged.length) { + if (end + 1 < count) { runBefore[end + 1] = runs.length; } runs.push({ after: end + 1, - afterFirst: last > 0 ? groups[1].char : undefined, + afterFirst, before: start - 1, - beforeLast: last > 0 ? groups[last - 1].char : undefined, - closes: groups[last].closes, - opens: groups[0].opens, + beforeLast, + closes, + opens, }); start = end; } - // Whether the word at the end (or start) of the piece at `index` is a bare - // URL, `www.` host, email or mention. A reference inside one would change - // what an autolinking reader links to, so the format is lost there instead, - // as before. - const linkish: boolean[][] = [[], []]; - const isLinkish = (index: number, atEnd: boolean): boolean => { - const known = linkish[atEnd ? 1 : 0]; - if (known[index] === undefined) { - const {value} = merged[index]; - let start = atEnd ? value.length : 0; - let end = start; - if (atEnd) { - while (start > 0 && /\S/.test(value[start - 1])) { - start--; - } - } else { - while (end < value.length && /\S/.test(value[end])) { - end++; - } - } - known[index] = /:\/\/|www\.|@/i.test(value.slice(start, end)); - } - return known[index]; - }; - // Whether the last (or first) character of the piece at `index` can go out - // as a reference. - const canReference = (index: number, atEnd: boolean): boolean => - index >= 0 && - index < merged.length && - merged[index].kind === 'text' && - isWordCharacter(atEnd ? endChar(index) : startChar(index)) && - !isLinkish(index, atEnd); + const firstChar = (index: number) => characterAt(values[index], 0)!; + const lastChar = (index: number) => + characterBefore(values[index], values[index].length)!; // A one-character piece written as a reference reads as punctuation at // both ends: `a`. - const firstChars = merged.map(({value}) => characterAt(value, 0)!); - const lastChars = merged.map( - ({value}) => characterBefore(value, value.length)!, - ); const isOneCharacter = (index: number) => - firstChars[index].length === merged[index].value.length; + firstChar(index).length === values[index].length; const isEncoded = (index: number) => (encodeStart[index] || encodeEnd[index]) && isOneCharacter(index); const startChar = (index: number) => - index >= merged.length + index >= count ? undefined : encodeStart[index] || isEncoded(index) ? '&' - : firstChars[index]; + : firstChar(index); const endChar = (index: number) => index < 0 ? undefined : encodeEnd[index] || isEncoded(index) ? ';' - : lastChars[index]; + : lastChar(index); + // Whether the word at the end (or start) of the piece at `index` is a bare + // URL, `www.` host, email or mention. A reference inside one would change + // what an autolinking reader links to, so the format is lost there instead, + // as before. + const isLinkish = (index: number, atEnd: boolean): boolean => { + const value = values[index]; + if (!LINKISH.test(value)) { + return false; + } + let start = atEnd ? value.length : 0; + let end = start; + if (atEnd) { + while (start > 0 && !SPACE.test(value[start - 1])) { + start--; + } + } else { + while (end < value.length && !SPACE.test(value[end])) { + end++; + } + } + return LINKISH.test(value.slice(start, end)); + }; + // Whether the last (or first) character of the piece at `index` can go out + // as a reference. + const canReference = (index: number, atEnd: boolean): boolean => + index >= 0 && + index < count && + kinds[index] === TEXT && + isWordCharacter(atEnd ? endChar(index) : startChar(index)) && + !isLinkish(index, atEnd); // Writing a one-character piece as a reference makes its other edge // punctuation too. That can stop the delimiter run touching that edge from // flanking unless the piece past it is written as a reference as well, and @@ -911,14 +938,15 @@ function decideReferences( // walked at most twice. Raw output and unreferenceable characters stay // what they are, and other references only make edges punctuation, so a // remembered answer can only go stale toward writing fewer references. - const spreads: (boolean | undefined)[][] = [[], []]; + // 0 while unknown, then 1 when it spreads and 2 when it is blocked. + const spreads = [new Uint8Array(count), new Uint8Array(count)]; const canSpread = (index: number, leftward: boolean): boolean => { const side = leftward ? 0 : 1; const visited: number[] = []; let result = true; for (let piece = index; ; ) { - if (spreads[side][piece] !== undefined) { - result = spreads[side][piece]!; + if (spreads[side][piece] !== 0) { + result = spreads[side][piece] === 1; break; } visited.push(piece); @@ -946,7 +974,7 @@ function decideReferences( piece = next; } for (const piece of visited) { - spreads[side][piece] = result; + spreads[side][piece] = result ? 1 : 2; } return result; }; @@ -967,29 +995,30 @@ function decideReferences( const charAfter = startChar(after); if ( opens && - canReference(before, true) && !isFlankedBy(afterFirst || charAfter, charBefore) && isFlankedBy(afterFirst || charAfter, ';') && + canReference(before, true) && isFree(before, true) ) { - encodeEnd[before] = true; + encodeEnd[before] = 1; if (isEncoded(before) && runBefore[before] !== -1) { pending.push(runBefore[before]); } } if ( closes && - canReference(after, false) && !isFlankedBy(beforeLast || charBefore, charAfter) && isFlankedBy(beforeLast || charBefore, '&') && + canReference(after, false) && isFree(after, false) ) { - encodeStart[after] = true; + encodeStart[after] = 1; if (isEncoded(after) && runAfter[after] !== -1) { pending.push(runAfter[after]); } } } + return {encodeEnd, encodeStart}; } function getTextSibling(node: TextNode, backward: boolean): TextNode | null { diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index 03c9cfe1352..acd6dfa7ba7 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -66,6 +66,36 @@ export function transformersByType(transformers: Transformer[]): Readonly<{ export const PUNCTUATION = /[\p{P}\p{S}]/u; export const WHITESPACE = /[\p{Zs}\t\n\f\r]/u; +const enum CharacterClass { + Whitespace, + Punctuation, + Other, +} + +/** How flanking sees `character`; the edge of the line is whitespace. */ +function classify(character: string | undefined): CharacterClass { + if (character === undefined) { + return CharacterClass.Whitespace; + } + const code = character.charCodeAt(0); + // ASCII, without a regular expression + if (code < 0x80) { + return code === 0x20 || (code >= 0x09 && code <= 0x0d && code !== 0x0b) + ? CharacterClass.Whitespace + : (code >= 0x21 && code <= 0x2f) || + (code >= 0x3a && code <= 0x40) || + (code >= 0x5b && code <= 0x60) || + (code >= 0x7b && code <= 0x7e) + ? CharacterClass.Punctuation + : CharacterClass.Other; + } + return WHITESPACE.test(character) + ? CharacterClass.Whitespace + : PUNCTUATION.test(character) + ? CharacterClass.Punctuation + : CharacterClass.Other; +} + /** * Whether a delimiter run with `primary` on the side it faces (after it for * left-flanking, before it for right-flanking) and `secondary` on the other @@ -76,25 +106,32 @@ export function isFlankedBy( primary: string | undefined, secondary: string | undefined, ): boolean { - if (primary === undefined || WHITESPACE.test(primary)) { - return false; - } + const primaryClass = classify(primary); return ( - !PUNCTUATION.test(primary) || - secondary === undefined || - WHITESPACE.test(secondary) || - PUNCTUATION.test(secondary) + primaryClass === CharacterClass.Other || + (primaryClass === CharacterClass.Punctuation && + classify(secondary) !== CharacterClass.Other) ); } export function isPunctuationOrSpace(character: string): boolean { - return PUNCTUATION.test(character) || WHITESPACE.test(character); + return classify(character) !== CharacterClass.Other; +} + +function isHighSurrogate(code: number): boolean { + return code >= 0xd800 && code <= 0xdbff; +} + +function isLowSurrogate(code: number): boolean { + return code >= 0xdc00 && code <= 0xdfff; } /** The character (code point) that starts at `index`, if any. */ export function characterAt(text: string, index: number): string | undefined { - const codePoint = text.codePointAt(index); - return codePoint === undefined ? undefined : String.fromCodePoint(codePoint); + return isHighSurrogate(text.charCodeAt(index)) && + isLowSurrogate(text.charCodeAt(index + 1)) + ? text.slice(index, index + 2) + : text[index]; } /** The character (code point) that ends just before `index`, if any. */ @@ -102,9 +139,9 @@ export function characterBefore( text: string, index: number, ): string | undefined { - const codePoint = index >= 2 ? text.codePointAt(index - 2) : undefined; - return codePoint !== undefined && codePoint > 0xffff - ? String.fromCodePoint(codePoint) + return isLowSurrogate(text.charCodeAt(index - 1)) && + isHighSurrogate(text.charCodeAt(index - 2)) + ? text.slice(index - 2, index) : text[index - 1]; } From a30ec8f7942548298e777c64de3b246337cc8ce1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 09:52:36 +0000 Subject: [PATCH 14/70] [lexical-markdown] Bug Fix: Look for autolinks at the edge of the text only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The autolink check took the whole run of non-whitespace at a piece's edge, so in spaceless CJK text or after a closing parenthesis an `@` anywhere in it stopped the reference (`联系我@微博你好` before a bold `“重要”` lost the bold), and it rescanned the whole piece on every call. It now scans at most 2,048 URL characters back from the edge and asks whether a URL, `www.` host, email or mention ends there (or starts there, after a closer). `CharacterClass` is plain numeric constants instead of a `const enum`, which Babel does not inline, and the importer's `_` rule asks the shared `isPunctuation` instead of a second regular expression. ## Test plan `./node_modules/.bin/vitest run --project unit packages/lexical-markdown` ### After ``` Test Files 9 passed (9) Tests 877 passed (877) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 30 +++++++------- .../__tests__/unit/LexicalMarkdown.test.ts | 16 ++++++++ .../src/importTextFormatTransformer.ts | 11 ++++-- packages/lexical-markdown/src/utils.ts | 39 ++++++++++--------- 4 files changed, 61 insertions(+), 35 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index ca6156f6c41..092c5bffdae 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -715,9 +715,14 @@ function isMarker(kind: number | undefined): boolean { return kind === OPENER || kind === CLOSER; } -const SPACE = /\s/; -// A bare URL, `www.` host, email or mention, which a reader may autolink. -const LINKISH = /:\/\/|www\.|@/i; +// Characters a URL, email or mention can hold. +const LINK_CHARACTER = /[\w!#$%&'()*+,./:;=?@~-]/; +// How far the export looks for one, which keeps the look linear. +const LINK_LOOKAHEAD = 2048; +// A URL, `www.` host, email or mention at the end of the text before it, or +// at the start of the text after it. +const LINK_AT_END = /:\/\/|(?:^|[^\w.-])www\.|@[\w.-]*\w$/i; +const LINK_AT_START = /^[(]*(?:[a-z][\w+.-]*:\/\/|www\.)|^[\w.+-]*@\w/i; // Characters that a reference does not stand in for everywhere: CommonMark // readers and HTML decode references to control characters, unpaired @@ -898,27 +903,26 @@ function decideReferences( : encodeEnd[index] || isEncoded(index) ? ';' : lastChar(index); - // Whether the word at the end (or start) of the piece at `index` is a bare - // URL, `www.` host, email or mention. A reference inside one would change - // what an autolinking reader links to, so the format is lost there instead, - // as before. + // Whether the end (or start) of the piece at `index` is a bare URL, `www.` + // host, email or mention. A reference inside one would change what an + // autolinking reader links to, so the format is lost there instead, as + // before. const isLinkish = (index: number, atEnd: boolean): boolean => { const value = values[index]; - if (!LINKISH.test(value)) { - return false; - } let start = atEnd ? value.length : 0; let end = start; if (atEnd) { - while (start > 0 && !SPACE.test(value[start - 1])) { + const limit = Math.max(0, start - LINK_LOOKAHEAD); + while (start > limit && LINK_CHARACTER.test(value[start - 1])) { start--; } } else { - while (end < value.length && !SPACE.test(value[end])) { + const limit = Math.min(value.length, LINK_LOOKAHEAD); + while (end < limit && LINK_CHARACTER.test(value[end])) { end++; } } - return LINKISH.test(value.slice(start, end)); + return (atEnd ? LINK_AT_END : LINK_AT_START).test(value.slice(start, end)); }; // Whether the last (or first) character of the piece at `index` can go out // as a reference. diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 2e2afe378a1..a9c1684aa7b 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -4903,6 +4903,22 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ], 'x**a*“b”***', ], + // An `@` that is not part of a link where the text ends does not stop + // the reference. + [ + [ + ['联系我@微博你好', []], + [`${LEFT_DOUBLE_QUOTE}重要${RIGHT_DOUBLE_QUOTE}`, ['bold']], + ], + `联系我@微博你好**${LEFT_DOUBLE_QUOTE}重要${RIGHT_DOUBLE_QUOTE}**`, + ], + [ + [ + ['(a@b.co)and', []], + [`${LEFT_DOUBLE_QUOTE}x${RIGHT_DOUBLE_QUOTE}`, ['bold']], + ], + `(a@b.co)and**${LEFT_DOUBLE_QUOTE}x${RIGHT_DOUBLE_QUOTE}**`, + ], // A word character beside a word character needs no reference. [ [ diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index 099c20f579a..0341af1fbdb 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -10,7 +10,12 @@ import type {TextFormatTransformersIndex} from './MarkdownImport'; import type {TextFormatTransformer} from './MarkdownTransformers'; import type {TextNode} from 'lexical'; -import {characterAt, characterBefore, isFlankedBy, PUNCTUATION} from './utils'; +import { + characterAt, + characterBefore, + isFlankedBy, + isPunctuation, +} from './utils'; interface Delimiter { index: number; @@ -393,8 +398,8 @@ function canEmphasis( } // `_` opens or closes inside a word only beside punctuation. return { - canClose: isRight && (!isLeft || PUNCTUATION.test(charAfter || '')), - canOpen: isLeft && (!isRight || PUNCTUATION.test(charBefore || '')), + canClose: isRight && (!isLeft || isPunctuation(charAfter)), + canOpen: isLeft && (!isRight || isPunctuation(charBefore)), }; } diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index acd6dfa7ba7..8a1e856ca1a 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -63,37 +63,35 @@ export function transformersByType(transformers: Transformer[]): Readonly<{ // U+FEFF. Delimiter flanking depends on both. // https://spec.commonmark.org/0.31.2/#unicode-punctuation-character // https://spec.commonmark.org/0.31.2/#unicode-whitespace-character -export const PUNCTUATION = /[\p{P}\p{S}]/u; -export const WHITESPACE = /[\p{Zs}\t\n\f\r]/u; +const PUNCTUATION = /[\p{P}\p{S}]/u; +const WHITESPACE = /[\p{Zs}\t\n\f\r]/u; -const enum CharacterClass { - Whitespace, - Punctuation, - Other, -} +const WHITESPACE_CLASS = 0; +const PUNCTUATION_CLASS = 1; +const OTHER_CLASS = 2; /** How flanking sees `character`; the edge of the line is whitespace. */ -function classify(character: string | undefined): CharacterClass { +function classify(character: string | undefined): number { if (character === undefined) { - return CharacterClass.Whitespace; + return WHITESPACE_CLASS; } const code = character.charCodeAt(0); // ASCII, without a regular expression if (code < 0x80) { return code === 0x20 || (code >= 0x09 && code <= 0x0d && code !== 0x0b) - ? CharacterClass.Whitespace + ? WHITESPACE_CLASS : (code >= 0x21 && code <= 0x2f) || (code >= 0x3a && code <= 0x40) || (code >= 0x5b && code <= 0x60) || (code >= 0x7b && code <= 0x7e) - ? CharacterClass.Punctuation - : CharacterClass.Other; + ? PUNCTUATION_CLASS + : OTHER_CLASS; } return WHITESPACE.test(character) - ? CharacterClass.Whitespace + ? WHITESPACE_CLASS : PUNCTUATION.test(character) - ? CharacterClass.Punctuation - : CharacterClass.Other; + ? PUNCTUATION_CLASS + : OTHER_CLASS; } /** @@ -108,14 +106,17 @@ export function isFlankedBy( ): boolean { const primaryClass = classify(primary); return ( - primaryClass === CharacterClass.Other || - (primaryClass === CharacterClass.Punctuation && - classify(secondary) !== CharacterClass.Other) + primaryClass === OTHER_CLASS || + (primaryClass === PUNCTUATION_CLASS && classify(secondary) !== OTHER_CLASS) ); } export function isPunctuationOrSpace(character: string): boolean { - return classify(character) !== CharacterClass.Other; + return classify(character) !== OTHER_CLASS; +} + +export function isPunctuation(character: string | undefined): boolean { + return classify(character) === PUNCTUATION_CLASS; } function isHighSurrogate(code: number): boolean { From bab34854421dff151f94802ea2629e1e125439d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 10:04:57 +0000 Subject: [PATCH 15/70] [lexical-markdown] Bug Fix: See escaped underscores in autolinks and write nested pieces in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The export's autolink check stopped at the backslash that escapes `_`, `~` or `*`, so a mention like `@john_doe` or a URL path like `/a_b` next to formatted punctuation got a reference written inside it (`@john\_doe**(x)**`). The check now steps over escaping backslashes. Nested inline elements also wrote their pieces into a fresh array that the parent copied; they now write into the caller's pieces, in both the full and the selection export. ## Test plan ### Before ``` pnpm vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t autolink × writes no reference inside "https://example.com/a_b", which readers may autolink × writes no reference inside "@john_doe", which readers may autolink Tests 2 failed | 3 passed | 698 skipped (703) ``` ### After ``` pnpm vitest run --project unit packages/lexical-markdown Tests 879 passed (879) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 52 +++++++++---------- .../__tests__/unit/LexicalMarkdown.test.ts | 12 +++-- 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 092c5bffdae..67ef6c94977 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -250,8 +250,8 @@ function $exportChildrenForSelection( shouldPreserveNewLines: boolean, unclosedTags?: OpenTag[], unclosableTags?: OpenTag[], + output: MarkdownPieces = createPieces(), ): {shouldInclude: boolean; pieces: MarkdownPieces} { - const output = createPieces(); const children = node.getChildren(); let anyChildIncluded = false; @@ -337,6 +337,7 @@ function $exportChildrenForSelection( anyChildIncluded = true; } } else if ($isElementNode(child)) { + const childStart = output.kinds.length; const childResult = $exportChildrenForSelection( child, selection, @@ -345,6 +346,7 @@ function $exportChildrenForSelection( shouldPreserveNewLines, unclosedTags, unclosableTags, + output, ); // extractWithChild: if child has selected descendants, ask parent if it should be included @@ -357,8 +359,10 @@ function $exportChildrenForSelection( } if (childIncluded || childResult.shouldInclude) { - appendPieces(output, childResult.pieces); anyChildIncluded = true; + } else { + output.kinds.length = childStart; + output.values.length = childStart; } } else if ($isDecoratorNode(child)) { if (childIncluded) { @@ -441,8 +445,8 @@ function $exportChildPieces( unclosedTags?: OpenTag[], unclosableTags?: OpenTag[], shouldPreserveNewLines: boolean = false, + output: MarkdownPieces = createPieces(), ): MarkdownPieces { - const output = createPieces(); const children = node.getChildren(); // keep track of unclosed tags from the very beginning if (!unclosedTags) { @@ -509,16 +513,14 @@ function $exportChildPieces( ); } else if ($isElementNode(child)) { // empty paragraph returns "" - appendPieces( + $exportChildPieces( + child, + textTransformersIndex, + textMatchTransformers, + unclosedTags, + unclosableTags, + shouldPreserveNewLines, output, - $exportChildPieces( - child, - textTransformersIndex, - textMatchTransformers, - unclosedTags, - unclosableTags, - shouldPreserveNewLines, - ), ); } else if ($isDecoratorNode(child)) { pushPiece(output, TEXT, child.getTextContent()); @@ -704,25 +706,19 @@ function pushPiece(pieces: MarkdownPieces, kind: number, value: string) { } } -function appendPieces(output: MarkdownPieces, pieces: MarkdownPieces) { - for (let i = 0; i < pieces.kinds.length; i++) { - output.kinds.push(pieces.kinds[i]); - output.values.push(pieces.values[i]); - } -} - function isMarker(kind: number | undefined): boolean { return kind === OPENER || kind === CLOSER; } -// Characters a URL, email or mention can hold. -const LINK_CHARACTER = /[\w!#$%&'()*+,./:;=?@~-]/; +// Characters a URL, email or mention can hold, and the backslash that +// escapes one of them. +const LINK_CHARACTER = /[\w!#$%&'()*+,./:;=?@~\\-]/; // How far the export looks for one, which keeps the look linear. const LINK_LOOKAHEAD = 2048; // A URL, `www.` host, email or mention at the end of the text before it, or // at the start of the text after it. const LINK_AT_END = /:\/\/|(?:^|[^\w.-])www\.|@[\w.-]*\w$/i; -const LINK_AT_START = /^[(]*(?:[a-z][\w+.-]*:\/\/|www\.)|^[\w.+-]*@\w/i; +const LINK_AT_START = /^(?:[a-z][\w+.-]*:\/\/|www\.)|^[\w.+-]*@\w/i; // Characters that a reference does not stand in for everywhere: CommonMark // readers and HTML decode references to control characters, unpaired @@ -835,7 +831,7 @@ function decideReferences( const firstMarker = values[start][0]; let opens = false; let afterFirst: string | undefined; - for (let i = start; i <= end && afterFirst === undefined; i++) { + findAfterFirst: for (let i = start; i <= end; i++) { const value = values[i]; if (value[0] !== firstMarker) { afterFirst = value[0]; @@ -845,14 +841,14 @@ function decideReferences( for (let c = 1; c < value.length; c++) { if (value[c] !== firstMarker) { afterFirst = value[c]; - break; + break findAfterFirst; } } } const lastMarker = values[end][values[end].length - 1]; let closes = false; let beforeLast: string | undefined; - for (let i = end; i >= start && beforeLast === undefined; i--) { + findBeforeLast: for (let i = end; i >= start; i--) { const value = values[i]; if (value[value.length - 1] !== lastMarker) { beforeLast = value[value.length - 1]; @@ -862,7 +858,7 @@ function decideReferences( for (let c = value.length - 2; c >= 0; c--) { if (value[c] !== lastMarker) { beforeLast = value[c]; - break; + break findBeforeLast; } } } @@ -922,7 +918,9 @@ function decideReferences( end++; } } - return (atEnd ? LINK_AT_END : LINK_AT_START).test(value.slice(start, end)); + return (atEnd ? LINK_AT_END : LINK_AT_START).test( + value.slice(start, end).replace(/\\/g, ''), + ); }; // Whether the last (or first) character of the piece at `index` can go out // as a reference. diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index a9c1684aa7b..44daab0b9b8 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5086,9 +5086,15 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ).toBe(true); }); - it.each(['www.example.com', 'me@example.com', 'https://example.com/a'])( + it.each([ + ['www.example.com', 'www.example.com'], + ['me@example.com', 'me@example.com'], + ['https://example.com/a', 'https://example.com/a'], + ['https://example.com/a_b', 'https://example.com/a\\_b'], + ['@john_doe', '@john\\_doe'], + ])( 'writes no reference inside %j, which readers may autolink', - link => { + (link, markdown) => { const editor = createTestEditor(); editor.update( () => @@ -5104,7 +5110,7 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ); expect( editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), - ).toMatch(new RegExp(`^see ${link.replace(/\./g, '\\.')}\\*\\*`)); + ).toMatch(new RegExp(`^see ${markdown.replace(/[.\\]/g, '\\$&')}\\*\\*`)); }, ); From 7675fe4d938187fa05511d180a27d82c78396c7c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 10:25:07 +0000 Subject: [PATCH 16/70] [lexical-markdown] Refactor: Remember each link check and share the delimiter run scan ## Description The export now works out whether a piece edge is a URL, email or mention once per edge instead of on every reference decision, scans a delimiter run's two ends with one helper, and names the reference check `isReferenceable`. `characterAt` uses `codePointAt`. The changeset's Verify paragraph now also names stored Markdown such as `a**\_x**`, which imports with literal markers now that the backslash counts as punctuation. ## Test plan ### After ``` pnpm vitest run --project unit packages/lexical-markdown Tests 879 passed (879) pnpm run check-changesets Changeset check passed: 32 pending changeset(s) valid. ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 2 +- .../lexical-markdown/src/MarkdownExport.ts | 99 ++++++++++++------- packages/lexical-markdown/src/utils.ts | 6 +- 3 files changed, 65 insertions(+), 42 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index f5895f93da8..c24a88ae9ef 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -6,4 +6,4 @@ Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. The backslash, which was missing from the ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. -Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. Lexical exported this shape for such formatting before, so check stored Markdown that has it. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it. No reference is written inside a bare URL, `www.` host, email or mention, so formatting right beside one is still lost there. +Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it. No reference is written inside a bare URL, `www.` host, email or mention, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `ping @user**\_x**`. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 67ef6c94977..ffd308e79d2 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -726,7 +726,7 @@ const LINK_AT_START = /^(?:[a-z][\w+.-]*:\/\/|www\.)|^[\w.+-]*@\w/i; const UNREFERENCEABLE = /[\p{Cc}\p{Cs}\p{Noncharacter_Code_Point}]/u; /** Whether a character reference can stand in for `character` beside a marker. */ -function isWordCharacter(character: string | undefined): boolean { +function isReferenceable(character: string | undefined): boolean { return ( character !== undefined && !isPunctuationOrSpace(character) && @@ -803,6 +803,39 @@ function joinPieces(pieces: MarkdownPieces): string { return markdown; } +/** + * Walks the delimiter run at the edge of the markers from `from` to `to`, + * forward when `step` is 1 and backward when it is -1. Returns whether a + * marker of `kind` is part of that run, and the character past it within the + * markers, if any. + */ +function scanDelimiterRun( + kinds: number[], + values: string[], + from: number, + to: number, + step: 1 | -1, + kind: number, +): [boolean, string | undefined] { + const edge = values[from]; + const delimiter = edge[step === 1 ? 0 : edge.length - 1]; + let hasKind = false; + for (let i = from; i !== to + step; i += step) { + const value = values[i]; + let c = step === 1 ? 0 : value.length - 1; + if (value[c] !== delimiter) { + return [hasKind, value[c]]; + } + hasKind ||= kinds[i] === kind; + for (c += step; c >= 0 && c < value.length; c += step) { + if (value[c] !== delimiter) { + return [hasKind, value[c]]; + } + } + } + return [hasKind, undefined]; +} + /** * Marks the text pieces whose first or last character has to go out as a * reference for the marker run beside it to open or close. @@ -828,40 +861,22 @@ function decideReferences( } // Markers of one character form one delimiter run, and only the first // and the last delimiter run touch text. - const firstMarker = values[start][0]; - let opens = false; - let afterFirst: string | undefined; - findAfterFirst: for (let i = start; i <= end; i++) { - const value = values[i]; - if (value[0] !== firstMarker) { - afterFirst = value[0]; - break; - } - opens ||= kinds[i] === OPENER; - for (let c = 1; c < value.length; c++) { - if (value[c] !== firstMarker) { - afterFirst = value[c]; - break findAfterFirst; - } - } - } - const lastMarker = values[end][values[end].length - 1]; - let closes = false; - let beforeLast: string | undefined; - findBeforeLast: for (let i = end; i >= start; i--) { - const value = values[i]; - if (value[value.length - 1] !== lastMarker) { - beforeLast = value[value.length - 1]; - break; - } - closes ||= kinds[i] === CLOSER; - for (let c = value.length - 2; c >= 0; c--) { - if (value[c] !== lastMarker) { - beforeLast = value[c]; - break findBeforeLast; - } - } - } + const [opens, afterFirst] = scanDelimiterRun( + kinds, + values, + start, + end, + 1, + OPENER, + ); + const [closes, beforeLast] = scanDelimiterRun( + kinds, + values, + end, + start, + -1, + CLOSER, + ); if (start > 0) { runAfter[start - 1] = runs.length; } @@ -903,7 +918,7 @@ function decideReferences( // host, email or mention. A reference inside one would change what an // autolinking reader links to, so the format is lost there instead, as // before. - const isLinkish = (index: number, atEnd: boolean): boolean => { + const findsLink = (index: number, atEnd: boolean): boolean => { const value = values[index]; let start = atEnd ? value.length : 0; let end = start; @@ -922,13 +937,23 @@ function decideReferences( value.slice(start, end).replace(/\\/g, ''), ); }; + // `findsLink` for each piece edge, worked out once: 0 while unknown, then 1 + // when it finds one and 2 when it does not. + const linkish = [new Uint8Array(count), new Uint8Array(count)]; + const isLinkish = (index: number, atEnd: boolean): boolean => { + const side = atEnd ? 1 : 0; + if (linkish[side][index] === 0) { + linkish[side][index] = findsLink(index, atEnd) ? 1 : 2; + } + return linkish[side][index] === 1; + }; // Whether the last (or first) character of the piece at `index` can go out // as a reference. const canReference = (index: number, atEnd: boolean): boolean => index >= 0 && index < count && kinds[index] === TEXT && - isWordCharacter(atEnd ? endChar(index) : startChar(index)) && + isReferenceable(atEnd ? endChar(index) : startChar(index)) && !isLinkish(index, atEnd); // Writing a one-character piece as a reference makes its other edge // punctuation too. That can stop the delimiter run touching that edge from diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index 8a1e856ca1a..c553818eca3 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -129,10 +129,8 @@ function isLowSurrogate(code: number): boolean { /** The character (code point) that starts at `index`, if any. */ export function characterAt(text: string, index: number): string | undefined { - return isHighSurrogate(text.charCodeAt(index)) && - isLowSurrogate(text.charCodeAt(index + 1)) - ? text.slice(index, index + 2) - : text[index]; + const code = text.codePointAt(index); + return code === undefined ? undefined : String.fromCodePoint(code); } /** The character (code point) that ends just before `index`, if any. */ From 7cc4a6030ce92a4b57549b42e9698210a9628c5f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 10:57:43 +0000 Subject: [PATCH 17/70] [lexical-markdown] Bug Fix: Keep mailto and xmpp links whole and skip reference work when none is needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Formatted punctuation followed by plain `mailto:me@example.com` exported as `**“x”**mailto:me@example.com`, splitting the URI that GFM readers autolink. The start-of-text link check now knows `mailto:` and `xmpp:`. The export also returns before allocating its per-piece caches when no marker run could use a reference, and the delimiter run scan is one loop. ## Test plan ### Before ``` pnpm vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "at the start of" × writes no reference at the start of "mailto:me@example.com", which readers may autolink ``` ### After ``` pnpm vitest run --project unit packages/lexical-markdown Tests 883 passed (883) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 64 +++++++++++-------- .../__tests__/unit/LexicalMarkdown.test.ts | 27 ++++++++ 2 files changed, 63 insertions(+), 28 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index ffd308e79d2..7a6f871eb7b 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -337,7 +337,6 @@ function $exportChildrenForSelection( anyChildIncluded = true; } } else if ($isElementNode(child)) { - const childStart = output.kinds.length; const childResult = $exportChildrenForSelection( child, selection, @@ -358,11 +357,9 @@ function $exportChildrenForSelection( childIncluded = true; } + // An element child writes pieces only when it has included ones. if (childIncluded || childResult.shouldInclude) { anyChildIncluded = true; - } else { - output.kinds.length = childStart; - output.values.length = childStart; } } else if ($isDecoratorNode(child)) { if (childIncluded) { @@ -718,7 +715,8 @@ const LINK_LOOKAHEAD = 2048; // A URL, `www.` host, email or mention at the end of the text before it, or // at the start of the text after it. const LINK_AT_END = /:\/\/|(?:^|[^\w.-])www\.|@[\w.-]*\w$/i; -const LINK_AT_START = /^(?:[a-z][\w+.-]*:\/\/|www\.)|^[\w.+-]*@\w/i; +const LINK_AT_START = + /^(?:[a-z][\w+.-]*:\/\/|www\.|mailto:|xmpp:)|^[\w.+-]*@\w/i; // Characters that a reference does not stand in for everywhere: CommonMark // readers and HTML decode references to control characters, unpaired @@ -822,15 +820,15 @@ function scanDelimiterRun( let hasKind = false; for (let i = from; i !== to + step; i += step) { const value = values[i]; - let c = step === 1 ? 0 : value.length - 1; - if (value[c] !== delimiter) { - return [hasKind, value[c]]; - } - hasKind ||= kinds[i] === kind; - for (c += step; c >= 0 && c < value.length; c += step) { + for ( + let c = step === 1 ? 0 : value.length - 1; + c >= 0 && c < value.length; + c += step + ) { if (value[c] !== delimiter) { return [hasKind, value[c]]; } + hasKind ||= kinds[i] === kind; } } return [hasKind, undefined]; @@ -937,9 +935,7 @@ function decideReferences( value.slice(start, end).replace(/\\/g, ''), ); }; - // `findsLink` for each piece edge, worked out once: 0 while unknown, then 1 - // when it finds one and 2 when it does not. - const linkish = [new Uint8Array(count), new Uint8Array(count)]; + // `findsLink` for each piece edge, worked out once. const isLinkish = (index: number, atEnd: boolean): boolean => { const side = atEnd ? 1 : 0; if (linkish[side][index] === 0) { @@ -965,8 +961,6 @@ function decideReferences( // walked at most twice. Raw output and unreferenceable characters stay // what they are, and other references only make edges punctuation, so a // remembered answer can only go stale toward writing fewer references. - // 0 while unknown, then 1 when it spreads and 2 when it is blocked. - const spreads = [new Uint8Array(count), new Uint8Array(count)]; const canSpread = (index: number, leftward: boolean): boolean => { const side = leftward ? 0 : 1; const visited: number[] = []; @@ -1009,21 +1003,37 @@ function decideReferences( !isOneCharacter(index) || (leftward ? encodeStart[index] : encodeEnd[index]) || canSpread(index, leftward); + // A reference helps only where the run would flank if the character + // beside it were punctuation. + const opensWithReference = ({opens, before, after, afterFirst}: MarkerRun) => + opens && + !isFlankedBy(afterFirst || startChar(after), endChar(before)) && + isFlankedBy(afterFirst || startChar(after), ';'); + const closesWithReference = ({ + closes, + before, + after, + beforeLast, + }: MarkerRun) => + closes && + !isFlankedBy(beforeLast || endChar(before), startChar(after)) && + isFlankedBy(beforeLast || endChar(before), '&'); + if (!runs.some(run => opensWithReference(run) || closesWithReference(run))) { + return {encodeEnd, encodeStart}; + } + // By side, 0 while unknown, then 1 when the edge is a link (or spreads) and + // 2 when it is not (or is blocked). + const linkish = [new Uint8Array(count), new Uint8Array(count)]; + const spreads = [new Uint8Array(count), new Uint8Array(count)]; // Writing a one-character piece as a reference changes what the run on its // other side sees, so that run is decided again. Each piece changes at most // twice, so this stays linear. const pending = runs.map((_run, index) => index); while (pending.length > 0) { - const {before, after, opens, closes, afterFirst, beforeLast} = - runs[pending.pop()!]; - // A reference helps only where the run would flank if the character - // beside it were punctuation. - const charBefore = endChar(before); - const charAfter = startChar(after); + const run = runs[pending.pop()!]; + const {before, after} = run; if ( - opens && - !isFlankedBy(afterFirst || charAfter, charBefore) && - isFlankedBy(afterFirst || charAfter, ';') && + opensWithReference(run) && canReference(before, true) && isFree(before, true) ) { @@ -1033,9 +1043,7 @@ function decideReferences( } } if ( - closes && - !isFlankedBy(beforeLast || charBefore, charAfter) && - isFlankedBy(beforeLast || charBefore, '&') && + closesWithReference(run) && canReference(after, false) && isFree(after, false) ) { diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 44daab0b9b8..072f966442b 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5114,6 +5114,33 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it.each([ + 'www.example.com', + 'me@example.com', + 'https://example.com', + 'mailto:me@example.com', + ])( + 'writes no reference at the start of %j, which readers may autolink', + link => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode( + `${LEFT_DOUBLE_QUOTE}x${RIGHT_DOUBLE_QUOTE}`, + ).toggleFormat('bold'), + $createTextNode(link), + ), + ), + {discrete: true}, + ); + expect( + editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), + ).toBe(`**${LEFT_DOUBLE_QUOTE}x${RIGHT_DOUBLE_QUOTE}**${link}`); + }, + ); + it('guards word characters in a selection export', () => { const editor = createTestEditor(); editor.update( From 1931552f3c56589757607c77bea18c562893e008 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 11:21:06 +0000 Subject: [PATCH 18/70] [lexical-markdown] Bug Fix: Apply emphasis that closes before a code span first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The importer applied the first code span in a text node before any emphasis that did not wrap it, even emphasis that closed before the span began. Splitting the node at the span then separated the delimiters of a later emphasis around the span, so `**a.**x**`c`**` imported as `a.` bold, `x**` plain, `c` code and a literal `**`. An emphasis match that starts first is now applied first: delimiters inside code spans are excluded, so such a match either holds the whole span or ends before it. `characterAt` also returns the BMP character itself again instead of building a new string. ## Test plan ### Before ``` **a.**x**`c`** => [["a.",1],["x**",0],["c",16],["**",0]] **“***.b `c`* => [["“",1],["*.b ",0],["c",16],["*",0]] ``` ### After ``` **a.**x**`c`** => [["a.",1],["x",0],["c",17]] **“***.b `c`* => [["“",1],[".b ",2],["c",18]] pnpm vitest run --project unit packages/lexical-markdown Tests 885 passed (885) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 2 +- .../src/__tests__/unit/LexicalMarkdown.test.ts | 17 +++++++++++++++++ .../src/importTextFormatTransformer.ts | 8 ++++---- packages/lexical-markdown/src/utils.ts | 6 ++++-- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index c24a88ae9ef..21dc4dc2a6f 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -4,6 +4,6 @@ [fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_` and `~~` markers open or close formatting -Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. The backslash, which was missing from the ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. +Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. The backslash, which was missing from the ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `**a.**x**`c`**`, so its markers no longer split apart and stay as literal text. Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it. No reference is written inside a bare URL, `www.` host, email or mention, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `ping @user**\_x**`. diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 072f966442b..c086171ade0 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -4928,6 +4928,23 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ], 'a**b**c', ], + // Emphasis that ends before a code span is read before the span. + [ + [ + ['a.', ['bold']], + ['x', []], + ['c', ['bold', 'code']], + ], + '**a.**x**`c`**', + ], + [ + [ + [LEFT_DOUBLE_QUOTE, ['bold']], + ['.b ', ['italic']], + ['c', ['italic', 'code']], + ], + '**“***.b `c`*', + ], ] as [[string, TextFormatType[]][], string][])( 'exports and reimports %j as %j', (runs, exported) => { diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index 0341af1fbdb..2f0a2989a88 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -82,10 +82,10 @@ export function findOutermostTextFormatTransformer( let resultTransformer = null; if (codeMatch && emphasisMatch) { - if ( - emphasisMatch.startIndex <= codeMatch.startIndex && - emphasisMatch.endIndex >= codeMatch.endIndex - ) { + // Delimiters inside the code span were excluded, so an emphasis match + // that starts first either holds the whole span or ends before it, and + // either way it is applied first. + if (emphasisMatch.startIndex <= codeMatch.startIndex) { resultMatch = emphasisMatch; resultTransformer = textFormatTransformersIndex.transformersByTag[emphasisMatch.tag]; diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index c553818eca3..f0e61ea3e2a 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -129,8 +129,10 @@ function isLowSurrogate(code: number): boolean { /** The character (code point) that starts at `index`, if any. */ export function characterAt(text: string, index: number): string | undefined { - const code = text.codePointAt(index); - return code === undefined ? undefined : String.fromCodePoint(code); + // Only a surrogate pair reads as a code point past U+FFFF. + return (text.codePointAt(index) ?? 0) > 0xffff + ? text.slice(index, index + 2) + : text[index]; } /** The character (code point) that ends just before `index`, if any. */ From 8ae76245279f8fb2771d578e6a253727f0c49cec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 11:38:22 +0000 Subject: [PATCH 19/70] [lexical-markdown] Bug Fix: Keep code spans ahead of links when emphasis before them goes first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Applying emphasis that closes before a code span first skipped the importer's check that a link (or other text match) never takes part of a code span (#8687), so `[x *a* `y](z)`` imported as a link to `z` with a stray backtick. The importer now reports the first code span alongside the chosen format, and drops a text match that takes part of it. The export also merges adjacent text and raw pieces as it pushes them, instead of copying every piece again before deciding references. ## Test plan ### Before ``` pnpm vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts × can import "[x *a* `y](z)`" Tests 1 failed | 710 passed (711) ``` ### After ``` pnpm vitest run --project unit packages/lexical-markdown Tests 889 passed (889) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 44 ++++++++----------- .../__tests__/unit/LexicalMarkdown.test.ts | 12 +++++ .../src/importTextFormatTransformer.ts | 11 ++++- .../src/importTextTransformers.ts | 14 +++++- 4 files changed, 54 insertions(+), 27 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 7a6f871eb7b..51787601da6 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -696,10 +696,18 @@ function createPieces(): MarkdownPieces { return {kinds: [], values: []}; } +// Adjacent text (and adjacent raw output) is merged, so that each run of +// markers sits between the pieces whose characters a reader sees beside it. function pushPiece(pieces: MarkdownPieces, kind: number, value: string) { - if (value !== '') { - pieces.kinds.push(kind); - pieces.values.push(value); + const {kinds, values} = pieces; + const last = kinds.length - 1; + if (value === '') { + return; + } else if (last >= 0 && kinds[last] === kind && !isMarker(kind)) { + values[last] += value; + } else { + kinds.push(kind); + values.push(value); } } @@ -753,23 +761,9 @@ interface MarkerRun { * `a**\***`. The word character beside such a run goes out as a numeric * reference instead, whose `&` or `;` is punctuation: `a**\***`. */ -function joinPieces(pieces: MarkdownPieces): string { - if (!pieces.kinds.some(isMarker)) { - return pieces.values.join(''); - } - // Merge adjacent text (and adjacent raw output), so that each run of - // markers sits between the pieces whose characters a reader sees beside it. - const kinds: number[] = []; - const values: string[] = []; - for (let i = 0; i < pieces.kinds.length; i++) { - const kind = pieces.kinds[i]; - const last = kinds.length - 1; - if (last >= 0 && !isMarker(kind) && kinds[last] === kind) { - values[last] += pieces.values[i]; - } else { - kinds.push(kind); - values.push(pieces.values[i]); - } +function joinPieces({kinds, values}: MarkdownPieces): string { + if (!kinds.some(isMarker)) { + return values.join(''); } const {encodeStart, encodeEnd} = decideReferences(kinds, values); let markdown = ''; @@ -935,7 +929,9 @@ function decideReferences( value.slice(start, end).replace(/\\/g, ''), ); }; - // `findsLink` for each piece edge, worked out once. + // `findsLink` for each piece edge, worked out once: by side, 0 while + // unknown, then 1 when it finds one and 2 when it does not. + const linkish = [new Uint8Array(count), new Uint8Array(count)]; const isLinkish = (index: number, atEnd: boolean): boolean => { const side = atEnd ? 1 : 0; if (linkish[side][index] === 0) { @@ -961,6 +957,8 @@ function decideReferences( // walked at most twice. Raw output and unreferenceable characters stay // what they are, and other references only make edges punctuation, so a // remembered answer can only go stale toward writing fewer references. + // 0 while unknown, then 1 when it spreads and 2 when it is blocked. + const spreads = [new Uint8Array(count), new Uint8Array(count)]; const canSpread = (index: number, leftward: boolean): boolean => { const side = leftward ? 0 : 1; const visited: number[] = []; @@ -1021,10 +1019,6 @@ function decideReferences( if (!runs.some(run => opensWithReference(run) || closesWithReference(run))) { return {encodeEnd, encodeStart}; } - // By side, 0 while unknown, then 1 when the edge is a link (or spreads) and - // 2 when it is not (or is blocked). - const linkish = [new Uint8Array(count), new Uint8Array(count)]; - const spreads = [new Uint8Array(count), new Uint8Array(count)]; // Writing a one-character piece as a reference changes what the run on its // other side sees, so that run is decided again. Each piece changes at most // twice, so this stays linear. diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index c086171ade0..361a1272099 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -566,6 +566,18 @@ describe('Markdown', () => { html: '

$a $b

', md: '`$a` `$b`', }, + { + // Emphasis before a code span is applied first, and a link that takes + // part of the span still loses to it. + html: '

[x a y](z)

', + md: '[x *a* `y](z)`', + skipExport: true, + }, + { + html: '

a [b c](d)

', + md: '*a [b* `c](d)`', + skipExport: true, + }, { // Inline code containing a backtick must use a longer fence (CommonMark // code spans) and pad with spaces so it round-trips losslessly. diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index 2f0a2989a88..c009457fb12 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -38,6 +38,9 @@ export function findOutermostTextFormatTransformer( // content is never reprocessed and they bind tighter than text-match // transformers, so this is tagged here rather than re-derived by callers. isCodeSpan: boolean; + // The first code span, which a text-match transformer must not take part + // of even when an emphasis match before it is applied first. + codeSpan: {startIndex: number; endIndex: number} | null; } | null { const textContent = textNode.getTextContent(); @@ -47,7 +50,12 @@ export function findOutermostTextFormatTransformer( const codeTransformer = textFormatTransformersIndex.transformersByTag['`']; const excludeRanges: {start: number; end: number}[] = []; - let codeMatch = null; + let codeMatch: { + content: string; + endIndex: number; + startIndex: number; + tag: string; + } | null = null; if (codeTransformer) { const codeSpans = scanCodeSpans(textContent); @@ -115,6 +123,7 @@ export function findOutermostTextFormatTransformer( regexMatch.input = textContent; return { + codeSpan: codeMatch, endIndex: resultMatch.endIndex, // resultTransformer is the registered code transformer (by identity) // exactly when the chosen match is the code span. diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 8b26df811c8..b07d0d371cc 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -54,7 +54,19 @@ export function importTextTransformers( ); if (foundTextFormat && foundTextMatch) { - if (foundTextFormat.isCodeSpan) { + const {codeSpan} = foundTextFormat; + if ( + codeSpan && + !foundTextFormat.isCodeSpan && + foundTextMatch.startIndex < codeSpan.endIndex && + foundTextMatch.endIndex > codeSpan.startIndex && + (foundTextMatch.startIndex > codeSpan.startIndex || + foundTextMatch.endIndex < codeSpan.endIndex) + ) { + // The emphasis before the code span goes first, and the text match, + // which takes part of the span, is not applied. + foundTextMatch = null; + } else if (foundTextFormat.isCodeSpan) { // Code spans bind tighter than text-match transformers (e.g. equations // or links). A code span must never be partially consumed by a text // match, so prefer the code format unless the text match fully wraps it From 5000f5e0c0fe50ee3961b1154c8401619bc08f17 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 11:50:06 +0000 Subject: [PATCH 20/70] [lexical-markdown] Bug Fix: Recognize more bare URLs at the edge of exported text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The export's autolink check stopped scanning at ASCII characters that GFM allows in an autolink path, such as `|`, and did not know an `xmpp:` URI that ends the text, so `https://example.com/a|b` or `xmpp:a@b.co/res` before formatted punctuation got its last letter written as a reference. The scan now runs to whitespace or `<`, where GFM ends an autolink, and `mailto:`/`xmpp:` count at the end of the text too. The marker runs that cannot need a reference are now left out of the work list in the same pass that checks for them. ## Test plan ### Before ``` pnpm vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t autolink × writes no reference inside "xmpp:a@b.co/res", which readers may autolink × writes no reference inside "https://example.com/a|b", which readers may autolink ``` ### After ``` pnpm vitest run --project unit packages/lexical-markdown Tests 891 passed (891) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 18 ++++++++++++------ .../src/__tests__/unit/LexicalMarkdown.test.ts | 9 +++++++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 51787601da6..c2154f67a7f 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -715,14 +715,15 @@ function isMarker(kind: number | undefined): boolean { return kind === OPENER || kind === CLOSER; } -// Characters a URL, email or mention can hold, and the backslash that -// escapes one of them. -const LINK_CHARACTER = /[\w!#$%&'()*+,./:;=?@~\\-]/; +// Characters a URL, email or mention can hold: GFM ends an autolink at +// whitespace or `<`. This takes in the backslash that escapes a marker too. +const LINK_CHARACTER = /[!-;=-~]/; // How far the export looks for one, which keeps the look linear. const LINK_LOOKAHEAD = 2048; // A URL, `www.` host, email or mention at the end of the text before it, or // at the start of the text after it. -const LINK_AT_END = /:\/\/|(?:^|[^\w.-])www\.|@[\w.-]*\w$/i; +const LINK_AT_END = + /:\/\/|(?:^|[^\w.-])www\.|(?:^|[^\w.+-])(?:mailto|xmpp):|@[\w.-]*\w$/i; const LINK_AT_START = /^(?:[a-z][\w+.-]*:\/\/|www\.|mailto:|xmpp:)|^[\w.+-]*@\w/i; @@ -1016,13 +1017,18 @@ function decideReferences( closes && !isFlankedBy(beforeLast || endChar(before), startChar(after)) && isFlankedBy(beforeLast || endChar(before), '&'); - if (!runs.some(run => opensWithReference(run) || closesWithReference(run))) { + const pending: number[] = []; + runs.forEach((run, index) => { + if (opensWithReference(run) || closesWithReference(run)) { + pending.push(index); + } + }); + if (pending.length === 0) { return {encodeEnd, encodeStart}; } // Writing a one-character piece as a reference changes what the run on its // other side sees, so that run is decided again. Each piece changes at most // twice, so this stays linear. - const pending = runs.map((_run, index) => index); while (pending.length > 0) { const run = runs[pending.pop()!]; const {before, after} = run; diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 361a1272099..0cda04bfeb9 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5121,6 +5121,8 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ['https://example.com/a', 'https://example.com/a'], ['https://example.com/a_b', 'https://example.com/a\\_b'], ['@john_doe', '@john\\_doe'], + ['xmpp:a@b.co/res', 'xmpp:a@b.co/res'], + ['https://example.com/a|b', 'https://example.com/a|b'], ])( 'writes no reference inside %j, which readers may autolink', (link, markdown) => { @@ -5137,9 +5139,12 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ), {discrete: true}, ); + const prefix = `see ${markdown}**`; expect( - editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), - ).toMatch(new RegExp(`^see ${markdown.replace(/[.\\]/g, '\\$&')}\\*\\*`)); + editor + .read('latest', () => $convertToMarkdownString(TRANSFORMERS)) + .slice(0, prefix.length), + ).toBe(prefix); }, ); From 6a4d7e322dce80435f7dac5b132b57e68ec4b9ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 12:09:06 +0000 Subject: [PATCH 21/70] [lexical-markdown] Refactor: Tidy the reference decision and code-span precedence ## Description Removes a clause of the export's chain check that could never decide its result and an early return that skipped nothing, types the export's piece kinds, and states the importer's code-span precedence test once for both the code-span and the emphasis-first paths. No behavior change. ## Test plan ### After ``` pnpm vitest run --project unit packages/lexical-markdown Tests 891 passed (891) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 21 ++++------ .../src/importTextTransformers.ts | 41 +++++++++---------- 2 files changed, 28 insertions(+), 34 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index c2154f67a7f..ecaa2be9909 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -682,8 +682,10 @@ function $exportTextFormat( * breaks and the output of transformers, whose syntax a reference could * break. `OPENER` and `CLOSER` are format markers. */ +type PieceKind = typeof TEXT | typeof RAW | typeof OPENER | typeof CLOSER; + interface MarkdownPieces { - kinds: number[]; + kinds: PieceKind[]; values: string[]; } @@ -698,7 +700,7 @@ function createPieces(): MarkdownPieces { // Adjacent text (and adjacent raw output) is merged, so that each run of // markers sits between the pieces whose characters a reader sees beside it. -function pushPiece(pieces: MarkdownPieces, kind: number, value: string) { +function pushPiece(pieces: MarkdownPieces, kind: PieceKind, value: string) { const {kinds, values} = pieces; const last = kinds.length - 1; if (value === '') { @@ -711,7 +713,7 @@ function pushPiece(pieces: MarkdownPieces, kind: number, value: string) { } } -function isMarker(kind: number | undefined): boolean { +function isMarker(kind: PieceKind | undefined): boolean { return kind === OPENER || kind === CLOSER; } @@ -803,12 +805,12 @@ function joinPieces({kinds, values}: MarkdownPieces): string { * markers, if any. */ function scanDelimiterRun( - kinds: number[], + kinds: PieceKind[], values: string[], from: number, to: number, step: 1 | -1, - kind: number, + kind: PieceKind, ): [boolean, string | undefined] { const edge = values[from]; const delimiter = edge[step === 1 ? 0 : edge.length - 1]; @@ -834,7 +836,7 @@ function scanDelimiterRun( * reference for the marker run beside it to open or close. */ function decideReferences( - kinds: number[], + kinds: PieceKind[], values: string[], ): {encodeStart: Uint8Array; encodeEnd: Uint8Array} { const count = kinds.length; @@ -999,9 +1001,7 @@ function decideReferences( return result; }; const isFree = (index: number, leftward: boolean) => - !isOneCharacter(index) || - (leftward ? encodeStart[index] : encodeEnd[index]) || - canSpread(index, leftward); + !isOneCharacter(index) || canSpread(index, leftward); // A reference helps only where the run would flank if the character // beside it were punctuation. const opensWithReference = ({opens, before, after, afterFirst}: MarkerRun) => @@ -1023,9 +1023,6 @@ function decideReferences( pending.push(index); } }); - if (pending.length === 0) { - return {encodeEnd, encodeStart}; - } // Writing a one-character piece as a reference changes what the run on its // other side sees, so that run is decided again. Each piece changes at most // twice, so this stays linear. diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index b07d0d371cc..3e09ef1bc23 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -54,34 +54,31 @@ export function importTextTransformers( ); if (foundTextFormat && foundTextMatch) { + // Code spans bind tighter than text-match transformers (e.g. equations + // or links). A code span must never be partially consumed by a text + // match, so the match goes only when it fully wraps the span (in which + // case the span is part of the match's raw content). + // https://github.com/meta/lexical/issues/8687 const {codeSpan} = foundTextFormat; - if ( - codeSpan && - !foundTextFormat.isCodeSpan && + const wrapsCodeSpan = + codeSpan !== null && + foundTextMatch.startIndex <= codeSpan.startIndex && + foundTextMatch.endIndex >= codeSpan.endIndex; + if (foundTextFormat.isCodeSpan) { + if (wrapsCodeSpan) { + foundTextFormat = null; + } else { + foundTextMatch = null; + } + } else if ( + codeSpan !== null && + !wrapsCodeSpan && foundTextMatch.startIndex < codeSpan.endIndex && - foundTextMatch.endIndex > codeSpan.startIndex && - (foundTextMatch.startIndex > codeSpan.startIndex || - foundTextMatch.endIndex < codeSpan.endIndex) + foundTextMatch.endIndex > codeSpan.startIndex ) { // The emphasis before the code span goes first, and the text match, // which takes part of the span, is not applied. foundTextMatch = null; - } else if (foundTextFormat.isCodeSpan) { - // Code spans bind tighter than text-match transformers (e.g. equations - // or links). A code span must never be partially consumed by a text - // match, so prefer the code format unless the text match fully wraps it - // (in which case the code span is part of the match's raw content). - // https://github.com/meta/lexical/issues/8687 - if ( - foundTextMatch.startIndex <= foundTextFormat.startIndex && - foundTextMatch.endIndex >= foundTextFormat.endIndex - ) { - // foundTextMatch wraps the code span - apply foundTextMatch - foundTextFormat = null; - } else { - // Apply the code span - foundTextMatch = null; - } } else if ( // Find the outermost transformer (foundTextFormat.startIndex <= foundTextMatch.startIndex && From 25d9b10cba404b18584fca1e5be4e14917c0a56b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 12:41:34 +0000 Subject: [PATCH 22/70] [lexical-markdown] Bug Fix: Find bare URLs that run on through formatted text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The export's autolink check looked only at the piece that would get a reference, so a bold `https://x.com/` followed by plain `ab` and an italic `“c”` exported as `**https://x.com/**ab*“c”*`, a reference inside what GFM readers autolink. The check now reads the pieces as one text in a single linear pass: a run of link characters (up to whitespace or `<`) that holds a URL, `www.` host, `mailto:` or `xmpp:` counts as a link throughout, and so does an email or mention within it. This replaces the bounded per-edge scan. The ASCII fast path for flanking is now filled from the Unicode categories on first use, so the two cannot drift apart, and the README describes the new export references. ## Test plan ### Before ``` pnpm vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "runs on through" Expected: "**https://x.com/**ab*“c”*" Received: "**https://x.com/**ab*“c”*" ``` ### After ``` pnpm vitest run --project unit packages/lexical-markdown Tests 892 passed (892) ``` Co-Authored-By: Claude Opus 5.5 --- packages/lexical-markdown/README.md | 8 ++ .../lexical-markdown/src/MarkdownExport.ts | 120 ++++++++++++------ .../__tests__/unit/LexicalMarkdown.test.ts | 20 +++ packages/lexical-markdown/src/utils.ts | 36 +++--- 4 files changed, 130 insertions(+), 54 deletions(-) diff --git a/packages/lexical-markdown/README.md b/packages/lexical-markdown/README.md index 9cb8e548812..68c0d1ca7f4 100644 --- a/packages/lexical-markdown/README.md +++ b/packages/lexical-markdown/README.md @@ -97,6 +97,14 @@ backslash escapes and decimal or hexadecimal references in one pass. Literal brackets and guarded `<`/`&` use numeric references so escaping does not change adjacent emphasis flanking. +Import follows CommonMark's Unicode punctuation and whitespace when deciding +whether `*`, `_` and `~~` open or close formatting. When formatted text starts +or ends with punctuation right beside a letter, export writes that letter as a +numeric reference (`foo**“bar”**`) so that the markers still open and +close, also with `shouldPreserveNewLines`, where a raw backslash right before +such a marker or reference goes out as `\`. No reference is written inside +a bare URL, `www.` host, email or mention. + There're three types of transformers: - **Element transformer** handles top level elements (lists, headings, quotes, tables or code blocks) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index ecaa2be9909..afbac101641 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -719,15 +719,26 @@ function isMarker(kind: PieceKind | undefined): boolean { // Characters a URL, email or mention can hold: GFM ends an autolink at // whitespace or `<`. This takes in the backslash that escapes a marker too. -const LINK_CHARACTER = /[!-;=-~]/; -// How far the export looks for one, which keeps the look linear. -const LINK_LOOKAHEAD = 2048; -// A URL, `www.` host, email or mention at the end of the text before it, or -// at the start of the text after it. -const LINK_AT_END = - /:\/\/|(?:^|[^\w.-])www\.|(?:^|[^\w.+-])(?:mailto|xmpp):|@[\w.-]*\w$/i; -const LINK_AT_START = - /^(?:[a-z][\w+.-]*:\/\/|www\.|mailto:|xmpp:)|^[\w.+-]*@\w/i; +function isLinkCharacter(code: number): boolean { + return code > 0x20 && code < 0x7f && code !== 0x3c; +} +// A URL or `www.` host in a run of link characters. +const LINK_URL = /:\/\/|(?:^|[^\w.-])www\.|(?:^|[^\w.+-])(?:mailto|xmpp):/i; +// Characters an email or mention can hold (word characters, `.+@-`, and the +// backslash that escapes one), and what makes one. +function isAddressCharacter(code: number): boolean { + return ( + (code >= 0x30 && code <= 0x39) || + (code >= 0x40 && code <= 0x5a) || + (code >= 0x61 && code <= 0x7a) || + code === 0x5f || + code === 0x2e || + code === 0x2b || + code === 0x2d || + code === 0x5c + ); +} +const ADDRESS = /@\w/; // Characters that a reference does not stand in for everywhere: CommonMark // readers and HTML decode references to control characters, unpaired @@ -798,6 +809,60 @@ function joinPieces({kinds, values}: MarkdownPieces): string { return markdown; } +/** + * For each piece, 1 when its first character is part of a bare URL, `www.` + * host, email or mention, plus 2 when its last one is. A reference inside one + * would change what an autolinking reader links to, so the format is lost + * there instead, as before. The pieces are read as one text, since a URL can + * run on through markers and other formats, and each character is looked at + * a bounded number of times. + */ +function findLinkEdges(values: string[]): Uint8Array { + const text = values.join(''); + const inLink = new Uint8Array(text.length); + const runEnd = ( + start: number, + isPart: (code: number) => boolean, + limit: number, + ) => { + let end = start; + while (end < limit && isPart(text.charCodeAt(end))) { + end++; + } + return end; + }; + for (let start = 0; start < text.length; ) { + const end = runEnd(start, isLinkCharacter, text.length); + if (end === start) { + start++; + continue; + } + if (LINK_URL.test(text.slice(start, end).replace(/\\/g, ''))) { + inLink.fill(1, start, end); + } else { + for (let from = start; from < end; ) { + const to = runEnd(from, isAddressCharacter, end); + if (to === from) { + from++; + continue; + } + if (ADDRESS.test(text.slice(from, to).replace(/\\/g, ''))) { + inLink.fill(1, from, to); + } + from = to; + } + } + start = end; + } + const edges = new Uint8Array(values.length); + for (let i = 0, offset = 0; i < values.length; i++) { + const length = values[i].length; + edges[i] = inLink[offset] | (inLink[offset + length - 1] << 1); + offset += length; + } + return edges; +} + /** * Walks the delimiter run at the edge of the markers from `from` to `to`, * forward when `step` is 1 and backward when it is -1. Returns whether a @@ -909,38 +974,15 @@ function decideReferences( : encodeEnd[index] || isEncoded(index) ? ';' : lastChar(index); - // Whether the end (or start) of the piece at `index` is a bare URL, `www.` - // host, email or mention. A reference inside one would change what an - // autolinking reader links to, so the format is lost there instead, as - // before. - const findsLink = (index: number, atEnd: boolean): boolean => { - const value = values[index]; - let start = atEnd ? value.length : 0; - let end = start; - if (atEnd) { - const limit = Math.max(0, start - LINK_LOOKAHEAD); - while (start > limit && LINK_CHARACTER.test(value[start - 1])) { - start--; - } - } else { - const limit = Math.min(value.length, LINK_LOOKAHEAD); - while (end < limit && LINK_CHARACTER.test(value[end])) { - end++; - } - } - return (atEnd ? LINK_AT_END : LINK_AT_START).test( - value.slice(start, end).replace(/\\/g, ''), - ); - }; - // `findsLink` for each piece edge, worked out once: by side, 0 while - // unknown, then 1 when it finds one and 2 when it does not. - const linkish = [new Uint8Array(count), new Uint8Array(count)]; + // Whether the last (or first) character of the piece at `index` is part of + // a bare URL, `www.` host, email or mention, worked out for every piece the + // first time it is asked. + let linkEdges: Uint8Array | undefined; const isLinkish = (index: number, atEnd: boolean): boolean => { - const side = atEnd ? 1 : 0; - if (linkish[side][index] === 0) { - linkish[side][index] = findsLink(index, atEnd) ? 1 : 2; + if (linkEdges === undefined) { + linkEdges = findLinkEdges(values); } - return linkish[side][index] === 1; + return (linkEdges[index] & (atEnd ? 2 : 1)) !== 0; }; // Whether the last (or first) character of the piece at `index` can go out // as a reference. diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 0cda04bfeb9..5c403482732 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5175,6 +5175,26 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it('writes no reference inside a URL that runs on through formats', () => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('https://x.com/').toggleFormat('bold'), + $createTextNode('ab'), + $createTextNode( + `${LEFT_DOUBLE_QUOTE}c${RIGHT_DOUBLE_QUOTE}`, + ).toggleFormat('italic'), + ), + ), + {discrete: true}, + ); + expect( + editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), + ).toBe(`**https://x.com/**ab*${LEFT_DOUBLE_QUOTE}c${RIGHT_DOUBLE_QUOTE}*`); + }); + it('guards word characters in a selection export', () => { const editor = createTestEditor(); editor.update( diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index f0e61ea3e2a..31f4a8cbf86 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -70,28 +70,34 @@ const WHITESPACE_CLASS = 0; const PUNCTUATION_CLASS = 1; const OTHER_CLASS = 2; +function classifyByCategory(character: string): number { + return WHITESPACE.test(character) + ? WHITESPACE_CLASS + : PUNCTUATION.test(character) + ? PUNCTUATION_CLASS + : OTHER_CLASS; +} + +// ASCII classes, filled from the categories on first use so that the common +// case needs no regular expression. +let asciiClasses: Uint8Array | undefined; + /** How flanking sees `character`; the edge of the line is whitespace. */ function classify(character: string | undefined): number { if (character === undefined) { return WHITESPACE_CLASS; } const code = character.charCodeAt(0); - // ASCII, without a regular expression - if (code < 0x80) { - return code === 0x20 || (code >= 0x09 && code <= 0x0d && code !== 0x0b) - ? WHITESPACE_CLASS - : (code >= 0x21 && code <= 0x2f) || - (code >= 0x3a && code <= 0x40) || - (code >= 0x5b && code <= 0x60) || - (code >= 0x7b && code <= 0x7e) - ? PUNCTUATION_CLASS - : OTHER_CLASS; + if (code >= 0x80) { + return classifyByCategory(character); } - return WHITESPACE.test(character) - ? WHITESPACE_CLASS - : PUNCTUATION.test(character) - ? PUNCTUATION_CLASS - : OTHER_CLASS; + if (asciiClasses === undefined) { + asciiClasses = new Uint8Array(0x80); + for (let c = 0; c < 0x80; c++) { + asciiClasses[c] = classifyByCategory(String.fromCharCode(c)); + } + } + return asciiClasses[code]; } /** From 75e72d07f8c81a8ec1094df8536113280f708518 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 13:03:41 +0000 Subject: [PATCH 23/70] [lexical-markdown] Refactor: State code-span precedence once and correct the chain memo comment ## Description The importer's rule that a text match never takes part of a code span is now one condition for both the code-first and the emphasis-first case. The export's chain memo comment now says when a remembered block can go stale (a single-character marker run that both closes and opens) instead of claiming it never costs a format. No behavior change: 500,000 random paragraphs exported the same with and without the memo. ## Test plan ### After ``` pnpm vitest run --project unit packages/lexical-markdown Tests 892 passed (892) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 6 ++++-- .../src/importTextTransformers.ts | 20 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index afbac101641..9077c5d436d 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -1000,8 +1000,10 @@ function decideReferences( // would only trade one lost format for another, so it is not written. // Each piece's answer is remembered for each direction, so each piece is // walked at most twice. Raw output and unreferenceable characters stay - // what they are, and other references only make edges punctuation, so a - // remembered answer can only go stale toward writing fewer references. + // what they are. A remembered block could only lift if a piece on its + // chain were later written as a reference from the other side, which takes + // a run of one marker character that both closes and opens; then a + // reference that would have kept a format is not written. // 0 while unknown, then 1 when it spreads and 2 when it is blocked. const spreads = [new Uint8Array(count), new Uint8Array(count)]; const canSpread = (index: number, leftward: boolean): boolean => { diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 3e09ef1bc23..6f994a94370 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -64,21 +64,19 @@ export function importTextTransformers( codeSpan !== null && foundTextMatch.startIndex <= codeSpan.startIndex && foundTextMatch.endIndex >= codeSpan.endIndex; - if (foundTextFormat.isCodeSpan) { - if (wrapsCodeSpan) { - foundTextFormat = null; - } else { - foundTextMatch = null; - } - } else if ( + if ( codeSpan !== null && !wrapsCodeSpan && - foundTextMatch.startIndex < codeSpan.endIndex && - foundTextMatch.endIndex > codeSpan.startIndex + (foundTextFormat.isCodeSpan || + (foundTextMatch.startIndex < codeSpan.endIndex && + foundTextMatch.endIndex > codeSpan.startIndex)) ) { - // The emphasis before the code span goes first, and the text match, - // which takes part of the span, is not applied. + // Apply the code span, or the emphasis before it, and not the text + // match, which would take part of the span. foundTextMatch = null; + } else if (foundTextFormat.isCodeSpan) { + // foundTextMatch wraps the code span - apply foundTextMatch + foundTextFormat = null; } else if ( // Find the outermost transformer (foundTextFormat.startIndex <= foundTextMatch.startIndex && From 1dd3e8146fc32ca8e40593774994a0e8a551dd32 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 13:26:27 +0000 Subject: [PATCH 24/70] [lexical-markdown] Bug Fix: Leave inline link targets out of the bare URL check and guard every code span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The export read raw output such as an inline link's `](https://e.com)` as bare text, so a letter right after a link counted as part of a URL and no reference was written: `[API](https://e.com)s**(beta)**` re-imported with literal asterisks. Raw output now ends a run of link characters. On import, the rule that a text match never takes part of a code span only knew the first span, so in `**`a` [b** `c](d)`` the link took part of the second; it now checks every span. ## Test plan ### Before ``` pnpm vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "inline link's destination|later code span" × guards a letter right after an inline link's destination × keeps a link from taking part of a later code span ``` ### After ``` pnpm vitest run --project unit packages/lexical-markdown Tests 894 passed (894) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 2 +- .../lexical-markdown/src/MarkdownExport.ts | 12 ++++-- .../__tests__/unit/LexicalMarkdown.test.ts | 43 +++++++++++++++++++ .../src/importTextFormatTransformer.ts | 11 +++-- .../src/importTextTransformers.ts | 20 ++++----- 5 files changed, 69 insertions(+), 19 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 21dc4dc2a6f..4b117dac15b 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -4,6 +4,6 @@ [fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_` and `~~` markers open or close formatting -Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. The backslash, which was missing from the ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `**a.**x**`c`**`, so its markers no longer split apart and stay as literal text. +Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. The backslash, which was missing from the ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `**a.**x**`c`**`, so its markers no longer split apart and stay as literal text. A link or other text match no longer takes part of any code span in the text, not only the first. Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it. No reference is written inside a bare URL, `www.` host, email or mention, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `ping @user**\_x**`. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 9077c5d436d..6512f499fbc 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -815,10 +815,14 @@ function joinPieces({kinds, values}: MarkdownPieces): string { * would change what an autolinking reader links to, so the format is lost * there instead, as before. The pieces are read as one text, since a URL can * run on through markers and other formats, and each character is looked at - * a bounded number of times. + * a bounded number of times. Raw output, such as an inline link's destination, + * is not bare text, so it ends a run. */ -function findLinkEdges(values: string[]): Uint8Array { - const text = values.join(''); +function findLinkEdges(kinds: PieceKind[], values: string[]): Uint8Array { + let text = ''; + for (let i = 0; i < values.length; i++) { + text += kinds[i] === RAW ? ' '.repeat(values[i].length) : values[i]; + } const inLink = new Uint8Array(text.length); const runEnd = ( start: number, @@ -980,7 +984,7 @@ function decideReferences( let linkEdges: Uint8Array | undefined; const isLinkish = (index: number, atEnd: boolean): boolean => { if (linkEdges === undefined) { - linkEdges = findLinkEdges(values); + linkEdges = findLinkEdges(kinds, values); } return (linkEdges[index] & (atEnd ? 2 : 1)) !== 0; }; diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 5c403482732..7e4bf0307ff 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5175,6 +5175,49 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it("guards a letter right after an inline link's destination", () => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createLinkNode('https://e.com').append($createTextNode('API')), + $createTextNode('s'), + $createTextNode('(beta)').toggleFormat('bold'), + $createTextNode(' end'), + ), + ), + {discrete: true}, + ); + expect( + editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), + ).toBe('[API](https://e.com)s**(beta)** end'); + }); + + it('keeps a link from taking part of a later code span', () => { + const editor = createTestEditor(); + editor.update( + () => $convertFromMarkdownString('**`a` [b** `c](d)`', TRANSFORMERS), + {discrete: true}, + ); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [ + node.getTextContent(), + node.hasFormat('bold'), + node.hasFormat('code'), + ]), + ), + ).toEqual([ + ['a', true, true], + [' [b', true, false], + [' ', false, false], + ['c](d)', false, true], + ]); + }); + it('writes no reference inside a URL that runs on through formats', () => { const editor = createTestEditor(); editor.update( diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index c009457fb12..3ab4dbe5004 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -38,9 +38,9 @@ export function findOutermostTextFormatTransformer( // content is never reprocessed and they bind tighter than text-match // transformers, so this is tagged here rather than re-derived by callers. isCodeSpan: boolean; - // The first code span, which a text-match transformer must not take part - // of even when an emphasis match before it is applied first. - codeSpan: {startIndex: number; endIndex: number} | null; + // The code spans, which a text-match transformer must not take part of + // even when an emphasis match around or before them is applied first. + codeSpans: {startIndex: number; endIndex: number}[]; } | null { const textContent = textNode.getTextContent(); @@ -123,7 +123,10 @@ export function findOutermostTextFormatTransformer( regexMatch.input = textContent; return { - codeSpan: codeMatch, + codeSpans: excludeRanges.map(({start, end}) => ({ + endIndex: end, + startIndex: start, + })), endIndex: resultMatch.endIndex, // resultTransformer is the registered code transformer (by identity) // exactly when the chosen match is the code span. diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 6f994a94370..91667ad1098 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -59,17 +59,17 @@ export function importTextTransformers( // match, so the match goes only when it fully wraps the span (in which // case the span is part of the match's raw content). // https://github.com/meta/lexical/issues/8687 - const {codeSpan} = foundTextFormat; - const wrapsCodeSpan = - codeSpan !== null && - foundTextMatch.startIndex <= codeSpan.startIndex && - foundTextMatch.endIndex >= codeSpan.endIndex; + const {startIndex, endIndex} = foundTextMatch; + const wraps = (span: {startIndex: number; endIndex: number}) => + startIndex <= span.startIndex && endIndex >= span.endIndex; if ( - codeSpan !== null && - !wrapsCodeSpan && - (foundTextFormat.isCodeSpan || - (foundTextMatch.startIndex < codeSpan.endIndex && - foundTextMatch.endIndex > codeSpan.startIndex)) + (foundTextFormat.isCodeSpan && !wraps(foundTextFormat)) || + foundTextFormat.codeSpans.some( + span => + startIndex < span.endIndex && + endIndex > span.startIndex && + !wraps(span), + ) ) { // Apply the code span, or the emphasis before it, and not the text // match, which would take part of the span. From 5a5dcfbc66b1d0310ae3d9e496858c554ffe8a2f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 13:45:07 +0000 Subject: [PATCH 25/70] [lexical-markdown] Refactor: Classify a delimiter's neighbours once and reuse the code span list ## Description The importer's emphasis check classified each neighbouring character up to three times; `getEmphasisSides` now classifies the two once and derives left- and right-flanking and the `_` rule from them. The importer also returns the code spans it scanned instead of copying them into new objects. No behavior change. ## Test plan ### After ``` pnpm vitest run --project unit packages/lexical-markdown Tests 894 passed (894) ``` Co-Authored-By: Claude Opus 5.5 --- .../src/importTextFormatTransformer.ts | 70 ++++--------------- packages/lexical-markdown/src/utils.ts | 36 ++++++++-- 2 files changed, 44 insertions(+), 62 deletions(-) diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index 3ab4dbe5004..9b15468d0c1 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -10,12 +10,7 @@ import type {TextFormatTransformersIndex} from './MarkdownImport'; import type {TextFormatTransformer} from './MarkdownTransformers'; import type {TextNode} from 'lexical'; -import { - characterAt, - characterBefore, - isFlankedBy, - isPunctuation, -} from './utils'; +import {characterAt, characterBefore, getEmphasisSides} from './utils'; interface Delimiter { index: number; @@ -49,32 +44,12 @@ export function findOutermostTextFormatTransformer( // (e.g., links, raw HTML) may need similar treatment in the future. const codeTransformer = textFormatTransformersIndex.transformersByTag['`']; - const excludeRanges: {start: number; end: number}[] = []; - let codeMatch: { - content: string; - endIndex: number; - startIndex: number; - tag: string; - } | null = null; - if (codeTransformer) { - const codeSpans = scanCodeSpans(textContent); - - for (const span of codeSpans) { - if (!codeMatch) { - codeMatch = { - content: span.content, - endIndex: span.endIndex, - startIndex: span.startIndex, - tag: '`', - }; - } - - excludeRanges.push({ - end: span.endIndex, - start: span.startIndex, - }); - } - } + const codeSpans = codeTransformer ? scanCodeSpans(textContent) : []; + const codeMatch = codeSpans.length > 0 ? {...codeSpans[0], tag: '`'} : null; + const excludeRanges = codeSpans.map(span => ({ + end: span.endIndex, + start: span.startIndex, + })); const delimiters = scanDelimiters( textContent, @@ -123,10 +98,7 @@ export function findOutermostTextFormatTransformer( regexMatch.input = textContent; return { - codeSpans: excludeRanges.map(({start, end}) => ({ - endIndex: end, - startIndex: start, - })), + codeSpans, endIndex: resultMatch.endIndex, // resultTransformer is the registered code transformer (by identity) // exactly when the chosen match is the code span. @@ -257,7 +229,11 @@ function scanDelimiters( len++; } - const {canOpen, canClose} = canEmphasis(char, text, i, len); + const {canOpen, canClose} = getEmphasisSides( + char, + characterBefore(text, i), + characterAt(text, i + len), + ); if (canOpen || canClose) { delimiters.push({ @@ -395,26 +371,6 @@ function processEmphasis( return result; } -function canEmphasis( - char: string, - text: string, - index: number, - length: number, -): {canOpen: boolean; canClose: boolean} { - const charBefore = characterBefore(text, index); - const charAfter = characterAt(text, index + length); - const isLeft = isFlankedBy(charAfter, charBefore); - const isRight = isFlankedBy(charBefore, charAfter); - if (char !== '_') { - return {canClose: isRight, canOpen: isLeft}; - } - // `_` opens or closes inside a word only beside punctuation. - return { - canClose: isRight && (!isLeft || isPunctuation(charAfter)), - canOpen: isLeft && (!isRight || isPunctuation(charBefore)), - }; -} - export function importTextFormatTransformer( textNode: TextNode, startIndex: number, diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index 31f4a8cbf86..1f710f643f5 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -113,16 +113,42 @@ export function isFlankedBy( const primaryClass = classify(primary); return ( primaryClass === OTHER_CLASS || - (primaryClass === PUNCTUATION_CLASS && classify(secondary) !== OTHER_CLASS) + isFlankedByClass(primaryClass, classify(secondary)) ); } -export function isPunctuationOrSpace(character: string): boolean { - return classify(character) !== OTHER_CLASS; +function isFlankedByClass(primary: number, secondary: number): boolean { + return ( + primary === OTHER_CLASS || + (primary === PUNCTUATION_CLASS && secondary !== OTHER_CLASS) + ); +} + +/** + * Whether a delimiter run of `marker` between `before` and `after` can open + * and close emphasis; `_` opens or closes inside a word only beside + * punctuation. + * https://spec.commonmark.org/0.31.2/#can-open-emphasis + */ +export function getEmphasisSides( + marker: string, + before: string | undefined, + after: string | undefined, +): {canOpen: boolean; canClose: boolean} { + const beforeClass = classify(before); + const afterClass = classify(after); + const isLeft = isFlankedByClass(afterClass, beforeClass); + const isRight = isFlankedByClass(beforeClass, afterClass); + return marker === '_' + ? { + canClose: isRight && (!isLeft || afterClass === PUNCTUATION_CLASS), + canOpen: isLeft && (!isRight || beforeClass === PUNCTUATION_CLASS), + } + : {canClose: isRight, canOpen: isLeft}; } -export function isPunctuation(character: string | undefined): boolean { - return classify(character) === PUNCTUATION_CLASS; +export function isPunctuationOrSpace(character: string): boolean { + return classify(character) !== OTHER_CLASS; } function isHighSurrogate(code: number): boolean { From 07ab071ad6e2bcfeb86ce789dff4485d13e88aa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 13:56:37 +0000 Subject: [PATCH 26/70] [lexical-markdown] Refactor: Skip code spans in one forward pass when scanning delimiters ## Description The importer's delimiter scan checked every code span for each delimiter character, which costs spans times delimiters on a long line; it now walks the ordered spans alongside the scan and reads them in the shape the code span scanner returns. `isFlankedBy` is written in terms of the class-based rule the importer uses, and `characterAt` mirrors `characterBefore`. No behavior change. ## Test plan ### After ``` pnpm vitest run --project unit packages/lexical-markdown Tests 894 passed (894) ``` Co-Authored-By: Claude Opus 5.5 --- .../src/importTextFormatTransformer.ts | 21 +++++++++---------- packages/lexical-markdown/src/utils.ts | 10 +++------ 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index 9b15468d0c1..c99b7ff49c2 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -46,15 +46,11 @@ export function findOutermostTextFormatTransformer( const codeSpans = codeTransformer ? scanCodeSpans(textContent) : []; const codeMatch = codeSpans.length > 0 ? {...codeSpans[0], tag: '`'} : null; - const excludeRanges = codeSpans.map(span => ({ - end: span.endIndex, - start: span.startIndex, - })); const delimiters = scanDelimiters( textContent, textFormatTransformersIndex, - excludeRanges, + codeSpans, ); const emphasisMatch = delimiters.length > 0 @@ -192,7 +188,7 @@ export function scanCodeSpans(text: string): { function scanDelimiters( text: string, transformersIndex: TextFormatTransformersIndex, - excludeRanges: {start: number; end: number}[] = [], + codeSpans: {startIndex: number; endIndex: number}[], ): Delimiter[] { const delimiters: Delimiter[] = []; const delimiterChars = new Set( @@ -209,17 +205,20 @@ function scanDelimiters( return count % 2 === 1; }; - const isInExcludedRange = (index: number): boolean => { - return excludeRanges.some( - range => index >= range.start && index < range.end, - ); + // The spans are in order and the scan only moves forward. + let span = 0; + const isInCodeSpan = (index: number): boolean => { + while (span < codeSpans.length && codeSpans[span].endIndex <= index) { + span++; + } + return span < codeSpans.length && index >= codeSpans[span].startIndex; }; let i = 0; while (i < text.length) { const char = text[i]; - if (!delimiterChars.has(char) || isEscaped(i) || isInExcludedRange(i)) { + if (!delimiterChars.has(char) || isEscaped(i) || isInCodeSpan(i)) { i++; continue; } diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index 1f710f643f5..8b9e1b9c3b9 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -110,11 +110,7 @@ export function isFlankedBy( primary: string | undefined, secondary: string | undefined, ): boolean { - const primaryClass = classify(primary); - return ( - primaryClass === OTHER_CLASS || - isFlankedByClass(primaryClass, classify(secondary)) - ); + return isFlankedByClass(classify(primary), classify(secondary)); } function isFlankedByClass(primary: number, secondary: number): boolean { @@ -161,8 +157,8 @@ function isLowSurrogate(code: number): boolean { /** The character (code point) that starts at `index`, if any. */ export function characterAt(text: string, index: number): string | undefined { - // Only a surrogate pair reads as a code point past U+FFFF. - return (text.codePointAt(index) ?? 0) > 0xffff + return isHighSurrogate(text.charCodeAt(index)) && + isLowSurrogate(text.charCodeAt(index + 1)) ? text.slice(index, index + 2) : text[index]; } From 5a6562d4a2cedcc7d09018cd192adb3a02472f32 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:18:39 +0000 Subject: [PATCH 27/70] [lexical-markdown] Documentation Update: Say the backslash change applies to import ## Description The changeset said the typing shortcuts newly count the backslash as punctuation, but they already did; only the importer's ASCII list lacked it. Co-Authored-By: Claude Opus 5.5 --- .changeset/markdown-unicode-punctuation-flanking.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 4b117dac15b..3ef2ca84bf3 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -4,6 +4,6 @@ [fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_` and `~~` markers open or close formatting -Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. The backslash, which was missing from the ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `**a.**x**`c`**`, so its markers no longer split apart and stay as literal text. A link or other text match no longer takes part of any code span in the text, not only the first. +Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `**a.**x**`c`**`, so its markers no longer split apart and stay as literal text. A link or other text match no longer takes part of any code span in the text, not only the first. Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it. No reference is written inside a bare URL, `www.` host, email or mention, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `ping @user**\_x**`. From af73d88f019f192a3c9ebc854e54cb4251ce6638 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:39:57 +0000 Subject: [PATCH 28/70] [lexical-markdown] Bug Fix: Keep an unescaped tag character in text out of the marker beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Text does not escape `=`, so plain `a=` before a highlighted `“b”` exported as `a===“b”==`: the reader sees one `===` run between a letter and punctuation, which does not open, and the highlight came back as literal text. A text character that is the same as the marker character beside it, and is not already escaped, now goes out as a numeric reference (`a===“b”===c`), which keeps it out of the run for any registered tag. The README and changeset now name `==` with the other markers. ## Test plan ### Before ``` pnpm vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "a=" × exports and reimports [["a=",[]],["“b”",["highlight"]],["=c",[]]] as "a===“b”===c" ``` ### After ``` pnpm vitest run --project unit packages/lexical-markdown Tests 895 passed (895) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 2 +- packages/lexical-markdown/README.md | 2 +- .../lexical-markdown/src/MarkdownExport.ts | 21 +++++++++++++++++++ .../__tests__/unit/LexicalMarkdown.test.ts | 21 ++++++++++++++++--- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 3ef2ca84bf3..3a302344274 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -2,7 +2,7 @@ '@lexical/markdown': patch --- -[fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_` and `~~` markers open or close formatting +[fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_`, `~~` and `==` markers open or close formatting Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `**a.**x**`c`**`, so its markers no longer split apart and stay as literal text. A link or other text match no longer takes part of any code span in the text, not only the first. diff --git a/packages/lexical-markdown/README.md b/packages/lexical-markdown/README.md index 68c0d1ca7f4..baff4e3867f 100644 --- a/packages/lexical-markdown/README.md +++ b/packages/lexical-markdown/README.md @@ -98,7 +98,7 @@ brackets and guarded `<`/`&` use numeric references so escaping does not change adjacent emphasis flanking. Import follows CommonMark's Unicode punctuation and whitespace when deciding -whether `*`, `_` and `~~` open or close formatting. When formatted text starts +whether `*`, `_`, `~~` and `==` open or close formatting. When formatted text starts or ends with punctuation right beside a letter, export writes that letter as a numeric reference (`foo**“bar”**`) so that the markers still open and close, also with `shouldPreserveNewLines`, where a raw backslash right before diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 6512f499fbc..96a3e451557 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -911,6 +911,27 @@ function decideReferences( const count = kinds.length; const encodeStart = new Uint8Array(count); const encodeEnd = new Uint8Array(count); + // Text does not escape every tag character (the `=` of `==`, say), and one + // right beside a marker of the same character would join its delimiter + // run, so it goes out as a reference. + for (let i = 0; i < count; i++) { + const value = values[i]; + if (kinds[i] === TEXT) { + if ( + isMarker(kinds[i + 1]) && + value[value.length - 1] === values[i + 1][0] && + !isEscaped(value, value.length - 1) + ) { + encodeEnd[i] = 1; + } + if ( + isMarker(kinds[i - 1]) && + value[0] === values[i - 1][values[i - 1].length - 1] + ) { + encodeStart[i] = 1; + } + } + } const runs: MarkerRun[] = []; // The run that follows and the run that precedes each piece, or -1. const runAfter = new Int32Array(count).fill(-1); diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 7e4bf0307ff..aba0b723e7f 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -4940,6 +4940,15 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ], 'a**b**c', ], + // A tag character that text does not escape is kept out of the marker. + [ + [ + ['a=', []], + [`${LEFT_DOUBLE_QUOTE}b${RIGHT_DOUBLE_QUOTE}`, ['highlight']], + ['=c', []], + ], + 'a===“b”===c', + ], // Emphasis that ends before a code span is read before the span. [ [ @@ -4987,9 +4996,15 @@ describe('emphasis next to Unicode punctuation and symbols', () => { .getAllTextNodes() .map(node => [ node.getTextContent(), - (['bold', 'italic', 'strikethrough', 'code'] as const).filter( - format => node.hasFormat(format), - ), + ( + [ + 'bold', + 'italic', + 'strikethrough', + 'highlight', + 'code', + ] as const + ).filter(format => node.hasFormat(format)), ]), ), ).toEqual(runs); From d8dc6f360c3bf63be565d6c238f4dc9c4183f695 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:02:31 +0000 Subject: [PATCH 29/70] [lexical-markdown] Bug Fix: Apply the bare URL rule to tag characters and to www after an underscore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The reference written for a tag character beside its own marker (the `=` before `==`) skipped the bare URL check, so `see https://x.com/?a=` before highlighted `b` exported with `=` inside the URL. It now goes through the same check as other references. The check also missed a `www.` host or `mailto:`/`xmpp:` right after `_`, where GFM starts an autolink, so `x_www.example.co` got a reference inside it. ## Test plan ### Before ``` pnpm vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "URL|autolink" × writes no reference inside "x_www.example.co", which readers may autolink × writes no reference for a tag character inside a URL ``` ### After ``` pnpm vitest run --project unit packages/lexical-markdown Tests 897 passed (897) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 49 ++++++++++--------- .../__tests__/unit/LexicalMarkdown.test.ts | 18 +++++++ 2 files changed, 45 insertions(+), 22 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 96a3e451557..0cf8b1e27d8 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -723,7 +723,9 @@ function isLinkCharacter(code: number): boolean { return code > 0x20 && code < 0x7f && code !== 0x3c; } // A URL or `www.` host in a run of link characters. -const LINK_URL = /:\/\/|(?:^|[^\w.-])www\.|(?:^|[^\w.+-])(?:mailto|xmpp):/i; +// GFM starts one after `_` too. +const LINK_URL = + /:\/\/|(?:^|[^a-z\d.-])www\.|(?:^|[^a-z\d.+-])(?:mailto|xmpp):/i; // Characters an email or mention can hold (word characters, `.+@-`, and the // backslash that escapes one), and what makes one. function isAddressCharacter(code: number): boolean { @@ -911,27 +913,6 @@ function decideReferences( const count = kinds.length; const encodeStart = new Uint8Array(count); const encodeEnd = new Uint8Array(count); - // Text does not escape every tag character (the `=` of `==`, say), and one - // right beside a marker of the same character would join its delimiter - // run, so it goes out as a reference. - for (let i = 0; i < count; i++) { - const value = values[i]; - if (kinds[i] === TEXT) { - if ( - isMarker(kinds[i + 1]) && - value[value.length - 1] === values[i + 1][0] && - !isEscaped(value, value.length - 1) - ) { - encodeEnd[i] = 1; - } - if ( - isMarker(kinds[i - 1]) && - value[0] === values[i - 1][values[i - 1].length - 1] - ) { - encodeStart[i] = 1; - } - } - } const runs: MarkerRun[] = []; // The run that follows and the run that precedes each piece, or -1. const runAfter = new Int32Array(count).fill(-1); @@ -1086,6 +1067,30 @@ function decideReferences( closes && !isFlankedBy(beforeLast || endChar(before), startChar(after)) && isFlankedBy(beforeLast || endChar(before), '&'); + // Text does not escape every tag character (the `=` of `==`, say), and one + // right beside a marker of the same character would join its delimiter + // run, so it goes out as a reference, except in a bare URL, as with any + // reference. + for (let i = 0; i < count; i++) { + const value = values[i]; + if (kinds[i] === TEXT) { + if ( + isMarker(kinds[i + 1]) && + value[value.length - 1] === values[i + 1][0] && + !isEscaped(value, value.length - 1) && + !isLinkish(i, true) + ) { + encodeEnd[i] = 1; + } + if ( + isMarker(kinds[i - 1]) && + value[0] === values[i - 1][values[i - 1].length - 1] && + !isLinkish(i, false) + ) { + encodeStart[i] = 1; + } + } + } const pending: number[] = []; runs.forEach((run, index) => { if (opensWithReference(run) || closesWithReference(run)) { diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index aba0b723e7f..78afbb95b39 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5138,6 +5138,7 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ['@john_doe', '@john\\_doe'], ['xmpp:a@b.co/res', 'xmpp:a@b.co/res'], ['https://example.com/a|b', 'https://example.com/a|b'], + ['x_www.example.co', 'x\\_www.example.co'], ])( 'writes no reference inside %j, which readers may autolink', (link, markdown) => { @@ -5190,6 +5191,23 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it('writes no reference for a tag character inside a URL', () => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('see https://x.com/?a='), + $createTextNode('b').toggleFormat('highlight'), + ), + ), + {discrete: true}, + ); + expect( + editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), + ).toBe('see https://x.com/?a===b=='); + }); + it("guards a letter right after an inline link's destination", () => { const editor = createTestEditor(); editor.update( From 07b26dfc22a662ea3c3ed9f5b04ca4e21e1f316d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:26:53 +0000 Subject: [PATCH 30/70] [lexical-markdown] Bug Fix: Apply emphasis ahead of a code span only when that keeps its delimiter run whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Applying emphasis that ends before a code span ahead of the span split the node right after it, so a delimiter left over from the same run sat at the start of the next node and read as an opener at the edge of a line: `*a**`x`*` imported as italic `a` and italic code `x`, dropping both literal asterisks. Emphasis now goes first only when nothing is left of its runs before it, and anything left after it could open in the full text, as in `**“***.b `c`*`; otherwise the code span goes first, as before. The changeset's code example now uses a double-backtick span so it renders. ## Test plan ### Before ``` *a**`x`* => [["a",2],["x",18]] ``` ### After ``` *a**`x`* => [["a",2],["*",0],["x",16],["*",0]] pnpm vitest run --project unit packages/lexical-markdown Tests 899 passed (899) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 2 +- .../__tests__/unit/LexicalMarkdown.test.ts | 7 ++++ .../src/importTextFormatTransformer.ts | 41 +++++++++++++++++-- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 3a302344274..05594df29d1 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -4,6 +4,6 @@ [fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_`, `~~` and `==` markers open or close formatting -Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `**a.**x**`c`**`, so its markers no longer split apart and stay as literal text. A link or other text match no longer takes part of any code span in the text, not only the first. +Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match no longer takes part of any code span in the text, not only the first. Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it. No reference is written inside a bare URL, `www.` host, email or mention, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `ping @user**\_x**`. diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 78afbb95b39..fd1079f4484 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -578,6 +578,13 @@ describe('Markdown', () => { md: '*a [b* `c](d)`', skipExport: true, }, + { + // A delimiter left over beside emphasis that ends before a code span + // stays literal. + html: '

a*x*

', + md: '*a**`x`*', + skipExport: true, + }, { // Inline code containing a backtick must use a longer fence (CommonMark // code spans) and pad with spaces so it round-trips losslessly. diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index c99b7ff49c2..4240d9c2cc7 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -62,9 +62,15 @@ export function findOutermostTextFormatTransformer( if (codeMatch && emphasisMatch) { // Delimiters inside the code span were excluded, so an emphasis match - // that starts first either holds the whole span or ends before it, and - // either way it is applied first. - if (emphasisMatch.startIndex <= codeMatch.startIndex) { + // that starts first either holds the whole span or ends before it. One + // that holds it is applied first, and so is one that ends before it, + // unless that would split a delimiter run: the part left in the text + // after the split would read as being at the edge of a line. + if ( + emphasisMatch.startIndex <= codeMatch.startIndex && + (emphasisMatch.endIndex >= codeMatch.endIndex || + !splitsRun(textContent, emphasisMatch)) + ) { resultMatch = emphasisMatch; resultTransformer = textFormatTransformersIndex.transformersByTag[emphasisMatch.tag]; @@ -105,6 +111,35 @@ export function findOutermostTextFormatTransformer( }; } +// Whether applying `match` on its own splits a delimiter run in a way that +// changes how its other part reads: a part left before the match always +// does, and a part left after it does when the whole run could not open. +function splitsRun( + text: string, + match: {startIndex: number; endIndex: number; tag: string}, +): boolean { + const marker = match.tag[0]; + if (text[match.startIndex - 1] === marker) { + return true; + } + if (text[match.endIndex] !== marker) { + return false; + } + let start = match.endIndex; + while (start > 0 && text[start - 1] === marker) { + start--; + } + let end = match.endIndex; + while (end < text.length && text[end] === marker) { + end++; + } + return !getEmphasisSides( + marker, + characterBefore(text, start), + characterAt(text, end), + ).canOpen; +} + // Finds all inline code spans, left to right and non-overlapping, per CommonMark // rules: https://spec.commonmark.org/#code-spans. A run opens a span and the // next run of equal length closes it. An escaped backtick (`\``) cannot open a From 13358b9b5597e0325dddc742fd44b4f23df4720b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:32:39 +0000 Subject: [PATCH 31/70] [lexical-markdown] Bug Fix: Read delimiter runs at a split edge with their real neighbours on import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The importer applies one match at a time and splits the text node around it, and a delimiter run left at the edge of a split node read as if it stood at the edge of the line. So `*)**a*` imported as italic `)a` with both asterisks of `**` gone: after `*a*` was applied, the `*` left at the end of `*)*` closed the first one. Each split node now carries the characters beyond the runs at its edges in the text it came from, so a cut run reads as it did there (`*)*` stays literal, as in CommonMark). This also replaces the special case that kept emphasis ahead of a code span from splitting a run. ## Test plan ### Before ``` pnpm vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "cut by an earlier" × reads a delimiter run cut by an earlier match as it was in "*)**a*" × reads a delimiter run cut by an earlier match as it was in "“_)__a(“[)_" ``` ### After ``` pnpm vitest run --project unit packages/lexical-markdown packages/lexical-mdast packages/lexical-playground Tests 1577 passed (1577) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 2 +- .../__tests__/unit/LexicalMarkdown.test.ts | 41 ++++++++++++++ .../src/importTextFormatTransformer.ts | 53 +++++------------- .../src/importTextTransformers.ts | 54 ++++++++++++++++++- 4 files changed, 108 insertions(+), 42 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 05594df29d1..c5f37bb1b7d 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -4,6 +4,6 @@ [fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_`, `~~` and `==` markers open or close formatting -Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match no longer takes part of any code span in the text, not only the first. +Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match no longer takes part of any code span in the text, not only the first. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it. No reference is written inside a bare URL, `www.` host, email or mention, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `ping @user**\_x**`. diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index fd1079f4484..d6a5e2dca41 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5234,6 +5234,47 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ).toBe('[API](https://e.com)s**(beta)** end'); }); + it.each([ + [ + '*)**a*', + [ + ['*)*', false], + ['a', true], + ], + ], + [ + '“_)__a(“[)_', + [ + ['“_)_', false], + ['a(“[)', true], + ], + ], + [ + '*a**`x`*', + [ + ['a', true], + ['*', false], + ['x', false], + ['*', false], + ], + ], + ] as [string, [string, boolean][]][])( + 'reads a delimiter run cut by an earlier match as it was in %j', + (markdown, runs) => { + const editor = createTestEditor(); + editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS), { + discrete: true, + }); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.hasFormat('italic')]), + ), + ).toEqual(runs); + }, + ); + it('keeps a link from taking part of a later code span', () => { const editor = createTestEditor(); editor.update( diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index 4240d9c2cc7..bc6364d560c 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -24,6 +24,8 @@ interface Delimiter { export function findOutermostTextFormatTransformer( textNode: TextNode, textFormatTransformersIndex: TextFormatTransformersIndex, + before?: string, + after?: string, ): { startIndex: number; endIndex: number; @@ -51,6 +53,8 @@ export function findOutermostTextFormatTransformer( textContent, textFormatTransformersIndex, codeSpans, + before, + after, ); const emphasisMatch = delimiters.length > 0 @@ -62,15 +66,9 @@ export function findOutermostTextFormatTransformer( if (codeMatch && emphasisMatch) { // Delimiters inside the code span were excluded, so an emphasis match - // that starts first either holds the whole span or ends before it. One - // that holds it is applied first, and so is one that ends before it, - // unless that would split a delimiter run: the part left in the text - // after the split would read as being at the edge of a line. - if ( - emphasisMatch.startIndex <= codeMatch.startIndex && - (emphasisMatch.endIndex >= codeMatch.endIndex || - !splitsRun(textContent, emphasisMatch)) - ) { + // that starts first either holds the whole span or ends before it, and + // either way it is applied first. + if (emphasisMatch.startIndex <= codeMatch.startIndex) { resultMatch = emphasisMatch; resultTransformer = textFormatTransformersIndex.transformersByTag[emphasisMatch.tag]; @@ -111,35 +109,6 @@ export function findOutermostTextFormatTransformer( }; } -// Whether applying `match` on its own splits a delimiter run in a way that -// changes how its other part reads: a part left before the match always -// does, and a part left after it does when the whole run could not open. -function splitsRun( - text: string, - match: {startIndex: number; endIndex: number; tag: string}, -): boolean { - const marker = match.tag[0]; - if (text[match.startIndex - 1] === marker) { - return true; - } - if (text[match.endIndex] !== marker) { - return false; - } - let start = match.endIndex; - while (start > 0 && text[start - 1] === marker) { - start--; - } - let end = match.endIndex; - while (end < text.length && text[end] === marker) { - end++; - } - return !getEmphasisSides( - marker, - characterBefore(text, start), - characterAt(text, end), - ).canOpen; -} - // Finds all inline code spans, left to right and non-overlapping, per CommonMark // rules: https://spec.commonmark.org/#code-spans. A run opens a span and the // next run of equal length closes it. An escaped backtick (`\``) cannot open a @@ -224,6 +193,10 @@ function scanDelimiters( text: string, transformersIndex: TextFormatTransformersIndex, codeSpans: {startIndex: number; endIndex: number}[], + // What a run at the start (or end) of the text sees beyond it; the edge of + // the line when undefined. + before: string | undefined, + after: string | undefined, ): Delimiter[] { const delimiters: Delimiter[] = []; const delimiterChars = new Set( @@ -265,8 +238,8 @@ function scanDelimiters( const {canOpen, canClose} = getEmphasisSides( char, - characterBefore(text, i), - characterAt(text, i + len), + i === 0 ? before : characterBefore(text, i), + i + len === text.length ? after : characterAt(text, i + len), ); if (canOpen || canClose) { diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 91667ad1098..e51d6577907 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -18,7 +18,7 @@ import { findOutermostTextMatchTransformer, importFoundTextMatchTransformer, } from './importTextMatchTransformer'; -import {unescapeText} from './utils'; +import {characterAt, characterBefore, unescapeText} from './utils'; /** * Returns true if the node can contain transformable markdown. @@ -32,6 +32,39 @@ export function canContainTransformableMarkdown( return $isTextNode(node) && !node.hasFormat('code'); } +/** + * What a delimiter run that starts at `index` of `text` sees before it, once + * the text before `index` is split off: the character before the whole run, + * or `outside` when the run reaches the start of the text. + */ +function characterBeforeRun( + text: string, + index: number, + outside: string | undefined, +): string | undefined { + let start = index; + while (start > 0 && text[start - 1] === text[index]) { + start--; + } + return start > 0 ? characterBefore(text, start) : outside; +} + +/** + * What a delimiter run that ends at `index` of `text` sees after it, once the + * text from `index` is split off. + */ +function characterAfterRun( + text: string, + index: number, + outside: string | undefined, +): string | undefined { + let end = index; + while (end < text.length && text[end] === text[index - 1]) { + end++; + } + return end < text.length ? characterAt(text, end) : outside; +} + /** * Handles applying both text format and text match transformers. * It finds the outermost text format or text match and applies it, @@ -42,10 +75,17 @@ export function importTextTransformers( textNode: TextNode, textFormatTransformersIndex: TextFormatTransformersIndex, textMatchTransformers: TextMatchTransformer[], + // The characters around this text in the text it was split from, so that + // a delimiter run at its edge reads as it did there. + before?: string, + after?: string, ) { + const text = textNode.getTextContent(); let foundTextFormat = findOutermostTextFormatTransformer( textNode, textFormatTransformersIndex, + before, + after, ); let foundTextMatch = findOutermostTextMatchTransformer( @@ -100,12 +140,16 @@ export function importTextTransformers( foundTextFormat.transformer, foundTextFormat.match, ); + const {startIndex, endIndex} = foundTextFormat; + const tagLength = foundTextFormat.match[1].length; if (canContainTransformableMarkdown(result.nodeAfter)) { importTextTransformers( result.nodeAfter, textFormatTransformersIndex, textMatchTransformers, + characterBeforeRun(text, endIndex, before), + after, ); } if (canContainTransformableMarkdown(result.nodeBefore)) { @@ -113,6 +157,8 @@ export function importTextTransformers( result.nodeBefore, textFormatTransformersIndex, textMatchTransformers, + before, + characterAfterRun(text, startIndex, after), ); } if (canContainTransformableMarkdown(result.transformedNode)) { @@ -120,6 +166,8 @@ export function importTextTransformers( result.transformedNode, textFormatTransformersIndex, textMatchTransformers, + characterBeforeRun(text, startIndex + tagLength, before), + characterAfterRun(text, endIndex - tagLength, after), ); } } else if (foundTextMatch) { @@ -139,6 +187,8 @@ export function importTextTransformers( result.nodeAfter, textFormatTransformersIndex, textMatchTransformers, + characterBeforeRun(text, foundTextMatch.endIndex, before), + after, ); } if (canContainTransformableMarkdown(result.nodeBefore)) { @@ -146,6 +196,8 @@ export function importTextTransformers( result.nodeBefore, textFormatTransformersIndex, textMatchTransformers, + before, + characterAfterRun(text, foundTextMatch.startIndex, after), ); } if (canContainTransformableMarkdown(result.transformedNode)) { From e6db0a15b6ce03e338fc88932fd972c409c08147 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:46:42 +0000 Subject: [PATCH 32/70] [lexical-markdown] Bug Fix: Let an earlier link go before a code span it does not touch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description When the chosen format was a code span, the importer dropped any text match that did not wrap it, even one that ended before it, so in `[a](https://u) and [`b`](https://v)` the code span `b` went first and split the second link apart. A text match now gives way only to a code span it takes part of; otherwise the outermost match goes first, and the second link later wraps its own code span. The two run-edge helpers are folded into one, and the email/mention character class is a regular expression again. ## Test plan ### Before ``` pnpm vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "later link" × applies a link before a later link that holds a code span ``` ### After ``` pnpm vitest run --project unit packages/lexical-markdown packages/lexical-mdast packages/lexical-playground Tests 1578 passed (1578) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 12 +---- .../__tests__/unit/LexicalMarkdown.test.ts | 27 ++++++++++ .../src/importTextTransformers.ts | 54 ++++++++----------- 3 files changed, 52 insertions(+), 41 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 0cf8b1e27d8..20d204ff290 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -728,17 +728,9 @@ const LINK_URL = /:\/\/|(?:^|[^a-z\d.-])www\.|(?:^|[^a-z\d.+-])(?:mailto|xmpp):/i; // Characters an email or mention can hold (word characters, `.+@-`, and the // backslash that escapes one), and what makes one. +const ADDRESS_CHARACTER = /[\w.+@\\-]/; function isAddressCharacter(code: number): boolean { - return ( - (code >= 0x30 && code <= 0x39) || - (code >= 0x40 && code <= 0x5a) || - (code >= 0x61 && code <= 0x7a) || - code === 0x5f || - code === 0x2e || - code === 0x2b || - code === 0x2d || - code === 0x5c - ); + return ADDRESS_CHARACTER.test(String.fromCharCode(code)); } const ADDRESS = /@\w/; diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index d6a5e2dca41..378f851eeb7 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5275,6 +5275,33 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it('applies a link before a later link that holds a code span', () => { + const editor = createTestEditor(); + editor.update( + () => + $convertFromMarkdownString( + '[a](https://u) and [`b`](https://v)', + TRANSFORMERS, + ), + {discrete: true}, + ); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [ + node.getTextContent(), + node.hasFormat('code'), + $isLinkNode(node.getParent()), + ]), + ), + ).toEqual([ + ['a', false, true], + [' and ', false, false], + ['b', true, true], + ]); + }); + it('keeps a link from taking part of a later code span', () => { const editor = createTestEditor(); editor.update( diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index e51d6577907..8cf068ebdfd 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -33,36 +33,29 @@ export function canContainTransformableMarkdown( } /** - * What a delimiter run that starts at `index` of `text` sees before it, once - * the text before `index` is split off: the character before the whole run, - * or `outside` when the run reaches the start of the text. + * What a delimiter run cut at `index` of `text` sees beyond its far end once + * the text is split there: before the run that starts at `index` when `step` + * is -1, after the run that ends there when it is 1. `outside` is what lies + * beyond the text. */ -function characterBeforeRun( +function characterBeyondRun( text: string, index: number, + step: 1 | -1, outside: string | undefined, ): string | undefined { - let start = index; - while (start > 0 && text[start - 1] === text[index]) { - start--; + const isInside = (edge: number) => + step === 1 ? edge < text.length : edge > 0; + const delimiter = text[step === 1 ? index - 1 : index]; + let edge = index; + while (isInside(edge) && text[step === 1 ? edge : edge - 1] === delimiter) { + edge += step; } - return start > 0 ? characterBefore(text, start) : outside; -} - -/** - * What a delimiter run that ends at `index` of `text` sees after it, once the - * text from `index` is split off. - */ -function characterAfterRun( - text: string, - index: number, - outside: string | undefined, -): string | undefined { - let end = index; - while (end < text.length && text[end] === text[index - 1]) { - end++; - } - return end < text.length ? characterAt(text, end) : outside; + return !isInside(edge) + ? outside + : step === 1 + ? characterAt(text, edge) + : characterBefore(text, edge); } /** @@ -103,7 +96,6 @@ export function importTextTransformers( const wraps = (span: {startIndex: number; endIndex: number}) => startIndex <= span.startIndex && endIndex >= span.endIndex; if ( - (foundTextFormat.isCodeSpan && !wraps(foundTextFormat)) || foundTextFormat.codeSpans.some( span => startIndex < span.endIndex && @@ -148,7 +140,7 @@ export function importTextTransformers( result.nodeAfter, textFormatTransformersIndex, textMatchTransformers, - characterBeforeRun(text, endIndex, before), + characterBeyondRun(text, endIndex, -1, before), after, ); } @@ -158,7 +150,7 @@ export function importTextTransformers( textFormatTransformersIndex, textMatchTransformers, before, - characterAfterRun(text, startIndex, after), + characterBeyondRun(text, startIndex, 1, after), ); } if (canContainTransformableMarkdown(result.transformedNode)) { @@ -166,8 +158,8 @@ export function importTextTransformers( result.transformedNode, textFormatTransformersIndex, textMatchTransformers, - characterBeforeRun(text, startIndex + tagLength, before), - characterAfterRun(text, endIndex - tagLength, after), + characterBeyondRun(text, startIndex + tagLength, -1, before), + characterBeyondRun(text, endIndex - tagLength, 1, after), ); } } else if (foundTextMatch) { @@ -187,7 +179,7 @@ export function importTextTransformers( result.nodeAfter, textFormatTransformersIndex, textMatchTransformers, - characterBeforeRun(text, foundTextMatch.endIndex, before), + characterBeyondRun(text, foundTextMatch.endIndex, -1, before), after, ); } @@ -197,7 +189,7 @@ export function importTextTransformers( textFormatTransformersIndex, textMatchTransformers, before, - characterAfterRun(text, foundTextMatch.startIndex, after), + characterBeyondRun(text, foundTextMatch.startIndex, 1, after), ); } if (canContainTransformableMarkdown(result.transformedNode)) { From 6b0f82691277e964a35bee5baf180dc191ae7774 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 16:01:09 +0000 Subject: [PATCH 33/70] [lexical-markdown] Bug Fix: Apply a code span before a later link again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The previous change let any text match that did not touch the chosen code span go first, so in `` `c` *[a](b)* `` the link was applied before the code span and split the italic around it, leaving literal asterisks. A text match now goes before the code span only when it wraps the span or ends before it, as with `[a](https://u) and [`b`](https://v)`; one after the span waits for it, as on main. The export's link character checks read the character directly instead of building one from its code, and the preserve-mode backslash guard drops a condition that the marker check already covered. ## Test plan ### Before ``` pnpm vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "after a code span" × keeps emphasis around a link after a code span in "`c` *[a](b)*" × keeps emphasis around a link after a code span in "`c` x *[a](b) `d`*" ``` ### After ``` pnpm vitest run --project unit packages/lexical-markdown packages/lexical-mdast packages/lexical-playground Tests 1580 passed (1580) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 14 ++++---- .../__tests__/unit/LexicalMarkdown.test.ts | 36 +++++++++++++++++++ .../src/importTextTransformers.ts | 9 +++-- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 20d204ff290..d2656815040 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -719,8 +719,8 @@ function isMarker(kind: PieceKind | undefined): boolean { // Characters a URL, email or mention can hold: GFM ends an autolink at // whitespace or `<`. This takes in the backslash that escapes a marker too. -function isLinkCharacter(code: number): boolean { - return code > 0x20 && code < 0x7f && code !== 0x3c; +function isLinkCharacter(character: string): boolean { + return character > ' ' && character <= '~' && character !== '<'; } // A URL or `www.` host in a run of link characters. // GFM starts one after `_` too. @@ -729,8 +729,8 @@ const LINK_URL = // Characters an email or mention can hold (word characters, `.+@-`, and the // backslash that escapes one), and what makes one. const ADDRESS_CHARACTER = /[\w.+@\\-]/; -function isAddressCharacter(code: number): boolean { - return ADDRESS_CHARACTER.test(String.fromCharCode(code)); +function isAddressCharacter(character: string): boolean { + return ADDRESS_CHARACTER.test(character); } const ADDRESS = /@\w/; @@ -793,7 +793,7 @@ function joinPieces({kinds, values}: MarkdownPieces): string { // text) would escape the reference or marker written after it. if ( kinds[index] === TEXT && - (end !== '' || isMarker(kinds[index + 1])) && + isMarker(kinds[index + 1]) && isEscaped(value, value.length) ) { value = value.slice(0, -1) + toCharacterReference('\\'); @@ -820,11 +820,11 @@ function findLinkEdges(kinds: PieceKind[], values: string[]): Uint8Array { const inLink = new Uint8Array(text.length); const runEnd = ( start: number, - isPart: (code: number) => boolean, + isPart: (character: string) => boolean, limit: number, ) => { let end = start; - while (end < limit && isPart(text.charCodeAt(end))) { + while (end < limit && isPart(text[end])) { end++; } return end; diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 378f851eeb7..da937c35891 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5302,6 +5302,42 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ]); }); + it.each([ + [ + '`c` *[a](b)*', + [ + ['c', false], + [' ', false], + ['a', true], + ], + ], + [ + '`c` x *[a](b) `d`*', + [ + ['c', false], + [' x ', false], + ['a', true], + [' ', true], + ['d', true], + ], + ], + ] as [string, [string, boolean][]][])( + 'keeps emphasis around a link after a code span in %j', + (markdown, runs) => { + const editor = createTestEditor(); + editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS), { + discrete: true, + }); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.hasFormat('italic')]), + ), + ).toEqual(runs); + }, + ); + it('keeps a link from taking part of a later code span', () => { const editor = createTestEditor(); editor.update( diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 8cf068ebdfd..69b6e9e0408 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -107,8 +107,13 @@ export function importTextTransformers( // match, which would take part of the span. foundTextMatch = null; } else if (foundTextFormat.isCodeSpan) { - // foundTextMatch wraps the code span - apply foundTextMatch - foundTextFormat = null; + // The text match goes first when it wraps the code span or ends before + // it; one after it waits for the span. + if (wraps(foundTextFormat) || endIndex <= foundTextFormat.startIndex) { + foundTextFormat = null; + } else { + foundTextMatch = null; + } } else if ( // Find the outermost transformer (foundTextFormat.startIndex <= foundTextMatch.startIndex && From 19427dbd4fe89cb2b6afe2fcffb64bd69f0f98be Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 16:24:32 +0000 Subject: [PATCH 34/70] [lexical-markdown] Bug Fix: Leave a backslash inside a bare URL raw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description With `shouldPreserveNewLines`, the guard that writes a raw backslash before a marker as `\` did so inside a bare URL too, so `see http://x.com/a\` before bold `b` exported as `see http://x.com/a\**b**`, against the rule that no reference goes inside a bare URL, email or mention. The guard now skips those, as other references do. The importer's recursion into the text on either side of a match is shared by the format and text-match paths. ## Test plan ### Before ``` pnpm vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "backslash reference inside" × writes no backslash reference inside a URL ``` ### After ``` pnpm vitest run --project unit packages/lexical-markdown Tests 906 passed (906) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 2 +- packages/lexical-markdown/README.md | 2 +- .../lexical-markdown/src/MarkdownExport.ts | 16 +++-- .../__tests__/unit/LexicalMarkdown.test.ts | 19 ++++++ .../src/importTextTransformers.ts | 65 +++++++++---------- 5 files changed, 63 insertions(+), 41 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index c5f37bb1b7d..ebb83b21590 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -6,4 +6,4 @@ Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match no longer takes part of any code span in the text, not only the first. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. -Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it. No reference is written inside a bare URL, `www.` host, email or mention, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `ping @user**\_x**`. +Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it, except inside a bare URL. No reference is written inside a bare URL, `www.` host, email or mention, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `ping @user**\_x**`. diff --git a/packages/lexical-markdown/README.md b/packages/lexical-markdown/README.md index baff4e3867f..aa4c1f46a9c 100644 --- a/packages/lexical-markdown/README.md +++ b/packages/lexical-markdown/README.md @@ -102,7 +102,7 @@ whether `*`, `_`, `~~` and `==` open or close formatting. When formatted text st or ends with punctuation right beside a letter, export writes that letter as a numeric reference (`foo**“bar”**`) so that the markers still open and close, also with `shouldPreserveNewLines`, where a raw backslash right before -such a marker or reference goes out as `\`. No reference is written inside +such a marker or reference goes out as `\` outside a bare URL. No reference is written inside a bare URL, `www.` host, email or mention. There're three types of transformers: diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index d2656815040..0939d8a51f1 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -773,7 +773,7 @@ function joinPieces({kinds, values}: MarkdownPieces): string { if (!kinds.some(isMarker)) { return values.join(''); } - const {encodeStart, encodeEnd} = decideReferences(kinds, values); + const {encodeStart, encodeEnd, isLinkish} = decideReferences(kinds, values); let markdown = ''; for (let index = 0; index < kinds.length; index++) { let value = values[index]; @@ -790,11 +790,13 @@ function joinPieces({kinds, values}: MarkdownPieces): string { value = value.slice(0, -last.length); } // A backslash left unescaped (when preserving newlines, or in decorator - // text) would escape the reference or marker written after it. + // text) would escape the reference or marker written after it, though + // not inside a bare URL, where no reference is written. if ( kinds[index] === TEXT && isMarker(kinds[index + 1]) && - isEscaped(value, value.length) + isEscaped(value, value.length) && + !isLinkish(index, true) ) { value = value.slice(0, -1) + toCharacterReference('\\'); } @@ -901,7 +903,11 @@ function scanDelimiterRun( function decideReferences( kinds: PieceKind[], values: string[], -): {encodeStart: Uint8Array; encodeEnd: Uint8Array} { +): { + encodeStart: Uint8Array; + encodeEnd: Uint8Array; + isLinkish: (index: number, atEnd: boolean) => boolean; +} { const count = kinds.length; const encodeStart = new Uint8Array(count); const encodeEnd = new Uint8Array(count); @@ -1116,7 +1122,7 @@ function decideReferences( } } } - return {encodeEnd, encodeStart}; + return {encodeEnd, encodeStart, isLinkish}; } function getTextSibling(node: TextNode, backward: boolean): TextNode | null { diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index da937c35891..818f68f6017 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5198,6 +5198,25 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it('writes no backslash reference inside a URL', () => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('see http://x.com/a\\'), + $createTextNode('b').toggleFormat('bold'), + ), + ), + {discrete: true}, + ); + expect( + editor.read('latest', () => + $convertToMarkdownString(TRANSFORMERS, undefined, true), + ), + ).toBe('see http://x.com/a\\**b**'); + }); + it('writes no reference for a tag character inside a URL', () => { const editor = createTestEditor(); editor.update( diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 69b6e9e0408..75364bbd3e2 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -129,35 +129,45 @@ export function importTextTransformers( } } - if (foundTextFormat) { - const result = importTextFormatTransformer( - textNode, - foundTextFormat.startIndex, - foundTextFormat.endIndex, - foundTextFormat.transformer, - foundTextFormat.match, - ); - const {startIndex, endIndex} = foundTextFormat; - const tagLength = foundTextFormat.match[1].length; - - if (canContainTransformableMarkdown(result.nodeAfter)) { + // Imports the text left on either side of a match from `startIndex` to + // `endIndex`, each with what lies beyond the run cut at its edge. + const importAround = ( + nodeBefore: LexicalNode | undefined, + nodeAfter: LexicalNode | undefined, + startIndex: number, + endIndex: number, + ) => { + if (canContainTransformableMarkdown(nodeAfter)) { importTextTransformers( - result.nodeAfter, + nodeAfter, textFormatTransformersIndex, textMatchTransformers, characterBeyondRun(text, endIndex, -1, before), after, ); } - if (canContainTransformableMarkdown(result.nodeBefore)) { + if (canContainTransformableMarkdown(nodeBefore)) { importTextTransformers( - result.nodeBefore, + nodeBefore, textFormatTransformersIndex, textMatchTransformers, before, characterBeyondRun(text, startIndex, 1, after), ); } + }; + + if (foundTextFormat) { + const result = importTextFormatTransformer( + textNode, + foundTextFormat.startIndex, + foundTextFormat.endIndex, + foundTextFormat.transformer, + foundTextFormat.match, + ); + const {startIndex, endIndex} = foundTextFormat; + const tagLength = foundTextFormat.match[1].length; + importAround(result.nodeBefore, result.nodeAfter, startIndex, endIndex); if (canContainTransformableMarkdown(result.transformedNode)) { importTextTransformers( result.transformedNode, @@ -178,25 +188,12 @@ export function importTextTransformers( if (!result) { return; } - - if (canContainTransformableMarkdown(result.nodeAfter)) { - importTextTransformers( - result.nodeAfter, - textFormatTransformersIndex, - textMatchTransformers, - characterBeyondRun(text, foundTextMatch.endIndex, -1, before), - after, - ); - } - if (canContainTransformableMarkdown(result.nodeBefore)) { - importTextTransformers( - result.nodeBefore, - textFormatTransformersIndex, - textMatchTransformers, - before, - characterBeyondRun(text, foundTextMatch.startIndex, 1, after), - ); - } + importAround( + result.nodeBefore, + result.nodeAfter, + foundTextMatch.startIndex, + foundTextMatch.endIndex, + ); if (canContainTransformableMarkdown(result.transformedNode)) { importTextTransformers( result.transformedNode, From 38aa12a730798586cfd8ad72c0c8fba99f6f2d80 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 16:41:21 +0000 Subject: [PATCH 35/70] [lexical-markdown] Refactor: Give a code span's content no outside context and decide each run once ## Description A transformer registered for the backtick tag whose format is not code would have its content re-imported with neighbours taken from inside a longer fence, since the fence length is not the tag's; a code span's content now gets no outside context. The export also seeds its work list with every marker run instead of checking each one twice. No change with the built-in transformers. ## Test plan ### After ``` pnpm vitest run --project unit packages/lexical-markdown Tests 906 passed (906) ``` Co-Authored-By: Claude Opus 5.5 --- packages/lexical-markdown/src/MarkdownExport.ts | 7 +------ .../lexical-markdown/src/importTextTransformers.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 0939d8a51f1..7709ca5aa8c 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -1089,12 +1089,7 @@ function decideReferences( } } } - const pending: number[] = []; - runs.forEach((run, index) => { - if (opensWithReference(run) || closesWithReference(run)) { - pending.push(index); - } - }); + const pending = runs.map((_run, index) => index); // Writing a one-character piece as a reference changes what the run on its // other side sees, so that run is decided again. Each piece changes at most // twice, so this stays linear. diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 75364bbd3e2..7a5ddcf9686 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -169,12 +169,19 @@ export function importTextTransformers( const tagLength = foundTextFormat.match[1].length; importAround(result.nodeBefore, result.nodeAfter, startIndex, endIndex); if (canContainTransformableMarkdown(result.transformedNode)) { + // A code span's fence is not its tag's length, and its content is not + // read for emphasis against what lies outside it. + const {isCodeSpan} = foundTextFormat; importTextTransformers( result.transformedNode, textFormatTransformersIndex, textMatchTransformers, - characterBeyondRun(text, startIndex + tagLength, -1, before), - characterBeyondRun(text, endIndex - tagLength, 1, after), + isCodeSpan + ? undefined + : characterBeyondRun(text, startIndex + tagLength, -1, before), + isCodeSpan + ? undefined + : characterBeyondRun(text, endIndex - tagLength, 1, after), ); } } else if (foundTextMatch) { From d8bfb1e8e36ed2b08aeccb554b8e7d0e5369ff26 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:08:38 +0000 Subject: [PATCH 36/70] [lexical-markdown] Bug Fix: Measure a cut delimiter run whole for the rule of 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description When an earlier match cuts a delimiter run, the text split off it now gets the real neighbour of the run, which can make the leftover delimiters both-flanking. The importer's rule of 3 measured only the remaining lengths, so `**b.***.**` imported as bold `b.` followed by a literal `*.**`, while main and CommonMark give bold `b.`, italic `.` and a literal `*`. Split text now also learns how many delimiters of the run at its edge were cut off, and a pairing is refused by the rule of 3 only when both the original lengths (as the spec measures them) and the remaining lengths (needed for #4895) refuse it. ## Test plan ### Before ``` $ npx vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "rule of 3" × measures the whole run cut by an earlier match for the rule of 3 in "**b.***.**" × measures the whole run cut by an earlier match for the rule of 3 in "**b_***.**" Tests 2 failed | 730 skipped (732) ``` ### After ``` $ npx vitest run packages/lexical-markdown --project unit Test Files 9 passed (9) Tests 908 passed (908) ``` Compared with markdown-it on 40,000 random inputs, the import now disagrees on 233 fewer of them and on none it agreed on before. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/unit/LexicalMarkdown.test.ts | 25 ++++++++ .../src/importTextFormatTransformer.ts | 64 ++++++++++++------- .../src/importTextTransformers.ts | 45 +++++++------ 3 files changed, 92 insertions(+), 42 deletions(-) diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 818f68f6017..0cf441912b3 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5294,6 +5294,31 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it.each(['**b.***.**', '**b_***.**'])( + 'measures the whole run cut by an earlier match for the rule of 3 in %j', + markdown => { + const editor = createTestEditor(); + editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS), { + discrete: true, + }); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [ + node.getTextContent(), + node.hasFormat('bold'), + node.hasFormat('italic'), + ]), + ), + ).toEqual([ + [markdown.slice(2, 4), true, false], + ['.', false, true], + ['*', false, false], + ]); + }, + ); + it('applies a link before a later link that holds a code span', () => { const editor = createTestEditor(); editor.update( diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index bc6364d560c..830f1c43743 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -12,10 +12,19 @@ import type {TextNode} from 'lexical'; import {characterAt, characterBefore, getEmphasisSides} from './utils'; +/** + * What a delimiter run at the edge of a text sees beyond the text it was split + * from: the next character (undefined at the edge of the line) and how many + * delimiters of the run itself were cut off there. + */ +export type RunEdge = {character: string | undefined; length: number}; + interface Delimiter { index: number; char: string; length: number; + // The length of the whole run in the line, for the rule of 3. + origLength: number; canOpen: boolean; canClose: boolean; active: boolean; @@ -24,8 +33,8 @@ interface Delimiter { export function findOutermostTextFormatTransformer( textNode: TextNode, textFormatTransformersIndex: TextFormatTransformersIndex, - before?: string, - after?: string, + before?: RunEdge, + after?: RunEdge, ): { startIndex: number; endIndex: number; @@ -195,8 +204,8 @@ function scanDelimiters( codeSpans: {startIndex: number; endIndex: number}[], // What a run at the start (or end) of the text sees beyond it; the edge of // the line when undefined. - before: string | undefined, - after: string | undefined, + before: RunEdge | undefined, + after: RunEdge | undefined, ): Delimiter[] { const delimiters: Delimiter[] = []; const delimiterChars = new Set( @@ -236,10 +245,12 @@ function scanDelimiters( len++; } + const atStart = i === 0 && before; + const atEnd = i + len === text.length && after; const {canOpen, canClose} = getEmphasisSides( char, - i === 0 ? before : characterBefore(text, i), - i + len === text.length ? after : characterAt(text, i + len), + atStart ? before.character : characterBefore(text, i), + atEnd ? after.character : characterAt(text, i + len), ); if (canOpen || canClose) { @@ -250,6 +261,8 @@ function scanDelimiters( char, index: i, length: len, + origLength: + len + (atStart ? before.length : 0) + (atEnd ? after.length : 0), }); } @@ -259,6 +272,10 @@ function scanDelimiters( return delimiters; } +function isRefusedByRuleOf3(opener: number, closer: number): boolean { + return (opener + closer) % 3 === 0 && opener % 3 !== 0 && closer % 3 !== 0; +} + function processEmphasis( text: string, delimiters: Delimiter[], @@ -288,7 +305,10 @@ function processEmphasis( // openers_bottom is likewise per length-mod-3). The length key uses the // *current* length: a partially consumed closer switches class and must // rescan openers a shorter closer already gave up on (#4895). - const bottomKey = `${closer.char}${closer.canOpen}${closer.length % 3}`; + // The original length joins it, since the rule of 3 below also reads it. + const bottomKey = `${closer.char}${closer.canOpen}${closer.length % 3}${ + closer.origLength % 3 + }`; const bottom = openersBottom[bottomKey] ?? -1; let foundOpener = false; @@ -304,21 +324,21 @@ function processEmphasis( continue; } - // Rule of 3, on the *remaining* lengths: a delimiter run partially - // consumed by an earlier pairing is re-measured, matching micromark - // (the CommonMark reference this models). Using the original lengths - // instead would refuse pairings like the `**` opener with the two - // markers left of a `****` run in `**llo*wor****`, so the exporter's - // own output for overlapping formats would not re-import (#4895). - if (opener.canClose || closer.canOpen) { - const sum = opener.length + closer.length; - if ( - sum % 3 === 0 && - opener.length % 3 !== 0 && - closer.length % 3 !== 0 - ) { - continue; - } + // Rule of 3. The spec measures the original runs, but a pairing is + // refused only when the *remaining* lengths refuse it too, matching + // micromark: a delimiter run partially consumed by an earlier pairing is + // re-measured. Refusing by the original lengths alone would refuse + // pairings like the `**` opener with the two markers left of a `****` + // run in `**llo*wor****`, so the exporter's own output for overlapping + // formats would not re-import (#4895). Refusing by the remaining + // lengths alone would refuse the `*` left of `***` with the `**` in + // `**b.***.**`, which the spec pairs. + if ( + (opener.canClose || closer.canOpen) && + isRefusedByRuleOf3(opener.length, closer.length) && + isRefusedByRuleOf3(opener.origLength, closer.origLength) + ) { + continue; } const maxLen = Math.min(opener.length, closer.length); diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 7a5ddcf9686..1bc2b54937b 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -13,6 +13,7 @@ import {$isTextNode, type LexicalNode, type TextNode} from 'lexical'; import { findOutermostTextFormatTransformer, importTextFormatTransformer, + type RunEdge, } from './importTextFormatTransformer'; import { findOutermostTextMatchTransformer, @@ -34,16 +35,16 @@ export function canContainTransformableMarkdown( /** * What a delimiter run cut at `index` of `text` sees beyond its far end once - * the text is split there: before the run that starts at `index` when `step` - * is -1, after the run that ends there when it is 1. `outside` is what lies - * beyond the text. + * the text is split there, and how many of its delimiters are cut off: before + * the run that starts at `index` when `step` is -1, after the run that ends + * there when it is 1. `outside` is what lies beyond the text. */ -function characterBeyondRun( +function runEdge( text: string, index: number, step: 1 | -1, - outside: string | undefined, -): string | undefined { + outside: RunEdge | undefined, +): RunEdge { const isInside = (edge: number) => step === 1 ? edge < text.length : edge > 0; const delimiter = text[step === 1 ? index - 1 : index]; @@ -51,11 +52,17 @@ function characterBeyondRun( while (isInside(edge) && text[step === 1 ? edge : edge - 1] === delimiter) { edge += step; } + const length = Math.abs(edge - index); return !isInside(edge) - ? outside - : step === 1 - ? characterAt(text, edge) - : characterBefore(text, edge); + ? { + character: outside && outside.character, + length: length + (outside ? outside.length : 0), + } + : { + character: + step === 1 ? characterAt(text, edge) : characterBefore(text, edge), + length, + }; } /** @@ -68,10 +75,10 @@ export function importTextTransformers( textNode: TextNode, textFormatTransformersIndex: TextFormatTransformersIndex, textMatchTransformers: TextMatchTransformer[], - // The characters around this text in the text it was split from, so that - // a delimiter run at its edge reads as it did there. - before?: string, - after?: string, + // What lies around this text in the text it was split from, so that a + // delimiter run at its edge reads as it did there. + before?: RunEdge, + after?: RunEdge, ) { const text = textNode.getTextContent(); let foundTextFormat = findOutermostTextFormatTransformer( @@ -142,7 +149,7 @@ export function importTextTransformers( nodeAfter, textFormatTransformersIndex, textMatchTransformers, - characterBeyondRun(text, endIndex, -1, before), + runEdge(text, endIndex, -1, before), after, ); } @@ -152,7 +159,7 @@ export function importTextTransformers( textFormatTransformersIndex, textMatchTransformers, before, - characterBeyondRun(text, startIndex, 1, after), + runEdge(text, startIndex, 1, after), ); } }; @@ -178,10 +185,8 @@ export function importTextTransformers( textMatchTransformers, isCodeSpan ? undefined - : characterBeyondRun(text, startIndex + tagLength, -1, before), - isCodeSpan - ? undefined - : characterBeyondRun(text, endIndex - tagLength, 1, after), + : runEdge(text, startIndex + tagLength, -1, before), + isCodeSpan ? undefined : runEdge(text, endIndex - tagLength, 1, after), ); } } else if (foundTextMatch) { From f46f447e3a17d0958dfa35d3a6e7c0fd528f4f1f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:44:02 +0000 Subject: [PATCH 37/70] [lexical-markdown] Refactor: Join raw output as one space when finding links and read node text once ## Description findLinkEdges joined each raw piece (code spans, links, transformer output) as a run of spaces as long as the piece, so a long code span made it allocate and scan a blank string of that length. A raw piece only needs to end a run, so it is now joined as a single space, with each piece's offset tracked. The importer also unescapes the text it already read instead of reading the node again. ## Test plan ### After ``` $ npx vitest run packages/lexical-markdown --project unit Test Files 9 passed (9) Tests 908 passed (908) ``` Co-Authored-By: Claude Opus 5.5 --- packages/lexical-markdown/src/MarkdownExport.ts | 12 +++++++----- .../lexical-markdown/src/importTextTransformers.ts | 4 +--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 7709ca5aa8c..b5cf4f0870a 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -815,10 +815,14 @@ function joinPieces({kinds, values}: MarkdownPieces): string { * is not bare text, so it ends a run. */ function findLinkEdges(kinds: PieceKind[], values: string[]): Uint8Array { + // Raw output ends a run, so it is joined as a single space. let text = ''; + const starts = new Uint32Array(values.length + 1); for (let i = 0; i < values.length; i++) { - text += kinds[i] === RAW ? ' '.repeat(values[i].length) : values[i]; + starts[i] = text.length; + text += kinds[i] === RAW ? ' ' : values[i]; } + starts[values.length] = text.length; const inLink = new Uint8Array(text.length); const runEnd = ( start: number, @@ -855,10 +859,8 @@ function findLinkEdges(kinds: PieceKind[], values: string[]): Uint8Array { start = end; } const edges = new Uint8Array(values.length); - for (let i = 0, offset = 0; i < values.length; i++) { - const length = values[i].length; - edges[i] = inLink[offset] | (inLink[offset + length - 1] << 1); - offset += length; + for (let i = 0; i < values.length; i++) { + edges[i] = inLink[starts[i]] | (inLink[starts[i + 1] - 1] << 1); } return edges; } diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 1bc2b54937b..de4384db6ea 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -230,8 +230,6 @@ export function importTextTransformers( // unescaped here: retained non-code results above have already had their // escapes read, and code spans must keep their backslashes. if (!foundTextFormat && !foundTextMatch) { - const textContent = textNode.getTextContent(); - const unescapedText = unescapeText(textContent); - textNode.setTextContent(unescapedText); + textNode.setTextContent(unescapeText(text)); } } From 063395e888ce71cff37392b20019cdc128e6c838 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:04:29 +0000 Subject: [PATCH 38/70] [lexical-markdown] Bug Fix: Leave an escaped delimiter out of a cut run's length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description When an earlier match cut a delimiter run, the importer counted the cut-off delimiters by walking back over the same character, including an escaped one before the run. `**\*ba\****.**` then measured the leftover run as 4 instead of 3, the rule of 3 refused the italic, and the text imported as bold `*ba*` followed by a literal `*.**`. The walk now stops at an escaped delimiter, so it imports as bold `*ba*`, italic `.` and a literal `*`, as CommonMark reads it. ## Test plan ### Before ``` $ npx vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "rule of 3" × measures the whole run cut by an earlier match for the rule of 3 in "**\\*ba\\****.**" Tests 1 failed | 2 passed | 730 skipped (733) ``` ### After ``` $ npx vitest run packages/lexical-markdown --project unit Test Files 9 passed (9) Tests 909 passed (909) ``` Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/unit/LexicalMarkdown.test.ts | 10 +++++++--- .../lexical-markdown/src/importTextTransformers.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 0cf441912b3..6f050d9ae8c 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5294,9 +5294,13 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); - it.each(['**b.***.**', '**b_***.**'])( + it.each([ + ['**b.***.**', 'b.'], + ['**b_***.**', 'b_'], + ['**\\*ba\\****.**', '*ba*'], + ])( 'measures the whole run cut by an earlier match for the rule of 3 in %j', - markdown => { + (markdown, bold) => { const editor = createTestEditor(); editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS), { discrete: true, @@ -5312,7 +5316,7 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ]), ), ).toEqual([ - [markdown.slice(2, 4), true, false], + [bold, true, false], ['.', false, true], ['*', false, false], ]); diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index de4384db6ea..cc6e0bfd581 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -19,7 +19,7 @@ import { findOutermostTextMatchTransformer, importFoundTextMatchTransformer, } from './importTextMatchTransformer'; -import {characterAt, characterBefore, unescapeText} from './utils'; +import {characterAt, characterBefore, isEscaped, unescapeText} from './utils'; /** * Returns true if the node can contain transformable markdown. @@ -49,7 +49,12 @@ function runEdge( step === 1 ? edge < text.length : edge > 0; const delimiter = text[step === 1 ? index - 1 : index]; let edge = index; - while (isInside(edge) && text[step === 1 ? edge : edge - 1] === delimiter) { + // An escaped delimiter before the run is not part of it. + while ( + isInside(edge) && + text[step === 1 ? edge : edge - 1] === delimiter && + (step === 1 || !isEscaped(text, edge - 1)) + ) { edge += step; } const length = Math.abs(edge - index); From d63f19f8c3882d656e8a3f8c226a40d17fa53329 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:40:12 +0000 Subject: [PATCH 39/70] [lexical-markdown] Bug Fix: Keep non-ASCII characters inside a bare URL when guarding references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The export's autolink guard ended a link run at the first non-ASCII character, so the part of a bare URL after one was treated as text. Plain `see https://example.com/éa` followed by bold `“b”` exported as `see https://example.com/éa**“b”**`, and GFM autolink readers keep that reference literally in the URL. A link run now ends only at whitespace, a control character or `<`, as GFM autolinks do, so a Unicode host or path stays inside it and gets no reference. ## Test plan ### Before ``` $ npx vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "writes no reference inside" × writes no reference inside "https://example.com/éa", which readers may autolink × writes no reference inside "https://例え.jp/a", which readers may autolink × writes no reference inside "www.例え.jp", which readers may autolink × writes no reference inside "https://example.com/a😀b", which readers may autolink Tests 4 failed | 9 passed | 724 skipped (737) ``` ### After ``` $ npx vitest run packages/lexical-markdown packages/lexical-mdast --project unit Test Files 17 passed (17) Tests 1198 passed (1198) ``` Co-Authored-By: Claude Opus 5.5 --- packages/lexical-markdown/src/MarkdownExport.ts | 6 ++++-- .../src/__tests__/unit/LexicalMarkdown.test.ts | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index b5cf4f0870a..bf711c6591c 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -718,9 +718,11 @@ function isMarker(kind: PieceKind | undefined): boolean { } // Characters a URL, email or mention can hold: GFM ends an autolink at -// whitespace or `<`. This takes in the backslash that escapes a marker too. +// whitespace or `<`, and a URL's host and path may hold any other character. +// This takes in the backslash that escapes a marker too. +const LINK_END = /[\s\p{Cc}<]/u; function isLinkCharacter(character: string): boolean { - return character > ' ' && character <= '~' && character !== '<'; + return !LINK_END.test(character); } // A URL or `www.` host in a run of link characters. // GFM starts one after `_` too. diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 6f050d9ae8c..5e26a109253 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5146,6 +5146,10 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ['xmpp:a@b.co/res', 'xmpp:a@b.co/res'], ['https://example.com/a|b', 'https://example.com/a|b'], ['x_www.example.co', 'x\\_www.example.co'], + ['https://example.com/\u00e9a', 'https://example.com/\u00e9a'], + ['https://\u4f8b\u3048.jp/a', 'https://\u4f8b\u3048.jp/a'], + ['www.\u4f8b\u3048.jp', 'www.\u4f8b\u3048.jp'], + ['https://example.com/a\u{1f600}b', 'https://example.com/a\u{1f600}b'], ])( 'writes no reference inside %j, which readers may autolink', (link, markdown) => { From 8a734d21bef84ffced31d83549502a854a7572fd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 19:19:42 +0000 Subject: [PATCH 40/70] [lexical-markdown] Bug Fix: Restore the remaining-length rule of 3 and reference letters around an underscore between letters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The importer's rule of 3 had been changed to refuse a pairing only when both the original and the remaining run lengths refuse it. That let a leftover `**` from a mixed `***` run pair with a later `*`, so Lexical's own export of italic `)`, bold `(` and bold italic `)`, `*)***(*)***`, re-imported as three italics and a literal `**`. Main read it back exactly. This restores main's remaining-length rule (#4895). Split text still gets the character beyond a cut delimiter run, and that walk now stops at an escaped delimiter. The exporter decided whether a character reference helps by flanking alone, which ignores that `_` cannot open or close between two letters. With underscore italics, plain `a`, italic `b` and plain `c` exported as `a_b_c` and imported as plain text. The decision now uses the same emphasis rules as import, so it exports `a_b_c` and round-trips. The importer also uses the shared `isEscaped` from utils instead of two local copies. ## Test plan ### Before ``` $ npx vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "between punctuation" × round-trips italic then bold italic between punctuation Tests 1 failed | 734 skipped (735) $ npx vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "underscore italic between letters" × writes references around an underscore italic between letters Tests 1 failed | 735 skipped (736) ``` ### After ``` $ npx vitest run packages/lexical-markdown packages/lexical-mdast scripts/__tests__/unit/treeShakingSource.test.ts Test Files 18 passed (18) Tests 1571 passed (1571) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 40 ++++++-- .../__tests__/unit/LexicalMarkdown.test.ts | 77 ++++++++++------ .../src/importTextFormatTransformer.ts | 91 ++++++------------- .../src/importTextTransformers.ts | 45 ++++----- 4 files changed, 128 insertions(+), 125 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index bf711c6591c..4830bf5134e 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -36,6 +36,7 @@ import { import { characterAt, characterBefore, + getEmphasisSides, isEmptyParagraph, isEscaped, isFlankedBy, @@ -1054,21 +1055,40 @@ function decideReferences( }; const isFree = (index: number, leftward: boolean) => !isOneCharacter(index) || canSpread(index, leftward); - // A reference helps only where the run would flank if the character - // beside it were punctuation. - const opensWithReference = ({opens, before, after, afterFirst}: MarkerRun) => - opens && - !isFlankedBy(afterFirst || startChar(after), endChar(before)) && - isFlankedBy(afterFirst || startChar(after), ';'); + // A reference helps only where the run could open (or close) if the + // character beside it were punctuation. `_` between two letters cannot. + const opensWithReference = ({ + opens, + before, + after, + afterFirst, + }: MarkerRun) => { + if (!opens) { + return false; + } + const marker = values[before + 1][0]; + const next = afterFirst || startChar(after); + return ( + !getEmphasisSides(marker, endChar(before), next).canOpen && + getEmphasisSides(marker, ';', next).canOpen + ); + }; const closesWithReference = ({ closes, before, after, beforeLast, - }: MarkerRun) => - closes && - !isFlankedBy(beforeLast || endChar(before), startChar(after)) && - isFlankedBy(beforeLast || endChar(before), '&'); + }: MarkerRun) => { + if (!closes) { + return false; + } + const marker = values[after - 1][values[after - 1].length - 1]; + const previous = beforeLast || endChar(before); + return ( + !getEmphasisSides(marker, previous, startChar(after)).canClose && + getEmphasisSides(marker, previous, '&').canClose + ); + }; // Text does not escape every tag character (the `=` of `==`, say), and one // right beside a marker of the same character would join its delimiter // run, so it goes out as a reference, except in a bare URL, as with any diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 5e26a109253..e8bf7e3fc8a 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -26,6 +26,7 @@ import { CODE, type ElementTransformer, HEADING, + ITALIC_STAR, LINK, type MultilineElementTransformer, registerMarkdownShortcuts, @@ -1665,6 +1666,17 @@ describe('Markdown', () => { ); }); + it('round-trips italic then bold italic between punctuation', () => { + expectRoundTrip( + [ + [')', ITALIC], + ['(', BOLD], + [')', BOLD | ITALIC], + ], + '*)***(*)***', + ); + }); + it('round-trips strikethrough overlapping bold', () => { expectRoundTrip( [ @@ -5238,6 +5250,42 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ).toBe('see https://x.com/?a===b=='); }); + it('writes references around an underscore italic between letters', () => { + const transformers = TRANSFORMERS.filter( + transformer => transformer !== ITALIC_STAR, + ); + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('a'), + $createTextNode('b').toggleFormat('italic'), + $createTextNode('c'), + ), + ), + {discrete: true}, + ); + const markdown = editor.read('latest', () => + $convertToMarkdownString(transformers), + ); + expect(markdown).toBe('a_b_c'); + editor.update(() => $convertFromMarkdownString(markdown, transformers), { + discrete: true, + }); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.hasFormat('italic')]), + ), + ).toEqual([ + ['a', false], + ['b', true], + ['c', false], + ]); + }); + it("guards a letter right after an inline link's destination", () => { const editor = createTestEditor(); editor.update( @@ -5298,35 +5346,6 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); - it.each([ - ['**b.***.**', 'b.'], - ['**b_***.**', 'b_'], - ['**\\*ba\\****.**', '*ba*'], - ])( - 'measures the whole run cut by an earlier match for the rule of 3 in %j', - (markdown, bold) => { - const editor = createTestEditor(); - editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS), { - discrete: true, - }); - expect( - editor.read('latest', () => - $getRoot() - .getAllTextNodes() - .map(node => [ - node.getTextContent(), - node.hasFormat('bold'), - node.hasFormat('italic'), - ]), - ), - ).toEqual([ - [bold, true, false], - ['.', false, true], - ['*', false, false], - ]); - }, - ); - it('applies a link before a later link that holds a code span', () => { const editor = createTestEditor(); editor.update( diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index 830f1c43743..0ddf2ed2a3e 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -10,21 +10,17 @@ import type {TextFormatTransformersIndex} from './MarkdownImport'; import type {TextFormatTransformer} from './MarkdownTransformers'; import type {TextNode} from 'lexical'; -import {characterAt, characterBefore, getEmphasisSides} from './utils'; - -/** - * What a delimiter run at the edge of a text sees beyond the text it was split - * from: the next character (undefined at the edge of the line) and how many - * delimiters of the run itself were cut off there. - */ -export type RunEdge = {character: string | undefined; length: number}; +import { + characterAt, + characterBefore, + getEmphasisSides, + isEscaped, +} from './utils'; interface Delimiter { index: number; char: string; length: number; - // The length of the whole run in the line, for the rule of 3. - origLength: number; canOpen: boolean; canClose: boolean; active: boolean; @@ -33,8 +29,8 @@ interface Delimiter { export function findOutermostTextFormatTransformer( textNode: TextNode, textFormatTransformersIndex: TextFormatTransformersIndex, - before?: RunEdge, - after?: RunEdge, + before?: string, + after?: string, ): { startIndex: number; endIndex: number; @@ -127,14 +123,6 @@ export function scanCodeSpans(text: string): { endIndex: number; content: string; }[] { - const isEscaped = (index: number): boolean => { - let count = 0; - for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) { - count++; - } - return count % 2 === 1; - }; - // Collect maximal backtick runs. const runs: {index: number; length: number}[] = []; let i = 0; @@ -157,7 +145,7 @@ export function scanCodeSpans(text: string): { const opener = runs[openIdx]; // An escaped backtick run is a literal backtick and cannot open a span. - if (isEscaped(opener.index)) { + if (isEscaped(text, opener.index)) { openIdx++; continue; } @@ -204,8 +192,8 @@ function scanDelimiters( codeSpans: {startIndex: number; endIndex: number}[], // What a run at the start (or end) of the text sees beyond it; the edge of // the line when undefined. - before: RunEdge | undefined, - after: RunEdge | undefined, + before: string | undefined, + after: string | undefined, ): Delimiter[] { const delimiters: Delimiter[] = []; const delimiterChars = new Set( @@ -214,14 +202,6 @@ function scanDelimiters( .map(tag => tag[0]), ); - const isEscaped = (index: number): boolean => { - let count = 0; - for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) { - count++; - } - return count % 2 === 1; - }; - // The spans are in order and the scan only moves forward. let span = 0; const isInCodeSpan = (index: number): boolean => { @@ -235,7 +215,7 @@ function scanDelimiters( while (i < text.length) { const char = text[i]; - if (!delimiterChars.has(char) || isEscaped(i) || isInCodeSpan(i)) { + if (!delimiterChars.has(char) || isEscaped(text, i) || isInCodeSpan(i)) { i++; continue; } @@ -245,12 +225,10 @@ function scanDelimiters( len++; } - const atStart = i === 0 && before; - const atEnd = i + len === text.length && after; const {canOpen, canClose} = getEmphasisSides( char, - atStart ? before.character : characterBefore(text, i), - atEnd ? after.character : characterAt(text, i + len), + i === 0 ? before : characterBefore(text, i), + i + len === text.length ? after : characterAt(text, i + len), ); if (canOpen || canClose) { @@ -261,8 +239,6 @@ function scanDelimiters( char, index: i, length: len, - origLength: - len + (atStart ? before.length : 0) + (atEnd ? after.length : 0), }); } @@ -272,10 +248,6 @@ function scanDelimiters( return delimiters; } -function isRefusedByRuleOf3(opener: number, closer: number): boolean { - return (opener + closer) % 3 === 0 && opener % 3 !== 0 && closer % 3 !== 0; -} - function processEmphasis( text: string, delimiters: Delimiter[], @@ -305,10 +277,7 @@ function processEmphasis( // openers_bottom is likewise per length-mod-3). The length key uses the // *current* length: a partially consumed closer switches class and must // rescan openers a shorter closer already gave up on (#4895). - // The original length joins it, since the rule of 3 below also reads it. - const bottomKey = `${closer.char}${closer.canOpen}${closer.length % 3}${ - closer.origLength % 3 - }`; + const bottomKey = `${closer.char}${closer.canOpen}${closer.length % 3}`; const bottom = openersBottom[bottomKey] ?? -1; let foundOpener = false; @@ -324,21 +293,21 @@ function processEmphasis( continue; } - // Rule of 3. The spec measures the original runs, but a pairing is - // refused only when the *remaining* lengths refuse it too, matching - // micromark: a delimiter run partially consumed by an earlier pairing is - // re-measured. Refusing by the original lengths alone would refuse - // pairings like the `**` opener with the two markers left of a `****` - // run in `**llo*wor****`, so the exporter's own output for overlapping - // formats would not re-import (#4895). Refusing by the remaining - // lengths alone would refuse the `*` left of `***` with the `**` in - // `**b.***.**`, which the spec pairs. - if ( - (opener.canClose || closer.canOpen) && - isRefusedByRuleOf3(opener.length, closer.length) && - isRefusedByRuleOf3(opener.origLength, closer.origLength) - ) { - continue; + // Rule of 3, on the *remaining* lengths: a delimiter run partially + // consumed by an earlier pairing is re-measured, matching micromark + // (the CommonMark reference this models). Using the original lengths + // instead would refuse pairings like the `**` opener with the two + // markers left of a `****` run in `**llo*wor****`, so the exporter's + // own output for overlapping formats would not re-import (#4895). + if (opener.canClose || closer.canOpen) { + const sum = opener.length + closer.length; + if ( + sum % 3 === 0 && + opener.length % 3 !== 0 && + closer.length % 3 !== 0 + ) { + continue; + } } const maxLen = Math.min(opener.length, closer.length); diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index cc6e0bfd581..acf8b740f93 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -13,7 +13,6 @@ import {$isTextNode, type LexicalNode, type TextNode} from 'lexical'; import { findOutermostTextFormatTransformer, importTextFormatTransformer, - type RunEdge, } from './importTextFormatTransformer'; import { findOutermostTextMatchTransformer, @@ -35,16 +34,16 @@ export function canContainTransformableMarkdown( /** * What a delimiter run cut at `index` of `text` sees beyond its far end once - * the text is split there, and how many of its delimiters are cut off: before - * the run that starts at `index` when `step` is -1, after the run that ends - * there when it is 1. `outside` is what lies beyond the text. + * the text is split there: before the run that starts at `index` when `step` + * is -1, after the run that ends there when it is 1. `outside` is what lies + * beyond the text. */ -function runEdge( +function characterBeyondRun( text: string, index: number, step: 1 | -1, - outside: RunEdge | undefined, -): RunEdge { + outside: string | undefined, +): string | undefined { const isInside = (edge: number) => step === 1 ? edge < text.length : edge > 0; const delimiter = text[step === 1 ? index - 1 : index]; @@ -57,17 +56,11 @@ function runEdge( ) { edge += step; } - const length = Math.abs(edge - index); return !isInside(edge) - ? { - character: outside && outside.character, - length: length + (outside ? outside.length : 0), - } - : { - character: - step === 1 ? characterAt(text, edge) : characterBefore(text, edge), - length, - }; + ? outside + : step === 1 + ? characterAt(text, edge) + : characterBefore(text, edge); } /** @@ -80,10 +73,10 @@ export function importTextTransformers( textNode: TextNode, textFormatTransformersIndex: TextFormatTransformersIndex, textMatchTransformers: TextMatchTransformer[], - // What lies around this text in the text it was split from, so that a - // delimiter run at its edge reads as it did there. - before?: RunEdge, - after?: RunEdge, + // The characters around this text in the text it was split from, so that + // a delimiter run at its edge reads as it did there. + before?: string, + after?: string, ) { const text = textNode.getTextContent(); let foundTextFormat = findOutermostTextFormatTransformer( @@ -154,7 +147,7 @@ export function importTextTransformers( nodeAfter, textFormatTransformersIndex, textMatchTransformers, - runEdge(text, endIndex, -1, before), + characterBeyondRun(text, endIndex, -1, before), after, ); } @@ -164,7 +157,7 @@ export function importTextTransformers( textFormatTransformersIndex, textMatchTransformers, before, - runEdge(text, startIndex, 1, after), + characterBeyondRun(text, startIndex, 1, after), ); } }; @@ -190,8 +183,10 @@ export function importTextTransformers( textMatchTransformers, isCodeSpan ? undefined - : runEdge(text, startIndex + tagLength, -1, before), - isCodeSpan ? undefined : runEdge(text, endIndex - tagLength, 1, after), + : characterBeyondRun(text, startIndex + tagLength, -1, before), + isCodeSpan + ? undefined + : characterBeyondRun(text, endIndex - tagLength, 1, after), ); } } else if (foundTextMatch) { From 454bf816aaf07198cb2160d2c3ccd38a692dc19d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:22:21 +0000 Subject: [PATCH 41/70] [lexical-markdown] Bug Fix: Follow the CJK-friendly amendment for emphasis flanking on import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description With CJK punctuation now counted as Unicode punctuation, Markdown such as `**你好。**世界`, `這是**「重要」**的` or `한국어*(강조)*입니다` imported with literal asterisks. That matches CommonMark, GFM, micromark and markdown-it, but main read these as formatted, so stored CJK documents would lose their formatting. Import now follows the CJK-friendly amendment (https://github.com/tats-u/markdown-cjk-friendly/blob/main/specification.md), the rule that the `remark-cjk-friendly` and `markdown-it-cjk-friendly` plugins implement. CJK punctuation on the side a run faces flanks as a letter does, a CJK character on the other side flanks as punctuation does, and a text variation selector reads as the character it follows. Export keeps relying on plain CommonMark, so bold `你好。` before plain `世界` still exports as `**你好。**世界`, which every reader formats. ## Test plan ### Before ``` $ npx vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "emphasis next to Unicode" × imports "**你好。**世界" × imports "這是**「重要」**的" × imports "한국어*(강조)*입니다" × imports "**\"︀**b" Tests 4 failed | 72 passed | 666 skipped (742) ``` ### After ``` $ npx vitest run packages/lexical-markdown packages/lexical-mdast scripts/__tests__/unit/treeShakingSource.test.ts Test Files 18 passed (18) Tests 1578 passed (1578) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 4 +- packages/lexical-markdown/README.md | 4 +- .../__tests__/unit/LexicalMarkdown.test.ts | 71 ++++++++++++++++- .../src/importTextFormatTransformer.ts | 5 +- .../src/importTextTransformers.ts | 9 ++- packages/lexical-markdown/src/utils.ts | 76 +++++++++++++++++-- 6 files changed, 154 insertions(+), 15 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index ebb83b21590..2517bb57b58 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -4,6 +4,6 @@ [fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_`, `~~` and `==` markers open or close formatting -Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match no longer takes part of any code span in the text, not only the first. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. +Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match no longer takes part of any code span in the text, not only the first. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. Import also follows the [CJK-friendly amendment](https://github.com/tats-u/markdown-cjk-friendly/blob/main/specification.md), so CJK punctuation beside a CJK character does not stop a marker from flanking, and `**你好。**世界` stays bold. -Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it, except inside a bare URL. No reference is written inside a bare URL, `www.` host, email or mention, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `ping @user**\_x**`. +Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Export does not rely on the CJK-friendly amendment, so bold `你好。` before plain `世界` exports as `**你好。**世界`. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it, except inside a bare URL. No reference is written inside a bare URL, `www.` host, email or mention, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `ping @user**\_x**`. diff --git a/packages/lexical-markdown/README.md b/packages/lexical-markdown/README.md index aa4c1f46a9c..ff4d716a1ff 100644 --- a/packages/lexical-markdown/README.md +++ b/packages/lexical-markdown/README.md @@ -98,7 +98,9 @@ brackets and guarded `<`/`&` use numeric references so escaping does not change adjacent emphasis flanking. Import follows CommonMark's Unicode punctuation and whitespace when deciding -whether `*`, `_`, `~~` and `==` open or close formatting. When formatted text starts +whether `*`, `_`, `~~` and `==` open or close formatting, with the +[CJK-friendly amendment](https://github.com/tats-u/markdown-cjk-friendly/blob/main/specification.md) +so that `**你好。**世界` is bold. Export does not rely on the amendment. When formatted text starts or ends with punctuation right beside a letter, export writes that letter as a numeric reference (`foo**“bar”**`) so that the markers still open and close, also with `shouldPreserveNewLines`, where a raw backslash right before diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index e8bf7e3fc8a..b52fcf6fcdc 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -4682,7 +4682,7 @@ describe('emphasis next to Unicode punctuation and symbols', () => { } // Each expected result matches what micromark, a CommonMark parser, produces - // for the same input. + // for the same input, apart from the CJK-friendly cases. const CASES: [md: string, runs: Run[]][] = [ [ `See ${EN_DASH}**${HERE_LINK}** now.`, @@ -4799,6 +4799,44 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ['**', 'plain'], ], ], + // The CJK-friendly amendment: CJK punctuation beside a CJK character does + // not stop a run from flanking. + // https://github.com/tats-u/markdown-cjk-friendly/blob/main/specification.md + [ + '**\u4f60\u597d\u3002**\u4e16\u754c', + [ + ['\u4f60\u597d\u3002', 'bold'], + ['\u4e16\u754c', 'plain'], + ], + ], + [ + '\u9019\u662f**\u300c\u91cd\u8981\u300d**\u7684', + [ + ['\u9019\u662f', 'plain'], + ['\u300c\u91cd\u8981\u300d', 'bold'], + ['\u7684', 'plain'], + ], + ], + [ + '\ud55c\uad6d\uc5b4*\uff08\uac15\uc870\uff09*\uc785\ub2c8\ub2e4', + [ + ['\ud55c\uad6d\uc5b4', 'plain'], + ['\uff08\uac15\uc870\uff09', 'italic'], + ['\uc785\ub2c8\ub2e4', 'plain'], + ], + ], + // `_` between CJK characters is still inside a word. + [ + '\u65e5\u672c_\u8a9e_\u3067\u3059', + [['\u65e5\u672c_\u8a9e_\u3067\u3059', 'plain']], + ], + // Non-CJK punctuation beside a letter still stops it, and a variation + // selector reads as the punctuation it follows. + [ + `a**${LEFT_DOUBLE_QUOTE}\u5b57${RIGHT_DOUBLE_QUOTE}**b`, + [[`a**${LEFT_DOUBLE_QUOTE}\u5b57${RIGHT_DOUBLE_QUOTE}**b`, 'plain']], + ], + ['**"\ufe00**b', [['**"\ufe00**b', 'plain']]], ]; for (const [md, runs] of CASES) { @@ -5250,6 +5288,37 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ).toBe('see https://x.com/?a===b=='); }); + it('keeps export readable without the CJK-friendly amendment', () => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('\u4f60\u597d\u3002').toggleFormat('bold'), + $createTextNode('\u4e16\u754c'), + ), + ), + {discrete: true}, + ); + const markdown = editor.read('latest', () => + $convertToMarkdownString(TRANSFORMERS), + ); + expect(markdown).toBe('**\u4f60\u597d\u3002**世\u754c'); + editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS), { + discrete: true, + }); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.hasFormat('bold')]), + ), + ).toEqual([ + ['\u4f60\u597d\u3002', true], + ['\u4e16\u754c', false], + ]); + }); + it('writes references around an underscore italic between letters', () => { const transformers = TRANSFORMERS.filter( transformer => transformer !== ITALIC_STAR, diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index 0ddf2ed2a3e..571db37ce1a 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -12,7 +12,7 @@ import type {TextNode} from 'lexical'; import { characterAt, - characterBefore, + characterBeforeRun, getEmphasisSides, isEscaped, } from './utils'; @@ -227,8 +227,9 @@ function scanDelimiters( const {canOpen, canClose} = getEmphasisSides( char, - i === 0 ? before : characterBefore(text, i), + i === 0 ? before : characterBeforeRun(text, i), i + len === text.length ? after : characterAt(text, i + len), + true, ); if (canOpen || canClose) { diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index acf8b740f93..bc1d35a7c20 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -18,7 +18,12 @@ import { findOutermostTextMatchTransformer, importFoundTextMatchTransformer, } from './importTextMatchTransformer'; -import {characterAt, characterBefore, isEscaped, unescapeText} from './utils'; +import { + characterAt, + characterBeforeRun, + isEscaped, + unescapeText, +} from './utils'; /** * Returns true if the node can contain transformable markdown. @@ -60,7 +65,7 @@ function characterBeyondRun( ? outside : step === 1 ? characterAt(text, edge) - : characterBefore(text, edge); + : characterBeforeRun(text, edge); } /** diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index 8b9e1b9c3b9..521ed17e00d 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -66,6 +66,18 @@ export function transformersByType(transformers: Transformer[]): Readonly<{ const PUNCTUATION = /[\p{P}\p{S}]/u; const WHITESPACE = /[\p{Zs}\t\n\f\r]/u; +// CJK characters for the CJK-friendly amendment to emphasis flanking: +// East Asian Width W, F or H and not emoji, or Hangul (generated ranges), plus +// the ideographic variation selectors, which flank as a CJK character does. +// https://github.com/tats-u/markdown-cjk-friendly/blob/main/specification.md +const CJK = + // Some ranges start or end at a combining mark, which is not misleading. + // eslint-disable-next-line no-misleading-character-class + /[\u1100-\u11ff\u20a9\u2329-\u232a\u2630-\u2637\u268a-\u268f\u2e80-\u2e99\u2e9b-\u2ef3\u2f00-\u2fd5\u2ff0-\u303e\u3041-\u3096\u3099-\u30ff\u3105-\u312f\u3131-\u318e\u3190-\u31e5\u31ef-\u321e\u3220-\u3247\u3250-\ua48c\ua490-\ua4c6\ua960-\ua97c\uac00-\ud7a3\ud7b0-\ud7c6\ud7cb-\ud7fb\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe52\ufe54-\ufe66\ufe68-\ufe6b\uff01-\uffbe\uffc2-\uffc7\uffca-\uffcf\uffd2-\uffd7\uffda-\uffdc\uffe0-\uffe6\uffe8-\uffee\u{16fe0}-\u{16fe4}\u{16ff0}-\u{16ff6}\u{17000}-\u{18cda}\u{18cff}-\u{18d20}\u{18d80}-\u{18df2}\u{18e00}-\u{19191}\u{191a0}-\u{191d2}\u{1aff0}-\u{1aff3}\u{1aff5}-\u{1affb}\u{1affd}-\u{1affe}\u{1b000}-\u{1b128}\u{1b132}\u{1b150}-\u{1b152}\u{1b155}\u{1b164}-\u{1b168}\u{1b170}-\u{1b2fb}\u{1d300}-\u{1d356}\u{1d360}-\u{1d376}\u{1f1ae}\u{1f200}\u{1f202}\u{1f210}-\u{1f219}\u{1f21b}-\u{1f22e}\u{1f230}-\u{1f231}\u{1f237}\u{1f23b}\u{1f240}-\u{1f248}\u{1f260}-\u{1f265}\u{1f7da}\u{20000}-\u{3fffd}\u{e0100}-\u{e01ef}]/u; +// Variation selectors other than the emoji one, which join the character +// before them. +const TEXT_VARIATION_SELECTOR = /[\ufe00-\ufe0e]/; + const WHITESPACE_CLASS = 0; const PUNCTUATION_CLASS = 1; const OTHER_CLASS = 2; @@ -120,29 +132,79 @@ function isFlankedByClass(primary: number, secondary: number): boolean { ); } +function isCJK(character: string | undefined): boolean { + return ( + character !== undefined && + character.charCodeAt(0) >= 0x1100 && + CJK.test(character) + ); +} + +// Under the CJK-friendly amendment, CJK punctuation on the side a run faces +// flanks as a letter does, and a CJK character on its other side lets it +// flank as punctuation there does. +function facingClass(character: string | undefined, isCJKFriendly: boolean) { + const characterClass = classify(character); + return isCJKFriendly && + characterClass === PUNCTUATION_CLASS && + isCJK(character) + ? OTHER_CLASS + : characterClass; +} + +function awayClass(character: string | undefined, isCJKFriendly: boolean) { + const characterClass = classify(character); + return isCJKFriendly && characterClass === OTHER_CLASS && isCJK(character) + ? PUNCTUATION_CLASS + : characterClass; +} + /** * Whether a delimiter run of `marker` between `before` and `after` can open * and close emphasis; `_` opens or closes inside a word only beside - * punctuation. + * punctuation. With `isCJKFriendly`, it follows the CJK-friendly amendment, + * so that `**你好。**世界` is strong as it is in CJK-friendly readers. * https://spec.commonmark.org/0.31.2/#can-open-emphasis */ export function getEmphasisSides( marker: string, before: string | undefined, after: string | undefined, + isCJKFriendly = false, ): {canOpen: boolean; canClose: boolean} { - const beforeClass = classify(before); - const afterClass = classify(after); - const isLeft = isFlankedByClass(afterClass, beforeClass); - const isRight = isFlankedByClass(beforeClass, afterClass); + const isLeft = isFlankedByClass( + facingClass(after, isCJKFriendly), + awayClass(before, isCJKFriendly), + ); + const isRight = isFlankedByClass( + facingClass(before, isCJKFriendly), + awayClass(after, isCJKFriendly), + ); return marker === '_' ? { - canClose: isRight && (!isLeft || afterClass === PUNCTUATION_CLASS), - canOpen: isLeft && (!isRight || beforeClass === PUNCTUATION_CLASS), + canClose: isRight && (!isLeft || classify(after) === PUNCTUATION_CLASS), + canOpen: isLeft && (!isRight || classify(before) === PUNCTUATION_CLASS), } : {canClose: isRight, canOpen: isLeft}; } +/** + * The character before `index`, read with a variation selector other than + * the emoji one as the character it follows, as the CJK-friendly amendment + * does. + */ +export function characterBeforeRun( + text: string, + index: number, +): string | undefined { + const character = characterBefore(text, index); + return character !== undefined && + index > 1 && + TEXT_VARIATION_SELECTOR.test(character) + ? characterBefore(text, index - 1) + : character; +} + export function isPunctuationOrSpace(character: string): boolean { return classify(character) !== OTHER_CLASS; } From 49ef33c9a685a3ea006372fbbc1cffafa4a7777b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 22:07:29 +0000 Subject: [PATCH 42/70] [lexical-markdown] Chore: Revert the CJK-friendly import amendment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description This reverts 454bf81. Import follows strict CommonMark again, as GFM, micromark and markdown-it do, so `**你好。**世界` imports with literal asterisks. A CJK-friendly rule can come in a follow-up that works with both the `@lexical/markdown` and `@lexical/mdast` importers. The test that bold `你好。` before `世界` exports with a reference and round-trips is kept. ## Test plan ### After ``` $ npx vitest run packages/lexical-markdown packages/lexical-mdast --project unit Test Files 17 passed (17) Tests 1198 passed (1198) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 4 +- packages/lexical-markdown/README.md | 4 +- .../__tests__/unit/LexicalMarkdown.test.ts | 42 +--------- .../src/importTextFormatTransformer.ts | 5 +- .../src/importTextTransformers.ts | 9 +-- packages/lexical-markdown/src/utils.ts | 76 ++----------------- 6 files changed, 16 insertions(+), 124 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 2517bb57b58..ebb83b21590 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -4,6 +4,6 @@ [fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_`, `~~` and `==` markers open or close formatting -Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match no longer takes part of any code span in the text, not only the first. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. Import also follows the [CJK-friendly amendment](https://github.com/tats-u/markdown-cjk-friendly/blob/main/specification.md), so CJK punctuation beside a CJK character does not stop a marker from flanking, and `**你好。**世界` stays bold. +Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match no longer takes part of any code span in the text, not only the first. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. -Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Export does not rely on the CJK-friendly amendment, so bold `你好。` before plain `世界` exports as `**你好。**世界`. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it, except inside a bare URL. No reference is written inside a bare URL, `www.` host, email or mention, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `ping @user**\_x**`. +Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it, except inside a bare URL. No reference is written inside a bare URL, `www.` host, email or mention, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `ping @user**\_x**`. diff --git a/packages/lexical-markdown/README.md b/packages/lexical-markdown/README.md index ff4d716a1ff..aa4c1f46a9c 100644 --- a/packages/lexical-markdown/README.md +++ b/packages/lexical-markdown/README.md @@ -98,9 +98,7 @@ brackets and guarded `<`/`&` use numeric references so escaping does not change adjacent emphasis flanking. Import follows CommonMark's Unicode punctuation and whitespace when deciding -whether `*`, `_`, `~~` and `==` open or close formatting, with the -[CJK-friendly amendment](https://github.com/tats-u/markdown-cjk-friendly/blob/main/specification.md) -so that `**你好。**世界` is bold. Export does not rely on the amendment. When formatted text starts +whether `*`, `_`, `~~` and `==` open or close formatting. When formatted text starts or ends with punctuation right beside a letter, export writes that letter as a numeric reference (`foo**“bar”**`) so that the markers still open and close, also with `shouldPreserveNewLines`, where a raw backslash right before diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index b52fcf6fcdc..ee493e6ac89 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -4682,7 +4682,7 @@ describe('emphasis next to Unicode punctuation and symbols', () => { } // Each expected result matches what micromark, a CommonMark parser, produces - // for the same input, apart from the CJK-friendly cases. + // for the same input. const CASES: [md: string, runs: Run[]][] = [ [ `See ${EN_DASH}**${HERE_LINK}** now.`, @@ -4799,44 +4799,6 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ['**', 'plain'], ], ], - // The CJK-friendly amendment: CJK punctuation beside a CJK character does - // not stop a run from flanking. - // https://github.com/tats-u/markdown-cjk-friendly/blob/main/specification.md - [ - '**\u4f60\u597d\u3002**\u4e16\u754c', - [ - ['\u4f60\u597d\u3002', 'bold'], - ['\u4e16\u754c', 'plain'], - ], - ], - [ - '\u9019\u662f**\u300c\u91cd\u8981\u300d**\u7684', - [ - ['\u9019\u662f', 'plain'], - ['\u300c\u91cd\u8981\u300d', 'bold'], - ['\u7684', 'plain'], - ], - ], - [ - '\ud55c\uad6d\uc5b4*\uff08\uac15\uc870\uff09*\uc785\ub2c8\ub2e4', - [ - ['\ud55c\uad6d\uc5b4', 'plain'], - ['\uff08\uac15\uc870\uff09', 'italic'], - ['\uc785\ub2c8\ub2e4', 'plain'], - ], - ], - // `_` between CJK characters is still inside a word. - [ - '\u65e5\u672c_\u8a9e_\u3067\u3059', - [['\u65e5\u672c_\u8a9e_\u3067\u3059', 'plain']], - ], - // Non-CJK punctuation beside a letter still stops it, and a variation - // selector reads as the punctuation it follows. - [ - `a**${LEFT_DOUBLE_QUOTE}\u5b57${RIGHT_DOUBLE_QUOTE}**b`, - [[`a**${LEFT_DOUBLE_QUOTE}\u5b57${RIGHT_DOUBLE_QUOTE}**b`, 'plain']], - ], - ['**"\ufe00**b', [['**"\ufe00**b', 'plain']]], ]; for (const [md, runs] of CASES) { @@ -5288,7 +5250,7 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ).toBe('see https://x.com/?a===b=='); }); - it('keeps export readable without the CJK-friendly amendment', () => { + it('writes a reference after CJK punctuation that ends bold before a CJK character', () => { const editor = createTestEditor(); editor.update( () => diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index 571db37ce1a..0ddf2ed2a3e 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -12,7 +12,7 @@ import type {TextNode} from 'lexical'; import { characterAt, - characterBeforeRun, + characterBefore, getEmphasisSides, isEscaped, } from './utils'; @@ -227,9 +227,8 @@ function scanDelimiters( const {canOpen, canClose} = getEmphasisSides( char, - i === 0 ? before : characterBeforeRun(text, i), + i === 0 ? before : characterBefore(text, i), i + len === text.length ? after : characterAt(text, i + len), - true, ); if (canOpen || canClose) { diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index bc1d35a7c20..acf8b740f93 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -18,12 +18,7 @@ import { findOutermostTextMatchTransformer, importFoundTextMatchTransformer, } from './importTextMatchTransformer'; -import { - characterAt, - characterBeforeRun, - isEscaped, - unescapeText, -} from './utils'; +import {characterAt, characterBefore, isEscaped, unescapeText} from './utils'; /** * Returns true if the node can contain transformable markdown. @@ -65,7 +60,7 @@ function characterBeyondRun( ? outside : step === 1 ? characterAt(text, edge) - : characterBeforeRun(text, edge); + : characterBefore(text, edge); } /** diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index 521ed17e00d..8b9e1b9c3b9 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -66,18 +66,6 @@ export function transformersByType(transformers: Transformer[]): Readonly<{ const PUNCTUATION = /[\p{P}\p{S}]/u; const WHITESPACE = /[\p{Zs}\t\n\f\r]/u; -// CJK characters for the CJK-friendly amendment to emphasis flanking: -// East Asian Width W, F or H and not emoji, or Hangul (generated ranges), plus -// the ideographic variation selectors, which flank as a CJK character does. -// https://github.com/tats-u/markdown-cjk-friendly/blob/main/specification.md -const CJK = - // Some ranges start or end at a combining mark, which is not misleading. - // eslint-disable-next-line no-misleading-character-class - /[\u1100-\u11ff\u20a9\u2329-\u232a\u2630-\u2637\u268a-\u268f\u2e80-\u2e99\u2e9b-\u2ef3\u2f00-\u2fd5\u2ff0-\u303e\u3041-\u3096\u3099-\u30ff\u3105-\u312f\u3131-\u318e\u3190-\u31e5\u31ef-\u321e\u3220-\u3247\u3250-\ua48c\ua490-\ua4c6\ua960-\ua97c\uac00-\ud7a3\ud7b0-\ud7c6\ud7cb-\ud7fb\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe52\ufe54-\ufe66\ufe68-\ufe6b\uff01-\uffbe\uffc2-\uffc7\uffca-\uffcf\uffd2-\uffd7\uffda-\uffdc\uffe0-\uffe6\uffe8-\uffee\u{16fe0}-\u{16fe4}\u{16ff0}-\u{16ff6}\u{17000}-\u{18cda}\u{18cff}-\u{18d20}\u{18d80}-\u{18df2}\u{18e00}-\u{19191}\u{191a0}-\u{191d2}\u{1aff0}-\u{1aff3}\u{1aff5}-\u{1affb}\u{1affd}-\u{1affe}\u{1b000}-\u{1b128}\u{1b132}\u{1b150}-\u{1b152}\u{1b155}\u{1b164}-\u{1b168}\u{1b170}-\u{1b2fb}\u{1d300}-\u{1d356}\u{1d360}-\u{1d376}\u{1f1ae}\u{1f200}\u{1f202}\u{1f210}-\u{1f219}\u{1f21b}-\u{1f22e}\u{1f230}-\u{1f231}\u{1f237}\u{1f23b}\u{1f240}-\u{1f248}\u{1f260}-\u{1f265}\u{1f7da}\u{20000}-\u{3fffd}\u{e0100}-\u{e01ef}]/u; -// Variation selectors other than the emoji one, which join the character -// before them. -const TEXT_VARIATION_SELECTOR = /[\ufe00-\ufe0e]/; - const WHITESPACE_CLASS = 0; const PUNCTUATION_CLASS = 1; const OTHER_CLASS = 2; @@ -132,79 +120,29 @@ function isFlankedByClass(primary: number, secondary: number): boolean { ); } -function isCJK(character: string | undefined): boolean { - return ( - character !== undefined && - character.charCodeAt(0) >= 0x1100 && - CJK.test(character) - ); -} - -// Under the CJK-friendly amendment, CJK punctuation on the side a run faces -// flanks as a letter does, and a CJK character on its other side lets it -// flank as punctuation there does. -function facingClass(character: string | undefined, isCJKFriendly: boolean) { - const characterClass = classify(character); - return isCJKFriendly && - characterClass === PUNCTUATION_CLASS && - isCJK(character) - ? OTHER_CLASS - : characterClass; -} - -function awayClass(character: string | undefined, isCJKFriendly: boolean) { - const characterClass = classify(character); - return isCJKFriendly && characterClass === OTHER_CLASS && isCJK(character) - ? PUNCTUATION_CLASS - : characterClass; -} - /** * Whether a delimiter run of `marker` between `before` and `after` can open * and close emphasis; `_` opens or closes inside a word only beside - * punctuation. With `isCJKFriendly`, it follows the CJK-friendly amendment, - * so that `**你好。**世界` is strong as it is in CJK-friendly readers. + * punctuation. * https://spec.commonmark.org/0.31.2/#can-open-emphasis */ export function getEmphasisSides( marker: string, before: string | undefined, after: string | undefined, - isCJKFriendly = false, ): {canOpen: boolean; canClose: boolean} { - const isLeft = isFlankedByClass( - facingClass(after, isCJKFriendly), - awayClass(before, isCJKFriendly), - ); - const isRight = isFlankedByClass( - facingClass(before, isCJKFriendly), - awayClass(after, isCJKFriendly), - ); + const beforeClass = classify(before); + const afterClass = classify(after); + const isLeft = isFlankedByClass(afterClass, beforeClass); + const isRight = isFlankedByClass(beforeClass, afterClass); return marker === '_' ? { - canClose: isRight && (!isLeft || classify(after) === PUNCTUATION_CLASS), - canOpen: isLeft && (!isRight || classify(before) === PUNCTUATION_CLASS), + canClose: isRight && (!isLeft || afterClass === PUNCTUATION_CLASS), + canOpen: isLeft && (!isRight || beforeClass === PUNCTUATION_CLASS), } : {canClose: isRight, canOpen: isLeft}; } -/** - * The character before `index`, read with a variation selector other than - * the emoji one as the character it follows, as the CJK-friendly amendment - * does. - */ -export function characterBeforeRun( - text: string, - index: number, -): string | undefined { - const character = characterBefore(text, index); - return character !== undefined && - index > 1 && - TEXT_VARIATION_SELECTOR.test(character) - ? characterBefore(text, index - 1) - : character; -} - export function isPunctuationOrSpace(character: string): boolean { return classify(character) !== OTHER_CLASS; } From 474fe5601acfcbcecea8cc50a1f60d476c2c3025 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 22:17:55 +0000 Subject: [PATCH 43/70] [lexical-markdown] Bug Fix: Choose export references that micromark readers such as @lexical/mdast also format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Export decided where to write a character reference by CommonMark's flanking rules alone. micromark, which `@lexical/mdast` imports with, reads a character outside the Basic Multilingual Plane one UTF-16 unit at a time, so an emoji beside a marker is a letter to it. Shapes that CommonMark formats then came back with literal markers from `@lexical/mdast`: plain `😀` then bold `“b”` exported as `😀**“b”**`, and italic `😀 😀` then bold `a“` as `*😀 😀***a“**`, whose `**` micromark cannot open. Export now requires each marker to open and close for both readers. An emoji beside a marker can take a reference too (`😀**“b”**`), and when a reference written for the closing side of a run that also opens changes what its opener sees, the run is decided again. Exporting 10,000 random paragraphs and importing them with `@lexical/mdast`, misread paragraphs fall from 2,015 (main) and 1,008 (previous head) to 318, none of them newly broken, and round trips through `@lexical/markdown` gain no failures. ## Test plan ### Before ``` $ npx vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "both importers" × exports [["😀",0],["“b”",1]] so that both importers read it back × exports [["a",1],["😀",2]] so that both importers read it back Tests 2 failed | 737 skipped (739) ``` ### After ``` $ npx vitest run packages/lexical-markdown packages/lexical-mdast scripts/__tests__/unit/treeShakingSource.test.ts Test Files 18 passed (18) Tests 1574 passed (1574) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 2 +- packages/lexical-markdown/README.md | 2 +- .../lexical-markdown/src/MarkdownExport.ts | 26 +++++--- .../__tests__/unit/LexicalMarkdown.test.ts | 61 +++++++++++++++++++ packages/lexical-markdown/src/utils.ts | 52 ++++++++++++++++ 5 files changed, 131 insertions(+), 12 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index ebb83b21590..58a1de4c0e8 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -6,4 +6,4 @@ Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match no longer takes part of any code span in the text, not only the first. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. -Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it, except inside a bare URL. No reference is written inside a bare URL, `www.` host, email or mention, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `ping @user**\_x**`. +Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Those references are chosen so that micromark-based readers such as `@lexical/mdast`, which read an emoji or another character outside the Basic Multilingual Plane as a letter, format it the same way, so plain `😀` then bold `“b”` exports as `😀**“b”**`. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it, except inside a bare URL. No reference is written inside a bare URL, `www.` host, email or mention, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `ping @user**\_x**`. diff --git a/packages/lexical-markdown/README.md b/packages/lexical-markdown/README.md index aa4c1f46a9c..aefd1488c4f 100644 --- a/packages/lexical-markdown/README.md +++ b/packages/lexical-markdown/README.md @@ -101,7 +101,7 @@ Import follows CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_`, `~~` and `==` open or close formatting. When formatted text starts or ends with punctuation right beside a letter, export writes that letter as a numeric reference (`foo**“bar”**`) so that the markers still open and -close, also with `shouldPreserveNewLines`, where a raw backslash right before +close, for micromark-based readers such as `@lexical/mdast` too, also with `shouldPreserveNewLines`, where a raw backslash right before such a marker or reference goes out as `\` outside a bare URL. No reference is written inside a bare URL, `www.` host, email or mention. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 4830bf5134e..7fefc61c3e2 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -36,11 +36,11 @@ import { import { characterAt, characterBefore, - getEmphasisSides, + getPortableEmphasisSides, isEmptyParagraph, isEscaped, - isFlankedBy, - isPunctuationOrSpace, + isLetterForSomeReader, + isPortablyFlankedBy, toCharacterReference, transformersByType, } from './utils'; @@ -746,7 +746,7 @@ const UNREFERENCEABLE = /[\p{Cc}\p{Cs}\p{Noncharacter_Code_Point}]/u; function isReferenceable(character: string | undefined): boolean { return ( character !== undefined && - !isPunctuationOrSpace(character) && + isLetterForSomeReader(character) && !UNREFERENCEABLE.test(character) ); } @@ -1036,7 +1036,7 @@ function decideReferences( : run.closes && run.beforeLast === undefined; const next = leftward ? run.before : run.after; const char = leftward ? endChar(next) : startChar(next); - if (!isAffected || isFlankedBy(leftward ? '&' : ';', char)) { + if (!isAffected || isPortablyFlankedBy(leftward ? '&' : ';', char)) { break; } if (!canReference(next, leftward)) { @@ -1069,8 +1069,8 @@ function decideReferences( const marker = values[before + 1][0]; const next = afterFirst || startChar(after); return ( - !getEmphasisSides(marker, endChar(before), next).canOpen && - getEmphasisSides(marker, ';', next).canOpen + !getPortableEmphasisSides(marker, endChar(before), next).canOpen && + getPortableEmphasisSides(marker, ';', next).canOpen ); }; const closesWithReference = ({ @@ -1085,8 +1085,8 @@ function decideReferences( const marker = values[after - 1][values[after - 1].length - 1]; const previous = beforeLast || endChar(before); return ( - !getEmphasisSides(marker, previous, startChar(after)).canClose && - getEmphasisSides(marker, previous, '&').canClose + !getPortableEmphasisSides(marker, previous, startChar(after)).canClose && + getPortableEmphasisSides(marker, previous, '&').canClose ); }; // Text does not escape every tag character (the `=` of `==`, say), and one @@ -1118,7 +1118,8 @@ function decideReferences( // other side sees, so that run is decided again. Each piece changes at most // twice, so this stays linear. while (pending.length > 0) { - const run = runs[pending.pop()!]; + const index = pending.pop()!; + const run = runs[index]; const {before, after} = run; if ( opensWithReference(run) && @@ -1136,6 +1137,11 @@ function decideReferences( isFree(after, false) ) { encodeStart[after] = 1; + // An opener that sees the same character, of a run that both closes + // and opens, may now need its own reference. + if (run.opens && run.afterFirst === undefined) { + pending.push(index); + } if (isEncoded(after) && runAfter[after] !== -1) { pending.push(runAfter[after]); } diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index ee493e6ac89..5251ac60356 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -6,6 +6,7 @@ * */ import {$createCodeNode, CodeNode} from '@lexical/code-core'; +import {buildEditorFromExtensions} from '@lexical/extension'; import {createHeadlessEditor} from '@lexical/headless'; import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; import invariant from '@lexical/internal/invariant'; @@ -34,6 +35,10 @@ import { type Transformer, TRANSFORMERS, } from '@lexical/markdown'; +import { + $convertFromMarkdownString as $convertFromMdastMarkdownString, + MdastGfmExtension, +} from '@lexical/mdast'; import { $createHeadingNode, $createQuoteNode, @@ -64,6 +69,7 @@ import { $setState, COMPOSITION_END_TAG, type CreateEditorArgs, + defineExtension, type ElementNode, KEY_ENTER_COMMAND, type LexicalEditor, @@ -5281,6 +5287,61 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ]); }); + // micromark, which @lexical/mdast imports with, reads a character outside + // the Basic Multilingual Plane as a letter, so export writes references + // that make the markers open and close for it too. + it.each<[runs: [string, number][], markdown: string]>([ + [ + [ + ['\u{1f600}', 0], + ['\u201cb\u201d', TEXT_TYPE_TO_FORMAT.bold], + ], + '😀**\u201cb\u201d**', + ], + [ + [ + ['a', TEXT_TYPE_TO_FORMAT.bold], + ['\u{1f600}', TEXT_TYPE_TO_FORMAT.italic], + ], + '**a***😀*', + ], + ])('exports %j so that both importers read it back', (runs, markdown) => { + const $runs = () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.getFormat()]); + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + ...runs.map(([text, format]) => + $createTextNode(text).setFormat(format), + ), + ), + ), + {discrete: true}, + ); + expect( + editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), + ).toBe(markdown); + editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS), { + discrete: true, + }); + expect(editor.read('latest', $runs)).toEqual(runs); + const mdastEditor = buildEditorFromExtensions( + defineExtension({ + dependencies: [MdastGfmExtension], + name: '[root]', + }), + ); + onTestFinished(() => mdastEditor.dispose()); + mdastEditor.update(() => $convertFromMdastMarkdownString(markdown), { + discrete: true, + }); + expect(mdastEditor.read('latest', $runs)).toEqual(runs); + }); + it('writes references around an underscore italic between letters', () => { const transformers = TRANSFORMERS.filter( transformer => transformer !== ITALIC_STAR, diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index 8b9e1b9c3b9..e7b00bfebdb 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -147,6 +147,58 @@ export function isPunctuationOrSpace(character: string): boolean { return classify(character) !== OTHER_CLASS; } +// Readers built on micromark, such as `@lexical/mdast`, read a character +// outside the Basic Multilingual Plane one UTF-16 unit at a time, so as a +// letter, and count JavaScript's whitespace, U+FEFF among it, as whitespace. +// Export has to open and close for those readers as well. +const MICROMARK_ONLY_WHITESPACE = /[\v\u2028\u2029\ufeff]/; +function asMicromarkReads(character: string | undefined): string | undefined { + return character === undefined + ? character + : character.length > 1 + ? 'a' + : MICROMARK_ONLY_WHITESPACE.test(character) + ? ' ' + : character; +} + +/** {@link isFlankedBy}, both for CommonMark and for micromark. */ +export function isPortablyFlankedBy( + primary: string | undefined, + secondary: string | undefined, +): boolean { + return ( + isFlankedBy(primary, secondary) && + isFlankedBy(asMicromarkReads(primary), asMicromarkReads(secondary)) + ); +} + +/** {@link getEmphasisSides}, both for CommonMark and for micromark. */ +export function getPortableEmphasisSides( + marker: string, + before: string | undefined, + after: string | undefined, +): {canOpen: boolean; canClose: boolean} { + const sides = getEmphasisSides(marker, before, after); + const micromark = getEmphasisSides( + marker, + asMicromarkReads(before), + asMicromarkReads(after), + ); + return { + canClose: sides.canClose && micromark.canClose, + canOpen: sides.canOpen && micromark.canOpen, + }; +} + +/** Whether CommonMark or micromark reads `character` as a letter. */ +export function isLetterForSomeReader(character: string): boolean { + return ( + !isPunctuationOrSpace(character) || + !isPunctuationOrSpace(asMicromarkReads(character)!) + ); +} + function isHighSurrogate(code: number): boolean { return code >= 0xd800 && code <= 0xdbff; } From 99522b74aaafcc08f714afdb8447c8348e011da8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 23:20:01 +0000 Subject: [PATCH 44/70] [lexical-markdown] Bug Fix: Start a link at the `[` its label really opens with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The link importer matched from the first `[` it found, so in `Use [this](u) or the [`--flag` from [docs](u)` the second match ran from the stray `[` across the code span to `[docs](u)`. The label's real opener was found later, and the text before it was kept as literal source, so the code span came back as plain text with backticks. The branch applies the first link before the code span, which is how it reached this fragment where main did not. Link matching now starts again from the label's real opener, so the text before it is ordinary Markdown that the importer reads: `[`, the code span, then the link, as CommonMark readers show it. The `MarkdownEscaping` test that kept `` [`\`[x](u) `` as literal prefix text now expects the code span that CommonMark reads there. ## Test plan ### Before ``` $ npx vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "stray" × keeps a code span after a stray `[` before a link in "Use [this](u) or the [`--flag` from [docs](u)" × keeps a code span after a stray `[` before a link in "[a](b) [`x`[c](d)" Tests 2 failed | 739 skipped (741) ``` ### After ``` $ npx vitest run packages/lexical-markdown --project unit Test Files 9 passed (9) Tests 917 passed (917) ``` Co-Authored-By: Claude Opus 5.5 --- .../src/MarkdownTransformers.ts | 2 +- .../__tests__/unit/LexicalMarkdown.test.ts | 42 +++++++++++++++++++ .../__tests__/unit/MarkdownEscaping.test.ts | 12 +++--- .../src/importTextMatchTransformer.ts | 15 +++++-- 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownTransformers.ts b/packages/lexical-markdown/src/MarkdownTransformers.ts index ccc5becd5fa..6fc439e0a2e 100644 --- a/packages/lexical-markdown/src/MarkdownTransformers.ts +++ b/packages/lexical-markdown/src/MarkdownTransformers.ts @@ -1201,7 +1201,7 @@ function escapeLinkPart(value: string, syntax: RegExp): string { // Mask escaped/code brackets without changing source offsets, then find an inner // opener after a literal prefix. Return -1 for balanced labels or null when // unmatched closers leave no recoverable opener. -function getLinkLabelOpener(text: string): number | null { +export function getLinkLabelOpener(text: string): number | null { const codeSpans = scanCodeSpans(text); let codeIndex = 0; const brackets = text.replace(/\\.|[[\]]/g, (part, index: number) => { diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 5251ac60356..56ce23a7adb 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5438,6 +5438,48 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it.each<[string, [string, boolean, boolean][]]>([ + [ + 'Use [this](u) or the [`--flag` from [docs](u)', + [ + ['Use ', false, false], + ['this', false, true], + [' or the [', false, false], + ['--flag', true, false], + [' from ', false, false], + ['docs', false, true], + ], + ], + [ + '[a](b) [`x`[c](d)', + [ + ['a', false, true], + [' [', false, false], + ['x', true, false], + ['c', false, true], + ], + ], + ])( + 'keeps a code span after a stray `[` before a link in %j', + (markdown, nodes) => { + const editor = createTestEditor(); + editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS), { + discrete: true, + }); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [ + node.getTextContent(), + node.hasFormat('code'), + $isLinkNode(node.getParent()), + ]), + ), + ).toEqual(nodes); + }, + ); + it('applies a link before a later link that holds a code span', () => { const editor = createTestEditor(); editor.update( diff --git a/packages/lexical-markdown/src/__tests__/unit/MarkdownEscaping.test.ts b/packages/lexical-markdown/src/__tests__/unit/MarkdownEscaping.test.ts index a808b41f064..97a968c1e37 100644 --- a/packages/lexical-markdown/src/__tests__/unit/MarkdownEscaping.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/MarkdownEscaping.test.ts @@ -631,18 +631,20 @@ describe('literal Markdown text', () => { }, ); - it('preserves existing literal prefix text before an unbalanced link', () => { + it('reads the text before an unbalanced link as ordinary Markdown', () => { using editor = buildEditorFromExtensions([MarkdownTestExtension]); - const prefix = '[`\\`'; - editor.update(() => $convertFromMarkdownString(prefix + '[x](u)'), { + // CommonMark reads `[` as literal text and `` `\` `` as a code span here. + editor.update(() => $convertFromMarkdownString('[`\\`[x](u)'), { discrete: true, }); editor.read(() => { const paragraph = $getRoot().getFirstChildOrThrow(); assert($isElementNode(paragraph)); - const [text, link] = paragraph.getChildren(); + const [text, code, link] = paragraph.getChildren(); assert($isTextNode(text)); - expect(text.getTextContent()).toBe(prefix); + expect(text.getTextContent()).toBe('['); + assert($isTextNode(code) && code.hasFormat('code')); + expect(code.getTextContent()).toBe('\\'); assert($isLinkNode(link)); expect(link.getURL()).toBe('u'); expect(link.getTextContent()).toBe('x'); diff --git a/packages/lexical-markdown/src/importTextMatchTransformer.ts b/packages/lexical-markdown/src/importTextMatchTransformer.ts index 858309aff1e..f2425fff9c5 100644 --- a/packages/lexical-markdown/src/importTextMatchTransformer.ts +++ b/packages/lexical-markdown/src/importTextMatchTransformer.ts @@ -8,7 +8,7 @@ import type {TextMatchTransformer} from './MarkdownTransformers'; import type {TextNode} from 'lexical'; -import {LINK} from './MarkdownTransformers'; +import {getLinkLabelOpener, LINK} from './MarkdownTransformers'; import {isEscaped} from './utils'; function matchLink(text: string, regExp: RegExp): RegExpExecArray | null { @@ -18,8 +18,17 @@ function matchLink(text: string, regExp: RegExp): RegExpExecArray | null { ); let match; while ((match = search.exec(text)) !== null) { - if (!isEscaped(text, match.index)) return match; - search.lastIndex = match.index + 1; + // A `[` that the label holds outside a code span is the link's real + // opener, and what comes before it is ordinary text, so search again + // from there. + const opener = getLinkLabelOpener(match[1]); + if (opener !== null && opener >= 0) { + search.lastIndex = match.index + opener + 1; + } else if (!isEscaped(text, match.index)) { + return match; + } else { + search.lastIndex = match.index + 1; + } } return null; } From 8692fa7748bb7e5565fc04c50dd3e9535d8aad85 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 23:21:33 +0000 Subject: [PATCH 45/70] [lexical-markdown] Bug Fix: Keep a bare URL's guard running through a code span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The export's bare-URL guard joined every raw piece as a space, which ended the URL at an inline code span. GFM autolinks run on through backticks, so `@lexical/mdast` reads `see https://a.com/`v2`s` as one autolink, and the branch wrote `s` inside it: `see https://a.com/`v2`s**(beta)**`, whose link text and URL then held a literal `s`. A code span is now joined as its own text, so the guard covers it and no reference is written: `see https://a.com/`v2`s**(beta)**`, as main exports. ## Test plan ### Before ``` $ npx vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "code span inside a bare URL" × writes no reference after a code span inside a bare URL Received: "see https://a.com/`v2`s**(beta)**" ``` ### After ``` $ npx vitest run packages/lexical-markdown --project unit Test Files 9 passed (9) Tests 918 passed (918) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 5 +++-- .../__tests__/unit/LexicalMarkdown.test.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 7fefc61c3e2..85a33f3e073 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -818,12 +818,13 @@ function joinPieces({kinds, values}: MarkdownPieces): string { * is not bare text, so it ends a run. */ function findLinkEdges(kinds: PieceKind[], values: string[]): Uint8Array { - // Raw output ends a run, so it is joined as a single space. + // Raw output ends a run, so it is joined as a single space, except a code + // span: GFM autolinks run on through backticks, as in `https://a.com/`v`s`. let text = ''; const starts = new Uint32Array(values.length + 1); for (let i = 0; i < values.length; i++) { starts[i] = text.length; - text += kinds[i] === RAW ? ' ' : values[i]; + text += kinds[i] === RAW && values[i][0] !== '`' ? ' ' : values[i]; } starts[values.length] = text.length; const inLink = new Uint8Array(text.length); diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 56ce23a7adb..f0fad33ab2e 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5378,6 +5378,25 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ]); }); + it('writes no reference after a code span inside a bare URL', () => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('see https://a.com/'), + $createTextNode('v2').toggleFormat('code'), + $createTextNode('s'), + $createTextNode('(beta)').toggleFormat('bold'), + ), + ), + {discrete: true}, + ); + expect( + editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), + ).toBe('see https://a.com/`v2`s**(beta)**'); + }); + it("guards a letter right after an inline link's destination", () => { const editor = createTestEditor(); editor.update( From 520f9105de3af82a5851d08986ef4c1d07aaa1f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 23:27:03 +0000 Subject: [PATCH 46/70] [lexical-markdown] Bug Fix: Import the text after each match in a loop instead of by recursion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The importer recursed once per match into the text after it, and the branch's helper for the text around a match added a second frame to every level, so a flat paragraph such as `'*a* '.repeat(6000)` overflowed the stack, where main overflowed only at 8,000. The text after a match is now imported in a loop, so the depth no longer grows with the number of matches in a paragraph, and `'*a* '.repeat(8000)` imports. The text before a match and inside it is still imported first, by recursion. Tests pin the #9443 document's export and round trip, and check that `**注意:**请先登录` style export reads back in both `@lexical/markdown` and `@lexical/mdast`. The changeset names the link-before-code-span change and the full-width colon case. ## Test plan ### Before `$convertFromMarkdownString('*a* '.repeat(6000), TRANSFORMERS)` threw `RangeError: Maximum call stack size exceeded`. ### After ``` '*a* '.repeat(8000): ok 17599ms 16000 text nodes $ npx vitest run packages/lexical-markdown packages/lexical-mdast scripts/__tests__/unit/treeShakingSource.test.ts Test Files 18 passed (18) Tests 1583 passed (1583) ``` Co-Authored-By: Claude Opus 5.5 --- .../__tests__/unit/LexicalMarkdown.test.ts | 38 ++++++++++ .../src/importTextTransformers.ts | 75 +++++++++++++------ 2 files changed, 92 insertions(+), 21 deletions(-) diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index f0fad33ab2e..c02705ef6e0 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5305,6 +5305,13 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ], '**a***😀*', ], + [ + [ + ['\u6ce8\u610f\uff1a', TEXT_TYPE_TO_FORMAT.bold], + ['\u8bf7\u5148\u767b\u5f55', 0], + ], + '**\u6ce8\u610f\uff1a**请\u5148\u767b\u5f55', + ], ])('exports %j so that both importers read it back', (runs, markdown) => { const $runs = () => $getRoot() @@ -5342,6 +5349,37 @@ describe('emphasis next to Unicode punctuation and symbols', () => { expect(mdastEditor.read('latest', $runs)).toEqual(runs); }); + it('exports bold italic text followed by italic text so that it re-imports (#9443)', () => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('a').toggleFormat('bold').toggleFormat('italic'), + $createTextNode(': b').toggleFormat('italic'), + ), + ), + {discrete: true}, + ); + const markdown = editor.read('latest', () => + $convertToMarkdownString(TRANSFORMERS), + ); + expect(markdown).toBe('***a****: b*'); + editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS), { + discrete: true, + }); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.getFormat()]), + ), + ).toEqual([ + ['a', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.italic], + [': b', TEXT_TYPE_TO_FORMAT.italic], + ]); + }); + it('writes references around an underscore italic between letters', () => { const transformers = TRANSFORMERS.filter( transformer => transformer !== ITALIC_STAR, diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index acf8b740f93..00f6fa360be 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -78,6 +78,38 @@ export function importTextTransformers( before?: string, after?: string, ) { + // The text after each match is imported in this loop rather than by + // recursion, so that a long paragraph does not nest a call per match. + let rest: TextRest | null = [textNode, before, after]; + while (rest !== null) { + rest = importOutermost( + rest[0], + textFormatTransformersIndex, + textMatchTransformers, + rest[1], + rest[2], + ); + } +} + +type TextRest = [ + textNode: TextNode, + before: string | undefined, + after: string | undefined, +]; + +/** + * Applies the outermost transformer in `textNode`, imports the text before + * and inside its match, and returns the text after it, for the caller to + * import next. + */ +function importOutermost( + textNode: TextNode, + textFormatTransformersIndex: TextFormatTransformersIndex, + textMatchTransformers: TextMatchTransformer[], + before: string | undefined, + after: string | undefined, +): TextRest | null { const text = textNode.getTextContent(); let foundTextFormat = findOutermostTextFormatTransformer( textNode, @@ -134,23 +166,15 @@ export function importTextTransformers( } } - // Imports the text left on either side of a match from `startIndex` to - // `endIndex`, each with what lies beyond the run cut at its edge. + // Imports the text before a match from `startIndex` to `endIndex`, and + // returns the text after it, each with what lies beyond the run cut at its + // edge. const importAround = ( nodeBefore: LexicalNode | undefined, nodeAfter: LexicalNode | undefined, startIndex: number, endIndex: number, - ) => { - if (canContainTransformableMarkdown(nodeAfter)) { - importTextTransformers( - nodeAfter, - textFormatTransformersIndex, - textMatchTransformers, - characterBeyondRun(text, endIndex, -1, before), - after, - ); - } + ): TextRest | null => { if (canContainTransformableMarkdown(nodeBefore)) { importTextTransformers( nodeBefore, @@ -160,6 +184,9 @@ export function importTextTransformers( characterBeyondRun(text, startIndex, 1, after), ); } + return canContainTransformableMarkdown(nodeAfter) + ? [nodeAfter, characterBeyondRun(text, endIndex, -1, before), after] + : null; }; if (foundTextFormat) { @@ -172,7 +199,12 @@ export function importTextTransformers( ); const {startIndex, endIndex} = foundTextFormat; const tagLength = foundTextFormat.match[1].length; - importAround(result.nodeBefore, result.nodeAfter, startIndex, endIndex); + const rest = importAround( + result.nodeBefore, + result.nodeAfter, + startIndex, + endIndex, + ); if (canContainTransformableMarkdown(result.transformedNode)) { // A code span's fence is not its tag's length, and its content is not // read for emphasis against what lies outside it. @@ -189,6 +221,7 @@ export function importTextTransformers( : characterBeyondRun(text, endIndex - tagLength, 1, after), ); } + return rest; } else if (foundTextMatch) { const result = importFoundTextMatchTransformer( textNode, @@ -198,9 +231,9 @@ export function importTextTransformers( foundTextMatch.match, ); if (!result) { - return; + return null; } - importAround( + const rest = importAround( result.nodeBefore, result.nodeAfter, foundTextMatch.startIndex, @@ -224,12 +257,12 @@ export function importTextTransformers( unescapeText(result.matchedNode.getTextContent()), ); } + return rest; } - // Handle escape characters. Only a node that no transformer matched is - // unescaped here: retained non-code results above have already had their - // escapes read, and code spans must keep their backslashes. - if (!foundTextFormat && !foundTextMatch) { - textNode.setTextContent(unescapeText(text)); - } + // Handle escape characters. Only a node that no transformer matched gets + // here: retained non-code results above have already had their escapes + // read, and code spans must keep their backslashes. + textNode.setTextContent(unescapeText(text)); + return null; } From 21af6a999187771e474e42d84d4f53bd516f42eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 23:27:24 +0000 Subject: [PATCH 47/70] [lexical-markdown] Documentation Update: Name the link and code span changes and the full-width colon case in the changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The changeset did not say that a link ending before a code span is now applied ahead of it, or that a link starts at the `[` its label really opens with. Both change what some Markdown imports as, so they are described now, and the Verify paragraph names `**注意:**请先登录` among the shapes that now import with literal asterisks. The previous commit's message said this was done; it was not, because the edit failed. ## Test plan ### After ``` $ pnpm run check-changesets Changeset check passed: 57 pending changeset(s) valid. ``` Co-Authored-By: Claude Opus 5.5 --- .changeset/markdown-unicode-punctuation-flanking.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 58a1de4c0e8..943617993ac 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -4,6 +4,6 @@ [fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_`, `~~` and `==` markers open or close formatting -Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match no longer takes part of any code span in the text, not only the first. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. +Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match that ends before a code span is applied ahead of it, so `[a](b) and [`c`](d)` keeps both links, and one no longer takes part of any code span in the text, not only the first. A link starts at the `[` its label really opens with, so text before a stray `[` is read as Markdown: `[`x`[c](d)` has a code span. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. -Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz` or `**你好。**世界`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Those references are chosen so that micromark-based readers such as `@lexical/mdast`, which read an emoji or another character outside the Basic Multilingual Plane as a letter, format it the same way, so plain `😀` then bold `“b”` exports as `😀**“b”**`. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it, except inside a bare URL. No reference is written inside a bare URL, `www.` host, email or mention, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `ping @user**\_x**`. +Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz`, `**你好。**世界` or `**注意:**请先登录`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Those references are chosen so that micromark-based readers such as `@lexical/mdast`, which read an emoji or another character outside the Basic Multilingual Plane as a letter, format it the same way, so plain `😀` then bold `“b”` exports as `😀**“b”**`. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it, except inside a bare URL. No reference is written inside a bare URL, `www.` host, email or mention, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `ping @user**\_x**`. From 253eff303c923998067f0b4a42199764523430ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 00:00:49 +0000 Subject: [PATCH 48/70] [lexical-markdown] Bug Fix: Read runs beside other markers as micromark does, and start bare URLs at their scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Three regressions against main that the review loop found on this branch: - A delimiter run whose neighbour is another `*`, `_` or `~` marker is read the way micromark reads it again, so `**bold***~~gone~~*` and `**a***_b_*` import as on main and in @lexical/mdast. - The bare-URL guard on export treated the whole run before a URL as part of it, so `foo**“bar”**(www.x.com)` got no reference and lost its bold on import. The URL now starts at its scheme, `www.` or `mailto:`. - The URL scan now sees code-span content as letters, so a URL may run on through a code span, but a URL inside a code span does not cover the text before it. A link whose label holds a stray `[` now starts its match at that `[` directly, rather than searching again, so the inner match keeps the same shape for any label. ## Test plan ### Before `pnpm vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts --project unit -t "writes a reference outside|another marker"` with the sources of d63f19f: ``` × writes a reference outside a URL that starts later in the run 14ms × reads a run beside another marker as micromark does in "**bold***~~gone~~*" 8ms × reads a run beside another marker as micromark does in "**a***_b_*" 2ms Tests 3 failed | 1 passed | 744 skipped (748) ``` ### After ``` Tests 4 passed | 744 skipped (748) ``` `pnpm vitest run packages/lexical-markdown --project unit`: ``` Test Files 9 passed (9) Tests 924 passed (924) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 41 +++++++++-- .../__tests__/unit/LexicalMarkdown.test.ts | 72 +++++++++++++++++++ .../src/importTextFormatTransformer.ts | 1 + .../src/importTextMatchTransformer.ts | 18 +++-- .../src/importTextTransformers.ts | 8 +-- packages/lexical-markdown/src/utils.ts | 21 ++++-- 6 files changed, 141 insertions(+), 20 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 85a33f3e073..99e1ec210bc 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -24,6 +24,7 @@ import { type TextNode, } from 'lexical'; +import {scanCodeSpans} from './importTextFormatTransformer'; import { type ElementTransformer, getCodeSpanDelimiter, @@ -817,14 +818,33 @@ function joinPieces({kinds, values}: MarkdownPieces): string { * a bounded number of times. Raw output, such as an inline link's destination, * is not bare text, so it ends a run. */ +const SCHEME_CHARACTER = /[a-z\d+.-]/i; + +/** + * Raw output as the bare-URL scan sees it: spaces, except that the content + * of a code span is letters (and its whitespace stays whitespace). + */ +function maskRaw(value: string): string { + let masked = ''; + let previous = 0; + for (const {startIndex, endIndex} of scanCodeSpans(value)) { + masked += + ' '.repeat(startIndex - previous) + + value.slice(startIndex, endIndex).replace(/\S/g, 'x'); + previous = endIndex; + } + return masked + ' '.repeat(value.length - previous); +} + function findLinkEdges(kinds: PieceKind[], values: string[]): Uint8Array { - // Raw output ends a run, so it is joined as a single space, except a code - // span: GFM autolinks run on through backticks, as in `https://a.com/`v`s`. + // Raw output ends a run, so it is joined as spaces, except a code span: + // GFM autolinks run on through backticks, as in `https://a.com/`v`s`, so + // its characters join as letters, which cannot start a URL themselves. let text = ''; const starts = new Uint32Array(values.length + 1); for (let i = 0; i < values.length; i++) { starts[i] = text.length; - text += kinds[i] === RAW && values[i][0] !== '`' ? ' ' : values[i]; + text += kinds[i] === RAW ? maskRaw(values[i]) : values[i]; } starts[values.length] = text.length; const inLink = new Uint8Array(text.length); @@ -845,8 +865,19 @@ function findLinkEdges(kinds: PieceKind[], values: string[]): Uint8Array { start++; continue; } - if (LINK_URL.test(text.slice(start, end).replace(/\\/g, ''))) { - inLink.fill(1, start, end); + const url = LINK_URL.exec(text.slice(start, end)); + if (url !== null) { + // The URL starts at its scheme, `www.` or `mailto:`, and what comes + // before it in the run is ordinary text. + let urlStart = start + url.index; + if (url[0] === '://') { + while (urlStart > start && SCHEME_CHARACTER.test(text[urlStart - 1])) { + urlStart--; + } + } else if (!/^[wmx]/i.test(url[0])) { + urlStart++; + } + inLink.fill(1, urlStart, end); } else { for (let from = start; from < end; ) { const to = runEnd(from, isAddressCharacter, end); diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index c02705ef6e0..99e28557742 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5416,6 +5416,78 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ]); }); + it.each<[string, [string, number][], string]>([ + [ + 'a URL inside a code span', + [ + ['a', 0], + ['\u201cb\u201d', TEXT_TYPE_TO_FORMAT.bold], + ['http://y', TEXT_TYPE_TO_FORMAT.code], + ], + 'a**\u201cb\u201d**`http://y`', + ], + [ + 'a URL that starts later in the run', + [ + ['foo', 0], + ['\u201cbar\u201d', TEXT_TYPE_TO_FORMAT.bold], + ['(www.x.com)', 0], + ], + 'foo**\u201cbar\u201d**(www.x.com)', + ], + ])('writes a reference outside %s', (_name, runs, markdown) => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + ...runs.map(([text, format]) => + $createTextNode(text).setFormat(format), + ), + ), + ), + {discrete: true}, + ); + expect( + editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), + ).toBe(markdown); + }); + + it.each<[string, [string, number][]]>([ + [ + '**bold***~~gone~~*', + [ + ['bold', TEXT_TYPE_TO_FORMAT.bold], + [ + 'gone', + TEXT_TYPE_TO_FORMAT.italic | TEXT_TYPE_TO_FORMAT.strikethrough, + ], + ], + ], + [ + '**a***_b_*', + [ + ['a', TEXT_TYPE_TO_FORMAT.bold], + ['b', TEXT_TYPE_TO_FORMAT.italic], + ], + ], + ])( + 'reads a run beside another marker as micromark does in %j', + (markdown, runs) => { + const editor = createTestEditor(); + editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS), { + discrete: true, + }); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.getFormat()]), + ), + ).toEqual(runs); + }, + ); + it('writes no reference after a code span inside a bare URL', () => { const editor = createTestEditor(); editor.update( diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index 0ddf2ed2a3e..cbf841363bf 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -229,6 +229,7 @@ function scanDelimiters( char, i === 0 ? before : characterBefore(text, i), i + len === text.length ? after : characterAt(text, i + len), + true, ); if (canOpen || canClose) { diff --git a/packages/lexical-markdown/src/importTextMatchTransformer.ts b/packages/lexical-markdown/src/importTextMatchTransformer.ts index f2425fff9c5..2735489f0ce 100644 --- a/packages/lexical-markdown/src/importTextMatchTransformer.ts +++ b/packages/lexical-markdown/src/importTextMatchTransformer.ts @@ -19,16 +19,22 @@ function matchLink(text: string, regExp: RegExp): RegExpExecArray | null { let match; while ((match = search.exec(text)) !== null) { // A `[` that the label holds outside a code span is the link's real - // opener, and what comes before it is ordinary text, so search again - // from there. + // opener, and what comes before it is ordinary text, so the match starts + // there; its label may then be empty, as in `x[[](u)`. const opener = getLinkLabelOpener(match[1]); if (opener !== null && opener >= 0) { - search.lastIndex = match.index + opener + 1; - } else if (!isEscaped(text, match.index)) { + const cut = opener + 1; + const inner = [...match] as RegExpExecArray; + inner[0] = match[0].slice(cut); + inner[1] = match[1].slice(cut); + inner.index = match.index + cut; + inner.input = text; + return inner; + } + if (!isEscaped(text, match.index)) { return match; - } else { - search.lastIndex = match.index + 1; } + search.lastIndex = match.index + 1; } return null; } diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 00f6fa360be..d3782a5eafc 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -64,10 +64,10 @@ function characterBeyondRun( } /** - * Handles applying both text format and text match transformers. - * It finds the outermost text format or text match and applies it, - * then recursively calls itself to apply the next outermost transformer, - * until there are no more transformers to apply. + * Handles applying both text format and text match transformers. It finds + * the outermost text format or text match and applies it, imports the text + * before the match and then the text inside it the same way, and then goes + * on with the text after it, until there are no more transformers to apply. */ export function importTextTransformers( textNode: TextNode, diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index e7b00bfebdb..a4ea15e2d08 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -120,25 +120,36 @@ function isFlankedByClass(primary: number, secondary: number): boolean { ); } +// micromark, which `@lexical/mdast` imports with, also lets a delimiter run +// open before, and close after, another emphasis or strikethrough marker, so +// that `**a***~~b~~*` and `**a***_b_*` nest as written. +const ATTENTION_MARKER = /^[*_~]$/; + /** * Whether a delimiter run of `marker` between `before` and `after` can open * and close emphasis; `_` opens or closes inside a word only beside - * punctuation. + * punctuation. With `isBesideMarkerFlanking`, a run beside another marker + * flanks on that side, as micromark reads it. * https://spec.commonmark.org/0.31.2/#can-open-emphasis */ export function getEmphasisSides( marker: string, before: string | undefined, after: string | undefined, + isBesideMarkerFlanking = false, ): {canOpen: boolean; canClose: boolean} { const beforeClass = classify(before); const afterClass = classify(after); - const isLeft = isFlankedByClass(afterClass, beforeClass); - const isRight = isFlankedByClass(beforeClass, afterClass); + const isLeft = + isFlankedByClass(afterClass, beforeClass) || + (isBesideMarkerFlanking && ATTENTION_MARKER.test(after || '')); + const isRight = + isFlankedByClass(beforeClass, afterClass) || + (isBesideMarkerFlanking && ATTENTION_MARKER.test(before || '')); return marker === '_' ? { - canClose: isRight && (!isLeft || afterClass === PUNCTUATION_CLASS), - canOpen: isLeft && (!isRight || beforeClass === PUNCTUATION_CLASS), + canClose: isRight && (!isLeft || afterClass !== OTHER_CLASS), + canOpen: isLeft && (!isRight || beforeClass !== OTHER_CLASS), } : {canClose: isRight, canOpen: isLeft}; } From 90771626331d0d3193cbeb83f158b688f9a6c453 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 00:40:16 +0000 Subject: [PATCH 49/70] [lexical-markdown] Bug Fix: Read runs beside other markers as CommonMark does, narrow the bare-URL guard, and scan each line's formats once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Fixes from the third review round of this branch against main: - Import reads a delimiter run beside another `*`, `_` or `~` marker as CommonMark does again. The micromark rule added in the previous commit also applied to `~~` and `==`, so `~~a*~~b` and `==a*==b` imported as formatted, and `==*a*==b==`, which export writes for plain `==`, italic `a` and highlighted `b`, no longer round-tripped. - The bare-URL guard on export only counts what GFM autolinks: `http://`, `https://` and `ftp://` after anything but a letter, `www.`, `mailto:` and `xmpp:`, and an email with a dot in its domain. Mentions such as `@john`, `foo@bar` and `a://b` get their reference again, so `foo@bar` then bold `“q”` round-trips. - With `shouldPreserveNewLines`, backslashes right before a character that export escapes are escaped too, so text `a\\_b_` no longer exports as `a\\\_b\_`, where its first backslash cancelled the escape. - One helper finds where a delimiter run ends, for the importer's scan and for the character beyond a cut run. - Import scans a line's code spans and outermost emphasis once and reads them again for the text left after each match, rather than scanning that text afresh, as long as no match there reaches into what came before. A long line that mixes emphasis, links and code spans imports about 2.5 times faster than on main, where this branch had been up to 2 times slower. What remains quadratic is core `splitText`'s walk over the paragraph's siblings, as on main. - The doc comment for `findLinkEdges` is back above it. ## Test plan ### Before `pnpm vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts --project unit -t "another marker|autolink|escaping an escape"` with the sources of 253eff3: ``` × keeps raw backslashes from escaping an escape when preserving newlines 26ms × writes a reference after "@john", which readers do not autolink 2ms × writes a reference after "foo@bar", which readers do not autolink 1ms × writes a reference after "a://b", which readers do not autolink 1ms × writes a reference after "xhttp://b", which readers do not autolink 1ms × reads a run beside another marker as CommonMark does in "**bold***~~gone~~*" 12ms × reads a run beside another marker as CommonMark does in "**a***_b_*" 2ms × reads a run beside another marker as CommonMark does in "**~~**a" 2ms × reads a run beside another marker as CommonMark does in "~~a*~~b" 2ms Tests 9 failed | 15 passed | 730 skipped (754) ``` Importing `'a **b** [c](d) \`e\` _f_ '.repeat(n)` (milliseconds): ``` n=400 n=1600 main 421 8955 253eff3 951 11634 ``` ### After ``` Tests 24 passed | 730 skipped (754) ``` ``` n=400 n=1600 this commit 218 3714 ``` The import of 30,000 random lines over `***___~~~==``[[]]()\ab .,é“”😀:/` and another 30,000 over `**__~a b a.*`[](u)x` gives the same editor state with and without the single scan. `pnpm vitest run packages/lexical-markdown scripts/__tests__/unit/treeShakingSource.test.ts`: ``` Test Files 11 passed (11) Tests 1327 passed (1327) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 2 +- packages/lexical-markdown/README.md | 13 +- .../lexical-markdown/src/MarkdownExport.ts | 54 ++-- .../__tests__/unit/LexicalMarkdown.test.ts | 77 +++++- .../src/importTextFormatTransformer.ts | 242 ++++++++++++------ .../src/importTextTransformers.ts | 77 +++--- packages/lexical-markdown/src/utils.ts | 44 ++-- 7 files changed, 335 insertions(+), 174 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 943617993ac..0efe06ed03d 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -6,4 +6,4 @@ Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match that ends before a code span is applied ahead of it, so `[a](b) and [`c`](d)` keeps both links, and one no longer takes part of any code span in the text, not only the first. A link starts at the `[` its label really opens with, so text before a stray `[` is read as Markdown: `[`x`[c](d)` has a code span. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. -Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz`, `**你好。**世界` or `**注意:**请先登录`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Those references are chosen so that micromark-based readers such as `@lexical/mdast`, which read an emoji or another character outside the Basic Multilingual Plane as a letter, format it the same way, so plain `😀` then bold `“b”` exports as `😀**“b”**`. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it, except inside a bare URL. No reference is written inside a bare URL, `www.` host, email or mention, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `ping @user**\_x**`. +Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz`, `**你好。**世界` or `**注意:**请先登录`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Those references are chosen so that micromark-based readers such as `@lexical/mdast`, which read an emoji or another character outside the Basic Multilingual Plane as a letter, format it the same way, so plain `😀` then bold `“b”` exports as `😀**“b”**`. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it, except inside a bare URL. With `shouldPreserveNewLines`, backslashes right before a `*`, `_`, `` ` `` or `~` of the text are escaped too, so that they no longer cancel that character's escape. No reference is written inside a bare URL, `www.` host or email, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `see https://x.com/a**\_x**`. diff --git a/packages/lexical-markdown/README.md b/packages/lexical-markdown/README.md index aefd1488c4f..d46e078604d 100644 --- a/packages/lexical-markdown/README.md +++ b/packages/lexical-markdown/README.md @@ -98,12 +98,13 @@ brackets and guarded `<`/`&` use numeric references so escaping does not change adjacent emphasis flanking. Import follows CommonMark's Unicode punctuation and whitespace when deciding -whether `*`, `_`, `~~` and `==` open or close formatting. When formatted text starts -or ends with punctuation right beside a letter, export writes that letter as a -numeric reference (`foo**“bar”**`) so that the markers still open and -close, for micromark-based readers such as `@lexical/mdast` too, also with `shouldPreserveNewLines`, where a raw backslash right before -such a marker or reference goes out as `\` outside a bare URL. No reference is written inside -a bare URL, `www.` host, email or mention. +whether `*`, `_`, `~~` and `==` open or close formatting. When formatted text +starts or ends with punctuation right beside a letter, export writes that +letter as a numeric reference (`foo**“bar”**`) so that the markers still +open and close, for micromark-based readers such as `@lexical/mdast` too. This +holds with `shouldPreserveNewLines` as well, where a raw backslash right before +such a marker or reference goes out as `\` outside a bare URL. No +reference is written inside a bare URL, `www.` host or email. There're three types of transformers: diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 99e1ec210bc..902b99c4bec 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -554,12 +554,16 @@ function $exportTextFormat( let output = textContent; if (!isCode) { - // Preserve literal backslashes when preserving source newlines. + // Preserve literal backslashes when preserving source newlines, except + // right before a character escaped here, where they would escape the + // escape instead. // Default mode guards numeric references; named references keep legacy output. // Numeric escapes avoid changing emphasis flanking beside formatting. // Guard ambiguous '<' and '&' at node ends for constructs split across nodes. output = shouldPreserveNewLines - ? output.replace(/([*_`~])/g, '\\$1') + ? output.replace(/\\+(?=[*_`~])|[*_`~]/g, escaped => + escaped[0] === '\\' ? escaped + escaped : `\\${escaped}`, + ) : output.replace(/([[\]*_`~\\]|<(?=\S|$)|&(?=#|$))/g, character => /[*_`~\\]/.test(character) ? `\\${character}` @@ -719,24 +723,25 @@ function isMarker(kind: PieceKind | undefined): boolean { return kind === OPENER || kind === CLOSER; } -// Characters a URL, email or mention can hold: GFM ends an autolink at +// Characters a URL or email can hold: GFM ends an autolink at // whitespace or `<`, and a URL's host and path may hold any other character. // This takes in the backslash that escapes a marker too. const LINK_END = /[\s\p{Cc}<]/u; function isLinkCharacter(character: string): boolean { return !LINK_END.test(character); } -// A URL or `www.` host in a run of link characters. -// GFM starts one after `_` too. +// A URL or `www.` host in a run of link characters: GFM links `http://`, +// `https://` (and cmark-gfm `ftp://`) after anything but a letter, and starts +// `www.` after `_` too. const LINK_URL = - /:\/\/|(?:^|[^a-z\d.-])www\.|(?:^|[^a-z\d.+-])(?:mailto|xmpp):/i; -// Characters an email or mention can hold (word characters, `.+@-`, and the -// backslash that escapes one), and what makes one. + /(?:^|[^a-z])(?:https?|ftp):\/\/|(?:^|[^a-z\d.-])www\.|(?:^|[^a-z\d.+-])(?:mailto|xmpp):/i; +// Characters an email can hold (word characters, `.+@-`, and the backslash +// that escapes one), and what makes one: GFM needs a dot in its domain. const ADDRESS_CHARACTER = /[\w.+@\\-]/; function isAddressCharacter(character: string): boolean { return ADDRESS_CHARACTER.test(character); } -const ADDRESS = /@\w/; +const ADDRESS = /[\w.+-]@[\w-]+\.[\w-]/; // Characters that a reference does not stand in for everywhere: CommonMark // readers and HTML decode references to control characters, unpaired @@ -809,17 +814,6 @@ function joinPieces({kinds, values}: MarkdownPieces): string { return markdown; } -/** - * For each piece, 1 when its first character is part of a bare URL, `www.` - * host, email or mention, plus 2 when its last one is. A reference inside one - * would change what an autolinking reader links to, so the format is lost - * there instead, as before. The pieces are read as one text, since a URL can - * run on through markers and other formats, and each character is looked at - * a bounded number of times. Raw output, such as an inline link's destination, - * is not bare text, so it ends a run. - */ -const SCHEME_CHARACTER = /[a-z\d+.-]/i; - /** * Raw output as the bare-URL scan sees it: spaces, except that the content * of a code span is letters (and its whitespace stays whitespace). @@ -836,6 +830,15 @@ function maskRaw(value: string): string { return masked + ' '.repeat(value.length - previous); } +/** + * For each piece, 1 when its first character is part of a bare URL, `www.` + * host or email, plus 2 when its last one is. A reference inside one + * would change what an autolinking reader links to, so the format is lost + * there instead, as before. The pieces are read as one text, since a URL can + * run on through markers and other formats, and each character is looked at + * a bounded number of times. Raw output, such as an inline link's destination, + * is not bare text, so it ends a run. + */ function findLinkEdges(kinds: PieceKind[], values: string[]): Uint8Array { // Raw output ends a run, so it is joined as spaces, except a code span: // GFM autolinks run on through backticks, as in `https://a.com/`v`s`, so @@ -869,14 +872,7 @@ function findLinkEdges(kinds: PieceKind[], values: string[]): Uint8Array { if (url !== null) { // The URL starts at its scheme, `www.` or `mailto:`, and what comes // before it in the run is ordinary text. - let urlStart = start + url.index; - if (url[0] === '://') { - while (urlStart > start && SCHEME_CHARACTER.test(text[urlStart - 1])) { - urlStart--; - } - } else if (!/^[wmx]/i.test(url[0])) { - urlStart++; - } + const urlStart = start + url.index + (/^[a-z]/i.test(url[0]) ? 0 : 1); inLink.fill(1, urlStart, end); } else { for (let from = start; from < end; ) { @@ -1016,7 +1012,7 @@ function decideReferences( ? ';' : lastChar(index); // Whether the last (or first) character of the piece at `index` is part of - // a bare URL, `www.` host, email or mention, worked out for every piece the + // a bare URL, `www.` host, or email, worked out for every piece the // first time it is asked. let linkEdges: Uint8Array | undefined; const isLinkish = (index: number, atEnd: boolean): boolean => { diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 99e28557742..297fe830c76 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5111,6 +5111,38 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it('keeps raw backslashes from escaping an escape when preserving newlines', () => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('a\\\\_b_'), + $createTextNode('c').toggleFormat('bold'), + ), + ), + {discrete: true}, + ); + const markdown = editor.read('latest', () => + $convertToMarkdownString(TRANSFORMERS, undefined, true), + ); + expect(markdown).toBe('a\\\\\\\\\\_b\\_**c**'); + editor.update( + () => $convertFromMarkdownString(markdown, TRANSFORMERS, undefined, true), + {discrete: true}, + ); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.hasFormat('bold')]), + ), + ).toEqual([ + ['a\\\\_b_', false], + ['c', true], + ]); + }); + it('writes no reference that would break a run beside raw output', () => { // Writing `a` as a reference for the italic would leave the bold's `**` // between the raw `x` and the reference's `&`, unable to open. @@ -5160,7 +5192,6 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ['me@example.com', 'me@example.com'], ['https://example.com/a', 'https://example.com/a'], ['https://example.com/a_b', 'https://example.com/a\\_b'], - ['@john_doe', '@john\\_doe'], ['xmpp:a@b.co/res', 'xmpp:a@b.co/res'], ['https://example.com/a|b', 'https://example.com/a|b'], ['x_www.example.co', 'x\\_www.example.co'], @@ -5193,6 +5224,35 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + // GFM links none of these: a mention is not Markdown, an email needs a dot + // in its domain, and a URL needs `http`, `https` or `ftp` for its scheme. + it.each([ + ['@john', '@john'], + ['foo@bar', 'foo@bar'], + ['a://b', 'a://b'], + ['xhttp://b', 'xhttp://b'], + ])( + 'writes a reference after %j, which readers do not autolink', + (text, markdown) => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode(`see ${text}`), + $createTextNode( + `${LEFT_DOUBLE_QUOTE}x${RIGHT_DOUBLE_QUOTE}`, + ).toggleFormat('bold'), + ), + ), + {discrete: true}, + ); + expect( + editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), + ).toBe(`see ${markdown}**${LEFT_DOUBLE_QUOTE}x${RIGHT_DOUBLE_QUOTE}**`); + }, + ); + it.each([ 'www.example.com', 'me@example.com', @@ -5453,26 +5513,31 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ).toBe(markdown); }); + // Not as micromark reads them: it also lets a run open before, and close + // after, another `*`, `_` or `~` marker. it.each<[string, [string, number][]]>([ [ '**bold***~~gone~~*', [ ['bold', TEXT_TYPE_TO_FORMAT.bold], - [ - 'gone', - TEXT_TYPE_TO_FORMAT.italic | TEXT_TYPE_TO_FORMAT.strikethrough, - ], + ['*', 0], + ['gone', TEXT_TYPE_TO_FORMAT.strikethrough], + ['*', 0], ], ], [ '**a***_b_*', [ ['a', TEXT_TYPE_TO_FORMAT.bold], + ['*', 0], ['b', TEXT_TYPE_TO_FORMAT.italic], + ['*', 0], ], ], + ['**~~**a', [['**~~**a', 0]]], + ['~~a*~~b', [['~~a*~~b', 0]]], ])( - 'reads a run beside another marker as micromark does in %j', + 'reads a run beside another marker as CommonMark does in %j', (markdown, runs) => { const editor = createTestEditor(); editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS), { diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index cbf841363bf..2d8b0161eb1 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -13,6 +13,7 @@ import type {TextNode} from 'lexical'; import { characterAt, characterBefore, + getDelimiterRunEdge, getEmphasisSides, isEscaped, } from './utils'; @@ -26,91 +27,170 @@ interface Delimiter { active: boolean; } -export function findOutermostTextFormatTransformer( - textNode: TextNode, - textFormatTransformersIndex: TextFormatTransformersIndex, - before?: string, - after?: string, -): { +interface FormatMatch { startIndex: number; endIndex: number; - transformer: TextFormatTransformer; - match: RegExpMatchArray; - // True when the outermost format is a code span. Code spans are atomic: their - // content is never reprocessed and they bind tighter than text-match - // transformers, so this is tagged here rather than re-derived by callers. - isCodeSpan: boolean; - // The code spans, which a text-match transformer must not take part of - // even when an emphasis match around or before them is applied first. - codeSpans: {startIndex: number; endIndex: number}[]; -} | null { - const textContent = textNode.getTextContent(); + tag: string; + content: string; +} + +/** + * The code spans and outermost emphasis of a text, found once and read again + * for what is left of the text after each match, at `offset`. A match there + * starts with the code span at `nextCodeSpan` or the emphasis at + * `nextEmphasis`. + */ +export interface FormatScan { + text: string; + codeSpans: FormatMatch[]; + emphasis: FormatMatch[]; + offset: number; + nextCodeSpan: number; + nextEmphasis: number; +} +export function scanTextFormats( + text: string, + textFormatTransformersIndex: TextFormatTransformersIndex, + before: string | undefined, + after: string | undefined, +): FormatScan { // Find code spans first. Emphasis delimiters inside inline elements (e.g., code spans) // should not be processed. Currently only code spans are handled; other inline elements // (e.g., links, raw HTML) may need similar treatment in the future. - const codeTransformer = textFormatTransformersIndex.transformersByTag['`']; - - const codeSpans = codeTransformer ? scanCodeSpans(textContent) : []; - const codeMatch = codeSpans.length > 0 ? {...codeSpans[0], tag: '`'} : null; - + const codeSpans = textFormatTransformersIndex.transformersByTag['`'] + ? scanCodeSpans(text).map(span => ({...span, tag: '`'})) + : []; const delimiters = scanDelimiters( - textContent, + text, textFormatTransformersIndex, codeSpans, before, after, ); - const emphasisMatch = - delimiters.length > 0 - ? processEmphasis(textContent, delimiters, textFormatTransformersIndex) - : null; - - let resultMatch = null; - let resultTransformer = null; - - if (codeMatch && emphasisMatch) { - // Delimiters inside the code span were excluded, so an emphasis match - // that starts first either holds the whole span or ends before it, and - // either way it is applied first. - if (emphasisMatch.startIndex <= codeMatch.startIndex) { - resultMatch = emphasisMatch; - resultTransformer = - textFormatTransformersIndex.transformersByTag[emphasisMatch.tag]; - } else { - resultMatch = codeMatch; - resultTransformer = codeTransformer; + return { + codeSpans, + emphasis: + delimiters.length > 0 + ? processEmphasis(text, delimiters, textFormatTransformersIndex) + : [], + nextCodeSpan: 0, + nextEmphasis: 0, + offset: 0, + text, + }; +} + +/** + * Moves `scan` on to the text after `offset`. What it found there holds for + * that text on its own as long as no code span or emphasis runs on from + * before `offset` (a pair cannot reach past the end of the outermost one), and + * no backslash before it escapes it; returns false otherwise. + */ +export function advanceFormatScan(scan: FormatScan, offset: number): boolean { + const {codeSpans, emphasis, text} = scan; + scan.offset = offset; + while ( + scan.nextCodeSpan < codeSpans.length && + codeSpans[scan.nextCodeSpan].startIndex < offset + ) { + if (codeSpans[scan.nextCodeSpan++].endIndex > offset) { + return false; } - } else if (codeMatch) { - resultMatch = codeMatch; - resultTransformer = codeTransformer; - } else if (emphasisMatch) { - resultMatch = emphasisMatch; - resultTransformer = - textFormatTransformersIndex.transformersByTag[emphasisMatch.tag]; } + while ( + scan.nextEmphasis < emphasis.length && + emphasis[scan.nextEmphasis].startIndex < offset + ) { + if (emphasis[scan.nextEmphasis++].endIndex > offset) { + return false; + } + } + return text[offset - 1] !== '\\'; +} - if (!resultMatch || !resultTransformer) { - return null; +/** + * Whether the text from `startIndex` to `endIndex` after `scan`'s offset + * holds part of a code span but not all of it. + */ +export function partlyHoldsCodeSpan( + scan: FormatScan, + startIndex: number, + endIndex: number, +): boolean { + const {codeSpans, offset} = scan; + for (let i = scan.nextCodeSpan; i < codeSpans.length; i++) { + const spanStart = codeSpans[i].startIndex - offset; + const spanEnd = codeSpans[i].endIndex - offset; + if (spanStart >= endIndex) { + break; + } + if ( + spanEnd > startIndex && + (spanStart < startIndex || spanEnd > endIndex) + ) { + return true; + } } + return false; +} +/** + * The outermost format match in `text`, what is left after `scan`'s offset of + * the text it scanned. + */ +export function findOutermostTextFormatTransformer( + text: string, + textFormatTransformersIndex: TextFormatTransformersIndex, + scan: FormatScan, +): { + startIndex: number; + endIndex: number; + transformer: TextFormatTransformer; + match: RegExpMatchArray; + // True when the outermost format is a code span. Code spans are atomic: their + // content is never reprocessed and they bind tighter than text-match + // transformers, so this is tagged here rather than re-derived by callers. + isCodeSpan: boolean; +} | null { + const codeMatch: FormatMatch | undefined = scan.codeSpans[scan.nextCodeSpan]; + const emphasisMatch: FormatMatch | undefined = + scan.emphasis[scan.nextEmphasis]; + // Delimiters inside a code span were excluded, so an emphasis match that + // starts first either holds the whole span or ends before it, and either + // way it is applied first. + const resultMatch = + emphasisMatch !== undefined && + (codeMatch === undefined || + emphasisMatch.startIndex <= codeMatch.startIndex) + ? emphasisMatch + : codeMatch; + if (resultMatch === undefined) { + return null; + } + const isCodeSpan = resultMatch === codeMatch; + const transformer = + textFormatTransformersIndex.transformersByTag[resultMatch.tag]; + if (!transformer) { + return null; + } + const {offset} = scan; + const startIndex = resultMatch.startIndex - offset; + const endIndex = resultMatch.endIndex - offset; const regexMatch: RegExpMatchArray = [ - textContent.slice(resultMatch.startIndex, resultMatch.endIndex), + text.slice(startIndex, endIndex), resultMatch.tag, resultMatch.content, ]; - regexMatch.index = resultMatch.startIndex; - regexMatch.input = textContent; + regexMatch.index = startIndex; + regexMatch.input = text; return { - codeSpans, - endIndex: resultMatch.endIndex, - // resultTransformer is the registered code transformer (by identity) - // exactly when the chosen match is the code span. - isCodeSpan: resultTransformer === codeTransformer, + endIndex, + isCodeSpan, match: regexMatch, - startIndex: resultMatch.startIndex, - transformer: resultTransformer, + startIndex, + transformer, }; } @@ -220,16 +300,12 @@ function scanDelimiters( continue; } - let len = 1; - while (i + len < text.length && text[i + len] === char) { - len++; - } + const len = getDelimiterRunEdge(text, i + 1, 1) - i; const {canOpen, canClose} = getEmphasisSides( char, i === 0 ? before : characterBefore(text, i), i + len === text.length ? after : characterAt(text, i + len), - true, ); if (canOpen || canClose) { @@ -249,19 +325,15 @@ function scanDelimiters( return delimiters; } +/** The outermost emphasis matches, in order. */ function processEmphasis( text: string, delimiters: Delimiter[], transformersIndex: TextFormatTransformersIndex, -): { - startIndex: number; - endIndex: number; - tag: string; - content: string; -} | null { +): FormatMatch[] { const openersBottom: Record = {}; let currentPos = 0; - let result = null; + const matches: FormatMatch[] = []; while (currentPos < delimiters.length) { const closer = delimiters[currentPos]; @@ -329,14 +401,7 @@ function processEmphasis( tag: matchedTag, }; - if ( - !result || - match.startIndex < result.startIndex || - (match.startIndex === result.startIndex && - match.endIndex > result.endIndex) - ) { - result = match; - } + matches.push(match); for (let j = openIdx + 1; j < currentPos; j++) { delimiters[j].active = false; @@ -365,7 +430,20 @@ function processEmphasis( } } - return result; + // Pairs nest, so the outermost ones are those that no earlier one holds. + matches.sort( + (a, b) => a.startIndex - b.startIndex || b.endIndex - a.endIndex, + ); + const outermost: FormatMatch[] = []; + for (const match of matches) { + if ( + outermost.length === 0 || + match.startIndex >= outermost[outermost.length - 1].endIndex + ) { + outermost.push(match); + } + } + return outermost; } export function importTextFormatTransformer( diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index d3782a5eafc..9df581bf2b3 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -11,14 +11,23 @@ import type {TextMatchTransformer} from './MarkdownTransformers'; import {$isTextNode, type LexicalNode, type TextNode} from 'lexical'; import { + advanceFormatScan, findOutermostTextFormatTransformer, + type FormatScan, importTextFormatTransformer, + partlyHoldsCodeSpan, + scanTextFormats, } from './importTextFormatTransformer'; import { findOutermostTextMatchTransformer, importFoundTextMatchTransformer, } from './importTextMatchTransformer'; -import {characterAt, characterBefore, isEscaped, unescapeText} from './utils'; +import { + characterAt, + characterBefore, + getDelimiterRunEdge, + unescapeText, +} from './utils'; /** * Returns true if the node can contain transformable markdown. @@ -44,23 +53,14 @@ function characterBeyondRun( step: 1 | -1, outside: string | undefined, ): string | undefined { - const isInside = (edge: number) => - step === 1 ? edge < text.length : edge > 0; - const delimiter = text[step === 1 ? index - 1 : index]; - let edge = index; - // An escaped delimiter before the run is not part of it. - while ( - isInside(edge) && - text[step === 1 ? edge : edge - 1] === delimiter && - (step === 1 || !isEscaped(text, edge - 1)) - ) { - edge += step; - } - return !isInside(edge) - ? outside - : step === 1 + const edge = getDelimiterRunEdge(text, index, step); + return step === 1 + ? edge < text.length ? characterAt(text, edge) - : characterBefore(text, edge); + : outside + : edge > 0 + ? characterBefore(text, edge) + : outside; } /** @@ -79,8 +79,10 @@ export function importTextTransformers( after?: string, ) { // The text after each match is imported in this loop rather than by - // recursion, so that a long paragraph does not nest a call per match. - let rest: TextRest | null = [textNode, before, after]; + // recursion, so that a long paragraph does not nest a call per match, and + // with the formats found in the whole text, which still hold for the rest + // of it as long as no match there reached into what came before. + let rest: TextRest | null = [textNode, before, after, null]; while (rest !== null) { rest = importOutermost( rest[0], @@ -88,6 +90,7 @@ export function importTextTransformers( textMatchTransformers, rest[1], rest[2], + rest[3], ); } } @@ -96,6 +99,7 @@ type TextRest = [ textNode: TextNode, before: string | undefined, after: string | undefined, + scan: FormatScan | null, ]; /** @@ -109,13 +113,18 @@ function importOutermost( textMatchTransformers: TextMatchTransformer[], before: string | undefined, after: string | undefined, + previousScan: FormatScan | null, ): TextRest | null { const text = textNode.getTextContent(); + const scan = + previousScan !== null && + previousScan.text.length - previousScan.offset === text.length + ? previousScan + : scanTextFormats(text, textFormatTransformersIndex, before, after); let foundTextFormat = findOutermostTextFormatTransformer( - textNode, + text, textFormatTransformersIndex, - before, - after, + scan, ); let foundTextMatch = findOutermostTextMatchTransformer( @@ -130,23 +139,18 @@ function importOutermost( // case the span is part of the match's raw content). // https://github.com/meta/lexical/issues/8687 const {startIndex, endIndex} = foundTextMatch; - const wraps = (span: {startIndex: number; endIndex: number}) => - startIndex <= span.startIndex && endIndex >= span.endIndex; - if ( - foundTextFormat.codeSpans.some( - span => - startIndex < span.endIndex && - endIndex > span.startIndex && - !wraps(span), - ) - ) { + if (partlyHoldsCodeSpan(scan, startIndex, endIndex)) { // Apply the code span, or the emphasis before it, and not the text // match, which would take part of the span. foundTextMatch = null; } else if (foundTextFormat.isCodeSpan) { // The text match goes first when it wraps the code span or ends before // it; one after it waits for the span. - if (wraps(foundTextFormat) || endIndex <= foundTextFormat.startIndex) { + if ( + (startIndex <= foundTextFormat.startIndex && + endIndex >= foundTextFormat.endIndex) || + endIndex <= foundTextFormat.startIndex + ) { foundTextFormat = null; } else { foundTextMatch = null; @@ -185,7 +189,12 @@ function importOutermost( ); } return canContainTransformableMarkdown(nodeAfter) - ? [nodeAfter, characterBeyondRun(text, endIndex, -1, before), after] + ? [ + nodeAfter, + characterBeyondRun(text, endIndex, -1, before), + after, + advanceFormatScan(scan, scan.offset + endIndex) ? scan : null, + ] : null; }; diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index a4ea15e2d08..8e51015fd4a 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -120,36 +120,25 @@ function isFlankedByClass(primary: number, secondary: number): boolean { ); } -// micromark, which `@lexical/mdast` imports with, also lets a delimiter run -// open before, and close after, another emphasis or strikethrough marker, so -// that `**a***~~b~~*` and `**a***_b_*` nest as written. -const ATTENTION_MARKER = /^[*_~]$/; - /** * Whether a delimiter run of `marker` between `before` and `after` can open * and close emphasis; `_` opens or closes inside a word only beside - * punctuation. With `isBesideMarkerFlanking`, a run beside another marker - * flanks on that side, as micromark reads it. + * punctuation. * https://spec.commonmark.org/0.31.2/#can-open-emphasis */ export function getEmphasisSides( marker: string, before: string | undefined, after: string | undefined, - isBesideMarkerFlanking = false, ): {canOpen: boolean; canClose: boolean} { const beforeClass = classify(before); const afterClass = classify(after); - const isLeft = - isFlankedByClass(afterClass, beforeClass) || - (isBesideMarkerFlanking && ATTENTION_MARKER.test(after || '')); - const isRight = - isFlankedByClass(beforeClass, afterClass) || - (isBesideMarkerFlanking && ATTENTION_MARKER.test(before || '')); + const isLeft = isFlankedByClass(afterClass, beforeClass); + const isRight = isFlankedByClass(beforeClass, afterClass); return marker === '_' ? { - canClose: isRight && (!isLeft || afterClass !== OTHER_CLASS), - canOpen: isLeft && (!isRight || beforeClass !== OTHER_CLASS), + canClose: isRight && (!isLeft || afterClass === PUNCTUATION_CLASS), + canOpen: isLeft && (!isRight || beforeClass === PUNCTUATION_CLASS), } : {canClose: isRight, canOpen: isLeft}; } @@ -294,3 +283,26 @@ export function isEscaped(text: string, index: number): boolean { } return backslashes % 2 === 1; } + +/** + * Where the delimiter run that `text` is cut in at `index` ends: walking from + * `index` by `step` over the delimiter beside the cut, the first edge with + * something else beyond it. Walking back, an escaped delimiter is not part of + * the run. + */ +export function getDelimiterRunEdge( + text: string, + index: number, + step: 1 | -1, +): number { + const delimiter = text[step === 1 ? index - 1 : index]; + let edge = index; + while ( + step === 1 + ? edge < text.length && text[edge] === delimiter + : edge > 0 && text[edge - 1] === delimiter && !isEscaped(text, edge - 1) + ) { + edge += step; + } + return edge; +} From 095b617f5ffe5b87706741307180301218209278 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 01:10:02 +0000 Subject: [PATCH 50/70] [lexical-markdown] Bug Fix: Restore main's import order, find links once per line, and open code spans after an escaped backtick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Fixes from the fourth review round of this branch against main: - Import goes back to main's order: the text after a match first, then the text before it, then the text inside it. The loop from 520f910 had imported the text before and inside a match first, which custom text-match transformers looking at their siblings could see. The text after each match is still imported in a loop, and the rest is deferred and run from the last match back. - The format scan from 9077162 is only reused when the match did not end inside a run of one character: the scan measured that run whole, so a text match such as `:a*` in `:a**b*` left `*b*` unformatted. - Links are found once per line as well: a link match still holds for the text after an earlier match as long as it starts there. Lines with many unmatched `[` no longer import in cubic time. - An escaped backtick right before more backticks is literal, and the rest of its run may open a code span, as in CommonMark: `` x\``bb`x `` has code `bb`. Our export writes that shape for plain `` ` `` before inline code, which did not round-trip. A closer is found by binary search over the runs of its length rather than a scan of all later runs. - On export, the bare-URL scan keeps a code span's backticks, so a URL right after a code span is guarded again, and it looks for an email before the URL in the same run. ## Test plan ### Before `pnpm vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts --project unit -t "writes no reference inside|escaped backtick|a text match cuts|after a match before"` with the sources of 9077162: ``` × writes no reference inside a URL right after a code span 14ms × writes no reference inside an email before a URL 2ms × opens a code span after an escaped backtick in "x\\``bb`x" 6ms × opens a code span after an escaped backtick in "\\``a` `b`" 9ms × reads the rest of a run that a text match cuts in ":a**b*" on its own 3ms × reads the rest of a run that a text match cuts in ":a***b*" on its own 2ms × imports the text after a match before the text inside it 2ms Tests 7 failed | 13 passed | 742 skipped (762) ``` Import times (milliseconds) on main: ``` n=400 n=1600 'a **b** [c](d) `e` _f_ ' 443 9138 '[a] *b* ' 129 4240 ``` ### After ``` Tests 20 passed | 742 skipped (762) ``` ``` n=400 n=1600 'a **b** [c](d) `e` _f_ ' 220 3981 '[a] *b* ' 21 196 ``` Importing 60,000 random lines without backslashes gives the same editor state as 9077162. `pnpm vitest run packages/lexical-markdown scripts/__tests__/unit/treeShakingSource.test.ts`: ``` Test Files 11 passed (11) Tests 1335 passed (1335) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 2 +- .../lexical-markdown/src/MarkdownExport.ts | 41 +++-- .../__tests__/unit/LexicalMarkdown.test.ts | 127 +++++++++++++ .../src/importTextFormatTransformer.ts | 59 ++++-- .../src/importTextMatchTransformer.ts | 40 ++++- .../src/importTextTransformers.ts | 169 +++++++++++------- 6 files changed, 330 insertions(+), 108 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 0efe06ed03d..5bd1fe50613 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -4,6 +4,6 @@ [fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_`, `~~` and `==` markers open or close formatting -Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match that ends before a code span is applied ahead of it, so `[a](b) and [`c`](d)` keeps both links, and one no longer takes part of any code span in the text, not only the first. A link starts at the `[` its label really opens with, so text before a stray `[` is read as Markdown: `[`x`[c](d)` has a code span. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. +Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match that ends before a code span is applied ahead of it, so `[a](b) and [`c`](d)` keeps both links, and one no longer takes part of any code span in the text, not only the first. A link starts at the `[` its label really opens with, so text before a stray `[` is read as Markdown: `[`x`[c](d)` has a code span. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. An escaped backtick right before more backticks no longer keeps the rest of that run from opening a code span, so ``x\``bb`x`` imports with code `bb`, as CommonMark reads it. Long lines import faster, since their code spans, emphasis and links are found once per line rather than again after each match. Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz`, `**你好。**世界` or `**注意:**请先登录`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Those references are chosen so that micromark-based readers such as `@lexical/mdast`, which read an emoji or another character outside the Basic Multilingual Plane as a letter, format it the same way, so plain `😀` then bold `“b”` exports as `😀**“b”**`. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it, except inside a bare URL. With `shouldPreserveNewLines`, backslashes right before a `*`, `_`, `` ` `` or `~` of the text are escaped too, so that they no longer cancel that character's escape. No reference is written inside a bare URL, `www.` host or email, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `see https://x.com/a**\_x**`. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 902b99c4bec..6a03833c4e9 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -815,8 +815,8 @@ function joinPieces({kinds, values}: MarkdownPieces): string { } /** - * Raw output as the bare-URL scan sees it: spaces, except that the content - * of a code span is letters (and its whitespace stays whitespace). + * Raw output as the bare-URL scan sees it: spaces, except that a code span + * keeps its backticks and its whitespace, and the rest of it is letters. */ function maskRaw(value: string): string { let masked = ''; @@ -824,7 +824,7 @@ function maskRaw(value: string): string { for (const {startIndex, endIndex} of scanCodeSpans(value)) { masked += ' '.repeat(startIndex - previous) + - value.slice(startIndex, endIndex).replace(/\S/g, 'x'); + value.slice(startIndex, endIndex).replace(/[^\s`]/g, 'x'); previous = endIndex; } return masked + ' '.repeat(value.length - previous); @@ -841,8 +841,9 @@ function maskRaw(value: string): string { */ function findLinkEdges(kinds: PieceKind[], values: string[]): Uint8Array { // Raw output ends a run, so it is joined as spaces, except a code span: - // GFM autolinks run on through backticks, as in `https://a.com/`v`s`, so - // its characters join as letters, which cannot start a URL themselves. + // GFM autolinks run on through backticks, as in `https://a.com/`v`s`, and + // start after them, so it joins as its backticks around letters, which + // cannot start a URL themselves. let text = ''; const starts = new Uint32Array(values.length + 1); for (let i = 0; i < values.length; i++) { @@ -868,24 +869,22 @@ function findLinkEdges(kinds: PieceKind[], values: string[]): Uint8Array { start++; continue; } + // The URL starts at its scheme, `www.` or `mailto:`, and what comes + // before it in the run is ordinary text, which may hold an email. const url = LINK_URL.exec(text.slice(start, end)); - if (url !== null) { - // The URL starts at its scheme, `www.` or `mailto:`, and what comes - // before it in the run is ordinary text. - const urlStart = start + url.index + (/^[a-z]/i.test(url[0]) ? 0 : 1); - inLink.fill(1, urlStart, end); - } else { - for (let from = start; from < end; ) { - const to = runEnd(from, isAddressCharacter, end); - if (to === from) { - from++; - continue; - } - if (ADDRESS.test(text.slice(from, to).replace(/\\/g, ''))) { - inLink.fill(1, from, to); - } - from = to; + const urlStart = + url === null ? end : start + url.index + (/^[a-z]/i.test(url[0]) ? 0 : 1); + inLink.fill(1, urlStart, end); + for (let from = start; from < urlStart; ) { + const to = runEnd(from, isAddressCharacter, urlStart); + if (to === from) { + from++; + continue; + } + if (ADDRESS.test(text.slice(from, to).replace(/\\/g, ''))) { + inLink.fill(1, from, to); } + from = to; } start = end; } diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 297fe830c76..cb6d9e0b121 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5553,6 +5553,133 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it.each<[string, [string, number][], string]>([ + [ + 'a URL right after a code span', + [ + ['x ', 0], + ['a', TEXT_TYPE_TO_FORMAT.code], + ['http://x.co/b', 0], + ['\u201cq\u201d', TEXT_TYPE_TO_FORMAT.bold], + [' y', 0], + ], + 'x `a`http://x.co/b**\u201cq\u201d** y', + ], + [ + 'an email before a URL', + [ + ['x a@b.co', 0], + ['\u201cq\u201d', TEXT_TYPE_TO_FORMAT.bold], + [',http://x.co/ y', 0], + ], + 'x a@b.co**\u201cq\u201d**,http://x.co/ y', + ], + ])('writes no reference inside %s', (_name, runs, markdown) => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + ...runs.map(([text, format]) => + $createTextNode(text).setFormat(format), + ), + ), + ), + {discrete: true}, + ); + expect( + editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), + ).toBe(markdown); + }); + + it.each<[string, [string, number][]]>([ + [ + 'x\\``bb`x', + [ + ['x`', 0], + ['bb', TEXT_TYPE_TO_FORMAT.code], + ['x', 0], + ], + ], + [ + '\\``a` `b`', + [ + ['`', 0], + ['a', TEXT_TYPE_TO_FORMAT.code], + [' ', 0], + ['b', TEXT_TYPE_TO_FORMAT.code], + ], + ], + ['`a\\`', [['a\\', TEXT_TYPE_TO_FORMAT.code]]], + ])('opens a code span after an escaped backtick in %j', (markdown, runs) => { + const editor = createTestEditor(); + editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS), { + discrete: true, + }); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.getFormat()]), + ), + ).toEqual(runs); + }); + + it.each<[string, [string, number][]]>([ + [ + ':a**b*', + [ + ['X', 0], + ['b', TEXT_TYPE_TO_FORMAT.italic], + ], + ], + [':a***b*', [['X**b*', 0]]], + ])( + 'reads the rest of a run that a text match cuts in %j on its own', + (markdown, runs) => { + const TAG: TextMatchTransformer = { + dependencies: [], + importRegExp: /:[a-z]+\*/, + regExp: /:[a-z]+\*$/, + replace: textNode => { + textNode.setTextContent('X'); + }, + type: 'text-match', + }; + const editor = createTestEditor(); + editor.update( + () => $convertFromMarkdownString(markdown, [TAG, ...TRANSFORMERS]), + {discrete: true}, + ); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.getFormat()]), + ), + ).toEqual(runs); + }, + ); + + it('imports the text after a match before the text inside it', () => { + const seen: string[] = []; + const TAG: TextMatchTransformer = { + dependencies: [], + importRegExp: /@x/, + regExp: /@x$/, + replace: textNode => { + seen.push(textNode.getParentOrThrow().getTextContent()); + }, + type: 'text-match', + }; + const editor = createTestEditor(); + editor.update( + () => $convertFromMarkdownString('**_a_** @x', [TAG, ...TRANSFORMERS]), + {discrete: true}, + ); + expect(seen).toEqual(['_a_ @x']); + }); + it('writes no reference after a code span inside a bare URL', () => { const editor = createTestEditor(); editor.update( diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index 2d8b0161eb1..e9743930f1b 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -84,8 +84,9 @@ export function scanTextFormats( /** * Moves `scan` on to the text after `offset`. What it found there holds for * that text on its own as long as no code span or emphasis runs on from - * before `offset` (a pair cannot reach past the end of the outermost one), and - * no backslash before it escapes it; returns false otherwise. + * before `offset` (a pair cannot reach past the end of the outermost one), no + * run of a character is cut there, which the scan measured whole, and no + * backslash before it escapes it; returns false otherwise. */ export function advanceFormatScan(scan: FormatScan, offset: number): boolean { const {codeSpans, emphasis, text} = scan; @@ -106,7 +107,7 @@ export function advanceFormatScan(scan: FormatScan, offset: number): boolean { return false; } } - return text[offset - 1] !== '\\'; + return text[offset - 1] !== '\\' && text[offset - 1] !== text[offset]; } /** @@ -196,8 +197,9 @@ export function findOutermostTextFormatTransformer( // Finds all inline code spans, left to right and non-overlapping, per CommonMark // rules: https://spec.commonmark.org/#code-spans. A run opens a span and the -// next run of equal length closes it. An escaped backtick (`\``) cannot open a -// span, but backslashes are otherwise literal and don't prevent closing. +// next run of equal length closes it. An escaped backtick (`\``) is literal, +// and the rest of its run may open a span, but backslashes are otherwise +// literal and don't prevent closing. export function scanCodeSpans(text: string): { startIndex: number; endIndex: number; @@ -219,25 +221,50 @@ export function scanCodeSpans(text: string): { } } + // The runs of each length, in order, to find a closer in. + const runsByLength = new Map(); + runs.forEach(({length}, index) => { + const indexes = runsByLength.get(length); + if (indexes) { + indexes.push(index); + } else { + runsByLength.set(length, [index]); + } + }); + // The first run after `index` that is `length` long, or -1. + const findCloser = (index: number, length: number): number => { + const indexes = runsByLength.get(length) || []; + let low = 0; + let high = indexes.length; + while (low < high) { + const middle = (low + high) >> 1; + if (indexes[middle] <= index) { + low = middle + 1; + } else { + high = middle; + } + } + return low < indexes.length ? indexes[low] : -1; + }; + const spans: {content: string; endIndex: number; startIndex: number}[] = []; let openIdx = 0; while (openIdx < runs.length) { - const opener = runs[openIdx]; + let opener = runs[openIdx]; - // An escaped backtick run is a literal backtick and cannot open a span. + // An escaped backtick is a literal backtick, and the rest of its run is + // a run of its own that can open a span. Inside a span a backslash is + // literal, so the whole run still closes one. if (isEscaped(text, opener.index)) { - openIdx++; - continue; - } - - let closeIdx = -1; - for (let c = openIdx + 1; c < runs.length; c++) { - if (runs[c].length === opener.length) { - closeIdx = c; - break; + if (opener.length === 1) { + openIdx++; + continue; } + opener = {index: opener.index + 1, length: opener.length - 1}; } + const closeIdx = findCloser(openIdx, opener.length); + if (closeIdx === -1) { // No matching closer; treat this run as literal and try the next one. openIdx++; diff --git a/packages/lexical-markdown/src/importTextMatchTransformer.ts b/packages/lexical-markdown/src/importTextMatchTransformer.ts index 2735489f0ce..5005625892d 100644 --- a/packages/lexical-markdown/src/importTextMatchTransformer.ts +++ b/packages/lexical-markdown/src/importTextMatchTransformer.ts @@ -39,9 +39,24 @@ function matchLink(text: string, regExp: RegExp): RegExpExecArray | null { return null; } +/** + * The match each transformer with the built-in link syntax found in a text, + * with where it starts in that text, or `null` when there is none. It is the + * first match in what is left of the text after any offset up to its start, + * since the link syntax does not look behind its match. + */ +export type LinkMatches = Map< + TextMatchTransformer, + {index: number; match: RegExpExecArray} | null +>; + export function findOutermostTextMatchTransformer( textNode_: TextNode, textMatchTransformers: TextMatchTransformer[], + // The link matches found earlier in the text that `textNode_` is what is + // left of from `offset`. + links: LinkMatches = new Map(), + offset = 0, ): { startIndex: number; endIndex: number; @@ -60,12 +75,31 @@ export function findOutermostTextMatchTransformer( continue; } const text = textNode.getTextContent(); + let match: RegExpMatchArray | null; // Transformers sharing the built-in link syntax need the same candidate rules. - const match = + if ( LINK.importRegExp !== undefined && transformer.importRegExp.source === LINK.importRegExp.source - ? matchLink(text, transformer.importRegExp) - : text.match(transformer.importRegExp); + ) { + const found = links.get(transformer); + if (found === undefined || (found !== null && found.index < offset)) { + const linkMatch = matchLink(text, transformer.importRegExp); + links.set( + transformer, + linkMatch && {index: offset + linkMatch.index, match: linkMatch}, + ); + match = linkMatch; + } else if (found === null) { + match = null; + } else { + const moved = [...found.match] as RegExpMatchArray; + moved.index = found.index - offset; + moved.input = text; + match = moved; + } + } else { + match = text.match(transformer.importRegExp); + } if (!match) { continue; diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 9df581bf2b3..c75762e817d 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -21,6 +21,7 @@ import { import { findOutermostTextMatchTransformer, importFoundTextMatchTransformer, + type LinkMatches, } from './importTextMatchTransformer'; import { characterAt, @@ -65,9 +66,10 @@ function characterBeyondRun( /** * Handles applying both text format and text match transformers. It finds - * the outermost text format or text match and applies it, imports the text - * before the match and then the text inside it the same way, and then goes - * on with the text after it, until there are no more transformers to apply. + * the outermost text format or text match and applies it, goes on with the + * text after it the same way, and then, from the last match back to the + * first, imports the text before each match and then the text inside it, + * until there are no more transformers to apply. */ export function importTextTransformers( textNode: TextNode, @@ -80,8 +82,9 @@ export function importTextTransformers( ) { // The text after each match is imported in this loop rather than by // recursion, so that a long paragraph does not nest a call per match, and - // with the formats found in the whole text, which still hold for the rest - // of it as long as no match there reached into what came before. + // with what was found in the whole text, which still holds for the rest of + // it as long as no match there reached into what came before. + const deferred: (() => void)[] = []; let rest: TextRest | null = [textNode, before, after, null]; while (rest !== null) { rest = importOutermost( @@ -91,21 +94,31 @@ export function importTextTransformers( rest[1], rest[2], rest[3], + deferred, ); } + for (let i = deferred.length - 1; i >= 0; i--) { + deferred[i](); + } +} + +/** The formats and links found in a text, for what is left of it. */ +interface TextScan { + formats: FormatScan; + links: LinkMatches; } type TextRest = [ textNode: TextNode, before: string | undefined, after: string | undefined, - scan: FormatScan | null, + scan: TextScan | null, ]; /** - * Applies the outermost transformer in `textNode`, imports the text before - * and inside its match, and returns the text after it, for the caller to - * import next. + * Applies the outermost transformer in `textNode`, adds the import of the + * text before and inside its match to `deferred`, and returns the text after + * it, for the caller to import next. */ function importOutermost( textNode: TextNode, @@ -113,23 +126,37 @@ function importOutermost( textMatchTransformers: TextMatchTransformer[], before: string | undefined, after: string | undefined, - previousScan: FormatScan | null, + previousScan: TextScan | null, + deferred: (() => void)[], ): TextRest | null { const text = textNode.getTextContent(); - const scan = + const scan: TextScan = previousScan !== null && - previousScan.text.length - previousScan.offset === text.length + previousScan.formats.text.length - previousScan.formats.offset === + text.length && + previousScan.formats.text.endsWith(text) ? previousScan - : scanTextFormats(text, textFormatTransformersIndex, before, after); + : { + formats: scanTextFormats( + text, + textFormatTransformersIndex, + before, + after, + ), + links: new Map(), + }; + const {formats} = scan; let foundTextFormat = findOutermostTextFormatTransformer( text, textFormatTransformersIndex, - scan, + formats, ); let foundTextMatch = findOutermostTextMatchTransformer( textNode, textMatchTransformers, + scan.links, + formats.offset, ); if (foundTextFormat && foundTextMatch) { @@ -139,7 +166,7 @@ function importOutermost( // case the span is part of the match's raw content). // https://github.com/meta/lexical/issues/8687 const {startIndex, endIndex} = foundTextMatch; - if (partlyHoldsCodeSpan(scan, startIndex, endIndex)) { + if (partlyHoldsCodeSpan(formats, startIndex, endIndex)) { // Apply the code span, or the emphasis before it, and not the text // match, which would take part of the span. foundTextMatch = null; @@ -170,30 +197,35 @@ function importOutermost( } } - // Imports the text before a match from `startIndex` to `endIndex`, and - // returns the text after it, each with what lies beyond the run cut at its - // edge. + // Defers the import of the text before a match from `startIndex` to + // `endIndex`, then of the text inside it with `importInside`, and returns + // the text after it, each with what lies beyond the run cut at its edge. const importAround = ( nodeBefore: LexicalNode | undefined, nodeAfter: LexicalNode | undefined, startIndex: number, endIndex: number, + importInside: () => void, ): TextRest | null => { - if (canContainTransformableMarkdown(nodeBefore)) { - importTextTransformers( - nodeBefore, - textFormatTransformersIndex, - textMatchTransformers, - before, - characterBeyondRun(text, startIndex, 1, after), - ); - } + const beforeMatch = characterBeyondRun(text, startIndex, 1, after); + deferred.push(() => { + if (canContainTransformableMarkdown(nodeBefore)) { + importTextTransformers( + nodeBefore, + textFormatTransformersIndex, + textMatchTransformers, + before, + beforeMatch, + ); + } + importInside(); + }); return canContainTransformableMarkdown(nodeAfter) ? [ nodeAfter, characterBeyondRun(text, endIndex, -1, before), after, - advanceFormatScan(scan, scan.offset + endIndex) ? scan : null, + advanceFormatScan(formats, formats.offset + endIndex) ? scan : null, ] : null; }; @@ -206,31 +238,33 @@ function importOutermost( foundTextFormat.transformer, foundTextFormat.match, ); - const {startIndex, endIndex} = foundTextFormat; + const {startIndex, endIndex, isCodeSpan} = foundTextFormat; const tagLength = foundTextFormat.match[1].length; - const rest = importAround( + // A code span's fence is not its tag's length, and its content is not + // read for emphasis against what lies outside it. + const insideBefore = isCodeSpan + ? undefined + : characterBeyondRun(text, startIndex + tagLength, -1, before); + const insideAfter = isCodeSpan + ? undefined + : characterBeyondRun(text, endIndex - tagLength, 1, after); + return importAround( result.nodeBefore, result.nodeAfter, startIndex, endIndex, + () => { + if (canContainTransformableMarkdown(result.transformedNode)) { + importTextTransformers( + result.transformedNode, + textFormatTransformersIndex, + textMatchTransformers, + insideBefore, + insideAfter, + ); + } + }, ); - if (canContainTransformableMarkdown(result.transformedNode)) { - // A code span's fence is not its tag's length, and its content is not - // read for emphasis against what lies outside it. - const {isCodeSpan} = foundTextFormat; - importTextTransformers( - result.transformedNode, - textFormatTransformersIndex, - textMatchTransformers, - isCodeSpan - ? undefined - : characterBeyondRun(text, startIndex + tagLength, -1, before), - isCodeSpan - ? undefined - : characterBeyondRun(text, endIndex - tagLength, 1, after), - ); - } - return rest; } else if (foundTextMatch) { const result = importFoundTextMatchTransformer( textNode, @@ -242,31 +276,32 @@ function importOutermost( if (!result) { return null; } - const rest = importAround( + return importAround( result.nodeBefore, result.nodeAfter, foundTextMatch.startIndex, foundTextMatch.endIndex, + () => { + if (canContainTransformableMarkdown(result.transformedNode)) { + importTextTransformers( + result.transformedNode, + textFormatTransformersIndex, + textMatchTransformers, + ); + } else if ( + result.transformedNode === undefined && + canContainTransformableMarkdown(result.matchedNode) && + result.matchedNode.getParent() !== null + ) { + // A void return may leave the matched node in place or move it into + // a new parent. Read its escapes once without trying the same match + // again. A parent also exists when importing into a detached tree. + result.matchedNode.setTextContent( + unescapeText(result.matchedNode.getTextContent()), + ); + } + }, ); - if (canContainTransformableMarkdown(result.transformedNode)) { - importTextTransformers( - result.transformedNode, - textFormatTransformersIndex, - textMatchTransformers, - ); - } else if ( - result.transformedNode === undefined && - canContainTransformableMarkdown(result.matchedNode) && - result.matchedNode.getParent() !== null - ) { - // A void return may leave the matched node in place or move it into a - // new parent. Read its escapes once without trying the same match again. - // A parent also exists when importing into a detached tree. - result.matchedNode.setTextContent( - unescapeText(result.matchedNode.getTextContent()), - ); - } - return rest; } // Handle escape characters. Only a node that no transformer matched gets From 52c4375ea25bebb29ee98a66bc09c9278b92af44 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 01:55:53 +0000 Subject: [PATCH 51/70] [lexical-markdown] Bug Fix: Write no reference that starts an autolink, and keep deferred imports and scan reuse cheap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Fixes from the fifth review round of this branch against main: - Export no longer writes a reference for the letter right before `http://`, `https://`, `ftp://`, `www.`, `mailto:` or `xmpp:`. Its `;` would let GFM readers start an autolink there, so bold `.` then plain `ahttp://x.co` came back from `@lexical/mdast` with a link that was never in the document. It goes out as `**.**ahttp://x.co`, as on main. - The imports deferred until the text after each match is done are kept as plain tuples of their nodes and characters, rather than closures that held each step's whole scan. `'**a***b*'.repeat(4000)` ran the worker out of memory. - The scan is reused after an emphasis match that cuts a closing run, which it measured by what is left of the run, as a rescan would; only a text match that ends inside a run of a delimiter or backtick forces a rescan, not any repeated character such as `))`. - Whether the scan still fits the rest of the text is checked by comparing the rest's text with the one recorded for it, which is the same string, rather than by `endsWith` over the whole rest at every step. - A link match is found again from where the previous search found it rather than copied, so a transformer with the link syntax and the `d` flag gets its `indices` on every link of a line; a link cut to its real opener moves its `indices` too. - A code span's closer is found with a forward cursor per run length rather than a binary search. ## Test plan ### Before `pnpm vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts --project unit -t "the letter before a URL"` with the sources of 095b617: ``` × writes no reference inside the letter before a URL scheme 29ms Tests 1 failed | 762 skipped (763) ``` Import of n repetitions, in milliseconds, with the heap used afterwards: ``` main 095b617 '**a***b*' n=1600: 3409 ms n=1600: 4142 ms, 389 MB '([a](u)) ' n=1600: 388 ms n=1600: 458 ms ``` ### After ``` Tests 1 passed | 762 skipped (763) ``` ``` '**a***b*' n=1600: 262 ms, 70 MB '([a](u)) ' n=1600: 168 ms ``` Importing 90,000 random lines over emphasis-heavy, Unicode and bracket-heavy alphabets gives the same editor state as 095b617. `pnpm vitest run scripts/__tests__/unit/treeShakingSource.test.ts packages/lexical-markdown`: ``` Test Files 11 passed (11) Tests 1336 passed (1336) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 9 + .../__tests__/unit/LexicalMarkdown.test.ts | 8 + .../src/importTextFormatTransformer.ts | 33 ++-- .../src/importTextMatchTransformer.ts | 62 +++--- .../src/importTextTransformers.ts | 180 +++++++++++------- 5 files changed, 182 insertions(+), 110 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 6a03833c4e9..7b2d6699787 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -735,6 +735,9 @@ function isLinkCharacter(character: string): boolean { // `www.` after `_` too. const LINK_URL = /(?:^|[^a-z])(?:https?|ftp):\/\/|(?:^|[^a-z\d.-])www\.|(?:^|[^a-z\d.+-])(?:mailto|xmpp):/i; +// The start of a scheme or `www.` anywhere: a reference for the letter before +// it would put the `;` there, after which a URL starts. +const URL_START = /(?:https?|ftp):\/\/|www\.|(?:mailto|xmpp):/gi; // Characters an email can hold (word characters, `.+@-`, and the backslash // that escapes one), and what makes one: GFM needs a dot in its domain. const ADDRESS_CHARACTER = /[\w.+@\\-]/; @@ -875,6 +878,12 @@ function findLinkEdges(kinds: PieceKind[], values: string[]): Uint8Array { const urlStart = url === null ? end : start + url.index + (/^[a-z]/i.test(url[0]) ? 0 : 1); inLink.fill(1, urlStart, end); + const run = text.slice(start, urlStart); + for (const {index} of run.matchAll(URL_START)) { + if (index > 0) { + inLink[start + index - 1] = 1; + } + } for (let from = start; from < urlStart; ) { const to = runEnd(from, isAddressCharacter, urlStart); if (to === from) { diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index cb6d9e0b121..0f3c692403a 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5574,6 +5574,14 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ], 'x a@b.co**\u201cq\u201d**,http://x.co/ y', ], + [ + 'the letter before a URL scheme', + [ + ['.', TEXT_TYPE_TO_FORMAT.bold], + ['ahttp://x.co', 0], + ], + '**.**ahttp://x.co', + ], ])('writes no reference inside %s', (_name, runs, markdown) => { const editor = createTestEditor(); editor.update( diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index e9743930f1b..5bb9a2e5351 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -84,9 +84,10 @@ export function scanTextFormats( /** * Moves `scan` on to the text after `offset`. What it found there holds for * that text on its own as long as no code span or emphasis runs on from - * before `offset` (a pair cannot reach past the end of the outermost one), no - * run of a character is cut there, which the scan measured whole, and no - * backslash before it escapes it; returns false otherwise. + * before `offset` (a pair cannot reach past the end of the outermost one), + * and no backslash before it escapes it; returns false otherwise. A run that + * the end of an emphasis match cuts was measured by what is left of it, as a + * scan of the rest would, but one that a text match cuts was not. */ export function advanceFormatScan(scan: FormatScan, offset: number): boolean { const {codeSpans, emphasis, text} = scan; @@ -107,7 +108,7 @@ export function advanceFormatScan(scan: FormatScan, offset: number): boolean { return false; } } - return text[offset - 1] !== '\\' && text[offset - 1] !== text[offset]; + return text[offset - 1] !== '\\'; } /** @@ -221,7 +222,8 @@ export function scanCodeSpans(text: string): { } } - // The runs of each length, in order, to find a closer in. + // The runs of each length, in order, to find a closer in, and how many of + // them the openers, which only move forward, have passed. const runsByLength = new Map(); runs.forEach(({length}, index) => { const indexes = runsByLength.get(length); @@ -231,20 +233,19 @@ export function scanCodeSpans(text: string): { runsByLength.set(length, [index]); } }); + const passed = new Map(); // The first run after `index` that is `length` long, or -1. const findCloser = (index: number, length: number): number => { - const indexes = runsByLength.get(length) || []; - let low = 0; - let high = indexes.length; - while (low < high) { - const middle = (low + high) >> 1; - if (indexes[middle] <= index) { - low = middle + 1; - } else { - high = middle; - } + const indexes = runsByLength.get(length); + if (indexes === undefined) { + return -1; + } + let next = passed.get(length) || 0; + while (next < indexes.length && indexes[next] <= index) { + next++; } - return low < indexes.length ? indexes[low] : -1; + passed.set(length, next); + return next < indexes.length ? indexes[next] : -1; }; const spans: {content: string; endIndex: number; startIndex: number}[] = []; diff --git a/packages/lexical-markdown/src/importTextMatchTransformer.ts b/packages/lexical-markdown/src/importTextMatchTransformer.ts index 5005625892d..6f02941f7ea 100644 --- a/packages/lexical-markdown/src/importTextMatchTransformer.ts +++ b/packages/lexical-markdown/src/importTextMatchTransformer.ts @@ -11,11 +11,20 @@ import type {TextNode} from 'lexical'; import {getLinkLabelOpener, LINK} from './MarkdownTransformers'; import {isEscaped} from './utils'; -function matchLink(text: string, regExp: RegExp): RegExpExecArray | null { +/** + * The first link match in `text` from `from` on, and where the search found + * it, which is before the match when its label holds the real opener. + */ +function matchLink( + text: string, + regExp: RegExp, + from: number, +): [match: RegExpExecArray, found: number] | null { const search = new RegExp( regExp.source, regExp.flags.replace(/[gy]/g, '') + 'g', ); + search.lastIndex = from; let match; while ((match = search.exec(text)) !== null) { // A `[` that the label holds outside a code span is the link's real @@ -29,10 +38,19 @@ function matchLink(text: string, regExp: RegExp): RegExpExecArray | null { inner[1] = match[1].slice(cut); inner.index = match.index + cut; inner.input = text; - return inner; + const {indices} = match; + const whole = indices && indices[0]; + const label = indices && indices[1]; + if (indices && whole && label) { + const moved = [...indices] as RegExpIndicesArray; + moved[0] = [inner.index, whole[1]]; + moved[1] = [label[0] + cut, label[1]]; + inner.indices = moved; + } + return [inner, match.index]; } if (!isEscaped(text, match.index)) { - return match; + return [match, match.index]; } search.lastIndex = match.index + 1; } @@ -40,15 +58,12 @@ function matchLink(text: string, regExp: RegExp): RegExpExecArray | null { } /** - * The match each transformer with the built-in link syntax found in a text, - * with where it starts in that text, or `null` when there is none. It is the - * first match in what is left of the text after any offset up to its start, - * since the link syntax does not look behind its match. + * Where the search for each transformer with the built-in link syntax found + * its match in a text, or `null` when there is none. The search finds the + * same match again from there in what is left of the text after any offset + * up to it, since the link syntax does not look behind its match. */ -export type LinkMatches = Map< - TextMatchTransformer, - {index: number; match: RegExpExecArray} | null ->; +export type LinkMatches = Map; export function findOutermostTextMatchTransformer( textNode_: TextNode, @@ -82,21 +97,16 @@ export function findOutermostTextMatchTransformer( transformer.importRegExp.source === LINK.importRegExp.source ) { const found = links.get(transformer); - if (found === undefined || (found !== null && found.index < offset)) { - const linkMatch = matchLink(text, transformer.importRegExp); - links.set( - transformer, - linkMatch && {index: offset + linkMatch.index, match: linkMatch}, - ); - match = linkMatch; - } else if (found === null) { - match = null; - } else { - const moved = [...found.match] as RegExpMatchArray; - moved.index = found.index - offset; - moved.input = text; - match = moved; - } + const linkMatch = + found === null + ? null + : matchLink( + text, + transformer.importRegExp, + found === undefined || found < offset ? 0 : found - offset, + ); + links.set(transformer, linkMatch && offset + linkMatch[1]); + match = linkMatch && linkMatch[0]; } else { match = text.match(transformer.importRegExp); } diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index c75762e817d..622a171dd4b 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -84,7 +84,7 @@ export function importTextTransformers( // recursion, so that a long paragraph does not nest a call per match, and // with what was found in the whole text, which still holds for the rest of // it as long as no match there reached into what came before. - const deferred: (() => void)[] = []; + const deferred: Deferred[] = []; let rest: TextRest | null = [textNode, before, after, null]; while (rest !== null) { rest = importOutermost( @@ -98,14 +98,68 @@ export function importTextTransformers( ); } for (let i = deferred.length - 1; i >= 0; i--) { - deferred[i](); + const [ + nodeBefore, + beforeBefore, + beforeAfter, + inside, + insideBefore, + insideAfter, + matched, + ] = deferred[i]; + if (canContainTransformableMarkdown(nodeBefore)) { + importTextTransformers( + nodeBefore, + textFormatTransformersIndex, + textMatchTransformers, + beforeBefore, + beforeAfter, + ); + } + if (canContainTransformableMarkdown(inside)) { + importTextTransformers( + inside, + textFormatTransformersIndex, + textMatchTransformers, + insideBefore, + insideAfter, + ); + } else if ( + canContainTransformableMarkdown(matched) && + matched.getParent() !== null + ) { + // A text match transformer that returned no node may leave the matched + // node in place or move it into a new parent. Read its escapes once + // without trying the same match again. A parent also exists when + // importing into a detached tree. + matched.setTextContent(unescapeText(matched.getTextContent())); + } } } -/** The formats and links found in a text, for what is left of it. */ +/** + * What a match leaves to import once the text after it is done: the text + * before it, then the text inside it, each with the characters around it, + * or the matched node of a text match that returned none. + */ +type Deferred = [ + nodeBefore: LexicalNode | undefined, + beforeBefore: string | undefined, + beforeAfter: string | undefined, + inside: LexicalNode | undefined, + insideBefore: string | undefined, + insideAfter: string | undefined, + matched: LexicalNode | undefined, +]; + +/** + * The formats and links found in a text, for what is left of it, which is + * `rest`. + */ interface TextScan { formats: FormatScan; links: LinkMatches; + rest: string; } type TextRest = [ @@ -127,14 +181,11 @@ function importOutermost( before: string | undefined, after: string | undefined, previousScan: TextScan | null, - deferred: (() => void)[], + deferred: Deferred[], ): TextRest | null { const text = textNode.getTextContent(); const scan: TextScan = - previousScan !== null && - previousScan.formats.text.length - previousScan.formats.offset === - text.length && - previousScan.formats.text.endsWith(text) + previousScan !== null && previousScan.rest === text ? previousScan : { formats: scanTextFormats( @@ -144,6 +195,7 @@ function importOutermost( after, ), links: new Map(), + rest: text, }; const {formats} = scan; let foundTextFormat = findOutermostTextFormatTransformer( @@ -198,36 +250,51 @@ function importOutermost( } // Defers the import of the text before a match from `startIndex` to - // `endIndex`, then of the text inside it with `importInside`, and returns + // `endIndex` and of `inside` (or the escapes of `matched`), and returns // the text after it, each with what lies beyond the run cut at its edge. + // A text match that ends inside a run of a delimiter cuts it, which the + // scan measured whole, so the rest is scanned again then. const importAround = ( nodeBefore: LexicalNode | undefined, nodeAfter: LexicalNode | undefined, startIndex: number, endIndex: number, - importInside: () => void, + inside: LexicalNode | undefined, + insideBefore: string | undefined, + insideAfter: string | undefined, + matched: LexicalNode | undefined, + isTextMatch: boolean, ): TextRest | null => { - const beforeMatch = characterBeyondRun(text, startIndex, 1, after); - deferred.push(() => { - if (canContainTransformableMarkdown(nodeBefore)) { - importTextTransformers( - nodeBefore, - textFormatTransformersIndex, - textMatchTransformers, - before, - beforeMatch, - ); - } - importInside(); - }); - return canContainTransformableMarkdown(nodeAfter) - ? [ - nodeAfter, - characterBeyondRun(text, endIndex, -1, before), - after, - advanceFormatScan(formats, formats.offset + endIndex) ? scan : null, - ] - : null; + deferred.push([ + nodeBefore, + before, + characterBeyondRun(text, startIndex, 1, after), + inside, + insideBefore, + insideAfter, + matched, + ]); + if (!canContainTransformableMarkdown(nodeAfter)) { + return null; + } + const cut = text[endIndex - 1]; + const isReusable = + advanceFormatScan(formats, formats.offset + endIndex) && + !( + isTextMatch && + cut === text[endIndex] && + (cut === '`' || + Object.keys(textFormatTransformersIndex.transformersByTag).some( + tag => tag[0] === cut, + )) + ); + scan.rest = nodeAfter.getTextContent(); + return [ + nodeAfter, + characterBeyondRun(text, endIndex, -1, before), + after, + isReusable ? scan : null, + ]; }; if (foundTextFormat) { @@ -242,28 +309,20 @@ function importOutermost( const tagLength = foundTextFormat.match[1].length; // A code span's fence is not its tag's length, and its content is not // read for emphasis against what lies outside it. - const insideBefore = isCodeSpan - ? undefined - : characterBeyondRun(text, startIndex + tagLength, -1, before); - const insideAfter = isCodeSpan - ? undefined - : characterBeyondRun(text, endIndex - tagLength, 1, after); return importAround( result.nodeBefore, result.nodeAfter, startIndex, endIndex, - () => { - if (canContainTransformableMarkdown(result.transformedNode)) { - importTextTransformers( - result.transformedNode, - textFormatTransformersIndex, - textMatchTransformers, - insideBefore, - insideAfter, - ); - } - }, + result.transformedNode, + isCodeSpan + ? undefined + : characterBeyondRun(text, startIndex + tagLength, -1, before), + isCodeSpan + ? undefined + : characterBeyondRun(text, endIndex - tagLength, 1, after), + undefined, + false, ); } else if (foundTextMatch) { const result = importFoundTextMatchTransformer( @@ -281,26 +340,11 @@ function importOutermost( result.nodeAfter, foundTextMatch.startIndex, foundTextMatch.endIndex, - () => { - if (canContainTransformableMarkdown(result.transformedNode)) { - importTextTransformers( - result.transformedNode, - textFormatTransformersIndex, - textMatchTransformers, - ); - } else if ( - result.transformedNode === undefined && - canContainTransformableMarkdown(result.matchedNode) && - result.matchedNode.getParent() !== null - ) { - // A void return may leave the matched node in place or move it into - // a new parent. Read its escapes once without trying the same match - // again. A parent also exists when importing into a detached tree. - result.matchedNode.setTextContent( - unescapeText(result.matchedNode.getTextContent()), - ); - } - }, + result.transformedNode, + undefined, + undefined, + result.transformedNode === undefined ? result.matchedNode : undefined, + true, ); } From 3436ba9aa3b7220c9f713ec685bacd4a0281a0df Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:24:00 +0000 Subject: [PATCH 52/70] [lexical-markdown] Bug Fix: Rescan after a transformer edits the next node, keep link matches, and narrow the scheme guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Fixes from the sixth review round of this branch against main: - The scan of a line is only reused when the text after a match is still what it was before the text-match transformer ran. 52c4375 read it after `replace`, so a transformer that edits the next node, such as a mention that trims the space after it, had the old offsets applied to the changed text: `@bob *a* *b*` kept its asterisks. - A link match found earlier in a line is kept and moved to the rest of the text, with its `index`, `input`, `groups` and `indices`, rather than found again. Searching again cost the whole label at each step, so emphasis before a long link imported slower than on main. - No reference is held back before `www.` or `mailto:` any more, only before `http://`, `https://` and `ftp://`. A `;` before `www.` or `mailto:` starts no autolink, so bold `.` then `bwww.x.co` exports as `**.**bwww.x.co` again, which both importers read back. - Whether a text match cut a run of a delimiter or backtick is decided in `advanceFormatScan`, from the delimiter characters the scan records once, rather than at its caller from the transformer tags at each step. - The deferred imports have named fields. - The changeset says that the letter right before a scheme gets no reference. Formatting right beside a bare URL still cannot round-trip: no reference is written inside the URL, since GFM readers would link a different URL, and import now follows CommonMark there. ## Test plan ### Before `pnpm vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts --project unit -t "writes a reference before|changes it"` with the sources of 52c4375: ``` × writes a reference before "bwww.x.co", which readers do not autolink 28ms × writes a reference before "bmailto:a@b.co", which readers do not autolink 3ms × scans the text after a match again when its transformer changes it 9ms Tests 3 failed | 763 skipped (766) ``` Importing `'*a* '.repeat(n) + '[a'.repeat(20000) + '](u)'`: main 999 ms at n=1000 and 7241 ms at n=4000. ### After ``` Tests 3 passed | 763 skipped (766) ``` The same import: 409 ms and 1494 ms. Importing 90,000 random lines over emphasis-heavy, Unicode and bracket-heavy alphabets gives the same editor state as 52c4375. `@lexical/mdast` reads `**.**bwww.x.co` as bold `.` then plain `bwww.x.co`. `pnpm vitest run scripts/__tests__/unit/treeShakingSource.test.ts packages/lexical-markdown`: ``` Test Files 11 passed (11) Tests 1339 passed (1339) ``` Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 2 +- .../lexical-markdown/src/MarkdownExport.ts | 6 +- .../__tests__/unit/LexicalMarkdown.test.ts | 56 +++++++ .../src/importTextFormatTransformer.ts | 44 +++-- .../src/importTextMatchTransformer.ts | 75 ++++++--- .../src/importTextTransformers.ts | 158 +++++++++--------- 6 files changed, 220 insertions(+), 121 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 5bd1fe50613..143895ed929 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -6,4 +6,4 @@ Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match that ends before a code span is applied ahead of it, so `[a](b) and [`c`](d)` keeps both links, and one no longer takes part of any code span in the text, not only the first. A link starts at the `[` its label really opens with, so text before a stray `[` is read as Markdown: `[`x`[c](d)` has a code span. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. An escaped backtick right before more backticks no longer keeps the rest of that run from opening a code span, so ``x\``bb`x`` imports with code `bb`, as CommonMark reads it. Long lines import faster, since their code spans, emphasis and links are found once per line rather than again after each match. -Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz`, `**你好。**世界` or `**注意:**请先登录`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Those references are chosen so that micromark-based readers such as `@lexical/mdast`, which read an emoji or another character outside the Basic Multilingual Plane as a letter, format it the same way, so plain `😀` then bold `“b”` exports as `😀**“b”**`. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it, except inside a bare URL. With `shouldPreserveNewLines`, backslashes right before a `*`, `_`, `` ` `` or `~` of the text are escaped too, so that they no longer cancel that character's escape. No reference is written inside a bare URL, `www.` host or email, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `see https://x.com/a**\_x**`. +Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz`, `**你好。**世界` or `**注意:**请先登录`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Those references are chosen so that micromark-based readers such as `@lexical/mdast`, which read an emoji or another character outside the Basic Multilingual Plane as a letter, format it the same way, so plain `😀` then bold `“b”` exports as `😀**“b”**`. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it, except inside a bare URL. With `shouldPreserveNewLines`, backslashes right before a `*`, `_`, `` ` `` or `~` of the text are escaped too, so that they no longer cancel that character's escape. No reference is written inside a bare URL, `www.` host or email, or for the letter right before `http://`, `https://` or `ftp://`, where its `;` would start a URL, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `see https://x.com/a**\_x**`. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 7b2d6699787..c996058d1bc 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -735,9 +735,9 @@ function isLinkCharacter(character: string): boolean { // `www.` after `_` too. const LINK_URL = /(?:^|[^a-z])(?:https?|ftp):\/\/|(?:^|[^a-z\d.-])www\.|(?:^|[^a-z\d.+-])(?:mailto|xmpp):/i; -// The start of a scheme or `www.` anywhere: a reference for the letter before -// it would put the `;` there, after which a URL starts. -const URL_START = /(?:https?|ftp):\/\/|www\.|(?:mailto|xmpp):/gi; +// A URL scheme anywhere: a reference for the letter before it would put the +// `;` there, after which a URL starts (`www.` and `mailto:` need more). +const URL_START = /(?:https?|ftp):\/\//gi; // Characters an email can hold (word characters, `.+@-`, and the backslash // that escapes one), and what makes one: GFM needs a dot in its domain. const ADDRESS_CHARACTER = /[\w.+@\\-]/; diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 0f3c692403a..1b1c8160051 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5669,6 +5669,62 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + // A `;` before them starts no URL: `www.` needs whitespace or `*_~(` before + // it, and readers ignore what comes before `mailto:`. + it.each(['bwww.x.co', 'bmailto:a@b.co'])( + 'writes a reference before %j, which readers do not autolink', + text => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('.').toggleFormat('bold'), + $createTextNode(text), + ), + ), + {discrete: true}, + ); + expect( + editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), + ).toBe(`**.**b${text.slice(1)}`); + }, + ); + + it('scans the text after a match again when its transformer changes it', () => { + const MENTION: TextMatchTransformer = { + dependencies: [], + importRegExp: /@(\w+)/, + regExp: /@(\w+)$/, + replace: textNode => { + const next = textNode.getNextSibling(); + if ($isTextNode(next)) { + next.setTextContent(next.getTextContent().trimStart()); + } + textNode.toggleFormat('code'); + }, + type: 'text-match', + }; + const editor = createTestEditor(); + editor.update( + () => + $convertFromMarkdownString('@bob *a* *b*', [MENTION, ...TRANSFORMERS]), + {discrete: true}, + ); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.getFormat()]), + ), + ).toEqual([ + ['@bob', TEXT_TYPE_TO_FORMAT.code], + ['a', TEXT_TYPE_TO_FORMAT.italic], + [' ', 0], + ['b', TEXT_TYPE_TO_FORMAT.italic], + ]); + }); + it('imports the text after a match before the text inside it', () => { const seen: string[] = []; const TAG: TextMatchTransformer = { diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index 5bb9a2e5351..02ebc41ed68 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -42,6 +42,8 @@ interface FormatMatch { */ export interface FormatScan { text: string; + /** The characters that start an emphasis tag. */ + delimiters: Set; codeSpans: FormatMatch[]; emphasis: FormatMatch[]; offset: number; @@ -61,15 +63,21 @@ export function scanTextFormats( const codeSpans = textFormatTransformersIndex.transformersByTag['`'] ? scanCodeSpans(text).map(span => ({...span, tag: '`'})) : []; + const delimiterChars = new Set( + Object.keys(textFormatTransformersIndex.transformersByTag) + .filter(tag => tag[0] !== '`') + .map(tag => tag[0]), + ); const delimiters = scanDelimiters( text, - textFormatTransformersIndex, + delimiterChars, codeSpans, before, after, ); return { codeSpans, + delimiters: delimiterChars, emphasis: delimiters.length > 0 ? processEmphasis(text, delimiters, textFormatTransformersIndex) @@ -82,14 +90,19 @@ export function scanTextFormats( } /** - * Moves `scan` on to the text after `offset`. What it found there holds for - * that text on its own as long as no code span or emphasis runs on from - * before `offset` (a pair cannot reach past the end of the outermost one), - * and no backslash before it escapes it; returns false otherwise. A run that - * the end of an emphasis match cuts was measured by what is left of it, as a - * scan of the rest would, but one that a text match cuts was not. + * Moves `scan` on to the text after `offset`, where a match ends. What it + * found there holds for that text on its own as long as no code span or + * emphasis runs on from before `offset` (a pair cannot reach past the end of + * the outermost one), no backslash before it escapes it, and no run of a + * delimiter or backtick goes on across it; returns false otherwise. A + * closing run that the end of a format match cuts is the exception: the scan + * measured it by what was left of it, as a scan of the rest does. */ -export function advanceFormatScan(scan: FormatScan, offset: number): boolean { +export function advanceFormatScan( + scan: FormatScan, + offset: number, + isFormatEnd: boolean, +): boolean { const {codeSpans, emphasis, text} = scan; scan.offset = offset; while ( @@ -108,7 +121,13 @@ export function advanceFormatScan(scan: FormatScan, offset: number): boolean { return false; } } - return text[offset - 1] !== '\\'; + const cut = text[offset - 1]; + return ( + cut !== '\\' && + (isFormatEnd || + cut !== text[offset] || + (cut !== '`' && !scan.delimiters.has(cut))) + ); } /** @@ -296,7 +315,7 @@ export function scanCodeSpans(text: string): { function scanDelimiters( text: string, - transformersIndex: TextFormatTransformersIndex, + delimiterChars: Set, codeSpans: {startIndex: number; endIndex: number}[], // What a run at the start (or end) of the text sees beyond it; the edge of // the line when undefined. @@ -304,11 +323,6 @@ function scanDelimiters( after: string | undefined, ): Delimiter[] { const delimiters: Delimiter[] = []; - const delimiterChars = new Set( - Object.keys(transformersIndex.transformersByTag) - .filter(tag => tag[0] !== '`') - .map(tag => tag[0]), - ); // The spans are in order and the scan only moves forward. let span = 0; diff --git a/packages/lexical-markdown/src/importTextMatchTransformer.ts b/packages/lexical-markdown/src/importTextMatchTransformer.ts index 6f02941f7ea..7498401c1fe 100644 --- a/packages/lexical-markdown/src/importTextMatchTransformer.ts +++ b/packages/lexical-markdown/src/importTextMatchTransformer.ts @@ -12,19 +12,17 @@ import {getLinkLabelOpener, LINK} from './MarkdownTransformers'; import {isEscaped} from './utils'; /** - * The first link match in `text` from `from` on, and where the search found - * it, which is before the match when its label holds the real opener. + * The first link match in `text`, and where the search found it, which is + * before the match when its label holds the real opener. */ function matchLink( text: string, regExp: RegExp, - from: number, ): [match: RegExpExecArray, found: number] | null { const search = new RegExp( regExp.source, regExp.flags.replace(/[gy]/g, '') + 'g', ); - search.lastIndex = from; let match; while ((match = search.exec(text)) !== null) { // A `[` that the label holds outside a code span is the link's real @@ -58,12 +56,43 @@ function matchLink( } /** - * Where the search for each transformer with the built-in link syntax found - * its match in a text, or `null` when there is none. The search finds the - * same match again from there in what is left of the text after any offset - * up to it, since the link syntax does not look behind its match. + * The match each transformer with the built-in link syntax found in a text, + * where its search found it and the offset of the text it was found in, or + * `null` when there is none. It is the first match in what is left of the + * text after any offset up to where it was found, since the link syntax does + * not look behind its match. */ -export type LinkMatches = Map; +export type LinkMatches = Map< + TextMatchTransformer, + {found: number; match: RegExpExecArray; offset: number} | null +>; + +/** `match`, for the text `distance` characters further on. */ +function moveMatch( + match: RegExpExecArray, + text: string, + distance: number, +): RegExpExecArray { + const moved = [...match] as RegExpExecArray; + moved.index = match.index - distance; + moved.input = text; + moved.groups = match.groups; + const {indices} = match; + if (indices) { + const move = (range: [number, number] | undefined) => + range && ([range[0] - distance, range[1] - distance] as [number, number]); + const movedIndices = indices.map(move) as RegExpIndicesArray; + if (indices.groups) { + const groups: Record = {}; + for (const name in indices.groups) { + groups[name] = move(indices.groups[name]); + } + movedIndices.groups = groups as RegExpIndicesArray['groups']; + } + moved.indices = movedIndices; + } + return moved; +} export function findOutermostTextMatchTransformer( textNode_: TextNode, @@ -97,16 +126,20 @@ export function findOutermostTextMatchTransformer( transformer.importRegExp.source === LINK.importRegExp.source ) { const found = links.get(transformer); - const linkMatch = - found === null - ? null - : matchLink( - text, - transformer.importRegExp, - found === undefined || found < offset ? 0 : found - offset, - ); - links.set(transformer, linkMatch && offset + linkMatch[1]); - match = linkMatch && linkMatch[0]; + if (found === undefined || (found !== null && found.found < offset)) { + const linkMatch = matchLink(text, transformer.importRegExp); + links.set( + transformer, + linkMatch && { + found: offset + linkMatch[1], + match: linkMatch[0], + offset, + }, + ); + match = linkMatch && linkMatch[0]; + } else { + match = found && moveMatch(found.match, text, offset - found.offset); + } } else { match = text.match(transformer.importRegExp); } @@ -166,6 +199,8 @@ export function importFoundTextMatchTransformer( transformedNode?: TextNode; nodeBefore: TextNode | undefined; // If split nodeAfter: TextNode | undefined; // If split + // The text of `nodeAfter` before the transformer ran, which may change it. + textAfter: string | undefined; } | null { let transformedNode, nodeAfter, nodeBefore; @@ -181,12 +216,14 @@ export function importFoundTextMatchTransformer( if (!transformer.replace) { return null; } + const textAfter = nodeAfter && nodeAfter.getTextContent(); const potentialTransformedNode = transformer.replace(transformedNode, match); return { matchedNode: transformedNode, nodeAfter, nodeBefore, + textAfter, transformedNode: potentialTransformedNode || undefined, }; } diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 622a171dd4b..e3a46f71a89 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -98,33 +98,20 @@ export function importTextTransformers( ); } for (let i = deferred.length - 1; i >= 0; i--) { - const [ - nodeBefore, - beforeBefore, - beforeAfter, - inside, - insideBefore, - insideAfter, - matched, - ] = deferred[i]; - if (canContainTransformableMarkdown(nodeBefore)) { - importTextTransformers( - nodeBefore, - textFormatTransformersIndex, - textMatchTransformers, - beforeBefore, - beforeAfter, - ); + const {outside, inside, matched} = deferred[i]; + for (const part of [outside, inside]) { + if (canContainTransformableMarkdown(part.node)) { + importTextTransformers( + part.node, + textFormatTransformersIndex, + textMatchTransformers, + part.before, + part.after, + ); + } } - if (canContainTransformableMarkdown(inside)) { - importTextTransformers( - inside, - textFormatTransformersIndex, - textMatchTransformers, - insideBefore, - insideAfter, - ); - } else if ( + if ( + !canContainTransformableMarkdown(inside.node) && canContainTransformableMarkdown(matched) && matched.getParent() !== null ) { @@ -137,20 +124,23 @@ export function importTextTransformers( } } +/** Text to import, with the characters around it in the text it was cut from. */ +interface TextPart { + node: LexicalNode | undefined; + before: string | undefined; + after: string | undefined; +} + /** * What a match leaves to import once the text after it is done: the text - * before it, then the text inside it, each with the characters around it, - * or the matched node of a text match that returned none. + * before it, then the text inside it, or the escapes of the matched node of + * a text match that returned no node. */ -type Deferred = [ - nodeBefore: LexicalNode | undefined, - beforeBefore: string | undefined, - beforeAfter: string | undefined, - inside: LexicalNode | undefined, - insideBefore: string | undefined, - insideAfter: string | undefined, - matched: LexicalNode | undefined, -]; +interface Deferred { + outside: TextPart; + inside: TextPart; + matched: LexicalNode | undefined; +} /** * The formats and links found in a text, for what is left of it, which is @@ -250,45 +240,46 @@ function importOutermost( } // Defers the import of the text before a match from `startIndex` to - // `endIndex` and of `inside` (or the escapes of `matched`), and returns - // the text after it, each with what lies beyond the run cut at its edge. - // A text match that ends inside a run of a delimiter cuts it, which the - // scan measured whole, so the rest is scanned again then. + // `endIndex` and of the text `inside` it (or the escapes of `matched`), + // and returns the text after it, `textAfter`, each with what lies beyond + // the run cut at its edge. const importAround = ( - nodeBefore: LexicalNode | undefined, - nodeAfter: LexicalNode | undefined, + { + nodeBefore, + nodeAfter, + textAfter, + }: { + nodeBefore: LexicalNode | undefined; + nodeAfter: LexicalNode | undefined; + textAfter: string | undefined; + }, startIndex: number, endIndex: number, - inside: LexicalNode | undefined, - insideBefore: string | undefined, - insideAfter: string | undefined, + inside: TextPart, matched: LexicalNode | undefined, - isTextMatch: boolean, + isFormat: boolean, ): TextRest | null => { - deferred.push([ - nodeBefore, - before, - characterBeyondRun(text, startIndex, 1, after), + deferred.push({ inside, - insideBefore, - insideAfter, matched, - ]); - if (!canContainTransformableMarkdown(nodeAfter)) { + outside: { + after: characterBeyondRun(text, startIndex, 1, after), + before, + node: nodeBefore, + }, + }); + if ( + !canContainTransformableMarkdown(nodeAfter) || + textAfter === undefined + ) { return null; } - const cut = text[endIndex - 1]; - const isReusable = - advanceFormatScan(formats, formats.offset + endIndex) && - !( - isTextMatch && - cut === text[endIndex] && - (cut === '`' || - Object.keys(textFormatTransformersIndex.transformersByTag).some( - tag => tag[0] === cut, - )) - ); - scan.rest = nodeAfter.getTextContent(); + const isReusable = advanceFormatScan( + formats, + formats.offset + endIndex, + isFormat, + ); + scan.rest = textAfter; return [ nodeAfter, characterBeyondRun(text, endIndex, -1, before), @@ -310,19 +301,23 @@ function importOutermost( // A code span's fence is not its tag's length, and its content is not // read for emphasis against what lies outside it. return importAround( - result.nodeBefore, - result.nodeAfter, + { + ...result, + textAfter: result.nodeAfter && result.nodeAfter.getTextContent(), + }, startIndex, endIndex, - result.transformedNode, - isCodeSpan - ? undefined - : characterBeyondRun(text, startIndex + tagLength, -1, before), - isCodeSpan - ? undefined - : characterBeyondRun(text, endIndex - tagLength, 1, after), + { + after: isCodeSpan + ? undefined + : characterBeyondRun(text, endIndex - tagLength, 1, after), + before: isCodeSpan + ? undefined + : characterBeyondRun(text, startIndex + tagLength, -1, before), + node: result.transformedNode, + }, undefined, - false, + true, ); } else if (foundTextMatch) { const result = importFoundTextMatchTransformer( @@ -336,15 +331,12 @@ function importOutermost( return null; } return importAround( - result.nodeBefore, - result.nodeAfter, + result, foundTextMatch.startIndex, foundTextMatch.endIndex, - result.transformedNode, - undefined, - undefined, + {after: undefined, before: undefined, node: result.transformedNode}, result.transformedNode === undefined ? result.matchedNode : undefined, - true, + false, ); } From 2c799853ba3d8a074661a66083dc4175ab480b1b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:44:23 +0000 Subject: [PATCH 53/70] [lexical-markdown] Bug Fix: Treat a scheme as a URL only with a host after it, and read edited text after a match on its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Fixes from the seventh review round of this branch against main: - On export, `http://`, `https://`, `ftp://` and `www.` count as the start of a bare URL only when a host follows, and `mailto:` and `xmpp:` only when an address does, as GFM readers link them. Scheme-only text such as italic `你.w mailto:` then bold `1` no longer holds back the reference that keeps the format, so it exports as `*你.w mailto:***1**`, which both importers read back. - The letter right before `mailto:` or `xmpp:` with an address gets no reference again: cmark-gfm checks the character before the scheme, so a `;` there would make GitHub link a different span. - When a text-match transformer changes the text after its match, that text is imported on its own, with no characters around it, as main does. 3436ba9 rescanned it but still gave it the character before the removed text, so `@bob _a_ _b_` with a mention transformer that trims the next space kept `_a_` literal. - The text after a match is passed on with named fields, a link match cut to its real opener is copied with the same helper as a moved one, and the deferred imports allocate no array per entry. ## Test plan ### Before `pnpm vitest run packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts --project unit -t "both importers read it back|before .mailto|on its own when its transformer"` with the sources of 3436ba9: ``` × exports [["你.w mailto:",2],["1",1]] so that both importers read it back 13ms × exports [[":http://",1],["你",2]] so that both importers read it back 2ms × writes no reference inside the letter before `mailto:`, which cmark-gfm would then link 2ms × reads the text after a match on its own when its transformer changes it 5ms Tests 4 failed | 3 passed | 762 skipped (769) ``` ### After ``` Tests 7 passed | 762 skipped (769) ``` Importing 60,000 random lines gives the same editor state as 3436ba9. `pnpm vitest run scripts/__tests__/unit/treeShakingSource.test.ts packages/lexical-markdown`: ``` Test Files 11 passed (11) Tests 1342 passed (1342) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 10 +- .../__tests__/unit/LexicalMarkdown.test.ts | 63 +++++++++++- .../src/importTextMatchTransformer.ts | 63 +++++------- .../src/importTextTransformers.ts | 97 ++++++++++--------- 4 files changed, 140 insertions(+), 93 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index c996058d1bc..ecdd85ef6dd 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -732,12 +732,14 @@ function isLinkCharacter(character: string): boolean { } // A URL or `www.` host in a run of link characters: GFM links `http://`, // `https://` (and cmark-gfm `ftp://`) after anything but a letter, and starts -// `www.` after `_` too. +// `www.` after `_` too, each only with a host after it, and `mailto:` or +// `xmpp:` only with an address. const LINK_URL = - /(?:^|[^a-z])(?:https?|ftp):\/\/|(?:^|[^a-z\d.-])www\.|(?:^|[^a-z\d.+-])(?:mailto|xmpp):/i; + /(?:^|[^a-z])(?:https?|ftp):\/\/(?=[\p{L}\p{N}_-])|(?:^|[^a-z\d.-])www\.(?=[\p{L}\p{N}_-])|(?:^|[^a-z\d.+-])(?:mailto|xmpp):(?=[\w.+-]+@)/iu; // A URL scheme anywhere: a reference for the letter before it would put the -// `;` there, after which a URL starts (`www.` and `mailto:` need more). -const URL_START = /(?:https?|ftp):\/\//gi; +// `;` there, after which a URL starts (`www.` needs whitespace or `*_~(`). +const URL_START = + /(?:https?|ftp):\/\/(?=[\p{L}\p{N}_-])|(?:mailto|xmpp):(?=[\w.+-]+@)/giu; // Characters an email can hold (word characters, `.+@-`, and the backslash // that escapes one), and what makes one: GFM needs a dot in its domain. const ADDRESS_CHARACTER = /[\w.+@\\-]/; diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 1b1c8160051..8a3789f5a18 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5372,6 +5372,21 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ], '**\u6ce8\u610f\uff1a**请\u5148\u767b\u5f55', ], + // A scheme or `mailto:` with no host or address after it starts no URL. + [ + [ + ['\u4f60.w mailto:', TEXT_TYPE_TO_FORMAT.italic], + ['1', TEXT_TYPE_TO_FORMAT.bold], + ], + '*\u4f60.w mailto:***1**', + ], + [ + [ + [':http://', TEXT_TYPE_TO_FORMAT.bold], + ['\u4f60', TEXT_TYPE_TO_FORMAT.italic], + ], + '**:http://***你*', + ], ])('exports %j so that both importers read it back', (runs, markdown) => { const $runs = () => $getRoot() @@ -5582,6 +5597,14 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ], '**.**ahttp://x.co', ], + [ + 'the letter before `mailto:`, which cmark-gfm would then link', + [ + ['.', TEXT_TYPE_TO_FORMAT.bold], + ['bmailto:a@b.co', 0], + ], + '**.**bmailto:a@b.co', + ], ])('writes no reference inside %s', (_name, runs, markdown) => { const editor = createTestEditor(); editor.update( @@ -5669,9 +5692,9 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); - // A `;` before them starts no URL: `www.` needs whitespace or `*_~(` before - // it, and readers ignore what comes before `mailto:`. - it.each(['bwww.x.co', 'bmailto:a@b.co'])( + // A `;` before it starts no URL: `www.` needs whitespace or `*_~(` before + // it. + it.each(['bwww.x.co'])( 'writes a reference before %j, which readers do not autolink', text => { const editor = createTestEditor(); @@ -5725,6 +5748,40 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ]); }); + it('reads the text after a match on its own when its transformer changes it', () => { + const MENTION: TextMatchTransformer = { + dependencies: [], + importRegExp: /@(\w+)/, + regExp: /@(\w+)$/, + replace: textNode => { + const next = textNode.getNextSibling(); + if ($isTextNode(next)) { + next.setTextContent(next.getTextContent().trimStart()); + } + textNode.toggleFormat('code'); + }, + type: 'text-match', + }; + const editor = createTestEditor(); + editor.update( + () => + $convertFromMarkdownString('@bob _a_ _b_', [MENTION, ...TRANSFORMERS]), + {discrete: true}, + ); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.getFormat()]), + ), + ).toEqual([ + ['@bob', TEXT_TYPE_TO_FORMAT.code], + ['a', TEXT_TYPE_TO_FORMAT.italic], + [' ', 0], + ['b', TEXT_TYPE_TO_FORMAT.italic], + ]); + }); + it('imports the text after a match before the text inside it', () => { const seen: string[] = []; const TAG: TextMatchTransformer = { diff --git a/packages/lexical-markdown/src/importTextMatchTransformer.ts b/packages/lexical-markdown/src/importTextMatchTransformer.ts index 7498401c1fe..cd040a0d01d 100644 --- a/packages/lexical-markdown/src/importTextMatchTransformer.ts +++ b/packages/lexical-markdown/src/importTextMatchTransformer.ts @@ -11,6 +11,26 @@ import type {TextNode} from 'lexical'; import {getLinkLabelOpener, LINK} from './MarkdownTransformers'; import {isEscaped} from './utils'; +/** + * A copy of `match` for `text`, which is what is left of the text it was + * found in after `distance` characters. The link syntax has no named groups. + */ +function moveMatch( + match: RegExpExecArray, + text: string, + distance: number, +): RegExpExecArray { + const moved = [...match] as RegExpExecArray; + moved.index = match.index - distance; + moved.input = text; + if (match.indices) { + moved.indices = match.indices.map( + range => range && [range[0] - distance, range[1] - distance], + ) as RegExpIndicesArray; + } + return moved; +} + /** * The first link match in `text`, and where the search found it, which is * before the match when its label holds the real opener. @@ -31,19 +51,13 @@ function matchLink( const opener = getLinkLabelOpener(match[1]); if (opener !== null && opener >= 0) { const cut = opener + 1; - const inner = [...match] as RegExpExecArray; + const inner = moveMatch(match, text, 0); inner[0] = match[0].slice(cut); inner[1] = match[1].slice(cut); - inner.index = match.index + cut; - inner.input = text; - const {indices} = match; - const whole = indices && indices[0]; - const label = indices && indices[1]; - if (indices && whole && label) { - const moved = [...indices] as RegExpIndicesArray; - moved[0] = [inner.index, whole[1]]; - moved[1] = [label[0] + cut, label[1]]; - inner.indices = moved; + inner.index += cut; + if (inner.indices && inner.indices[0] && inner.indices[1]) { + inner.indices[0][0] += cut; + inner.indices[1][0] += cut; } return [inner, match.index]; } @@ -67,33 +81,6 @@ export type LinkMatches = Map< {found: number; match: RegExpExecArray; offset: number} | null >; -/** `match`, for the text `distance` characters further on. */ -function moveMatch( - match: RegExpExecArray, - text: string, - distance: number, -): RegExpExecArray { - const moved = [...match] as RegExpExecArray; - moved.index = match.index - distance; - moved.input = text; - moved.groups = match.groups; - const {indices} = match; - if (indices) { - const move = (range: [number, number] | undefined) => - range && ([range[0] - distance, range[1] - distance] as [number, number]); - const movedIndices = indices.map(move) as RegExpIndicesArray; - if (indices.groups) { - const groups: Record = {}; - for (const name in indices.groups) { - groups[name] = move(indices.groups[name]); - } - movedIndices.groups = groups as RegExpIndicesArray['groups']; - } - moved.indices = movedIndices; - } - return moved; -} - export function findOutermostTextMatchTransformer( textNode_: TextNode, textMatchTransformers: TextMatchTransformer[], diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index e3a46f71a89..326dcfaf971 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -85,31 +85,19 @@ export function importTextTransformers( // with what was found in the whole text, which still holds for the rest of // it as long as no match there reached into what came before. const deferred: Deferred[] = []; - let rest: TextRest | null = [textNode, before, after, null]; + let rest: TextRest | null = {after, before, node: textNode, scan: null}; while (rest !== null) { rest = importOutermost( - rest[0], + rest, textFormatTransformersIndex, textMatchTransformers, - rest[1], - rest[2], - rest[3], deferred, ); } for (let i = deferred.length - 1; i >= 0; i--) { const {outside, inside, matched} = deferred[i]; - for (const part of [outside, inside]) { - if (canContainTransformableMarkdown(part.node)) { - importTextTransformers( - part.node, - textFormatTransformersIndex, - textMatchTransformers, - part.before, - part.after, - ); - } - } + importTextPart(outside, textFormatTransformersIndex, textMatchTransformers); + importTextPart(inside, textFormatTransformersIndex, textMatchTransformers); if ( !canContainTransformableMarkdown(inside.node) && canContainTransformableMarkdown(matched) && @@ -131,6 +119,22 @@ interface TextPart { after: string | undefined; } +function importTextPart( + {node, before, after}: TextPart, + textFormatTransformersIndex: TextFormatTransformersIndex, + textMatchTransformers: TextMatchTransformer[], +) { + if (canContainTransformableMarkdown(node)) { + importTextTransformers( + node, + textFormatTransformersIndex, + textMatchTransformers, + before, + after, + ); + } +} + /** * What a match leaves to import once the text after it is done: the text * before it, then the text inside it, or the escapes of the matched node of @@ -152,12 +156,13 @@ interface TextScan { rest: string; } -type TextRest = [ - textNode: TextNode, - before: string | undefined, - after: string | undefined, - scan: TextScan | null, -]; +/** The text after a match, and the scan of the text it was cut from. */ +interface TextRest { + node: TextNode; + before: string | undefined; + after: string | undefined; + scan: TextScan | null; +} /** * Applies the outermost transformer in `textNode`, adds the import of the @@ -165,12 +170,9 @@ type TextRest = [ * it, for the caller to import next. */ function importOutermost( - textNode: TextNode, + {node: textNode, before, after, scan: previousScan}: TextRest, textFormatTransformersIndex: TextFormatTransformersIndex, textMatchTransformers: TextMatchTransformer[], - before: string | undefined, - after: string | undefined, - previousScan: TextScan | null, deferred: Deferred[], ): TextRest | null { const text = textNode.getTextContent(); @@ -241,23 +243,24 @@ function importOutermost( // Defers the import of the text before a match from `startIndex` to // `endIndex` and of the text `inside` it (or the escapes of `matched`), - // and returns the text after it, `textAfter`, each with what lies beyond - // the run cut at its edge. + // and returns the text after it, each with what lies beyond the run cut at + // its edge. A text match's transformer has run, and when it changed the + // text after it, that text is imported on its own, as before it was cut + // from the line; `textAfter` is what that text was before, and `null` for + // a format match. const importAround = ( { nodeBefore, nodeAfter, - textAfter, }: { nodeBefore: LexicalNode | undefined; nodeAfter: LexicalNode | undefined; - textAfter: string | undefined; }, + textAfter: string | null | undefined, startIndex: number, endIndex: number, inside: TextPart, matched: LexicalNode | undefined, - isFormat: boolean, ): TextRest | null => { deferred.push({ inside, @@ -268,24 +271,25 @@ function importOutermost( node: nodeBefore, }, }); - if ( - !canContainTransformableMarkdown(nodeAfter) || - textAfter === undefined - ) { + if (!canContainTransformableMarkdown(nodeAfter)) { return null; } + const rest = nodeAfter.getTextContent(); + if (textAfter !== null && textAfter !== rest) { + return {after: undefined, before: undefined, node: nodeAfter, scan: null}; + } const isReusable = advanceFormatScan( formats, formats.offset + endIndex, - isFormat, + textAfter === null, ); - scan.rest = textAfter; - return [ - nodeAfter, - characterBeyondRun(text, endIndex, -1, before), + scan.rest = rest; + return { after, - isReusable ? scan : null, - ]; + before: characterBeyondRun(text, endIndex, -1, before), + node: nodeAfter, + scan: isReusable ? scan : null, + }; }; if (foundTextFormat) { @@ -301,10 +305,8 @@ function importOutermost( // A code span's fence is not its tag's length, and its content is not // read for emphasis against what lies outside it. return importAround( - { - ...result, - textAfter: result.nodeAfter && result.nodeAfter.getTextContent(), - }, + result, + null, startIndex, endIndex, { @@ -317,7 +319,6 @@ function importOutermost( node: result.transformedNode, }, undefined, - true, ); } else if (foundTextMatch) { const result = importFoundTextMatchTransformer( @@ -332,11 +333,11 @@ function importOutermost( } return importAround( result, + result.textAfter, foundTextMatch.startIndex, foundTextMatch.endIndex, {after: undefined, before: undefined, node: result.transformedNode}, result.transformedNode === undefined ? result.matchedNode : undefined, - false, ); } From 8a5047aee5be117db2b632a2d790305b700ac3a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:45:39 +0000 Subject: [PATCH 54/70] [lexical-markdown] Documentation Update: Fence the escaped-backtick example in the changeset so it renders ## Description The changeset's example of an escaped backtick before a code span holds two backticks in a row, so its two-backtick fence closed early. It now uses a three-backtick fence. Co-Authored-By: Claude Opus 5.5 --- .changeset/markdown-unicode-punctuation-flanking.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 143895ed929..37ec54e72c5 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -4,6 +4,6 @@ [fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_`, `~~` and `==` markers open or close formatting -Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match that ends before a code span is applied ahead of it, so `[a](b) and [`c`](d)` keeps both links, and one no longer takes part of any code span in the text, not only the first. A link starts at the `[` its label really opens with, so text before a stray `[` is read as Markdown: `[`x`[c](d)` has a code span. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. An escaped backtick right before more backticks no longer keeps the rest of that run from opening a code span, so ``x\``bb`x`` imports with code `bb`, as CommonMark reads it. Long lines import faster, since their code spans, emphasis and links are found once per line rather than again after each match. +Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match that ends before a code span is applied ahead of it, so `[a](b) and [`c`](d)` keeps both links, and one no longer takes part of any code span in the text, not only the first. A link starts at the `[` its label really opens with, so text before a stray `[` is read as Markdown: `[`x`[c](d)` has a code span. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. An escaped backtick right before more backticks no longer keeps the rest of that run from opening a code span, so ```x\``bb`x``` imports with code `bb`, as CommonMark reads it. Long lines import faster, since their code spans, emphasis and links are found once per line rather than again after each match. Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz`, `**你好。**世界` or `**注意:**请先登录`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Those references are chosen so that micromark-based readers such as `@lexical/mdast`, which read an emoji or another character outside the Basic Multilingual Plane as a letter, format it the same way, so plain `😀` then bold `“b”` exports as `😀**“b”**`. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it, except inside a bare URL. With `shouldPreserveNewLines`, backslashes right before a `*`, `_`, `` ` `` or `~` of the text are escaped too, so that they no longer cancel that character's escape. No reference is written inside a bare URL, `www.` host or email, or for the letter right before `http://`, `https://` or `ftp://`, where its `;` would start a URL, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `see https://x.com/a**\_x**`. From 6be285214ca07dd4bf57c1582e3e2e1f141b5bcf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 15:30:55 +0000 Subject: [PATCH 55/70] [lexical-markdown] Bug Fix: Keep formatting beside a bare URL by spoiling the link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Export wrote no character reference inside a bare URL or `www.` host, or for the letter right before a scheme, so formatting that needed one there was lost. It also wrote markers and code spans right after a URL, which GFM readers (micromark for `@lexical/mdast`, and cmark-gfm) take into the link, so the formatting came back as literal markers inside a link: `**www.x.co**a` and `see https://x.com/a**“x”**`. Export now writes the reference, and where a URL that GFM readers would link would hold one, or would take in the markers, code span or other raw output after it, it also writes the `.` of `www.` or the `:` after the scheme as a reference (`**www.x.co**a`, `see https://x.com/a**“x”**`). No reader then links it, and the text and formats come through in `@lexical/markdown`, `@lexical/mdast` and cmark-gfm. A reference that already lands in the prefix spoils the link by itself, and a URL that a reference before it would start is spoiled the same way. Emails and `mailto:` addresses need nothing: both readers find them in the decoded text and take in no format. A URL is found as GFM readers start one: `www.` only at the start of a run or after `*_~(`, a scheme after anything but a letter. Raw output keeps its letters and `*_~(` for that scan, so a URL runs on into it as it does for the readers. This also fixes plain text such as `[hello]](https://lexical.dev)` before a link, whose URL GFM readers ran on into the link. ## Test plan Command: `pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts` ### Before With this commit's tests and the previous `MarkdownExport.ts`: ``` × can export "[hello]](https://lexical.dev)[world](https://lexical.dev)" 17ms × exports formatting beside a `www.` host, which both importers read back 2ms × exports formatting beside an email, whose references readers decode first, which both importers read back 1ms × exports formatting beside a URL, which both importers read back 1ms × exports formatting beside an `xmpp:` address, whose references readers decode first, which both importers read back 1ms × exports formatting beside a `www.` host after `_`, which both importers read back 1ms × exports formatting beside a URL with a Unicode host, which both importers read back 1ms × exports formatting beside a URL that holds an emoji, which both importers read back 1ms × exports formatting beside the start of a `www.` host, which its reference spoils, which both importers read back 1ms × exports formatting beside the start of a URL, which its reference spoils, which both importers read back 1ms × exports formatting beside the start of `mailto:`, which its reference spoils, which both importers read back 1ms × exports formatting beside the start of an email, which both importers read back 1ms × exports formatting beside a URL that a reference before it would start, which both importers read back 1ms × exports formatting beside `mailto:`, which a reference before it does not change, which both importers read back 1ms × exports formatting beside a URL right after a code span, which both importers read back 1ms × exports formatting beside an email before a URL, which both importers read back 1ms × exports formatting beside a URL that would take in a code span, which both importers read back 1ms × exports formatting beside a URL that runs on through formats, which both importers read back 1ms × exports formatting beside a `www.` host that would take in markers, which both importers read back 1ms × exports formatting beside a `www.` host that would take in italic, which both importers read back 1ms × exports formatting beside a `www.` host that would take in markers after `:`, which both importers read back 1ms × exports formatting beside a `www.` host that would take in a code span, which both importers read back 1ms × exports formatting beside a URL that would take in strikethrough, which both importers read back 1ms × exports formatting beside a URL that holds a `www.` host, which both importers read back 1ms × exports formatting beside a URL that would take in the backslash of an escape at its end, which both importers read back 1ms × exports formatting beside a URL that holds an email, which both importers read back 1ms × spoils a URL for the reference to a backslash before a marker 1ms × spoils a URL for the reference to a tag character before its marker 1ms ⎯⎯⎯⎯⎯⎯ Failed Tests 28 ⎯⎯⎯⎯⎯⎯⎯ Test Files 1 failed (1) Tests 28 failed | 747 passed (775) ``` ### After ``` Test Files 1 passed (1) Tests 775 passed (775) ``` The new expectations were also checked by hand against cmark-gfm. On 80,000 random paragraphs of URL fragments and formats, against the previous commit, no export reads back worse in `@lexical/markdown`, `@lexical/mdast` or cmark-gfm, and about 1,700 in 20,000 more read back right in each GFM reader. Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 2 +- packages/lexical-markdown/README.md | 7 +- .../lexical-markdown/src/MarkdownExport.ts | 270 +++++----- .../__tests__/unit/LexicalMarkdown.test.ts | 461 +++++++++++------- 4 files changed, 458 insertions(+), 282 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 37ec54e72c5..71d2751c986 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -6,4 +6,4 @@ Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match that ends before a code span is applied ahead of it, so `[a](b) and [`c`](d)` keeps both links, and one no longer takes part of any code span in the text, not only the first. A link starts at the `[` its label really opens with, so text before a stray `[` is read as Markdown: `[`x`[c](d)` has a code span. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. An escaped backtick right before more backticks no longer keeps the rest of that run from opening a code span, so ```x\``bb`x``` imports with code `bb`, as CommonMark reads it. Long lines import faster, since their code spans, emphasis and links are found once per line rather than again after each match. -Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz`, `**你好。**世界` or `**注意:**请先登录`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Those references are chosen so that micromark-based readers such as `@lexical/mdast`, which read an emoji or another character outside the Basic Multilingual Plane as a letter, format it the same way, so plain `😀` then bold `“b”` exports as `😀**“b”**`. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it, except inside a bare URL. With `shouldPreserveNewLines`, backslashes right before a `*`, `_`, `` ` `` or `~` of the text are escaped too, so that they no longer cancel that character's escape. No reference is written inside a bare URL, `www.` host or email, or for the letter right before `http://`, `https://` or `ftp://`, where its `;` would start a URL, so formatting with punctuation at its edge right beside one is lost there, as is formatting that starts with an escaped character, such as `see https://x.com/a**\_x**`. +Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz`, `**你好。**世界` or `**注意:**请先登录`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Those references are chosen so that micromark-based readers such as `@lexical/mdast`, which read an emoji or another character outside the Basic Multilingual Plane as a letter, format it the same way, so plain `😀` then bold `“b”` exports as `😀**“b”**`. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it. With `shouldPreserveNewLines`, backslashes right before a `*`, `_`, `` ` `` or `~` of the text are escaped too, so that they no longer cancel that character's escape. A bare URL, `www.` host or email that GFM readers would link, and that would hold such a reference or take in the markers or code span after it, now has a character of its prefix written as a reference too, so that no reader links it and the formatting and text come through: bold `www.x.co` then `a` exports as `**www.x.co**a`, and plain `see https://x.com/a` then bold `“x”` as `see https://x.com/a**“x”**`, where a GFM reader used to take the markers into the link. `@lexical/mdast` still links the decoded text. diff --git a/packages/lexical-markdown/README.md b/packages/lexical-markdown/README.md index d46e078604d..ec3addbcfc5 100644 --- a/packages/lexical-markdown/README.md +++ b/packages/lexical-markdown/README.md @@ -103,8 +103,11 @@ starts or ends with punctuation right beside a letter, export writes that letter as a numeric reference (`foo**“bar”**`) so that the markers still open and close, for micromark-based readers such as `@lexical/mdast` too. This holds with `shouldPreserveNewLines` as well, where a raw backslash right before -such a marker or reference goes out as `\` outside a bare URL. No -reference is written inside a bare URL, `www.` host or email. +such a marker or reference goes out as `\`. Where a bare URL, `www.` host +or email that GFM readers would link holds such a reference, or would take in +the markers or code span after it, export writes a character of its prefix as +a reference too (`www.x.co*a*b`), so that no reader links it and the +formatting comes through. There're three types of transformers: diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index ecdd85ef6dd..6515e81ff3e 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -24,7 +24,6 @@ import { type TextNode, } from 'lexical'; -import {scanCodeSpans} from './importTextFormatTransformer'; import { type ElementTransformer, getCodeSpanDelimiter, @@ -730,23 +729,20 @@ const LINK_END = /[\s\p{Cc}<]/u; function isLinkCharacter(character: string): boolean { return !LINK_END.test(character); } -// A URL or `www.` host in a run of link characters: GFM links `http://`, -// `https://` (and cmark-gfm `ftp://`) after anything but a letter, and starts -// `www.` after `_` too, each only with a host after it, and `mailto:` or -// `xmpp:` only with an address. -const LINK_URL = - /(?:^|[^a-z])(?:https?|ftp):\/\/(?=[\p{L}\p{N}_-])|(?:^|[^a-z\d.-])www\.(?=[\p{L}\p{N}_-])|(?:^|[^a-z\d.+-])(?:mailto|xmpp):(?=[\w.+-]+@)/iu; -// A URL scheme anywhere: a reference for the letter before it would put the -// `;` there, after which a URL starts (`www.` needs whitespace or `*_~(`). -const URL_START = - /(?:https?|ftp):\/\/(?=[\p{L}\p{N}_-])|(?:mailto|xmpp):(?=[\w.+-]+@)/giu; -// Characters an email can hold (word characters, `.+@-`, and the backslash -// that escapes one), and what makes one: GFM needs a dot in its domain. -const ADDRESS_CHARACTER = /[\w.+@\\-]/; -function isAddressCharacter(character: string): boolean { - return ADDRESS_CHARACTER.test(character); +// What starts a URL: `http://`, `https://` (and cmark-gfm's `ftp://`) with +// a host after it, which may start with an emoji but not with other ASCII +// punctuation than `-` and `_`, and `www.`, which micromark links with +// anything after it. +// GFM readers start `www.` only at the start of a run or after `*_~(`, and a +// scheme after anything but a letter. They find an email, and the address +// after `mailto:` or `xmpp:`, in the text with its references decoded, and +// take no format into one. +const LINK_PREFIX = /(?:https?|ftp):\/\/(?![\s\p{Cc}!-,./:-@[-^`{-~])|www\./giu; +function startsLink(prefix: string, before: string): boolean { + return /^w/i.test(prefix) ? '*_~('.includes(before) : !/[a-z]/i.test(before); } -const ADDRESS = /[\w.+-]@[\w-]+\.[\w-]/; +// Characters that GFM readers leave out at the end of a URL. +const LINK_TRAIL = '?!.,:*_~'; // Characters that a reference does not stand in for everywhere: CommonMark // readers and HTML decode references to control characters, unpaired @@ -784,71 +780,101 @@ interface MarkerRun { * reference instead, whose `&` or `;` is punctuation: `a**\***`. */ function joinPieces({kinds, values}: MarkdownPieces): string { - if (!kinds.some(isMarker)) { + if (kinds.every(kind => kind === TEXT)) { return values.join(''); } - const {encodeStart, encodeEnd, isLinkish} = decideReferences(kinds, values); - let markdown = ''; - for (let index = 0; index < kinds.length; index++) { - let value = values[index]; - let start = ''; - let end = ''; + const {encodeStart, encodeEnd} = decideReferences(kinds, values); + const count = kinds.length; + // How many code units of each piece go out as a reference at its start + // and at its end, and whether the backslash before the end does. + const startLengths = new Uint8Array(count); + const endLengths = new Uint8Array(count); + const escapesEnd = new Uint8Array(count); + for (let index = 0; index < count; index++) { + const value = values[index]; if (encodeStart[index]) { - const first = characterAt(value, 0)!; - start = toCharacterReference(first); - value = value.slice(first.length); + startLengths[index] = characterAt(value, 0)!.length; } - if (encodeEnd[index] && value !== '') { - const last = characterBefore(value, value.length)!; - end = toCharacterReference(last); - value = value.slice(0, -last.length); + if (encodeEnd[index] && value.length > startLengths[index]) { + endLengths[index] = characterBefore(value, value.length)!.length; } // A backslash left unescaped (when preserving newlines, or in decorator - // text) would escape the reference or marker written after it, though - // not inside a bare URL, where no reference is written. - if ( + // text) would escape the reference or marker written after it. + const bodyEnd = value.length - endLengths[index]; + escapesEnd[index] = Number( kinds[index] === TEXT && - isMarker(kinds[index + 1]) && - isEscaped(value, value.length) && - !isLinkish(index, true) + isMarker(kinds[index + 1]) && + bodyEnd > startLengths[index] && + isEscaped(value, bodyEnd), + ); + } + const {starts, spoiled} = findSpoiledLinks(kinds, values, (index, mark) => { + mark(0, startLengths[index]); + const end = values[index].length - endLengths[index]; + mark(end - escapesEnd[index], values[index].length); + }); + let markdown = ''; + let next = 0; + for (let index = 0; index < count; index++) { + const value = values[index]; + const base = starts[index]; + const end = value.length - endLengths[index] - escapesEnd[index]; + let from = startLengths[index]; + markdown += toReferences(value.slice(0, from)); + for ( + ; + next < spoiled.length && spoiled[next] < base + value.length; + next++ ) { - value = value.slice(0, -1) + toCharacterReference('\\'); + const at = spoiled[next] - base; + markdown += value.slice(from, at) + toCharacterReference(value[at]); + from = at + 1; } - markdown += start + value + end; + markdown += + value.slice(from, end) + + toReferences(value.slice(end, end + escapesEnd[index])) + + toReferences(value.slice(end + escapesEnd[index])); } return markdown; } +// The character in `value`, if any, as a reference. +function toReferences(value: string): string { + return value === '' ? '' : toCharacterReference(value); +} + /** - * Raw output as the bare-URL scan sees it: spaces, except that a code span - * keeps its backticks and its whitespace, and the rest of it is letters. + * Raw output as the bare-URL scan sees it. It keeps whitespace and `<`, which + * end a URL, `*_~(`, after which `www.` starts one, and ASCII letters, before + * which no scheme starts one, as `x`, and is `#` elsewhere. So no URL starts + * inside it, but one before it runs on into it, as GFM readers run a URL on + * through a code span or a link. */ function maskRaw(value: string): string { - let masked = ''; - let previous = 0; - for (const {startIndex, endIndex} of scanCodeSpans(value)) { - masked += - ' '.repeat(startIndex - previous) + - value.slice(startIndex, endIndex).replace(/[^\s`]/g, 'x'); - previous = endIndex; - } - return masked + ' '.repeat(value.length - previous); + return value.replace(/[a-z]/gi, 'x').replace(/[^\sx<*_~(]/g, '#'); } /** - * For each piece, 1 when its first character is part of a bare URL, `www.` - * host or email, plus 2 when its last one is. A reference inside one - * would change what an autolinking reader links to, so the format is lost - * there instead, as before. The pieces are read as one text, since a URL can - * run on through markers and other formats, and each character is looked at - * a bounded number of times. Raw output, such as an inline link's destination, - * is not bare text, so it ends a run. + * Finds the bare URLs and `www.` hosts that GFM readers would link in the + * joined pieces, and that a reference would land in or that would take in + * the markers, code span or other raw output after them: the reader would + * keep the reference's characters in the link, or the markers and code as + * text. Such a URL is spoiled instead, by writing the `.` of `www.` or the + * `:` after its scheme as a reference, so that no reader links it and its + * text and formats come through. So is a URL that a reference before it + * would start. `markReferences` is given each text piece and a function that + * marks the code units of it, from one offset to another, that go out as + * references. Returns where each piece starts in the joined text and, in + * order, the code units of it to write as references. */ -function findLinkEdges(kinds: PieceKind[], values: string[]): Uint8Array { - // Raw output ends a run, so it is joined as spaces, except a code span: - // GFM autolinks run on through backticks, as in `https://a.com/`v`s`, and - // start after them, so it joins as its backticks around letters, which - // cannot start a URL themselves. +function findSpoiledLinks( + kinds: PieceKind[], + values: string[], + markReferences: ( + index: number, + mark: (from: number, to: number) => void, + ) => void, +): {starts: Uint32Array; spoiled: number[]} { let text = ''; const starts = new Uint32Array(values.length + 1); for (let i = 0; i < values.length; i++) { @@ -856,54 +882,81 @@ function findLinkEdges(kinds: PieceKind[], values: string[]): Uint8Array { text += kinds[i] === RAW ? maskRaw(values[i]) : values[i]; } starts[values.length] = text.length; - const inLink = new Uint8Array(text.length); - const runEnd = ( - start: number, - isPart: (character: string) => boolean, - limit: number, - ) => { + const isReference = new Uint8Array(text.length); + for (let i = 0; i < values.length; i++) { + if (kinds[i] === TEXT) { + markReferences(i, (from, to) => + isReference.fill(1, starts[i] + from, starts[i] + to), + ); + } + } + // Running counts of referenced code units and of code units that are not + // text, so that whether any lie in a span is a subtraction. + const references = new Uint32Array(text.length + 1); + const formats = new Uint32Array(text.length + 1); + for (let i = 0, piece = 0; i < text.length; i++) { + while (starts[piece + 1] <= i) { + piece++; + } + references[i + 1] = references[i] + isReference[i]; + formats[i + 1] = formats[i] + Number(kinds[piece] !== TEXT); + } + const has = (sums: Uint32Array, from: number, to: number) => + from < to && sums[to] > sums[from]; + const spoiled: number[] = []; + for (let start = 0; start < text.length; ) { let end = start; - while (end < limit && isPart(text[end])) { + while (end < text.length && isLinkCharacter(text[end])) { end++; } - return end; - }; - for (let start = 0; start < text.length; ) { - const end = runEnd(start, isLinkCharacter, text.length); if (end === start) { start++; continue; } - // The URL starts at its scheme, `www.` or `mailto:`, and what comes - // before it in the run is ordinary text, which may hold an email. - const url = LINK_URL.exec(text.slice(start, end)); - const urlStart = - url === null ? end : start + url.index + (/^[a-z]/i.test(url[0]) ? 0 : 1); - inLink.fill(1, urlStart, end); - const run = text.slice(start, urlStart); - for (const {index} of run.matchAll(URL_START)) { - if (index > 0) { - inLink[start + index - 1] = 1; - } + // A URL runs to the end of the run, less the punctuation at its end. It + // takes in a backslash before that punctuation, which then no longer + // escapes it, so the punctuation can join the markers after it. + let trimmed = end; + while (trimmed > start && LINK_TRAIL.includes(text[trimmed - 1])) { + trimmed--; } - for (let from = start; from < urlStart; ) { - const to = runEnd(from, isAddressCharacter, urlStart); - if (to === from) { - from++; + if (isEscaped(text, trimmed) && has(formats, trimmed, end)) { + trimmed = end; + } + // Where the next URL may start: after the prefix of a spoiled one. + let rest = start; + for (const prefix of text.slice(start, end).matchAll(LINK_PREFIX)) { + const at = start + prefix.index; + if (at < rest) { + continue; + } + // A reference before the prefix, or a spoiled `.` or `:`, puts `;` + // there. + const before = at > start ? text[at - 1] : ''; + const wasLink = at === start || startsLink(prefix[0], before); + const isLink = + at === start || + startsLink( + prefix[0], + isReference[at - 1] || spoiled[spoiled.length - 1] === at - 1 + ? ';' + : before, + ); + if (!isLink) { continue; } - if (ADDRESS.test(text.slice(from, to).replace(/\\/g, ''))) { - inLink.fill(1, from, to); + if (wasLink && !has(references, at, end) && !has(formats, at, trimmed)) { + break; + } + rest = at + prefix[0].length; + // A reference in the prefix spoils it already. + if (!has(references, at, rest)) { + spoiled.push(at + prefix[0].search(/[.:]/)); } - from = to; } start = end; } - const edges = new Uint8Array(values.length); - for (let i = 0; i < values.length; i++) { - edges[i] = inLink[starts[i]] | (inLink[starts[i + 1] - 1] << 1); - } - return edges; + return {spoiled, starts}; } /** @@ -949,7 +1002,6 @@ function decideReferences( ): { encodeStart: Uint8Array; encodeEnd: Uint8Array; - isLinkish: (index: number, atEnd: boolean) => boolean; } { const count = kinds.length; const encodeStart = new Uint8Array(count); @@ -1021,24 +1073,13 @@ function decideReferences( : encodeEnd[index] || isEncoded(index) ? ';' : lastChar(index); - // Whether the last (or first) character of the piece at `index` is part of - // a bare URL, `www.` host, or email, worked out for every piece the - // first time it is asked. - let linkEdges: Uint8Array | undefined; - const isLinkish = (index: number, atEnd: boolean): boolean => { - if (linkEdges === undefined) { - linkEdges = findLinkEdges(kinds, values); - } - return (linkEdges[index] & (atEnd ? 2 : 1)) !== 0; - }; // Whether the last (or first) character of the piece at `index` can go out // as a reference. const canReference = (index: number, atEnd: boolean): boolean => index >= 0 && index < count && kinds[index] === TEXT && - isReferenceable(atEnd ? endChar(index) : startChar(index)) && - !isLinkish(index, atEnd); + isReferenceable(atEnd ? endChar(index) : startChar(index)); // Writing a one-character piece as a reference makes its other edge // punctuation too. That can stop the delimiter run touching that edge from // flanking unless the piece past it is written as a reference as well, and @@ -1129,23 +1170,20 @@ function decideReferences( }; // Text does not escape every tag character (the `=` of `==`, say), and one // right beside a marker of the same character would join its delimiter - // run, so it goes out as a reference, except in a bare URL, as with any - // reference. + // run, so it goes out as a reference. for (let i = 0; i < count; i++) { const value = values[i]; if (kinds[i] === TEXT) { if ( isMarker(kinds[i + 1]) && value[value.length - 1] === values[i + 1][0] && - !isEscaped(value, value.length - 1) && - !isLinkish(i, true) + !isEscaped(value, value.length - 1) ) { encodeEnd[i] = 1; } if ( isMarker(kinds[i - 1]) && - value[0] === values[i - 1][values[i - 1].length - 1] && - !isLinkish(i, false) + value[0] === values[i - 1][values[i - 1].length - 1] ) { encodeStart[i] = 1; } @@ -1185,7 +1223,7 @@ function decideReferences( } } } - return {encodeEnd, encodeStart, isLinkish}; + return {encodeEnd, encodeStart}; } function getTextSibling(node: TextNode, backward: boolean): TextNode | null { diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 8a3789f5a18..fe225da8811 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -990,8 +990,9 @@ describe('Markdown', () => { { html: '

[hello]](https://lexical.dev)world

', md: '[hello]](https://lexical.dev)[world](https://lexical.dev)', + // GFM readers would run the URL on into the link after it. mdAfterExport: - '[hello]](https://lexical.dev)[world](https://lexical.dev)', + '[hello]](https://lexical.dev)[world](https://lexical.dev)', }, { html: '

[helloworld

', @@ -5187,40 +5188,285 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ).toBe(true); }); - it.each([ - ['www.example.com', 'www.example.com'], - ['me@example.com', 'me@example.com'], - ['https://example.com/a', 'https://example.com/a'], - ['https://example.com/a_b', 'https://example.com/a\\_b'], - ['xmpp:a@b.co/res', 'xmpp:a@b.co/res'], - ['https://example.com/a|b', 'https://example.com/a|b'], - ['x_www.example.co', 'x\\_www.example.co'], - ['https://example.com/\u00e9a', 'https://example.com/\u00e9a'], - ['https://\u4f8b\u3048.jp/a', 'https://\u4f8b\u3048.jp/a'], - ['www.\u4f8b\u3048.jp', 'www.\u4f8b\u3048.jp'], - ['https://example.com/a\u{1f600}b', 'https://example.com/a\u{1f600}b'], + // GFM readers would link a URL here, keeping a reference inside as text or + // taking markers or a code span into it, so export writes the `.` or `:` of + // its prefix as a reference too, and no reader links it (@lexical/mdast + // links the decoded text, which keeps the formats). Readers find an email + // in the decoded text, so a reference inside one does not change it. + it.each<[string, [string, number][], string]>([ + [ + 'a `www.` host', + [ + ['see www.example.com', 0], + ['\u201cx\u201d', TEXT_TYPE_TO_FORMAT.bold], + ], + 'see www.example.com**\u201cx\u201d**', + ], + [ + 'an email, whose references readers decode first', + [ + ['see me@example.com', 0], + ['\u201cx\u201d', TEXT_TYPE_TO_FORMAT.bold], + ], + 'see me@example.com**\u201cx\u201d**', + ], + [ + 'a URL', + [ + ['see https://example.com/a', 0], + ['\u201cx\u201d', TEXT_TYPE_TO_FORMAT.bold], + ], + 'see https://example.com/a**\u201cx\u201d**', + ], + [ + 'an `xmpp:` address, whose references readers decode first', + [ + ['see xmpp:a@b.co/res', 0], + ['\u201cx\u201d', TEXT_TYPE_TO_FORMAT.bold], + ], + 'see xmpp:a@b.co/res**\u201cx\u201d**', + ], + [ + 'a `www.` host after `_`', + [ + ['see x_www.example.co', 0], + ['\u201cx\u201d', TEXT_TYPE_TO_FORMAT.bold], + ], + 'see x\\_www.example.co**\u201cx\u201d**', + ], + [ + 'a URL with a Unicode host', + [ + ['see https://\u4f8b\u3048.jp/a', 0], + ['\u201cx\u201d', TEXT_TYPE_TO_FORMAT.bold], + ], + 'see https://\u4f8b\u3048.jp/a**\u201cx\u201d**', + ], + [ + 'a URL that holds an emoji', + [ + ['see https://example.com/a\u{1f600}b', 0], + ['\u201cx\u201d', TEXT_TYPE_TO_FORMAT.bold], + ], + 'see https://example.com/a\u{1f600}b**\u201cx\u201d**', + ], + [ + 'the start of a `www.` host, which its reference spoils', + [ + ['\u201cx\u201d', TEXT_TYPE_TO_FORMAT.bold], + ['www.example.com', 0], + ], + '**\u201cx\u201d**www.example.com', + ], + [ + 'the start of a URL, which its reference spoils', + [ + ['\u201cx\u201d', TEXT_TYPE_TO_FORMAT.bold], + ['https://example.com', 0], + ], + '**\u201cx\u201d**https://example.com', + ], + [ + 'the start of `mailto:`, which its reference spoils', + [ + ['\u201cx\u201d', TEXT_TYPE_TO_FORMAT.bold], + ['mailto:me@example.com', 0], + ], + '**\u201cx\u201d**mailto:me@example.com', + ], + [ + 'the start of an email', + [ + ['\u201cx\u201d', TEXT_TYPE_TO_FORMAT.bold], + ['me@example.com', 0], + ], + '**\u201cx\u201d**me@example.com', + ], + [ + 'a URL that a reference before it would start', + [ + ['.', TEXT_TYPE_TO_FORMAT.bold], + ['ahttp://x.co', 0], + ], + '**.**ahttp://x.co', + ], + [ + '`mailto:`, which a reference before it does not change', + [ + ['.', TEXT_TYPE_TO_FORMAT.bold], + ['bmailto:a@b.co', 0], + ], + '**.**bmailto:a@b.co', + ], + [ + 'a URL right after a code span', + [ + ['x ', 0], + ['a', TEXT_TYPE_TO_FORMAT.code], + ['http://x.co/b', 0], + ['\u201cq\u201d', TEXT_TYPE_TO_FORMAT.bold], + [' y', 0], + ], + 'x `a`http://x.co/b**\u201cq\u201d** y', + ], + [ + 'an email before a URL', + [ + ['x a@b.co', 0], + ['\u201cq\u201d', TEXT_TYPE_TO_FORMAT.bold], + [',http://x.co/ y', 0], + ], + 'x a@b.co**\u201cq\u201d**,http://x.co/ y', + ], + [ + 'a URL that would take in a code span', + [ + ['see https://a.com/', 0], + ['v2', TEXT_TYPE_TO_FORMAT.code], + ['s', 0], + ['(beta)', TEXT_TYPE_TO_FORMAT.bold], + ], + 'see https://a.com/`v2`s**(beta)**', + ], + [ + 'a URL that runs on through formats', + [ + ['https://x.com/', TEXT_TYPE_TO_FORMAT.bold], + ['ab', 0], + ['\u201cc\u201d', TEXT_TYPE_TO_FORMAT.italic], + ], + '**https://x.com/**ab*\u201cc\u201d*', + ], + [ + 'a `www.` host that would take in markers', + [ + ['www.x.co', TEXT_TYPE_TO_FORMAT.bold], + ['a', 0], + ], + '**www.x.co**a', + ], + [ + 'a `www.` host that would take in italic', + [ + ['www.x.co', 0], + ['a', TEXT_TYPE_TO_FORMAT.italic], + ['b', 0], + ], + 'www.x.co*a*b', + ], + [ + 'a `www.` host that would take in markers after `:`', + [ + ['www.x.co:', 0], + ['a', TEXT_TYPE_TO_FORMAT.bold], + [' z', 0], + ], + 'www.x.co:**a** z', + ], + [ + 'a `www.` host that would take in a code span', + [ + ['www.x.co', 0], + ['v', TEXT_TYPE_TO_FORMAT.code], + ['s', 0], + ], + 'www.x.co`v`s', + ], + [ + 'a URL that would take in strikethrough', + [ + ['see https://x.com/a', 0], + ['b', TEXT_TYPE_TO_FORMAT.strikethrough], + ], + 'see https://x.com/a~~b~~', + ], + [ + 'a URL that holds a `www.` host', + [ + ['http://a.co/www.x.co', 0], + ['!', TEXT_TYPE_TO_FORMAT.bold], + ], + 'http://a.co/www.x.co**!**', + ], + [ + 'a URL that would take in the backslash of an escape at its end', + [ + ['see ', 0], + ['https://a*', TEXT_TYPE_TO_FORMAT.italic], + ], + 'see *https://a\\**', + ], + [ + 'a `www.` host that leaves out the markers after it', + [ + ['www.x.co', TEXT_TYPE_TO_FORMAT.bold], + [' a', 0], + ], + '**www.x.co** a', + ], + [ + 'a `www.` host after `/`, which starts no link', + [ + ['/www.x.co', 0], + ['a', TEXT_TYPE_TO_FORMAT.italic], + ['b', 0], + ], + '/www.x.co*a*b', + ], + [ + 'a URL that holds an email', + [ + ['http://a.co/x@y.co', 0], + ['!', TEXT_TYPE_TO_FORMAT.bold], + ], + 'http://a.co/x@y.co**!**', + ], ])( - 'writes no reference inside %j, which readers may autolink', - (link, markdown) => { + 'exports formatting beside %s, which both importers read back', + (_name, runs, markdown) => { + // Links split the text, and @lexical/mdast keeps its own links. + const $runs = () => { + const merged: [string, number][] = []; + for (const node of $getRoot().getAllTextNodes()) { + const last = merged[merged.length - 1]; + if (last !== undefined && last[1] === node.getFormat()) { + last[0] += node.getTextContent(); + } else { + merged.push([node.getTextContent(), node.getFormat()]); + } + } + return merged; + }; const editor = createTestEditor(); editor.update( () => $getRoot().append( $createParagraphNode().append( - $createTextNode(`see ${link}`), - $createTextNode( - `${LEFT_DOUBLE_QUOTE}x${RIGHT_DOUBLE_QUOTE}`, - ).toggleFormat('bold'), + ...runs.map(([text, format]) => + $createTextNode(text).setFormat(format), + ), ), ), {discrete: true}, ); - const prefix = `see ${markdown}**`; expect( - editor - .read('latest', () => $convertToMarkdownString(TRANSFORMERS)) - .slice(0, prefix.length), - ).toBe(prefix); + editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), + ).toBe(markdown); + editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS), { + discrete: true, + }); + expect(editor.read('latest', $runs)).toEqual(runs); + const mdastEditor = buildEditorFromExtensions( + defineExtension({ + dependencies: [MdastGfmExtension], + name: '[root]', + }), + ); + onTestFinished(() => mdastEditor.dispose()); + mdastEditor.update(() => $convertFromMdastMarkdownString(markdown), { + discrete: true, + }); + expect(mdastEditor.read('latest', $runs)).toEqual(runs); }, ); @@ -5253,41 +5499,36 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); - it.each([ - 'www.example.com', - 'me@example.com', - 'https://example.com', - 'mailto:me@example.com', - ])( - 'writes no reference at the start of %j, which readers may autolink', - link => { - const editor = createTestEditor(); - editor.update( - () => - $getRoot().append( - $createParagraphNode().append( - $createTextNode( - `${LEFT_DOUBLE_QUOTE}x${RIGHT_DOUBLE_QUOTE}`, - ).toggleFormat('bold'), - $createTextNode(link), - ), - ), - {discrete: true}, - ); - expect( - editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), - ).toBe(`**${LEFT_DOUBLE_QUOTE}x${RIGHT_DOUBLE_QUOTE}**${link}`); - }, - ); - - it('writes no backslash reference inside a URL', () => { + it.each<[string, [string, number][], string]>([ + [ + 'a backslash before a marker', + [ + ['see http://x.com/a\\', 0], + ['b', TEXT_TYPE_TO_FORMAT.bold], + ], + 'see http://x.com/a\**b**', + ], + [ + 'a tag character before its marker', + [ + ['see https://x.com/?a=', 0], + ['b', TEXT_TYPE_TO_FORMAT.highlight], + ], + 'see https://x.com/?a===b==', + ], + ])('spoils a URL for the reference to %s', (_name, runs, markdown) => { + const $runs = () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.getFormat()]); const editor = createTestEditor(); editor.update( () => $getRoot().append( $createParagraphNode().append( - $createTextNode('see http://x.com/a\\'), - $createTextNode('b').toggleFormat('bold'), + ...runs.map(([text, format]) => + $createTextNode(text).setFormat(format), + ), ), ), {discrete: true}, @@ -5296,24 +5537,12 @@ describe('emphasis next to Unicode punctuation and symbols', () => { editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS, undefined, true), ), - ).toBe('see http://x.com/a\\**b**'); - }); - - it('writes no reference for a tag character inside a URL', () => { - const editor = createTestEditor(); + ).toBe(markdown); editor.update( - () => - $getRoot().append( - $createParagraphNode().append( - $createTextNode('see https://x.com/?a='), - $createTextNode('b').toggleFormat('highlight'), - ), - ), + () => $convertFromMarkdownString(markdown, TRANSFORMERS, undefined, true), {discrete: true}, ); - expect( - editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), - ).toBe('see https://x.com/?a===b=='); + expect(editor.read('latest', $runs)).toEqual(runs); }); it('writes a reference after CJK punctuation that ends bold before a CJK character', () => { @@ -5568,61 +5797,6 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); - it.each<[string, [string, number][], string]>([ - [ - 'a URL right after a code span', - [ - ['x ', 0], - ['a', TEXT_TYPE_TO_FORMAT.code], - ['http://x.co/b', 0], - ['\u201cq\u201d', TEXT_TYPE_TO_FORMAT.bold], - [' y', 0], - ], - 'x `a`http://x.co/b**\u201cq\u201d** y', - ], - [ - 'an email before a URL', - [ - ['x a@b.co', 0], - ['\u201cq\u201d', TEXT_TYPE_TO_FORMAT.bold], - [',http://x.co/ y', 0], - ], - 'x a@b.co**\u201cq\u201d**,http://x.co/ y', - ], - [ - 'the letter before a URL scheme', - [ - ['.', TEXT_TYPE_TO_FORMAT.bold], - ['ahttp://x.co', 0], - ], - '**.**ahttp://x.co', - ], - [ - 'the letter before `mailto:`, which cmark-gfm would then link', - [ - ['.', TEXT_TYPE_TO_FORMAT.bold], - ['bmailto:a@b.co', 0], - ], - '**.**bmailto:a@b.co', - ], - ])('writes no reference inside %s', (_name, runs, markdown) => { - const editor = createTestEditor(); - editor.update( - () => - $getRoot().append( - $createParagraphNode().append( - ...runs.map(([text, format]) => - $createTextNode(text).setFormat(format), - ), - ), - ), - {discrete: true}, - ); - expect( - editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), - ).toBe(markdown); - }); - it.each<[string, [string, number][]]>([ [ 'x\\``bb`x', @@ -5801,25 +5975,6 @@ describe('emphasis next to Unicode punctuation and symbols', () => { expect(seen).toEqual(['_a_ @x']); }); - it('writes no reference after a code span inside a bare URL', () => { - const editor = createTestEditor(); - editor.update( - () => - $getRoot().append( - $createParagraphNode().append( - $createTextNode('see https://a.com/'), - $createTextNode('v2').toggleFormat('code'), - $createTextNode('s'), - $createTextNode('(beta)').toggleFormat('bold'), - ), - ), - {discrete: true}, - ); - expect( - editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), - ).toBe('see https://a.com/`v2`s**(beta)**'); - }); - it("guards a letter right after an inline link's destination", () => { const editor = createTestEditor(); editor.update( @@ -6009,26 +6164,6 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ]); }); - it('writes no reference inside a URL that runs on through formats', () => { - const editor = createTestEditor(); - editor.update( - () => - $getRoot().append( - $createParagraphNode().append( - $createTextNode('https://x.com/').toggleFormat('bold'), - $createTextNode('ab'), - $createTextNode( - `${LEFT_DOUBLE_QUOTE}c${RIGHT_DOUBLE_QUOTE}`, - ).toggleFormat('italic'), - ), - ), - {discrete: true}, - ); - expect( - editor.read('latest', () => $convertToMarkdownString(TRANSFORMERS)), - ).toBe(`**https://x.com/**ab*${LEFT_DOUBLE_QUOTE}c${RIGHT_DOUBLE_QUOTE}*`); - }); - it('guards word characters in a selection export', () => { const editor = createTestEditor(); editor.update( From 13595d0d99e43f2921c5433cdd39fb8724ec5aa0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 15:51:12 +0000 Subject: [PATCH 56/70] [lexical-markdown] Bug Fix: Keep emails whole for micromark, guard a backslash before raw output, and keep all look-ahead text matches per line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description - Emails: a reference that export writes inside an email (`a@b.co**“x”**`) cut `@lexical/mdast`'s link short at the reference, since micromark finds an email only in the text as written. The `@` of such an email now goes out as `@` too, and `@lexical/mdast` links the whole decoded address, as cmark-gfm does. - Backslash before raw output: with `shouldPreserveNewLines`, a raw backslash that ends text right before a code span or other raw output starting with ASCII punctuation or a line ending escaped it, so `a\` then code `x` came back as plain text. It now goes out as `\`, as it already did before a marker. - Text matches: import kept the first match per line only for the link syntax, so any other text-match transformer searched what was left of a long line again after each match. It now keeps the match of every transformer whose pattern does not look behind (no `^`, word boundary or lookbehind outside a character class, no `g`, `y` or `v` flag). One helper now shifts a kept match, and copies its named groups. ## Test plan Command: `pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts` ### Before With this commit's tests and the previous sources: ``` × exports formatting beside an email, which both importers read back 20ms × exports formatting beside the start of an email, which both importers read back 2ms × exports formatting beside an email before a URL, which both importers read back 2ms × exports formatting beside a URL that holds an email, which both importers read back 1ms × keeps an email that holds a reference whole for @lexical/mdast 2ms × spoils a URL for the reference to a backslash before a code span 1ms × searches a line once for a text match that it does not hold 42ms AssertionError: expected 5459 to be less than 520 ``` ### After ``` Test Files 1 passed (1) Tests 778 passed (778) ``` On 30,000 random lines with custom text-match transformers, import gives the same result with the kept matches as with a fresh search after each match. Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 2 +- .../lexical-markdown/src/MarkdownExport.ts | 49 ++++++-- .../__tests__/unit/LexicalMarkdown.test.ts | 99 +++++++++++++-- .../src/importTextMatchTransformer.ts | 116 ++++++++++++------ .../src/importTextTransformers.ts | 10 +- 5 files changed, 215 insertions(+), 61 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 71d2751c986..7573d0b49f9 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -4,6 +4,6 @@ [fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_`, `~~` and `==` markers open or close formatting -Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match that ends before a code span is applied ahead of it, so `[a](b) and [`c`](d)` keeps both links, and one no longer takes part of any code span in the text, not only the first. A link starts at the `[` its label really opens with, so text before a stray `[` is read as Markdown: `[`x`[c](d)` has a code span. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. An escaped backtick right before more backticks no longer keeps the rest of that run from opening a code span, so ```x\``bb`x``` imports with code `bb`, as CommonMark reads it. Long lines import faster, since their code spans, emphasis and links are found once per line rather than again after each match. +Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match that ends before a code span is applied ahead of it, so `[a](b) and [`c`](d)` keeps both links, and one no longer takes part of any code span in the text, not only the first. A link starts at the `[` its label really opens with, so text before a stray `[` is read as Markdown: `[`x`[c](d)` has a code span. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. An escaped backtick right before more backticks no longer keeps the rest of that run from opening a code span, so ```x\``bb`x``` imports with code `bb`, as CommonMark reads it. Long lines import faster, since their code spans, emphasis, links and the matches of other text-match transformers whose patterns do not look behind are found once per line rather than again after each match. Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz`, `**你好。**世界` or `**注意:**请先登录`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Those references are chosen so that micromark-based readers such as `@lexical/mdast`, which read an emoji or another character outside the Basic Multilingual Plane as a letter, format it the same way, so plain `😀` then bold `“b”` exports as `😀**“b”**`. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it. With `shouldPreserveNewLines`, backslashes right before a `*`, `_`, `` ` `` or `~` of the text are escaped too, so that they no longer cancel that character's escape. A bare URL, `www.` host or email that GFM readers would link, and that would hold such a reference or take in the markers or code span after it, now has a character of its prefix written as a reference too, so that no reader links it and the formatting and text come through: bold `www.x.co` then `a` exports as `**www.x.co**a`, and plain `see https://x.com/a` then bold `“x”` as `see https://x.com/a**“x”**`, where a GFM reader used to take the markers into the link. `@lexical/mdast` still links the decoded text. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 6515e81ff3e..9819b1b52bc 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -732,15 +732,18 @@ function isLinkCharacter(character: string): boolean { // What starts a URL: `http://`, `https://` (and cmark-gfm's `ftp://`) with // a host after it, which may start with an emoji but not with other ASCII // punctuation than `-` and `_`, and `www.`, which micromark links with -// anything after it. -// GFM readers start `www.` only at the start of a run or after `*_~(`, and a -// scheme after anything but a letter. They find an email, and the address -// after `mailto:` or `xmpp:`, in the text with its references decoded, and -// take no format into one. +// anything after it. GFM readers start `www.` only at the start of a run or +// after `*_~(`, and a scheme after anything but a letter. const LINK_PREFIX = /(?:https?|ftp):\/\/(?![\s\p{Cc}!-,./:-@[-^`{-~])|www\./giu; function startsLink(prefix: string, before: string): boolean { return /^w/i.test(prefix) ? '*_~('.includes(before) : !/[a-z]/i.test(before); } +// Characters an email can hold (word characters, `.+@-`, and the backslash +// that escapes one), and what makes one: GFM needs a dot in its domain. An +// email takes in no format, and cmark-gfm finds it in the text with its +// references decoded, but micromark only where its `@` is written as is. +const ADDRESS_CHARACTER = /[\w.+@\\-]/; +const ADDRESS = /[\w.+-]@[\w-]+\.[\w-]/; // Characters that GFM readers leave out at the end of a URL. const LINK_TRAIL = '?!.,:*_~'; @@ -799,11 +802,13 @@ function joinPieces({kinds, values}: MarkdownPieces): string { endLengths[index] = characterBefore(value, value.length)!.length; } // A backslash left unescaped (when preserving newlines, or in decorator - // text) would escape the reference or marker written after it. + // text) would escape the reference, marker or raw punctuation written + // after it, or end the line with a hard break. const bodyEnd = value.length - endLengths[index]; escapesEnd[index] = Number( kinds[index] === TEXT && - isMarker(kinds[index + 1]) && + (isMarker(kinds[index + 1]) || + (kinds[index + 1] === RAW && ESCAPABLE.test(values[index + 1]))) && bodyEnd > startLengths[index] && isEscaped(value, bodyEnd), ); @@ -838,6 +843,9 @@ function joinPieces({kinds, values}: MarkdownPieces): string { return markdown; } +// What a backslash before it escapes: ASCII punctuation or a line ending. +const ESCAPABLE = /^[!-/:-@[-`{-~\n\r]/; + // The character in `value`, if any, as a reference. function toReferences(value: string): string { return value === '' ? '' : toCharacterReference(value); @@ -904,6 +912,27 @@ function findSpoiledLinks( const has = (sums: Uint32Array, from: number, to: number) => from < to && sums[to] > sums[from]; const spoiled: number[] = []; + // A reference in an email would cut a micromark reader's link short, so + // each `@` of one that holds a reference is spoiled. + const spoilEmails = (from: number, to: number) => { + for (let start = from; start < to; ) { + let end = start; + while (end < to && ADDRESS_CHARACTER.test(text[end])) { + end++; + } + if ( + has(references, start, end) && + ADDRESS.test(text.slice(start, end).replace(/\\/g, '')) + ) { + for (let i = start; i < end; i++) { + if (text[i] === '@' && !isReference[i]) { + spoiled.push(i); + } + } + } + start = Math.max(end, start + 1); + } + }; for (let start = 0; start < text.length; ) { let end = start; while (end < text.length && isLinkCharacter(text[end])) { @@ -923,7 +952,8 @@ function findSpoiledLinks( if (isEscaped(text, trimmed) && has(formats, trimmed, end)) { trimmed = end; } - // Where the next URL may start: after the prefix of a spoiled one. + // Where the text that is not part of a URL starts, which may hold an + // email, and where the next URL may start. let rest = start; for (const prefix of text.slice(start, end).matchAll(LINK_PREFIX)) { const at = start + prefix.index; @@ -945,7 +975,9 @@ function findSpoiledLinks( if (!isLink) { continue; } + spoilEmails(rest, at); if (wasLink && !has(references, at, end) && !has(formats, at, trimmed)) { + rest = end; break; } rest = at + prefix[0].length; @@ -954,6 +986,7 @@ function findSpoiledLinks( spoiled.push(at + prefix[0].search(/[.:]/)); } } + spoilEmails(rest, end); start = end; } return {spoiled, starts}; diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index fe225da8811..88dfa26aca2 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5190,9 +5190,9 @@ describe('emphasis next to Unicode punctuation and symbols', () => { // GFM readers would link a URL here, keeping a reference inside as text or // taking markers or a code span into it, so export writes the `.` or `:` of - // its prefix as a reference too, and no reader links it (@lexical/mdast - // links the decoded text, which keeps the formats). Readers find an email - // in the decoded text, so a reference inside one does not change it. + // its prefix (or the `@` of an email) as a reference too, and no reader + // links it as written (@lexical/mdast links the decoded text, which keeps + // the formats). it.each<[string, [string, number][], string]>([ [ 'a `www.` host', @@ -5203,12 +5203,12 @@ describe('emphasis next to Unicode punctuation and symbols', () => { 'see www.example.com**\u201cx\u201d**', ], [ - 'an email, whose references readers decode first', + 'an email', [ ['see me@example.com', 0], ['\u201cx\u201d', TEXT_TYPE_TO_FORMAT.bold], ], - 'see me@example.com**\u201cx\u201d**', + 'see me@example.com**\u201cx\u201d**', ], [ 'a URL', @@ -5219,7 +5219,7 @@ describe('emphasis next to Unicode punctuation and symbols', () => { 'see https://example.com/a**\u201cx\u201d**', ], [ - 'an `xmpp:` address, whose references readers decode first', + 'the resource of an `xmpp:` address', [ ['see xmpp:a@b.co/res', 0], ['\u201cx\u201d', TEXT_TYPE_TO_FORMAT.bold], @@ -5280,7 +5280,7 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ['\u201cx\u201d', TEXT_TYPE_TO_FORMAT.bold], ['me@example.com', 0], ], - '**\u201cx\u201d**me@example.com', + '**\u201cx\u201d**me@example.com', ], [ 'a URL that a reference before it would start', @@ -5316,7 +5316,7 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ['\u201cq\u201d', TEXT_TYPE_TO_FORMAT.bold], [',http://x.co/ y', 0], ], - 'x a@b.co**\u201cq\u201d**,http://x.co/ y', + 'x a@b.co**\u201cq\u201d**,http://x.co/ y', ], [ 'a URL that would take in a code span', @@ -5419,7 +5419,7 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ['http://a.co/x@y.co', 0], ['!', TEXT_TYPE_TO_FORMAT.bold], ], - 'http://a.co/x@y.co**!**', + 'http://a.co/x@y.co**!**', ], ])( 'exports formatting beside %s, which both importers read back', @@ -5499,6 +5499,50 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it('keeps an email that holds a reference whole for @lexical/mdast', () => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('a@b.co'), + $createTextNode('\u201cx\u201d').toggleFormat('bold'), + ), + ), + {discrete: true}, + ); + const markdown = editor.read('latest', () => + $convertToMarkdownString(TRANSFORMERS), + ); + expect(markdown).toBe('a@b.co**\u201cx\u201d**'); + const mdastEditor = buildEditorFromExtensions( + defineExtension({ + dependencies: [MdastGfmExtension], + name: '[root]', + }), + ); + onTestFinished(() => mdastEditor.dispose()); + mdastEditor.update(() => $convertFromMdastMarkdownString(markdown), { + discrete: true, + }); + expect( + mdastEditor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => { + const parent = node.getParent(); + return [ + node.getTextContent(), + $isLinkNode(parent) ? parent.getURL() : null, + ]; + }), + ), + ).toEqual([ + ['a@b.co', 'mailto:a@b.co'], + ['\u201cx\u201d', null], + ]); + }); + it.each<[string, [string, number][], string]>([ [ 'a backslash before a marker', @@ -5508,6 +5552,14 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ], 'see http://x.com/a\**b**', ], + [ + 'a backslash before a code span', + [ + ['see http://x.com/a\\', 0], + ['b', TEXT_TYPE_TO_FORMAT.code], + ], + 'see http://x.com/a\`b`', + ], [ 'a tag character before its marker', [ @@ -5888,6 +5940,35 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it('searches a line once for a text match that it does not hold', () => { + // The characters searched: each text left after a match was searched in + // full again before. + let searched = 0; + class CountingRegExp extends RegExp { + exec(text: string) { + searched += text.length; + return super.exec(text); + } + } + const NEVER: TextMatchTransformer = { + dependencies: [], + importRegExp: new CountingRegExp('\\$\\$[^$]+\\$\\$'), + regExp: /\$\$[^$]+\$\$$/, + replace: () => {}, + type: 'text-match', + }; + const markdown = '**a** [b](c) '.repeat(20); + const editor = createTestEditor(); + editor.update( + () => $convertFromMarkdownString(markdown, [NEVER, ...TRANSFORMERS]), + {discrete: true}, + ); + expect( + editor.read('latest', () => $getRoot().getAllTextNodes().length), + ).toBe(80); + expect(searched).toBeLessThan(2 * markdown.length); + }); + it('scans the text after a match again when its transformer changes it', () => { const MENTION: TextMatchTransformer = { dependencies: [], diff --git a/packages/lexical-markdown/src/importTextMatchTransformer.ts b/packages/lexical-markdown/src/importTextMatchTransformer.ts index cd040a0d01d..f2b632e5eea 100644 --- a/packages/lexical-markdown/src/importTextMatchTransformer.ts +++ b/packages/lexical-markdown/src/importTextMatchTransformer.ts @@ -13,20 +13,33 @@ import {isEscaped} from './utils'; /** * A copy of `match` for `text`, which is what is left of the text it was - * found in after `distance` characters. The link syntax has no named groups. + * found in after `distance` characters, less the first `cut` characters of + * the match and of its first group, which starts with it. */ function moveMatch( match: RegExpExecArray, text: string, distance: number, + cut = 0, ): RegExpExecArray { const moved = [...match] as RegExpExecArray; - moved.index = match.index - distance; + moved.index = match.index - distance + cut; moved.input = text; + moved.groups = match.groups; + if (cut > 0) { + moved[0] = match[0].slice(cut); + moved[1] = match[1].slice(cut); + } if (match.indices) { - moved.indices = match.indices.map( - range => range && [range[0] - distance, range[1] - distance], + const indices = match.indices.map( + (range, group) => + range && [ + range[0] - distance + (group < 2 ? cut : 0), + range[1] - distance, + ], ) as RegExpIndicesArray; + indices.groups = match.indices.groups; + moved.indices = indices; } return moved; } @@ -50,16 +63,7 @@ function matchLink( // there; its label may then be empty, as in `x[[](u)`. const opener = getLinkLabelOpener(match[1]); if (opener !== null && opener >= 0) { - const cut = opener + 1; - const inner = moveMatch(match, text, 0); - inner[0] = match[0].slice(cut); - inner[1] = match[1].slice(cut); - inner.index += cut; - if (inner.indices && inner.indices[0] && inner.indices[1]) { - inner.indices[0][0] += cut; - inner.indices[1][0] += cut; - } - return [inner, match.index]; + return [moveMatch(match, text, 0, opener + 1), match.index]; } if (!isEscaped(text, match.index)) { return [match, match.index]; @@ -69,14 +73,46 @@ function matchLink( return null; } +const lookless = new WeakMap(); + /** - * The match each transformer with the built-in link syntax found in a text, - * where its search found it and the offset of the text it was found in, or - * `null` when there is none. It is the first match in what is left of the - * text after any offset up to where it was found, since the link syntax does - * not look behind its match. + * Whether a match of `regExp` depends only on the text from where it starts + * on: it has no `^`, word boundary or lookbehind outside a character class, + * and no flag that makes it keep state. Its first match in what is left of a + * text after a cut is then the first one found at or after the cut. */ -export type LinkMatches = Map< +function looksOnlyAhead(regExp: RegExp): boolean { + let result = lookless.get(regExp); + if (result === undefined) { + const {source} = regExp; + result = !/[gyv]/.test(regExp.flags); + for (let i = 0, inClass = false; result && i < source.length; i++) { + const character = source[i]; + if (character === '\\') { + result = inClass || !/[bB]/.test(source[++i]); + } else if (inClass) { + inClass = character !== ']'; + } else if (character === '[') { + inClass = true; + } else { + result = + (character !== '^' && !source.startsWith('(?<', i)) || + /^\(\?<[^=!]/.test(source.slice(i, i + 4)); + } + } + lookless.set(regExp, result); + } + return result; +} + +/** + * The match each text-match transformer found in a text, where its search + * found it and the offset of the text it was found in, or `null` when there + * is none. Kept for the built-in link syntax and for patterns that only look + * ahead, for which it is still the first match in what is left of the text + * after any offset up to where it was found. + */ +export type TextMatches = Map< TextMatchTransformer, {found: number; match: RegExpExecArray; offset: number} | null >; @@ -84,9 +120,9 @@ export type LinkMatches = Map< export function findOutermostTextMatchTransformer( textNode_: TextNode, textMatchTransformers: TextMatchTransformer[], - // The link matches found earlier in the text that `textNode_` is what is - // left of from `offset`. - links: LinkMatches = new Map(), + // The matches found earlier in the text that `textNode_` is what is left + // of from `offset`. + matches: TextMatches = new Map(), offset = 0, ): { startIndex: number; @@ -106,29 +142,33 @@ export function findOutermostTextMatchTransformer( continue; } const text = textNode.getTextContent(); - let match: RegExpMatchArray | null; - // Transformers sharing the built-in link syntax need the same candidate rules. - if ( + const {importRegExp} = transformer; + // Transformers sharing the built-in link syntax need the same candidate + // rules. + const isLink = LINK.importRegExp !== undefined && - transformer.importRegExp.source === LINK.importRegExp.source - ) { - const found = links.get(transformer); + importRegExp.source === LINK.importRegExp.source; + let match: RegExpMatchArray | null; + if (isLink || looksOnlyAhead(importRegExp)) { + const found = matches.get(transformer); if (found === undefined || (found !== null && found.found < offset)) { - const linkMatch = matchLink(text, transformer.importRegExp); - links.set( + let result: [RegExpExecArray, number] | null; + if (isLink) { + result = matchLink(text, importRegExp); + } else { + const exec = importRegExp.exec(text); + result = exec && [exec, exec.index]; + } + matches.set( transformer, - linkMatch && { - found: offset + linkMatch[1], - match: linkMatch[0], - offset, - }, + result && {found: offset + result[1], match: result[0], offset}, ); - match = linkMatch && linkMatch[0]; + match = result && result[0]; } else { match = found && moveMatch(found.match, text, offset - found.offset); } } else { - match = text.match(transformer.importRegExp); + match = text.match(importRegExp); } if (!match) { diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 326dcfaf971..f88b86739ff 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -21,7 +21,7 @@ import { import { findOutermostTextMatchTransformer, importFoundTextMatchTransformer, - type LinkMatches, + type TextMatches, } from './importTextMatchTransformer'; import { characterAt, @@ -147,12 +147,12 @@ interface Deferred { } /** - * The formats and links found in a text, for what is left of it, which is + * The formats and text matches found in a text, for what is left of it, which is * `rest`. */ interface TextScan { formats: FormatScan; - links: LinkMatches; + matches: TextMatches; rest: string; } @@ -186,7 +186,7 @@ function importOutermost( before, after, ), - links: new Map(), + matches: new Map(), rest: text, }; const {formats} = scan; @@ -199,7 +199,7 @@ function importOutermost( let foundTextMatch = findOutermostTextMatchTransformer( textNode, textMatchTransformers, - scan.links, + scan.matches, formats.offset, ); From 297cf865ec3939f1cbb6c245302e022f983e1e07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 16:29:22 +0000 Subject: [PATCH 57/70] [lexical-markdown] Bug Fix: Search a line for a link a bounded number of times, and leave a URL alone whose prefix holds a custom marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Two problems found in the review of the previous round: - Import searched for the link syntax again after every escaped `[`, and after every code span in a link label, so a line of 100 `\[` before a link ran the search 101 times, and 100 `` [`a` `` before `](u)` ran it 200 times. Each of those searches walks on to the end of the line. The link search now stops at the last `](`, jumps over a run of escaped openers in one step, and starts a link at the real opener its label holds, so such lines take a few searches. - Export spoiled a bare `www.` host or URL by writing its `.` or `:` as a character reference even when that character was the marker of a custom text-format transformer (a `.` tag, for example), which turned the marker into text and lost the format. Such a URL is now left as it is. When a reference already lands in the URL's prefix, no second one is written. ## Test plan Command: `pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts` ### Before New tests with the source of the previous commit: ``` × writes no reference for the `.` of `www.` that is a format's marker 15ms × searches the line for a link a few times with escaped openers 16ms × searches the line for a link a few times with code spans before a link 22ms AssertionError: expected 'www.x.co. or www.y.co' to be 'www.x.co. or www.y.co' // Object.is equality AssertionError: expected 101 to be less than 5 AssertionError: expected 200 to be less than 5 Tests 3 failed | 778 passed (781) ``` ### After ``` Test Files 1 passed (1) Tests 781 passed (781) ``` Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 35 +++++++----- .../__tests__/unit/LexicalMarkdown.test.ts | 53 +++++++++++++++++++ .../src/importTextMatchTransformer.ts | 33 ++++++++++-- 3 files changed, 104 insertions(+), 17 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 9819b1b52bc..c3b868ea4e2 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -744,6 +744,7 @@ function startsLink(prefix: string, before: string): boolean { // references decoded, but micromark only where its `@` is written as is. const ADDRESS_CHARACTER = /[\w.+@\\-]/; const ADDRESS = /[\w.+-]@[\w-]+\.[\w-]/; +const LINKISH = /www\.|:\/\/|@/i; // Characters that GFM readers leave out at the end of a URL. const LINK_TRAIL = '?!.,:*_~'; @@ -813,16 +814,15 @@ function joinPieces({kinds, values}: MarkdownPieces): string { isEscaped(value, bodyEnd), ); } - const {starts, spoiled} = findSpoiledLinks(kinds, values, (index, mark) => { + const spoiled = findSpoiledLinks(kinds, values, (index, mark) => { mark(0, startLengths[index]); const end = values[index].length - endLengths[index]; mark(end - escapesEnd[index], values[index].length); }); let markdown = ''; let next = 0; - for (let index = 0; index < count; index++) { + for (let index = 0, base = 0; index < count; index++) { const value = values[index]; - const base = starts[index]; const end = value.length - endLengths[index] - escapesEnd[index]; let from = startLengths[index]; markdown += toReferences(value.slice(0, from)); @@ -839,6 +839,7 @@ function joinPieces({kinds, values}: MarkdownPieces): string { value.slice(from, end) + toReferences(value.slice(end, end + escapesEnd[index])) + toReferences(value.slice(end + escapesEnd[index])); + base += value.length; } return markdown; } @@ -872,8 +873,8 @@ function maskRaw(value: string): string { * text and formats come through. So is a URL that a reference before it * would start. `markReferences` is given each text piece and a function that * marks the code units of it, from one offset to another, that go out as - * references. Returns where each piece starts in the joined text and, in - * order, the code units of it to write as references. + * references. Returns, in order, the code units of the joined text to write + * as references, each in a text piece. */ function findSpoiledLinks( kinds: PieceKind[], @@ -882,7 +883,11 @@ function findSpoiledLinks( index: number, mark: (from: number, to: number) => void, ) => void, -): {starts: Uint32Array; spoiled: number[]} { +): number[] { + // Every URL and email starts with one of these. + if (!values.some((value, i) => kinds[i] === TEXT && LINKISH.test(value))) { + return []; + } let text = ''; const starts = new Uint32Array(values.length + 1); for (let i = 0; i < values.length; i++) { @@ -925,7 +930,7 @@ function findSpoiledLinks( ADDRESS.test(text.slice(start, end).replace(/\\/g, '')) ) { for (let i = start; i < end; i++) { - if (text[i] === '@' && !isReference[i]) { + if (text[i] === '@' && !isReference[i] && !has(formats, i, i + 1)) { spoiled.push(i); } } @@ -980,16 +985,22 @@ function findSpoiledLinks( rest = end; break; } - rest = at + prefix[0].length; - // A reference in the prefix spoils it already. - if (!has(references, at, rest)) { - spoiled.push(at + prefix[0].search(/[.:]/)); + // A reference in the prefix spoils it already. A URL whose `.` or `:` + // is a custom format's marker is left as it is. + const spoil = at + prefix[0].search(/[.:]/); + if (!has(references, at, at + prefix[0].length)) { + if (has(formats, spoil, spoil + 1)) { + rest = end; + break; + } + spoiled.push(spoil); } + rest = at + prefix[0].length; } spoilEmails(rest, end); start = end; } - return {spoiled, starts}; + return spoiled; } /** diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 88dfa26aca2..49547ee99b2 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -31,6 +31,7 @@ import { LINK, type MultilineElementTransformer, registerMarkdownShortcuts, + type TextFormatTransformer, type TextMatchTransformer, type Transformer, TRANSFORMERS, @@ -5499,6 +5500,29 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it("writes no reference for the `.` of `www.` that is a format's marker", () => { + const DOT: TextFormatTransformer = { + format: ['italic'], + tag: '.', + type: 'text-format', + }; + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('www'), + $createTextNode('x.co').toggleFormat('italic'), + $createTextNode(' or www.y.co'), + ), + ), + {discrete: true}, + ); + expect(editor.read('latest', () => $convertToMarkdownString([DOT]))).toBe( + 'www.x.co. or www.y.co', + ); + }); + it('keeps an email that holds a reference whole for @lexical/mdast', () => { const editor = createTestEditor(); editor.update( @@ -5969,6 +5993,35 @@ describe('emphasis next to Unicode punctuation and symbols', () => { expect(searched).toBeLessThan(2 * markdown.length); }); + it.each([ + ['escaped openers', '\\['.repeat(100) + 'a](u)'], + ['code spans before a link', '[`a`'.repeat(100) + '](u)'], + ])('searches the line for a link a few times with %s', (_name, markdown) => { + let searches = 0; + const exec = RegExp.prototype.exec; + const spy = vi.spyOn(RegExp.prototype, 'exec').mockImplementation(function ( + this: RegExp, + text: string, + ) { + if (this.source === LINK.importRegExp!.source) { + searches++; + } + return exec.call(this, text); + }); + onTestFinished(() => spy.mockRestore()); + const editor = createTestEditor(); + editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS), { + discrete: true, + }); + spy.mockRestore(); + expect(editor.read('latest', () => $getRoot().getTextContent())).toBe( + markdown.startsWith('\\') + ? markdown.replace(/\\/g, '') + : '[a'.repeat(99) + 'a', + ); + expect(searches).toBeLessThan(5); + }); + it('scans the text after a match again when its transformer changes it', () => { const MENTION: TextMatchTransformer = { dependencies: [], diff --git a/packages/lexical-markdown/src/importTextMatchTransformer.ts b/packages/lexical-markdown/src/importTextMatchTransformer.ts index f2b632e5eea..c0abed5887d 100644 --- a/packages/lexical-markdown/src/importTextMatchTransformer.ts +++ b/packages/lexical-markdown/src/importTextMatchTransformer.ts @@ -45,8 +45,7 @@ function moveMatch( } /** - * The first link match in `text`, and where the search found it, which is - * before the match when its label holds the real opener. + * The first link match in `text`, and up to where a search would find it. */ function matchLink( text: string, @@ -56,19 +55,43 @@ function matchLink( regExp.source, regExp.flags.replace(/[gy]/g, '') + 'g', ); + // No link starts after the last `](`, and searching from each `[` there + // would walk on to the end of the text. + const lastCloser = text.lastIndexOf(']('); let match; - while ((match = search.exec(text)) !== null) { + while ( + search.lastIndex < lastCloser && + (match = search.exec(text)) !== null + ) { // A `[` that the label holds outside a code span is the link's real // opener, and what comes before it is ordinary text, so the match starts // there; its label may then be empty, as in `x[[](u)`. const opener = getLinkLabelOpener(match[1]); if (opener !== null && opener >= 0) { - return [moveMatch(match, text, 0, opener + 1), match.index]; + // A search from any `[` up to the real opener finds the same link, or + // up to the one before it when the label is empty, since the search + // needs a label. + const inner = moveMatch(match, text, 0, opener + 1); + return [ + inner, + inner[1] === '' ? text.lastIndexOf('[', inner.index - 1) : inner.index, + ]; } if (!isEscaped(text, match.index)) { return [match, match.index]; } - search.lastIndex = match.index + 1; + // The label of an escaped `[` holds no opener, so each escaped `[` up to + // the next one written as is finds what the last of them finds: that + // opener, even with an empty label, or nothing. + let next = match.index; + do { + next = text.indexOf('[', next + 1); + } while (next !== -1 && isEscaped(text, next)); + if (next === -1) { + break; + } + const last = text.lastIndexOf('[', next - 1); + search.lastIndex = last > match.index ? last : next; } return null; } From ca8d02848261dcadcdcd851b60dbf8111c9b1dbf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 16:55:42 +0000 Subject: [PATCH 58/70] [lexical-markdown] Bug Fix: Write no reference for an email's `@` after a raw backslash, and walk only delimiter runs at a cut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description - When an email holds a reference, export writes each of its `@` as `@` so that micromark readers link the whole address. With `shouldPreserveNewLines`, a raw backslash before the `@` (`x\@b.co`) then escaped the reference's `&`, and the reference came back as literal text. Such an `@` is now left as it is. - After each match, import looks past the delimiter run cut at each edge to read its flanking as it was in the whole line. It walked the run of whatever character sat at the cut, so a look-ahead text-match transformer matching a long run of one character piece by piece walked the rest of the run at every match. It now walks only a run of a code-span or format-tag character, which is the only kind whose far side is read. - A comment in `LINK.replace` says that its text-before-the-opener branch serves custom callers, since import passes the label from its real opener. ## Test plan Command: `pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts` ### Before The new test with the export source of the previous commit: ``` × writes no reference for an email's `@` after a raw backslash 18ms AssertionError: expected 'x\@b.co**“y”**' to be 'x\@b.co**“y”**' // Object.is equality ``` ### After ``` Test Files 1 passed (1) Tests 782 passed (782) ``` The run-walk change has no observable output; import was compared with the previous commit on 20,000 random lines with custom text-match transformers, with no differences. Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 8 +++- .../src/MarkdownTransformers.ts | 2 + .../__tests__/unit/LexicalMarkdown.test.ts | 19 ++++++++ .../src/importTextTransformers.ts | 46 +++++++++++++++++-- 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index c3b868ea4e2..a4bab0c2d8a 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -930,7 +930,13 @@ function findSpoiledLinks( ADDRESS.test(text.slice(start, end).replace(/\\/g, '')) ) { for (let i = start; i < end; i++) { - if (text[i] === '@' && !isReference[i] && !has(formats, i, i + 1)) { + // A raw backslash before an `@` would escape the reference's `&`. + if ( + text[i] === '@' && + !isReference[i] && + !has(formats, i, i + 1) && + !isEscaped(text, i) + ) { spoiled.push(i); } } diff --git a/packages/lexical-markdown/src/MarkdownTransformers.ts b/packages/lexical-markdown/src/MarkdownTransformers.ts index 6fc439e0a2e..f7ad981b777 100644 --- a/packages/lexical-markdown/src/MarkdownTransformers.ts +++ b/packages/lexical-markdown/src/MarkdownTransformers.ts @@ -1336,6 +1336,8 @@ export const LINK: TextMatchTransformer = { const linkTitle = rawLinkTitle != null ? unescapeText(rawLinkTitle) : undefined; const linkNode = $createLinkNode(linkUrl, {title: linkTitle}); + // Import passes the label from its real opener, so this finds none, but + // a custom caller may pass a match that starts at an earlier `[`. const openBracketIndex = getLinkLabelOpener(linkText); if (openBracketIndex === null) return; let parsedLinkText = linkText; diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 49547ee99b2..e8552760be0 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5523,6 +5523,25 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ); }); + it("writes no reference for an email's `@` after a raw backslash", () => { + const editor = createTestEditor(); + editor.update( + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('x\\@b.co'), + $createTextNode('\u201cy\u201d').toggleFormat('bold'), + ), + ), + {discrete: true}, + ); + expect( + editor.read('latest', () => + $convertToMarkdownString(TRANSFORMERS, undefined, true), + ), + ).toBe('x\\@b.co**\u201cy\u201d**'); + }); + it('keeps an email that holds a reference whole for @lexical/mdast', () => { const editor = createTestEditor(); editor.update( diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index f88b86739ff..bd5f091f25e 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -42,6 +42,22 @@ export function canContainTransformableMarkdown( return $isTextNode(node) && !node.hasFormat('code'); } +const delimitersByIndex = new WeakMap(); + +/** The characters that start a code span or a format's tag. */ +function getDelimiters(index: TextFormatTransformersIndex): string { + let delimiters = delimitersByIndex.get(index); + if (delimiters === undefined) { + delimiters = + '`' + + Object.keys(index.transformersByTag) + .map(tag => tag[0]) + .join(''); + delimitersByIndex.set(index, delimiters); + } + return delimiters; +} + /** * What a delimiter run cut at `index` of `text` sees beyond its far end once * the text is split there: before the run that starts at `index` when `step` @@ -53,8 +69,15 @@ function characterBeyondRun( index: number, step: 1 | -1, outside: string | undefined, + delimiters: string, ): string | undefined { - const edge = getDelimiterRunEdge(text, index, step); + // Only a delimiter's run is walked: beside anything else, what lies beyond + // is never read, and a long run of it would be walked at every cut. + const beside = text[step === 1 ? index - 1 : index]; + const edge = + beside !== undefined && delimiters.includes(beside) + ? getDelimiterRunEdge(text, index, step) + : index; return step === 1 ? edge < text.length ? characterAt(text, edge) @@ -190,6 +213,7 @@ function importOutermost( rest: text, }; const {formats} = scan; + const delimiters = getDelimiters(textFormatTransformersIndex); let foundTextFormat = findOutermostTextFormatTransformer( text, textFormatTransformersIndex, @@ -266,7 +290,7 @@ function importOutermost( inside, matched, outside: { - after: characterBeyondRun(text, startIndex, 1, after), + after: characterBeyondRun(text, startIndex, 1, after, delimiters), before, node: nodeBefore, }, @@ -286,7 +310,7 @@ function importOutermost( scan.rest = rest; return { after, - before: characterBeyondRun(text, endIndex, -1, before), + before: characterBeyondRun(text, endIndex, -1, before, delimiters), node: nodeAfter, scan: isReusable ? scan : null, }; @@ -312,10 +336,22 @@ function importOutermost( { after: isCodeSpan ? undefined - : characterBeyondRun(text, endIndex - tagLength, 1, after), + : characterBeyondRun( + text, + endIndex - tagLength, + 1, + after, + delimiters, + ), before: isCodeSpan ? undefined - : characterBeyondRun(text, startIndex + tagLength, -1, before), + : characterBeyondRun( + text, + startIndex + tagLength, + -1, + before, + delimiters, + ), node: result.transformedNode, }, undefined, From 57d2d27c53739a6766aeac309d6a8020dc30a9d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 18:12:53 +0000 Subject: [PATCH 59/70] [lexical-markdown] Documentation Update: Leave the link-opener change to the changeset of #9600 ## Description The changeset said that a link now starts at the `[` its label really opens with. Main's changeset for #9600 says the same, and that change is on main now, so this changeset drops the sentence rather than listing it twice. ## Test plan ### After ``` $ pnpm run check-changesets Changeset check passed: 69 pending changeset(s) valid. ``` Co-Authored-By: Claude Opus 5.5 --- .changeset/markdown-unicode-punctuation-flanking.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index 7573d0b49f9..abacc6201eb 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -4,6 +4,6 @@ [fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_`, `~~` and `==` markers open or close formatting -Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match that ends before a code span is applied ahead of it, so `[a](b) and [`c`](d)` keeps both links, and one no longer takes part of any code span in the text, not only the first. A link starts at the `[` its label really opens with, so text before a stray `[` is read as Markdown: `[`x`[c](d)` has a code span. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. An escaped backtick right before more backticks no longer keeps the rest of that run from opening a code span, so ```x\``bb`x``` imports with code `bb`, as CommonMark reads it. Long lines import faster, since their code spans, emphasis, links and the matches of other text-match transformers whose patterns do not look behind are found once per line rather than again after each match. +Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match that ends before a code span is applied ahead of it, so `[a](b) and [`c`](d)` keeps both links, and one no longer takes part of any code span in the text, not only the first. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. An escaped backtick right before more backticks no longer keeps the rest of that run from opening a code span, so ```x\``bb`x``` imports with code `bb`, as CommonMark reads it. Long lines import faster, since their code spans, emphasis, links and the matches of other text-match transformers whose patterns do not look behind are found once per line rather than again after each match. Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz`, `**你好。**世界` or `**注意:**请先登录`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Those references are chosen so that micromark-based readers such as `@lexical/mdast`, which read an emoji or another character outside the Basic Multilingual Plane as a letter, format it the same way, so plain `😀` then bold `“b”` exports as `😀**“b”**`. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it. With `shouldPreserveNewLines`, backslashes right before a `*`, `_`, `` ` `` or `~` of the text are escaped too, so that they no longer cancel that character's escape. A bare URL, `www.` host or email that GFM readers would link, and that would hold such a reference or take in the markers or code span after it, now has a character of its prefix written as a reference too, so that no reader links it and the formatting and text come through: bold `www.x.co` then `a` exports as `**www.x.co**a`, and plain `see https://x.com/a` then bold `“x”` as `see https://x.com/a**“x”**`, where a GFM reader used to take the markers into the link. `@lexical/mdast` still links the decoded text. From 4399a01be6bb74316698cf1754ce476ec74557bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 19:27:38 +0000 Subject: [PATCH 60/70] [lexical-markdown] Bug Fix: Read the text beside a text match as beside the edge of a line again, and keep a referenced character inside a bare URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description On main, the text after a text match is imported on its own, so a delimiter run at its start reads as one at the start of a line. This branch gave that text the match's last character as its neighbour, and the text before a match the match's first character, so that a delimiter run that a match cuts reads as it did in the whole line. For a custom transformer whose syntax ends or starts in a letter, that broke round trips: mention `user:bob` then bold `(admin)` exports as `user:bob**(admin)**`, as before, but re-imported as literal `**(admin)**`, and the exporter cannot put a reference inside transformer output to avoid it. The text beside a text match now reads the match's edge as the edge of a line again, as on main, unless a delimiter run goes on across the cut. The line's scan is still reused for the text after the match. Such a run can then only differ in whether it opens, since nothing in that text comes before it, and so only in the pair it opens. That pair is found by scanning ever longer prefixes of the text, so finding it costs about as much as the text it holds, instead of a scan of the rest of the line after every match. On a line of 4,000 `user:ab**(x)** ` it imports in about the time the same line with a space before each `**` took before this change. The remaining growth with line length is core `splitText`, as on main. Export ended a bare URL at any JavaScript whitespace when deciding whether to spoil it, but U+000B, U+2028, U+2029 and U+FEFF are letters for CommonMark, so beside a marker they go out as references, which GFM readers keep in the link: plain `see `, bold `http://x.co/`, then U+2028 and `x` exported as `see **http://x.co/**
x`, which `@lexical/mdast` read as one link with the bold lost. A character written as a reference now counts as part of the URL, so this exports as `see **http://x.co/**
x`. The changeset now gives the import fixes and the export changes paragraphs of their own, and its `Verify:` paragraph names the export snapshots. ## Test plan Command: `pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "whose syntax has a letter"`, and `-t "letter only for CommonMark"` ### Before ``` × reads formatting after a text match whose syntax has a letter beside it 49ms × reads formatting before a text match whose syntax has a letter beside it 4ms × reads formatting in italic after a text match whose syntax has a letter beside it 4ms AssertionError: expected [ Array(2) ] to deep equally contain [ '(admin)', 1 ] AssertionError: expected [ Array(2) ] to deep equally contain [ '(admin)', 1 ] AssertionError: expected [ [ 'bob', +0 ], [ '*“a”*', +0 ] ] to deep equally contain [ '“a”', 2 ] Tests 3 failed | 782 skipped (785) × exports formatting beside a URL before a character that is a letter only for CommonMark, which both importers read back 33ms AssertionError: expected 'see **http://x.co/**
x' to be 'see **http://x.co/**
x' // Object.is equality ``` ### After ``` $ pnpm exec vitest run --project unit packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit scripts/__tests__/unit/treeShakingSource.test.ts Test Files 46 passed (46) Tests 1603 passed (1603) ``` The prefix search was compared with a full scan of the rest after every match on 47,000 random lines of delimiters, code spans, escapes and a letter-ending text match, with no differences. Round trips of 20,000 random paragraphs with links, bare URLs, emails and U+2028 or U+FEFF found none that main reads back correctly and this branch does not, in either importer. Co-Authored-By: Claude Opus 5.5 --- .../markdown-unicode-punctuation-flanking.md | 8 ++- .../lexical-markdown/src/MarkdownExport.ts | 5 +- .../__tests__/unit/LexicalMarkdown.test.ts | 51 ++++++++++++++ .../src/importTextFormatTransformer.ts | 70 ++++++++++++++++++- .../src/importTextTransformers.ts | 68 ++++++++++++++++-- 5 files changed, 191 insertions(+), 11 deletions(-) diff --git a/.changeset/markdown-unicode-punctuation-flanking.md b/.changeset/markdown-unicode-punctuation-flanking.md index abacc6201eb..a22b458a477 100644 --- a/.changeset/markdown-unicode-punctuation-flanking.md +++ b/.changeset/markdown-unicode-punctuation-flanking.md @@ -4,6 +4,10 @@ [fix] Follow CommonMark's Unicode punctuation and whitespace when deciding whether `*`, `_`, `~~` and `==` markers open or close formatting -Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match that ends before a code span is applied ahead of it, so `[a](b) and [`c`](d)` keeps both links, and one no longer takes part of any code span in the text, not only the first. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. An escaped backtick right before more backticks no longer keeps the rest of that run from opening a code span, so ```x\``bb`x``` imports with code `bb`, as CommonMark reads it. Long lines import faster, since their code spans, emphasis, links and the matches of other text-match transformers whose patterns do not look behind are found once per line rather than again after each match. +Markdown import and the typing shortcuts now count every character in the Unicode punctuation (P) and symbol (S) categories as punctuation, so an en dash, a curly quote, a currency sign or a symbol such as ✓ or 😀 next to a marker no longer reads as a letter, and `See –**[here](https://example.com)**` imports as a bold link. On import, the backslash, which was missing from its ASCII list, counts too. Whitespace is the Zs category plus tab, line feed, form feed and carriage return, so U+000B, U+2028, U+2029 and U+FEFF no longer count as whitespace beside a marker, on import or for the typing shortcuts. -Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz`, `**你好。**世界` or `**注意:**请先登录`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Those references are chosen so that micromark-based readers such as `@lexical/mdast`, which read an emoji or another character outside the Basic Multilingual Plane as a letter, format it the same way, so plain `😀` then bold `“b”` exports as `😀**“b”**`. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it. With `shouldPreserveNewLines`, backslashes right before a `*`, `_`, `` ` `` or `~` of the text are escaped too, so that they no longer cancel that character's escape. A bare URL, `www.` host or email that GFM readers would link, and that would hold such a reference or take in the markers or code span after it, now has a character of its prefix written as a reference too, so that no reader links it and the formatting and text come through: bold `www.x.co` then `a` exports as `**www.x.co**a`, and plain `see https://x.com/a` then bold `“x”` as `see https://x.com/a**“x”**`, where a GFM reader used to take the markers into the link. `@lexical/mdast` still links the decoded text. +Import also applies emphasis that closes before a later code span ahead of that span, as in `` **a.**x**`c`** ``, so its markers no longer split apart and stay as literal text. A link or other text match that ends before a code span is applied ahead of it, so `[a](b) and [`c`](d)` keeps both links, and one no longer takes part of any code span in the text, not only the first. A delimiter run that an earlier match cuts through now reads as it did in the whole text instead of as one at the edge of a line, so `*)**a*` keeps its literal asterisks. An escaped backtick right before more backticks no longer keeps the rest of that run from opening a code span, so ```x\``bb`x``` imports with code `bb`, as CommonMark reads it. Long lines import faster, since their code spans, emphasis, links and the matches of other text-match transformers whose patterns do not look behind are found once per line rather than again after each match. + +Export of formatting that starts or ends with punctuation right beside a letter, such as plain `foo` then bold `“bar”`, now writes that letter as a numeric character reference (`foo**“bar”**baz`), so that the formatting survives a round trip through a CommonMark reader. Those references are chosen so that micromark-based readers such as `@lexical/mdast`, which read an emoji or another character outside the Basic Multilingual Plane as a letter, format it the same way, so plain `😀` then bold `“b”` exports as `😀**“b”**`. Characters that are neither punctuation nor whitespace count as letters here, including the variation selector that ends an emoji such as ❤️. A raw backslash that ends text right before a marker or such a reference (text exported with `shouldPreserveNewLines`, or decorator text) now goes out as `\`, so that it does not escape it. With `shouldPreserveNewLines`, backslashes right before a `*`, `_`, `` ` `` or `~` of the text are escaped too, so that they no longer cancel that character's escape. A bare URL, `www.` host or email that GFM readers would link, and that would hold such a reference or take in the markers or code span after it, now has a character of its prefix written as a reference too, so that no reader links it and the formatting and text come through: bold `www.x.co` then `a` exports as `**www.x.co**a`, and plain `see https://x.com/a` then bold `“x”` as `see https://x.com/a**“x”**`, where a GFM reader used to take the markers into the link. `@lexical/mdast` still links the decoded text. + +Verify: Markdown with a marker between a letter and Unicode punctuation, such as `**“bar”**baz`, `**你好。**世界` or `**注意:**请先登录`, now imports with literal asterisks, as CommonMark readers show it, where it used to import as bold. The same goes for formatting that starts or ends with an escaped ASCII character right beside a letter, such as `a**\_x**`, since the backslash is punctuation. Lexical exported these shapes for such formatting before, so check stored Markdown that has them. Exported Markdown for such formatting, and for formatting beside a bare URL, now holds the character references described above, so check export snapshots too. diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index e5c8d20284a..d990fb8c941 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -955,7 +955,10 @@ function findSpoiledLinks( }; for (let start = 0; start < text.length; ) { let end = start; - while (end < text.length && isLinkCharacter(text[end])) { + while ( + end < text.length && + (isLinkCharacter(text[end]) || isReference[end] === 1) + ) { end++; } if (end === start) { diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 677a25d4a69..8e4aa6fe20a 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5358,6 +5358,15 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ], 'www.x.co:**a** z', ], + [ + 'a URL before a character that is a letter only for CommonMark', + [ + ['see ', 0], + ['http://x.co/', TEXT_TYPE_TO_FORMAT.bold], + ['\u2028x', 0], + ], + 'see **http://x.co/**
x', + ], [ 'a `www.` host that would take in a code span', [ @@ -5494,6 +5503,48 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it.each([ + ['after', 'user:bob**(admin)**', ['(admin)', 1]], + ['before', '**(admin)**user:bob', ['(admin)', 1]], + ['in italic after', 'user:bob*\u201ca\u201d*', ['\u201ca\u201d', 2]], + ] as const)( + 'reads formatting %s a text match whose syntax has a letter beside it', + (_where, markdown, formatted) => { + // A mention-style transformer whose syntax starts and ends in a letter. + const MENTION: TextMatchTransformer = { + dependencies: [LinkNode], + export: node => + $isLinkNode(node) && node.getURL().startsWith('mention:') + ? 'user:' + node.getURL().slice('mention:'.length) + : null, + importRegExp: /user:(\w+)/, + regExp: /user:(\w+)$/, + replace: (textNode, match) => { + const link = $createLinkNode('mention:' + match[1]); + link.append($createTextNode(match[1])); + textNode.replace(link); + }, + type: 'text-match', + }; + const transformers = [MENTION, ...TRANSFORMERS]; + const editor = createTestEditor(); + editor.update(() => $convertFromMarkdownString(markdown, transformers), { + discrete: true, + }); + const read = () => + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.getFormat()]), + ); + const imported = read(); + expect(imported).toContainEqual(formatted); + expect( + editor.read('latest', () => $convertToMarkdownString(transformers)), + ).toBe(markdown); + }, + ); + it("writes no reference for the `.` of `www.` that is a format's marker", () => { const DOT: TextFormatTransformer = { format: ['italic'], diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index 02ebc41ed68..f01a0909246 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -49,6 +49,8 @@ export interface FormatScan { offset: number; nextCodeSpan: number; nextEmphasis: number; + /** An emphasis that starts at `offset`, found after the scan. */ + pending: FormatMatch | undefined; } export function scanTextFormats( @@ -85,6 +87,7 @@ export function scanTextFormats( nextCodeSpan: 0, nextEmphasis: 0, offset: 0, + pending: undefined, text, }; } @@ -103,8 +106,12 @@ export function advanceFormatScan( offset: number, isFormatEnd: boolean, ): boolean { - const {codeSpans, emphasis, text} = scan; + const {codeSpans, emphasis, pending, text} = scan; scan.offset = offset; + scan.pending = undefined; + if (pending !== undefined && pending.endIndex > offset) { + return false; + } while ( scan.nextCodeSpan < codeSpans.length && codeSpans[scan.nextCodeSpan].startIndex < offset @@ -124,12 +131,69 @@ export function advanceFormatScan( const cut = text[offset - 1]; return ( cut !== '\\' && - (isFormatEnd || + // A pair found after the scan cut a run that the scan measured whole. + ((isFormatEnd && pending === undefined) || cut !== text[offset] || (cut !== '`' && !scan.delimiters.has(cut))) ); } +/** + * Lets `scan`, moved on to `rest` by {@link advanceFormatScan}, read the + * delimiter run that starts `rest` beside the edge of a line, where it opens, + * rather than beside what came before, where it did not. Nothing in `rest` + * comes before the run, so the rest reads the same either way but for the + * pair the run opens, which nothing in `rest` can hold. That pair is found in + * ever longer prefixes of `rest`, so that finding it costs about as much as + * the text it holds. Returns false when no prefix short of the whole of + * `rest` holds it, and the rest has to be scanned again. + */ +export function findPairAtEdge( + scan: FormatScan, + rest: string, + textFormatTransformersIndex: TextFormatTransformersIndex, + after: string | undefined, +): boolean { + const {codeSpans, offset} = scan; + let span = scan.nextCodeSpan; + for (let length = 64; length < rest.length; length *= 2) { + // Cut neither a code span nor a run of a delimiter or backtick. + while ( + span < codeSpans.length && + codeSpans[span].endIndex <= offset + length + ) { + span++; + } + if ( + span < codeSpans.length && + codeSpans[span].startIndex < offset + length + ) { + length = codeSpans[span].endIndex - offset; + } + if (rest[length - 1] === '`' || scan.delimiters.has(rest[length - 1])) { + length = getDelimiterRunEdge(rest, length, 1); + } + if (length >= rest.length) { + break; + } + const pair = scanTextFormats( + rest.slice(0, length), + textFormatTransformersIndex, + undefined, + characterAt(rest, length), + ).emphasis[0]; + if (pair !== undefined && pair.startIndex === 0) { + scan.pending = { + ...pair, + endIndex: pair.endIndex + offset, + startIndex: offset, + }; + return true; + } + } + return false; +} + /** * Whether the text from `startIndex` to `endIndex` after `scan`'s offset * holds part of a code span but not all of it. @@ -176,7 +240,7 @@ export function findOutermostTextFormatTransformer( } | null { const codeMatch: FormatMatch | undefined = scan.codeSpans[scan.nextCodeSpan]; const emphasisMatch: FormatMatch | undefined = - scan.emphasis[scan.nextEmphasis]; + scan.pending ?? scan.emphasis[scan.nextEmphasis]; // Delimiters inside a code span were excluded, so an emphasis match that // starts first either holds the whole span or ends before it, and either // way it is applied first. diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 534bd166fae..edf96987f35 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -13,6 +13,7 @@ import {$isTextNode, type LexicalNode, type TextNode} from 'lexical'; import { advanceFormatScan, findOutermostTextFormatTransformer, + findPairAtEdge, type FormatScan, importTextFormatTransformer, partlyHoldsCodeSpan, @@ -27,6 +28,7 @@ import { characterAt, characterBefore, getDelimiterRunEdge, + getEmphasisSides, unescapeText, } from './utils'; @@ -42,6 +44,23 @@ export function canContainTransformableMarkdown( return $isTextNode(node) && !node.hasFormat('code'); } +/** + * Whether the delimiter run that starts `text` opens beside the edge of a + * line but not beside `before`. + */ +function opensOnlyBesideEdge( + text: string, + before: string | undefined, + outside: string | undefined, +): boolean { + const edge = getDelimiterRunEdge(text, 1, 1); + const after = edge < text.length ? characterAt(text, edge) : outside; + return ( + getEmphasisSides(text[0], undefined, after).canOpen && + !getEmphasisSides(text[0], before, after).canOpen + ); +} + const delimitersByIndex = new WeakMap(); /** The characters that start a code span or a format's tag. */ @@ -62,7 +81,8 @@ function getDelimiters(index: TextFormatTransformersIndex): string { * What a delimiter run cut at `index` of `text` sees beyond its far end once * the text is split there: before the run that starts at `index` when `step` * is -1, after the run that ends there when it is 1. `outside` is what lies - * beyond the text. + * beyond the text. Beside a text match, which is written in a syntax of its + * own, a run that the cut does not go through sees the edge of a line. */ function characterBeyondRun( text: string, @@ -70,6 +90,7 @@ function characterBeyondRun( step: 1 | -1, outside: string | undefined, delimiters: string, + isBesideTextMatch = false, ): string | undefined { // Only a delimiter's run is walked: beside anything else, what lies beyond // is never read, and a long run of it would be walked at every cut. @@ -78,6 +99,9 @@ function characterBeyondRun( beside !== undefined && delimiters.includes(beside) ? getDelimiterRunEdge(text, index, step) : index; + if (isBesideTextMatch && edge === index) { + return undefined; + } return step === 1 ? edge < text.length ? characterAt(text, edge) @@ -290,11 +314,19 @@ function importOutermost( inside: TextPart, matched: LexicalNode | undefined, ): TextRest | null => { + const isTextMatch = textAfter !== null; deferred.push({ inside, matched, outside: { - after: characterBeyondRun(text, startIndex, 1, after, delimiters), + after: characterBeyondRun( + text, + startIndex, + 1, + after, + delimiters, + isTextMatch, + ), before, node: nodeBefore, }, @@ -306,15 +338,41 @@ function importOutermost( if (textAfter !== null && textAfter !== rest) { return {after: undefined, before: undefined, node: nodeAfter, scan: null}; } - const isReusable = advanceFormatScan( + const restBefore = characterBeyondRun( + text, + endIndex, + -1, + before, + delimiters, + isTextMatch, + ); + // The scan read a run at the start of the rest beside the end of the + // match. Read beside the edge of a line instead, it can only differ in + // whether it opens, since it has nothing before it in the rest to close, + // and then only in the pair it opens. + let isReusable = advanceFormatScan( formats, formats.offset + endIndex, - textAfter === null, + !isTextMatch, ); + if ( + isReusable && + isTextMatch && + restBefore === undefined && + formats.delimiters.has(rest[0]) && + opensOnlyBesideEdge(rest, characterBefore(text, endIndex), after) + ) { + isReusable = findPairAtEdge( + formats, + rest, + textFormatTransformersIndex, + after, + ); + } scan.rest = rest; return { after, - before: characterBeyondRun(text, endIndex, -1, before, delimiters), + before: restBefore, node: nodeAfter, scan: isReusable ? scan : null, }; From 3824c396094b81d6ebc6d8b69908dfe6c722a0a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 18:51:34 +0000 Subject: [PATCH 61/70] [lexical-markdown] Bug Fix: Read a run after a text match the same whatever follows it on the line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description After a link or other text match, the text after it reuses the line's emphasis scan unless the delimiter run that starts it reads differently beside the edge of a line. That check compared only whether the run can open. Whether it can close matters too, through CommonMark's rule of 3, so `[a](u)**#*` kept a literal `**#*` while `[a](u)**#* [b](v)`, whose first part is scanned on its own, read `*` and italic `#`, as main reads both. The check now compares both, and either difference goes to the prefix search for the pair the run opens, so both lines read as main reads them. ## Test plan Command: `pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "whatever follows it|whose syntax has a letter"` ### Before ``` × reads a run after a link the same whatever follows it on the line, with a short run 24ms × reads a run after a link the same whatever follows it on the line, with a long run 3ms AssertionError: expected [ [ 'a', +0 ], [ '**#*', +0 ] ] to deeply equal [ Array(3) ] AssertionError: expected [ [ 'a', +0 ], [ …(2) ] ] to deeply equal [ [ 'a', +0 ], [ '*', +0 ], [ …(2) ] ] Tests 2 failed | 4 passed | 783 skipped (789) ``` ### After ``` $ pnpm exec vitest run --project unit packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit scripts/__tests__/unit/treeShakingSource.test.ts Test Files 46 passed (46) Tests 1606 passed (1606) ``` The new tests include lines with more than 64 characters after the match, where the prefix search starts, both with the pair inside the first prefix and beyond it, and a mention row whose pair lies past the first prefix. The prefix search was compared with a full scan of the rest after every match on 33,000 random lines of links, letter-ending text matches, delimiters, code spans and escapes, starting from prefixes of 2 and of 64 characters, with no differences. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/unit/LexicalMarkdown.test.ts | 40 +++++++++++++++++++ .../src/importTextFormatTransformer.ts | 15 +++---- .../src/importTextTransformers.ts | 20 +++++----- 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 8e4aa6fe20a..b3cc6f0c096 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5507,6 +5507,11 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ['after', 'user:bob**(admin)**', ['(admin)', 1]], ['before', '**(admin)**user:bob', ['(admin)', 1]], ['in italic after', 'user:bob*\u201ca\u201d*', ['\u201ca\u201d', 2]], + [ + 'far after', + 'user:bob**(' + 'x '.repeat(40) + 'y)**', + ['(' + 'x '.repeat(40) + 'y)', 1], + ], ] as const)( 'reads formatting %s a text match whose syntax has a letter beside it', (_where, markdown, formatted) => { @@ -5545,6 +5550,41 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it.each([ + ['a short run', '#'], + ['a long run', '#' + 'x'.repeat(80)], + ])( + 'reads a run after a link the same whatever follows it on the line, with %s', + (_name, inner) => { + // As on main, the run reads the link's end as the edge of a line, so + // it opens without closing, and the rule of 3 does not keep `*` from + // closing it. + const editor = createTestEditor(); + for (const tail of ['', ' [b](v)', ' ' + 'y'.repeat(80)]) { + editor.update( + () => + $convertFromMarkdownString( + `[a](u)**${inner}*${tail}`, + TRANSFORMERS, + ), + {discrete: true}, + ); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .slice(0, 3) + .map(node => [node.getTextContent(), node.getFormat()]), + ), + ).toEqual([ + ['a', 0], + ['*', 0], + [inner, TEXT_TYPE_TO_FORMAT.italic], + ]); + } + }, + ); + it("writes no reference for the `.` of `www.` that is a format's marker", () => { const DOT: TextFormatTransformer = { format: ['italic'], diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index f01a0909246..9053470263c 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -140,13 +140,14 @@ export function advanceFormatScan( /** * Lets `scan`, moved on to `rest` by {@link advanceFormatScan}, read the - * delimiter run that starts `rest` beside the edge of a line, where it opens, - * rather than beside what came before, where it did not. Nothing in `rest` - * comes before the run, so the rest reads the same either way but for the - * pair the run opens, which nothing in `rest` can hold. That pair is found in - * ever longer prefixes of `rest`, so that finding it costs about as much as - * the text it holds. Returns false when no prefix short of the whole of - * `rest` holds it, and the rest has to be scanned again. + * delimiter run that starts `rest` beside the edge of a line rather than + * beside what came before, where it opened or closed otherwise. Nothing in + * `rest` comes before the run, so the rest reads the same either way but for + * the pair the run opens, which nothing in `rest` can hold, and whatever + * pairs across its end, which `advanceFormatScan` turns down there. That + * pair is found in ever longer prefixes of `rest`, so that finding it costs + * about as much as the text it holds. Returns false when no prefix short of + * the whole of `rest` holds it, and the rest has to be scanned again. */ export function findPairAtEdge( scan: FormatScan, diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index edf96987f35..7c0b27e8af2 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -45,19 +45,22 @@ export function canContainTransformableMarkdown( } /** - * Whether the delimiter run that starts `text` opens beside the edge of a - * line but not beside `before`. + * Whether the delimiter run that starts `text` can open or close otherwise + * beside the edge of a line than beside `before`. Whether it can close + * matters even with nothing before it, through the rule of 3. */ -function opensOnlyBesideEdge( +function readsOtherwiseBesideEdge( text: string, before: string | undefined, outside: string | undefined, ): boolean { const edge = getDelimiterRunEdge(text, 1, 1); const after = edge < text.length ? characterAt(text, edge) : outside; + const atEdge = getEmphasisSides(text[0], undefined, after); + const besideBefore = getEmphasisSides(text[0], before, after); return ( - getEmphasisSides(text[0], undefined, after).canOpen && - !getEmphasisSides(text[0], before, after).canOpen + atEdge.canOpen !== besideBefore.canOpen || + atEdge.canClose !== besideBefore.canClose ); } @@ -347,9 +350,8 @@ function importOutermost( isTextMatch, ); // The scan read a run at the start of the rest beside the end of the - // match. Read beside the edge of a line instead, it can only differ in - // whether it opens, since it has nothing before it in the rest to close, - // and then only in the pair it opens. + // match. Read beside the edge of a line instead, it can only change the + // pair it opens, since it has nothing before it in the rest to close. let isReusable = advanceFormatScan( formats, formats.offset + endIndex, @@ -360,7 +362,7 @@ function importOutermost( isTextMatch && restBefore === undefined && formats.delimiters.has(rest[0]) && - opensOnlyBesideEdge(rest, characterBefore(text, endIndex), after) + readsOtherwiseBesideEdge(rest, characterBefore(text, endIndex), after) ) { isReusable = findPairAtEdge( formats, From 55672047752d29bc365334e4776720b607f51692 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 18:51:34 +0000 Subject: [PATCH 62/70] [lexical-markdown] Bug Fix: Read a run after a text match the same whatever follows it on the line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description After a link or other text match, the text after it reuses the line's emphasis scan unless the delimiter run that starts it reads differently beside the edge of a line. That check compared only whether the run can open. Whether it can close matters too, through CommonMark's rule of 3, so `[a](u)**#*` kept a literal `**#*` while `[a](u)**#* [b](v)`, whose first part is scanned on its own, read `*` and italic `#`, as main reads both. The check now compares both, and either difference goes to the prefix search for the pair the run opens, so both lines read as main reads them. ## Test plan Command: `pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "whatever follows it|whose syntax has a letter"` ### Before ``` × reads a run after a link the same whatever follows it on the line, with a short run 24ms × reads a run after a link the same whatever follows it on the line, with a long run 3ms AssertionError: expected [ [ 'a', +0 ], [ '**#*', +0 ] ] to deeply equal [ Array(3) ] AssertionError: expected [ [ 'a', +0 ], [ …(2) ] ] to deeply equal [ [ 'a', +0 ], [ '*', +0 ], [ …(2) ] ] Tests 2 failed | 4 passed | 783 skipped (789) ``` ### After ``` $ pnpm exec vitest run --project unit packages/lexical-markdown packages/lexical-mdast packages/lexical-playground/__tests__/unit scripts/__tests__/unit/treeShakingSource.test.ts Test Files 46 passed (46) Tests 1606 passed (1606) ``` The new tests include lines with more than 64 characters after the match, where the prefix search starts, both with the pair inside the first prefix and beyond it, and a mention row whose pair lies past the first prefix. The prefix search was compared with a full scan of the rest after every match on 33,000 random lines of links, letter-ending text matches, delimiters, code spans and escapes, starting from prefixes of 2 and of 64 characters, with no differences. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/unit/LexicalMarkdown.test.ts | 40 +++++++++++++++++++ .../src/importTextFormatTransformer.ts | 15 +++---- .../src/importTextTransformers.ts | 20 +++++----- 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 8e4aa6fe20a..b3cc6f0c096 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5507,6 +5507,11 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ['after', 'user:bob**(admin)**', ['(admin)', 1]], ['before', '**(admin)**user:bob', ['(admin)', 1]], ['in italic after', 'user:bob*\u201ca\u201d*', ['\u201ca\u201d', 2]], + [ + 'far after', + 'user:bob**(' + 'x '.repeat(40) + 'y)**', + ['(' + 'x '.repeat(40) + 'y)', 1], + ], ] as const)( 'reads formatting %s a text match whose syntax has a letter beside it', (_where, markdown, formatted) => { @@ -5545,6 +5550,41 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it.each([ + ['a short run', '#'], + ['a long run', '#' + 'x'.repeat(80)], + ])( + 'reads a run after a link the same whatever follows it on the line, with %s', + (_name, inner) => { + // As on main, the run reads the link's end as the edge of a line, so + // it opens without closing, and the rule of 3 does not keep `*` from + // closing it. + const editor = createTestEditor(); + for (const tail of ['', ' [b](v)', ' ' + 'y'.repeat(80)]) { + editor.update( + () => + $convertFromMarkdownString( + `[a](u)**${inner}*${tail}`, + TRANSFORMERS, + ), + {discrete: true}, + ); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .slice(0, 3) + .map(node => [node.getTextContent(), node.getFormat()]), + ), + ).toEqual([ + ['a', 0], + ['*', 0], + [inner, TEXT_TYPE_TO_FORMAT.italic], + ]); + } + }, + ); + it("writes no reference for the `.` of `www.` that is a format's marker", () => { const DOT: TextFormatTransformer = { format: ['italic'], diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index f01a0909246..9053470263c 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -140,13 +140,14 @@ export function advanceFormatScan( /** * Lets `scan`, moved on to `rest` by {@link advanceFormatScan}, read the - * delimiter run that starts `rest` beside the edge of a line, where it opens, - * rather than beside what came before, where it did not. Nothing in `rest` - * comes before the run, so the rest reads the same either way but for the - * pair the run opens, which nothing in `rest` can hold. That pair is found in - * ever longer prefixes of `rest`, so that finding it costs about as much as - * the text it holds. Returns false when no prefix short of the whole of - * `rest` holds it, and the rest has to be scanned again. + * delimiter run that starts `rest` beside the edge of a line rather than + * beside what came before, where it opened or closed otherwise. Nothing in + * `rest` comes before the run, so the rest reads the same either way but for + * the pair the run opens, which nothing in `rest` can hold, and whatever + * pairs across its end, which `advanceFormatScan` turns down there. That + * pair is found in ever longer prefixes of `rest`, so that finding it costs + * about as much as the text it holds. Returns false when no prefix short of + * the whole of `rest` holds it, and the rest has to be scanned again. */ export function findPairAtEdge( scan: FormatScan, diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index edf96987f35..7c0b27e8af2 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -45,19 +45,22 @@ export function canContainTransformableMarkdown( } /** - * Whether the delimiter run that starts `text` opens beside the edge of a - * line but not beside `before`. + * Whether the delimiter run that starts `text` can open or close otherwise + * beside the edge of a line than beside `before`. Whether it can close + * matters even with nothing before it, through the rule of 3. */ -function opensOnlyBesideEdge( +function readsOtherwiseBesideEdge( text: string, before: string | undefined, outside: string | undefined, ): boolean { const edge = getDelimiterRunEdge(text, 1, 1); const after = edge < text.length ? characterAt(text, edge) : outside; + const atEdge = getEmphasisSides(text[0], undefined, after); + const besideBefore = getEmphasisSides(text[0], before, after); return ( - getEmphasisSides(text[0], undefined, after).canOpen && - !getEmphasisSides(text[0], before, after).canOpen + atEdge.canOpen !== besideBefore.canOpen || + atEdge.canClose !== besideBefore.canClose ); } @@ -347,9 +350,8 @@ function importOutermost( isTextMatch, ); // The scan read a run at the start of the rest beside the end of the - // match. Read beside the edge of a line instead, it can only differ in - // whether it opens, since it has nothing before it in the rest to close, - // and then only in the pair it opens. + // match. Read beside the edge of a line instead, it can only change the + // pair it opens, since it has nothing before it in the rest to close. let isReusable = advanceFormatScan( formats, formats.offset + endIndex, @@ -360,7 +362,7 @@ function importOutermost( isTextMatch && restBefore === undefined && formats.delimiters.has(rest[0]) && - opensOnlyBesideEdge(rest, characterBefore(text, endIndex), after) + readsOtherwiseBesideEdge(rest, characterBefore(text, endIndex), after) ) { isReusable = findPairAtEdge( formats, From 7e499d6d5e00a6066c8bcd91647b9fc2c2a7c271 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:11:09 +0000 Subject: [PATCH 63/70] [lexical-markdown] Bug Fix: Give a text match found earlier in the line its named groups where they are ## Description A look-ahead text match kept for the rest of a line is moved to the text left after an earlier match, but its `indices.groups` still pointed at the ranges in the text it was found in, so a transformer reading a named group's range from a `d`-flag regex got the wrong text (`**a** @bob` with `/@(?\w+)/d` read `''` for `name`). Each named group now takes the moved range of its numbered group. ## Test plan ### Before ``` pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "named groups" AssertionError: expected [ '', 'bob' ] to deeply equal [ 'bob', 'bob' ] Tests 1 failed | 789 skipped (790) ``` ### After ``` pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts Tests 790 passed (790) ``` Co-Authored-By: Claude Opus 5.5 --- .../__tests__/unit/LexicalMarkdown.test.ts | 25 +++++++++++++++++++ .../src/importTextMatchTransformer.ts | 12 ++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index b3cc6f0c096..dd6f8c11ae8 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5585,6 +5585,31 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it('gives a text match found earlier in the line its named groups where they are', () => { + const ranges: string[] = []; + const MENTION: TextMatchTransformer = { + dependencies: [], + importRegExp: new RegExp('@(?\\w+)', 'd'), + regExp: new RegExp('@(?\\w+)$', 'd'), + replace: (textNode, match) => { + const {indices, input = ''} = match; + const name = indices?.groups?.name; + assert(name !== undefined && indices?.[1] !== undefined); + const [start, end] = name; + ranges.push(input.slice(start, end), input.slice(...indices[1])); + textNode.setTextContent(input.slice(start, end)); + }, + type: 'text-match', + }; + const editor = createTestEditor(); + editor.update( + () => + $convertFromMarkdownString('**a** @bob', [MENTION, ...TRANSFORMERS]), + {discrete: true}, + ); + expect(ranges).toEqual(['bob', 'bob']); + }); + it("writes no reference for the `.` of `www.` that is a format's marker", () => { const DOT: TextFormatTransformer = { format: ['italic'], diff --git a/packages/lexical-markdown/src/importTextMatchTransformer.ts b/packages/lexical-markdown/src/importTextMatchTransformer.ts index 46286b93edd..85468a42a1f 100644 --- a/packages/lexical-markdown/src/importTextMatchTransformer.ts +++ b/packages/lexical-markdown/src/importTextMatchTransformer.ts @@ -39,7 +39,17 @@ function moveMatch( range[1] - distance, ], ) as RegExpIndicesArray; - indices.groups = match.indices.groups; + const {groups} = match.indices; + if (groups) { + // Each named group's range is the one of its numbered group. + const ranges: Record = + Object.create(null); + for (const name in groups) { + const range = groups[name]; + ranges[name] = range && indices[match.indices.indexOf(range)]; + } + indices.groups = ranges as typeof groups; + } moved.indices = indices; } return moved; From 286353c54386746b48037cb0436d46d5914bf124 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:20:09 +0000 Subject: [PATCH 64/70] [lexical-markdown] Bug Fix: Read a highlight inside a format as a line of its own again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The content of a format now reads the outer marker beyond its edges, so that `*`, `_` and `~` runs there flank as CommonMark and GFM define. For `==`, which neither defines, this let the rule of 3 refuse a highlight at the content's edge that main applies: `` **==`====** `` imported as bold literal text instead of a bold highlight. A run of a marker that CommonMark and GFM do not define now reads the content's edge as the edge of a line, as on main, while `` **~~`~~~~** `` stays bold literal text as @lexical/mdast reads it. ## Test plan ### Before ``` pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "highlight inside a format" × reads a highlight inside a format as a line of its own, and GFM's markers as GFM does, in "**==`====**" Tests 1 failed | 1 passed | 790 skipped (792) ``` ### After ``` pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "highlight inside a format" Tests 2 passed | 790 skipped (792) ``` Co-Authored-By: Claude Opus 5.5 --- .../__tests__/unit/LexicalMarkdown.test.ts | 26 +++++++++++++++++++ .../src/importTextTransformers.ts | 15 ++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index dd6f8c11ae8..86fde42e75e 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -6002,6 +6002,32 @@ describe('emphasis next to Unicode punctuation and symbols', () => { }, ); + it.each<[string, [string, number][]]>([ + [ + '**==`====**', + [ + ['`', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.highlight], + ['==', TEXT_TYPE_TO_FORMAT.bold], + ], + ], + ['**~~`~~~~**', [['~~`~~~~', TEXT_TYPE_TO_FORMAT.bold]]], + ])( + "reads a highlight inside a format as a line of its own, and GFM's markers as GFM does, in %j", + (markdown, runs) => { + const editor = createTestEditor(); + editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS), { + discrete: true, + }); + expect( + editor.read('latest', () => + $getRoot() + .getAllTextNodes() + .map(node => [node.getTextContent(), node.getFormat()]), + ), + ).toEqual(runs); + }, + ); + it.each<[string, [string, number][]]>([ [ 'x\\``bb`x', diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 7c0b27e8af2..cdd54740572 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -64,6 +64,9 @@ function readsOtherwiseBesideEdge( ); } +/** The markers whose runs CommonMark and GFM define. */ +const SPEC_MARKERS = '*_~'; + const delimitersByIndex = new WeakMap(); /** The characters that start a code span or a format's tag. */ @@ -84,8 +87,10 @@ function getDelimiters(index: TextFormatTransformersIndex): string { * What a delimiter run cut at `index` of `text` sees beyond its far end once * the text is split there: before the run that starts at `index` when `step` * is -1, after the run that ends there when it is 1. `outside` is what lies - * beyond the text. Beside a text match, which is written in a syntax of its - * own, a run that the cut does not go through sees the edge of a line. + * beyond the text. When `readsLineEdge`, a run that the cut does not go + * through sees the edge of a line: beside a text match, which is written in a + * syntax of its own, and inside a format, for a marker that CommonMark and GFM + * do not define, which reads the format's content as a line of its own. */ function characterBeyondRun( text: string, @@ -93,7 +98,7 @@ function characterBeyondRun( step: 1 | -1, outside: string | undefined, delimiters: string, - isBesideTextMatch = false, + readsLineEdge = false, ): string | undefined { // Only a delimiter's run is walked: beside anything else, what lies beyond // is never read, and a long run of it would be walked at every cut. @@ -102,7 +107,7 @@ function characterBeyondRun( beside !== undefined && delimiters.includes(beside) ? getDelimiterRunEdge(text, index, step) : index; - if (isBesideTextMatch && edge === index) { + if (readsLineEdge && edge === index) { return undefined; } return step === 1 @@ -406,6 +411,7 @@ function importOutermost( 1, after, delimiters, + !SPEC_MARKERS.includes(text[endIndex - tagLength - 1]), ), before: isCodeSpan ? undefined @@ -415,6 +421,7 @@ function importOutermost( -1, before, delimiters, + !SPEC_MARKERS.includes(text[startIndex + tagLength]), ), node: result.transformedNode, }, From f6e79e675b9c70d03e4a10adec7956be0e11deec Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 01:35:02 +0000 Subject: [PATCH 65/70] [lexical-markdown] Bug Fix: Pair a run after a link only before the next format, and read a run a format's tag cuts beside a line edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Two inputs imported otherwise than on main and @lexical/mdast: - `` [a](u)*`c`)** `` imported italic code and an italic `)`. The run after a link, read beside the edge of a line, was paired across the code span that the line's scan had found next. It now pairs only before that format, which is imported first as it would be with the link before it. - `` **)*(.`*b!****_ `` lost both italics. The bold's content read the `**` that its closing tag cut beside what lies after the whole run, so it could open, and the rule of 3 then measured it without the part the tag took. A run that the tag cuts now reads beside the edge of a line, as on main; one it does not cut still reads beside the tag when its marker is one CommonMark or GFM define. ## Test plan ### Before ``` pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "beside another marker" × reads a run beside another marker as CommonMark does in "[a](u)*`c`)**" × reads a run beside another marker as CommonMark does in "**)*(.`*b!****_" Tests 2 failed | 4 passed | 788 skipped (794) ``` ### After ``` pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "beside another marker" Tests 6 passed | 788 skipped (794) ``` Co-Authored-By: Claude Opus 5.5 --- .../__tests__/unit/LexicalMarkdown.test.ts | 17 ++++++ .../src/importTextFormatTransformer.ts | 48 +++++++++-------- .../src/importTextTransformers.ts | 52 ++++++------------- 3 files changed, 59 insertions(+), 58 deletions(-) diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 86fde42e75e..5241347b03b 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5985,6 +5985,23 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ], ['**~~**a', [['**~~**a', 0]]], ['~~a*~~b', [['~~a*~~b', 0]]], + [ + '[a](u)*`c`)**', + [ + ['a', 0], + ['*', 0], + ['c', TEXT_TYPE_TO_FORMAT.code], + [')**', 0], + ], + ], + [ + '**)*(.`*b!****_', + [ + [')', TEXT_TYPE_TO_FORMAT.bold], + ['(.`b!', TEXT_TYPE_TO_FORMAT.bold | TEXT_TYPE_TO_FORMAT.italic], + ['_', 0], + ], + ], ])( 'reads a run beside another marker as CommonMark does in %j', (markdown, runs) => { diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index 9053470263c..f9c807e565d 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -144,38 +144,38 @@ export function advanceFormatScan( * beside what came before, where it opened or closed otherwise. Nothing in * `rest` comes before the run, so the rest reads the same either way but for * the pair the run opens, which nothing in `rest` can hold, and whatever - * pairs across its end, which `advanceFormatScan` turns down there. That - * pair is found in ever longer prefixes of `rest`, so that finding it costs - * about as much as the text it holds. Returns false when no prefix short of - * the whole of `rest` holds it, and the rest has to be scanned again. + * pairs across its end, which `advanceFormatScan` turns down there. The pair + * ends before the next format that the scan found, which is imported first + * as it would be with the match before it, and it is found in ever longer + * prefixes of `rest`, so that finding it costs about as much as the text it + * holds. Returns false when the rest has to be scanned again. */ export function findPairAtEdge( scan: FormatScan, rest: string, textFormatTransformersIndex: TextFormatTransformersIndex, - after: string | undefined, ): boolean { - const {codeSpans, offset} = scan; - let span = scan.nextCodeSpan; - for (let length = 64; length < rest.length; length *= 2) { - // Cut neither a code span nor a run of a delimiter or backtick. - while ( - span < codeSpans.length && - codeSpans[span].endIndex <= offset + length - ) { - span++; - } + const {offset} = scan; + const span = scan.codeSpans[scan.nextCodeSpan]; + const next = scan.emphasis[scan.nextEmphasis]; + const end = Math.min( + rest.length, + span ? span.startIndex - offset : Infinity, + next ? next.startIndex - offset : Infinity, + ); + for (let length = 64; ; length *= 2) { + // Cut no run of a delimiter or backtick; no code span starts before `end`. if ( - span < codeSpans.length && - codeSpans[span].startIndex < offset + length + length < end && + (rest[length - 1] === '`' || scan.delimiters.has(rest[length - 1])) ) { - length = codeSpans[span].endIndex - offset; - } - if (rest[length - 1] === '`' || scan.delimiters.has(rest[length - 1])) { length = getDelimiterRunEdge(rest, length, 1); } - if (length >= rest.length) { - break; + if (length >= end) { + if (end === rest.length) { + return false; + } + length = end; } const pair = scanTextFormats( rest.slice(0, length), @@ -191,8 +191,10 @@ export function findPairAtEdge( }; return true; } + if (length === end) { + return true; + } } - return false; } /** diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index cdd54740572..800c175a9dc 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -87,10 +87,8 @@ function getDelimiters(index: TextFormatTransformersIndex): string { * What a delimiter run cut at `index` of `text` sees beyond its far end once * the text is split there: before the run that starts at `index` when `step` * is -1, after the run that ends there when it is 1. `outside` is what lies - * beyond the text. When `readsLineEdge`, a run that the cut does not go - * through sees the edge of a line: beside a text match, which is written in a - * syntax of its own, and inside a format, for a marker that CommonMark and GFM - * do not define, which reads the format's content as a line of its own. + * beyond the text. Beside a text match, which is written in a syntax of its + * own, a run that the cut does not go through sees the edge of a line. */ function characterBeyondRun( text: string, @@ -98,7 +96,7 @@ function characterBeyondRun( step: 1 | -1, outside: string | undefined, delimiters: string, - readsLineEdge = false, + isBesideTextMatch = false, ): string | undefined { // Only a delimiter's run is walked: beside anything else, what lies beyond // is never read, and a long run of it would be walked at every cut. @@ -107,7 +105,7 @@ function characterBeyondRun( beside !== undefined && delimiters.includes(beside) ? getDelimiterRunEdge(text, index, step) : index; - if (readsLineEdge && edge === index) { + if (isBesideTextMatch && edge === index) { return undefined; } return step === 1 @@ -369,12 +367,7 @@ function importOutermost( formats.delimiters.has(rest[0]) && readsOtherwiseBesideEdge(rest, characterBefore(text, endIndex), after) ) { - isReusable = findPairAtEdge( - formats, - rest, - textFormatTransformersIndex, - after, - ); + isReusable = findPairAtEdge(formats, rest, textFormatTransformersIndex); } scan.rest = rest; return { @@ -394,35 +387,24 @@ function importOutermost( foundTextFormat.match, ); const {startIndex, endIndex, isCodeSpan} = foundTextFormat; - const tagLength = foundTextFormat.match[1].length; - // A code span's fence is not its tag's length, and its content is not - // read for emphasis against what lies outside it. + const tag = foundTextFormat.match[1]; + // The content reads a run at its edge beside the tag, so that it flanks + // as it does in the line, when it is of a marker that CommonMark or GFM + // define and the tag does not cut it; otherwise beside the edge of a + // line, as the rule of 3 would measure a cut run without the part the + // tag took. A code span's content is not read for emphasis. + const beside = (char: string) => + !isCodeSpan && SPEC_MARKERS.includes(char) && char !== tag[0] + ? tag[0] + : undefined; return importAround( result, null, startIndex, endIndex, { - after: isCodeSpan - ? undefined - : characterBeyondRun( - text, - endIndex - tagLength, - 1, - after, - delimiters, - !SPEC_MARKERS.includes(text[endIndex - tagLength - 1]), - ), - before: isCodeSpan - ? undefined - : characterBeyondRun( - text, - startIndex + tagLength, - -1, - before, - delimiters, - !SPEC_MARKERS.includes(text[startIndex + tagLength]), - ), + after: beside(text[endIndex - tag.length - 1]), + before: beside(text[startIndex + tag.length]), node: result.transformedNode, }, undefined, From 536955033df78180c0cdcec156ccab28c93c4ef5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 01:39:22 +0000 Subject: [PATCH 66/70] [lexical-markdown] Refactor: Classify a character for flanking without an ASCII table ## Description `classify` filled a lazy 128-entry table so that ASCII characters needed no regular expression. It is called once per delimiter run and per marker, not per character, so the table saved nothing measurable, and it cost 186 bytes minified (73 gzipped). It now tests the two category expressions directly. ## Test plan ### Before ``` $ esbuild packages/lexical-markdown/src/index.ts --bundle --minify --external:lexical --external:'@lexical/*' | wc -c; ... | gzip -9 | wc -c 43701 16621 ``` ### After ``` $ pnpm exec vitest run --project unit packages/lexical-markdown Test Files 9 passed (9) Tests 1063 passed (1063) $ esbuild packages/lexical-markdown/src/index.ts --bundle --minify --external:lexical --external:'@lexical/*' | wc -c; ... | gzip -9 | wc -c 43515 16548 ``` Co-Authored-By: Claude Opus 5.5 --- packages/lexical-markdown/src/utils.ts | 27 +++----------------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index 8e51015fd4a..ab4eaeb97af 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -70,36 +70,15 @@ const WHITESPACE_CLASS = 0; const PUNCTUATION_CLASS = 1; const OTHER_CLASS = 2; -function classifyByCategory(character: string): number { - return WHITESPACE.test(character) +/** How flanking sees `character`; the edge of the line is whitespace. */ +function classify(character: string | undefined): number { + return character === undefined || WHITESPACE.test(character) ? WHITESPACE_CLASS : PUNCTUATION.test(character) ? PUNCTUATION_CLASS : OTHER_CLASS; } -// ASCII classes, filled from the categories on first use so that the common -// case needs no regular expression. -let asciiClasses: Uint8Array | undefined; - -/** How flanking sees `character`; the edge of the line is whitespace. */ -function classify(character: string | undefined): number { - if (character === undefined) { - return WHITESPACE_CLASS; - } - const code = character.charCodeAt(0); - if (code >= 0x80) { - return classifyByCategory(character); - } - if (asciiClasses === undefined) { - asciiClasses = new Uint8Array(0x80); - for (let c = 0; c < 0x80; c++) { - asciiClasses[c] = classifyByCategory(String.fromCharCode(c)); - } - } - return asciiClasses[code]; -} - /** * Whether a delimiter run with `primary` on the side it faces (after it for * left-flanking, before it for right-flanking) and `secondary` on the other From 205ed4e00ec7623f301e18101c7272d5dd62cf5e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 02:08:16 +0000 Subject: [PATCH 67/70] [lexical-markdown] Refactor: Write the export's references and the link search in less code ## Description The same output in less code, from 43,515 to 42,305 bytes minified (16,548 to 16,152 gzipped) for the package bundle: - Export marks the code units that go out as references on one array over the joined text, which the URL spoiling marks too, and writes them in one pass, rather than keeping per-piece lengths and a list of spoiled positions. A marker run is a tuple, and one function decides whether a reference helps a run open or close. - A link whose label holds its real opener is found by searching on from that opener, which finds the same link, so `moveMatch` no longer cuts a match and a found link needs no separate position. - `characterAt` and `characterBefore` use `codePointAt`, and the cut-run walk reads the scan's delimiter set instead of a cached string. ## Test plan ### Before ``` $ esbuild packages/lexical-markdown/src/index.ts --bundle --minify --external:lexical --external:'@lexical/*' | wc -c; ... | gzip -9 | wc -c 43515 16548 ``` ### After ``` $ pnpm exec vitest run --project unit packages/lexical-markdown packages/lexical-mdast Test Files 17 passed (17) Tests 1348 passed (1348) $ esbuild packages/lexical-markdown/src/index.ts --bundle --minify --external:lexical --external:'@lexical/*' | wc -c; ... | gzip -9 | wc -c 42305 16152 ``` The export and import output was also compared with the previous commit on 210,000 random paragraphs and 830,000 random lines (formats, links, line breaks, raw transformer output, URLs, emails, references, code spans and brackets), with no difference. Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 338 +++++++----------- .../src/importTextMatchTransformer.ts | 65 ++-- .../src/importTextTransformers.ts | 24 +- packages/lexical-markdown/src/utils.ts | 20 +- 4 files changed, 158 insertions(+), 289 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index d990fb8c941..b3ed4fd046d 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -771,19 +771,20 @@ function isReferenceable(character: string | undefined): boolean { ); } -/** Adjacent markers, between the pieces at `before` and `after`. */ -interface MarkerRun { - before: number; - after: number; - /** Whether the first delimiter run opens a tag. */ - opens: boolean; - /** Whether the last delimiter run closes a tag. */ - closes: boolean; - /** The marker character after the first delimiter run, if any. */ - afterFirst: string | undefined; - /** The marker character before the last delimiter run, if any. */ - beforeLast: string | undefined; -} +/** + * Adjacent markers between the pieces at `before` and `after`: whether the + * first delimiter run opens a tag, the marker character after it within the + * markers (if any), whether the last delimiter run closes a tag, and the + * marker character before it. + */ +type MarkerRun = [ + before: number, + after: number, + opens: boolean, + afterFirst: string | undefined, + closes: boolean, + beforeLast: string | undefined, +]; /** * Joins exported pieces into Markdown. A delimiter run between a word @@ -793,74 +794,53 @@ interface MarkerRun { * reference instead, whose `&` or `;` is punctuation: `a**\***`. */ function joinPieces({kinds, values}: MarkdownPieces): string { + const text = values.join(''); if (kinds.every(kind => kind === TEXT)) { - return values.join(''); + return text; } - const {encodeStart, encodeEnd} = decideReferences(kinds, values); - const count = kinds.length; - // How many code units of each piece go out as a reference at its start - // and at its end, and whether the backslash before the end does. - const startLengths = new Uint8Array(count); - const endLengths = new Uint8Array(count); - const escapesEnd = new Uint8Array(count); - for (let index = 0; index < count; index++) { + const [encodeStart, encodeEnd] = decideReferences(kinds, values); + // The code units of `text` that go out as references. + const isReference = new Uint8Array(text.length); + for (let index = 0, base = 0; index < kinds.length; index++) { const value = values[index]; - if (encodeStart[index]) { - startLengths[index] = characterAt(value, 0)!.length; - } - if (encodeEnd[index] && value.length > startLengths[index]) { - endLengths[index] = characterBefore(value, value.length)!.length; + const start = encodeStart[index] ? characterAt(value, 0)!.length : 0; + let end = value.length; + if (encodeEnd[index] && end > start) { + end -= characterBefore(value, end)!.length; } // A backslash left unescaped (when preserving newlines, or in decorator // text) would escape the reference, marker or raw punctuation written // after it, or end the line with a hard break. - const bodyEnd = value.length - endLengths[index]; - escapesEnd[index] = Number( + if ( kinds[index] === TEXT && - (isMarker(kinds[index + 1]) || - (kinds[index + 1] === RAW && ESCAPABLE.test(values[index + 1]))) && - bodyEnd > startLengths[index] && - isEscaped(value, bodyEnd), - ); - } - const spoiled = findSpoiledLinks(kinds, values, (index, mark) => { - mark(0, startLengths[index]); - const end = values[index].length - endLengths[index]; - mark(end - escapesEnd[index], values[index].length); - }); - let markdown = ''; - let next = 0; - for (let index = 0, base = 0; index < count; index++) { - const value = values[index]; - const end = value.length - endLengths[index] - escapesEnd[index]; - let from = startLengths[index]; - markdown += toReferences(value.slice(0, from)); - for ( - ; - next < spoiled.length && spoiled[next] < base + value.length; - next++ + (isMarker(kinds[index + 1]) || + (kinds[index + 1] === RAW && ESCAPABLE.test(values[index + 1]))) && + end > start && + isEscaped(value, end) ) { - const at = spoiled[next] - base; - markdown += value.slice(from, at) + toCharacterReference(value[at]); - from = at + 1; + end--; } - markdown += - value.slice(from, end) + - toReferences(value.slice(end, end + escapesEnd[index])) + - toReferences(value.slice(end + escapesEnd[index])); - base += value.length; + isReference.fill(1, base, base + start); + isReference.fill(1, base + end, (base += value.length)); } - return markdown; + spoilLinks(kinds, values, isReference); + let markdown = ''; + let from = 0; + for ( + let at = isReference.indexOf(1); + at !== -1; + at = isReference.indexOf(1, from) + ) { + const character = characterAt(text, at)!; + markdown += text.slice(from, at) + toCharacterReference(character); + from = at + character.length; + } + return markdown + text.slice(from); } // What a backslash before it escapes: ASCII punctuation or a line ending. const ESCAPABLE = /^[!-/:-@[-`{-~\n\r]/; -// The character in `value`, if any, as a reference. -function toReferences(value: string): string { - return value === '' ? '' : toCharacterReference(value); -} - /** * Raw output as the bare-URL scan sees it. It keeps whitespace and `<`, which * end a URL, `*_~(`, after which `www.` starts one, and ASCII letters, before @@ -880,52 +860,35 @@ function maskRaw(value: string): string { * text. Such a URL is spoiled instead, by writing the `.` of `www.` or the * `:` after its scheme as a reference, so that no reader links it and its * text and formats come through. So is a URL that a reference before it - * would start. `markReferences` is given each text piece and a function that - * marks the code units of it, from one offset to another, that go out as - * references. Returns, in order, the code units of the joined text to write - * as references, each in a text piece. + * would start. `isReference` marks the code units of the joined text that go + * out as references, and the ones that spoil a link are marked in it too. */ -function findSpoiledLinks( +function spoilLinks( kinds: PieceKind[], values: string[], - markReferences: ( - index: number, - mark: (from: number, to: number) => void, - ) => void, -): number[] { + isReference: Uint8Array, +): void { // Every URL and email starts with one of these. if (!values.some((value, i) => kinds[i] === TEXT && LINKISH.test(value))) { - return []; + return; } let text = ''; - const starts = new Uint32Array(values.length + 1); + // Whether each code unit is not text, as `1`. + let isFormat = ''; for (let i = 0; i < values.length; i++) { - starts[i] = text.length; text += kinds[i] === RAW ? maskRaw(values[i]) : values[i]; - } - starts[values.length] = text.length; - const isReference = new Uint8Array(text.length); - for (let i = 0; i < values.length; i++) { - if (kinds[i] === TEXT) { - markReferences(i, (from, to) => - isReference.fill(1, starts[i] + from, starts[i] + to), - ); - } + isFormat += (kinds[i] === TEXT ? '0' : '1').repeat(values[i].length); } // Running counts of referenced code units and of code units that are not // text, so that whether any lie in a span is a subtraction. const references = new Uint32Array(text.length + 1); const formats = new Uint32Array(text.length + 1); - for (let i = 0, piece = 0; i < text.length; i++) { - while (starts[piece + 1] <= i) { - piece++; - } + for (let i = 0; i < text.length; i++) { references[i + 1] = references[i] + isReference[i]; - formats[i + 1] = formats[i] + Number(kinds[piece] !== TEXT); + formats[i + 1] = formats[i] + Number(isFormat[i] === '1'); } const has = (sums: Uint32Array, from: number, to: number) => from < to && sums[to] > sums[from]; - const spoiled: number[] = []; // A reference in an email would cut a micromark reader's link short, so // each `@` of one that holds a reference is spoiled. const spoilEmails = (from: number, to: number) => { @@ -943,10 +906,10 @@ function findSpoiledLinks( if ( text[i] === '@' && !isReference[i] && - !has(formats, i, i + 1) && + isFormat[i] === '0' && !isEscaped(text, i) ) { - spoiled.push(i); + isReference[i] = 1; } } } @@ -987,15 +950,10 @@ function findSpoiledLinks( // there. const before = at > start ? text[at - 1] : ''; const wasLink = at === start || startsLink(prefix[0], before); - const isLink = - at === start || - startsLink( - prefix[0], - isReference[at - 1] || spoiled[spoiled.length - 1] === at - 1 - ? ';' - : before, - ); - if (!isLink) { + if ( + at > start && + !startsLink(prefix[0], isReference[at - 1] ? ';' : before) + ) { continue; } spoilEmails(rest, at); @@ -1007,18 +965,17 @@ function findSpoiledLinks( // is a custom format's marker is left as it is. const spoil = at + prefix[0].search(/[.:]/); if (!has(references, at, at + prefix[0].length)) { - if (has(formats, spoil, spoil + 1)) { + if (isFormat[spoil] === '1') { rest = end; break; } - spoiled.push(spoil); + isReference[spoil] = 1; } rest = at + prefix[0].length; } spoilEmails(rest, end); start = end; } - return spoiled; } /** @@ -1055,64 +1012,38 @@ function scanDelimiterRun( } /** - * Marks the text pieces whose first or last character has to go out as a - * reference for the marker run beside it to open or close. + * Marks the text pieces whose first (in the first array) or last character + * has to go out as a reference for the marker run beside it to open or close. */ function decideReferences( kinds: PieceKind[], values: string[], -): { - encodeStart: Uint8Array; - encodeEnd: Uint8Array; -} { +): [Uint8Array, Uint8Array] { const count = kinds.length; const encodeStart = new Uint8Array(count); const encodeEnd = new Uint8Array(count); const runs: MarkerRun[] = []; - // The run that follows and the run that precedes each piece, or -1. + // The run that follows and the run that precedes each piece, or -1. (A + // typed array ignores a write out of its bounds.) const runAfter = new Int32Array(count).fill(-1); const runBefore = new Int32Array(count).fill(-1); for (let start = 0; start < count; start++) { - if (!isMarker(kinds[start])) { - continue; - } - let end = start; - while (isMarker(kinds[end + 1])) { - end++; - } - // Markers of one character form one delimiter run, and only the first - // and the last delimiter run touch text. - const [opens, afterFirst] = scanDelimiterRun( - kinds, - values, - start, - end, - 1, - OPENER, - ); - const [closes, beforeLast] = scanDelimiterRun( - kinds, - values, - end, - start, - -1, - CLOSER, - ); - if (start > 0) { - runAfter[start - 1] = runs.length; - } - if (end + 1 < count) { - runBefore[end + 1] = runs.length; + if (isMarker(kinds[start])) { + let end = start; + while (isMarker(kinds[end + 1])) { + end++; + } + runAfter[start - 1] = runBefore[end + 1] = runs.length; + // Markers of one character form one delimiter run, and only the first + // and the last delimiter run touch text. + runs.push([ + start - 1, + end + 1, + ...scanDelimiterRun(kinds, values, start, end, 1, OPENER), + ...scanDelimiterRun(kinds, values, end, start, -1, CLOSER), + ]); + start = end; } - runs.push({ - after: end + 1, - afterFirst, - before: start - 1, - beforeLast, - closes, - opens, - }); - start = end; } const firstChar = (index: number) => characterAt(values[index], 0)!; const lastChar = (index: number) => @@ -1138,8 +1069,6 @@ function decideReferences( // Whether the last (or first) character of the piece at `index` can go out // as a reference. const canReference = (index: number, atEnd: boolean): boolean => - index >= 0 && - index < count && kinds[index] === TEXT && isReferenceable(atEnd ? endChar(index) : startChar(index)); // Writing a one-character piece as a reference makes its other edge @@ -1157,12 +1086,12 @@ function decideReferences( // 0 while unknown, then 1 when it spreads and 2 when it is blocked. const spreads = [new Uint8Array(count), new Uint8Array(count)]; const canSpread = (index: number, leftward: boolean): boolean => { - const side = leftward ? 0 : 1; + const known = spreads[Number(leftward)]; const visited: number[] = []; let result = true; for (let piece = index; ; ) { - if (spreads[side][piece] !== 0) { - result = spreads[side][piece] === 1; + if (known[piece] !== 0) { + result = known[piece] === 1; break; } visited.push(piece); @@ -1170,14 +1099,19 @@ function decideReferences( if (runIndex === -1) { break; } - const run = runs[runIndex]; + const [before, after, opens, afterFirst, closes, beforeLast] = + runs[runIndex]; + const next = leftward ? before : after; // Only a run of a single delimiter character touches both pieces. - const isAffected = leftward - ? run.opens && run.afterFirst === undefined - : run.closes && run.beforeLast === undefined; - const next = leftward ? run.before : run.after; - const char = leftward ? endChar(next) : startChar(next); - if (!isAffected || isPortablyFlankedBy(leftward ? '&' : ';', char)) { + if ( + !(leftward + ? opens && afterFirst === undefined + : closes && beforeLast === undefined) || + isPortablyFlankedBy( + leftward ? '&' : ';', + leftward ? endChar(next) : startChar(next), + ) + ) { break; } if (!canReference(next, leftward)) { @@ -1190,44 +1124,36 @@ function decideReferences( piece = next; } for (const piece of visited) { - spreads[side][piece] = result ? 1 : 2; + known[piece] = result ? 1 : 2; } return result; }; - const isFree = (index: number, leftward: boolean) => - !isOneCharacter(index) || canSpread(index, leftward); - // A reference helps only where the run could open (or close) if the - // character beside it were punctuation. `_` between two letters cannot. - const opensWithReference = ({ - opens, - before, - after, - afterFirst, - }: MarkerRun) => { - if (!opens) { + // Whether a reference beside the run, before it when `opening` and after it + // otherwise, lets its first delimiter run open (or its last close) where it + // could not, and whether that reference can be written. `_` between two + // letters cannot open either way. + const isHelpedBy = ( + [before, after, opens, afterFirst, closes, beforeLast]: MarkerRun, + opening: boolean, + ): boolean => { + const piece = opening ? before : after; + if (!(opening ? opens : closes) || !canReference(piece, opening)) { return false; } - const marker = values[before + 1][0]; - const next = afterFirst || startChar(after); + const side = opening ? 'canOpen' : 'canClose'; + const marker = opening + ? values[before + 1][0] + : values[after - 1][values[after - 1].length - 1]; + const previous = (!opening && beforeLast) || endChar(before); + const next = (opening && afterFirst) || startChar(after); return ( - !getPortableEmphasisSides(marker, endChar(before), next).canOpen && - getPortableEmphasisSides(marker, ';', next).canOpen - ); - }; - const closesWithReference = ({ - closes, - before, - after, - beforeLast, - }: MarkerRun) => { - if (!closes) { - return false; - } - const marker = values[after - 1][values[after - 1].length - 1]; - const previous = beforeLast || endChar(before); - return ( - !getPortableEmphasisSides(marker, previous, startChar(after)).canClose && - getPortableEmphasisSides(marker, previous, '&').canClose + !getPortableEmphasisSides(marker, previous, next)[side] && + getPortableEmphasisSides( + marker, + opening ? ';' : previous, + opening ? next : '&', + )[side] && + (!isOneCharacter(piece) || canSpread(piece, opening)) ); }; // Text does not escape every tag character (the `=` of `==`, say), and one @@ -1258,26 +1184,18 @@ function decideReferences( while (pending.length > 0) { const index = pending.pop()!; const run = runs[index]; - const {before, after} = run; - if ( - opensWithReference(run) && - canReference(before, true) && - isFree(before, true) - ) { + const [before, after, opens, afterFirst] = run; + if (isHelpedBy(run, true)) { encodeEnd[before] = 1; if (isEncoded(before) && runBefore[before] !== -1) { pending.push(runBefore[before]); } } - if ( - closesWithReference(run) && - canReference(after, false) && - isFree(after, false) - ) { + if (isHelpedBy(run, false)) { encodeStart[after] = 1; // An opener that sees the same character, of a run that both closes // and opens, may now need its own reference. - if (run.opens && run.afterFirst === undefined) { + if (opens && afterFirst === undefined) { pending.push(index); } if (isEncoded(after) && runAfter[after] !== -1) { @@ -1285,7 +1203,7 @@ function decideReferences( } } } - return {encodeEnd, encodeStart}; + return [encodeStart, encodeEnd]; } /** diff --git a/packages/lexical-markdown/src/importTextMatchTransformer.ts b/packages/lexical-markdown/src/importTextMatchTransformer.ts index 85468a42a1f..2de4f01ecf0 100644 --- a/packages/lexical-markdown/src/importTextMatchTransformer.ts +++ b/packages/lexical-markdown/src/importTextMatchTransformer.ts @@ -14,30 +14,20 @@ import {isEscaped} from './utils'; /** * A copy of `match` for `text`, which is what is left of the text it was - * found in after `distance` characters, less the first `cut` characters of - * the match and of its first group, which starts with it. + * found in after `distance` characters. */ function moveMatch( match: RegExpExecArray, text: string, distance: number, - cut = 0, ): RegExpExecArray { const moved = [...match] as RegExpExecArray; - moved.index = match.index - distance + cut; + moved.index = match.index - distance; moved.input = text; moved.groups = match.groups; - if (cut > 0) { - moved[0] = match[0].slice(cut); - moved[1] = match[1].slice(cut); - } if (match.indices) { const indices = match.indices.map( - (range, group) => - range && [ - range[0] - distance + (group < 2 ? cut : 0), - range[1] - distance, - ], + range => range && [range[0] - distance, range[1] - distance], ) as RegExpIndicesArray; const {groups} = match.indices; if (groups) { @@ -60,16 +50,15 @@ export type CodeSpans = readonly {startIndex: number; endIndex: number}[]; /** * The first link in `text`, starting at the `[` that really opens its text, - * with none of its syntax inside a code span, and up to where a search would - * find it. `codeSpans` are those of the text that `text` is what is left of - * after `offset`, when they are known. + * with none of its syntax inside a code span. `codeSpans` are those of the + * text that `text` is what is left of after `offset`, when they are known. */ function matchLink( text: string, regExp: RegExp, codeSpans: CodeSpans | undefined, offset: number, -): [match: RegExpExecArray, found: number] | null { +): RegExpExecArray | null { const search = new RegExp( regExp.source, regExp.flags.replace(/[gy]/g, '') + 'g', @@ -126,21 +115,16 @@ function matchLink( continue; } // A `[` that the label holds outside a code span is the link's real - // opener, and what comes before it is ordinary text, so the match starts - // there. A search from any `[` up to the real opener finds the same link. - // A link with an empty label, as in `x[[](u)`, stays text, as `[](u)` - // does, so the search goes on from the opener. + // opener, and what comes before it is ordinary text, so the search goes + // on from there, where it finds the same link. A link with an empty + // label, as in `x[[](u)`, stays text, as `[](u)` does. const opener = getLinkLabelOpener(match[1]); if (opener !== null && opener >= 0) { - if (opener + 1 < match[1].length) { - const inner = moveMatch(match, text, 0, opener + 1); - return [inner, inner.index]; - } search.lastIndex = match.index + 1 + opener; continue; } if (!isEscaped(text, match.index)) { - return [match, match.index]; + return match; } // The label of an escaped `[` holds no opener, so each escaped `[` up to // the next one written as is finds what the last of them finds: that @@ -191,15 +175,14 @@ function looksOnlyAhead(regExp: RegExp): boolean { } /** - * The match each text-match transformer found in a text, where its search - * found it and the offset of the text it was found in, or `null` when there - * is none. Kept for the built-in link syntax and for patterns that only look + * The match each text-match transformer found in a text and the offset of + * the text it was found in, or `null` when there is none. Kept for the built-in link syntax and for patterns that only look * ahead, for which it is still the first match in what is left of the text * after any offset up to where it was found. */ export type TextMatches = Map< TextMatchTransformer, - {found: number; match: RegExpExecArray; offset: number} | null + {match: RegExpExecArray; offset: number} | null >; export function findOutermostTextMatchTransformer( @@ -238,19 +221,15 @@ export function findOutermostTextMatchTransformer( let match: RegExpMatchArray | null; if (isLink || looksOnlyAhead(importRegExp)) { const found = matches.get(transformer); - if (found === undefined || (found !== null && found.found < offset)) { - let result: [RegExpExecArray, number] | null; - if (isLink) { - result = matchLink(text, importRegExp, codeSpans, offset); - } else { - const exec = importRegExp.exec(text); - result = exec && [exec, exec.index]; - } - matches.set( - transformer, - result && {found: offset + result[1], match: result[0], offset}, - ); - match = result && result[0]; + if ( + found === undefined || + (found !== null && found.offset + found.match.index < offset) + ) { + const result = isLink + ? matchLink(text, importRegExp, codeSpans, offset) + : importRegExp.exec(text); + matches.set(transformer, result && {match: result, offset}); + match = result; } else { match = found && moveMatch(found.match, text, offset - found.offset); } diff --git a/packages/lexical-markdown/src/importTextTransformers.ts b/packages/lexical-markdown/src/importTextTransformers.ts index 800c175a9dc..0a510185c98 100644 --- a/packages/lexical-markdown/src/importTextTransformers.ts +++ b/packages/lexical-markdown/src/importTextTransformers.ts @@ -67,22 +67,6 @@ function readsOtherwiseBesideEdge( /** The markers whose runs CommonMark and GFM define. */ const SPEC_MARKERS = '*_~'; -const delimitersByIndex = new WeakMap(); - -/** The characters that start a code span or a format's tag. */ -function getDelimiters(index: TextFormatTransformersIndex): string { - let delimiters = delimitersByIndex.get(index); - if (delimiters === undefined) { - delimiters = - '`' + - Object.keys(index.transformersByTag) - .map(tag => tag[0]) - .join(''); - delimitersByIndex.set(index, delimiters); - } - return delimiters; -} - /** * What a delimiter run cut at `index` of `text` sees beyond its far end once * the text is split there: before the run that starts at `index` when `step` @@ -95,14 +79,14 @@ function characterBeyondRun( index: number, step: 1 | -1, outside: string | undefined, - delimiters: string, + delimiters: Set, isBesideTextMatch = false, ): string | undefined { // Only a delimiter's run is walked: beside anything else, what lies beyond // is never read, and a long run of it would be walked at every cut. const beside = text[step === 1 ? index - 1 : index]; const edge = - beside !== undefined && delimiters.includes(beside) + beside === '`' || delimiters.has(beside) ? getDelimiterRunEdge(text, index, step) : index; if (isBesideTextMatch && edge === index) { @@ -243,7 +227,7 @@ function importOutermost( rest: text, }; const {formats} = scan; - const delimiters = getDelimiters(textFormatTransformersIndex); + const {delimiters} = formats; let foundTextFormat = findOutermostTextFormatTransformer( text, textFormatTransformersIndex, @@ -364,7 +348,7 @@ function importOutermost( isReusable && isTextMatch && restBefore === undefined && - formats.delimiters.has(rest[0]) && + delimiters.has(rest[0]) && readsOtherwiseBesideEdge(rest, characterBefore(text, endIndex), after) ) { isReusable = findPairAtEdge(formats, rest, textFormatTransformersIndex); diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index ab4eaeb97af..806f78dd09b 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -178,20 +178,10 @@ export function isLetterForSomeReader(character: string): boolean { ); } -function isHighSurrogate(code: number): boolean { - return code >= 0xd800 && code <= 0xdbff; -} - -function isLowSurrogate(code: number): boolean { - return code >= 0xdc00 && code <= 0xdfff; -} - /** The character (code point) that starts at `index`, if any. */ export function characterAt(text: string, index: number): string | undefined { - return isHighSurrogate(text.charCodeAt(index)) && - isLowSurrogate(text.charCodeAt(index + 1)) - ? text.slice(index, index + 2) - : text[index]; + const code = text.codePointAt(index); + return code === undefined ? undefined : String.fromCodePoint(code); } /** The character (code point) that ends just before `index`, if any. */ @@ -199,10 +189,8 @@ export function characterBefore( text: string, index: number, ): string | undefined { - return isLowSurrogate(text.charCodeAt(index - 1)) && - isHighSurrogate(text.charCodeAt(index - 2)) - ? text.slice(index - 2, index) - : text[index - 1]; + const pair = characterAt(text, index - 2); + return pair !== undefined && pair.length === 2 ? pair : text[index - 1]; } export function toCharacterReference(character: string): string { From 12f3bb01776b036b62d48a41ef0628795ef10be1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 02:21:33 +0000 Subject: [PATCH 68/70] [lexical-markdown] Bug Fix: Skip the bare-URL scan of an export where no URL can need spoiling ## Description Export scanned every paragraph whose text holds `www.`, `://` or `@` for bare URLs to spoil, building a masked copy of the paragraph and running counts over it, even where nothing could need it. A URL is spoiled only for a reference that is written, or for one that runs on to the end of its text into markers or raw output, so the scan now runs only for a paragraph that writes a reference or has such a URL. Output is unchanged. ## Test plan ### Before A 300-paragraph document, each paragraph with a bare URL and one bold word, median of 15 runs of `$convertToMarkdownString`: ``` url=true cur 7.9ms url=true main 3.0ms ``` ### After ``` url=true cur 4.2ms url=true main 3.0ms $ pnpm exec vitest run --project unit packages/lexical-markdown Test Files 9 passed (9) Tests 1063 passed (1063) ``` The export was also compared with the previous commit on 45,000 random paragraphs with URLs, emails, references, markers and raw output, with no difference. Co-Authored-By: Claude Opus 5.5 --- .../lexical-markdown/src/MarkdownExport.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index b3ed4fd046d..5fd5d385285 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -738,6 +738,14 @@ const LINK_END = /[\s\p{Cc}<]/u; function isLinkCharacter(character: string): boolean { return !LINK_END.test(character); } +/** Where the run of URL characters that ends `text` starts. */ +function linkRunStart(text: string): number { + let start = text.length; + while (start > 0 && isLinkCharacter(text[start - 1])) { + start--; + } + return start; +} // What starts a URL: `http://`, `https://` (and cmark-gfm's `ftp://`) with // a host after it, which may start with an emoji but not with other ASCII // punctuation than `-` and `_`, and `www.`, which micromark links with @@ -868,8 +876,18 @@ function spoilLinks( values: string[], isReference: Uint8Array, ): void { - // Every URL and email starts with one of these. - if (!values.some((value, i) => kinds[i] === TEXT && LINKISH.test(value))) { + // Every URL and email starts with one of these. With no reference written, + // only a URL that runs on to the end of its text, into the markers or raw + // output after it, is spoiled. + const hasReferences = isReference.includes(1); + if ( + !values.some( + (value, i) => + kinds[i] === TEXT && + LINKISH.test(value) && + (hasReferences || LINKISH.test(value.slice(linkRunStart(value)))), + ) + ) { return; } let text = ''; From 8f7a57d85407a44aa6574a1258f326841fc44ee1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:18:57 +0000 Subject: [PATCH 69/70] [lexical-markdown] Bug Fix: Escape the backslashes before a marker in linear time when preserving newlines ## Description With `shouldPreserveNewLines`, export doubled a run of backslashes before a marker with `/\\+(?=[*_`~])/`, which a run that no marker follows retries from each of its backslashes, so a text node of 16,000 backslashes took 400 ms to export. The whole run is now matched once and the character after it checked, which gives the same output in linear time. ## Test plan ### Before Exporting a text node of 16,000 backslashes and a letter with `shouldPreserveNewLines`: ``` ref 400.0ms ``` ### After ``` cur 1.8ms $ pnpm exec vitest run --project unit packages/lexical-markdown Test Files 9 passed (9) Tests 1063 passed (1063) ``` The export was also compared with the previous commit on 25,000 random paragraphs of backslashes, markers, letters and newlines in both modes, with no difference. Co-Authored-By: Claude Opus 5.5 --- packages/lexical-markdown/src/MarkdownExport.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 5fd5d385285..857131f21ce 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -563,8 +563,16 @@ function $exportTextFormat( // Numeric escapes avoid changing emphasis flanking beside formatting. // Guard ambiguous '<' and '&' at node ends for constructs split across nodes. if (shouldPreserveNewLines) { - output = output.replace(/\\+(?=[*_`~])|[*_`~]/g, escaped => - escaped[0] === '\\' ? escaped + escaped : `\\${escaped}`, + // A whole run of backslashes is matched, so that one before no marker + // is not tried again from each of its backslashes. + output = output.replace( + /\\+|[*_`~]/g, + (escaped, offset: number, text: string) => + escaped[0] !== '\\' + ? `\\${escaped}` + : /[*_`~]/.test(text[offset + escaped.length]) + ? escaped + escaped + : escaped, ); } else { output = $escapeLiteralBrackets( From e99f8f3ccf4832ed964b8739097cbb3ae622e784 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:46:10 +0000 Subject: [PATCH 70/70] [lexical-markdown] Bug Fix: Read the run after a text match beside a line edge up to a code span only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Text right after a text match reads the match's edge as the edge of a line, as on main, but the search for the pair that the run there opens stopped at the next emphasis the scan had found. A pair that holds that emphasis was then never found: with a `user:(\w+)` mention transformer, `user:bob**(text *more*)**`, which Lexical exports for a mention followed by bold text holding italic, re-imported with literal `**` markers, and `[x](u)**:*a.**` lost its bold the same way. The search now stops only at a code span that comes before the next emphasis, which is imported first as it is on main, so both read as they do on main. ## Test plan ### Before ``` pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "nested after" × reads formatting nested after a text match whose syntax has a letter beside it 43ms AssertionError: expected [ [ 'bob', +0 ], …(3) ] to deep equally contain [ 'more', 3 ] Tests 1 failed | 794 skipped (795) ``` ### After ``` pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "a text match whose syntax" Tests 5 passed | 790 skipped (795) ``` Co-Authored-By: Claude Opus 5.5 --- .../__tests__/unit/LexicalMarkdown.test.ts | 1 + .../src/importTextFormatTransformer.ts | 36 ++++++++++++------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 5241347b03b..90d0778401c 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -5507,6 +5507,7 @@ describe('emphasis next to Unicode punctuation and symbols', () => { ['after', 'user:bob**(admin)**', ['(admin)', 1]], ['before', '**(admin)**user:bob', ['(admin)', 1]], ['in italic after', 'user:bob*\u201ca\u201d*', ['\u201ca\u201d', 2]], + ['nested after', 'user:bob**(text *more*)**', ['more', 3]], [ 'far after', 'user:bob**(' + 'x '.repeat(40) + 'y)**', diff --git a/packages/lexical-markdown/src/importTextFormatTransformer.ts b/packages/lexical-markdown/src/importTextFormatTransformer.ts index f9c807e565d..a7a223d637d 100644 --- a/packages/lexical-markdown/src/importTextFormatTransformer.ts +++ b/packages/lexical-markdown/src/importTextFormatTransformer.ts @@ -145,26 +145,38 @@ export function advanceFormatScan( * `rest` comes before the run, so the rest reads the same either way but for * the pair the run opens, which nothing in `rest` can hold, and whatever * pairs across its end, which `advanceFormatScan` turns down there. The pair - * ends before the next format that the scan found, which is imported first - * as it would be with the match before it, and it is found in ever longer - * prefixes of `rest`, so that finding it costs about as much as the text it - * holds. Returns false when the rest has to be scanned again. + * ends before a code span that the scan found next, which is imported first + * as it is with the match before it, and it is found in ever longer prefixes + * of `rest`, so that finding it costs about as much as the text it holds. + * Returns false when the rest has to be scanned again. */ export function findPairAtEdge( scan: FormatScan, rest: string, textFormatTransformersIndex: TextFormatTransformersIndex, ): boolean { - const {offset} = scan; - const span = scan.codeSpans[scan.nextCodeSpan]; + const {codeSpans, offset} = scan; + let span = scan.nextCodeSpan; const next = scan.emphasis[scan.nextEmphasis]; - const end = Math.min( - rest.length, - span ? span.startIndex - offset : Infinity, - next ? next.startIndex - offset : Infinity, - ); + const end = + span < codeSpans.length && + (!next || codeSpans[span].startIndex < next.startIndex) + ? codeSpans[span].startIndex - offset + : rest.length; for (let length = 64; ; length *= 2) { - // Cut no run of a delimiter or backtick; no code span starts before `end`. + // Cut neither a code span nor a run of a delimiter or backtick. + while ( + span < codeSpans.length && + codeSpans[span].endIndex <= offset + length + ) { + span++; + } + if ( + span < codeSpans.length && + codeSpans[span].startIndex < offset + length + ) { + length = codeSpans[span].endIndex - offset; + } if ( length < end && (rest[length - 1] === '`' || scan.delimiters.has(rest[length - 1]))