Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
41d86af
test(editor): add day-0 unit-test infrastructure
claude May 10, 2026
33b5b39
test(editor): add paste-sanitizer spec; reject unsafe URL schemes
claude May 10, 2026
d7df09f
fix(editor): treat empty headings as visually-empty for placeholder
claude May 10, 2026
1cd63a6
fix(editor): always sanitize text/html paste; remove childCount short…
claude May 10, 2026
ff56263
fix(editor): sanitize text/html drops; wire drop handler in EmailEditor
claude May 10, 2026
08b3527
fix(editor): harden image upload flow against destroy + dup-src races
claude May 10, 2026
20f9bcc
test(editor): add serializer round-trip corpus + fast-check property
claude May 10, 2026
f09a759
test(editor): add typed contract test for image-upload-error event
claude May 10, 2026
dc00ce0
test(editor): add EmailEditor controller spec
claude May 10, 2026
79b179a
test(editor): add inspector section specs (border/padding/size/typogr…
claude May 10, 2026
0321127
test(editor): cover the remaining 4 inspector sections
claude May 10, 2026
d1a8054
test(editor): add inspector breadcrumb spec
claude May 10, 2026
3fb852c
test(editor): cover slash command commands.tsx and utils.ts
claude May 10, 2026
db6e7a6
test(editor): snapshot 10 untested extensions through their React Ema…
claude May 10, 2026
263f498
test(editor): cover h-padding legacy branch and mergeCssJs
claude May 10, 2026
367a73e
test(editor): cover image file-handler ProseMirror plugin
claude May 10, 2026
0bfd284
test(editor): cover extend() identity for EmailMark/EmailNode
claude May 10, 2026
13c2b96
test(editor): enable v8 coverage gate on the seams the plan locked down
claude May 10, 2026
37094f6
chore(editor): drop now-redundant .gitkeep files
claude May 11, 2026
58495cf
chore(editor): strip Linear ticket references from test comments
claude May 11, 2026
09e1568
test(editor): expand fixture corpora with realistic email + paste sam…
claude May 11, 2026
87f3e83
revert(editor): restore original create-drop-handler implementation
claude May 11, 2026
cd1984b
fix(editor): lint cleanup + two review findings
claude May 11, 2026
8665d28
fix
danilowoz May 11, 2026
b9fec52
test(editor): address PR review feedback
claude May 13, 2026
2a95350
chore(editor): sync pnpm-lock.yaml after removing @vitest/coverage-v8
claude May 13, 2026
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
3 changes: 3 additions & 0 deletions packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"test": "vitest run",
"test:unit": "vitest run --project unit",
"test:browser": "vitest run --project browser",
"test:coverage": "vitest run --project unit --coverage",
"test:watch": "vitest"
},
"repository": {
Expand Down Expand Up @@ -145,6 +146,8 @@
"@types/prismjs": "1.26.6",
"@vitejs/plugin-react": "catalog:",
"@vitest/browser-playwright": "4.1.4",
"@vitest/coverage-v8": "4.1.4",
"fast-check": "3.23.2",
"playwright": "1.59.1",
"postcss": "8.5.10",
"postcss-import": "16.1.1",
Expand Down
56 changes: 56 additions & 0 deletions packages/editor/src/__tests__/arbitraries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { JSONContent } from '@tiptap/core';
import * as fc from 'fast-check';

/**
* Generators for `fast-check` property tests.
*
* Constrained intentionally to keep shrinking fast on ProseMirror trees:
* - shallow nesting (heading/paragraph/list only)
* - small text content
* - schema-valid by construction (so `fc.pre()` filters are rare)
*/

const safeText = fc
.string({ minLength: 1, maxLength: 30 })
.filter((s) => /^[a-zA-Z0-9 .,!?-]+$/.test(s));

const textNode = safeText.map<JSONContent>((text) => ({ type: 'text', text }));

const paragraphNode = fc
.array(textNode, { minLength: 1, maxLength: 3 })
.map<JSONContent>((content) => ({ type: 'paragraph', content }));

const headingNode = fc
.tuple(fc.integer({ min: 1, max: 3 }), safeText)
.map<JSONContent>(([level, text]) => ({
type: 'heading',
attrs: { level },
content: [{ type: 'text', text }],
}));

const listItemNode = paragraphNode.map<JSONContent>((p) => ({
type: 'listItem',
content: [p],
}));

const bulletListNode = fc
.array(listItemNode, { minLength: 1, maxLength: 3 })
.map<JSONContent>((content) => ({ type: 'bulletList', content }));

const blockNode: fc.Arbitrary<JSONContent> = fc.oneof(
paragraphNode,
headingNode,
bulletListNode,
);

/**
* Arbitrary doc with up to `maxBlocks` top-level block nodes.
*/
export function proseMirrorDocArbitrary(
options: { maxBlocks?: number } = {},
): fc.Arbitrary<JSONContent> {
const maxBlocks = options.maxBlocks ?? 5;
return fc
.array(blockNode, { minLength: 1, maxLength: maxBlocks })
.map((content) => ({ type: 'doc', content }));
}
51 changes: 51 additions & 0 deletions packages/editor/src/__tests__/editor-test-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
type AnyExtension,
Editor,
type EditorOptions,
type JSONContent,
} from '@tiptap/core';
import { StarterKit } from '../extensions';

interface CreateTestEditorOptions {
content?: EditorOptions['content'];
extensions?: AnyExtension[];
editorProps?: EditorOptions['editorProps'];
}

/**
* Creates a real tiptap `Editor` instance for unit tests.
* Mirrors the inline `createEditorWithContent` pattern from
* `core/serializer/compose-react-email.spec.tsx` so all specs share the
* same setup. Always remember to `editor.destroy()` in `afterEach`.
*/
export function createTestEditor(
options: CreateTestEditorOptions = {},
): Editor {
const editorOptions: EditorOptions = {
extensions: options.extensions ?? [StarterKit],
} as EditorOptions;
if (options.content !== undefined) {
(editorOptions as { content: EditorOptions['content'] }).content =
options.content;
}
if (options.editorProps !== undefined) {
(editorOptions as { editorProps: EditorOptions['editorProps'] }).editorProps =
options.editorProps;
}
return new Editor(editorOptions);
}

/**
* Convenience: returns the doc JSON for a single-paragraph string of `text`.
*/
export function paragraphDoc(text: string): JSONContent {
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: text ? [{ type: 'text', text }] : undefined,
},
],
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<blockquote>To be, or not to be.</blockquote>
<p>Inline <code>code()</code> mid-sentence.</p>
<pre><code>const x = 1;
const y = 2;
</code></pre>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<h1>Welcome</h1>
<h2>Subheader</h2>
<p>Intro paragraph.</p>
<ul>
<li>One</li>
<li>Two</li>
</ul>
<ol>
<li>First</li>
<li>Second</li>
</ol>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<p style="color: rgb(255, 0, 0); font-weight: 700;">Red bold paragraph.</p>
<h2 style="color: #1f3864;">Heading with color.</h2>
<p>Plain after.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<p>Visit <a href="https://example.com">our site</a> for more.</p>
<p><img src="https://example.com/logo.png" alt="Logo" width="200" height="50"></p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<p>Some <strong>bold</strong>, <em>italic</em>, <u>underline</u>, and <s>strike</s> text.</p>
<p>And <strong><em>both bold and italic</em></strong> together.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<table>
<tr>
<td>Cell A</td>
<td>Cell B</td>
</tr>
<tr>
<td colspan="2">Spans both</td>
</tr>
</table>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p>Hello world.</p>
24 changes: 24 additions & 0 deletions packages/editor/src/__tests__/fixtures/load-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

const FIXTURE_DIR = resolve(process.cwd(), 'src/__tests__/fixtures');
const cache = new Map<string, string>();

/**
* Reads a fixture file relative to `src/__tests__/fixtures/`.
* Cached so a single fixture file is read once per test process.
*
* Vitest runs from the package root, which is where `process.cwd()`
* points; that anchors the resolution.
*
* Example: `loadFixture('paste-sources/word.html')`
*/
export function loadFixture(relativePath: string): string {
const absolute = resolve(FIXTURE_DIR, relativePath);
let content = cache.get(absolute);
if (content === undefined) {
content = readFileSync(absolute, 'utf8');
cache.set(absolute, content);
}
return content;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"><div style="font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue',Helvetica,sans-serif;font-size:14px"><div>Hello,</div><div><br></div><div>Quoted text:</div><blockquote type="cite" style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex"><div>Original message text.</div></blockquote></div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div dir="ltr" style="font-family:Arial,sans-serif"><div>Hi team,</div><div><br></div><div>Please review the <a href="https://example.com/doc" target="_blank" rel="noopener" style="color:#1155cc">attached document</a>.</div><div><br></div><div>Thanks,<br>Sender</div></div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<meta charset="utf-8"><h2 style="font-weight:600;font-size:1.5em;color:rgb(55,53,47)">Project Plan</h2><p style="padding:3px 2px;color:rgb(55,53,47)"><strong>Phase 1:</strong> kickoff</p><ul style="padding-left:1.5em"><li>Research</li><li>Design</li></ul><p>See <a href="https://www.notion.so/page-id" style="color:rgb(46,170,220)">notion link</a>.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<html><head><title>x</title><style>.evil{display:none}</style></head><body><table border="1" cellpadding="2" cellspacing="0"><tr><td colspan="2">Spans two</td></tr><tr><td>A</td><td>B</td></tr></table><img src="https://example.com/x.png" alt="x" width="100" height="50" data-track="abc"></body></html>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div style="color:#d4d4d4;background-color:#1e1e1e;font-family:'Cascadia Code', 'Fira Code', monospace;font-size:14px;line-height:19px;white-space:pre"><div><span style="color:#c586c0">function</span> <span style="color:#dcdcaa">greet</span>(<span style="color:#9cdcfe">name</span>: <span style="color:#4ec9b0">string</span>) {</div><div> <span style="color:#c586c0">return</span> <span style="color:#ce9178">`Hello, ${</span><span style="color:#9cdcfe">name</span><span style="color:#ce9178">}`</span>;</div><div>}</div></div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<meta charset="utf-8"><meta name="ProgId" content="Word.Document"><div class="WordSection1" style="font-family:Calibri;font-size:11pt"><p class="MsoNormal" style="margin:0in 0in 8pt"><span style="font-size:11.0pt;font-family:&quot;Calibri&quot;,sans-serif;color:#1F3864">Hello from Word</span></p><p class="MsoNormal"><b><span style="color:#C00000">Important note</span></b> with <i>mixed</i> formatting.</p></div>
37 changes: 37 additions & 0 deletions packages/editor/src/__tests__/test-helpers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createTestEditor, paragraphDoc } from './editor-test-helpers';

vi.mock('@/actions/ai', () => ({
uploadImageViaAI: vi.fn(),
}));

describe('createTestEditor', () => {
let editor: ReturnType<typeof createTestEditor> | null = null;
afterEach(() => {
editor?.destroy();
editor = null;
});

it('creates an editor with default StarterKit and renders text', () => {
editor = createTestEditor({ content: paragraphDoc('hello') });
expect(editor.getHTML()).toContain('hello');
});

it('accepts custom content as JSON', () => {
editor = createTestEditor({
content: {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'World' }],
},
],
},
});
const html = editor.getHTML();
expect(html).toContain('<h2');
expect(html).toContain('World');
});
});
60 changes: 51 additions & 9 deletions packages/editor/src/core/create-drop-handler.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,68 @@
import type { Extensions } from '@tiptap/core';
import { generateJSON } from '@tiptap/html';
import type { EditorView } from '@tiptap/pm/view';
import { sanitizePastedHtml } from '../utils/paste-sanitizer';
import type { PasteHandler } from './create-paste-handler';

export function createDropHandler({ onPaste }: { onPaste?: PasteHandler }) {
interface CreateDropHandlerOptions {
onPaste?: PasteHandler;
/**
* Editor extensions; required to convert dropped HTML/text into
* ProseMirror JSON via `generateJSON`. When omitted, only file drops
* (the legacy behavior) are handled.
*/
extensions?: Extensions;
}

export function createDropHandler({
onPaste,
extensions,
}: CreateDropHandlerOptions) {
return (
view: EditorView,
event: DragEvent,
_slice: unknown,
moved: boolean,
): boolean => {
if (
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files[0]
) {
// Internal block reorders: let ProseMirror handle them.
if (moved) return false;

const dataTransfer = event.dataTransfer;
if (!dataTransfer) return false;

if (dataTransfer.files && dataTransfer.files[0]) {
event.preventDefault();
const file = event.dataTransfer.files[0];
const file = dataTransfer.files[0];

if (onPaste?.(file, view)) {
return true;
}
}
return false;

if (extensions === undefined) {
return false;
}

const html = dataTransfer.getData('text/html');
const text = dataTransfer.getData('text/plain');

if (!html && !text) return false;

event.preventDefault();
const source = html || `<p>${escapeHtml(text)}</p>`;
const sanitized = sanitizePastedHtml(source);
const json = generateJSON(sanitized, extensions);
const node = view.state.schema.nodeFromJSON(json);
view.dispatch(view.state.tr.replaceSelectionWith(node, false));
Comment thread
danilowoz marked this conversation as resolved.
Outdated
return true;
};
}

function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
8 changes: 4 additions & 4 deletions packages/editor/src/core/create-paste-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ export function createPasteHandler({
}
}

if (slice.content.childCount === 1) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous behavior short-circuited single-node slices (skipping sanitization), which let pasted HTML from external sources bypass the paste-sanitizer and corrupt the document. Always sanitize when text/html is present.

return false;
}

// The previous behavior short-circuited single-node slices (skipping
// sanitization), which let pasted HTML from external sources bypass
// the paste-sanitizer and corrupt the document. Always sanitize when
// text/html is present.
if (event.clipboardData?.getData?.('text/html')) {
event.preventDefault();
const html = event.clipboardData.getData('text/html');
Expand Down
20 changes: 20 additions & 0 deletions packages/editor/src/core/event-bus.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,24 @@ describe('EditorEventBus', () => {
sub.unsubscribe();
consoleError.mockRestore();
});

// Contract test for the public events. If a producer renames or removes
// one, this spec breaks at the type level instead of silently changing
// behavior. (Subscribers like the dashboard rely on these names.)
it('publishes the typed image-upload-error contract', () => {
const handler = vi.fn();
const sub = editorEventBus.on('image-upload-error', handler);

editorEventBus.dispatch('image-upload-error', {
fileName: 'a.png',
error: new Error('boom'),
});

expect(handler).toHaveBeenCalledOnce();
const payload = handler.mock.calls[0][0];
expect(payload.fileName).toBe('a.png');
expect(payload.error).toBeInstanceOf(Error);
expect(payload.error.message).toBe('boom');
sub.unsubscribe();
});
});
6 changes: 6 additions & 0 deletions packages/editor/src/core/event-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const EVENT_PREFIX = '@react-email/editor:';
export interface EditorEventMap {
'bubble-menu:add-link': undefined;
'node-clicked': NodeClickedEvent;
'image-upload-error': ImageUploadErrorEvent;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is also part of the changes in the image upload flow that should be part of a follow up

}

export type NodeClickedEvent = {
Expand All @@ -26,6 +27,11 @@ export type NodeClickedEvent = {
nodePos: { pos: number; inside: number };
};

export type ImageUploadErrorEvent = {
fileName: string;
error: Error;
};

export type EditorEventName = keyof EditorEventMap;

export type EditorEventHandler<T extends EditorEventName> = (
Expand Down
Loading
Loading