From 69d11e831ec3cebaaa0167d0513a859aab297ffa Mon Sep 17 00:00:00 2001 From: Daniel Lautzenheiser Date: Sun, 24 Sep 2023 10:34:16 -0400 Subject: [PATCH] fix: blockquote newline (#894) --- packages/core/src/lib/util/string.util.ts | 7 +- .../nodes/blockquote/BlockquoteElement.css | 3 +- .../plugins/soft-break/softBreakPlugin.ts | 1 - .../plate/serialization/serializeMarkdown.ts | 90 +++++--- .../slate/deserializeMarkdown.ts | 68 +++++- .../tests-util/serializationTests.util.tsx | 210 ++++++++++++++++-- 6 files changed, 317 insertions(+), 62 deletions(-) diff --git a/packages/core/src/lib/util/string.util.ts b/packages/core/src/lib/util/string.util.ts index 93903147..3096d6d0 100644 --- a/packages/core/src/lib/util/string.util.ts +++ b/packages/core/src/lib/util/string.util.ts @@ -1,7 +1,10 @@ import { isNotNullish, isNullish } from './null.util'; -export function isEmpty(value: string | null | undefined): value is null | undefined { - return isNullish(value) || value === ''; +export function isEmpty( + value: string | null | undefined, + ignoreWhitespace?: boolean, +): value is null | undefined { + return isNullish(value) || (ignoreWhitespace ? value.trim() === '' : value === ''); } export function isNotEmpty(value: string | null | undefined): value is string { diff --git a/packages/core/src/widgets/markdown/plate/components/nodes/blockquote/BlockquoteElement.css b/packages/core/src/widgets/markdown/plate/components/nodes/blockquote/BlockquoteElement.css index 9224557e..c5d30d58 100644 --- a/packages/core/src/widgets/markdown/plate/components/nodes/blockquote/BlockquoteElement.css +++ b/packages/core/src/widgets/markdown/plate/components/nodes/blockquote/BlockquoteElement.css @@ -2,5 +2,6 @@ @apply border-l-2 border-gray-400 ml-2 - pl-2; + pl-2 + my-2; } diff --git a/packages/core/src/widgets/markdown/plate/plugins/soft-break/softBreakPlugin.ts b/packages/core/src/widgets/markdown/plate/plugins/soft-break/softBreakPlugin.ts index 4e57f429..a669d07a 100644 --- a/packages/core/src/widgets/markdown/plate/plugins/soft-break/softBreakPlugin.ts +++ b/packages/core/src/widgets/markdown/plate/plugins/soft-break/softBreakPlugin.ts @@ -6,7 +6,6 @@ import type { MdPlatePlugin } from '@staticcms/markdown'; const softBreakPlugin: Partial> = { options: { rules: [ - { hotkey: 'shift+enter' }, { hotkey: 'enter', query: { diff --git a/packages/core/src/widgets/markdown/plate/serialization/serializeMarkdown.ts b/packages/core/src/widgets/markdown/plate/serialization/serializeMarkdown.ts index 376c3c27..5fc1cd1e 100644 --- a/packages/core/src/widgets/markdown/plate/serialization/serializeMarkdown.ts +++ b/packages/core/src/widgets/markdown/plate/serialization/serializeMarkdown.ts @@ -108,33 +108,60 @@ function serializeMarkdownNode( childrenHasLink = chunk.children.some(f => !isLeafNode(f) && f.type === NodeTypes.link); } - return serializeMarkdownNode( - { ...c, parentType: type }, - { - // 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 || isInTable) && - // if we have c.break, never ignore empty paragraph new line - !(c as MdBlockType).break, + return { + type: 'type' in c ? c.type : undefined, + response: serializeMarkdownNode( + { ...c, parentType: type }, + { + // 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 || isInTable) && + // if we have c.break, never ignore empty paragraph new line + !(c as MdBlockType).break, - // track depth of nested lists so we can add proper spacing - listDepth: selfIsList ? listDepth + 1 : listDepth, - isInTable: selfIsTable || isInTable, - isInCode: selfIsCode || isInCode, - blockquoteDepth: selfIsBlockquote ? blockquoteDepth + 1 : blockquoteDepth, - useMdx, - index: childIndex, - shortcodeConfigs, - }, - ); + // track depth of nested lists so we can add proper spacing + listDepth: selfIsList ? listDepth + 1 : listDepth, + isInTable: selfIsTable || isInTable, + isInCode: selfIsCode || isInCode, + blockquoteDepth: selfIsBlockquote ? blockquoteDepth + 1 : blockquoteDepth, + useMdx, + index: childIndex, + shortcodeConfigs, + }, + ), + }; }) - .join(separator); + .map(({ response, type }) => { + if (selfIsBlockquote) { + let serializedChild = response; + + if (listDepth === 0) { + serializedChild = serializedChild.replace( + /(? { + if (selfIsBlockquote) { + if (type === NodeTypes.block_quote) { + return index === 0 ? response : `${acc}${separator}\n${response}`; + } + } + + return index === 0 ? response : `${acc}${separator}${response}`; + }, ''); } // This is pretty fragile code, check the long comment where we iterate over children @@ -152,7 +179,7 @@ function serializeMarkdownNode( } if (children === '' && !VOID_ELEMENTS.find(k => NodeTypes[k] === type)) { - return; + return '\n'; } // Never allow decorating break tags with rich text formatting, @@ -237,10 +264,11 @@ function serializeMarkdownNode( return `###### ${handleInBlockNewline(children)}\n`; case NodeTypes.block_quote: - return `${selfIsBlockquote && blockquoteDepth > 0 ? '\n' : ''}> ${children - .replace(/^[\n]*|[\n]*$/gm, '') + return `> ${children + .replace(/[\n]+$/g, '') .split('\n') - .join('\n> ')}\n`; + .join('\n> ') + .replace(/\n>[ \t]*\n/g, '\n>\n')}${selfIsBlockquote && blockquoteDepth === 0 ? '\n' : ''}`; case NodeTypes.code_block: const codeBlock = chunk as MdCodeBlockElement; @@ -319,7 +347,9 @@ ${bodyRows.join('\n')}`; case NodeTypes.tableHeaderCell: case NodeTypes.tableCell: - return isEmpty(children) ? ' ' : children.replace(/\|/g, '\\|').replace(/\n/g, BREAK_TAG); + return isEmpty(children, true) + ? ' ' + : children.replace(/\|/g, '\\|').replace(/\n/g, BREAK_TAG); case NodeTypes.shortcode: const shortcodeNode = chunk as MdShortcodeElement; diff --git a/packages/core/src/widgets/markdown/plate/serialization/slate/deserializeMarkdown.ts b/packages/core/src/widgets/markdown/plate/serialization/slate/deserializeMarkdown.ts index 914bdbd1..e8daf140 100644 --- a/packages/core/src/widgets/markdown/plate/serialization/slate/deserializeMarkdown.ts +++ b/packages/core/src/widgets/markdown/plate/serialization/slate/deserializeMarkdown.ts @@ -92,6 +92,8 @@ export interface Options { isInTable?: boolean; isInLink?: boolean; isInTableHeaderRow?: boolean; + isInBlockquote?: boolean; + isInList?: boolean; tableAlign?: (string | null)[]; useMdx: boolean; shortcodeConfigs: Record; @@ -105,6 +107,8 @@ export default function deserializeMarkdown(node: MdastNode, options: Options) { isInTable = false, isInLink = false, isInTableHeaderRow = false, + isInBlockquote = false, + isInList = false, tableAlign, useMdx, shortcodeConfigs, @@ -114,6 +118,8 @@ export default function deserializeMarkdown(node: MdastNode, options: Options) { const selfIsTable = node.type === 'table'; const selfIsLink = node.type === 'link'; const selfIsTableHeaderRow = node.type === 'tableRow' && index === 0; + const selfIsBlockquote = node.type === 'blockquote'; + const selfIsList = node.type === 'list'; const nodeChildren = node.children; if (nodeChildren && Array.isArray(nodeChildren) && nodeChildren.length > 0) { @@ -128,6 +134,8 @@ export default function deserializeMarkdown(node: MdastNode, options: Options) { isInTable: selfIsTable || isInTable, isInLink: selfIsLink || isInLink, isInTableHeaderRow: selfIsTableHeaderRow || isInTableHeaderRow, + isInBlockquote: selfIsBlockquote || isInBlockquote, + isInList: selfIsList || isInList, useMdx, shortcodeConfigs, index: childIndex, @@ -181,7 +189,25 @@ export default function deserializeMarkdown(node: MdastNode, options: Options) { } as ListItemNode; case 'paragraph': - if ('ordered' in node) { + if (isInBlockquote || isInList) { + if (isInBlockquote && index > 0) { + if (children.length > 0) { + let firstChild = children[0]; + if ('text' in firstChild) { + firstChild = { text: `\n\n${firstChild.text}` }; + } + + if (children.length > 1) { + const [_, ...rest] = children; + return [firstChild, ...rest]; + } + + return [firstChild]; + } + + return children; + } + return children; } @@ -212,7 +238,20 @@ export default function deserializeMarkdown(node: MdastNode, options: Options) { } as ImageNode; case 'blockquote': - return { type: NodeTypes.block_quote, children } as BlockQuoteNode; + const blockquoteChildren = children.reduce((acc, n) => { + const lastNode = acc.length > 0 ? acc[acc.length - 1] : null; + if (lastNode && 'text' in lastNode && lastNode.text && 'text' in n && n.text) { + acc[acc.length - 1] = { + text: `${lastNode.text}${n.text}`, + }; + } else { + acc.push(n); + } + + return acc; + }, [] as DeserializedNode[]); + + return { type: NodeTypes.block_quote, children: blockquoteChildren } as BlockQuoteNode; case 'code': return { @@ -230,7 +269,7 @@ export default function deserializeMarkdown(node: MdastNode, options: Options) { children: [{ text: node.value?.replace(/
/g, '') || '' }], } as ParagraphNode; } - return { type: 'p', children: [{ text: node.value || '' }] }; + return { type: 'p', children: [{ text: node.value ?? '' }] }; case 'emphasis': return { @@ -285,7 +324,7 @@ export default function deserializeMarkdown(node: MdastNode, options: Options) { } } - return { text: node.value || '' }; + return { text: node.value ?? '' }; case 'mdxJsxTextElement': if ('name' in node && node.type === 'mdxJsxTextElement') { @@ -344,26 +383,37 @@ export default function deserializeMarkdown(node: MdastNode, options: Options) { } } - return { text: node.value || '' }; + return { text: node.value ?? '' }; + + case 'break': + return { text: '\n' }; case 'text': if (useMdx) { - return { text: node.value || '' }; + return { text: (node.value ?? '').replace(/(? (node.type === 'text' ? { text: node.value ?? '' } : node)); + nodes = nodes.map(n => { + if (n.type !== 'text') { + return n; + } + + return { text: (n.value ?? '').replace(/(? I am a block quote\n> And another line', - slate: [ - { - type: ELEMENT_BLOCKQUOTE, - children: [ - { - text: 'I am a block quote\nAnd another line', - }, - ], - }, - ], - }, - 'nested blockquote': { markdown: '> I am a block quote\n> > And another line', slate: [ @@ -269,6 +254,116 @@ And a completely new paragraph`, }, ] as MdValue, }, + + 'multiline blockquote (double space and carrot each line)': { + markdown: '> One line \n> Another line', + slate: [ + { + type: ELEMENT_BLOCKQUOTE, + children: [ + { + text: 'One line\nAnother line', + }, + ], + }, + ] as MdValue, + }, + + 'multiline blockquote (empty line carrot)': { + markdown: '> One line\n>\n> Another line', + slate: [ + { + type: ELEMENT_BLOCKQUOTE, + children: [ + { + text: 'One line\n\nAnother line', + }, + ], + }, + ] as MdValue, + }, + + 'sequential blockquote': { + markdown: `> I am a block quote + +> And another block quote`, + slate: [ + { + type: ELEMENT_BLOCKQUOTE, + children: [ + { + text: 'I am a block quote', + }, + ], + }, + { + type: ELEMENT_BLOCKQUOTE, + children: [ + { + text: 'And another block quote', + }, + ], + }, + ] as MdValue, + }, + + 'blockquote with link': { + // First line has double space + markdown: `> I am a [block quote](https://example.com/). Another line +> +> Final Line`, + slate: [ + { + type: ELEMENT_BLOCKQUOTE, + children: [ + { + text: 'I am a ', + }, + { + type: ELEMENT_LINK, + url: 'https://example.com/', + children: [ + { + text: 'block quote', + }, + ], + }, + { + text: '. Another line\n\nFinal Line', + }, + ], + }, + ], + }, + + 'blockquote with link (no punctuation)': { + // First line has double space + markdown: `> I am a [block quote](https://example.com/) and another line +> +> Final Line`, + slate: [ + { + type: ELEMENT_BLOCKQUOTE, + children: [ + { + text: 'I am a ', + }, + { + type: ELEMENT_LINK, + url: 'https://example.com/', + children: [ + { + text: 'block quote', + }, + ], + }, + { + text: ' and another line\n\nFinal Line', + }, + ], + }, + ], + }, }, }, @@ -1543,8 +1638,7 @@ label: 'Blog post content', widget: 'markdown', \`\`\` -> See the table below for default options -> More API information can be found in the document +> See the table below for default options \n> More API information can be found in the document |Name|Type|Default|Description| |---|---|---|---| @@ -2683,8 +2777,86 @@ Text ahead [youtube|p6h-rYSVX90] and behind and another {{< twitter 917359331535 }; export const deserializationOnlyTestData: SerializationTests = { + blcokquote: { + both: { + 'blockquote with link': { + // First line has double space + markdown: `> I am a [block quote](https://example.com/). +> Another line +> +> Final Line`, + slate: [ + { + type: ELEMENT_BLOCKQUOTE, + children: [ + { + text: 'I am a ', + }, + { + type: ELEMENT_LINK, + url: 'https://example.com/', + children: [ + { + text: 'block quote', + }, + ], + }, + { + text: '. Another line\n\nFinal Line', + }, + ], + }, + ], + }, + + 'multiline blockquote (carrot each line)': { + markdown: `> One line +> Another line`, + slate: [ + { + type: ELEMENT_BLOCKQUOTE, + children: [ + { + text: 'One line Another line', + }, + ], + }, + ] as MdValue, + }, + + 'multiline blockquote (double space)': { + markdown: '> One line \nAnother line', + slate: [ + { + type: ELEMENT_BLOCKQUOTE, + children: [ + { + text: 'One line\nAnother line', + }, + ], + }, + ] as MdValue, + }, + }, + }, + paragraph: { markdown: { + 'paragraph with line break': { + markdown: `A line of text +With another in the same paragraph`, + slate: [ + { + type: ELEMENT_PARAGRAPH, + children: [ + { + text: 'A line of text With another in the same paragraph', + }, + ], + }, + ], + }, + 'paragraph with link': { markdown: 'A line of text with a link https://www.youtube.com/watch?v=p6h-rYSVX90 and some more text',