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>>,
    }),
  ],
}
```
This commit is contained in:
Alessio Gravili
2025-03-05 17:13:56 -07:00
committed by GitHub
parent 3af0468062
commit 36921bd62b
76 changed files with 2077 additions and 322 deletions

View File

@@ -1,11 +1,23 @@
'use client'
import type { DefaultNodeTypes, SerializedBlockNode } from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { getRestPopulateFn } from '@payloadcms/richtext-lexical/client'
import {
convertLexicalToHTML,
type HTMLConvertersFunction,
} from '@payloadcms/richtext-lexical/html'
import {
convertLexicalToHTMLAsync,
type HTMLConvertersFunctionAsync,
} from '@payloadcms/richtext-lexical/html-async'
import { type JSXConvertersFunction, RichText } from '@payloadcms/richtext-lexical/react'
import { useConfig, useDocumentInfo, usePayloadAPI } from '@payloadcms/ui'
import React from 'react'
import React, { useEffect, useMemo, useState } from 'react'
const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
const jsxConverters: JSXConvertersFunction<DefaultNodeTypes | SerializedBlockNode<any>> = ({
defaultConverters,
}) => ({
...defaultConverters,
blocks: {
myTextBlock: ({ node }) => <div style={{ backgroundColor: 'red' }}>{node.fields.text}</div>,
@@ -15,6 +27,30 @@ const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
},
})
const htmlConverters: HTMLConvertersFunction<DefaultNodeTypes | SerializedBlockNode<any>> = ({
defaultConverters,
}) => ({
...defaultConverters,
blocks: {
myTextBlock: ({ node }) => `<div style="background-color: red;">${node.fields.text}</div>`,
relationshipBlock: () => {
return `<p>Test</p>`
},
},
})
const htmlConvertersAsync: HTMLConvertersFunctionAsync<
DefaultNodeTypes | SerializedBlockNode<any>
> = ({ defaultConverters }) => ({
...defaultConverters,
blocks: {
myTextBlock: ({ node }) => `<div style="background-color: red;">${node.fields.text}</div>`,
relationshipBlock: () => {
return `<p>Test</p>`
},
},
})
export const LexicalRendered: React.FC = () => {
const { id, collectionSlug } = useDocumentInfo()
@@ -31,14 +67,54 @@ export const LexicalRendered: React.FC = () => {
},
})
const [{ data: unpopulatedData }] = usePayloadAPI(`${serverURL}${api}/${collectionSlug}/${id}`, {
initialParams: {
depth: 0,
},
})
const html: null | string = useMemo(() => {
if (!data.lexicalWithBlocks) {
return null
}
return convertLexicalToHTML({
converters: htmlConverters,
data: data.lexicalWithBlocks as SerializedEditorState,
})
}, [data.lexicalWithBlocks])
const [htmlFromUnpopulatedData, setHtmlFromUnpopulatedData] = useState<null | string>(null)
useEffect(() => {
async function convert() {
const html = await convertLexicalToHTMLAsync({
converters: htmlConvertersAsync,
data: unpopulatedData.lexicalWithBlocks as SerializedEditorState,
populate: getRestPopulateFn({
apiURL: `${serverURL}${api}`,
}),
})
setHtmlFromUnpopulatedData(html)
}
void convert()
}, [unpopulatedData.lexicalWithBlocks, api, serverURL])
if (!data.lexicalWithBlocks) {
return null
}
return (
<div>
<h1>Rendered:</h1>
<h1>Rendered JSX:</h1>
<RichText converters={jsxConverters} data={data.lexicalWithBlocks as SerializedEditorState} />
<h1>Rendered HTML:</h1>
{html && <div dangerouslySetInnerHTML={{ __html: html }} />}
<h1>Rendered HTML 2:</h1>
{htmlFromUnpopulatedData && (
<div dangerouslySetInnerHTML={{ __html: htmlFromUnpopulatedData }} />
)}
<h1>Raw JSON:</h1>
<pre>{JSON.stringify(data.lexicalWithBlocks, null, 2)}</pre>
</div>

View File

@@ -1,9 +1,8 @@
import type { CollectionConfig } from 'payload'
import {
HTMLConverterFeature,
lexicalEditor,
lexicalHTML,
lexicalHTMLField,
LinkFeature,
TreeViewFeature,
UploadFeature,
@@ -39,7 +38,6 @@ export const LexicalMigrateFields: CollectionConfig = {
...defaultFeatures,
LexicalPluginToLexicalFeature({ quiet: true }),
TreeViewFeature(),
HTMLConverterFeature(),
LinkFeature({
fields: ({ defaultFields }) => [
...defaultFields,
@@ -80,7 +78,6 @@ export const LexicalMigrateFields: CollectionConfig = {
...defaultFeatures,
SlateToLexicalFeature(),
TreeViewFeature(),
HTMLConverterFeature(),
LinkFeature({
fields: ({ defaultFields }) => [
...defaultFields,
@@ -117,11 +114,11 @@ export const LexicalMigrateFields: CollectionConfig = {
name: 'lexicalSimple',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures, HTMLConverterFeature()],
features: ({ defaultFeatures }) => [...defaultFeatures],
}),
defaultValue: getSimpleLexicalData('simple'),
},
lexicalHTML('lexicalSimple', { name: 'lexicalSimple_html' }),
lexicalHTMLField({ htmlFieldName: 'lexicalSimple_html', lexicalFieldName: 'lexicalSimple' }),
{
name: 'groupWithLexicalField',
type: 'group',
@@ -130,11 +127,14 @@ export const LexicalMigrateFields: CollectionConfig = {
name: 'lexicalInGroupField',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures, HTMLConverterFeature()],
features: ({ defaultFeatures }) => [...defaultFeatures],
}),
defaultValue: getSimpleLexicalData('group'),
},
lexicalHTML('lexicalInGroupField', { name: 'lexicalInGroupField_html' }),
lexicalHTMLField({
htmlFieldName: 'lexicalInGroupField_html',
lexicalFieldName: 'lexicalInGroupField',
}),
],
},
{
@@ -145,10 +145,13 @@ export const LexicalMigrateFields: CollectionConfig = {
name: 'lexicalInArrayField',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures, HTMLConverterFeature()],
features: ({ defaultFeatures }) => [...defaultFeatures],
}),
},
lexicalHTML('lexicalInArrayField', { name: 'lexicalInArrayField_html' }),
lexicalHTMLField({
htmlFieldName: 'lexicalInArrayField_html',
lexicalFieldName: 'lexicalInArrayField',
}),
],
},
],

View File

@@ -304,8 +304,8 @@ describe('Lexical', () => {
})
).docs[0] as never
const htmlField: string = lexicalDoc?.lexicalSimple_html
expect(htmlField).toStrictEqual('<p>simple</p>')
const htmlField = lexicalDoc?.lexicalSimple_html
expect(htmlField).toStrictEqual('<div class="payload-richtext"><p>simple</p></div>')
})
it('htmlConverter: should output correct HTML for lexical field nested in group', async () => {
const lexicalDoc: LexicalMigrateField = (
@@ -320,8 +320,8 @@ describe('Lexical', () => {
})
).docs[0] as never
const htmlField: string = lexicalDoc?.groupWithLexicalField?.lexicalInGroupField_html
expect(htmlField).toStrictEqual('<p>group</p>')
const htmlField = lexicalDoc?.groupWithLexicalField?.lexicalInGroupField_html
expect(htmlField).toStrictEqual('<div class="payload-richtext"><p>group</p></div>')
})
it('htmlConverter: should output correct HTML for lexical field nested in array', async () => {
const lexicalDoc: LexicalMigrateField = (
@@ -336,11 +336,11 @@ describe('Lexical', () => {
})
).docs[0] as never
const htmlField1: string = lexicalDoc?.arrayWithLexicalField[0].lexicalInArrayField_html
const htmlField2: string = lexicalDoc?.arrayWithLexicalField[1].lexicalInArrayField_html
const htmlField1 = lexicalDoc?.arrayWithLexicalField?.[0]?.lexicalInArrayField_html
const htmlField2 = lexicalDoc?.arrayWithLexicalField?.[1]?.lexicalInArrayField_html
expect(htmlField1).toStrictEqual('<p>array 1</p>')
expect(htmlField2).toStrictEqual('<p>array 2</p>')
expect(htmlField1).toStrictEqual('<div class="payload-richtext"><p>array 1</p></div>')
expect(htmlField2).toStrictEqual('<div class="payload-richtext"><p>array 2</p></div>')
})
})
describe('advanced - blocks', () => {
@@ -654,7 +654,7 @@ describe('Lexical', () => {
locale: 'en',
data: {
title: 'Localized Lexical hooks',
lexicalBlocksLocalized: textToLexicalJSON({ text: 'some text' }) as any,
lexicalBlocksLocalized: textToLexicalJSON({ text: 'some text' }),
lexicalBlocksSubLocalized: generateLexicalLocalizedRichText(
'Shared text',
'English text in block',