react-email 4.0.0-alpha.5 → 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 (203) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cli/index.js +1179 -2659
  3. package/dist/cli/index.mjs +17 -11
  4. package/dist/preview/.next/BUILD_ID +1 -1
  5. package/dist/preview/.next/app-build-manifest.json +32 -31
  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 +51 -11
  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/446.js +6 -0
  35. package/dist/preview/.next/server/chunks/600.js +8 -0
  36. package/dist/preview/.next/server/chunks/811.js +13 -0
  37. package/dist/preview/.next/server/chunks/816.js +14 -0
  38. package/dist/preview/.next/server/chunks/943.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/Pms2orsQgT5xpttCfZfH5/_buildManifest.js +1 -0
  55. package/dist/preview/.next/static/chunks/287-7864b805e6bdc854.js +1 -0
  56. package/dist/preview/.next/static/chunks/412-31817e53b50a3e73.js +1 -0
  57. package/dist/preview/.next/static/chunks/683-1fb40795502f6e63.js +1 -0
  58. package/dist/preview/.next/static/chunks/744-79730358b37b2212.js +1 -0
  59. package/dist/preview/.next/static/chunks/781-5f16c6bc9d9d4cc1.js +1 -0
  60. package/dist/preview/.next/static/chunks/832ad4be-cb988facfb8f955f.js +1 -0
  61. package/dist/preview/.next/static/chunks/880-9c0b721328117b8b.js +1 -0
  62. package/dist/preview/.next/static/chunks/{afa401a5-a600c227dacf3ab4.js → afa401a5-3e949a1cfd317dd3.js} +3 -3
  63. package/dist/preview/.next/static/chunks/app/_not-found/page-09d694081cc9d4dc.js +1 -0
  64. package/dist/preview/.next/static/chunks/app/layout-ffdee5cc1be30e7b.js +1 -0
  65. package/dist/preview/.next/static/chunks/app/page-9ea0bd45cd6294b0.js +1 -0
  66. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-9e22979a25c836c0.js +1 -0
  67. package/dist/preview/.next/static/chunks/framework-c2bd6d936e3077bc.js +1 -0
  68. package/dist/preview/.next/static/chunks/main-44463a8301435b64.js +1 -0
  69. package/dist/preview/.next/static/chunks/main-app-256b213b179a95cc.js +1 -0
  70. package/dist/preview/.next/static/chunks/pages/_app-f3011d3f00bb8dba.js +1 -0
  71. package/dist/preview/.next/static/chunks/pages/_error-39a87dee2e97a2a3.js +1 -0
  72. package/dist/preview/.next/static/chunks/{webpack-2eb145a20ee6cb77.js → webpack-41e2667c9f086a4f.js} +1 -1
  73. package/dist/preview/.next/static/css/eaae8ce545b295f9.css +3 -0
  74. package/dist/preview/.next/trace +26 -22
  75. package/dist/preview/.next/types/app/layout.ts +1 -1
  76. package/dist/preview/.next/types/app/page.ts +84 -0
  77. package/dist/preview/.next/types/app/preview/[...slug]/page.ts +1 -1
  78. package/dist/preview/.next/types/cache-life.d.ts +3 -3
  79. package/package.json +14 -9
  80. package/scripts/build-preview-server.mjs +32 -0
  81. package/scripts/fill-caniemail-data.mjs +36 -0
  82. package/src/actions/email-validation/caniemail-data.ts +85993 -0
  83. package/src/actions/email-validation/check-compatibility.ts +321 -0
  84. package/src/actions/email-validation/check-images.spec.tsx +15 -13
  85. package/src/actions/email-validation/check-images.ts +8 -2
  86. package/src/actions/email-validation/check-links.spec.tsx +27 -15
  87. package/src/actions/email-validation/check-links.ts +8 -2
  88. package/src/actions/email-validation/get-code-location-from-ast-element.ts +18 -0
  89. package/src/actions/get-email-path-from-slug.ts +1 -1
  90. package/src/actions/render-email-by-path.tsx +2 -1
  91. package/src/{utils/emails-directory-absolute-path.ts → app/env.ts} +5 -0
  92. package/src/app/layout.tsx +1 -1
  93. package/src/app/page.tsx +1 -1
  94. package/src/app/preview/[...slug]/page.tsx +89 -19
  95. package/src/app/preview/[...slug]/preview.tsx +25 -68
  96. package/src/components/code-container.tsx +90 -71
  97. package/src/components/code.tsx +106 -43
  98. package/src/components/icons/icon-info.tsx +18 -0
  99. package/src/components/icons/icon-reload.tsx +13 -14
  100. package/src/components/logo.tsx +3 -2
  101. package/src/components/resizable-wrapper.tsx +1 -4
  102. package/src/components/sidebar/file-tree-directory-children.tsx +1 -0
  103. package/src/components/sidebar/sidebar.tsx +2 -3
  104. package/src/components/toolbar/code-preview-line-link.tsx +40 -0
  105. package/src/components/toolbar/compatibility.tsx +113 -0
  106. package/src/components/toolbar/linter.tsx +226 -125
  107. package/src/components/toolbar/results.tsx +5 -2
  108. package/src/components/toolbar/spam-assassin.tsx +40 -43
  109. package/src/components/toolbar/toolbar-button.tsx +52 -0
  110. package/src/components/toolbar/use-cached-state.ts +33 -0
  111. package/src/components/toolbar.tsx +196 -110
  112. package/src/components/tooltip-content.tsx +1 -1
  113. package/src/components/topbar/view-size-controls.tsx +1 -1
  114. package/src/components/topbar.tsx +4 -29
  115. package/src/contexts/emails.tsx +2 -1
  116. package/src/contexts/fragment-identifier.tsx +46 -0
  117. package/src/contexts/preview.tsx +81 -0
  118. package/src/hooks/use-email-rendering-result.ts +2 -1
  119. package/src/hooks/use-fragment-identifier.ts +14 -0
  120. package/src/utils/__snapshots__/get-email-component.spec.ts.snap +1 -1
  121. package/src/utils/caniemail/all-css-properties.ts +358 -0
  122. package/src/utils/caniemail/ast/get-object-variables.ts +61 -0
  123. package/src/utils/caniemail/ast/get-used-style-properties.ts +91 -0
  124. package/src/utils/caniemail/get-compatibility-stats-for-entry.ts +118 -0
  125. package/src/utils/caniemail/get-css-functions.ts +25 -0
  126. package/src/utils/caniemail/get-css-property-names.ts +32 -0
  127. package/src/utils/caniemail/get-css-property-with-value.ts +14 -0
  128. package/src/utils/caniemail/get-css-unit.ts +3 -0
  129. package/src/utils/caniemail/get-element-attributes.ts +7 -0
  130. package/src/utils/caniemail/get-element-names.ts +20 -0
  131. package/src/utils/caniemail/tailwind/generate-tailwind-rules.ts +30 -0
  132. package/src/utils/caniemail/tailwind/get-tailwind-config.ts +203 -0
  133. package/src/utils/caniemail/tailwind/get-tailwind-metadata.spec.ts +25 -0
  134. package/src/utils/caniemail/tailwind/get-tailwind-metadata.ts +45 -0
  135. package/src/utils/caniemail/tailwind/setup-tailwind-context.ts +15 -0
  136. package/src/utils/get-email-component.ts +34 -67
  137. package/src/utils/get-line-and-column-from-offset.spec.ts +11 -0
  138. package/src/utils/get-line-and-column-from-offset.ts +11 -0
  139. package/src/utils/index.ts +1 -0
  140. package/src/utils/linting.ts +60 -0
  141. package/src/utils/load-stream.ts +15 -0
  142. package/src/utils/result.ts +49 -0
  143. package/src/utils/run-bundled-code.ts +64 -0
  144. package/src/utils/sanitize.ts +6 -0
  145. package/tailwind-internals.d.ts +133 -0
  146. package/tsconfig.json +9 -3
  147. package/build-preview-server.mjs +0 -25
  148. package/dist/preview/.next/cache/images/TcyzHbFXGFjrOu3wEMvDoSmqCh3qP3iiNqJf0QbED9Y/60.1741728556140.cQ5qicbpvoXZ7leVmWqG2ElLwXB1ynYeSv8MBSA-QeM.Vy8iMWM3MGUtMTk1ODcxYmIyNzMi.webp +0 -0
  149. package/dist/preview/.next/cache/webpack/client-development/0.pack.gz +0 -0
  150. package/dist/preview/.next/cache/webpack/client-development/1.pack.gz +0 -0
  151. package/dist/preview/.next/cache/webpack/client-development/10.pack.gz +0 -0
  152. package/dist/preview/.next/cache/webpack/client-development/11.pack.gz +0 -0
  153. package/dist/preview/.next/cache/webpack/client-development/12.pack.gz +0 -0
  154. package/dist/preview/.next/cache/webpack/client-development/13.pack.gz +0 -0
  155. package/dist/preview/.next/cache/webpack/client-development/2.pack.gz +0 -0
  156. package/dist/preview/.next/cache/webpack/client-development/3.pack.gz +0 -0
  157. package/dist/preview/.next/cache/webpack/client-development/4.pack.gz +0 -0
  158. package/dist/preview/.next/cache/webpack/client-development/5.pack.gz +0 -0
  159. package/dist/preview/.next/cache/webpack/client-development/6.pack.gz +0 -0
  160. package/dist/preview/.next/cache/webpack/client-development/7.pack.gz +0 -0
  161. package/dist/preview/.next/cache/webpack/client-development/8.pack.gz +0 -0
  162. package/dist/preview/.next/cache/webpack/client-development/9.pack.gz +0 -0
  163. package/dist/preview/.next/cache/webpack/client-development/index.pack.gz +0 -0
  164. package/dist/preview/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
  165. package/dist/preview/.next/cache/webpack/server-development/0.pack.gz +0 -0
  166. package/dist/preview/.next/cache/webpack/server-development/1.pack.gz +0 -0
  167. package/dist/preview/.next/cache/webpack/server-development/2.pack.gz +0 -0
  168. package/dist/preview/.next/cache/webpack/server-development/3.pack.gz +0 -0
  169. package/dist/preview/.next/cache/webpack/server-development/4.pack.gz +0 -0
  170. package/dist/preview/.next/cache/webpack/server-development/5.pack.gz +0 -0
  171. package/dist/preview/.next/cache/webpack/server-development/6.pack.gz +0 -0
  172. package/dist/preview/.next/cache/webpack/server-development/7.pack.gz +0 -0
  173. package/dist/preview/.next/cache/webpack/server-development/8.pack.gz +0 -0
  174. package/dist/preview/.next/cache/webpack/server-development/9.pack.gz +0 -0
  175. package/dist/preview/.next/cache/webpack/server-development/index.pack.gz +0 -0
  176. package/dist/preview/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
  177. package/dist/preview/.next/server/chunks/143.js +0 -6
  178. package/dist/preview/.next/server/chunks/409.js +0 -5
  179. package/dist/preview/.next/server/chunks/46.js +0 -1
  180. package/dist/preview/.next/server/chunks/478.js +0 -14
  181. package/dist/preview/.next/server/chunks/707.js +0 -13
  182. package/dist/preview/.next/static/B4EYZiVzdylEG9lAIl-aO/_buildManifest.js +0 -1
  183. package/dist/preview/.next/static/chunks/575-bc52750855c25df4.js +0 -2
  184. package/dist/preview/.next/static/chunks/684-0f1ef7361c499798.js +0 -1
  185. package/dist/preview/.next/static/chunks/684c6b30-0c65da32762fc4ee.js +0 -1
  186. package/dist/preview/.next/static/chunks/81-e7539b08d9d3fb4d.js +0 -1
  187. package/dist/preview/.next/static/chunks/883-70c8267c50bc4133.js +0 -1
  188. package/dist/preview/.next/static/chunks/921-d1dc8c63f49e85d6.js +0 -1
  189. package/dist/preview/.next/static/chunks/app/_not-found/page-03ce767859c36d4e.js +0 -1
  190. package/dist/preview/.next/static/chunks/app/layout-7cf14e28880544f1.js +0 -1
  191. package/dist/preview/.next/static/chunks/app/page-065cb49b0a078541.js +0 -1
  192. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-656510fd180c803c.js +0 -1
  193. package/dist/preview/.next/static/chunks/framework-2a724981073c3a29.js +0 -1
  194. package/dist/preview/.next/static/chunks/main-552b9719bbc3a274.js +0 -1
  195. package/dist/preview/.next/static/chunks/main-app-914a73336fd45af5.js +0 -1
  196. package/dist/preview/.next/static/chunks/pages/_app-77ca34bce25ac75c.js +0 -1
  197. package/dist/preview/.next/static/chunks/pages/_error-73f611c46abbb495.js +0 -1
  198. package/dist/preview/.next/static/css/2df96d9ee014e8de.css +0 -3
  199. package/src/actions/email-validation/get-line-and-column-from-index.spec.ts +0 -22
  200. package/src/actions/email-validation/get-line-and-column-from-index.ts +0 -43
  201. package/src/components/icons/icon-scanner.tsx +0 -19
  202. package/src/components/icons/icon-scissors.tsx +0 -19
  203. /package/dist/preview/.next/static/{B4EYZiVzdylEG9lAIl-aO → Pms2orsQgT5xpttCfZfH5}/_ssgManifest.js +0 -0
@@ -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
- import { useEffect, useState } from 'react';
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
 
@@ -8,7 +8,7 @@ interface SpamAssassinProps {
8
8
  result: SpamCheckingResult | undefined;
9
9
  }
10
10
 
11
- interface SpamCheckingResult {
11
+ export interface SpamCheckingResult {
12
12
  checks: {
13
13
  name: string;
14
14
  description: string;
@@ -25,33 +25,26 @@ function toSorted<T>(array: T[], sorter: (a: T, b: T) => number): T[] {
25
25
  }
26
26
 
27
27
  export const useSpamAssassin = ({
28
- slug,
29
28
  markup,
30
29
  plainText,
30
+
31
+ initialResult,
31
32
  }: {
32
- slug: string;
33
33
  markup: string;
34
34
  plainText: string;
35
- }) => {
36
- const cacheKey = `spam-assassin-${slug.replaceAll('/', '-')}`;
37
35
 
38
- const [result, setResult] = useState<SpamCheckingResult | undefined>();
39
-
40
- useEffect(() => {
41
- const cachedValue =
42
- 'localStorage' in global ? global.localStorage.getItem(cacheKey) : null;
43
- if (cachedValue) {
44
- try {
45
- setResult(JSON.parse(cachedValue));
46
- } catch (exception) {
47
- setResult(undefined);
48
- }
49
- }
50
- }, [cacheKey]);
36
+ initialResult?: SpamCheckingResult;
37
+ }) => {
38
+ const [result, setResult] = useState<SpamCheckingResult | undefined>(
39
+ initialResult,
40
+ );
51
41
 
52
42
  const [loading, setLoading] = useState(false);
43
+ const isLoadingRef = useRef(false);
53
44
 
54
45
  const load = async () => {
46
+ if (isLoadingRef.current) return;
47
+ isLoadingRef.current = true;
55
48
  setLoading(true);
56
49
 
57
50
  try {
@@ -64,25 +57,21 @@ export const useSpamAssassin = ({
64
57
  }),
65
58
  });
66
59
 
67
- if (response.ok) {
68
- const responseBody = (await response.json()) as
69
- | { error: string }
70
- | SpamCheckingResult;
71
- if ('error' in responseBody) {
72
- toast.error(responseBody.error);
73
- } else {
74
- setResult(responseBody);
75
- localStorage.setItem(cacheKey, JSON.stringify(responseBody));
76
- }
60
+ const responseBody = (await response.json()) as
61
+ | { error: string }
62
+ | SpamCheckingResult;
63
+ if ('error' in responseBody) {
64
+ toast.error(responseBody.error);
77
65
  } else {
78
- console.error(await response.text());
79
- toast.error('Something went wrong');
66
+ setResult(responseBody);
67
+ return responseBody;
80
68
  }
81
69
  } catch (exception) {
82
70
  console.error(exception);
83
71
  toast.error(JSON.stringify(exception));
84
72
  } finally {
85
73
  setLoading(false);
74
+ isLoadingRef.current = false;
86
75
  }
87
76
  };
88
77
 
@@ -94,25 +83,33 @@ export const SpamAssassin = ({ result }: SpamAssassinProps) => {
94
83
  <>
95
84
  {result ? (
96
85
  <Results>
97
- <Results.Row className="sticky border-b-2 top-0 bg-black">
86
+ <Results.Row className="sticky border-b top-0 bg-black">
98
87
  <Results.Column className="uppercase">
99
- <span className="flex gap-1 items-center">
88
+ <span className="flex gap-2 items-center">
100
89
  <IconWarning
101
90
  className={cn(
102
- result.points > 1.5 ? 'text-yellow-200' : null,
103
- 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,
104
95
  result.points >= 5 ? 'text-red-400' : null,
105
96
  )}
106
97
  />
107
98
  Score
108
99
  </span>
109
100
  </Results.Column>
110
- <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>
111
106
  <Results.Column
112
107
  className={cn(
113
- '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,
114
111
  result.points > 1.5 ? 'text-yellow-200' : null,
115
- result.points > 3 ? 'text-orange-300' : null,
112
+ result.points > 3 ? 'text-orange-400' : null,
116
113
  result.points >= 5 ? 'text-red-400' : null,
117
114
  )}
118
115
  >
@@ -123,15 +120,15 @@ export const SpamAssassin = ({ result }: SpamAssassinProps) => {
123
120
  (check) => (
124
121
  <Results.Row key={check.name}>
125
122
  <Results.Column className="uppercase">
126
- <span className="flex gap-1 items-center">
123
+ <span className="flex gap-2 items-center">
127
124
  <IconWarning
128
125
  className={cn(
129
126
  check.points > 1 ? 'text-yellow-200' : null,
130
- check.points > 2 ? 'text-orange-300' : null,
127
+ check.points > 2 ? 'text-orange-400' : null,
131
128
  check.points > 3 ? 'text-red-400' : null,
132
129
  )}
133
130
  />
134
- {check.name}
131
+ {sanitize(check.name)}
135
132
  </span>
136
133
  </Results.Column>
137
134
  <Results.Column>{check.description}</Results.Column>
@@ -139,7 +136,7 @@ export const SpamAssassin = ({ result }: SpamAssassinProps) => {
139
136
  className={cn(
140
137
  'text-right font-mono tracking-tighter',
141
138
  check.points > 1 ? 'text-yellow-200' : null,
142
- check.points > 2 ? 'text-orange-300' : null,
139
+ check.points > 2 ? 'text-orange-400' : null,
143
140
  check.points > 3 ? 'text-red-400' : null,
144
141
  )}
145
142
  >
@@ -0,0 +1,52 @@
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
+ delayDuration?: number;
10
+ }
11
+
12
+ export const ToolbarButton = ({
13
+ children,
14
+ className,
15
+ active,
16
+ tooltip,
17
+ delayDuration = 500,
18
+ ...props
19
+ }: ToolbarButtonProps) => {
20
+ return (
21
+ <Tooltip.Provider>
22
+ <Tooltip delayDuration={delayDuration}>
23
+ <Tooltip.Trigger asChild>
24
+ <button
25
+ type="button"
26
+ {...props}
27
+ className={cn(
28
+ 'h-full w-fit font-regular flex text-sm text-slate-10 items-center align-middle justify-center px-1 gap-2 relative',
29
+ 'hover:text-slate-12 transition-colors',
30
+ active && 'data-[state=active]:text-cyan-11',
31
+ className,
32
+ )}
33
+ >
34
+ {children}
35
+ {active ? (
36
+ <motion.span
37
+ className="-bottom-px absolute rounded-sm left-0 w-full bg-cyan-11 h-px"
38
+ layoutId="active-toolbar-button"
39
+ transition={{
40
+ type: 'spring',
41
+ bounce: 0.2,
42
+ duration: 0.6,
43
+ }}
44
+ />
45
+ ) : null}
46
+ </button>
47
+ </Tooltip.Trigger>
48
+ {tooltip ? <Tooltip.Content>{tooltip}</Tooltip.Content> : null}
49
+ </Tooltip>
50
+ </Tooltip.Provider>
51
+ );
52
+ };
@@ -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
+ };
@@ -1,185 +1,241 @@
1
+ 'use client';
1
2
  import * as Tabs from '@radix-ui/react-tabs';
2
- import { LayoutGroup, motion } from 'framer-motion';
3
+ import { LayoutGroup } from 'framer-motion';
3
4
  import { usePathname, useRouter, useSearchParams } from 'next/navigation';
4
- import { useEffect } from 'react';
5
+ import { use, useEffect } from 'react';
6
+ import type { CompatibilityCheckingResult } from '../actions/email-validation/check-compatibility';
7
+ import { isBuilding } from '../app/env';
8
+ import { PreviewContext } from '../contexts/preview';
5
9
  import { cn } from '../utils';
6
10
  import { IconArrowDown } from './icons/icon-arrow-down';
11
+ import { IconInfo } from './icons/icon-info';
7
12
  import { IconReload } from './icons/icon-reload';
8
- import { IconScanner } from './icons/icon-scanner';
9
- import { IconScissors } from './icons/icon-scissors';
10
- import { Linter, useLinter } from './toolbar/linter';
11
- import { SpamAssassin, useSpamAssassin } from './toolbar/spam-assassin';
12
- import { Tooltip } from './tooltip';
13
+ import { Compatibility, useCompatibility } from './toolbar/compatibility';
14
+ import { Linter, type LintingRow, useLinter } from './toolbar/linter';
15
+ import {
16
+ SpamAssassin,
17
+ type SpamCheckingResult,
18
+ useSpamAssassin,
19
+ } from './toolbar/spam-assassin';
20
+ import { ToolbarButton } from './toolbar/toolbar-button';
21
+ import { useCachedState } from './toolbar/use-cached-state';
13
22
 
14
- type ToolbarProps = React.ComponentProps<'div'> & {
15
- emailSlug: string;
16
- markup: string;
17
- plainText: string;
18
- };
19
-
20
- type ActivePanelValue = 'linter' | 'spam-assassin';
21
-
22
- interface ToolbarButton extends React.ComponentProps<'button'> {
23
- children: React.ReactNode;
24
- active?: boolean;
25
- tooltip?: React.ReactNode;
26
- }
23
+ export type ToolbarTabValue = 'linter' | 'compatibility' | 'spam-assassin';
27
24
 
28
- const ToolbarButton = ({
29
- children,
30
- className,
31
- active,
32
- tooltip,
33
- ...props
34
- }: ToolbarButton) => {
35
- return (
36
- <Tooltip.Provider>
37
- <Tooltip>
38
- <Tooltip.Trigger asChild>
39
- <button
40
- type="button"
41
- {...props}
42
- className={cn(
43
- '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',
44
- 'hover:text-slate-12 transition-colors',
45
- active && 'data-[state=active]:text-cyan-11',
46
- className,
47
- )}
48
- >
49
- {children}
50
- {active ? (
51
- <motion.span
52
- className="-bottom-px absolute rounded-sm left-0 w-full bg-cyan-11 h-px"
53
- layoutId="active-toolbar-button"
54
- transition={{
55
- type: 'spring',
56
- bounce: 0.2,
57
- duration: 0.6,
58
- }}
59
- />
60
- ) : null}
61
- </button>
62
- </Tooltip.Trigger>
63
- {tooltip ? <Tooltip.Content>{tooltip}</Tooltip.Content> : null}
64
- </Tooltip>
65
- </Tooltip.Provider>
66
- );
67
- };
25
+ const ToolbarInner = ({
26
+ serverLintingRows,
27
+ serverSpamCheckingResult,
28
+ serverCompatibilityResults,
68
29
 
69
- export const Toolbar = ({
70
- emailSlug,
71
30
  markup,
31
+ reactMarkup,
72
32
  plainText,
73
- className,
74
- ...rest
75
- }: ToolbarProps) => {
33
+ emailPath,
34
+ emailSlug,
35
+ }: ToolbarProps & {
36
+ markup: string;
37
+ reactMarkup: string;
38
+ plainText: string;
39
+ emailSlug: string;
40
+ emailPath: string;
41
+ }) => {
76
42
  const pathname = usePathname();
77
43
  const searchParams = useSearchParams();
78
44
  const router = useRouter();
79
45
 
80
- const activePanelValue = (searchParams.get('toolbar-panel') ?? undefined) as
81
- | ActivePanelValue
46
+ const activeTab = (searchParams.get('toolbar-panel') ?? undefined) as
47
+ | ToolbarTabValue
82
48
  | undefined;
83
49
 
84
- const toggled = activePanelValue !== undefined;
50
+ const toggled = activeTab !== undefined;
85
51
 
86
- const setActivePanelValue = (newValue: ActivePanelValue | undefined) => {
87
- console.log(newValue);
52
+ const setActivePanelValue = (newValue: ToolbarTabValue | undefined) => {
88
53
  const params = new URLSearchParams(searchParams);
89
54
  if (newValue === undefined) {
90
55
  params.delete('toolbar-panel');
91
56
  } else {
92
57
  params.set('toolbar-panel', newValue);
93
58
  }
94
- router.push(`${pathname}?${params.toString()}`);
59
+ router.push(`${pathname}?${params.toString()}${location.hash}`);
95
60
  };
96
61
 
97
- const [spamCheckingResult, { load: loadSpamChecking }] = useSpamAssassin({
98
- slug: emailSlug,
62
+ const [cachedSpamCheckingResult, setCachedSpamCheckingResult] =
63
+ useCachedState<SpamCheckingResult>(
64
+ `spam-assassin-${emailSlug.replaceAll('/', '-')}`,
65
+ );
66
+ const [spamCheckingResult, { load: loadSpamChecking, loading: spamLoading }] =
67
+ useSpamAssassin({
68
+ markup,
69
+ plainText,
70
+
71
+ initialResult: serverSpamCheckingResult ?? cachedSpamCheckingResult,
72
+ });
73
+
74
+ const [cachedLintingRows, setCachedLintingRows] = useCachedState<
75
+ LintingRow[]
76
+ >(`linter-${emailSlug.replaceAll('/', '-')}`);
77
+ const [lintingRows, { load: loadLinting, loading: lintLoading }] = useLinter({
99
78
  markup,
100
- plainText,
79
+
80
+ initialRows: serverLintingRows ?? cachedLintingRows,
101
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,
102
92
 
103
- const [lintingResults, { load: loadLinting }] = useLinter({
104
- slug: emailSlug,
105
- markup,
93
+ initialResults: serverCompatibilityResults ?? cachedCompatibilityResults,
106
94
  });
107
95
 
108
- useEffect(() => {
109
- loadLinting();
110
- loadSpamChecking();
111
- }, []);
96
+ if (!isBuilding) {
97
+ useEffect(() => {
98
+ (async () => {
99
+ const lintingRows = await loadLinting();
100
+ setCachedLintingRows(lintingRows);
101
+
102
+ const spamCheckingResult = await loadSpamChecking();
103
+ setCachedSpamCheckingResult(spamCheckingResult);
104
+
105
+ const compatibilityCheckingResults = await loadCompatibility();
106
+ setCachedCompatibilityResults(compatibilityCheckingResults);
107
+ })();
108
+ }, []);
109
+ }
112
110
 
113
111
  return (
114
112
  <div
115
- {...rest}
116
113
  data-toggled={toggled}
117
114
  className={cn(
118
- 'bg-black group/toolbar text-xs text-slate-11 h-48 transition-all',
119
- 'data-[toggled=false]:h-8',
120
- className,
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]',
121
118
  )}
122
119
  >
123
120
  <Tabs.Root
124
- value={activePanelValue}
121
+ value={activeTab ?? ''}
125
122
  onValueChange={(newValue) => {
126
- setActivePanelValue(newValue as ActivePanelValue);
123
+ setActivePanelValue(newValue as ToolbarTabValue);
127
124
  }}
128
125
  asChild
129
126
  >
130
127
  <div className="flex flex-col h-full">
131
- <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">
132
129
  <LayoutGroup id="toolbar">
133
- <Tabs.Trigger asChild value="spam-assassin">
134
- <ToolbarButton active={activePanelValue === 'spam-assassin'}>
135
- <IconScissors />
136
- Spam Assassin
137
- </ToolbarButton>
138
- </Tabs.Trigger>
139
130
  <Tabs.Trigger asChild value="linter">
140
- <ToolbarButton active={activePanelValue === 'linter'}>
141
- <IconScanner />
131
+ <ToolbarButton active={activeTab === 'linter'}>
142
132
  Linter
143
133
  </ToolbarButton>
144
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>
145
145
  </LayoutGroup>
146
- <div className="flex gap-1 ml-auto">
146
+ <div className="flex gap-0.5 ml-auto">
147
147
  <ToolbarButton
148
- tooltip="Reload"
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
+ }
149
158
  onClick={() => {
150
- if (activePanelValue === 'spam-assassin') {
151
- void loadSpamChecking();
152
- } else if (activePanelValue === 'linter') {
153
- void loadLinting();
154
- } else {
159
+ if (activeTab === undefined) {
155
160
  setActivePanelValue('linter');
156
- void loadLinting();
161
+ } else {
162
+ setActivePanelValue(undefined);
157
163
  }
158
164
  }}
159
165
  >
160
- <IconReload />
166
+ <IconInfo size={24} />
161
167
  </ToolbarButton>
168
+ {isBuilding ? null : (
169
+ <ToolbarButton
170
+ tooltip="Reload"
171
+ disabled={lintLoading || spamLoading}
172
+ onClick={async () => {
173
+ if (activeTab === undefined) {
174
+ setActivePanelValue('linter');
175
+ }
176
+ if (activeTab === 'spam-assassin') {
177
+ await loadSpamChecking();
178
+ } else if (activeTab === 'linter') {
179
+ await loadLinting();
180
+ } else if (activeTab === 'compatibility') {
181
+ await loadCompatibility();
182
+ }
183
+ }}
184
+ >
185
+ <IconReload
186
+ size={24}
187
+ className={cn({
188
+ 'animate-spin opacity-60 animate-spin-fast':
189
+ lintLoading || spamLoading,
190
+ })}
191
+ />
192
+ </ToolbarButton>
193
+ )}
162
194
  <ToolbarButton
163
195
  tooltip="Toggle toolbar"
164
196
  onClick={() => {
165
- if (activePanelValue === undefined) {
197
+ if (activeTab === undefined) {
166
198
  setActivePanelValue('linter');
167
199
  } else {
168
200
  setActivePanelValue(undefined);
169
201
  }
170
202
  }}
171
203
  >
172
- <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
+ />
173
208
  </ToolbarButton>
174
209
  </div>
175
210
  </Tabs.List>
176
211
 
177
- <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">
178
213
  <Tabs.Content value="linter">
179
- <Linter results={lintingResults} />
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
+ )}
180
221
  </Tabs.Content>
181
222
  <Tabs.Content value="spam-assassin">
182
- <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
+ )}
183
239
  </Tabs.Content>
184
240
  </div>
185
241
  </div>
@@ -187,3 +243,33 @@ export const Toolbar = ({
187
243
  </div>
188
244
  );
189
245
  };
246
+
247
+ interface ToolbarProps {
248
+ serverSpamCheckingResult: SpamCheckingResult | undefined;
249
+ serverLintingRows: LintingRow[] | undefined;
250
+ serverCompatibilityResults: CompatibilityCheckingResult[] | undefined;
251
+ }
252
+
253
+ export const Toolbar = ({
254
+ serverLintingRows,
255
+ serverSpamCheckingResult,
256
+ serverCompatibilityResults,
257
+ }: ToolbarProps) => {
258
+ const { emailPath, emailSlug, renderedEmailMetadata } = use(PreviewContext)!;
259
+
260
+ if (renderedEmailMetadata === undefined) return null;
261
+ const { markup, plainText, reactMarkup } = renderedEmailMetadata;
262
+
263
+ return (
264
+ <ToolbarInner
265
+ emailPath={emailPath}
266
+ emailSlug={emailSlug}
267
+ markup={markup}
268
+ reactMarkup={reactMarkup}
269
+ plainText={plainText}
270
+ serverLintingRows={serverLintingRows}
271
+ serverSpamCheckingResult={serverSpamCheckingResult}
272
+ serverCompatibilityResults={serverCompatibilityResults}
273
+ />
274
+ );
275
+ };
@@ -18,7 +18,7 @@ export const TooltipContent = React.forwardRef<
18
18
  {...props}
19
19
  className={cn(
20
20
  'z-20 rounded-md border border-slate-6 bg-black px-3 py-2 text-white text-xs',
21
- 'font-sans',
21
+ 'font-sans max-w-60',
22
22
  )}
23
23
  ref={forwardedRef}
24
24
  sideOffset={sideOffset}
@@ -123,7 +123,7 @@ const PresetMenuItem = ({
123
123
  onClick={() => onSelect(dimensions)}
124
124
  >
125
125
  {name}
126
- <span className="flex h-fit items-center rounded-full bg-slate-6 px-1.5 py-0.5 font-bold text-white text-xs">
126
+ <span className="flex h-fit items-center rounded-full bg-slate-6 px-2 py-1 font-medium text-slate-11 text-xs">
127
127
  {dimensions.width}x{dimensions.height}
128
128
  </span>
129
129
  </DropdownMenu.Item>