Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/neat-ravens-lick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@platejs/markdown": patch
---

Serialize new lines (\n) within a paragraph's text node
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -29,7 +29,7 @@ describe('streamSerializeMd', () => {

const output = streamSerializeMd(editor, { value: input }, chunk);

expect(output).toBe('chunk1\n');
expect(output).toBe('chunk1<br />\n');
});

it('drops an incomplete trailing block without a line break', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <br /> 1`] = `
"Paragaph with a new Line
<br />
"Paragraph with a new Line<br />
"
`;

exports[`serializeMd serializes paragraphs containing only a new line as <br /> 1`] = `
"
<br />

"<br />

<br />
"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -222,16 +222,23 @@ 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 <br />`
* 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<br />\n'
*/
expect(serializeMd(editor as any, { value: slateNodes })).toBe(
'Paragaph with two new Lines\\\n\\ \n<br />\n'
'Paragraph ending with two blank Lines\\\n\\ <br />\n'
);
});
});

it('serializes a trailing break in a paragraph as <br />', () => {
const slateNodes = [
{
children: [{ text: 'Paragaph with a new Line' }, { text: '\n' }],
children: [{ text: 'Paragraph with a new Line' }, { text: '\n' }],
type: 'p',
},
];
Expand All @@ -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 = [
{
Expand Down
124 changes: 124 additions & 0 deletions packages/markdown/src/lib/rules/defaultRules.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <br />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: '<br />' },
],
});
});

it('Should serialize ONLY the last line break of a paragraph to a html <br />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: '<br />' },
],
});
});

it('Should serialize ONLY the last line break of a paragraph to a html <br />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: '<br />' },
],
});
});

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 <br />', () => {
/* See the test `serializes three trailing \n in a paragraph as a forced line break and <br />` 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: '<br />' },
],
});
});
});
52 changes: 42 additions & 10 deletions packages/markdown/src/lib/rules/defaultRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 <br /> (why?)
convertedNodes.at(-1)!.type = 'html';
// @ts-expect-error -- value is the right property here
convertedNodes.at(-1)!.value = '\n<br />';
convertedNodes.at(-1)!.value = '<br />';
}

return {
Expand Down