diff --git a/.changeset/editor-lazy-code-block.md b/.changeset/editor-lazy-code-block.md new file mode 100644 index 0000000000..1fa8f89c6f --- /dev/null +++ b/.changeset/editor-lazy-code-block.md @@ -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. diff --git a/packages/editor/src/extensions/code-block-email.tsx b/packages/editor/src/extensions/code-block-email.tsx new file mode 100644 index 0000000000..59d903ac23 --- /dev/null +++ b/packages/editor/src/extensions/code-block-email.tsx @@ -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 = { + 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 { + 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 ; +} diff --git a/packages/editor/src/extensions/code-block.spec.tsx b/packages/editor/src/extensions/code-block.spec.tsx index d0d9c6ab67..36aee93a38 100644 --- a/packages/editor/src/extensions/code-block.spec.tsx +++ b/packages/editor/src/extensions/code-block.spec.tsx @@ -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) @@ -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()); + }); }); diff --git a/packages/editor/src/extensions/code-block.tsx b/packages/editor/src/extensions/code-block.tsx index d09c404435..e1a9fb444a 100644 --- a/packages/editor/src/extensions/code-block.tsx +++ b/packages/editor/src/extensions/code-block.tsx @@ -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'; @@ -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({ addOptions(): CodeBlockPrismOptions { @@ -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 ( -