react-email 4.0.0-alpha.4 → 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 (170) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/cli/index.js +5 -4
  3. package/dist/cli/index.mjs +9 -5
  4. package/dist/preview/.next/BUILD_ID +1 -1
  5. package/dist/preview/.next/app-build-manifest.json +31 -34
  6. package/dist/preview/.next/app-path-routes-manifest.json +1 -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/images/TcyzHbFXGFjrOu3wEMvDoSmqCh3qP3iiNqJf0QbED9Y/60.1741728556140.cQ5qicbpvoXZ7leVmWqG2ElLwXB1ynYeSv8MBSA-QeM.Vy8iMWM3MGUtMTk1ODcxYmIyNzMi.webp +0 -0
  10. package/dist/preview/.next/cache/webpack/client-development/0.pack.gz +0 -0
  11. package/dist/preview/.next/cache/webpack/client-development/1.pack.gz +0 -0
  12. package/dist/preview/.next/cache/webpack/client-development/10.pack.gz +0 -0
  13. package/dist/preview/.next/cache/webpack/client-development/11.pack.gz +0 -0
  14. package/dist/preview/.next/cache/webpack/client-development/12.pack.gz +0 -0
  15. package/dist/preview/.next/cache/webpack/client-development/13.pack.gz +0 -0
  16. package/dist/preview/.next/cache/webpack/client-development/2.pack.gz +0 -0
  17. package/dist/preview/.next/cache/webpack/client-development/3.pack.gz +0 -0
  18. package/dist/preview/.next/cache/webpack/client-development/4.pack.gz +0 -0
  19. package/dist/preview/.next/cache/webpack/client-development/5.pack.gz +0 -0
  20. package/dist/preview/.next/cache/webpack/client-development/6.pack.gz +0 -0
  21. package/dist/preview/.next/cache/webpack/client-development/7.pack.gz +0 -0
  22. package/dist/preview/.next/cache/webpack/client-development/8.pack.gz +0 -0
  23. package/dist/preview/.next/cache/webpack/client-development/9.pack.gz +0 -0
  24. package/dist/preview/.next/cache/webpack/client-development/index.pack.gz +0 -0
  25. package/dist/preview/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
  26. package/dist/preview/.next/cache/webpack/client-production/0.pack +0 -0
  27. package/dist/preview/.next/cache/webpack/client-production/index.pack +0 -0
  28. package/dist/preview/.next/cache/webpack/edge-server-production/index.pack +0 -0
  29. package/dist/preview/.next/cache/webpack/server-development/0.pack.gz +0 -0
  30. package/dist/preview/.next/cache/webpack/server-development/1.pack.gz +0 -0
  31. package/dist/preview/.next/cache/webpack/server-development/2.pack.gz +0 -0
  32. package/dist/preview/.next/cache/webpack/server-development/3.pack.gz +0 -0
  33. package/dist/preview/.next/cache/webpack/server-development/4.pack.gz +0 -0
  34. package/dist/preview/.next/cache/webpack/server-development/5.pack.gz +0 -0
  35. package/dist/preview/.next/cache/webpack/server-development/6.pack.gz +0 -0
  36. package/dist/preview/.next/cache/webpack/server-development/7.pack.gz +0 -0
  37. package/dist/preview/.next/cache/webpack/server-development/8.pack.gz +0 -0
  38. package/dist/preview/.next/cache/webpack/server-development/9.pack.gz +0 -0
  39. package/dist/preview/.next/cache/webpack/server-development/index.pack.gz +0 -0
  40. package/dist/preview/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
  41. package/dist/preview/.next/cache/webpack/server-production/0.pack +0 -0
  42. package/dist/preview/.next/cache/webpack/server-production/index.pack +0 -0
  43. package/dist/preview/.next/diagnostics/framework.json +1 -1
  44. package/dist/preview/.next/next-minimal-server.js.nft.json +1 -1
  45. package/dist/preview/.next/next-server.js.nft.json +1 -1
  46. package/dist/preview/.next/prerender-manifest.json +1 -1
  47. package/dist/preview/.next/required-server-files.json +1 -1
  48. package/dist/preview/.next/server/app/_not-found/page.js +1 -1
  49. package/dist/preview/.next/server/app/_not-found/page.js.nft.json +1 -1
  50. package/dist/preview/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  51. package/dist/preview/.next/server/app/favicon.ico/route.js +1 -1
  52. package/dist/preview/.next/server/app/favicon.ico/route.js.nft.json +1 -1
  53. package/dist/preview/.next/server/app/page.js +1 -1
  54. package/dist/preview/.next/server/app/page.js.nft.json +1 -1
  55. package/dist/preview/.next/server/app/page_client-reference-manifest.js +1 -1
  56. package/dist/preview/.next/server/app/preview/[...slug]/page.js +9 -8
  57. package/dist/preview/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
  58. package/dist/preview/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
  59. package/dist/preview/.next/server/app-paths-manifest.json +1 -1
  60. package/dist/preview/.next/server/chunks/143.js +6 -0
  61. package/dist/preview/.next/server/chunks/409.js +5 -0
  62. package/dist/preview/.next/server/chunks/46.js +1 -0
  63. package/dist/preview/.next/server/chunks/478.js +14 -0
  64. package/dist/preview/.next/server/chunks/707.js +13 -0
  65. package/dist/preview/.next/server/middleware-build-manifest.js +1 -1
  66. package/dist/preview/.next/server/next-font-manifest.js +1 -1
  67. package/dist/preview/.next/server/next-font-manifest.json +1 -1
  68. package/dist/preview/.next/server/pages/500.html +1 -1
  69. package/dist/preview/.next/server/pages/_app.js +1 -1
  70. package/dist/preview/.next/server/pages/_app.js.nft.json +1 -1
  71. package/dist/preview/.next/server/pages/_document.js +1 -1
  72. package/dist/preview/.next/server/pages/_document.js.nft.json +1 -1
  73. package/dist/preview/.next/server/pages/_error.js +1 -1
  74. package/dist/preview/.next/server/pages/_error.js.nft.json +1 -1
  75. package/dist/preview/.next/server/server-reference-manifest.js +1 -1
  76. package/dist/preview/.next/server/server-reference-manifest.json +1 -1
  77. package/dist/preview/.next/static/{Pt6wqIrWnQxbiyqaKNFOx → B4EYZiVzdylEG9lAIl-aO}/_buildManifest.js +1 -1
  78. package/dist/preview/.next/static/chunks/575-bc52750855c25df4.js +2 -0
  79. package/dist/preview/.next/static/chunks/684-0f1ef7361c499798.js +1 -0
  80. package/dist/preview/.next/static/chunks/684c6b30-0c65da32762fc4ee.js +1 -0
  81. package/dist/preview/.next/static/chunks/81-e7539b08d9d3fb4d.js +1 -0
  82. package/dist/preview/.next/static/chunks/883-70c8267c50bc4133.js +1 -0
  83. package/dist/preview/.next/static/chunks/921-d1dc8c63f49e85d6.js +1 -0
  84. package/dist/preview/.next/static/chunks/{afa401a5-9ebf2515b1397993.js → afa401a5-a600c227dacf3ab4.js} +1 -1
  85. package/dist/preview/.next/static/chunks/app/_not-found/{page-96d3eac723be3ee2.js → page-03ce767859c36d4e.js} +1 -1
  86. package/dist/preview/.next/static/chunks/app/layout-7cf14e28880544f1.js +1 -0
  87. package/dist/preview/.next/static/chunks/app/page-065cb49b0a078541.js +1 -0
  88. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-656510fd180c803c.js +1 -0
  89. package/dist/preview/.next/static/chunks/{framework-e7cae9cecd5c9ba2.js → framework-2a724981073c3a29.js} +1 -1
  90. package/dist/preview/.next/static/chunks/main-552b9719bbc3a274.js +1 -0
  91. package/dist/preview/.next/static/chunks/main-app-914a73336fd45af5.js +1 -0
  92. package/dist/preview/.next/static/chunks/pages/_app-77ca34bce25ac75c.js +1 -0
  93. package/dist/preview/.next/static/chunks/pages/_error-73f611c46abbb495.js +1 -0
  94. package/dist/preview/.next/static/chunks/{webpack-9255716c9496e606.js → webpack-2eb145a20ee6cb77.js} +1 -1
  95. package/dist/preview/.next/static/css/2df96d9ee014e8de.css +3 -0
  96. package/dist/preview/.next/static/media/05613964ce6c782e-s.p.otf +0 -0
  97. package/dist/preview/.next/static/media/11c6126b9369e85e-s.p.otf +0 -0
  98. package/dist/preview/.next/static/media/26cb97734d8cb717-s.p.otf +0 -0
  99. package/dist/preview/.next/static/media/bb6462617151f6b7-s.p.otf +0 -0
  100. package/dist/preview/.next/static/media/cf6daef822ab0142-s.p.otf +0 -0
  101. package/dist/preview/.next/static/media/e4051546b3043204-s.p.otf +0 -0
  102. package/dist/preview/.next/trace +22 -22
  103. package/package.json +5 -4
  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 -32
  124. package/src/components/icons/icon-base.tsx +4 -2
  125. package/src/components/icons/icon-reload.tsx +19 -0
  126. package/src/components/icons/icon-scanner.tsx +19 -0
  127. package/src/components/icons/icon-scissors.tsx +19 -0
  128. package/src/components/icons/icon-warning.tsx +31 -0
  129. package/src/components/send.tsx +1 -2
  130. package/src/components/shell.tsx +52 -88
  131. package/src/components/sidebar/file-tree-directory-children.tsx +1 -1
  132. package/src/components/sidebar/file-tree.tsx +1 -1
  133. package/src/components/sidebar/sidebar.tsx +23 -378
  134. package/src/components/toolbar/linter.tsx +167 -0
  135. package/src/components/toolbar/results-table.tsx +0 -0
  136. package/src/components/toolbar/results.tsx +48 -0
  137. package/src/components/toolbar/spam-assassin.tsx +155 -0
  138. package/src/components/toolbar.tsx +189 -0
  139. package/src/components/tooltip-content.tsx +1 -2
  140. package/src/components/topbar.tsx +28 -41
  141. package/tailwind.config.ts +1 -0
  142. package/dist/preview/.next/server/chunks/196.js +0 -5
  143. package/dist/preview/.next/server/chunks/300.js +0 -13
  144. package/dist/preview/.next/server/chunks/631.js +0 -6
  145. package/dist/preview/.next/server/chunks/644.js +0 -1
  146. package/dist/preview/.next/server/chunks/734.js +0 -15
  147. package/dist/preview/.next/static/chunks/285-dbf6306a0d45c33d.js +0 -1
  148. package/dist/preview/.next/static/chunks/447-886131c35ca42b91.js +0 -1
  149. package/dist/preview/.next/static/chunks/490-d5745684930d49e0.js +0 -1
  150. package/dist/preview/.next/static/chunks/5fec7a0a-5179023f3f5a9421.js +0 -1
  151. package/dist/preview/.next/static/chunks/603-36207c8905355e23.js +0 -1
  152. package/dist/preview/.next/static/chunks/797-46f6c20952f0a280.js +0 -2
  153. package/dist/preview/.next/static/chunks/app/layout-d06046b8a368df3b.js +0 -1
  154. package/dist/preview/.next/static/chunks/app/page-ef1c23b954fbd0b5.js +0 -1
  155. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-ea8e1ae2b5a4a0ec.js +0 -1
  156. package/dist/preview/.next/static/chunks/main-app-9f2fb5ea26e2765b.js +0 -1
  157. package/dist/preview/.next/static/chunks/main-df761fde212f9cda.js +0 -1
  158. package/dist/preview/.next/static/chunks/pages/_app-203a61b355820ccf.js +0 -1
  159. package/dist/preview/.next/static/chunks/pages/_error-1764ca54938748c8.js +0 -1
  160. package/dist/preview/.next/static/css/e4822d5ba3082a95.css +0 -3
  161. package/dist/preview/.next/static/css/ec5d7e66bd3b6cb8.css +0 -1
  162. package/src/app/inter.ts +0 -7
  163. package/src/components/icons/icon-circle-check.tsx +0 -21
  164. package/src/components/icons/icon-circle-close.tsx +0 -17
  165. package/src/components/icons/icon-circle-warning.tsx +0 -17
  166. package/src/components/sidebar/image-checker.tsx +0 -162
  167. package/src/components/sidebar/link-checker.tsx +0 -151
  168. package/src/components/sidebar/spam-assassin.tsx +0 -158
  169. /package/dist/preview/.next/static/{Pt6wqIrWnQxbiyqaKNFOx → B4EYZiVzdylEG9lAIl-aO}/_ssgManifest.js +0 -0
  170. /package/src/components/{sidebar → toolbar}/checking-results.tsx +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-email",
3
- "version": "4.0.0-alpha.4",
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,7 +29,7 @@
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"
@@ -83,9 +83,10 @@
83
83
  },
84
84
  "scripts": {
85
85
  "build": "tsup-node && node build-preview-server.mjs",
86
+ "clean": "rm -rf dist",
86
87
  "dev": "tsup-node --watch",
88
+ "dev:preview": "cd ../../apps/demo && tsx ../../packages/react-email/src/cli/index.ts dev",
87
89
  "test": "vitest run",
88
- "test:watch": "vitest",
89
- "clean": "rm -rf dist"
90
+ "test:watch": "vitest"
90
91
  }
91
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
  };