generate-cover-letter.mjs
Rama main · réplica de lectura del repositorio original.
#!/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` and `safeOutputPath` are exported as pure functions so the
* template and --out path guard can be tested without loading Playwright
* (renderHtmlToPdf is imported lazily inside main).
*/
import { readFileSync, existsSync, mkdirSync } from "fs";
import { dirname, resolve, join, relative, isAbsolute } from "path";
import { fileURLToPath } from "url";
import { parseArgs } from "util";
import { assertFacts, newFactWarnings, printFactWarnings } from "./verify-cv-facts.mjs";
import { resolveTemplate } from "./cv-templates.mjs";
import { isMainModule } from "./lib/is-main-module.mjs";
import { PAGE_FORMATS, normalizePageFormat, pageFormatForCountry } from "./lib/page-format.mjs";
import { getCareerOpsRoot } from "./path-resolver.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
// output/ is a USER-layer directory. Anchoring it to the script directory made
// the cover letter unwritable under an external data directory: this module
// insisted on <checkout>/output while the shared PDF guard in generate-pdf.mjs
// required the tracker workspace, and the two could not both be satisfied.
// getCareerOpsRoot() returns the checkout when no external root is configured,
// so the default install is unchanged.
const OUTPUT_ROOT = resolve(getCareerOpsRoot(), "output");
/**
* Resolve a requested cover-letter output path.
*
* Paths that stay inside `output/` keep their relative subdirectory (the
* application-bundle layout `generate-pdf.mjs` already supports). Paths that
* would escape `output/` — `..` traversal or an absolute path outside it —
* are rejected instead of being silently flattened to `output/<basename>`.
*
* @param {string} raw - Caller-supplied --out / payload.output_path value.
* @returns {string} Absolute path inside OUTPUT_ROOT.
*/
export function safeOutputPath(raw) {
if (raw == null || String(raw).trim() === "") {
throw new Error("Refusing to write the cover letter outside output/: (empty path)");
}
const trimmed = String(raw).trim();
const asWritten = resolve(trimmed);
if (containedInOutput(asWritten)) return asWritten;
// Absolute paths and any `..` segment already chose a location; if that
// location is not inside output/, refuse instead of rewriting to a basename.
if (isAbsolute(trimmed) || /(^|[\\/])\.\.([\\/]|$)/.test(trimmed)) {
throw new Error(`Refusing to write the cover letter outside output/: ${raw}`);
}
// Bare filename or a relative path that is not already under output/
// (e.g. --out cover.pdf, or --out output/foo/bar.pdf from another cwd).
const posix = trimmed.replace(/\\/g, "/").replace(/^\.\//, "");
const relativeToRoot = posix === "output" || posix === "output/"
? ""
: posix.startsWith("output/")
? posix.slice("output/".length)
: posix;
const candidate = resolve(OUTPUT_ROOT, relativeToRoot);
if (containedInOutput(candidate)) return candidate;
throw new Error(`Refusing to write the cover letter outside output/: ${raw}`);
}
/** True when absPath is a file (not output/ itself) still inside OUTPUT_ROOT. */
function containedInOutput(absPath) {
const rel = relative(OUTPUT_ROOT, absPath);
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
}
/** Assert that a payload object contains the required keys and that their values are non-empty strings. */
function _require(obj, keys, context) {
for (const key of keys) {
const value = obj && typeof obj === "object" ? obj[key] : undefined;
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`Missing required field: ${context}.${key}`);
}
}
}
/** Assert that a payload contains the required object-shaped fields. */
function _requireObject(obj, keys, context) {
for (const key of keys) {
if (!obj || typeof obj !== "object" || !(key in obj) || typeof obj[key] !== "object" || obj[key] === null) {
throw new Error(`Missing required field: ${context}.${key}`);
}
}
}
/** Escape user-provided text before inserting it into generated HTML. */
function escapeHtml(text) {
if (!text) return "";
return String(text)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/** Add an HTTPS scheme to a profile URL when it is omitted. */
function asUrl(value) {
return /^https?:\/\//i.test(value) ? value : `https://${value}`;
}
/** Build the escaped contact line shown in the cover-letter header. */
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) {
const display = candidate.linkedin.replace(/^https?:\/\//i, "");
parts.push(`<a href="${escapeHtml(asUrl(candidate.linkedin))}">${escapeHtml(display)}</a>`);
}
if (candidate.github) {
const display = candidate.github.replace(/^https?:\/\//i, "");
parts.push(`<a href="${escapeHtml(asUrl(candidate.github))}">${escapeHtml(display)}</a>`);
}
return parts.join(" | ");
}
/** Build the optional credentials line from the candidate payload. */
function buildCredentialsBlock(candidate) {
const credentials = candidate.credentials || [];
if (!credentials.length) return "";
return `<div class="credentials">${credentials.map(escapeHtml).join(" | ")}</div>`;
}
/** Build the escaped company, city, and date line for the letter. */
function buildDateline(letter, hasRecipientBlock = false) {
// The pack contract gives {{DATELINE}} the date and leaves the company and
// city to the address block directly beneath it, so joining all three prints
// the company twice, three lines apart.
//
// Gated on the block actually RENDERING, not on `letter.recipient` merely
// being set — see the caller, which needs both the data and a template slot
// to conclude that. An empty or whitespace-only recipient produces no address
// block, and a template without a {{RECIPIENT_BLOCK}} slot has nowhere to put
// one; either way, dropping company and city would lose them with nothing
// taking their place. The shipped base template is the second case, so it
// keeps the full join exactly as before.
const company = typeof letter.company === "string" ? letter.company : "";
const parts = hasRecipientBlock
? [letter.date]
: [company, letter.city, letter.date];
return parts.filter(Boolean).map(escapeHtml).join(" ");
}
/**
* Build the optional recipient address block for a business letter.
*
* The pack authoring contract places {{RECIPIENT_BLOCK}} bare and expects the
* filler to emit its own wrapper, so this returns a complete
* `<div class="recipient">` or an empty string, never a bare fragment. Each
* line is its own `<div>` rather than a `<br>` join, which is what the packs'
* own CSS targets.
*
* A partial recipient is normal and renders as far as it goes: a company with
* no named individual, or a name with no street address, are both ordinary
* states for a cover letter. Only a recipient with nothing usable in it (blank or whitespace-only fields included), or no
* recipient at all, yields the empty string, so a letter without an addressee
* still renders instead of failing.
*
* Accepts `address_lines` (array, the contract's shape) or `address` (string).
*/
function buildRecipientBlock(letter) {
const r = letter.recipient;
if (!r || typeof r !== "object") return "";
const addressLines = Array.isArray(r.address_lines)
? r.address_lines
: r.address
? [r.address]
: [];
// Trim before filtering: `filter(Boolean)` alone keeps " ", which renders as
// a blank line inside the wrapper rather than as the absent field it is.
// Falsy first, so 0 / "" / null drop out as they always have, THEN coerce.
// Coercing first would turn 0 into the string "0" and keep it as an address
// line; leaving a truthy non-string uncoerced crashes the compare below.
const lines = [r.name, r.title, r.company, ...addressLines]
.filter(Boolean)
.map((v) => String(v).trim())
.filter(Boolean);
if (!lines.length) return "";
// Once this block renders, the dateline drops the company and city, so they
// have to land here or they leave the letter entirely. A recipient given as a
// bare name is the case that exposed it: the block held one line, the dateline
// went date-only, and both values were simply gone from the output.
//
// Appended only when the recipient did not already supply them, compared
// case-insensitively against every line including the address, so a recipient
// that names its own company keeps exactly one copy.
// Compared by comma-delimited component, not by whole line. An address line
// is routinely "123 Main St, Boston, MA" while letter.city is "Boston, MA":
// the lines differ, so a whole-line compare appends the city a second time,
// which is the duplication this whole change exists to stop.
// A trailing US ZIP is stripped from the LAST component only. "Boston, MA
// 02101" and the city "Boston, MA" otherwise differ in that component and the
// city gets appended a second time, and an address carrying a ZIP is the
// ordinary case rather than an edge one. Confined to the final component and
// to a recognisable ZIP shape, so a street number cannot be eaten; formats
// this cannot recognise simply keep today's behaviour.
const components = (v) => {
const parts = String(v)
.split(",")
.map((part) => part.toLowerCase().replace(/\s+/g, " ").trim())
.filter(Boolean);
if (parts.length) {
const last = parts[parts.length - 1].replace(/\s+\d{5}(?:-\d{4})?$/, "").trim();
if (last) parts[parts.length - 1] = last;
}
return parts;
};
/** Does `hay` contain `needle` as a contiguous run of components? */
const containsRun = (hay, needle) => {
if (!needle.length || needle.length > hay.length) return false;
for (let i = 0; i + needle.length <= hay.length; i++) {
if (needle.every((n, j) => hay[i + j] === n)) return true;
}
return false;
};
// Where each one belongs differs, so they are not both appended. A company
// goes straight after the recipient's name and title and ABOVE the street
// address; pushing it to the end put it below the street, which is not an
// address block (#4069 review). The city stays last, where it already sat.
// `lines` starts as name, title, company, then the address lines, all through
// the same filters, so the first `headCount` entries are exactly that head.
const headCount = [r.name, r.title, r.company]
.filter(Boolean)
.map((v) => String(v).trim())
.filter(Boolean).length;
for (const [extra, insertAt] of [[letter.company, headCount], [letter.city, null]]) {
const v = typeof extra === "string" ? extra.trim() : "";
if (!v) continue;
const want = components(v);
if (lines.some((l) => containsRun(components(l), want))) continue;
if (insertAt === null) lines.push(v);
else lines.splice(insertAt, 0, v);
}
const escaped = lines.map(escapeHtml);
return `<div class="recipient">\n${escaped.map((l) => ` <div>${l}</div>`).join("\n")}\n </div>`;
}
/** Build the optional achievements list for the letter body. */
function buildAchievementsBlock(achievements) {
if (!achievements || !achievements.length) return "";
const items = achievements.map(ach => {
// Trim a caller-supplied trailing comma (cover.md's own bullet-format
// example shows the lead ending in a comma) so it never doubles up with
// the comma this function always appends.
const lead = escapeHtml((ach.lead || "").replace(/,\s*$/, ""));
const impact = escapeHtml(ach.impact || "");
return ` <li><b>${lead},</b> ${impact}</li>`;
}).join("\n");
return `<ul class="achievements">\n${items}\n </ul>`;
}
/** Build the optional footnotes block with escaped links. */
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>`;
}
/**
* Build the optional sign-off block: a valediction over the signing name.
*
* Accepts either a plain string (used verbatim as the valediction) or an
* object `{ valediction, name }`. `name` defaults to the candidate name so a
* payload can set only the valediction. Returns "" when unset, which keeps
* every pre-existing payload rendering byte-identical.
*/
function buildSignatureBlock(signature, candidateName) {
if (!signature) return "";
const isObject = typeof signature === "object" && signature !== null;
const valediction = isObject ? signature.valediction : signature;
const name = (isObject ? signature.name : "") || candidateName || "";
if (!valediction && !name) return "";
// Each value is escaped independently; the <br> separator is template markup
// emitted between them, never injected into escaped content.
const lines = [valediction, name].filter(Boolean).map(escapeHtml);
return `<p class="signature">${lines.join("<br>")}</p>`;
}
// 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. With nothing configured the
// resolver returns the base template (its default name is `standard`).
//
// Resolver errors propagate (#6370). This used to pass `fallback: true` and
// catch everything, returning the base template, so a malformed
// config/profile.yml, a template name that does not exist, or a template
// missing required placeholders all rendered a plain letter with no warning:
// the candidate's letterhead, signature or layout silently dropped. That
// contradicts modes/cover.md Step 8 and `cv-templates.mjs resolve cover`,
// which both surface the error. main() already prints a buildHtml error and
// exits 1. A caller can still pass `{ fallback: true }` in opts, but that only
// covers a template NAME that does not exist (it then resolves `standard`). A
// malformed profile or an invalid template is an error either way: falling
// back over those is exactly the silent substitution this function stopped
// doing.
export function resolveCoverTemplatePath(payload = {}, opts = {}) {
return resolveTemplate("cover", payload.template, { format: "html", strict: true, ...opts });
}
/**
* Replace every balanced `{{ … }}` token in `text` in a single pass.
*
* Depth-aware: `{{` opens a level and `}}` closes one, and a token is the span
* from an opening `{{` to the `}}` that brings the depth back to zero. So
* `{{ outer {{ inner }} }}` is ONE token, not `{{ outer {{ inner }}` followed by
* a stray ` }}`. Single braces (`({})`) do not count.
*
* An opening `{{` that is never closed is left as literal text and the scan
* continues just after it. Giving up there instead would leave every later
* token in the template unfilled and unreported, which is worse than what a
* regex did.
*
* Each token is replaced by `replaceToken(token)`, and the result is never
* re-scanned, so a substituted value that contains `{{…}}` stays literal.
*
* @param {string} text
* @param {(token: string) => string} replaceToken
* @returns {string}
*/
export function replaceBalancedTokens(text, replaceToken) {
let out = "";
let emitted = 0;
let searchFrom = 0;
while (true) {
const start = text.indexOf("{{", searchFrom);
if (start === -1) break;
let depth = 0;
let end = -1;
for (let i = start; i < text.length - 1; ) {
if (text[i] === "{" && text[i + 1] === "{") {
depth++;
i += 2;
} else if (text[i] === "}" && text[i + 1] === "}") {
depth--;
i += 2;
if (depth === 0) {
end = i;
break;
}
} else {
i++;
}
}
if (end === -1) {
// Unterminated: keep this `{{` literal and look for the next one.
searchFrom = start + 2;
continue;
}
out += text.slice(emitted, start) + replaceToken(text.slice(start, end));
emitted = end;
searchFrom = end;
}
return out + text.slice(emitted);
}
export function buildHtml(payload, templatePath) {
_requireObject(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>` : "";
// Optional sign-off (e.g. valediction "Sincerely," over the signing name).
// Omitted -> no signature, preserving behavior for payloads that don't set it.
// The name falls back to the candidate name so a payload can set only the
// valediction. The <br> is emitted around escaped values, never inside one.
const signatureBlock = buildSignatureBlock(letter.signature, candidate.name);
const recipientBlock = buildRecipientBlock(letter);
// The gate's predicate is "the address block will RENDER", which needs both
// halves: recipient data to put in it, and a slot in the loaded template to
// put it in. The shipped base template has {{DATELINE}} and no
// {{RECIPIENT_BLOCK}}, so a payload carrying a recipient would otherwise lose
// the company and city entirely — dropped from the dateline, with no address
// block downstream to reprint them.
const rendersRecipientBlock = Boolean(recipientBlock) && html.includes("{{RECIPIENT_BLOCK}}");
const replacements = {
"{{NAME}}": escapeHtml(candidate.name),
"{{CONTACT_LINE}}": buildContactLine(candidate),
"{{CREDENTIALS_BLOCK}}": buildCredentialsBlock(candidate),
"{{ROLE_TITLE}}": escapeHtml(letter.role_title),
"{{DATELINE}}": buildDateline(letter, rendersRecipientBlock),
"{{RECIPIENT_BLOCK}}": recipientBlock,
"{{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,
"{{SIGNATURE_BLOCK}}": signatureBlock,
"{{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.
//
// A token with no entry in the map is a template the renderer cannot fill —
// a custom cover-letter template (KINDS.cover in cv-templates.mjs) carrying a
// typo'd or unsupported token. Collect those DURING the pass rather than
// scanning the result: a scan of the output cannot tell a template token from
// the same sequence appearing inside a substituted value, which is exactly
// what the single pass above is careful to leave literal.
//
// The pass visits EVERY `{{…}}` sequence, not only `{{UPPER_SNAKE}}` ones.
// Matching only the shape the map uses meant a lowercase, digit-bearing,
// hyphenated or space-padded token (`{{role_title}}`, `{{OPENING2}}`,
// `{{ COMPANY }}`) was never visited, so it was neither filled nor reported
// and shipped verbatim in the PDF (#6374). Lookup stays exact on purpose:
// `{{ COMPANY }}` is not `{{COMPANY}}`, and guessing would hide the typo.
//
// Tokens are found by balancing `{{` against `}}` (replaceBalancedTokens),
// not by a regex, so a token that contains braces — `{{ config | default({}) }}`
// — or another token — `{{ outer {{ inner }} }}` — is visited and reported
// whole, rather than cut at the first `}}` it happens to contain.
const unresolved = new Set();
const rendered = replaceBalancedTokens(html, (token) => {
const value = replacements[token];
if (value == null) {
unresolved.add(token);
return token;
}
return value;
});
// Fail loudly, matching build-cv-html.mjs and build-cv-latex.mjs. Shipping a
// letter with a literal {{TOKEN}} in it is worse than not producing one.
if (unresolved.size) {
throw new Error(`Unresolved placeholders: ${[...unresolved].join(', ')}`);
}
return rendered;
}
/**
* Answers and other text the candidate supplied for this letter.
*
* Read `user_inputs` (the list cover mode writes) and `answers` (the same
* texts as an object). Do not read `letter`: the draft is the document under
* check, and a sentence that appears only there must not support itself.
*
* @param {object} [payload]
* @returns {string[]}
*/
export function letterInputTexts(payload = {}) {
return [...collectInputText(payload?.user_inputs), ...collectInputText(payload?.answers)];
}
function collectInputText(value) {
if (typeof value === 'string') return value.trim() ? [value] : [];
if (Array.isArray(value)) return value.flatMap(collectInputText);
if (value && typeof value === 'object') return Object.values(value).flatMap(collectInputText);
return [];
}
/** A cover letter is one page by convention; a CV's default budget is 2. */
export const DEFAULT_COVER_MAX_PAGES = 1;
const USAGE = `
Usage:
node generate-cover-letter.mjs --payload payload.json [--out output/path.pdf] [--format letter|a4] [--country XX] [--report NNN] [--max-pages N] [--strict-pages]
--payload Path to the JSON payload file (required)
--out Override output path from payload (optional)
--format Override output PDF page format (letter|a4). Defaults to
config/profile.yml page_format, then letter.
--country Employer's ISO 3166-1 country code (e.g. DE). Picks the sheet
only when page_format is "auto". Defaults to the payload's country.
--report Link the PDF to a tracker report number in data/pdf-index.tsv
--max-pages Preferred letter length in pages (default: ${DEFAULT_COVER_MAX_PAGES})
--strict-pages Fail instead of warning when the letter is longer than --max-pages
`;
/**
* Parse and validate the CLI flags.
*
* Strict on purpose (#6373). With `strict: false` an unknown flag (a typo, or
* --max-pages / --strict-pages, which this script did not declare) was dropped
* without a word, and a string flag given no value swallowed the next flag:
* `--report --out x.pdf` set report to "--out" and wrote the PDF to the
* default path. Every case is now an error that names the flag.
*
* @param {string[]} argv
* @returns {{ ok: true, args: object } | { ok: false, error: string }}
*/
export function parseCoverArgs(argv) {
let values;
try {
({ values } = parseArgs({
args: argv,
options: {
payload: { type: "string" },
out: { type: "string" },
format: { type: "string" },
country: { type: "string" },
report: { type: "string" },
"max-pages": { type: "string" },
"strict-pages": { type: "boolean" },
help: { type: "boolean", short: "h" },
},
strict: true,
allowPositionals: false,
}));
} catch (err) {
return { ok: false, error: err.message };
}
if (values.help) return { ok: true, args: values };
// Supplied flags are validated like generate-pdf.mjs does. A typo that fell
// through to the profile or the payload would print a letter on the wrong sheet.
// `auto` is a profile or payload value, never a sheet, so --format rejects it.
if (values.format !== undefined && !normalizePageFormat(values.format)) {
return { ok: false, error: `Invalid format "${values.format}". Use: ${[...PAGE_FORMATS].join(", ")}` };
}
if (values.country !== undefined && !pageFormatForCountry(values.country)) {
return { ok: false, error: `Invalid country "${values.country}". Use a two-letter ISO 3166-1 code, e.g. --country=DE.` };
}
// Checked as typed, not trimmed: `--report=' 42 '` is not a report number.
if (values.report !== undefined && !/^\d+$/.test(values.report)) {
return { ok: false, error: `Invalid --report "${values.report}". Use a numeric report number, e.g. --report=42.` };
}
// An empty --out= would otherwise be read as "not given" by main(), which
// then writes to the payload's own destination: a flag the caller typed,
// silently ignored.
if (values.out !== undefined && values.out.trim() === "") {
return { ok: false, error: 'Invalid --out "". Give a path inside output/, e.g. --out output/acme-cover.pdf.' };
}
const maxPagesInput = values["max-pages"];
const maxPages = maxPagesInput === undefined ? DEFAULT_COVER_MAX_PAGES : Number(maxPagesInput);
if (!Number.isInteger(maxPages) || maxPages < 1) {
return { ok: false, error: `Invalid --max-pages "${maxPagesInput}". Use a positive integer, e.g. --max-pages=1.` };
}
return {
ok: true,
args: {
...values,
report: values.report,
maxPages,
strictPages: Boolean(values["strict-pages"]),
},
};
}
/** Parse a payload, run the fact gate, and render the cover-letter PDF. */
async function main() {
const parsed = parseCoverArgs(process.argv.slice(2));
if (!parsed.ok) {
console.error(parsed.error);
console.error(USAGE);
process.exit(1);
}
const args = parsed.args;
if (args.help || !args.payload) {
console.log(USAGE);
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 = (typeof payload.letter?.company === "string" ? payload.letter.company : "company")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-");
const role = (typeof payload.letter?.role_title === "string" ? 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 {
try {
payload.output_path = safeOutputPath(payload.output_path);
} catch (err) {
console.error(err.message);
process.exit(1);
}
}
if (!existsSync(OUTPUT_ROOT)) mkdirSync(OUTPUT_ROOT, { recursive: true });
try {
const html = buildHtml(payload);
// Cover letters are candidate-facing documents too. Reuse the CV fact
// validator before importing Playwright or writing a PDF so a failed gate
// cannot leave behind a misleading artifact.
const factOptions = {
label: "cover letter",
planTexts: letterInputTexts(payload),
};
const factCheck = assertFacts(html, factOptions);
// Ahead of the verdict, because it qualifies it: with no config the phrase
// lists are empty, so a silent gate here covers metrics and facts only.
if (factCheck.configMissing) {
console.error("No config/cv-facts.json — forbidden/advisory phrase checks did not run.");
}
if (factCheck.verdict === "warn") {
console.error(`CV fact check warning: cover letter`);
printFactWarnings(factCheck, console.error);
}
// Imported only after fact validation so a failed gate does not load
// Playwright or create a PDF artifact.
const { normalizeTextForATS, reportAtsReplacements, renderHtmlToPdf } = await import("./generate-pdf.mjs");
const outputPath = resolve(payload.output_path);
// The pass a CV gets before it prints. Without it a letter kept its em
// dashes and curly quotes, and printed a draft's **bold** with the asterisks.
const { html: printed, replacements } = normalizeTextForATS(html);
reportAtsReplacements(replacements, console.error);
// As for a CV, the gate also reads the document that prints: the pass can
// spell out a phrase the draft hid, as `self–starter` prints as
// `self-starter`. A forbidden one stops here. Anything advisory is reported,
// unless the check above already named it.
const printedWarnings = newFactWarnings(factCheck, assertFacts(printed, factOptions));
if (printedWarnings) {
console.error(`CV fact check warning: cover letter`);
printFactWarnings(printedWarnings, console.error);
}
const result = await renderHtmlToPdf(printed, outputPath, {
// Passed through unresolved. renderHtmlToPdf ranks it against the user's
// config/profile.yml, so a cover letter and its CV cannot end up on
// different paper because only one of them carried a flag.
format: args.format,
country: args.country || payload.country,
reportNum: args.report,
maxPages: args.maxPages,
strictPages: args.strictPages,
// Declared, never inferred: this script always renders a cover letter, and
// the manifest must not file it as the report's CV (#3887).
kind: 'cover',
inputPath: payloadPath,
});
// A render over a warn-only page budget returns the sibling draft it
// wrote instead and leaves the output path as it was (#6377); saying the
// PDF is there would hand over a file that was never written.
if (result.draftPath) {
console.log(`\nCover letter over its page budget: nothing was written to ${payload.output_path}; the draft is ${result.draftPath}. Trim and rerun.`);
} else {
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 = isMainModule(import.meta.url);
if (isMain) main();