mirror of
https://github.com/resend/react-email.git
synced 2026-09-14 14:18:02 +08:00
feat(ui): also preview HTML email templates (#3526)
Signed-off-by: gabriel miranda <gabriel@resend.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@react-email/ui": minor
|
||||
---
|
||||
|
||||
support previewing HTML email templates
|
||||
@@ -6,7 +6,7 @@ import { cache } from 'react';
|
||||
import { emailsDirectoryAbsolutePath } from '../app/env';
|
||||
|
||||
export const getEmailPathFromSlug = cache(async (slug: string) => {
|
||||
if (['.tsx', '.jsx', '.ts', '.js'].includes(path.extname(slug)))
|
||||
if (['.tsx', '.jsx', '.ts', '.js', '.html'].includes(path.extname(slug)))
|
||||
return path.join(emailsDirectoryAbsolutePath, slug);
|
||||
|
||||
const pathWithoutExtension = path.join(emailsDirectoryAbsolutePath, slug);
|
||||
@@ -23,6 +23,9 @@ export const getEmailPathFromSlug = cache(async (slug: string) => {
|
||||
if (fs.existsSync(`${pathWithoutExtension}.js`)) {
|
||||
return `${pathWithoutExtension}.js`;
|
||||
}
|
||||
if (fs.existsSync(`${pathWithoutExtension}.html`)) {
|
||||
return `${pathWithoutExtension}.html`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import path from 'node:path';
|
||||
import { renderEmailByPath } from './render-email-by-path';
|
||||
|
||||
describe('renderEmailByPath() with raw .html templates', () => {
|
||||
const htmlPath = path.resolve(
|
||||
__dirname,
|
||||
'../utils/testing/raw-html-email.html',
|
||||
);
|
||||
|
||||
it('renders raw HTML without bundling a React component', async () => {
|
||||
const result = await renderEmailByPath(htmlPath, true);
|
||||
|
||||
expect('error' in result).toBe(false);
|
||||
if ('error' in result) return;
|
||||
|
||||
expect(result.basename).toBe('raw-html-email');
|
||||
expect(result.extname).toBe('html');
|
||||
expect(result.markup).toContain('<h1>Hello from a raw HTML email</h1>');
|
||||
expect(result.reactMarkup).toContain(
|
||||
'<h1>Hello from a raw HTML email</h1>',
|
||||
);
|
||||
// The fixture ships as a single minified line; prettyMarkup should be
|
||||
// formatted across multiple lines so the source view is readable.
|
||||
expect(result.prettyMarkup).toContain('Hello from a raw HTML email');
|
||||
expect(result.prettyMarkup.split('\n').length).toBeGreaterThan(
|
||||
result.markup.split('\n').length,
|
||||
);
|
||||
// html-to-text uppercases <h1> content by default
|
||||
expect(result.plainText.toLowerCase()).toContain(
|
||||
'hello from a raw html email',
|
||||
);
|
||||
expect(result.plainText).toContain(
|
||||
'This template has no JavaScript exports.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import logSymbols from 'log-symbols';
|
||||
import type React from 'react';
|
||||
import { pretty, toPlainText } from 'react-email';
|
||||
import {
|
||||
isBuilding,
|
||||
isPreviewDevelopment,
|
||||
@@ -118,6 +119,31 @@ export const renderEmailByPath = async (
|
||||
registerSpinnerAutostopping(spinner);
|
||||
}
|
||||
|
||||
if (path.extname(emailPath) === '.html') {
|
||||
const renderingResult = await renderRawHtmlEmailByPath(emailPath);
|
||||
if ('error' in renderingResult) {
|
||||
stopSpinnerAndPersist(spinner, {
|
||||
symbol: logSymbols.error,
|
||||
text: `Failed while rendering ${emailFilename}`,
|
||||
});
|
||||
} else {
|
||||
stopSpinnerAndPersist(spinner, {
|
||||
symbol: logSymbols.success,
|
||||
text: `Successfully rendered ${emailFilename}`,
|
||||
});
|
||||
}
|
||||
logBufferer.flush();
|
||||
errorBufferer.flush();
|
||||
infoBufferer.flush();
|
||||
warnBufferer.flush();
|
||||
|
||||
if (!('error' in renderingResult)) {
|
||||
cache.set(emailPath, renderingResult);
|
||||
}
|
||||
|
||||
return renderingResult;
|
||||
}
|
||||
|
||||
const originalJsxRuntimePath = path.resolve(
|
||||
previewServerLocation,
|
||||
'jsx-runtime',
|
||||
@@ -312,3 +338,44 @@ export const renderEmailByPath = async (
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const renderRawHtmlEmailByPath = async (
|
||||
emailPath: string,
|
||||
): Promise<EmailRenderingResult> => {
|
||||
try {
|
||||
const source = await fs.promises.readFile(emailPath, 'utf-8');
|
||||
const markup = source.replaceAll('\0', '');
|
||||
|
||||
let prettyMarkup = markup;
|
||||
try {
|
||||
prettyMarkup = await pretty(markup);
|
||||
} catch (_) {
|
||||
// Fall back to the raw markup if prettier cannot parse the HTML so the
|
||||
// preview still renders something the user can iterate on.
|
||||
}
|
||||
|
||||
const plainText = toPlainText(markup);
|
||||
|
||||
return {
|
||||
prettyMarkup,
|
||||
markup,
|
||||
plainText,
|
||||
reactMarkup: source,
|
||||
|
||||
basename: path.basename(emailPath, path.extname(emailPath)),
|
||||
extname: path.extname(emailPath).slice(1),
|
||||
};
|
||||
} catch (exception) {
|
||||
const error = exception as Error;
|
||||
return {
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
cause: error.cause
|
||||
? JSON.parse(JSON.stringify(error.cause))
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -325,6 +325,13 @@ export function EmailFrame({
|
||||
srcDoc={markup}
|
||||
width={width}
|
||||
height={height}
|
||||
// `srcDoc` content inherits the parent's origin, so a `<script>` in a
|
||||
// template (especially raw `.html` files read from disk) would execute
|
||||
// with same-origin access to the preview app. Sandboxing disables
|
||||
// scripts, forms, popups, and top-level navigation while keeping
|
||||
// `allow-same-origin` so the parent can still inspect/modify the
|
||||
// iframe document for color inversion and event bubbling.
|
||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||
onLoad={(event) => {
|
||||
const iframe = event.currentTarget;
|
||||
if (darkMode) {
|
||||
|
||||
@@ -89,13 +89,16 @@ This is most likely not an issue with the preview server. Maybe there was a typo
|
||||
return 0;
|
||||
});
|
||||
compatibilityCheckingResults = [];
|
||||
for await (const result of loadStream(
|
||||
await checkCompatibility(
|
||||
serverEmailRenderingResult.reactMarkup,
|
||||
emailPath,
|
||||
),
|
||||
)) {
|
||||
compatibilityCheckingResults.push(result);
|
||||
// Compatibility checks parse JSX/TS — they don't apply to raw .html emails.
|
||||
if (serverEmailRenderingResult.extname !== 'html') {
|
||||
for await (const result of loadStream(
|
||||
await checkCompatibility(
|
||||
serverEmailRenderingResult.reactMarkup,
|
||||
emailPath,
|
||||
),
|
||||
)) {
|
||||
compatibilityCheckingResults.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch('https://react.email/api/check-spam', {
|
||||
|
||||
@@ -36,7 +36,16 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
|
||||
|
||||
const isDarkModeEnabled = searchParams.get('dark') !== null;
|
||||
const activeView = searchParams.get('view') ?? 'preview';
|
||||
const activeLang = searchParams.get('lang') ?? 'tsx';
|
||||
const isRawHtmlEmail = renderedEmailMetadata?.extname === 'html';
|
||||
const requestedLang = searchParams.get('lang');
|
||||
const defaultLang = isRawHtmlEmail ? 'html' : 'tsx';
|
||||
// Raw HTML templates only expose `html` and `markdown` tabs, so coerce any
|
||||
// lingering `tsx` selection from URL state to the HTML tab to avoid the
|
||||
// "No markup found for the active language!" error in CodeContainer.
|
||||
const activeLang =
|
||||
requestedLang === null || (isRawHtmlEmail && requestedLang === 'tsx')
|
||||
? defaultLang
|
||||
: requestedLang;
|
||||
|
||||
const handleDarkModeChange = (enabled: boolean) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
@@ -208,22 +217,37 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
|
||||
<CodeContainer
|
||||
activeLang={activeLang}
|
||||
basename={renderedEmailMetadata.basename}
|
||||
markups={[
|
||||
{
|
||||
language: 'tsx',
|
||||
extension: renderedEmailMetadata.extname,
|
||||
content: renderedEmailMetadata.reactMarkup,
|
||||
},
|
||||
{
|
||||
language: 'html',
|
||||
content: renderedEmailMetadata.prettyMarkup,
|
||||
},
|
||||
{
|
||||
language: 'markdown',
|
||||
extension: 'md',
|
||||
content: renderedEmailMetadata.plainText,
|
||||
},
|
||||
]}
|
||||
markups={
|
||||
isRawHtmlEmail
|
||||
? [
|
||||
{
|
||||
language: 'html',
|
||||
extension: 'html',
|
||||
content: renderedEmailMetadata.prettyMarkup,
|
||||
},
|
||||
{
|
||||
language: 'markdown',
|
||||
extension: 'md',
|
||||
content: renderedEmailMetadata.plainText,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
language: 'tsx',
|
||||
extension: renderedEmailMetadata.extname,
|
||||
content: renderedEmailMetadata.reactMarkup,
|
||||
},
|
||||
{
|
||||
language: 'html',
|
||||
content: renderedEmailMetadata.prettyMarkup,
|
||||
},
|
||||
{
|
||||
language: 'markdown',
|
||||
extension: 'md',
|
||||
content: renderedEmailMetadata.plainText,
|
||||
},
|
||||
]
|
||||
}
|
||||
setActiveLang={handleLangChange}
|
||||
/>
|
||||
</Tooltip.Provider>
|
||||
|
||||
@@ -57,10 +57,13 @@ export const FileTreeDirectoryChildren = (props: {
|
||||
: `${props.emailsDirectoryMetadata.relativePath}/${emailFilename}`;
|
||||
|
||||
const removeExtensionFrom = (path: string) => {
|
||||
const ext = path.split('.').pop();
|
||||
if (
|
||||
path.split('.').pop() === 'tsx' ||
|
||||
path.split('.').pop() === 'jsx' ||
|
||||
path.split('.').pop() === 'js'
|
||||
ext === 'tsx' ||
|
||||
ext === 'jsx' ||
|
||||
ext === 'ts' ||
|
||||
ext === 'js' ||
|
||||
ext === 'html'
|
||||
) {
|
||||
return path.split('.').slice(0, -1).join('.');
|
||||
}
|
||||
@@ -72,15 +75,31 @@ export const FileTreeDirectoryChildren = (props: {
|
||||
emailSlug
|
||||
: false;
|
||||
|
||||
// Raw .html templates don't expose a Compatibility tab, so
|
||||
// dropping the param prevents the toolbar from opening on
|
||||
// a hidden tab when navigating from a .tsx email.
|
||||
const isHtmlTarget = emailFilename.endsWith('.html');
|
||||
const targetSearchParams = new URLSearchParams(
|
||||
searchParams,
|
||||
);
|
||||
if (
|
||||
isHtmlTarget &&
|
||||
targetSearchParams.get('toolbar-panel') ===
|
||||
'compatibility'
|
||||
) {
|
||||
targetSearchParams.set('toolbar-panel', 'linter');
|
||||
}
|
||||
const targetSearch = targetSearchParams.toString();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={{
|
||||
pathname: `/preview/${emailSlug}`,
|
||||
search: searchParams.toString(),
|
||||
search: targetSearch,
|
||||
}}
|
||||
onMouseOver={() => {
|
||||
router.prefetch(
|
||||
`/preview/${emailSlug}?${searchParams.toString()}`,
|
||||
`/preview/${emailSlug}${targetSearch ? `?${targetSearch}` : ''}`,
|
||||
);
|
||||
}}
|
||||
key={emailSlug}
|
||||
|
||||
@@ -57,12 +57,14 @@ const ToolbarInner = ({
|
||||
plainText,
|
||||
emailPath,
|
||||
emailSlug,
|
||||
isRawHtmlEmail,
|
||||
}: ToolbarProps & {
|
||||
prettyMarkup: string;
|
||||
reactMarkup: string;
|
||||
plainText: string;
|
||||
emailSlug: string;
|
||||
emailPath: string;
|
||||
isRawHtmlEmail: boolean;
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -125,8 +127,12 @@ const ToolbarInner = ({
|
||||
const spamCheckingResult = await loadSpamChecking();
|
||||
setCachedSpamCheckingResult(spamCheckingResult);
|
||||
|
||||
const compatibilityCheckingResults = await loadCompatibility();
|
||||
setCachedCompatibilityResults(compatibilityCheckingResults);
|
||||
// Compatibility checks rely on parsing JSX/TS, so they don't apply to
|
||||
// raw .html templates and would only produce noise.
|
||||
if (!isRawHtmlEmail) {
|
||||
const compatibilityCheckingResults = await loadCompatibility();
|
||||
setCachedCompatibilityResults(compatibilityCheckingResults);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
}
|
||||
@@ -157,11 +163,13 @@ const ToolbarInner = ({
|
||||
Linter
|
||||
</ToolbarButton>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger asChild value="compatibility">
|
||||
<ToolbarButton active={activeTab === 'compatibility'}>
|
||||
Compatibility
|
||||
</ToolbarButton>
|
||||
</Tabs.Trigger>
|
||||
{isRawHtmlEmail ? null : (
|
||||
<Tabs.Trigger asChild value="compatibility">
|
||||
<ToolbarButton active={activeTab === 'compatibility'}>
|
||||
Compatibility
|
||||
</ToolbarButton>
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
<Tabs.Trigger asChild value="spam-assassin">
|
||||
<ToolbarButton active={activeTab === 'spam-assassin'}>
|
||||
Spam
|
||||
@@ -179,6 +187,7 @@ const ToolbarInner = ({
|
||||
compatibilityResults={compatibilityCheckingResults}
|
||||
spamResult={spamCheckingResult}
|
||||
reactMarkup={reactMarkup}
|
||||
isRawHtmlEmail={isRawHtmlEmail}
|
||||
activeTab={activeTab}
|
||||
/>
|
||||
<ToolbarButton
|
||||
@@ -209,7 +218,10 @@ const ToolbarInner = ({
|
||||
await loadSpamChecking();
|
||||
} else if (activeTab === 'linter') {
|
||||
await loadLinting();
|
||||
} else if (activeTab === 'compatibility') {
|
||||
} else if (
|
||||
activeTab === 'compatibility' &&
|
||||
!isRawHtmlEmail
|
||||
) {
|
||||
await loadCompatibility();
|
||||
}
|
||||
}}
|
||||
@@ -258,7 +270,15 @@ const ToolbarInner = ({
|
||||
)}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="compatibility">
|
||||
{compatibilityLoading ? (
|
||||
{isRawHtmlEmail ? (
|
||||
<SuccessWrapper>
|
||||
<SuccessTitle>Compatibility unavailable</SuccessTitle>
|
||||
<SuccessDescription>
|
||||
Compatibility checks rely on the React Email source and are
|
||||
skipped for raw HTML templates.
|
||||
</SuccessDescription>
|
||||
</SuccessWrapper>
|
||||
) : compatibilityLoading ? (
|
||||
<LoadingState message="Checking email compatibility..." />
|
||||
) : compatibilityCheckingResults?.length === 0 ? (
|
||||
<SuccessWrapper>
|
||||
@@ -398,7 +418,8 @@ export function Toolbar({
|
||||
const { emailPath, emailSlug, renderedEmailMetadata } = usePreviewContext();
|
||||
|
||||
if (renderedEmailMetadata === undefined) return null;
|
||||
const { prettyMarkup, plainText, reactMarkup } = renderedEmailMetadata;
|
||||
const { prettyMarkup, plainText, reactMarkup, extname } =
|
||||
renderedEmailMetadata;
|
||||
|
||||
return (
|
||||
<ToolbarInner
|
||||
@@ -407,6 +428,7 @@ export function Toolbar({
|
||||
prettyMarkup={prettyMarkup}
|
||||
reactMarkup={reactMarkup}
|
||||
plainText={plainText}
|
||||
isRawHtmlEmail={extname === 'html'}
|
||||
serverLintingRows={serverLintingRows}
|
||||
serverSpamCheckingResult={serverSpamCheckingResult}
|
||||
serverCompatibilityResults={serverCompatibilityResults}
|
||||
|
||||
@@ -24,6 +24,7 @@ interface CopyForAIProps {
|
||||
compatibilityResults: CompatibilityCheckingResult[] | undefined;
|
||||
spamResult: SpamCheckingResult | undefined;
|
||||
reactMarkup: string;
|
||||
isRawHtmlEmail: boolean;
|
||||
activeTab: ActiveTab;
|
||||
}
|
||||
|
||||
@@ -168,6 +169,7 @@ function getPromptForTab(
|
||||
compatibilityResults: CompatibilityCheckingResult[] | undefined,
|
||||
spamResult: SpamCheckingResult | undefined,
|
||||
reactMarkup: string,
|
||||
isRawHtmlEmail: boolean,
|
||||
): string {
|
||||
let issuePrompt = '';
|
||||
|
||||
@@ -194,11 +196,14 @@ function getPromptForTab(
|
||||
issuePrompt = parts.join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
const sourceLang = isRawHtmlEmail ? 'html' : 'tsx';
|
||||
const templateLabel = isRawHtmlEmail ? 'HTML email' : 'React Email';
|
||||
|
||||
if (!issuePrompt) {
|
||||
return `Here is the source code of my React Email template:\n\n\`\`\`tsx\n${reactMarkup}\n\`\`\`\n\nHelp me review and improve this email template.`;
|
||||
return `Here is the source code of my ${templateLabel} template:\n\n\`\`\`${sourceLang}\n${reactMarkup}\n\`\`\`\n\nHelp me review and improve this email template.`;
|
||||
}
|
||||
|
||||
return `${issuePrompt}\n\nHere is the source code of my email template:\n\n\`\`\`tsx\n${reactMarkup}\n\`\`\``;
|
||||
return `${issuePrompt}\n\nHere is the source code of my email template:\n\n\`\`\`${sourceLang}\n${reactMarkup}\n\`\`\``;
|
||||
}
|
||||
|
||||
function getLinkDescription(activeTab: ActiveTab): string {
|
||||
@@ -233,6 +238,7 @@ export const CopyForAI = ({
|
||||
compatibilityResults,
|
||||
spamResult,
|
||||
reactMarkup,
|
||||
isRawHtmlEmail,
|
||||
activeTab,
|
||||
}: CopyForAIProps) => {
|
||||
const markdown = React.useMemo(
|
||||
@@ -243,8 +249,16 @@ export const CopyForAI = ({
|
||||
compatibilityResults,
|
||||
spamResult,
|
||||
reactMarkup,
|
||||
isRawHtmlEmail,
|
||||
),
|
||||
[activeTab, lintingRows, compatibilityResults, spamResult, reactMarkup],
|
||||
[
|
||||
activeTab,
|
||||
lintingRows,
|
||||
compatibilityResults,
|
||||
spamResult,
|
||||
reactMarkup,
|
||||
isRawHtmlEmail,
|
||||
],
|
||||
);
|
||||
|
||||
const linkDescription = getLinkDescription(activeTab);
|
||||
|
||||
@@ -18,6 +18,11 @@ const isFileAnEmail = async (fullPath: string): Promise<boolean> => {
|
||||
|
||||
const { ext } = path.parse(fullPath);
|
||||
|
||||
if (ext === '.html') {
|
||||
await fileHandle.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!['.js', '.tsx', '.jsx'].includes(ext)) {
|
||||
await fileHandle.close();
|
||||
return false;
|
||||
|
||||
@@ -21,4 +21,9 @@ describe('JavaScript Email Detection', async () => {
|
||||
expect(emailsMetadata).toBeDefined();
|
||||
expect(emailsMetadata?.emailFilenames).toContain('mdx-email-test.js');
|
||||
});
|
||||
|
||||
it('detects raw .html templates without requiring any exports', async () => {
|
||||
expect(emailsMetadata).toBeDefined();
|
||||
expect(emailsMetadata?.emailFilenames).toContain('raw-html-email.html');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8" /><title>Raw HTML Email</title></head><body><h1>Hello from a raw HTML email</h1><p>This template has no JavaScript exports.</p></body></html>
|
||||
@@ -1,2 +1,3 @@
|
||||
**/*.tsx
|
||||
**/*.html
|
||||
!example.tsx
|
||||
|
||||
Reference in New Issue
Block a user