diff --git a/.changeset/neat-ravens-lick.md b/.changeset/neat-ravens-lick.md new file mode 100644 index 0000000000..fa37555774 --- /dev/null +++ b/.changeset/neat-ravens-lick.md @@ -0,0 +1,5 @@ +--- +"@platejs/markdown": patch +--- + +Serialize new lines (\n) within a paragraph's text node diff --git a/apps/www/src/__tests__/package-integration/ai-chat-streaming/streamSerializeMd.slow.tsx b/apps/www/src/__tests__/package-integration/ai-chat-streaming/streamSerializeMd.slow.tsx index 6c2fa39e46..c347c1f896 100644 --- a/apps/www/src/__tests__/package-integration/ai-chat-streaming/streamSerializeMd.slow.tsx +++ b/apps/www/src/__tests__/package-integration/ai-chat-streaming/streamSerializeMd.slow.tsx @@ -20,7 +20,7 @@ describe('streamSerializeMd', () => { const output = streamSerializeMd(editor, { value: input }, chunk); - expect(output).toBe('chunk1\n '); + expect(output).toBe('chunk1\\\n '); }); it('preserves a trailing line break', () => { @@ -29,7 +29,7 @@ describe('streamSerializeMd', () => { const output = streamSerializeMd(editor, { value: input }, chunk); - expect(output).toBe('chunk1\n'); + expect(output).toBe('chunk1
\n'); }); it('drops an incomplete trailing block without a line break', () => { diff --git a/apps/www/src/__tests__/package-integration/markdown-rich/__snapshots__/serializeMd.spec.tsx.snap b/apps/www/src/__tests__/package-integration/markdown-rich/__snapshots__/serializeMd.spec.tsx.snap index eb3ab67402..095bc4f1c2 100644 --- a/apps/www/src/__tests__/package-integration/markdown-rich/__snapshots__/serializeMd.spec.tsx.snap +++ b/apps/www/src/__tests__/package-integration/markdown-rich/__snapshots__/serializeMd.spec.tsx.snap @@ -138,15 +138,12 @@ exports[`serializeMd fixtures serializes two \\n within a block quote as two new `; exports[`serializeMd serializes a trailing break in a paragraph as
1`] = ` -"Paragaph with a new Line -
+"Paragraph with a new Line
" `; exports[`serializeMd serializes paragraphs containing only a new line as
1`] = ` -" -
- +"

" diff --git a/apps/www/src/__tests__/package-integration/markdown-rich/serializeMd.spec.tsx b/apps/www/src/__tests__/package-integration/markdown-rich/serializeMd.spec.tsx index 34bd6a90e1..a6c355bd2f 100644 --- a/apps/www/src/__tests__/package-integration/markdown-rich/serializeMd.spec.tsx +++ b/apps/www/src/__tests__/package-integration/markdown-rich/serializeMd.spec.tsx @@ -213,7 +213,7 @@ describe('serializeMd', () => { const slateNodes = [ { children: [ - { text: 'Paragaph with two new Lines' }, + { text: 'Paragraph ending with two blank Lines' }, { text: '\n' }, { text: '\n' }, { text: '\n' }, @@ -222,8 +222,15 @@ describe('serializeMd', () => { }, ]; + /* Remark: The expected output below does not match expectations raised by the intermediate mdast. See test + * `Serializes a paragraph with three trailing line breaks as normal line breaks, except for the last one which becomes a
` + * in `defaultRules.spec.ts` that tests the intermediate mdast comming from the same input. I assume this is the + * result of the way remark-stringify handles multiple line breaks at the end of a block (remark-stringify is + * used in serializeMd). + * I would have expected: 'Paragraph ending with two blank Lines\\\n\\\n
\n' + */ expect(serializeMd(editor as any, { value: slateNodes })).toBe( - 'Paragaph with two new Lines\\\n\\ \n
\n' + 'Paragraph ending with two blank Lines\\\n\\
\n' ); }); }); @@ -231,7 +238,7 @@ describe('serializeMd', () => { it('serializes a trailing break in a paragraph as
', () => { const slateNodes = [ { - children: [{ text: 'Paragaph with a new Line' }, { text: '\n' }], + children: [{ text: 'Paragraph with a new Line' }, { text: '\n' }], type: 'p', }, ]; @@ -254,6 +261,24 @@ describe('serializeMd', () => { expect(serializeMd(editor as any, { value: slateNodes })).toMatchSnapshot(); }); + it('serializes new lines WITHIN a single text node to line breaks in Markdown', () => { + const slateNodes = [ + { + children: [ + { + text: 'Text followed by two empty lines\n\n\nFollowed by more text.', + }, + ], + type: 'p', + }, + ]; + + const result = serializeMd(editor as any, { value: slateNodes }); + expect(result).toEqual( + `Text followed by two empty lines\\\n\\\n\\\nFollowed by more text.\n` + ); + }); + it('serializes lists with spread correctly', () => { const listFragment = [ { diff --git a/packages/markdown/src/lib/rules/defaultRules.spec.ts b/packages/markdown/src/lib/rules/defaultRules.spec.ts new file mode 100644 index 0000000000..2f92a79f07 --- /dev/null +++ b/packages/markdown/src/lib/rules/defaultRules.spec.ts @@ -0,0 +1,124 @@ +import type { TElement } from 'platejs'; +import type { SerializeMdOptions } from '../serializer'; +import { defaultRules } from './defaultRules'; +import { createTestEditor } from '../__tests__/createTestEditor'; + +describe('defaultRules p:', () => { + it('Should serialize the last line break of a paragraph to a html
node', () => { + const mdast = defaultRules.p!.serialize!( + { + type: 'p', + children: [{ text: 'line1\n' }], + } as TElement, + { editor: createTestEditor(), rules: defaultRules } as SerializeMdOptions + ); + + expect(mdast).toEqual({ + type: 'paragraph', + children: [ + { type: 'text', value: 'line1' }, + { type: 'html', value: '
' }, + ], + }); + }); + + it('Should serialize ONLY the last line break of a paragraph to a html
node', () => { + const mdast = defaultRules.p!.serialize!( + { + type: 'p', + children: [{ text: 'line1\n\n' }], + } as TElement, + { editor: createTestEditor(), rules: defaultRules } as SerializeMdOptions + ); + + expect(mdast).toEqual({ + type: 'paragraph', + children: [ + { type: 'text', value: 'line1' }, + { type: 'break' }, + { type: 'html', value: '
' }, + ], + }); + }); + + it('Should serialize ONLY the last line break of a paragraph to a html
node, even with multiple text children', () => { + const mdast = defaultRules.p!.serialize!( + { + type: 'p', + children: [ + { text: 'line1\n' }, + { text: 'line2\n\n' }, + { text: 'line3\n' }, + ], + } as TElement, + { editor: createTestEditor(), rules: defaultRules } as SerializeMdOptions + ); + + expect(mdast).toEqual({ + type: 'paragraph', + children: [ + { type: 'text', value: 'line1' }, + { type: 'break' }, + { type: 'text', value: 'line2' }, + { type: 'break' }, + { type: 'break' }, + { type: 'text', value: 'line3' }, + { type: 'html', value: '
' }, + ], + }); + }); + + it('Should serialize line breaks in the middle of a paragraph as break nodes', () => { + const editor = createTestEditor(); + const mdast = defaultRules.p!.serialize!( + { + type: 'p', + children: [{ text: 'line1\nline2\n\nline3' }], + } as TElement, + { editor, rules: defaultRules } as SerializeMdOptions + ); + + expect(mdast).toEqual({ + type: 'paragraph', + children: [ + { type: 'text', value: 'line1' }, + { type: 'break' }, + { type: 'text', value: 'line2' }, + { type: 'break' }, + { type: 'break' }, + { type: 'text', value: 'line3' }, + ], + }); + }); + + it('Serializes a paragraph with three trailing line breaks as normal line breaks, except for the last one which becomes a
', () => { + /* See the test `serializes three trailing \n in a paragraph as a forced line break and
` in `serializeMd.spec.tsx`. + * This test verifies that the correct mdast is generated from the given slate nodes, while the test in `serializeMd.spec.tsx` + * verifies that the correct markdown is generated from the same slate nodes. The expected mdast in this test does not + * match the expected markdown in the other test, which is likely due to the way remark-stringify handles multiple line breaks + * at the end of a block. + */ + const mdast = defaultRules.p!.serialize!( + { + children: [ + { text: 'Paragraph ending with two blank Lines' }, + { text: '\n' }, + { text: '\n' }, + { text: '\n' }, + ], + type: 'p', + } as TElement, + { editor: createTestEditor(), rules: defaultRules } as SerializeMdOptions + ); + + expect(mdast).toEqual({ + type: 'paragraph', + children: [ + { type: 'text', value: 'Paragraph ending with two blank Lines' }, + { type: 'break' }, + { type: 'break' }, + { type: 'html', value: '
' }, + ], + }); + }); +}); diff --git a/packages/markdown/src/lib/rules/defaultRules.ts b/packages/markdown/src/lib/rules/defaultRules.ts index f00420acb3..74279be751 100644 --- a/packages/markdown/src/lib/rules/defaultRules.ts +++ b/packages/markdown/src/lib/rules/defaultRules.ts @@ -771,20 +771,52 @@ export const defaultRules: MdRules = { return elements.length === 1 ? elements[0] : elements; }, serialize: (node, options) => { - let enrichedChildren = node.children; - - enrichedChildren = enrichedChildren.map((child) => { + // a child may be split in multiple parts, therefore use flatMap + const enrichedChildren = node.children.flatMap((child) => { if (child.text === '\n') { - return { - type: 'break', - } as any; + return [ + { + type: 'break', + } as Descendant, + ]; } if (child.text === '' && options.preserveEmptyParagraphs !== false) { - return { ...child, text: '\u200B' }; + return [{ ...child, text: '\u200B' }]; } - return child; + //support linebreaks within a single text node + if ( + child.text && + typeof child.text === 'string' && + child.text.includes('\n') + ) { + const enrichedParts: Descendant[] = []; + const childParts = child.text.split('\n'); + childParts.forEach((part, index) => { + // handle hard line breaks on empty lines + if (part.length === 0) { + // ignore superfluous empty childPart at the end of the childParts array as a result of split('\n') + if (childParts.length !== index + 1) { + enrichedParts.push({ + type: 'break', + } as Descendant); + } + } else { + //handle lines with text that should be followed by a break + enrichedParts.push({ ...child, text: part }); + // do not add line break after the last text that ends this child node (read: the text node) + if (childParts.length !== index + 1) { + enrichedParts.push({ + type: 'break', + } as Descendant); + } + } + }); + return enrichedParts; + } + + return [child]; }); const convertedNodes = convertNodesSerialize( @@ -796,10 +828,10 @@ export const defaultRules: MdRules = { convertedNodes.length > 0 && enrichedChildren.at(-1)!.type === 'break' ) { - // if the last child of the paragraph is a line break add an additional one + // if the last child of the paragraph is a line break replace it by a html node with a
(why?) convertedNodes.at(-1)!.type = 'html'; // @ts-expect-error -- value is the right property here - convertedNodes.at(-1)!.value = '\n
'; + convertedNodes.at(-1)!.value = '
'; } return {