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,4 +1,4 @@
|
|
|
1
|
-
// File: /home/gabriel/Projects/Resend/react-email
|
|
1
|
+
// File: /home/gabriel/Projects/Resend/react-email/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
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// File: /home/gabriel/Projects/Resend/react-email/packages/react-email/src/app/page.tsx
|
|
2
|
+
import * as entry from '../../../src/app/page.js'
|
|
3
|
+
import type { ResolvingMetadata, ResolvingViewport } from 'next/dist/lib/metadata/types/metadata-interface.js'
|
|
4
|
+
|
|
5
|
+
type TEntry = typeof import('../../../src/app/page.js')
|
|
6
|
+
|
|
7
|
+
type SegmentParams<T extends Object = any> = T extends Record<string, any>
|
|
8
|
+
? { [K in keyof T]: T[K] extends string ? string | string[] | undefined : never }
|
|
9
|
+
: T
|
|
10
|
+
|
|
11
|
+
// Check that the entry is a valid entry
|
|
12
|
+
checkFields<Diff<{
|
|
13
|
+
default: Function
|
|
14
|
+
config?: {}
|
|
15
|
+
generateStaticParams?: Function
|
|
16
|
+
revalidate?: RevalidateRange<TEntry> | false
|
|
17
|
+
dynamic?: 'auto' | 'force-dynamic' | 'error' | 'force-static'
|
|
18
|
+
dynamicParams?: boolean
|
|
19
|
+
fetchCache?: 'auto' | 'force-no-store' | 'only-no-store' | 'default-no-store' | 'default-cache' | 'only-cache' | 'force-cache'
|
|
20
|
+
preferredRegion?: 'auto' | 'global' | 'home' | string | string[]
|
|
21
|
+
runtime?: 'nodejs' | 'experimental-edge' | 'edge'
|
|
22
|
+
maxDuration?: number
|
|
23
|
+
|
|
24
|
+
metadata?: any
|
|
25
|
+
generateMetadata?: Function
|
|
26
|
+
viewport?: any
|
|
27
|
+
generateViewport?: Function
|
|
28
|
+
experimental_ppr?: boolean
|
|
29
|
+
|
|
30
|
+
}, TEntry, ''>>()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
// Check the prop type of the entry function
|
|
34
|
+
checkFields<Diff<PageProps, FirstArg<TEntry['default']>, 'default'>>()
|
|
35
|
+
|
|
36
|
+
// Check the arguments and return type of the generateMetadata function
|
|
37
|
+
if ('generateMetadata' in entry) {
|
|
38
|
+
checkFields<Diff<PageProps, FirstArg<MaybeField<TEntry, 'generateMetadata'>>, 'generateMetadata'>>()
|
|
39
|
+
checkFields<Diff<ResolvingMetadata, SecondArg<MaybeField<TEntry, 'generateMetadata'>>, 'generateMetadata'>>()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check the arguments and return type of the generateViewport function
|
|
43
|
+
if ('generateViewport' in entry) {
|
|
44
|
+
checkFields<Diff<PageProps, FirstArg<MaybeField<TEntry, 'generateViewport'>>, 'generateViewport'>>()
|
|
45
|
+
checkFields<Diff<ResolvingViewport, SecondArg<MaybeField<TEntry, 'generateViewport'>>, 'generateViewport'>>()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check the arguments and return type of the generateStaticParams function
|
|
49
|
+
if ('generateStaticParams' in entry) {
|
|
50
|
+
checkFields<Diff<{ params: SegmentParams }, FirstArg<MaybeField<TEntry, 'generateStaticParams'>>, 'generateStaticParams'>>()
|
|
51
|
+
checkFields<Diff<{ __tag__: 'generateStaticParams', __return_type__: any[] | Promise<any[]> }, { __tag__: 'generateStaticParams', __return_type__: ReturnType<MaybeField<TEntry, 'generateStaticParams'>> }>>()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PageProps {
|
|
55
|
+
params?: Promise<SegmentParams>
|
|
56
|
+
searchParams?: Promise<any>
|
|
57
|
+
}
|
|
58
|
+
export interface LayoutProps {
|
|
59
|
+
children?: React.ReactNode
|
|
60
|
+
|
|
61
|
+
params?: Promise<SegmentParams>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// =============
|
|
65
|
+
// Utility types
|
|
66
|
+
type RevalidateRange<T> = T extends { revalidate: any } ? NonNegative<T['revalidate']> : never
|
|
67
|
+
|
|
68
|
+
// If T is unknown or any, it will be an empty {} type. Otherwise, it will be the same as Omit<T, keyof Base>.
|
|
69
|
+
type OmitWithTag<T, K extends keyof any, _M> = Omit<T, K>
|
|
70
|
+
type Diff<Base, T extends Base, Message extends string = ''> = 0 extends (1 & T) ? {} : OmitWithTag<T, keyof Base, Message>
|
|
71
|
+
|
|
72
|
+
type FirstArg<T extends Function> = T extends (...args: [infer T, any]) => any ? unknown extends T ? any : T : never
|
|
73
|
+
type SecondArg<T extends Function> = T extends (...args: [any, infer T]) => any ? unknown extends T ? any : T : never
|
|
74
|
+
type MaybeField<T, K extends string> = T extends { [k in K]: infer G } ? G extends Function ? G : never : never
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
function checkFields<_ extends { [k in keyof any]: never }>() {}
|
|
79
|
+
|
|
80
|
+
// https://github.com/sindresorhus/type-fest
|
|
81
|
+
type Numeric = number | bigint
|
|
82
|
+
type Zero = 0 | 0n
|
|
83
|
+
type Negative<T extends Numeric> = T extends Zero ? never : `${T}` extends `-${string}` ? T : never
|
|
84
|
+
type NonNegative<T extends Numeric> = T extends Zero ? T : Negative<T> extends never ? T : '__invalid_negative_number__'
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// File: /home/gabriel/Projects/Resend/react-email
|
|
1
|
+
// File: /home/gabriel/Projects/Resend/react-email/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,18 +1,12 @@
|
|
|
1
|
-
import { render } from '@react-email/render';
|
|
2
1
|
import { type ImageCheckingResult, checkImages } from './check-images';
|
|
3
2
|
|
|
4
3
|
test('checkImages()', async () => {
|
|
5
4
|
const results: ImageCheckingResult[] = [];
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
<img src="/static/codepen-challengers.png" alt="codepen challenges" />,
|
|
12
|
-
</div>,
|
|
13
|
-
),
|
|
14
|
-
'https://demo.react.email',
|
|
15
|
-
);
|
|
5
|
+
const html = `<div>
|
|
6
|
+
<img src="https://resend.com/static/brand/resend-icon-white.png" />,
|
|
7
|
+
<img src="/static/codepen-challengers.png" alt="codepen challenges" />,
|
|
8
|
+
</div>`;
|
|
9
|
+
const stream = await checkImages(html, 'https://demo.react.email');
|
|
16
10
|
const reader = stream.getReader();
|
|
17
11
|
while (true) {
|
|
18
12
|
const { done, value } = await reader.read();
|
|
@@ -26,6 +20,10 @@ test('checkImages()', async () => {
|
|
|
26
20
|
expect(results).toEqual([
|
|
27
21
|
{
|
|
28
22
|
source: 'https://resend.com/static/brand/resend-icon-white.png',
|
|
23
|
+
codeLocation: {
|
|
24
|
+
line: 2,
|
|
25
|
+
column: 3,
|
|
26
|
+
},
|
|
29
27
|
checks: [
|
|
30
28
|
{
|
|
31
29
|
passed: false,
|
|
@@ -60,6 +58,10 @@ test('checkImages()', async () => {
|
|
|
60
58
|
status: 'warning',
|
|
61
59
|
},
|
|
62
60
|
{
|
|
61
|
+
codeLocation: {
|
|
62
|
+
line: 3,
|
|
63
|
+
column: 3,
|
|
64
|
+
},
|
|
63
65
|
checks: [
|
|
64
66
|
{
|
|
65
67
|
metadata: {
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import type { IncomingMessage } from 'node:http';
|
|
4
4
|
import { parse } from 'node-html-parser';
|
|
5
|
+
import {
|
|
6
|
+
type CodeLocation,
|
|
7
|
+
getCodeLocationFromAstElement,
|
|
8
|
+
} from './get-code-location-from-ast-element';
|
|
5
9
|
import { quickFetch } from './quick-fetch';
|
|
6
10
|
|
|
7
11
|
export type ImageCheck = { passed: boolean } & (
|
|
@@ -34,6 +38,7 @@ export type ImageCheck = { passed: boolean } & (
|
|
|
34
38
|
export interface ImageCheckingResult {
|
|
35
39
|
status: 'success' | 'warning' | 'error';
|
|
36
40
|
source: string;
|
|
41
|
+
codeLocation: CodeLocation;
|
|
37
42
|
checks: ImageCheck[];
|
|
38
43
|
}
|
|
39
44
|
|
|
@@ -61,6 +66,7 @@ export const checkImages = async (code: string, base: string) => {
|
|
|
61
66
|
|
|
62
67
|
const result: ImageCheckingResult = {
|
|
63
68
|
source: rawSource,
|
|
69
|
+
codeLocation: getCodeLocationFromAstElement(image, code),
|
|
64
70
|
status: 'success',
|
|
65
71
|
checks: [],
|
|
66
72
|
};
|
|
@@ -1,18 +1,14 @@
|
|
|
1
|
-
import { render } from '@react-email/render';
|
|
2
1
|
import { type LinkCheckingResult, checkLinks } from './check-links';
|
|
3
2
|
|
|
4
3
|
test('checkLinks()', async () => {
|
|
5
4
|
const results: LinkCheckingResult[] = [];
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
</div>,
|
|
14
|
-
),
|
|
15
|
-
);
|
|
5
|
+
const html = `<div>
|
|
6
|
+
<a href="/">Root</a>
|
|
7
|
+
<a href="https://resend.com">Resend</a>
|
|
8
|
+
<a href="https://notion.so">Notion</a>
|
|
9
|
+
<a href="http://react.email">React Email unsafe</a>
|
|
10
|
+
</div>`;
|
|
11
|
+
const stream = await checkLinks(html);
|
|
16
12
|
const reader = stream.getReader();
|
|
17
13
|
while (true) {
|
|
18
14
|
const { done, value } = await reader.read();
|
|
@@ -26,6 +22,10 @@ test('checkLinks()', async () => {
|
|
|
26
22
|
expect(results).toEqual([
|
|
27
23
|
{
|
|
28
24
|
status: 'error',
|
|
25
|
+
codeLocation: {
|
|
26
|
+
line: 2,
|
|
27
|
+
column: 3,
|
|
28
|
+
},
|
|
29
29
|
checks: [
|
|
30
30
|
{
|
|
31
31
|
type: 'syntax',
|
|
@@ -36,6 +36,10 @@ test('checkLinks()', async () => {
|
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
status: 'success',
|
|
39
|
+
codeLocation: {
|
|
40
|
+
line: 3,
|
|
41
|
+
column: 3,
|
|
42
|
+
},
|
|
39
43
|
checks: [
|
|
40
44
|
{
|
|
41
45
|
type: 'syntax',
|
|
@@ -57,6 +61,10 @@ test('checkLinks()', async () => {
|
|
|
57
61
|
},
|
|
58
62
|
{
|
|
59
63
|
status: 'warning',
|
|
64
|
+
codeLocation: {
|
|
65
|
+
line: 4,
|
|
66
|
+
column: 3,
|
|
67
|
+
},
|
|
60
68
|
checks: [
|
|
61
69
|
{
|
|
62
70
|
type: 'syntax',
|
|
@@ -78,6 +86,10 @@ test('checkLinks()', async () => {
|
|
|
78
86
|
},
|
|
79
87
|
{
|
|
80
88
|
status: 'warning',
|
|
89
|
+
codeLocation: {
|
|
90
|
+
line: 5,
|
|
91
|
+
column: 3,
|
|
92
|
+
},
|
|
81
93
|
checks: [
|
|
82
94
|
{
|
|
83
95
|
type: 'syntax',
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
'use server';
|
|
2
2
|
|
|
3
3
|
import { parse } from 'node-html-parser';
|
|
4
|
+
import {
|
|
5
|
+
type CodeLocation,
|
|
6
|
+
getCodeLocationFromAstElement,
|
|
7
|
+
} from './get-code-location-from-ast-element';
|
|
4
8
|
import { quickFetch } from './quick-fetch';
|
|
5
9
|
|
|
6
10
|
export type LinkCheck = { passed: boolean } & (
|
|
@@ -21,6 +25,7 @@ export type LinkCheck = { passed: boolean } & (
|
|
|
21
25
|
export interface LinkCheckingResult {
|
|
22
26
|
status: 'success' | 'warning' | 'error';
|
|
23
27
|
link: string;
|
|
28
|
+
codeLocation: CodeLocation;
|
|
24
29
|
checks: LinkCheck[];
|
|
25
30
|
}
|
|
26
31
|
|
|
@@ -37,6 +42,7 @@ export const checkLinks = async (code: string) => {
|
|
|
37
42
|
|
|
38
43
|
const result: LinkCheckingResult = {
|
|
39
44
|
link,
|
|
45
|
+
codeLocation: getCodeLocationFromAstElement(anchor, code),
|
|
40
46
|
status: 'success',
|
|
41
47
|
checks: [],
|
|
42
48
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { HTMLElement } from 'node-html-parser';
|
|
2
|
+
import { getLineAndColumnFromOffset } from '../../utils/get-line-and-column-from-offset';
|
|
3
|
+
|
|
4
|
+
export interface CodeLocation {
|
|
5
|
+
line: number;
|
|
6
|
+
column: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const getCodeLocationFromAstElement = (
|
|
10
|
+
ast: HTMLElement,
|
|
11
|
+
html: string,
|
|
12
|
+
): CodeLocation => {
|
|
13
|
+
const [line, column] = getLineAndColumnFromOffset(ast.range[0], html);
|
|
14
|
+
return {
|
|
15
|
+
line,
|
|
16
|
+
column,
|
|
17
|
+
};
|
|
18
|
+
};
|
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import logSymbols from 'log-symbols';
|
|
6
6
|
import ora from 'ora';
|
|
7
|
-
import { isBuilding } from '../app/env';
|
|
7
|
+
import { isBuilding, isPreviewDevelopment } from '../app/env';
|
|
8
8
|
import { getEmailComponent } from '../utils/get-email-component';
|
|
9
9
|
import { improveErrorWithSourceMap } from '../utils/improve-error-with-sourcemap';
|
|
10
10
|
import { registerSpinnerAutostopping } from '../utils/register-spinner-autostopping';
|
|
@@ -36,7 +36,7 @@ export const renderEmailByPath = async (
|
|
|
36
36
|
|
|
37
37
|
const emailFilename = path.basename(emailPath);
|
|
38
38
|
let spinner: ora.Ora | undefined;
|
|
39
|
-
if (!isBuilding) {
|
|
39
|
+
if (!isBuilding && !isPreviewDevelopment) {
|
|
40
40
|
spinner = ora({
|
|
41
41
|
text: `Rendering email template ${emailFilename}\n`,
|
|
42
42
|
prefixText: ' ',
|
package/src/app/env.ts
CHANGED
|
@@ -10,3 +10,6 @@ export const emailsDirectoryAbsolutePath =
|
|
|
10
10
|
process.env.EMAILS_DIR_ABSOLUTE_PATH!;
|
|
11
11
|
|
|
12
12
|
export const isBuilding = process.env.NEXT_PUBLIC_IS_BUILDING === 'true';
|
|
13
|
+
|
|
14
|
+
export const isPreviewDevelopment =
|
|
15
|
+
process.env.NEXT_PUBLIC_IS_PREVIEW_DEVELOPMENT === 'true';
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { redirect } from 'next/navigation';
|
|
3
3
|
import { Suspense } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
type CompatibilityCheckingResult,
|
|
6
|
+
checkCompatibility,
|
|
7
|
+
} from '../../../actions/email-validation/check-compatibility';
|
|
4
8
|
import { getEmailPathFromSlug } from '../../../actions/get-email-path-from-slug';
|
|
5
9
|
import { renderEmailByPath } from '../../../actions/render-email-by-path';
|
|
6
10
|
import { Shell } from '../../../components/shell';
|
|
@@ -10,8 +14,8 @@ import type { SpamCheckingResult } from '../../../components/toolbar/spam-assass
|
|
|
10
14
|
import { PreviewProvider } from '../../../contexts/preview';
|
|
11
15
|
import { getEmailsDirectoryMetadata } from '../../../utils/get-emails-directory-metadata';
|
|
12
16
|
import { getLintingSources, loadLintingRowsFrom } from '../../../utils/linting';
|
|
17
|
+
import { loadStream } from '../../../utils/load-stream';
|
|
13
18
|
import { emailsDirectoryAbsolutePath, isBuilding } from '../../env';
|
|
14
|
-
import Home from '../../page';
|
|
15
19
|
import Preview from './preview';
|
|
16
20
|
|
|
17
21
|
export const dynamicParams = true;
|
|
@@ -56,20 +60,19 @@ This is most likely not an issue with the preview server. Maybe there was a typo
|
|
|
56
60
|
|
|
57
61
|
const serverEmailRenderingResult = await renderEmailByPath(emailPath);
|
|
58
62
|
|
|
59
|
-
if (isBuilding && 'error' in serverEmailRenderingResult) {
|
|
60
|
-
throw new Error(serverEmailRenderingResult.error.message, {
|
|
61
|
-
cause: serverEmailRenderingResult.error,
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
63
|
let spamCheckingResult: SpamCheckingResult | undefined = undefined;
|
|
66
64
|
let lintingRows: LintingRow[] | undefined = undefined;
|
|
65
|
+
let compatibilityCheckingResults: CompatibilityCheckingResult[] | undefined =
|
|
66
|
+
undefined;
|
|
67
67
|
|
|
68
|
-
if (isBuilding
|
|
68
|
+
if (isBuilding) {
|
|
69
|
+
if ('error' in serverEmailRenderingResult) {
|
|
70
|
+
throw new Error(serverEmailRenderingResult.error.message, {
|
|
71
|
+
cause: serverEmailRenderingResult.error,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
69
74
|
const lintingSources = getLintingSources(
|
|
70
75
|
serverEmailRenderingResult.markup,
|
|
71
|
-
serverEmailRenderingResult.reactMarkup,
|
|
72
|
-
emailPath,
|
|
73
76
|
'',
|
|
74
77
|
);
|
|
75
78
|
lintingRows = [];
|
|
@@ -87,6 +90,15 @@ This is most likely not an issue with the preview server. Maybe there was a typo
|
|
|
87
90
|
|
|
88
91
|
return 0;
|
|
89
92
|
});
|
|
93
|
+
compatibilityCheckingResults = [];
|
|
94
|
+
for await (const result of loadStream(
|
|
95
|
+
await checkCompatibility(
|
|
96
|
+
serverEmailRenderingResult.reactMarkup,
|
|
97
|
+
emailPath,
|
|
98
|
+
),
|
|
99
|
+
)) {
|
|
100
|
+
compatibilityCheckingResults.push(result);
|
|
101
|
+
}
|
|
90
102
|
|
|
91
103
|
const response = await fetch('https://react.email/api/check-spam', {
|
|
92
104
|
method: 'POST',
|
|
@@ -118,12 +130,13 @@ This is most likely not an issue with the preview server. Maybe there was a typo
|
|
|
118
130
|
{/* This suspense is so that this page doesn't throw warnings */}
|
|
119
131
|
{/* on the build of the preview server de-opting into */}
|
|
120
132
|
{/* client-side rendering on build */}
|
|
121
|
-
<Suspense
|
|
133
|
+
<Suspense>
|
|
122
134
|
<Preview emailTitle={path.basename(emailPath)} />
|
|
123
135
|
|
|
124
136
|
<Toolbar
|
|
125
137
|
serverLintingRows={lintingRows}
|
|
126
138
|
serverSpamCheckingResult={spamCheckingResult}
|
|
139
|
+
serverCompatibilityResults={compatibilityCheckingResults}
|
|
127
140
|
/>
|
|
128
141
|
</Suspense>
|
|
129
142
|
</Shell>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
4
|
-
import { use,
|
|
4
|
+
import { use, useRef } from 'react';
|
|
5
5
|
import { flushSync } from 'react-dom';
|
|
6
6
|
import { Toaster } from 'sonner';
|
|
7
7
|
import { useDebouncedCallback } from 'use-debounce';
|
|
@@ -37,21 +37,24 @@ const Preview = ({ emailTitle }: PreviewProps) => {
|
|
|
37
37
|
const handleViewChange = (view: string) => {
|
|
38
38
|
const params = new URLSearchParams(searchParams);
|
|
39
39
|
params.set('view', view);
|
|
40
|
-
router.push(`${pathname}?${params.toString()}`);
|
|
40
|
+
router.push(`${pathname}?${params.toString()}${location.hash}`);
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
const handleLangChange = (lang: string) => {
|
|
44
44
|
const params = new URLSearchParams(searchParams);
|
|
45
45
|
params.set('view', 'source');
|
|
46
46
|
params.set('lang', lang);
|
|
47
|
-
|
|
47
|
+
const isSameLang = searchParams.get('lang') === lang;
|
|
48
|
+
router.push(
|
|
49
|
+
`${pathname}?${params.toString()}${isSameLang ? location.hash : ''}`,
|
|
50
|
+
);
|
|
48
51
|
};
|
|
49
52
|
|
|
50
53
|
const hasRenderingMetadata = typeof renderedEmailMetadata !== 'undefined';
|
|
51
54
|
const hasErrors = 'error' in renderingResult;
|
|
52
55
|
|
|
53
|
-
const
|
|
54
|
-
const
|
|
56
|
+
const maxWidthRef = useRef(Number.POSITIVE_INFINITY);
|
|
57
|
+
const maxHeightRef = useRef(Number.POSITIVE_INFINITY);
|
|
55
58
|
const minWidth = 350;
|
|
56
59
|
const minHeight = 600;
|
|
57
60
|
const storedWidth = searchParams.get('width');
|
|
@@ -59,19 +62,19 @@ const Preview = ({ emailTitle }: PreviewProps) => {
|
|
|
59
62
|
const [width, setWidth] = useClampedState(
|
|
60
63
|
storedWidth ? Number.parseInt(storedWidth) : 600,
|
|
61
64
|
350,
|
|
62
|
-
|
|
65
|
+
maxWidthRef.current,
|
|
63
66
|
);
|
|
64
67
|
const [height, setHeight] = useClampedState(
|
|
65
68
|
storedHeight ? Number.parseInt(storedHeight) : 1024,
|
|
66
69
|
600,
|
|
67
|
-
|
|
70
|
+
maxHeightRef.current,
|
|
68
71
|
);
|
|
69
72
|
|
|
70
73
|
const handleSaveViewSize = useDebouncedCallback(() => {
|
|
71
74
|
const params = new URLSearchParams(searchParams);
|
|
72
75
|
params.set('width', width.toString());
|
|
73
76
|
params.set('height', height.toString());
|
|
74
|
-
router.push(`${pathname}?${params.toString()}`);
|
|
77
|
+
router.push(`${pathname}?${params.toString()}${location.hash}`);
|
|
75
78
|
}, 300);
|
|
76
79
|
|
|
77
80
|
return (
|
|
@@ -110,8 +113,8 @@ const Preview = ({ emailTitle }: PreviewProps) => {
|
|
|
110
113
|
const observer = new ResizeObserver((entry) => {
|
|
111
114
|
const [elementEntry] = entry;
|
|
112
115
|
if (elementEntry) {
|
|
113
|
-
|
|
114
|
-
|
|
116
|
+
maxWidthRef.current = elementEntry.contentRect.width - 80;
|
|
117
|
+
maxHeightRef.current = elementEntry.contentRect.height - 80;
|
|
115
118
|
}
|
|
116
119
|
});
|
|
117
120
|
|
|
@@ -132,8 +135,8 @@ const Preview = ({ emailTitle }: PreviewProps) => {
|
|
|
132
135
|
<ResizableWarpper
|
|
133
136
|
minHeight={minHeight}
|
|
134
137
|
minWidth={minWidth}
|
|
135
|
-
maxHeight={
|
|
136
|
-
maxWidth={
|
|
138
|
+
maxHeight={maxHeightRef.current}
|
|
139
|
+
maxWidth={maxWidthRef.current}
|
|
137
140
|
height={height}
|
|
138
141
|
onResizeEnd={() => {
|
|
139
142
|
handleSaveViewSize();
|
|
@@ -27,52 +27,19 @@ export const CodeContainer: React.FC<Readonly<CodeContainerProps>> = ({
|
|
|
27
27
|
activeLang,
|
|
28
28
|
setActiveLang,
|
|
29
29
|
}) => {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
<a
|
|
40
|
-
className="text-slate-11 transition duration-200 ease-in-out hover:text-slate-12"
|
|
41
|
-
download={file.name}
|
|
42
|
-
href={url}
|
|
43
|
-
>
|
|
44
|
-
<IconDownload />
|
|
45
|
-
</a>
|
|
46
|
-
);
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const renderClipboardIcon = () => {
|
|
50
|
-
const handleClipboard = async () => {
|
|
51
|
-
const activeContent = markups.filter(({ language }) => {
|
|
52
|
-
return activeLang === language;
|
|
53
|
-
});
|
|
54
|
-
setIsCopied(true);
|
|
55
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
56
|
-
await copyTextToClipboard(activeContent[0]!.content);
|
|
57
|
-
setTimeout(() => {
|
|
58
|
-
setIsCopied(false);
|
|
59
|
-
}, 3000);
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
return (
|
|
63
|
-
<IconButton onClick={() => void handleClipboard()}>
|
|
64
|
-
{isCopied ? <IconCheck /> : <IconClipboard />}
|
|
65
|
-
</IconButton>
|
|
66
|
-
);
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
React.useEffect(() => {
|
|
70
|
-
setIsCopied(false);
|
|
71
|
-
}, [activeLang]);
|
|
30
|
+
const activeMarkup = markups.find(({ language }) => activeLang === language);
|
|
31
|
+
if (!activeMarkup) {
|
|
32
|
+
throw new Error('No markup found for the active language!', {
|
|
33
|
+
cause: {
|
|
34
|
+
activeLang,
|
|
35
|
+
markups,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
72
39
|
|
|
73
40
|
return (
|
|
74
41
|
<div
|
|
75
|
-
className="relative w-full items-center whitespace-pre rounded-md border border-slate-6 text-sm
|
|
42
|
+
className="relative w-full items-center whitespace-pre rounded-md border border-slate-6 text-sm"
|
|
76
43
|
style={{
|
|
77
44
|
lineHeight: '130%',
|
|
78
45
|
background:
|
|
@@ -111,35 +78,87 @@ export const CodeContainer: React.FC<Readonly<CodeContainerProps>> = ({
|
|
|
111
78
|
})}
|
|
112
79
|
</LayoutGroup>
|
|
113
80
|
</div>
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
</Tooltip>
|
|
123
|
-
<Tooltip>
|
|
124
|
-
<Tooltip.Trigger
|
|
125
|
-
asChild
|
|
126
|
-
className="text-gray-11 absolute right-8 top-2 hidden md:block"
|
|
127
|
-
>
|
|
128
|
-
{renderDownloadIcon()}
|
|
129
|
-
</Tooltip.Trigger>
|
|
130
|
-
<Tooltip.Content>Download</Tooltip.Content>
|
|
131
|
-
</Tooltip>
|
|
81
|
+
<CopyToClipboardButton content={activeMarkup.content} />
|
|
82
|
+
<DownloadButton
|
|
83
|
+
content={activeMarkup.content}
|
|
84
|
+
filename={`email.${activeMarkup.language}`}
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
<div>
|
|
88
|
+
<Code language={activeLang}>{activeMarkup.content}</Code>
|
|
132
89
|
</div>
|
|
133
|
-
{markups.map(({ language, content }) => {
|
|
134
|
-
return (
|
|
135
|
-
<div
|
|
136
|
-
className={`${activeLang !== language && 'hidden'}`}
|
|
137
|
-
key={language}
|
|
138
|
-
>
|
|
139
|
-
<Code language={language}>{content}</Code>
|
|
140
|
-
</div>
|
|
141
|
-
);
|
|
142
|
-
})}
|
|
143
90
|
</div>
|
|
144
91
|
);
|
|
145
92
|
};
|
|
93
|
+
|
|
94
|
+
interface CopyToClipboardButtonProps {
|
|
95
|
+
content: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const CopyToClipboardButton = ({ content }: CopyToClipboardButtonProps) => {
|
|
99
|
+
const [isCopied, setIsCopied] = React.useState(false);
|
|
100
|
+
|
|
101
|
+
const unsetIsCopiedTimeout = React.useRef<NodeJS.Timeout>(undefined);
|
|
102
|
+
React.useEffect(() => {
|
|
103
|
+
setIsCopied(false);
|
|
104
|
+
clearTimeout(unsetIsCopiedTimeout.current);
|
|
105
|
+
unsetIsCopiedTimeout.current = undefined;
|
|
106
|
+
}, [content]);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<Tooltip>
|
|
110
|
+
<Tooltip.Trigger
|
|
111
|
+
asChild
|
|
112
|
+
className="absolute right-2 top-2 hidden md:block"
|
|
113
|
+
>
|
|
114
|
+
<IconButton
|
|
115
|
+
onClick={async () => {
|
|
116
|
+
setIsCopied(true);
|
|
117
|
+
await copyTextToClipboard(content);
|
|
118
|
+
unsetIsCopiedTimeout.current = setTimeout(() => {
|
|
119
|
+
setIsCopied(false);
|
|
120
|
+
}, 3000);
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
{isCopied ? <IconCheck /> : <IconClipboard />}
|
|
124
|
+
</IconButton>
|
|
125
|
+
</Tooltip.Trigger>
|
|
126
|
+
<Tooltip.Content>Copy to Clipboard</Tooltip.Content>
|
|
127
|
+
</Tooltip>
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
interface DownloadButtonProps {
|
|
132
|
+
content: string;
|
|
133
|
+
filename: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const DownloadButton = ({ content, filename }: DownloadButtonProps) => {
|
|
137
|
+
const generatedUrl = React.useMemo(() => {
|
|
138
|
+
const file = new File([content], filename);
|
|
139
|
+
return URL.createObjectURL(file);
|
|
140
|
+
}, [content, filename]);
|
|
141
|
+
const url = React.useSyncExternalStore(
|
|
142
|
+
() => () => {},
|
|
143
|
+
() => generatedUrl,
|
|
144
|
+
() => undefined,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<Tooltip>
|
|
149
|
+
<Tooltip.Trigger
|
|
150
|
+
asChild
|
|
151
|
+
className="text-gray-11 absolute right-8 top-2 hidden md:block"
|
|
152
|
+
>
|
|
153
|
+
<a
|
|
154
|
+
className="text-slate-11 transition duration-200 ease-in-out hover:text-slate-12"
|
|
155
|
+
download={filename}
|
|
156
|
+
href={url}
|
|
157
|
+
>
|
|
158
|
+
<IconDownload />
|
|
159
|
+
</a>
|
|
160
|
+
</Tooltip.Trigger>
|
|
161
|
+
<Tooltip.Content>Download</Tooltip.Content>
|
|
162
|
+
</Tooltip>
|
|
163
|
+
);
|
|
164
|
+
};
|