Skip to content

Commit 8cce44c

Browse files
committed
initial separated preview server
1 parent 735f038 commit 8cce44c

File tree

199 files changed

+758
-1532
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

199 files changed

+758
-1532
lines changed

apps/demo/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"react-email": "workspace:*"
1616
},
1717
"devDependencies": {
18+
"@react-email/preview-server": "workspace:*",
1819
"next": "15.3.1",
1920
"@types/react": "^19",
2021
"@types/react-dom": "^19",

apps/preview-server/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules
2+
.next
3+
4+
# for testing
5+
static

apps/preview-server/.npmignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.react-email
2+
./emails
3+
./emails/static
4+
node_modules
5+
.turbo

apps/preview-server/_index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/**
2+
* this file is just a placeholder file so that import.meta.resolve and require.resolve can properly
3+
* find out the path to this module. This file does not do anything nor does it need to export anything of value.
4+
*/

apps/preview-server/license.md

Lines changed: 7 additions & 0 deletions

apps/preview-server/package.json

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{
2+
"name": "@react-email/preview-server",
3+
"version": "1.0.0",
4+
"description": "A live preview of your emails right in your browser.",
5+
"scripts": {
6+
"caniemail:fetch": "node ./scripts/fill-caniemail-data.mjs",
7+
"clean": "rm -rf dist",
8+
"build": "node ./scripts/build-preview-server.mjs",
9+
"dev": "",
10+
"test": "vitest run",
11+
"test:watch": "vitest"
12+
},
13+
"main": "./_index.js",
14+
"dependencies": {
15+
"@babel/parser": "^7.27.0",
16+
"@babel/traverse": "^7.27.0",
17+
"@lottiefiles/dotlottie-react": "0.13.3",
18+
"@radix-ui/colors": "1.0.1",
19+
"@radix-ui/react-collapsible": "1.1.7",
20+
"@radix-ui/react-dropdown-menu": "2.1.10",
21+
"@radix-ui/react-popover": "1.1.10",
22+
"@radix-ui/react-slot": "1.2.0",
23+
"@radix-ui/react-tabs": "1.1.7",
24+
"@radix-ui/react-toggle-group": "1.1.6",
25+
"@radix-ui/react-tooltip": "1.2.3",
26+
"chalk": "^4.1.2",
27+
"clsx": "2.1.1",
28+
"esbuild": "^0.25.0",
29+
"framer-motion": "12.7.5",
30+
"json5": "2.2.3",
31+
"log-symbols": "^4.1.0",
32+
"module-punycode": "npm:[email protected]",
33+
"next": "^15.2.4",
34+
"node-html-parser": "6.1.13",
35+
"ora": "^5.4.1",
36+
"pretty-bytes": "6.1.1",
37+
"prism-react-renderer": "2.4.1",
38+
"react": "19.0.0",
39+
"react-dom": "19.0.0",
40+
"sharp": "0.34.1",
41+
"socket.io-client": "^4.8.1",
42+
"sonner": "1.7.4",
43+
"source-map-js": "1.2.1",
44+
"stacktrace-parser": "0.1.11",
45+
"tailwind-merge": "2.6.0",
46+
"use-debounce": "10.0.4",
47+
"zod": "3.24.3"
48+
},
49+
"devDependencies": {
50+
"@react-email/components": "workspace:*",
51+
"@types/babel__core": "7.20.5",
52+
"@types/babel__traverse": "7.20.7",
53+
"@types/fs-extra": "11.0.1",
54+
"@types/mime-types": "2.1.4",
55+
"@types/node": "22.10.2",
56+
"@types/normalize-path": "3.0.2",
57+
"@types/react": "19.0.10",
58+
"@types/react-dom": "19.0.4",
59+
"@types/webpack": "5.28.5",
60+
"autoprefixer": "10.4.21",
61+
"postcss": "8.5.3",
62+
"tailwindcss": "3.4.0",
63+
"typescript": "5.8.3"
64+
},
65+
"license": "MIT",
66+
"repository": {
67+
"type": "git",
68+
"url": "https://github.com/resend/react-email.git",
69+
"directory": "apps/preview-server"
70+
}
71+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { spawn } from 'node:child_process';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
5+
const nextBuildProcess = spawn('pnpm', ['next', 'build'], {
6+
detached: true,
7+
shell: true,
8+
stdio: 'inherit',
9+
cwd: path.resolve(import.meta.dirname, '../'),
10+
});
11+
12+
process.on('SIGINT', () => {
13+
nextBuildProcess.kill('SIGINT');
14+
});
15+
16+
nextBuildProcess.on('exit', (code) => {
17+
if (code !== 0) {
18+
console.error(`next build failed with exit code ${code}`);
19+
process.exit(code);
20+
}
21+
22+
fs.rmSync(path.resolve(import.meta.dirname, '../.next/cache'), { recursive: true });
23+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function escapeStringForRegex(string: string) {
2+
return string.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d');
3+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { promises as fs } from 'node:fs';
2+
import path from 'node:path';
3+
import type { Loader, PluginBuild, ResolveOptions } from 'esbuild';
4+
import { escapeStringForRegex } from './escape-string-for-regex';
5+
6+
/**
7+
* Made to export the `render` function out of the user's email template
8+
* so that issues like https://github.com/resend/react-email/issues/649 don't
9+
* happen.
10+
*
11+
* This also exports the `createElement` from the user's React version as well
12+
* to avoid mismatches.
13+
*
14+
* This avoids multiple versions of React being involved, i.e., the version
15+
* in the CLI vs. the version the user has on their emails.
16+
*/
17+
export const renderingUtilitiesExporter = (emailTemplates: string[]) => ({
18+
name: 'rendering-utilities-exporter',
19+
setup: (b: PluginBuild) => {
20+
b.onLoad(
21+
{
22+
filter: new RegExp(
23+
emailTemplates
24+
.map((emailPath) => escapeStringForRegex(emailPath))
25+
.join('|'),
26+
),
27+
},
28+
async ({ path: pathToFile }) => {
29+
return {
30+
contents: `${await fs.readFile(pathToFile, 'utf8')};
31+
export { render } from 'react-email-module-that-will-export-render'
32+
export { createElement as reactEmailCreateReactElement } from 'react';
33+
`,
34+
loader: path.extname(pathToFile).slice(1) as Loader,
35+
};
36+
},
37+
);
38+
39+
b.onResolve(
40+
{ filter: /^react-email-module-that-will-export-render$/ },
41+
async (args) => {
42+
const options: ResolveOptions = {
43+
kind: 'import-statement',
44+
importer: args.importer,
45+
resolveDir: args.resolveDir,
46+
namespace: args.namespace,
47+
};
48+
let result = await b.resolve('@react-email/render', options);
49+
if (result.errors.length === 0) {
50+
return result;
51+
}
52+
53+
// If @react-email/render does not exist, resolve to @react-email/components
54+
result = await b.resolve('@react-email/components', options);
55+
if (result.errors.length > 0 && result.errors[0]) {
56+
result.errors[0].text =
57+
"Failed trying to import `render` from either `@react-email/render` or `@react-email/components` to be able to render your email template.\n Maybe you don't have either of them installed?";
58+
}
59+
return result;
60+
},
61+
);
62+
},
63+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import path from 'node:path';
2+
import { getEmailsDirectoryMetadata } from './get-emails-directory-metadata';
3+
4+
test('getEmailsDirectoryMetadata on demo emails', async () => {
5+
const emailsDirectoryPath = path.resolve(
6+
__dirname,
7+
'../../../../apps/demo/emails',
8+
);
9+
expect(await getEmailsDirectoryMetadata(emailsDirectoryPath)).toEqual({
10+
absolutePath: emailsDirectoryPath,
11+
directoryName: 'emails',
12+
relativePath: '',
13+
emailFilenames: [],
14+
subDirectories: [
15+
{
16+
absolutePath: `${emailsDirectoryPath}/magic-links`,
17+
directoryName: 'magic-links',
18+
relativePath: 'magic-links',
19+
emailFilenames: [
20+
'aws-verify-email',
21+
'linear-login-code',
22+
'notion-magic-link',
23+
'plaid-verify-identity',
24+
'raycast-magic-link',
25+
'slack-confirm',
26+
],
27+
subDirectories: [],
28+
},
29+
{
30+
absolutePath: `${emailsDirectoryPath}/newsletters`,
31+
directoryName: 'newsletters',
32+
relativePath: 'newsletters',
33+
emailFilenames: [
34+
'codepen-challengers',
35+
'google-play-policy-update',
36+
'stack-overflow-tips',
37+
],
38+
subDirectories: [],
39+
},
40+
{
41+
absolutePath: `${emailsDirectoryPath}/notifications`,
42+
directoryName: 'notifications',
43+
relativePath: 'notifications',
44+
emailFilenames: [
45+
'github-access-token',
46+
'papermark-year-in-review',
47+
'vercel-invite-user',
48+
'yelp-recent-login',
49+
],
50+
subDirectories: [],
51+
},
52+
{
53+
absolutePath: `${emailsDirectoryPath}/receipts`,
54+
directoryName: 'receipts',
55+
relativePath: 'receipts',
56+
emailFilenames: ['apple-receipt', 'nike-receipt'],
57+
subDirectories: [],
58+
},
59+
{
60+
absolutePath: `${emailsDirectoryPath}/reset-password`,
61+
directoryName: 'reset-password',
62+
relativePath: 'reset-password',
63+
emailFilenames: ['dropbox-reset-password', 'twitch-reset-password'],
64+
subDirectories: [],
65+
},
66+
{
67+
absolutePath: `${emailsDirectoryPath}/reviews`,
68+
directoryName: 'reviews',
69+
relativePath: 'reviews',
70+
emailFilenames: ['airbnb-review', 'amazon-review'],
71+
subDirectories: [],
72+
},
73+
{
74+
absolutePath: `${emailsDirectoryPath}/welcome`,
75+
directoryName: 'welcome',
76+
relativePath: 'welcome',
77+
emailFilenames: ['koala-welcome', 'netlify-welcome', 'stripe-welcome'],
78+
subDirectories: [],
79+
},
80+
],
81+
});
82+
});

0 commit comments

Comments
 (0)