UNPKG

@maizzle/framework

Version:

Maizzle is a framework that helps you quickly build HTML emails with Tailwind CSS.

234 lines (233 loc) 8.79 kB
import { resolveConfig } from "./config/index.js"; import { EventManager } from "./events/index.js"; import { normalizeComponentSources } from "./utils/componentSources.js"; import { createRenderer } from "./render/createRenderer.js"; import { buildTemplate, computeContentBase } from "./render/buildTemplate.js"; import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; import { dirname, join, parse, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { availableParallelism } from "node:os"; import { glob } from "tinyglobby"; import ora from "ora"; //#region src/build.ts /** * Build all SFC email templates to HTML files. * * Creates a single Renderer instance, then loops through each template * calling render → transformers → write to disk. * * Pass a `Partial<MaizzleConfig>` to override config inline, or a string * to load config from a specific file path. Omit to load `maizzle.config` * from the working directory. */ async function build(configInput) { const start = Date.now(); const spinner = ora({ text: "Building templates...", spinner: "circleHalves" }).start(); try { const config = await resolveConfig(configInput); const events = new EventManager(); events.registerConfig(config); await events.fireBeforeCreate({ config }); const outputPath = resolve(config.output?.path ?? "dist"); const outputExtension = config.output?.extension ?? "html"; const contentPatterns = config.content ?? ["emails/**/*.vue"]; const contentBase = computeContentBase(contentPatterns); const templateFiles = await glob(contentPatterns); if (templateFiles.length === 0) { spinner.succeed("No templates found"); return { files: [], config }; } const cwd = process.cwd(); if (outputPath === parse(outputPath).root || outputPath === cwd || cwd.startsWith(outputPath + sep)) throw new Error(`Refusing to clear output path "${outputPath}": it is the filesystem root, the current working directory, or a parent of it. Set output.path to a subdirectory like "dist".`); if (existsSync(outputPath)) rmSync(outputPath, { recursive: true, force: true }); const outputFiles = []; let droppedAfterBuild = 0; const parallel = resolveParallel(config, templateFiles.length, configInput); if (parallel.enabled) { spinner.text = `Building ${templateFiles.length} templates across ${parallel.workers} workers...`; const result = await runParallelBuild({ templateFiles, workers: parallel.workers, config, configInput, outputPath, outputExtension, contentBase }); outputFiles.push(...result.files); droppedAfterBuild = result.sfcAfterBuildCount; await copyStatic(config, outputPath); await events.fireAfterBuild({ files: outputFiles, config }); } else { const renderer = await createRenderer({ markdown: config.markdown, root: config.root, componentDirs: normalizeComponentSources(config.components?.source, process.cwd()), vite: config.vite }); try { for (const templatePath of templateFiles) { const { files } = await buildTemplate(templatePath, { config, renderer, events, outputPath, outputExtension, contentBase }); outputFiles.push(...files); } await copyStatic(config, outputPath); await events.fireAfterBuild({ files: outputFiles, config }); } finally { await renderer.close(); } } if (droppedAfterBuild > 0) console.warn(`[maizzle] Skipped ${droppedAfterBuild} SFC-registered afterBuild handler(s): afterBuild can't run inside a parallel build worker. Move build-completion logic to the config's afterBuild hook.`); const duration = ((Date.now() - start) / 1e3).toFixed(2); const count = outputFiles.length; spinner.stopAndPersist({ symbol: "✅", text: `Built ${count} template${count !== 1 ? "s" : ""} in ${duration}s` }); return { files: outputFiles, config }; } catch (err) { if (spinner.isSpinning) spinner.fail("Build failed"); throw err; } } /** * Default template count above which parallel build turns on. Benchmarked * crossover (with the worker cap below) is ~25 templates; 50 leaves margin so * auto-parallel only kicks in where it's a reliable win across hardware. * Override per project with `parallel: { threshold }`. */ const DEFAULT_PARALLEL_THRESHOLD = 50; /** * Default worker cap. Each worker runs a full Vite SSR renderer, so startup + * contention outweighs added parallelism past ~8 — benchmarks showed 8 beating * 12/16/23 at every size. Override with `parallel: { workers }`. */ const DEFAULT_MAX_WORKERS = 8; /** * Decide whether to build in parallel and with how many workers. * * `config.parallel`: * - omitted → parallel when `count > 50`, min(CPU count − 1, 8) workers * - `true` → always parallel (ignores threshold), default workers * - `false` → always sequential * - `{ workers, threshold }` → parallel when `count > threshold` (default 50), * using `workers` threads (default min(CPU count − 1, 8)) * * Workers reload the config file to recover function hooks, so parallel only * applies to file-based configs (a path or the default cwd config) — an inline * config object has no file to reload and always builds sequentially. */ function resolveParallel(config, count, configInput) { const setting = config.parallel; if (setting === false) return { enabled: false, workers: 0 }; if (!(typeof configInput === "string" || configInput == null)) return { enabled: false, workers: 0 }; const cpus = availableParallelism(); let maxWorkers = Math.min(Math.max(1, cpus - 1), DEFAULT_MAX_WORKERS); let threshold = DEFAULT_PARALLEL_THRESHOLD; const ignoreThreshold = setting === true; if (typeof setting === "object" && setting !== null) { if (typeof setting.workers === "number" && setting.workers > 0) maxWorkers = Math.floor(setting.workers); if (typeof setting.threshold === "number" && setting.threshold >= 0) threshold = Math.floor(setting.threshold); } if (!ignoreThreshold && count <= threshold) return { enabled: false, workers: 0 }; const workers = Math.min(maxWorkers, count); return { enabled: workers >= 2 && count >= 2, workers }; } /** * Run the build across worker threads. Each worker reloads the config (for its * function hooks), builds its batch via the same `buildTemplate` as the * sequential path, and returns the files it wrote. beforeCreate/afterBuild stay * on the main thread (handled by the caller). */ async function runParallelBuild(opts) { const { templateFiles, workers, config, configInput, outputPath, outputExtension, contentBase } = opts; const { default: Tinypool } = await import("tinypool"); const workerPath = resolve(dirname(fileURLToPath(import.meta.url)), "render/parallel/worker.mjs"); const configPath = typeof configInput === "string" ? configInput : void 0; const configData = JSON.parse(JSON.stringify(config)); const batches = shardEvenly(templateFiles, workers); const pool = new Tinypool({ filename: workerPath, minThreads: batches.length, maxThreads: batches.length }); try { const results = await Promise.all(batches.map((templatePaths) => pool.run({ templatePaths, configPath, configData, outputPath, outputExtension, contentBase }))); return { files: results.flatMap((r) => r.files), sfcAfterBuildCount: results.reduce((n, r) => n + r.sfcAfterBuildCount, 0) }; } finally { await pool.destroy(); } } /** Round-robin items into up to `buckets` non-empty groups for even balance. */ function shardEvenly(items, buckets) { const out = Array.from({ length: buckets }, () => []); items.forEach((item, i) => out[i % buckets].push(item)); return out.filter((b) => b.length > 0); } async function copyStatic(config, outputPath) { const sources = config.static?.source ?? ["public/**/*.*"]; const destination = config.static?.destination ?? "public"; const files = await glob(sources); const bases = sources.filter((s) => !s.startsWith("!")).map(staticBase); for (const file of files) { const abs = resolve(file); const destPath = join(outputPath, destination, relative(bases.filter((b) => abs === b || abs.startsWith(b + sep)).sort((a, b) => b.length - a.length)[0] ?? bases[0] ?? resolve("."), abs)); const destDir = dirname(destPath); if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true }); cpSync(file, destPath); } } /** Absolute static (non-glob) prefix of a source pattern, used as the strip base. */ function staticBase(pattern) { const staticPart = pattern.split(/[*{?[]/)[0]; return resolve(/[/\\]$/.test(staticPart) ? staticPart : dirname(staticPart)); } //#endregion export { build, resolveParallel }; //# sourceMappingURL=build.js.map