feat(richtext-lexical): support escaping markdown characters (#11784)

Fixes https://github.com/payloadcms/payload/issues/10289 and
https://github.com/payloadcms/payload/issues/11772

This adds support for escaping markdown characters. For example,` \*` is
supposed to be imported as `*` and exported back to `\*`.

Equivalent PR in lexical repo:
https://github.com/facebook/lexical/pull/7353
This commit is contained in:
Alessio Gravili
2025-03-20 10:29:45 -06:00
committed by GitHub
parent 7d9d067faf
commit 43cdccdef0
5 changed files with 120 additions and 22 deletions

View File

@@ -205,6 +205,12 @@ function exportTextFormat(
// bring the whitespace back. So our returned string looks like this: " **foo** "
const frozenString = textContent.trim()
let output = frozenString
if (!node.hasFormat('code')) {
// Escape any markdown characters in the text content
output = output.replace(/([*_`~\\])/g, '\\$1')
}
// the opening tags to be added to the result
let openingTags = ''
// the closing tags to be added to the result

View File

@@ -29,7 +29,6 @@ import type {
Transformer,
} from './MarkdownTransformers.js'
import { IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI } from '../../../lexical/utils/environment.js'
import { importTextTransformers } from './importTextTransformers.js'
import { isEmptyParagraph, transformersByType } from './utils.js'
@@ -255,28 +254,26 @@ function createTextFormatTransformersIndex(
const tagRegExp = tag.replace(/([*^+])/g, '\\$1')
openTagsRegExp.push(tagRegExp)
if (IS_SAFARI || IS_IOS || IS_APPLE_WEBKIT) {
fullMatchRegExpByTag[tag] = new RegExp(
`(${tagRegExp})(?![${tagRegExp}\\s])(.*?[^${tagRegExp}\\s])${tagRegExp}(?!${tagRegExp})`,
)
} else {
// Single-char tag (e.g. "*"),
if (tag.length === 1) {
fullMatchRegExpByTag[tag] = new RegExp(
`(?<![\\\\${tagRegExp}])(${tagRegExp})((\\\\${tagRegExp})?.*?[^${tagRegExp}\\s](\\\\${tagRegExp})?)((?<!\\\\)|(?<=\\\\\\\\))(${tagRegExp})(?![\\\\${tagRegExp}])`,
)
} else {
// Multichar tags (e.g. "**")
fullMatchRegExpByTag[tag] = new RegExp(
`(?<!\\\\)(${tagRegExp})((\\\\${tagRegExp})?.*?[^\\s](\\\\${tagRegExp})?)((?<!\\\\)|(?<=\\\\\\\\))(${tagRegExp})(?!\\\\)`,
)
}
}
return {
// Reg exp to find open tag + content + close tag
fullMatchRegExpByTag,
// Reg exp to find opening tags
openTagsRegExp: new RegExp(
(IS_SAFARI || IS_IOS || IS_APPLE_WEBKIT ? '' : `${escapeRegExp}`) +
'(' +
openTagsRegExp.join('|') +
')',
'g',
),
// Regexp to locate *any* potential opening tag (longest first).
// eslint-disable-next-line regexp/no-useless-character-class, regexp/no-empty-capturing-group, regexp/no-empty-group
openTagsRegExp: new RegExp(`${escapeRegExp}(${openTagsRegExp.join('|')})`, 'g'),
transformersByTag,
}
}

View File

@@ -78,7 +78,6 @@ export function importTextTransformers(
textMatchTransformers,
)
}
return
} else if (foundTextMatch) {
const result = importFoundTextMatchTransformer(
textNode,
@@ -112,9 +111,9 @@ export function importTextTransformers(
textMatchTransformers,
)
}
return
} else {
// Done!
return
}
// Handle escape characters
const textContent = textNode.getTextContent()
const escapedText = textContent.replace(/\\([*_`~])/g, '$1')
textNode.setTextContent(escapedText)
}

View File

@@ -137,7 +137,7 @@ export const tableJson = {
format: 0,
mode: 'normal',
style: '',
text: ' ',
text: ' * ',
type: 'text',
version: 1,
},

View File

@@ -376,13 +376,13 @@ there4
input: `
| Option | Default route | Description |
| ----------------- | ----------------------- | ----------------------------------------------- |
| \`account\` | | The user's account page. |
| \`account\` \\* | | The user's account page. |
| \`createFirstUser\` | \`/create-first-user\` | The page to create the first user. |
`,
inputAfterConvertFromEditorJSON: `
| Option | Default route | Description |
|---|---|---|
| \`account\` | | The user's account page. |
| \`account\` \\* | | The user's account page. |
| \`createFirstUser\` | \`/create-first-user\` | The page to create the first user. |
`,
rootChildren: [tableJson],
@@ -400,6 +400,19 @@ there4
},
},
},
{
input: `
<Banner>
Escaped \\*
</Banner>
`,
blockNode: {
fields: {
blockType: 'Banner',
content: textToRichText('Escaped *'),
},
},
},
{
input: `\`inline code\``,
rootChildren: [
@@ -1286,6 +1299,89 @@ Some line [Start of link
},
},
},
{
input: `
<Banner>
Some line [Text **bold** \\*normal\\*](/some/link)
</Banner>
`,
blockNode: {
fields: {
blockType: 'Banner',
content: {
root: {
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Some line ',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Text ',
type: 'text',
version: 1,
},
{
detail: 0,
format: 1,
mode: 'normal',
style: '',
text: 'bold',
type: 'text',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' *normal*',
type: 'text',
version: 1,
},
],
fields: {
linkType: 'custom',
newTab: false,
url: '/some/link',
},
format: '',
indent: 0,
type: 'link',
version: 3,
},
],
direction: null,
format: '',
indent: 0,
textFormat: 0,
textStyle: '',
type: 'paragraph',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'root',
version: 1,
},
},
},
},
},
{
inputAfterConvertFromEditorJSON: `
<Banner>