react-email 4.0.0-alpha.6 → 4.0.0-alpha.7
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.
- package/CHANGELOG.md +6 -0
- package/dist/cli/index.js +7 -3
- package/dist/cli/index.mjs +7 -3
- package/dist/preview/.next/BUILD_ID +1 -1
- package/dist/preview/.next/app-build-manifest.json +14 -13
- package/dist/preview/.next/app-path-routes-manifest.json +1 -1
- package/dist/preview/.next/build-manifest.json +3 -3
- package/dist/preview/.next/cache/.rscinfo +1 -1
- package/dist/preview/.next/cache/webpack/client-production/0.pack +0 -0
- package/dist/preview/.next/cache/webpack/client-production/index.pack +0 -0
- package/dist/preview/.next/cache/webpack/edge-server-production/index.pack +0 -0
- package/dist/preview/.next/cache/webpack/server-production/0.pack +0 -0
- package/dist/preview/.next/cache/webpack/server-production/index.pack +0 -0
- package/dist/preview/.next/next-minimal-server.js.nft.json +1 -1
- package/dist/preview/.next/next-server.js.nft.json +1 -1
- package/dist/preview/.next/prerender-manifest.json +3 -3
- package/dist/preview/.next/required-server-files.json +3 -3
- package/dist/preview/.next/server/app/_not-found/page.js +1 -1
- package/dist/preview/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/dist/preview/.next/server/app/favicon.ico/route.js +1 -1
- package/dist/preview/.next/server/app/page.js +1 -1
- package/dist/preview/.next/server/app/page.js.nft.json +1 -1
- package/dist/preview/.next/server/app/page_client-reference-manifest.js +1 -1
- package/dist/preview/.next/server/app/preview/[...slug]/page.js +29 -25
- package/dist/preview/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
- package/dist/preview/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
- package/dist/preview/.next/server/app-paths-manifest.json +1 -1
- package/dist/preview/.next/server/chunks/600.js +3 -3
- package/dist/preview/.next/server/chunks/{171.js → 816.js} +6 -6
- package/dist/preview/.next/server/chunks/943.js +1 -0
- package/dist/preview/.next/server/middleware-build-manifest.js +1 -1
- package/dist/preview/.next/server/next-font-manifest.js +1 -1
- package/dist/preview/.next/server/next-font-manifest.json +1 -1
- package/dist/preview/.next/server/pages/500.html +1 -1
- package/dist/preview/.next/server/pages-manifest.json +1 -1
- package/dist/preview/.next/server/server-reference-manifest.js +1 -1
- package/dist/preview/.next/server/server-reference-manifest.json +1 -1
- package/dist/preview/.next/static/chunks/287-7864b805e6bdc854.js +1 -0
- package/dist/preview/.next/static/chunks/412-31817e53b50a3e73.js +1 -0
- package/dist/preview/.next/static/chunks/683-1fb40795502f6e63.js +1 -0
- package/dist/preview/.next/static/chunks/880-9c0b721328117b8b.js +1 -0
- package/dist/preview/.next/static/chunks/app/layout-ffdee5cc1be30e7b.js +1 -0
- package/dist/preview/.next/static/chunks/app/page-9ea0bd45cd6294b0.js +1 -0
- package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-9e22979a25c836c0.js +1 -0
- package/dist/preview/.next/static/chunks/{main-app-c2e686acf8d370d7.js → main-app-256b213b179a95cc.js} +1 -1
- package/dist/preview/.next/static/css/eaae8ce545b295f9.css +3 -0
- package/dist/preview/.next/trace +26 -26
- package/dist/preview/.next/types/app/layout.ts +1 -1
- package/dist/preview/.next/types/app/page.ts +84 -0
- package/dist/preview/.next/types/app/preview/[...slug]/page.ts +1 -1
- package/package.json +1 -1
- package/src/actions/email-validation/check-compatibility.ts +0 -1
- package/src/actions/email-validation/check-images.spec.tsx +13 -11
- package/src/actions/email-validation/check-images.ts +6 -0
- package/src/actions/email-validation/check-links.spec.tsx +23 -11
- package/src/actions/email-validation/check-links.ts +6 -0
- package/src/actions/email-validation/get-code-location-from-ast-element.ts +18 -0
- package/src/actions/render-email-by-path.tsx +2 -2
- package/src/app/env.ts +3 -0
- package/src/app/preview/[...slug]/page.tsx +24 -11
- package/src/app/preview/[...slug]/preview.tsx +15 -12
- package/src/components/code-container.tsx +90 -71
- package/src/components/code.tsx +106 -42
- package/src/components/icons/icon-info.tsx +18 -0
- package/src/components/icons/icon-reload.tsx +13 -14
- package/src/components/logo.tsx +3 -2
- package/src/components/resizable-wrapper.tsx +1 -4
- package/src/components/sidebar/file-tree-directory-children.tsx +1 -0
- package/src/components/sidebar/sidebar.tsx +2 -3
- package/src/components/toolbar/code-preview-line-link.tsx +40 -0
- package/src/components/toolbar/compatibility.tsx +113 -0
- package/src/components/toolbar/linter.tsx +69 -111
- package/src/components/toolbar/results.tsx +5 -2
- package/src/components/toolbar/spam-assassin.tsx +20 -12
- package/src/components/toolbar/toolbar-button.tsx +4 -2
- package/src/components/toolbar.tsx +108 -30
- package/src/components/tooltip-content.tsx +1 -1
- package/src/components/topbar/view-size-controls.tsx +1 -2
- package/src/components/topbar.tsx +1 -20
- package/src/contexts/fragment-identifier.tsx +46 -0
- package/src/hooks/use-fragment-identifier.ts +14 -0
- package/src/utils/caniemail/tailwind/get-tailwind-config.ts +0 -2
- package/src/utils/get-email-component.ts +1 -1
- package/src/utils/get-line-and-column-from-offset.spec.ts +11 -0
- package/src/utils/get-line-and-column-from-offset.ts +11 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/linting.ts +5 -30
- package/src/utils/load-stream.ts +15 -0
- package/src/utils/sanitize.ts +6 -0
- package/dist/preview/.next/server/chunks/833.js +0 -1
- package/dist/preview/.next/static/chunks/416-56f79fc7e689f06f.js +0 -1
- package/dist/preview/.next/static/chunks/683-8bbfd191e5105f01.js +0 -1
- package/dist/preview/.next/static/chunks/87-38e35f08507de015.js +0 -1
- package/dist/preview/.next/static/chunks/app/layout-a6640e62690d8fd6.js +0 -1
- package/dist/preview/.next/static/chunks/app/page-ba68f50b287e7478.js +0 -1
- package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-4a5b026ab543e27f.js +0 -1
- package/dist/preview/.next/static/css/d7df9cfc3e182163.css +0 -3
- package/src/actions/email-validation/get-line-and-column-from-index.spec.ts +0 -22
- package/src/actions/email-validation/get-line-and-column-from-index.ts +0 -43
- package/src/components/icons/icon-scanner.tsx +0 -19
- package/src/components/icons/icon-scissors.tsx +0 -19
- /package/dist/preview/.next/static/{gFk9UfWL8joM4iD7-wlKF → Pms2orsQgT5xpttCfZfH5}/_buildManifest.js +0 -0
- /package/dist/preview/.next/static/{gFk9UfWL8joM4iD7-wlKF → Pms2orsQgT5xpttCfZfH5}/_ssgManifest.js +0 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import prettyBytes from 'pretty-bytes';
|
|
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';
|
|
2
|
+
import { Children, useRef, useState } from 'react';
|
|
5
3
|
import type { ImageCheckingResult } from '../../actions/email-validation/check-images';
|
|
6
4
|
import type { LinkCheckingResult } from '../../actions/email-validation/check-links';
|
|
7
|
-
import { cn } from '../../utils';
|
|
5
|
+
import { cn, sanitize } from '../../utils';
|
|
8
6
|
import { getLintingSources, loadLintingRowsFrom } from '../../utils/linting';
|
|
9
7
|
import { IconWarning } from '../icons/icon-warning';
|
|
8
|
+
import { CodePreviewLineLink } from './code-preview-line-link';
|
|
10
9
|
import { Results } from './results';
|
|
11
10
|
|
|
12
11
|
export type LintingRow =
|
|
@@ -17,10 +16,6 @@ export type LintingRow =
|
|
|
17
16
|
| {
|
|
18
17
|
source: 'link';
|
|
19
18
|
result: LinkCheckingResult;
|
|
20
|
-
}
|
|
21
|
-
| {
|
|
22
|
-
source: 'compatibility';
|
|
23
|
-
result: CompatibilityCheckingResult;
|
|
24
19
|
};
|
|
25
20
|
|
|
26
21
|
interface LinterProps {
|
|
@@ -29,14 +24,10 @@ interface LinterProps {
|
|
|
29
24
|
|
|
30
25
|
export const useLinter = ({
|
|
31
26
|
markup,
|
|
32
|
-
reactMarkup,
|
|
33
|
-
emailPath,
|
|
34
27
|
|
|
35
28
|
initialRows,
|
|
36
29
|
}: {
|
|
37
|
-
reactMarkup: string;
|
|
38
30
|
markup: string;
|
|
39
|
-
emailPath: string;
|
|
40
31
|
|
|
41
32
|
initialRows?: LintingRow[];
|
|
42
33
|
}) => {
|
|
@@ -44,8 +35,6 @@ export const useLinter = ({
|
|
|
44
35
|
|
|
45
36
|
const sources = getLintingSources(
|
|
46
37
|
markup,
|
|
47
|
-
reactMarkup,
|
|
48
|
-
emailPath,
|
|
49
38
|
'location' in global
|
|
50
39
|
? `${global.location.protocol}//${global.location.host}`
|
|
51
40
|
: '',
|
|
@@ -105,80 +94,44 @@ export const Linter = ({ rows }: LinterProps) => {
|
|
|
105
94
|
)!;
|
|
106
95
|
return (
|
|
107
96
|
<Result status={row.result.status} key={i}>
|
|
108
|
-
<Result.Name>{failingCheck.type}</Result.Name>
|
|
97
|
+
<Result.Name>{sanitize(failingCheck.type)}</Result.Name>
|
|
109
98
|
<Result.Description>
|
|
110
99
|
{failingCheck.type === 'security'
|
|
111
|
-
? 'Insecure URL, use HTTPS
|
|
100
|
+
? 'Insecure URL, use HTTPS instead of HTTP'
|
|
112
101
|
: null}
|
|
113
102
|
{failingCheck.type === 'fetch_attempt' &&
|
|
114
103
|
failingCheck.metadata.fetchStatusCode &&
|
|
115
104
|
failingCheck.metadata.fetchStatusCode >= 300 &&
|
|
116
|
-
failingCheck.metadata.fetchStatusCode < 400
|
|
117
|
-
|
|
118
|
-
|
|
105
|
+
failingCheck.metadata.fetchStatusCode < 400 ? (
|
|
106
|
+
<>
|
|
107
|
+
<strong>{failingCheck.metadata.fetchStatusCode}</strong>:
|
|
108
|
+
There was a redirect, the content may have been moved
|
|
109
|
+
</>
|
|
110
|
+
) : null}
|
|
119
111
|
{failingCheck.type === 'fetch_attempt' &&
|
|
120
112
|
failingCheck.metadata.fetchStatusCode &&
|
|
121
|
-
failingCheck.metadata.fetchStatusCode >= 400
|
|
122
|
-
|
|
123
|
-
|
|
113
|
+
failingCheck.metadata.fetchStatusCode >= 400 ? (
|
|
114
|
+
<>
|
|
115
|
+
<strong>{failingCheck.metadata.fetchStatusCode}</strong>:
|
|
116
|
+
The link is broken
|
|
117
|
+
</>
|
|
118
|
+
) : null}
|
|
124
119
|
{failingCheck.type === 'syntax'
|
|
125
120
|
? 'The link is broken due to invalid syntax'
|
|
126
121
|
: null}
|
|
127
122
|
|
|
128
|
-
<span className="
|
|
123
|
+
<span className="ml-2 text-ellipsis overflow-hidden text-nowrap max-w-[30ch]">
|
|
129
124
|
{row.result.link}
|
|
130
125
|
</span>
|
|
131
126
|
</Result.Description>
|
|
132
127
|
<Result.Metadata>
|
|
133
|
-
{
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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(', ');
|
|
155
|
-
|
|
156
|
-
return (
|
|
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"
|
|
179
|
-
>
|
|
180
|
-
See more info
|
|
181
|
-
</a>
|
|
128
|
+
{[
|
|
129
|
+
<CodePreviewLineLink
|
|
130
|
+
line={row.result.codeLocation.line}
|
|
131
|
+
column={row.result.codeLocation.column}
|
|
132
|
+
type="html"
|
|
133
|
+
/>,
|
|
134
|
+
]}
|
|
182
135
|
</Result.Metadata>
|
|
183
136
|
</Result>
|
|
184
137
|
);
|
|
@@ -188,24 +141,43 @@ export const Linter = ({ rows }: LinterProps) => {
|
|
|
188
141
|
const failingCheck = row.result.checks.find(
|
|
189
142
|
(check) => check.passed === false,
|
|
190
143
|
)!;
|
|
144
|
+
const metadata: React.ReactNode[] = [];
|
|
145
|
+
for (const check of row.result.checks) {
|
|
146
|
+
if (check.type === 'image_size' && check.metadata.byteCount) {
|
|
147
|
+
metadata.push(prettyBytes(check.metadata.byteCount));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
metadata.push(
|
|
151
|
+
<CodePreviewLineLink
|
|
152
|
+
line={row.result.codeLocation.line}
|
|
153
|
+
column={row.result.codeLocation.column}
|
|
154
|
+
type="html"
|
|
155
|
+
/>,
|
|
156
|
+
);
|
|
191
157
|
return (
|
|
192
158
|
<Result status={row.result.status} key={i}>
|
|
193
|
-
<Result.Name>{failingCheck.type}</Result.Name>
|
|
159
|
+
<Result.Name>{sanitize(failingCheck.type)}</Result.Name>
|
|
194
160
|
<Result.Description>
|
|
195
161
|
{failingCheck.type === 'security'
|
|
196
|
-
? 'Insecure URL, use HTTPS
|
|
162
|
+
? 'Insecure URL, use HTTPS instead of HTTP'
|
|
197
163
|
: null}
|
|
198
164
|
{failingCheck.type === 'fetch_attempt' &&
|
|
199
165
|
failingCheck.metadata.fetchStatusCode &&
|
|
200
166
|
failingCheck.metadata.fetchStatusCode >= 300 &&
|
|
201
|
-
failingCheck.metadata.fetchStatusCode < 400
|
|
202
|
-
|
|
203
|
-
|
|
167
|
+
failingCheck.metadata.fetchStatusCode < 400 ? (
|
|
168
|
+
<>
|
|
169
|
+
<strong>{failingCheck.metadata.fetchStatusCode}</strong>:
|
|
170
|
+
There was a redirect, the image may have been moved
|
|
171
|
+
</>
|
|
172
|
+
) : null}
|
|
204
173
|
{failingCheck.type === 'fetch_attempt' &&
|
|
205
174
|
failingCheck.metadata.fetchStatusCode &&
|
|
206
|
-
failingCheck.metadata.fetchStatusCode >= 400
|
|
207
|
-
|
|
208
|
-
|
|
175
|
+
failingCheck.metadata.fetchStatusCode >= 400 ? (
|
|
176
|
+
<>
|
|
177
|
+
<strong>{failingCheck.metadata.fetchStatusCode}</strong>:
|
|
178
|
+
The image is broken
|
|
179
|
+
</>
|
|
180
|
+
) : null}
|
|
209
181
|
{failingCheck.type === 'syntax'
|
|
210
182
|
? 'The image is broken due to an invalid source'
|
|
211
183
|
: null}
|
|
@@ -219,32 +191,11 @@ export const Linter = ({ rows }: LinterProps) => {
|
|
|
219
191
|
? 'This image is too large, keep it under 1mb'
|
|
220
192
|
: null}
|
|
221
193
|
|
|
222
|
-
<span className="
|
|
194
|
+
<span className="ml-2 text-ellipsis overflow-hidden text-nowrap max-w-[30ch]">
|
|
223
195
|
{row.result.source}
|
|
224
196
|
</span>
|
|
225
197
|
</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>
|
|
198
|
+
<Result.Metadata>{metadata}</Result.Metadata>
|
|
248
199
|
</Result>
|
|
249
200
|
);
|
|
250
201
|
}
|
|
@@ -277,9 +228,9 @@ Result.Name = ({
|
|
|
277
228
|
}: React.ComponentProps<typeof Results.Column>) => {
|
|
278
229
|
return (
|
|
279
230
|
<Results.Column {...props}>
|
|
280
|
-
<span className="flex uppercase gap-
|
|
231
|
+
<span className="flex uppercase gap-2 items-center group-data-[status=error]/result:text-red-400 group-data-[status=warning]/result:text-orange-300">
|
|
281
232
|
<IconWarning />
|
|
282
|
-
{children}
|
|
233
|
+
{typeof children === 'string' ? sanitize(children) : children}
|
|
283
234
|
</span>
|
|
284
235
|
</Results.Column>
|
|
285
236
|
);
|
|
@@ -293,18 +244,25 @@ Result.Description = ({
|
|
|
293
244
|
return <Results.Column {...props}>{children}</Results.Column>;
|
|
294
245
|
};
|
|
295
246
|
|
|
296
|
-
|
|
297
|
-
children
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}:
|
|
247
|
+
interface MetadatProps extends React.ComponentProps<typeof Results.Column> {
|
|
248
|
+
children: React.ReactNode[];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
Result.Metadata = ({ children, className, ...props }: MetadatProps) => {
|
|
301
252
|
return (
|
|
302
253
|
<Results.Column
|
|
303
254
|
align="right"
|
|
304
255
|
{...props}
|
|
305
256
|
className={cn('font-mono text-slate-11', className)}
|
|
306
257
|
>
|
|
307
|
-
{children
|
|
258
|
+
{Children.map(children, (child, index) => {
|
|
259
|
+
return (
|
|
260
|
+
<>
|
|
261
|
+
{index > 0 ? ' · ' : null}
|
|
262
|
+
{child}
|
|
263
|
+
</>
|
|
264
|
+
);
|
|
265
|
+
})}
|
|
308
266
|
</Results.Column>
|
|
309
267
|
);
|
|
310
268
|
};
|
|
@@ -8,7 +8,7 @@ export const Results = ({
|
|
|
8
8
|
return (
|
|
9
9
|
<table
|
|
10
10
|
className={cn(
|
|
11
|
-
'group relative w-full border-collapse text-left text-slate-10 text-
|
|
11
|
+
'group relative w-full border-collapse text-left text-slate-10 text-sm',
|
|
12
12
|
className,
|
|
13
13
|
)}
|
|
14
14
|
>
|
|
@@ -41,7 +41,10 @@ Results.Column = ({
|
|
|
41
41
|
...props
|
|
42
42
|
}: React.ComponentProps<'td'>) => {
|
|
43
43
|
return (
|
|
44
|
-
<td
|
|
44
|
+
<td
|
|
45
|
+
className={cn('py-1.5 align-bottom font-regular', className)}
|
|
46
|
+
{...props}
|
|
47
|
+
>
|
|
45
48
|
{children}
|
|
46
49
|
</td>
|
|
47
50
|
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useRef, useState } from 'react';
|
|
2
2
|
import { toast } from 'sonner';
|
|
3
|
-
import { cn } from '../../utils';
|
|
3
|
+
import { cn, sanitize } from '../../utils';
|
|
4
4
|
import { IconWarning } from '../icons/icon-warning';
|
|
5
5
|
import { Results } from './results';
|
|
6
6
|
|
|
@@ -83,25 +83,33 @@ export const SpamAssassin = ({ result }: SpamAssassinProps) => {
|
|
|
83
83
|
<>
|
|
84
84
|
{result ? (
|
|
85
85
|
<Results>
|
|
86
|
-
<Results.Row className="sticky border-b
|
|
86
|
+
<Results.Row className="sticky border-b top-0 bg-black">
|
|
87
87
|
<Results.Column className="uppercase">
|
|
88
|
-
<span className="flex gap-
|
|
88
|
+
<span className="flex gap-2 items-center">
|
|
89
89
|
<IconWarning
|
|
90
90
|
className={cn(
|
|
91
|
-
result.points
|
|
92
|
-
result.points >
|
|
91
|
+
result.points === 0 ? 'text-green-400' : null,
|
|
92
|
+
result.points > 0 && result.points <= 1.5 ? null : null,
|
|
93
|
+
result.points > 1.5 ? 'text-yellow-100' : null,
|
|
94
|
+
result.points > 3 ? 'text-orange-400' : null,
|
|
93
95
|
result.points >= 5 ? 'text-red-400' : null,
|
|
94
96
|
)}
|
|
95
97
|
/>
|
|
96
98
|
Score
|
|
97
99
|
</span>
|
|
98
100
|
</Results.Column>
|
|
99
|
-
<Results.Column>
|
|
101
|
+
<Results.Column>
|
|
102
|
+
{result.points === 0
|
|
103
|
+
? 'Congratulations! Your email is clean of abuse indicators.'
|
|
104
|
+
: 'Lower scores are better'}
|
|
105
|
+
</Results.Column>
|
|
100
106
|
<Results.Column
|
|
101
107
|
className={cn(
|
|
102
|
-
'text-right text-
|
|
108
|
+
'text-right text-3xl tracking-tighter font-bold',
|
|
109
|
+
result.points === 0 ? 'text-green-400' : null,
|
|
110
|
+
result.points > 0 && result.points <= 1.5 ? null : null,
|
|
103
111
|
result.points > 1.5 ? 'text-yellow-200' : null,
|
|
104
|
-
result.points > 3 ? 'text-orange-
|
|
112
|
+
result.points > 3 ? 'text-orange-400' : null,
|
|
105
113
|
result.points >= 5 ? 'text-red-400' : null,
|
|
106
114
|
)}
|
|
107
115
|
>
|
|
@@ -112,15 +120,15 @@ export const SpamAssassin = ({ result }: SpamAssassinProps) => {
|
|
|
112
120
|
(check) => (
|
|
113
121
|
<Results.Row key={check.name}>
|
|
114
122
|
<Results.Column className="uppercase">
|
|
115
|
-
<span className="flex gap-
|
|
123
|
+
<span className="flex gap-2 items-center">
|
|
116
124
|
<IconWarning
|
|
117
125
|
className={cn(
|
|
118
126
|
check.points > 1 ? 'text-yellow-200' : null,
|
|
119
|
-
check.points > 2 ? 'text-orange-
|
|
127
|
+
check.points > 2 ? 'text-orange-400' : null,
|
|
120
128
|
check.points > 3 ? 'text-red-400' : null,
|
|
121
129
|
)}
|
|
122
130
|
/>
|
|
123
|
-
{check.name}
|
|
131
|
+
{sanitize(check.name)}
|
|
124
132
|
</span>
|
|
125
133
|
</Results.Column>
|
|
126
134
|
<Results.Column>{check.description}</Results.Column>
|
|
@@ -128,7 +136,7 @@ export const SpamAssassin = ({ result }: SpamAssassinProps) => {
|
|
|
128
136
|
className={cn(
|
|
129
137
|
'text-right font-mono tracking-tighter',
|
|
130
138
|
check.points > 1 ? 'text-yellow-200' : null,
|
|
131
|
-
check.points > 2 ? 'text-orange-
|
|
139
|
+
check.points > 2 ? 'text-orange-400' : null,
|
|
132
140
|
check.points > 3 ? 'text-red-400' : null,
|
|
133
141
|
)}
|
|
134
142
|
>
|
|
@@ -6,6 +6,7 @@ interface ToolbarButtonProps extends React.ComponentProps<'button'> {
|
|
|
6
6
|
children: React.ReactNode;
|
|
7
7
|
active?: boolean;
|
|
8
8
|
tooltip?: React.ReactNode;
|
|
9
|
+
delayDuration?: number;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export const ToolbarButton = ({
|
|
@@ -13,17 +14,18 @@ export const ToolbarButton = ({
|
|
|
13
14
|
className,
|
|
14
15
|
active,
|
|
15
16
|
tooltip,
|
|
17
|
+
delayDuration = 500,
|
|
16
18
|
...props
|
|
17
19
|
}: ToolbarButtonProps) => {
|
|
18
20
|
return (
|
|
19
21
|
<Tooltip.Provider>
|
|
20
|
-
<Tooltip>
|
|
22
|
+
<Tooltip delayDuration={delayDuration}>
|
|
21
23
|
<Tooltip.Trigger asChild>
|
|
22
24
|
<button
|
|
23
25
|
type="button"
|
|
24
26
|
{...props}
|
|
25
27
|
className={cn(
|
|
26
|
-
'h-full w-fit font-
|
|
28
|
+
'h-full w-fit font-regular flex text-sm text-slate-10 items-center align-middle justify-center px-1 gap-2 relative',
|
|
27
29
|
'hover:text-slate-12 transition-colors',
|
|
28
30
|
active && 'data-[state=active]:text-cyan-11',
|
|
29
31
|
className,
|
|
@@ -3,13 +3,14 @@ import * as Tabs from '@radix-ui/react-tabs';
|
|
|
3
3
|
import { LayoutGroup } from 'framer-motion';
|
|
4
4
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
5
5
|
import { use, useEffect } from 'react';
|
|
6
|
+
import type { CompatibilityCheckingResult } from '../actions/email-validation/check-compatibility';
|
|
6
7
|
import { isBuilding } from '../app/env';
|
|
7
8
|
import { PreviewContext } from '../contexts/preview';
|
|
8
9
|
import { cn } from '../utils';
|
|
9
10
|
import { IconArrowDown } from './icons/icon-arrow-down';
|
|
11
|
+
import { IconInfo } from './icons/icon-info';
|
|
10
12
|
import { IconReload } from './icons/icon-reload';
|
|
11
|
-
import {
|
|
12
|
-
import { IconScissors } from './icons/icon-scissors';
|
|
13
|
+
import { Compatibility, useCompatibility } from './toolbar/compatibility';
|
|
13
14
|
import { Linter, type LintingRow, useLinter } from './toolbar/linter';
|
|
14
15
|
import {
|
|
15
16
|
SpamAssassin,
|
|
@@ -19,11 +20,12 @@ import {
|
|
|
19
20
|
import { ToolbarButton } from './toolbar/toolbar-button';
|
|
20
21
|
import { useCachedState } from './toolbar/use-cached-state';
|
|
21
22
|
|
|
22
|
-
export type ToolbarTabValue = 'linter' | 'spam-assassin';
|
|
23
|
+
export type ToolbarTabValue = 'linter' | 'compatibility' | 'spam-assassin';
|
|
23
24
|
|
|
24
25
|
const ToolbarInner = ({
|
|
25
26
|
serverLintingRows,
|
|
26
27
|
serverSpamCheckingResult,
|
|
28
|
+
serverCompatibilityResults,
|
|
27
29
|
|
|
28
30
|
markup,
|
|
29
31
|
reactMarkup,
|
|
@@ -54,30 +56,42 @@ const ToolbarInner = ({
|
|
|
54
56
|
} else {
|
|
55
57
|
params.set('toolbar-panel', newValue);
|
|
56
58
|
}
|
|
57
|
-
router.push(`${pathname}?${params.toString()}`);
|
|
59
|
+
router.push(`${pathname}?${params.toString()}${location.hash}`);
|
|
58
60
|
};
|
|
59
61
|
|
|
60
62
|
const [cachedSpamCheckingResult, setCachedSpamCheckingResult] =
|
|
61
63
|
useCachedState<SpamCheckingResult>(
|
|
62
64
|
`spam-assassin-${emailSlug.replaceAll('/', '-')}`,
|
|
63
65
|
);
|
|
64
|
-
const [spamCheckingResult, { load: loadSpamChecking }] =
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
const [spamCheckingResult, { load: loadSpamChecking, loading: spamLoading }] =
|
|
67
|
+
useSpamAssassin({
|
|
68
|
+
markup,
|
|
69
|
+
plainText,
|
|
67
70
|
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
initialResult: serverSpamCheckingResult ?? cachedSpamCheckingResult,
|
|
72
|
+
});
|
|
70
73
|
|
|
71
74
|
const [cachedLintingRows, setCachedLintingRows] = useCachedState<
|
|
72
75
|
LintingRow[]
|
|
73
76
|
>(`linter-${emailSlug.replaceAll('/', '-')}`);
|
|
74
|
-
const [lintingRows, { load: loadLinting }] = useLinter({
|
|
75
|
-
reactMarkup,
|
|
76
|
-
emailPath,
|
|
77
|
+
const [lintingRows, { load: loadLinting, loading: lintLoading }] = useLinter({
|
|
77
78
|
markup,
|
|
78
79
|
|
|
79
80
|
initialRows: serverLintingRows ?? cachedLintingRows,
|
|
80
81
|
});
|
|
82
|
+
const [cachedCompatibilityResults, setCachedCompatibilityResults] =
|
|
83
|
+
useCachedState<CompatibilityCheckingResult[]>(
|
|
84
|
+
`compatibility-${emailSlug.replaceAll('/', '-')}`,
|
|
85
|
+
);
|
|
86
|
+
const [
|
|
87
|
+
compatibilityCheckingResults,
|
|
88
|
+
{ load: loadCompatibility, loading: compatibilityLoading },
|
|
89
|
+
] = useCompatibility({
|
|
90
|
+
emailPath,
|
|
91
|
+
reactMarkup,
|
|
92
|
+
|
|
93
|
+
initialResults: serverCompatibilityResults ?? cachedCompatibilityResults,
|
|
94
|
+
});
|
|
81
95
|
|
|
82
96
|
if (!isBuilding) {
|
|
83
97
|
useEffect(() => {
|
|
@@ -87,6 +101,9 @@ const ToolbarInner = ({
|
|
|
87
101
|
|
|
88
102
|
const spamCheckingResult = await loadSpamChecking();
|
|
89
103
|
setCachedSpamCheckingResult(spamCheckingResult);
|
|
104
|
+
|
|
105
|
+
const compatibilityCheckingResults = await loadCompatibility();
|
|
106
|
+
setCachedCompatibilityResults(compatibilityCheckingResults);
|
|
90
107
|
})();
|
|
91
108
|
}, []);
|
|
92
109
|
}
|
|
@@ -95,49 +112,83 @@ const ToolbarInner = ({
|
|
|
95
112
|
<div
|
|
96
113
|
data-toggled={toggled}
|
|
97
114
|
className={cn(
|
|
98
|
-
'
|
|
99
|
-
'
|
|
115
|
+
'absolute bottom-0 left-0 right-0',
|
|
116
|
+
'bg-black group/toolbar text-xs text-slate-11 h-52 transition-transform',
|
|
117
|
+
'data-[toggled=false]:translate-y-[170px]',
|
|
100
118
|
)}
|
|
101
119
|
>
|
|
102
120
|
<Tabs.Root
|
|
103
|
-
value={activeTab}
|
|
121
|
+
value={activeTab ?? ''}
|
|
104
122
|
onValueChange={(newValue) => {
|
|
105
123
|
setActivePanelValue(newValue as ToolbarTabValue);
|
|
106
124
|
}}
|
|
107
125
|
asChild
|
|
108
126
|
>
|
|
109
127
|
<div className="flex flex-col h-full">
|
|
110
|
-
<Tabs.List className="flex gap-4 px-
|
|
128
|
+
<Tabs.List className="flex gap-4 px-4 border-b border-solid border-slate-6 h-10 w-full flex-shrink-0">
|
|
111
129
|
<LayoutGroup id="toolbar">
|
|
112
|
-
<Tabs.Trigger asChild value="spam-assassin">
|
|
113
|
-
<ToolbarButton active={activeTab === 'spam-assassin'}>
|
|
114
|
-
<IconScissors />
|
|
115
|
-
Spam Assassin
|
|
116
|
-
</ToolbarButton>
|
|
117
|
-
</Tabs.Trigger>
|
|
118
130
|
<Tabs.Trigger asChild value="linter">
|
|
119
131
|
<ToolbarButton active={activeTab === 'linter'}>
|
|
120
|
-
<IconScanner />
|
|
121
132
|
Linter
|
|
122
133
|
</ToolbarButton>
|
|
123
134
|
</Tabs.Trigger>
|
|
135
|
+
<Tabs.Trigger asChild value="compatibility">
|
|
136
|
+
<ToolbarButton active={activeTab === 'compatibility'}>
|
|
137
|
+
Compatibility
|
|
138
|
+
</ToolbarButton>
|
|
139
|
+
</Tabs.Trigger>
|
|
140
|
+
<Tabs.Trigger asChild value="spam-assassin">
|
|
141
|
+
<ToolbarButton active={activeTab === 'spam-assassin'}>
|
|
142
|
+
Spam
|
|
143
|
+
</ToolbarButton>
|
|
144
|
+
</Tabs.Trigger>
|
|
124
145
|
</LayoutGroup>
|
|
125
|
-
<div className="flex gap-
|
|
146
|
+
<div className="flex gap-0.5 ml-auto">
|
|
147
|
+
<ToolbarButton
|
|
148
|
+
delayDuration={0}
|
|
149
|
+
tooltip={
|
|
150
|
+
(activeTab === 'linter' &&
|
|
151
|
+
'The Linter tab checks all the images and links for common issues like missing alt text, broken URLs, insecure HTTP methods, and more.') ||
|
|
152
|
+
(activeTab === 'spam-assassin' &&
|
|
153
|
+
'The Spam tab will look at the content and use a robust scoring framework to determine if the email is likely to be spam. Powered by SpamAssassin.') ||
|
|
154
|
+
(activeTab === 'compatibility' &&
|
|
155
|
+
'The Compatibility tab shows how well the HTML/CSS is supported across mail clients like Outlook, Gmail, etc. Powered by Can I Email.') ||
|
|
156
|
+
'Info'
|
|
157
|
+
}
|
|
158
|
+
onClick={() => {
|
|
159
|
+
if (activeTab === undefined) {
|
|
160
|
+
setActivePanelValue('linter');
|
|
161
|
+
} else {
|
|
162
|
+
setActivePanelValue(undefined);
|
|
163
|
+
}
|
|
164
|
+
}}
|
|
165
|
+
>
|
|
166
|
+
<IconInfo size={24} />
|
|
167
|
+
</ToolbarButton>
|
|
126
168
|
{isBuilding ? null : (
|
|
127
169
|
<ToolbarButton
|
|
128
170
|
tooltip="Reload"
|
|
171
|
+
disabled={lintLoading || spamLoading}
|
|
129
172
|
onClick={async () => {
|
|
130
173
|
if (activeTab === undefined) {
|
|
131
174
|
setActivePanelValue('linter');
|
|
132
175
|
}
|
|
133
176
|
if (activeTab === 'spam-assassin') {
|
|
134
177
|
await loadSpamChecking();
|
|
135
|
-
} else {
|
|
178
|
+
} else if (activeTab === 'linter') {
|
|
136
179
|
await loadLinting();
|
|
180
|
+
} else if (activeTab === 'compatibility') {
|
|
181
|
+
await loadCompatibility();
|
|
137
182
|
}
|
|
138
183
|
}}
|
|
139
184
|
>
|
|
140
|
-
<IconReload
|
|
185
|
+
<IconReload
|
|
186
|
+
size={24}
|
|
187
|
+
className={cn({
|
|
188
|
+
'animate-spin opacity-60 animate-spin-fast':
|
|
189
|
+
lintLoading || spamLoading,
|
|
190
|
+
})}
|
|
191
|
+
/>
|
|
141
192
|
</ToolbarButton>
|
|
142
193
|
)}
|
|
143
194
|
<ToolbarButton
|
|
@@ -150,17 +201,41 @@ const ToolbarInner = ({
|
|
|
150
201
|
}
|
|
151
202
|
}}
|
|
152
203
|
>
|
|
153
|
-
<IconArrowDown
|
|
204
|
+
<IconArrowDown
|
|
205
|
+
size={24}
|
|
206
|
+
className="transition-transform group-data-[toggled=false]/toolbar:rotate-180"
|
|
207
|
+
/>
|
|
154
208
|
</ToolbarButton>
|
|
155
209
|
</div>
|
|
156
210
|
</Tabs.List>
|
|
157
211
|
|
|
158
|
-
<div className="flex-grow transition-opacity opacity-100 group-data-[toggled=false]/toolbar:opacity-0 overflow-y-auto px-2">
|
|
212
|
+
<div className="flex-grow transition-opacity opacity-100 group-data-[toggled=false]/toolbar:opacity-0 overflow-y-auto px-2 pt-3">
|
|
159
213
|
<Tabs.Content value="linter">
|
|
160
|
-
|
|
214
|
+
{lintLoading ? (
|
|
215
|
+
<div className="animate-pulse text-slate-11 text-sm pt-1">
|
|
216
|
+
Running linting...
|
|
217
|
+
</div>
|
|
218
|
+
) : (
|
|
219
|
+
<Linter rows={lintingRows} />
|
|
220
|
+
)}
|
|
161
221
|
</Tabs.Content>
|
|
162
222
|
<Tabs.Content value="spam-assassin">
|
|
163
|
-
|
|
223
|
+
{spamLoading ? (
|
|
224
|
+
<div className="animate-pulse text-slate-11 text-sm pt-1">
|
|
225
|
+
Running spam check...
|
|
226
|
+
</div>
|
|
227
|
+
) : (
|
|
228
|
+
<SpamAssassin result={spamCheckingResult} />
|
|
229
|
+
)}
|
|
230
|
+
</Tabs.Content>
|
|
231
|
+
<Tabs.Content value="compatibility">
|
|
232
|
+
{compatibilityLoading ? (
|
|
233
|
+
<div className="animate-pulse text-slate-11 text-sm pt-1">
|
|
234
|
+
Running compatibility check...
|
|
235
|
+
</div>
|
|
236
|
+
) : (
|
|
237
|
+
<Compatibility results={compatibilityCheckingResults} />
|
|
238
|
+
)}
|
|
164
239
|
</Tabs.Content>
|
|
165
240
|
</div>
|
|
166
241
|
</div>
|
|
@@ -172,11 +247,13 @@ const ToolbarInner = ({
|
|
|
172
247
|
interface ToolbarProps {
|
|
173
248
|
serverSpamCheckingResult: SpamCheckingResult | undefined;
|
|
174
249
|
serverLintingRows: LintingRow[] | undefined;
|
|
250
|
+
serverCompatibilityResults: CompatibilityCheckingResult[] | undefined;
|
|
175
251
|
}
|
|
176
252
|
|
|
177
253
|
export const Toolbar = ({
|
|
178
254
|
serverLintingRows,
|
|
179
255
|
serverSpamCheckingResult,
|
|
256
|
+
serverCompatibilityResults,
|
|
180
257
|
}: ToolbarProps) => {
|
|
181
258
|
const { emailPath, emailSlug, renderedEmailMetadata } = use(PreviewContext)!;
|
|
182
259
|
|
|
@@ -192,6 +269,7 @@ export const Toolbar = ({
|
|
|
192
269
|
plainText={plainText}
|
|
193
270
|
serverLintingRows={serverLintingRows}
|
|
194
271
|
serverSpamCheckingResult={serverSpamCheckingResult}
|
|
272
|
+
serverCompatibilityResults={serverCompatibilityResults}
|
|
195
273
|
/>
|
|
196
274
|
);
|
|
197
275
|
};
|