From 69f03ca962b2db01ed4d1b637e112fb146272717 Mon Sep 17 00:00:00 2001 From: Cosmin Popovici Date: Fri, 5 Jun 2026 17:32:45 +0300 Subject: [PATCH 1/5] feat(build): parallel build across worker threads --- package-lock.json | 1 + package.json | 1 + src/build.ts | 260 ++++++++++++++++------------- src/render/buildTemplate.ts | 170 +++++++++++++++++++ src/render/createRenderer.ts | 42 ++++- src/render/parallel/buildWorker.ts | 73 ++++++++ src/render/parallel/worker.mjs | 28 ++++ src/tests/build.test.ts | 184 +++++++++++++++++++- src/types/config.ts | 18 ++ tsdown.config.ts | 2 + 10 files changed, 659 insertions(+), 120 deletions(-) create mode 100644 src/render/buildTemplate.ts create mode 100644 src/render/parallel/buildWorker.ts create mode 100644 src/render/parallel/worker.mjs diff --git a/package-lock.json b/package-lock.json index 59d1efc5..2dee6b8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "reka-ui": "^2.9.3", "string-strip-html": "^13.5.3", "tinyglobby": "^0.2.15", + "tinypool": "^2.1.0", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", "unplugin-auto-import": "^21.0.0", diff --git a/package.json b/package.json index 21cc7806..84b1b677 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "reka-ui": "^2.9.3", "string-strip-html": "^13.5.3", "tinyglobby": "^0.2.15", + "tinypool": "^2.1.0", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", "unplugin-auto-import": "^21.0.0", diff --git a/src/build.ts b/src/build.ts index 51e886ea..dd8ae762 100644 --- a/src/build.ts +++ b/src/build.ts @@ -1,16 +1,14 @@ -import { readFileSync, writeFileSync, mkdirSync, cpSync, existsSync, rmSync } from 'node:fs' -import { resolve, dirname, basename, relative, join, parse as parsePath } from 'node:path' +import { mkdirSync, cpSync, existsSync, rmSync } from 'node:fs' +import { resolve, dirname, relative, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { availableParallelism } from 'node:os' import { glob } from 'tinyglobby' import ora from 'ora' import { resolveConfig } from './config/index.ts' import { EventManager } from './events/index.ts' -import { runTransformers } from './transformers/index.ts' import { createRenderer } from './render/createRenderer.ts' -import { createPlaintext } from './plaintext.ts' -import { stripForHtml, stripForPlaintext } from './utils/output-markers.ts' import { normalizeComponentSources } from './utils/componentSources.ts' -import { _setCurrentTemplate } from './composables/useCurrentTemplate.ts' -import defu from 'defu' +import { buildTemplate, computeContentBase } from './render/buildTemplate.ts' import type { MaizzleConfig } from './types/index.ts' export interface BuildResult { @@ -55,103 +53,47 @@ export async function build(configInput?: Partial | string): Prom rmSync(outputPath, { recursive: true, force: true }) } - const renderer = await createRenderer({ markdown: config.markdown, root: config.root, componentDirs: normalizeComponentSources(config.components?.source, process.cwd()), vite: config.vite }) const outputFiles: string[] = [] + let droppedAfterBuild = 0 - try { - for (const templatePath of templateFiles) { - const absolutePath = resolve(templatePath) - const parsedPath = parsePath(absolutePath) - const template = { source: readFileSync(absolutePath, 'utf-8'), path: parsedPath } - - _setCurrentTemplate(parsedPath) - - try { - await events.fireBeforeRender({ config, template }) - - const rendered = await renderer.render(absolutePath, config) - - /** - * Register SFC event handlers collected during render so they take - * part in the post-render events (afterRender / afterTransform). - * They're cleared at the end of the iteration so they don't - * leak into the next template. - */ - for (const { name, handler } of rendered.sfcEventHandlers) { - events.on(name, handler) - } - - let html = await events.fireAfterRender({ config, template, html: rendered.html }) - - /** - * Use the per-template merged config (from defineConfig() in the SFC) so - * that template-level overrides like css.safe: false are respected - * by transformers. - */ - const templateConfig = rendered.templateConfig - - const doctype = rendered.doctype ?? templateConfig.doctype ?? '' - - if (templateConfig.useTransformers !== false) { - html = await runTransformers(html, templateConfig, absolutePath, doctype, rendered.tailwindBlocks) - } - - html = await events.fireAfterTransform({ config, template, html }) - if (doctype) html = `${doctype}\n${html}` - - const htmlOut = stripForHtml(html) - const sfcOutputPath = rendered.outputPath - let outputFilePath: string - - if (sfcOutputPath) { - const parsed = parsePath(resolve(sfcOutputPath)) - const ext = parsed.ext ? parsed.ext.slice(1) : outputExtension - outputFilePath = join(parsed.dir, `${parsed.name}.${ext}`) - } else { - outputFilePath = resolveOutputPath(templatePath, outputPath, outputExtension, contentBase) - } - - mkdirSync(dirname(outputFilePath), { recursive: true }) - writeFileSync(outputFilePath, htmlOut) - outputFiles.push(outputFilePath) - - // Generate plaintext version if configured - const globalPlaintext = templateConfig.plaintext - const sfcPlaintext = rendered.plaintext - - if (globalPlaintext || sfcPlaintext) { - const globalCfg = typeof globalPlaintext === 'object' ? globalPlaintext : {} - const stripOptions = defu(sfcPlaintext?.options, globalCfg.options) - const plaintext = createPlaintext(stripForPlaintext(html), stripOptions) - const ptExtension = sfcPlaintext?.extension ?? globalCfg.extension ?? 'txt' - - let ptOutputPath: string - - if (sfcPlaintext?.destination) { - const name = basename(templatePath).replace(/\.(vue|md)$/, '') - ptOutputPath = join(resolve(sfcPlaintext.destination), `${name}.${ptExtension}`) - } else if (sfcOutputPath) { - const parsed = parsePath(outputFilePath) - ptOutputPath = join(parsed.dir, `${parsed.name}.${ptExtension}`) - } else if (globalCfg.destination) { - ptOutputPath = resolveOutputPath(templatePath, resolve(globalCfg.destination), ptExtension, contentBase) - } else { - ptOutputPath = resolveOutputPath(templatePath, outputPath, ptExtension, contentBase) - } - - mkdirSync(dirname(ptOutputPath), { recursive: true }) - writeFileSync(ptOutputPath, plaintext) - } - } finally { - _setCurrentTemplate(undefined) - events.clearSfcHandlers() - } - } + 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 }) - } finally { - await renderer.close() + } 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) / 1000).toFixed(2) @@ -165,30 +107,118 @@ export async function build(configInput?: Partial | string): Prom } /** - * Extract the static (non-glob) prefix from content patterns. + * 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. * - * For example, `['/abs/path/emails/**\/*.vue']` → `'/abs/path/emails'` + * `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)) * - * This is used to strip the content base from template paths - * so the output preserves only the subdirectory structure. + * 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 computeContentBase(patterns: string[]): string { - // Use the first non-negated pattern - const pattern = patterns.find(p => !p.startsWith('!')) ?? patterns[0] +export function resolveParallel( + config: MaizzleConfig, + count: number, + configInput: Partial | string | undefined, +): { enabled: boolean; workers: number } { + const setting = config.parallel + if (setting === false) return { enabled: false, workers: 0 } + + const fileBased = typeof configInput === 'string' || configInput == null + if (!fileBased) return { enabled: false, workers: 0 } + + const cpus = availableParallelism() + const defaultWorkers = Math.min(Math.max(1, cpus - 1), DEFAULT_MAX_WORKERS) + + let maxWorkers = defaultWorkers + let threshold = DEFAULT_PARALLEL_THRESHOLD + // `true` opts in regardless of count; object/omitted stay threshold-gated. + 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) + } - // Split on first glob character (* { ? [) and take the directory part - const staticPart = pattern.split(/[*{?[]/)[0] + if (!ignoreThreshold && count <= threshold) return { enabled: false, workers: 0 } - // Ensure we have a clean directory path (not a partial segment) - return resolve(staticPart.endsWith('/') ? staticPart : dirname(staticPart)) + const workers = Math.min(maxWorkers, count) + return { enabled: workers >= 2 && count >= 2, workers } } -function resolveOutputPath(templatePath: string, outputDir: string, extension: string, contentBase: string): string { - const name = basename(templatePath).replace(/\.(vue|md)$/, '') - const absTemplate = resolve(templatePath) - const rel = relative(contentBase, dirname(absTemplate)) +/** + * 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: { + templateFiles: string[] + workers: number + config: MaizzleConfig + configInput: Partial | string | undefined + outputPath: string + outputExtension: string + contentBase: string +}): Promise<{ files: string[]; sfcAfterBuildCount: number }> { + 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 : undefined + // Serializable snapshot of the post-beforeCreate config (functions dropped). + const configData = JSON.parse(JSON.stringify(config)) as Partial + + 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() + } +} - return join(outputDir, rel, `${name}.${extension}`) +/** Round-robin items into up to `buckets` non-empty groups for even balance. */ +function shardEvenly(items: T[], buckets: number): T[][] { + const out: T[][] = Array.from({ length: buckets }, () => []) + items.forEach((item, i) => out[i % buckets].push(item)) + return out.filter(b => b.length > 0) } async function copyStatic(config: MaizzleConfig, outputPath: string): Promise { diff --git a/src/render/buildTemplate.ts b/src/render/buildTemplate.ts new file mode 100644 index 00000000..df57d019 --- /dev/null +++ b/src/render/buildTemplate.ts @@ -0,0 +1,170 @@ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { resolve, dirname, basename, relative, join, parse as parsePath } from 'node:path' +import defu from 'defu' +import { runTransformers } from '../transformers/index.ts' +import { createPlaintext } from '../plaintext.ts' +import { stripForHtml, stripForPlaintext } from '../utils/output-markers.ts' +import { _setCurrentTemplate } from '../composables/useCurrentTemplate.ts' +import type { EventManager } from '../events/index.ts' +import type { Renderer } from './createRenderer.ts' +import type { MaizzleConfig } from '../types/index.ts' + +export interface BuildTemplateContext { + config: MaizzleConfig + renderer: Renderer + events: EventManager + outputPath: string + outputExtension: string + contentBase: string +} + +export interface BuildTemplateResult { + /** Output files written for this template (html + optional plaintext). */ + files: string[] + /** + * Number of SFC-registered `afterBuild` handlers seen while rendering. They + * only fire once at end of build on the main thread, so a worker can't run + * them — the count lets the orchestrator warn instead of silently dropping. + */ + sfcAfterBuildCount: number +} + +/** + * Render a single template through the full pipeline and write its output. + * + * Shared by the sequential build loop and the parallel build worker so both + * paths produce byte-identical output. `events` is the manager the per-template + * events fire on (config handlers registered via `registerConfig`, SFC handlers + * registered here from the render). The caller owns build-scoped events + * (`beforeCreate`/`afterBuild`). + */ +export async function buildTemplate( + templatePath: string, + ctx: BuildTemplateContext, +): Promise { + const { config, renderer, events, outputPath, outputExtension, contentBase } = ctx + const absolutePath = resolve(templatePath) + const parsedPath = parsePath(absolutePath) + const template = { source: readFileSync(absolutePath, 'utf-8'), path: parsedPath } + const files: string[] = [] + let sfcAfterBuildCount = 0 + + _setCurrentTemplate(parsedPath) + + try { + /** + * Clone config per template so beforeRender mutations (setting a + * preheader, injecting fetched data, etc.) stay scoped to this template + * instead of leaking into later ones through the shared config object. + */ + const renderConfig = defu({}, config) as MaizzleConfig + const originalSource = template.source + + await events.fireBeforeRender({ config: renderConfig, template }) + + const rendered = await renderer.render( + absolutePath, + renderConfig, + template.source !== originalSource ? { source: template.source } : undefined, + ) + + /** + * Register SFC event handlers collected during render so they take part in + * the post-render events. Cleared at the end of this call so they don't + * leak into the next template (afterBuild is the exception — it's never + * cleared by clearSfcHandlers; see the count above). + */ + for (const { name, handler } of rendered.sfcEventHandlers) { + if (name === 'afterBuild') sfcAfterBuildCount++ + events.on(name, handler) + } + + let html = await events.fireAfterRender({ config: renderConfig, template, html: rendered.html }) + + const templateConfig = rendered.templateConfig + const doctype = rendered.doctype ?? templateConfig.doctype ?? '' + + if (templateConfig.useTransformers !== false) { + html = await runTransformers(html, templateConfig, absolutePath, doctype, rendered.tailwindBlocks) + } + + html = await events.fireAfterTransform({ config: renderConfig, template, html }) + if (doctype) html = `${doctype}\n${html}` + + const htmlOut = stripForHtml(html) + const sfcOutputPath = rendered.outputPath + let outputFilePath: string + + if (sfcOutputPath) { + const parsed = parsePath(resolve(sfcOutputPath)) + const ext = parsed.ext ? parsed.ext.slice(1) : outputExtension + outputFilePath = join(parsed.dir, `${parsed.name}.${ext}`) + } else { + outputFilePath = resolveOutputPath(templatePath, outputPath, outputExtension, contentBase) + } + + mkdirSync(dirname(outputFilePath), { recursive: true }) + writeFileSync(outputFilePath, htmlOut) + files.push(outputFilePath) + + // Generate plaintext version if configured + const globalPlaintext = templateConfig.plaintext + const sfcPlaintext = rendered.plaintext + + if (globalPlaintext || sfcPlaintext) { + const globalCfg = typeof globalPlaintext === 'object' ? globalPlaintext : {} + const stripOptions = defu(sfcPlaintext?.options, globalCfg.options) + const plaintext = createPlaintext(stripForPlaintext(html), stripOptions) + const ptExtension = sfcPlaintext?.extension ?? globalCfg.extension ?? 'txt' + + let ptOutputPath: string + + if (sfcPlaintext?.destination) { + const name = basename(templatePath).replace(/\.(vue|md)$/, '') + ptOutputPath = join(resolve(sfcPlaintext.destination), `${name}.${ptExtension}`) + } else if (sfcOutputPath) { + const parsed = parsePath(outputFilePath) + ptOutputPath = join(parsed.dir, `${parsed.name}.${ptExtension}`) + } else if (globalCfg.destination) { + ptOutputPath = resolveOutputPath(templatePath, resolve(globalCfg.destination), ptExtension, contentBase) + } else { + ptOutputPath = resolveOutputPath(templatePath, outputPath, ptExtension, contentBase) + } + + mkdirSync(dirname(ptOutputPath), { recursive: true }) + writeFileSync(ptOutputPath, plaintext) + } + } finally { + _setCurrentTemplate(undefined) + events.clearSfcHandlers() + } + + return { files, sfcAfterBuildCount } +} + +/** + * Extract the static (non-glob) prefix from content patterns. + * + * For example, `['/abs/path/emails/**\/*.vue']` → `'/abs/path/emails'` + * + * Used to strip the content base from template paths so the output preserves + * only the subdirectory structure. + */ +export function computeContentBase(patterns: string[]): string { + // Use the first non-negated pattern + const pattern = patterns.find(p => !p.startsWith('!')) ?? patterns[0] + + // Split on first glob character (* { ? [) and take the directory part + const staticPart = pattern.split(/[*{?[]/)[0] + + // Ensure we have a clean directory path (not a partial segment) + return resolve(staticPart.endsWith('/') ? staticPart : dirname(staticPart)) +} + +export function resolveOutputPath(templatePath: string, outputDir: string, extension: string, contentBase: string): string { + const name = basename(templatePath).replace(/\.(vue|md)$/, '') + const absTemplate = resolve(templatePath) + const rel = relative(contentBase, dirname(absTemplate)) + + return join(outputDir, rel, `${name}.${extension}`) +} diff --git a/src/render/createRenderer.ts b/src/render/createRenderer.ts index 117f142f..721a1906 100644 --- a/src/render/createRenderer.ts +++ b/src/render/createRenderer.ts @@ -44,7 +44,7 @@ export interface RenderedTemplate { } export interface Renderer { - render(input: string | Component, config: MaizzleConfig): Promise + render(input: string | Component, config: MaizzleConfig, opts?: { source?: string }): Promise invalidate(filePath: string): Promise invalidateAll(): Promise close(): Promise @@ -223,6 +223,15 @@ export async function createRenderer( const VIRTUAL_SFC_ID = 'virtual:maizzle-sfc.vue' let virtualSfcSource = '' + /** + * Per-render source overrides keyed by absolute template path. Lets the + * build's beforeRender event rewrite a template's source before compile + * while keeping the real file id — so relative imports, asset URLs and + * component resolution still resolve against the actual file location + * (which the virtual-SFC path can't do). + */ + const sourceOverrides = new Map() + /** * Never load the host project's vite.config.ts here. Doing so pulls * every host plugin (Nitro, TanStack Start, the Maizzle plugin @@ -247,6 +256,13 @@ export async function createRenderer( if (id === VIRTUAL_SFC_ID) return virtualSfcSource }, }, + { + name: 'maizzle:source-override', + load(id) { + const override = sourceOverrides.get(id.split('?')[0]) + if (override !== undefined) return override + }, + }, vue({ include: [/\.vue$/, /\.md$/], template: { @@ -375,7 +391,7 @@ export async function createRenderer( const server = await createServer(finalConfig) return { - async render(input: string | Component, config: MaizzleConfig): Promise { + async render(input: string | Component, config: MaizzleConfig, opts?: { source?: string }): Promise { let component: Component let configKey: InjectionKey let contextKey: InjectionKey @@ -396,7 +412,27 @@ export async function createRenderer( if (mod) server.moduleGraph.invalidateModule(mod) component = (await server.ssrLoadModule(VIRTUAL_SFC_ID)).default } else { - component = (await server.ssrLoadModule(input)).default + /** + * A beforeRender handler may have rewritten the source. Register it + * under the real path id and invalidate so ssrLoadModule compiles + * the override; clear + invalidate afterwards so the override never + * leaks into a later render of the same path. + */ + const hasOverride = opts?.source !== undefined + if (hasOverride) { + sourceOverrides.set(input, opts!.source!) + const mod = await server.moduleGraph.getModuleByUrl(input) + if (mod) server.moduleGraph.invalidateModule(mod) + } + try { + component = (await server.ssrLoadModule(input)).default + } finally { + if (hasOverride) { + sourceOverrides.delete(input) + const mod = await server.moduleGraph.getModuleByUrl(input) + if (mod) server.moduleGraph.invalidateModule(mod) + } + } } } else { // Pre-compiled component — use directly imported keys diff --git a/src/render/parallel/buildWorker.ts b/src/render/parallel/buildWorker.ts new file mode 100644 index 00000000..1040977b --- /dev/null +++ b/src/render/parallel/buildWorker.ts @@ -0,0 +1,73 @@ +import defu from 'defu' +import { resolveConfig } from '../../config/index.ts' +import { createRenderer } from '../createRenderer.ts' +import { EventManager } from '../../events/index.ts' +import { normalizeComponentSources } from '../../utils/componentSources.ts' +import { buildTemplate } from '../buildTemplate.ts' +import type { MaizzleConfig } from '../../types/index.ts' + +export interface BuildWorkerData { + /** Template paths (glob-relative, as produced on the main thread) for this batch. */ + templatePaths: string[] + /** Config file path to reload (undefined → load maizzle.config from cwd). */ + configPath?: string + /** Serialized, post-beforeCreate config data from the main thread. */ + configData: Partial + outputPath: string + outputExtension: string + contentBase: string +} + +export interface BuildWorkerResult { + files: string[] + sfcAfterBuildCount: number +} + +/** + * Build one batch of templates in a worker thread. + * + * Config function hooks (beforeRender/afterRender/afterTransform) can't cross + * the thread boundary, so the worker reloads the config module to recover them, + * then overlays the main thread's serialized config DATA on top — so + * beforeCreate mutations and resolved values win while the reloaded config only + * backfills the lost functions. beforeCreate/afterBuild are owned by the main + * thread and never run here. + */ +export async function run(data: BuildWorkerData): Promise { + const { templatePaths, configPath, configData, outputPath, outputExtension, contentBase } = data + + const reloaded = await resolveConfig(configPath) + const config = defu(configData, reloaded) as MaizzleConfig + + const events = new EventManager() + events.registerConfig(config) + + const renderer = await createRenderer({ + markdown: config.markdown, + root: config.root, + componentDirs: normalizeComponentSources(config.components?.source, process.cwd()), + vite: config.vite, + }) + + const files: string[] = [] + let sfcAfterBuildCount = 0 + + try { + for (const templatePath of templatePaths) { + const result = await buildTemplate(templatePath, { + config, + renderer, + events, + outputPath, + outputExtension, + contentBase, + }) + files.push(...result.files) + sfcAfterBuildCount += result.sfcAfterBuildCount + } + } finally { + await renderer.close() + } + + return { files, sfcAfterBuildCount } +} diff --git a/src/render/parallel/worker.mjs b/src/render/parallel/worker.mjs new file mode 100644 index 00000000..eb52b967 --- /dev/null +++ b/src/render/parallel/worker.mjs @@ -0,0 +1,28 @@ +// Tinypool worker entry. Plain JS so it loads in a raw worker thread without a +// TS toolchain. In the published dist the implementation is already compiled to +// `.js` and is imported natively (fast); during dev/tests only the `.ts` source +// exists and is loaded through jiti. +import { fileURLToPath, pathToFileURL } from 'node:url' +import { existsSync } from 'node:fs' + +let implPromise + +function loadImpl() { + if (!implPromise) { + const jsPath = fileURLToPath(new URL('./buildWorker.js', import.meta.url)) + if (existsSync(jsPath)) { + implPromise = import(pathToFileURL(jsPath).href) + } else { + const tsPath = fileURLToPath(new URL('./buildWorker.ts', import.meta.url)) + implPromise = import('jiti').then(({ createJiti }) => + createJiti(fileURLToPath(import.meta.url)).import(tsPath), + ) + } + } + return implPromise +} + +export default async function buildWorker(data) { + const impl = await loadImpl() + return impl.run(data) +} diff --git a/src/tests/build.test.ts b/src/tests/build.test.ts index d4fd49fe..87702fd5 100644 --- a/src/tests/build.test.ts +++ b/src/tests/build.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, symlinkSync } from 'node:fs' import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { build } from '../build.ts' +import { tmpdir, availableParallelism } from 'node:os' +import { build, resolveParallel } from '../build.ts' function createTempProject() { const dir = mkdtempSync(join(tmpdir(), 'maizzle-build-')) @@ -149,6 +149,66 @@ describe('build', () => { delete (globalThis as any).__beforeRenderFired }) + it('compiles the source returned from beforeRender', async () => { + writeSfc(tempDir, 'emails/test.vue', ` + + `) + + writeFileSync(join(tempDir, 'maizzle.config.js'), ` + export default { + beforeRender({ template }) { + return template.source.replace('ORIGINAL', 'REWRITTEN') + } + } + `) + + const result = await build() + const html = readFileSync(result.files[0], 'utf-8') + + expect(html).toContain('REWRITTEN') + expect(html).not.toContain('ORIGINAL') + }) + + it('uses beforeRender config mutations during compile, scoped per template', async () => { + writeSfc(tempDir, 'emails/a.vue', ` + + + `) + + writeSfc(tempDir, 'emails/b.vue', ` + + + `) + + writeFileSync(join(tempDir, 'maizzle.config.js'), ` + export default { + beforeRender({ template, config }) { + if (template.path.name === 'a') config.greeting = 'AAA' + } + } + `) + + const result = await build() + const aHtml = readFileSync(result.files.find(f => f.includes('a.html'))!, 'utf-8') + const bHtml = readFileSync(result.files.find(f => f.includes('b.html'))!, 'utf-8') + + // 'a' sees its own mutation + expect(aHtml).toContain('AAA') + // 'b' must NOT inherit 'a's mutation (per-template config clone) + expect(bHtml).not.toContain('AAA') + expect(bHtml).toContain('none') + }) + it('fires afterRender event and uses modified HTML', async () => { writeSfc(tempDir, 'emails/test.vue', `