diff --git a/src/render/file-adapter.ts b/src/adapters/file-renderer.ts similarity index 94% rename from src/render/file-adapter.ts rename to src/adapters/file-renderer.ts index 2d0d791..565152f 100644 --- a/src/render/file-adapter.ts +++ b/src/adapters/file-renderer.ts @@ -2,11 +2,12 @@ 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'; -/** Legacy adapter. Not part of the core render path. Requires allowFileTemplates. */ +/** Opt-in spawn path. Engine must not import this unless allowFileTemplates. */ export function renderFromFile(ctx: RenderContext): string { if (!ctx.entry.file) { throw new GenerateTemplateError( @@ -17,7 +18,7 @@ export function renderFromFile(ctx: RenderContext): string { } const modulePath = path.join(ctx.templatesDir, ctx.entry.file); - if (!fs.existsSync(modulePath)) { + if (!pathExists(modulePath)) { throw new GenerateTemplateError( 'RENDER_FAILED', `Template file missing: ${path.relative(ctx.root, modulePath)}`, 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(