Skip to content

Commit 7c18bd3

Browse files
gabrielmfernbukinoshitacubic-dev-ai[bot]github-actions[bot]
authored
feat(react-email): installation of UI without npm install (#2573)
Co-authored-by: Bu Kinoshita <6929565+bukinoshita@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 60e3a7f commit 7c18bd3

File tree

9 files changed

+332
-181
lines changed

9 files changed

+332
-181
lines changed

.changeset/spicy-shirts-unite.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-email": minor
3+
---
4+
5+
don't require installing @react-email/preview-server in the project, pack it into `$HOME/.react-email`

packages/preview-server/package.json

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@
1313
},
1414
"main": "./index.mjs",
1515
"dependencies": {
16-
"next": "16.0.10"
17-
},
18-
"devDependencies": {
16+
"next": "16.0.10",
1917
"@babel/core": "7.26.10",
2018
"@babel/parser": "7.27.0",
2119
"@babel/traverse": "7.27.0",
@@ -29,33 +27,21 @@
2927
"@radix-ui/react-toggle": "1.1.10",
3028
"@radix-ui/react-toggle-group": "1.1.11",
3129
"@radix-ui/react-tooltip": "1.2.8",
30+
"@react-email/tailwind": "workspace:2.0.3",
3231
"@react-email/body": "workspace:*",
3332
"@react-email/button": "workspace:*",
3433
"@react-email/code-block": "workspace:*",
3534
"@react-email/code-inline": "workspace:*",
36-
"@react-email/components": "workspace:*",
3735
"@react-email/container": "workspace:*",
3836
"@react-email/heading": "workspace:*",
3937
"@react-email/hr": "workspace:*",
4038
"@react-email/img": "workspace:*",
4139
"@react-email/link": "workspace:*",
4240
"@react-email/preview": "workspace:*",
43-
"@react-email/tailwind": "workspace:2.0.3",
4441
"@react-email/text": "workspace:*",
4542
"@tailwindcss/postcss": "4.1.17",
46-
"@types/babel__core": "7.20.5",
47-
"@types/babel__traverse": "7.20.7",
48-
"@types/css-tree": "2.3.11",
49-
"@types/fs-extra": "11.0.4",
50-
"@types/mime-types": "2.1.4",
51-
"@types/node": "22.19.7",
52-
"@types/normalize-path": "3.0.2",
53-
"@types/react": "19.0.10",
54-
"@types/react-dom": "19.0.4",
55-
"@types/webpack": "5.28.5",
5643
"clsx": "2.1.1",
5744
"colorjs.io": "0.5.2",
58-
"cross-env": "^10.1.0",
5945
"esbuild": "0.25.10",
6046
"framer-motion": "12.23.22",
6147
"log-symbols": "4.1.0",
@@ -69,18 +55,32 @@
6955
"react": "19.0.0",
7056
"react-dom": "19.0.0",
7157
"resend": "6.4.0",
72-
"rimraf": "^6.1.2",
73-
"sharp": "0.34.5",
7458
"socket.io-client": "4.8.3",
7559
"sonner": "2.0.7",
7660
"source-map-js": "1.2.1",
7761
"stacktrace-parser": "0.1.11",
7862
"tailwind-merge": "3.4.0",
7963
"tailwindcss": "4.1.17",
80-
"typescript": "5.8.3",
8164
"use-debounce": "10.0.6",
8265
"zod": "4.1.12"
8366
},
67+
"devDependencies": {
68+
"@react-email/components": "workspace:*",
69+
"@types/babel__core": "7.20.5",
70+
"@types/babel__traverse": "7.20.7",
71+
"cross-env": "10.1.0",
72+
"@types/css-tree": "2.3.11",
73+
"rimraf": "6.1.2",
74+
"sharp": "0.34.5",
75+
"@types/fs-extra": "11.0.4",
76+
"@types/mime-types": "2.1.4",
77+
"@types/node": "22.19.7",
78+
"@types/normalize-path": "3.0.2",
79+
"@types/react": "19.0.10",
80+
"@types/react-dom": "19.0.4",
81+
"@types/webpack": "5.28.5",
82+
"typescript": "5.8.3"
83+
},
8484
"license": "MIT",
8585
"repository": {
8686
"type": "git",

packages/react-email/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,15 @@
3535
"debounce": "^2.0.0",
3636
"esbuild": "^0.25.0",
3737
"glob": "^11.0.0",
38-
"jiti": "2.4.2",
38+
"jiti": "^2.4.2",
3939
"log-symbols": "^7.0.0",
4040
"mime-types": "^3.0.0",
4141
"normalize-path": "^3.0.0",
42-
"nypm": "0.6.2",
4342
"ora": "^8.0.0",
44-
"prompts": "2.4.2",
43+
"prompts": "^2.4.2",
4544
"socket.io": "^4.8.1",
46-
"tsconfig-paths": "4.2.0"
45+
"tar": "^7.5.1",
46+
"tsconfig-paths": "^4.2.0"
4747
},
4848
"devDependencies": {
4949
"@react-email/components": "workspace:*",

packages/react-email/src/commands/build.ts

Lines changed: 41 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import fs from 'node:fs';
22
import path from 'node:path';
3+
import { getPackages } from '@manypkg/get-packages';
34
import logSymbols from 'log-symbols';
4-
import { installDependencies, type PackageManagerName, runScript } from 'nypm';
5+
import { type PackageManagerName, runScript } from 'nypm';
56
import ora from 'ora';
67
import {
78
type EmailsDirectory,
@@ -17,13 +18,15 @@ interface Args {
1718

1819
const setNextEnvironmentVariablesForBuild = async (
1920
emailsDirRelativePath: string,
20-
builtPreviewAppPath: string,
21+
appPath: string,
22+
rootDirectory: string,
2123
) => {
2224
const nextConfigContents = `
2325
import path from 'path';
2426
const emailsDirRelativePath = path.normalize('${emailsDirRelativePath}');
2527
const userProjectLocation = '${process.cwd().replace(/\\/g, '/')}';
26-
const previewServerLocation = '${builtPreviewAppPath.replace(/\\/g, '/')}';
28+
const previewServerLocation = '${appPath.replace(/\\/g, '/')}';
29+
const rootDirectory = '${rootDirectory.replace(/\\/g, '/')}';
2730
/** @type {import('next').NextConfig} */
2831
const nextConfig = {
2932
env: {
@@ -33,7 +36,10 @@ const nextConfig = {
3336
PREVIEW_SERVER_LOCATION: previewServerLocation,
3437
USER_PROJECT_LOCATION: userProjectLocation
3538
},
36-
outputFileTracingRoot: previewServerLocation,
39+
turbopack: {
40+
root: rootDirectory,
41+
},
42+
outputFileTracingRoot: rootDirectory,
3743
serverExternalPackages: ['esbuild'],
3844
typescript: {
3945
ignoreBuildErrors: true
@@ -44,7 +50,7 @@ const nextConfig = {
4450
export default nextConfig`;
4551

4652
await fs.promises.writeFile(
47-
path.resolve(builtPreviewAppPath, './next.config.mjs'),
53+
path.resolve(appPath, './next.config.mjs'),
4854
nextConfigContents,
4955
'utf8',
5056
);
@@ -84,7 +90,7 @@ const getEmailSlugsFromEmailDirectory = (
8490
// after build
8591
const forceSSGForEmailPreviews = async (
8692
emailsDirPath: string,
87-
builtPreviewAppPath: string,
93+
appPath: string,
8894
) => {
8995
const emailDirectoryMetadata = (await getEmailsDirectoryMetadata(
9096
emailsDirPath,
@@ -104,15 +110,13 @@ const forceSSGForEmailPreviews = async (
104110
'utf8',
105111
);
106112
};
113+
await removeForceDynamic(path.resolve(appPath, './src/app/layout.tsx'));
107114
await removeForceDynamic(
108-
path.resolve(builtPreviewAppPath, './src/app/layout.tsx'),
109-
);
110-
await removeForceDynamic(
111-
path.resolve(builtPreviewAppPath, './src/app/preview/[...slug]/page.tsx'),
115+
path.resolve(appPath, './src/app/preview/[...slug]/page.tsx'),
112116
);
113117

114118
await fs.promises.appendFile(
115-
path.resolve(builtPreviewAppPath, './src/app/preview/[...slug]/page.tsx'),
119+
path.resolve(appPath, './src/app/preview/[...slug]/page.tsx'),
116120
`
117121
118122
export function generateStaticParams() {
@@ -124,8 +128,8 @@ export function generateStaticParams() {
124128
);
125129
};
126130

127-
const updatePackageJson = async (builtPreviewAppPath: string) => {
128-
const packageJsonPath = path.resolve(builtPreviewAppPath, './package.json');
131+
const updatePackageJson = async (appPath: string) => {
132+
const packageJsonPath = path.resolve(appPath, './package.json');
129133
const packageJson = JSON.parse(
130134
await fs.promises.readFile(packageJsonPath, 'utf8'),
131135
) as {
@@ -134,22 +138,11 @@ const updatePackageJson = async (builtPreviewAppPath: string) => {
134138
dependencies: Record<string, string>;
135139
devDependencies: Record<string, string>;
136140
};
137-
// Turbopack has some errors with the imports in @react-email/tailwind
138141
packageJson.scripts.build =
139142
'cross-env NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning" next build';
140143
packageJson.scripts.start =
141144
'cross-env NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning" next start';
142-
delete packageJson.scripts.postbuild;
143-
144-
packageJson.name = 'preview-server';
145145

146-
for (const [dependency, version] of Object.entries(
147-
packageJson.devDependencies,
148-
)) {
149-
packageJson.devDependencies[dependency] = version.replace('workspace:', '');
150-
}
151-
152-
delete packageJson.devDependencies['@react-email/components'];
153146
delete packageJson.scripts.prepare;
154147

155148
await fs.promises.writeFile(
@@ -165,6 +158,7 @@ export const build = async ({
165158
}: Args) => {
166159
try {
167160
const previewServerLocation = await getPreviewServerLocation();
161+
const { rootDir: rootDirectory } = await getPackages(process.cwd());
168162

169163
const spinner = ora({
170164
text: 'Starting build process...',
@@ -174,6 +168,10 @@ export const build = async ({
174168

175169
spinner.text = `Checking if ${emailsDirRelativePath} folder exists`;
176170
if (!fs.existsSync(emailsDirRelativePath)) {
171+
spinner.stopAndPersist({
172+
symbol: logSymbols.error,
173+
text: `Directory does not exist: ${emailsDirRelativePath}`,
174+
});
177175
process.exit(1);
178176
}
179177

@@ -187,20 +185,32 @@ export const build = async ({
187185
await fs.promises.rm(builtPreviewAppPath, { recursive: true });
188186
}
189187

190-
spinner.text = 'Copying preview app from CLI to `.react-email`';
188+
spinner.text = 'Copying UI source to `.react-email`';
191189
await fs.promises.cp(previewServerLocation, builtPreviewAppPath, {
192190
recursive: true,
193191
filter: (source: string) => {
194-
// do not copy the CLI files
192+
const relativeSource = path.relative(previewServerLocation, source);
195193
return (
196-
!/(\/|\\)cli(\/|\\)?/.test(source) &&
197-
!/(\/|\\)\.next(\/|\\)?/.test(source) &&
198-
!/(\/|\\)\.turbo(\/|\\)?/.test(source) &&
199-
!/(\/|\\)node_modules(\/|\\)?$/.test(source)
194+
!/\.next/.test(relativeSource) &&
195+
!/\.turbo/.test(relativeSource) &&
196+
!/node_modules\/.bin/.test(relativeSource)
200197
);
201198
},
202199
});
203200

201+
await fs.promises.cp(
202+
path.resolve(previewServerLocation, 'node_modules/.bin'),
203+
path.resolve(builtPreviewAppPath, 'node_modules/.bin'),
204+
{
205+
recursive: true,
206+
// With this enabled, means the symlinks remain relative, which is
207+
// what we want for copying it over. If we don't enable this,
208+
// it resolves the symlink to the original location on disk,
209+
// which ends up causing unexpected errors.
210+
verbatimSymlinks: true,
211+
},
212+
);
213+
204214
if (fs.existsSync(staticPath)) {
205215
spinner.text =
206216
'Copying `static` folder into `.react-email/public/static`';
@@ -218,6 +228,7 @@ export const build = async ({
218228
await setNextEnvironmentVariablesForBuild(
219229
emailsDirRelativePath,
220230
builtPreviewAppPath,
231+
rootDirectory,
221232
);
222233

223234
spinner.text = 'Setting server side generation for the email preview pages';
@@ -226,13 +237,6 @@ export const build = async ({
226237
spinner.text = "Updating package.json's build and start scripts";
227238
await updatePackageJson(builtPreviewAppPath);
228239

229-
spinner.text = 'Installing dependencies on `.react-email`';
230-
await installDependencies({
231-
cwd: builtPreviewAppPath,
232-
silent: true,
233-
packageManager,
234-
});
235-
236240
spinner.stopAndPersist({
237241
text: 'Successfully prepared `.react-email` for `next build`',
238242
symbol: logSymbols.success,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.test
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { installPreviewServer } from './get-preview-server-location.js';
4+
import { packageJson } from './packageJson.js';
5+
6+
test.sequential('installPreviewServer()', { timeout: 60_000 }, async () => {
7+
const testDirectory = path.join(import.meta.dirname, '.test');
8+
await installPreviewServer(testDirectory, packageJson.version);
9+
expect(fs.existsSync(testDirectory)).toBe(true);
10+
11+
const importedModule = await import(path.join(testDirectory, 'index.mjs'));
12+
expect({ ...importedModule }).toEqual({
13+
version: packageJson.version,
14+
});
15+
await fs.promises.rm(testDirectory, { recursive: true, force: true });
16+
});

0 commit comments

Comments
 (0)