react-email 4.0.0-alpha.3 → 4.0.0-alpha.5

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 (174) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cli/index.js +7 -5
  3. package/dist/cli/index.mjs +11 -6
  4. package/dist/preview/.next/BUILD_ID +1 -1
  5. package/dist/preview/.next/app-build-manifest.json +31 -34
  6. package/dist/preview/.next/build-manifest.json +14 -14
  7. package/dist/preview/.next/cache/.rscinfo +1 -1
  8. package/dist/preview/.next/cache/images/TcyzHbFXGFjrOu3wEMvDoSmqCh3qP3iiNqJf0QbED9Y/60.1741728556140.cQ5qicbpvoXZ7leVmWqG2ElLwXB1ynYeSv8MBSA-QeM.Vy8iMWM3MGUtMTk1ODcxYmIyNzMi.webp +0 -0
  9. package/dist/preview/.next/cache/webpack/client-development/0.pack.gz +0 -0
  10. package/dist/preview/.next/cache/webpack/client-development/1.pack.gz +0 -0
  11. package/dist/preview/.next/cache/webpack/client-development/10.pack.gz +0 -0
  12. package/dist/preview/.next/cache/webpack/client-development/11.pack.gz +0 -0
  13. package/dist/preview/.next/cache/webpack/client-development/12.pack.gz +0 -0
  14. package/dist/preview/.next/cache/webpack/client-development/13.pack.gz +0 -0
  15. package/dist/preview/.next/cache/webpack/client-development/2.pack.gz +0 -0
  16. package/dist/preview/.next/cache/webpack/client-development/3.pack.gz +0 -0
  17. package/dist/preview/.next/cache/webpack/client-development/4.pack.gz +0 -0
  18. package/dist/preview/.next/cache/webpack/client-development/5.pack.gz +0 -0
  19. package/dist/preview/.next/cache/webpack/client-development/6.pack.gz +0 -0
  20. package/dist/preview/.next/cache/webpack/client-development/7.pack.gz +0 -0
  21. package/dist/preview/.next/cache/webpack/client-development/8.pack.gz +0 -0
  22. package/dist/preview/.next/cache/webpack/client-development/9.pack.gz +0 -0
  23. package/dist/preview/.next/cache/webpack/client-development/index.pack.gz +0 -0
  24. package/dist/preview/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
  25. package/dist/preview/.next/cache/webpack/client-production/0.pack +0 -0
  26. package/dist/preview/.next/cache/webpack/client-production/index.pack +0 -0
  27. package/dist/preview/.next/cache/webpack/edge-server-production/index.pack +0 -0
  28. package/dist/preview/.next/cache/webpack/server-development/0.pack.gz +0 -0
  29. package/dist/preview/.next/cache/webpack/server-development/1.pack.gz +0 -0
  30. package/dist/preview/.next/cache/webpack/server-development/2.pack.gz +0 -0
  31. package/dist/preview/.next/cache/webpack/server-development/3.pack.gz +0 -0
  32. package/dist/preview/.next/cache/webpack/server-development/4.pack.gz +0 -0
  33. package/dist/preview/.next/cache/webpack/server-development/5.pack.gz +0 -0
  34. package/dist/preview/.next/cache/webpack/server-development/6.pack.gz +0 -0
  35. package/dist/preview/.next/cache/webpack/server-development/7.pack.gz +0 -0
  36. package/dist/preview/.next/cache/webpack/server-development/8.pack.gz +0 -0
  37. package/dist/preview/.next/cache/webpack/server-development/9.pack.gz +0 -0
  38. package/dist/preview/.next/cache/webpack/server-development/index.pack.gz +0 -0
  39. package/dist/preview/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
  40. package/dist/preview/.next/cache/webpack/server-production/0.pack +0 -0
  41. package/dist/preview/.next/cache/webpack/server-production/index.pack +0 -0
  42. package/dist/preview/.next/diagnostics/framework.json +1 -1
  43. package/dist/preview/.next/next-minimal-server.js.nft.json +1 -1
  44. package/dist/preview/.next/next-server.js.nft.json +1 -1
  45. package/dist/preview/.next/prerender-manifest.json +1 -1
  46. package/dist/preview/.next/required-server-files.json +1 -1
  47. package/dist/preview/.next/server/app/_not-found/page.js +1 -1
  48. package/dist/preview/.next/server/app/_not-found/page.js.nft.json +1 -1
  49. package/dist/preview/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  50. package/dist/preview/.next/server/app/favicon.ico/route.js +1 -1
  51. package/dist/preview/.next/server/app/favicon.ico/route.js.nft.json +1 -1
  52. package/dist/preview/.next/server/app/page.js +1 -1
  53. package/dist/preview/.next/server/app/page.js.nft.json +1 -1
  54. package/dist/preview/.next/server/app/page_client-reference-manifest.js +1 -1
  55. package/dist/preview/.next/server/app/preview/[...slug]/page.js +9 -8
  56. package/dist/preview/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
  57. package/dist/preview/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
  58. package/dist/preview/.next/server/chunks/143.js +6 -0
  59. package/dist/preview/.next/server/chunks/409.js +5 -0
  60. package/dist/preview/.next/server/chunks/46.js +1 -0
  61. package/dist/preview/.next/server/chunks/478.js +14 -0
  62. package/dist/preview/.next/server/chunks/707.js +13 -0
  63. package/dist/preview/.next/server/middleware-build-manifest.js +1 -1
  64. package/dist/preview/.next/server/next-font-manifest.js +1 -1
  65. package/dist/preview/.next/server/next-font-manifest.json +1 -1
  66. package/dist/preview/.next/server/pages/500.html +1 -1
  67. package/dist/preview/.next/server/pages/_app.js +1 -1
  68. package/dist/preview/.next/server/pages/_app.js.nft.json +1 -1
  69. package/dist/preview/.next/server/pages/_document.js +1 -1
  70. package/dist/preview/.next/server/pages/_document.js.nft.json +1 -1
  71. package/dist/preview/.next/server/pages/_error.js +1 -1
  72. package/dist/preview/.next/server/pages/_error.js.nft.json +1 -1
  73. package/dist/preview/.next/server/server-reference-manifest.js +1 -1
  74. package/dist/preview/.next/server/server-reference-manifest.json +1 -1
  75. package/dist/preview/.next/static/{iP6qiNn8FML_AvKcxGPhM → B4EYZiVzdylEG9lAIl-aO}/_buildManifest.js +1 -1
  76. package/dist/preview/.next/static/chunks/575-bc52750855c25df4.js +2 -0
  77. package/dist/preview/.next/static/chunks/684-0f1ef7361c499798.js +1 -0
  78. package/dist/preview/.next/static/chunks/684c6b30-0c65da32762fc4ee.js +1 -0
  79. package/dist/preview/.next/static/chunks/81-e7539b08d9d3fb4d.js +1 -0
  80. package/dist/preview/.next/static/chunks/883-70c8267c50bc4133.js +1 -0
  81. package/dist/preview/.next/static/chunks/921-d1dc8c63f49e85d6.js +1 -0
  82. package/dist/preview/.next/static/chunks/{afa401a5-9ebf2515b1397993.js → afa401a5-a600c227dacf3ab4.js} +1 -1
  83. package/dist/preview/.next/static/chunks/app/_not-found/{page-96d3eac723be3ee2.js → page-03ce767859c36d4e.js} +1 -1
  84. package/dist/preview/.next/static/chunks/app/layout-7cf14e28880544f1.js +1 -0
  85. package/dist/preview/.next/static/chunks/app/page-065cb49b0a078541.js +1 -0
  86. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-656510fd180c803c.js +1 -0
  87. package/dist/preview/.next/static/chunks/{framework-e7cae9cecd5c9ba2.js → framework-2a724981073c3a29.js} +1 -1
  88. package/dist/preview/.next/static/chunks/main-552b9719bbc3a274.js +1 -0
  89. package/dist/preview/.next/static/chunks/main-app-914a73336fd45af5.js +1 -0
  90. package/dist/preview/.next/static/chunks/pages/_app-77ca34bce25ac75c.js +1 -0
  91. package/dist/preview/.next/static/chunks/pages/_error-73f611c46abbb495.js +1 -0
  92. package/dist/preview/.next/static/chunks/{webpack-9255716c9496e606.js → webpack-2eb145a20ee6cb77.js} +1 -1
  93. package/dist/preview/.next/static/css/2df96d9ee014e8de.css +3 -0
  94. package/dist/preview/.next/static/media/05613964ce6c782e-s.p.otf +0 -0
  95. package/dist/preview/.next/static/media/11c6126b9369e85e-s.p.otf +0 -0
  96. package/dist/preview/.next/static/media/26cb97734d8cb717-s.p.otf +0 -0
  97. package/dist/preview/.next/static/media/bb6462617151f6b7-s.p.otf +0 -0
  98. package/dist/preview/.next/static/media/cf6daef822ab0142-s.p.otf +0 -0
  99. package/dist/preview/.next/static/media/e4051546b3043204-s.p.otf +0 -0
  100. package/dist/preview/.next/trace +22 -22
  101. package/dist/preview/.next/types/app/layout.ts +1 -1
  102. package/dist/preview/.next/types/app/preview/[...slug]/page.ts +1 -1
  103. package/package.json +7 -5
  104. package/src/actions/email-validation/check-images.spec.tsx +23 -14
  105. package/src/actions/email-validation/check-images.ts +89 -87
  106. package/src/actions/email-validation/check-links.spec.tsx +27 -17
  107. package/src/actions/email-validation/check-links.ts +60 -57
  108. package/src/app/fonts/SFMono/SFMonoBold.otf +0 -0
  109. package/src/app/fonts/SFMono/SFMonoBoldItalic.otf +0 -0
  110. package/src/app/fonts/SFMono/SFMonoHeavy.otf +0 -0
  111. package/src/app/fonts/SFMono/SFMonoHeavyItalic.otf +0 -0
  112. package/src/app/fonts/SFMono/SFMonoLight.otf +0 -0
  113. package/src/app/fonts/SFMono/SFMonoLightItalic.otf +0 -0
  114. package/src/app/fonts/SFMono/SFMonoMedium.otf +0 -0
  115. package/src/app/fonts/SFMono/SFMonoMediumItalic.otf +0 -0
  116. package/src/app/fonts/SFMono/SFMonoRegular.otf +0 -0
  117. package/src/app/fonts/SFMono/SFMonoRegularItalic.otf +0 -0
  118. package/src/app/fonts/SFMono/SFMonoSemibold.otf +0 -0
  119. package/src/app/fonts/SFMono/SFMonoSemiboldItalic.otf +0 -0
  120. package/src/app/fonts.ts +39 -0
  121. package/src/app/layout.tsx +5 -2
  122. package/src/app/page.tsx +3 -3
  123. package/src/app/preview/[...slug]/preview.tsx +50 -31
  124. package/src/components/icons/icon-base.tsx +4 -2
  125. package/src/components/icons/icon-bug.tsx +19 -0
  126. package/src/components/icons/icon-reload.tsx +19 -0
  127. package/src/components/icons/icon-scanner.tsx +19 -0
  128. package/src/components/icons/icon-scissors.tsx +19 -0
  129. package/src/components/icons/icon-warning.tsx +31 -0
  130. package/src/components/send.tsx +1 -2
  131. package/src/components/shell.tsx +52 -85
  132. package/src/components/sidebar/file-tree-directory-children.tsx +15 -12
  133. package/src/components/sidebar/file-tree.tsx +1 -1
  134. package/src/components/sidebar/sidebar.tsx +23 -344
  135. package/src/components/toolbar/linter.tsx +167 -0
  136. package/src/components/toolbar/results-table.tsx +0 -0
  137. package/src/components/toolbar/results.tsx +48 -0
  138. package/src/components/toolbar/spam-assassin.tsx +155 -0
  139. package/src/components/toolbar.tsx +189 -0
  140. package/src/components/tooltip-content.tsx +1 -2
  141. package/src/components/topbar.tsx +28 -41
  142. package/src/utils/__snapshots__/get-email-component.spec.ts.snap +1 -1
  143. package/tailwind.config.ts +1 -0
  144. package/tsconfig.json +1 -1
  145. package/vitest.config.ts +5 -3
  146. package/dist/preview/.next/server/chunks/196.js +0 -5
  147. package/dist/preview/.next/server/chunks/300.js +0 -13
  148. package/dist/preview/.next/server/chunks/509.js +0 -1
  149. package/dist/preview/.next/server/chunks/631.js +0 -6
  150. package/dist/preview/.next/server/chunks/734.js +0 -15
  151. package/dist/preview/.next/static/chunks/285-dbf6306a0d45c33d.js +0 -1
  152. package/dist/preview/.next/static/chunks/447-886131c35ca42b91.js +0 -1
  153. package/dist/preview/.next/static/chunks/490-0db0db14b377daca.js +0 -1
  154. package/dist/preview/.next/static/chunks/5fec7a0a-5179023f3f5a9421.js +0 -1
  155. package/dist/preview/.next/static/chunks/603-36207c8905355e23.js +0 -1
  156. package/dist/preview/.next/static/chunks/797-46f6c20952f0a280.js +0 -2
  157. package/dist/preview/.next/static/chunks/app/layout-f6f64b817a2cf938.js +0 -1
  158. package/dist/preview/.next/static/chunks/app/page-f5f96bd66526060f.js +0 -1
  159. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-fb2bf0253c2dada4.js +0 -1
  160. package/dist/preview/.next/static/chunks/main-app-d1b0aa870bcfb13e.js +0 -1
  161. package/dist/preview/.next/static/chunks/main-df761fde212f9cda.js +0 -1
  162. package/dist/preview/.next/static/chunks/pages/_app-203a61b355820ccf.js +0 -1
  163. package/dist/preview/.next/static/chunks/pages/_error-1764ca54938748c8.js +0 -1
  164. package/dist/preview/.next/static/css/778d574c88a1db3c.css +0 -3
  165. package/dist/preview/.next/static/css/ec5d7e66bd3b6cb8.css +0 -1
  166. package/src/app/inter.ts +0 -7
  167. package/src/components/icons/icon-circle-check.tsx +0 -21
  168. package/src/components/icons/icon-circle-close.tsx +0 -17
  169. package/src/components/icons/icon-circle-warning.tsx +0 -17
  170. package/src/components/sidebar/image-checker.tsx +0 -161
  171. package/src/components/sidebar/link-checker.tsx +0 -151
  172. package/tsconfig.test.json +0 -8
  173. /package/dist/preview/.next/static/{iP6qiNn8FML_AvKcxGPhM → B4EYZiVzdylEG9lAIl-aO}/_ssgManifest.js +0 -0
  174. /package/src/components/{sidebar → toolbar}/checking-results.tsx +0 -0
@@ -1,4 +1,4 @@
1
- // File: /home/gabriel/Projects/Resend/react-email.git/chore-1/packages/react-email/src/app/layout.tsx
1
+ // File: /home/gabriel/Projects/Resend/react-email.git/4.0/packages/react-email/src/app/layout.tsx
2
2
  import * as entry from '../../../src/app/layout.js'
3
3
  import type { ResolvingMetadata, ResolvingViewport } from 'next/dist/lib/metadata/types/metadata-interface.js'
4
4
 
@@ -1,4 +1,4 @@
1
- // File: /home/gabriel/Projects/Resend/react-email.git/chore-1/packages/react-email/src/app/preview/[...slug]/page.tsx
1
+ // File: /home/gabriel/Projects/Resend/react-email.git/4.0/packages/react-email/src/app/preview/[...slug]/page.tsx
2
2
  import * as entry from '../../../../../src/app/preview/[...slug]/page.js'
3
3
  import type { ResolvingMetadata, ResolvingViewport } from 'next/dist/lib/metadata/types/metadata-interface.js'
4
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-email",
3
- "version": "4.0.0-alpha.3",
3
+ "version": "4.0.0-alpha.5",
4
4
  "description": "A live preview of your emails right in your browser.",
5
5
  "bin": {
6
6
  "email": "./dist/cli/index.js"
@@ -29,12 +29,13 @@
29
29
  "glob": "10.3.4",
30
30
  "log-symbols": "4.1.0",
31
31
  "mime-types": "2.1.35",
32
- "next": "15.1.2",
32
+ "next": "15.1.7",
33
33
  "normalize-path": "3.0.0",
34
34
  "ora": "5.4.1",
35
35
  "socket.io": "4.8.1"
36
36
  },
37
37
  "devDependencies": {
38
+ "@lottiefiles/dotlottie-react": "0.12.3",
38
39
  "@radix-ui/colors": "1.0.1",
39
40
  "@radix-ui/react-collapsible": "1.1.0",
40
41
  "@radix-ui/react-dropdown-menu": "2.1.4",
@@ -57,7 +58,6 @@
57
58
  "autoprefixer": "10.4.20",
58
59
  "clsx": "2.1.0",
59
60
  "framer-motion": "12.0.0-alpha.2",
60
- "@lottiefiles/dotlottie-react": "0.12.3",
61
61
  "node-html-parser": "6.1.13",
62
62
  "postcss": "8.4.40",
63
63
  "prettier-plugin-tailwindcss": "0.6.6",
@@ -70,6 +70,7 @@
70
70
  "socket.io-client": "4.8.0",
71
71
  "sonner": "1.7.1",
72
72
  "source-map-js": "1.0.2",
73
+ "spamc": "0.0.5",
73
74
  "stacktrace-parser": "0.1.10",
74
75
  "tailwind-merge": "2.2.0",
75
76
  "tailwindcss": "3.4.0",
@@ -82,9 +83,10 @@
82
83
  },
83
84
  "scripts": {
84
85
  "build": "tsup-node && node build-preview-server.mjs",
86
+ "clean": "rm -rf dist",
85
87
  "dev": "tsup-node --watch",
88
+ "dev:preview": "cd ../../apps/demo && tsx ../../packages/react-email/src/cli/index.ts dev",
86
89
  "test": "vitest run",
87
- "test:watch": "vitest",
88
- "clean": "rm -rf dist"
90
+ "test:watch": "vitest"
89
91
  }
90
92
  }
@@ -2,21 +2,30 @@ import { render } from '@react-email/render';
2
2
  import { type ImageCheckingResult, checkImages } from './check-images';
3
3
 
4
4
  test('checkImages()', async () => {
5
- expect(
6
- await checkImages(
7
- await render(
8
- <div>
9
- {/* biome-ignore lint/a11y/useAltText: This is intentional to test the checking for accessibility */}
10
- <img src="https://resend.com/static/brand/resend-icon-white.png" />,
11
- <img src="/static/codepen-challengers.png" alt="codepen challenges" />
12
- ,
13
- </div>,
14
- ),
15
- 'https://demo.react.email',
5
+ const results: ImageCheckingResult[] = [];
6
+ const stream = await checkImages(
7
+ await render(
8
+ <div>
9
+ {/* biome-ignore lint/a11y/useAltText: This is intentional to test the checking for accessibility */}
10
+ <img src="https://resend.com/static/brand/resend-icon-white.png" />,
11
+ <img src="/static/codepen-challengers.png" alt="codepen challenges" />,
12
+ </div>,
16
13
  ),
17
- ).toEqual([
14
+ 'https://demo.react.email',
15
+ );
16
+ const reader = stream.getReader();
17
+ while (true) {
18
+ const { done, value } = await reader.read();
19
+ if (value) {
20
+ results.push(value);
21
+ }
22
+ if (done) {
23
+ break;
24
+ }
25
+ }
26
+ expect(results).toEqual([
18
27
  {
19
- source: 'https://resend.com/static/brand/resend-icon-white.png',
28
+ intendedFor: 'https://resend.com/static/brand/resend-icon-white.png',
20
29
  checks: [
21
30
  {
22
31
  passed: false,
@@ -82,7 +91,7 @@ test('checkImages()', async () => {
82
91
  type: 'image_size',
83
92
  },
84
93
  ],
85
- source: '/static/codepen-challengers.png',
94
+ intendedFor: '/static/codepen-challengers.png',
86
95
  status: 'success',
87
96
  },
88
97
  ] satisfies ImageCheckingResult[]);
@@ -4,7 +4,7 @@ import type { IncomingMessage } from 'node:http';
4
4
  import { parse } from 'node-html-parser';
5
5
  import { quickFetch } from './quick-fetch';
6
6
 
7
- type Check = { passed: boolean } & (
7
+ export type ImageCheck = { passed: boolean } & (
8
8
  | {
9
9
  type: 'accessibility';
10
10
  metadata: {
@@ -33,8 +33,8 @@ type Check = { passed: boolean } & (
33
33
 
34
34
  export interface ImageCheckingResult {
35
35
  status: 'success' | 'warning' | 'error';
36
- source: string;
37
- checks: Check[];
36
+ intendedFor: string;
37
+ checks: ImageCheck[];
38
38
  }
39
39
 
40
40
  const getResponseSizeInBytes = async (res: IncomingMessage) => {
@@ -48,94 +48,96 @@ const getResponseSizeInBytes = async (res: IncomingMessage) => {
48
48
  export const checkImages = async (code: string, base: string) => {
49
49
  const ast = parse(code);
50
50
 
51
- const imageCheckingResults: ImageCheckingResult[] = [];
52
-
53
- const images = ast.querySelectorAll('img');
54
- for await (const image of images) {
55
- const rawSource = image.attributes.src;
56
- if (!rawSource) continue;
57
- if (imageCheckingResults.some((result) => result.source === rawSource))
58
- continue;
59
-
60
- const source = rawSource?.startsWith('/')
61
- ? `${base}${rawSource}`
62
- : rawSource;
63
-
64
- const result: ImageCheckingResult = {
65
- source: rawSource,
66
- status: 'success',
67
- checks: [],
68
- };
69
-
70
- const alt = image.attributes.alt;
71
- result.checks.push({
72
- passed: alt !== undefined,
73
- type: 'accessibility',
74
- metadata: {
75
- alt,
76
- },
77
- });
78
- if (alt === undefined) {
79
- result.status = 'warning';
80
- }
51
+ const readableStream = new ReadableStream<ImageCheckingResult>({
52
+ async start(controller) {
53
+ const images = ast.querySelectorAll('img');
54
+ for await (const image of images) {
55
+ const rawSource = image.attributes.src;
56
+ if (!rawSource) continue;
81
57
 
82
- try {
83
- const url = new URL(source);
84
- result.checks.push({
85
- passed: true,
86
- type: 'syntax',
87
- });
58
+ const source = rawSource?.startsWith('/')
59
+ ? `${base}${rawSource}`
60
+ : rawSource;
88
61
 
89
- if (source.startsWith('https://')) {
90
- result.checks.push({
91
- passed: true,
92
- type: 'security',
93
- });
94
- } else {
62
+ const result: ImageCheckingResult = {
63
+ intendedFor: rawSource,
64
+ status: 'success',
65
+ checks: [],
66
+ };
67
+
68
+ const alt = image.attributes.alt;
95
69
  result.checks.push({
96
- passed: false,
97
- type: 'security',
70
+ passed: alt !== undefined,
71
+ type: 'accessibility',
72
+ metadata: {
73
+ alt,
74
+ },
98
75
  });
99
- result.status = 'warning';
100
- }
101
-
102
- const res = await quickFetch(url);
103
- const hasSucceeded = res.statusCode?.toString().startsWith('2') ?? false;
104
-
105
- result.checks.push({
106
- type: 'fetch_attempt',
107
- passed: hasSucceeded,
108
- metadata: {
109
- fetchStatusCode: res.statusCode,
110
- },
111
- });
112
- if (!hasSucceeded) {
113
- result.status = res.statusCode?.toString().startsWith('3')
114
- ? 'warning'
115
- : 'error';
116
- }
117
-
118
- const responseSizeBytes = await getResponseSizeInBytes(res);
119
- result.checks.push({
120
- type: 'image_size',
121
- passed: responseSizeBytes < 1_048_576, // 1024 x 1024 bytes
122
- metadata: {
123
- byteCount: responseSizeBytes,
124
- },
125
- });
126
- if (responseSizeBytes > 1_048_576) {
127
- result.status = 'warning';
76
+ if (alt === undefined) {
77
+ result.status = 'warning';
78
+ }
79
+
80
+ try {
81
+ const url = new URL(source);
82
+ result.checks.push({
83
+ passed: true,
84
+ type: 'syntax',
85
+ });
86
+
87
+ if (rawSource.startsWith('http://')) {
88
+ result.checks.push({
89
+ passed: false,
90
+ type: 'security',
91
+ });
92
+ result.status = 'warning';
93
+ } else {
94
+ result.checks.push({
95
+ passed: true,
96
+ type: 'security',
97
+ });
98
+ }
99
+
100
+ const res = await quickFetch(url);
101
+ const hasSucceeded =
102
+ res.statusCode?.toString().startsWith('2') ?? false;
103
+
104
+ result.checks.push({
105
+ type: 'fetch_attempt',
106
+ passed: hasSucceeded,
107
+ metadata: {
108
+ fetchStatusCode: res.statusCode,
109
+ },
110
+ });
111
+ if (!hasSucceeded) {
112
+ result.status = res.statusCode?.toString().startsWith('3')
113
+ ? 'warning'
114
+ : 'error';
115
+ }
116
+
117
+ const responseSizeBytes = await getResponseSizeInBytes(res);
118
+ result.checks.push({
119
+ type: 'image_size',
120
+ passed: responseSizeBytes < 1_048_576, // 1024 x 1024 bytes
121
+ metadata: {
122
+ byteCount: responseSizeBytes,
123
+ },
124
+ });
125
+ if (responseSizeBytes > 1_048_576) {
126
+ result.status = 'warning';
127
+ }
128
+ } catch (exception) {
129
+ result.checks.push({
130
+ passed: false,
131
+ type: 'syntax',
132
+ });
133
+ result.status = 'error';
134
+ }
135
+
136
+ controller.enqueue(result);
128
137
  }
129
- } catch (exception) {
130
- result.checks.push({
131
- passed: false,
132
- type: 'syntax',
133
- });
134
- result.status = 'error';
135
- }
136
-
137
- imageCheckingResults.push(result);
138
- }
138
+ controller.close();
139
+ },
140
+ });
139
141
 
140
- return imageCheckingResults;
142
+ return readableStream;
141
143
  };
@@ -2,18 +2,28 @@ import { render } from '@react-email/render';
2
2
  import { type LinkCheckingResult, checkLinks } from './check-links';
3
3
 
4
4
  test('checkLinks()', async () => {
5
- expect(
6
- await checkLinks(
7
- await render(
8
- <div>
9
- <a href="/">Root</a>
10
- <a href="https://resend.com">Resend</a>
11
- <a href="https://notion.so">Notion</a>
12
- <a href="http://example.com">Example unsafe</a>
13
- </div>,
14
- ),
5
+ const results: LinkCheckingResult[] = [];
6
+ const stream = await checkLinks(
7
+ await render(
8
+ <div>
9
+ <a href="/">Root</a>
10
+ <a href="https://resend.com">Resend</a>
11
+ <a href="https://notion.so">Notion</a>
12
+ <a href="http://react.email">React Email unsafe</a>
13
+ </div>,
15
14
  ),
16
- ).toEqual([
15
+ );
16
+ const reader = stream.getReader();
17
+ while (true) {
18
+ const { done, value } = await reader.read();
19
+ if (value) {
20
+ results.push(value);
21
+ }
22
+ if (done) {
23
+ break;
24
+ }
25
+ }
26
+ expect(results).toEqual([
17
27
  {
18
28
  status: 'error',
19
29
  checks: [
@@ -22,7 +32,7 @@ test('checkLinks()', async () => {
22
32
  passed: false,
23
33
  },
24
34
  ],
25
- link: '/',
35
+ intendedFor: '/',
26
36
  },
27
37
  {
28
38
  status: 'success',
@@ -43,7 +53,7 @@ test('checkLinks()', async () => {
43
53
  },
44
54
  },
45
55
  ],
46
- link: 'https://resend.com',
56
+ intendedFor: 'https://resend.com',
47
57
  },
48
58
  {
49
59
  status: 'warning',
@@ -64,7 +74,7 @@ test('checkLinks()', async () => {
64
74
  passed: false,
65
75
  },
66
76
  ],
67
- link: 'https://notion.so',
77
+ intendedFor: 'https://notion.so',
68
78
  },
69
79
  {
70
80
  status: 'warning',
@@ -80,12 +90,12 @@ test('checkLinks()', async () => {
80
90
  {
81
91
  type: 'fetch_attempt',
82
92
  metadata: {
83
- fetchStatusCode: 200,
93
+ fetchStatusCode: 308,
84
94
  },
85
- passed: true,
95
+ passed: false,
86
96
  },
87
97
  ],
88
- link: 'http://example.com',
98
+ intendedFor: 'http://react.email',
89
99
  },
90
100
  ] satisfies LinkCheckingResult[]);
91
101
  });
@@ -3,7 +3,7 @@
3
3
  import { parse } from 'node-html-parser';
4
4
  import { quickFetch } from './quick-fetch';
5
5
 
6
- type Check = { passed: boolean } & (
6
+ export type LinkCheck = { passed: boolean } & (
7
7
  | {
8
8
  type: 'fetch_attempt';
9
9
  metadata: {
@@ -20,72 +20,75 @@ type Check = { passed: boolean } & (
20
20
 
21
21
  export interface LinkCheckingResult {
22
22
  status: 'success' | 'warning' | 'error';
23
- link: string;
24
- checks: Check[];
23
+ intendedFor: string;
24
+ checks: LinkCheck[];
25
25
  }
26
26
 
27
27
  export const checkLinks = async (code: string) => {
28
28
  const ast = parse(code);
29
29
 
30
- const linkCheckingResults: LinkCheckingResult[] = [];
30
+ const readableStream = new ReadableStream<LinkCheckingResult>({
31
+ async start(controller) {
32
+ const anchors = ast.querySelectorAll('a');
33
+ for await (const anchor of anchors) {
34
+ const link = anchor.attributes.href;
35
+ if (!link) continue;
36
+ if (link.startsWith('mailto:')) continue;
31
37
 
32
- const anchors = ast.querySelectorAll('a');
33
- for await (const anchor of anchors) {
34
- const link = anchor.attributes.href;
35
- if (!link) continue;
36
- if (linkCheckingResults.some((result) => result.link === link)) continue;
37
- if (link.startsWith('mailto:')) continue;
38
+ const result: LinkCheckingResult = {
39
+ intendedFor: link,
40
+ status: 'success',
41
+ checks: [],
42
+ };
38
43
 
39
- const result: LinkCheckingResult = {
40
- link,
41
- status: 'success',
42
- checks: [],
43
- };
44
+ try {
45
+ const url = new URL(link);
46
+ result.checks.push({
47
+ passed: true,
48
+ type: 'syntax',
49
+ });
44
50
 
45
- try {
46
- const url = new URL(link);
47
- result.checks.push({
48
- passed: true,
49
- type: 'syntax',
50
- });
51
+ if (link.startsWith('http://')) {
52
+ result.checks.push({
53
+ passed: false,
54
+ type: 'security',
55
+ });
56
+ result.status = 'warning';
57
+ } else {
58
+ result.checks.push({
59
+ passed: true,
60
+ type: 'security',
61
+ });
62
+ }
51
63
 
52
- if (link.startsWith('https://')) {
53
- result.checks.push({
54
- passed: true,
55
- type: 'security',
56
- });
57
- } else {
58
- result.checks.push({
59
- passed: false,
60
- type: 'security',
61
- });
62
- result.status = 'warning';
63
- }
64
+ const res = await quickFetch(url);
65
+ const hasSucceeded =
66
+ res.statusCode?.toString().startsWith('2') ?? false;
67
+ result.checks.push({
68
+ type: 'fetch_attempt',
69
+ passed: hasSucceeded,
70
+ metadata: {
71
+ fetchStatusCode: res.statusCode,
72
+ },
73
+ });
74
+ if (!hasSucceeded) {
75
+ result.status = res.statusCode?.toString().startsWith('3')
76
+ ? 'warning'
77
+ : 'error';
78
+ }
79
+ } catch (exception) {
80
+ result.checks.push({
81
+ passed: false,
82
+ type: 'syntax',
83
+ });
84
+ result.status = 'error';
85
+ }
64
86
 
65
- const res = await quickFetch(url);
66
- const hasSucceeded = res.statusCode?.toString().startsWith('2') ?? false;
67
- result.checks.push({
68
- type: 'fetch_attempt',
69
- passed: hasSucceeded,
70
- metadata: {
71
- fetchStatusCode: res.statusCode,
72
- },
73
- });
74
- if (!hasSucceeded) {
75
- result.status = res.statusCode?.toString().startsWith('3')
76
- ? 'warning'
77
- : 'error';
87
+ controller.enqueue(result);
78
88
  }
79
- } catch (exception) {
80
- result.checks.push({
81
- passed: false,
82
- type: 'syntax',
83
- });
84
- result.status = 'error';
85
- }
86
-
87
- linkCheckingResults.push(result);
88
- }
89
+ controller.close();
90
+ },
91
+ });
89
92
 
90
- return linkCheckingResults;
93
+ return readableStream;
91
94
  };
@@ -0,0 +1,39 @@
1
+ import { Inter } from 'next/font/google';
2
+ import Local from 'next/font/local';
3
+
4
+ export const inter = Inter({
5
+ subsets: ['latin'],
6
+ variable: '--font-inter',
7
+ display: 'swap',
8
+ });
9
+
10
+ export const sfMono = Local({
11
+ src: [
12
+ {
13
+ path: './fonts/SFMono/SFMonoLight.otf',
14
+ weight: '300',
15
+ },
16
+ {
17
+ path: './fonts/SFMono/SFMonoRegular.otf',
18
+ weight: '400',
19
+ },
20
+ {
21
+ path: './fonts/SFMono/SFMonoMedium.otf',
22
+ weight: '500',
23
+ },
24
+ {
25
+ path: './fonts/SFMono/SFMonoSemibold.otf',
26
+ weight: '600',
27
+ },
28
+ {
29
+ path: './fonts/SFMono/SFMonoBold.otf',
30
+ weight: '700',
31
+ },
32
+ {
33
+ path: './fonts/SFMono/SFMonoHeavy.otf',
34
+ weight: '800',
35
+ },
36
+ ],
37
+ variable: '--font-sf-mono',
38
+ display: 'swap',
39
+ });
@@ -3,7 +3,7 @@ import './globals.css';
3
3
  import { EmailsProvider } from '../contexts/emails';
4
4
  import { emailsDirectoryAbsolutePath } from '../utils/emails-directory-absolute-path';
5
5
  import { getEmailsDirectoryMetadata } from '../utils/get-emails-directory-metadata';
6
- import { inter } from './inter';
6
+ import { inter, sfMono } from './fonts';
7
7
 
8
8
  export const metadata: Metadata = {
9
9
  title: 'React Email',
@@ -23,7 +23,10 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
23
23
  }
24
24
 
25
25
  return (
26
- <html className={inter.className} lang="en">
26
+ <html
27
+ className={`${inter.variable} ${sfMono.variable} font-sans`}
28
+ lang="en"
29
+ >
27
30
  <body className="relative flex h-screen flex-col overflow-x-hidden bg-black text-slate-11 leading-loose selection:bg-cyan-5 selection:text-cyan-12">
28
31
  <EmailsProvider
29
32
  initialEmailsDirectoryMetadata={emailsDirectoryMetadata}
package/src/app/page.tsx CHANGED
@@ -3,7 +3,7 @@ import Image from 'next/image';
3
3
  import Link from 'next/link';
4
4
  import { Button, Heading, Text } from '../components';
5
5
  import CodeSnippet from '../components/code-snippet';
6
- import { Shell } from '../components/shell';
6
+ import { Shell, ShellContent } from '../components/shell';
7
7
  import { emailsDirectoryAbsolutePath } from '../utils/emails-directory-absolute-path';
8
8
  import logo from './logo.png';
9
9
 
@@ -12,7 +12,7 @@ const Home = () => {
12
12
 
13
13
  return (
14
14
  <Shell>
15
- <div className="relative mx-auto flex h-[inherit] max-w-lg items-center justify-center p-8">
15
+ <ShellContent className="mx-auto flex max-w-lg items-center justify-center p-8">
16
16
  <div className="-mt-10 relative flex flex-col items-center gap-3 text-center">
17
17
  <Image
18
18
  alt="React Email Icon"
@@ -38,7 +38,7 @@ const Home = () => {
38
38
  <Link href="https://react.email/docs">Check the docs</Link>
39
39
  </Button>
40
40
  </div>
41
- </div>
41
+ </ShellContent>
42
42
  </Shell>
43
43
  );
44
44
  };