diff --git a/.gitignore b/.gitignore
index 7139420e..8054e1a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
.moon/cache
.eslintcache
+.compiled
.rendered
.test
node_modules
diff --git a/apps/web/.vitepress/sidebar.mts b/apps/web/.vitepress/sidebar.mts
index 769ff870..6b8caed5 100644
--- a/apps/web/.vitepress/sidebar.mts
+++ b/apps/web/.vitepress/sidebar.mts
@@ -38,6 +38,7 @@ export const sidebar = [
items: [
{ text: 'Introduction', link: '/docs/introduction' },
{ text: 'Quick Start', link: '/docs/quick-start' },
+ { text: 'Recipes', link: '/docs/recipes' },
{ text: 'Email Providers', link: '/docs/email-providers' },
{ text: 'Email Samples', link: 'https://samples.jsx.email' },
{ text: 'FAQ', link: '/docs/faq' },
diff --git a/apps/web/.vitepress/theme/custom.css b/apps/web/.vitepress/theme/custom.css
index 3dd0556d..bd42828d 100644
--- a/apps/web/.vitepress/theme/custom.css
+++ b/apps/web/.vitepress/theme/custom.css
@@ -221,3 +221,13 @@ img.clients {
height: auto;
}
}
+
+table.recipes {
+ border-collapse: collapse;
+}
+
+table.recipes td {
+ border: 0 !important;
+ padding: 0 10px 0 0 !important;
+ white-space: nowrap;
+}
diff --git a/docs/core/compile.md b/docs/core/compile.md
new file mode 100644
index 00000000..2d0e6514
--- /dev/null
+++ b/docs/core/compile.md
@@ -0,0 +1,77 @@
+---
+title: 'Compile'
+description: 'Compile jsx-email templates into a bundle'
+params: -D
+slug: render
+type: package
+---
+
+
+
+
+
+## Usage
+
+```jsx
+import { readFile } from 'node:fs/promises;';
+import { resolve } from 'node:path';
+
+import { compile } from 'jsx-email';
+
+const templatePath = resolve(__dirname, './emails/Batman');
+const outputDir = resolve(__dirname, '.compiled');
+
+const compiledFiles = await compile({ files: [templatePath], hashFiles: false, outDir });
+```
+
+::: tip
+Once compiled into a bundle, the file can be imported and passed to render such like:
+
+```jsx
+import { Template } from './.compiled/batman.js';
+
+import { render } from 'jsx-email';
+
+const html = render();
+```
+
+Note that whether or not to use a file extension in the import depends on your project's settings. When using TypeScript you may have to adjust types to avoid errors, using this method.
+:::
+
+## Method Options
+
+```ts
+export interface Options {
+ disableDefaultStyle?: boolean;
+ inlineCss?: boolean;
+ minify?: boolean;
+ plainText?: boolean | PlainTextOptions;
+ pretty?: boolean;
+}
+```
+
+### Options
+
+```ts
+files: string[];
+```
+
+An array of absolute paths for JSX/TSX template files to compile
+
+```ts
+hashFiles?: boolean;
+```
+
+Default: true. If `true`, adds the build hash to compiled file names. Set this to `false` if hashing and unique output filenames aren't needed.
+
+```ts
+outDir: string;
+```
+
+An absolute path to output the compiled file(s)
+
+```ts
+writeMeta?: boolean;
+```
+
+If `true`, writes the ESBuild metadata for the compiled file(s)
diff --git a/docs/core/config.md b/docs/core/config.md
index 1546e203..10bcf847 100644
--- a/docs/core/config.md
+++ b/docs/core/config.md
@@ -86,7 +86,7 @@ esbuild?: {
_Optional_. Default: `undefined`. Allows the configuration file to specify [ESBuild Plugins](https://esbuild.github.io/plugins) to use during the initial transform from JSX/TSX to JavaScript for the `build` and `preview` commands.
-::: note
+::: tip
ESBuild plugins are only run when using the CLI's `build` or `preview` commands. ESBuild, and by extension the `esbuild` configuration option, are not used when using `render` directly in code
:::
diff --git a/docs/recipes.md b/docs/recipes.md
new file mode 100644
index 00000000..3cdcf909
--- /dev/null
+++ b/docs/recipes.md
@@ -0,0 +1,31 @@
+---
+title: 'Recipes'
+description: 'Recipes for jsx-email'
+---
+
+## 🧁 jsx-email Recipes
+
+Recipes are actual, working examples of jsx-email features and techniques that can be copied and pasted. They help users to understand how a thing works outside of the documentation.
+
+### Available recipes:
+
+
+
+### External Recipes
+
+
+
+
+Don't see a recipe that you'd like to? [Open an Issue!](https://github.com/shellscape/jsx-email/issues/new?assignees=&labels=&projects=&template=DOCS.md)
diff --git a/packages/jsx-email/src/cli/commands/build.mts b/packages/jsx-email/src/cli/commands/build.mts
index c17b3223..8cc17d9b 100644
--- a/packages/jsx-email/src/cli/commands/build.mts
+++ b/packages/jsx-email/src/cli/commands/build.mts
@@ -5,21 +5,18 @@ import { dirname, basename, extname, join, resolve, win32, posix } from 'path';
import { pathToFileURL } from 'url';
import chalk from 'chalk';
-import esbuild from 'esbuild';
import globby from 'globby';
-// @ts-ignore
-// eslint-disable-next-line
-import { render } from 'jsx-email';
import micromatch from 'micromatch';
import { isWindows } from 'std-env';
import type { Output as Infer } from 'valibot';
import { parse as assert, boolean, object, optional, string } from 'valibot';
import { log } from '../../log.js';
-import { formatBytes, gmailByteLimit, originalCwd } from '../helpers.mjs';
+import { formatBytes, gmailByteLimit } from '../helpers.mjs';
+import { compile } from '../../renderer/compile.js';
+import { render } from '../../renderer/render.js';
import type { CommandFn, TemplateFn } from './types.mjs';
-import { loadConfig } from '../../config.js';
const BuildCommandOptionsStruct = object({
exclude: optional(string()),
@@ -40,6 +37,11 @@ interface BuildCommandOptionsInternal extends BuildCommandOptions {
showStats?: boolean;
}
+interface BuildTemplateParams {
+ buildOptions: BuildCommandOptionsInternal;
+ targetPath: string;
+}
+
interface BuildOptions {
argv: BuildCommandOptions;
outputBasePath?: string;
@@ -55,12 +57,6 @@ export interface BuildResult {
writePath: string;
}
-interface CompileOptions {
- files: string[];
- outDir: string;
- writeMeta: boolean;
-}
-
export const help = chalk`
{blue email build}
@@ -143,51 +139,6 @@ export const build = async (options: BuildOptions): Promise => {
};
};
-const compile = async (options: CompileOptions) => {
- const config = await loadConfig();
-
- const { files, outDir, writeMeta } = options;
- const { metafile } = await esbuild.build({
- bundle: true,
- define: {
- 'import.meta.isJsxEmailPreview': JSON.stringify(globalThis.isJsxEmailPreview || false)
- },
- entryNames: '[dir]/[name]-[hash]',
- entryPoints: files,
- jsx: 'automatic',
- logLevel: 'error',
- metafile: true,
- outdir: outDir,
- platform: 'node',
- write: true,
- ...config.esbuild
- });
-
- const affectedFiles = Object.keys(metafile.outputs);
- const affectedPaths = affectedFiles.map((file) => resolve('/', file));
-
- if (metafile && writeMeta) {
- const { outputs } = metafile;
- const ops = Object.entries(outputs).map(async ([path]) => {
- const fileName = basename(path, extname(path));
- const metaPath = join(dirname(path), `${fileName}.meta.json`);
- const writePath = resolve(originalCwd, metaPath);
- const json = JSON.stringify(metafile);
-
- log.debug('meta writePath:', writePath);
- await writeFile(writePath, json, 'utf8');
- });
- await Promise.all(ops);
- }
-
- return affectedPaths;
-};
-
-interface BuildTemplateParams {
- buildOptions: BuildCommandOptionsInternal;
- targetPath: string;
-}
-
export const buildTemplates = async ({ targetPath, buildOptions }: BuildTemplateParams) => {
const esbuildOutPath = await getTempPath('build');
diff --git a/packages/jsx-email/src/cli/helpers.mts b/packages/jsx-email/src/cli/helpers.mts
index 682aa77e..897cc11a 100644
--- a/packages/jsx-email/src/cli/helpers.mts
+++ b/packages/jsx-email/src/cli/helpers.mts
@@ -3,6 +3,8 @@ import prettyBytes from 'pretty-bytes';
import { buildTemplates } from './commands/build.mjs';
+export { originalCwd } from '../helpers.js';
+
interface BuildForPreviewParams {
buildPath: string;
exclude?: string;
@@ -14,9 +16,6 @@ interface BuildForPreviewParams {
export const gmailByteLimit = 102e3;
export const gmailBytesSafe = 102e3 - 20e3;
-// Note: after server start we change the root directory to trick vite
-export const originalCwd = process.cwd();
-
export const buildForPreview = async ({
buildPath,
exclude,
diff --git a/packages/jsx-email/src/declarations.d.ts b/packages/jsx-email/src/declarations.d.ts
deleted file mode 100644
index 95bf3610..00000000
--- a/packages/jsx-email/src/declarations.d.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-/* eslint-disable no-var, vars-on-top */
-
-declare global {
- namespace globalThis {
- var isJsxEmailPreview: boolean;
- }
-
- interface ImportMeta {
- isJsxEmailPreview: boolean;
- }
-}
-
-export {};
diff --git a/packages/jsx-email/src/helpers.ts b/packages/jsx-email/src/helpers.ts
new file mode 100644
index 00000000..4eabfbc2
--- /dev/null
+++ b/packages/jsx-email/src/helpers.ts
@@ -0,0 +1,2 @@
+// Note: after server start we change the root directory to trick vite
+export const originalCwd = process.cwd();
diff --git a/packages/jsx-email/src/index.ts b/packages/jsx-email/src/index.ts
index ce73e811..b5db180b 100644
--- a/packages/jsx-email/src/index.ts
+++ b/packages/jsx-email/src/index.ts
@@ -1,3 +1,5 @@
+import './helpers.js';
+
// components
export * from './components/background.js';
export * from './components/body.js';
@@ -26,6 +28,7 @@ export * from './components/text.js';
// renderer
export * from './renderer/compat/context.js';
export * from './renderer/compat/hooks.js';
+export * from './renderer/compile.js';
export * from './renderer/jsx-to-string.js';
export * from './renderer/render.js';
export { useData } from './renderer/suspense.js';
diff --git a/packages/jsx-email/src/renderer/compile.ts b/packages/jsx-email/src/renderer/compile.ts
new file mode 100644
index 00000000..4163b0a7
--- /dev/null
+++ b/packages/jsx-email/src/renderer/compile.ts
@@ -0,0 +1,86 @@
+import { readFile, writeFile } from 'node:fs/promises';
+import { dirname, basename, extname, join, resolve } from 'path';
+
+import esbuild from 'esbuild';
+
+import { loadConfig } from '../config.js';
+import { log } from '../log.js';
+
+interface CompileOptions {
+ /**
+ * @desc an array of absolute paths for JSX/TSX template files to compile
+ */
+ files: string[];
+ /**
+ * @desc Default: true. If true, adds the build hash to compiled file names.
+ */
+ hashFiles?: boolean;
+ /**
+ * @desc the path to output the compiled file(s)
+ */
+ outDir: string;
+ /**
+ * @desc If true, writes the ESBuild metadata for the compiled file(s)
+ */
+ writeMeta?: boolean;
+}
+
+// Note: after server start we change the root directory to trick vite
+const originalCwd = process.cwd();
+
+const cssPlugin: esbuild.Plugin = {
+ name: 'jsx-email/css-plugin',
+ setup(builder) {
+ builder.onLoad({ filter: /\.css$/ }, async (args) => {
+ const buffer = await readFile(args.path);
+ const css = await esbuild.transform(buffer, { loader: 'css', minify: false });
+ return { contents: css.code, loader: 'text' };
+ });
+ }
+};
+
+/**
+ * @desc Compiles a JSX/TSX template file using esbuild
+ * @param options CompileOptions
+ * @returns string[] An array of files affected by the compilation
+ */
+export const compile = async (options: CompileOptions) => {
+ const config = await loadConfig();
+
+ const { files, hashFiles = true, outDir, writeMeta = false } = options;
+ const { metafile } = await esbuild.build({
+ bundle: true,
+ define: {
+ 'import.meta.isJsxEmailPreview': JSON.stringify(globalThis.isJsxEmailPreview || false)
+ },
+ entryNames: hashFiles ? '[dir]/[name]-[hash]' : '[dir]/[name]',
+ entryPoints: files,
+ jsx: 'automatic',
+ logLevel: 'error',
+ metafile: true,
+ outdir: outDir,
+ platform: 'node',
+ plugins: [cssPlugin],
+ write: true,
+ ...config.esbuild
+ });
+
+ const affectedFiles = Object.keys(metafile.outputs);
+ const affectedPaths = affectedFiles.map((file) => resolve('/', file));
+
+ if (metafile && writeMeta) {
+ const { outputs } = metafile;
+ const ops = Object.entries(outputs).map(async ([path]) => {
+ const fileName = basename(path, extname(path));
+ const metaPath = join(dirname(path), `${fileName}.meta.json`);
+ const writePath = resolve(originalCwd, metaPath);
+ const json = JSON.stringify(metafile);
+
+ log.debug('meta writePath:', writePath);
+ await writeFile(writePath, json, 'utf8');
+ });
+ await Promise.all(ops);
+ }
+
+ return affectedPaths;
+};
diff --git a/packages/jsx-email/src/types.ts b/packages/jsx-email/src/types.ts
index bc81f76c..4d21a06e 100644
--- a/packages/jsx-email/src/types.ts
+++ b/packages/jsx-email/src/types.ts
@@ -26,3 +26,14 @@ export interface RenderOptions {
export interface ProcessOptions extends Required> {
html: string;
}
+
+declare global {
+ namespace globalThis {
+ // eslint-disable-next-line vars-on-top, no-var
+ var isJsxEmailPreview: boolean;
+ }
+
+ interface ImportMeta {
+ isJsxEmailPreview: boolean;
+ }
+}
diff --git a/packages/jsx-email/test/render/.snapshots/import-css.test.tsx.snap b/packages/jsx-email/test/render/.snapshots/import-css.test.tsx.snap
new file mode 100644
index 00000000..aa7d3313
--- /dev/null
+++ b/packages/jsx-email/test/render/.snapshots/import-css.test.tsx.snap
@@ -0,0 +1,18 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`import css > simple 1`] = `
+"
+
+
+
+
+
+
+
+
+"
+`;
diff --git a/packages/jsx-email/test/render/fixtures/async-template.tsx b/packages/jsx-email/test/render/fixtures/async-template.tsx
index 4e5871d5..b4864fcf 100644
--- a/packages/jsx-email/test/render/fixtures/async-template.tsx
+++ b/packages/jsx-email/test/render/fixtures/async-template.tsx
@@ -1,4 +1,4 @@
-import { Suspense } from 'react';
+import React, { Suspense } from 'react';
import { useData } from '../../../src/renderer/suspense.js';
diff --git a/packages/jsx-email/test/render/fixtures/import-css.css b/packages/jsx-email/test/render/fixtures/import-css.css
new file mode 100644
index 00000000..aa60f61a
--- /dev/null
+++ b/packages/jsx-email/test/render/fixtures/import-css.css
@@ -0,0 +1,3 @@
+body {
+ background: #000;
+}
diff --git a/packages/jsx-email/test/render/fixtures/import-css.tsx b/packages/jsx-email/test/render/fixtures/import-css.tsx
new file mode 100644
index 00000000..fc64c9a8
--- /dev/null
+++ b/packages/jsx-email/test/render/fixtures/import-css.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+
+import css from './import-css.css';
+
+export const Template: React.FC = () => (
+ <>
+
+ >
+);
diff --git a/packages/jsx-email/test/render/import-css.test.tsx b/packages/jsx-email/test/render/import-css.test.tsx
new file mode 100644
index 00000000..4992e9e2
--- /dev/null
+++ b/packages/jsx-email/test/render/import-css.test.tsx
@@ -0,0 +1,20 @@
+import { join, resolve } from 'node:path';
+
+// @ts-ignore
+import React from 'react';
+
+import { compile } from '../../src/renderer/compile.js';
+import { render } from '../../src/renderer/render.js';
+
+describe('import css', () => {
+ it('simple', async () => {
+ const filePath = resolve(__dirname, './fixtures/import-css.js');
+ const outDir = resolve(__dirname, '.compiled');
+
+ await compile({ files: [filePath], hashFiles: false, outDir });
+
+ const { Template } = await import(join(outDir, 'import-css.js'));
+
+ expect(await render(, { minify: false, pretty: true })).toMatchSnapshot();
+ });
+});
diff --git a/packages/jsx-email/tsconfig.json b/packages/jsx-email/tsconfig.json
index 7b4f1df2..03ace29e 100644
--- a/packages/jsx-email/tsconfig.json
+++ b/packages/jsx-email/tsconfig.json
@@ -1,4 +1,4 @@
{
"extends": "../../shared/tsconfig.base.json",
- "include": ["src"]
+ "include": ["src/**/*.ts", "src/**/*.d.ts"]
}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 7d6b79f3..d7abf011 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -2,3 +2,4 @@ packages:
- 'apps/*'
- 'packages/*'
- 'test/*'
+ - '!recipes/*'
diff --git a/recipes/import-css/.gitignore b/recipes/import-css/.gitignore
new file mode 100644
index 00000000..97797f47
--- /dev/null
+++ b/recipes/import-css/.gitignore
@@ -0,0 +1,12 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+node_modules
+
+# env
+.env
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
\ No newline at end of file
diff --git a/recipes/import-css/README.md b/recipes/import-css/README.md
new file mode 100644
index 00000000..0bea2d3a
--- /dev/null
+++ b/recipes/import-css/README.md
@@ -0,0 +1,23 @@
+# 🧁 Importing CSS
+
+If you're using a design system based on Styled Components or similar, and aren't using Tailwind, you may have the need to export that design system as CSS for use in jsx-email` templates. Or, you may be raw-dogging CSS straight up. For either scenario, importing CSS directly may be useful when crafting your email templates. jsx-email ships with the ability to import CSS as a string for use in your templates.
+
+This recipe contains code to demonstrate how.
+
+## Run the Recipe
+
+To run this recipe, please open the terminal or console of your choice, and navigate to the directory this file resides in. Then, run:
+
+```shell
+$ npm i && npm run dev
+```
+
+Once the preview app opens, select the `Import CSS` template and examine the HTML and JSX code tabs to view the source and result of a CSS import.
+
+## Notes
+
+_Caveat: Importing CSS is only supported when using the CLI [`build`](https://jsx.email/docs/core/cli#build) or [`preview`](https://jsx.email/docs/core/cli#preview) commands, or the [`compile`](https://jsx.email/docs/core/render) API method_
+
+It's important to remember that jsx-email templates _are not_ React apps. This is something that often trips up users; where they expect a React app feature, such as importing CSS, to work exactly like a React app. With React, the user is _always_ compiling the app to run it, and React's bundler configurations provide for things like automatic import and injection of CSS.
+
+The `render` method simply renders JSX/TSX; it doesn't perform a bundle or a build, hence traversing imports isn't something that it does. If a use case calls for using the API instead of the CLI and importing CSS is needed, use the `compile` API first, import the `Template` from the compiled file, and pass the `Template` to `render`.
diff --git a/recipes/import-css/package.json b/recipes/import-css/package.json
new file mode 100644
index 00000000..80aa392e
--- /dev/null
+++ b/recipes/import-css/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "email-project",
+ "version": "0.0.0",
+ "private": true,
+ "description": "A simple starter for jsx-email",
+ "scripts": {
+ "build": "email build ./templates",
+ "create": "email create",
+ "dev": "email preview ./templates"
+ },
+ "dependencies": {
+ "jsx-email": "^2.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.0",
+ "react": "^18.2.0",
+ "typescript": "^5.2.2"
+ }
+}
diff --git a/recipes/import-css/templates/email.css b/recipes/import-css/templates/email.css
new file mode 100644
index 00000000..a6a89ef7
--- /dev/null
+++ b/recipes/import-css/templates/email.css
@@ -0,0 +1,3 @@
+body {
+ background: #eee !important;
+}
diff --git a/recipes/import-css/templates/email.tsx b/recipes/import-css/templates/email.tsx
new file mode 100644
index 00000000..f6ed450f
--- /dev/null
+++ b/recipes/import-css/templates/email.tsx
@@ -0,0 +1,93 @@
+import { Body, Button, Container, Head, Hr, Html, Link, Preview, Section, Text } from 'jsx-email';
+
+import css from './email.css';
+
+interface TemplateProps {
+ email: string;
+ name: string;
+}
+
+const main = {
+ backgroundColor: '#f6f9fc',
+ fontFamily:
+ '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif'
+};
+
+const container = {
+ backgroundColor: '#ffffff',
+ margin: '0 auto',
+ marginBottom: '64px',
+ padding: '20px 0 48px'
+};
+
+const box = {
+ padding: '0 48px'
+};
+
+const hr = {
+ borderColor: '#e6ebf1',
+ margin: '20px 0'
+};
+
+const paragraph = {
+ color: '#777',
+ fontSize: '16px',
+ lineHeight: '24px',
+ textAlign: 'left' as const
+};
+
+const anchor = {
+ color: '#777'
+};
+
+const button = {
+ fontWeight: 'bold',
+ padding: '10px',
+ textDecoration: 'none'
+};
+
+export const previewProps: TemplateProps = {
+ email: 'batman@example.com',
+ name: 'Bruce Wayne'
+};
+
+export const templateName = 'Import CSS';
+
+export const Template = ({ email, name }: TemplateProps) => (
+
+
+
+
+
+ This is our email preview text for {name} <{email}>
+
+
+
+
+ This is our email body text
+
+
+
+ This is text content with a{' '}
+
+ link
+
+ .
+
+
+
+
+
+);
diff --git a/recipes/import-css/tsconfig.json b/recipes/import-css/tsconfig.json
new file mode 100644
index 00000000..888d0bfc
--- /dev/null
+++ b/recipes/import-css/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "declaration": true,
+ "declarationMap": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "moduleResolution": "node",
+ "noEmitOnError": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "preserveSymlinks": true,
+ "preserveWatchOutput": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "strictNullChecks": true,
+ "target": "ESNext"
+ },
+ "exclude": ["**/dist", "**/node_modules"],
+ "include": ["templates"]
+}
diff --git a/shared/tsconfig.eslint.json b/shared/tsconfig.eslint.json
index 27148a9c..0a550a7a 100644
--- a/shared/tsconfig.eslint.json
+++ b/shared/tsconfig.eslint.json
@@ -7,6 +7,7 @@
"../**/.eslintrc.js",
"../apps",
"../packages",
+ "../recipes",
"../scripts",
"../shared",
"../test",