jsx-email
Version:
Render JSX email components to HTML email
187 lines (183 loc) • 7.84 kB
JavaScript
import { existsSync } from 'node:fs';
import { mkdir, realpath, writeFile } from 'node:fs/promises';
import os from 'node:os';
import { dirname, basename, extname, join, resolve, win32, posix, relative } from 'path';
import { pathToFileURL } from 'url';
import chalk from 'chalk';
import { globby } from 'globby';
import micromatch from 'micromatch';
import { isWindows } from 'std-env';
import { parse as assert, boolean, object, optional, string } from 'valibot';
import { log } from '../../log.js';
import { formatBytes, gmailByteLimit } from '../helpers.mjs';
import { compile } from '../../renderer/compile.js';
import { render } from '../../renderer/render.js';
const BuildCommandOptionsStruct = object({
exclude: optional(string()),
html: optional(boolean()),
inlineCss: optional(boolean()),
minify: optional(boolean()),
out: optional(string()),
plain: optional(boolean()),
pretty: optional(boolean()),
props: optional(string()),
silent: optional(boolean()),
usePreviewProps: optional(boolean()),
writeToFile: optional(boolean())
});
export const help = chalk `
{blue email build}
Builds a template and saves the result
{underline Usage}
$ email build <template file or dir path> [...options]
{underline Options}
--exclude A micromatch glob pattern that specifies files to exclude from the build
--inline-css Inlines all CSS classes as style attributes on elements
--minify Minify the rendered template before saving
--no-html Disable rendering the HTML for a template. Useful for when only needing plain text
--out File path to save the rendered template
--plain Emit template as plain text
--pretty Oututs HTML in a pretty-print format. Note: Don't use this for production.
--props A JSON string containing props to be passed to the email template
This is usually only useful when building a single template, unless all of your
templates share the same props.
--use-preview-props
When set, use the \`previewProps\` exported by the template file (if present).
{underline Examples}
$ email build ./src/emails
$ email build ./src/templates/Invite.tsx
$ email build ./src/templates/Invite.tsx --props='\{"batman": "Bruce Wayne"\}'
`;
// Credit: https://github.com/rollup/plugins/blob/master/packages/pluginutils/src/normalizePath.ts#L5
export const normalizePath = (filename) => filename.split(win32.sep).join(posix.sep);
export const getTempPath = async (type) => {
const tmpdir = await realpath(os.tmpdir());
const buildPath = join(tmpdir, `jsx-email/${type}`);
const result = normalizePath(buildPath);
log.debug('getTempPath', { buildPath, result });
return result;
};
export const build = async (options) => {
const { argv, outputBasePath, path, sourceFile } = options;
const { html = true, out, plain, props = '{}', usePreviewProps, writeToFile = true } = argv;
const compiledPath = isWindows ? pathToFileURL(normalizePath(path)).toString() : path;
const template = await import(compiledPath);
// proper named export
const componentExport = template.Template;
if (typeof componentExport !== 'function') {
log.error(chalk `{red Template Export Problem:} ${path} doesn't have an export of a JSX Element named \`Template\`. If you feel this is a bug, please open a new issue.`);
process.exit(1);
}
// const extension = plain ? '.txt' : '.html';
const renderProps = usePreviewProps ? template.previewProps || {} : JSON.parse(props);
const fileExt = extname(path);
const templateName = basename(path, fileExt).replace(/-[^-]{8}$/, '');
const component = componentExport(renderProps);
const baseDir = dirname(path);
const relativeBaseDir = outputBasePath ? relative(outputBasePath, baseDir) : '';
const writePath = outputBasePath
? join(out, relativeBaseDir, templateName)
: join(out, templateName);
// const writePath = outputBasePath
// ? join(out!, baseDir.replace(outputBasePath, ''), templateName + extension)
// : join(out!, templateName + extension);
let plainText = null;
await mkdir(dirname(writePath), { recursive: true });
if (plain) {
plainText = await render(component, { plainText: plain });
if (writeToFile)
await writeFile(`${writePath}.txt`, plainText, 'utf8');
if (!html)
return {
compiledPath,
html: null,
plainText,
sourceFile,
templateName: template.templateName,
writePathBase: writePath
};
}
const htmlText = await render(component, argv);
if (writeToFile)
await writeFile(`${writePath}.html`, htmlText, 'utf8');
return {
compiledPath,
html: htmlText,
metaPath: compiledPath.replace(/(\.js)$/, '.meta.json'),
plainText,
sourceFile,
templateName: template.templateName,
writePathBase: writePath
};
};
export const buildTemplates = async ({ targetPath, buildOptions }) => {
const esbuildOutPath = await getTempPath('build');
// Note: niave check that will probably get us into some edge cases
const isFile = targetPath.endsWith('.tsx') || targetPath.endsWith('.jsx');
const { exclude, out = '.rendered', showStats = true, silent, writeToFile = true } = buildOptions;
const glob = isFile ? targetPath : join(targetPath, '**/*.{jsx,tsx}');
const outputPath = resolve(out);
let largeCount = 0;
let targetFiles = await globby([normalizePath(glob)]);
if (exclude)
targetFiles = targetFiles.filter((path) => !micromatch.isMatch(path, exclude));
if (!silent) {
const suffix = targetFiles.length > 1 ? 's' : '';
log.info(chalk `{cyan Found}`, targetFiles.length, `file${suffix}:`);
log.info(' ', targetFiles.join('\n '), '\n');
log.info(chalk `{blue Starting build...}`);
}
const compiledFiles = await compile({
files: targetFiles,
outDir: esbuildOutPath,
writeMeta: buildOptions.html ?? true
});
const result = await Promise.all(compiledFiles.map(async ({ entryPoint, path }, index) => {
const buildResult = await build({
argv: { ...buildOptions, out: outputPath },
outputBasePath: esbuildOutPath,
path,
sourceFile: entryPoint
});
const res = {
fileName: targetFiles[index],
...buildResult
};
if (showStats) {
const bytes = Buffer.byteLength(res.html ?? res.plainText, 'utf8');
const htmlSize = formatBytes(bytes);
if (bytes > gmailByteLimit)
largeCount += 1;
log.info(` ${res.fileName} → HTML: ${htmlSize}`);
}
return res;
}));
if (!silent) {
if (showStats && largeCount > 0) {
log.warn('');
log.warn(chalk `${largeCount} template(s) exceed the 102kb Gmail Clipping limit`);
}
if (writeToFile) {
log.info('');
log.info(chalk `{green Build complete}. File(s) written to:`, outputPath);
}
else {
log.info('');
log.info(chalk `{green Build complete}`);
}
}
return result;
};
export const command = async (argv, input) => {
if (input.length < 1)
return false;
const [targetPath] = input;
if (!existsSync(targetPath)) {
log.error(chalk `{red The provided build target '${targetPath}' does not exist}`);
process.exit(1);
}
assert(BuildCommandOptionsStruct, argv);
await buildTemplates({ buildOptions: argv, targetPath });
return true;
};
//# sourceMappingURL=build.mjs.map