Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/mdast-edited-autolink-literals.md
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)`.
73 changes: 64 additions & 9 deletions packages/lexical-mdast/src/MdastExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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'
Comment on lines +205 to +206

Copy link
Copy Markdown

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.

) {
// Preserve the bare form only while it still parses as the same link.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 */_/~/( before them, and they run on over the characters that follow. For example, deleting the space in see https://one.example x exports seehttps://one.example x, which re-imports without a link, the same as before this PR. A follow-up could use info.before/info.after to fall back to [label](url) in those cases; until then, this comment could note the limitation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional follow-up: this case is reachable by typing. see https://a.example plus a typed x (which lands outside the link) exports see https://a.examplex and re-imports as a link to https://a.examplex, since GFM lets any non-space non-< characters follow a valid domain. Checking info.after here before returning the bare text would close it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left this one out. In the link handler info.after holds only the first character of what follows, so a check cannot tell a sentence-ending . (which GFM leaves outside the autolink) from .x. Guarding on any following non-space character would turn every imported https://a.example. into [https://a.example](https://a.example). on an unchanged round trip. Fixing it properly needs the full serialized suffix, so it fits better as a separate change.

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
Expand Down Expand Up @@ -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,
Expand All @@ -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, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional, non-blocking: each label not yet cached costs a full fromMarkdown run here, about 0.35 ms in a local Node run. The first export after import went from about 11 ms to 111 ms with 300 bare URLs, and from 60 ms to 544 ms with 1,500; repeated exports match main. Headless one-shot conversions always pay it. Skipping the parse while the label and URL are still the imported ones, or batching the uncached labels of one export into a single parse, would remove most of it. Fine as a follow-up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional, non-blocking: this runs every contributed mdastExtensions transform on each uncached label during export, which used to happen only on import. One sentence in the MdastConfig.mdastExtensions JSDoc (MdastExtension.ts:111) or in the changeset's Verify: paragraph would tell extension authors whose transforms have side effects.

});
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
Expand All @@ -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$/, '');
Expand All @@ -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 = (
Expand All @@ -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,
),
);
};

Expand Down
195 changes: 195 additions & 0 deletions packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading