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:
@@ -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<ClientProps> = (props) =>
|
||||
clientFeatureProps: props,
|
||||
feature: () => ({
|
||||
clientFeatureProps: props,
|
||||
markdownTransformers: [LinkMarkdownTransformer],
|
||||
nodes: [LinkNode, AutoLinkNode],
|
||||
plugins: [
|
||||
{
|
||||
|
||||
@@ -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<LinkFeatureServerProps,
|
||||
return schemaMap
|
||||
},
|
||||
i18n,
|
||||
markdownTransformers: [LinkMarkdownTransformer],
|
||||
nodes: [
|
||||
createNode({
|
||||
converters: {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Code taken from https://github.com/facebook/lexical/blob/main/packages/lexical-markdown/src/MarkdownTransformers.ts#L357
|
||||
*/
|
||||
|
||||
// Order of text transformers matters:
|
||||
//
|
||||
// - code should go first as it prevents any transformations inside
|
||||
|
||||
import type { TextMatchTransformer } from '@lexical/markdown'
|
||||
|
||||
import { $createTextNode, $isTextNode } from 'lexical'
|
||||
|
||||
import { $createLinkNode, $isLinkNode, LinkNode } from './nodes/LinkNode.js'
|
||||
|
||||
// - then longer tags match (e.g. ** or __ should go before * or _)
|
||||
export const LinkMarkdownTransformer: TextMatchTransformer = {
|
||||
type: 'text-match',
|
||||
dependencies: [LinkNode],
|
||||
export: (_node, exportChildren, exportFormat) => {
|
||||
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: ')',
|
||||
}
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -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==}
|
||||
|
||||
@@ -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