@@ -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
+ / d a t a - s o u r c e - f i l e = " (?< 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
} ;
0 commit comments