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
58 changes: 58 additions & 0 deletions packages/editor/src/core/serializer/compose-react-email.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,64 @@ describe('StarterKit node wrappers', () => {
});
});

describe('Trailing empty paragraph stripping', () => {
it('strips trailing empty paragraph from container ending with button', async () => {
const content: JSONContent = {
type: 'doc',
content: [
{
type: 'container',
content: [
{
type: 'button',
attrs: { href: 'https://example.com' },
content: [{ type: 'text', text: 'Click me' }],
},
{ type: 'paragraph' },
],
},
],
};

const editor = createEditorWithContent(content);
const result = await composeReactEmail({ editor, preview: '' });

const buttonIndex = result.html.indexOf('Click me');
const lastParagraph = result.html.lastIndexOf('<p');

expect(buttonIndex).toBeGreaterThan(-1);
expect(lastParagraph).toBeLessThan(buttonIndex);
});

it('preserves trailing paragraph with content in container', async () => {
const content: JSONContent = {
type: 'doc',
content: [
{
type: 'container',
content: [
{
type: 'button',
attrs: { href: 'https://example.com' },
content: [{ type: 'text', text: 'Click me' }],
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Footer text' }],
},
],
},
],
};

const editor = createEditorWithContent(content);
const result = await composeReactEmail({ editor, preview: '' });

expect(result.html).toContain('Click me');
expect(result.html).toContain('Footer text');
});
});

describe('Button and image reset styles', () => {
it('should include display:inline-block on buttons with the basic theme', async () => {
const content = docWithGlobalContent(
Expand Down
3 changes: 2 additions & 1 deletion packages/editor/src/core/serializer/compose-react-email.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { pretty, render, toPlainText } from '@react-email/components';
import type { Editor, JSONContent } from '@tiptap/core';
import type { MarkType, Schema } from '@tiptap/pm/model';
import { inlineCssToJs } from '../../utils/styles';
import { stripTrailingEmptyParagraphs } from '../../utils/strip-trailing-empty-paragraphs';
import { DefaultBaseTemplate } from './default-base-template';
import { EmailMark } from './email-mark';
import { EmailNode } from './email-node';
Expand Down Expand Up @@ -45,7 +46,7 @@ export const composeReactEmail = async ({
editor: Editor;
preview?: string;
}): Promise<ComposeReactEmailResult> => {
const data = editor.getJSON();
const data = stripTrailingEmptyParagraphs(editor.getJSON());
const extensions = editor.extensionManager.extensions;

const serializerPlugin = extensions
Expand Down
180 changes: 180 additions & 0 deletions packages/editor/src/utils/strip-trailing-empty-paragraphs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import type { JSONContent } from '@tiptap/core';
import { describe, expect, it } from 'vitest';
import { stripTrailingEmptyParagraphs } from './strip-trailing-empty-paragraphs';

describe('stripTrailingEmptyParagraphs', () => {
it('removes trailing empty paragraph from container', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'container',
content: [
{
type: 'button',
attrs: { href: 'https://example.com' },
content: [{ type: 'text', text: 'Click' }],
},
{ type: 'paragraph' },
],
},
],
};

const result = stripTrailingEmptyParagraphs(input);
const container = result.content![0]!;
expect(container.content).toHaveLength(1);
expect(container.content![0]!.type).toBe('button');
});

it('removes trailing empty paragraph with empty content array', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'container',
content: [
{
type: 'heading',
content: [{ type: 'text', text: 'Title' }],
},
{ type: 'paragraph', content: [] },
],
},
],
};

const result = stripTrailingEmptyParagraphs(input);
const container = result.content![0]!;
expect(container.content).toHaveLength(1);
expect(container.content![0]!.type).toBe('heading');
});

it('preserves trailing paragraph with content', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'container',
content: [
{
type: 'button',
attrs: { href: 'https://example.com' },
content: [{ type: 'text', text: 'Click' }],
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Some text' }],
},
],
},
],
};

const result = stripTrailingEmptyParagraphs(input);
const container = result.content![0]!;
expect(container.content).toHaveLength(2);
expect(container.content![1]!.type).toBe('paragraph');
});

it('keeps single empty paragraph in container to avoid empty content', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'container',
content: [{ type: 'paragraph' }],
},
],
};

const result = stripTrailingEmptyParagraphs(input);
const container = result.content![0]!;
expect(container.content).toHaveLength(1);
expect(container.content![0]!.type).toBe('paragraph');
});

it('does not strip trailing paragraph from non-container nodes', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'section',
content: [
{
type: 'button',
attrs: { href: 'https://example.com' },
content: [{ type: 'text', text: 'Click' }],
},
{ type: 'paragraph' },
],
},
],
};

const result = stripTrailingEmptyParagraphs(input);
const section = result.content![0]!;
expect(section.content).toHaveLength(2);
});

it('handles nested containers', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'container',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hello' }],
},
{ type: 'paragraph' },
],
},
],
};

const result = stripTrailingEmptyParagraphs(input);
const container = result.content![0]!;
expect(container.content).toHaveLength(1);
expect(container.content![0]!.type).toBe('paragraph');
expect(container.content![0]!.content![0]!.text).toBe('Hello');
});

it('handles doc with no content', () => {
const input: JSONContent = { type: 'doc' };
const result = stripTrailingEmptyParagraphs(input);
expect(result).toEqual({ type: 'doc' });
});

it('handles doc with empty content array', () => {
const input: JSONContent = { type: 'doc', content: [] };
const result = stripTrailingEmptyParagraphs(input);
expect(result).toEqual({ type: 'doc', content: [] });
});

it('does not strip non-trailing empty paragraphs from container', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'container',
content: [
{ type: 'paragraph' },
{
type: 'button',
attrs: { href: 'https://example.com' },
content: [{ type: 'text', text: 'Click' }],
},
],
},
],
};

const result = stripTrailingEmptyParagraphs(input);
const container = result.content![0]!;
expect(container.content).toHaveLength(2);
expect(container.content![0]!.type).toBe('paragraph');
expect(container.content![1]!.type).toBe('button');
});
});
32 changes: 32 additions & 0 deletions packages/editor/src/utils/strip-trailing-empty-paragraphs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { JSONContent } from '@tiptap/core';

const isEmptyParagraph = (node: JSONContent): boolean =>
node.type === 'paragraph' && (!node.content || node.content.length === 0);

export const stripTrailingEmptyParagraphs = (
content: JSONContent,
): JSONContent => {
if (!content.content || content.content.length === 0) {
return content;
}

const processedChildren = content.content.map((child) =>
stripTrailingEmptyParagraphs(child),
);

if (content.type !== 'container') {
return { ...content, content: processedChildren };
}

const lastChild = processedChildren[processedChildren.length - 1];
if (!lastChild || !isEmptyParagraph(lastChild)) {
return { ...content, content: processedChildren };
}

const trimmed = processedChildren.slice(0, -1);

return {
...content,
content: trimmed.length > 0 ? trimmed : processedChildren,
};
};
Loading