Skip to content

Commit 8caea2d

Browse files
committed
feat(react-email): Bottom layout for toolbar (#1950)
1 parent 0e01f10 commit 8caea2d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+1186
-1269
lines changed

.changeset/swift-glasses-try.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-email": minor
3+
---
4+
5+
Use a bottom layout for the toolbar

apps/web/src/utils/spam-assassin/parse-pointing-table-rows.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { rule } from 'postcss';
2-
31
export const parsePointingTableRows = (response: string) => {
42
const tableHeader =
53
/pts\s+rule\s+name\s+description\s+(?<ptsWidth>-+) (?<ruleNameWidth>-+) (?<descriptionWidth>-+) *(?:\r\n|\n|\r)*/;

packages/react-email/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
},
88
"scripts": {
99
"build": "tsup-node && node build-preview-server.mjs",
10+
"clean": "rm -rf dist",
1011
"dev": "tsup-node --watch",
12+
"dev:preview": "cd ../../apps/demo && tsx ../../packages/react-email/src/cli/index.ts dev",
1113
"test": "vitest run",
12-
"test:watch": "vitest",
13-
"clean": "rm -rf dist"
14+
"test:watch": "vitest"
1415
},
1516
"license": "MIT",
1617
"repository": {
@@ -33,7 +34,7 @@
3334
"glob": "10.3.4",
3435
"log-symbols": "4.1.0",
3536
"mime-types": "2.1.35",
36-
"next": "15.1.2",
37+
"next": "15.1.7",
3738
"normalize-path": "3.0.0",
3839
"ora": "5.4.1",
3940
"socket.io": "4.8.1"

packages/react-email/src/actions/email-validation/check-images.spec.tsx

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,30 @@ import { render } from '@react-email/render';
22
import { type ImageCheckingResult, checkImages } from './check-images';
33

44
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>,
1613
),
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([
1827
{
19-
source: 'https://resend.com/static/brand/resend-icon-white.png',
28+
intendedFor: 'https://resend.com/static/brand/resend-icon-white.png',
2029
checks: [
2130
{
2231
passed: false,
@@ -82,7 +91,7 @@ test('checkImages()', async () => {
8291
type: 'image_size',
8392
},
8493
],
85-
source: '/static/codepen-challengers.png',
94+
intendedFor: '/static/codepen-challengers.png',
8695
status: 'success',
8796
},
8897
] satisfies ImageCheckingResult[]);

packages/react-email/src/actions/email-validation/check-images.ts

Lines changed: 89 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { IncomingMessage } from 'node:http';
44
import { parse } from 'node-html-parser';
55
import { quickFetch } from './quick-fetch';
66

7-
type Check = { passed: boolean } & (
7+
export type ImageCheck = { passed: boolean } & (
88
| {
99
type: 'accessibility';
1010
metadata: {
@@ -33,8 +33,8 @@ type Check = { passed: boolean } & (
3333

3434
export interface ImageCheckingResult {
3535
status: 'success' | 'warning' | 'error';
36-
source: string;
37-
checks: Check[];
36+
intendedFor: string;
37+
checks: ImageCheck[];
3838
}
3939

4040
const getResponseSizeInBytes = async (res: IncomingMessage) => {
@@ -48,94 +48,96 @@ const getResponseSizeInBytes = async (res: IncomingMessage) => {
4848
export const checkImages = async (code: string, base: string) => {
4949
const ast = parse(code);
5050

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;
8157

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;
8861

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;
9569
result.checks.push({
96-
passed: false,
97-
type: 'security',
70+
passed: alt !== undefined,
71+
type: 'accessibility',
72+
metadata: {
73+
alt,
74+
},
9875
});
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);
128137
}
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+
});
139141

140-
return imageCheckingResults;
142+
return readableStream;
141143
};

packages/react-email/src/actions/email-validation/check-links.spec.tsx

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,28 @@ import { render } from '@react-email/render';
22
import { type LinkCheckingResult, checkLinks } from './check-links';
33

44
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>,
1514
),
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([
1727
{
1828
status: 'error',
1929
checks: [
@@ -22,7 +32,7 @@ test('checkLinks()', async () => {
2232
passed: false,
2333
},
2434
],
25-
link: '/',
35+
intendedFor: '/',
2636
},
2737
{
2838
status: 'success',
@@ -43,7 +53,7 @@ test('checkLinks()', async () => {
4353
},
4454
},
4555
],
46-
link: 'https://resend.com',
56+
intendedFor: 'https://resend.com',
4757
},
4858
{
4959
status: 'warning',
@@ -64,7 +74,7 @@ test('checkLinks()', async () => {
6474
passed: false,
6575
},
6676
],
67-
link: 'https://notion.so',
77+
intendedFor: 'https://notion.so',
6878
},
6979
{
7080
status: 'warning',
@@ -80,12 +90,12 @@ test('checkLinks()', async () => {
8090
{
8191
type: 'fetch_attempt',
8292
metadata: {
83-
fetchStatusCode: 200,
93+
fetchStatusCode: 308,
8494
},
85-
passed: true,
95+
passed: false,
8696
},
8797
],
88-
link: 'http://example.com',
98+
intendedFor: 'http://react.email',
8999
},
90100
] satisfies LinkCheckingResult[]);
91101
});

0 commit comments

Comments
 (0)