Skip to content

Commit

Permalink
feat(jsx-email): import css in templates (#244)
Browse files Browse the repository at this point in the history
  • Loading branch information
shellscape authored Nov 23, 2024
1 parent d731f47 commit 3981f3e
Show file tree
Hide file tree
Showing 27 changed files with 461 additions and 76 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

.moon/cache
.eslintcache
.compiled
.rendered
.test
node_modules
Expand Down
1 change: 1 addition & 0 deletions apps/web/.vitepress/sidebar.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
10 changes: 10 additions & 0 deletions apps/web/.vitepress/theme/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
77 changes: 77 additions & 0 deletions docs/core/compile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
title: 'Compile'
description: 'Compile jsx-email templates into a bundle'
params: -D
slug: render
type: package
---

<!--@include: @/include/header.md-->

<!--@include: @/include/install.md-->

## 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(<Template />);
```

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)
2 changes: 1 addition & 1 deletion docs/core/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
:::

Expand Down
31 changes: 31 additions & 0 deletions docs/recipes.md
Original file line number Diff line number Diff line change
@@ -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:

<table border="0" cellspacing="0" cellpadding="0" class="recipes">
<tr>
<td>🧁</td>
<td>Importing CSS</td>
<td width="100%"><a href="https://github.com/shellscape/jsx-email/tree/main/recipes/import-css">/recipes/import-css</a></td>
</tr>
</table>

### External Recipes

<table border="0" cellspacing="0" cellpadding="0" class="recipes">
<tr>
<td>🧁</td>
<td>Integration with Keycloak(ify)</td>
<td width="100%"><a href="https://github.com/timofei-iatsenko/keycloakify-jsx-emails">github.com/timofei-iatsenko/keycloakify-jsx-emails</a></td>
</tr>
</table>

<br/><br/><br/><br/><br/><br/><br/>
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)
65 changes: 8 additions & 57 deletions packages/jsx-email/src/cli/commands/build.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand All @@ -40,6 +37,11 @@ interface BuildCommandOptionsInternal extends BuildCommandOptions {
showStats?: boolean;
}

interface BuildTemplateParams {
buildOptions: BuildCommandOptionsInternal;
targetPath: string;
}

interface BuildOptions {
argv: BuildCommandOptions;
outputBasePath?: string;
Expand All @@ -55,12 +57,6 @@ export interface BuildResult {
writePath: string;
}

interface CompileOptions {
files: string[];
outDir: string;
writeMeta: boolean;
}

export const help = chalk`
{blue email build}
Expand Down Expand Up @@ -143,51 +139,6 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => {
};
};

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');

Expand Down
5 changes: 2 additions & 3 deletions packages/jsx-email/src/cli/helpers.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
13 changes: 0 additions & 13 deletions packages/jsx-email/src/declarations.d.ts

This file was deleted.

2 changes: 2 additions & 0 deletions packages/jsx-email/src/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Note: after server start we change the root directory to trick vite
export const originalCwd = process.cwd();
3 changes: 3 additions & 0 deletions packages/jsx-email/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import './helpers.js';

// components
export * from './components/background.js';
export * from './components/body.js';
Expand Down Expand Up @@ -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';
Expand Down
86 changes: 86 additions & 0 deletions packages/jsx-email/src/renderer/compile.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading

0 comments on commit 3981f3e

Please sign in to comment.