diff --git a/.changeset/major-queens-talk.md b/.changeset/major-queens-talk.md new file mode 100644 index 000000000..34a2009ca --- /dev/null +++ b/.changeset/major-queens-talk.md @@ -0,0 +1,5 @@ +--- +"@react-email/ui": minor +--- + +support previewing HTML email templates diff --git a/packages/ui/src/actions/get-email-path-from-slug.ts b/packages/ui/src/actions/get-email-path-from-slug.ts index abc6744aa..e7b86157b 100644 --- a/packages/ui/src/actions/get-email-path-from-slug.ts +++ b/packages/ui/src/actions/get-email-path-from-slug.ts @@ -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; }); diff --git a/packages/ui/src/actions/render-email-by-path.spec.ts b/packages/ui/src/actions/render-email-by-path.spec.ts new file mode 100644 index 000000000..f14fba23a --- /dev/null +++ b/packages/ui/src/actions/render-email-by-path.spec.ts @@ -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('

Hello from a raw HTML email

'); + expect(result.reactMarkup).toContain( + '

Hello from a raw HTML email

', + ); + // 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

content by default + expect(result.plainText.toLowerCase()).toContain( + 'hello from a raw html email', + ); + expect(result.plainText).toContain( + 'This template has no JavaScript exports.', + ); + }); +}); diff --git a/packages/ui/src/actions/render-email-by-path.tsx b/packages/ui/src/actions/render-email-by-path.tsx index 401999f06..80aefbb4c 100644 --- a/packages/ui/src/actions/render-email-by-path.tsx +++ b/packages/ui/src/actions/render-email-by-path.tsx @@ -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 => { + 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, + }, + }; + } +}; diff --git a/packages/ui/src/app/preview/[...slug]/email-frame.tsx b/packages/ui/src/app/preview/[...slug]/email-frame.tsx index 00aa4094b..03ac0c167 100644 --- a/packages/ui/src/app/preview/[...slug]/email-frame.tsx +++ b/packages/ui/src/app/preview/[...slug]/email-frame.tsx @@ -325,6 +325,13 @@ export function EmailFrame({ srcDoc={markup} width={width} height={height} + // `srcDoc` content inherits the parent's origin, so a `