react-email 4.0.0-alpha.5 → 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 (173) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/cli/index.js +1175 -2659
  3. package/dist/cli/index.mjs +16 -14
  4. package/dist/preview/.next/BUILD_ID +1 -1
  5. package/dist/preview/.next/app-build-manifest.json +31 -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 +47 -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/chunks/171.js +14 -0
  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/833.js +1 -0
  38. package/dist/preview/.next/server/functions-config-manifest.json +4 -1
  39. package/dist/preview/.next/server/middleware-build-manifest.js +1 -1
  40. package/dist/preview/.next/server/pages/500.html +1 -1
  41. package/dist/preview/.next/server/pages/_app.js +1 -1
  42. package/dist/preview/.next/server/pages/_app.js.nft.json +1 -1
  43. package/dist/preview/.next/server/pages/_document.js +1 -1
  44. package/dist/preview/.next/server/pages/_document.js.nft.json +1 -1
  45. package/dist/preview/.next/server/pages/_error.js +1 -1
  46. package/dist/preview/.next/server/pages/_error.js.nft.json +1 -1
  47. package/dist/preview/.next/server/pages-manifest.json +5 -1
  48. package/dist/preview/.next/server/server-reference-manifest.js +1 -1
  49. package/dist/preview/.next/server/server-reference-manifest.json +1 -1
  50. package/dist/preview/.next/server/webpack-runtime.js +1 -1
  51. package/dist/preview/.next/static/chunks/416-56f79fc7e689f06f.js +1 -0
  52. package/dist/preview/.next/static/chunks/683-8bbfd191e5105f01.js +1 -0
  53. package/dist/preview/.next/static/chunks/744-79730358b37b2212.js +1 -0
  54. package/dist/preview/.next/static/chunks/781-5f16c6bc9d9d4cc1.js +1 -0
  55. package/dist/preview/.next/static/chunks/832ad4be-cb988facfb8f955f.js +1 -0
  56. package/dist/preview/.next/static/chunks/87-38e35f08507de015.js +1 -0
  57. package/dist/preview/.next/static/chunks/{afa401a5-a600c227dacf3ab4.js → afa401a5-3e949a1cfd317dd3.js} +3 -3
  58. package/dist/preview/.next/static/chunks/app/_not-found/page-09d694081cc9d4dc.js +1 -0
  59. package/dist/preview/.next/static/chunks/app/layout-a6640e62690d8fd6.js +1 -0
  60. package/dist/preview/.next/static/chunks/app/page-ba68f50b287e7478.js +1 -0
  61. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-4a5b026ab543e27f.js +1 -0
  62. package/dist/preview/.next/static/chunks/framework-c2bd6d936e3077bc.js +1 -0
  63. package/dist/preview/.next/static/chunks/main-44463a8301435b64.js +1 -0
  64. package/dist/preview/.next/static/chunks/main-app-c2e686acf8d370d7.js +1 -0
  65. package/dist/preview/.next/static/chunks/pages/_app-f3011d3f00bb8dba.js +1 -0
  66. package/dist/preview/.next/static/chunks/pages/_error-39a87dee2e97a2a3.js +1 -0
  67. package/dist/preview/.next/static/chunks/{webpack-2eb145a20ee6cb77.js → webpack-41e2667c9f086a4f.js} +1 -1
  68. package/dist/preview/.next/static/css/d7df9cfc3e182163.css +3 -0
  69. package/dist/preview/.next/static/gFk9UfWL8joM4iD7-wlKF/_buildManifest.js +1 -0
  70. package/dist/preview/.next/trace +26 -22
  71. package/dist/preview/.next/types/cache-life.d.ts +3 -3
  72. package/package.json +14 -9
  73. package/scripts/build-preview-server.mjs +32 -0
  74. package/scripts/fill-caniemail-data.mjs +36 -0
  75. package/src/actions/email-validation/caniemail-data.ts +85993 -0
  76. package/src/actions/email-validation/check-compatibility.ts +322 -0
  77. package/src/actions/email-validation/check-images.spec.tsx +2 -2
  78. package/src/actions/email-validation/check-images.ts +2 -2
  79. package/src/actions/email-validation/check-links.spec.tsx +4 -4
  80. package/src/actions/email-validation/check-links.ts +2 -2
  81. package/src/actions/get-email-path-from-slug.ts +1 -1
  82. package/src/actions/render-email-by-path.tsx +2 -1
  83. package/src/{utils/emails-directory-absolute-path.ts → app/env.ts} +2 -0
  84. package/src/app/layout.tsx +1 -1
  85. package/src/app/page.tsx +1 -1
  86. package/src/app/preview/[...slug]/page.tsx +73 -16
  87. package/src/app/preview/[...slug]/preview.tsx +11 -57
  88. package/src/components/code.tsx +0 -1
  89. package/src/components/toolbar/linter.tsx +267 -124
  90. package/src/components/toolbar/spam-assassin.tsx +20 -31
  91. package/src/components/toolbar/toolbar-button.tsx +50 -0
  92. package/src/components/toolbar/use-cached-state.ts +33 -0
  93. package/src/components/toolbar.tsx +106 -98
  94. package/src/components/topbar/view-size-controls.tsx +1 -0
  95. package/src/components/topbar.tsx +3 -9
  96. package/src/contexts/emails.tsx +2 -1
  97. package/src/contexts/preview.tsx +81 -0
  98. package/src/hooks/use-email-rendering-result.ts +2 -1
  99. package/src/utils/__snapshots__/get-email-component.spec.ts.snap +1 -1
  100. package/src/utils/caniemail/all-css-properties.ts +358 -0
  101. package/src/utils/caniemail/ast/get-object-variables.ts +61 -0
  102. package/src/utils/caniemail/ast/get-used-style-properties.ts +91 -0
  103. package/src/utils/caniemail/get-compatibility-stats-for-entry.ts +118 -0
  104. package/src/utils/caniemail/get-css-functions.ts +25 -0
  105. package/src/utils/caniemail/get-css-property-names.ts +32 -0
  106. package/src/utils/caniemail/get-css-property-with-value.ts +14 -0
  107. package/src/utils/caniemail/get-css-unit.ts +3 -0
  108. package/src/utils/caniemail/get-element-attributes.ts +7 -0
  109. package/src/utils/caniemail/get-element-names.ts +20 -0
  110. package/src/utils/caniemail/tailwind/generate-tailwind-rules.ts +30 -0
  111. package/src/utils/caniemail/tailwind/get-tailwind-config.ts +205 -0
  112. package/src/utils/caniemail/tailwind/get-tailwind-metadata.spec.ts +25 -0
  113. package/src/utils/caniemail/tailwind/get-tailwind-metadata.ts +45 -0
  114. package/src/utils/caniemail/tailwind/setup-tailwind-context.ts +15 -0
  115. package/src/utils/get-email-component.ts +34 -67
  116. package/src/utils/linting.ts +85 -0
  117. package/src/utils/result.ts +49 -0
  118. package/src/utils/run-bundled-code.ts +64 -0
  119. package/tailwind-internals.d.ts +133 -0
  120. package/tsconfig.json +9 -3
  121. package/build-preview-server.mjs +0 -25
  122. package/dist/preview/.next/cache/images/TcyzHbFXGFjrOu3wEMvDoSmqCh3qP3iiNqJf0QbED9Y/60.1741728556140.cQ5qicbpvoXZ7leVmWqG2ElLwXB1ynYeSv8MBSA-QeM.Vy8iMWM3MGUtMTk1ODcxYmIyNzMi.webp +0 -0
  123. package/dist/preview/.next/cache/webpack/client-development/0.pack.gz +0 -0
  124. package/dist/preview/.next/cache/webpack/client-development/1.pack.gz +0 -0
  125. package/dist/preview/.next/cache/webpack/client-development/10.pack.gz +0 -0
  126. package/dist/preview/.next/cache/webpack/client-development/11.pack.gz +0 -0
  127. package/dist/preview/.next/cache/webpack/client-development/12.pack.gz +0 -0
  128. package/dist/preview/.next/cache/webpack/client-development/13.pack.gz +0 -0
  129. package/dist/preview/.next/cache/webpack/client-development/2.pack.gz +0 -0
  130. package/dist/preview/.next/cache/webpack/client-development/3.pack.gz +0 -0
  131. package/dist/preview/.next/cache/webpack/client-development/4.pack.gz +0 -0
  132. package/dist/preview/.next/cache/webpack/client-development/5.pack.gz +0 -0
  133. package/dist/preview/.next/cache/webpack/client-development/6.pack.gz +0 -0
  134. package/dist/preview/.next/cache/webpack/client-development/7.pack.gz +0 -0
  135. package/dist/preview/.next/cache/webpack/client-development/8.pack.gz +0 -0
  136. package/dist/preview/.next/cache/webpack/client-development/9.pack.gz +0 -0
  137. package/dist/preview/.next/cache/webpack/client-development/index.pack.gz +0 -0
  138. package/dist/preview/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
  139. package/dist/preview/.next/cache/webpack/server-development/0.pack.gz +0 -0
  140. package/dist/preview/.next/cache/webpack/server-development/1.pack.gz +0 -0
  141. package/dist/preview/.next/cache/webpack/server-development/2.pack.gz +0 -0
  142. package/dist/preview/.next/cache/webpack/server-development/3.pack.gz +0 -0
  143. package/dist/preview/.next/cache/webpack/server-development/4.pack.gz +0 -0
  144. package/dist/preview/.next/cache/webpack/server-development/5.pack.gz +0 -0
  145. package/dist/preview/.next/cache/webpack/server-development/6.pack.gz +0 -0
  146. package/dist/preview/.next/cache/webpack/server-development/7.pack.gz +0 -0
  147. package/dist/preview/.next/cache/webpack/server-development/8.pack.gz +0 -0
  148. package/dist/preview/.next/cache/webpack/server-development/9.pack.gz +0 -0
  149. package/dist/preview/.next/cache/webpack/server-development/index.pack.gz +0 -0
  150. package/dist/preview/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
  151. package/dist/preview/.next/server/chunks/143.js +0 -6
  152. package/dist/preview/.next/server/chunks/409.js +0 -5
  153. package/dist/preview/.next/server/chunks/46.js +0 -1
  154. package/dist/preview/.next/server/chunks/478.js +0 -14
  155. package/dist/preview/.next/server/chunks/707.js +0 -13
  156. package/dist/preview/.next/static/B4EYZiVzdylEG9lAIl-aO/_buildManifest.js +0 -1
  157. package/dist/preview/.next/static/chunks/575-bc52750855c25df4.js +0 -2
  158. package/dist/preview/.next/static/chunks/684-0f1ef7361c499798.js +0 -1
  159. package/dist/preview/.next/static/chunks/684c6b30-0c65da32762fc4ee.js +0 -1
  160. package/dist/preview/.next/static/chunks/81-e7539b08d9d3fb4d.js +0 -1
  161. package/dist/preview/.next/static/chunks/883-70c8267c50bc4133.js +0 -1
  162. package/dist/preview/.next/static/chunks/921-d1dc8c63f49e85d6.js +0 -1
  163. package/dist/preview/.next/static/chunks/app/_not-found/page-03ce767859c36d4e.js +0 -1
  164. package/dist/preview/.next/static/chunks/app/layout-7cf14e28880544f1.js +0 -1
  165. package/dist/preview/.next/static/chunks/app/page-065cb49b0a078541.js +0 -1
  166. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-656510fd180c803c.js +0 -1
  167. package/dist/preview/.next/static/chunks/framework-2a724981073c3a29.js +0 -1
  168. package/dist/preview/.next/static/chunks/main-552b9719bbc3a274.js +0 -1
  169. package/dist/preview/.next/static/chunks/main-app-914a73336fd45af5.js +0 -1
  170. package/dist/preview/.next/static/chunks/pages/_app-77ca34bce25ac75c.js +0 -1
  171. package/dist/preview/.next/static/chunks/pages/_error-73f611c46abbb495.js +0 -1
  172. package/dist/preview/.next/static/css/2df96d9ee014e8de.css +0 -3
  173. /package/dist/preview/.next/static/{B4EYZiVzdylEG9lAIl-aO → gFk9UfWL8joM4iD7-wlKF}/_ssgManifest.js +0 -0
@@ -1,11 +1,10 @@
1
1
  'use client';
2
2
 
3
3
  import { usePathname, useRouter, useSearchParams } from 'next/navigation';
4
- import { useState } from 'react';
4
+ import { use, useState } from 'react';
5
5
  import { flushSync } from 'react-dom';
6
6
  import { Toaster } from 'sonner';
7
7
  import { useDebouncedCallback } from 'use-debounce';
8
- import type { EmailRenderingResult } from '../../../actions/render-email-by-path';
9
8
  import { Topbar } from '../../../components';
10
9
  import { CodeContainer } from '../../../components/code-container';
11
10
  import {
@@ -13,30 +12,21 @@ import {
13
12
  makeIframeDocumentBubbleEvents,
14
13
  } from '../../../components/resizable-wrapper';
15
14
  import { Send } from '../../../components/send';
16
- import { Shell, ShellContent } from '../../../components/shell';
17
- import { Toolbar } from '../../../components/toolbar';
15
+ import { ShellContent } from '../../../components/shell';
18
16
  import { Tooltip } from '../../../components/tooltip';
19
17
  import { ActiveViewToggleGroup } from '../../../components/topbar/active-view-toggle-group';
20
18
  import { ViewSizeControls } from '../../../components/topbar/view-size-controls';
19
+ import { PreviewContext } from '../../../contexts/preview';
21
20
  import { useClampedState } from '../../../hooks/use-clamped-state';
22
- import { useEmailRenderingResult } from '../../../hooks/use-email-rendering-result';
23
- import { useHotreload } from '../../../hooks/use-hot-reload';
24
- import { useRenderingMetadata } from '../../../hooks/use-rendering-metadata';
25
21
  import { RenderingError } from './rendering-error';
26
22
 
27
23
  interface PreviewProps {
28
- slug: string;
29
- emailPath: string;
30
- pathSeparator: string;
31
- serverRenderingResult: EmailRenderingResult;
24
+ emailTitle: string;
32
25
  }
33
26
 
34
- const Preview = ({
35
- slug,
36
- emailPath,
37
- pathSeparator,
38
- serverRenderingResult,
39
- }: PreviewProps) => {
27
+ const Preview = ({ emailTitle }: PreviewProps) => {
28
+ const { renderingResult, renderedEmailMetadata } = use(PreviewContext)!;
29
+
40
30
  const router = useRouter();
41
31
  const pathname = usePathname();
42
32
  const searchParams = useSearchParams();
@@ -44,34 +34,6 @@ const Preview = ({
44
34
  const activeView = searchParams.get('view') ?? 'preview';
45
35
  const activeLang = searchParams.get('lang') ?? 'jsx';
46
36
 
47
- const renderingResult = useEmailRenderingResult(
48
- emailPath,
49
- serverRenderingResult,
50
- );
51
-
52
- const renderedEmailMetadata = useRenderingMetadata(
53
- emailPath,
54
- renderingResult,
55
- serverRenderingResult,
56
- );
57
-
58
- if (process.env.NEXT_PUBLIC_IS_BUILDING !== 'true') {
59
- // this will not change on runtime so it doesn't violate
60
- // the rules of hooks
61
- // eslint-disable-next-line react-hooks/rules-of-hooks
62
- useHotreload((changes) => {
63
- const changeForThisEmail = changes.find((change) =>
64
- change.filename.includes(slug),
65
- );
66
-
67
- if (typeof changeForThisEmail !== 'undefined') {
68
- if (changeForThisEmail.event === 'unlink') {
69
- router.push('/');
70
- }
71
- }
72
- });
73
- }
74
-
75
37
  const handleViewChange = (view: string) => {
76
38
  const params = new URLSearchParams(searchParams);
77
39
  params.set('view', view);
@@ -113,8 +75,8 @@ const Preview = ({
113
75
  }, 300);
114
76
 
115
77
  return (
116
- <Shell currentEmailOpenSlug={slug}>
117
- <Topbar pathSeparator={pathSeparator} currentEmailOpenSlug={slug}>
78
+ <>
79
+ <Topbar emailTitle={emailTitle}>
118
80
  <ViewSizeControls
119
81
  setViewHeight={(height) => {
120
82
  setHeight(height);
@@ -199,7 +161,7 @@ const Preview = ({
199
161
  width: `${width}px`,
200
162
  height: `${height}px`,
201
163
  }}
202
- title={slug}
164
+ title={emailTitle}
203
165
  />
204
166
  </ResizableWarpper>
205
167
  )}
@@ -235,15 +197,7 @@ const Preview = ({
235
197
 
236
198
  <Toaster />
237
199
  </ShellContent>
238
-
239
- {!hasErrors && hasRenderingMetadata ? (
240
- <Toolbar
241
- emailSlug={slug}
242
- markup={renderedEmailMetadata.markup}
243
- plainText={renderedEmailMetadata.plainText}
244
- />
245
- ) : undefined}
246
- </Shell>
200
+ </>
247
201
  );
248
202
  };
249
203
 
@@ -78,7 +78,6 @@ export const Code: React.FC<Readonly<CodeProps>> = ({
78
78
  {line.map((token, key) => {
79
79
  const tokenProps = getTokenProps({
80
80
  token,
81
- key,
82
81
  });
83
82
  const isException =
84
83
  token.content === 'from' &&
@@ -1,167 +1,310 @@
1
1
  import prettyBytes from 'pretty-bytes';
2
- import { useEffect, useState } from 'react';
3
- import {
4
- type ImageCheck,
5
- type ImageCheckingResult,
6
- checkImages,
7
- } from '../../actions/email-validation/check-images';
8
- import {
9
- type LinkCheck,
10
- type LinkCheckingResult,
11
- checkLinks,
12
- } from '../../actions/email-validation/check-links';
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';
13
9
  import { IconWarning } from '../icons/icon-warning';
14
10
  import { Results } from './results';
15
11
 
16
- type LintingResult = Omit<
17
- ImageCheckingResult | LinkCheckingResult,
18
- 'checks'
19
- > & {
20
- checks: (LinkCheck | ImageCheck)[];
21
- };
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
+ };
22
25
 
23
26
  interface LinterProps {
24
- results: LintingResult[] | undefined;
27
+ rows: LintingRow[] | undefined;
25
28
  }
26
29
 
27
30
  export const useLinter = ({
28
31
  markup,
29
- slug,
32
+ reactMarkup,
33
+ emailPath,
34
+
35
+ initialRows,
30
36
  }: {
37
+ reactMarkup: string;
31
38
  markup: string;
32
- slug: string;
39
+ emailPath: string;
40
+
41
+ initialRows?: LintingRow[];
33
42
  }) => {
34
- const cacheKey = `linter-${slug.replaceAll('/', '-')}`;
35
- const [results, setResults] = useState<LintingResult[] | undefined>();
36
-
37
- useEffect(() => {
38
- const cachedValue =
39
- 'localStorage' in global ? global.localStorage.getItem(cacheKey) : null;
40
- if (cachedValue) {
41
- setResults(JSON.parse(cachedValue));
42
- }
43
- }, [cacheKey]);
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
+ );
44
53
 
45
54
  const [loading, setLoading] = useState(false);
55
+ const isStreaming = useRef(false);
46
56
 
47
57
  const load = async () => {
58
+ if (isStreaming.current) return;
59
+ isStreaming.current = true;
48
60
  setLoading(true);
49
- const imageResultsReader = (
50
- await checkImages(markup, `${location.protocol}//${location.host}`)
51
- ).getReader();
52
- const linkResultsReader = (await checkLinks(markup)).getReader();
53
- setResults([]);
54
61
 
62
+ setRows([]);
55
63
  try {
56
- while (true) {
57
- const { done: imageResultsReaderDone, value: imageLintingResult } =
58
- await imageResultsReader.read();
59
- if (imageLintingResult) {
60
- setResults((results) => [...results!, imageLintingResult]);
61
- }
62
- const { done: linkResultsReaderDone, value: linkLintingResult } =
63
- await linkResultsReader.read();
64
- if (linkLintingResult) {
65
- setResults((results) => [...results!, linkLintingResult]);
66
- }
67
- if (linkResultsReaderDone && imageResultsReaderDone) {
68
- break;
69
- }
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
+ });
70
85
  }
86
+ return rows;
71
87
  } finally {
72
- linkResultsReader.releaseLock();
73
- imageResultsReader.releaseLock();
74
88
  setLoading(false);
89
+ isStreaming.current = false;
75
90
  }
76
91
  };
77
92
 
78
- useEffect(() => {
79
- load();
80
- }, []);
81
-
82
- return [results, { loading, load }] as const;
93
+ return [rows, { loading, load }] as const;
83
94
  };
84
95
 
85
- export const Linter = ({ results }: LinterProps) => {
86
- if (results === undefined) return null;
96
+ export const Linter = ({ rows }: LinterProps) => {
97
+ if (rows === undefined) return null;
87
98
 
88
99
  return (
89
100
  <Results>
90
- {results
91
- .filter((result) => result.status !== 'success')
92
- .map((result, i) => {
93
- const metadataString = result.checks
94
- .map((check) => {
95
- if (
96
- check.type === 'fetch_attempt' &&
97
- check.metadata.fetchStatusCode
98
- )
99
- return `${check.metadata?.fetchStatusCode}`;
100
- if (check.type === 'image_size' && check.metadata.byteCount)
101
- return `${prettyBytes(check.metadata.byteCount)}`;
102
- return null;
103
- })
104
- .filter(Boolean)
105
- .join(' - ');
106
- const failingCheck = result.checks.find(
101
+ {rows.map((row, i) => {
102
+ if (row.source === 'link') {
103
+ const failingCheck = row.result.checks.find(
107
104
  (check) => check.passed === false,
108
105
  )!;
109
-
110
- const checkDescription = (() => {
111
- if (failingCheck.type === 'security') {
112
- return 'Insecure URL, use HTTPS instead of HTTP';
113
- }
114
- if (
115
- failingCheck.type === 'fetch_attempt' &&
116
- failingCheck.metadata.fetchStatusCode !== undefined
117
- ) {
118
- if (
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 &&
119
115
  failingCheck.metadata.fetchStatusCode >= 300 &&
120
116
  failingCheck.metadata.fetchStatusCode < 400
121
- ) {
122
- return `There was a redirect (${failingCheck.metadata.fetchStatusCode}), the content may have been moved`;
123
- }
124
- return `The link is broken, returning ${failingCheck.metadata.fetchStatusCode}`;
125
- }
126
- if (failingCheck.type === 'accessibility') {
127
- return 'Missing alt text';
128
- }
129
- if (failingCheck.type === 'syntax') {
130
- return 'The link is broken due to invalid syntax';
131
- }
132
- if (
133
- failingCheck.type === 'image_size' &&
134
- failingCheck.metadata.byteCount
135
- ) {
136
- return `This image is too large (${prettyBytes(failingCheck.metadata.byteCount)}), keep it under 1mb`;
137
- }
138
- })();
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(', ');
139
155
 
140
156
  return (
141
- <Results.Row key={i}>
142
- <Results.Column>
143
- <span
144
- className="flex uppercase gap-1 items-center data-[status=error]:text-red-400 data-[status=warning]:text-orange-300"
145
- data-status={result.status}
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"
146
179
  >
147
- <IconWarning />
148
- {failingCheck.type}
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}
149
224
  </span>
150
- </Results.Column>
151
- <Results.Column>
152
- {checkDescription}
153
- {result.intendedFor && (
154
- <span className="float-right font-mono text-slate-11 max-w-[30ch] text-nowrap overflow-hidden text-ellipsis">
155
- {result.intendedFor}
156
- </span>
157
- )}
158
- </Results.Column>
159
- <Results.Column align="right" className="font-mono text-slate-11">
160
- {metadataString}
161
- </Results.Column>
162
- </Results.Row>
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>
163
249
  );
164
- })}
250
+ }
251
+
252
+ return undefined;
253
+ })}
165
254
  </Results>
166
255
  );
167
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
+ };
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useRef, useState } from 'react';
2
2
  import { toast } from 'sonner';
3
3
  import { cn } from '../../utils';
4
4
  import { IconWarning } from '../icons/icon-warning';
@@ -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