From 941ba3023722f7f9431eaaad573a69b50df765cb Mon Sep 17 00:00:00 2001 From: Arthur Tkachenko Date: Tue, 29 Sep 2026 06:41:21 +0300 Subject: [PATCH 1/2] PR B+C: list files on CLI only; isolate file-spawn from engine --- src/adapters/file-renderer.ts | 87 +++++++++++++++++++++++++++++++++ src/adapters/list-files.test.ts | 27 ++++++++++ src/cli.ts | 9 ++-- src/generator.test.ts | 11 +---- src/generator.ts | 8 +-- src/render.ts | 16 +++--- 6 files changed, 133 insertions(+), 25 deletions(-) create mode 100644 src/adapters/file-renderer.ts create mode 100644 src/adapters/list-files.test.ts diff --git a/src/adapters/file-renderer.ts b/src/adapters/file-renderer.ts new file mode 100644 index 0000000..565152f --- /dev/null +++ b/src/adapters/file-renderer.ts @@ -0,0 +1,87 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathExists } from 'markup-generator'; +import { GenerateTemplateError } from '../errors'; +import { serializePayload } from '../payload'; +import type { RenderContext } from '../types'; + +/** Opt-in spawn path. Engine must not import this unless allowFileTemplates. */ +export function renderFromFile(ctx: RenderContext): string { + if (!ctx.entry.file) { + throw new GenerateTemplateError( + 'RENDER_FAILED', + `Template "${ctx.templateId}" has no file`, + ctx.templateId + ); + } + + const modulePath = path.join(ctx.templatesDir, ctx.entry.file); + if (!pathExists(modulePath)) { + throw new GenerateTemplateError( + 'RENDER_FAILED', + `Template file missing: ${path.relative(ctx.root, modulePath)}`, + ctx.templateId + ); + } + + try { + return spawnTemplateModule(modulePath, ctx.entry.exportName ?? 'default', ctx.payload, ctx.root); + } catch (error) { + if (error instanceof GenerateTemplateError) throw error; + const message = error instanceof Error ? error.message : String(error); + throw new GenerateTemplateError('RENDER_FAILED', message, ctx.templateId); + } +} + +function spawnTemplateModule(modulePath: string, exportName: string, payload: unknown, root: string): string { + const payloadPath = path.join(os.tmpdir(), `generate-template-payload-${process.pid}-${Date.now()}.json`); + fs.writeFileSync(payloadPath, serializePayload(payload), 'utf8'); + const source = ` +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +function revive(value) { + if (value && typeof value === 'object' && typeof value.__date === 'string') return new Date(value.__date); + if (Array.isArray(value)) return value.map(revive); + if (value && typeof value === 'object') { + const next = {}; + for (const [key, val] of Object.entries(value)) { + next[key] = revive(val); + } + return next; + } + return value; +} +const raw = JSON.parse(readFileSync(${JSON.stringify(payloadPath)}, 'utf8')); +const mod = await import(pathToFileURL(${JSON.stringify(modulePath)}).href); +const exported = mod[${JSON.stringify(exportName)}] ?? mod.default; +if (!exported) throw new Error('Export missing'); +const html = typeof exported === 'function' ? exported(revive(raw)) : exported.render(revive(raw)); +process.stdout.write(html); +`; + try { + return runStripTypes(source, root); + } finally { + fs.rmSync(payloadPath, { force: true }); + } +} + +function runStripTypes(source: string, root: string): string { + const tmpFile = path.join(os.tmpdir(), `generate-template-${process.pid}-${Date.now()}.mts`); + fs.writeFileSync(tmpFile, source, 'utf8'); + try { + const result = spawnSync( + process.execPath, + ['--experimental-strip-types', '--no-warnings', tmpFile], + { encoding: 'utf8', cwd: root, maxBuffer: 10 * 1024 * 1024 } + ); + if (result.status !== 0) { + const err = (result.stderr || result.stdout || '').trim(); + throw new Error(err || `node exited ${result.status}`); + } + return result.stdout; + } finally { + fs.rmSync(tmpFile, { force: true }); + } +} diff --git a/src/adapters/list-files.test.ts b/src/adapters/list-files.test.ts new file mode 100644 index 0000000..ee4419f --- /dev/null +++ b/src/adapters/list-files.test.ts @@ -0,0 +1,27 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { listTemplateFiles } from './list-files'; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('listTemplateFiles adapter', () => { + it('lists ts/js files and skips registry names', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gt-list-')); + tempDirs.push(dir); + fs.writeFileSync(path.join(dir, 'hello.ts'), ''); + fs.writeFileSync(path.join(dir, 'index.js'), ''); + expect(listTemplateFiles(dir, { skipFiles: ['index.js'] })).toEqual(['hello.ts']); + }); + + it('returns [] when the directory is missing', () => { + expect(listTemplateFiles('/nonexistent/templates')).toEqual([]); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 3b208ab..81cd3e2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,8 @@ #!/usr/bin/env node -import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { pathExists } from 'markup-generator'; +import { listTemplateFiles } from './adapters/list-files'; import { createGenerator, TemplateGenerator } from './generator'; import type { CliArgs, GenerateRequest } from './types'; @@ -42,7 +43,7 @@ export function requestsFromArgs(args: CliArgs, generator: TemplateGenerator): G export async function main(argv = process.argv.slice(2), generator?: TemplateGenerator): Promise { const args = parseArgs(argv); const gen = generator ?? createGenerator(); - const templateFiles = gen.listTemplateFiles(); + const templateFiles = listTemplateFiles(gen.templatesDir, { skipFiles: gen.skipFiles }); if (args.list) { console.log(`Files in ${path.relative(gen.root, gen.templatesDir) || gen.templatesDir}:`); @@ -52,7 +53,9 @@ export async function main(argv = process.argv.slice(2), generator?: TemplateGen console.log(' (No catalog configured — pass createGenerator({ catalog }) from your project)'); } else { gen.catalog.forEach((entry) => { - const exists = entry.render ? true : Boolean(entry.file && fs.existsSync(path.join(gen.templatesDir, entry.file))); + const exists = entry.render + ? true + : Boolean(entry.file && pathExists(path.join(gen.templatesDir, entry.file))); const source = entry.render ? 'renderer' : entry.file ?? '(no source)'; console.log(` ${entry.ids.join(' | ')} <- ${source}${exists ? '' : ' (missing)'}`); }); diff --git a/src/generator.test.ts b/src/generator.test.ts index ccc96c6..8fec504 100644 --- a/src/generator.test.ts +++ b/src/generator.test.ts @@ -31,6 +31,7 @@ describe('createGenerator', () => { }); expect(gen.find('WELCOME')?.ids).toContain('welcome'); + expect(gen.ids()).toEqual(['welcome', 'WelcomeEmail']); expect(await gen.render('welcome')).toBe('

Hello Alex

'); expect(await gen.render('WelcomeEmail', { payload: { name: 'Sam' } })).toBe('

Hello Sam

'); }); @@ -61,7 +62,6 @@ describe('createGenerator', () => { }); expect(gen.templatesDir).toBe(path.join(root, 'emails')); - expect(gen.listTemplateFiles()).toEqual(['hello.ts']); }); it('writes rendered html through markup-generator', async () => { @@ -85,13 +85,4 @@ describe('createGenerator', () => { await expect(gen.render('welcome', { payload: {} })).rejects.toThrow(/Unknown template id/); }); - - it('handles missing templates directory gracefully', () => { - const gen = createGenerator({ - templatesDir: '/nonexistent/templates', - catalog: [{ ids: ['test'], render: () => 'test' }], - }); - - expect(gen.listTemplateFiles()).toEqual([]); - }); }); diff --git a/src/generator.ts b/src/generator.ts index e3a901e..2dd728a 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { Catalog } from './catalog'; -import { listTemplateFiles as listTemplateFilesOnDisk } from './adapters/list-files'; +import { renderFromFile } from './adapters/file-renderer'; import { PayloadSource } from './payload'; import { renderEntry } from './render'; import { Writer } from './writer'; @@ -68,8 +68,8 @@ export class TemplateGenerator { return this.catalogPort.slug(templateId); } - listTemplateFiles(): string[] { - return listTemplateFilesOnDisk(this.templatesDir, { skipFiles: this.skipFiles }); + ids(): string[] { + return this.catalog.flatMap((entry) => entry.ids); } loadPayload(templateId: string, dataPath?: string): Promise { @@ -84,8 +84,8 @@ export class TemplateGenerator { catalog: this.catalog, templatesDir: this.templatesDir, root: this.root, - allowFileTemplates: this.allowFileTemplates, reviveDates: options.reviveDates ?? this.reviveDates, + fileRenderer: this.allowFileTemplates ? renderFromFile : undefined, }); } diff --git a/src/render.ts b/src/render.ts index 832f525..dc12c88 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,12 +1,12 @@ import { GenerateTemplateError } from './errors'; -import { renderFromFile } from './render/file-adapter'; import { renderInProcess } from './render/in-process'; import { availableIds, findEntry } from './resolve'; import { reviveDates } from './payload'; -import type { TemplateCatalogEntry } from './types'; +import type { RenderContext, TemplateCatalogEntry } from './types'; export { invokeRenderer } from './render/in-process'; -export { renderFromFile } from './render/file-adapter'; + +export type FileRenderer = (ctx: RenderContext) => string | Promise; export async function renderEntry(options: { templateId: string; @@ -14,8 +14,8 @@ export async function renderEntry(options: { catalog: TemplateCatalogEntry[]; templatesDir: string; root: string; - allowFileTemplates?: boolean; reviveDates?: boolean; + fileRenderer?: FileRenderer; }): Promise { const { templateId, @@ -23,8 +23,8 @@ export async function renderEntry(options: { catalog, templatesDir, root, - allowFileTemplates = false, reviveDates: shouldRevive = false, + fileRenderer, } = options; const entry = findEntry(catalog, templateId); if (!entry) { @@ -35,7 +35,7 @@ export async function renderEntry(options: { ); } - const ctx = { + const ctx: RenderContext = { templateId, payload: shouldRevive ? reviveDates(payload) : payload, entry, @@ -48,14 +48,14 @@ export async function renderEntry(options: { } if (entry.file) { - if (!allowFileTemplates) { + if (!fileRenderer) { throw new GenerateTemplateError( 'RENDER_FAILED', `Template "${templateId}" uses a file adapter. Pass allowFileTemplates: true or inject a render function.`, templateId ); } - return renderFromFile(ctx); + return fileRenderer(ctx); } throw new GenerateTemplateError( From 2a181561d1bb410099edb96b1f792e64c7b72d12 Mon Sep 17 00:00:00 2001 From: Arthur Tkachenko Date: Tue, 29 Sep 2026 06:41:28 +0300 Subject: [PATCH 2/2] Remove render/file-adapter; spawn lives in adapters/file-renderer --- src/render/file-adapter.ts | 86 -------------------------------------- 1 file changed, 86 deletions(-) delete mode 100644 src/render/file-adapter.ts diff --git a/src/render/file-adapter.ts b/src/render/file-adapter.ts deleted file mode 100644 index 2d0d791..0000000 --- a/src/render/file-adapter.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { GenerateTemplateError } from '../errors'; -import { serializePayload } from '../payload'; -import type { RenderContext } from '../types'; - -/** Legacy adapter. Not part of the core render path. Requires allowFileTemplates. */ -export function renderFromFile(ctx: RenderContext): string { - if (!ctx.entry.file) { - throw new GenerateTemplateError( - 'RENDER_FAILED', - `Template "${ctx.templateId}" has no file`, - ctx.templateId - ); - } - - const modulePath = path.join(ctx.templatesDir, ctx.entry.file); - if (!fs.existsSync(modulePath)) { - throw new GenerateTemplateError( - 'RENDER_FAILED', - `Template file missing: ${path.relative(ctx.root, modulePath)}`, - ctx.templateId - ); - } - - try { - return spawnTemplateModule(modulePath, ctx.entry.exportName ?? 'default', ctx.payload, ctx.root); - } catch (error) { - if (error instanceof GenerateTemplateError) throw error; - const message = error instanceof Error ? error.message : String(error); - throw new GenerateTemplateError('RENDER_FAILED', message, ctx.templateId); - } -} - -function spawnTemplateModule(modulePath: string, exportName: string, payload: unknown, root: string): string { - const payloadPath = path.join(os.tmpdir(), `generate-template-payload-${process.pid}-${Date.now()}.json`); - fs.writeFileSync(payloadPath, serializePayload(payload), 'utf8'); - const source = ` -import { readFileSync } from 'node:fs'; -import { pathToFileURL } from 'node:url'; -function revive(value) { - if (value && typeof value === 'object' && typeof value.__date === 'string') return new Date(value.__date); - if (Array.isArray(value)) return value.map(revive); - if (value && typeof value === 'object') { - const next = {}; - for (const [key, val] of Object.entries(value)) { - next[key] = revive(val); - } - return next; - } - return value; -} -const raw = JSON.parse(readFileSync(${JSON.stringify(payloadPath)}, 'utf8')); -const mod = await import(pathToFileURL(${JSON.stringify(modulePath)}).href); -const exported = mod[${JSON.stringify(exportName)}] ?? mod.default; -if (!exported) throw new Error('Export missing'); -const html = typeof exported === 'function' ? exported(revive(raw)) : exported.render(revive(raw)); -process.stdout.write(html); -`; - try { - return runStripTypes(source, root); - } finally { - fs.rmSync(payloadPath, { force: true }); - } -} - -function runStripTypes(source: string, root: string): string { - const tmpFile = path.join(os.tmpdir(), `generate-template-${process.pid}-${Date.now()}.mts`); - fs.writeFileSync(tmpFile, source, 'utf8'); - try { - const result = spawnSync( - process.execPath, - ['--experimental-strip-types', '--no-warnings', tmpFile], - { encoding: 'utf8', cwd: root, maxBuffer: 10 * 1024 * 1024 } - ); - if (result.status !== 0) { - const err = (result.stderr || result.stdout || '').trim(); - throw new Error(err || `node exited ${result.status}`); - } - return result.stdout; - } finally { - fs.rmSync(tmpFile, { force: true }); - } -}