diff --git a/.changeset/mdast-edited-autolink-literals.md b/.changeset/mdast-edited-autolink-literals.md new file mode 100644 index 00000000000..03407819c24 --- /dev/null +++ b/.changeset/mdast-edited-autolink-literals.md @@ -0,0 +1,7 @@ +--- +'@lexical/mdast': patch +--- + +[fix] Preserve edits to imported autolink literals when exporting Markdown + +Verify: Import a bare URL with `MdastAutolinkLiteralExtension`, change its destination, label or text formatting, then export and reimport it. The edits should survive. Unchanged bare URLs and email addresses should keep their original syntax. Also check that `**https://a.example**` exports as `[**https://a.example**](https://a.example)`, literal-styled links loaded from JSON without `MdastAutolinkLiteralExtension` export as ``, and copying `https://one.ex` from `https://one.example` exports as `[https://one.ex](https://one.example)`. diff --git a/packages/lexical-mdast/src/MdastExport.ts b/packages/lexical-mdast/src/MdastExport.ts index bb310243bbf..dec0eccba92 100644 --- a/packages/lexical-mdast/src/MdastExport.ts +++ b/packages/lexical-mdast/src/MdastExport.ts @@ -41,6 +41,7 @@ import { $isElementNode, $isLineBreakNode, $isRangeSelection, + $isRootNode, $isSlotHost, $isTextNode, IS_BOLD, @@ -48,6 +49,7 @@ import { IS_ITALIC, IS_STRIKETHROUGH, } from 'lexical'; +import {fromMarkdown} from 'mdast-util-from-markdown'; import {defaultHandlers, toMarkdown} from 'mdast-util-to-markdown'; import {toString as mdastToString} from 'mdast-util-to-string'; @@ -148,6 +150,7 @@ function collectHardBreaks(tree: Root): Set { * options (and contributed `toMarkdownExtensions`) still apply to them. */ const syntaxToMarkdown = ( + getLiteralUrl: (text: string) => string | null, hardBreaks: ReadonlySet, ): ToMarkdownExtension => ({ handlers: { @@ -196,11 +199,19 @@ const syntaxToMarkdown = ( // A title can't be expressed in autolink/literal form, so links with a // title always fall through to the default (resource form). const style = node.data && node.data.mdastLinkStyle; - if (style === 'literal' && node.title == null) { - // A bare GFM autolink literal: emit the visible text as-is. The - // gfm-autolink-literal to-markdown extension keeps surrounding - // escaping consistent so it re-parses as the same literal. - return mdastToString(node); + if ( + style === 'literal' && + node.title == null && + node.children.length === 1 && + node.children[0].type === 'text' + ) { + // Preserve the bare form only while it still parses as the same link. + // Edits to its destination, label or formatting need resource syntax. + // Neighbouring text can still change how the bare form re-parses. + const text = node.children[0].value; + if (getLiteralUrl(text) === node.url) { + return text; + } } if (style === 'inline') { // Force `[text](url)` even when the text equals the URL (the @@ -647,6 +658,7 @@ export function createMdastExport(compiled: CompiledMdast): { // The unfiltered exporter has no per-call state and is shared by every // whole-document export; selection exports build a fresh one per call. const documentExporter = createNodeExporter(compiled); + let literalUrls = new Map(); const $toMdast = ( root: ElementNode, @@ -658,7 +670,39 @@ export function createMdastExport(compiled: CompiledMdast): { type: 'root', }); - const $serialize = (tree: Root, root: ElementNode): string => { + const $serialize = ( + tree: Root, + root: ElementNode, + isDocumentExport: boolean, + ): string => { + const nextLiteralUrls = isDocumentExport + ? new Map() + : literalUrls; + const getLiteralUrl = (text: string): string | null => { + let url = literalUrls.get(text); + if (url === undefined) { + const parsed = fromMarkdown(text, { + extensions: compiled.micromarkExtensions, + mdastExtensions: compiled.mdastExtensions, + }); + const paragraph = parsed.children[0]; + const literal = + parsed.children.length === 1 && + paragraph.type === 'paragraph' && + paragraph.children.length === 1 + ? paragraph.children[0] + : null; + url = + literal !== null && + literal.type === 'link' && + mdastToString(literal) === text + ? literal.url + : null; + literalUrls.set(text, url); + } + nextLiteralUrls.set(text, url); + return url; + }; // Emphasis/strong delimiters are document-level: `_` when one was // recorded on import, unless contributed toMarkdownExtensions set them. // Defaults (with the '-' bullet baseline) ride as the FIRST extension so @@ -677,9 +721,12 @@ export function createMdastExport(compiled: CompiledMdast): { extensions: [ defaults, ...compiled.toMarkdownExtensions, - syntaxToMarkdown(collectHardBreaks(tree)), + syntaxToMarkdown(getLiteralUrl, collectHardBreaks(tree)), ], }); + if (isDocumentExport) { + literalUrls = nextLiteralUrls; + } // toMarkdown always appends a trailing newline; drop it so callers get the // same shape as `@lexical/markdown`'s `$convertToMarkdownString`. return out.replace(/\n$/, ''); @@ -690,7 +737,11 @@ export function createMdastExport(compiled: CompiledMdast): { const $exportToMarkdown = (node?: ElementNode): string => { const root = node || $getRoot(); - return $serialize($toMdast(root, documentExporter), root); + return $serialize( + $toMdast(root, documentExporter), + root, + $isRootNode(root), + ); }; const $exportSelectionToMarkdown = ( @@ -710,7 +761,11 @@ export function createMdastExport(compiled: CompiledMdast): { return $withRenderContext([ contextValue(RenderContextMarkdownSelection, selection), ])(() => - $serialize($toMdast(root, createNodeExporter(compiled, selection)), root), + $serialize( + $toMdast(root, createNodeExporter(compiled, selection)), + root, + false, + ), ); }; diff --git a/packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts b/packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts index 54c838efb73..073a4f9a5f6 100644 --- a/packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts +++ b/packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts @@ -614,6 +614,201 @@ describe('@lexical/mdast import/export', () => { } }); + it('reuses literal validation during lookahead and repeated exports', () => { + let parses = 0; + using editor = buildEditorFromExtensions( + MdastCommonMarkExtension, + MdastAutolinkLiteralExtension, + configExtension(MdastExtension, { + mdastExtensions: [ + { + transforms: [ + () => { + parses++; + }, + ], + }, + ], + }), + ); + const markdown = 'https://lexical.dev www.example.com'; + editor.update(() => $convertFromMarkdownString(markdown), { + discrete: true, + }); + parses = 0; + expect(editor.read(() => $convertToMarkdownString())).toBe(markdown); + expect(parses).toBeLessThanOrEqual(2); + const firstExportParses = parses; + expect(editor.read(() => $convertToMarkdownString())).toBe(markdown); + expect(parses).toBe(firstExportParses); + editor.update( + () => $getRoot().getAllTextNodes()[0].setTextContent('caption'), + {discrete: true}, + ); + const edited = '[caption](https://lexical.dev) www.example.com'; + expect(editor.read(() => $convertToMarkdownString())).toBe(edited); + expect(parses).toBeLessThanOrEqual(firstExportParses + 1); + const editedExportParses = parses; + expect(editor.read(() => $convertToMarkdownString())).toBe(edited); + expect(parses).toBe(editedExportParses); + }); + + it('reuses literal validation across subtree and selection exports', () => { + let parses = 0; + using editor = buildEditorFromExtensions( + MdastCommonMarkExtension, + MdastAutolinkLiteralExtension, + configExtension(MdastExtension, { + mdastExtensions: [ + { + transforms: [ + () => { + parses++; + }, + ], + }, + ], + }), + ); + const markdown = 'https://one.example\n\nhttps://two.example'; + editor.update(() => $convertFromMarkdownString(markdown), { + discrete: true, + }); + parses = 0; + editor.read(() => { + const first = $assertNodeType( + $getRoot().getFirstChildOrThrow(), + $isElementNode, + ); + const last = $assertNodeType( + $getRoot().getLastChildOrThrow(), + $isElementNode, + ); + expect($convertToMarkdownString(first)).toBe('https://one.example'); + expect($convertToMarkdownString(last)).toBe('https://two.example'); + const warmedParses = parses; + expect($convertToMarkdownString(first)).toBe('https://one.example'); + expect($convertToMarkdownString(last)).toBe('https://two.example'); + expect($convertToMarkdownString()).toBe(markdown); + expect(parses).toBe(warmedParses); + }); + editor.update(() => $getRoot().getAllTextNodes()[0].select(0), { + discrete: true, + }); + const warmedParses = parses; + expect(editor.read(() => $convertSelectionToMarkdownString())).toBe( + 'https://one.example', + ); + expect(editor.read(() => $convertToMarkdownString())).toBe(markdown); + expect(parses).toBe(warmedParses); + }); + + it('retains the literal labels used by the latest document export', () => { + let parses = 0; + using editor = buildEditorFromExtensions( + MdastCommonMarkExtension, + MdastAutolinkLiteralExtension, + configExtension(MdastExtension, { + mdastExtensions: [ + { + transforms: [ + () => { + parses++; + }, + ], + }, + ], + }), + ); + const markdown = Array.from( + {length: 1001}, + (_, i) => `https://example.com/${i}`, + ).join('\n\n'); + editor.update(() => $convertFromMarkdownString(markdown), { + discrete: true, + }); + expect(editor.read(() => $convertToMarkdownString())).toBe(markdown); + const warmedParses = parses; + expect(editor.read(() => $convertToMarkdownString())).toBe(markdown); + expect(parses).toBe(warmedParses); + editor.update(() => $getRoot().getLastChildOrThrow().remove(), { + discrete: true, + }); + const shortened = markdown.slice(0, markdown.lastIndexOf('\n\n')); + expect(editor.read(() => $convertToMarkdownString($getRoot()))).toBe( + shortened, + ); + expect(parses).toBe(warmedParses); + editor.update(() => $convertFromMarkdownString(markdown), { + discrete: true, + }); + parses = 0; + expect(editor.read(() => $convertToMarkdownString())).toBe(markdown); + expect(parses).toBe(1); + }); + + it('keeps edits to imported autolink literals', () => { + using editor = buildEditorFromExtensions( + MdastCommonMarkExtension, + MdastAutolinkLiteralExtension, + MdastExtension, + ); + editor.update( + () => + $convertFromMarkdownString( + 'https://one.example https://two.example https://three.example', + ), + {discrete: true}, + ); + expect(editor.read(() => $convertToMarkdownString())).toBe( + 'https://one.example https://two.example https://three.example', + ); + const $links = () => + $assertNodeType($getRoot().getFirstChildOrThrow(), $isElementNode) + .getChildren() + .filter($isLinkNode); + editor.update( + () => { + const [destination, label, formatted] = $links(); + destination.setURL('https://changed.example'); + $assertNodeType( + label.getFirstChildOrThrow(), + $isTextNode, + ).setTextContent('caption'); + $assertNodeType( + formatted.getFirstChildOrThrow(), + $isTextNode, + ).setFormat('bold'); + }, + {discrete: true}, + ); + const markdown = editor.read(() => $convertToMarkdownString()); + expect(markdown).toBe( + '[https://one.example](https://changed.example) ' + + '[caption](https://two.example) ' + + '[**https://three.example**](https://three.example)', + ); + editor.update(() => $convertFromMarkdownString(markdown), { + discrete: true, + }); + editor.read(() => { + expect( + $links().map(link => [link.getURL(), link.getTextContent()]), + ).toEqual([ + ['https://changed.example', 'https://one.example'], + ['https://two.example', 'caption'], + ['https://three.example', 'https://three.example'], + ]); + expect( + $assertNodeType( + $links()[2].getFirstChildOrThrow(), + $isTextNode, + ).hasFormat('bold'), + ).toBe(true); + expect($convertToMarkdownString()).toBe(markdown); + }); + }); + describe('mdast tree interop', () => { it('$convertToMdast exposes the mdast tree before serialization', () => { using editor = createEditor();