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

@@ -2,8 +2,8 @@
title: Lexical Converters
label: Converters
order: 20
desc: Conversion between lexical, markdown and html
keywords: lexical, rich text, editor, headless cms, convert, html, mdx, markdown, md, conversion, export
desc: Conversion between lexical, markdown, jsx and html
keywords: lexical, rich text, editor, headless cms, convert, html, mdx, markdown, md, conversion, export, jsx
---
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
```tsx
'use client'
import type { MyInlineBlock, MyTextBlock } from '@/payload-types'
import type { DefaultNodeTypes, SerializedBlockNode } from '@payloadcms/richtext-lexical'
import type { MyInlineBlock, MyNumberBlock, MyTextBlock } from '@/payload-types'
import type {
DefaultNodeTypes,
SerializedBlockNode,
SerializedInlineBlockNode,
} from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { type JSXConvertersFunction, RichText } from '@payloadcms/richtext-lexical/react'
import React from 'react'
// Extend the default node types with your custom blocks for full type safety
type NodeTypes = DefaultNodeTypes | SerializedBlockNode<MyInlineBlock | MyTextBlock>
type NodeTypes =
| DefaultNodeTypes
| SerializedBlockNode<MyNumberBlock | MyTextBlock>
| SerializedInlineBlockNode<MyInlineBlock>
const jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({
...defaultConverters,
blocks: {
// Each key should match your block's slug
myNumberBlock: ({ node }) => <div>{node.fields.number}</div>,
myTextBlock: ({ node }) => <div style={{ backgroundColor: 'red' }}>{node.fields.text}</div>,
},
inlineBlocks: {
@@ -155,19 +163,156 @@ export const MyComponent: React.FC<{
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:
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.
2. **Generating HTML on any server** Convert JSON to HTML on-demand on the server.
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.
1. **Generating HTML in your frontend** Convert JSON to HTML on-demand wherever you need it (Recommended).
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.
### Generating HTML in your frontend
If you wish to convert JSON to HTML ad-hoc, use the `convertLexicalToHTML` function exported from `@payloadcms/richtext-lexical/html`:
```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 }} />
}
```
### Generating HTML in your frontend with dynamic population
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:
```tsx
'use client'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { getRestPopulateFn } from '@payloadcms/richtext-lexical/client'
import { convertLexicalToHTMLAsync } from '@payloadcms/richtext-lexical/html-async'
import React, { useEffect, useState } from 'react'
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
const [html, setHTML] = useState<null | string>(null)
useEffect(() => {
async function convert() {
const html = await convertLexicalToHTMLAsync({
data,
populate: getRestPopulateFn({
apiURL: `http://localhost:3000/api`,
}),
})
setHTML(html)
}
void convert()
}, [data])
return html && <div dangerouslySetInnerHTML={{ __html: html }} />
}
```
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:
```tsx
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { getPayloadPopulateFn } from '@payloadcms/richtext-lexical'
import { convertLexicalToHTMLAsync } from '@payloadcms/richtext-lexical/html-async'
import { getPayload } from 'payload'
import React from 'react'
import config from '../../config.js'
export const MyRSCComponent = async ({ data }: { data: SerializedEditorState }) => {
const payload = await getPayload({
config,
})
const html = await convertLexicalToHTMLAsync({
data,
populate: await getPayloadPopulateFn({
currentDepth: 0,
depth: 1,
payload,
}),
})
return html && <div dangerouslySetInnerHTML={{ __html: html }} />
}
```
### 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 }} />
}
```
### Outputting HTML from the Collection
To add HTML generation directly within the collection, follow the example below:
```ts
import type { HTMLConvertersFunction } from '@payloadcms/richtext-lexical/html'
import type { MyTextBlock } from '@/payload-types.js'
import type { CollectionConfig } from 'payload'
import { HTMLConverterFeature, lexicalEditor, lexicalHTML } from '@payloadcms/richtext-lexical'
import {
BlocksFeature,
type DefaultNodeTypes,
lexicalEditor,
lexicalHTMLField,
type SerializedBlockNode,
} from '@payloadcms/richtext-lexical'
const Pages: CollectionConfig = {
slug: 'pages',
@@ -175,71 +320,53 @@ const Pages: CollectionConfig = {
{
name: 'nameOfYourRichTextField',
type: 'richText',
editor: lexicalEditor(),
},
lexicalHTMLField({
htmlFieldName: 'nameOfYourRichTextField_html',
lexicalFieldName: 'nameOfYourRichTextField',
}),
{
name: 'customRichText',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
// The HTMLConverter Feature is the feature which manages the HTML serializers.
// If you do not pass any arguments to it, it will use the default serializers.
HTMLConverterFeature({}),
BlocksFeature({
blocks: [
{
interfaceName: 'MyTextBlock',
slug: 'myTextBlock',
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
}),
],
}),
},
lexicalHTML('nameOfYourRichTextField', { name: 'nameOfYourRichTextField_html' }),
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>>,
}),
],
}
```
The `lexicalHTML()` function creates a new field that automatically converts the referenced lexical richText field into HTML through an afterRead hook.
### Generating HTML anywhere on the server
If you wish to convert JSON to HTML ad-hoc, use the `convertLexicalToHTML` function:
```ts
import { consolidateHTMLConverters, convertLexicalToHTML } from '@payloadcms/richtext-lexical'
await convertLexicalToHTML({
converters: consolidateHTMLConverters({ editorConfig }),
data: editorData,
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)
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.
})
```
This method employs `convertLexicalToHTML` from `@payloadcms/richtext-lexical`, which converts the serialized editor state into HTML.
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.
#### Example: Generating HTML within an afterRead hook
```ts
import type { FieldHook } from 'payload'
import {
HTMLConverterFeature,
consolidateHTMLConverters,
convertLexicalToHTML,
defaultEditorConfig,
defaultEditorFeatures,
sanitizeServerEditorConfig,
} from '@payloadcms/richtext-lexical'
const hook: FieldHook = async ({ req, siblingData }) => {
const editorConfig = defaultEditorConfig
editorConfig.features = [...defaultEditorFeatures, HTMLConverterFeature({})]
const sanitizedEditorConfig = await sanitizeServerEditorConfig(editorConfig, req.payload.config)
const html = await convertLexicalToHTML({
converters: consolidateHTMLConverters({ editorConfig: sanitizedEditorConfig }),
data: siblingData.lexicalSimple,
req,
})
return html
}
```
### CSS
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.
@@ -253,87 +380,6 @@ Here is some "base" CSS you can use to ensure that nested lists render correctly
}
```
### Creating your own HTML Converter
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:
```ts
import type { HTMLConverter } from '@payloadcms/richtext-lexical'
const UploadHTMLConverter: HTMLConverter<SerializedUploadNode> = {
converter: async ({ node, req }) => {
const uploadDocument: {
value?: any
} = {}
if(req) {
await populate({
id,
collectionSlug: node.relationTo,
currentDepth: 0,
data: uploadDocument,
depth: 1,
draft: false,
key: 'value',
overrideAccess: false,
req,
showHiddenFields: false,
})
}
const url = (req?.payload?.config?.serverURL || '') + uploadDocument?.value?.url
if (!(uploadDocument?.value?.mimeType as string)?.startsWith('image')) {
// Only images can be serialized as HTML
return ``
}
return `<img src="${url}" alt="${uploadDocument?.value?.filename}" width="${uploadDocument?.value?.width}" height="${uploadDocument?.value?.height}"/>`
},
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.
}
```
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.
The `convertLexicalToHTML` is part of `@payloadcms/richtext-lexical` automatically handles traversing the editor state and calling the correct converter for each node.
### Embedding the HTML Converter in your Feature
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:
```ts
import { createNode } from '@payloadcms/richtext-lexical'
import type { FeatureProviderProviderServer } from '@payloadcms/richtext-lexical'
export const UploadFeature: FeatureProviderProviderServer<
UploadFeatureProps,
UploadFeaturePropsClient
> = (props) => {
/*...*/
return {
feature: () => {
return {
nodes: [
createNode({
converters: {
html: yourHTMLConverter, // <= This is where you define your HTML Converter
},
node: UploadNode,
//...
}),
],
ClientComponent: UploadFeatureClientComponent,
clientFeatureProps: clientProps,
serverFeatureProps: props,
/*...*/
}
},
key: 'upload',
serverFeatureProps: props,
}
}
```
## Headless Editor
Lexical provides a seamless way to perform conversions between various other formats:

View File

@@ -35,6 +35,16 @@
"types": "./src/exports/react/index.ts",
"default": "./src/exports/react/index.ts"
},
"./html": {
"import": "./src/exports/html/index.ts",
"types": "./src/exports/html/index.ts",
"default": "./src/exports/html/index.ts"
},
"./html-async": {
"import": "./src/exports/html-async/index.ts",
"types": "./src/exports/html-async/index.ts",
"default": "./src/exports/html-async/index.ts"
},
"./rsc": {
"import": "./src/exports/server/rsc.ts",
"types": "./src/exports/server/rsc.ts",
@@ -368,6 +378,7 @@
"mdast-util-from-markdown": "2.0.2",
"mdast-util-mdx-jsx": "3.1.3",
"micromark-extension-mdx-jsx": "3.0.1",
"qs-esm": "7.0.2",
"react-error-boundary": "4.1.2",
"ts-essentials": "10.0.3",
"uuid": "10.0.0"
@@ -421,6 +432,16 @@
"types": "./dist/exports/react/index.d.ts",
"default": "./dist/exports/react/index.js"
},
"./html": {
"import": "./dist/exports/html/index.js",
"types": "./dist/exports/html/index.d.ts",
"default": "./dist/exports/html/index.js"
},
"./html-async": {
"import": "./dist/exports/html-async/index.js",
"types": "./dist/exports/html-async/index.d.ts",
"default": "./dist/exports/html-async/index.js"
},
"./rsc": {
"import": "./dist/exports/server/rsc.js",
"types": "./dist/exports/server/rsc.d.ts",

View File

@@ -146,3 +146,4 @@ export { BlockCollapsible } from '../../features/blocks/client/component/compone
export { BlockEditButton } from '../../features/blocks/client/component/components/BlockEditButton.js'
export { BlockRemoveButton } from '../../features/blocks/client/component/components/BlockRemoveButton.js'
export { useBlockComponentContext } from '../../features/blocks/client/component/BlockContent.js'
export { getRestPopulateFn } from '../../features/converters/utilities/restPopulateFn.js'

View File

@@ -0,0 +1,25 @@
export { BlockquoteHTMLConverterAsync } from '../../features/converters/html/async/converters/blockquote.js'
export { HeadingHTMLConverterAsync } from '../../features/converters/html/async/converters/heading.js'
export { HorizontalRuleHTMLConverterAsync } from '../../features/converters/html/async/converters/horizontalRule.js'
export { LinebreakHTMLConverterAsync } from '../../features/converters/html/async/converters/linebreak.js'
export { LinkHTMLConverterAsync } from '../../features/converters/html/async/converters/link.js'
export { ListHTMLConverterAsync } from '../../features/converters/html/async/converters/list.js'
export { ParagraphHTMLConverterAsync } from '../../features/converters/html/async/converters/paragraph.js'
export { TabHTMLConverterAsync } from '../../features/converters/html/async/converters/tab.js'
export { TableHTMLConverterAsync } from '../../features/converters/html/async/converters/table.js'
export { TextHTMLConverterAsync } from '../../features/converters/html/async/converters/text.js'
export { UploadHTMLConverterAsync } from '../../features/converters/html/async/converters/upload.js'
export { defaultHTMLConvertersAsync } from '../../features/converters/html/async/defaultConverters.js'
export { convertLexicalToHTMLAsync } from '../../features/converters/html/async/index.js'
export type {
HTMLConverterAsync,
HTMLConvertersAsync,
HTMLConvertersFunctionAsync,
} from '../../features/converters/html/async/types.js'
export type {
ProvidedCSS,
SerializedLexicalNodeWithParent,
} from '../../features/converters/html/shared/types.js'

View File

@@ -0,0 +1,25 @@
export type {
ProvidedCSS,
SerializedLexicalNodeWithParent,
} from '../../features/converters/html/shared/types.js'
export { BlockquoteHTMLConverter } from '../../features/converters/html/sync/converters/blockquote.js'
export { HeadingHTMLConverter } from '../../features/converters/html/sync/converters/heading.js'
export { HorizontalRuleHTMLConverter } from '../../features/converters/html/sync/converters/horizontalRule.js'
export { LinebreakHTMLConverter } from '../../features/converters/html/sync/converters/linebreak.js'
export { LinkHTMLConverter } from '../../features/converters/html/sync/converters/link.js'
export { ListHTMLConverter } from '../../features/converters/html/sync/converters/list.js'
export { ParagraphHTMLConverter } from '../../features/converters/html/sync/converters/paragraph.js'
export { TabHTMLConverter } from '../../features/converters/html/sync/converters/tab.js'
export { TableHTMLConverter } from '../../features/converters/html/sync/converters/table.js'
export { TextHTMLConverter } from '../../features/converters/html/sync/converters/text.js'
export { UploadHTMLConverter } from '../../features/converters/html/sync/converters/upload.js'
export { defaultHTMLConverters } from '../../features/converters/html/sync/defaultConverters.js'
export { convertLexicalToHTML } from '../../features/converters/html/sync/index.js'
export type {
HTMLConverter,
HTMLConverters,
HTMLConvertersFunction,
} from '../../features/converters/html/sync/types.js'

View File

@@ -1,9 +0,0 @@
import type { SerializedTabNode } from '../../../../../../nodeTypes.js'
import type { JSXConverters } from '../types.js'
export const TabJSXConverter: JSXConverters<SerializedTabNode> = {
tab: ({ node }) => {
// Tab
return node.text
},
}

View File

@@ -1,21 +1,24 @@
export { BlockquoteJSXConverter } from './components/RichText/converter/converters/blockquote.js'
export { HeadingJSXConverter } from './components/RichText/converter/converters/heading.js'
export { HorizontalRuleJSXConverter } from './components/RichText/converter/converters/horizontalRule.js'
export { LinebreakJSXConverter } from './components/RichText/converter/converters/linebreak.js'
export { LinkJSXConverter } from './components/RichText/converter/converters/link.js'
export { ListJSXConverter } from './components/RichText/converter/converters/list.js'
export { ParagraphJSXConverter } from './components/RichText/converter/converters/paragraph.js'
export { TabJSXConverter } from './components/RichText/converter/converters/tab.js'
export { TableJSXConverter } from './components/RichText/converter/converters/table.js'
export { TextJSXConverter } from './components/RichText/converter/converters/text.js'
export {
type JSXConvertersFunction,
RichText,
} from '../../features/converters/jsx/Component/index.js'
export { BlockquoteJSXConverter } from '../../features/converters/jsx/converter/converters/blockquote.js'
export { HeadingJSXConverter } from '../../features/converters/jsx/converter/converters/heading.js'
export { HorizontalRuleJSXConverter } from '../../features/converters/jsx/converter/converters/horizontalRule.js'
export { LinebreakJSXConverter } from '../../features/converters/jsx/converter/converters/linebreak.js'
export { LinkJSXConverter } from '../../features/converters/jsx/converter/converters/link.js'
export { ListJSXConverter } from '../../features/converters/jsx/converter/converters/list.js'
export { ParagraphJSXConverter } from '../../features/converters/jsx/converter/converters/paragraph.js'
export { TabJSXConverter } from '../../features/converters/jsx/converter/converters/tab.js'
export { TableJSXConverter } from '../../features/converters/jsx/converter/converters/table.js'
export { UploadJSXConverter } from './components/RichText/converter/converters/upload.js'
export { TextJSXConverter } from '../../features/converters/jsx/converter/converters/text.js'
export { defaultJSXConverters } from './components/RichText/converter/defaultConverters.js'
export { convertLexicalNodesToJSX } from './components/RichText/converter/index.js'
export { UploadJSXConverter } from '../../features/converters/jsx/converter/converters/upload.js'
export { defaultJSXConverters } from '../../features/converters/jsx/converter/defaultConverters.js'
export { convertLexicalNodesToJSX } from '../../features/converters/jsx/converter/index.js'
export type {
JSXConverter,
JSXConverters,
SerializedLexicalNodeWithParent,
} from './components/RichText/converter/types.js'
export { type JSXConvertersFunction, RichText } from './components/RichText/index.js'
} from '../../features/converters/jsx/converter/types.js'

View File

@@ -4,7 +4,7 @@ import type { Spread } from 'lexical'
import { QuoteNode } from '@lexical/rich-text'
import { createServerFeature } from '../../../utilities/createServerFeature.js'
import { convertLexicalNodesToHTML } from '../../converters/html/converter/index.js'
import { convertLexicalNodesToHTML } from '../../converters/html_deprecated/converter/index.js'
import { createNode } from '../../typeUtilities.js'
import { MarkdownTransformer } from '../markdownTransformer.js'
import { i18n } from './i18n.js'

View File

@@ -0,0 +1,14 @@
import type { SerializedQuoteNode } from '../../../../../nodeTypes.js'
import type { HTMLConvertersAsync } from '../types.js'
export const BlockquoteHTMLConverterAsync: HTMLConvertersAsync<SerializedQuoteNode> = {
quote: async ({ node, nodesToHTML, providedStyleTag }) => {
const children = (
await nodesToHTML({
nodes: node.children,
})
).join('')
return `<blockquote${providedStyleTag}>${children}</blockquote>`
},
}

View File

@@ -0,0 +1,14 @@
import type { SerializedHeadingNode } from '../../../../../nodeTypes.js'
import type { HTMLConvertersAsync } from '../types.js'
export const HeadingHTMLConverterAsync: HTMLConvertersAsync<SerializedHeadingNode> = {
heading: async ({ node, nodesToHTML, providedStyleTag }) => {
const children = (
await nodesToHTML({
nodes: node.children,
})
).join('')
return `<${node.tag}${providedStyleTag}>${children}</${node.tag}>`
},
}

View File

@@ -0,0 +1,5 @@
import type { SerializedHorizontalRuleNode } from '../../../../../nodeTypes.js'
import type { HTMLConvertersAsync } from '../types.js'
export const HorizontalRuleHTMLConverterAsync: HTMLConvertersAsync<SerializedHorizontalRuleNode> = {
horizontalrule: '<hr />',
}

View File

@@ -0,0 +1,6 @@
import type { SerializedLineBreakNode } from '../../../../../nodeTypes.js'
import type { HTMLConvertersAsync } from '../types.js'
export const LinebreakHTMLConverterAsync: HTMLConvertersAsync<SerializedLineBreakNode> = {
linebreak: '<br />',
}

View File

@@ -0,0 +1,56 @@
import type { SerializedAutoLinkNode, SerializedLinkNode } from '../../../../../nodeTypes.js'
import type { HTMLConvertersAsync, HTMLPopulateFn } from '../types.js'
export const LinkHTMLConverterAsync: (args: {
internalDocToHref?: (args: {
linkNode: SerializedLinkNode
populate?: HTMLPopulateFn
}) => Promise<string> | string
}) => HTMLConvertersAsync<SerializedAutoLinkNode | SerializedLinkNode> = ({
internalDocToHref,
}) => ({
autolink: async ({ node, nodesToHTML, providedStyleTag }) => {
const children = (
await nodesToHTML({
nodes: node.children,
})
).join('')
const rel: string | undefined = node.fields.newTab ? 'noopener noreferrer' : undefined
const target: string | undefined = node.fields.newTab ? '_blank' : undefined
return `(
<a${providedStyleTag} href="${node.fields.url}" rel=${rel} target=${target}>
${children}
</a>
)`
},
link: async ({ node, nodesToHTML, populate, providedStyleTag }) => {
const children = (
await nodesToHTML({
nodes: node.children,
})
).join('')
const rel: string | undefined = node.fields.newTab ? 'noopener noreferrer' : undefined
const target: string | undefined = node.fields.newTab ? '_blank' : undefined
let href: string = node.fields.url ?? ''
if (node.fields.linkType === 'internal') {
if (internalDocToHref) {
href = await internalDocToHref({ linkNode: node, populate })
} else {
console.error(
'Lexical => HTML converter: Link converter: found internal link, but internalDocToHref is not provided',
)
href = '#' // fallback
}
}
return `(
<a${providedStyleTag} href="${href}" rel=${rel} target=${target}>
${children}
</a>
)`
},
})

View File

@@ -0,0 +1,53 @@
import { v4 as uuidv4 } from 'uuid'
import type { SerializedListItemNode, SerializedListNode } from '../../../../../nodeTypes.js'
import type { HTMLConvertersAsync } from '../types.js'
export const ListHTMLConverterAsync: HTMLConvertersAsync<
SerializedListItemNode | SerializedListNode
> = {
list: async ({ node, nodesToHTML, providedStyleTag }) => {
const children = (
await nodesToHTML({
nodes: node.children,
})
).join('')
return `<${node.tag}${providedStyleTag} class="list-${node.listType}">${children}</${node.tag}>`
},
listitem: async ({ node, nodesToHTML, parent, providedCSSString }) => {
const hasSubLists = node.children.some((child) => child.type === 'list')
const children = (
await nodesToHTML({
nodes: node.children,
})
).join('')
if ('listType' in parent && parent?.listType === 'check') {
const uuid = uuidv4()
return `<li
aria-checked="${node.checked ? 'true' : 'false'}"
class="list-item-checkbox${node.checked ? ' list-item-checkbox-checked' : ' list-item-checkbox-unchecked'}${hasSubLists ? ' nestedListItem' : ''}"
role="checkbox"
style="list-style-type: none;${providedCSSString}"
tabIndex="-1"
value="${node.value}"
>
${
hasSubLists
? children
: `<input checked="${node.checked}" id="${uuid}" readOnly="true" type="checkbox" />
<label htmlFor="${uuid}">${children}</label>
<br />`
}
</li>`
} else {
return `<li
class="${hasSubLists ? 'nestedListItem' : ''}"
style="${hasSubLists ? `list-style-type: none;${providedCSSString}` : providedCSSString}"
value="${node.value}"
>${children}</li>`
}
},
}

View File

@@ -0,0 +1,16 @@
import type { SerializedParagraphNode } from '../../../../../nodeTypes.js'
import type { HTMLConvertersAsync } from '../types.js'
export const ParagraphHTMLConverterAsync: HTMLConvertersAsync<SerializedParagraphNode> = {
paragraph: async ({ node, nodesToHTML, providedStyleTag }) => {
const children = await nodesToHTML({
nodes: node.children,
})
if (!children?.length) {
return `<p${providedStyleTag}><br /></p>`
}
return `<p${providedStyleTag}>${children.join('')}</p>`
},
}

View File

@@ -0,0 +1,6 @@
import type { SerializedTabNode } from '../../../../../nodeTypes.js'
import type { HTMLConvertersAsync } from '../types.js'
export const TabHTMLConverterAsync: HTMLConvertersAsync<SerializedTabNode> = {
tab: '\t',
}

View File

@@ -0,0 +1,65 @@
import type {
SerializedTableCellNode,
SerializedTableNode,
SerializedTableRowNode,
} from '../../../../../nodeTypes.js'
import type { HTMLConvertersAsync } from '../types.js'
export const TableHTMLConverterAsync: HTMLConvertersAsync<
SerializedTableCellNode | SerializedTableNode | SerializedTableRowNode
> = {
table: async ({ node, nodesToHTML, providedStyleTag }) => {
const children = (
await nodesToHTML({
nodes: node.children,
})
).join('')
return `<div${providedStyleTag} class="lexical-table-container">
<table class="lexical-table" style="border-collapse: collapse;">
<tbody>${children}</tbody>
</table>
</div>`
},
tablecell: async ({ node, nodesToHTML, providedCSSString }) => {
const children = (
await nodesToHTML({
nodes: node.children,
})
).join('')
const TagName = node.headerState > 0 ? 'th' : 'td'
const headerStateClass = `lexical-table-cell-header-${node.headerState}`
let style = 'border: 1px solid #ccc; padding: 8px;' + providedCSSString
if (node.backgroundColor) {
style += ` background-color: ${node.backgroundColor};`
}
const colSpanAttr = node.colSpan && node.colSpan > 1 ? ` colspan="${node.colSpan}"` : ''
const rowSpanAttr = node.rowSpan && node.rowSpan > 1 ? ` rowspan="${node.rowSpan}"` : ''
return `<${TagName}
class="lexical-table-cell ${headerStateClass}"
${colSpanAttr}
${rowSpanAttr}
style="${style}"
>
${children}
</${TagName}>
`
},
tablerow: async ({ node, nodesToHTML, providedStyleTag }) => {
const children = (
await nodesToHTML({
nodes: node.children,
})
).join('')
return `<tr${providedStyleTag} class="lexical-table-row">
${children}
</tr>`
},
}

View File

@@ -0,0 +1,34 @@
import type { SerializedTextNode } from '../../../../../nodeTypes.js'
import type { HTMLConvertersAsync } from '../types.js'
import { NodeFormat } from '../../../../../lexical/utils/nodeFormat.js'
export const TextHTMLConverterAsync: HTMLConvertersAsync<SerializedTextNode> = {
text: ({ node }) => {
let text = node.text
if (node.format & NodeFormat.IS_BOLD) {
text = `<strong>${text}</strong>`
}
if (node.format & NodeFormat.IS_ITALIC) {
text = `<em>${text}</em>`
}
if (node.format & NodeFormat.IS_STRIKETHROUGH) {
text = `<span style="text-decoration: line-through;">${text}</span>`
}
if (node.format & NodeFormat.IS_UNDERLINE) {
text = `<span style="text-decoration: underline;">${text}</span>`
}
if (node.format & NodeFormat.IS_CODE) {
text = `<code>${text}</code>`
}
if (node.format & NodeFormat.IS_SUBSCRIPT) {
text = `<sub>${text}</sub>`
}
if (node.format & NodeFormat.IS_SUPERSCRIPT) {
text = `<sup>${text}</sup>`
}
return text
},
}

View File

@@ -0,0 +1,87 @@
import type { FileData, FileSizeImproved, TypeWithID } from 'payload'
import type { SerializedUploadNode } from '../../../../../nodeTypes.js'
import type { UploadDataImproved } from '../../../../upload/server/nodes/UploadNode.js'
import type { HTMLConvertersAsync } from '../types.js'
export const UploadHTMLConverterAsync: HTMLConvertersAsync<SerializedUploadNode> = {
upload: async ({ node, populate, providedStyleTag }) => {
const uploadNode = node as UploadDataImproved
let uploadDoc: (FileData & TypeWithID) | undefined = undefined
// If there's no valid upload data, populate return an empty string
if (typeof uploadNode.value !== 'object') {
if (!populate) {
return ''
}
uploadDoc = await populate<FileData & TypeWithID>({
id: uploadNode.value,
collectionSlug: uploadNode.relationTo,
})
} else {
uploadDoc = uploadNode.value as unknown as FileData & TypeWithID
}
if (!uploadDoc) {
return ''
}
const url = uploadDoc.url
// 1) If upload is NOT an image, return a link
if (!uploadDoc.mimeType.startsWith('image')) {
return `<a${providedStyleTag} href="${url}" rel="noopener noreferrer">${uploadDoc.filename}</a$>`
}
// 2) If image has no different sizes, return a simple <img />
if (!uploadDoc.sizes || !Object.keys(uploadDoc.sizes).length) {
return `
<img${providedStyleTag}
alt="${uploadDoc.filename}"
height="${uploadDoc.height}"
src="${url}"
width="${uploadDoc.width}"
/>
`
}
// 3) If image has different sizes, build a <picture> element with <source> tags
let pictureHTML = ''
for (const size in uploadDoc.sizes) {
const imageSize = uploadDoc.sizes[size] as FileSizeImproved
if (
!imageSize ||
!imageSize.width ||
!imageSize.height ||
!imageSize.mimeType ||
!imageSize.filesize ||
!imageSize.filename ||
!imageSize.url
) {
continue
}
pictureHTML += `
<source
media="(max-width: ${imageSize.width}px)"
srcset="${imageSize.url}"
type="${imageSize.mimeType}"
/>
`
}
pictureHTML += `
<img
alt="${uploadDoc.filename}"
height="${uploadDoc.height}"
src="${url}"
width="${uploadDoc.width}"
/>
`
return `<picture${providedStyleTag}>${pictureHTML}</picture$>`
},
}

View File

@@ -0,0 +1,28 @@
import type { DefaultNodeTypes } from '../../../../nodeTypes.js'
import type { HTMLConvertersAsync } from './types.js'
import { BlockquoteHTMLConverterAsync } from './converters/blockquote.js'
import { HeadingHTMLConverterAsync } from './converters/heading.js'
import { HorizontalRuleHTMLConverterAsync } from './converters/horizontalRule.js'
import { LinebreakHTMLConverterAsync } from './converters/linebreak.js'
import { LinkHTMLConverterAsync } from './converters/link.js'
import { ListHTMLConverterAsync } from './converters/list.js'
import { ParagraphHTMLConverterAsync } from './converters/paragraph.js'
import { TabHTMLConverterAsync } from './converters/tab.js'
import { TableHTMLConverterAsync } from './converters/table.js'
import { TextHTMLConverterAsync } from './converters/text.js'
import { UploadHTMLConverterAsync } from './converters/upload.js'
export const defaultHTMLConvertersAsync: HTMLConvertersAsync<DefaultNodeTypes> = {
...ParagraphHTMLConverterAsync,
...TextHTMLConverterAsync,
...LinebreakHTMLConverterAsync,
...BlockquoteHTMLConverterAsync,
...TableHTMLConverterAsync,
...HeadingHTMLConverterAsync,
...HorizontalRuleHTMLConverterAsync,
...ListHTMLConverterAsync,
...LinkHTMLConverterAsync({}),
...UploadHTMLConverterAsync,
...TabHTMLConverterAsync,
}

View File

@@ -0,0 +1,93 @@
import type { SerializedEditorState } from 'lexical'
import type { Field } from 'payload'
import type { HTMLConvertersAsync, HTMLConvertersFunctionAsync } from '../types.js'
import { getPayloadPopulateFn } from '../../../utilities/payloadPopulateFn.js'
import { convertLexicalToHTMLAsync } from '../index.js'
type Args = {
converters?: HTMLConvertersAsync | HTMLConvertersFunctionAsync
/**
* Whether the lexicalHTML field should be hidden in the admin panel
*
* @default true
*/
hidden?: boolean
htmlFieldName: string
/**
* A string which matches the lexical field name you want to convert to HTML.
*
* This has to be a sibling field of this lexicalHTML field - otherwise, it won't be able to find the lexical field.
**/
lexicalFieldName: string
/**
* Whether the HTML should be stored in the database
*
* @default false
*/
storeInDB?: boolean
}
/**
*
* Field that converts a sibling lexical field to HTML
*
* @todo will be renamed to lexicalHTML in 4.0, replacing the deprecated `lexicalHTML` converter
*/
export const lexicalHTMLField: (args: Args) => Field = (args) => {
const { converters, hidden = true, htmlFieldName, lexicalFieldName, storeInDB = false } = args
return {
name: htmlFieldName,
type: 'code',
admin: {
editorOptions: {
language: 'html',
},
hidden,
},
hooks: {
afterRead: [
async ({
currentDepth,
depth,
draft,
overrideAccess,
req,
showHiddenFields,
siblingData,
}) => {
const lexicalFieldData: SerializedEditorState = siblingData[lexicalFieldName]
if (!lexicalFieldData) {
return ''
}
const htmlPopulateFn = await getPayloadPopulateFn({
currentDepth: currentDepth ?? 0,
depth: depth ?? req.payload.config.defaultDepth,
draft: draft ?? false,
overrideAccess: overrideAccess ?? false,
req,
showHiddenFields: showHiddenFields ?? false,
})
return await convertLexicalToHTMLAsync({
converters,
data: lexicalFieldData,
populate: htmlPopulateFn,
})
},
],
beforeChange: [
({ siblingData, value }) => {
if (storeInDB) {
return value
}
delete siblingData[htmlFieldName]
return null
},
],
},
}
}

View File

@@ -0,0 +1,157 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-console */
import type { SerializedEditorState, SerializedLexicalNode } from 'lexical'
import type { SerializedLexicalNodeWithParent } from '../shared/types.js'
import type {
HTMLConverterAsync,
HTMLConvertersAsync,
HTMLConvertersFunctionAsync,
HTMLPopulateFn,
} from './types.js'
import { hasText } from '../../../../validate/hasText.js'
import { findConverterForNode } from '../shared/findConverterForNode.js'
import { defaultHTMLConvertersAsync } from './defaultConverters.js'
export type ConvertLexicalToHTMLAsyncArgs = {
/**
* Override class names for the container.
*/
className?: string
converters?: HTMLConvertersAsync | HTMLConvertersFunctionAsync
data: SerializedEditorState
/**
* If true, removes the container div wrapper.
*/
disableContainer?: boolean
/**
* If true, disables indentation globally. If an array, disables for specific node `type` values.
*/
disableIndent?: boolean | string[]
/**
* If true, disables text alignment globally. If an array, disables for specific node `type` values.
*/
disableTextAlign?: boolean | string[]
populate?: HTMLPopulateFn
}
export async function convertLexicalToHTMLAsync({
className,
converters,
data,
disableContainer,
disableIndent,
disableTextAlign,
populate,
}: ConvertLexicalToHTMLAsyncArgs): Promise<string> {
if (hasText(data)) {
let finalConverters: HTMLConvertersAsync = {}
if (converters) {
if (typeof converters === 'function') {
finalConverters = converters({ defaultConverters: defaultHTMLConvertersAsync })
} else {
finalConverters = converters
}
} else {
finalConverters = defaultHTMLConvertersAsync
}
const html = (
await convertLexicalNodesToHTMLAsync({
converters: finalConverters,
disableIndent,
disableTextAlign,
nodes: data?.root?.children,
parent: data?.root,
populate,
})
).join('')
if (disableContainer) {
return html
} else {
return `<div class="${className ?? 'payload-richtext'}">${html}</div>`
}
}
if (disableContainer) {
return ''
} else {
return `<div class="${className ?? 'payload-richtext'}"></div>`
}
}
export async function convertLexicalNodesToHTMLAsync({
converters,
disableIndent,
disableTextAlign,
nodes,
parent,
populate,
}: {
converters: HTMLConvertersAsync
disableIndent?: boolean | string[]
disableTextAlign?: boolean | string[]
nodes: SerializedLexicalNode[]
parent: SerializedLexicalNodeWithParent
populate?: HTMLPopulateFn
}): Promise<string[]> {
const unknownConverter: HTMLConverterAsync<any> = converters.unknown as HTMLConverterAsync<any>
const htmlArray: string[] = []
let i = -1
for (const node of nodes) {
i++
const { converterForNode, providedCSSString, providedStyleTag } = findConverterForNode({
converters,
disableIndent,
disableTextAlign,
node,
unknownConverter,
})
try {
let nodeHTML: string
if (converterForNode) {
const converted =
typeof converterForNode === 'function'
? await converterForNode({
childIndex: i,
converters,
node,
populate,
nodesToHTML: async (args) => {
return await convertLexicalNodesToHTMLAsync({
converters: args.converters ?? converters,
disableIndent: args.disableIndent ?? disableIndent,
disableTextAlign: args.disableTextAlign ?? disableTextAlign,
nodes: args.nodes,
parent: args.parent ?? {
...node,
parent,
},
populate,
})
},
parent,
providedCSSString,
providedStyleTag,
})
: converterForNode
nodeHTML = converted
} else {
nodeHTML = '<span>unknown node</span>'
}
htmlArray.push(nodeHTML)
} catch (error) {
console.error('Error converting lexical node to HTML:', error, 'node:', node)
htmlArray.push('')
}
}
return htmlArray.filter(Boolean)
}

View File

@@ -0,0 +1,93 @@
import type { SerializedLexicalNode } from 'lexical'
import type { SelectType, TypeWithID } from 'payload'
import type {
DefaultNodeTypes,
SerializedBlockNode,
SerializedInlineBlockNode,
} from '../../../../nodeTypes.js'
import type { SerializedLexicalNodeWithParent } from '../shared/types.js'
export type HTMLPopulateArguments = {
collectionSlug: string
id: number | string
select?: SelectType
}
export type HTMLPopulateFn = <TData extends object = TypeWithID>(
args: HTMLPopulateArguments,
) => Promise<TData | undefined>
export type HTMLConverterAsync<
T extends { [key: string]: any; type?: string } = SerializedLexicalNode,
> =
| ((args: {
childIndex: number
converters: HTMLConvertersAsync
node: T
nodesToHTML: (args: {
converters?: HTMLConvertersAsync
disableIndent?: boolean | string[]
disableTextAlign?: boolean | string[]
nodes: SerializedLexicalNode[]
parent?: SerializedLexicalNodeWithParent
}) => Promise<string[]>
parent: SerializedLexicalNodeWithParent
populate?: HTMLPopulateFn
providedCSSString: string
providedStyleTag: string
}) => Promise<string> | string)
| string
export type HTMLConvertersAsync<
T extends { [key: string]: any; type?: string } =
| DefaultNodeTypes
| SerializedBlockNode<{ blockName?: null | string; blockType: string }> // need these to ensure types for blocks and inlineBlocks work if no generics are provided
| SerializedInlineBlockNode<{ blockName?: null | string; blockType: string }>, // need these to ensure types for blocks and inlineBlocks work if no generics are provided
> = {
[key: string]:
| {
[blockSlug: string]: HTMLConverterAsync<any>
}
| HTMLConverterAsync<any>
| undefined
} & {
[nodeType in Exclude<NonNullable<T['type']>, 'block' | 'inlineBlock'>]?: HTMLConverterAsync<
Extract<T, { type: nodeType }>
>
} & {
blocks?: {
[K in Extract<
Extract<T, { type: 'block' }> extends SerializedBlockNode<infer B>
? B extends { blockType: string }
? B['blockType']
: never
: never,
string
>]?: HTMLConverterAsync<
Extract<T, { type: 'block' }> extends SerializedBlockNode<infer B>
? SerializedBlockNode<Extract<B, { blockType: K }>>
: SerializedBlockNode
>
}
inlineBlocks?: {
[K in Extract<
Extract<T, { type: 'inlineBlock' }> extends SerializedInlineBlockNode<infer B>
? B extends { blockType: string }
? B['blockType']
: never
: never,
string
>]?: HTMLConverterAsync<
Extract<T, { type: 'inlineBlock' }> extends SerializedInlineBlockNode<infer B>
? SerializedInlineBlockNode<Extract<B, { blockType: K }>>
: SerializedInlineBlockNode
>
}
}
export type HTMLConvertersFunctionAsync<
T extends { [key: string]: any; type?: string } =
| DefaultNodeTypes
| SerializedBlockNode<{ blockName?: null | string }>
| SerializedInlineBlockNode<{ blockName?: null | string; blockType: string }>,
> = (args: { defaultConverters: HTMLConvertersAsync<DefaultNodeTypes> }) => HTMLConvertersAsync<T>

View File

@@ -0,0 +1,101 @@
import type { SerializedLexicalNode } from 'lexical'
import type { SerializedBlockNode, SerializedInlineBlockNode } from '../../../../nodeTypes.js'
import type { HTMLConverterAsync, HTMLConvertersAsync } from '../async/types.js'
import type { HTMLConverter, HTMLConverters } from '../sync/types.js'
import type { ProvidedCSS } from './types.js'
export function findConverterForNode<
TConverters extends HTMLConverters | HTMLConvertersAsync,
TConverter extends HTMLConverter | HTMLConverterAsync,
>({
converters,
disableIndent,
disableTextAlign,
node,
unknownConverter,
}: {
converters: TConverters
disableIndent?: boolean | string[]
disableTextAlign?: boolean | string[]
node: SerializedLexicalNode
unknownConverter: TConverter
}): {
converterForNode: TConverter | undefined
providedCSSString: string
providedStyleTag: string
} {
let converterForNode: TConverter | undefined
if (node.type === 'block') {
converterForNode = converters?.blocks?.[
(node as SerializedBlockNode)?.fields?.blockType
] as TConverter
if (!converterForNode) {
console.error(
`Lexical => HTML converter: Blocks converter: found ${(node as SerializedBlockNode)?.fields?.blockType} block, but no converter is provided`,
)
}
} else if (node.type === 'inlineBlock') {
converterForNode = converters?.inlineBlocks?.[
(node as SerializedInlineBlockNode)?.fields?.blockType
] as TConverter
if (!converterForNode) {
console.error(
`Lexical => HTML converter: Inline Blocks converter: found ${(node as SerializedInlineBlockNode)?.fields?.blockType} inline block, but no converter is provided`,
)
}
} else {
converterForNode = converters[node.type] as TConverter
}
const style: ProvidedCSS = {}
// Check if disableTextAlign is not true and does not include node type
if (
!disableTextAlign &&
(!Array.isArray(disableTextAlign) || !disableTextAlign?.includes(node.type))
) {
if ('format' in node && node.format) {
switch (node.format) {
case 'center':
style['text-align'] = 'center'
break
case 'end':
style['text-align'] = 'right'
break
case 'justify':
style['text-align'] = 'justify'
break
case 'left':
//style['text-align'] = 'left'
// Do nothing, as left is the default
break
case 'right':
style['text-align'] = 'right'
break
case 'start':
style['text-align'] = 'left'
break
}
}
}
if (!disableIndent && (!Array.isArray(disableIndent) || !disableIndent?.includes(node.type))) {
if ('indent' in node && node.indent && node.type !== 'listitem') {
style['padding-inline-start'] = `${Number(node.indent) * 2}em`
}
}
let providedCSSString: string = ''
for (const key of Object.keys(style)) {
// @ts-expect-error we're iterating over the keys of the object
providedCSSString += `${key}: ${style[key]};`
}
const providedStyleTag = providedCSSString?.length ? ` style="${providedCSSString}"` : ''
return {
converterForNode: converterForNode ?? unknownConverter,
providedCSSString,
providedStyleTag,
}
}

View File

@@ -0,0 +1,10 @@
import type { SerializedLexicalNode } from 'lexical'
export type ProvidedCSS = {
'padding-inline-start'?: string
'text-align'?: string
}
export type SerializedLexicalNodeWithParent = {
parent?: SerializedLexicalNode
} & SerializedLexicalNode

View File

@@ -0,0 +1,12 @@
import type { SerializedQuoteNode } from '../../../../../nodeTypes.js'
import type { HTMLConverters } from '../types.js'
export const BlockquoteHTMLConverter: HTMLConverters<SerializedQuoteNode> = {
quote: ({ node, nodesToHTML, providedStyleTag }) => {
const children = nodesToHTML({
nodes: node.children,
}).join('')
return `<blockquote${providedStyleTag}>${children}</blockquote>`
},
}

View File

@@ -0,0 +1,12 @@
import type { SerializedHeadingNode } from '../../../../../nodeTypes.js'
import type { HTMLConverters } from '../types.js'
export const HeadingHTMLConverter: HTMLConverters<SerializedHeadingNode> = {
heading: ({ node, nodesToHTML, providedStyleTag }) => {
const children = nodesToHTML({
nodes: node.children,
}).join('')
return `<${node.tag}${providedStyleTag}>${children}</${node.tag}>`
},
}

View File

@@ -0,0 +1,5 @@
import type { SerializedHorizontalRuleNode } from '../../../../../nodeTypes.js'
import type { HTMLConverters } from '../types.js'
export const HorizontalRuleHTMLConverter: HTMLConverters<SerializedHorizontalRuleNode> = {
horizontalrule: '<hr />',
}

View File

@@ -0,0 +1,6 @@
import type { SerializedLineBreakNode } from '../../../../../nodeTypes.js'
import type { HTMLConverters } from '../types.js'
export const LinebreakHTMLConverter: HTMLConverters<SerializedLineBreakNode> = {
linebreak: '<br />',
}

View File

@@ -0,0 +1,47 @@
import type { SerializedAutoLinkNode, SerializedLinkNode } from '../../../../../nodeTypes.js'
import type { HTMLConverters } from '../types.js'
export const LinkHTMLConverter: (args: {
internalDocToHref?: (args: { linkNode: SerializedLinkNode }) => string
}) => HTMLConverters<SerializedAutoLinkNode | SerializedLinkNode> = ({ internalDocToHref }) => ({
autolink: ({ node, nodesToHTML, providedStyleTag }) => {
const children = nodesToHTML({
nodes: node.children,
}).join('')
const rel: string | undefined = node.fields.newTab ? 'noopener noreferrer' : undefined
const target: string | undefined = node.fields.newTab ? '_blank' : undefined
return `(
<a${providedStyleTag} href="${node.fields.url}" rel=${rel} target=${target}>
${children}
</a>
)`
},
link: ({ node, nodesToHTML, providedStyleTag }) => {
const children = nodesToHTML({
nodes: node.children,
}).join('')
const rel: string | undefined = node.fields.newTab ? 'noopener noreferrer' : undefined
const target: string | undefined = node.fields.newTab ? '_blank' : undefined
let href: string = node.fields.url ?? ''
if (node.fields.linkType === 'internal') {
if (internalDocToHref) {
href = internalDocToHref({ linkNode: node })
} else {
console.error(
'Lexical => HTML converter: Link converter: found internal link, but internalDocToHref is not provided',
)
href = '#' // fallback
}
}
return `(
<a${providedStyleTag} href="${href}" rel=${rel} target=${target}>
${children}
</a>
)`
},
})

View File

@@ -0,0 +1,47 @@
import { v4 as uuidv4 } from 'uuid'
import type { SerializedListItemNode, SerializedListNode } from '../../../../../nodeTypes.js'
import type { HTMLConverters } from '../types.js'
export const ListHTMLConverter: HTMLConverters<SerializedListItemNode | SerializedListNode> = {
list: ({ node, nodesToHTML, providedStyleTag }) => {
const children = nodesToHTML({
nodes: node.children,
}).join('')
return `<${node.tag}${providedStyleTag} class="list-${node.listType}">${children}</${node.tag}>`
},
listitem: ({ node, nodesToHTML, parent, providedCSSString }) => {
const hasSubLists = node.children.some((child) => child.type === 'list')
const children = nodesToHTML({
nodes: node.children,
}).join('')
if ('listType' in parent && parent?.listType === 'check') {
const uuid = uuidv4()
return `<li
aria-checked="${node.checked ? 'true' : 'false'}"
class="list-item-checkbox${node.checked ? ' list-item-checkbox-checked' : ' list-item-checkbox-unchecked'}${hasSubLists ? ' nestedListItem' : ''}"
role="checkbox"
style="list-style-type: none;${providedCSSString}"
tabIndex="-1"
value="${node.value}"
>
${
hasSubLists
? children
: `<input checked="${node.checked}" id="${uuid}" readOnly="true" type="checkbox" />
<label htmlFor="${uuid}">${children}</label>
<br />`
}
</li>`
} else {
return `<li
class="${hasSubLists ? 'nestedListItem' : ''}"
style="${hasSubLists ? `list-style-type: none;${providedCSSString}` : providedCSSString}"
value="${node.value}"
>${children}</li>`
}
},
}

View File

@@ -0,0 +1,16 @@
import type { SerializedParagraphNode } from '../../../../../nodeTypes.js'
import type { HTMLConverters } from '../types.js'
export const ParagraphHTMLConverter: HTMLConverters<SerializedParagraphNode> = {
paragraph: ({ node, nodesToHTML, providedStyleTag }) => {
const children = nodesToHTML({
nodes: node.children,
})
if (!children?.length) {
return `<p${providedStyleTag}><br /></p>`
}
return `<p${providedStyleTag}>${children.join('')}</p>`
},
}

View File

@@ -0,0 +1,6 @@
import type { SerializedTabNode } from '../../../../../nodeTypes.js'
import type { HTMLConverters } from '../types.js'
export const TabHTMLConverter: HTMLConverters<SerializedTabNode> = {
tab: '\t',
}

View File

@@ -0,0 +1,59 @@
import type {
SerializedTableCellNode,
SerializedTableNode,
SerializedTableRowNode,
} from '../../../../../nodeTypes.js'
import type { HTMLConverters } from '../types.js'
export const TableHTMLConverter: HTMLConverters<
SerializedTableCellNode | SerializedTableNode | SerializedTableRowNode
> = {
table: ({ node, nodesToHTML, providedStyleTag }) => {
const children = nodesToHTML({
nodes: node.children,
}).join('')
return `<div${providedStyleTag} class="lexical-table-container">
<table class="lexical-table" style="border-collapse: collapse;">
<tbody>${children}</tbody>
</table>
</div>`
},
tablecell: ({ node, nodesToHTML, providedCSSString }) => {
const children = nodesToHTML({
nodes: node.children,
}).join('')
const TagName = node.headerState > 0 ? 'th' : 'td'
const headerStateClass = `lexical-table-cell-header-${node.headerState}`
let style = 'border: 1px solid #ccc; padding: 8px;' + providedCSSString
if (node.backgroundColor) {
style += ` background-color: ${node.backgroundColor};`
}
const colSpanAttr = node.colSpan && node.colSpan > 1 ? ` colspan="${node.colSpan}"` : ''
const rowSpanAttr = node.rowSpan && node.rowSpan > 1 ? ` rowspan="${node.rowSpan}"` : ''
return `<${TagName}
class="lexical-table-cell ${headerStateClass}"
${colSpanAttr}
${rowSpanAttr}
style="${style}"
>
${children}
</${TagName}>
`
},
tablerow: ({ node, nodesToHTML, providedStyleTag }) => {
const children = nodesToHTML({
nodes: node.children,
}).join('')
return `<tr${providedStyleTag} class="lexical-table-row">
${children}
</tr>`
},
}

View File

@@ -0,0 +1,34 @@
import type { SerializedTextNode } from '../../../../../nodeTypes.js'
import type { HTMLConverters } from '../types.js'
import { NodeFormat } from '../../../../../lexical/utils/nodeFormat.js'
export const TextHTMLConverter: HTMLConverters<SerializedTextNode> = {
text: ({ node }) => {
let text = node.text
if (node.format & NodeFormat.IS_BOLD) {
text = `<strong>${text}</strong>`
}
if (node.format & NodeFormat.IS_ITALIC) {
text = `<em>${text}</em>`
}
if (node.format & NodeFormat.IS_STRIKETHROUGH) {
text = `<span style="text-decoration: line-through;">${text}</span>`
}
if (node.format & NodeFormat.IS_UNDERLINE) {
text = `<span style="text-decoration: underline;">${text}</span>`
}
if (node.format & NodeFormat.IS_CODE) {
text = `<code>${text}</code>`
}
if (node.format & NodeFormat.IS_SUBSCRIPT) {
text = `<sub>${text}</sub>`
}
if (node.format & NodeFormat.IS_SUPERSCRIPT) {
text = `<sup>${text}</sup>`
}
return text
},
}

View File

@@ -0,0 +1,81 @@
import type { FileData, FileSizeImproved, TypeWithID } from 'payload'
import type { SerializedUploadNode } from '../../../../../nodeTypes.js'
import type { UploadDataImproved } from '../../../../upload/server/nodes/UploadNode.js'
import type { HTMLConverters } from '../types.js'
export const UploadHTMLConverter: HTMLConverters<SerializedUploadNode> = {
upload: ({ node, providedStyleTag }) => {
const uploadNode = node as UploadDataImproved
let uploadDoc: (FileData & TypeWithID) | undefined = undefined
// If there's no valid upload data, populate return an empty string
if (typeof uploadNode.value !== 'object') {
return ''
} else {
uploadDoc = uploadNode.value as unknown as FileData & TypeWithID
}
if (!uploadDoc) {
return ''
}
const url = uploadDoc.url
// 1) If upload is NOT an image, return a link
if (!uploadDoc.mimeType.startsWith('image')) {
return `<a${providedStyleTag} href="${url}" rel="noopener noreferrer">${uploadDoc.filename}</a$>`
}
// 2) If image has no different sizes, return a simple <img />
if (!uploadDoc.sizes || !Object.keys(uploadDoc.sizes).length) {
return `
<img${providedStyleTag}
alt="${uploadDoc.filename}"
height="${uploadDoc.height}"
src="${url}"
width="${uploadDoc.width}"
/>
`
}
// 3) If image has different sizes, build a <picture> element with <source> tags
let pictureHTML = ''
for (const size in uploadDoc.sizes) {
const imageSize = uploadDoc.sizes[size] as FileSizeImproved
if (
!imageSize ||
!imageSize.width ||
!imageSize.height ||
!imageSize.mimeType ||
!imageSize.filesize ||
!imageSize.filename ||
!imageSize.url
) {
continue
}
pictureHTML += `
<source
media="(max-width: ${imageSize.width}px)"
srcset="${imageSize.url}"
type="${imageSize.mimeType}"
/>
`
}
pictureHTML += `
<img
alt="${uploadDoc.filename}"
height="${uploadDoc.height}"
src="${url}"
width="${uploadDoc.width}"
/>
`
return `<picture${providedStyleTag}>${pictureHTML}</picture$>`
},
}

View File

@@ -0,0 +1,28 @@
import type { DefaultNodeTypes } from '../../../../nodeTypes.js'
import type { HTMLConverters } from './types.js'
import { BlockquoteHTMLConverter } from './converters/blockquote.js'
import { HeadingHTMLConverter } from './converters/heading.js'
import { HorizontalRuleHTMLConverter } from './converters/horizontalRule.js'
import { LinebreakHTMLConverter } from './converters/linebreak.js'
import { LinkHTMLConverter } from './converters/link.js'
import { ListHTMLConverter } from './converters/list.js'
import { ParagraphHTMLConverter } from './converters/paragraph.js'
import { TabHTMLConverter } from './converters/tab.js'
import { TableHTMLConverter } from './converters/table.js'
import { TextHTMLConverter } from './converters/text.js'
import { UploadHTMLConverter } from './converters/upload.js'
export const defaultHTMLConverters: HTMLConverters<DefaultNodeTypes> = {
...ParagraphHTMLConverter,
...TextHTMLConverter,
...LinebreakHTMLConverter,
...BlockquoteHTMLConverter,
...TableHTMLConverter,
...HeadingHTMLConverter,
...HorizontalRuleHTMLConverter,
...ListHTMLConverter,
...LinkHTMLConverter({}),
...UploadHTMLConverter,
...TabHTMLConverter,
}

View File

@@ -0,0 +1,142 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-console */
import type { SerializedEditorState, SerializedLexicalNode } from 'lexical'
import type { SerializedLexicalNodeWithParent } from '../shared/types.js'
import type { HTMLConverter, HTMLConverters, HTMLConvertersFunction } from './types.js'
import { hasText } from '../../../../validate/hasText.js'
import { findConverterForNode } from '../shared/findConverterForNode.js'
import { defaultHTMLConverters } from './defaultConverters.js'
export type ConvertLexicalToHTMLArgs = {
/**
* Override class names for the container.
*/
className?: string
converters?: HTMLConverters | HTMLConvertersFunction
data: SerializedEditorState
/**
* If true, removes the container div wrapper.
*/
disableContainer?: boolean
/**
* If true, disables indentation globally. If an array, disables for specific node `type` values.
*/
disableIndent?: boolean | string[]
/**
* If true, disables text alignment globally. If an array, disables for specific node `type` values.
*/
disableTextAlign?: boolean | string[]
}
export function convertLexicalToHTML({
className,
converters,
data,
disableContainer,
disableIndent,
disableTextAlign,
}: ConvertLexicalToHTMLArgs): string {
if (hasText(data)) {
let finalConverters: HTMLConverters = {}
if (converters) {
if (typeof converters === 'function') {
finalConverters = converters({ defaultConverters: defaultHTMLConverters })
} else {
finalConverters = converters
}
} else {
finalConverters = defaultHTMLConverters
}
const html = convertLexicalNodesToHTML({
converters: finalConverters,
disableIndent,
disableTextAlign,
nodes: data?.root?.children,
parent: data?.root,
}).join('')
if (disableContainer) {
return html
} else {
return `<div class="${className ?? 'payload-richtext'}">${html}</div>`
}
}
if (disableContainer) {
return ''
} else {
return `<div class="${className ?? 'payload-richtext'}"></div>`
}
}
export function convertLexicalNodesToHTML({
converters,
disableIndent,
disableTextAlign,
nodes,
parent,
}: {
converters: HTMLConverters
disableIndent?: boolean | string[]
disableTextAlign?: boolean | string[]
nodes: SerializedLexicalNode[]
parent: SerializedLexicalNodeWithParent
}): string[] {
const unknownConverter: HTMLConverter<any> = converters.unknown as HTMLConverter<any>
const htmlArray: string[] = []
let i = -1
for (const node of nodes) {
i++
const { converterForNode, providedCSSString, providedStyleTag } = findConverterForNode({
converters,
disableIndent,
disableTextAlign,
node,
unknownConverter,
})
try {
let nodeHTML: string
if (converterForNode) {
const converted =
typeof converterForNode === 'function'
? converterForNode({
childIndex: i,
converters,
node,
nodesToHTML: (args) => {
return convertLexicalNodesToHTML({
converters: args.converters ?? converters,
disableIndent: args.disableIndent ?? disableIndent,
disableTextAlign: args.disableTextAlign ?? disableTextAlign,
nodes: args.nodes,
parent: args.parent ?? {
...node,
parent,
},
})
},
parent,
providedCSSString,
providedStyleTag,
})
: converterForNode
nodeHTML = converted
} else {
nodeHTML = '<span>unknown node</span>'
}
htmlArray.push(nodeHTML)
} catch (error) {
console.error('Error converting lexical node to HTML:', error, 'node:', node)
htmlArray.push('')
}
}
return htmlArray.filter(Boolean)
}

View File

@@ -0,0 +1,81 @@
import type { SerializedLexicalNode } from 'lexical'
import type {
DefaultNodeTypes,
SerializedBlockNode,
SerializedInlineBlockNode,
} from '../../../../nodeTypes.js'
import type { SerializedLexicalNodeWithParent } from '../shared/types.js'
export type HTMLConverter<T extends { [key: string]: any; type?: string } = SerializedLexicalNode> =
| ((args: {
childIndex: number
converters: HTMLConverters
node: T
nodesToHTML: (args: {
converters?: HTMLConverters
disableIndent?: boolean | string[]
disableTextAlign?: boolean | string[]
nodes: SerializedLexicalNode[]
parent?: SerializedLexicalNodeWithParent
}) => string[]
parent: SerializedLexicalNodeWithParent
providedCSSString: string
providedStyleTag: string
}) => string)
| string
export type HTMLConverters<
T extends { [key: string]: any; type?: string } =
| DefaultNodeTypes
| SerializedBlockNode<{ blockName?: null | string; blockType: string }> // need these to ensure types for blocks and inlineBlocks work if no generics are provided
| SerializedInlineBlockNode<{ blockName?: null | string; blockType: string }>, // need these to ensure types for blocks and inlineBlocks work if no generics are provided
> = {
[key: string]:
| {
[blockSlug: string]: HTMLConverter<any>
}
| HTMLConverter<any>
| undefined
} & {
[nodeType in Exclude<NonNullable<T['type']>, 'block' | 'inlineBlock'>]?: HTMLConverter<
Extract<T, { type: nodeType }>
>
} & {
blocks?: {
[K in Extract<
Extract<T, { type: 'block' }> extends SerializedBlockNode<infer B>
? B extends { blockType: string }
? B['blockType']
: never
: never,
string
>]?: HTMLConverter<
Extract<T, { type: 'block' }> extends SerializedBlockNode<infer B>
? SerializedBlockNode<Extract<B, { blockType: K }>>
: SerializedBlockNode
>
}
inlineBlocks?: {
[K in Extract<
Extract<T, { type: 'inlineBlock' }> extends SerializedInlineBlockNode<infer B>
? B extends { blockType: string }
? B['blockType']
: never
: never,
string
>]?: HTMLConverter<
Extract<T, { type: 'inlineBlock' }> extends SerializedInlineBlockNode<infer B>
? SerializedInlineBlockNode<Extract<B, { blockType: K }>>
: SerializedInlineBlockNode
>
}
}
export type HTMLConvertersFunction<
T extends { [key: string]: any; type?: string } =
| DefaultNodeTypes
| SerializedBlockNode<{ blockName?: null | string }>
| SerializedInlineBlockNode<{ blockName?: null | string; blockType: string }>,
> = (args: { defaultConverters: HTMLConverters<DefaultNodeTypes> }) => HTMLConverters<T>

View File

@@ -5,6 +5,9 @@ import { ParagraphHTMLConverter } from './converters/paragraph.js'
import { TabHTMLConverter } from './converters/tab.js'
import { TextHTMLConverter } from './converters/text.js'
/**
* @deprecated - will be removed in 4.0
*/
export const defaultHTMLConverters: HTMLConverter<any>[] = [
ParagraphHTMLConverter,
TextHTMLConverter,

View File

@@ -7,6 +7,9 @@ import type { HTMLConverter, SerializedLexicalNodeWithParent } from './types.js'
import { hasText } from '../../../../validate/hasText.js'
/**
* @deprecated - will be removed in 4.0
*/
export type ConvertLexicalToHTMLArgs = {
converters: HTMLConverter[]
currentDepth?: number
@@ -44,6 +47,9 @@ export type ConvertLexicalToHTMLArgs = {
}
)
/**
* @deprecated - will be removed in 4.0
*/
export async function convertLexicalToHTML({
converters,
currentDepth,
@@ -83,6 +89,9 @@ export async function convertLexicalToHTML({
return ''
}
/**
* @deprecated - will be removed in 4.0
*/
export async function convertLexicalNodesToHTML({
converters,
currentDepth,

View File

@@ -1,6 +1,9 @@
import type { SerializedLexicalNode } from 'lexical'
import type { PayloadRequest } from 'payload'
/**
* @deprecated - will be removed in 4.0
*/
export type HTMLConverter<T extends SerializedLexicalNode = SerializedLexicalNode> = {
converter: (args: {
childIndex: number

View File

@@ -1,5 +1,5 @@
import type { SerializedEditorState } from 'lexical'
import type { Field, FieldAffectingData, PayloadRequest, RichTextField } from 'payload'
import type { Field, RichTextField } from 'payload'
import type { SanitizedServerEditorConfig } from '../../../../lexical/config/types.js'
import type { AdapterProps, LexicalRichTextAdapter } from '../../../../types.js'
@@ -9,7 +9,7 @@ import type { HTMLConverterFeatureProps } from '../index.js'
import { defaultHTMLConverters } from '../converter/defaultConverters.js'
import { convertLexicalToHTML } from '../converter/index.js'
type Props = {
type Args = {
/**
* Whether the lexicalHTML field should be hidden in the admin panel
*
@@ -28,6 +28,7 @@ type Props = {
/**
* Combines the default HTML converters with HTML converters found in the features, and with HTML converters configured in the htmlConverter feature.
*
* @deprecated - will be removed in 4.0
* @param editorConfig
*/
export const consolidateHTMLConverters = ({
@@ -83,16 +84,19 @@ export const consolidateHTMLConverters = ({
return filteredConverters
}
/**
* @deprecated - will be removed in 4.0
*/
export const lexicalHTML: (
/**
* A string which matches the lexical field name you want to convert to HTML.
*
* This has to be a SIBLING field of this lexicalHTML field - otherwise, it won't be able to find the lexical field.
* This has to be a sibling field of this lexicalHTML field - otherwise, it won't be able to find the lexical field.
**/
lexicalFieldName: string,
props: Props,
) => Field = (lexicalFieldName, props) => {
const { name = 'lexicalHTML', hidden = true, storeInDB = false } = props
args: Args,
) => Field = (lexicalFieldName, args) => {
const { name = 'lexicalHTML', hidden = true, storeInDB = false } = args
return {
name,
type: 'code',

View File

@@ -9,6 +9,9 @@ export type HTMLConverterFeatureProps = {
}
// This is just used to save the props on the richText field
/**
* @deprecated - will be removed in 4.0
*/
export const HTMLConverterFeature = createServerFeature<HTMLConverterFeatureProps>({
feature: {},
key: 'htmlConverter',

View File

@@ -7,10 +7,10 @@ import type {
SerializedBlockNode,
SerializedInlineBlockNode,
} from '../../../../nodeTypes.js'
import type { JSXConverters } from './converter/types.js'
import type { JSXConverters } from '../converter/types.js'
import { defaultJSXConverters } from './converter/defaultConverters.js'
import { convertLexicalToJSX } from './converter/index.js'
import { defaultJSXConverters } from '../converter/defaultConverters.js'
import { convertLexicalToJSX } from '../converter/index.js'
export type JSXConvertersFunction<
T extends { [key: string]: any; type?: string } =
@@ -21,7 +21,7 @@ export type JSXConvertersFunction<
type RichTextProps = {
/**
* Additional class names for the container.
* Override class names for the container.
*/
className?: string
/**

View File

@@ -1,4 +1,4 @@
import type { SerializedQuoteNode } from '../../../../../../nodeTypes.js'
import type { SerializedQuoteNode } from '../../../../../nodeTypes.js'
import type { JSXConverters } from '../types.js'
export const BlockquoteJSXConverter: JSXConverters<SerializedQuoteNode> = {

View File

@@ -1,4 +1,4 @@
import type { SerializedHeadingNode } from '../../../../../../nodeTypes.js'
import type { SerializedHeadingNode } from '../../../../../nodeTypes.js'
import type { JSXConverters } from '../types.js'
export const HeadingJSXConverter: JSXConverters<SerializedHeadingNode> = {

View File

@@ -1,7 +1,5 @@
import type { SerializedHorizontalRuleNode } from '../../../../../../nodeTypes.js'
import type { SerializedHorizontalRuleNode } from '../../../../../nodeTypes.js'
import type { JSXConverters } from '../types.js'
export const HorizontalRuleJSXConverter: JSXConverters<SerializedHorizontalRuleNode> = {
horizontalrule: () => {
return <hr />
},
horizontalrule: <hr />,
}

View File

@@ -1,8 +1,6 @@
import type { SerializedLineBreakNode } from '../../../../../../nodeTypes.js'
import type { SerializedLineBreakNode } from '../../../../../nodeTypes.js'
import type { JSXConverters } from '../types.js'
export const LinebreakJSXConverter: JSXConverters<SerializedLineBreakNode> = {
linebreak: () => {
return <br />
},
linebreak: <br />,
}

View File

@@ -1,4 +1,4 @@
import type { SerializedAutoLinkNode, SerializedLinkNode } from '../../../../../../nodeTypes.js'
import type { SerializedAutoLinkNode, SerializedLinkNode } from '../../../../../nodeTypes.js'
import type { JSXConverters } from '../types.js'
export const LinkJSXConverter: (args: {

View File

@@ -1,6 +1,6 @@
import { v4 as uuidv4 } from 'uuid'
import type { SerializedListItemNode, SerializedListNode } from '../../../../../../nodeTypes.js'
import type { SerializedListItemNode, SerializedListNode } from '../../../../../nodeTypes.js'
import type { JSXConverters } from '../types.js'
export const ListJSXConverter: JSXConverters<SerializedListItemNode | SerializedListNode> = {

View File

@@ -1,4 +1,4 @@
import type { SerializedParagraphNode } from '../../../../../../nodeTypes.js'
import type { SerializedParagraphNode } from '../../../../../nodeTypes.js'
import type { JSXConverters } from '../types.js'
export const ParagraphJSXConverter: JSXConverters<SerializedParagraphNode> = {

View File

@@ -0,0 +1,6 @@
import type { SerializedTabNode } from '../../../../../nodeTypes.js'
import type { JSXConverters } from '../types.js'
export const TabJSXConverter: JSXConverters<SerializedTabNode> = {
tab: '\t',
}

View File

@@ -2,7 +2,7 @@ import type {
SerializedTableCellNode,
SerializedTableNode,
SerializedTableRowNode,
} from '../../../../../../nodeTypes.js'
} from '../../../../../nodeTypes.js'
import type { JSXConverters } from '../types.js'
export const TableJSXConverter: JSXConverters<

View File

@@ -1,9 +1,9 @@
import React from 'react'
import type { SerializedTextNode } from '../../../../../../nodeTypes.js'
import type { SerializedTextNode } from '../../../../../nodeTypes.js'
import type { JSXConverters } from '../types.js'
import { NodeFormat } from '../../../../../../lexical/utils/nodeFormat.js'
import { NodeFormat } from '../../../../../lexical/utils/nodeFormat.js'
export const TextJSXConverter: JSXConverters<SerializedTextNode> = {
text: ({ node }) => {

View File

@@ -1,7 +1,7 @@
import type { FileData, FileSizeImproved, TypeWithID } from 'payload'
import type { UploadDataImproved } from '../../../../../../features/upload/server/nodes/UploadNode.js'
import type { SerializedUploadNode } from '../../../../../../nodeTypes.js'
import type { SerializedUploadNode } from '../../../../../nodeTypes.js'
import type { UploadDataImproved } from '../../../../upload/server/nodes/UploadNode.js'
import type { JSXConverters } from '../types.js'
export const UploadJSXConverter: JSXConverters<SerializedUploadNode> = {
@@ -12,7 +12,9 @@ export const UploadJSXConverter: JSXConverters<SerializedUploadNode> = {
return null
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const uploadDoc = uploadNode.value as FileData & TypeWithID
const url = uploadDoc.url
/**
@@ -29,7 +31,7 @@ export const UploadJSXConverter: JSXConverters<SerializedUploadNode> = {
/**
* If the upload is a simple image with no different sizes, return a simple img tag
*/
if (!Object.keys(uploadDoc.sizes).length) {
if (!uploadDoc.sizes || !Object.keys(uploadDoc.sizes).length) {
return (
<img alt={uploadDoc.filename} height={uploadDoc.height} src={url} width={uploadDoc.width} />
)
@@ -64,7 +66,7 @@ export const UploadJSXConverter: JSXConverters<SerializedUploadNode> = {
media={`(max-width: ${imageSize.width}px)`}
srcSet={imageSizeURL}
type={imageSize.mimeType}
></source>,
/>,
)
}

View File

@@ -1,4 +1,4 @@
import type { DefaultNodeTypes } from '../../../../../nodeTypes.js'
import type { DefaultNodeTypes } from '../../../../nodeTypes.js'
import type { JSXConverters } from './types.js'
import { BlockquoteJSXConverter } from './converters/blockquote.js'

View File

@@ -2,12 +2,12 @@ import type { SerializedEditorState, SerializedLexicalNode } from 'lexical'
import React from 'react'
import type { SerializedBlockNode, SerializedInlineBlockNode } from '../../../../../nodeTypes.js'
import type { SerializedBlockNode, SerializedInlineBlockNode } from '../../../../nodeTypes.js'
import type { JSXConverter, JSXConverters, SerializedLexicalNodeWithParent } from './types.js'
import { hasText } from '../../../../../validate/hasText.js'
import { hasText } from '../../../../validate/hasText.js'
export type ConvertLexicalToHTMLArgs = {
export type ConvertLexicalToJSXArgs = {
converters: JSXConverters
data: SerializedEditorState
disableIndent?: boolean | string[]
@@ -19,7 +19,7 @@ export function convertLexicalToJSX({
data,
disableIndent,
disableTextAlign,
}: ConvertLexicalToHTMLArgs): React.ReactNode {
}: ConvertLexicalToJSXArgs): React.ReactNode {
if (hasText(data)) {
return convertLexicalNodesToJSX({
converters,
@@ -69,31 +69,15 @@ export function convertLexicalNodesToJSX({
}
try {
if (!converterForNode) {
if (unknownConverter) {
return unknownConverter({
childIndex: i,
converters,
node,
nodesToJSX: (args) => {
return convertLexicalNodesToJSX({
converters: args.converters ?? converters,
disableIndent: args.disableIndent ?? disableIndent,
disableTextAlign: args.disableTextAlign ?? disableTextAlign,
nodes: args.nodes,
parent: args.parent ?? {
...node,
parent,
},
})
},
parent,
})
}
return <span key={i}>unknown node</span>
if (!converterForNode && unknownConverter) {
converterForNode = unknownConverter
}
const reactNode = converterForNode({
let reactNode: React.ReactNode
if (converterForNode) {
const converted =
typeof converterForNode === 'function'
? converterForNode({
childIndex: i,
converters,
node,
@@ -111,6 +95,11 @@ export function convertLexicalNodesToJSX({
},
parent,
})
: converterForNode
reactNode = converted
} else {
reactNode = <span key={i}>unknown node</span>
}
const style: React.CSSProperties = {}
@@ -176,10 +165,10 @@ export function convertLexicalNodesToJSX({
return reactNode
} catch (error) {
console.error('Error converting lexical node to HTML:', error, 'node:', node)
console.error('Error converting lexical node to JSX:', error, 'node:', node)
return null
}
})
return jsxArray.filter(Boolean).map((jsx) => jsx)
return jsxArray.filter(Boolean)
}

View File

@@ -4,9 +4,9 @@ import type {
DefaultNodeTypes,
SerializedBlockNode,
SerializedInlineBlockNode,
} from '../../../../../nodeTypes.js'
} from '../../../../nodeTypes.js'
export type JSXConverter<T extends { [key: string]: any; type?: string } = SerializedLexicalNode> =
(args: {
| ((args: {
childIndex: number
converters: JSXConverters
node: T
@@ -18,7 +18,8 @@ export type JSXConverter<T extends { [key: string]: any; type?: string } = Seria
parent?: SerializedLexicalNodeWithParent
}) => React.ReactNode[]
parent: SerializedLexicalNodeWithParent
}) => React.ReactNode
}) => React.ReactNode)
| React.ReactNode
export type JSXConverters<
T extends { [key: string]: any; type?: string } =

View File

@@ -0,0 +1,81 @@
import { createLocalReq, type Payload, type PayloadRequest, type TypedLocale } from 'payload'
import type { HTMLPopulateFn } from '../html/async/types.js'
import { populate } from '../../../populateGraphQL/populate.js'
export const getPayloadPopulateFn: (
args: {
currentDepth: number
depth: number
draft?: boolean
locale?: TypedLocale
overrideAccess?: boolean
showHiddenFields?: boolean
} & (
| {
/**
* This payload property will only be used if req is undefined. If localization is enabled, you must pass `req` instead.
*/
payload: Payload
/**
* When the converter is called, req CAN be passed in depending on where it's run.
* If this is undefined and config is passed through, lexical will create a new req object for you.
*/
req?: never
}
| {
/**
* This payload property will only be used if req is undefined. If localization is enabled, you must pass `req` instead.
*/
payload?: never
/**
* When the converter is called, req CAN be passed in depending on where it's run.
* If this is undefined and config is passed through, lexical will create a new req object for you.
*/
req: PayloadRequest
}
),
) => Promise<HTMLPopulateFn> = async ({
currentDepth,
depth,
draft,
overrideAccess,
payload,
req,
showHiddenFields,
}) => {
let reqToUse: PayloadRequest | undefined = req
if (req === undefined && payload) {
reqToUse = await createLocalReq({}, payload)
}
if (!reqToUse) {
throw new Error('No req or payload provided')
}
const populateFn: HTMLPopulateFn = async ({ id, collectionSlug, select }) => {
const dataContainer: {
value?: any
} = {}
await populate({
id,
collectionSlug,
currentDepth,
data: dataContainer,
depth,
draft: draft ?? false,
key: 'value',
overrideAccess: overrideAccess ?? true,
req: reqToUse,
select,
showHiddenFields: showHiddenFields ?? false,
})
return dataContainer.value
}
return populateFn
}

View File

@@ -0,0 +1,33 @@
import { stringify } from 'qs-esm'
import type { HTMLPopulateFn } from '../html/async/types.js'
export const getRestPopulateFn: (args: {
/**
* E.g. `http://localhost:3000/api`
*/
apiURL: string
depth?: number
draft?: boolean
locale?: string
}) => HTMLPopulateFn = ({ apiURL, depth, draft, locale }) => {
const populateFn: HTMLPopulateFn = async ({ id, collectionSlug, select }) => {
const query = stringify(
{ depth: depth ?? 0, draft: draft ?? false, locale, select },
{ addQueryPrefix: true },
)
const res = await fetch(`${apiURL}/${collectionSlug}/${id}${query}`, {
credentials: 'include',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
method: 'GET',
}).then((res) => res.json())
return res
}
return populateFn
}

View File

@@ -10,7 +10,7 @@ import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'
import { sanitizeFields } from 'payload'
import { createServerFeature } from '../../../utilities/createServerFeature.js'
import { convertLexicalNodesToHTML } from '../../converters/html/converter/index.js'
import { convertLexicalNodesToHTML } from '../../converters/html_deprecated/converter/index.js'
import { createNode } from '../../typeUtilities.js'
import { TableMarkdownTransformer } from '../markdownTransformer.js'

View File

@@ -7,7 +7,7 @@ import type { Spread } from 'lexical'
import { HeadingNode } from '@lexical/rich-text'
import { createServerFeature } from '../../../utilities/createServerFeature.js'
import { convertLexicalNodesToHTML } from '../../converters/html/converter/index.js'
import { convertLexicalNodesToHTML } from '../../converters/html_deprecated/converter/index.js'
import { createNode } from '../../typeUtilities.js'
import { MarkdownTransformer } from '../markdownTransformer.js'
import { i18n } from './i18n.js'

View File

@@ -13,7 +13,7 @@ import { sanitizeFields } from 'payload'
import type { ClientProps } from '../client/index.js'
import { createServerFeature } from '../../../utilities/createServerFeature.js'
import { convertLexicalNodesToHTML } from '../../converters/html/converter/index.js'
import { convertLexicalNodesToHTML } from '../../converters/html_deprecated/converter/index.js'
import { createNode } from '../../typeUtilities.js'
import { LinkMarkdownTransformer } from '../markdownTransformer.js'
import { AutoLinkNode } from '../nodes/AutoLinkNode.js'

View File

@@ -1,10 +1,10 @@
import { ListItemNode, ListNode } from '@lexical/list'
import { v4 as uuidv4 } from 'uuid'
import type { HTMLConverter } from '../converters/html/converter/types.js'
import type { HTMLConverter } from '../converters/html_deprecated/converter/types.js'
import type { SerializedListItemNode, SerializedListNode } from './plugin/index.js'
import { convertLexicalNodesToHTML } from '../converters/html/converter/index.js'
import { convertLexicalNodesToHTML } from '../converters/html_deprecated/converter/index.js'
export const ListHTMLConverter: HTMLConverter<SerializedListNode> = {
converter: async ({

View File

@@ -27,7 +27,7 @@ import type {
import type { ServerEditorConfig } from '../lexical/config/types.js'
import type { Transformer } from '../packages/@lexical/markdown/index.js'
import type { AdapterProps } from '../types.js'
import type { HTMLConverter } from './converters/html/converter/types.js'
import type { HTMLConverter } from './converters/html_deprecated/converter/types.js'
import type { BaseClientFeatureProps } from './typesClient.js'
export type PopulationPromise<T extends SerializedLexicalNode = SerializedLexicalNode> = (args: {
@@ -221,8 +221,13 @@ export type NodeWithHooks<T extends LexicalNode = any> = {
/**
* Allows you to define how a node can be serialized into different formats. Currently, only supports html.
* Markdown converters are defined in `markdownTransformers` and not here.
*
* @deprecated - will be removed in 4.0
*/
converters?: {
/**
* @deprecated - will be removed in 4.0
*/
html?: HTMLConverter<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>
}
/**

View File

@@ -870,25 +870,32 @@ export {
ServerBlockNode,
} from './features/blocks/server/nodes/BlocksNode.js'
export { LinebreakHTMLConverter } from './features/converters/html/converter/converters/linebreak.js'
export { ParagraphHTMLConverter } from './features/converters/html/converter/converters/paragraph.js'
export { lexicalHTMLField } from './features/converters/html/async/field/index.js'
export { TabHTMLConverter } from './features/converters/html/converter/converters/tab.js'
export { LinebreakHTMLConverter } from './features/converters/html_deprecated/converter/converters/linebreak.js'
export { ParagraphHTMLConverter } from './features/converters/html_deprecated/converter/converters/paragraph.js'
export { TextHTMLConverter } from './features/converters/html/converter/converters/text.js'
export { defaultHTMLConverters } from './features/converters/html/converter/defaultConverters.js'
export { TabHTMLConverter } from './features/converters/html_deprecated/converter/converters/tab.js'
export { TextHTMLConverter } from './features/converters/html_deprecated/converter/converters/text.js'
export { defaultHTMLConverters } from './features/converters/html_deprecated/converter/defaultConverters.js'
export {
convertLexicalNodesToHTML,
convertLexicalToHTML,
} from './features/converters/html/converter/index.js'
} from './features/converters/html_deprecated/converter/index.js'
export type { HTMLConverter } from './features/converters/html/converter/types.js'
export { consolidateHTMLConverters, lexicalHTML } from './features/converters/html/field/index.js'
export type { HTMLConverter } from './features/converters/html_deprecated/converter/types.js'
export {
consolidateHTMLConverters,
lexicalHTML,
} from './features/converters/html_deprecated/field/index.js'
export {
HTMLConverterFeature,
type HTMLConverterFeatureProps,
} from './features/converters/html/index.js'
} from './features/converters/html_deprecated/index.js'
export { convertHTMLToLexical } from './features/converters/htmlToLexical/index.js'
export { getPayloadPopulateFn } from './features/converters/utilities/payloadPopulateFn.js'
export { getRestPopulateFn } from './features/converters/utilities/restPopulateFn.js'
export { TestRecorderFeature } from './features/debug/testRecorder/server/index.js'
export { TreeViewFeature } from './features/debug/treeView/server/index.js'
export { EXPERIMENTAL_TableFeature } from './features/experimental_table/server/index.js'
@@ -1028,6 +1035,7 @@ export type { LexicalEditorProps, LexicalFieldAdminProps, LexicalRichTextAdapter
export { createServerFeature } from './utilities/createServerFeature.js'
export { editorConfigFactory } from './utilities/editorConfigFactory.js'
export type { FieldsDrawerProps } from './utilities/fieldsDrawer/Drawer.js'
export { extractPropsFromJSXPropsString } from './utilities/jsx/extractPropsFromJSXPropsString.js'
@@ -1037,4 +1045,5 @@ export {
objectToFrontmatter,
propsToJSXString,
} from './utilities/jsx/jsx.js'
export { upgradeLexicalData } from './utilities/upgradeLexicalData/index.js'

View File

@@ -2,11 +2,13 @@ import type { PayloadRequest, SelectType } from 'payload'
import { createDataloaderCacheKey } from 'payload'
type Arguments = {
type PopulateArguments = {
collectionSlug: string
currentDepth?: number
data: unknown
depth: number
draft: boolean
id: number | string
key: number | string
overrideAccess: boolean
req: PayloadRequest
@@ -14,7 +16,9 @@ type Arguments = {
showHiddenFields: boolean
}
export const populate = async ({
type PopulateFn = (args: PopulateArguments) => Promise<void>
export const populate: PopulateFn = async ({
id,
collectionSlug,
currentDepth,
@@ -26,10 +30,7 @@ export const populate = async ({
req,
select,
showHiddenFields,
}: {
collectionSlug: string
id: number | string
} & Arguments): Promise<void> => {
}) => {
const shouldPopulate = depth && currentDepth! <= depth
// usually depth is checked within recursivelyPopulateFieldsForGraphQL. But since this populate function can be called outside of that (in rest afterRead node hooks) we need to check here too
if (!shouldPopulate) {

45
pnpm-lock.yaml generated
View File

@@ -45,7 +45,7 @@ importers:
version: 1.50.0
'@sentry/nextjs':
specifier: ^8.33.1
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12))
'@sentry/node':
specifier: ^8.33.1
version: 8.37.1
@@ -135,7 +135,7 @@ importers:
version: 10.1.3(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3)
next:
specifier: 15.2.0
version: 15.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
version: 15.2.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
open:
specifier: ^10.1.0
version: 10.1.0
@@ -1079,7 +1079,7 @@ importers:
dependencies:
next:
specifier: ^15.0.3
version: 15.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
version: 15.1.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
devDependencies:
'@payloadcms/eslint-config':
specifier: workspace:*
@@ -1144,7 +1144,7 @@ importers:
dependencies:
'@sentry/nextjs':
specifier: ^8.33.1
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12))
'@sentry/types':
specifier: ^8.33.1
version: 8.37.1
@@ -1316,6 +1316,9 @@ importers:
micromark-extension-mdx-jsx:
specifier: 3.0.1
version: 3.0.1
qs-esm:
specifier: 7.0.2
version: 7.0.2
react:
specifier: 19.0.0
version: 19.0.0
@@ -1497,7 +1500,7 @@ importers:
version: link:../plugin-cloud-storage
uploadthing:
specifier: 7.3.0
version: 7.3.0(next@15.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))
version: 7.3.0(next@15.2.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))
devDependencies:
payload:
specifier: workspace:*
@@ -1783,7 +1786,7 @@ importers:
version: link:../packages/ui
'@sentry/nextjs':
specifier: ^8.33.1
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12))
'@sentry/react':
specifier: ^7.77.0
version: 7.119.2(react@19.0.0)
@@ -1840,7 +1843,7 @@ importers:
version: 8.9.5(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3)
next:
specifier: 15.2.0
version: 15.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
version: 15.2.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
nodemailer:
specifier: 6.9.16
version: 6.9.16
@@ -8069,6 +8072,7 @@ packages:
libsql@0.4.7:
resolution: {integrity: sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==}
cpu: [x64, arm64, wasm32]
os: [darwin, linux, win32]
lie@3.1.1:
@@ -13967,7 +13971,7 @@ snapshots:
'@sentry/utils': 7.119.2
localforage: 1.10.0
'@sentry/nextjs@8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))':
'@sentry/nextjs@8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation-http': 0.53.0(@opentelemetry/api@1.9.0)
@@ -13981,9 +13985,9 @@ snapshots:
'@sentry/types': 8.37.1
'@sentry/utils': 8.37.1
'@sentry/vercel-edge': 8.37.1
'@sentry/webpack-plugin': 2.22.6(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
'@sentry/webpack-plugin': 2.22.6(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12))
chalk: 3.0.0
next: 15.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
next: 15.2.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
resolve: 1.22.8
rollup: 3.29.5
stacktrace-parser: 0.1.10
@@ -14091,12 +14095,12 @@ snapshots:
'@sentry/types': 8.37.1
'@sentry/utils': 8.37.1
'@sentry/webpack-plugin@2.22.6(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))':
'@sentry/webpack-plugin@2.22.6(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12))':
dependencies:
'@sentry/bundler-plugin-core': 2.22.6
unplugin: 1.0.1
uuid: 9.0.0
webpack: 5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))
webpack: 5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12)
transitivePeerDependencies:
- encoding
- supports-color
@@ -18646,7 +18650,7 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
next@15.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4):
next@15.1.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4):
dependencies:
'@next/env': 15.1.3
'@swc/counter': 0.1.3
@@ -18674,7 +18678,7 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
next@15.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4):
next@15.2.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4):
dependencies:
'@next/env': 15.2.0
'@swc/counter': 0.1.3
@@ -20075,16 +20079,17 @@ snapshots:
ansi-escapes: 4.3.2
supports-hyperlinks: 2.3.0
terser-webpack-plugin@5.3.10(@swc/core@1.10.12(@swc/helpers@0.5.15))(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))):
terser-webpack-plugin@5.3.10(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12)):
dependencies:
'@jridgewell/trace-mapping': 0.3.25
jest-worker: 27.5.1
schema-utils: 3.3.0
serialize-javascript: 6.0.2
terser: 5.36.0
webpack: 5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))
webpack: 5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12)
optionalDependencies:
'@swc/core': 1.10.12(@swc/helpers@0.5.15)
esbuild: 0.19.12
terser@5.36.0:
dependencies:
@@ -20365,14 +20370,14 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
uploadthing@7.3.0(next@15.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)):
uploadthing@7.3.0(next@15.2.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)):
dependencies:
'@effect/platform': 0.69.8(effect@3.10.3)
'@uploadthing/mime-types': 0.3.2
'@uploadthing/shared': 7.1.1
effect: 3.10.3
optionalDependencies:
next: 15.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
next: 15.2.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
uri-js@4.4.1:
dependencies:
@@ -20470,7 +20475,7 @@ snapshots:
webpack-virtual-modules@0.5.0: {}
webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)):
webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.6
@@ -20492,7 +20497,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 3.3.0
tapable: 2.2.1
terser-webpack-plugin: 5.3.10(@swc/core@1.10.12(@swc/helpers@0.5.15))(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
terser-webpack-plugin: 5.3.10(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12))
watchpack: 2.4.2
webpack-sources: 3.2.3
transitivePeerDependencies:

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