From 4bc06faa7a18cddd64d548ef5ef7421d8c69174e Mon Sep 17 00:00:00 2001 From: Marcella Maki Date: Wed, 13 May 2026 11:23:34 -0400 Subject: [PATCH] update how linebreaks are saved so they render properly in perseus --- .../TipTapEditor/utils/markdownSerializer.js | 17 +++++-- .../__tests__/markdownSerializer.spec.js | 49 ++++++++++++------- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js index 5892701658..6a0c28e247 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js @@ -57,14 +57,25 @@ export const createCustomMarkdownSerializer = editor => { switch (node.type.name) { case 'doc': { const parts = []; - // Process all children if (node.content && node.content.size > 0) { for (let i = 0; i < node.content.size; i++) { const child = node.content.content[i]; - if (child) parts.push(serializeNode(child, null, depth)); + if (child) + parts.push({ type: child.type?.name, md: serializeNode(child, null, depth) }); } } - return parts.join('\n\n'); + // Use a hard break ( \n) between consecutive plain paragraphs so Perseus + // renders them as
(inline-safe). All other block transitions keep \n\n + // so marked still parses lists, headings, etc. correctly. + return parts + .map((part, idx) => { + if (idx === 0) return part.md; + const prev = parts[idx - 1]; + const separator = + prev.type === 'paragraph' && part.type === 'paragraph' ? ' \n' : '\n\n'; + return separator + part.md; + }) + .join(''); } case 'paragraph': { diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdownSerializer.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdownSerializer.spec.js index ed2235b988..15317c543d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdownSerializer.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdownSerializer.spec.js @@ -1,20 +1,17 @@ // Import the function we are testing import { createCustomMarkdownSerializer } from '../TipTapEditor/utils/markdownSerializer'; -// Mock the IMAGE_PLACEHOLDER to match what our tests expect -jest.mock('../TipTapEditor/utils/markdown', () => { - const originalModule = jest.requireActual('../TipTapEditor/utils/markdown'); - return { - ...originalModule, - IMAGE_PLACEHOLDER: '${CONTENTSTORAGE}', - paramsToImageMd: jest.fn(params => { - const alt = params.alt || ''; - const src = params.permanentSrc ? `\${CONTENTSTORAGE}/${params.permanentSrc}` : params.src; - const dimensions = params.width && params.height ? ` =${params.width}x${params.height}` : ''; - return `![${alt}](${src}${dimensions})`; - }), - }; -}); +// Mock the whole module to avoid pulling in the ESM `marked` library in Jest. +jest.mock('../TipTapEditor/utils/markdown', () => ({ + IMAGE_PLACEHOLDER: '${CONTENTSTORAGE}', + paramsToMathMd: ({ latex }) => `$$${latex || ''}$$`, + paramsToImageMd: jest.fn(params => { + const alt = params.alt || ''; + const src = params.permanentSrc ? `\${CONTENTSTORAGE}/${params.permanentSrc}` : params.src; + const dimensions = params.width && params.height ? ` =${params.width}x${params.height}` : ''; + return `![${alt}](${src}${dimensions})`; + }), +})); // mock editor object with a proper ProseMirror-like structure const createMockEditor = docContent => { @@ -177,14 +174,32 @@ describe('createCustomMarkdownSerializer', () => { expect(getMarkdown()).toBe('- Item 1\n - Nested 1.1\n- Item 2'); }); - it('should place two newlines between block elements', () => { + it('should use a hard break between consecutive paragraphs so Perseus renders them as
', () => { const docContent = [ { type: 'paragraph', content: [{ type: 'text', text: 'First paragraph.' }] }, { type: 'paragraph', content: [{ type: 'text', text: 'Second paragraph.' }] }, ]; const mockEditor = createMockEditor(docContent); const getMarkdown = createCustomMarkdownSerializer(mockEditor); - expect(getMarkdown()).toBe('First paragraph.\n\nSecond paragraph.'); + expect(getMarkdown()).toBe('First paragraph. \nSecond paragraph.'); + }); + + it('should use two newlines between a paragraph and a non-paragraph block', () => { + const docContent = [ + { type: 'paragraph', content: [{ type: 'text', text: 'Intro.' }] }, + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item A' }] }], + }, + ], + }, + ]; + const mockEditor = createMockEditor(docContent); + const getMarkdown = createCustomMarkdownSerializer(mockEditor); + expect(getMarkdown()).toBe('Intro.\n\n- Item A'); }); }); @@ -405,7 +420,7 @@ describe('createCustomMarkdownSerializer', () => { const mockEditor = createMockEditor(docContent); const getMarkdown = createCustomMarkdownSerializer(mockEditor); expect(getMarkdown()).toBe( - '

Centered

\n\nNormal\n\n

Right

', + '

Centered

\nNormal \n

Right

', ); });