react-email 4.0.0-alpha.5 → 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 (203) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cli/index.js +1179 -2659
  3. package/dist/cli/index.mjs +17 -11
  4. package/dist/preview/.next/BUILD_ID +1 -1
  5. package/dist/preview/.next/app-build-manifest.json +32 -31
  6. package/dist/preview/.next/app-path-routes-manifest.json +6 -1
  7. package/dist/preview/.next/build-manifest.json +14 -14
  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/diagnostics/framework.json +1 -1
  15. package/dist/preview/.next/export-marker.json +6 -1
  16. package/dist/preview/.next/images-manifest.json +57 -1
  17. package/dist/preview/.next/next-minimal-server.js.nft.json +1 -1
  18. package/dist/preview/.next/next-server.js.nft.json +1 -1
  19. package/dist/preview/.next/prerender-manifest.json +41 -1
  20. package/dist/preview/.next/required-server-files.json +310 -1
  21. package/dist/preview/.next/routes-manifest.json +64 -1
  22. package/dist/preview/.next/server/app/_not-found/page.js +1 -1
  23. package/dist/preview/.next/server/app/_not-found/page.js.nft.json +1 -1
  24. package/dist/preview/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  25. package/dist/preview/.next/server/app/favicon.ico/route.js +1 -1
  26. package/dist/preview/.next/server/app/favicon.ico/route.js.nft.json +1 -1
  27. package/dist/preview/.next/server/app/page.js +1 -1
  28. package/dist/preview/.next/server/app/page.js.nft.json +1 -1
  29. package/dist/preview/.next/server/app/page_client-reference-manifest.js +1 -1
  30. package/dist/preview/.next/server/app/preview/[...slug]/page.js +51 -11
  31. package/dist/preview/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
  32. package/dist/preview/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
  33. package/dist/preview/.next/server/app-paths-manifest.json +1 -1
  34. package/dist/preview/.next/server/chunks/446.js +6 -0
  35. package/dist/preview/.next/server/chunks/600.js +8 -0
  36. package/dist/preview/.next/server/chunks/811.js +13 -0
  37. package/dist/preview/.next/server/chunks/816.js +14 -0
  38. package/dist/preview/.next/server/chunks/943.js +1 -0
  39. package/dist/preview/.next/server/functions-config-manifest.json +4 -1
  40. package/dist/preview/.next/server/middleware-build-manifest.js +1 -1
  41. package/dist/preview/.next/server/next-font-manifest.js +1 -1
  42. package/dist/preview/.next/server/next-font-manifest.json +1 -1
  43. package/dist/preview/.next/server/pages/500.html +1 -1
  44. package/dist/preview/.next/server/pages/_app.js +1 -1
  45. package/dist/preview/.next/server/pages/_app.js.nft.json +1 -1
  46. package/dist/preview/.next/server/pages/_document.js +1 -1
  47. package/dist/preview/.next/server/pages/_document.js.nft.json +1 -1
  48. package/dist/preview/.next/server/pages/_error.js +1 -1
  49. package/dist/preview/.next/server/pages/_error.js.nft.json +1 -1
  50. package/dist/preview/.next/server/pages-manifest.json +5 -1
  51. package/dist/preview/.next/server/server-reference-manifest.js +1 -1
  52. package/dist/preview/.next/server/server-reference-manifest.json +1 -1
  53. package/dist/preview/.next/server/webpack-runtime.js +1 -1
  54. package/dist/preview/.next/static/Pms2orsQgT5xpttCfZfH5/_buildManifest.js +1 -0
  55. package/dist/preview/.next/static/chunks/287-7864b805e6bdc854.js +1 -0
  56. package/dist/preview/.next/static/chunks/412-31817e53b50a3e73.js +1 -0
  57. package/dist/preview/.next/static/chunks/683-1fb40795502f6e63.js +1 -0
  58. package/dist/preview/.next/static/chunks/744-79730358b37b2212.js +1 -0
  59. package/dist/preview/.next/static/chunks/781-5f16c6bc9d9d4cc1.js +1 -0
  60. package/dist/preview/.next/static/chunks/832ad4be-cb988facfb8f955f.js +1 -0
  61. package/dist/preview/.next/static/chunks/880-9c0b721328117b8b.js +1 -0
  62. package/dist/preview/.next/static/chunks/{afa401a5-a600c227dacf3ab4.js → afa401a5-3e949a1cfd317dd3.js} +3 -3
  63. package/dist/preview/.next/static/chunks/app/_not-found/page-09d694081cc9d4dc.js +1 -0
  64. package/dist/preview/.next/static/chunks/app/layout-ffdee5cc1be30e7b.js +1 -0
  65. package/dist/preview/.next/static/chunks/app/page-9ea0bd45cd6294b0.js +1 -0
  66. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-9e22979a25c836c0.js +1 -0
  67. package/dist/preview/.next/static/chunks/framework-c2bd6d936e3077bc.js +1 -0
  68. package/dist/preview/.next/static/chunks/main-44463a8301435b64.js +1 -0
  69. package/dist/preview/.next/static/chunks/main-app-256b213b179a95cc.js +1 -0
  70. package/dist/preview/.next/static/chunks/pages/_app-f3011d3f00bb8dba.js +1 -0
  71. package/dist/preview/.next/static/chunks/pages/_error-39a87dee2e97a2a3.js +1 -0
  72. package/dist/preview/.next/static/chunks/{webpack-2eb145a20ee6cb77.js → webpack-41e2667c9f086a4f.js} +1 -1
  73. package/dist/preview/.next/static/css/eaae8ce545b295f9.css +3 -0
  74. package/dist/preview/.next/trace +26 -22
  75. package/dist/preview/.next/types/app/layout.ts +1 -1
  76. package/dist/preview/.next/types/app/page.ts +84 -0
  77. package/dist/preview/.next/types/app/preview/[...slug]/page.ts +1 -1
  78. package/dist/preview/.next/types/cache-life.d.ts +3 -3
  79. package/package.json +14 -9
  80. package/scripts/build-preview-server.mjs +32 -0
  81. package/scripts/fill-caniemail-data.mjs +36 -0
  82. package/src/actions/email-validation/caniemail-data.ts +85993 -0
  83. package/src/actions/email-validation/check-compatibility.ts +321 -0
  84. package/src/actions/email-validation/check-images.spec.tsx +15 -13
  85. package/src/actions/email-validation/check-images.ts +8 -2
  86. package/src/actions/email-validation/check-links.spec.tsx +27 -15
  87. package/src/actions/email-validation/check-links.ts +8 -2
  88. package/src/actions/email-validation/get-code-location-from-ast-element.ts +18 -0
  89. package/src/actions/get-email-path-from-slug.ts +1 -1
  90. package/src/actions/render-email-by-path.tsx +2 -1
  91. package/src/{utils/emails-directory-absolute-path.ts → app/env.ts} +5 -0
  92. package/src/app/layout.tsx +1 -1
  93. package/src/app/page.tsx +1 -1
  94. package/src/app/preview/[...slug]/page.tsx +89 -19
  95. package/src/app/preview/[...slug]/preview.tsx +25 -68
  96. package/src/components/code-container.tsx +90 -71
  97. package/src/components/code.tsx +106 -43
  98. package/src/components/icons/icon-info.tsx +18 -0
  99. package/src/components/icons/icon-reload.tsx +13 -14
  100. package/src/components/logo.tsx +3 -2
  101. package/src/components/resizable-wrapper.tsx +1 -4
  102. package/src/components/sidebar/file-tree-directory-children.tsx +1 -0
  103. package/src/components/sidebar/sidebar.tsx +2 -3
  104. package/src/components/toolbar/code-preview-line-link.tsx +40 -0
  105. package/src/components/toolbar/compatibility.tsx +113 -0
  106. package/src/components/toolbar/linter.tsx +226 -125
  107. package/src/components/toolbar/results.tsx +5 -2
  108. package/src/components/toolbar/spam-assassin.tsx +40 -43
  109. package/src/components/toolbar/toolbar-button.tsx +52 -0
  110. package/src/components/toolbar/use-cached-state.ts +33 -0
  111. package/src/components/toolbar.tsx +196 -110
  112. package/src/components/tooltip-content.tsx +1 -1
  113. package/src/components/topbar/view-size-controls.tsx +1 -1
  114. package/src/components/topbar.tsx +4 -29
  115. package/src/contexts/emails.tsx +2 -1
  116. package/src/contexts/fragment-identifier.tsx +46 -0
  117. package/src/contexts/preview.tsx +81 -0
  118. package/src/hooks/use-email-rendering-result.ts +2 -1
  119. package/src/hooks/use-fragment-identifier.ts +14 -0
  120. package/src/utils/__snapshots__/get-email-component.spec.ts.snap +1 -1
  121. package/src/utils/caniemail/all-css-properties.ts +358 -0
  122. package/src/utils/caniemail/ast/get-object-variables.ts +61 -0
  123. package/src/utils/caniemail/ast/get-used-style-properties.ts +91 -0
  124. package/src/utils/caniemail/get-compatibility-stats-for-entry.ts +118 -0
  125. package/src/utils/caniemail/get-css-functions.ts +25 -0
  126. package/src/utils/caniemail/get-css-property-names.ts +32 -0
  127. package/src/utils/caniemail/get-css-property-with-value.ts +14 -0
  128. package/src/utils/caniemail/get-css-unit.ts +3 -0
  129. package/src/utils/caniemail/get-element-attributes.ts +7 -0
  130. package/src/utils/caniemail/get-element-names.ts +20 -0
  131. package/src/utils/caniemail/tailwind/generate-tailwind-rules.ts +30 -0
  132. package/src/utils/caniemail/tailwind/get-tailwind-config.ts +203 -0
  133. package/src/utils/caniemail/tailwind/get-tailwind-metadata.spec.ts +25 -0
  134. package/src/utils/caniemail/tailwind/get-tailwind-metadata.ts +45 -0
  135. package/src/utils/caniemail/tailwind/setup-tailwind-context.ts +15 -0
  136. package/src/utils/get-email-component.ts +34 -67
  137. package/src/utils/get-line-and-column-from-offset.spec.ts +11 -0
  138. package/src/utils/get-line-and-column-from-offset.ts +11 -0
  139. package/src/utils/index.ts +1 -0
  140. package/src/utils/linting.ts +60 -0
  141. package/src/utils/load-stream.ts +15 -0
  142. package/src/utils/result.ts +49 -0
  143. package/src/utils/run-bundled-code.ts +64 -0
  144. package/src/utils/sanitize.ts +6 -0
  145. package/tailwind-internals.d.ts +133 -0
  146. package/tsconfig.json +9 -3
  147. package/build-preview-server.mjs +0 -25
  148. package/dist/preview/.next/cache/images/TcyzHbFXGFjrOu3wEMvDoSmqCh3qP3iiNqJf0QbED9Y/60.1741728556140.cQ5qicbpvoXZ7leVmWqG2ElLwXB1ynYeSv8MBSA-QeM.Vy8iMWM3MGUtMTk1ODcxYmIyNzMi.webp +0 -0
  149. package/dist/preview/.next/cache/webpack/client-development/0.pack.gz +0 -0
  150. package/dist/preview/.next/cache/webpack/client-development/1.pack.gz +0 -0
  151. package/dist/preview/.next/cache/webpack/client-development/10.pack.gz +0 -0
  152. package/dist/preview/.next/cache/webpack/client-development/11.pack.gz +0 -0
  153. package/dist/preview/.next/cache/webpack/client-development/12.pack.gz +0 -0
  154. package/dist/preview/.next/cache/webpack/client-development/13.pack.gz +0 -0
  155. package/dist/preview/.next/cache/webpack/client-development/2.pack.gz +0 -0
  156. package/dist/preview/.next/cache/webpack/client-development/3.pack.gz +0 -0
  157. package/dist/preview/.next/cache/webpack/client-development/4.pack.gz +0 -0
  158. package/dist/preview/.next/cache/webpack/client-development/5.pack.gz +0 -0
  159. package/dist/preview/.next/cache/webpack/client-development/6.pack.gz +0 -0
  160. package/dist/preview/.next/cache/webpack/client-development/7.pack.gz +0 -0
  161. package/dist/preview/.next/cache/webpack/client-development/8.pack.gz +0 -0
  162. package/dist/preview/.next/cache/webpack/client-development/9.pack.gz +0 -0
  163. package/dist/preview/.next/cache/webpack/client-development/index.pack.gz +0 -0
  164. package/dist/preview/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
  165. package/dist/preview/.next/cache/webpack/server-development/0.pack.gz +0 -0
  166. package/dist/preview/.next/cache/webpack/server-development/1.pack.gz +0 -0
  167. package/dist/preview/.next/cache/webpack/server-development/2.pack.gz +0 -0
  168. package/dist/preview/.next/cache/webpack/server-development/3.pack.gz +0 -0
  169. package/dist/preview/.next/cache/webpack/server-development/4.pack.gz +0 -0
  170. package/dist/preview/.next/cache/webpack/server-development/5.pack.gz +0 -0
  171. package/dist/preview/.next/cache/webpack/server-development/6.pack.gz +0 -0
  172. package/dist/preview/.next/cache/webpack/server-development/7.pack.gz +0 -0
  173. package/dist/preview/.next/cache/webpack/server-development/8.pack.gz +0 -0
  174. package/dist/preview/.next/cache/webpack/server-development/9.pack.gz +0 -0
  175. package/dist/preview/.next/cache/webpack/server-development/index.pack.gz +0 -0
  176. package/dist/preview/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
  177. package/dist/preview/.next/server/chunks/143.js +0 -6
  178. package/dist/preview/.next/server/chunks/409.js +0 -5
  179. package/dist/preview/.next/server/chunks/46.js +0 -1
  180. package/dist/preview/.next/server/chunks/478.js +0 -14
  181. package/dist/preview/.next/server/chunks/707.js +0 -13
  182. package/dist/preview/.next/static/B4EYZiVzdylEG9lAIl-aO/_buildManifest.js +0 -1
  183. package/dist/preview/.next/static/chunks/575-bc52750855c25df4.js +0 -2
  184. package/dist/preview/.next/static/chunks/684-0f1ef7361c499798.js +0 -1
  185. package/dist/preview/.next/static/chunks/684c6b30-0c65da32762fc4ee.js +0 -1
  186. package/dist/preview/.next/static/chunks/81-e7539b08d9d3fb4d.js +0 -1
  187. package/dist/preview/.next/static/chunks/883-70c8267c50bc4133.js +0 -1
  188. package/dist/preview/.next/static/chunks/921-d1dc8c63f49e85d6.js +0 -1
  189. package/dist/preview/.next/static/chunks/app/_not-found/page-03ce767859c36d4e.js +0 -1
  190. package/dist/preview/.next/static/chunks/app/layout-7cf14e28880544f1.js +0 -1
  191. package/dist/preview/.next/static/chunks/app/page-065cb49b0a078541.js +0 -1
  192. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-656510fd180c803c.js +0 -1
  193. package/dist/preview/.next/static/chunks/framework-2a724981073c3a29.js +0 -1
  194. package/dist/preview/.next/static/chunks/main-552b9719bbc3a274.js +0 -1
  195. package/dist/preview/.next/static/chunks/main-app-914a73336fd45af5.js +0 -1
  196. package/dist/preview/.next/static/chunks/pages/_app-77ca34bce25ac75c.js +0 -1
  197. package/dist/preview/.next/static/chunks/pages/_error-73f611c46abbb495.js +0 -1
  198. package/dist/preview/.next/static/css/2df96d9ee014e8de.css +0 -3
  199. package/src/actions/email-validation/get-line-and-column-from-index.spec.ts +0 -22
  200. package/src/actions/email-validation/get-line-and-column-from-index.ts +0 -43
  201. package/src/components/icons/icon-scanner.tsx +0 -19
  202. package/src/components/icons/icon-scissors.tsx +0 -19
  203. /package/dist/preview/.next/static/{B4EYZiVzdylEG9lAIl-aO → Pms2orsQgT5xpttCfZfH5}/_ssgManifest.js +0 -0
@@ -1,11 +1,21 @@
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
- import { emailsDirectoryAbsolutePath } from '../../../utils/emails-directory-absolute-path';
10
+ import { Shell } from '../../../components/shell';
11
+ import { Toolbar } from '../../../components/toolbar';
12
+ import type { LintingRow } from '../../../components/toolbar/linter';
13
+ import type { SpamCheckingResult } from '../../../components/toolbar/spam-assassin';
14
+ import { PreviewProvider } from '../../../contexts/preview';
7
15
  import { getEmailsDirectoryMetadata } from '../../../utils/get-emails-directory-metadata';
8
- import Home from '../../page';
16
+ import { getLintingSources, loadLintingRowsFrom } from '../../../utils/linting';
17
+ import { loadStream } from '../../../utils/load-stream';
18
+ import { emailsDirectoryAbsolutePath, isBuilding } from '../../env';
9
19
  import Preview from './preview';
10
20
 
11
21
  export const dynamicParams = true;
@@ -50,27 +60,87 @@ This is most likely not an issue with the preview server. Maybe there was a typo
50
60
 
51
61
  const serverEmailRenderingResult = await renderEmailByPath(emailPath);
52
62
 
53
- if (
54
- process.env.NEXT_PUBLIC_IS_BUILDING === 'true' &&
55
- 'error' in serverEmailRenderingResult
56
- ) {
57
- throw new Error(serverEmailRenderingResult.error.message, {
58
- cause: serverEmailRenderingResult.error,
63
+ let spamCheckingResult: SpamCheckingResult | undefined = undefined;
64
+ let lintingRows: LintingRow[] | undefined = undefined;
65
+ let compatibilityCheckingResults: CompatibilityCheckingResult[] | undefined =
66
+ undefined;
67
+
68
+ if (isBuilding) {
69
+ if ('error' in serverEmailRenderingResult) {
70
+ throw new Error(serverEmailRenderingResult.error.message, {
71
+ cause: serverEmailRenderingResult.error,
72
+ });
73
+ }
74
+ const lintingSources = getLintingSources(
75
+ serverEmailRenderingResult.markup,
76
+ '',
77
+ );
78
+ lintingRows = [];
79
+ for await (const row of loadLintingRowsFrom(lintingSources)) {
80
+ lintingRows.push(row);
81
+ }
82
+ lintingRows.sort((a, b) => {
83
+ if (a.result.status === 'error' && b.result.status === 'warning') {
84
+ return -1;
85
+ }
86
+
87
+ if (a.result.status === 'warning' && b.result.status === 'error') {
88
+ return 1;
89
+ }
90
+
91
+ return 0;
59
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
+ }
102
+
103
+ const response = await fetch('https://react.email/api/check-spam', {
104
+ method: 'POST',
105
+ headers: { 'Content-Type': 'application/json' },
106
+ body: JSON.stringify({
107
+ html: serverEmailRenderingResult.markup,
108
+ plainText: serverEmailRenderingResult.plainText,
109
+ }),
110
+ });
111
+ const responseBody = (await response.json()) as
112
+ | { error: string }
113
+ | SpamCheckingResult;
114
+ if ('error' in responseBody) {
115
+ throw new Error(`Failed doing Spam Check. ${responseBody.error}`, {
116
+ cause: responseBody,
117
+ });
118
+ }
119
+
120
+ spamCheckingResult = responseBody;
60
121
  }
61
122
 
62
123
  return (
63
- // This suspense is so that this page doesn't throw warnings
64
- // on the build of the preview server de-opting into
65
- // client-side rendering on build
66
- <Suspense fallback={<Home />}>
67
- <Preview
68
- emailPath={emailPath}
69
- pathSeparator={path.sep}
70
- serverRenderingResult={serverEmailRenderingResult}
71
- slug={slug}
72
- />
73
- </Suspense>
124
+ <PreviewProvider
125
+ emailSlug={slug}
126
+ emailPath={emailPath}
127
+ serverRenderingResult={serverEmailRenderingResult}
128
+ >
129
+ <Shell currentEmailOpenSlug={slug}>
130
+ {/* This suspense is so that this page doesn't throw warnings */}
131
+ {/* on the build of the preview server de-opting into */}
132
+ {/* client-side rendering on build */}
133
+ <Suspense>
134
+ <Preview emailTitle={path.basename(emailPath)} />
135
+
136
+ <Toolbar
137
+ serverLintingRows={lintingRows}
138
+ serverSpamCheckingResult={spamCheckingResult}
139
+ serverCompatibilityResults={compatibilityCheckingResults}
140
+ />
141
+ </Suspense>
142
+ </Shell>
143
+ </PreviewProvider>
74
144
  );
75
145
  };
76
146
 
@@ -1,11 +1,10 @@
1
1
  'use client';
2
2
 
3
3
  import { usePathname, useRouter, useSearchParams } from 'next/navigation';
4
- import { 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';
8
- import type { EmailRenderingResult } from '../../../actions/render-email-by-path';
9
8
  import { Topbar } from '../../../components';
10
9
  import { CodeContainer } from '../../../components/code-container';
11
10
  import {
@@ -13,30 +12,21 @@ import {
13
12
  makeIframeDocumentBubbleEvents,
14
13
  } from '../../../components/resizable-wrapper';
15
14
  import { Send } from '../../../components/send';
16
- import { Shell, ShellContent } from '../../../components/shell';
17
- import { Toolbar } from '../../../components/toolbar';
15
+ import { ShellContent } from '../../../components/shell';
18
16
  import { Tooltip } from '../../../components/tooltip';
19
17
  import { ActiveViewToggleGroup } from '../../../components/topbar/active-view-toggle-group';
20
18
  import { ViewSizeControls } from '../../../components/topbar/view-size-controls';
19
+ import { PreviewContext } from '../../../contexts/preview';
21
20
  import { useClampedState } from '../../../hooks/use-clamped-state';
22
- import { useEmailRenderingResult } from '../../../hooks/use-email-rendering-result';
23
- import { useHotreload } from '../../../hooks/use-hot-reload';
24
- import { useRenderingMetadata } from '../../../hooks/use-rendering-metadata';
25
21
  import { RenderingError } from './rendering-error';
26
22
 
27
23
  interface PreviewProps {
28
- slug: string;
29
- emailPath: string;
30
- pathSeparator: string;
31
- serverRenderingResult: EmailRenderingResult;
24
+ emailTitle: string;
32
25
  }
33
26
 
34
- const Preview = ({
35
- slug,
36
- emailPath,
37
- pathSeparator,
38
- serverRenderingResult,
39
- }: PreviewProps) => {
27
+ const Preview = ({ emailTitle }: PreviewProps) => {
28
+ const { renderingResult, renderedEmailMetadata } = use(PreviewContext)!;
29
+
40
30
  const router = useRouter();
41
31
  const pathname = usePathname();
42
32
  const searchParams = useSearchParams();
@@ -44,52 +34,27 @@ const Preview = ({
44
34
  const activeView = searchParams.get('view') ?? 'preview';
45
35
  const activeLang = searchParams.get('lang') ?? 'jsx';
46
36
 
47
- const renderingResult = useEmailRenderingResult(
48
- emailPath,
49
- serverRenderingResult,
50
- );
51
-
52
- const renderedEmailMetadata = useRenderingMetadata(
53
- emailPath,
54
- renderingResult,
55
- serverRenderingResult,
56
- );
57
-
58
- if (process.env.NEXT_PUBLIC_IS_BUILDING !== 'true') {
59
- // this will not change on runtime so it doesn't violate
60
- // the rules of hooks
61
- // eslint-disable-next-line react-hooks/rules-of-hooks
62
- useHotreload((changes) => {
63
- const changeForThisEmail = changes.find((change) =>
64
- change.filename.includes(slug),
65
- );
66
-
67
- if (typeof changeForThisEmail !== 'undefined') {
68
- if (changeForThisEmail.event === 'unlink') {
69
- router.push('/');
70
- }
71
- }
72
- });
73
- }
74
-
75
37
  const handleViewChange = (view: string) => {
76
38
  const params = new URLSearchParams(searchParams);
77
39
  params.set('view', view);
78
- router.push(`${pathname}?${params.toString()}`);
40
+ router.push(`${pathname}?${params.toString()}${location.hash}`);
79
41
  };
80
42
 
81
43
  const handleLangChange = (lang: string) => {
82
44
  const params = new URLSearchParams(searchParams);
83
45
  params.set('view', 'source');
84
46
  params.set('lang', lang);
85
- router.push(`${pathname}?${params.toString()}`);
47
+ const isSameLang = searchParams.get('lang') === lang;
48
+ router.push(
49
+ `${pathname}?${params.toString()}${isSameLang ? location.hash : ''}`,
50
+ );
86
51
  };
87
52
 
88
53
  const hasRenderingMetadata = typeof renderedEmailMetadata !== 'undefined';
89
54
  const hasErrors = 'error' in renderingResult;
90
55
 
91
- const [maxWidth, setMaxWidth] = useState(Number.POSITIVE_INFINITY);
92
- const [maxHeight, setMaxHeight] = useState(Number.POSITIVE_INFINITY);
56
+ const maxWidthRef = useRef(Number.POSITIVE_INFINITY);
57
+ const maxHeightRef = useRef(Number.POSITIVE_INFINITY);
93
58
  const minWidth = 350;
94
59
  const minHeight = 600;
95
60
  const storedWidth = searchParams.get('width');
@@ -97,24 +62,24 @@ const Preview = ({
97
62
  const [width, setWidth] = useClampedState(
98
63
  storedWidth ? Number.parseInt(storedWidth) : 600,
99
64
  350,
100
- maxWidth,
65
+ maxWidthRef.current,
101
66
  );
102
67
  const [height, setHeight] = useClampedState(
103
68
  storedHeight ? Number.parseInt(storedHeight) : 1024,
104
69
  600,
105
- maxHeight,
70
+ maxHeightRef.current,
106
71
  );
107
72
 
108
73
  const handleSaveViewSize = useDebouncedCallback(() => {
109
74
  const params = new URLSearchParams(searchParams);
110
75
  params.set('width', width.toString());
111
76
  params.set('height', height.toString());
112
- router.push(`${pathname}?${params.toString()}`);
77
+ router.push(`${pathname}?${params.toString()}${location.hash}`);
113
78
  }, 300);
114
79
 
115
80
  return (
116
- <Shell currentEmailOpenSlug={slug}>
117
- <Topbar pathSeparator={pathSeparator} currentEmailOpenSlug={slug}>
81
+ <>
82
+ <Topbar emailTitle={emailTitle}>
118
83
  <ViewSizeControls
119
84
  setViewHeight={(height) => {
120
85
  setHeight(height);
@@ -148,8 +113,8 @@ const Preview = ({
148
113
  const observer = new ResizeObserver((entry) => {
149
114
  const [elementEntry] = entry;
150
115
  if (elementEntry) {
151
- setMaxWidth(elementEntry.contentRect.width - 80);
152
- setMaxHeight(elementEntry.contentRect.height - 80);
116
+ maxWidthRef.current = elementEntry.contentRect.width - 80;
117
+ maxHeightRef.current = elementEntry.contentRect.height - 80;
153
118
  }
154
119
  });
155
120
 
@@ -170,8 +135,8 @@ const Preview = ({
170
135
  <ResizableWarpper
171
136
  minHeight={minHeight}
172
137
  minWidth={minWidth}
173
- maxHeight={maxHeight}
174
- maxWidth={maxWidth}
138
+ maxHeight={maxHeightRef.current}
139
+ maxWidth={maxWidthRef.current}
175
140
  height={height}
176
141
  onResizeEnd={() => {
177
142
  handleSaveViewSize();
@@ -199,7 +164,7 @@ const Preview = ({
199
164
  width: `${width}px`,
200
165
  height: `${height}px`,
201
166
  }}
202
- title={slug}
167
+ title={emailTitle}
203
168
  />
204
169
  </ResizableWarpper>
205
170
  )}
@@ -235,15 +200,7 @@ const Preview = ({
235
200
 
236
201
  <Toaster />
237
202
  </ShellContent>
238
-
239
- {!hasErrors && hasRenderingMetadata ? (
240
- <Toolbar
241
- emailSlug={slug}
242
- markup={renderedEmailMetadata.markup}
243
- plainText={renderedEmailMetadata.plainText}
244
- />
245
- ) : undefined}
246
- </Shell>
203
+ </>
247
204
  );
248
205
  };
249
206
 
@@ -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
+ };
@@ -1,6 +1,10 @@
1
+ 'use client';
2
+ import Link from 'next/link';
3
+ import { useSearchParams } from 'next/navigation';
1
4
  import type { Language } from 'prism-react-renderer';
2
5
  import { Highlight } from 'prism-react-renderer';
3
- import * as React from 'react';
6
+ import { Fragment, useEffect } from 'react';
7
+ import { useFragmentIdentifier } from '../hooks/use-fragment-identifier';
4
8
  import { cn } from '../utils';
5
9
 
6
10
  interface CodeProps {
@@ -43,10 +47,43 @@ const theme = {
43
47
  ],
44
48
  };
45
49
 
50
+ const lineHashRegex = /#L(?<start>\d+)(?:,(?<end>\d+))?/;
51
+
46
52
  export const Code: React.FC<Readonly<CodeProps>> = ({
47
53
  children,
48
54
  language = 'html',
49
55
  }) => {
56
+ const locationHash = useFragmentIdentifier();
57
+ const highlight = (() => {
58
+ if (locationHash) {
59
+ const match = locationHash.match(lineHashRegex);
60
+ if (match?.groups?.start) {
61
+ const start = Number.parseInt(match.groups.start);
62
+ const end = match.groups.end
63
+ ? Number.parseInt(match.groups.end)
64
+ : start;
65
+ return [start, end] as const;
66
+ }
67
+ }
68
+ })();
69
+
70
+ const isHighlighting = (line: number) => {
71
+ if (!highlight) return false;
72
+
73
+ return highlight[0] <= line && highlight[1] >= line;
74
+ };
75
+
76
+ useEffect(() => {
77
+ if (highlight) {
78
+ document.getElementById(`L${highlight[0]}`)?.scrollIntoView({
79
+ block: 'start',
80
+ behavior: 'smooth',
81
+ });
82
+ }
83
+ }, [highlight]);
84
+
85
+ const searchParams = useSearchParams();
86
+
50
87
  const value = children.trim();
51
88
 
52
89
  return (
@@ -60,51 +97,77 @@ export const Code: React.FC<Readonly<CodeProps>> = ({
60
97
  'linear-gradient(90deg, rgba(56, 189, 248, 0) 0%, rgba(56, 189, 248, 0) 0%, rgba(232, 232, 232, 0.2) 33.02%, rgba(143, 143, 143, 0.6719) 64.41%, rgba(236, 72, 153, 0) 98.93%)',
61
98
  }}
62
99
  />
63
- <pre className="h-[650px] overflow-auto p-4">
64
- {tokens.map((line, i) => {
65
- const lineProps = getLineProps({
66
- line,
67
- key: i,
68
- });
69
- return (
70
- <div
71
- {...lineProps}
72
- className={cn('whitespace-pre', {
73
- "before:mr-2 before:text-slate-11 before:content-['$']":
74
- language === 'bash' && tokens.length === 1,
75
- })}
100
+ <div className="flex h-[650px] p-4 max-h-[calc(100vh-10rem)] after:w-full after:static after:block after:h-4 after:content-[''] overflow-auto">
101
+ <div className="text-[#49494f] text-[13px] font-light font-[MonoLisa,_Menlo,_monospace]">
102
+ {tokens.map((_, i) => (
103
+ <Link
104
+ id={`L${i + 1}`}
76
105
  key={i}
106
+ href={{
107
+ hash: `#L${i + 1}`,
108
+ search: searchParams.toString(),
109
+ }}
110
+ scroll={false}
111
+ className={cn(
112
+ 'align-middle block scroll-mt-[325px] rounded-l-sm select-none pr-3 cursor-pointer hover:text-slate-12',
113
+ isHighlighting(i + 1) &&
114
+ 'text-cyan-11 hover:text-cyan-11 bg-cyan-5',
115
+ )}
116
+ type="button"
77
117
  >
78
- {line.map((token, key) => {
79
- const tokenProps = getTokenProps({
80
- token,
81
- key,
82
- });
83
- const isException =
84
- token.content === 'from' &&
85
- line[key + 1]?.content === ':';
86
- const newTypes = isException
87
- ? [...token.types, 'key-white']
88
- : token.types;
89
- token.types = newTypes;
118
+ {i + 1}
119
+ </Link>
120
+ ))}
121
+ </div>
122
+ <pre>
123
+ {tokens.map((line, i) => {
124
+ const lineProps = getLineProps({
125
+ line,
126
+ key: i,
127
+ });
128
+ return (
129
+ <div
130
+ {...lineProps}
131
+ className={cn(
132
+ 'whitespace-pre flex transition-colors rounded-r-sm',
133
+ isHighlighting(i + 1) && 'bg-cyan-5',
134
+ {
135
+ "before:mr-2 before:text-slate-11 before:content-['$']":
136
+ language === 'bash' && tokens.length === 1,
137
+ },
138
+ )}
139
+ key={i}
140
+ >
141
+ {line.map((token, key) => {
142
+ const tokenProps = getTokenProps({
143
+ token,
144
+ });
145
+ const isException =
146
+ token.content === 'from' &&
147
+ line[key + 1]?.content === ':';
148
+ const newTypes = isException
149
+ ? [...token.types, 'key-white']
150
+ : token.types;
151
+ token.types = newTypes;
90
152
 
91
- return (
92
- <React.Fragment key={key}>
93
- <span {...tokenProps} />
94
- </React.Fragment>
95
- );
96
- })}
97
- </div>
98
- );
99
- })}
100
- </pre>
101
- <div
102
- className="absolute bottom-0 left-0 h-px w-[200px]"
103
- style={{
104
- background:
105
- 'linear-gradient(90deg, rgba(56, 189, 248, 0) 0%, rgba(56, 189, 248, 0) 0%, rgba(232, 232, 232, 0.2) 33.02%, rgba(143, 143, 143, 0.6719) 64.41%, rgba(236, 72, 153, 0) 98.93%)',
106
- }}
107
- />
153
+ return (
154
+ <Fragment key={key}>
155
+ <span {...tokenProps} />
156
+ </Fragment>
157
+ );
158
+ })}
159
+ </div>
160
+ );
161
+ })}
162
+ </pre>
163
+ <div
164
+ className="absolute bottom-0 left-0 h-px w-[200px]"
165
+ style={{
166
+ background:
167
+ 'linear-gradient(90deg, rgba(56, 189, 248, 0) 0%, rgba(56, 189, 248, 0) 0%, rgba(232, 232, 232, 0.2) 33.02%, rgba(143, 143, 143, 0.6719) 64.41%, rgba(236, 72, 153, 0) 98.93%)',
168
+ }}
169
+ />
170
+ </div>
108
171
  </>
109
172
  )}
110
173
  </Highlight>