Skip to content

Commit 4af5a85

Browse files
veeceeyGermanJablo
andauthored
feat(richtext-lexical): add markdown transformer for upload nodes (#15630)
## Problem When converting Lexical rich text content to markdown using `convertLexicalToMarkdown`, upload nodes (images and files) are completely stripped from the output. There's no markdown transformer registered for the upload feature, so the converter silently drops them. This means any content that includes uploaded images loses those images entirely when exported to markdown - which is unexpected since other node types like links, headings, and horizontal rules all have proper markdown transformers. ## Fix Added an `ElementTransformer` for upload nodes that handles three cases: 1. **Populated image uploads** - outputs standard markdown image syntax: ``` ![alt text](/uploads/image.jpg) ``` 2. **Populated non-image uploads** (PDFs, docs, etc.) - outputs a link: ``` [document.pdf](/uploads/document.pdf) ``` 3. **Non-populated uploads** (value is just an ID) - outputs a reference placeholder so the data isn't silently lost: ``` ![media:abc123]() ``` The transformer is registered in the `UploadFeature` server configuration alongside the existing HTML converter. The import side (markdown -> Lexical) uses a never-matching regex because upload nodes should be created through the upload UI/drawer, not parsed from markdown text. ## Test plan - Verified that upload nodes with populated data (containing `url`, `alt`, `filename`) produce valid markdown image syntax - Verified that non-image uploads produce link syntax - Verified that non-populated uploads produce a reference placeholder instead of being dropped Fixes #13086 --------- Co-authored-by: German Jablonski <43938777+GermanJablo@users.noreply.github.com>
1 parent 558345b commit 4af5a85

4 files changed

Lines changed: 186 additions & 0 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { createServerFeature } from '../../../utilities/createServerFeature.js'
1818
import { createNode } from '../../typeUtilities.js'
1919
import { uploadPopulationPromiseHOC } from './graphQLPopulationPromise.js'
2020
import { i18n } from './i18n.js'
21+
import { UploadMarkdownTransformer } from './markdownTransformer.js'
2122
import { UploadServerNode } from './nodes/UploadNode.js'
2223
import { uploadValidation } from './validate.js'
2324

@@ -130,6 +131,7 @@ export const UploadFeature = createServerFeature<
130131
return schemaMap
131132
},
132133
i18n,
134+
markdownTransformers: [UploadMarkdownTransformer],
133135
nodes: [
134136
createNode({
135137
converters: {
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { ElementTransformer } from '../../../packages/@lexical/markdown/MarkdownTransformers.js'
2+
3+
import {
4+
$createUploadServerNode,
5+
$isUploadServerNode,
6+
UploadServerNode,
7+
} from './nodes/UploadNode.js'
8+
9+
/** Matches upload placeholder written by export: ![relationTo:id]() */
10+
const UPLOAD_PLACEHOLDER_REGEX = /!\[([^\]:]+):([^\]]+)\]\(\)/
11+
12+
export const UploadMarkdownTransformer: ElementTransformer = {
13+
type: 'element',
14+
dependencies: [UploadServerNode],
15+
export: (node) => {
16+
if (!$isUploadServerNode(node)) {
17+
return null
18+
}
19+
20+
const data = node.getData()
21+
const value = data?.value
22+
23+
// When the value is a populated document object (not just an ID),
24+
// we can extract the URL and alt text
25+
if (value && typeof value === 'object' && 'url' in value) {
26+
const url = (value as Record<string, unknown>).url as string
27+
const alt =
28+
(data.fields as Record<string, unknown>)?.alt ||
29+
(value as Record<string, unknown>).alt ||
30+
(value as Record<string, unknown>).filename ||
31+
''
32+
33+
if ((value as Record<string, unknown>).mimeType) {
34+
const mimeType = (value as Record<string, unknown>).mimeType as string
35+
// For non-image uploads, output a link instead of an image
36+
if (!mimeType.startsWith('image')) {
37+
const filename = ((value as Record<string, unknown>).filename as string) || url
38+
return `[${filename}](${url})`
39+
}
40+
}
41+
42+
return `![${alt}](${url})`
43+
}
44+
45+
// When value is just an ID (not populated), output a reference placeholder
46+
// so the upload is not silently dropped from the markdown output
47+
const id = typeof value === 'object' ? (value as Record<string, string>)?.id : value
48+
return `![${data.relationTo}:${id}]()`
49+
},
50+
regExp: UPLOAD_PLACEHOLDER_REGEX,
51+
replace: (parentNode, _children, match, isImport) => {
52+
if (!isImport || !match[1] || !match[2]) {
53+
return false
54+
}
55+
const relationTo = match[1]
56+
const value = match[2]
57+
const id = /^\d+$/.test(value) ? Number(value) : value
58+
const node = $createUploadServerNode({
59+
data: {
60+
fields: {},
61+
relationTo,
62+
value: id,
63+
},
64+
})
65+
parentNode.replace(node)
66+
return true
67+
},
68+
}

test/lexical-mdx/int.spec.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,77 @@ describe('Lexical MDX', () => {
177177
})
178178
}
179179
}
180+
181+
describe('upload markdown: Markdown → Lexical (import)', () => {
182+
function countUploadNodes(node: {
183+
type?: string
184+
children?: unknown[]
185+
[key: string]: unknown
186+
}): number {
187+
let n = node.type === 'upload' ? 1 : 0
188+
const children =
189+
node.children ?? (node.fields as { root?: { children?: unknown[] } })?.root?.children
190+
if (Array.isArray(children)) {
191+
for (const c of children) {
192+
n += countUploadNodes(c as typeof node)
193+
}
194+
}
195+
return n
196+
}
197+
198+
function collectUploadNodes(node: {
199+
type?: string
200+
relationTo?: string
201+
value?: unknown
202+
children?: unknown[]
203+
[key: string]: unknown
204+
}): { relationTo: string; value: unknown }[] {
205+
const out: { relationTo: string; value: unknown }[] = []
206+
if (node.type === 'upload' && node.relationTo != null) {
207+
out.push({ relationTo: node.relationTo, value: node.value })
208+
}
209+
const children =
210+
node.children ?? (node.fields as { root?: { children?: unknown[] } })?.root?.children
211+
if (Array.isArray(children)) {
212+
for (const c of children) {
213+
out.push(...collectUploadNodes(c as typeof node))
214+
}
215+
}
216+
return out
217+
}
218+
219+
it('imports upload placeholder as upload node and verifies it is there', () => {
220+
const markdown = '![uploads:123]()'
221+
const result = mdxToEditorJSON({ mdxWithFrontmatter: markdown, editorConfig })
222+
const rootChildren = result.editorState.root?.children ?? []
223+
const uploads = rootChildren.flatMap((child) =>
224+
collectUploadNodes(
225+
child as { type?: string; relationTo?: string; value?: unknown; [key: string]: unknown },
226+
),
227+
)
228+
expect(uploads).toHaveLength(1)
229+
expect(uploads[0].relationTo).toBe('uploads')
230+
expect(uploads[0].value).toBe(123)
231+
})
232+
233+
it('imports image markdown without creating upload node and preserves content', () => {
234+
const markdown = '![alt](/uploads/image.jpg)'
235+
const result = mdxToEditorJSON({ mdxWithFrontmatter: markdown, editorConfig })
236+
const rootChildren = result.editorState.root?.children ?? []
237+
expect(rootChildren.length).toBeGreaterThanOrEqual(1)
238+
const uploadCount = rootChildren.reduce(
239+
(sum, child) =>
240+
sum +
241+
countUploadNodes(
242+
child as { type?: string; children?: unknown[]; [key: string]: unknown },
243+
),
244+
0,
245+
)
246+
expect(uploadCount).toBe(0)
247+
const text = JSON.stringify(result.editorState)
248+
expect(text).toMatch(/alt|image\.jpg|\/uploads\//)
249+
})
250+
})
180251
})
181252

182253
function removeUndefinedAndIDRecursively(obj: object) {

test/lexical/lexical.int.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,51 @@ describe('Lexical', () => {
293293
)
294294
})
295295

296+
describe('upload markdown: Lexical → Markdown (export)', () => {
297+
it('exports upload node to markdown placeholder when unpopulated', async () => {
298+
const newLexicalDoc = await payload.create({
299+
collection: lexicalFieldsSlug,
300+
depth: 0,
301+
data: {
302+
title: 'Lexical Upload Markdown Unpopulated',
303+
lexicalWithBlocks: {
304+
root: {
305+
type: 'root',
306+
format: '',
307+
indent: 0,
308+
version: 1,
309+
children: [
310+
{
311+
format: '',
312+
type: 'upload',
313+
version: 2,
314+
relationTo: 'uploads',
315+
value: createdJPGDocID,
316+
fields: {},
317+
},
318+
],
319+
direction: 'ltr',
320+
},
321+
},
322+
},
323+
})
324+
325+
expect(newLexicalDoc.lexicalWithBlocks_markdown).toEqual(`![uploads:${createdJPGDocID}]()`)
326+
})
327+
328+
it('exported markdown contains upload placeholder in seeded doc', async () => {
329+
const lexicalDoc = await payload.find({
330+
collection: lexicalFieldsSlug,
331+
depth: 0,
332+
where: { title: { equals: lexicalDocData.title } },
333+
})
334+
335+
const markdown = lexicalDoc.docs[0]?.lexicalWithBlocks_markdown as string
336+
expect(markdown).toBeDefined()
337+
expect(markdown).toContain(`![uploads:${createdJPGDocID}]()`)
338+
})
339+
})
340+
296341
describe('converters and migrations', () => {
297342
it('htmlConverter: should output correct HTML for top-level lexical field', async () => {
298343
const lexicalDoc: LexicalMigrateField = (

0 commit comments

Comments
 (0)