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 {