225 lines
8.2 KiB
JavaScript
225 lines
8.2 KiB
JavaScript
#!/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";
|
|
import { resolveTemplate } from "./cv-templates.mjs";
|
|
|
|
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, """)
|
|
.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(`<a href="mailto:${email}">${email}</a>`);
|
|
}
|
|
if (candidate.phone) parts.push(escapeHtml(candidate.phone));
|
|
if (candidate.linkedin) {
|
|
parts.push(`<a href="${escapeHtml(asUrl(candidate.linkedin))}">LinkedIn</a>`);
|
|
}
|
|
if (candidate.github) {
|
|
const display = candidate.github.replace(/^https?:\/\//, "");
|
|
parts.push(`<a href="${escapeHtml(asUrl(candidate.github))}">${escapeHtml(display)}</a>`);
|
|
}
|
|
return parts.join(" | ");
|
|
}
|
|
|
|
function buildCredentialsBlock(candidate) {
|
|
const credentials = candidate.credentials || [];
|
|
if (!credentials.length) return "";
|
|
return `<div class="credentials">${credentials.map(escapeHtml).join(" | ")}</div>`;
|
|
}
|
|
|
|
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 ` <li><b>${lead},</b> ${impact}</li>`;
|
|
}).join("\n");
|
|
return `<ul class="achievements">\n${items}\n </ul>`;
|
|
}
|
|
|
|
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
|
|
? ` <a href="${escapeHtml(fn.url)}">${escapeHtml(fn.url)}</a>`
|
|
: "";
|
|
return ` <p>${marker} ${text}${url}</p>`;
|
|
}
|
|
return ` <p>${escapeHtml(fn)}</p>`;
|
|
}).join("\n");
|
|
return `<div class="footnotes">\n${lines}\n </div>`;
|
|
}
|
|
|
|
// Resolve the cover-letter template through the shared resolver so a
|
|
// `cover_letter.template` profile default, an explicit `payload.template`, and
|
|
// installed template packs are all honored. Any resolver failure (no profile,
|
|
// no templates dir, bad config) falls back to the base template, preserving the
|
|
// original hardcoded behavior.
|
|
export function resolveCoverTemplatePath(payload = {}, opts = {}) {
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
const base = resolve(scriptDir, "templates", "cover-letter-template.html");
|
|
try {
|
|
return resolveTemplate("cover", payload.template, { format: "html", fallback: true, ...opts });
|
|
} catch {
|
|
return base;
|
|
}
|
|
}
|
|
|
|
export function buildHtml(payload, templatePath) {
|
|
_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 resolvedPath = templatePath || resolveCoverTemplatePath(payload);
|
|
let html = readFileSync(resolvedPath, "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 ? `<p class="greeting">${escapeHtml(letter.greeting)}</p>` : "";
|
|
const closingBlock = letter.closing ? `<p>${escapeHtml(letter.closing)}</p>` : "";
|
|
const languageClosingBlock = letter.language_closing
|
|
? `<p class="language-closing">${escapeHtml(letter.language_closing)}</p>`
|
|
: "";
|
|
const problemsBlock = letter.problems_section ? `<p>${escapeHtml(letter.problems_section)}</p>` : "";
|
|
|
|
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();
|