react-email 4.0.7 → 4.1.0-canary.2

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 (59) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/cli/index.js +20 -18
  3. package/dist/cli/index.mjs +20 -18
  4. package/dist/preview/.next/BUILD_ID +1 -1
  5. package/dist/preview/.next/app-build-manifest.json +9 -9
  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/next-minimal-server.js.nft.json +1 -1
  9. package/dist/preview/.next/next-server.js.nft.json +1 -1
  10. package/dist/preview/.next/prerender-manifest.json +3 -3
  11. package/dist/preview/.next/required-server-files.json +4 -4
  12. package/dist/preview/.next/server/app/_not-found/page.js +1 -1
  13. package/dist/preview/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/dist/preview/.next/server/app/favicon.ico/route.js +1 -1
  15. package/dist/preview/.next/server/app/page.js +1 -1
  16. package/dist/preview/.next/server/app/page.js.nft.json +1 -1
  17. package/dist/preview/.next/server/app/page_client-reference-manifest.js +1 -1
  18. package/dist/preview/.next/server/app/preview/[...slug]/page.js +74 -72
  19. package/dist/preview/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
  20. package/dist/preview/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
  21. package/dist/preview/.next/server/app-paths-manifest.json +1 -1
  22. package/dist/preview/.next/server/chunks/18.js +1 -0
  23. package/dist/preview/.next/server/chunks/840.js +2 -2
  24. package/dist/preview/.next/server/chunks/886.js +4 -4
  25. package/dist/preview/.next/server/middleware-build-manifest.js +1 -1
  26. package/dist/preview/.next/server/next-font-manifest.js +1 -1
  27. package/dist/preview/.next/server/next-font-manifest.json +1 -1
  28. package/dist/preview/.next/server/pages/500.html +1 -1
  29. package/dist/preview/.next/server/server-reference-manifest.js +1 -1
  30. package/dist/preview/.next/server/server-reference-manifest.json +1 -1
  31. package/dist/preview/.next/static/chunks/{587-352f8079202a48d0.js → 587-4858c761db7745c7.js} +1 -1
  32. package/dist/preview/.next/static/chunks/app/layout-e9997533099ea6ce.js +1 -0
  33. package/dist/preview/.next/static/chunks/app/page-dd13899a1b8e35f9.js +1 -0
  34. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-d668e90cc7af328b.js +1 -0
  35. package/dist/preview/.next/static/chunks/{main-app-a6a05ec7ce09e366.js → main-app-2cfbaf0185a1cd0e.js} +1 -1
  36. package/dist/preview/.next/trace +27 -27
  37. package/dist/preview/.next/types/app/layout.ts +1 -1
  38. package/dist/preview/.next/types/app/page.ts +1 -1
  39. package/dist/preview/.next/types/app/preview/[...slug]/page.ts +1 -1
  40. package/package.json +16 -16
  41. package/src/actions/render-email-by-path.tsx +4 -3
  42. package/src/app/preview/[...slug]/preview.tsx +19 -1
  43. package/src/components/icons/icon-moon.tsx +16 -0
  44. package/src/components/icons/icon-sun.tsx +16 -0
  45. package/src/components/topbar/theme-toggle-group.tsx +87 -0
  46. package/src/hooks/use-email-rendering-result.ts +10 -1
  47. package/src/hooks/use-iframe-color-scheme.ts +35 -0
  48. package/src/utils/__snapshots__/get-email-component.spec.ts.snap +1 -1
  49. package/src/utils/contains-email-template.spec.ts +86 -0
  50. package/src/utils/contains-email-template.ts +23 -0
  51. package/src/utils/esbuild/renderring-utilities-exporter.ts +1 -1
  52. package/src/utils/get-email-component.ts +20 -1
  53. package/src/utils/get-emails-directory-metadata.ts +1 -1
  54. package/dist/preview/.next/server/chunks/380.js +0 -1
  55. package/dist/preview/.next/static/chunks/app/layout-fa93a7ef0cc5ebdb.js +0 -1
  56. package/dist/preview/.next/static/chunks/app/page-0ee3a37f3a3f6f17.js +0 -1
  57. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-95449af2d870e732.js +0 -1
  58. /package/dist/preview/.next/static/{Oy7kpIZ6Nbnd7hpoEKBWw → SSj2mIeUmYssEdHvw7yXK}/_buildManifest.js +0 -0
  59. /package/dist/preview/.next/static/{Oy7kpIZ6Nbnd7hpoEKBWw → SSj2mIeUmYssEdHvw7yXK}/_ssgManifest.js +0 -0
@@ -1,4 +1,4 @@
1
- // File: /home/gabriel/Projects/Resend/react-email/packages/react-email/src/app/layout.tsx
1
+ // File: /home/runner/actions-runner/_work/react-email/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
 
@@ -1,4 +1,4 @@
1
- // File: /home/gabriel/Projects/Resend/react-email/packages/react-email/src/app/page.tsx
1
+ // File: /home/runner/actions-runner/_work/react-email/react-email/packages/react-email/src/app/page.tsx
2
2
  import * as entry from '../../../src/app/page.js'
3
3
  import type { ResolvingMetadata, ResolvingViewport } from 'next/dist/lib/metadata/types/metadata-interface.js'
4
4
 
@@ -1,4 +1,4 @@
1
- // File: /home/gabriel/Projects/Resend/react-email/packages/react-email/src/app/preview/[...slug]/page.tsx
1
+ // File: /home/runner/actions-runner/_work/react-email/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.7",
3
+ "version": "4.1.0-canary.2",
4
4
  "description": "A live preview of your emails right in your browser.",
5
5
  "bin": {
6
6
  "email": "./dist/cli/index.js"
@@ -19,20 +19,20 @@
19
19
  "node": ">=18.0.0"
20
20
  },
21
21
  "dependencies": {
22
- "@babel/parser": "7.24.5",
23
- "@babel/traverse": "7.25.6",
24
- "chalk": "4.1.2",
25
- "chokidar": "4.0.3",
26
- "commander": "11.1.0",
27
- "debounce": "2.0.0",
28
- "esbuild": "0.25.0",
29
- "glob": "10.3.4",
30
- "log-symbols": "4.1.0",
31
- "mime-types": "2.1.35",
32
- "next": "15.2.4",
33
- "normalize-path": "3.0.0",
34
- "ora": "5.4.1",
35
- "socket.io": "4.8.1"
22
+ "@babel/parser": "^7.24.5",
23
+ "@babel/traverse": "^7.25.6",
24
+ "chalk": "^4.1.2",
25
+ "chokidar": "^4.0.3",
26
+ "commander": "^11.1.0",
27
+ "debounce": "^2.0.0",
28
+ "esbuild": "^0.25.0",
29
+ "glob": "^10.3.4",
30
+ "log-symbols": "^4.1.0",
31
+ "mime-types": "^2.1.35",
32
+ "next": "^15.2.4",
33
+ "normalize-path": "^3.0.0",
34
+ "ora": "^5.4.1",
35
+ "socket.io": "^4.8.1"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@babel/core": "7.26.10",
@@ -82,7 +82,7 @@
82
82
  "typescript": "5.8.2",
83
83
  "use-debounce": "10.0.4",
84
84
  "zod": "3.24.2",
85
- "@react-email/components": "0.0.36"
85
+ "@react-email/components": "0.0.37-canary.2"
86
86
  },
87
87
  "scripts": {
88
88
  "build": "tsup-node && node ./scripts/build-preview-server.mjs && pnpm install --frozen-lockfile",
@@ -58,15 +58,16 @@ export const renderEmailByPath = async (
58
58
  emailComponent: Email,
59
59
  createElement,
60
60
  render,
61
+ pretty,
61
62
  sourceMapToOriginalFile,
62
63
  } = componentResult;
63
64
 
64
65
  const previewProps = Email.PreviewProps || {};
65
66
  const EmailComponent = Email as React.FC;
66
67
  try {
67
- const markup = await render(createElement(EmailComponent, previewProps), {
68
- pretty: true,
69
- });
68
+ const markup = await pretty(
69
+ await render(createElement(EmailComponent, previewProps)),
70
+ );
70
71
  const plainText = await render(
71
72
  createElement(EmailComponent, previewProps),
72
73
  {
@@ -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, useState } from 'react';
5
5
  import { flushSync } from 'react-dom';
6
6
  import { Toaster } from 'sonner';
7
7
  import { useDebouncedCallback } from 'use-debounce';
@@ -15,9 +15,11 @@ import { Send } from '../../../components/send';
15
15
  import { useToolbarState } from '../../../components/toolbar';
16
16
  import { Tooltip } from '../../../components/tooltip';
17
17
  import { ActiveViewToggleGroup } from '../../../components/topbar/active-view-toggle-group';
18
+ import { ThemeToggleGroup } from '../../../components/topbar/theme-toggle-group';
18
19
  import { ViewSizeControls } from '../../../components/topbar/view-size-controls';
19
20
  import { PreviewContext } from '../../../contexts/preview';
20
21
  import { useClampedState } from '../../../hooks/use-clamped-state';
22
+ import { useIframeColorScheme } from '../../../hooks/use-iframe-color-scheme';
21
23
  import { cn } from '../../../utils';
22
24
  import { RenderingError } from './rendering-error';
23
25
 
@@ -32,9 +34,17 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
32
34
  const pathname = usePathname();
33
35
  const searchParams = useSearchParams();
34
36
 
37
+ const activeTheme: 'dark' | 'light' =
38
+ searchParams.get('theme') === 'dark' ? 'dark' : 'light';
35
39
  const activeView = searchParams.get('view') ?? 'preview';
36
40
  const activeLang = searchParams.get('lang') ?? 'jsx';
37
41
 
42
+ const handleThemeChange = (theme: 'dark' | 'light') => {
43
+ const params = new URLSearchParams(searchParams);
44
+ params.set('theme', theme);
45
+ router.push(`${pathname}?${params.toString()}`);
46
+ };
47
+
38
48
  const handleViewChange = (view: string) => {
39
49
  const params = new URLSearchParams(searchParams);
40
50
  params.set('view', view);
@@ -51,6 +61,9 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
51
61
  );
52
62
  };
53
63
 
64
+ const iframeRef = useRef<HTMLIFrameElement>(null);
65
+ useIframeColorScheme(iframeRef, activeTheme);
66
+
54
67
  const hasRenderingMetadata = typeof renderedEmailMetadata !== 'undefined';
55
68
  const hasErrors = 'error' in renderingResult;
56
69
 
@@ -99,6 +112,10 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
99
112
  viewHeight={height}
100
113
  viewWidth={width}
101
114
  />
115
+ <ThemeToggleGroup
116
+ active={activeTheme}
117
+ onChange={(theme) => handleThemeChange(theme)}
118
+ />
102
119
  <ActiveViewToggleGroup
103
120
  activeView={activeView}
104
121
  setActiveView={handleViewChange}
@@ -164,6 +181,7 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
164
181
  <iframe
165
182
  className="solid max-h-full rounded-lg bg-white"
166
183
  ref={(iframe) => {
184
+ iframeRef.current = iframe;
167
185
  if (iframe) {
168
186
  return makeIframeDocumentBubbleEvents(iframe);
169
187
  }
@@ -0,0 +1,16 @@
1
+ import * as React from 'react';
2
+ import type { IconElement, IconProps } from './icon-base';
3
+ import { IconBase } from './icon-base';
4
+
5
+ export const IconMoon = React.forwardRef<IconElement, Readonly<IconProps>>(
6
+ ({ ...props }, forwardedRef) => (
7
+ <IconBase ref={forwardedRef} {...props}>
8
+ <path
9
+ fill="currentColor"
10
+ d="m17.75 4.09l-2.53 1.94l.91 3.06l-2.63-1.81l-2.63 1.81l.91-3.06l-2.53-1.94L12.44 4l1.06-3l1.06 3zm3.5 6.91l-1.64 1.25l.59 1.98l-1.7-1.17l-1.7 1.17l.59-1.98L15.75 11l2.06-.05L18.5 9l.69 1.95zm-2.28 4.95c.83-.08 1.72 1.1 1.19 1.85c-.32.45-.66.87-1.08 1.27C15.17 23 8.84 23 4.94 19.07c-3.91-3.9-3.91-10.24 0-14.14c.4-.4.82-.76 1.27-1.08c.75-.53 1.93.36 1.85 1.19c-.27 2.86.69 5.83 2.89 8.02a9.96 9.96 0 0 0 8.02 2.89m-1.64 2.02a12.08 12.08 0 0 1-7.8-3.47c-2.17-2.19-3.33-5-3.49-7.82c-2.81 3.14-2.7 7.96.31 10.98c3.02 3.01 7.84 3.12 10.98.31"
11
+ />
12
+ </IconBase>
13
+ ),
14
+ );
15
+
16
+ IconMoon.displayName = 'IconMoon';
@@ -0,0 +1,16 @@
1
+ import * as React from 'react';
2
+ import type { IconElement, IconProps } from './icon-base';
3
+ import { IconBase } from './icon-base';
4
+
5
+ export const IconSun = React.forwardRef<IconElement, Readonly<IconProps>>(
6
+ ({ ...props }, forwardedRef) => (
7
+ <IconBase ref={forwardedRef} {...props}>
8
+ <path
9
+ fill="currentColor"
10
+ d="m3.55 19.09l1.41 1.41l1.8-1.79l-1.42-1.42M12 6c-3.31 0-6 2.69-6 6s2.69 6 6 6s6-2.69 6-6c0-3.32-2.69-6-6-6m8 7h3v-2h-3m-2.76 7.71l1.8 1.79l1.41-1.41l-1.79-1.8M20.45 5l-1.41-1.4l-1.8 1.79l1.42 1.42M13 1h-2v3h2M6.76 5.39L4.96 3.6L3.55 5l1.79 1.81zM1 13h3v-2H1m12 9h-2v3h2"
11
+ />
12
+ </IconBase>
13
+ ),
14
+ );
15
+
16
+ IconSun.displayName = 'IconSun';
@@ -0,0 +1,87 @@
1
+ import * as ToggleGroup from '@radix-ui/react-toggle-group';
2
+ import { motion } from 'framer-motion';
3
+ import { cn } from '../../utils';
4
+ import { tabTransition } from '../../utils/constants';
5
+ import { IconMoon } from '../icons/icon-moon';
6
+ import { IconSun } from '../icons/icon-sun';
7
+ import { Tooltip } from '../tooltip';
8
+
9
+ interface ThemeToggleGroupProps {
10
+ active: 'light' | 'dark';
11
+ onChange: (theme: 'light' | 'dark') => unknown;
12
+ }
13
+
14
+ export const ThemeToggleGroup = ({
15
+ active,
16
+ onChange,
17
+ }: ThemeToggleGroupProps) => {
18
+ return (
19
+ <ToggleGroup.Root
20
+ aria-label="Color Scheme"
21
+ className="inline-block items-center bg-slate-2 border border-slate-6 rounded-md overflow-hidden h-[36px]"
22
+ id="theme-toggle"
23
+ onValueChange={(value) => {
24
+ if (value) onChange(value as 'light' | 'dark');
25
+ }}
26
+ type="single"
27
+ value={active}
28
+ >
29
+ <ToggleGroup.Item value="light">
30
+ <Tooltip>
31
+ <Tooltip.Trigger asChild>
32
+ <div
33
+ className={cn(
34
+ 'relative px-3 py-2 transition duration-200 ease-in-out hover:text-slate-12',
35
+ {
36
+ 'text-slate-11': active !== 'light',
37
+ 'text-slate-12': active === 'light',
38
+ },
39
+ )}
40
+ >
41
+ {active === 'light' && (
42
+ <motion.span
43
+ animate={{ opacity: 1 }}
44
+ className="absolute top-0 right-0 bottom-0 left-0 bg-slate-4"
45
+ exit={{ opacity: 0 }}
46
+ initial={{ opacity: 0 }}
47
+ layoutId="topbar-theme-tabs"
48
+ transition={tabTransition}
49
+ />
50
+ )}
51
+ <IconSun />
52
+ </div>
53
+ </Tooltip.Trigger>
54
+ <Tooltip.Content>Light</Tooltip.Content>
55
+ </Tooltip>
56
+ </ToggleGroup.Item>
57
+ <ToggleGroup.Item value="dark">
58
+ <Tooltip>
59
+ <Tooltip.Trigger asChild>
60
+ <div
61
+ className={cn(
62
+ 'relative px-3 py-2 transition duration-200 ease-in-out hover:text-slate-12',
63
+ {
64
+ 'text-slate-11': active !== 'dark',
65
+ 'text-slate-12': active === 'dark',
66
+ },
67
+ )}
68
+ >
69
+ {active === 'dark' && (
70
+ <motion.span
71
+ animate={{ opacity: 1 }}
72
+ className="absolute top-0 right-0 bottom-0 left-0 bg-slate-4"
73
+ exit={{ opacity: 0 }}
74
+ initial={{ opacity: 0 }}
75
+ layoutId="topbar-theme-tabs"
76
+ transition={tabTransition}
77
+ />
78
+ )}
79
+ <IconMoon />
80
+ </div>
81
+ </Tooltip.Trigger>
82
+ <Tooltip.Content>Dark</Tooltip.Content>
83
+ </Tooltip>
84
+ </ToggleGroup.Item>
85
+ </ToggleGroup.Root>
86
+ );
87
+ };
@@ -5,6 +5,8 @@ import {
5
5
  renderEmailByPath,
6
6
  } from '../actions/render-email-by-path';
7
7
  import { isBuilding } from '../app/env';
8
+ import { useEmails } from '../contexts/emails';
9
+ import { containsEmailTemplate } from '../utils/contains-email-template';
8
10
  import { useHotreload } from './use-hot-reload';
9
11
 
10
12
  export const useEmailRenderingResult = (
@@ -15,6 +17,8 @@ export const useEmailRenderingResult = (
15
17
  serverEmailRenderedResult,
16
18
  );
17
19
 
20
+ const { emailsDirectoryMetadata } = useEmails();
21
+
18
22
  if (!isBuilding) {
19
23
  // eslint-disable-next-line react-hooks/rules-of-hooks
20
24
  useHotreload(async (changes) => {
@@ -25,10 +29,15 @@ export const useEmailRenderingResult = (
25
29
  // going to be equivalent to the slug
26
30
  change.filename;
27
31
 
32
+ if (
33
+ containsEmailTemplate(slugForChangedEmail, emailsDirectoryMetadata)
34
+ ) {
35
+ continue;
36
+ }
37
+
28
38
  const pathForChangedEmail =
29
39
  await getEmailPathFromSlug(slugForChangedEmail);
30
40
 
31
- // We always render the email template here so that we can allow
32
41
  const newRenderingResult = await renderEmailByPath(
33
42
  pathForChangedEmail,
34
43
  true,
@@ -0,0 +1,35 @@
1
+ import * as React from 'react';
2
+
3
+ export function useIframeColorScheme(
4
+ iframeRef: React.RefObject<HTMLIFrameElement | null>,
5
+ theme: string,
6
+ ) {
7
+ React.useEffect(() => {
8
+ const iframe = iframeRef.current;
9
+
10
+ if (!iframe) return;
11
+
12
+ // Set on iframe element itself
13
+ iframe.style.colorScheme = theme;
14
+
15
+ // Set on iframe's document if available
16
+ if (iframe.contentDocument) {
17
+ iframe.contentDocument.documentElement.style.colorScheme = theme;
18
+ iframe.contentDocument.body.style.colorScheme = theme;
19
+ }
20
+
21
+ // Ensure styles are applied after it loads
22
+ const handleLoad = () => {
23
+ if (iframe.contentDocument) {
24
+ iframe.contentDocument.documentElement.style.colorScheme = theme;
25
+ iframe.contentDocument.body.style.colorScheme = theme;
26
+ }
27
+ };
28
+
29
+ iframe.addEventListener('load', handleLoad);
30
+
31
+ return () => {
32
+ iframe.removeEventListener('load', handleLoad);
33
+ };
34
+ }, [theme, iframeRef]);
35
+ }
@@ -1,3 +1,3 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
- exports[`getEmailComponent() > with a demo email template 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="/static/vercel-logo.png"/><link rel="preload" as="image" href="/static/vercel-user.png"/><link rel="preload" as="image" href="/static/vercel-arrow.png"/><link rel="preload" as="image" href="/static/vercel-team.png"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><!--$--></head><body style="background-color:rgb(255,255,255);margin-top:auto;margin-bottom:auto;margin-left:auto;margin-right:auto;font-family:ui-sans-serif, system-ui, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;padding-left:0.5rem;padding-right:0.5rem"><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0">Join Alan on Vercel<div> ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏</div></div><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="border-width:1px;border-style:solid;border-color:rgb(234,234,234);border-radius:0.25rem;margin-top:40px;margin-bottom:40px;margin-left:auto;margin-right:auto;padding:20px;max-width:465px"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:32px"><tbody><tr><td><img alt="Vercel Logo" height="37" src="/static/vercel-logo.png" style="margin-top:0px;margin-bottom:0px;margin-left:auto;margin-right:auto;display:block;outline:none;border:none;text-decoration:none" width="40"/></td></tr></tbody></table><h1 style="color:rgb(0,0,0);font-size:24px;font-weight:400;text-align:center;padding:0px;margin-top:30px;margin-bottom:30px;margin-left:0px;margin-right:0px">Join <strong>Enigma</strong> on <strong>Vercel</strong></h1><p style="color:rgb(0,0,0);font-size:14px;line-height:24px;margin-bottom:16px;margin-top:16px">Hello <!-- -->alanturing<!-- -->,</p><p style="color:rgb(0,0,0);font-size:14px;line-height:24px;margin-bottom:16px;margin-top:16px"><strong>Alan</strong> (<a href="mailto:alan.turing@example.com" style="color:rgb(37,99,235);text-decoration-line:none" target="_blank">alan.turing@example.com</a>) has invited you to the <strong>Enigma</strong> team on<!-- --> <strong>Vercel</strong>.</p><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td align="right" data-id="__react-email-column"><img alt="alanturing&#x27;s profile picture" height="64" src="/static/vercel-user.png" style="border-radius:9999px;display:block;outline:none;border:none;text-decoration:none" width="64"/></td><td align="center" data-id="__react-email-column"><img alt="Arrow indicating invitation" height="9" src="/static/vercel-arrow.png" style="display:block;outline:none;border:none;text-decoration:none" width="12"/></td><td align="left" data-id="__react-email-column"><img alt="Enigma team logo" height="64" src="/static/vercel-team.png" style="border-radius:9999px;display:block;outline:none;border:none;text-decoration:none" width="64"/></td></tr></tbody></table></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="text-align:center;margin-top:32px;margin-bottom:32px"><tbody><tr><td><a href="https://vercel.com" style="background-color:rgb(0,0,0);border-radius:0.25rem;color:rgb(255,255,255);font-size:12px;font-weight:600;text-decoration-line:none;text-align:center;padding-left:1.25rem;padding-right:1.25rem;padding-top:0.75rem;padding-bottom:0.75rem;line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;padding:12px 20px 12px 20px" target="_blank"><span><!--[if mso]><i style="mso-font-width:500%;mso-text-raise:18" hidden>&#8202;&#8202;</i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Join the team</span><span><!--[if mso]><i style="mso-font-width:500%" hidden>&#8202;&#8202;&#8203;</i><![endif]--></span></a></td></tr></tbody></table><p style="color:rgb(0,0,0);font-size:14px;line-height:24px;margin-bottom:16px;margin-top:16px">or copy and paste this URL into your browser:<!-- --> <a href="https://vercel.com" style="color:rgb(37,99,235);text-decoration-line:none" target="_blank">https://vercel.com</a></p><hr style="border-width:1px;border-style:solid;border-color:rgb(234,234,234);margin-top:26px;margin-bottom:26px;margin-left:0px;margin-right:0px;width:100%;border:none;border-top:1px solid #eaeaea"/><p style="color:rgb(102,102,102);font-size:12px;line-height:24px;margin-bottom:16px;margin-top:16px">This invitation was intended for<!-- --> <span style="color:rgb(0,0,0)">alanturing</span>. This invite was sent from <span style="color:rgb(0,0,0)">204.13.186.218</span> <!-- -->located in<!-- --> <span style="color:rgb(0,0,0)">São Paulo, Brazil</span>. If you were not expecting this invitation, you can ignore this email. If you are concerned about your account&#x27;s safety, please reply to this email to get in touch with us.</p></td></tr></tbody></table><!--/$--></body></html>"`;
3
+ exports[`getEmailComponent() > with a demo email template 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="/static/vercel-logo.png"/><link rel="preload" as="image" href="/static/vercel-user.png"/><link rel="preload" as="image" href="/static/vercel-arrow.png"/><link rel="preload" as="image" href="/static/vercel-team.png"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><!--$--></head><body style="margin-left:auto;margin-right:auto;margin-top:auto;margin-bottom:auto;background-color:rgb(255,255,255);padding-left:0.5rem;padding-right:0.5rem;font-family:ui-sans-serif, system-ui, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;"><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0">Join Alan on Vercel<div> ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏</div></div><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-left:auto;margin-right:auto;margin-top:40px;margin-bottom:40px;max-width:465px;border-radius:0.25rem;border-width:1px;border-color:rgb(234,234,234);border-style:solid;padding:20px"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:32px"><tbody><tr><td><img alt="Vercel Logo" height="37" src="/static/vercel-logo.png" style="margin-left:auto;margin-right:auto;margin-top:0px;margin-bottom:0px;display:block;outline:none;border:none;text-decoration:none" width="40"/></td></tr></tbody></table><h1 style="margin-left:0px;margin-right:0px;margin-top:30px;margin-bottom:30px;padding:0px;text-align:center;font-weight:400;font-size:24px;color:rgb(0,0,0)">Join <strong>Enigma</strong> on <strong>Vercel</strong></h1><p style="font-size:14px;color:rgb(0,0,0);line-height:24px;margin-bottom:16px;margin-top:16px">Hello <!-- -->alanturing<!-- -->,</p><p style="font-size:14px;color:rgb(0,0,0);line-height:24px;margin-bottom:16px;margin-top:16px"><strong>Alan</strong> (<a href="mailto:alan.turing@example.com" style="color:rgb(37,99,235);text-decoration-line:none" target="_blank">alan.turing@example.com</a>) has invited you to the <strong>Enigma</strong> team on<!-- --> <strong>Vercel</strong>.</p><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td align="right" data-id="__react-email-column"><img alt="alanturing&#x27;s profile picture" height="64" src="/static/vercel-user.png" style="border-radius:9999px;display:block;outline:none;border:none;text-decoration:none" width="64"/></td><td align="center" data-id="__react-email-column"><img alt="Arrow indicating invitation" height="9" src="/static/vercel-arrow.png" style="display:block;outline:none;border:none;text-decoration:none" width="12"/></td><td align="left" data-id="__react-email-column"><img alt="Enigma team logo" height="64" src="/static/vercel-team.png" style="border-radius:9999px;display:block;outline:none;border:none;text-decoration:none" width="64"/></td></tr></tbody></table></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:32px;margin-bottom:32px;text-align:center"><tbody><tr><td><a href="https://vercel.com" style="border-radius:0.25rem;background-color:rgb(0,0,0);padding-left:1.25rem;padding-right:1.25rem;padding-top:0.75rem;padding-bottom:0.75rem;text-align:center;font-weight:600;font-size:12px;color:rgb(255,255,255);text-decoration-line:none;line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;padding:12px 20px 12px 20px" target="_blank"><span><!--[if mso]><i style="mso-font-width:500%;mso-text-raise:18" hidden>&#8202;&#8202;</i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Join the team</span><span><!--[if mso]><i style="mso-font-width:500%" hidden>&#8202;&#8202;&#8203;</i><![endif]--></span></a></td></tr></tbody></table><p style="font-size:14px;color:rgb(0,0,0);line-height:24px;margin-bottom:16px;margin-top:16px">or copy and paste this URL into your browser:<!-- --> <a href="https://vercel.com" style="color:rgb(37,99,235);text-decoration-line:none" target="_blank">https://vercel.com</a></p><hr style="margin-left:0px;margin-right:0px;margin-top:26px;margin-bottom:26px;width:100%;border-width:1px;border-color:rgb(234,234,234);border-style:solid;border:none;border-top:1px solid #eaeaea"/><p style="color:rgb(102,102,102);font-size:12px;line-height:24px;margin-bottom:16px;margin-top:16px">This invitation was intended for<!-- --> <span style="color:rgb(0,0,0)">alanturing</span>. This invite was sent from <span style="color:rgb(0,0,0)">204.13.186.218</span> <!-- -->located in<!-- --> <span style="color:rgb(0,0,0)">São Paulo, Brazil</span>. If you were not expecting this invitation, you can ignore this email. If you are concerned about your account&#x27;s safety, please reply to this email to get in touch with us.</p></td></tr></tbody></table><!--/$--></body></html>"`;
@@ -0,0 +1,86 @@
1
+ import path from 'node:path';
2
+ import { containsEmailTemplate } from './contains-email-template';
3
+
4
+ test('containsEmailTemplate()', async () => {
5
+ const emailsDirectoryPath = path.resolve(
6
+ __dirname,
7
+ '../../../../apps/demo/emails',
8
+ );
9
+ const directory = {
10
+ absolutePath: emailsDirectoryPath,
11
+ directoryName: 'emails',
12
+ relativePath: '',
13
+ emailFilenames: [],
14
+ subDirectories: [
15
+ {
16
+ absolutePath: `${emailsDirectoryPath}/magic-links`,
17
+ directoryName: 'magic-links',
18
+ relativePath: 'magic-links',
19
+ emailFilenames: [
20
+ 'aws-verify-email',
21
+ 'linear-login-code',
22
+ 'notion-magic-link',
23
+ 'plaid-verify-identity',
24
+ 'raycast-magic-link',
25
+ 'slack-confirm',
26
+ ],
27
+ subDirectories: [],
28
+ },
29
+ {
30
+ absolutePath: `${emailsDirectoryPath}/newsletters`,
31
+ directoryName: 'newsletters',
32
+ relativePath: 'newsletters',
33
+ emailFilenames: [
34
+ 'codepen-challengers',
35
+ 'google-play-policy-update',
36
+ 'stack-overflow-tips',
37
+ ],
38
+ subDirectories: [],
39
+ },
40
+ {
41
+ absolutePath: `${emailsDirectoryPath}/notifications`,
42
+ directoryName: 'notifications',
43
+ relativePath: 'notifications',
44
+ emailFilenames: [
45
+ 'github-access-token',
46
+ 'papermark-year-in-review',
47
+ 'vercel-invite-user',
48
+ 'yelp-recent-login',
49
+ ],
50
+ subDirectories: [],
51
+ },
52
+ {
53
+ absolutePath: `${emailsDirectoryPath}/receipts`,
54
+ directoryName: 'receipts',
55
+ relativePath: 'receipts',
56
+ emailFilenames: ['apple-receipt', 'nike-receipt'],
57
+ subDirectories: [],
58
+ },
59
+ {
60
+ absolutePath: `${emailsDirectoryPath}/reset-password`,
61
+ directoryName: 'reset-password',
62
+ relativePath: 'reset-password',
63
+ emailFilenames: ['dropbox-reset-password', 'twitch-reset-password'],
64
+ subDirectories: [],
65
+ },
66
+ {
67
+ absolutePath: `${emailsDirectoryPath}/reviews`,
68
+ directoryName: 'reviews',
69
+ relativePath: 'reviews',
70
+ emailFilenames: ['airbnb-review', 'amazon-review'],
71
+ subDirectories: [],
72
+ },
73
+ {
74
+ absolutePath: `${emailsDirectoryPath}/welcome`,
75
+ directoryName: 'welcome',
76
+ relativePath: 'welcome',
77
+ emailFilenames: ['koala-welcome', 'netlify-welcome', 'stripe-welcome'],
78
+ subDirectories: [],
79
+ },
80
+ ],
81
+ };
82
+ expect(containsEmailTemplate('welcome/koala-welcome', directory)).toBe(true);
83
+ expect(containsEmailTemplate('welcome/missing-template', directory)).toBe(
84
+ false,
85
+ );
86
+ });
@@ -0,0 +1,23 @@
1
+ import type { EmailsDirectory } from './get-emails-directory-metadata';
2
+
3
+ export const containsEmailTemplate = (
4
+ relativeEmailPath: string,
5
+ directory: EmailsDirectory,
6
+ ) => {
7
+ const remainingSegments = relativeEmailPath
8
+ .replace(directory.relativePath, '')
9
+ .split('/')
10
+ .filter(Boolean);
11
+ if (remainingSegments.length === 1) {
12
+ const emailFilename = remainingSegments[0]!;
13
+ return directory.emailFilenames.includes(emailFilename);
14
+ }
15
+ const subDirectory = directory.subDirectories.find(
16
+ (sub) => sub.relativePath === remainingSegments[0],
17
+ );
18
+ if (subDirectory === undefined) {
19
+ return false;
20
+ }
21
+
22
+ return containsEmailTemplate(relativeEmailPath, subDirectory);
23
+ };
@@ -28,7 +28,7 @@ export const renderingUtilitiesExporter = (emailTemplates: string[]) => ({
28
28
  async ({ path: pathToFile }) => {
29
29
  return {
30
30
  contents: `${await fs.readFile(pathToFile, 'utf8')};
31
- export { render } from 'react-email-module-that-will-export-render'
31
+ export { render, pretty } from 'react-email-module-that-will-export-render'
32
32
  export { createElement as reactEmailCreateReactElement } from 'react';
33
33
  `,
34
34
  loader: path.extname(pathToFile).slice(1) as Loader,
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import type { render } from '@react-email/components';
2
+ import type { pretty, render } from '@react-email/components';
3
3
  import { type BuildFailure, type OutputFile, build } from 'esbuild';
4
4
  import type React from 'react';
5
5
  import type { RawSourceMap } from 'source-map-js';
@@ -14,6 +14,7 @@ import type { ErrorObject } from './types/error-object';
14
14
  const EmailComponentModule = z.object({
15
15
  default: z.any(),
16
16
  render: z.function(),
17
+ pretty: z.function(),
17
18
  reactEmailCreateReactElement: z.function(),
18
19
  });
19
20
 
@@ -27,6 +28,8 @@ export const getEmailComponent = async (
27
28
 
28
29
  render: typeof render;
29
30
 
31
+ pretty: typeof pretty;
32
+
30
33
  sourceMapToOriginalFile: RawSourceMap;
31
34
  }
32
35
  | { error: ErrorObject }
@@ -106,11 +109,27 @@ export const getEmailComponent = async (
106
109
  };
107
110
  }
108
111
 
112
+ if (typeof parseResult.data.default !== 'function') {
113
+ return {
114
+ error: improveErrorWithSourceMap(
115
+ new Error(
116
+ `The email component at ${emailPath} does not contain a default exported function`,
117
+ {
118
+ cause: parseResult.error,
119
+ },
120
+ ),
121
+ emailPath,
122
+ sourceMapToEmail,
123
+ ),
124
+ };
125
+ }
126
+
109
127
  const { data: componentModule } = parseResult;
110
128
 
111
129
  return {
112
130
  emailComponent: componentModule.default as EmailComponent,
113
131
  render: componentModule.render as typeof render,
132
+ pretty: componentModule.pretty as typeof pretty,
114
133
  createElement:
115
134
  componentModule.reactEmailCreateReactElement as typeof React.createElement,
116
135
 
@@ -12,7 +12,7 @@ const isFileAnEmail = (fullPath: string): boolean => {
12
12
  if (!['.js', '.tsx', '.jsx'].includes(ext)) return false;
13
13
 
14
14
  // This is to avoid a possible race condition where the file doesn't exist anymore
15
- // once we are checking if it is an actual email, this couuld cause issues that
15
+ // once we are checking if it is an actual email, this could cause issues that
16
16
  // would be very hard to debug and find out the why of it happening.
17
17
  if (!fs.existsSync(fullPath)) {
18
18
  return false;