react-email 4.0.0-alpha.4 → 4.0.0-alpha.6

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 (189) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cli/index.js +1175 -2658
  3. package/dist/cli/index.mjs +18 -12
  4. package/dist/preview/.next/BUILD_ID +1 -1
  5. package/dist/preview/.next/app-build-manifest.json +31 -34
  6. package/dist/preview/.next/app-path-routes-manifest.json +6 -1
  7. package/dist/preview/.next/build-manifest.json +14 -14
  8. package/dist/preview/.next/cache/.rscinfo +1 -1
  9. package/dist/preview/.next/cache/webpack/client-production/0.pack +0 -0
  10. package/dist/preview/.next/cache/webpack/client-production/index.pack +0 -0
  11. package/dist/preview/.next/cache/webpack/edge-server-production/index.pack +0 -0
  12. package/dist/preview/.next/cache/webpack/server-production/0.pack +0 -0
  13. package/dist/preview/.next/cache/webpack/server-production/index.pack +0 -0
  14. package/dist/preview/.next/diagnostics/framework.json +1 -1
  15. package/dist/preview/.next/export-marker.json +6 -1
  16. package/dist/preview/.next/images-manifest.json +57 -1
  17. package/dist/preview/.next/next-minimal-server.js.nft.json +1 -1
  18. package/dist/preview/.next/next-server.js.nft.json +1 -1
  19. package/dist/preview/.next/prerender-manifest.json +41 -1
  20. package/dist/preview/.next/required-server-files.json +310 -1
  21. package/dist/preview/.next/routes-manifest.json +64 -1
  22. package/dist/preview/.next/server/app/_not-found/page.js +1 -1
  23. package/dist/preview/.next/server/app/_not-found/page.js.nft.json +1 -1
  24. package/dist/preview/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  25. package/dist/preview/.next/server/app/favicon.ico/route.js +1 -1
  26. package/dist/preview/.next/server/app/favicon.ico/route.js.nft.json +1 -1
  27. package/dist/preview/.next/server/app/page.js +1 -1
  28. package/dist/preview/.next/server/app/page.js.nft.json +1 -1
  29. package/dist/preview/.next/server/app/page_client-reference-manifest.js +1 -1
  30. package/dist/preview/.next/server/app/preview/[...slug]/page.js +47 -10
  31. package/dist/preview/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
  32. package/dist/preview/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
  33. package/dist/preview/.next/server/app-paths-manifest.json +1 -1
  34. package/dist/preview/.next/server/chunks/171.js +14 -0
  35. package/dist/preview/.next/server/chunks/446.js +6 -0
  36. package/dist/preview/.next/server/chunks/600.js +8 -0
  37. package/dist/preview/.next/server/chunks/811.js +13 -0
  38. package/dist/preview/.next/server/chunks/833.js +1 -0
  39. package/dist/preview/.next/server/functions-config-manifest.json +4 -1
  40. package/dist/preview/.next/server/middleware-build-manifest.js +1 -1
  41. package/dist/preview/.next/server/next-font-manifest.js +1 -1
  42. package/dist/preview/.next/server/next-font-manifest.json +1 -1
  43. package/dist/preview/.next/server/pages/500.html +1 -1
  44. package/dist/preview/.next/server/pages/_app.js +1 -1
  45. package/dist/preview/.next/server/pages/_app.js.nft.json +1 -1
  46. package/dist/preview/.next/server/pages/_document.js +1 -1
  47. package/dist/preview/.next/server/pages/_document.js.nft.json +1 -1
  48. package/dist/preview/.next/server/pages/_error.js +1 -1
  49. package/dist/preview/.next/server/pages/_error.js.nft.json +1 -1
  50. package/dist/preview/.next/server/pages-manifest.json +5 -1
  51. package/dist/preview/.next/server/server-reference-manifest.js +1 -1
  52. package/dist/preview/.next/server/server-reference-manifest.json +1 -1
  53. package/dist/preview/.next/server/webpack-runtime.js +1 -1
  54. package/dist/preview/.next/static/chunks/416-56f79fc7e689f06f.js +1 -0
  55. package/dist/preview/.next/static/chunks/683-8bbfd191e5105f01.js +1 -0
  56. package/dist/preview/.next/static/chunks/744-79730358b37b2212.js +1 -0
  57. package/dist/preview/.next/static/chunks/781-5f16c6bc9d9d4cc1.js +1 -0
  58. package/dist/preview/.next/static/chunks/832ad4be-cb988facfb8f955f.js +1 -0
  59. package/dist/preview/.next/static/chunks/87-38e35f08507de015.js +1 -0
  60. package/dist/preview/.next/static/chunks/{afa401a5-9ebf2515b1397993.js → afa401a5-3e949a1cfd317dd3.js} +3 -3
  61. package/dist/preview/.next/static/chunks/app/_not-found/page-09d694081cc9d4dc.js +1 -0
  62. package/dist/preview/.next/static/chunks/app/layout-a6640e62690d8fd6.js +1 -0
  63. package/dist/preview/.next/static/chunks/app/page-ba68f50b287e7478.js +1 -0
  64. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-4a5b026ab543e27f.js +1 -0
  65. package/dist/preview/.next/static/chunks/framework-c2bd6d936e3077bc.js +1 -0
  66. package/dist/preview/.next/static/chunks/main-44463a8301435b64.js +1 -0
  67. package/dist/preview/.next/static/chunks/main-app-c2e686acf8d370d7.js +1 -0
  68. package/dist/preview/.next/static/chunks/pages/_app-f3011d3f00bb8dba.js +1 -0
  69. package/dist/preview/.next/static/chunks/pages/_error-39a87dee2e97a2a3.js +1 -0
  70. package/dist/preview/.next/static/chunks/{webpack-9255716c9496e606.js → webpack-41e2667c9f086a4f.js} +1 -1
  71. package/dist/preview/.next/static/css/d7df9cfc3e182163.css +3 -0
  72. package/dist/preview/.next/static/gFk9UfWL8joM4iD7-wlKF/_buildManifest.js +1 -0
  73. package/dist/preview/.next/static/media/05613964ce6c782e-s.p.otf +0 -0
  74. package/dist/preview/.next/static/media/11c6126b9369e85e-s.p.otf +0 -0
  75. package/dist/preview/.next/static/media/26cb97734d8cb717-s.p.otf +0 -0
  76. package/dist/preview/.next/static/media/bb6462617151f6b7-s.p.otf +0 -0
  77. package/dist/preview/.next/static/media/cf6daef822ab0142-s.p.otf +0 -0
  78. package/dist/preview/.next/static/media/e4051546b3043204-s.p.otf +0 -0
  79. package/dist/preview/.next/trace +26 -22
  80. package/dist/preview/.next/types/cache-life.d.ts +3 -3
  81. package/package.json +17 -11
  82. package/scripts/build-preview-server.mjs +32 -0
  83. package/scripts/fill-caniemail-data.mjs +36 -0
  84. package/src/actions/email-validation/caniemail-data.ts +85993 -0
  85. package/src/actions/email-validation/check-compatibility.ts +322 -0
  86. package/src/actions/email-validation/check-images.spec.tsx +21 -12
  87. package/src/actions/email-validation/check-images.ts +88 -86
  88. package/src/actions/email-validation/check-links.spec.tsx +24 -14
  89. package/src/actions/email-validation/check-links.ts +59 -56
  90. package/src/actions/get-email-path-from-slug.ts +1 -1
  91. package/src/actions/render-email-by-path.tsx +2 -1
  92. package/src/{utils/emails-directory-absolute-path.ts → app/env.ts} +2 -0
  93. package/src/app/fonts/SFMono/SFMonoBold.otf +0 -0
  94. package/src/app/fonts/SFMono/SFMonoBoldItalic.otf +0 -0
  95. package/src/app/fonts/SFMono/SFMonoHeavy.otf +0 -0
  96. package/src/app/fonts/SFMono/SFMonoHeavyItalic.otf +0 -0
  97. package/src/app/fonts/SFMono/SFMonoLight.otf +0 -0
  98. package/src/app/fonts/SFMono/SFMonoLightItalic.otf +0 -0
  99. package/src/app/fonts/SFMono/SFMonoMedium.otf +0 -0
  100. package/src/app/fonts/SFMono/SFMonoMediumItalic.otf +0 -0
  101. package/src/app/fonts/SFMono/SFMonoRegular.otf +0 -0
  102. package/src/app/fonts/SFMono/SFMonoRegularItalic.otf +0 -0
  103. package/src/app/fonts/SFMono/SFMonoSemibold.otf +0 -0
  104. package/src/app/fonts/SFMono/SFMonoSemiboldItalic.otf +0 -0
  105. package/src/app/fonts.ts +39 -0
  106. package/src/app/layout.tsx +6 -3
  107. package/src/app/page.tsx +4 -4
  108. package/src/app/preview/[...slug]/page.tsx +73 -16
  109. package/src/app/preview/[...slug]/preview.tsx +49 -77
  110. package/src/components/code.tsx +0 -1
  111. package/src/components/icons/icon-base.tsx +4 -2
  112. package/src/components/icons/icon-reload.tsx +19 -0
  113. package/src/components/icons/icon-scanner.tsx +19 -0
  114. package/src/components/icons/icon-scissors.tsx +19 -0
  115. package/src/components/icons/icon-warning.tsx +31 -0
  116. package/src/components/send.tsx +1 -2
  117. package/src/components/shell.tsx +52 -88
  118. package/src/components/sidebar/file-tree-directory-children.tsx +1 -1
  119. package/src/components/sidebar/file-tree.tsx +1 -1
  120. package/src/components/sidebar/sidebar.tsx +23 -378
  121. package/src/components/toolbar/linter.tsx +310 -0
  122. package/src/components/toolbar/results-table.tsx +0 -0
  123. package/src/components/toolbar/results.tsx +48 -0
  124. package/src/components/toolbar/spam-assassin.tsx +144 -0
  125. package/src/components/toolbar/toolbar-button.tsx +50 -0
  126. package/src/components/toolbar/use-cached-state.ts +33 -0
  127. package/src/components/toolbar.tsx +197 -0
  128. package/src/components/tooltip-content.tsx +1 -2
  129. package/src/components/topbar/view-size-controls.tsx +1 -0
  130. package/src/components/topbar.tsx +29 -48
  131. package/src/contexts/emails.tsx +2 -1
  132. package/src/contexts/preview.tsx +81 -0
  133. package/src/hooks/use-email-rendering-result.ts +2 -1
  134. package/src/utils/__snapshots__/get-email-component.spec.ts.snap +1 -1
  135. package/src/utils/caniemail/all-css-properties.ts +358 -0
  136. package/src/utils/caniemail/ast/get-object-variables.ts +61 -0
  137. package/src/utils/caniemail/ast/get-used-style-properties.ts +91 -0
  138. package/src/utils/caniemail/get-compatibility-stats-for-entry.ts +118 -0
  139. package/src/utils/caniemail/get-css-functions.ts +25 -0
  140. package/src/utils/caniemail/get-css-property-names.ts +32 -0
  141. package/src/utils/caniemail/get-css-property-with-value.ts +14 -0
  142. package/src/utils/caniemail/get-css-unit.ts +3 -0
  143. package/src/utils/caniemail/get-element-attributes.ts +7 -0
  144. package/src/utils/caniemail/get-element-names.ts +20 -0
  145. package/src/utils/caniemail/tailwind/generate-tailwind-rules.ts +30 -0
  146. package/src/utils/caniemail/tailwind/get-tailwind-config.ts +205 -0
  147. package/src/utils/caniemail/tailwind/get-tailwind-metadata.spec.ts +25 -0
  148. package/src/utils/caniemail/tailwind/get-tailwind-metadata.ts +45 -0
  149. package/src/utils/caniemail/tailwind/setup-tailwind-context.ts +15 -0
  150. package/src/utils/get-email-component.ts +34 -67
  151. package/src/utils/linting.ts +85 -0
  152. package/src/utils/result.ts +49 -0
  153. package/src/utils/run-bundled-code.ts +64 -0
  154. package/tailwind-internals.d.ts +133 -0
  155. package/tailwind.config.ts +1 -0
  156. package/tsconfig.json +9 -3
  157. package/build-preview-server.mjs +0 -25
  158. package/dist/preview/.next/server/chunks/196.js +0 -5
  159. package/dist/preview/.next/server/chunks/300.js +0 -13
  160. package/dist/preview/.next/server/chunks/631.js +0 -6
  161. package/dist/preview/.next/server/chunks/644.js +0 -1
  162. package/dist/preview/.next/server/chunks/734.js +0 -15
  163. package/dist/preview/.next/static/Pt6wqIrWnQxbiyqaKNFOx/_buildManifest.js +0 -1
  164. package/dist/preview/.next/static/chunks/285-dbf6306a0d45c33d.js +0 -1
  165. package/dist/preview/.next/static/chunks/447-886131c35ca42b91.js +0 -1
  166. package/dist/preview/.next/static/chunks/490-d5745684930d49e0.js +0 -1
  167. package/dist/preview/.next/static/chunks/5fec7a0a-5179023f3f5a9421.js +0 -1
  168. package/dist/preview/.next/static/chunks/603-36207c8905355e23.js +0 -1
  169. package/dist/preview/.next/static/chunks/797-46f6c20952f0a280.js +0 -2
  170. package/dist/preview/.next/static/chunks/app/_not-found/page-96d3eac723be3ee2.js +0 -1
  171. package/dist/preview/.next/static/chunks/app/layout-d06046b8a368df3b.js +0 -1
  172. package/dist/preview/.next/static/chunks/app/page-ef1c23b954fbd0b5.js +0 -1
  173. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-ea8e1ae2b5a4a0ec.js +0 -1
  174. package/dist/preview/.next/static/chunks/framework-e7cae9cecd5c9ba2.js +0 -1
  175. package/dist/preview/.next/static/chunks/main-app-9f2fb5ea26e2765b.js +0 -1
  176. package/dist/preview/.next/static/chunks/main-df761fde212f9cda.js +0 -1
  177. package/dist/preview/.next/static/chunks/pages/_app-203a61b355820ccf.js +0 -1
  178. package/dist/preview/.next/static/chunks/pages/_error-1764ca54938748c8.js +0 -1
  179. package/dist/preview/.next/static/css/e4822d5ba3082a95.css +0 -3
  180. package/dist/preview/.next/static/css/ec5d7e66bd3b6cb8.css +0 -1
  181. package/src/app/inter.ts +0 -7
  182. package/src/components/icons/icon-circle-check.tsx +0 -21
  183. package/src/components/icons/icon-circle-close.tsx +0 -17
  184. package/src/components/icons/icon-circle-warning.tsx +0 -17
  185. package/src/components/sidebar/image-checker.tsx +0 -162
  186. package/src/components/sidebar/link-checker.tsx +0 -151
  187. package/src/components/sidebar/spam-assassin.tsx +0 -158
  188. /package/dist/preview/.next/static/{Pt6wqIrWnQxbiyqaKNFOx → gFk9UfWL8joM4iD7-wlKF}/_ssgManifest.js +0 -0
  189. /package/src/components/{sidebar → toolbar}/checking-results.tsx +0 -0
@@ -0,0 +1,322 @@
1
+ 'use server';
2
+ import { parse } from '@babel/parser';
3
+ import traverse from '@babel/traverse';
4
+ import {
5
+ type SourceLocation,
6
+ convertLocationIntoObject,
7
+ getObjectVariables,
8
+ } from '../../utils/caniemail/ast/get-object-variables';
9
+ import type { StylePropertyUsage } from '../../utils/caniemail/ast/get-used-style-properties';
10
+ import {
11
+ doesPropertyHaveLocation,
12
+ getUsedStyleProperties,
13
+ } from '../../utils/caniemail/ast/get-used-style-properties';
14
+ import type {
15
+ CompatibilityStats,
16
+ SupportStatus,
17
+ } from '../../utils/caniemail/get-compatibility-stats-for-entry';
18
+ import { getCompatibilityStatsForEntry } from '../../utils/caniemail/get-compatibility-stats-for-entry';
19
+ import { getCssFunctions } from '../../utils/caniemail/get-css-functions';
20
+ import { getCssPropertyNames } from '../../utils/caniemail/get-css-property-names';
21
+ import { getCssPropertyWithValue } from '../../utils/caniemail/get-css-property-with-value';
22
+ import { getCssUnit } from '../../utils/caniemail/get-css-unit';
23
+ import { getElementAttributes } from '../../utils/caniemail/get-element-attributes';
24
+ import { getElementNames } from '../../utils/caniemail/get-element-names';
25
+ import { supportEntries } from './caniemail-data';
26
+
27
+ export interface CompatibilityCheckingResult {
28
+ location: SourceLocation;
29
+ source: string;
30
+ entry: SupportEntry;
31
+ status: SupportStatus;
32
+ statsPerEmailClient: CompatibilityStats['perEmailClient'];
33
+ }
34
+
35
+ export type EmailClient =
36
+ | 'gmail'
37
+ | 'outlook'
38
+ | 'yahoo'
39
+ | 'apple-mail'
40
+ | 'aol'
41
+ | 'thunderbird'
42
+ | 'microsoft'
43
+ | 'samsung-email'
44
+ | 'sfr'
45
+ | 'orange'
46
+ | 'protonmail'
47
+ | 'hey'
48
+ | 'mail-ru'
49
+ | 'fastmail'
50
+ | 'laposte'
51
+ | 't-online-de'
52
+ | 'free-fr'
53
+ | 'gmx'
54
+ | 'web-de'
55
+ | 'ionos-1and1'
56
+ | 'rainloop'
57
+ | 'wp-pl';
58
+
59
+ export type Platform =
60
+ | 'desktop-app'
61
+ | 'desktop-webmail'
62
+ | 'mobile-webmail'
63
+ | 'webmail'
64
+ | 'ios'
65
+ | 'android'
66
+ | 'windows'
67
+ | 'macos'
68
+ | 'windows-mail'
69
+ | 'outlook-com';
70
+
71
+ export type SupportEntryCategroy = 'html' | 'css' | 'image' | 'others';
72
+
73
+ export interface SupportEntry {
74
+ slug: string;
75
+ title: string;
76
+ description: string | null;
77
+ url: string;
78
+ category: SupportEntryCategroy;
79
+ tags: string[];
80
+ keywords: string | null;
81
+ last_test_date: string;
82
+ test_url: string;
83
+ test_results_url: string | null;
84
+ stats: Partial<
85
+ Record<
86
+ EmailClient,
87
+ Partial<
88
+ Record<
89
+ Platform,
90
+ /*
91
+ This last Record<string, string> has only one key, as the
92
+ ordered version of caniemail's data is meant to be something like:
93
+
94
+ [
95
+ { "1.0": "u" },
96
+ { "2.0": "y" },
97
+ { "3.0": "p #1" },
98
+ ]
99
+
100
+ So only one key for each object inside of this array, TypeScript can't really
101
+ describe this though AFAIK.
102
+ */
103
+ Record</* version */ string, string>[]
104
+ >
105
+ >
106
+ >
107
+ >;
108
+ notes: string | null;
109
+ notes_by_num: Record<number, string> | null;
110
+ }
111
+
112
+ const relevantEmailClients: EmailClient[] = [
113
+ 'gmail',
114
+ 'apple-mail',
115
+ 'hey',
116
+ 'outlook',
117
+ 'yahoo',
118
+ ];
119
+
120
+ export const checkCompatibility = async (
121
+ reactCode: string,
122
+ emailPath: string,
123
+ ) => {
124
+ const ast = parse(reactCode, {
125
+ strictMode: false,
126
+ errorRecovery: true,
127
+ sourceType: 'unambiguous',
128
+ plugins: ['jsx', 'typescript', 'decorators'],
129
+ });
130
+
131
+ const getSourceCodeAt = (location: SourceLocation) => {
132
+ const codeLines = reactCode.split(/\n|\r|\r\n/);
133
+ const source = codeLines
134
+ .slice(
135
+ Math.max(location.start.line - 2, 0),
136
+ Math.min(location.end.line + 2, codeLines.length),
137
+ )
138
+ .join('\n');
139
+ return source;
140
+ };
141
+
142
+ const objectVariables = getObjectVariables(ast);
143
+ const usedStyleProperties = await getUsedStyleProperties(
144
+ ast,
145
+ reactCode,
146
+ emailPath,
147
+ objectVariables,
148
+ );
149
+ const readableStream = new ReadableStream<CompatibilityCheckingResult>({
150
+ async start(controller) {
151
+ for (const entry of supportEntries) {
152
+ const compatibilityStats = getCompatibilityStatsForEntry(
153
+ entry,
154
+ relevantEmailClients,
155
+ );
156
+ if (Object.keys(compatibilityStats.perEmailClient).length === 0)
157
+ continue;
158
+ if (compatibilityStats.status === 'success') continue;
159
+
160
+ if (entry.category === 'html') {
161
+ const entryElements = getElementNames(entry.title, entry.keywords);
162
+ const entryAttributes = getElementAttributes(entry.title);
163
+ const htmlEntryType = (() => {
164
+ if (entryElements.length > 0) {
165
+ return 'element';
166
+ }
167
+
168
+ if (entryAttributes.length > 0) {
169
+ return 'attribute';
170
+ }
171
+ })();
172
+
173
+ if (!htmlEntryType) continue;
174
+
175
+ let addedInsight = false;
176
+ if (htmlEntryType === 'element') {
177
+ traverse(ast, {
178
+ JSXOpeningElement(path) {
179
+ if (path.node.name.type === 'JSXIdentifier' && !addedInsight) {
180
+ const elementName = path.node.name.name;
181
+ if (
182
+ entryElements.includes(elementName) &&
183
+ path.node.name.loc
184
+ ) {
185
+ addedInsight = true;
186
+ controller.enqueue({
187
+ entry,
188
+ source: getSourceCodeAt(path.node.name.loc),
189
+ location: convertLocationIntoObject(path.node.name.loc),
190
+ statsPerEmailClient: compatibilityStats.perEmailClient,
191
+ status: compatibilityStats.status,
192
+ });
193
+ }
194
+ }
195
+ },
196
+ });
197
+ } else {
198
+ traverse(ast, {
199
+ JSXAttribute(path) {
200
+ if (path.node.name.type === 'JSXIdentifier' && !addedInsight) {
201
+ const attributeName = path.node.name.name;
202
+ if (
203
+ entryAttributes.includes(attributeName) &&
204
+ path.node.name.loc
205
+ ) {
206
+ addedInsight = true;
207
+ controller.enqueue({
208
+ entry,
209
+ source: getSourceCodeAt(path.node.name.loc),
210
+ location: convertLocationIntoObject(path.node.name.loc),
211
+ statsPerEmailClient: compatibilityStats.perEmailClient,
212
+ status: compatibilityStats.status,
213
+ });
214
+ }
215
+ }
216
+ },
217
+ });
218
+ }
219
+ }
220
+
221
+ if (entry.category === 'css') {
222
+ const entryFullProperty = getCssPropertyWithValue(entry.title);
223
+ const entryProperties = getCssPropertyNames(
224
+ entry.title,
225
+ entry.keywords,
226
+ );
227
+ const entryUnit = getCssUnit(entry.title);
228
+ const entryFunctions = getCssFunctions(entry.title);
229
+
230
+ const cssEntryType = (() => {
231
+ if (entryFullProperty?.name && entryFullProperty.value) {
232
+ return 'full property';
233
+ }
234
+
235
+ if (entryFunctions.length > 0) {
236
+ return 'function';
237
+ }
238
+
239
+ if (entryUnit) {
240
+ return 'unit';
241
+ }
242
+
243
+ if (entryProperties.length > 0) {
244
+ return 'property name';
245
+ }
246
+ })();
247
+
248
+ if (!cssEntryType) continue;
249
+ const addToInsights = (
250
+ property: StylePropertyUsage & { location: SourceLocation },
251
+ ) => {
252
+ controller.enqueue({
253
+ entry,
254
+ location: convertLocationIntoObject(property.location),
255
+ source: getSourceCodeAt(property.location),
256
+ statsPerEmailClient: compatibilityStats.perEmailClient,
257
+ status: compatibilityStats.status,
258
+ });
259
+ };
260
+
261
+ for (const property of usedStyleProperties) {
262
+ if (!doesPropertyHaveLocation(property)) {
263
+ throw new Error(
264
+ "One of the properties' node did not contain the proper location for it on the source code. This must be an issue because we always need access to the source.",
265
+ {
266
+ cause: {
267
+ property,
268
+ entry,
269
+ reactCode,
270
+ ast,
271
+ },
272
+ },
273
+ );
274
+ }
275
+
276
+ if (cssEntryType === 'full property') {
277
+ if (
278
+ property.name === entryFullProperty?.name &&
279
+ property.value === entryFullProperty.value
280
+ ) {
281
+ addToInsights(property);
282
+ break;
283
+ }
284
+ } else if (cssEntryType === 'function') {
285
+ const functionRegex =
286
+ /(?<functionName>[a-zA-Z_][a-zA-Z0-9_-]*)\s*\(/g;
287
+ const functionName = functionRegex.exec(property.value)?.groups
288
+ ?.functionName;
289
+ if (functionName !== undefined) {
290
+ if (entryFunctions.includes(functionName)) {
291
+ addToInsights(property);
292
+ break;
293
+ }
294
+ }
295
+ } else if (cssEntryType === 'unit') {
296
+ const match = property.value.match(/[0-9](?<unit>[a-zA-Z%]+)$/g);
297
+ if (match) {
298
+ const unit = match.groups?.unit;
299
+ if (entryUnit && unit && entryUnit === unit) {
300
+ addToInsights(property);
301
+ break;
302
+ }
303
+ }
304
+ } else if (
305
+ entryProperties.some(
306
+ (propertyName) => property.name === propertyName,
307
+ )
308
+ ) {
309
+ addToInsights(property);
310
+ break;
311
+ }
312
+ }
313
+ }
314
+ }
315
+ controller.close();
316
+ },
317
+ });
318
+
319
+ return readableStream;
320
+ };
321
+
322
+ export type AST = ReturnType<typeof parse>;
@@ -2,19 +2,28 @@ import { render } from '@react-email/render';
2
2
  import { type ImageCheckingResult, checkImages } from './check-images';
3
3
 
4
4
  test('checkImages()', async () => {
5
- expect(
6
- await checkImages(
7
- await render(
8
- <div>
9
- {/* biome-ignore lint/a11y/useAltText: This is intentional to test the checking for accessibility */}
10
- <img src="https://resend.com/static/brand/resend-icon-white.png" />,
11
- <img src="/static/codepen-challengers.png" alt="codepen challenges" />
12
- ,
13
- </div>,
14
- ),
15
- 'https://demo.react.email',
5
+ const results: ImageCheckingResult[] = [];
6
+ const stream = await checkImages(
7
+ await render(
8
+ <div>
9
+ {/* biome-ignore lint/a11y/useAltText: This is intentional to test the checking for accessibility */}
10
+ <img src="https://resend.com/static/brand/resend-icon-white.png" />,
11
+ <img src="/static/codepen-challengers.png" alt="codepen challenges" />,
12
+ </div>,
16
13
  ),
17
- ).toEqual([
14
+ 'https://demo.react.email',
15
+ );
16
+ const reader = stream.getReader();
17
+ while (true) {
18
+ const { done, value } = await reader.read();
19
+ if (value) {
20
+ results.push(value);
21
+ }
22
+ if (done) {
23
+ break;
24
+ }
25
+ }
26
+ expect(results).toEqual([
18
27
  {
19
28
  source: 'https://resend.com/static/brand/resend-icon-white.png',
20
29
  checks: [
@@ -4,7 +4,7 @@ import type { IncomingMessage } from 'node:http';
4
4
  import { parse } from 'node-html-parser';
5
5
  import { quickFetch } from './quick-fetch';
6
6
 
7
- type Check = { passed: boolean } & (
7
+ export type ImageCheck = { passed: boolean } & (
8
8
  | {
9
9
  type: 'accessibility';
10
10
  metadata: {
@@ -34,7 +34,7 @@ type Check = { passed: boolean } & (
34
34
  export interface ImageCheckingResult {
35
35
  status: 'success' | 'warning' | 'error';
36
36
  source: string;
37
- checks: Check[];
37
+ checks: ImageCheck[];
38
38
  }
39
39
 
40
40
  const getResponseSizeInBytes = async (res: IncomingMessage) => {
@@ -48,94 +48,96 @@ const getResponseSizeInBytes = async (res: IncomingMessage) => {
48
48
  export const checkImages = async (code: string, base: string) => {
49
49
  const ast = parse(code);
50
50
 
51
- const imageCheckingResults: ImageCheckingResult[] = [];
52
-
53
- const images = ast.querySelectorAll('img');
54
- for await (const image of images) {
55
- const rawSource = image.attributes.src;
56
- if (!rawSource) continue;
57
- if (imageCheckingResults.some((result) => result.source === rawSource))
58
- continue;
59
-
60
- const source = rawSource?.startsWith('/')
61
- ? `${base}${rawSource}`
62
- : rawSource;
63
-
64
- const result: ImageCheckingResult = {
65
- source: rawSource,
66
- status: 'success',
67
- checks: [],
68
- };
69
-
70
- const alt = image.attributes.alt;
71
- result.checks.push({
72
- passed: alt !== undefined,
73
- type: 'accessibility',
74
- metadata: {
75
- alt,
76
- },
77
- });
78
- if (alt === undefined) {
79
- result.status = 'warning';
80
- }
51
+ const readableStream = new ReadableStream<ImageCheckingResult>({
52
+ async start(controller) {
53
+ const images = ast.querySelectorAll('img');
54
+ for await (const image of images) {
55
+ const rawSource = image.attributes.src;
56
+ if (!rawSource) continue;
81
57
 
82
- try {
83
- const url = new URL(source);
84
- result.checks.push({
85
- passed: true,
86
- type: 'syntax',
87
- });
58
+ const source = rawSource?.startsWith('/')
59
+ ? `${base}${rawSource}`
60
+ : rawSource;
88
61
 
89
- if (source.startsWith('https://')) {
90
- result.checks.push({
91
- passed: true,
92
- type: 'security',
93
- });
94
- } else {
62
+ const result: ImageCheckingResult = {
63
+ source: rawSource,
64
+ status: 'success',
65
+ checks: [],
66
+ };
67
+
68
+ const alt = image.attributes.alt;
95
69
  result.checks.push({
96
- passed: false,
97
- type: 'security',
70
+ passed: alt !== undefined,
71
+ type: 'accessibility',
72
+ metadata: {
73
+ alt,
74
+ },
98
75
  });
99
- result.status = 'warning';
100
- }
101
-
102
- const res = await quickFetch(url);
103
- const hasSucceeded = res.statusCode?.toString().startsWith('2') ?? false;
104
-
105
- result.checks.push({
106
- type: 'fetch_attempt',
107
- passed: hasSucceeded,
108
- metadata: {
109
- fetchStatusCode: res.statusCode,
110
- },
111
- });
112
- if (!hasSucceeded) {
113
- result.status = res.statusCode?.toString().startsWith('3')
114
- ? 'warning'
115
- : 'error';
116
- }
117
-
118
- const responseSizeBytes = await getResponseSizeInBytes(res);
119
- result.checks.push({
120
- type: 'image_size',
121
- passed: responseSizeBytes < 1_048_576, // 1024 x 1024 bytes
122
- metadata: {
123
- byteCount: responseSizeBytes,
124
- },
125
- });
126
- if (responseSizeBytes > 1_048_576) {
127
- result.status = 'warning';
76
+ if (alt === undefined) {
77
+ result.status = 'warning';
78
+ }
79
+
80
+ try {
81
+ const url = new URL(source);
82
+ result.checks.push({
83
+ passed: true,
84
+ type: 'syntax',
85
+ });
86
+
87
+ if (rawSource.startsWith('http://')) {
88
+ result.checks.push({
89
+ passed: false,
90
+ type: 'security',
91
+ });
92
+ result.status = 'warning';
93
+ } else {
94
+ result.checks.push({
95
+ passed: true,
96
+ type: 'security',
97
+ });
98
+ }
99
+
100
+ const res = await quickFetch(url);
101
+ const hasSucceeded =
102
+ res.statusCode?.toString().startsWith('2') ?? false;
103
+
104
+ result.checks.push({
105
+ type: 'fetch_attempt',
106
+ passed: hasSucceeded,
107
+ metadata: {
108
+ fetchStatusCode: res.statusCode,
109
+ },
110
+ });
111
+ if (!hasSucceeded) {
112
+ result.status = res.statusCode?.toString().startsWith('3')
113
+ ? 'warning'
114
+ : 'error';
115
+ }
116
+
117
+ const responseSizeBytes = await getResponseSizeInBytes(res);
118
+ result.checks.push({
119
+ type: 'image_size',
120
+ passed: responseSizeBytes < 1_048_576, // 1024 x 1024 bytes
121
+ metadata: {
122
+ byteCount: responseSizeBytes,
123
+ },
124
+ });
125
+ if (responseSizeBytes > 1_048_576) {
126
+ result.status = 'warning';
127
+ }
128
+ } catch (exception) {
129
+ result.checks.push({
130
+ passed: false,
131
+ type: 'syntax',
132
+ });
133
+ result.status = 'error';
134
+ }
135
+
136
+ controller.enqueue(result);
128
137
  }
129
- } catch (exception) {
130
- result.checks.push({
131
- passed: false,
132
- type: 'syntax',
133
- });
134
- result.status = 'error';
135
- }
136
-
137
- imageCheckingResults.push(result);
138
- }
138
+ controller.close();
139
+ },
140
+ });
139
141
 
140
- return imageCheckingResults;
142
+ return readableStream;
141
143
  };
@@ -2,18 +2,28 @@ import { render } from '@react-email/render';
2
2
  import { type LinkCheckingResult, checkLinks } from './check-links';
3
3
 
4
4
  test('checkLinks()', async () => {
5
- expect(
6
- await checkLinks(
7
- await render(
8
- <div>
9
- <a href="/">Root</a>
10
- <a href="https://resend.com">Resend</a>
11
- <a href="https://notion.so">Notion</a>
12
- <a href="http://example.com">Example unsafe</a>
13
- </div>,
14
- ),
5
+ const results: LinkCheckingResult[] = [];
6
+ const stream = await checkLinks(
7
+ await render(
8
+ <div>
9
+ <a href="/">Root</a>
10
+ <a href="https://resend.com">Resend</a>
11
+ <a href="https://notion.so">Notion</a>
12
+ <a href="http://react.email">React Email unsafe</a>
13
+ </div>,
15
14
  ),
16
- ).toEqual([
15
+ );
16
+ const reader = stream.getReader();
17
+ while (true) {
18
+ const { done, value } = await reader.read();
19
+ if (value) {
20
+ results.push(value);
21
+ }
22
+ if (done) {
23
+ break;
24
+ }
25
+ }
26
+ expect(results).toEqual([
17
27
  {
18
28
  status: 'error',
19
29
  checks: [
@@ -80,12 +90,12 @@ test('checkLinks()', async () => {
80
90
  {
81
91
  type: 'fetch_attempt',
82
92
  metadata: {
83
- fetchStatusCode: 200,
93
+ fetchStatusCode: 308,
84
94
  },
85
- passed: true,
95
+ passed: false,
86
96
  },
87
97
  ],
88
- link: 'http://example.com',
98
+ link: 'http://react.email',
89
99
  },
90
100
  ] satisfies LinkCheckingResult[]);
91
101
  });