#!/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 `
${credentials.map(escapeHtml).join("  |  ")}
`; } function buildDateline(letter) { const parts = [letter.company, letter.city, letter.date].filter(Boolean).map(escapeHtml); return parts.join("    "); } function buildAchievementsBlock(achievements) { if (!achievements || !achievements.length) return ""; const items = achievements.map(ach => { const lead = escapeHtml(ach.lead || ""); const impact = escapeHtml(ach.impact || ""); return `
  • ${lead}, ${impact}
  • `; }).join("\n"); return ``; } function buildFootnotesBlock(footnotes) { if (!footnotes || !footnotes.length) return ""; const lines = footnotes.map(fn => { if (typeof fn === "object" && fn !== null) { const marker = escapeHtml(fn.marker || ""); const text = escapeHtml(fn.text || ""); const url = fn.url ? ` ${escapeHtml(fn.url)}` : ""; return `

    ${marker} ${text}${url}

    `; } return `

    ${escapeHtml(fn)}

    `; }).join("\n"); return `
    \n${lines}\n
    `; } export function buildHtml(payload) { _require(payload, ["candidate", "letter"], "payload"); const candidate = payload.candidate; const letter = payload.letter; _require(candidate, ["name"], "candidate"); _require(letter, ["role_title", "opening", "profile_intro"], "letter"); const scriptDir = dirname(fileURLToPath(import.meta.url)); const templatePath = resolve(scriptDir, "templates", "cover-letter-template.html"); let html = readFileSync(templatePath, "utf-8"); // Optional salutation (e.g. "Dear Jane Smith,"). Omitted -> no salutation, // preserving the original behavior for payloads that don't set it. const greetingBlock = letter.greeting ? `

    ${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();