Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/esbuild-plugins-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'react-email': minor
'@react-email/ui': minor
---

Use custom esbuild plugins by passing the new `--esbuild-plugins` flag to `email dev`, `email build` and `email export`, so templates that need a custom compile-time transform can be previewed and exported.
9 changes: 9 additions & 0 deletions apps/docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ your email template when you make changes.
<ResponseField name="--port" type="string" default="3000">
Port to run dev server on
</ResponseField>
<ResponseField name="--esbuild-plugins" type="string">
Path to a module whose default export is an array of [esbuild plugins](/guides/esbuild-plugins), or a function returning one, to run when bundling your templates.
</ResponseField>

**F.A.Q**

Expand Down Expand Up @@ -147,6 +150,9 @@ Copies the preview app for onto `.react-email` and builds it.
<ResponseField name="--packageManager" type="string" default="npm">
Package manager to use on the installation of `.react-email`.
</ResponseField>
<ResponseField name="--esbuild-plugins" type="string">
Path to a module whose default export is an array of [esbuild plugins](/guides/esbuild-plugins), or a function returning one, to run when bundling your templates.
</ResponseField>

## `email start`

Expand Down Expand Up @@ -194,6 +200,9 @@ Generates the plain HTML files of your emails into a `out` directory.
Set a custom file extension for rendered templates (for example, `blade.php`). When omitted,
the extension defaults to `.html`, or `.txt` when `--plainText` is enabled.
</ResponseField>
<ResponseField name="--esbuild-plugins" type="string">
Path to a module whose default export is an array of [esbuild plugins](/guides/esbuild-plugins), or a function returning one, to run when bundling your templates.
</ResponseField>

## `email help <cmd>`

Expand Down
3 changes: 2 additions & 1 deletion apps/docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@
"guides/internationalization/react-i18next",
"guides/internationalization/react-intl"
]
}
},
"guides/esbuild-plugins"
]
},
{
Expand Down
115 changes: 115 additions & 0 deletions apps/docs/guides/esbuild-plugins.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
title: 'Custom esbuild plugins'
sidebarTitle: 'esbuild plugins'
description: 'Run your own esbuild plugins when React Email bundles your templates'
'og:image': 'https://react.email/static/covers/react-email.png'
icon: 'puzzle-piece'
---

To preview, build, and export your emails, React Email bundles each template with [esbuild](https://esbuild.github.io/).
The `--esbuild-plugins` option adds your own [esbuild plugins](https://esbuild.github.io/plugins/) to that bundling step.

Use it when a template depends on something only a bundler plugin can provide, such as:

- A compile-time transform
- Imports that need a custom resolver, like virtual modules or data fetched at build time
- File types esbuild doesn't load on its own

## 1. Create a plugins module

Create a module whose default export is an array of esbuild plugins.
It can also export a function that returns the array, sync or async, for plugins that need setup.
The module can be written in JavaScript or TypeScript.

This example lets templates import the latest releases of any GitHub repository, fetched while the template is bundled:

```js esbuild-plugins.mjs
const cache = new Map();

export default [
{
name: 'github-releases',
setup(build) {
build.onResolve({ filter: /^github-releases:/ }, (args) => ({
path: args.path.slice('github-releases:'.length),
namespace: 'github-releases',
}));

build.onLoad(
{ filter: /.*/, namespace: 'github-releases' },
async ({ path: repo }) => {
if (!cache.has(repo)) {
const response = await fetch(
`https://api.github.com/repos/${repo}/releases?per_page=3`,
);
if (!response.ok) {
throw new Error(`Could not fetch releases for ${repo}`);
}
const releases = await response.json();
cache.set(
repo,
releases.map((release) => ({
name: release.name || release.tag_name,
url: release.html_url,
body: release.body ?? '',
})),
);
}

return { contents: JSON.stringify(cache.get(repo)), loader: 'json' };
},
);
},
},
];
```

<Info>
The preview server bundles a template again every time it renders it, so
cache anything expensive your plugins do, like the network request above.
</Info>

## 2. Use it in a template

With the plugin in place, a template can import the releases like any other module:

```jsx emails/whats-new.jsx
import { Button, Heading, Html, Markdown, Section } from 'react-email';
import releases from 'github-releases:resend/react-email';

export default function WhatsNew() {
return (
<Html>
{releases.map((release) => (
<Section key={release.url}>
<Heading as="h2">{release.name}</Heading>
<Markdown>{release.body}</Markdown>
<Button href={release.url}>View on GitHub</Button>
</Section>
))}
</Html>
);
}
```

## 3. Pass the plugins module to the CLI

Pass the module's path to every command that bundles your templates. The path is relative to the directory you run the command from.

```json package.json
{
"scripts": {
"dev": "email dev --esbuild-plugins ./esbuild-plugins.mjs",
"build": "email build --esbuild-plugins ./esbuild-plugins.mjs",
"export": "email export --esbuild-plugins ./esbuild-plugins.mjs"
}
}
```

`email start` doesn't take the option, since it serves the app `email build` already bundled with your plugins.

## Things to know

- **Plugins are loaded once per process.** After editing a working plugins module, restart `email dev` for the changes to apply.
- **Loading errors show in the preview.** If the plugins module fails to load, the preview shows the error, and picks up your fix on the next render without a restart.
- **React Email's own plugins run first.**
8 changes: 8 additions & 0 deletions packages/react-email/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ Starts a local development server that will watch your files and automatically r
npx react-email dev
```

Templates are bundled with [esbuild](https://esbuild.github.io/). To run additional esbuild plugins, for example a compile-time transform your templates depend on, pass `--esbuild-plugins` with the path to a module whose default export is an array of plugins (or a function returning one):

```sh
npx react-email dev --esbuild-plugins ./esbuild-plugins.mjs
```

The same option is accepted by `email build` and `email export`.

### `email export`

Generates the plain HTML files of your emails into a `out` directory.
Expand Down
18 changes: 16 additions & 2 deletions packages/react-email/src/cli/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { createSpinner, stopSpinnerAndPersist } from '../utils/spinner.js';
interface Args {
dir: string;
packageManager?: string;
esbuildPlugins?: string;
}

const dirname = path.dirname(fileURLToPath(import.meta.url));
Expand All @@ -24,8 +25,14 @@ const setNextEnvironmentVariablesForBuild = async (
emailsDirRelativePath: string,
builtPreviewAppPath: string,
usersProjectLocation: string,
esbuildPluginsPath?: string,
) => {
const rootDir = await getTracingRootDir(usersProjectLocation);
const esbuildPluginsAbsolutePath = esbuildPluginsPath
? path
.resolve(usersProjectLocation, esbuildPluginsPath)
.replaceAll('\\', '/')
: undefined;
const nextConfigContents = `
import path from 'path';
const emailsDirRelativePath = path.normalize('${emailsDirRelativePath}');
Expand All @@ -39,13 +46,18 @@ const nextConfig = {
REACT_EMAIL_INTERNAL_EMAILS_DIR_RELATIVE_PATH: emailsDirRelativePath,
REACT_EMAIL_INTERNAL_EMAILS_DIR_ABSOLUTE_PATH: path.resolve(userProjectLocation, emailsDirRelativePath),
REACT_EMAIL_INTERNAL_PREVIEW_SERVER_LOCATION: previewServerLocation,
REACT_EMAIL_INTERNAL_USER_PROJECT_LOCATION: userProjectLocation
REACT_EMAIL_INTERNAL_USER_PROJECT_LOCATION: userProjectLocation,
${
esbuildPluginsAbsolutePath
? `REACT_EMAIL_INTERNAL_ESBUILD_PLUGINS_PATH: '${esbuildPluginsAbsolutePath}',`
: ''
}
},
turbopack: {
root: rootDir,
},
outputFileTracingRoot: rootDir,
serverExternalPackages: ['esbuild'],
serverExternalPackages: ['esbuild', 'jiti'],
typescript: {
ignoreBuildErrors: true
},
Expand Down Expand Up @@ -170,6 +182,7 @@ const updatePackageJson = async (builtUiPath: string) => {
export const build = async ({
dir: emailsDirRelativePath,
packageManager,
esbuildPlugins,
}: Args) => {
if (packageManager) {
console.warn(
Expand Down Expand Up @@ -239,6 +252,7 @@ export const build = async ({
emailsDirRelativePath,
builtPreviewAppPath,
usersProjectLocation,
esbuildPlugins,
);

spinner.setText(
Expand Down
3 changes: 3 additions & 0 deletions packages/react-email/src/cli/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ interface Args {
dir: string;
port: string;
clients?: string;
esbuildPlugins?: string;
}

export const dev = async ({
dir: emailsDirRelativePath,
port,
clients,
esbuildPlugins,
}: Args) => {
try {
if (!fs.existsSync(emailsDirRelativePath)) {
Expand All @@ -23,6 +25,7 @@ export const dev = async ({
emailsDirRelativePath, // defaults to ./emails/static for the static files that are served to the preview
Number.parseInt(port, 10),
clients,
esbuildPlugins,
);

await setupHotreloading(devServer, emailsDirRelativePath);
Expand Down
15 changes: 14 additions & 1 deletion packages/react-email/src/cli/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { glob } from 'glob';
import logSymbols from 'log-symbols';
import normalize from 'normalize-path';
import { inlineCssLoader } from '../utils/esbuild/inline-css-loader.js';
import { loadEsbuildPlugins } from '../utils/esbuild/load-esbuild-plugins.js';
import { renderingUtilitiesExporter } from '../utils/esbuild/renderring-utilities-exporter.js';
import {
type EmailsDirectory,
Expand Down Expand Up @@ -36,6 +37,8 @@ type ExportTemplatesOptions = Options & {
extension?: string;
silent?: boolean;
pretty?: boolean;
/** Path to a module exporting the esbuild plugins to apply when bundling templates */
esbuildPlugins?: string;
};

// Batch so esbuild's Go-side dep graph isn't held for every entry at once.
Expand Down Expand Up @@ -120,6 +123,12 @@ export const exportTemplates = async (
const allTemplates = getEmailTemplatesFromDirectory(emailsDirectoryMetadata);

try {
const userPlugins = options.esbuildPlugins
? await loadEsbuildPlugins(
path.resolve(process.cwd(), options.esbuildPlugins),
)
: [];

for (let i = 0; i < allTemplates.length; i += BUILD_BATCH_SIZE) {
const batch = allTemplates.slice(i, i + BUILD_BATCH_SIZE);
await build({
Expand All @@ -133,7 +142,11 @@ export const exportTemplates = async (
outExtension: { '.js': '.cjs' },
outdir: pathToWhereEmailMarkupShouldBeDumped,
platform: 'node',
plugins: [inlineCssLoader(), renderingUtilitiesExporter(batch)],
plugins: [
inlineCssLoader(),
renderingUtilitiesExporter(batch),
...userPlugins,
],
write: true,
});
await stop();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';

test('email export reports a plugins module that fails to load', {
timeout: 30_000,
}, () => {
const cli = path.resolve(__dirname, '../../index.ts');
const result = spawnSync(
'pnpm',
[
'tsx',
cli,
'export',
'--dir',
path.resolve(__dirname, './emails'),
'--outDir',
path.resolve(__dirname, './out-esbuild-plugins'),
'--esbuild-plugins',
'./does-not-exist.mjs',
],
{ cwd: __dirname, encoding: 'utf8' },
);

expect(result.status).toBe(1);
expect(result.stderr + result.stdout).toContain('Failed to build emails');
expect(result.stderr).toContain('does-not-exist.mjs');
});
31 changes: 29 additions & 2 deletions packages/react-email/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ if (!hasRequiredFlags) {
'Comma-separated list of email clients to show compatibility warnings for (overrides COMPATIBILITY_EMAIL_CLIENTS)',
parseClientsOption,
)
.option(
'--esbuild-plugins <path>',
'Path to a module whose default export is an array of esbuild plugins (or a function returning one) applied when bundling email templates',
)
.action(dev);

program
Expand All @@ -93,6 +97,10 @@ if (!hasRequiredFlags) {
// deprecated
new Option('-p, --packageManager <name>').hideHelp(),
)
.option(
'--esbuild-plugins <path>',
'Path to a module whose default export is an array of esbuild plugins (or a function returning one) applied when bundling email templates',
)
.action(build);

program
Expand Down Expand Up @@ -120,8 +128,27 @@ if (!hasRequiredFlags) {
'To, or not to show a spinner with process information',
false,
)
.action(({ outDir, pretty, plainText, silent, dir: srcDir, extension }) =>
exportTemplates(outDir, srcDir, { silent, plainText, pretty, extension }),
.option(
'--esbuild-plugins <path>',
'Path to a module whose default export is an array of esbuild plugins (or a function returning one) applied when bundling email templates',
)
.action(
({
outDir,
pretty,
plainText,
silent,
dir: srcDir,
extension,
esbuildPlugins,
}) =>
exportTemplates(outDir, srcDir, {
silent,
plainText,
pretty,
extension,
esbuildPlugins,
}),
);

const resend = program.command('resend');
Expand Down
Loading
Loading