react-email 4.0.0-alpha.6 → 4.0.0-alpha.8

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 (110) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/cli/index.js +18 -13
  3. package/dist/cli/index.mjs +26 -21
  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/build-manifest.json +3 -3
  7. package/dist/preview/.next/cache/.rscinfo +1 -1
  8. package/dist/preview/.next/cache/webpack/client-production/0.pack +0 -0
  9. package/dist/preview/.next/cache/webpack/client-production/index.pack +0 -0
  10. package/dist/preview/.next/cache/webpack/edge-server-production/index.pack +0 -0
  11. package/dist/preview/.next/cache/webpack/server-production/0.pack +0 -0
  12. package/dist/preview/.next/cache/webpack/server-production/index.pack +0 -0
  13. package/dist/preview/.next/next-minimal-server.js.nft.json +1 -1
  14. package/dist/preview/.next/next-server.js.nft.json +1 -1
  15. package/dist/preview/.next/prerender-manifest.json +3 -3
  16. package/dist/preview/.next/required-server-files.json +3 -3
  17. package/dist/preview/.next/server/app/_not-found/page.js +1 -1
  18. package/dist/preview/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  19. package/dist/preview/.next/server/app/favicon.ico/route.js +1 -1
  20. package/dist/preview/.next/server/app/page.js +1 -1
  21. package/dist/preview/.next/server/app/page.js.nft.json +1 -1
  22. package/dist/preview/.next/server/app/page_client-reference-manifest.js +1 -1
  23. package/dist/preview/.next/server/app/preview/[...slug]/page.js +133 -25
  24. package/dist/preview/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
  25. package/dist/preview/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
  26. package/dist/preview/.next/server/chunks/42.js +1 -0
  27. package/dist/preview/.next/server/chunks/600.js +3 -3
  28. package/dist/preview/.next/server/chunks/{171.js → 816.js} +6 -6
  29. package/dist/preview/.next/server/middleware-build-manifest.js +1 -1
  30. package/dist/preview/.next/server/next-font-manifest.js +1 -1
  31. package/dist/preview/.next/server/next-font-manifest.json +1 -1
  32. package/dist/preview/.next/server/pages/500.html +1 -1
  33. package/dist/preview/.next/server/pages-manifest.json +1 -1
  34. package/dist/preview/.next/server/server-reference-manifest.js +1 -1
  35. package/dist/preview/.next/server/server-reference-manifest.json +1 -1
  36. package/dist/preview/.next/static/chunks/287-7864b805e6bdc854.js +1 -0
  37. package/dist/preview/.next/static/chunks/412-31817e53b50a3e73.js +1 -0
  38. package/dist/preview/.next/static/chunks/683-b769e5d91bdf9a82.js +1 -0
  39. package/dist/preview/.next/static/chunks/880-9c0b721328117b8b.js +1 -0
  40. package/dist/preview/.next/static/chunks/app/layout-7dee682873546401.js +1 -0
  41. package/dist/preview/.next/static/chunks/app/page-9ea0bd45cd6294b0.js +1 -0
  42. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-a610d641c64448cc.js +1 -0
  43. package/dist/preview/.next/static/chunks/{main-app-c2e686acf8d370d7.js → main-app-256b213b179a95cc.js} +1 -1
  44. package/dist/preview/.next/static/css/e68ebc9bb8f7b3f4.css +3 -0
  45. package/dist/preview/.next/trace +26 -26
  46. package/dist/preview/.next/types/app/layout.ts +1 -1
  47. package/dist/preview/.next/types/app/page.ts +84 -0
  48. package/dist/preview/.next/types/app/preview/[...slug]/page.ts +1 -1
  49. package/package.json +1 -1
  50. package/src/actions/email-validation/check-compatibility.ts +16 -5
  51. package/src/actions/email-validation/check-images.spec.tsx +13 -11
  52. package/src/actions/email-validation/check-images.ts +6 -0
  53. package/src/actions/email-validation/check-links.spec.tsx +23 -11
  54. package/src/actions/email-validation/check-links.ts +6 -0
  55. package/src/actions/email-validation/get-code-location-from-ast-element.ts +18 -0
  56. package/src/actions/render-email-by-path.tsx +2 -2
  57. package/src/app/env.ts +3 -0
  58. package/src/app/preview/[...slug]/page.tsx +24 -11
  59. package/src/app/preview/[...slug]/preview.tsx +15 -12
  60. package/src/components/code-container.tsx +90 -71
  61. package/src/components/code.tsx +106 -42
  62. package/src/components/icons/icon-info.tsx +18 -0
  63. package/src/components/icons/icon-reload.tsx +13 -14
  64. package/src/components/logo.tsx +3 -2
  65. package/src/components/resizable-wrapper.tsx +1 -4
  66. package/src/components/sidebar/file-tree-directory-children.tsx +13 -2
  67. package/src/components/sidebar/file-tree-directory.tsx +26 -18
  68. package/src/components/sidebar/file-tree.tsx +2 -2
  69. package/src/components/sidebar/sidebar.tsx +16 -19
  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 +31 -20
  75. package/src/components/toolbar/toolbar-button.tsx +4 -2
  76. package/src/components/toolbar/use-cached-state.ts +2 -2
  77. package/src/components/toolbar.tsx +152 -30
  78. package/src/components/tooltip-content.tsx +1 -1
  79. package/src/components/topbar/view-size-controls.tsx +1 -2
  80. package/src/components/topbar.tsx +1 -20
  81. package/src/contexts/fragment-identifier.tsx +46 -0
  82. package/src/hooks/use-fragment-identifier.ts +14 -0
  83. package/src/utils/__snapshots__/get-email-component.spec.ts.snap +1 -1
  84. package/src/utils/caniemail/ast/__snapshots__/get-object-variables.spec.ts.snap +74 -0
  85. package/src/utils/caniemail/ast/__snapshots__/get-used-style-properties.spec.ts.snap +24 -0
  86. package/src/utils/caniemail/ast/get-object-variables.spec.ts +19 -0
  87. package/src/utils/caniemail/ast/get-used-style-properties.spec.ts +23 -0
  88. package/src/utils/caniemail/get-css-property-with-value.ts +2 -2
  89. package/src/utils/caniemail/tailwind/get-tailwind-config.ts +0 -2
  90. package/src/utils/get-email-component.ts +1 -1
  91. package/src/utils/get-line-and-column-from-offset.spec.ts +11 -0
  92. package/src/utils/get-line-and-column-from-offset.ts +11 -0
  93. package/src/utils/index.ts +1 -0
  94. package/src/utils/linting.ts +5 -30
  95. package/src/utils/load-stream.ts +15 -0
  96. package/src/utils/sanitize.ts +6 -0
  97. package/dist/preview/.next/server/chunks/833.js +0 -1
  98. package/dist/preview/.next/static/chunks/416-56f79fc7e689f06f.js +0 -1
  99. package/dist/preview/.next/static/chunks/683-8bbfd191e5105f01.js +0 -1
  100. package/dist/preview/.next/static/chunks/87-38e35f08507de015.js +0 -1
  101. package/dist/preview/.next/static/chunks/app/layout-a6640e62690d8fd6.js +0 -1
  102. package/dist/preview/.next/static/chunks/app/page-ba68f50b287e7478.js +0 -1
  103. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-4a5b026ab543e27f.js +0 -1
  104. package/dist/preview/.next/static/css/d7df9cfc3e182163.css +0 -3
  105. package/src/actions/email-validation/get-line-and-column-from-index.spec.ts +0 -22
  106. package/src/actions/email-validation/get-line-and-column-from-index.ts +0 -43
  107. package/src/components/icons/icon-scanner.tsx +0 -19
  108. package/src/components/icons/icon-scissors.tsx +0 -19
  109. /package/dist/preview/.next/static/{gFk9UfWL8joM4iD7-wlKF → SoPVDfPAp9R983pBBriVn}/_buildManifest.js +0 -0
  110. /package/dist/preview/.next/static/{gFk9UfWL8joM4iD7-wlKF → SoPVDfPAp9R983pBBriVn}/_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.8",
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
  ];
@@ -155,7 +154,11 @@ export const checkCompatibility = async (
155
154
  );
156
155
  if (Object.keys(compatibilityStats.perEmailClient).length === 0)
157
156
  continue;
158
- if (compatibilityStats.status === 'success') continue;
157
+ if (
158
+ compatibilityStats.status === 'success' ||
159
+ compatibilityStats.status === 'warning'
160
+ )
161
+ continue;
159
162
 
160
163
  if (entry.category === 'html') {
161
164
  const entryElements = getElementNames(entry.title, entry.keywords);
@@ -275,8 +278,9 @@ export const checkCompatibility = async (
275
278
 
276
279
  if (cssEntryType === 'full property') {
277
280
  if (
278
- property.name === entryFullProperty?.name &&
279
- property.value === entryFullProperty.value
281
+ snakeToCamel(property.name) ===
282
+ snakeToCamel(entryFullProperty!.name) &&
283
+ property.value === entryFullProperty!.value
280
284
  ) {
281
285
  addToInsights(property);
282
286
  break;
@@ -303,7 +307,8 @@ export const checkCompatibility = async (
303
307
  }
304
308
  } else if (
305
309
  entryProperties.some(
306
- (propertyName) => property.name === propertyName,
310
+ (propertyName) =>
311
+ snakeToCamel(property.name) === snakeToCamel(propertyName),
307
312
  )
308
313
  ) {
309
314
  addToInsights(property);
@@ -319,4 +324,10 @@ export const checkCompatibility = async (
319
324
  return readableStream;
320
325
  };
321
326
 
327
+ const snakeToCamel = (snakeStr: string) => {
328
+ return snakeStr
329
+ .toLowerCase()
330
+ .replace(/-+([a-z])/g, (_match, letter) => letter.toUpperCase());
331
+ };
332
+
322
333
  export type AST = ReturnType<typeof parse>;
@@ -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();