Ctrl K
ComunidadGuías…

build-cv-html.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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 = /<div class="contact-row">[\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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
    .replace(/\{/g, '&#123;')
    .replace(/\}/g, '&#125;');
}

// 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 <!--ENTRY--> and after <!--/ENTRY-->):
//      Free-form HTML comments or text. Completely ignored by the parser.
//
//   2. Entry zone (inside <!--ENTRY-->...<!--/ENTRY-->):
//      Contains the per-entry HTML template. {{PLACEHOLDER}} references are
//      filled by fillEntry(). Optional conditional-block *definitions* are
//      placed here too, encoded as HTML comments:
//
//        <!--BLOCK_NAME--><tag>{{PLACEHOLDER}}</tag><!--/BLOCK_NAME-->
//
//      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 <!--BLOCK_NAME_EMPTY--> 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<name, {present: string, absent: string}> }
//
// The entry template is extracted from inside <!--ENTRY-->...<!--/ENTRY-->.
// 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 = /<!--ENTRY-->([\s\S]*?)<!--\/ENTRY-->/.exec(source);
  if (!entryZoneMatch) {
    throw new Error('Malformed partial: missing <!--ENTRY-->...<!--/ENTRY--> tags');
  }
  const entryZone = entryZoneMatch[1];

  // Step 2: extract named conditional-block definitions from the entry zone.
  const blockRe = /<!--([A-Z][A-Z0-9_]+)-->([\s\S]*?)<!--\/\1-->/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. <!--FOO-->bar<!--/FOO-->
    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: <!--ORG_EMPTY--> and <!--ORG_BLOCK_EMPTY--> 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<name, { value: string, present: boolean }> 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<sectionName, { entryTemplate, blocks }> 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 => `<span class="competency-tag">${escapeHtml(String(tag))}</span>`)
      .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 => `        <li>${escapeHtml(b)}</li>`).join('\n')
        : '';
      const location = e.location
        ? `\n    <div class="job-location">${escapeHtml(e.location)}</div>`
        : '';
      const context = e.context
        ? `\n    <div class="job-context">${escapeHtml(e.context)}</div>`
        : '';
      return `<div class="job">
    <div class="job-header">
      <span class="job-company">${escapeHtml(e.company)}</span>
      <span class="job-period">${escapeHtml(e.dates || e.period || '')}</span>
    </div>
    <div class="job-role">${escapeHtml(e.role)}</div>${context}${location}
    <ul>
${bullets}
    </ul>
  </div>`;
    }).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 => `<li>${escapeHtml(b)}</li>`).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
        ? `<span class="project-badge">${escapeHtml(e.badge)}</span>`
        : '';
      const nameText = escapeHtml(e.name || '');
      const url = sanitizeUrl(e.url);
      const nameHtml = url
        ? `<a href="${url}">${nameText}</a>`
        : 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    <div class="project-desc">${escapeHtml(descText)}</div>`
        : '';
      const tech = e.tech
        ? `\n    <div class="project-tech">${escapeHtml(e.tech)}</div>`
        : '';
      return `<div class="project">
    <div class="project-title">${nameHtml}${badge}</div>${desc}${tech}
  </div>`;
    }).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
      ? `<a href="${url}">${nameText}</a>`
      : nameText;
    // A bullets array with 2+ items and no description renders one DESC_BLOCK
    // per bullet, matching how experience renders one <li> 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
        ? ` <span class="edu-org">${escapeHtml(e.org)}</span>`
        : '';
      const location = e.location
        ? `\n    <div class="edu-location">${escapeHtml(e.location)}</div>`
        : '';
      const desc = e.description
        ? `\n    <div class="edu-desc">${escapeHtml(e.description)}</div>`
        : '';
      return `<div class="edu-item">
    <div class="edu-header">
      <div class="edu-title">${escapeHtml(e.title)}${org}</div>
      <div class="edu-year">${escapeHtml(e.year || '')}</div>
    </div>${location}${desc}
  </div>`;
    }).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 ? `<span class="cert-org">${escapeHtml(e.org)}</span>` : '<span class="cert-org"></span>';
      const year = e.year ? `<span class="cert-year">${escapeHtml(e.year)}</span>` : '<span class="cert-year"></span>';
      return `<div class="cert-item">
      <span class="cert-title">${escapeHtml(e.title)}</span>
      ${org}
      ${year}
    </div>`;
    }).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 <span> 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 ? `<span class="award-org">${escapeHtml(e.org)}</span>` : '<span class="award-org"></span>';
      const year = e.year ? `<span class="award-year">${escapeHtml(e.year)}</span>` : '<span class="award-year"></span>';
      return `<div class="award-item">
      <span class="award-title">${escapeHtml(e.title)}</span>
      ${org}
      ${year}
    </div>`;
    }).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
        ? `<span class="skill-category">${escapeHtml(c.category)}:</span> `
        : '';
      return `    <div class="skill-item">${cat}${escapeHtml(joinItems(c.items))}</div>`;
    }).join('\n');
    return `<div class="skills-grid">\n${items}\n  </div>`;
  }

  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 `<div class="skills-grid">\n${items}\n  </div>`;
}

// 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 <a> 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(`<a href="${tel}">${escapeHtml(c.phone)}</a>`);
  }
  if (c.email) {
    items.push(`<a href="${sanitizeUrl('mailto:' + c.email)}">${escapeHtml(c.email)}</a>`);
  }
  if (c.linkedin && c.linkedin.url) {
    items.push(`<a href="${sanitizeUrl(c.linkedin.url)}">${escapeHtml(c.linkedin.display || c.linkedin.url)}</a>`);
  }
  if (c.github && c.github.url) {
    const githubHref = sanitizeUrl(c.github.url);
    if (githubHref) {
      items.push(`<a href="${githubHref}">${escapeHtml(c.github.display || c.github.url)}</a>`);
    }
  }
  if (c.portfolio && c.portfolio.url) {
    items.push(`<a href="${sanitizeUrl(c.portfolio.url)}">${escapeHtml(c.portfolio.display || c.portfolio.url)}</a>`);
  }
  if (c.location) {
    items.push(`<span>${escapeHtml(c.location)}</span>`);
  }
  // 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 `<div class="contact-row">\n      ${items.join('\n      ')}\n    </div>`;
}

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 `<img class="cv-photo cv-photo--${style}" src="${sanitizeImageSrc(photo)}" alt="${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 ? `<div class="header-title">${escapeHtml(title)}</div>` : '';
}

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 <input.json> --markdown <output.md> [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 <input.json> <output.html> [template.html]');
    console.error('  node build-cv-html.mjs --preview <input.json> [template.html]');
    console.error('  node build-cv-html.mjs <input.json> --markdown <output.md> [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('  <!-- career-ops-template --> 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 <input.json> <output.html> [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 &amp; 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 &amp; 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(/<div class="contact-row">([\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 = '<div class="job-context">Seed-stage startup; joined as employee #7.</div>';
  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('&#123;&#123;DESC&#125;&#125;') || !literalHtml.includes('&#123;&#123;DESC_BLOCK&#125;&#125;')) {
    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 <span> 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 <span class="cert-org"> 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();