#!/usr/bin/env node // Deterministic HTML CV renderer (#557 — the HTML twin of build-cv-latex.mjs). // // The agent reads cv.md + config/profile.yml, tailors the content, and writes a // compact JSON payload. This script merges that payload into the resolved CV // template (default templates/cv-template.html; pass a path resolved by // cv-templates.mjs to honor config-selectable templates, #1691) — it owns every // tag, class, and the HTML escaping, // so the model never has to emit the full document. That moves the PDF step's // output tokens from full HTML markup down to the structured JSON payload while // producing byte-for-byte the same ATS-safe template the agent fills today. // // The script does NOT parse cv.md / YAML: the authoritative read of the source // files stays in the agent (same contract as build-cv-latex.mjs / modes/latex.md). // generate-pdf.mjs remains the single PDF renderer and is unchanged. // // Section HTML partials (#2183): // A template pack can ship a sections/ directory alongside the main template // file (e.g. templates/sections/ for the built-in templates). When a partial // file exists for a section (e.g. sections/experience.html), the builder uses // that file's markup instead of the hard-coded tag structure. Partials follow // the same {{PLACEHOLDER}} convention used by the main template but carry // entry-level field names (COMPANY, PERIOD, ROLE, etc.). When no partial file // is found the built-in fallback builder is used, preserving full backward // compatibility. // // Section completeness (#3852): // A template's manifest may declare the sections it owns (`sections: all`, // or a list — see lib/template-manifest.mjs). A declared section // never falls back: a missing, malformed, or empty partial fails the render // with the file named. Templates that declare nothing keep the silent // fallback above. import { readFile, writeFile, stat, mkdir } from 'fs/promises'; import { existsSync, readFileSync, realpathSync } from 'fs'; import { resolve, dirname, basename, join, extname, isAbsolute } from 'path'; import { fileURLToPath } from 'url'; import { tmpdir } from 'os'; import { loadYaml } from './lib/yaml-document.mjs'; import { stripEmptySections } from './cv-sections-core.mjs'; import { getCareerOpsRoot, getProfilePath } from './path-resolver.mjs'; import { validateLockedSections, sectionKey } from './generate-pdf.mjs'; import { readLockedSections } from './theme-style.mjs'; import { hasRequiredFields, templateSlotWarnings, validatePayload } from './lib/cv-payload-schema.mjs'; import { resolveTrackerPath, resolveWorkspaceRoot } from './tracker-utils.mjs'; import { PAGE_WIDTHS, resolvePageFormat } from './lib/page-format.mjs'; import { PARTIAL_SECTIONS, declaredSections, parseMeta } from './lib/template-manifest.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const DATA_ROOT = getCareerOpsRoot(); const trackerPath = resolveTrackerPath(DATA_ROOT); const workspaceRoot = resolveWorkspaceRoot(trackerPath); const TEMPLATE_PATH = resolve(__dirname, 'templates', 'cv-template.html'); const CONTACT_ROW_RE = /
[\s\S]*?<\/div>/; const TITLE_BLOCK_RE = /\n([ \t]*)\{\{TITLE_BLOCK\}\}/g; const PROFILE_PATH = getProfilePath(); const PHOTO_MIME_BY_EXT = new Map([ ['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.webp', 'image/webp'], ['.gif', 'image/gif'], ]); const PHOTO_STYLES = new Set(['rounded', 'circle', 'square']); const IMAGE_DATA_URL_RE = /^data:image\/(?:png|jpeg|webp|gif);base64,[a-z0-9+/=\s]+$/i; const DEFAULT_SECTION_TITLES = { summary: 'Professional Summary', competencies: 'Core Competencies', experience: 'Work Experience', projects: 'Projects', education: 'Education', certifications: 'Certifications', awards: 'Awards & Honors', interests: 'Interests', skills: 'Skills', }; // Escape user text for HTML text/attribute context. Covers the five characters // that change meaning in markup so tailored bullets containing &, <, >, quotes // (e.g. "R&D", "scaled 10x < budget", 'the "north star" metric') render as // literal text instead of breaking the document or injecting tags. // // Braces too, so candidate text in a filled section is distinct from template // syntax. A bullet quoting {{ .Values.x }} renders literally and cannot // become a substitution or an unresolved placeholder. function escapeHtml(text) { // Blank out only truly absent/structural values. A number or boolean scalar // (e.g. a payload with `year: 2024` instead of `"2024"`) must render its value, // not vanish: the old `typeof text !== 'string' → ''` guard silently dropped // numeric years/dates from the CV while `present` stayed true. if (text === null || text === undefined || typeof text === 'object') return ''; return String(text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') .replace(/\{/g, '{') .replace(/\}/g, '}'); } // Normalize a URL: only allow the schemes the template's contact row uses, // coerce bare emails/domains, and drop javascript:/data: and other // script-bearing schemes. Returns '' when the URL is rejected. function normalizeUrl(url) { if (typeof url !== 'string') return ''; url = url.trim(); if (!url) return ''; const allowedSchemes = ['mailto:', 'tel:', 'http:', 'https:']; const lower = url.toLowerCase(); const hasScheme = allowedSchemes.some(s => lower.startsWith(s)); if (!hasScheme) { if (/^[a-z][a-z0-9+.-]*:/i.test(url)) { // An explicit but disallowed scheme (javascript:, data:, …) — reject it. return ''; } if (url.includes('@') && !url.includes('/')) { url = 'mailto:' + url; } else { url = 'https://' + url; } } return url; } // normalizeUrl(), HTML-escaped for an href attribute. function sanitizeUrl(url) { return escapeHtml(normalizeUrl(url)); } function sanitizeImageSrc(src) { if (typeof src !== 'string') return ''; const value = src.trim(); if (IMAGE_DATA_URL_RE.test(value)) return escapeHtml(value); if (/^https?:\/\//i.test(value)) return sanitizeUrl(value); return ''; } // The payload is written by the tailoring model from an untrusted posting, so a // local photo path is honoured only when it is the one the user set in // config/profile.yml (#5094). data: and http(s) URLs carry no local read. function profilePhotoValue() { try { const doc = loadYaml(readFileSync(PROFILE_PATH, 'utf-8')); const value = doc?.candidate?.photo; return typeof value === 'string' ? value.trim() : ''; } catch { return ''; } } // A relative candidate.photo comes from config/profile.yml, a user-layer file, // so it is read from the data root, where that file and the user's other files // live. It used to resolve against this script's directory, so with the user // layer moved out of the checkout (CAREER_OPS_ROOT, CAREER_OPS_DATA_DIR or a // .career-ops-data marker) the photo sitting next to the profile was never // found and the build failed. The checkout stays as a fallback, so a photo kept // there by an install that moved its data root still resolves. Without a data // root override the two are the same directory. function resolveProfileRelative(photo) { const fromDataRoot = resolve(DATA_ROOT, photo); if (existsSync(fromDataRoot)) return fromDataRoot; const fromCheckout = resolve(__dirname, photo); return existsSync(fromCheckout) ? fromCheckout : fromDataRoot; } async function prepareCandidatePhoto(candidate) { const c = candidate && typeof candidate === 'object' ? { ...candidate } : {}; const photo = typeof c.photo === 'string' ? c.photo.trim() : ''; const style = c.photo_style || c.photoStyle || 'rounded'; if (!PHOTO_STYLES.has(style)) { throw new Error(`Unsupported profile photo style: ${style} (expected rounded, circle, or square)`); } c.photo_style = style; if (!photo) { c.photo = ''; return c; } if (photo.startsWith('data:')) { if (!IMAGE_DATA_URL_RE.test(photo)) { throw new Error('Unsupported profile photo data URL (expected base64 PNG, JPEG, WebP, or GIF)'); } c.photo = photo; return c; } if (/^https?:\/\//i.test(photo)) { c.photo = photo; return c; } if (/^[a-z][a-z0-9+.-]+:/i.test(photo)) { throw new Error(`Unsupported profile photo URL scheme: ${photo.split(':', 1)[0]}`); } if (photo !== profilePhotoValue()) { throw new Error(`Profile photo path ${photo} does not match candidate.photo in config/profile.yml; a local photo path is read only from the profile`); } const photoPath = isAbsolute(photo) ? photo : resolveProfileRelative(photo); const mime = PHOTO_MIME_BY_EXT.get(extname(photoPath).toLowerCase()); if (!mime) { throw new Error(`Unsupported profile photo format: ${photo} (expected PNG, JPEG, WebP, or GIF)`); } let bytes; try { bytes = await readFile(photoPath); } catch (err) { throw new Error(`Profile photo not found or unreadable: ${photo} (${err.code || err.message})`); } if (bytes.length === 0) { throw new Error(`Profile photo is empty: ${photo}`); } c.photo = `data:${mime};base64,${bytes.toString('base64')}`; return c; } function joinItems(items) { if (Array.isArray(items)) return items.join(', '); return typeof items === 'string' ? items : ''; } // A token starts at {{ and ends at }} outside any balanced inner braces. // If inner braces are unbalanced, still report the token through the first // closing pair. Empty tokens also need to be reported (#6396). function* templateTokens(source) { let start = source.indexOf('{{'); while (start !== -1) { const firstClose = source.indexOf('}}', start + 2); if (firstClose === -1) return; let end = firstClose + 2; let depth = 0; for (let i = start + 2; i < source.length - 1; i++) { if (source[i] === '{') depth++; if (source[i] !== '}') continue; if (depth > 0) { depth--; } else if (source[i + 1] === '}') { end = i + 2; break; } } yield { start, end, key: source.slice(start + 2, end - 2) }; start = source.indexOf('{{', end); } } function fillTemplateTokens(source, fields) { let out = ''; let offset = 0; for (const { start, end, key } of templateTokens(source)) { if (!Object.hasOwn(fields, key)) continue; out += source.slice(offset, start) + fields[key]; offset = end; } return out + source.slice(offset); } // ── Section partials (#2183) ──────────────────────────────────────────────── // // Resolve the sections/ directory co-located with a given template file. // For "templates/cv-template.html" this is "templates/sections/". // For a custom pack at "templates/cv-template.compact.html" we look for // "templates/sections/" first (shared by all templates in that directory), // then fall back to the built-in builders. // // Partial file format (v2 — ENTRY zone): // The file is split into two zones: // // 1. Documentation zone (before and after ): // Free-form HTML comments or text. Completely ignored by the parser. // // 2. Entry zone (inside ...): // Contains the per-entry HTML template. {{PLACEHOLDER}} references are // filled by fillEntry(). Optional conditional-block *definitions* are // placed here too, encoded as HTML comments: // // {{PLACEHOLDER}} // // The builder extracts block definitions first, then the remaining markup // becomes the entry template. When a field is absent the entire block is // replaced with '' (or with the fallback if one // is defined, which the certifications partial uses for alignment). // // By keeping documentation outside the ENTRY zone we never need to strip // arbitrary HTML comments from the entry template, which avoids the // CodeQL js/incomplete-multi-character-sanitization rule entirely. // Parse a partial file and return: // { entryTemplate: string, blocks: Map } // // The entry template is extracted from inside .... // Named conditional-block definitions are extracted from the same zone and // removed to leave the clean entry template. No HTML comment stripping is // performed on arbitrary content (no CodeQL sanitization concern). function parsePartial(source) { // Step 1: locate the ENTRY zone. const entryZoneMatch = /([\s\S]*?)/.exec(source); if (!entryZoneMatch) { throw new Error('Malformed partial: missing ... tags'); } const entryZone = entryZoneMatch[1]; // Step 2: extract named conditional-block definitions from the entry zone. const blockRe = /([\s\S]*?)/g; const blocks = new Map(); // Collect the exact definition strings (open-tag + content + close-tag) so we // can remove them verbatim from the entry zone in step 3, without needing any // broad HTML-comment regex (which would trigger CodeQL). const definitionStrings = []; // Collect every definition first. The _EMPTY fallbacks are resolved in a // second pass because a fallback may be defined before the block it belongs // to, and pairing needs to know which block names exist. const definitions = new Map(); let m; while ((m = blockRe.exec(entryZone)) !== null) { const name = m[1]; const content = m[2]; if (name === 'ENTRY') continue; // skip the sentinel itself definitionStrings.push(m[0]); // full match, e.g. bar definitions.set(name, content); } const EMPTY_SUFFIX = '_EMPTY'; for (const [name, content] of definitions) { if (name.endsWith(EMPTY_SUFFIX)) continue; const existing = blocks.get(name) || { present: '', absent: '' }; blocks.set(name, { ...existing, present: content }); } // An EMPTY variant is the absent-field fallback for a sibling block, and the // renderer looks it up under the block's own name. Both spellings pair to the // same block: and attach to ORG_BLOCK. // Falling back to the bare stem keeps a FOO_EMPTY/FOO pair working. for (const [name, content] of definitions) { if (!name.endsWith(EMPTY_SUFFIX)) continue; const stem = name.slice(0, -EMPTY_SUFFIX.length); const base = definitions.has(`${stem}_BLOCK`) ? `${stem}_BLOCK` : stem; const existing = blocks.get(base) || { present: '', absent: '' }; blocks.set(base, { ...existing, absent: content }); } // Step 3: build the entry template by removing the captured block *definitions* // verbatim. Block *references* ({{BLOCKNAME}}) remain and are resolved by // fillEntry() at render time. // We use split/join on the exact captured strings — no broad HTML-comment regex, // so CodeQL js/incomplete-multi-character-sanitization cannot fire. let entryTemplate = entryZone; for (const def of definitionStrings) { entryTemplate = entryTemplate.split(def).join(''); } entryTemplate = entryTemplate.trim(); return { entryTemplate, blocks }; } // Fill an entry template with a map of { PLACEHOLDER: value } substitutions, // resolving conditional blocks before the normal field fill. // blockValues: Map drives which // conditional markup variant to use. function fillEntry(entryTemplate, blocks, fields, blockValues) { let out = entryTemplate; // Resolve conditional blocks: replace {{BLOCK_NAME}} with the // present/absent markup depending on whether the field has a value. if (blockValues) { for (const [name, { value, present }] of blockValues) { const block = blocks.get(name); if (!block) continue; const scalarKey = name.endsWith('_BLOCK') ? name.slice(0, -6) : name; const markup = present ? fillTemplateTokens(block.present, { [name]: value, [scalarKey]: value }) : block.absent; out = fillTemplateTokens(out, { [name]: markup }); } } // Fill remaining scalar placeholders. return fillTemplateTokens(out, fields); } // Load section partials from the sections/ directory co-located with the // template. Returns a Map for each // partial file found. // // Two contracts, chosen by the template's manifest (#3852): // // - A section the manifest declares (`sections: all`, or a list) is owned by // the template. Its partial must exist, parse, and carry a non-empty ENTRY // zone, or this throws and the render fails. Falling back would emit the // built-in DOM the pack exists to replace, with nothing in the output // saying so: a typo in experience.html produced a CV that rendered, // validated, and passed the placeholder check with the two-column markup // the pack was written to avoid. // - A section the manifest does not declare keeps the original contract: // absent from the map when there is no partial or the partial is // malformed, so the built-in builder renders it. // // Every problem is collected before throwing so one run names them all. function loadSectionPartials(templatePath) { const sectionsDir = join(dirname(templatePath), 'sections'); let declared; try { declared = declaredSections(parseMeta(templatePath)); } catch (err) { throw new Error(`Template ${templatePath}: ${err.message}`); } const owned = declared || new Set(); const partials = new Map(); const problems = []; for (const name of PARTIAL_SECTIONS) { const partialPath = join(sectionsDir, `${name}.html`); const rel = `sections/${name}.html`; if (!existsSync(partialPath)) { if (owned.has(name)) problems.push(`${rel} is declared but does not exist`); continue; } let parsed; try { parsed = parsePartial(readFileSync(partialPath, 'utf-8')); } catch (err) { if (owned.has(name)) problems.push(`${rel} is declared but malformed: ${err.message}`); continue; // undeclared: fall back to the built-in builder, as before } if (owned.has(name) && !parsed.entryTemplate) { problems.push(`${rel} is declared but its ENTRY zone is empty`); continue; } partials.set(name, parsed); } if (problems.length) { throw new Error( `Template ${templatePath} declares sections it cannot render:\n` + problems.map((p) => ` - ${p}`).join('\n') + '\nA declared section must ship a partial that parses — fix the file, or drop it from the manifest\'s "sections" list.' ); } return partials; } // ── Section builders ──────────────────────────────────────────────────────── // Each builder accepts an optional `partial` argument ({ entryTemplate, blocks }). // When a partial is provided the builder fills the partial's template instead // of its built-in tag structure. When partial is undefined/null the original // hard-coded output is produced (full backward compatibility). function buildCompetencies(entries, partial) { if (!Array.isArray(entries) || entries.length === 0) return ''; if (!partial) { return entries .filter(Boolean) .map(tag => `${escapeHtml(String(tag))}`) .join('\n '); } const { entryTemplate, blocks } = partial; return entries .filter(Boolean) .map(tag => fillEntry(entryTemplate, blocks, { TAG: escapeHtml(String(tag)) }, null)) .join('\n '); } function buildExperience(entries, partial) { if (!Array.isArray(entries) || entries.length === 0) return ''; if (!partial) { return entries.filter(e => hasRequiredFields(e, 'experience', 'html')).map(e => { const bullets = Array.isArray(e.bullets) ? e.bullets.filter(Boolean).map(b => `
  • ${escapeHtml(b)}
  • `).join('\n') : ''; const location = e.location ? `\n
    ${escapeHtml(e.location)}
    ` : ''; const context = e.context ? `\n
    ${escapeHtml(e.context)}
    ` : ''; return `
    ${escapeHtml(e.company)} ${escapeHtml(e.dates || e.period || '')}
    ${escapeHtml(e.role)}
    ${context}${location}
      ${bullets}
    `; }).join('\n '); } const { entryTemplate, blocks } = partial; return entries.filter(e => hasRequiredFields(e, 'experience', 'html')).map(e => { const bullets = Array.isArray(e.bullets) ? e.bullets.filter(Boolean).map(b => `
  • ${escapeHtml(b)}
  • `).join('\n ') : ''; const blockValues = new Map([ ['LOCATION_BLOCK', { value: escapeHtml(e.location || ''), present: Boolean(e.location) }], ['CONTEXT_BLOCK', { value: escapeHtml(e.context || ''), present: Boolean(e.context) }], ]); return fillEntry(entryTemplate, blocks, { COMPANY: escapeHtml(e.company || ''), PERIOD: escapeHtml(e.dates || e.period || ''), ROLE: escapeHtml(e.role || ''), LOCATION: escapeHtml(e.location || ''), CONTEXT: escapeHtml(e.context || ''), BULLETS: bullets, }, blockValues); }).join('\n '); } function buildProjects(entries, partial) { if (!Array.isArray(entries) || entries.length === 0) return ''; if (!partial) { return entries.filter(e => hasRequiredFields(e, 'projects', 'html')).map(e => { const badge = e.badge ? `${escapeHtml(e.badge)}` : ''; const nameText = escapeHtml(e.name || ''); const url = sanitizeUrl(e.url); const nameHtml = url ? `${nameText}` : nameText; // Prefer a single description; fall back to joining bullets into one line so // a bullets-shaped payload still renders inside the .project-desc block. const descText = e.description || (Array.isArray(e.bullets) ? e.bullets.filter(Boolean).join(' ') : ''); const desc = descText ? `\n
    ${escapeHtml(descText)}
    ` : ''; const tech = e.tech ? `\n
    ${escapeHtml(e.tech)}
    ` : ''; return `
    ${nameHtml}${badge}
    ${desc}${tech}
    `; }).join('\n '); } const { entryTemplate, blocks } = partial; return entries.filter(e => hasRequiredFields(e, 'projects', 'html')).map(e => { const descText = e.description || (Array.isArray(e.bullets) ? e.bullets.filter(Boolean).join(' ') : ''); const blockValues = new Map([ ['BADGE_BLOCK', { value: escapeHtml(e.badge || ''), present: Boolean(e.badge) }], ['DESC_BLOCK', { value: escapeHtml(descText), present: Boolean(descText) }], ['TECH_BLOCK', { value: escapeHtml(e.tech || ''), present: Boolean(e.tech) }], ]); const nameText = escapeHtml(e.name || ''); const url = sanitizeUrl(e.url); const nameHtml = url ? `${nameText}` : nameText; // A bullets array with 2+ items and no description renders one DESC_BLOCK // per bullet, matching how experience renders one
  • per bullet, instead // of joining them into a single block. let entryBlocks = blocks; const multi = !e.description && Array.isArray(e.bullets) ? e.bullets.filter(Boolean) : []; const descBlock = blocks.get('DESC_BLOCK'); if (multi.length > 1 && descBlock) { const present = multi .map(b => { const value = escapeHtml(b); return fillTemplateTokens(descBlock.present, { DESC_BLOCK: value, DESC: value }); }) .join('\n '); entryBlocks = new Map(blocks); entryBlocks.set('DESC_BLOCK', { ...descBlock, present }); } return fillEntry(entryTemplate, entryBlocks, { NAME: nameHtml, BADGE: escapeHtml(e.badge || ''), DESC: escapeHtml(descText), TECH: escapeHtml(e.tech || ''), }, blockValues); }).join('\n '); } function buildEducation(entries, partial) { if (!Array.isArray(entries) || entries.length === 0) return ''; if (!partial) { return entries.filter(e => hasRequiredFields(e, 'education', 'html')).map(e => { const org = e.org ? ` ${escapeHtml(e.org)}` : ''; const location = e.location ? `\n
    ${escapeHtml(e.location)}
    ` : ''; const desc = e.description ? `\n
    ${escapeHtml(e.description)}
    ` : ''; return `
    ${escapeHtml(e.title)}${org}
    ${escapeHtml(e.year || '')}
    ${location}${desc}
    `; }).join('\n '); } const { entryTemplate, blocks } = partial; return entries.filter(e => hasRequiredFields(e, 'education', 'html')).map(e => { const blockValues = new Map([ ['ORG_BLOCK', { value: escapeHtml(e.org || ''), present: Boolean(e.org) }], ['LOCATION_BLOCK', { value: escapeHtml(e.location || ''), present: Boolean(e.location) }], ['DESC_BLOCK', { value: escapeHtml(e.description || ''), present: Boolean(e.description) }], ]); return fillEntry(entryTemplate, blocks, { TITLE: escapeHtml(e.title || ''), ORG: escapeHtml(e.org || ''), LOCATION: escapeHtml(e.location || ''), YEAR: escapeHtml(e.year || ''), DESC: escapeHtml(e.description || ''), }, blockValues); }).join('\n '); } function buildCertifications(entries, partial) { if (!Array.isArray(entries) || entries.length === 0) return ''; if (!partial) { return entries.filter(e => hasRequiredFields(e, 'certifications', 'html')).map(e => { const org = e.org ? `${escapeHtml(e.org)}` : ''; const year = e.year ? `${escapeHtml(e.year)}` : ''; return `
    ${escapeHtml(e.title)} ${org} ${year}
    `; }).join('\n '); } const { entryTemplate, blocks } = partial; return entries.filter(e => hasRequiredFields(e, 'certifications', 'html')).map(e => { const blockValues = new Map([ // An absent field resolves to the partial's _EMPTY fallback, emitting an // empty for table-cell alignment rather than being removed. ['ORG_BLOCK', { value: escapeHtml(e.org || ''), present: Boolean(e.org) }], ['YEAR_BLOCK', { value: escapeHtml(e.year || ''), present: Boolean(e.year) }], ]); return fillEntry(entryTemplate, blocks, { TITLE: escapeHtml(e.title || ''), ORG: escapeHtml(e.org || ''), YEAR: escapeHtml(e.year || ''), }, blockValues); }).join('\n '); } // Awards mirror certifications: a title with an optional issuing body and year, // laid out on one baseline. Kept as its own builder rather than an alias so the // two can diverge (and so awards.html can be authored independently of // certifications.html) without one section's markup leaking into the other. function buildAwards(entries, partial) { if (!Array.isArray(entries) || entries.length === 0) return ''; if (!partial) { return entries.filter(e => hasRequiredFields(e, 'awards', 'html')).map(e => { const org = e.org ? `${escapeHtml(e.org)}` : ''; const year = e.year ? `${escapeHtml(e.year)}` : ''; return `
    ${escapeHtml(e.title)} ${org} ${year}
    `; }).join('\n '); } const { entryTemplate, blocks } = partial; return entries.filter(e => hasRequiredFields(e, 'awards', 'html')).map(e => { const blockValues = new Map([ // As with certifications, an absent field resolves to the partial's // _EMPTY fallback so the table cells stay aligned across rows. ['ORG_BLOCK', { value: escapeHtml(e.org || ''), present: Boolean(e.org) }], ['YEAR_BLOCK', { value: escapeHtml(e.year || ''), present: Boolean(e.year) }], ]); return fillEntry(entryTemplate, blocks, { TITLE: escapeHtml(e.title || ''), ORG: escapeHtml(e.org || ''), YEAR: escapeHtml(e.year || ''), }, blockValues); }).join('\n '); } // Interests renders as one comma-joined, sentence-cased line rather than a // repeating table like certifications/awards, so a partial's entryTemplate // (built for one row per entry) doesn't fit — html-only, no partial support, // same tradeoff certifications/competencies make for having no LaTeX marker. function buildInterests(items) { if (!Array.isArray(items) || items.length === 0) return ''; return items .filter(Boolean) .map(String) .map((item, idx) => (idx === 0 ? item : item.charAt(0).toLowerCase() + item.slice(1))) .map(item => escapeHtml(item)) .join(', '); } function buildSkills(categories, partial) { if (!Array.isArray(categories) || categories.length === 0) return ''; if (!partial) { const kept = categories.filter(c => hasRequiredFields(c, 'skills', 'html')); if (kept.length === 0) return ''; const items = kept.map(c => { const cat = c.category ? `${escapeHtml(c.category)}: ` : ''; return `
    ${cat}${escapeHtml(joinItems(c.items))}
    `; }).join('\n'); return `
    \n${items}\n
    `; } const { entryTemplate, blocks } = partial; const kept = categories.filter(c => hasRequiredFields(c, 'skills', 'html')); if (kept.length === 0) return ''; const items = kept.map(c => { const blockValues = new Map([ ['CATEGORY_BLOCK', { value: escapeHtml(c.category || ''), present: Boolean(c.category) }], ]); return fillEntry(entryTemplate, blocks, { CATEGORY: escapeHtml(c.category || ''), ITEMS_TEXT: escapeHtml(joinItems(c.items)), }, blockValues); }).join('\n'); return `
    \n${items}\n
    `; } // Rebuild the whole .contact-row block. Its markup uses fixed "|" separators // between phone / email / linkedin / github / portfolio / location, so an // absent optional field (phone, linkedin, github, portfolio) must drop BOTH // its and one separator. Building the present items and joining them is // more robust than excising separators from the template one placeholder at // a time. function buildContactRow(candidate) { const c = candidate || {}; const items = []; if (c.phone) { const tel = sanitizeUrl('tel:' + String(c.phone).replace(/\s+/g, '')); items.push(`${escapeHtml(c.phone)}`); } if (c.email) { items.push(`${escapeHtml(c.email)}`); } if (c.linkedin && c.linkedin.url) { items.push(`${escapeHtml(c.linkedin.display || c.linkedin.url)}`); } if (c.github && c.github.url) { const githubHref = sanitizeUrl(c.github.url); if (githubHref) { items.push(`${escapeHtml(c.github.display || c.github.url)}`); } } if (c.portfolio && c.portfolio.url) { items.push(`${escapeHtml(c.portfolio.display || c.portfolio.url)}`); } if (c.location) { items.push(`${escapeHtml(c.location)}`); } // Separators are generated by CSS (`.contact-row > *:not(:last-child)::after`) // rather than emitted as their own elements. An element of its own is an // independent box on both the flex and the inline path, so wrapping could put // a separator at the start of a line; generated content belongs to the item // before it and cannot be separated from it by a line break. return `
    \n ${items.join('\n ')}\n
    `; } function buildPhoto(candidate, name) { const photo = candidate && candidate.photo; if (!photo) return ''; const style = PHOTO_STYLES.has(candidate.photo_style) ? candidate.photo_style : 'rounded'; return `${escapeHtml(name || '')}`; } // Professional title / headline under the name (candidate.title). An ATS reads // this first to place the candidate ("Backend Engineer" vs "Accountant"); a CV // with no title forces the reader to infer the role. Empty/absent → no element, // so a payload without a title renders byte-identical to before. function buildTitle(candidate) { const title = candidate && candidate.title != null ? String(candidate.title).trim() : ''; return title ? `
    ${escapeHtml(title)}
    ` : ''; } function renderReport(payload, partials) { const sectionTitles = { ...DEFAULT_SECTION_TITLES, ...(payload.sections || {}) }; const candidate = payload.candidate || {}; // The sheet this body has to fit is chosen by generate-pdf.mjs, so both read // the same resolver rather than each keeping a fallback of their own. const pageWidth = PAGE_WIDTHS[resolvePageFormat(payload.page_format, { profilePath: PROFILE_PATH, country: payload.country })]; const substitutions = { LANG: escapeHtml(payload.lang || 'en'), PAGE_WIDTH: pageWidth, NAME: escapeHtml(candidate.name || ''), SECTION_SUMMARY: escapeHtml(sectionTitles.summary), SUMMARY_TEXT: escapeHtml(payload.summary), SECTION_COMPETENCIES: escapeHtml(sectionTitles.competencies), COMPETENCIES: buildCompetencies(payload.competencies, partials.get('competencies')), SECTION_EXPERIENCE: escapeHtml(sectionTitles.experience), EXPERIENCE: buildExperience(payload.experience, partials.get('experience')), SECTION_PROJECTS: escapeHtml(sectionTitles.projects), PROJECTS: buildProjects(payload.projects, partials.get('projects')), SECTION_EDUCATION: escapeHtml(sectionTitles.education), EDUCATION: buildEducation(payload.education, partials.get('education')), SECTION_CERTIFICATIONS: escapeHtml(sectionTitles.certifications), CERTIFICATIONS: buildCertifications(payload.certifications, partials.get('certifications')), SECTION_AWARDS: escapeHtml(sectionTitles.awards), AWARDS: buildAwards(payload.awards, partials.get('awards')), SECTION_INTERESTS: escapeHtml(sectionTitles.interests), INTERESTS: buildInterests(payload.interests), SECTION_SKILLS: escapeHtml(sectionTitles.skills), SKILLS: buildSkills(payload.skills, partials.get('skills')), CONSENT: escapeHtml((payload.consent || '').trim()), }; return { substitutions, candidate }; } function checkTemplateTokens(source, isResolved = () => false) { const unresolved = new Set(); for (const token of templateTokens(source)) { if (!isResolved(token)) unresolved.add(source.slice(token.start, token.end)); } if (unresolved.size) { throw new Error(`Unresolved placeholders: ${[...unresolved].join(', ')}`); } } // Merge a payload into the template and return the final HTML (throws on any // unresolved placeholder so a malformed template fails loudly, not silently). function renderHtml(template, payload, templatePath) { // Load section partials from the sections/ directory co-located with the // template. Falls back to built-in builders when no partials directory exists. const partials = templatePath ? loadSectionPartials(templatePath) : new Map(); const { substitutions, candidate } = renderReport(payload, partials); substitutions.PHOTO = buildPhoto(candidate, candidate.name); // Check the outer template before removing optional sections. The contact // row is replaced as a unit, and only a line-leading TITLE_BLOCK is filled. const source = template.replace(CONTACT_ROW_RE, ''); const titleSlots = new Set(Array.from(source.matchAll(TITLE_BLOCK_RE), (match) => match.index + 1 + match[1].length)); checkTemplateTokens(source, ({ start, key }) => Object.hasOwn(substitutions, key) || titleSlots.has(start)); // The contact row is rebuilt before placeholder fill to drop separators. let html = template.replace(CONTACT_ROW_RE, () => buildContactRow(candidate)); // Captures the placeholder's own leading newline + indentation so an empty // title drops the whole line — matching just the token (as every other // {{PLACEHOLDER}} above does) would leave a blank line where the token sat, // which is not byte-identical to a template that never had the slot // (CodeRabbit, #3763). With a title, the indentation is reused verbatim so // output is unchanged from the token-only replace this replaces. const titleBlock = buildTitle(candidate); html = html.replace(TITLE_BLOCK_RE, (_, indent) => (titleBlock ? `\n${indent}${titleBlock}` : '')); // Drop the optional sections (projects, education) that have no entries, so // an absent one leaves no bare header behind. See cv-sections-core.mjs. html = stripEmptySections(html, payload, 'html'); html = fillTemplateTokens(html, substitutions); // Only emitted partial markup can leave a token here. Candidate text was // escaped before insertion, and the outer fill never reuses an inserted token. checkTemplateTokens(html); return html; } // Payload validation lives in lib/cv-payload-schema.mjs, shared with // build-cv-latex.mjs: the two formats have different key contracts (this one's // education entry is {title, org, year, description}; the LaTeX one is // {institution, degree, dates, coursework}), and keeping both tables in one // place is what lets each reject the other's vocabulary by name (#3523). function countBullets(payload) { const ex = Array.isArray(payload.experience) ? payload.experience.flatMap(e => (Array.isArray(e?.bullets) ? e.bullets : [])) : []; return ex.length; } async function writeAndReport(html, absOutput, payload, extra = {}) { const { warnings = [], ...rest } = extra; const outDir = dirname(absOutput); if (!existsSync(outDir)) await mkdir(outDir, { recursive: true }); const lockedKeys = readLockedSections(PROFILE_PATH, sectionKey); const cvPath = resolve(workspaceRoot, 'cv.md'); const cvMarkdown = existsSync(cvPath) ? readFileSync(cvPath, 'utf-8') : ''; validateLockedSections(html, cvMarkdown, lockedKeys); await writeFile(absOutput, html, 'utf-8'); const fileInfo = await stat(absOutput); const report = { ...rest, file: basename(absOutput), path: absOutput, sizeKB: parseFloat((fileInfo.size / 1024).toFixed(1)), counts: { competencies: (payload.competencies || []).length, experienceEntries: (payload.experience || []).length, projectEntries: (payload.projects || []).length, educationEntries: (payload.education || []).length, certificationEntries: (payload.certifications || []).length, awardEntries: (payload.awards || []).length, skillCategories: (payload.skills || []).length, totalBullets: countBullets(payload), }, warnings, valid: true, }; console.log(JSON.stringify(report, null, 2)); } // ── Markdown rendering (#4824) ────────────────────────────────────────────── // // A zero-token, read-only view of the same payload the HTML builder renders, so // a built CV can be reviewed or diffed against cv.md without a browser. It // resolves section titles exactly like renderReport() (DEFAULT_SECTION_TITLES // plus payload.sections) and applies the same entry filter (hasRequiredFields), // so the markdown lists what the PDF contains. Sections are emitted in the order // their {{SECTION_*}} placeholders appear in the template (the default one, or // the same [template.html] the HTML build was given), and a section the template // does not carry is left out, as it is in the HTML. function mdLine(value) { return String(value ?? '').replace(/\s*\r?\n\s*/g, ' ').trim(); } function mdJoin(parts, sep) { return parts.map(mdLine).filter(Boolean).join(sep); } // A normalized URL with the characters that delimit a Markdown link // destination (parentheses, angle brackets, whitespace, backslash) // percent-encoded, so e.g. an unmatched ')' cannot end the link early. function mdUrl(url) { return normalizeUrl(url).replace(/[()<>\\\s]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`); } // The {{TOKEN}} that renders each payload section, for templateSlotWarnings. // renderHtml fills the content slot; renderMarkdown keeps a section only where // the template places its title (templateSectionOrder below), and the consent // footer only where the template has one. const HTML_SECTION_SLOTS = { summary: 'SUMMARY_TEXT', competencies: 'COMPETENCIES', experience: 'EXPERIENCE', projects: 'PROJECTS', education: 'EDUCATION', certifications: 'CERTIFICATIONS', awards: 'AWARDS', interests: 'INTERESTS', skills: 'SKILLS', consent: 'CONSENT', }; function markdownSectionSlots() { return { ...Object.fromEntries(Object.keys(DEFAULT_SECTION_TITLES).map((key) => [key, `SECTION_${key.toUpperCase()}`])), consent: 'CONSENT', }; } // Section keys in the order the template places their {{SECTION_*}} titles. function templateSectionOrder(template) { return Object.keys(DEFAULT_SECTION_TITLES) .map(key => [key, template.indexOf(`{{SECTION_${key.toUpperCase()}}}`)]) .filter(([, idx]) => idx !== -1) .sort((a, b) => a[1] - b[1]) .map(([key]) => key); } function renderMarkdown(payload, template) { const titles = { ...DEFAULT_SECTION_TITLES, ...(payload.sections || {}) }; const candidate = payload.candidate || {}; const out = []; const bodies = new Map(); const section = (key, body) => { if (body.length) bodies.set(key, [`## ${mdLine(titles[key])}`, '', ...body]); }; const link = (c) => (c && c.url ? mdLine(c.display || c.url) : ''); out.push(`# ${mdLine(candidate.name)}`, ''); if (mdLine(candidate.title)) out.push(mdLine(candidate.title), ''); const contact = mdJoin([ candidate.phone, candidate.email, link(candidate.linkedin), link(candidate.github), link(candidate.portfolio), candidate.location, ], ' | '); if (contact) out.push(contact, ''); const list = (v) => (Array.isArray(v) ? v : []); if (mdLine(payload.summary)) section('summary', [mdLine(payload.summary), '']); const tags = list(payload.competencies).filter(Boolean).map(mdLine); if (tags.length) section('competencies', [tags.join(', '), '']); const exp = []; for (const e of list(payload.experience).filter(e => hasRequiredFields(e, 'experience', 'html'))) { exp.push(`### ${mdJoin([e.company, e.role], ' — ')}`, ''); const meta = mdJoin([e.location, e.dates || e.period], ' · '); if (meta) exp.push(`*${meta}*`, ''); if (mdLine(e.context)) exp.push(mdLine(e.context), ''); const bullets = list(e.bullets).filter(Boolean); for (const b of bullets) exp.push(`- ${mdLine(b)}`); if (bullets.length) exp.push(''); } section('experience', exp); const proj = []; for (const e of list(payload.projects).filter(e => hasRequiredFields(e, 'projects', 'html'))) { const url = mdUrl(e.url); // Escape the label's own delimiters so a name cannot close the link early. const name = url ? `[${mdLine(e.name).replace(/[\\[\]]/g, '\\$&')}](${url})` : mdLine(e.name); proj.push(`### ${mdJoin([name, e.badge], ' · ')}`, ''); const desc = e.description || list(e.bullets).filter(Boolean).join(' '); if (mdLine(desc)) proj.push(mdLine(desc), ''); if (mdLine(e.tech)) proj.push(`*${mdLine(e.tech)}*`, ''); } section('projects', proj); const edu = []; for (const e of list(payload.education).filter(e => hasRequiredFields(e, 'education', 'html'))) { edu.push(`**${mdJoin([e.title, e.org], ' — ')}**${mdLine(e.year) ? ` · ${mdLine(e.year)}` : ''}`, ''); if (mdLine(e.location)) edu.push(mdLine(e.location), ''); if (mdLine(e.description)) edu.push(mdLine(e.description), ''); } section('education', edu); for (const key of ['certifications', 'awards']) { const rows = list(payload[key]).filter(e => hasRequiredFields(e, key, 'html')).map(e => `- **${mdLine(e.title)}**${mdLine(e.org) ? ` — ${mdLine(e.org)}` : ''}${mdLine(e.year) ? ` · ${mdLine(e.year)}` : ''}`); section(key, rows.length ? [...rows, ''] : []); } const interests = list(payload.interests).filter(Boolean).map(String) .map((item, idx) => (idx === 0 ? item : item.charAt(0).toLowerCase() + item.slice(1))) .join(', '); if (mdLine(interests)) section('interests', [mdLine(interests), '']); const skills = list(payload.skills).filter(c => hasRequiredFields(c, 'skills', 'html')).map(c => `- ${mdLine(c.category) ? `**${mdLine(c.category)}:** ` : ''}${mdLine(joinItems(c.items))}`); section('skills', skills.length ? [...skills, ''] : []); for (const key of templateSectionOrder(template)) { if (bodies.has(key)) out.push(...bodies.get(key)); } // The GDPR/RODO consent footer, where the template carries one (as the HTML does). if (template.includes('{{CONSENT}}') && mdLine(payload.consent)) out.push('---', '', mdLine(payload.consent), ''); return out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'; } async function runMarkdown(args) { const outIdx = args.indexOf('--markdown'); const outputPath = args[outIdx + 1]; const [inputPath, templateArg] = args.filter((a, i) => i !== outIdx && i !== outIdx + 1); if (!inputPath || !outputPath || outputPath.startsWith('--')) { console.error('Usage: node build-cv-html.mjs --markdown [template.html]'); process.exit(1); } const absInput = resolve(inputPath); const absOutput = resolve(outputPath); const templatePath = templateArg ? resolve(templateArg) : TEMPLATE_PATH; if (!existsSync(absInput)) { console.error(`Input file not found: ${absInput}`); process.exit(1); } const inputStat = await stat(absInput, { bigint: true }); const outputStat = existsSync(absOutput) ? await stat(absOutput, { bigint: true }) : null; if (outputStat && (realpathSync.native(absOutput) === realpathSync.native(absInput) || (inputStat.ino !== 0n && inputStat.dev === outputStat.dev && inputStat.ino === outputStat.ino))) { console.error(`Output path is the input file: ${absOutput}`); process.exit(1); } // Never overwrite the source payload with its own rendering (a swapped or // repeated argument). realpath also catches a symlink or a ./-relative alias. if (absOutput === absInput || (existsSync(absOutput) && realpathSync(absOutput) === realpathSync(absInput))) { console.error(`Output path is the input file: ${absOutput}`); process.exit(1); } if (!existsSync(templatePath)) { console.error(`Template not found: ${templatePath}`); process.exit(1); } let payload; try { payload = JSON.parse(await readFile(absInput, 'utf-8')); } catch (err) { console.error(`Failed to read CV input: ${err.message}`); process.exit(1); } const { errors, warnings } = validatePayload(payload, 'html'); if (errors.length) { console.error('Invalid CV payload:'); for (const message of errors) console.error(` - ${message}`); console.error(JSON.stringify({ valid: false, errors, warnings }, null, 2)); process.exit(1); } const template = await readFile(templatePath, 'utf-8'); warnings.push(...templateSlotWarnings(payload, template, markdownSectionSlots(), basename(templatePath))); for (const message of warnings) console.error(`Warning: ${message}`); await mkdir(dirname(absOutput), { recursive: true }); await writeFile(absOutput, renderMarkdown(payload, template), 'utf-8'); console.log(JSON.stringify({ file: basename(absOutput), path: absOutput, format: 'markdown', warnings, valid: true }, null, 2)); process.exit(0); } async function main() { const args = process.argv.slice(2); if (args.length === 0 || args.includes('--help')) { console.error('Usage:'); console.error(' node build-cv-html.mjs [template.html]'); console.error(' node build-cv-html.mjs --preview [template.html]'); console.error(' node build-cv-html.mjs --markdown [template.html]'); console.error(' node build-cv-html.mjs --test'); console.error(''); console.error(' [template.html] defaults to templates/cv-template.html. Pass the path'); console.error(' printed by `node cv-templates.mjs resolve cv` to use a selected template.'); console.error(''); console.error(' Section partials (#2183):'); console.error(' If a sections/ directory exists alongside the template file,'); console.error(' the builder loads per-section HTML partial files from it'); console.error(' (e.g. sections/experience.html). Partials control the DOM'); console.error(' structure, tag names, and class names for each section.'); console.error(' When no partial file is found the built-in builder is used —'); console.error(' unless the template manifest declares the section (#3852):'); console.error(' `sections: all` or `sections: experience, education` in the'); console.error(' block makes a missing or malformed'); console.error(' partial fail the build instead of falling back.'); process.exit(args.includes('--help') ? 0 : 1); } if (args.includes('--test')) { await runSelfTest(); return; } if (args.includes('--markdown')) { await runMarkdown(args); return; } const preview = args[0] === '--preview'; const [inputPath, outputPath, templateArg] = preview ? [args[1], resolve(DATA_ROOT, 'output', 'cv-preview.html'), args[2]] : args; if (!inputPath || !outputPath) { console.error('Usage: node build-cv-html.mjs [template.html]'); process.exit(1); } const absInput = resolve(inputPath); const absOutput = resolve(outputPath); const templatePath = templateArg ? resolve(templateArg) : TEMPLATE_PATH; if (!existsSync(absInput)) { console.error(`Input file not found: ${absInput}`); process.exit(1); } const inputStat = await stat(absInput, { bigint: true }); const outputStat = existsSync(absOutput) ? await stat(absOutput, { bigint: true }) : null; if (outputStat && (realpathSync.native(absOutput) === realpathSync.native(absInput) || (inputStat.ino !== 0n && inputStat.dev === outputStat.dev && inputStat.ino === outputStat.ino))) { console.error(`Output path is the input file: ${absOutput}`); process.exit(1); } if (!existsSync(templatePath)) { console.error(`Template not found: ${templatePath}`); process.exit(1); } let payload; try { payload = JSON.parse(await readFile(absInput, 'utf-8')); payload.candidate = await prepareCandidatePhoto(payload.candidate); } catch (err) { console.error(`Failed to prepare CV input: ${err.message}`); process.exit(1); } const { errors, warnings } = validatePayload(payload, 'html'); if (errors.length) { console.error('Invalid CV payload:'); for (const message of errors) console.error(` - ${message}`); console.error(JSON.stringify({ valid: false, errors, warnings }, null, 2)); process.exit(1); } const template = await readFile(templatePath, 'utf-8'); warnings.push(...templateSlotWarnings(payload, template, HTML_SECTION_SLOTS, basename(templatePath))); for (const message of warnings) console.error(`Warning: ${message}`); let html; try { html = renderHtml(template, payload, templatePath); } catch (err) { console.error(err.message); process.exit(1); } await writeAndReport(html, absOutput, payload, preview ? { status: 'preview-ready', warnings } : { warnings }); process.exit(0); } async function runSelfTest() { const sample = { lang: 'en', page_format: 'letter', candidate: { name: 'Test Candidate', phone: '+1 234 567 8900', email: 'test@example.com', linkedin: { url: 'https://linkedin.com/in/test', display: 'linkedin.com/in/test' }, github: { url: 'https://github.com/test', display: 'github.com/test' }, portfolio: { url: 'https://test.example.com', display: 'test.example.com' }, location: 'City, State', }, summary: 'Backend engineer with a focus on R&D and cost-efficient "north star" systems.', competencies: ['Cloud Architecture', 'RESTful API Design', 'Kubernetes & Docker'], experience: [{ company: 'Test Corp', role: 'Test Engineer', location: 'Remote', context: 'Seed-stage startup; joined as employee #7.', dates: 'June 2024 - Present', bullets: [ 'Built automated testing pipelines with CI/CD integration', 'Reduced regression test time by 60% through parallel execution', ], }], projects: [{ name: 'Test Project', badge: 'Open Source', tech: 'Python, FastAPI, Docker', description: 'Built a REST API with automated test coverage exceeding 90%.', }], education: [{ title: 'Bachelor of Science in Computer Science', org: 'Test University', location: 'City, State', year: '2024', description: 'Coursework: Data Structures, Algorithms, Machine Learning.', }], certifications: [{ title: 'Certified Kubernetes Administrator', org: 'CNCF', year: '2025' }], awards: [{ title: 'Gold Medal, International Olympiad in Informatics', org: 'IOI', year: '2023' }], skills: [ { category: 'Languages', items: 'Python, JavaScript, TypeScript' }, { category: 'Frameworks', items: ['FastAPI', 'React', 'PyTorch'] }, ], interests: ['Reading sci-fi & fantasy', 'Hiking', 'Chess'], }; if (!existsSync(TEMPLATE_PATH)) { console.error(`Self-test failed: template not found at ${TEMPLATE_PATH}`); process.exit(1); } const template = await readFile(TEMPLATE_PATH, 'utf-8'); let html; try { html = renderHtml(template, sample, TEMPLATE_PATH); } catch (err) { console.error(`Self-test failed: ${err.message}`); process.exit(1); } // Guard the escaping contract: the raw ampersand from "Kubernetes & Docker" // must reach the output escaped, and no unescaped literal must survive. if (!html.includes('Kubernetes & Docker')) { console.error('Self-test failed: HTML escaping did not apply to competency text'); process.exit(1); } if (/Kubernetes & Docker/.test(html)) { console.error('Self-test failed: found an unescaped ampersand in output'); process.exit(1); } // Guard buildInterests(): comma-joined, sentence-cased (only the first item // keeps its capital), and escaped like every other free-text field. if (!html.includes('Reading sci-fi & fantasy, hiking, chess')) { console.error('Self-test failed: Interests did not render as an escaped, comma-joined, sentence-cased line'); process.exit(1); } // Guard the github contact-row case added for #2170: the link must render // with the sanitized href from the sample. if (!html.includes('href="https://github.com/test"')) { console.error('Self-test failed: github contact link missing from output'); process.exit(1); } // Guard the absent-field side of the same case: omitting candidate.github // must drop its whole contact item, leaving no dangling one. // // Counted as ITEMS, not separator elements: separators are generated by CSS // on each item but the last, so there is no separator node to count and an // off-by-one in the item list is what a dangling separator would now look // like. One fewer item is exactly the old "one fewer separator" assertion. const { github, ...candidateWithoutGithub } = sample.candidate; const htmlWithoutGithub = renderHtml(template, { ...sample, candidate: candidateWithoutGithub }); const countSeparators = (h) => { const row = h.match(/
    ([\s\S]*?)<\/div>/); return row ? (row[1].match(/<\/a>|<\/span>/g) || []).length : 0; }; if (htmlWithoutGithub.includes('github.com/test')) { console.error('Self-test failed: github contact link rendered when candidate.github is absent'); process.exit(1); } if (countSeparators(htmlWithoutGithub) !== countSeparators(html) - 1) { console.error('Self-test failed: omitting candidate.github left a dangling separator in the contact row'); process.exit(1); } // Guard the rejected-scheme side: sanitizeUrl() must reject javascript:/data: // github URLs, which must drop the item and separator exactly like an // absent field, never fall through to an empty href="". const htmlWithRejectedGithub = renderHtml(template, { ...sample, candidate: { ...sample.candidate, github: { url: 'javascript:alert(1)', display: 'github.com/test' } }, }); if (htmlWithRejectedGithub.includes('href=""') || htmlWithRejectedGithub.includes('github.com/test')) { console.error('Self-test failed: rejected github URL still rendered a contact item'); process.exit(1); } if (countSeparators(htmlWithRejectedGithub) !== countSeparators(html) - 1) { console.error('Self-test failed: rejected github URL left a dangling separator in the contact row'); process.exit(1); } // Guard that section partial rendering produces expected class names (so a // broken partial file doesn't silently remove structural markup). if (!html.includes('class="job"')) { console.error('Self-test failed: experience section is missing .job class — partial may be broken'); process.exit(1); } if (!html.includes('class="competency-tag"')) { console.error('Self-test failed: competencies section is missing .competency-tag class'); process.exit(1); } if (!html.includes('class="project"')) { console.error('Self-test failed: projects section is missing .project class'); process.exit(1); } if (!html.includes('class="edu-item"')) { console.error('Self-test failed: education section is missing .edu-item class'); process.exit(1); } if (!html.includes('class="cert-item"')) { console.error('Self-test failed: certifications section is missing .cert-item class'); process.exit(1); } if (!html.includes('class="award-item"')) { console.error('Self-test failed: awards section is missing .award-item class'); process.exit(1); } if (!html.includes('class="skills-grid"')) { console.error('Self-test failed: skills section is missing .skills-grid wrapper'); process.exit(1); } // Guard that partials-based rendering produces the correct field values. if (!html.includes('Test Corp') || !html.includes('Test Engineer')) { console.error('Self-test failed: experience entry fields not found in output'); process.exit(1); } // Guard that conditional blocks work: location present → rendered; absent → not. if (!html.includes('class="job-location"')) { console.error('Self-test failed: job-location block not rendered when location is present'); process.exit(1); } if (!html.includes('class="job-context"') || !html.includes('Seed-stage startup; joined as employee #7.')) { console.error('Self-test failed: job-context block not rendered when context is present'); process.exit(1); } // context sits directly under the role, ahead of the location, on both paths: // the section partial and the built-in builder (templates with no sections/). const contextDiv = '
    Seed-stage startup; joined as employee #7.
    '; for (const [path, out] of [['partial', html], ['built-in', buildExperience(sample.experience)]]) { const [role, context, location] = ['class="job-role"', contextDiv, 'class="job-location"'].map(s => out.indexOf(s)); if (role === -1 || !(role < context && context < location)) { console.error(`Self-test failed: ${path} builder did not render job-context between the role and the location`); process.exit(1); } } if (!html.includes('class="edu-location"')) { console.error('Self-test failed: edu-location block not rendered when education location is present'); process.exit(1); } // Guard that a project's bullets array renders one DESC_BLOCK per bullet // (2+ bullets, no description), while a plain description stays a single block. const multiBulletHtml = renderHtml(template, { ...sample, projects: [{ name: 'Multi', bullets: ['First bullet', 'Second bullet', 'Third bullet'] }], }, TEMPLATE_PATH); // Literal placeholder text inside a bullet must render as text, not be // re-read as a template reference (which would fail as an unresolved marker). let literalHtml; try { literalHtml = renderHtml(template, { ...sample, projects: [{ name: 'Literal', bullets: ['Uses {{DESC}} syntax', 'Also {{DESC_BLOCK}} here'] }], }, TEMPLATE_PATH); } catch (err) { console.error(`Self-test failed: literal placeholder text in a project bullet: ${err.message}`); process.exit(1); } if (!literalHtml.includes('{{DESC}}') || !literalHtml.includes('{{DESC_BLOCK}}')) { console.error('Self-test failed: literal placeholders in project bullets were not preserved'); process.exit(1); } if ((multiBulletHtml.match(/class="project-desc"/g) || []).length !== 3) { console.error('Self-test failed: project bullets did not render one block per bullet'); process.exit(1); } if ((html.match(/class="project-desc"/g) || []).length !== 1) { console.error('Self-test failed: project description should render as a single block'); process.exit(1); } // Test with experience and education entries that have no location to verify // the LOCATION_BLOCK conditional removal path. const noLocSample = { ...sample, experience: [{ company: 'Acme', role: 'Engineer', dates: '2023', bullets: [] }], education: [{ title: 'BSc', org: 'Test University', year: '2024' }], projects: [], }; let noLocHtml; try { noLocHtml = renderHtml(template, noLocSample, TEMPLATE_PATH); } catch (err) { console.error(`Self-test failed (no-location variant): ${err.message}`); process.exit(1); } if (noLocHtml.includes('class="job-location"')) { console.error('Self-test failed: job-location block rendered when location is absent'); process.exit(1); } if (noLocHtml.includes('class="job-context"') || buildExperience(noLocSample.experience).includes('class="job-context"')) { console.error('Self-test failed: job-context block rendered when context is absent'); process.exit(1); } if (noLocHtml.includes('class="edu-location"')) { console.error('Self-test failed: edu-location block rendered when education location is absent'); process.exit(1); } // Test with certifications that are missing optional org/year fields to verify // the EMPTY block fallback (empty for table-cell alignment). const noOrgCert = { ...sample, certifications: [{ title: 'No Org Cert' }, { title: 'With Org', org: 'CNCF', year: '2025' }], projects: [], }; let certHtml; try { certHtml = renderHtml(template, noOrgCert, TEMPLATE_PATH); } catch (err) { console.error(`Self-test failed (cert variant): ${err.message}`); process.exit(1); } // The partial should emit an empty for alignment. const orgCount = (certHtml.match(/class="cert-org"/g) || []).length; if (orgCount !== 2) { console.error('Self-test failed: cert-org empty-block not emitted for table alignment'); process.exit(1); } // Guard the payload key contract (#3523): an education section written with // another tool's key names (institution/degree/dates) renders no education at // all. It must be rejected, not silently dropped, and the empty entry must // never reach the output as a bare .edu-item block. const wrongKeyEducation = [{ institution: 'Test University', degree: 'Bachelor of Science in Computer Science', dates: '2024', detail: 'Coursework: Data Structures.', }]; const wrongKeys = validatePayload({ ...sample, education: wrongKeyEducation }, 'html'); if (wrongKeys.errors.length === 0) { console.error('Self-test failed: education entry with wrong key names was accepted'); process.exit(1); } if (!wrongKeys.errors[0].includes('education[0]') || !wrongKeys.errors[0].includes('title') || !wrongKeys.errors[0].includes('institution')) { console.error(`Self-test failed: unhelpful error for wrong education keys: ${wrongKeys.errors[0]}`); process.exit(1); } if (buildEducation(wrongKeyEducation) !== '') { console.error('Self-test failed: buildEducation emitted a block for an entry with no title'); process.exit(1); } // A valid payload must stay clean: no errors, no warnings. const clean = validatePayload(sample, 'html'); if (clean.errors.length || clean.warnings.length) { console.error(`Self-test failed: valid sample payload reported ${JSON.stringify(clean)}`); process.exit(1); } // An extra key on an otherwise valid entry warns (it is ignored at render // time) but does not block the build. const extraKey = validatePayload({ ...sample, certifications: [{ title: 'CKA', org: 'CNCF', year: '2025', credential_id: 'X-1' }], }, 'html'); if (extraKey.errors.length !== 0 || extraKey.warnings.length !== 1 || !extraKey.warnings[0].includes('credential_id')) { console.error(`Self-test failed: unrecognised optional key not warned about: ${JSON.stringify(extraKey)}`); process.exit(1); } // A payload root that is not an object must be rejected: every named section // reads undefined on an array or null root, so it would otherwise validate clean. for (const badRoot of [[], null, 'x', 42]) { if (validatePayload(badRoot, 'html').errors.length === 0) { console.error(`Self-test failed: payload root ${JSON.stringify(badRoot)} was accepted`); process.exit(1); } } // skills[].items is the one required field that is not plain text: a string // or a non-empty array of strings renders; anything else does not. for (const items of ['Python, JavaScript', ['FastAPI', 'React']]) { const ok = validatePayload({ ...sample, skills: [{ category: 'L', items }] }, 'html'); if (ok.errors.length || ok.warnings.length) { console.error(`Self-test failed: valid skills items ${JSON.stringify(items)} rejected: ${JSON.stringify(ok)}`); process.exit(1); } } for (const items of [[], [' '], '', {}, null, undefined]) { if (validatePayload({ ...sample, skills: [{ category: 'L', items }] }, 'html').errors.length === 0) { console.error(`Self-test failed: unrenderable skills items ${JSON.stringify(items)} accepted`); process.exit(1); } } // A skills entry using another vocabulary must fail, not render an empty row. if (validatePayload({ ...sample, skills: [{ label: 'Languages', values: ['JS'] }] }, 'html').errors.length === 0) { console.error('Self-test failed: skills entry with wrong key names was accepted'); process.exit(1); } if (buildSkills([{ label: 'Languages', values: ['JS'] }]) !== '') { console.error('Self-test failed: buildSkills emitted markup for an unrenderable entry'); process.exit(1); } // A mistyped SECTION name is as invisible as a mistyped field name was: the // validator iterates its spec table, so an unknown root key is never visited. const typoSection = validatePayload({ ...sample, educations: sample.education }, 'html'); if (!typoSection.warnings.some(w => w.includes('educations') && w.includes('education'))) { console.error(`Self-test failed: mistyped section name not reported: ${JSON.stringify(typoSection.warnings)}`); process.exit(1); } if (typoSection.errors.length !== 0) { console.error('Self-test failed: an unknown root key must warn, not block the build'); process.exit(1); } // An unknown key with nothing in it is not worth reporting. for (const empty of [{ educations: [] }, { educations: '' }]) { if (validatePayload({ ...sample, ...empty }, 'html').warnings.some(w => w.includes('educations'))) { console.error(`Self-test failed: empty unknown key ${JSON.stringify(empty)} warned`); process.exit(1); } } // Known root keys the builders read but that carry no section must stay quiet. const cleanRoots = validatePayload(sample, 'html'); if (cleanRoots.warnings.length !== 0) { console.error(`Self-test failed: valid sample warned about its own root keys: ${JSON.stringify(cleanRoots.warnings)}`); process.exit(1); } // Every element of a skills items array must render — both builders join the // whole array, so one bad element reaches the CV as "[object Object]". if (validatePayload({ ...sample, skills: [{ category: 'L', items: ['JS', {}] }] }, 'html').errors.length === 0) { console.error('Self-test failed: skills items array with a non-text element was accepted'); process.exit(1); } // A mistyped section arrives in more shapes than an array: an object value // satisfies neither Array.isArray nor hasText, so it slipped through the // first version of this guard. const objectSection = validatePayload({ ...sample, educations: { title: 'BSc' } }, 'html'); if (!objectSection.warnings.some(w => w.includes('educations'))) { console.error('Self-test failed: object-valued unknown root key was not reported'); process.exit(1); } if (validatePayload({ ...sample, educations: {} }, 'html').warnings.some(w => w.includes('educations'))) { console.error('Self-test failed: an empty object unknown key warned'); process.exit(1); } // A scalar is the fourth shape a mistyped section arrives in, after array, // object and string. hasText() is string-only by design, so reusing it here // let a number or a boolean pass as "empty". for (const scalar of [2026, 0, true, false]) { if (!validatePayload({ ...sample, educations: scalar }, 'html').warnings.some(w => w.includes('educations'))) { console.error(`Self-test failed: scalar unknown root key ${JSON.stringify(scalar)} was not reported`); process.exit(1); } } // ...but a genuinely empty value still stays quiet. for (const empty of [null, undefined, '', ' ', [], {}]) { if (validatePayload({ ...sample, educations: empty }, 'html').warnings.some(w => w.includes('educations'))) { console.error(`Self-test failed: empty unknown root key ${JSON.stringify(empty)} warned`); process.exit(1); } } // Every list section carries the same guard, not just education. for (const [section, bad] of [ ['experience', [{ employer: 'Acme', title: 'Engineer' }]], ['projects', [{ project_name: 'Thing' }]], ['education', [{ org: 'Test University', year: '2024' }]], ['certifications', [{ name: 'CKA' }]], ['awards', [{ award: 'Gold Medal' }]], ]) { const result = validatePayload({ ...sample, [section]: bad }, 'html'); if (result.errors.length === 0) { console.error(`Self-test failed: ${section} entry with wrong key names was accepted`); process.exit(1); } } // A blank required field is as broken as an absent one. if (validatePayload({ ...sample, education: [{ title: ' ', org: 'X' }] }, 'html').errors.length === 0) { console.error('Self-test failed: education entry with a blank title was accepted'); process.exit(1); } // ...and so is a non-string one: escapeHtml() renders '' for an object or // array, so {"title": {}} would otherwise write the empty block this guard // exists to prevent. for (const badTitle of [{}, [], 0, true, null]) { if (validatePayload({ ...sample, education: [{ title: badTitle, org: 'X' }] }, 'html').errors.length === 0) { console.error(`Self-test failed: education title ${JSON.stringify(badTitle)} was accepted as text`); process.exit(1); } } const absOutput = resolve(join(tmpdir(), 'build-cv-html-test.html')); await writeAndReport(html, absOutput, sample, { status: 'self-test-passed' }); await import('fs/promises').then(fs => fs.rm(absOutput).catch(() => {})); process.exit(0); } main();