#!/usr/bin/env node /** * generate-cover-letter.mjs — Renders a cover letter payload to PDF. * * Usage: * node generate-cover-letter.mjs --payload payload.json * node generate-cover-letter.mjs --payload payload.json --out output/slug-cover.pdf * * Fills templates/cover-letter-template.html with the payload, then renders * it to PDF via the same Playwright pipeline used for CVs (generate-pdf.mjs). * * `buildHtml` is exported as a pure function so the template can be tested * without loading Playwright (renderHtmlToPdf is imported lazily inside main). */ import { readFileSync, existsSync, mkdirSync } from "fs"; import { dirname, resolve, basename, join } from "path"; import { fileURLToPath, pathToFileURL } from "url"; import { parseArgs } from "util"; const OUTPUT_ROOT = resolve("output"); function safeOutputPath(raw) { // Derive a sanitized filename from raw string (strip path separators and dots) const filename = basename(raw).replace(/[^a-zA-Z0-9._-]/g, "-").replace(/\.{2,}/g, "-"); return join(OUTPUT_ROOT, filename); } function _require(obj, keys, context) { for (const key of keys) { if (!obj || typeof obj !== "object" || !(key in obj)) { throw new Error(`Missing required field: ${context}.${key}`); } } } function escapeHtml(text) { if (!text) return ""; return String(text) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function asUrl(value) { return /^https?:\/\//i.test(value) ? value : `https://${value}`; } function buildContactLine(candidate) { const parts = []; if (candidate.location) parts.push(escapeHtml(candidate.location)); if (candidate.email) { const email = escapeHtml(candidate.email); parts.push(`${email}`); } if (candidate.phone) parts.push(escapeHtml(candidate.phone)); if (candidate.linkedin) { parts.push(`LinkedIn`); } if (candidate.github) { const display = candidate.github.replace(/^https?:\/\//, ""); parts.push(`${escapeHtml(display)}`); } return parts.join(" | "); } function buildCredentialsBlock(candidate) { const credentials = candidate.credentials || []; if (!credentials.length) return ""; return `
${marker} ${text}${url}
`; } return `${escapeHtml(fn)}
`; }).join("\n"); return `${escapeHtml(letter.greeting)}
` : ""; const closingBlock = letter.closing ? `${escapeHtml(letter.closing)}
` : ""; const languageClosingBlock = letter.language_closing ? `${escapeHtml(letter.language_closing)}
` : ""; const problemsBlock = letter.problems_section ? `${escapeHtml(letter.problems_section)}
` : ""; const replacements = { "{{NAME}}": escapeHtml(candidate.name), "{{CONTACT_LINE}}": buildContactLine(candidate), "{{CREDENTIALS_BLOCK}}": buildCredentialsBlock(candidate), "{{ROLE_TITLE}}": escapeHtml(letter.role_title), "{{DATELINE}}": buildDateline(letter), "{{GREETING_BLOCK}}": greetingBlock, "{{OPENING}}": escapeHtml(letter.opening), "{{PROFILE_INTRO}}": escapeHtml(letter.profile_intro), "{{ACHIEVEMENTS_BLOCK}}": buildAchievementsBlock(letter.achievements), "{{PROBLEMS_BLOCK}}": problemsBlock, "{{CLOSING_BLOCK}}": closingBlock, "{{LANGUAGE_CLOSING_BLOCK}}": languageClosingBlock, "{{FOOTNOTES_BLOCK}}": buildFootnotesBlock(letter.footnotes), }; // Single-pass substitution: each {{TOKEN}} is replaced exactly once against // the original template. A single regex pass (rather than iterative // split/join) ensures a substituted value that itself contains a {{TOKEN}} // sequence is left literal instead of being re-interpreted as a placeholder. // Tokens with no entry in the map are left untouched. return html.replace(/\{\{[A-Z_]+\}\}/g, (token) => replacements[token] ?? token); } async function main() { const { values: args } = parseArgs({ options: { payload: { type: "string" }, out: { type: "string" }, help: { type: "boolean", short: "h" }, }, strict: false, }); if (args.help || !args.payload) { console.log(` Usage: node generate-cover-letter.mjs --payload payload.json [--out output/path.pdf] --payload Path to the JSON payload file (required) --out Override output path from payload (optional) `); process.exit(args.help ? 0 : 1); } const payloadPath = resolve(args.payload); if (!existsSync(payloadPath)) { console.error(`ERROR: payload file not found: ${payloadPath}`); process.exit(1); } const payload = JSON.parse(readFileSync(payloadPath, "utf-8")); if (args.out) { payload.output_path = args.out; } if (!payload.output_path) { const company = (payload.letter?.company || "company").toLowerCase().replace(/[^a-z0-9]+/g, "-"); const role = (payload.letter?.role_title || "role").toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 30); payload.output_path = join(OUTPUT_ROOT, `${company}-${role}-cover.pdf`); } else { payload.output_path = safeOutputPath(payload.output_path); } if (!existsSync(OUTPUT_ROOT)) mkdirSync(OUTPUT_ROOT, { recursive: true }); // Imported lazily so buildHtml can be used (and tested) without Playwright. const { renderHtmlToPdf } = await import("./generate-pdf.mjs"); try { const html = buildHtml(payload); const outputPath = resolve(payload.output_path); await renderHtmlToPdf(html, outputPath, { format: "a4" }); console.log(`\nCover letter PDF: ${payload.output_path}`); } catch (err) { console.error("ERROR generating cover letter PDF:"); console.error(err.message); process.exit(1); } } const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; if (isMain) main();