Skip to content

Commit 36921bd

Browse files
authored
feat(richtext-lexical): new HTML converter (#11370)
Deprecates the old HTML converter and introduces a new one that functions similarly to our Lexical => JSX converter. The old converter had the following limitations: - It imported the entire lexical bundle - It was challenging to implement. The sanitized lexical editor config had to be passed in as an argument, which was difficult to obtain - It only worked on the server This new HTML converter is lightweight, user-friendly, and works on both server and client. Instead of retrieving HTML converters from the editor config, they can be explicitly provided to the converter function. By default, the converter expects populated data to function properly. If you need to use unpopulated data (e.g., when running it from a hook), you also have the option to use the async HTML converter, exported from `@payloadcms/richtext-lexical/html-async`, and provide a `populate` function - this function will then be used to dynamically populate nodes during the conversion process. ## Example 1 - generating HTML in your frontend ```tsx 'use client' import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical' import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html' import React from 'react' export const MyComponent = ({ data }: { data: SerializedEditorState }) => { const html = convertLexicalToHTML({ data }) return <div dangerouslySetInnerHTML={{ __html: html }} /> } ``` ## Example - converting Lexical Blocks ```tsx 'use client' import type { MyInlineBlock, MyTextBlock } from '@/payload-types' import type { DefaultNodeTypes, SerializedBlockNode, SerializedInlineBlockNode, } from '@payloadcms/richtext-lexical' import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical' import { convertLexicalToHTML, type HTMLConvertersFunction, } from '@payloadcms/richtext-lexical/html' import React from 'react' type NodeTypes = | DefaultNodeTypes | SerializedBlockNode<MyTextBlock> | SerializedInlineBlockNode<MyInlineBlock> const htmlConverters: HTMLConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({ ...defaultConverters, blocks: { // Each key should match your block's slug myTextBlock: ({ node, providedCSSString }) => `<div style="background-color: red;${providedCSSString}">${node.fields.text}</div>`, }, inlineBlocks: { // Each key should match your inline block's slug myInlineBlock: ({ node, providedStyleTag }) => `<span${providedStyleTag}>${node.fields.text}</span$>`, }, }) export const MyComponent = ({ data }: { data: SerializedEditorState }) => { const html = convertLexicalToHTML({ converters: htmlConverters, data, }) return <div dangerouslySetInnerHTML={{ __html: html }} /> } ``` ## Example 3 - outputting HTML from the collection ```ts import type { HTMLConvertersFunction } from '@payloadcms/richtext-lexical/html' import type { MyTextBlock } from '@/payload-types.js' import type { CollectionConfig } from 'payload' import { BlocksFeature, type DefaultNodeTypes, lexicalEditor, lexicalHTMLField, type SerializedBlockNode, } from '@payloadcms/richtext-lexical' const Pages: CollectionConfig = { slug: 'pages', fields: [ { name: 'nameOfYourRichTextField', type: 'richText', editor: lexicalEditor(), }, lexicalHTMLField({ htmlFieldName: 'nameOfYourRichTextField_html', lexicalFieldName: 'nameOfYourRichTextField', }), { name: 'customRichText', type: 'richText', editor: lexicalEditor({ features: ({ defaultFeatures }) => [ ...defaultFeatures, BlocksFeature({ blocks: [ { interfaceName: 'MyTextBlock', slug: 'myTextBlock', fields: [ { name: 'text', type: 'text', }, ], }, ], }), ], }), }, lexicalHTMLField({ htmlFieldName: 'customRichText_html', lexicalFieldName: 'customRichText', // can pass in additional converters or override default ones converters: (({ defaultConverters }) => ({ ...defaultConverters, blocks: { myTextBlock: ({ node, providedCSSString }) => `<div style="background-color: red;${providedCSSString}">${node.fields.text}</div>`, }, })) as HTMLConvertersFunction<DefaultNodeTypes | SerializedBlockNode<MyTextBlock>>, }), ], } ```
1 parent 3af0468 commit 36921bd

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

76 files changed

+2070
-315
lines changed

docs/rich-text/converters.mdx

Lines changed: 183 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
title: Lexical Converters
33
label: Converters
44
order: 20
5-
desc: Conversion between lexical, markdown and html
6-
keywords: lexical, rich text, editor, headless cms, convert, html, mdx, markdown, md, conversion, export
5+
desc: Conversion between lexical, markdown, jsx and html
6+
keywords: lexical, rich text, editor, headless cms, convert, html, mdx, markdown, md, conversion, export, jsx
77
---
88

99
Lexical saves data in JSON - this is great for storage and flexibility and allows you to easily to convert it to other formats like JSX, HTML or Markdown.
@@ -74,20 +74,28 @@ To convert Lexical Blocks or Inline Blocks to JSX, pass the converter for your b
7474

7575
```tsx
7676
'use client'
77-
import type { MyInlineBlock, MyTextBlock } from '@/payload-types'
78-
import type { DefaultNodeTypes, SerializedBlockNode } from '@payloadcms/richtext-lexical'
77+
import type { MyInlineBlock, MyNumberBlock, MyTextBlock } from '@/payload-types'
78+
import type {
79+
DefaultNodeTypes,
80+
SerializedBlockNode,
81+
SerializedInlineBlockNode,
82+
} from '@payloadcms/richtext-lexical'
7983
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
8084

8185
import { type JSXConvertersFunction, RichText } from '@payloadcms/richtext-lexical/react'
8286
import React from 'react'
8387

8488
// Extend the default node types with your custom blocks for full type safety
85-
type NodeTypes = DefaultNodeTypes | SerializedBlockNode<MyInlineBlock | MyTextBlock>
89+
type NodeTypes =
90+
| DefaultNodeTypes
91+
| SerializedBlockNode<MyNumberBlock | MyTextBlock>
92+
| SerializedInlineBlockNode<MyInlineBlock>
8693

8794
const jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({
8895
...defaultConverters,
8996
blocks: {
9097
// Each key should match your block's slug
98+
myNumberBlock: ({ node }) => <div>{node.fields.number}</div>,
9199
myTextBlock: ({ node }) => <div style={{ backgroundColor: 'red' }}>{node.fields.text}</div>,
92100
},
93101
inlineBlocks: {
@@ -155,182 +163,220 @@ export const MyComponent: React.FC<{
155163

156164
If you don't have a React-based frontend, or if you need to send the content to a third-party service, you can convert lexical to HTML. There are two ways to do this:
157165

158-
1. **Outputting HTML from the Collection:** Create a new field in your collection to convert saved JSON content to HTML. Payload generates and outputs the HTML for use in your frontend.
159-
2. **Generating HTML on any server** Convert JSON to HTML on-demand on the server.
160166

161-
In both cases, the conversion needs to happen on a server, as the HTML converter will automatically fetch data for nodes that require it (e.g. uploads and internal links). The editor comes with built-in HTML serializers, simplifying the process of converting JSON to HTML.
167+
1. **Generating HTML in your frontend** Convert JSON to HTML on-demand wherever you need it (Recommended).
168+
2. **Outputting HTML from the Collection:** Create a new field in your collection to convert saved JSON content to HTML. Payload generates and outputs the HTML for use in your frontend. This is not recommended, as this approach adds additional overhead to the Payload API and may not work with live preview.
162169

163-
### Outputting HTML from the Collection
170+
### Generating HTML in your frontend
164171

165-
To add HTML generation directly within the collection, follow the example below:
172+
If you wish to convert JSON to HTML ad-hoc, use the `convertLexicalToHTML` function exported from `@payloadcms/richtext-lexical/html`:
166173

167-
```ts
168-
import type { CollectionConfig } from 'payload'
174+
```tsx
175+
'use client'
169176

170-
import { HTMLConverterFeature, lexicalEditor, lexicalHTML } from '@payloadcms/richtext-lexical'
177+
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
178+
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
171179

172-
const Pages: CollectionConfig = {
173-
slug: 'pages',
174-
fields: [
175-
{
176-
name: 'nameOfYourRichTextField',
177-
type: 'richText',
178-
editor: lexicalEditor({
179-
features: ({ defaultFeatures }) => [
180-
...defaultFeatures,
181-
// The HTMLConverter Feature is the feature which manages the HTML serializers.
182-
// If you do not pass any arguments to it, it will use the default serializers.
183-
HTMLConverterFeature({}),
184-
],
185-
}),
186-
},
187-
lexicalHTML('nameOfYourRichTextField', { name: 'nameOfYourRichTextField_html' }),
188-
],
180+
import React from 'react'
181+
182+
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
183+
const html = convertLexicalToHTML({ data })
184+
185+
return <div dangerouslySetInnerHTML={{ __html: html }} />
189186
}
190187
```
191188

192-
The `lexicalHTML()` function creates a new field that automatically converts the referenced lexical richText field into HTML through an afterRead hook.
189+
### Generating HTML in your frontend with dynamic population
193190

194-
### Generating HTML anywhere on the server
191+
The default `convertLexicalToHTML` function does not populate data for nodes like uploads or links - it expects you to pass in the fully populated data. If you want the converter to dynamically populate those nodes as they are encountered, you have to use the async version of the converter, imported from `@payloadcms/richtext-lexical/html-async`, and pass in the `populate` function:
195192

196-
If you wish to convert JSON to HTML ad-hoc, use the `convertLexicalToHTML` function:
193+
```tsx
194+
'use client'
197195

198-
```ts
199-
import { consolidateHTMLConverters, convertLexicalToHTML } from '@payloadcms/richtext-lexical'
196+
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
200197

198+
import { getRestPopulateFn } from '@payloadcms/richtext-lexical/client'
199+
import { convertLexicalToHTMLAsync } from '@payloadcms/richtext-lexical/html-async'
200+
import React, { useEffect, useState } from 'react'
201201

202-
await convertLexicalToHTML({
203-
converters: consolidateHTMLConverters({ editorConfig }),
204-
data: editorData,
205-
payload, // if you have Payload but no req available, pass it in here to enable server-only functionality (e.g. proper conversion of upload nodes)
206-
req, // if you have req available, pass it in here to enable server-only functionality (e.g. proper conversion of upload nodes). No need to pass in Payload if req is passed in.
207-
})
208-
```
209-
This method employs `convertLexicalToHTML` from `@payloadcms/richtext-lexical`, which converts the serialized editor state into HTML.
202+
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
203+
const [html, setHTML] = useState<null | string>(null)
204+
useEffect(() => {
205+
async function convert() {
206+
const html = await convertLexicalToHTMLAsync({
207+
data,
208+
populate: getRestPopulateFn({
209+
apiURL: `http://localhost:3000/api`,
210+
}),
211+
})
212+
setHTML(html)
213+
}
210214

211-
Because every `Feature` is able to provide html converters, and because the `htmlFeature` can modify those or provide their own, we need to consolidate them with the default html Converters using the `consolidateHTMLConverters` function.
215+
void convert()
216+
}, [data])
212217

213-
#### Example: Generating HTML within an afterRead hook
218+
return html && <div dangerouslySetInnerHTML={{ __html: html }} />
219+
}
220+
```
214221

215-
```ts
216-
import type { FieldHook } from 'payload'
222+
Do note that using the REST populate function will result in each node sending a separate request to the REST API, which may be slow for a large amount of nodes. On the server, you can use the payload populate function, which will be more efficient:
217223

218-
import {
219-
HTMLConverterFeature,
220-
consolidateHTMLConverters,
221-
convertLexicalToHTML,
222-
defaultEditorConfig,
223-
defaultEditorFeatures,
224-
sanitizeServerEditorConfig,
225-
} from '@payloadcms/richtext-lexical'
224+
```tsx
225+
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
226226

227-
const hook: FieldHook = async ({ req, siblingData }) => {
228-
const editorConfig = defaultEditorConfig
227+
import { getPayloadPopulateFn } from '@payloadcms/richtext-lexical'
228+
import { convertLexicalToHTMLAsync } from '@payloadcms/richtext-lexical/html-async'
229+
import { getPayload } from 'payload'
230+
import React from 'react'
229231

230-
editorConfig.features = [...defaultEditorFeatures, HTMLConverterFeature({})]
232+
import config from '../../config.js'
231233

232-
const sanitizedEditorConfig = await sanitizeServerEditorConfig(editorConfig, req.payload.config)
234+
export const MyRSCComponent = async ({ data }: { data: SerializedEditorState }) => {
235+
const payload = await getPayload({
236+
config,
237+
})
233238

234-
const html = await convertLexicalToHTML({
235-
converters: consolidateHTMLConverters({ editorConfig: sanitizedEditorConfig }),
236-
data: siblingData.lexicalSimple,
237-
req,
239+
const html = await convertLexicalToHTMLAsync({
240+
data,
241+
populate: await getPayloadPopulateFn({
242+
currentDepth: 0,
243+
depth: 1,
244+
payload,
245+
}),
238246
})
239-
return html
247+
248+
return html && <div dangerouslySetInnerHTML={{ __html: html }} />
240249
}
241250
```
242251

243-
### CSS
252+
### Converting Lexical Blocks
244253

245-
Payload's lexical HTML converter does not generate CSS for you, but it does add classes to the generated HTML. You can use these classes to style the HTML in your frontend.
254+
```tsx
255+
'use client'
246256

247-
Here is some "base" CSS you can use to ensure that nested lists render correctly:
257+
import type { MyInlineBlock, MyTextBlock } from '@/payload-types'
258+
import type {
259+
DefaultNodeTypes,
260+
SerializedBlockNode,
261+
SerializedInlineBlockNode,
262+
} from '@payloadcms/richtext-lexical'
263+
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
248264

249-
```css
250-
/* Base CSS for Lexical HTML */
251-
.nestedListItem, .list-check {
252-
list-style-type: none;
265+
import {
266+
convertLexicalToHTML,
267+
type HTMLConvertersFunction,
268+
} from '@payloadcms/richtext-lexical/html'
269+
import React from 'react'
270+
271+
type NodeTypes =
272+
| DefaultNodeTypes
273+
| SerializedBlockNode<MyTextBlock>
274+
| SerializedInlineBlockNode<MyInlineBlock>
275+
276+
const htmlConverters: HTMLConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({
277+
...defaultConverters,
278+
blocks: {
279+
// Each key should match your block's slug
280+
myTextBlock: ({ node, providedCSSString }) =>
281+
`<div style="background-color: red;${providedCSSString}">${node.fields.text}</div>`,
282+
},
283+
inlineBlocks: {
284+
// Each key should match your inline block's slug
285+
myInlineBlock: ({ node, providedStyleTag }) =>
286+
`<span${providedStyleTag}>${node.fields.text}</span$>`,
287+
},
288+
})
289+
290+
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
291+
const html = convertLexicalToHTML({
292+
converters: htmlConverters,
293+
data,
294+
})
295+
296+
return <div dangerouslySetInnerHTML={{ __html: html }} />
253297
}
254298
```
255299

256-
### Creating your own HTML Converter
300+
### Outputting HTML from the Collection
257301

258-
HTML Converters are typed as `HTMLConverter`, which contains the node type it should handle, and a function that accepts the serialized node from the lexical editor, and outputs the HTML string. Here's the HTML Converter of the Upload node as an example:
302+
To add HTML generation directly within the collection, follow the example below:
259303

260304
```ts
261-
import type { HTMLConverter } from '@payloadcms/richtext-lexical'
262-
263-
const UploadHTMLConverter: HTMLConverter<SerializedUploadNode> = {
264-
converter: async ({ node, req }) => {
265-
const uploadDocument: {
266-
value?: any
267-
} = {}
268-
if(req) {
269-
await populate({
270-
id,
271-
collectionSlug: node.relationTo,
272-
currentDepth: 0,
273-
data: uploadDocument,
274-
depth: 1,
275-
draft: false,
276-
key: 'value',
277-
overrideAccess: false,
278-
req,
279-
showHiddenFields: false,
280-
})
281-
}
282-
283-
const url = (req?.payload?.config?.serverURL || '') + uploadDocument?.value?.url
305+
import type { HTMLConvertersFunction } from '@payloadcms/richtext-lexical/html'
306+
import type { MyTextBlock } from '@/payload-types.js'
307+
import type { CollectionConfig } from 'payload'
284308

285-
if (!(uploadDocument?.value?.mimeType as string)?.startsWith('image')) {
286-
// Only images can be serialized as HTML
287-
return ``
288-
}
309+
import {
310+
BlocksFeature,
311+
type DefaultNodeTypes,
312+
lexicalEditor,
313+
lexicalHTMLField,
314+
type SerializedBlockNode,
315+
} from '@payloadcms/richtext-lexical'
289316

290-
return `<img src="${url}" alt="${uploadDocument?.value?.filename}" width="${uploadDocument?.value?.width}" height="${uploadDocument?.value?.height}"/>`
291-
},
292-
nodeTypes: [UploadNode.getType()], // This is the type of the lexical node that this converter can handle. Instead of hardcoding 'upload' we can get the node type directly from the UploadNode, since it's static.
317+
const Pages: CollectionConfig = {
318+
slug: 'pages',
319+
fields: [
320+
{
321+
name: 'nameOfYourRichTextField',
322+
type: 'richText',
323+
editor: lexicalEditor(),
324+
},
325+
lexicalHTMLField({
326+
htmlFieldName: 'nameOfYourRichTextField_html',
327+
lexicalFieldName: 'nameOfYourRichTextField',
328+
}),
329+
{
330+
name: 'customRichText',
331+
type: 'richText',
332+
editor: lexicalEditor({
333+
features: ({ defaultFeatures }) => [
334+
...defaultFeatures,
335+
BlocksFeature({
336+
blocks: [
337+
{
338+
interfaceName: 'MyTextBlock',
339+
slug: 'myTextBlock',
340+
fields: [
341+
{
342+
name: 'text',
343+
type: 'text',
344+
},
345+
],
346+
},
347+
],
348+
}),
349+
],
350+
}),
351+
},
352+
lexicalHTMLField({
353+
htmlFieldName: 'customRichText_html',
354+
lexicalFieldName: 'customRichText',
355+
// can pass in additional converters or override default ones
356+
converters: (({ defaultConverters }) => ({
357+
...defaultConverters,
358+
blocks: {
359+
myTextBlock: ({ node, providedCSSString }) =>
360+
`<div style="background-color: red;${providedCSSString}">${node.fields.text}</div>`,
361+
},
362+
})) as HTMLConvertersFunction<DefaultNodeTypes | SerializedBlockNode<MyTextBlock>>,
363+
}),
364+
],
293365
}
294366
```
295367

296-
As you can see, we have access to all the information saved in the node (for the Upload node, this is `value`and `relationTo`) and we can use that to generate the HTML.
368+
The `lexicalHTML()` function creates a new field that automatically converts the referenced lexical richText field into HTML through an afterRead hook.
297369

298-
The `convertLexicalToHTML` is part of `@payloadcms/richtext-lexical` automatically handles traversing the editor state and calling the correct converter for each node.
370+
### CSS
299371

300-
### Embedding the HTML Converter in your Feature
372+
Payload's lexical HTML converter does not generate CSS for you, but it does add classes to the generated HTML. You can use these classes to style the HTML in your frontend.
301373

302-
You can embed your HTML Converter directly within your custom `ServerFeature`, allowing it to be handled automatically by the `consolidateHTMLConverters` function. Here is an example:
374+
Here is some "base" CSS you can use to ensure that nested lists render correctly:
303375

304-
```ts
305-
import { createNode } from '@payloadcms/richtext-lexical'
306-
import type { FeatureProviderProviderServer } from '@payloadcms/richtext-lexical'
307-
308-
export const UploadFeature: FeatureProviderProviderServer<
309-
UploadFeatureProps,
310-
UploadFeaturePropsClient
311-
> = (props) => {
312-
/*...*/
313-
return {
314-
feature: () => {
315-
return {
316-
nodes: [
317-
createNode({
318-
converters: {
319-
html: yourHTMLConverter, // <= This is where you define your HTML Converter
320-
},
321-
node: UploadNode,
322-
//...
323-
}),
324-
],
325-
ClientComponent: UploadFeatureClientComponent,
326-
clientFeatureProps: clientProps,
327-
serverFeatureProps: props,
328-
/*...*/
329-
}
330-
},
331-
key: 'upload',
332-
serverFeatureProps: props,
333-
}
376+
```css
377+
/* Base CSS for Lexical HTML */
378+
.nestedListItem, .list-check {
379+
list-style-type: none;
334380
}
335381
```
336382

0 commit comments

Comments
 (0)