Skip to content

Commit 3dc6041

Browse files
authored
fix(richtext-lexical): internal links export as [text](undefined) in markdown transformer (#16302)
## Overview Payload has two types of links: custom (you type a URL) and internal (you pick a page). For internal links, Payload never stores a URL, only a reference to the page (`doc`). Whatever renders the link is responsible for resolving that reference to a URL at render time. The HTML converters handled this with an `internalDocToHref` callback. The markdown transformer didn't, so it produced `[text](undefined)` for every internal link. ## Key Changes The transformer is now a `createLinkMarkdownTransformer(args?)` factory that accepts an optional `internalDocToHref` callback. Pass it to `LinkFeature` and it gets threaded through to the transformer. Without it, internal links fall back to `[text]()` instead of `[text](undefined)`. `LinkMarkdownTransformer` remains as a static backwards-compatible export. ## Design Decisions The callback is synchronous unlike the HTML converter variant, because `doc.value` is already populated by the time markdown export runs. --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1213983113414606
1 parent 9de13c2 commit 3dc6041

3 files changed

Lines changed: 231 additions & 25 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { createHeadlessEditor } from '@lexical/headless'
2+
import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical'
3+
import { describe, expect, it } from 'vitest'
4+
5+
import { $convertToMarkdownString } from '../../packages/@lexical/markdown/index.js'
6+
import { AutoLinkNode } from './nodes/AutoLinkNode.js'
7+
import { $createLinkNode, LinkNode } from './nodes/LinkNode.js'
8+
import type { SerializedLinkNode } from './nodes/types.js'
9+
import { LinkMarkdownTransformer, createLinkMarkdownTransformer } from './markdownTransformer.js'
10+
11+
function createEditor() {
12+
return createHeadlessEditor({ nodes: [LinkNode, AutoLinkNode] })
13+
}
14+
15+
function toMarkdown(setupFn: () => void, transformer = LinkMarkdownTransformer): string {
16+
const editor = createEditor()
17+
editor.update(setupFn, { discrete: true })
18+
let markdown = ''
19+
editor.getEditorState().read(() => {
20+
markdown = $convertToMarkdownString([transformer])
21+
})
22+
return markdown
23+
}
24+
25+
describe('createLinkMarkdownTransformer', () => {
26+
describe('custom links', () => {
27+
it('should export a custom link with its url', () => {
28+
const markdown = toMarkdown(() => {
29+
const link = $createLinkNode({
30+
fields: { linkType: 'custom', url: 'https://payloadcms.com', newTab: false, doc: null },
31+
})
32+
link.append($createTextNode('Payload'))
33+
$getRoot().append($createParagraphNode().append(link))
34+
})
35+
36+
expect(markdown).toBe('[Payload](https://payloadcms.com)')
37+
})
38+
39+
it('should export a custom link that opens in a new tab', () => {
40+
// newTab is a Payload field — markdown has no equivalent, so it is intentionally dropped
41+
const markdown = toMarkdown(() => {
42+
const link = $createLinkNode({
43+
fields: { linkType: 'custom', url: 'https://payloadcms.com', newTab: true, doc: null },
44+
})
45+
link.append($createTextNode('Payload'))
46+
$getRoot().append($createParagraphNode().append(link))
47+
})
48+
49+
expect(markdown).toBe('[Payload](https://payloadcms.com)')
50+
})
51+
52+
it('should produce an empty href when url is null or undefined', () => {
53+
const markdownNull = toMarkdown(() => {
54+
const link = $createLinkNode({
55+
fields: { linkType: 'custom', url: null as unknown as string, newTab: false, doc: null },
56+
})
57+
link.append($createTextNode('Broken'))
58+
$getRoot().append($createParagraphNode().append(link))
59+
})
60+
61+
const markdownUndefined = toMarkdown(() => {
62+
const link = $createLinkNode({
63+
fields: { linkType: 'custom', newTab: false, doc: null },
64+
})
65+
link.append($createTextNode('Broken'))
66+
$getRoot().append($createParagraphNode().append(link))
67+
})
68+
69+
expect(markdownNull).toBe('[Broken]()')
70+
expect(markdownUndefined).toBe('[Broken]()')
71+
})
72+
})
73+
74+
describe('internal links', () => {
75+
it('should export an empty href when no internalDocToHref is provided', () => {
76+
const markdown = toMarkdown(() => {
77+
const link = $createLinkNode({
78+
fields: {
79+
linkType: 'internal',
80+
doc: { relationTo: 'pages', value: { id: '1', slug: 'about' } },
81+
newTab: false,
82+
},
83+
})
84+
link.append($createTextNode('About'))
85+
$getRoot().append($createParagraphNode().append(link))
86+
})
87+
88+
expect(markdown).toBe('[About]()')
89+
})
90+
91+
it('should call internalDocToHref and use the returned url', () => {
92+
const transformer = createLinkMarkdownTransformer({
93+
internalDocToHref: ({ linkNode }) => {
94+
const value = linkNode.fields.doc?.value
95+
if (value && typeof value === 'object' && 'slug' in value) {
96+
return `/${value.slug}`
97+
}
98+
return '/'
99+
},
100+
})
101+
102+
const markdown = toMarkdown(() => {
103+
const link = $createLinkNode({
104+
fields: {
105+
linkType: 'internal',
106+
doc: { relationTo: 'pages', value: { id: '1', slug: 'about' } },
107+
newTab: false,
108+
},
109+
})
110+
link.append($createTextNode('About'))
111+
$getRoot().append($createParagraphNode().append(link))
112+
}, transformer)
113+
114+
expect(markdown).toBe('[About](/about)')
115+
})
116+
117+
it('should pass the full serialized link node to internalDocToHref', () => {
118+
let capturedLinkNode: SerializedLinkNode | null = null
119+
120+
const transformer = createLinkMarkdownTransformer({
121+
internalDocToHref: ({ linkNode }) => {
122+
capturedLinkNode = linkNode
123+
return '/captured'
124+
},
125+
})
126+
127+
toMarkdown(() => {
128+
const link = $createLinkNode({
129+
fields: {
130+
linkType: 'internal',
131+
doc: { relationTo: 'pages', value: { id: '42', title: 'Home' } },
132+
newTab: true,
133+
},
134+
})
135+
link.append($createTextNode('Home'))
136+
$getRoot().append($createParagraphNode().append(link))
137+
}, transformer)
138+
139+
expect(capturedLinkNode).not.toBeNull()
140+
expect(capturedLinkNode!.fields.linkType).toBe('internal')
141+
expect(capturedLinkNode!.fields.doc?.relationTo).toBe('pages')
142+
expect(capturedLinkNode!.fields.doc?.value).toMatchObject({ id: '42', title: 'Home' })
143+
expect(capturedLinkNode!.fields.newTab).toBe(true)
144+
})
145+
})
146+
147+
describe('LinkMarkdownTransformer (static export)', () => {
148+
it('should behave identically to createLinkMarkdownTransformer() with no args', () => {
149+
const setup = () => {
150+
const link = $createLinkNode({
151+
fields: { linkType: 'custom', url: 'https://example.com', newTab: false, doc: null },
152+
})
153+
link.append($createTextNode('Example'))
154+
$getRoot().append($createParagraphNode().append(link))
155+
}
156+
157+
const fromStatic = toMarkdown(setup, LinkMarkdownTransformer)
158+
const fromFactory = toMarkdown(setup, createLinkMarkdownTransformer())
159+
160+
expect(fromStatic).toBe(fromFactory)
161+
expect(fromStatic).toBe('[Example](https://example.com)')
162+
})
163+
})
164+
})

packages/richtext-lexical/src/features/link/markdownTransformer.ts

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,44 +9,77 @@
99
import { $createTextNode, $isTextNode } from 'lexical'
1010

1111
import type { TextMatchTransformer } from '../../packages/@lexical/markdown/MarkdownTransformers.js'
12+
import type { SerializedLinkNode } from './nodes/types.js'
1213

14+
import { sanitizeUrl } from '../../lexical/utils/url.js'
1315
import { $createLinkNode, $isLinkNode, LinkNode } from './nodes/LinkNode.js'
1416

1517
// - then longer tags match (e.g. ** or __ should go before * or _)
16-
export const LinkMarkdownTransformer: TextMatchTransformer = {
18+
19+
export type CreateLinkMarkdownTransformerArgs = {
20+
/**
21+
* A function that receives a serialized internal link node and returns the URL string.
22+
* Required for internal links (linkType === 'internal') to be exported correctly, since
23+
* internal links store a doc reference rather than a URL.
24+
*
25+
* Without this, internal links will export as `[text]()` with an empty href.
26+
*/
27+
internalDocToHref?: (args: { linkNode: SerializedLinkNode }) => string
28+
}
29+
30+
const replaceTransformer: TextMatchTransformer['replace'] = (textNode, match) => {
31+
const [, linkText, linkUrl] = match
32+
const linkNode = $createLinkNode({
33+
fields: {
34+
doc: null,
35+
linkType: 'custom',
36+
newTab: false,
37+
url: linkUrl,
38+
},
39+
})
40+
const linkTextNode = $createTextNode(linkText)
41+
linkTextNode.setFormat(textNode.getFormat())
42+
linkNode.append(linkTextNode)
43+
textNode.replace(linkNode)
44+
45+
return linkTextNode
46+
}
47+
48+
export const createLinkMarkdownTransformer = (
49+
args?: CreateLinkMarkdownTransformerArgs,
50+
): TextMatchTransformer => ({
1751
type: 'text-match',
1852
dependencies: [LinkNode],
1953
export: (_node, exportChildren) => {
2054
if (!$isLinkNode(_node)) {
2155
return null
2256
}
2357
const node: LinkNode = _node
24-
const { url } = node.getFields()
58+
const fields = node.getFields()
2559

26-
const textContent = exportChildren(node)
60+
let url: string
2761

28-
const linkContent = `[${textContent}](${url})`
62+
if (fields.linkType === 'internal') {
63+
if (args?.internalDocToHref) {
64+
url = sanitizeUrl(args.internalDocToHref({ linkNode: node.exportJSON() }))
65+
} else {
66+
// eslint-disable-next-line no-console
67+
console.warn(
68+
'Lexical → Markdown converter: found internal link but internalDocToHref is not provided — link will have an empty href',
69+
)
70+
url = ''
71+
}
72+
} else {
73+
url = sanitizeUrl(fields.url ?? '')
74+
}
2975

30-
return linkContent
76+
const textContent = exportChildren(node)
77+
return `[${textContent}](${url})`
3178
},
3279
importRegExp: /(?<!!)\[([^[]+)\]\(([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?\)/,
3380
regExp: /(?<!!)\[([^[]+)\]\(([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?\)$/,
34-
replace: (textNode, match) => {
35-
const [, linkText, linkUrl] = match
36-
const linkNode = $createLinkNode({
37-
fields: {
38-
doc: null,
39-
linkType: 'custom',
40-
newTab: false,
41-
url: linkUrl,
42-
},
43-
})
44-
const linkTextNode = $createTextNode(linkText)
45-
linkTextNode.setFormat(textNode.getFormat())
46-
linkNode.append(linkTextNode)
47-
textNode.replace(linkNode)
48-
49-
return linkTextNode
50-
},
81+
replace: replaceTransformer,
5182
trigger: ')',
52-
}
83+
})
84+
85+
export const LinkMarkdownTransformer: TextMatchTransformer = createLinkMarkdownTransformer()

packages/richtext-lexical/src/features/link/server/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ import { sanitizeFields } from 'payload'
1212

1313
import type { NodeWithHooks } from '../../typesServer.js'
1414
import type { ClientProps } from '../client/index.js'
15+
import type { SerializedLinkNode } from '../nodes/types.js'
1516

1617
import { createServerFeature } from '../../../utilities/createServerFeature.js'
1718
import { convertLexicalNodesToHTML } from '../../converters/lexicalToHtml_deprecated/converter/index.js'
1819
import { createNode } from '../../typeUtilities.js'
19-
import { LinkMarkdownTransformer } from '../markdownTransformer.js'
20+
import { createLinkMarkdownTransformer } from '../markdownTransformer.js'
2021
import { AutoLinkNode } from '../nodes/AutoLinkNode.js'
2122
import { LinkNode } from '../nodes/LinkNode.js'
2223
import { linkPopulationPromiseHOC } from './graphQLPopulationPromise.js'
@@ -67,6 +68,12 @@ export type LinkFeatureServerProps = {
6768
defaultFields: FieldAffectingData[]
6869
}) => (Field | FieldAffectingData)[])
6970
| Field[]
71+
/**
72+
* Resolves an internal link node to a URL string for use in the markdown converter.
73+
* Internal links store a doc reference rather than a URL, so without this the markdown
74+
* output will have an empty href: `[link text]()`.
75+
*/
76+
internalDocToHref?: (args: { linkNode: SerializedLinkNode }) => string
7077
/**
7178
* Sets a maximum population depth for the internal doc default field of link, regardless of the remaining depth when the field is reached.
7279
* This behaves exactly like the maxDepth properties of relationship and upload fields.
@@ -158,7 +165,9 @@ export const LinkFeature = createServerFeature<
158165
return schemaMap
159166
},
160167
i18n,
161-
markdownTransformers: [LinkMarkdownTransformer],
168+
markdownTransformers: [
169+
createLinkMarkdownTransformer({ internalDocToHref: props.internalDocToHref }),
170+
],
162171
nodes: [
163172
props?.disableAutoLinks === true
164173
? null

0 commit comments

Comments
 (0)