UNPKG

build-email

Version:

Framework & CLI Build Email - thiết kế HTML Email Template với Tailwind CSS, kế thừa Maizzle.

349 lines (289 loc) 9.9 kB
import { readFile, writeFile, copyFile, lstat, mkdir, rm, cp, } from 'node:fs/promises' import path from 'pathe' import fg from 'fast-glob' import { defu as merge } from 'defu' import get from 'lodash/get.js' import isEmpty from 'lodash-es/isEmpty.js' import ora from 'ora' import pico from 'picocolors' import cliTable from 'cli-table3' import { render } from '../generators/render.js' import { formatTime, getRootDirectories, getFileExtensionsFromPattern, } from '../utils/string.js' import { getColorizedFileSize } from '../utils/node.js' import { generatePlaintext, handlePlaintextTags, writePlaintextFile } from '../generators/plaintext.js' import { readFileConfig } from '../utils/getConfigByFilePath.js' /** * Ensures that a directory exists, creating it if needed. * * @param {string} filePath - The path to the file to check. */ async function ensureDirectoryExistence(filePath) { const dirname = path.dirname(filePath) await mkdir(dirname, { recursive: true }) } /** * Copy a file from source to target. * * @param {string} source - The source file path. * @param {string} target - The target file path. */ async function copyFileAsync(source, target) { await ensureDirectoryExistence(target) await copyFile(source, target) } /** * Compile templates and output to the build directory. * Returns a promise containing an object with files output and the config object. * * @param {object|string} config - The Maizzle config object, or path to a config file. * @returns {Promise<object>} The build output, containing the files and config. */ export default async (config = {}) => { const spinner = ora() try { const startTime = Date.now() /** * Read the config file for this environment, * merging it with the default config. */ config = await readFileConfig(config).catch(() => { throw new Error('Could not compute config') }) /** * Support customizing the spinner */ const spinnerConfig = get(config, 'build.spinner') if (spinnerConfig === false) { // Show only 'Building...' text spinner.isEnabled = false } else { spinner.spinner = get(config, 'build.spinner', 'circleHalves') } spinner.start('Building...') // Run beforeCreate event if (typeof config.beforeCreate === 'function') { await config.beforeCreate({ config }) } const buildOutputPath = get(config, 'build.output.path', 'build_local') // Remove output directory await rm(buildOutputPath, { recursive: true, force: true }) const table = new cliTable({ head: ['File name', 'File size', 'Build time'].map(item => pico.bold(item)), }) /** * Check that templates to be built, actually exist */ const contentPaths = get(config, 'build.content', ['emails/**/*.html']) const templateFolders = Array.isArray(contentPaths) ? contentPaths : [contentPaths] const templatePaths = await fg.glob([...new Set(templateFolders)]) // If there are no templates to build, throw error if (templatePaths.length === 0) { throw new Error(`No templates found in ${pico.inverse(templateFolders)}`) } /** * Copy source directories to destination * * Copies each `build.content` path to the `build.output.path` directory. */ let from = get(config, 'build.output.from', ['emails']) const globPathsToCopy = contentPaths.map(glob => { // Keep negated paths as they are if (glob.startsWith('!')) { return glob } // Keep single-file sources as they are if (!/\*/.test(glob)) { return glob } // Update non-negated paths to target all files, avoiding duplication return glob.replace(/\/\*\*\/\*\.\{.*?\}$|\/\*\*\/\*\.[^/]*$|\/*\.[^/]*$/, '/**/*') }) try { from = Array.isArray(from) ? from : [from] /** * Copy files from source to destination * * The array/set conversion is to remove duplicates */ for (const file of await fg(Array.from(new Set(globPathsToCopy)))) { let relativePath for (const dir of from) { if (file.startsWith(dir)) { relativePath = path.relative(dir, file) break } } if (!relativePath) { relativePath = path.relative('.', file) } const targetPath = path.join(buildOutputPath, relativePath) await copyFileAsync(file, targetPath) } } catch (error) { console.error('Error while processing pattern:', error) } /** * Get a list of files to render, from the output directory * * Uses all file extensions from non-negated glob paths in `build.content` * to determine which files to render from the output directory. */ const outputExtensions = new Set() for (const pattern of contentPaths) { getFileExtensionsFromPattern(pattern).map(ext => outputExtensions.add(ext)) } /** * Create a list of templates to compile */ const extensions = outputExtensions.size > 1 ? `{${[...outputExtensions].join(',')}}` : [...outputExtensions][0] || 'html' const templatesToCompile = await fg.glob( path.join( buildOutputPath, `**/*.${extensions}` ) ) /** * Render templates */ for await (const templatePath of templatesToCompile) { const templateBuildStartTime = Date.now() /** * Add the current template path to the config * * Can be used in events like `beforeRender` to determine * which template file is being rendered. */ config.build.current = { path: path.parse(templatePath), } const html = await readFile(templatePath, 'utf8') /** * Render the markup. * * Merging a custom `components` object to make sure that file extensions from both * `build.content` * and * `components.fileExtension` are used when scanning for * component files. */ const userComponentFileExtensions = get(config, 'components.fileExtension', ['html']) const rendered = await render(html, merge( { components: { fileExtension: [ ...outputExtensions, ...(new Set([].concat(userComponentFileExtensions))), ], } }, config )) /** * Generate plaintext * * We do this first so that we can remove the <plaintext> * tags from the markup before outputting the file. */ const plaintextConfig = get(rendered.config, 'plaintext') if (Boolean(plaintextConfig) || !isEmpty(plaintextConfig)) { const posthtmlOptions = get(rendered.config, 'posthtml.options', {}) await writePlaintextFile( await generatePlaintext(rendered.html, merge(plaintextConfig, posthtmlOptions)), rendered.config ).catch(error => { throw new Error(`Error writing plaintext file: ${error}`) }) rendered.html = await handlePlaintextTags(rendered.html, posthtmlOptions) } /** * Determine output path, creating directories if needed * * Prioritize `permalink` path from Front Matter, * fallback to the current template path. * * We do this before generating plaintext, so that * any paths will already have been created. */ const outputPathFromConfig = get(rendered.config, 'permalink', templatePath) const parsedOutputPath = path.parse(outputPathFromConfig) // This keeps original file extension if no output extension is set const extension = get(rendered.config, 'build.output.extension', parsedOutputPath.ext.slice(1)) const outputPath = `${parsedOutputPath.dir}/${parsedOutputPath.name}.${extension}` const pathExists = await lstat(path.dirname(outputPath)).catch(() => false) if (!pathExists) { await mkdir(path.dirname(outputPath), { recursive: true }) } /** * Write the rendered HTML to disk, creating directories if needed */ await writeFile(outputPath, rendered.html) /** * Remove original file if its path is different * from the final destination path. */ if (outputPath !== templatePath) { await rm(templatePath) } /** * Add file to CLI table for build summary logging */ table.push([ path.relative(get(rendered.config, 'build.output.path'), outputPath), getColorizedFileSize(rendered.html), formatTime(Date.now() - templateBuildStartTime) ]) } /** * Copy static files */ let staticFiles = get(config, 'build.static', []) if (!Array.isArray(staticFiles)) { staticFiles = [staticFiles] } for (const definition of staticFiles) { const staticSourcePaths = getRootDirectories([...new Set(definition.source)]) for await (const rootDir of staticSourcePaths) { await cp(rootDir, path.join(buildOutputPath, definition.destination), { recursive: true }) } } const allOutputFiles = await fg.glob(path.join(buildOutputPath, '**/*')) /** * Run `afterBuild` event */ if (typeof config.afterBuild === 'function') { await config.afterBuild({ config, files: allOutputFiles, }) } /** * Log a build summary if enabled in the config */ spinner.clear() if (config.build.summary) { console.log(table.toString() + '\n') } spinner.succeed(`Built ${table.length} template${table.length > 1 ? 's' : ''} in ${formatTime(Date.now() - startTime)}`) return { files: allOutputFiles, config } } catch (error) { spinner.fail('Build failed') throw error } }