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

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 (103) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/cli/index.js +7 -3
  3. package/dist/cli/index.mjs +7 -3
  4. package/dist/preview/.next/BUILD_ID +1 -1
  5. package/dist/preview/.next/app-build-manifest.json +14 -13
  6. package/dist/preview/.next/app-path-routes-manifest.json +1 -1
  7. package/dist/preview/.next/build-manifest.json +3 -3
  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/next-minimal-server.js.nft.json +1 -1
  15. package/dist/preview/.next/next-server.js.nft.json +1 -1
  16. package/dist/preview/.next/prerender-manifest.json +3 -3
  17. package/dist/preview/.next/required-server-files.json +3 -3
  18. package/dist/preview/.next/server/app/_not-found/page.js +1 -1
  19. package/dist/preview/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/dist/preview/.next/server/app/favicon.ico/route.js +1 -1
  21. package/dist/preview/.next/server/app/page.js +1 -1
  22. package/dist/preview/.next/server/app/page.js.nft.json +1 -1
  23. package/dist/preview/.next/server/app/page_client-reference-manifest.js +1 -1
  24. package/dist/preview/.next/server/app/preview/[...slug]/page.js +29 -25
  25. package/dist/preview/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
  26. package/dist/preview/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
  27. package/dist/preview/.next/server/app-paths-manifest.json +1 -1
  28. package/dist/preview/.next/server/chunks/600.js +3 -3
  29. package/dist/preview/.next/server/chunks/{171.js → 816.js} +6 -6
  30. package/dist/preview/.next/server/chunks/943.js +1 -0
  31. package/dist/preview/.next/server/middleware-build-manifest.js +1 -1
  32. package/dist/preview/.next/server/next-font-manifest.js +1 -1
  33. package/dist/preview/.next/server/next-font-manifest.json +1 -1
  34. package/dist/preview/.next/server/pages/500.html +1 -1
  35. package/dist/preview/.next/server/pages-manifest.json +1 -1
  36. package/dist/preview/.next/server/server-reference-manifest.js +1 -1
  37. package/dist/preview/.next/server/server-reference-manifest.json +1 -1
  38. package/dist/preview/.next/static/chunks/287-7864b805e6bdc854.js +1 -0
  39. package/dist/preview/.next/static/chunks/412-31817e53b50a3e73.js +1 -0
  40. package/dist/preview/.next/static/chunks/683-1fb40795502f6e63.js +1 -0
  41. package/dist/preview/.next/static/chunks/880-9c0b721328117b8b.js +1 -0
  42. package/dist/preview/.next/static/chunks/app/layout-ffdee5cc1be30e7b.js +1 -0
  43. package/dist/preview/.next/static/chunks/app/page-9ea0bd45cd6294b0.js +1 -0
  44. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-9e22979a25c836c0.js +1 -0
  45. package/dist/preview/.next/static/chunks/{main-app-c2e686acf8d370d7.js → main-app-256b213b179a95cc.js} +1 -1
  46. package/dist/preview/.next/static/css/eaae8ce545b295f9.css +3 -0
  47. package/dist/preview/.next/trace +26 -26
  48. package/dist/preview/.next/types/app/layout.ts +1 -1
  49. package/dist/preview/.next/types/app/page.ts +84 -0
  50. package/dist/preview/.next/types/app/preview/[...slug]/page.ts +1 -1
  51. package/package.json +1 -1
  52. package/src/actions/email-validation/check-compatibility.ts +0 -1
  53. package/src/actions/email-validation/check-images.spec.tsx +13 -11
  54. package/src/actions/email-validation/check-images.ts +6 -0
  55. package/src/actions/email-validation/check-links.spec.tsx +23 -11
  56. package/src/actions/email-validation/check-links.ts +6 -0
  57. package/src/actions/email-validation/get-code-location-from-ast-element.ts +18 -0
  58. package/src/actions/render-email-by-path.tsx +2 -2
  59. package/src/app/env.ts +3 -0
  60. package/src/app/preview/[...slug]/page.tsx +24 -11
  61. package/src/app/preview/[...slug]/preview.tsx +15 -12
  62. package/src/components/code-container.tsx +90 -71
  63. package/src/components/code.tsx +106 -42
  64. package/src/components/icons/icon-info.tsx +18 -0
  65. package/src/components/icons/icon-reload.tsx +13 -14
  66. package/src/components/logo.tsx +3 -2
  67. package/src/components/resizable-wrapper.tsx +1 -4
  68. package/src/components/sidebar/file-tree-directory-children.tsx +1 -0
  69. package/src/components/sidebar/sidebar.tsx +2 -3
  70. package/src/components/toolbar/code-preview-line-link.tsx +40 -0
  71. package/src/components/toolbar/compatibility.tsx +113 -0
  72. package/src/components/toolbar/linter.tsx +69 -111
  73. package/src/components/toolbar/results.tsx +5 -2
  74. package/src/components/toolbar/spam-assassin.tsx +20 -12
  75. package/src/components/toolbar/toolbar-button.tsx +4 -2
  76. package/src/components/toolbar.tsx +108 -30
  77. package/src/components/tooltip-content.tsx +1 -1
  78. package/src/components/topbar/view-size-controls.tsx +1 -2
  79. package/src/components/topbar.tsx +1 -20
  80. package/src/contexts/fragment-identifier.tsx +46 -0
  81. package/src/hooks/use-fragment-identifier.ts +14 -0
  82. package/src/utils/caniemail/tailwind/get-tailwind-config.ts +0 -2
  83. package/src/utils/get-email-component.ts +1 -1
  84. package/src/utils/get-line-and-column-from-offset.spec.ts +11 -0
  85. package/src/utils/get-line-and-column-from-offset.ts +11 -0
  86. package/src/utils/index.ts +1 -0
  87. package/src/utils/linting.ts +5 -30
  88. package/src/utils/load-stream.ts +15 -0
  89. package/src/utils/sanitize.ts +6 -0
  90. package/dist/preview/.next/server/chunks/833.js +0 -1
  91. package/dist/preview/.next/static/chunks/416-56f79fc7e689f06f.js +0 -1
  92. package/dist/preview/.next/static/chunks/683-8bbfd191e5105f01.js +0 -1
  93. package/dist/preview/.next/static/chunks/87-38e35f08507de015.js +0 -1
  94. package/dist/preview/.next/static/chunks/app/layout-a6640e62690d8fd6.js +0 -1
  95. package/dist/preview/.next/static/chunks/app/page-ba68f50b287e7478.js +0 -1
  96. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-4a5b026ab543e27f.js +0 -1
  97. package/dist/preview/.next/static/css/d7df9cfc3e182163.css +0 -3
  98. package/src/actions/email-validation/get-line-and-column-from-index.spec.ts +0 -22
  99. package/src/actions/email-validation/get-line-and-column-from-index.ts +0 -43
  100. package/src/components/icons/icon-scanner.tsx +0 -19
  101. package/src/components/icons/icon-scissors.tsx +0 -19
  102. /package/dist/preview/.next/static/{gFk9UfWL8joM4iD7-wlKF → Pms2orsQgT5xpttCfZfH5}/_buildManifest.js +0 -0
  103. /package/dist/preview/.next/static/{gFk9UfWL8joM4iD7-wlKF → Pms2orsQgT5xpttCfZfH5}/_ssgManifest.js +0 -0
@@ -1,12 +1,11 @@
1
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';
2
+ import { Children, useRef, useState } from 'react';
5
3
  import type { ImageCheckingResult } from '../../actions/email-validation/check-images';
6
4
  import type { LinkCheckingResult } from '../../actions/email-validation/check-links';
7
- import { cn } from '../../utils';
5
+ import { cn, sanitize } from '../../utils';
8
6
  import { getLintingSources, loadLintingRowsFrom } from '../../utils/linting';
9
7
  import { IconWarning } from '../icons/icon-warning';
8
+ import { CodePreviewLineLink } from './code-preview-line-link';
10
9
  import { Results } from './results';
11
10
 
12
11
  export type LintingRow =
@@ -17,10 +16,6 @@ export type LintingRow =
17
16
  | {
18
17
  source: 'link';
19
18
  result: LinkCheckingResult;
20
- }
21
- | {
22
- source: 'compatibility';
23
- result: CompatibilityCheckingResult;
24
19
  };
25
20
 
26
21
  interface LinterProps {
@@ -29,14 +24,10 @@ interface LinterProps {
29
24
 
30
25
  export const useLinter = ({
31
26
  markup,
32
- reactMarkup,
33
- emailPath,
34
27
 
35
28
  initialRows,
36
29
  }: {
37
- reactMarkup: string;
38
30
  markup: string;
39
- emailPath: string;
40
31
 
41
32
  initialRows?: LintingRow[];
42
33
  }) => {
@@ -44,8 +35,6 @@ export const useLinter = ({
44
35
 
45
36
  const sources = getLintingSources(
46
37
  markup,
47
- reactMarkup,
48
- emailPath,
49
38
  'location' in global
50
39
  ? `${global.location.protocol}//${global.location.host}`
51
40
  : '',
@@ -105,80 +94,44 @@ export const Linter = ({ rows }: LinterProps) => {
105
94
  )!;
106
95
  return (
107
96
  <Result status={row.result.status} key={i}>
108
- <Result.Name>{failingCheck.type}</Result.Name>
97
+ <Result.Name>{sanitize(failingCheck.type)}</Result.Name>
109
98
  <Result.Description>
110
99
  {failingCheck.type === 'security'
111
- ? 'Insecure URL, use HTTPS insted of HTTP'
100
+ ? 'Insecure URL, use HTTPS instead of HTTP'
112
101
  : null}
113
102
  {failingCheck.type === 'fetch_attempt' &&
114
103
  failingCheck.metadata.fetchStatusCode &&
115
104
  failingCheck.metadata.fetchStatusCode >= 300 &&
116
- failingCheck.metadata.fetchStatusCode < 400
117
- ? 'There was a redirect, the content may have been moved'
118
- : null}
105
+ failingCheck.metadata.fetchStatusCode < 400 ? (
106
+ <>
107
+ <strong>{failingCheck.metadata.fetchStatusCode}</strong>:
108
+ There was a redirect, the content may have been moved
109
+ </>
110
+ ) : null}
119
111
  {failingCheck.type === 'fetch_attempt' &&
120
112
  failingCheck.metadata.fetchStatusCode &&
121
- failingCheck.metadata.fetchStatusCode >= 400
122
- ? 'The link is broken'
123
- : null}
113
+ failingCheck.metadata.fetchStatusCode >= 400 ? (
114
+ <>
115
+ <strong>{failingCheck.metadata.fetchStatusCode}</strong>:
116
+ The link is broken
117
+ </>
118
+ ) : null}
124
119
  {failingCheck.type === 'syntax'
125
120
  ? 'The link is broken due to invalid syntax'
126
121
  : null}
127
122
 
128
- <span className="font-mono float-right text-ellipsis overflow-hidden text-nowrap max-w-[30ch]">
123
+ <span className="ml-2 text-ellipsis overflow-hidden text-nowrap max-w-[30ch]">
129
124
  {row.result.link}
130
125
  </span>
131
126
  </Result.Description>
132
127
  <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>
128
+ {[
129
+ <CodePreviewLineLink
130
+ line={row.result.codeLocation.line}
131
+ column={row.result.codeLocation.column}
132
+ type="html"
133
+ />,
134
+ ]}
182
135
  </Result.Metadata>
183
136
  </Result>
184
137
  );
@@ -188,24 +141,43 @@ export const Linter = ({ rows }: LinterProps) => {
188
141
  const failingCheck = row.result.checks.find(
189
142
  (check) => check.passed === false,
190
143
  )!;
144
+ const metadata: React.ReactNode[] = [];
145
+ for (const check of row.result.checks) {
146
+ if (check.type === 'image_size' && check.metadata.byteCount) {
147
+ metadata.push(prettyBytes(check.metadata.byteCount));
148
+ }
149
+ }
150
+ metadata.push(
151
+ <CodePreviewLineLink
152
+ line={row.result.codeLocation.line}
153
+ column={row.result.codeLocation.column}
154
+ type="html"
155
+ />,
156
+ );
191
157
  return (
192
158
  <Result status={row.result.status} key={i}>
193
- <Result.Name>{failingCheck.type}</Result.Name>
159
+ <Result.Name>{sanitize(failingCheck.type)}</Result.Name>
194
160
  <Result.Description>
195
161
  {failingCheck.type === 'security'
196
- ? 'Insecure URL, use HTTPS insted of HTTP'
162
+ ? 'Insecure URL, use HTTPS instead of HTTP'
197
163
  : null}
198
164
  {failingCheck.type === 'fetch_attempt' &&
199
165
  failingCheck.metadata.fetchStatusCode &&
200
166
  failingCheck.metadata.fetchStatusCode >= 300 &&
201
- failingCheck.metadata.fetchStatusCode < 400
202
- ? 'There was a redirect, the image may have been moved'
203
- : null}
167
+ failingCheck.metadata.fetchStatusCode < 400 ? (
168
+ <>
169
+ <strong>{failingCheck.metadata.fetchStatusCode}</strong>:
170
+ There was a redirect, the image may have been moved
171
+ </>
172
+ ) : null}
204
173
  {failingCheck.type === 'fetch_attempt' &&
205
174
  failingCheck.metadata.fetchStatusCode &&
206
- failingCheck.metadata.fetchStatusCode >= 400
207
- ? 'The image is broken'
208
- : null}
175
+ failingCheck.metadata.fetchStatusCode >= 400 ? (
176
+ <>
177
+ <strong>{failingCheck.metadata.fetchStatusCode}</strong>:
178
+ The image is broken
179
+ </>
180
+ ) : null}
209
181
  {failingCheck.type === 'syntax'
210
182
  ? 'The image is broken due to an invalid source'
211
183
  : null}
@@ -219,32 +191,11 @@ export const Linter = ({ rows }: LinterProps) => {
219
191
  ? 'This image is too large, keep it under 1mb'
220
192
  : null}
221
193
 
222
- <span className="font-mono float-right text-ellipsis overflow-hidden text-nowrap max-w-[30ch]">
194
+ <span className="ml-2 text-ellipsis overflow-hidden text-nowrap max-w-[30ch]">
223
195
  {row.result.source}
224
196
  </span>
225
197
  </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>
198
+ <Result.Metadata>{metadata}</Result.Metadata>
248
199
  </Result>
249
200
  );
250
201
  }
@@ -277,9 +228,9 @@ Result.Name = ({
277
228
  }: React.ComponentProps<typeof Results.Column>) => {
278
229
  return (
279
230
  <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">
231
+ <span className="flex uppercase gap-2 items-center group-data-[status=error]/result:text-red-400 group-data-[status=warning]/result:text-orange-300">
281
232
  <IconWarning />
282
- {children}
233
+ {typeof children === 'string' ? sanitize(children) : children}
283
234
  </span>
284
235
  </Results.Column>
285
236
  );
@@ -293,18 +244,25 @@ Result.Description = ({
293
244
  return <Results.Column {...props}>{children}</Results.Column>;
294
245
  };
295
246
 
296
- Result.Metadata = ({
297
- children,
298
- className,
299
- ...props
300
- }: React.ComponentProps<typeof Results.Column>) => {
247
+ interface MetadatProps extends React.ComponentProps<typeof Results.Column> {
248
+ children: React.ReactNode[];
249
+ }
250
+
251
+ Result.Metadata = ({ children, className, ...props }: MetadatProps) => {
301
252
  return (
302
253
  <Results.Column
303
254
  align="right"
304
255
  {...props}
305
256
  className={cn('font-mono text-slate-11', className)}
306
257
  >
307
- {children}
258
+ {Children.map(children, (child, index) => {
259
+ return (
260
+ <>
261
+ {index > 0 ? ' · ' : null}
262
+ {child}
263
+ </>
264
+ );
265
+ })}
308
266
  </Results.Column>
309
267
  );
310
268
  };
@@ -8,7 +8,7 @@ export const Results = ({
8
8
  return (
9
9
  <table
10
10
  className={cn(
11
- 'group relative w-full border-collapse text-left text-slate-10 text-xs',
11
+ 'group relative w-full border-collapse text-left text-slate-10 text-sm',
12
12
  className,
13
13
  )}
14
14
  >
@@ -41,7 +41,10 @@ Results.Column = ({
41
41
  ...props
42
42
  }: React.ComponentProps<'td'>) => {
43
43
  return (
44
- <td className={cn('py-1 align-bottom font-medium', className)} {...props}>
44
+ <td
45
+ className={cn('py-1.5 align-bottom font-regular', className)}
46
+ {...props}
47
+ >
45
48
  {children}
46
49
  </td>
47
50
  );
@@ -1,6 +1,6 @@
1
1
  import { useRef, useState } from 'react';
2
2
  import { toast } from 'sonner';
3
- import { cn } from '../../utils';
3
+ import { cn, sanitize } from '../../utils';
4
4
  import { IconWarning } from '../icons/icon-warning';
5
5
  import { Results } from './results';
6
6
 
@@ -83,25 +83,33 @@ export const SpamAssassin = ({ result }: SpamAssassinProps) => {
83
83
  <>
84
84
  {result ? (
85
85
  <Results>
86
- <Results.Row className="sticky border-b-2 top-0 bg-black">
86
+ <Results.Row className="sticky border-b top-0 bg-black">
87
87
  <Results.Column className="uppercase">
88
- <span className="flex gap-1 items-center">
88
+ <span className="flex gap-2 items-center">
89
89
  <IconWarning
90
90
  className={cn(
91
- result.points > 1.5 ? 'text-yellow-200' : null,
92
- result.points > 3 ? 'text-orange-300' : null,
91
+ result.points === 0 ? 'text-green-400' : null,
92
+ result.points > 0 && result.points <= 1.5 ? null : null,
93
+ result.points > 1.5 ? 'text-yellow-100' : null,
94
+ result.points > 3 ? 'text-orange-400' : null,
93
95
  result.points >= 5 ? 'text-red-400' : null,
94
96
  )}
95
97
  />
96
98
  Score
97
99
  </span>
98
100
  </Results.Column>
99
- <Results.Column>Lower scores are better</Results.Column>
101
+ <Results.Column>
102
+ {result.points === 0
103
+ ? 'Congratulations! Your email is clean of abuse indicators.'
104
+ : 'Lower scores are better'}
105
+ </Results.Column>
100
106
  <Results.Column
101
107
  className={cn(
102
- 'text-right text-2xl tracking-tighter font-mono',
108
+ 'text-right text-3xl tracking-tighter font-bold',
109
+ result.points === 0 ? 'text-green-400' : null,
110
+ result.points > 0 && result.points <= 1.5 ? null : null,
103
111
  result.points > 1.5 ? 'text-yellow-200' : null,
104
- result.points > 3 ? 'text-orange-300' : null,
112
+ result.points > 3 ? 'text-orange-400' : null,
105
113
  result.points >= 5 ? 'text-red-400' : null,
106
114
  )}
107
115
  >
@@ -112,15 +120,15 @@ export const SpamAssassin = ({ result }: SpamAssassinProps) => {
112
120
  (check) => (
113
121
  <Results.Row key={check.name}>
114
122
  <Results.Column className="uppercase">
115
- <span className="flex gap-1 items-center">
123
+ <span className="flex gap-2 items-center">
116
124
  <IconWarning
117
125
  className={cn(
118
126
  check.points > 1 ? 'text-yellow-200' : null,
119
- check.points > 2 ? 'text-orange-300' : null,
127
+ check.points > 2 ? 'text-orange-400' : null,
120
128
  check.points > 3 ? 'text-red-400' : null,
121
129
  )}
122
130
  />
123
- {check.name}
131
+ {sanitize(check.name)}
124
132
  </span>
125
133
  </Results.Column>
126
134
  <Results.Column>{check.description}</Results.Column>
@@ -128,7 +136,7 @@ export const SpamAssassin = ({ result }: SpamAssassinProps) => {
128
136
  className={cn(
129
137
  'text-right font-mono tracking-tighter',
130
138
  check.points > 1 ? 'text-yellow-200' : null,
131
- check.points > 2 ? 'text-orange-300' : null,
139
+ check.points > 2 ? 'text-orange-400' : null,
132
140
  check.points > 3 ? 'text-red-400' : null,
133
141
  )}
134
142
  >
@@ -6,6 +6,7 @@ interface ToolbarButtonProps extends React.ComponentProps<'button'> {
6
6
  children: React.ReactNode;
7
7
  active?: boolean;
8
8
  tooltip?: React.ReactNode;
9
+ delayDuration?: number;
9
10
  }
10
11
 
11
12
  export const ToolbarButton = ({
@@ -13,17 +14,18 @@ export const ToolbarButton = ({
13
14
  className,
14
15
  active,
15
16
  tooltip,
17
+ delayDuration = 500,
16
18
  ...props
17
19
  }: ToolbarButtonProps) => {
18
20
  return (
19
21
  <Tooltip.Provider>
20
- <Tooltip>
22
+ <Tooltip delayDuration={delayDuration}>
21
23
  <Tooltip.Trigger asChild>
22
24
  <button
23
25
  type="button"
24
26
  {...props}
25
27
  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',
28
+ 'h-full w-fit font-regular flex text-sm text-slate-10 items-center align-middle justify-center px-1 gap-2 relative',
27
29
  'hover:text-slate-12 transition-colors',
28
30
  active && 'data-[state=active]:text-cyan-11',
29
31
  className,
@@ -3,13 +3,14 @@ import * as Tabs from '@radix-ui/react-tabs';
3
3
  import { LayoutGroup } from 'framer-motion';
4
4
  import { usePathname, useRouter, useSearchParams } from 'next/navigation';
5
5
  import { use, useEffect } from 'react';
6
+ import type { CompatibilityCheckingResult } from '../actions/email-validation/check-compatibility';
6
7
  import { isBuilding } from '../app/env';
7
8
  import { PreviewContext } from '../contexts/preview';
8
9
  import { cn } from '../utils';
9
10
  import { IconArrowDown } from './icons/icon-arrow-down';
11
+ import { IconInfo } from './icons/icon-info';
10
12
  import { IconReload } from './icons/icon-reload';
11
- import { IconScanner } from './icons/icon-scanner';
12
- import { IconScissors } from './icons/icon-scissors';
13
+ import { Compatibility, useCompatibility } from './toolbar/compatibility';
13
14
  import { Linter, type LintingRow, useLinter } from './toolbar/linter';
14
15
  import {
15
16
  SpamAssassin,
@@ -19,11 +20,12 @@ import {
19
20
  import { ToolbarButton } from './toolbar/toolbar-button';
20
21
  import { useCachedState } from './toolbar/use-cached-state';
21
22
 
22
- export type ToolbarTabValue = 'linter' | 'spam-assassin';
23
+ export type ToolbarTabValue = 'linter' | 'compatibility' | 'spam-assassin';
23
24
 
24
25
  const ToolbarInner = ({
25
26
  serverLintingRows,
26
27
  serverSpamCheckingResult,
28
+ serverCompatibilityResults,
27
29
 
28
30
  markup,
29
31
  reactMarkup,
@@ -54,30 +56,42 @@ const ToolbarInner = ({
54
56
  } else {
55
57
  params.set('toolbar-panel', newValue);
56
58
  }
57
- router.push(`${pathname}?${params.toString()}`);
59
+ router.push(`${pathname}?${params.toString()}${location.hash}`);
58
60
  };
59
61
 
60
62
  const [cachedSpamCheckingResult, setCachedSpamCheckingResult] =
61
63
  useCachedState<SpamCheckingResult>(
62
64
  `spam-assassin-${emailSlug.replaceAll('/', '-')}`,
63
65
  );
64
- const [spamCheckingResult, { load: loadSpamChecking }] = useSpamAssassin({
65
- markup,
66
- plainText,
66
+ const [spamCheckingResult, { load: loadSpamChecking, loading: spamLoading }] =
67
+ useSpamAssassin({
68
+ markup,
69
+ plainText,
67
70
 
68
- initialResult: serverSpamCheckingResult ?? cachedSpamCheckingResult,
69
- });
71
+ initialResult: serverSpamCheckingResult ?? cachedSpamCheckingResult,
72
+ });
70
73
 
71
74
  const [cachedLintingRows, setCachedLintingRows] = useCachedState<
72
75
  LintingRow[]
73
76
  >(`linter-${emailSlug.replaceAll('/', '-')}`);
74
- const [lintingRows, { load: loadLinting }] = useLinter({
75
- reactMarkup,
76
- emailPath,
77
+ const [lintingRows, { load: loadLinting, loading: lintLoading }] = useLinter({
77
78
  markup,
78
79
 
79
80
  initialRows: serverLintingRows ?? cachedLintingRows,
80
81
  });
82
+ const [cachedCompatibilityResults, setCachedCompatibilityResults] =
83
+ useCachedState<CompatibilityCheckingResult[]>(
84
+ `compatibility-${emailSlug.replaceAll('/', '-')}`,
85
+ );
86
+ const [
87
+ compatibilityCheckingResults,
88
+ { load: loadCompatibility, loading: compatibilityLoading },
89
+ ] = useCompatibility({
90
+ emailPath,
91
+ reactMarkup,
92
+
93
+ initialResults: serverCompatibilityResults ?? cachedCompatibilityResults,
94
+ });
81
95
 
82
96
  if (!isBuilding) {
83
97
  useEffect(() => {
@@ -87,6 +101,9 @@ const ToolbarInner = ({
87
101
 
88
102
  const spamCheckingResult = await loadSpamChecking();
89
103
  setCachedSpamCheckingResult(spamCheckingResult);
104
+
105
+ const compatibilityCheckingResults = await loadCompatibility();
106
+ setCachedCompatibilityResults(compatibilityCheckingResults);
90
107
  })();
91
108
  }, []);
92
109
  }
@@ -95,49 +112,83 @@ const ToolbarInner = ({
95
112
  <div
96
113
  data-toggled={toggled}
97
114
  className={cn(
98
- 'bg-black group/toolbar text-xs text-slate-11 h-48 transition-all',
99
- 'data-[toggled=false]:h-8',
115
+ 'absolute bottom-0 left-0 right-0',
116
+ 'bg-black group/toolbar text-xs text-slate-11 h-52 transition-transform',
117
+ 'data-[toggled=false]:translate-y-[170px]',
100
118
  )}
101
119
  >
102
120
  <Tabs.Root
103
- value={activeTab}
121
+ value={activeTab ?? ''}
104
122
  onValueChange={(newValue) => {
105
123
  setActivePanelValue(newValue as ToolbarTabValue);
106
124
  }}
107
125
  asChild
108
126
  >
109
127
  <div className="flex flex-col h-full">
110
- <Tabs.List className="flex gap-4 px-2 border-b border-solid border-slate-6 h-7 w-full">
128
+ <Tabs.List className="flex gap-4 px-4 border-b border-solid border-slate-6 h-10 w-full flex-shrink-0">
111
129
  <LayoutGroup id="toolbar">
112
- <Tabs.Trigger asChild value="spam-assassin">
113
- <ToolbarButton active={activeTab === 'spam-assassin'}>
114
- <IconScissors />
115
- Spam Assassin
116
- </ToolbarButton>
117
- </Tabs.Trigger>
118
130
  <Tabs.Trigger asChild value="linter">
119
131
  <ToolbarButton active={activeTab === 'linter'}>
120
- <IconScanner />
121
132
  Linter
122
133
  </ToolbarButton>
123
134
  </Tabs.Trigger>
135
+ <Tabs.Trigger asChild value="compatibility">
136
+ <ToolbarButton active={activeTab === 'compatibility'}>
137
+ Compatibility
138
+ </ToolbarButton>
139
+ </Tabs.Trigger>
140
+ <Tabs.Trigger asChild value="spam-assassin">
141
+ <ToolbarButton active={activeTab === 'spam-assassin'}>
142
+ Spam
143
+ </ToolbarButton>
144
+ </Tabs.Trigger>
124
145
  </LayoutGroup>
125
- <div className="flex gap-1 ml-auto">
146
+ <div className="flex gap-0.5 ml-auto">
147
+ <ToolbarButton
148
+ delayDuration={0}
149
+ tooltip={
150
+ (activeTab === 'linter' &&
151
+ 'The Linter tab checks all the images and links for common issues like missing alt text, broken URLs, insecure HTTP methods, and more.') ||
152
+ (activeTab === 'spam-assassin' &&
153
+ 'The Spam tab will look at the content and use a robust scoring framework to determine if the email is likely to be spam. Powered by SpamAssassin.') ||
154
+ (activeTab === 'compatibility' &&
155
+ 'The Compatibility tab shows how well the HTML/CSS is supported across mail clients like Outlook, Gmail, etc. Powered by Can I Email.') ||
156
+ 'Info'
157
+ }
158
+ onClick={() => {
159
+ if (activeTab === undefined) {
160
+ setActivePanelValue('linter');
161
+ } else {
162
+ setActivePanelValue(undefined);
163
+ }
164
+ }}
165
+ >
166
+ <IconInfo size={24} />
167
+ </ToolbarButton>
126
168
  {isBuilding ? null : (
127
169
  <ToolbarButton
128
170
  tooltip="Reload"
171
+ disabled={lintLoading || spamLoading}
129
172
  onClick={async () => {
130
173
  if (activeTab === undefined) {
131
174
  setActivePanelValue('linter');
132
175
  }
133
176
  if (activeTab === 'spam-assassin') {
134
177
  await loadSpamChecking();
135
- } else {
178
+ } else if (activeTab === 'linter') {
136
179
  await loadLinting();
180
+ } else if (activeTab === 'compatibility') {
181
+ await loadCompatibility();
137
182
  }
138
183
  }}
139
184
  >
140
- <IconReload />
185
+ <IconReload
186
+ size={24}
187
+ className={cn({
188
+ 'animate-spin opacity-60 animate-spin-fast':
189
+ lintLoading || spamLoading,
190
+ })}
191
+ />
141
192
  </ToolbarButton>
142
193
  )}
143
194
  <ToolbarButton
@@ -150,17 +201,41 @@ const ToolbarInner = ({
150
201
  }
151
202
  }}
152
203
  >
153
- <IconArrowDown className="transition-transform group-data-[toggled=false]/toolbar:rotate-180" />
204
+ <IconArrowDown
205
+ size={24}
206
+ className="transition-transform group-data-[toggled=false]/toolbar:rotate-180"
207
+ />
154
208
  </ToolbarButton>
155
209
  </div>
156
210
  </Tabs.List>
157
211
 
158
- <div className="flex-grow transition-opacity opacity-100 group-data-[toggled=false]/toolbar:opacity-0 overflow-y-auto px-2">
212
+ <div className="flex-grow transition-opacity opacity-100 group-data-[toggled=false]/toolbar:opacity-0 overflow-y-auto px-2 pt-3">
159
213
  <Tabs.Content value="linter">
160
- <Linter rows={lintingRows} />
214
+ {lintLoading ? (
215
+ <div className="animate-pulse text-slate-11 text-sm pt-1">
216
+ Running linting...
217
+ </div>
218
+ ) : (
219
+ <Linter rows={lintingRows} />
220
+ )}
161
221
  </Tabs.Content>
162
222
  <Tabs.Content value="spam-assassin">
163
- <SpamAssassin result={spamCheckingResult} />
223
+ {spamLoading ? (
224
+ <div className="animate-pulse text-slate-11 text-sm pt-1">
225
+ Running spam check...
226
+ </div>
227
+ ) : (
228
+ <SpamAssassin result={spamCheckingResult} />
229
+ )}
230
+ </Tabs.Content>
231
+ <Tabs.Content value="compatibility">
232
+ {compatibilityLoading ? (
233
+ <div className="animate-pulse text-slate-11 text-sm pt-1">
234
+ Running compatibility check...
235
+ </div>
236
+ ) : (
237
+ <Compatibility results={compatibilityCheckingResults} />
238
+ )}
164
239
  </Tabs.Content>
165
240
  </div>
166
241
  </div>
@@ -172,11 +247,13 @@ const ToolbarInner = ({
172
247
  interface ToolbarProps {
173
248
  serverSpamCheckingResult: SpamCheckingResult | undefined;
174
249
  serverLintingRows: LintingRow[] | undefined;
250
+ serverCompatibilityResults: CompatibilityCheckingResult[] | undefined;
175
251
  }
176
252
 
177
253
  export const Toolbar = ({
178
254
  serverLintingRows,
179
255
  serverSpamCheckingResult,
256
+ serverCompatibilityResults,
180
257
  }: ToolbarProps) => {
181
258
  const { emailPath, emailSlug, renderedEmailMetadata } = use(PreviewContext)!;
182
259
 
@@ -192,6 +269,7 @@ export const Toolbar = ({
192
269
  plainText={plainText}
193
270
  serverLintingRows={serverLintingRows}
194
271
  serverSpamCheckingResult={serverSpamCheckingResult}
272
+ serverCompatibilityResults={serverCompatibilityResults}
195
273
  />
196
274
  );
197
275
  };