Skip to content

Commit 33d5312

Browse files
AlessioGrshawnvogt
andauthored
feat(richtext-lexical): link markdown transformers (#6543)
Closes #6507 --------- Co-authored-by: ShawnVogt <41651465+shawnvogt@users.noreply.github.com>
1 parent e0b201c commit 33d5312

File tree

8 files changed

+234
-72
lines changed

8 files changed

+234
-72
lines changed

packages/richtext-lexical/src/field/features/link/feature.client.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { LinkIcon } from '../../lexical/ui/icons/Link/index.js'
1414
import { getSelectedNode } from '../../lexical/utils/getSelectedNode.js'
1515
import { createClientComponent } from '../createClientComponent.js'
1616
import { toolbarFeatureButtonsGroupWithItems } from '../shared/toolbar/featureButtonsGroup.js'
17+
import { LinkMarkdownTransformer } from './markdownTransformer.js'
1718
import { AutoLinkNode } from './nodes/AutoLinkNode.js'
1819
import { $isLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from './nodes/LinkNode.js'
1920
import { AutoLinkPlugin } from './plugins/autoLink/index.js'
@@ -84,6 +85,7 @@ const LinkFeatureClient: FeatureProviderProviderClient<ClientProps> = (props) =>
8485
clientFeatureProps: props,
8586
feature: () => ({
8687
clientFeatureProps: props,
88+
markdownTransformers: [LinkMarkdownTransformer],
8789
nodes: [LinkNode, AutoLinkNode],
8890
plugins: [
8991
{

packages/richtext-lexical/src/field/features/link/feature.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { convertLexicalNodesToHTML } from '../converters/html/converter/index.js
1212
import { createNode } from '../typeUtilities.js'
1313
import { LinkFeatureClientComponent } from './feature.client.js'
1414
import { i18n } from './i18n.js'
15+
import { LinkMarkdownTransformer } from './markdownTransformer.js'
1516
import { AutoLinkNode } from './nodes/AutoLinkNode.js'
1617
import { LinkNode } from './nodes/LinkNode.js'
1718
import { transformExtraFields } from './plugins/floatingLinkEditor/utilities.js'
@@ -110,6 +111,7 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
110111
return schemaMap
111112
},
112113
i18n,
114+
markdownTransformers: [LinkMarkdownTransformer],
113115
nodes: [
114116
createNode({
115117
converters: {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Code taken from https://github.com/facebook/lexical/blob/main/packages/lexical-markdown/src/MarkdownTransformers.ts#L357
3+
*/
4+
5+
// Order of text transformers matters:
6+
//
7+
// - code should go first as it prevents any transformations inside
8+
9+
import type { TextMatchTransformer } from '@lexical/markdown'
10+
11+
import { $createTextNode, $isTextNode } from 'lexical'
12+
13+
import { $createLinkNode, $isLinkNode, LinkNode } from './nodes/LinkNode.js'
14+
15+
// - then longer tags match (e.g. ** or __ should go before * or _)
16+
export const LinkMarkdownTransformer: TextMatchTransformer = {
17+
type: 'text-match',
18+
dependencies: [LinkNode],
19+
export: (_node, exportChildren, exportFormat) => {
20+
if (!$isLinkNode(_node)) {
21+
return null
22+
}
23+
const node: LinkNode = _node
24+
const { url } = node.getFields()
25+
const linkContent = `[${node.getTextContent()}](${url})`
26+
const firstChild = node.getFirstChild()
27+
// Add text styles only if link has single text node inside. If it's more
28+
// then one we ignore it as markdown does not support nested styles for links
29+
if (node.getChildrenSize() === 1 && $isTextNode(firstChild)) {
30+
return exportFormat(firstChild, linkContent)
31+
} else {
32+
return linkContent
33+
}
34+
},
35+
importRegExp: /\[([^[]+)\]\(([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?\)/,
36+
regExp: /\[([^[]+)\]\(([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?\)$/,
37+
replace: (textNode, match) => {
38+
const [, linkText, linkUrl] = match
39+
const linkNode = $createLinkNode({
40+
fields: {
41+
doc: null,
42+
linkType: 'custom',
43+
newTab: false,
44+
url: linkUrl,
45+
},
46+
})
47+
const linkTextNode = $createTextNode(linkText)
48+
linkTextNode.setFormat(textNode.getFormat())
49+
linkNode.append(linkTextNode)
50+
textNode.replace(linkNode)
51+
},
52+
trigger: ')',
53+
}

pnpm-lock.yaml

Lines changed: 6 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 96 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1+
import type { ServerEditorConfig } from '@payloadcms/richtext-lexical'
2+
import type { SerializedEditorState } from 'lexical'
13
import type { CollectionConfig } from 'payload/types'
24

5+
import { createHeadlessEditor } from '@lexical/headless'
6+
import { $convertToMarkdownString } from '@lexical/markdown'
7+
import { getEnabledNodes } from '@payloadcms/richtext-lexical'
8+
import { sanitizeServerEditorConfig } from '@payloadcms/richtext-lexical'
39
import {
410
BlocksFeature,
511
FixedToolbarFeature,
612
HeadingFeature,
713
LinkFeature,
814
TreeViewFeature,
915
UploadFeature,
16+
defaultEditorFeatures,
1017
lexicalEditor,
1118
} from '@payloadcms/richtext-lexical'
1219

@@ -23,6 +30,58 @@ import {
2330
UploadAndRichTextBlock,
2431
} from './blocks.js'
2532

33+
const editorConfig: ServerEditorConfig = {
34+
features: [
35+
...defaultEditorFeatures,
36+
//TestRecorderFeature(),
37+
TreeViewFeature(),
38+
//HTMLConverterFeature(),
39+
FixedToolbarFeature(),
40+
LinkFeature({
41+
fields: ({ defaultFields }) => [
42+
...defaultFields,
43+
{
44+
name: 'rel',
45+
label: 'Rel Attribute',
46+
type: 'select',
47+
hasMany: true,
48+
options: ['noopener', 'noreferrer', 'nofollow'],
49+
admin: {
50+
description:
51+
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
52+
},
53+
},
54+
],
55+
}),
56+
UploadFeature({
57+
collections: {
58+
uploads: {
59+
fields: [
60+
{
61+
name: 'caption',
62+
type: 'richText',
63+
editor: lexicalEditor(),
64+
},
65+
],
66+
},
67+
},
68+
}),
69+
BlocksFeature({
70+
blocks: [
71+
RichTextBlock,
72+
TextBlock,
73+
UploadAndRichTextBlock,
74+
SelectFieldBlock,
75+
RelationshipBlock,
76+
RelationshipHasManyBlock,
77+
SubBlockBlock,
78+
RadioButtonsBlock,
79+
ConditionalLayoutBlock,
80+
],
81+
}),
82+
],
83+
}
84+
2685
export const LexicalFields: CollectionConfig = {
2786
slug: lexicalFieldsSlug,
2887
admin: {
@@ -70,56 +129,44 @@ export const LexicalFields: CollectionConfig = {
70129
admin: {
71130
hideGutter: false,
72131
},
73-
features: ({ defaultFeatures }) => [
74-
...defaultFeatures,
75-
//TestRecorderFeature(),
76-
TreeViewFeature(),
77-
//HTMLConverterFeature(),
78-
FixedToolbarFeature(),
79-
LinkFeature({
80-
fields: ({ defaultFields }) => [
81-
...defaultFields,
82-
{
83-
name: 'rel',
84-
label: 'Rel Attribute',
85-
type: 'select',
86-
hasMany: true,
87-
options: ['noopener', 'noreferrer', 'nofollow'],
88-
admin: {
89-
description:
90-
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
91-
},
92-
},
93-
],
94-
}),
95-
UploadFeature({
96-
collections: {
97-
uploads: {
98-
fields: [
99-
{
100-
name: 'caption',
101-
type: 'richText',
102-
editor: lexicalEditor(),
103-
},
104-
],
105-
},
106-
},
107-
}),
108-
BlocksFeature({
109-
blocks: [
110-
RichTextBlock,
111-
TextBlock,
112-
UploadAndRichTextBlock,
113-
SelectFieldBlock,
114-
RelationshipBlock,
115-
RelationshipHasManyBlock,
116-
SubBlockBlock,
117-
RadioButtonsBlock,
118-
ConditionalLayoutBlock,
119-
],
120-
}),
121-
],
132+
features: editorConfig.features,
122133
}),
123134
},
135+
{
136+
name: 'lexicalWithBlocks_markdown',
137+
type: 'textarea',
138+
hooks: {
139+
afterRead: [
140+
async ({ data, req, siblingData }) => {
141+
const yourSanitizedEditorConfig = await sanitizeServerEditorConfig(
142+
editorConfig,
143+
req.payload.config,
144+
)
145+
146+
const headlessEditor = createHeadlessEditor({
147+
nodes: getEnabledNodes({
148+
editorConfig: yourSanitizedEditorConfig,
149+
}),
150+
})
151+
152+
const yourEditorState: SerializedEditorState = siblingData.lexicalWithBlocks
153+
try {
154+
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState))
155+
} catch (e) {
156+
/* empty */
157+
}
158+
159+
// Export to markdown
160+
let markdown: string
161+
headlessEditor.getEditorState().read(() => {
162+
markdown = $convertToMarkdownString(
163+
yourSanitizedEditorConfig?.features?.markdownTransformers,
164+
)
165+
})
166+
return markdown
167+
},
168+
],
169+
},
170+
},
124171
],
125172
}

0 commit comments

Comments
 (0)