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
- Read the error's placeholder list and add matching keys to the substitutions object.
- Fix casing/spelling mismatches between template tokens and payload keys.
- Escape or strip literal `{{...}}` sequences from user-supplied values (e.g. job descriptions) before substitution.
- 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
- Validate payloads against lib/cv-payload-schema.mjs before rendering.
- Keep template tokens and payload keys in one shared constants file.
- Sanitize user-supplied strings (job descriptions, bios) to escape `{{` sequences.
- Add a render test with a fully-populated payload to catch schema drift.
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
- Template missing required placeholders: }}`).join(', ')}
- ⚠️ config/profile.yml cv.sections lists
- Malformed partial: missing <!--ENTRY-->...<!--/ENTRY--> tags
- Template missing required placeholders: }}`).join(', ')}
- Template name " " is claimed by two files: and . A name…
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)