From 33d53121a2bbc89d2cf22ca37dca0cc1d1494ca8 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Tue, 28 May 2024 23:28:26 -0400 Subject: [PATCH] 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> --- .../field/features/link/feature.client.tsx | 2 + .../src/field/features/link/feature.server.ts | 2 + .../features/link/markdownTransformer.ts | 53 +++++++ pnpm-lock.yaml | 19 +-- test/fields/collections/Lexical/index.ts | 145 ++++++++++++------ test/fields/lexical.int.spec.ts | 56 +++++++ test/fields/payload-types.ts | 27 ++-- test/package.json | 2 + 8 files changed, 234 insertions(+), 72 deletions(-) create mode 100644 packages/richtext-lexical/src/field/features/link/markdownTransformer.ts diff --git a/packages/richtext-lexical/src/field/features/link/feature.client.tsx b/packages/richtext-lexical/src/field/features/link/feature.client.tsx index 3cd1260d967..506eb0dac09 100644 --- a/packages/richtext-lexical/src/field/features/link/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/link/feature.client.tsx @@ -14,6 +14,7 @@ import { LinkIcon } from '../../lexical/ui/icons/Link/index.js' import { getSelectedNode } from '../../lexical/utils/getSelectedNode.js' import { createClientComponent } from '../createClientComponent.js' import { toolbarFeatureButtonsGroupWithItems } from '../shared/toolbar/featureButtonsGroup.js' +import { LinkMarkdownTransformer } from './markdownTransformer.js' import { AutoLinkNode } from './nodes/AutoLinkNode.js' import { $isLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from './nodes/LinkNode.js' import { AutoLinkPlugin } from './plugins/autoLink/index.js' @@ -84,6 +85,7 @@ const LinkFeatureClient: FeatureProviderProviderClient = (props) => clientFeatureProps: props, feature: () => ({ clientFeatureProps: props, + markdownTransformers: [LinkMarkdownTransformer], nodes: [LinkNode, AutoLinkNode], plugins: [ { diff --git a/packages/richtext-lexical/src/field/features/link/feature.server.ts b/packages/richtext-lexical/src/field/features/link/feature.server.ts index 85ae2c2f168..a5c86ee26ee 100644 --- a/packages/richtext-lexical/src/field/features/link/feature.server.ts +++ b/packages/richtext-lexical/src/field/features/link/feature.server.ts @@ -12,6 +12,7 @@ import { convertLexicalNodesToHTML } from '../converters/html/converter/index.js import { createNode } from '../typeUtilities.js' import { LinkFeatureClientComponent } from './feature.client.js' import { i18n } from './i18n.js' +import { LinkMarkdownTransformer } from './markdownTransformer.js' import { AutoLinkNode } from './nodes/AutoLinkNode.js' import { LinkNode } from './nodes/LinkNode.js' import { transformExtraFields } from './plugins/floatingLinkEditor/utilities.js' @@ -110,6 +111,7 @@ export const LinkFeature: FeatureProviderProviderServer { + if (!$isLinkNode(_node)) { + return null + } + const node: LinkNode = _node + const { url } = node.getFields() + const linkContent = `[${node.getTextContent()}](${url})` + const firstChild = node.getFirstChild() + // Add text styles only if link has single text node inside. If it's more + // then one we ignore it as markdown does not support nested styles for links + if (node.getChildrenSize() === 1 && $isTextNode(firstChild)) { + return exportFormat(firstChild, linkContent) + } else { + return linkContent + } + }, + importRegExp: /\[([^[]+)\]\(([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?\)/, + regExp: /\[([^[]+)\]\(([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?\)$/, + replace: (textNode, match) => { + const [, linkText, linkUrl] = match + const linkNode = $createLinkNode({ + fields: { + doc: null, + linkType: 'custom', + newTab: false, + url: linkUrl, + }, + }) + const linkTextNode = $createTextNode(linkText) + linkTextNode.setFormat(textNode.getFormat()) + linkNode.append(linkTextNode) + textNode.replace(linkNode) + }, + trigger: ')', +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c194de0695..dcd5f3cd202 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1677,6 +1677,12 @@ importers: test: devDependencies: + '@lexical/headless': + specifier: 0.15.0 + version: 0.15.0 + '@lexical/markdown': + specifier: 0.15.0 + version: 0.15.0 '@payloadcms/db-mongodb': specifier: workspace:* version: link:../packages/db-mongodb @@ -5585,7 +5591,6 @@ packages: '@lexical/selection': 0.15.0 '@lexical/utils': 0.15.0 lexical: 0.15.0 - dev: false /@lexical/code@0.15.0: resolution: {integrity: sha512-n185gjinGhz/M4BW1ayNPYAEgwW4T/NEFl2Wey/O+07W3zvh9k9ai7RjWd0c8Qzqc4DLlqvibvWPebWObQHA4w==} @@ -5593,7 +5598,6 @@ packages: '@lexical/utils': 0.15.0 lexical: 0.15.0 prismjs: 1.29.0 - dev: false /@lexical/devtools-core@0.15.0(react-dom@19.0.0-rc-f994737d14-20240522)(react@19.0.0-rc-f994737d14-20240522): resolution: {integrity: sha512-kK/IVEiQyqs2DsY4QRYFaFiKQMpaAukAl8PXmNeGTZ7cfFVsP29E4n0/pjY+oxmiRvxbO1s2i14q58nfuhj4VQ==} @@ -5636,7 +5640,6 @@ packages: resolution: {integrity: sha512-soLjCphUEHw+z2ulV9cOtisTWmGj6k7TU+O/6nzgn7E1FlvskrrykGhYFrXDsXqB1wJRaILHKlHxQSoNzf931A==} dependencies: lexical: 0.15.0 - dev: false /@lexical/history@0.15.0: resolution: {integrity: sha512-r+pzR2k/51AL6l8UfXeVe/GWPIeWY1kEOuKx9nsYB9tmAkTF66tTFz33DJIMWBVtAHWN7Dcdv0/yy6q8R6CAUQ==} @@ -5651,21 +5654,18 @@ packages: '@lexical/selection': 0.15.0 '@lexical/utils': 0.15.0 lexical: 0.15.0 - dev: false /@lexical/link@0.15.0: resolution: {integrity: sha512-KBV/zWk5FxqZGNcq3IKGBDCcS4t0uteU1osAIG+pefo4waTkOOgibxxEJDop2QR5wtjkYva3Qp0D8ZyJDMMMlw==} dependencies: '@lexical/utils': 0.15.0 lexical: 0.15.0 - dev: false /@lexical/list@0.15.0: resolution: {integrity: sha512-JuF4k7uo4rZFOSZGrmkxo1+sUrwTKNBhhJAiCgtM+6TO90jppxzCFNKur81yPzF1+g4GWLC9gbjzKb52QPb6cQ==} dependencies: '@lexical/utils': 0.15.0 lexical: 0.15.0 - dev: false /@lexical/mark@0.15.0: resolution: {integrity: sha512-cdePA98sOJRc4/HHqcOcPBFq4UDwzaFJOK1N1E6XUGcXH1GU8zHtV1ElTgmbsGkyjBRwhR+OqKm9eso1PBOUkg==} @@ -5684,7 +5684,6 @@ packages: '@lexical/text': 0.15.0 '@lexical/utils': 0.15.0 lexical: 0.15.0 - dev: false /@lexical/offset@0.15.0: resolution: {integrity: sha512-VO1f3m8+RRdRjuXMtCBhi1COVKRC2LhP8AFYxnFlvbV+Waz9R5xB9pqFFUe4RtyqyTLmOUj6+LtsUFhq+23voQ==} @@ -5746,26 +5745,22 @@ packages: '@lexical/selection': 0.15.0 '@lexical/utils': 0.15.0 lexical: 0.15.0 - dev: false /@lexical/selection@0.15.0: resolution: {integrity: sha512-S+AQC6eJiQYSa5zOPuecN85prCT0Bcb8miOdJaE17Zh+vgdUH5gk9I0tEBeG5T7tkSpq6lFiEqs2FZSfaHflbQ==} dependencies: lexical: 0.15.0 - dev: false /@lexical/table@0.15.0: resolution: {integrity: sha512-3IRBg8IoIHetqKozRQbJQ2aPyG0ziXZ+lc8TOIAGs6METW/wxntaV+rTNrODanKAgvk2iJTIyfFkYjsqS9+VFg==} dependencies: '@lexical/utils': 0.15.0 lexical: 0.15.0 - dev: false /@lexical/text@0.15.0: resolution: {integrity: sha512-WsAkAt9T1RH1iDrVuWeoRUeMCOAWar5oSFtnQ4m9vhT/zuf5b8efK87GiqCH00ZAn4DGzOuAfyXlMFqBVCQdkQ==} dependencies: lexical: 0.15.0 - dev: false /@lexical/utils@0.15.0: resolution: {integrity: sha512-/6954LDmTcVFgexhy5WOZDa4TxNQOEZNrf8z7TRAFiAQkihcME/GRoq1en5cbXoVNF8jv5AvNyyc7x0MByRJ6A==} @@ -5774,7 +5769,6 @@ packages: '@lexical/selection': 0.15.0 '@lexical/table': 0.15.0 lexical: 0.15.0 - dev: false /@lexical/yjs@0.15.0(yjs@13.6.14): resolution: {integrity: sha512-Rf4AIu620Cq90li6GU58gkzlGRdntHP4ZeZrbJ3ToW7vEEnkW6Wl9/HhO647GG4OL5w46M0iWvx1b1b8xjYT1w==} @@ -15437,7 +15431,6 @@ packages: /prismjs@1.29.0: resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==} engines: {node: '>=6'} - dev: false /process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} diff --git a/test/fields/collections/Lexical/index.ts b/test/fields/collections/Lexical/index.ts index 27ba58dac00..aaedaf4c0ce 100644 --- a/test/fields/collections/Lexical/index.ts +++ b/test/fields/collections/Lexical/index.ts @@ -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 + }, + ], + }, + }, ], } diff --git a/test/fields/lexical.int.spec.ts b/test/fields/lexical.int.spec.ts index 1dd466a563d..4725e7ce323 100644 --- a/test/fields/lexical.int.spec.ts +++ b/test/fields/lexical.int.spec.ts @@ -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 = ( diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index bf3bd601e56..60783d68dd4 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -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 {} -} +} \ No newline at end of file diff --git a/test/package.json b/test/package.json index 82db58e7d6d..03d073d40ab 100644 --- a/test/package.json +++ b/test/package.json @@ -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:*",