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,310 @@
1
+ import prettyBytes from 'pretty-bytes';
2
+ import { useRef, useState } from 'react';
3
+ import { nicenames } from '../../actions/email-validation/caniemail-data';
4
+ import type { CompatibilityCheckingResult } from '../../actions/email-validation/check-compatibility';
5
+ import type { ImageCheckingResult } from '../../actions/email-validation/check-images';
6
+ import type { LinkCheckingResult } from '../../actions/email-validation/check-links';
7
+ import { cn } from '../../utils';
8
+ import { getLintingSources, loadLintingRowsFrom } from '../../utils/linting';
9
+ import { IconWarning } from '../icons/icon-warning';
10
+ import { Results } from './results';
11
+
12
+ export type LintingRow =
13
+ | {
14
+ source: 'image';
15
+ result: ImageCheckingResult;
16
+ }
17
+ | {
18
+ source: 'link';
19
+ result: LinkCheckingResult;
20
+ }
21
+ | {
22
+ source: 'compatibility';
23
+ result: CompatibilityCheckingResult;
24
+ };
25
+
26
+ interface LinterProps {
27
+ rows: LintingRow[] | undefined;
28
+ }
29
+
30
+ export const useLinter = ({
31
+ markup,
32
+ reactMarkup,
33
+ emailPath,
34
+
35
+ initialRows,
36
+ }: {
37
+ reactMarkup: string;
38
+ markup: string;
39
+ emailPath: string;
40
+
41
+ initialRows?: LintingRow[];
42
+ }) => {
43
+ const [rows, setRows] = useState<LintingRow[] | undefined>(initialRows);
44
+
45
+ const sources = getLintingSources(
46
+ markup,
47
+ reactMarkup,
48
+ emailPath,
49
+ 'location' in global
50
+ ? `${global.location.protocol}//${global.location.host}`
51
+ : '',
52
+ );
53
+
54
+ const [loading, setLoading] = useState(false);
55
+ const isStreaming = useRef(false);
56
+
57
+ const load = async () => {
58
+ if (isStreaming.current) return;
59
+ isStreaming.current = true;
60
+ setLoading(true);
61
+
62
+ setRows([]);
63
+ try {
64
+ let rows: LintingRow[] = [];
65
+ for await (const row of loadLintingRowsFrom(sources)) {
66
+ setRows((current) => {
67
+ if (!current) {
68
+ return [row];
69
+ }
70
+ const newArray = [...current, row];
71
+ newArray.sort((a, b) => {
72
+ if (a.result.status === 'error' && b.result.status === 'warning') {
73
+ return -1;
74
+ }
75
+
76
+ if (a.result.status === 'warning' && b.result.status === 'error') {
77
+ return 1;
78
+ }
79
+
80
+ return 0;
81
+ });
82
+ rows = newArray;
83
+ return newArray;
84
+ });
85
+ }
86
+ return rows;
87
+ } finally {
88
+ setLoading(false);
89
+ isStreaming.current = false;
90
+ }
91
+ };
92
+
93
+ return [rows, { loading, load }] as const;
94
+ };
95
+
96
+ export const Linter = ({ rows }: LinterProps) => {
97
+ if (rows === undefined) return null;
98
+
99
+ return (
100
+ <Results>
101
+ {rows.map((row, i) => {
102
+ if (row.source === 'link') {
103
+ const failingCheck = row.result.checks.find(
104
+ (check) => check.passed === false,
105
+ )!;
106
+ return (
107
+ <Result status={row.result.status} key={i}>
108
+ <Result.Name>{failingCheck.type}</Result.Name>
109
+ <Result.Description>
110
+ {failingCheck.type === 'security'
111
+ ? 'Insecure URL, use HTTPS insted of HTTP'
112
+ : null}
113
+ {failingCheck.type === 'fetch_attempt' &&
114
+ failingCheck.metadata.fetchStatusCode &&
115
+ failingCheck.metadata.fetchStatusCode >= 300 &&
116
+ failingCheck.metadata.fetchStatusCode < 400
117
+ ? 'There was a redirect, the content may have been moved'
118
+ : null}
119
+ {failingCheck.type === 'fetch_attempt' &&
120
+ failingCheck.metadata.fetchStatusCode &&
121
+ failingCheck.metadata.fetchStatusCode >= 400
122
+ ? 'The link is broken'
123
+ : null}
124
+ {failingCheck.type === 'syntax'
125
+ ? 'The link is broken due to invalid syntax'
126
+ : null}
127
+
128
+ <span className="font-mono float-right text-ellipsis overflow-hidden text-nowrap max-w-[30ch]">
129
+ {row.result.link}
130
+ </span>
131
+ </Result.Description>
132
+ <Result.Metadata>
133
+ {failingCheck.type === 'fetch_attempt'
134
+ ? failingCheck.metadata.fetchStatusCode
135
+ : ''}
136
+ </Result.Metadata>
137
+ </Result>
138
+ );
139
+ }
140
+
141
+ if (row.source === 'compatibility') {
142
+ const statsReportedNotWorking = Object.entries(
143
+ row.result.statsPerEmailClient,
144
+ ).filter(([, stats]) => stats.status === 'error');
145
+ const statsReportedPartiallyWorking = Object.entries(
146
+ row.result.statsPerEmailClient,
147
+ ).filter(([, stats]) => stats.status === 'warning');
148
+
149
+ const unsupportedClientsString = statsReportedNotWorking
150
+ .map(([emailClient]) => nicenames.family[emailClient])
151
+ .join(', ');
152
+ const partiallySupportedClientsString = statsReportedPartiallyWorking
153
+ .map(([emailClient]) => nicenames.family[emailClient])
154
+ .join(', ');
155
+
156
+ return (
157
+ <Result status={row.result.status} key={i}>
158
+ <Result.Name>{row.result.entry.title}</Result.Name>
159
+ <Result.Description>
160
+ {statsReportedNotWorking.length > 0
161
+ ? `Not supported in ${unsupportedClientsString}`
162
+ : null}
163
+ {statsReportedPartiallyWorking.length > 0 &&
164
+ statsReportedNotWorking.length > 0
165
+ ? '. '
166
+ : null}
167
+ {statsReportedPartiallyWorking.length > 0
168
+ ? `Partially supported in ${partiallySupportedClientsString}`
169
+ : null}
170
+ </Result.Description>
171
+ <Result.Metadata>
172
+ {row.result.location.start.line.toString().padStart(2, '0')}:
173
+ {row.result.location.start.column.toString().padStart(2, '0')}
174
+ <a
175
+ href={row.result.entry.url}
176
+ className="underline ml-2"
177
+ rel="noreferrer"
178
+ target="_blank"
179
+ >
180
+ See more info
181
+ </a>
182
+ </Result.Metadata>
183
+ </Result>
184
+ );
185
+ }
186
+
187
+ if (row.source === 'image') {
188
+ const failingCheck = row.result.checks.find(
189
+ (check) => check.passed === false,
190
+ )!;
191
+ return (
192
+ <Result status={row.result.status} key={i}>
193
+ <Result.Name>{failingCheck.type}</Result.Name>
194
+ <Result.Description>
195
+ {failingCheck.type === 'security'
196
+ ? 'Insecure URL, use HTTPS insted of HTTP'
197
+ : null}
198
+ {failingCheck.type === 'fetch_attempt' &&
199
+ failingCheck.metadata.fetchStatusCode &&
200
+ failingCheck.metadata.fetchStatusCode >= 300 &&
201
+ failingCheck.metadata.fetchStatusCode < 400
202
+ ? 'There was a redirect, the image may have been moved'
203
+ : null}
204
+ {failingCheck.type === 'fetch_attempt' &&
205
+ failingCheck.metadata.fetchStatusCode &&
206
+ failingCheck.metadata.fetchStatusCode >= 400
207
+ ? 'The image is broken'
208
+ : null}
209
+ {failingCheck.type === 'syntax'
210
+ ? 'The image is broken due to an invalid source'
211
+ : null}
212
+
213
+ {failingCheck.type === 'accessibility'
214
+ ? 'Missing alt text'
215
+ : null}
216
+
217
+ {failingCheck.type === 'image_size' &&
218
+ failingCheck.metadata.byteCount
219
+ ? 'This image is too large, keep it under 1mb'
220
+ : null}
221
+
222
+ <span className="font-mono float-right text-ellipsis overflow-hidden text-nowrap max-w-[30ch]">
223
+ {row.result.source}
224
+ </span>
225
+ </Result.Description>
226
+ <Result.Metadata>
227
+ {row.result.checks
228
+ .map((check) => {
229
+ if (
230
+ check.type === 'fetch_attempt' &&
231
+ check.metadata.fetchStatusCode
232
+ ) {
233
+ return check.metadata.fetchStatusCode;
234
+ }
235
+
236
+ if (
237
+ check.type === 'image_size' &&
238
+ check.metadata.byteCount
239
+ ) {
240
+ return prettyBytes(check.metadata.byteCount);
241
+ }
242
+
243
+ return undefined;
244
+ })
245
+ .filter(Boolean)
246
+ .join('—')}
247
+ </Result.Metadata>
248
+ </Result>
249
+ );
250
+ }
251
+
252
+ return undefined;
253
+ })}
254
+ </Results>
255
+ );
256
+ };
257
+
258
+ interface ResultProps extends React.ComponentProps<typeof Results.Row> {
259
+ status: 'error' | 'warning' | 'success';
260
+ }
261
+
262
+ const Result = ({ children, className, status, ...props }: ResultProps) => {
263
+ return (
264
+ <Results.Row
265
+ data-status={status}
266
+ {...props}
267
+ className={cn('group/result', className)}
268
+ >
269
+ {children}
270
+ </Results.Row>
271
+ );
272
+ };
273
+
274
+ Result.Name = ({
275
+ children,
276
+ ...props
277
+ }: React.ComponentProps<typeof Results.Column>) => {
278
+ return (
279
+ <Results.Column {...props}>
280
+ <span className="flex uppercase gap-1 items-center group-data-[status=error]/result:text-red-400 group-data-[status=warning]/result:text-orange-300">
281
+ <IconWarning />
282
+ {children}
283
+ </span>
284
+ </Results.Column>
285
+ );
286
+ };
287
+
288
+ Result.Description = ({
289
+ children,
290
+ className,
291
+ ...props
292
+ }: React.ComponentProps<typeof Results.Column>) => {
293
+ return <Results.Column {...props}>{children}</Results.Column>;
294
+ };
295
+
296
+ Result.Metadata = ({
297
+ children,
298
+ className,
299
+ ...props
300
+ }: React.ComponentProps<typeof Results.Column>) => {
301
+ return (
302
+ <Results.Column
303
+ align="right"
304
+ {...props}
305
+ className={cn('font-mono text-slate-11', className)}
306
+ >
307
+ {children}
308
+ </Results.Column>
309
+ );
310
+ };
File without changes
@@ -0,0 +1,48 @@
1
+ import { cn } from '../../utils';
2
+
3
+ export const Results = ({
4
+ children,
5
+ className,
6
+ ...props
7
+ }: React.ComponentProps<'table'>) => {
8
+ return (
9
+ <table
10
+ className={cn(
11
+ 'group relative w-full border-collapse text-left text-slate-10 text-xs',
12
+ className,
13
+ )}
14
+ >
15
+ <tbody>{children}</tbody>
16
+ </table>
17
+ );
18
+ };
19
+
20
+ Results.Row = ({
21
+ children,
22
+ className,
23
+ ...props
24
+ }: React.ComponentProps<'tr'>) => {
25
+ return (
26
+ <tr
27
+ className={cn(
28
+ 'border-collapse align-bottom border-slate-6 border-b',
29
+ className,
30
+ )}
31
+ {...props}
32
+ >
33
+ {children}
34
+ </tr>
35
+ );
36
+ };
37
+
38
+ Results.Column = ({
39
+ children,
40
+ className,
41
+ ...props
42
+ }: React.ComponentProps<'td'>) => {
43
+ return (
44
+ <td className={cn('py-1 align-bottom font-medium', className)} {...props}>
45
+ {children}
46
+ </td>
47
+ );
48
+ };
@@ -0,0 +1,144 @@
1
+ import { useRef, useState } from 'react';
2
+ import { toast } from 'sonner';
3
+ import { cn } from '../../utils';
4
+ import { IconWarning } from '../icons/icon-warning';
5
+ import { Results } from './results';
6
+
7
+ interface SpamAssassinProps {
8
+ result: SpamCheckingResult | undefined;
9
+ }
10
+
11
+ export interface SpamCheckingResult {
12
+ checks: {
13
+ name: string;
14
+ description: string;
15
+ points: number;
16
+ }[];
17
+ isSpam: boolean;
18
+ points: number;
19
+ }
20
+
21
+ function toSorted<T>(array: T[], sorter: (a: T, b: T) => number): T[] {
22
+ const cloned = [...array];
23
+ cloned.sort(sorter);
24
+ return cloned;
25
+ }
26
+
27
+ export const useSpamAssassin = ({
28
+ markup,
29
+ plainText,
30
+
31
+ initialResult,
32
+ }: {
33
+ markup: string;
34
+ plainText: string;
35
+
36
+ initialResult?: SpamCheckingResult;
37
+ }) => {
38
+ const [result, setResult] = useState<SpamCheckingResult | undefined>(
39
+ initialResult,
40
+ );
41
+
42
+ const [loading, setLoading] = useState(false);
43
+ const isLoadingRef = useRef(false);
44
+
45
+ const load = async () => {
46
+ if (isLoadingRef.current) return;
47
+ isLoadingRef.current = true;
48
+ setLoading(true);
49
+
50
+ try {
51
+ const response = await fetch('https://react.email/api/check-spam', {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/json' },
54
+ body: JSON.stringify({
55
+ html: markup,
56
+ plainText: plainText,
57
+ }),
58
+ });
59
+
60
+ const responseBody = (await response.json()) as
61
+ | { error: string }
62
+ | SpamCheckingResult;
63
+ if ('error' in responseBody) {
64
+ toast.error(responseBody.error);
65
+ } else {
66
+ setResult(responseBody);
67
+ return responseBody;
68
+ }
69
+ } catch (exception) {
70
+ console.error(exception);
71
+ toast.error(JSON.stringify(exception));
72
+ } finally {
73
+ setLoading(false);
74
+ isLoadingRef.current = false;
75
+ }
76
+ };
77
+
78
+ return [result, { loading, load }] as const;
79
+ };
80
+
81
+ export const SpamAssassin = ({ result }: SpamAssassinProps) => {
82
+ return (
83
+ <>
84
+ {result ? (
85
+ <Results>
86
+ <Results.Row className="sticky border-b-2 top-0 bg-black">
87
+ <Results.Column className="uppercase">
88
+ <span className="flex gap-1 items-center">
89
+ <IconWarning
90
+ className={cn(
91
+ result.points > 1.5 ? 'text-yellow-200' : null,
92
+ result.points > 3 ? 'text-orange-300' : null,
93
+ result.points >= 5 ? 'text-red-400' : null,
94
+ )}
95
+ />
96
+ Score
97
+ </span>
98
+ </Results.Column>
99
+ <Results.Column>Lower scores are better</Results.Column>
100
+ <Results.Column
101
+ className={cn(
102
+ 'text-right text-2xl tracking-tighter font-mono',
103
+ result.points > 1.5 ? 'text-yellow-200' : null,
104
+ result.points > 3 ? 'text-orange-300' : null,
105
+ result.points >= 5 ? 'text-red-400' : null,
106
+ )}
107
+ >
108
+ {result.points.toFixed(1)}
109
+ </Results.Column>
110
+ </Results.Row>
111
+ {toSorted(result.checks, (a, b) => b.points - a.points).map(
112
+ (check) => (
113
+ <Results.Row key={check.name}>
114
+ <Results.Column className="uppercase">
115
+ <span className="flex gap-1 items-center">
116
+ <IconWarning
117
+ className={cn(
118
+ check.points > 1 ? 'text-yellow-200' : null,
119
+ check.points > 2 ? 'text-orange-300' : null,
120
+ check.points > 3 ? 'text-red-400' : null,
121
+ )}
122
+ />
123
+ {check.name}
124
+ </span>
125
+ </Results.Column>
126
+ <Results.Column>{check.description}</Results.Column>
127
+ <Results.Column
128
+ className={cn(
129
+ 'text-right font-mono tracking-tighter',
130
+ check.points > 1 ? 'text-yellow-200' : null,
131
+ check.points > 2 ? 'text-orange-300' : null,
132
+ check.points > 3 ? 'text-red-400' : null,
133
+ )}
134
+ >
135
+ {check.points.toFixed(1)}
136
+ </Results.Column>
137
+ </Results.Row>
138
+ ),
139
+ )}
140
+ </Results>
141
+ ) : null}
142
+ </>
143
+ );
144
+ };
@@ -0,0 +1,50 @@
1
+ import { motion } from 'framer-motion';
2
+ import { cn } from '../../utils';
3
+ import { Tooltip } from '../tooltip';
4
+
5
+ interface ToolbarButtonProps extends React.ComponentProps<'button'> {
6
+ children: React.ReactNode;
7
+ active?: boolean;
8
+ tooltip?: React.ReactNode;
9
+ }
10
+
11
+ export const ToolbarButton = ({
12
+ children,
13
+ className,
14
+ active,
15
+ tooltip,
16
+ ...props
17
+ }: ToolbarButtonProps) => {
18
+ return (
19
+ <Tooltip.Provider>
20
+ <Tooltip>
21
+ <Tooltip.Trigger asChild>
22
+ <button
23
+ type="button"
24
+ {...props}
25
+ className={cn(
26
+ 'h-full w-fit font-medium flex text-sm text-slate-10 items-center align-middle justify-center px-1 py-2 gap-1 relative',
27
+ 'hover:text-slate-12 transition-colors',
28
+ active && 'data-[state=active]:text-cyan-11',
29
+ className,
30
+ )}
31
+ >
32
+ {children}
33
+ {active ? (
34
+ <motion.span
35
+ className="-bottom-px absolute rounded-sm left-0 w-full bg-cyan-11 h-px"
36
+ layoutId="active-toolbar-button"
37
+ transition={{
38
+ type: 'spring',
39
+ bounce: 0.2,
40
+ duration: 0.6,
41
+ }}
42
+ />
43
+ ) : null}
44
+ </button>
45
+ </Tooltip.Trigger>
46
+ {tooltip ? <Tooltip.Content>{tooltip}</Tooltip.Content> : null}
47
+ </Tooltip>
48
+ </Tooltip.Provider>
49
+ );
50
+ };
@@ -0,0 +1,33 @@
1
+ import { useSyncExternalStore } from 'react';
2
+
3
+ export const useCachedState = <T>(key: string) => {
4
+ let value: T | undefined = undefined;
5
+ if ('localStorage' in global) {
6
+ const storedValue = global.localStorage.getItem(key);
7
+ if (storedValue !== null) {
8
+ try {
9
+ value = JSON.parse(storedValue) as T;
10
+ } catch (exception) {
11
+ console.warn(
12
+ 'Failed to load stored value for',
13
+ key,
14
+ 'with value',
15
+ value,
16
+ );
17
+ }
18
+ }
19
+ }
20
+
21
+ return [
22
+ useSyncExternalStore(
23
+ () => () => {},
24
+ () => value,
25
+ () => undefined,
26
+ ),
27
+ function setValue(newValue: T | undefined) {
28
+ if ('localStorage' in global) {
29
+ global.localStorage.setItem(key, JSON.stringify(newValue));
30
+ }
31
+ },
32
+ ] as const;
33
+ };