From cd0426bee301f9d01bab4c2028d7c0d0b144a004 Mon Sep 17 00:00:00 2001 From: Dzmitry Tamashevich Date: Wed, 27 Mar 2024 18:44:26 +0300 Subject: [PATCH 1/3] introduce md serializer --- .../__snapshots__/serializeMd.spec.tsx.snap | 31 ++ packages/serializer-md/src/serializer/data.ts | 206 +++++++++++++ .../serializer-md/src/serializer/serialize.ts | 290 ++++++++++++++++++ .../src/serializer/serializeMd.spec.tsx | 114 +++++++ .../src/serializer/serializeMd.ts | 40 +++ .../serializer-md/src/serializer/types.ts | 90 ++++++ 6 files changed, 771 insertions(+) create mode 100644 packages/serializer-md/src/serializer/__snapshots__/serializeMd.spec.tsx.snap create mode 100644 packages/serializer-md/src/serializer/data.ts create mode 100644 packages/serializer-md/src/serializer/serialize.ts create mode 100644 packages/serializer-md/src/serializer/serializeMd.spec.tsx create mode 100644 packages/serializer-md/src/serializer/serializeMd.ts create mode 100644 packages/serializer-md/src/serializer/types.ts diff --git a/packages/serializer-md/src/serializer/__snapshots__/serializeMd.spec.tsx.snap b/packages/serializer-md/src/serializer/__snapshots__/serializeMd.spec.tsx.snap new file mode 100644 index 0000000000..6a4c0eeb8a --- /dev/null +++ b/packages/serializer-md/src/serializer/__snapshots__/serializeMd.spec.tsx.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`deserializeMd should serialize editor value 1`] = ` +"# Rich Text Editor +### Introduction + +Package contains a full-featured Rich Text Editor, based on open-source [slate.js](https://www.slatejs.org/) library. Slate.JS is a framework to build editors, and it's highly configurable with plugins. Here we picked and tuned dozen of plugins, build several plugins ourselves, added common styles and UX on top of it. One can pick from our default set of plugins, or even introduce new, app-specific plugins, on top. + +Unlikely to most Rich-Text editors, Slate uses JSON data model instead of HTML, which allows it to embed any entities, like arbitrary React components. For example, this checkbox, is a custom react component: +An item +We include HTML to Slate JSON converter, which is also used to convert pasted HTML. +## Out of the box components +### Basic layout + +We support inline text styles: **bold**, _italic_, underlined, text colors: red, yellow, and green. + +Numbered lists: + +1. In edit mode, we detect '1.' and start list automatically +1. You can use 'tab' / 'shift/tab' to indent the list + +Bullet lists: + +- Type '- ' to start the list +- You can create multi-level lists with 'tab' / 'shift+tab'. Example: + - Level 2 + - Level 3 + +There's also support 3 levels of headers, hyperlinks, superscript, and more. +" +`; diff --git a/packages/serializer-md/src/serializer/data.ts b/packages/serializer-md/src/serializer/data.ts new file mode 100644 index 0000000000..d7df85ef99 --- /dev/null +++ b/packages/serializer-md/src/serializer/data.ts @@ -0,0 +1,206 @@ +export const editorValueMock = [ + { + type: 'uui-richTextEditor-header-1', + children: [ + { + text: 'Rich Text Editor', + }, + ], + }, + { + type: 'uui-richTextEditor-header-3', + children: [ + { + text: 'Introduction', + }, + ], + }, + { + type: 'paragraph', + children: [ + { + text: 'Package contains a full-featured Rich Text Editor, based on open-source ', + }, + { + type: 'link', + url: 'https://www.slatejs.org/', + children: [ + { + text: 'slate.js', + }, + ], + }, + { + text: " library. Slate.JS is a framework to build editors, and it's highly configurable with plugins. Here we picked and tuned dozen of plugins, build several plugins ourselves, added common styles and UX on top of it. One can pick from our default set of plugins, or even introduce new, app-specific plugins, on top.", + }, + ], + }, + { + type: 'paragraph', + children: [ + { + text: 'Unlikely to most Rich-Text editors, Slate uses JSON data model instead of HTML, which allows it to embed any entities, like arbitrary React components. For example, this checkbox, is a custom react component:\nAn item\nWe include HTML to Slate JSON converter, which is also used to convert pasted HTML.', + }, + ], + }, + { + type: 'uui-richTextEditor-header-2', + children: [ + { + text: 'Out of the box components', + }, + ], + }, + { + type: 'uui-richTextEditor-header-3', + children: [ + { + text: 'Basic layout', + }, + ], + }, + { + type: 'paragraph', + children: [ + { + text: 'We support inline text styles: ', + }, + { + text: 'bold', + 'uui-richTextEditor-bold': true, + }, + { + text: ', ', + }, + { + text: 'italic', + 'uui-richTextEditor-italic': true, + }, + { + text: ', underlined, text colors: red, yellow, and green.', + }, + ], + }, + { + type: 'paragraph', + children: [ + { + text: 'Numbered lists:', + }, + ], + }, + { + type: 'ordered-list', + children: [ + { + type: 'list-item', + children: [ + { + type: 'list-item-child', + children: [ + { + text: "In edit mode, we detect '1.' and start list automatically", + }, + ], + }, + ], + }, + { + type: 'list-item', + children: [ + { + type: 'list-item-child', + children: [ + { + text: "You can use 'tab' / 'shift/tab' to indent the list", + }, + ], + }, + ], + }, + ], + }, + { + type: 'paragraph', + children: [ + { + text: 'Bullet lists:', + }, + ], + }, + { + type: 'unordered-list', + children: [ + { + type: 'list-item', + children: [ + { + type: 'list-item-child', + children: [ + { + text: "Type '- ' to start the list", + }, + ], + }, + ], + }, + { + type: 'list-item', + children: [ + { + type: 'list-item-child', + children: [ + { + text: "You can create multi-level lists with 'tab' / 'shift+tab'. Example:", + }, + ], + }, + { + type: 'unordered-list', + children: [ + { + type: 'list-item', + children: [ + { + type: 'list-item-child', + children: [ + { + text: 'Level 2', + }, + ], + }, + { + type: 'unordered-list', + children: [ + { + type: 'list-item', + children: [ + { + type: 'list-item-child', + children: [ + { + text: 'Level 3', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + type: 'paragraph', + children: [ + { + text: "There's also support 3 levels of headers, hyperlinks, superscript, and more.", + }, + ], + }, +]; diff --git a/packages/serializer-md/src/serializer/serialize.ts b/packages/serializer-md/src/serializer/serialize.ts new file mode 100644 index 0000000000..bcc0767a07 --- /dev/null +++ b/packages/serializer-md/src/serializer/serialize.ts @@ -0,0 +1,290 @@ +import { MARK_BOLD, MARK_CODE, MARK_ITALIC } from '@udecode/plate-basic-marks'; +import { getPluginType, PlateEditor, Value } from '@udecode/plate-common'; + +import { BlockType, LeafType, NodeTypes } from './types'; + +interface Options { + nodeTypes: NodeTypes; + listDepth?: number; + ignoreParagraphNewline?: boolean; +} + +const isLeafNode = (node: BlockType | LeafType): node is LeafType => { + return typeof (node as LeafType).text === 'string'; +}; + +const VOID_ELEMENTS: Array = ['thematic_break', 'image']; + +const BREAK_TAG = '
'; + +export function serialize( + editor: PlateEditor, + chunk: BlockType | LeafType, + opts: Options +) { + const { + nodeTypes: userNodeTypes, + ignoreParagraphNewline = false, + listDepth = 0, + } = opts; + + const text = (chunk as LeafType).text || ''; + let type = (chunk as BlockType).type || ''; + + const nodeTypes: NodeTypes = { + ...userNodeTypes, + heading: { + ...userNodeTypes.heading, + }, + }; + + const LIST_TYPES = [nodeTypes.ul_list, nodeTypes.ol_list]; + + let children = text; + + if (!isLeafNode(chunk)) { + children = chunk.children + .map((c: BlockType | LeafType, index, all) => { + const isList = isLeafNode(c) + ? false + : (LIST_TYPES as string[]).includes(c.type || ''); + + const selfIsList = (LIST_TYPES as string[]).includes(chunk.type || ''); + + // Links can have the following shape + // In which case we don't want to surround + // with break tags + // { + // type: 'paragraph', + // children: [ + // { text: '' }, + // { type: 'link', children: [{ text: foo.com }]} + // { text: '' } + // ] + // } + let childrenHasLink = false; + + if (!isLeafNode(chunk) && Array.isArray(chunk.children)) { + childrenHasLink = chunk.children.some( + (f) => !isLeafNode(f) && f.type === nodeTypes.link + ); + } + + const listProps = + isList || selfIsList + ? { + index, + length: all.length, + } + : {}; + + return serialize( + editor, + { + ...c, + parent: { + type, + ...listProps, + }, + }, + { + nodeTypes, + // WOAH. + // what we're doing here is pretty tricky, it relates to the block below where + // we check for ignoreParagraphNewline and set type to paragraph. + // We want to strip out empty paragraphs sometimes, but other times we don't. + // If we're the descendant of a list, we know we don't want a bunch + // of whitespace. If we're parallel to a link we also don't want + // to respect neighboring paragraphs + ignoreParagraphNewline: + (ignoreParagraphNewline || + isList || + selfIsList || + childrenHasLink) && + // if we have c.break, never ignore empty paragraph new line + !(c as BlockType).break, + + // track depth of nested lists so we can add proper spacing + listDepth: (LIST_TYPES as string[]).includes( + (c as BlockType).type || '' + ) + ? listDepth + 1 + : listDepth, + } + ); + }) + .join(''); + } + + // This is pretty fragile code, check the long comment where we iterate over children + if ( + !ignoreParagraphNewline && + (text === '' || text === '\n') && + chunk.parent?.type === nodeTypes.paragraph + ) { + type = nodeTypes.paragraph; + children = BREAK_TAG; + } + + if (children === '' && !VOID_ELEMENTS.some((k) => nodeTypes[k] === type)) + return; + + // Never allow decorating break tags with rich text formatting, + // this can malform generated markdown + // Also ensure we're only ever applying text formatting to leaf node + // level chunks, otherwise we can end up in a situation where + // we try applying formatting like to a node like this: + // "Text foo bar **baz**" resulting in "**Text foo bar **baz****" + // which is invalid markup and can mess everything up + if (children !== BREAK_TAG && isLeafNode(chunk)) { + const markedChunk = chunk as any; + const boldMark = getPluginType(editor, MARK_BOLD); + const italicMark = getPluginType(editor, MARK_ITALIC); + const codeMark = getPluginType(editor, MARK_CODE); + + if ( + chunk.strikeThrough && + markedChunk[boldMark] && + markedChunk[italicMark] + ) { + children = retainWhitespaceAndFormat(children, '~~***'); + } else if (markedChunk[boldMark] && markedChunk[italicMark]) { + children = retainWhitespaceAndFormat(children, '***'); + } else { + if (markedChunk[boldMark]) { + children = retainWhitespaceAndFormat(children, '**'); + } + + if (markedChunk[italicMark]) { + children = retainWhitespaceAndFormat(children, '_'); + } + + if (chunk.strikeThrough) { + children = retainWhitespaceAndFormat(children, '~~'); + } + + if (markedChunk[codeMark]) { + children = retainWhitespaceAndFormat(children, '`'); + } + } + } + + switch (type) { + case nodeTypes.heading[1]: { + return `# ${children}\n`; + } + case nodeTypes.heading[2]: { + return `## ${children}\n`; + } + case nodeTypes.heading[3]: { + return `### ${children}\n`; + } + case nodeTypes.heading[4]: { + return `#### ${children}\n`; + } + case nodeTypes.heading[5]: { + return `##### ${children}\n`; + } + case nodeTypes.heading[6]: { + return `###### ${children}\n`; + } + + case nodeTypes.block_quote: { + // For some reason, marked is parsing blockquotes w/ one new line + // as contiued blockquotes, so adding two new lines ensures that doesn't + // happen + return `> ${children}\n\n`; + } + + case nodeTypes.code_block: { + return `\`\`\`${ + (chunk as BlockType).language || '' + }\n${children}\n\`\`\`\n`; + } + + case nodeTypes.link: { + return `[${children}](${(chunk as BlockType).url || ''})`; + } + case nodeTypes.image: { + const caption = (chunk as BlockType).caption + ?.map((c: BlockType | LeafType) => serialize(editor, c, opts)) + .join('') as string; + + return `![${caption}](${(chunk as BlockType).url || ''})`; + } + + case nodeTypes.ul_list: + case nodeTypes.ol_list: { + const newLineAfter = listDepth === 0 ? '\n' : ''; + return `${children}${newLineAfter}`; + } + + case nodeTypes.listItem: { + const isOL = chunk && chunk.parent?.type === nodeTypes.ol_list; + + let spacer = ''; + for (let k = 0; listDepth > k; k++) { + // https://github.com/remarkjs/remark-react/issues/65 + spacer += isOL ? ' ' : ' '; + } + + const isNewLine = + chunk && + (chunk.parent?.type === nodeTypes.ol_list || + chunk.parent?.type === nodeTypes.ul_list); + const emptyBefore = isNewLine ? '\n' : ''; + + // const isLastItem = + // chunk.parent && + // chunk.parent.length! - 1 === chunk.parent.index && + // (chunk as BlockType).children.length === 1; + // const emptyAfter = isLastItem && listDepth === 0 ? '\n' : ''; + + return `${emptyBefore}${spacer}${isOL ? '1.' : '-'} ${children}`; + } + + case nodeTypes.paragraph: { + return `\n${children}\n`; + } + + case nodeTypes.thematic_break: { + return '\n---\n'; + } + + default: { + return children; + } + } +} + +// This function handles the case of a string like this: " foo " +// Where it would be invalid markdown to generate this: "** foo **" +// We instead, want to trim the whitespace out, apply formatting, and then +// bring the whitespace back. So our returned string looks like this: " **foo** " +function retainWhitespaceAndFormat(string: string, format: string) { + // we keep this for a comparison later + const frozenString = string.trim(); + + // children will be mutated + const children = frozenString; + + // We reverse the right side formatting, to properly handle bold/italic and strikeThrough + // formats, so we can create ~~***FooBar***~~ + const fullFormat = `${format}${children}${reverseStr(format)}`; + + // This conditions accounts for no whitespace in our string + // if we don't have any, we can return early. + if (children.length === string.length) { + return fullFormat; + } + + // if we do have whitespace, let's add our formatting around our trimmed string + // We reverse the right side formatting, to properly handle bold/italic and strikeThrough + // formats, so we can create ~~***FooBar***~~ + const formattedString = format + children + reverseStr(format); + + // and replace the non-whitespace content of the string + return string.replace(frozenString, formattedString); +} + +const reverseStr = (string: string) => string.split('').reverse().join(''); diff --git a/packages/serializer-md/src/serializer/serializeMd.spec.tsx b/packages/serializer-md/src/serializer/serializeMd.spec.tsx new file mode 100644 index 0000000000..5093bdbc98 --- /dev/null +++ b/packages/serializer-md/src/serializer/serializeMd.spec.tsx @@ -0,0 +1,114 @@ +/** @jsx jsx */ + +import { + createBoldPlugin, + createItalicPlugin, + MARK_BOLD, + MARK_ITALIC, +} from '@udecode/plate-basic-marks'; +import { createPlateEditor } from '@udecode/plate-common'; +import { + createHeadingPlugin, + ELEMENT_H1, + ELEMENT_H2, + ELEMENT_H3, + ELEMENT_H4, + ELEMENT_H5, + ELEMENT_H6, +} from '@udecode/plate-heading'; +import { createLinkPlugin, ELEMENT_LINK } from '@udecode/plate-link'; +import { + createListPlugin, + ELEMENT_LI, + ELEMENT_LIC, + ELEMENT_OL, + ELEMENT_UL, +} from '@udecode/plate-list'; +import { + createParagraphPlugin, + ELEMENT_PARAGRAPH, +} from '@udecode/plate-paragraph'; +import { jsx } from '@udecode/plate-test-utils'; + +import { editorValueMock } from './data'; +import { serializeMd } from './serializeMd'; + +jsx; + +describe('deserializeMd', () => { + const editor = createPlateEditor({ + plugins: [ + createHeadingPlugin({ + overrideByKey: { + [ELEMENT_H1]: { + type: 'uui-richTextEditor-header-1', + }, + [ELEMENT_H2]: { + type: 'uui-richTextEditor-header-2', + }, + [ELEMENT_H3]: { + type: 'uui-richTextEditor-header-3', + }, + [ELEMENT_H4]: { + type: 'uui-richTextEditor-header-4', + }, + [ELEMENT_H5]: { + type: 'uui-richTextEditor-header-5', + }, + [ELEMENT_H6]: { + type: 'uui-richTextEditor-header-6', + }, + }, + }), + createBoldPlugin({ + overrideByKey: { + [MARK_BOLD]: { + type: 'uui-richTextEditor-bold', + }, + }, + }), + createItalicPlugin({ + overrideByKey: { + [MARK_ITALIC]: { + type: 'uui-richTextEditor-italic', + }, + }, + }), + createParagraphPlugin({ + overrideByKey: { + [ELEMENT_PARAGRAPH]: { + type: 'paragraph', + }, + }, + }), + createLinkPlugin({ + overrideByKey: { + [ELEMENT_LINK]: { + type: 'link', + }, + }, + }), + createListPlugin({ + overrideByKey: { + [ELEMENT_UL]: { + type: 'unordered-list', + }, + [ELEMENT_OL]: { + type: 'ordered-list', + }, + [ELEMENT_LI]: { + type: 'list-item', + }, + [ELEMENT_LIC]: { + type: 'list-item-child', + }, + }, + }), + ], + }); + + it('should serialize editor value', () => { + const serializedMd = serializeMd(editor, { nodes: editorValueMock }); + expect(serializedMd).toMatchSnapshot(); + }); +}); diff --git a/packages/serializer-md/src/serializer/serializeMd.ts b/packages/serializer-md/src/serializer/serializeMd.ts new file mode 100644 index 0000000000..a15e3c604c --- /dev/null +++ b/packages/serializer-md/src/serializer/serializeMd.ts @@ -0,0 +1,40 @@ +import { getPluginType, PlateEditor, Value } from '@udecode/plate-common'; +import { ELEMENT_PARAGRAPH } from '@udecode/plate-paragraph'; + +import { serialize } from './serialize'; +import { getRemarkNodeTypesMap } from './types'; + +const isEditorValueEmpty = ( + editor: PlateEditor, + value: Value +) => { + return ( + !value || + value.length === 0 || + (value.length === 1 && + value[0].type === getPluginType(editor, ELEMENT_PARAGRAPH) && + value[0].children[0].text === '') + ); +}; + +export const serializeMd = ( + editor: PlateEditor, + { + nodes, + }: { + /** + * Slate nodes to convert to HTML. + */ + nodes: Value; + } +) => { + if (isEditorValueEmpty(editor, nodes)) { + return ''; + } + + return nodes + ?.map((v) => + serialize(editor, v, { nodeTypes: getRemarkNodeTypesMap(editor) }) + ) + .join(''); +}; diff --git a/packages/serializer-md/src/serializer/types.ts b/packages/serializer-md/src/serializer/types.ts new file mode 100644 index 0000000000..61e4784eb8 --- /dev/null +++ b/packages/serializer-md/src/serializer/types.ts @@ -0,0 +1,90 @@ +import { MARK_BOLD, MARK_ITALIC } from '@udecode/plate-basic-marks'; +import { getPluginType, PlateEditor, Value } from '@udecode/plate-common'; +import { + ELEMENT_H1, + ELEMENT_H2, + ELEMENT_H3, + ELEMENT_H4, + ELEMENT_H5, + ELEMENT_H6, +} from '@udecode/plate-heading'; +import { ELEMENT_LINK } from '@udecode/plate-link'; +import { ELEMENT_LI, ELEMENT_OL, ELEMENT_UL } from '@udecode/plate-list'; +import { ELEMENT_PARAGRAPH } from '@udecode/plate-paragraph'; + +export type NodeTypes = { + paragraph: string; + heading: { + 1: string; + 2: string; + 3: string; + 4: string; + 5: string; + 6: string; + }; + link: string; + ul_list: string; + ol_list: string; + listItem: string; + emphasis_mark: string; + strong_mark: string; +} & Partial<{ + block_quote: string; + inline_code_mark: string; + thematic_break: string; + image: string; + code_block: string; + delete_mark: string; +}>; + +export interface LeafType { + text: string; + strikeThrough?: boolean; + parent?: { + type: string; + index?: number; + length?: number; + }; +} + +export interface BlockType { + type: string; + parent?: { + type: string; + index?: number; + length?: number; + }; + url?: string; + caption?: Array; + language?: string; + break?: boolean; + children: Array; +} + +export const getRemarkNodeTypesMap = ( + editor: PlateEditor +): NodeTypes => { + return { + paragraph: getPluginType(editor, ELEMENT_PARAGRAPH), + link: getPluginType(editor, ELEMENT_LINK), + ul_list: getPluginType(editor, ELEMENT_UL), + ol_list: getPluginType(editor, ELEMENT_OL), + listItem: getPluginType(editor, ELEMENT_LI), + heading: { + 1: getPluginType(editor, ELEMENT_H1), + 2: getPluginType(editor, ELEMENT_H2), + 3: getPluginType(editor, ELEMENT_H3), + 4: getPluginType(editor, ELEMENT_H4), + 5: getPluginType(editor, ELEMENT_H5), + 6: getPluginType(editor, ELEMENT_H6), + }, + emphasis_mark: getPluginType(editor, MARK_ITALIC), + strong_mark: getPluginType(editor, MARK_BOLD), + // block_quote: QUOTE_PLUGIN_KEY, + // thematic_break: SEPARATOR_KEY, + // inline_code_mark: INLINE_CODE_KEY, + // image: IMAGE_PLUGIN_TYPE, + // code_block + // delete_mark + }; +}; From a118261d660b7c7b9b9fa8ef0dd381601ec1361f Mon Sep 17 00:00:00 2001 From: Dzmitry Tamashevich Date: Thu, 28 Mar 2024 13:12:01 +0300 Subject: [PATCH 2/3] apply comments --- .changeset/thick-steaks-roll.md | 5 +++++ apps/www/content/docs/serializing-md.mdx | 12 +++++++++++- packages/serializer-md/src/index.ts | 1 + packages/serializer-md/src/serializer/index.ts | 1 + 4 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 .changeset/thick-steaks-roll.md create mode 100644 packages/serializer-md/src/serializer/index.ts diff --git a/.changeset/thick-steaks-roll.md b/.changeset/thick-steaks-roll.md new file mode 100644 index 0000000000..eb22a5bd86 --- /dev/null +++ b/.changeset/thick-steaks-roll.md @@ -0,0 +1,5 @@ +--- +"@udecode/plate-serializer-md": minor +--- + +introduce serializeMd function diff --git a/apps/www/content/docs/serializing-md.mdx b/apps/www/content/docs/serializing-md.mdx index 15fb91dbd5..2c7fe7c072 100644 --- a/apps/www/content/docs/serializing-md.mdx +++ b/apps/www/content/docs/serializing-md.mdx @@ -38,7 +38,17 @@ const plugins = [ ### Slate -> Markdown -Use [remark-slate](https://github.com/hanford/remark-slate#slate-object-to-markdown). +Currently supported plugins: paragraph, link, list, heading, italic, bold and code. + +```tsx +import { serializeMd } from '@udecode/plate-serializer-md'; + +const plugins = [ + // ...supportedPlugins, +]; + +serializeMd(editor, { nodes }); +``` ## API diff --git a/packages/serializer-md/src/index.ts b/packages/serializer-md/src/index.ts index 16de185481..45c0434272 100644 --- a/packages/serializer-md/src/index.ts +++ b/packages/serializer-md/src/index.ts @@ -4,3 +4,4 @@ export * from './deserializer/index'; export * from './remark-slate/index'; +export * from './serializer/index'; diff --git a/packages/serializer-md/src/serializer/index.ts b/packages/serializer-md/src/serializer/index.ts new file mode 100644 index 0000000000..abd2e7b867 --- /dev/null +++ b/packages/serializer-md/src/serializer/index.ts @@ -0,0 +1 @@ +export * from './serializeMd'; From da10756ccb170a39619a6b0acf5f0af09ea67f68 Mon Sep 17 00:00:00 2001 From: Ziad Beyens Date: Thu, 28 Mar 2024 11:35:30 +0100 Subject: [PATCH 3/3] Update thick-steaks-roll.md --- .changeset/thick-steaks-roll.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/thick-steaks-roll.md b/.changeset/thick-steaks-roll.md index eb22a5bd86..20944b311f 100644 --- a/.changeset/thick-steaks-roll.md +++ b/.changeset/thick-steaks-roll.md @@ -2,4 +2,4 @@ "@udecode/plate-serializer-md": minor --- -introduce serializeMd function +Add `serializeMd`