@react-email/preview-server 4.1.2 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +5 -5
  3. package/.next/build-manifest.json +2 -2
  4. package/.next/next-minimal-server.js.nft.json +1 -1
  5. package/.next/next-server.js.nft.json +1 -1
  6. package/.next/prerender-manifest.json +3 -3
  7. package/.next/server/app/_not-found/page.js +1 -1
  8. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  9. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  10. package/.next/server/app/page.js +1 -1
  11. package/.next/server/app/page.js.nft.json +1 -1
  12. package/.next/server/app/page_client-reference-manifest.js +1 -1
  13. package/.next/server/app/preview/[...slug]/page.js +134 -83
  14. package/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
  15. package/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
  16. package/.next/server/chunks/530.js +1 -0
  17. package/.next/server/chunks/715.js +1 -0
  18. package/.next/server/pages/500.html +1 -1
  19. package/.next/server/server-reference-manifest.js +1 -1
  20. package/.next/server/server-reference-manifest.json +1 -1
  21. package/.next/static/chunks/{483-c76320839aba32cc.js → 483-ea982f13ef64ac58.js} +1 -1
  22. package/.next/static/chunks/app/layout-ba38c60129649bc0.js +1 -0
  23. package/.next/static/chunks/app/preview/[...slug]/page-ef840d8f38306c17.js +1 -0
  24. package/.next/static/css/e2f28c91a6a919eb.css +3 -0
  25. package/.next/trace +27 -27
  26. package/CHANGELOG.md +12 -0
  27. package/jsx-runtime/jsx-dev-runtime.js +24 -0
  28. package/package.json +2 -2
  29. package/scripts/dev.mts +1 -0
  30. package/src/actions/render-email-by-path.tsx +122 -15
  31. package/src/app/env.ts +3 -0
  32. package/src/app/preview/[...slug]/error-overlay.tsx +57 -0
  33. package/src/app/preview/[...slug]/preview.tsx +2 -2
  34. package/src/utils/caniemail/tailwind/get-tailwind-config.spec.ts +44 -0
  35. package/src/utils/caniemail/tailwind/get-tailwind-config.ts +43 -64
  36. package/src/utils/caniemail/tailwind/tests/dummy-email-template.tsx +10 -0
  37. package/src/utils/caniemail/tailwind/tests/tailwind.config.ts +6 -0
  38. package/src/utils/{improve-error-with-sourcemap.ts → convert-stack-with-sourcemap.ts} +6 -11
  39. package/src/utils/create-jsx-runtime.ts +37 -0
  40. package/src/utils/get-email-component.spec.ts +2 -0
  41. package/src/utils/get-email-component.ts +42 -24
  42. package/src/utils/run-bundled-code.ts +10 -5
  43. package/.next/server/chunks/254.js +0 -1
  44. package/.next/static/chunks/app/layout-78fcd7716b488065.js +0 -1
  45. package/.next/static/chunks/app/preview/[...slug]/page-7fb6c9848dc9e8b0.js +0 -1
  46. package/.next/static/css/48dcea18d820a298.css +0 -3
  47. package/.next/types/app/layout.ts +0 -84
  48. package/src/app/preview/[...slug]/rendering-error.tsx +0 -40
  49. /package/.next/static/{w2GPuRfiK3aIRkrNCFTvo → ZE6fBFiuBPHv_MdtfG4Qn}/_buildManifest.js +0 -0
  50. /package/.next/static/{w2GPuRfiK3aIRkrNCFTvo → ZE6fBFiuBPHv_MdtfG4Qn}/_ssgManifest.js +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @react-email/preview-server
2
2
 
3
+ ## 4.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e52818c: add custom error handling for prettier's syntax errors
8
+
9
+ ## 4.1.3
10
+
11
+ ### Patch Changes
12
+
13
+ - 09d7d9d: improved method of resolving tailwind configs when checking compatibility
14
+
3
15
  ## 4.1.2
4
16
 
5
17
  ### Patch Changes
@@ -0,0 +1,24 @@
1
+ // This hack is necessary because React forces the use of the non-dev JSX runtime
2
+ // when NODE_ENV is set to 'production', which would break the data-source references
3
+ // we need for stack traces in the preview server.
4
+ const ReactJSXDevRuntime = require('react/jsx-dev-runtime');
5
+
6
+ export function jsxDEV(type, props, key, isStaticChildren, source, self) {
7
+ const newProps = { ...props };
8
+
9
+ if (source && shouldIncludeSourceReference) {
10
+ newProps['data-source-file'] = source.fileName;
11
+ newProps['data-source-line'] = source.lineNumber;
12
+ }
13
+
14
+ return ReactJSXDevRuntime.jsxDEV(
15
+ type,
16
+ newProps,
17
+ key,
18
+ isStaticChildren,
19
+ source,
20
+ self,
21
+ );
22
+ }
23
+
24
+ export const Fragment = ReactJSXDevRuntime.Fragment;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-email/preview-server",
3
- "version": "4.1.2",
3
+ "version": "4.2.0",
4
4
  "description": "A live preview of your emails right in your browser.",
5
5
  "main": "./index.mjs",
6
6
  "dependencies": {
@@ -61,7 +61,7 @@
61
61
  "postcss": "8.5.3",
62
62
  "tailwindcss": "3.4.0",
63
63
  "typescript": "5.8.3",
64
- "@react-email/components": "0.3.0"
64
+ "@react-email/components": "0.3.1"
65
65
  },
66
66
  "license": "MIT",
67
67
  "repository": {
package/scripts/dev.mts CHANGED
@@ -16,6 +16,7 @@ await fs.writeFile(
16
16
  `EMAILS_DIR_RELATIVE_PATH=./emails
17
17
  EMAILS_DIR_ABSOLUTE_PATH=${emailsDirectoryPath}
18
18
  USER_PROJECT_LOCATION=${previewServerRoot}
19
+ PREVIEW_SERVER_LOCATION=${previewServerRoot}
19
20
  NEXT_PUBLIC_IS_PREVIEW_DEVELOPMENT=true`,
20
21
  'utf8',
21
22
  );
@@ -4,14 +4,25 @@ import path from 'node:path';
4
4
  import chalk from 'chalk';
5
5
  import logSymbols from 'log-symbols';
6
6
  import ora, { type Ora } from 'ora';
7
- import { isBuilding, isPreviewDevelopment } from '../app/env';
7
+ import {
8
+ isBuilding,
9
+ isPreviewDevelopment,
10
+ previewServerLocation,
11
+ userProjectLocation,
12
+ } from '../app/env';
13
+ import { convertStackWithSourceMap } from '../utils/convert-stack-with-sourcemap';
14
+ import { createJsxRuntime } from '../utils/create-jsx-runtime';
8
15
  import { getEmailComponent } from '../utils/get-email-component';
9
- import { improveErrorWithSourceMap } from '../utils/improve-error-with-sourcemap';
10
16
  import { registerSpinnerAutostopping } from '../utils/register-spinner-autostopping';
11
17
  import type { ErrorObject } from '../utils/types/error-object';
12
18
 
13
19
  export interface RenderedEmailMetadata {
14
20
  markup: string;
21
+ /**
22
+ * HTML markup with `data-source-file` and `data-source-line` attributes pointing to the original
23
+ * .jsx/.tsx files corresponding to the rendered tag
24
+ */
25
+ markupWithReferences?: string;
15
26
  plainText: string;
16
27
  reactMarkup: string;
17
28
  }
@@ -44,7 +55,15 @@ export const renderEmailByPath = async (
44
55
  registerSpinnerAutostopping(spinner);
45
56
  }
46
57
 
47
- const componentResult = await getEmailComponent(emailPath);
58
+ const originalJsxRuntimePath = path.resolve(
59
+ previewServerLocation,
60
+ 'jsx-runtime',
61
+ );
62
+ const jsxRuntimePath = await createJsxRuntime(
63
+ userProjectLocation,
64
+ originalJsxRuntimePath,
65
+ );
66
+ const componentResult = await getEmailComponent(emailPath, jsxRuntimePath);
48
67
 
49
68
  if ('error' in componentResult) {
50
69
  spinner?.stopAndPersist({
@@ -58,21 +77,23 @@ export const renderEmailByPath = async (
58
77
  emailComponent: Email,
59
78
  createElement,
60
79
  render,
80
+ renderWithReferences,
61
81
  sourceMapToOriginalFile,
62
82
  } = componentResult;
63
83
 
64
84
  const previewProps = Email.PreviewProps || {};
65
85
  const EmailComponent = Email as React.FC;
66
86
  try {
67
- const markup = await render(createElement(EmailComponent, previewProps), {
87
+ const element = createElement(EmailComponent, previewProps);
88
+ const markupWithReferences = await renderWithReferences(element, {
68
89
  pretty: true,
69
90
  });
70
- const plainText = await render(
71
- createElement(EmailComponent, previewProps),
72
- {
73
- plainText: true,
74
- },
75
- );
91
+ const markup = await render(element, {
92
+ pretty: true,
93
+ });
94
+ const plainText = await render(element, {
95
+ plainText: true,
96
+ });
76
97
 
77
98
  const reactMarkup = await fs.promises.readFile(emailPath, 'utf-8');
78
99
 
@@ -90,11 +111,12 @@ export const renderEmailByPath = async (
90
111
  text: `Successfully rendered ${emailFilename} in ${timeForConsole}`,
91
112
  });
92
113
 
93
- const renderingResult = {
114
+ const renderingResult: RenderedEmailMetadata = {
94
115
  // This ensures that no null byte character ends up in the rendered
95
116
  // markup making users suspect of any issues. These null byte characters
96
117
  // only seem to happen with React 18, as it has no similar incident with React 19.
97
118
  markup: markup.replaceAll('\0', ''),
119
+ markupWithReferences: markupWithReferences.replaceAll('\0', ''),
98
120
  plainText,
99
121
  reactMarkup,
100
122
  };
@@ -110,12 +132,97 @@ export const renderEmailByPath = async (
110
132
  text: `Failed while rendering ${emailFilename}`,
111
133
  });
112
134
 
113
- return {
114
- error: improveErrorWithSourceMap(
115
- error,
135
+ if (exception instanceof SyntaxError) {
136
+ interface SpanPosition {
137
+ file: {
138
+ content: string;
139
+ };
140
+ offset: number;
141
+ line: number;
142
+ col: number;
143
+ }
144
+ // means the email's HTML was invalid and prettier threw this error
145
+ // TODO: always throw when the HTML is invalid during `render`
146
+ const cause = exception.cause as {
147
+ msg: string;
148
+ span: {
149
+ start: SpanPosition;
150
+ end: SpanPosition;
151
+ };
152
+ };
153
+
154
+ const sourceFileAttributeMatches = cause.span.start.file.content.matchAll(
155
+ /data-source-file="(?<file>[^"]*)"/g,
156
+ );
157
+ let closestSourceFileAttribute: RegExpExecArray | undefined;
158
+ for (const sourceFileAttributeMatch of sourceFileAttributeMatches) {
159
+ if (closestSourceFileAttribute === undefined) {
160
+ closestSourceFileAttribute = sourceFileAttributeMatch;
161
+ }
162
+ if (
163
+ Math.abs(sourceFileAttributeMatch.index - cause.span.start.offset) <
164
+ Math.abs(closestSourceFileAttribute.index - cause.span.start.offset)
165
+ ) {
166
+ closestSourceFileAttribute = sourceFileAttributeMatch;
167
+ }
168
+ }
169
+
170
+ const findClosestAttributeValue = (
171
+ attributeName: string,
172
+ ): string | undefined => {
173
+ const attributeMatches = cause.span.start.file.content.matchAll(
174
+ new RegExp(`${attributeName}="(?<value>[^"]*)"`, 'g'),
175
+ );
176
+ let closestAttribute: RegExpExecArray | undefined;
177
+ for (const attributeMatch of attributeMatches) {
178
+ if (closestAttribute === undefined) {
179
+ closestAttribute = attributeMatch;
180
+ }
181
+ if (
182
+ Math.abs(attributeMatch.index - cause.span.start.offset) <
183
+ Math.abs(closestAttribute.index - cause.span.start.offset)
184
+ ) {
185
+ closestAttribute = attributeMatch;
186
+ }
187
+ }
188
+ return closestAttribute?.groups?.value;
189
+ };
190
+
191
+ let stack = convertStackWithSourceMap(
192
+ error.stack,
116
193
  emailPath,
117
194
  sourceMapToOriginalFile,
118
- ),
195
+ );
196
+
197
+ const sourceFile = findClosestAttributeValue('data-source-file');
198
+ const sourceLine = findClosestAttributeValue('data-source-line');
199
+ if (sourceFile && sourceLine) {
200
+ stack = ` at ${sourceFile}:${sourceLine}\n${stack}`;
201
+ }
202
+
203
+ return {
204
+ error: {
205
+ name: exception.name,
206
+ message: cause.msg,
207
+ stack,
208
+ cause: error.cause ? JSON.parse(JSON.stringify(cause)) : undefined,
209
+ },
210
+ };
211
+ }
212
+
213
+ return {
214
+ error: {
215
+ name: error.name,
216
+ message: error.message,
217
+ stack: convertStackWithSourceMap(
218
+ error.stack,
219
+ emailPath,
220
+ sourceMapToOriginalFile,
221
+ ),
222
+ cause: error.cause
223
+ ? JSON.parse(JSON.stringify(error.cause))
224
+ : undefined,
225
+ },
119
226
  };
120
227
  }
121
228
  };
package/src/app/env.ts CHANGED
@@ -5,6 +5,9 @@ export const emailsDirRelativePath = process.env.EMAILS_DIR_RELATIVE_PATH!;
5
5
  /** ONLY ACCESSIBLE ON THE SERVER */
6
6
  export const userProjectLocation = process.env.USER_PROJECT_LOCATION!;
7
7
 
8
+ /** ONLY ACCESSIBLE ON THE SERVER */
9
+ export const previewServerLocation = process.env.PREVIEW_SERVER_LOCATION!;
10
+
8
11
  /** ONLY ACCESSIBLE ON THE SERVER */
9
12
  export const emailsDirectoryAbsolutePath =
10
13
  process.env.EMAILS_DIR_ABSOLUTE_PATH!;
@@ -0,0 +1,57 @@
1
+ 'use client';
2
+ import type { ErrorObject } from '../../../utils/types/error-object';
3
+
4
+ interface ErrorOverlayProps {
5
+ error: ErrorObject;
6
+ }
7
+
8
+ const Message = ({ children: content }: { children: string }) => {
9
+ const match = content.match(
10
+ /(Unexpected closing tag "[^"]+". It may happen when the tag has already been closed by another tag). (For more info see) (.+)/,
11
+ );
12
+ if (match) {
13
+ const [_, errorMessage, moreInfo, link] = match;
14
+ return (
15
+ <>
16
+ {errorMessage}.
17
+ <p className="text-lg">
18
+ {moreInfo}{' '}
19
+ <a className="underline" rel="noreferrer" target="_blank" href={link}>
20
+ {link}
21
+ </a>
22
+ </p>
23
+ </>
24
+ );
25
+ }
26
+ return content;
27
+ };
28
+
29
+ export const ErrorOverlay = ({ error }: ErrorOverlayProps) => {
30
+ return (
31
+ <>
32
+ <div className="absolute inset-0 z-50 bg-black/80" />
33
+ <div
34
+ className="
35
+ min-h-[50vh] w-full max-w-lg sm:rounded-lg md:max-w-[568px] lg:max-w-[920px]
36
+ absolute left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]
37
+ rounded-t-sm overflow-hidden bg-white text-black shadow-lg duration-200
38
+ flex flex-col selection:!text-black
39
+ "
40
+ >
41
+ <div className="bg-red-500 h-3" />
42
+ <div className="flex flex-grow p-6 min-w-0 max-w-full flex-col space-y-1.5">
43
+ <div className="flex-shrink pb-2 text-xl tracking-tight">
44
+ <b>{error.name}</b>: <Message>{error.message}</Message>
45
+ </div>
46
+ {error.stack ? (
47
+ <div className="flex-grow scroll-px-4 overflow-x-auto rounded-lg bg-black p-2 text-gray-100">
48
+ <pre className="w-full min-w-0 font-mono leading-6 selection:!text-cyan-12 text-xs">
49
+ {error.stack}
50
+ </pre>
51
+ </div>
52
+ ) : undefined}
53
+ </div>
54
+ </div>
55
+ </>
56
+ );
57
+ };
@@ -19,7 +19,7 @@ import { ViewSizeControls } from '../../../components/topbar/view-size-controls'
19
19
  import { PreviewContext } from '../../../contexts/preview';
20
20
  import { useClampedState } from '../../../hooks/use-clamped-state';
21
21
  import { cn } from '../../../utils';
22
- import { RenderingError } from './rendering-error';
22
+ import { ErrorOverlay } from './error-overlay';
23
23
 
24
24
  interface PreviewProps extends React.ComponentProps<'div'> {
25
25
  emailTitle: string;
@@ -136,7 +136,7 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
136
136
  };
137
137
  }}
138
138
  >
139
- {hasErrors ? <RenderingError error={renderingResult.error} /> : null}
139
+ {hasErrors ? <ErrorOverlay error={renderingResult.error} /> : null}
140
140
 
141
141
  {hasRenderingMetadata ? (
142
142
  <>
@@ -0,0 +1,44 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parse } from '@babel/parser';
4
+ import { pixelBasedPreset } from '@react-email/components';
5
+ import { getTailwindConfig } from './get-tailwind-config';
6
+
7
+ describe('getTailwindConfig()', () => {
8
+ it("should work on the demo's Vercel Invite template", async () => {
9
+ const sourcePath = path.resolve(
10
+ __dirname,
11
+ '../../../../../../apps/demo/emails/notifications/vercel-invite-user.tsx',
12
+ );
13
+ const sourceCode = await fs.readFile(sourcePath, 'utf8');
14
+ const ast = parse(sourceCode, {
15
+ strictMode: false,
16
+ errorRecovery: true,
17
+ sourceType: 'unambiguous',
18
+ plugins: ['jsx', 'typescript', 'decorators'],
19
+ });
20
+
21
+ expect(await getTailwindConfig(sourceCode, ast, sourcePath)).toEqual({
22
+ presets: [pixelBasedPreset],
23
+ });
24
+ });
25
+
26
+ it('should work with email templates that import the tailwind config', async () => {
27
+ const sourcePath = path.resolve(
28
+ __dirname,
29
+ './tests/dummy-email-template.tsx',
30
+ );
31
+ const sourceCode = await fs.readFile(sourcePath, 'utf8');
32
+ const ast = parse(sourceCode, {
33
+ strictMode: false,
34
+ errorRecovery: true,
35
+ sourceType: 'unambiguous',
36
+ plugins: ['jsx', 'typescript', 'decorators'],
37
+ });
38
+
39
+ expect(await getTailwindConfig(sourceCode, ast, sourcePath)).toEqual({
40
+ theme: {},
41
+ presets: [pixelBasedPreset],
42
+ });
43
+ });
44
+ });
@@ -2,8 +2,10 @@ import path from 'node:path';
2
2
  import type { Node } from '@babel/traverse';
3
3
  import traverse from '@babel/traverse';
4
4
  import * as esbuild from 'esbuild';
5
+ import type { RawSourceMap } from 'source-map-js';
5
6
  import type { Config as TailwindOriginalConfig } from 'tailwindcss';
6
7
  import type { AST } from '../../../actions/email-validation/check-compatibility';
8
+ import { convertStackWithSourceMap } from '../../convert-stack-with-sourcemap';
7
9
  import { isErr } from '../../result';
8
10
  import { runBundledCode } from '../../run-bundled-code';
9
11
 
@@ -23,8 +25,6 @@ export type TailwindConfig = Pick<
23
25
  | 'plugins'
24
26
  >;
25
27
 
26
- type ImportDeclaration = Node & { type: 'ImportDeclaration' };
27
-
28
28
  export const getTailwindConfig = async (
29
29
  sourceCode: string,
30
30
  ast: AST,
@@ -33,35 +33,23 @@ export const getTailwindConfig = async (
33
33
  const configAttribute = getTailwindConfigNode(ast);
34
34
 
35
35
  if (configAttribute) {
36
- const configIdentifierName =
37
- configAttribute.value?.type === 'JSXExpressionContainer' &&
38
- configAttribute.value.expression.type === 'Identifier'
39
- ? configAttribute.value.expression.name
40
- : undefined;
41
- if (configIdentifierName) {
42
- const tailwindConfigImport = getImportWithGivenDefaultSpecifier(
43
- ast,
44
- configIdentifierName,
45
- );
46
- if (tailwindConfigImport) {
47
- return getConfigFromImport(tailwindConfigImport, sourcePath);
48
- }
49
- }
50
-
51
- const configObjectExpression =
52
- configAttribute.value?.type === 'JSXExpressionContainer' &&
53
- configAttribute.value.expression.type === 'ObjectExpression'
36
+ const configExpressionValue =
37
+ configAttribute.value?.type === 'JSXExpressionContainer'
54
38
  ? configAttribute.value.expression
55
39
  : undefined;
56
- if (configObjectExpression?.start && configObjectExpression.end) {
57
- const configObjectSourceCode = sourceCode.slice(
58
- configObjectExpression.start,
59
- configObjectExpression.end,
40
+ if (configExpressionValue?.start && configExpressionValue.end) {
41
+ const configSourceValue = sourceCode.slice(
42
+ configExpressionValue.start,
43
+ configExpressionValue.end,
60
44
  );
61
45
 
62
46
  try {
63
- const getConfig = new Function(`return ${configObjectSourceCode}`);
64
- return getConfig() as TailwindConfig;
47
+ return getConfigFromCode(
48
+ `${sourceCode}
49
+
50
+ const reactEmailTailwindConfigInternal = ${configSourceValue};`,
51
+ sourcePath,
52
+ );
65
53
  } catch (exception) {
66
54
  console.warn(exception);
67
55
  console.warn(
@@ -74,80 +62,71 @@ export const getTailwindConfig = async (
74
62
  return {};
75
63
  };
76
64
 
77
- const getConfigFromImport = async (
78
- tailwindConfigImport: ImportDeclaration,
79
- sourcePath: string,
65
+ const getConfigFromCode = async (
66
+ code: string,
67
+ filepath: string,
80
68
  ): Promise<TailwindConfig> => {
81
- const configRelativePath = tailwindConfigImport.source.value;
82
- const sourceDirpath = path.dirname(sourcePath);
83
- const configFilepath = path.join(sourceDirpath, configRelativePath);
69
+ const configDirpath = path.dirname(filepath);
84
70
 
85
71
  const configBuildResult = await esbuild.build({
86
72
  bundle: true,
87
73
  stdin: {
88
- contents: `import tailwindConfig from "${configRelativePath}";
89
- export { tailwindConfig };`,
74
+ contents: `${code}
75
+ export { reactEmailTailwindConfigInternal };`,
76
+ sourcefile: filepath,
90
77
  loader: 'tsx',
91
- resolveDir: path.dirname(sourcePath),
78
+ resolveDir: configDirpath,
92
79
  },
93
80
  platform: 'node',
81
+ sourcemap: 'external',
82
+ jsx: 'automatic',
83
+ outdir: 'stdout', // just a stub for esbuild, it won't actually write to this folder
94
84
  write: false,
95
85
  format: 'cjs',
96
86
  logLevel: 'silent',
97
87
  });
98
- const configFile = configBuildResult.outputFiles[0];
88
+ const sourceMapFile = configBuildResult.outputFiles[0]!;
89
+ const configFile = configBuildResult.outputFiles[1];
99
90
  if (configFile === undefined) {
100
91
  throw new Error(
101
92
  'Could not build config file as it was found as undefined, this is most likely a bug, please open an issue.',
102
93
  );
103
94
  }
104
- const configModule = runBundledCode(configFile.text, configFilepath);
95
+
96
+ const configModule = runBundledCode(configFile.text, filepath);
105
97
  if (isErr(configModule)) {
106
- throw new Error(
107
- `Error when trying to run the config file: ${configModule.error}`,
98
+ const sourceMap = JSON.parse(sourceMapFile.text) as RawSourceMap;
99
+ // because it will have a path like <tsconfigLocation>/stdout/email.js.map
100
+ sourceMap.sourceRoot = path.resolve(sourceMapFile.path, '../..');
101
+ sourceMap.sources = sourceMap.sources.map((source) =>
102
+ path.resolve(sourceMapFile.path, '..', source),
108
103
  );
104
+
105
+ const error = configModule.error as Error;
106
+ error.stack = convertStackWithSourceMap(error.stack, filepath, sourceMap);
107
+ throw error;
109
108
  }
110
109
 
111
110
  if (
112
111
  typeof configModule.value === 'object' &&
113
112
  configModule.value !== null &&
114
- 'tailwindConfig' in configModule.value
113
+ 'reactEmailTailwindConfigInternal' in configModule.value
115
114
  ) {
116
- return configModule.value.tailwindConfig as TailwindConfig;
115
+ return configModule.value
116
+ .reactEmailTailwindConfigInternal as TailwindConfig;
117
117
  }
118
118
 
119
119
  throw new Error(
120
- `Could not read Tailwind config at ${configFilepath} because it doesn't have a default export in it.`,
120
+ 'Could not get the Tailwind config, this is likely a bug, please file an issue.',
121
121
  {
122
122
  cause: {
123
123
  configModule,
124
- configFilepath,
124
+ configFilepath: filepath,
125
125
  },
126
126
  },
127
127
  );
128
128
  };
129
129
 
130
- const getImportWithGivenDefaultSpecifier = (
131
- ast: AST,
132
- specifierName: string,
133
- ) => {
134
- let importNode: ImportDeclaration | undefined;
135
- traverse(ast, {
136
- ImportDeclaration(nodePath) {
137
- if (
138
- nodePath.node.specifiers.some(
139
- (specifier) =>
140
- specifier.type === 'ImportDefaultSpecifier' &&
141
- specifier.local.name === specifierName,
142
- )
143
- ) {
144
- importNode = nodePath.node;
145
- }
146
- },
147
- });
148
- return importNode;
149
- };
150
-
151
130
  type JSXAttribute = Node & { type: 'JSXAttribute' };
152
131
 
153
132
  const getTailwindConfigNode = (ast: AST) => {
@@ -0,0 +1,10 @@
1
+ import { Tailwind } from '@react-email/components';
2
+ import tailwindConfig from './tailwind.config';
3
+
4
+ export default function EmailTemplate() {
5
+ return (
6
+ <Tailwind config={tailwindConfig}>
7
+ <div className="bg-red-400 w-20" />
8
+ </Tailwind>
9
+ );
10
+ }
@@ -0,0 +1,6 @@
1
+ import { pixelBasedPreset, type TailwindConfig } from '@react-email/components';
2
+
3
+ export default {
4
+ theme: {},
5
+ presets: [pixelBasedPreset],
6
+ } satisfies TailwindConfig;
@@ -1,14 +1,13 @@
1
1
  import path from 'node:path';
2
2
  import { type RawSourceMap, SourceMapConsumer } from 'source-map-js';
3
3
  import * as stackTraceParser from 'stacktrace-parser';
4
- import type { ErrorObject } from './types/error-object';
5
4
 
6
- export const improveErrorWithSourceMap = (
7
- error: Error,
5
+ export const convertStackWithSourceMap = (
6
+ rawStack: string | undefined,
8
7
 
9
8
  originalFilePath: string,
10
9
  sourceMapToOriginalFile: RawSourceMap,
11
- ): ErrorObject => {
10
+ ): string | undefined => {
12
11
  let stack: string | undefined;
13
12
 
14
13
  const sourceRoot =
@@ -32,8 +31,8 @@ export const improveErrorWithSourceMap = (
32
31
  })`;
33
32
  };
34
33
 
35
- if (typeof error.stack !== 'undefined') {
36
- const parsedStack = stackTraceParser.parse(error.stack);
34
+ if (rawStack) {
35
+ const parsedStack = stackTraceParser.parse(rawStack);
37
36
  const sourceMapConsumer = new SourceMapConsumer(sourceMapToOriginalFile);
38
37
  const newStackLines = [] as string[];
39
38
  for (const stackFrame of parsedStack) {
@@ -77,9 +76,5 @@ export const improveErrorWithSourceMap = (
77
76
  stack = newStackLines.join('\n');
78
77
  }
79
78
 
80
- return {
81
- name: error.name,
82
- message: error.message,
83
- stack,
84
- };
79
+ return stack;
85
80
  };
@@ -0,0 +1,37 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import esbuild from 'esbuild';
4
+
5
+ /**
6
+ * Bundles the JSX runtime with the specified {@link cwd}. This is needed because the JSX runtime
7
+ * imports React's which is forcefully the production one if the `NODE_ENV` is set to `production`,
8
+ * even though we want to use the development one.
9
+ *
10
+ * It bundles into `/node_modules/.react-email-jsx-runtime` with the root being the {@link cwd}.
11
+ */
12
+ export const createJsxRuntime = async (
13
+ cwd: string,
14
+ originalJsxRuntimePath: string,
15
+ ) => {
16
+ const jsxRuntimePath = path.join(
17
+ cwd,
18
+ 'node_modules',
19
+ '.react-email-jsx-runtime',
20
+ );
21
+ await esbuild.build({
22
+ bundle: true,
23
+ outfile: path.join(jsxRuntimePath, 'jsx-dev-runtime.js'),
24
+ format: 'cjs',
25
+ logLevel: 'silent',
26
+ stdin: {
27
+ resolveDir: cwd,
28
+ sourcefile: 'jsx-dev-runtime.js',
29
+ loader: 'js',
30
+ contents: await fs.promises.readFile(
31
+ path.join(originalJsxRuntimePath, 'jsx-dev-runtime.js'),
32
+ ),
33
+ },
34
+ });
35
+
36
+ return jsxRuntimePath;
37
+ };