Skip to content

Commit ee700cf

Browse files
gabrielmfernbukinoshitadependabot[bot]luxonauta
committed
feat(react-email): Link checker for preview server on a toolbar (#1799)
Signed-off-by: dependabot[bot] <[email protected]> Co-authored-by: Bu Kinoshita <[email protected]> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Lucas de França <[email protected]>
1 parent cfd7e1c commit ee700cf

Some content is hidden

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

42 files changed

+5369
-175
lines changed

.changeset/empty-rivers-laugh.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+
Added toolbar with a link checker

packages/react-email/package.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
"node": ">=18.0.0"
2424
},
2525
"dependencies": {
26-
"@babel/core": "7.24.5",
2726
"@babel/parser": "7.24.5",
27+
"@babel/traverse": "7.25.6",
2828
"chalk": "4.1.2",
2929
"chokidar": "4.0.3",
3030
"commander": "11.1.0",
@@ -38,16 +38,22 @@
3838
"ora": "5.4.1",
3939
"socket.io": "4.8.1"
4040
},
41+
"overrides": {
42+
"react": "^19",
43+
"react-dom": "^19"
44+
},
4145
"devDependencies": {
4246
"@radix-ui/colors": "1.0.1",
4347
"@radix-ui/react-collapsible": "1.1.0",
4448
"@radix-ui/react-popover": "1.1.1",
4549
"@radix-ui/react-slot": "1.1.0",
50+
"@radix-ui/react-tabs": "1.1.1",
4651
"@radix-ui/react-toggle-group": "1.1.0",
4752
"@radix-ui/react-tooltip": "1.1.2",
4853
"@react-email/render": "workspace:*",
4954
"@swc/core": "1.4.15",
5055
"@types/babel__core": "7.20.5",
56+
"@types/babel__traverse": "*",
5157
"@types/fs-extra": "11.0.1",
5258
"@types/mime-types": "2.1.4",
5359
"@types/node": "22.10.2",
@@ -59,7 +65,10 @@
5965
"autoprefixer": "10.4.20",
6066
"clsx": "2.1.0",
6167
"framer-motion": "12.0.0-alpha.2",
68+
"lottie-react": "^2.4.0",
69+
"node-html-parser": "6.1.13",
6270
"postcss": "8.4.40",
71+
"prettier-plugin-tailwindcss": "0.6.6",
6372
"prism-react-renderer": "2.1.0",
6473
"module-punycode": "npm:[email protected]",
6574
"react": "^19",
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
'use server';
2+
3+
import { parse } from 'node-html-parser';
4+
import { quickFetch } from './quick-fetch';
5+
6+
type Check = { passed: boolean } & (
7+
| {
8+
type: 'fetch_attempt';
9+
metadata: {
10+
fetchStatusCode: number | undefined;
11+
};
12+
}
13+
| {
14+
type: 'syntax';
15+
}
16+
| {
17+
type: 'security';
18+
}
19+
);
20+
21+
export interface LinkCheckingResult {
22+
status: 'success' | 'warning' | 'error';
23+
link: string;
24+
checks: Check[];
25+
}
26+
27+
export const checkLinks = async (code: string) => {
28+
const ast = parse(code);
29+
30+
const linkCheckingResults: LinkCheckingResult[] = [];
31+
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;
37+
38+
const result: LinkCheckingResult = {
39+
link,
40+
status: 'success',
41+
checks: [],
42+
};
43+
44+
try {
45+
const url = new URL(link);
46+
47+
const res = await quickFetch(url);
48+
const hasntSucceeded =
49+
res.statusCode === undefined ||
50+
!res.statusCode.toString().startsWith('2');
51+
result.checks.push({
52+
type: 'fetch_attempt',
53+
passed: hasntSucceeded,
54+
metadata: {
55+
fetchStatusCode: res.statusCode,
56+
},
57+
});
58+
if (hasntSucceeded) {
59+
result.status = res.statusCode?.toString().startsWith('3')
60+
? 'warning'
61+
: 'error';
62+
}
63+
64+
if (link.startsWith('https://')) {
65+
result.checks.push({
66+
passed: true,
67+
type: 'security',
68+
});
69+
} else {
70+
result.checks.push({
71+
passed: false,
72+
type: 'security',
73+
});
74+
result.status = 'warning';
75+
}
76+
} catch (exception) {
77+
result.checks.push({
78+
passed: false,
79+
type: 'syntax',
80+
});
81+
result.status = 'error';
82+
}
83+
84+
linkCheckingResults.push(result);
85+
}
86+
87+
return linkCheckingResults;
88+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { getLineAndColumnFromIndex } from './get-line-and-column-from-index';
2+
3+
test('getLineAndColumnFromIndex()', () => {
4+
const code = `import { SomethingElse } from 'somewhere';
5+
6+
const myConstant = 'what';
7+
8+
const MyComponent = () => {
9+
return <SomethingElse>
10+
<div>
11+
<a>Hello World!</a>{' '}
12+
{myConstant}
13+
</div>
14+
</SomethingElse>;
15+
}`;
16+
const [line, column] = getLineAndColumnFromIndex(
17+
code,
18+
code.indexOf('Hello World!'),
19+
);
20+
expect(line).toBe(8);
21+
expect(column).toBe(10);
22+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
const splitByLines = (text: string) => {
2+
const properSplit: string[] = [];
3+
const unevenSplit = text.split(/(?<eol>\n|\r|\r\n)/);
4+
5+
for (const [i, segment] of unevenSplit.entries()) {
6+
if (i % 2 === 0) {
7+
let segmentToInsert = segment;
8+
if (i + 1 < unevenSplit.length) {
9+
segmentToInsert += unevenSplit[i + 1];
10+
}
11+
properSplit.push(segmentToInsert);
12+
}
13+
}
14+
15+
return properSplit;
16+
};
17+
18+
export const getLineAndColumnFromIndex = (
19+
code: string,
20+
index: number,
21+
): [line: number, column: number] => {
22+
const lines = splitByLines(code);
23+
24+
let lineNumber = 1;
25+
const line = () => {
26+
const l = lines[lineNumber - 1];
27+
if (l === undefined)
28+
throw new Error(
29+
'Could not find the line for a specific index in the code',
30+
{ cause: { lines, lineNumber, index } },
31+
);
32+
return l;
33+
};
34+
let charactersUpToLineStart = 0;
35+
while (charactersUpToLineStart + line().length < index) {
36+
charactersUpToLineStart += line().length;
37+
lineNumber++;
38+
}
39+
40+
const columnNumber = index - charactersUpToLineStart + 1;
41+
42+
return [lineNumber, columnNumber];
43+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { IncomingMessage } from 'node:http';
2+
import http from 'node:http';
3+
import https from 'node:https';
4+
5+
export const quickFetch = (url: URL) => {
6+
return new Promise<IncomingMessage>((resolve) => {
7+
const caller = url.protocol === 'https:' ? https : http;
8+
caller.get(url, (res) => {
9+
resolve(res);
10+
});
11+
});
12+
};

0 commit comments

Comments
 (0)