santifer/career-ops · error · Error

Unresolved placeholders

Error message

Unresolved placeholders: ${[...new Set(unresolved)].join(', ')}

What it means

After substituting all `{{key}}` placeholders with values from the substitutions map, the builder re-scans the HTML with PLACEHOLDER_RE. Any `{{...}}` token still present means the caller supplied no substitution for it, so the builder refuses to emit HTML containing unreplaced placeholders and throws with the deduplicated list of offending tokens.

Solutions

  1. Read the error's placeholder list and add matching keys to the substitutions object.
  2. Fix casing/spelling mismatches between template tokens and payload keys.
  3. Escape or strip literal `{{...}}` sequences from user-supplied values (e.g. job descriptions) before substitution.
  4. Validate the payload against lib/cv-payload-schema.mjs before rendering to catch missing keys early.

Example fix

// before
renderTemplate(html, { NAME: 'Ada' });

// after
renderTemplate(html, { NAME: 'Ada', EMAIL: 'ada@example.com', SUMMARY: 'Engineer' });
Defensive patterns

Strategy: validation

Validate before calling

const requiredKeys = [...template.matchAll(/\{\{([A-Z0-9_]+)\}\}/g)].map(m => m[1]);
const missing = [...new Set(requiredKeys)].filter(k => !(k in substitutions));
if (missing.length) throw new Error(`Missing substitution keys: ${missing.join(', ')}`);

Try / catch

try {
  return renderTemplate(tpl, subs);
} catch (e) {
  if (e.message.startsWith('Unresolved placeholders:')) {
    const keys = e.message.slice('Unresolved placeholders: '.length);
    console.error(`Payload missing keys: ${keys}`);
    // merge defaults and retry once
    return renderTemplate(tpl, { ...defaults, ...subs });
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling the render/substitute function where the template contains a `{{SOME_KEY}}` placeholder but the substitutions object either omits that key, spells it differently (case mismatch), or the value itself contains literal `{{...}}` text that re-matches PLACEHOLDER_RE after replacement.

Common situations: A new field was added to the CV template but not to the payload; the payload key casing doesn't match the template token; a job description or bio string legitimately contains `{{...}}` syntax that gets injected verbatim; schema drift between the HTML and LaTeX builders' key contracts.

Understand the failure class

Background: Schema validation failed / invalid input schema: payload rejected because its shape doesn't match the expected schema — this error's family across 28 libraries.

Related errors


AI-assisted analysis of santifer/career-ops@e7abd431fc (2026-09-16). Data as JSON: /api/errors/d63e5c481b244e61. Report an issue: GitHub.

Appendix: source

Thrown at build-cv-html.mjs:702

  const { substitutions, candidate } = renderReport(payload, partials);

  // The contact row and photo carry conditional markup (dropped separators /
  // no <img>), so they are rebuilt as whole blocks before placeholder fill.
  let html = template.replace(CONTACT_ROW_RE, () => buildContactRow(candidate));
  html = html.replace(/\{\{PHOTO\}\}/g, () => buildPhoto(candidate, candidate.name));

  // 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');

  for (const [key, value] of Object.entries(substitutions)) {
    html = html.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), () => value);
  }

  const unresolved = html.match(PLACEHOLDER_RE);
  if (unresolved) {
    throw new Error(`Unresolved placeholders: ${[...new Set(unresolved)].join(', ')}`);
  }
  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 = {}) {

View on GitHub (pinned to e7abd431fc)