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.
Files changed (103) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/cli/index.js +7 -3
  3. package/dist/cli/index.mjs +7 -3
  4. package/dist/preview/.next/BUILD_ID +1 -1
  5. package/dist/preview/.next/app-build-manifest.json +14 -13
  6. package/dist/preview/.next/app-path-routes-manifest.json +1 -1
  7. package/dist/preview/.next/build-manifest.json +3 -3
  8. package/dist/preview/.next/cache/.rscinfo +1 -1
  9. package/dist/preview/.next/cache/webpack/client-production/0.pack +0 -0
  10. package/dist/preview/.next/cache/webpack/client-production/index.pack +0 -0
  11. package/dist/preview/.next/cache/webpack/edge-server-production/index.pack +0 -0
  12. package/dist/preview/.next/cache/webpack/server-production/0.pack +0 -0
  13. package/dist/preview/.next/cache/webpack/server-production/index.pack +0 -0
  14. package/dist/preview/.next/next-minimal-server.js.nft.json +1 -1
  15. package/dist/preview/.next/next-server.js.nft.json +1 -1
  16. package/dist/preview/.next/prerender-manifest.json +3 -3
  17. package/dist/preview/.next/required-server-files.json +3 -3
  18. package/dist/preview/.next/server/app/_not-found/page.js +1 -1
  19. package/dist/preview/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/dist/preview/.next/server/app/favicon.ico/route.js +1 -1
  21. package/dist/preview/.next/server/app/page.js +1 -1
  22. package/dist/preview/.next/server/app/page.js.nft.json +1 -1
  23. package/dist/preview/.next/server/app/page_client-reference-manifest.js +1 -1
  24. package/dist/preview/.next/server/app/preview/[...slug]/page.js +29 -25
  25. package/dist/preview/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
  26. package/dist/preview/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
  27. package/dist/preview/.next/server/app-paths-manifest.json +1 -1
  28. package/dist/preview/.next/server/chunks/600.js +3 -3
  29. package/dist/preview/.next/server/chunks/{171.js → 816.js} +6 -6
  30. package/dist/preview/.next/server/chunks/943.js +1 -0
  31. package/dist/preview/.next/server/middleware-build-manifest.js +1 -1
  32. package/dist/preview/.next/server/next-font-manifest.js +1 -1
  33. package/dist/preview/.next/server/next-font-manifest.json +1 -1
  34. package/dist/preview/.next/server/pages/500.html +1 -1
  35. package/dist/preview/.next/server/pages-manifest.json +1 -1
  36. package/dist/preview/.next/server/server-reference-manifest.js +1 -1
  37. package/dist/preview/.next/server/server-reference-manifest.json +1 -1
  38. package/dist/preview/.next/static/chunks/287-7864b805e6bdc854.js +1 -0
  39. package/dist/preview/.next/static/chunks/412-31817e53b50a3e73.js +1 -0
  40. package/dist/preview/.next/static/chunks/683-1fb40795502f6e63.js +1 -0
  41. package/dist/preview/.next/static/chunks/880-9c0b721328117b8b.js +1 -0
  42. package/dist/preview/.next/static/chunks/app/layout-ffdee5cc1be30e7b.js +1 -0
  43. package/dist/preview/.next/static/chunks/app/page-9ea0bd45cd6294b0.js +1 -0
  44. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-9e22979a25c836c0.js +1 -0
  45. package/dist/preview/.next/static/chunks/{main-app-c2e686acf8d370d7.js → main-app-256b213b179a95cc.js} +1 -1
  46. package/dist/preview/.next/static/css/eaae8ce545b295f9.css +3 -0
  47. package/dist/preview/.next/trace +26 -26
  48. package/dist/preview/.next/types/app/layout.ts +1 -1
  49. package/dist/preview/.next/types/app/page.ts +84 -0
  50. package/dist/preview/.next/types/app/preview/[...slug]/page.ts +1 -1
  51. package/package.json +1 -1
  52. package/src/actions/email-validation/check-compatibility.ts +0 -1
  53. package/src/actions/email-validation/check-images.spec.tsx +13 -11
  54. package/src/actions/email-validation/check-images.ts +6 -0
  55. package/src/actions/email-validation/check-links.spec.tsx +23 -11
  56. package/src/actions/email-validation/check-links.ts +6 -0
  57. package/src/actions/email-validation/get-code-location-from-ast-element.ts +18 -0
  58. package/src/actions/render-email-by-path.tsx +2 -2
  59. package/src/app/env.ts +3 -0
  60. package/src/app/preview/[...slug]/page.tsx +24 -11
  61. package/src/app/preview/[...slug]/preview.tsx +15 -12
  62. package/src/components/code-container.tsx +90 -71
  63. package/src/components/code.tsx +106 -42
  64. package/src/components/icons/icon-info.tsx +18 -0
  65. package/src/components/icons/icon-reload.tsx +13 -14
  66. package/src/components/logo.tsx +3 -2
  67. package/src/components/resizable-wrapper.tsx +1 -4
  68. package/src/components/sidebar/file-tree-directory-children.tsx +1 -0
  69. package/src/components/sidebar/sidebar.tsx +2 -3
  70. package/src/components/toolbar/code-preview-line-link.tsx +40 -0
  71. package/src/components/toolbar/compatibility.tsx +113 -0
  72. package/src/components/toolbar/linter.tsx +69 -111
  73. package/src/components/toolbar/results.tsx +5 -2
  74. package/src/components/toolbar/spam-assassin.tsx +20 -12
  75. package/src/components/toolbar/toolbar-button.tsx +4 -2
  76. package/src/components/toolbar.tsx +108 -30
  77. package/src/components/tooltip-content.tsx +1 -1
  78. package/src/components/topbar/view-size-controls.tsx +1 -2
  79. package/src/components/topbar.tsx +1 -20
  80. package/src/contexts/fragment-identifier.tsx +46 -0
  81. package/src/hooks/use-fragment-identifier.ts +14 -0
  82. package/src/utils/caniemail/tailwind/get-tailwind-config.ts +0 -2
  83. package/src/utils/get-email-component.ts +1 -1
  84. package/src/utils/get-line-and-column-from-offset.spec.ts +11 -0
  85. package/src/utils/get-line-and-column-from-offset.ts +11 -0
  86. package/src/utils/index.ts +1 -0
  87. package/src/utils/linting.ts +5 -30
  88. package/src/utils/load-stream.ts +15 -0
  89. package/src/utils/sanitize.ts +6 -0
  90. package/dist/preview/.next/server/chunks/833.js +0 -1
  91. package/dist/preview/.next/static/chunks/416-56f79fc7e689f06f.js +0 -1
  92. package/dist/preview/.next/static/chunks/683-8bbfd191e5105f01.js +0 -1
  93. package/dist/preview/.next/static/chunks/87-38e35f08507de015.js +0 -1
  94. package/dist/preview/.next/static/chunks/app/layout-a6640e62690d8fd6.js +0 -1
  95. package/dist/preview/.next/static/chunks/app/page-ba68f50b287e7478.js +0 -1
  96. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-4a5b026ab543e27f.js +0 -1
  97. package/dist/preview/.next/static/css/d7df9cfc3e182163.css +0 -3
  98. package/src/actions/email-validation/get-line-and-column-from-index.spec.ts +0 -22
  99. package/src/actions/email-validation/get-line-and-column-from-index.ts +0 -43
  100. package/src/components/icons/icon-scanner.tsx +0 -19
  101. package/src/components/icons/icon-scissors.tsx +0 -19
  102. /package/dist/preview/.next/static/{gFk9UfWL8joM4iD7-wlKF → Pms2orsQgT5xpttCfZfH5}/_buildManifest.js +0 -0
  103. /package/dist/preview/.next/static/{gFk9UfWL8joM4iD7-wlKF → Pms2orsQgT5xpttCfZfH5}/_ssgManifest.js +0 -0
@@ -1,4 +1,4 @@
1
- // File: /home/gabriel/Projects/Resend/react-email.git/4.0/packages/react-email/src/app/layout.tsx
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.git/4.0/packages/react-email/src/app/preview/[...slug]/page.tsx
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,6 +1,6 @@
1
1
  {
2
2
  "name": "react-email",
3
- "version": "4.0.0-alpha.6",
3
+ "version": "4.0.0-alpha.7",
4
4
  "description": "A live preview of your emails right in your browser.",
5
5
  "bin": {
6
6
  "email": "./dist/cli/index.js"
@@ -112,7 +112,6 @@ export interface SupportEntry {
112
112
  const relevantEmailClients: EmailClient[] = [
113
113
  'gmail',
114
114
  'apple-mail',
115
- 'hey',
116
115
  'outlook',
117
116
  'yahoo',
118
117
  ];
@@ -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 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>,
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 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>,
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 && !('error' in serverEmailRenderingResult)) {
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 fallback={<Home />}>
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, useState } from 'react';
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
- router.push(`${pathname}?${params.toString()}`);
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 [maxWidth, setMaxWidth] = useState(Number.POSITIVE_INFINITY);
54
- const [maxHeight, setMaxHeight] = useState(Number.POSITIVE_INFINITY);
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
- maxWidth,
65
+ maxWidthRef.current,
63
66
  );
64
67
  const [height, setHeight] = useClampedState(
65
68
  storedHeight ? Number.parseInt(storedHeight) : 1024,
66
69
  600,
67
- maxHeight,
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
- setMaxWidth(elementEntry.contentRect.width - 80);
114
- setMaxHeight(elementEntry.contentRect.height - 80);
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={maxHeight}
136
- maxWidth={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 [isCopied, setIsCopied] = React.useState(false);
31
-
32
- const renderDownloadIcon = () => {
33
- const value = markups.filter((markup) => markup.language === activeLang);
34
- if (typeof value[0] === 'undefined') return;
35
- const file = new File([value[0].content], `email.${value[0].language}`);
36
- const url = URL.createObjectURL(file);
37
-
38
- return (
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 backdrop-blur-md"
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
- <Tooltip>
115
- <Tooltip.Trigger
116
- asChild
117
- className="absolute right-2 top-2 hidden md:block"
118
- >
119
- {renderClipboardIcon()}
120
- </Tooltip.Trigger>
121
- <Tooltip.Content>Copy to Clipboard</Tooltip.Content>
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
+ };