#!/usr/bin/env node // cv-templates.mjs — discover, resolve, and validate CV / cover-letter templates. // Single source of truth for "which template file, and is it usable?". // Backward-compatible: with no config and no named files, resolves the base // templates/cv-template.html (name "standard"), identical to prior behavior. import { readdirSync, readFileSync, existsSync } from 'fs'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; import yaml from 'js-yaml'; const __dirname = dirname(fileURLToPath(import.meta.url)); const DEFAULT_TEMPLATES_DIR = resolve(__dirname, 'templates'); const DEFAULT_PROFILE_PATH = process.env.CAREER_OPS_PROFILE || resolve(__dirname, 'config', 'profile.yml'); export const KINDS = { cv: { prefix: 'cv-template', profileKey: ['cv', 'template'], required: ['NAME', 'EXPERIENCE', 'EDUCATION'], }, cover: { prefix: 'cover-letter-template', profileKey: ['cover_letter', 'template'], required: ['NAME', 'ROLE_TITLE', 'OPENING'], }, }; export function prettify(name) { return name .split('-') .filter(Boolean) .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(' '); } export function kebab(display) { return String(display) .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } // The only template formats the resolver recognizes. `format` reaches path // construction (fileFor) unmodified, so it must be allowlisted or a value like // `--format=../../etc/passwd` would traverse out of the templates dir. const VALID_FORMATS = new Set(['html', 'tex']); function assertFormat(format) { if (!VALID_FORMATS.has(format)) { throw new Error(`Unsupported template format: ${format} (expected html or tex)`); } } // filename → {name, format} | null. Base "cv-template.html" → name "standard"; // "cv-template..html" → that name. Only html/tex are recognized. function parseFilename(prefix, file) { const m = file.match(new RegExp(`^${prefix}(?:\\.([a-z0-9-]+))?\\.(html|tex)$`)); if (!m) return null; return { name: m[1] || 'standard', format: m[2] }; } export function parseMeta(path) { let text; try { text = readFileSync(path, 'utf-8'); } catch { return {}; } const block = text.match(//); if (!block) return {}; const meta = {}; for (const line of block[1].split(/\r?\n/)) { const kv = line.match(/^\s*([a-zA-Z_]+)\s*:\s*(.+?)\s*$/); if (kv) meta[kv[1].toLowerCase()] = kv[2]; } return meta; } export function listTemplates(kind, { dir = DEFAULT_TEMPLATES_DIR, format = 'html' } = {}) { const cfg = KINDS[kind]; if (!cfg) throw new Error(`Unknown template kind: ${kind}`); assertFormat(format); if (!existsSync(dir)) return []; const out = []; for (const file of readdirSync(dir)) { const parsed = parseFilename(cfg.prefix, file); if (!parsed || parsed.format !== format) continue; const path = resolve(dir, file); const meta = parseMeta(path); out.push({ name: parsed.name, displayName: meta.name || prettify(parsed.name), path, format: parsed.format, meta, }); } return out.sort((a, b) => a.name.localeCompare(b.name)); } export function validateTemplate(path, kind) { const cfg = KINDS[kind]; if (!cfg) throw new Error(`Unknown template kind: ${kind}`); const text = readFileSync(path, 'utf-8'); const missing = cfg.required.filter((ph) => !text.includes(`{{${ph}}}`)); return { ok: missing.length === 0, missing }; } export function loadProfileDefault(kind, { profilePath = DEFAULT_PROFILE_PATH } = {}) { const cfg = KINDS[kind]; if (!cfg) throw new Error(`Unknown template kind: ${kind}`); if (!existsSync(profilePath)) return null; let doc; try { doc = yaml.load(readFileSync(profilePath, 'utf-8')) || {}; } catch { return null; } let node = doc; for (const key of cfg.profileKey) node = node?.[key]; return typeof node === 'string' && node.trim() ? node.trim() : null; } export function resolveTemplate(kind, name, opts = {}) { const cfg = KINDS[kind]; if (!cfg) throw new Error(`Unknown template kind: ${kind}`); const { dir = DEFAULT_TEMPLATES_DIR, format = 'html', profilePath = DEFAULT_PROFILE_PATH, fallback = false, } = opts; assertFormat(format); const explicit = Boolean(name && String(name).trim()); let chosen = kebab(explicit ? name : loadProfileDefault(kind, { profilePath }) || 'standard'); const fileFor = (n) => (n === 'standard' ? `${cfg.prefix}.${format}` : `${cfg.prefix}.${n}.${format}`); let path = resolve(dir, fileFor(chosen)); if (!existsSync(path)) { if (fallback && chosen !== 'standard') { chosen = 'standard'; path = resolve(dir, fileFor(chosen)); } if (!existsSync(path)) { throw new Error(`Template not found for kind=${kind} name=${chosen} (${fileFor(chosen)})`); } } if (format === 'html') { const v = validateTemplate(path, kind); if (!v.ok) { throw new Error( `Template ${fileFor(chosen)} missing required placeholders: ${v.missing.map((m) => `{{${m}}}`).join(', ')}` ); } } return path; } // ---- CLI ---- const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (isMain) { const argv = process.argv.slice(2); const cmd = argv[0]; const kind = argv[1]; const flags = Object.fromEntries( argv.filter((a) => a.startsWith('--')).map((a) => { const [k, v] = a.replace(/^--/, '').split('='); return [k, v ?? true]; }) ); const positionals = argv.slice(2).filter((a) => !a.startsWith('--')); const format = flags.format || 'html'; try { if (cmd === 'list') { const items = listTemplates(kind, { format }).map(({ name, displayName }) => ({ name, displayName })); process.stdout.write(JSON.stringify(items, null, 2) + '\n'); } else if (cmd === 'resolve') { const name = positionals[0]; process.stdout.write(resolveTemplate(kind, name, { format, fallback: Boolean(flags.fallback) }) + '\n'); } else { process.stderr.write('Usage: node cv-templates.mjs [name] [--format=html|tex] [--fallback]\n'); process.exit(2); } } catch (err) { process.stderr.write(`${err.message}\n`); process.exit(1); } }