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:
@@ -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
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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:*",
|
||||
|
||||
Reference in New Issue
Block a user