Skip to content
Merged
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/editor-lazy-code-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-email/editor": patch
---

Load react-email's `CodeBlock`, with its Prism grammars and themes, only when an email with a code block is rendered, instead of in every bundle that mounts the editor.
118 changes: 118 additions & 0 deletions packages/editor/src/extensions/code-block-email.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
a11yDark,
atomDark,
baseAteliersulphurpoolLight,
CodeBlock,
type CodeBlockProps,
cb,
coldarkCold,
coldarkDark,
coyWithoutShadows,
darcula,
dracula,
duotoneDark,
duotoneEarth,
duotoneForest,
duotoneLight,
duotoneSea,
duotoneSpace,
ghcolors,
gruvboxDark,
gruvboxLight,
holiTheme,
hopscotch,
laserwave,
lucario,
materialDark,
materialLight,
materialOceanic,
nightOwl,
nord,
oneDark,
oneLight,
pojoaque,
shadesOfPurple,
solarizedDarkAtom,
synthwave84,
vesper,
vs,
vscDarkPlus,
xonokai,
zTouch,
} from 'react-email';

export const codeBlockThemes: Record<string, CodeBlockProps['theme']> = {
a11yDark,
atomDark,
baseAteliersulphurpoolLight,
cb,
coldarkCold,
coldarkDark,
coyWithoutShadows,
darcula,
dracula,
duotoneDark,
duotoneEarth,
duotoneForest,
duotoneLight,
duotoneSea,
duotoneSpace,
ghcolors,
gruvboxDark,
gruvboxLight,
holiTheme,
hopscotch,
laserwave,
lucario,
materialDark,
materialLight,
materialOceanic,
nightOwl,
nord,
oneDark,
oneLight,
pojoaque,
shadesOfPurple,
solarizedDarkAtom,
synthwave84,
vesper,
vs,
vscDarkPlus,
xonokai,
zTouch,
};

export interface CodeBlockEmailProps extends Omit<CodeBlockProps, 'theme'> {
themeName?: string;
}

export function CodeBlockEmail({ themeName, ...props }: CodeBlockEmailProps) {
const userTheme =
themeName && Object.hasOwn(codeBlockThemes, themeName)
? codeBlockThemes[themeName]
: undefined;

// Without theme, render a gray code block
const theme = userTheme
? {
...userTheme,
base: {
...userTheme.base,
borderRadius: '0.125rem',
padding: '0.75rem 1rem',
},
}
: {
base: {
color: '#1e293b',
background: '#f1f5f9',
lineHeight: '1.5',
fontFamily:
'"Fira Code", "Fira Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace',
padding: '0.75rem 1rem',
borderRadius: '0.125rem',
},
};

return <CodeBlock {...props} theme={theme} />;
}
13 changes: 13 additions & 0 deletions packages/editor/src/extensions/code-block.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { generateJSON } from '@tiptap/html';
import StarterKit from '@tiptap/starter-kit';
import * as ReactEmail from 'react-email';
import { dracula, render } from 'react-email';
import { describe, expect, it } from 'vitest';
import { DEFAULT_STYLES } from '../utils/default-styles';
import { CodeBlockPrism } from './code-block';
import { codeBlockThemes } from './code-block-email';
import { StyleAttribute } from './style-attribute';

// Resolved style matching snapshot: codeBlock only, no reset (theme provides padding/margin)
Expand Down Expand Up @@ -119,4 +121,15 @@ describe('CodeBlockPrism Node', () => {

expect(recomposed).toBe(composed);
});

it('offers every code block theme react-email exports', () => {
const exportedThemes = Object.entries(ReactEmail)
.filter(
([, value]) =>
typeof value === 'object' && value !== null && 'base' in value,
)
.map(([name]) => name);

expect(Object.keys(codeBlockThemes).sort()).toEqual(exportedThemes.sort());
});
});
46 changes: 13 additions & 33 deletions packages/editor/src/extensions/code-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@ import type { CodeBlockOptions } from '@tiptap/extension-code-block';
import CodeBlock from '@tiptap/extension-code-block';
import { Fragment, type Schema } from '@tiptap/pm/model';
import { TextSelection } from '@tiptap/pm/state';
import * as ReactEmailComponents from 'react-email';
import {
type PrismLanguage,
CodeBlock as ReactEmailCodeBlock,
} from 'react-email';
import { lazy } from 'react';
import type { PrismLanguage } from 'react-email';
import { EmailNode } from '../core/serializer/email-node';
import { inlineCssToJs } from '../utils/styles';
import { PrismPlugin } from './prism-plugin';
Expand All @@ -21,6 +18,15 @@ export interface CodeBlockPrismOptions extends CodeBlockOptions {
// space so Spark Mail keeps the indentation.
const ENCODED_SPACE_REGEX = /\u00A0\u200D\u200B/g;

// react-email's CodeBlock carries every Prism grammar and theme, so it is only
// loaded once an email with a code block is rendered, not wherever the editor
// is mounted. `render()` waits for it inside its Suspense boundary.
const CodeBlockEmail = lazy(() =>
import('./code-block-email.js').then((module) => ({
default: module.CodeBlockEmail,
})),
);

export const CodeBlockPrism = EmailNode.from(
CodeBlock.extend<CodeBlockPrismOptions>({
addOptions(): CodeBlockPrismOptions {
Expand Down Expand Up @@ -190,41 +196,15 @@ export const CodeBlockPrism = EmailNode.from(
? `${node.attrs.language}`
: 'javascript';

// @ts-expect-error -- react-email does not export theme objects by name; dynamic access needed for user-selected themes
// biome-ignore lint/performance/noDynamicNamespaceImportAccess: dynamic access needed for user-selected themes
const userTheme = ReactEmailComponents[node.attrs?.theme];

// Without theme, render a gray code block
const theme = userTheme
? {
...userTheme,
base: {
...userTheme.base,
borderRadius: '0.125rem',
padding: '0.75rem 1rem',
},
}
: {
base: {
color: '#1e293b',
background: '#f1f5f9',
lineHeight: '1.5',
fontFamily:
'"Fira Code", "Fira Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace',
padding: '0.75rem 1rem',
borderRadius: '0.125rem',
},
};

return (
<ReactEmailCodeBlock
<CodeBlockEmail
code={node.content?.[0]?.text ?? ''}
// Neither the language nor the theme is recoverable from the rendered
// markup, so mark them for whoever imports this email back.
data-language={language}
data-theme={node.attrs?.theme}
language={language as PrismLanguage}
theme={theme}
themeName={node.attrs?.theme}
style={{
width: 'auto',
...style,
Expand Down
Loading