feat(richtext-lexical): link markdown transformers (#6543)

Closes https://github.com/payloadcms/payload/issues/6507

---------

Co-authored-by: ShawnVogt <41651465+shawnvogt@users.noreply.github.com>
This commit is contained in:
Alessio Gravili
2024-05-28 23:28:26 -04:00
committed by GitHub
parent e0b201c810
commit 33d53121a2
8 changed files with 234 additions and 72 deletions

View File

@@ -1,5 +1,11 @@
import type { ServerEditorConfig } from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from 'lexical'
import type { CollectionConfig } from 'payload/types'
import { createHeadlessEditor } from '@lexical/headless'
import { $convertToMarkdownString } from '@lexical/markdown'
import { getEnabledNodes } from '@payloadcms/richtext-lexical'
import { sanitizeServerEditorConfig } from '@payloadcms/richtext-lexical'
import {
BlocksFeature,
FixedToolbarFeature,
@@ -7,6 +13,7 @@ import {
LinkFeature,
TreeViewFeature,
UploadFeature,
defaultEditorFeatures,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
@@ -23,6 +30,58 @@ import {
UploadAndRichTextBlock,
} from './blocks.js'
const editorConfig: ServerEditorConfig = {
features: [
...defaultEditorFeatures,
//TestRecorderFeature(),
TreeViewFeature(),
//HTMLConverterFeature(),
FixedToolbarFeature(),
LinkFeature({
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'rel',
label: 'Rel Attribute',
type: 'select',
hasMany: true,
options: ['noopener', 'noreferrer', 'nofollow'],
admin: {
description:
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
},
},
],
}),
UploadFeature({
collections: {
uploads: {
fields: [
{
name: 'caption',
type: 'richText',
editor: lexicalEditor(),
},
],
},
},
}),
BlocksFeature({
blocks: [
RichTextBlock,
TextBlock,
UploadAndRichTextBlock,
SelectFieldBlock,
RelationshipBlock,
RelationshipHasManyBlock,
SubBlockBlock,
RadioButtonsBlock,
ConditionalLayoutBlock,
],
}),
],
}
export const LexicalFields: CollectionConfig = {
slug: lexicalFieldsSlug,
admin: {
@@ -70,56 +129,44 @@ export const LexicalFields: CollectionConfig = {
admin: {
hideGutter: false,
},
features: ({ defaultFeatures }) => [
...defaultFeatures,
//TestRecorderFeature(),
TreeViewFeature(),
//HTMLConverterFeature(),
FixedToolbarFeature(),
LinkFeature({
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'rel',
label: 'Rel Attribute',
type: 'select',
hasMany: true,
options: ['noopener', 'noreferrer', 'nofollow'],
admin: {
description:
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
},
},
],
}),
UploadFeature({
collections: {
uploads: {
fields: [
{
name: 'caption',
type: 'richText',
editor: lexicalEditor(),
},
],
},
},
}),
BlocksFeature({
blocks: [
RichTextBlock,
TextBlock,
UploadAndRichTextBlock,
SelectFieldBlock,
RelationshipBlock,
RelationshipHasManyBlock,
SubBlockBlock,
RadioButtonsBlock,
ConditionalLayoutBlock,
],
}),
],
features: editorConfig.features,
}),
},
{
name: 'lexicalWithBlocks_markdown',
type: 'textarea',
hooks: {
afterRead: [
async ({ data, req, siblingData }) => {
const yourSanitizedEditorConfig = await sanitizeServerEditorConfig(
editorConfig,
req.payload.config,
)
const headlessEditor = createHeadlessEditor({
nodes: getEnabledNodes({
editorConfig: yourSanitizedEditorConfig,
}),
})
const yourEditorState: SerializedEditorState = siblingData.lexicalWithBlocks
try {
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState))
} catch (e) {
/* empty */
}
// Export to markdown
let markdown: string
headlessEditor.getEditorState().read(() => {
markdown = $convertToMarkdownString(
yourSanitizedEditorConfig?.features?.markdownTransformers,
)
})
return markdown
},
],
},
},
],
}

View File

@@ -220,6 +220,62 @@ describe('Lexical', () => {
expect((uploadNode.value.media as any).filename).toStrictEqual('payload.png')
})
})
it('ensure link nodes convert to markdown', async () => {
const newLexicalDoc = await payload.create({
collection: lexicalFieldsSlug,
data: {
title: 'Lexical Markdown Test',
lexicalWithBlocks: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'link to payload',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'autolink',
version: 2,
fields: {
linkType: 'custom',
url: 'https://payloadcms.com',
},
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
],
direction: 'ltr',
},
},
},
})
expect(newLexicalDoc.lexicalWithBlocks_markdown).toEqual(
'[link to payload](https://payloadcms.com)',
)
})
describe('converters and migrations', () => {
it('htmlConverter: should output correct HTML for top-level lexical field', async () => {
const lexicalDoc: LexicalMigrateField = (

View File

@@ -100,6 +100,7 @@ export interface LexicalField {
};
[k: string]: unknown;
};
lexicalWithBlocks_markdown?: string | null;
updatedAt: string;
createdAt: string;
}
@@ -851,7 +852,7 @@ export interface GroupField {
nestedField?: string | null;
};
};
groups: {
groups?: {
groupInRow?: {
field?: string | null;
secondField?: string | null;
@@ -1207,16 +1208,16 @@ export interface TabsField {
}[]
| null;
};
namedTabWithDefaultValue: {
namedTabWithDefaultValue?: {
defaultValue?: string | null;
};
localizedTab: {
localizedTab?: {
text?: string | null;
};
accessControlTab: {
accessControlTab?: {
text?: string | null;
};
hooksTab: {
hooksTab?: {
beforeValidate?: boolean | null;
beforeChange?: boolean | null;
afterChange?: boolean | null;
@@ -1224,7 +1225,7 @@ export interface TabsField {
};
textarea?: string | null;
anotherText: string;
nestedTab: {
nestedTab?: {
text?: string | null;
};
updatedAt: string;
@@ -1262,6 +1263,8 @@ export interface Upload {
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
@@ -1280,6 +1283,8 @@ export interface Uploads2 {
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
@@ -1312,6 +1317,8 @@ export interface Uploads3 {
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
@@ -1363,7 +1370,7 @@ export interface PayloadMigration {
*/
export interface TabsWithRichText {
id: string;
tab1: {
tab1?: {
rt1?: {
root: {
type: string;
@@ -1380,7 +1387,7 @@ export interface TabsWithRichText {
[k: string]: unknown;
} | null;
};
tab2: {
tab2?: {
rt2?: {
root: {
type: string;
@@ -1413,6 +1420,6 @@ export interface LexicalBlocksRadioButtonsBlock {
declare module 'payload' {
// @ts-ignore
// @ts-ignore
export interface GeneratedTypes extends Config {}
}
}

View File

@@ -12,6 +12,8 @@
"typecheck": "pnpm turbo build --filter test && tsc --project tsconfig.typecheck.json"
},
"devDependencies": {
"@lexical/headless": "0.15.0",
"@lexical/markdown": "0.15.0",
"@payloadcms/db-mongodb": "workspace:*",
"@payloadcms/db-postgres": "workspace:*",
"@payloadcms/email-nodemailer": "workspace:*",