Skip to content

Commit 2377e89

Browse files
authored
fix(editor): emit theme link styles in renderHTML (#3418)
1 parent 5141175 commit 2377e89

5 files changed

Lines changed: 171 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-email/editor": patch
3+
---
4+
5+
Emit active EmailTheming link styles in the Link mark's `renderHTML` so plain links carry inline color + underline in exported HTML. User inline styles still take precedence via the CSS cascade. `RESET_MINIMAL.link` now also ships `#0670DB` + underline.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { Editor } from '@tiptap/core';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
import { EmailTheming } from '../plugins/email-theming/extension';
4+
import { StarterKit } from './index';
5+
6+
vi.mock('@tiptap/react', () => ({
7+
ReactNodeViewRenderer: () => () => null,
8+
useEditorState: vi.fn(),
9+
}));
10+
11+
vi.mock('tippy.js', () => ({
12+
default: vi.fn(),
13+
}));
14+
15+
vi.mock('@/env', () => ({
16+
env: new Proxy(
17+
{},
18+
{
19+
get: () => '',
20+
},
21+
),
22+
}));
23+
24+
function docWithLink(style?: string) {
25+
const attrs: Record<string, unknown> = { href: 'https://resend.com' };
26+
if (style !== undefined) {
27+
attrs.style = style;
28+
}
29+
return {
30+
type: 'doc',
31+
content: [
32+
{
33+
type: 'paragraph',
34+
content: [
35+
{
36+
type: 'text',
37+
marks: [{ type: 'link', attrs }],
38+
text: 'click',
39+
},
40+
],
41+
},
42+
],
43+
};
44+
}
45+
46+
function createEditor(theme: 'basic' | 'minimal', content = docWithLink()) {
47+
return new Editor({
48+
extensions: [StarterKit, EmailTheming.configure({ theme })],
49+
content,
50+
});
51+
}
52+
53+
function findLinkMark(editor: Editor) {
54+
const walk = (nodes: unknown[]): Record<string, unknown> | undefined => {
55+
for (const n of nodes) {
56+
const node = n as Record<string, unknown>;
57+
const marks = node.marks as Array<Record<string, unknown>> | undefined;
58+
const linkMark = marks?.find((m) => m.type === 'link');
59+
if (linkMark) return linkMark;
60+
const children = node.content as unknown[] | undefined;
61+
if (children) {
62+
const found = walk(children);
63+
if (found) return found;
64+
}
65+
}
66+
return undefined;
67+
};
68+
return walk((editor.getJSON().content ?? []) as unknown[]);
69+
}
70+
71+
const COLOR_RE = /color:\s*#0670DB/i;
72+
const UNDERLINE_RE = /text-decoration:\s*underline/i;
73+
74+
describe('Link mark theming', () => {
75+
let editor: Editor;
76+
77+
afterEach(() => {
78+
editor?.destroy();
79+
document.head
80+
.querySelectorAll('style[id^="tiptap-theme-"]')
81+
.forEach((node) => {
82+
node.remove();
83+
});
84+
});
85+
86+
it('emits theme-resolved color and text-decoration on plain links (basic)', () => {
87+
editor = createEditor('basic');
88+
const html = editor.getHTML();
89+
expect(html).toMatch(COLOR_RE);
90+
expect(html).toMatch(UNDERLINE_RE);
91+
});
92+
93+
it('emits theme-resolved color and text-decoration on plain links (minimal)', () => {
94+
editor = createEditor('minimal');
95+
const html = editor.getHTML();
96+
expect(html).toMatch(COLOR_RE);
97+
expect(html).toMatch(UNDERLINE_RE);
98+
});
99+
100+
it('preserves class="node-link" in the rendered output', () => {
101+
editor = createEditor('basic');
102+
expect(editor.getHTML()).toContain('class="node-link"');
103+
});
104+
105+
it('lets user-specified color win over the theme color', () => {
106+
editor = createEditor('basic', docWithLink('color: red'));
107+
const html = editor.getHTML();
108+
expect(html).toMatch(/color:\s*red/i);
109+
expect(html).not.toMatch(COLOR_RE);
110+
expect(html).toMatch(UNDERLINE_RE);
111+
});
112+
113+
it('keeps mark.attrs.style empty for plain links (inspector contract)', () => {
114+
editor = createEditor('basic');
115+
const mark = findLinkMark(editor);
116+
expect(mark?.type).toBe('link');
117+
expect((mark?.attrs as Record<string, unknown>).style).toBe('');
118+
});
119+
120+
it('round-trips a plain <a href> through setContent+getHTML with themed style', () => {
121+
editor = createEditor('basic');
122+
editor.commands.setContent(
123+
'<p><a href="https://resend.com">click</a></p>',
124+
{ emitUpdate: true },
125+
);
126+
const html = editor.getHTML();
127+
expect(html).toMatch(COLOR_RE);
128+
expect(html).toMatch(UNDERLINE_RE);
129+
});
130+
});

packages/editor/src/extensions/link.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { Editor } from '@tiptap/core';
2+
import { mergeAttributes } from '@tiptap/core';
13
import type { LinkOptions as TipTapLinkOptions } from '@tiptap/extension-link';
24
import TiptapLink from '@tiptap/extension-link';
35
import { Link as ReactEmailLink } from 'react-email';
@@ -6,9 +8,24 @@ export type LinkOptions = TipTapLinkOptions;
68

79
import { editorEventBus } from '../core';
810
import { EmailMark } from '../core/serializer/email-mark';
9-
import { inlineCssToJs } from '../utils/styles';
11+
import {
12+
getEmailTheming,
13+
getMergedCssJs,
14+
getResolvedNodeStyles,
15+
} from '../plugins/email-theming/extension';
16+
import { inlineCssToJs, jsToInlineCss } from '../utils/styles';
1017
import { processStylesForUnlink } from './preserved-style';
1118

19+
function resolveThemedLinkStyle(editor: Editor): string {
20+
const theming = getEmailTheming(editor);
21+
const resolved = getResolvedNodeStyles(
22+
{ type: 'link', attrs: {} },
23+
0,
24+
getMergedCssJs(theming.theme, theming.styles),
25+
);
26+
return jsToInlineCss(resolved).replace(/;$/, '');
27+
}
28+
1229
export const Link: EmailMark<TipTapLinkOptions, any> = EmailMark.from(
1330
TiptapLink,
1431
({ children, mark, style }) => {
@@ -84,6 +101,21 @@ export const Link: EmailMark<TipTapLinkOptions, any> = EmailMark.from(
84101
};
85102
},
86103

104+
renderHTML({ HTMLAttributes }) {
105+
const userStyle = ((HTMLAttributes.style as string | undefined) ?? '')
106+
.trim()
107+
.replace(/;$/, '');
108+
const themed = this.editor ? resolveThemedLinkStyle(this.editor) : '';
109+
const mergedStyle = [themed, userStyle].filter(Boolean).join('; ');
110+
return [
111+
'a',
112+
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
113+
style: mergedStyle || null,
114+
}),
115+
0,
116+
];
117+
},
118+
87119
addCommands() {
88120
return {
89121
...this.parent?.(),

packages/editor/src/plugins/email-theming/extension.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ function resolveThemeConfig(config: EditorThemeInput): {
174174
return { baseTheme, panels };
175175
}
176176

177-
function getEmailTheming(editor: Editor) {
177+
export function getEmailTheming(editor: Editor) {
178178
const theme = getEmailTheme(editor);
179179
const normalizedStyles =
180180
normalizeThemePanelStyles(theme, getEmailStyles(editor)) ??

packages/editor/src/plugins/email-theming/themes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,7 @@ const RESET_BASIC: ResetTheme = {
677677
fontFamily:
678678
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
679679
},
680-
link: { textDecoration: 'underline' },
680+
link: { color: '#0670DB', textDecoration: 'underline' },
681681
footer: {
682682
fontSize: '0.8em',
683683
},
@@ -729,6 +729,7 @@ const RESET_MINIMAL: ResetTheme = {
729729
nestedList: RESET_BASIC.nestedList,
730730
listItem: RESET_BASIC.listItem,
731731
listParagraph: RESET_BASIC.listParagraph,
732+
link: RESET_BASIC.link,
732733
};
733734

734735
export const RESET_THEMES: Record<EditorTheme, ResetTheme> = {

0 commit comments

Comments
 (0)