UNPKG

jsx-email

Version:

Render JSX email components to HTML email

187 lines (183 loc) 7.84 kB
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