Repository navigation
[lexical-mdast] Bug Fix: Keep edits to imported autolink literals #9669
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
086cfc8
165b860
6ce92d8
5d63219
74e3ed4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 `<https://a.example>`, and copying `https://one.ex` from `https://one.example` exports as `[https://one.ex](https://one.example)`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -41,13 +41,15 @@ import { | |
| $isElementNode, | ||
| $isLineBreakNode, | ||
| $isRangeSelection, | ||
| $isRootNode, | ||
| $isSlotHost, | ||
| $isTextNode, | ||
| IS_BOLD, | ||
| IS_CODE, | ||
| 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<Nodes> { | |
| * options (and contributed `toMarkdownExtensions`) still apply to them. | ||
| */ | ||
| const syntaxToMarkdown = ( | ||
| getLiteralUrl: (text: string) => string | null, | ||
| hardBreaks: ReadonlySet<Nodes>, | ||
| ): 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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional, non-blocking: this check only sees the label, so neighbouring text can still change how the bare form re-parses. GFM extended autolinks need a line start, whitespace, or
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added a note on the neighbouring-text limitation in that comment; the behavior there is unchanged in this PR. |
||
| // Edits to its destination, label or formatting need resource syntax. | ||
| // Neighbouring text can still change how the bare form re-parses. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional follow-up: this case is reachable by typing.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I left this one out. In the link handler |
||
| 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<string, string | null>(); | ||
|
|
||
| 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<string, string | null>() | ||
| : literalUrls; | ||
| const getLiteralUrl = (text: string): string | null => { | ||
| let url = literalUrls.get(text); | ||
| if (url === undefined) { | ||
| const parsed = fromMarkdown(text, { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional, non-blocking: each label not yet cached costs a full
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I left the cold path as is: what the importer produced for a literal can depend on document-level transforms, so an unchanged label and URL don't prove the standalone re-parse would give the same answer, and skipping it could change output for some configurations. I'd leave batching for a follow-up. |
||
| extensions: compiled.micromarkExtensions, | ||
| mdastExtensions: compiled.mdastExtensions, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional, non-blocking: this runs every contributed |
||
| }); | ||
| 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, | ||
| ), | ||
| ); | ||
| }; | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Optional follow-up: since only a single text child keeps the bare form, an unchanged
**https://a.example**now exports as[**https://a.example**](https://a.example). That's lossless (the base dropped the bold), but GFM recognises an extended autolink after*,_and~, so writing one uniform wrapper around the bare text could keep the source spelling.