santifer/career-ops · error

Unresolved placeholders

Error message

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

What it means

generate-cover-letter.mjs's buildHtml renders a cover-letter HTML template by single-pass substituting a fixed map of {{TOKEN}} placeholders. If the template contains an all-caps {{TOKEN}} that has no entry in the replacement map (e.g. a typo'd or unsupported token in a custom template), it is left literal, collected, and the function throws listing every unresolved token — refusing to ship a letter containing a literal {{TOKEN}}.

Solutions

  1. Read the listed token names from the error and fix the template: either correct the typo to a supported token or remove the unsupported token
  2. Check which tokens the renderer supports (NAME, CONTACT_LINE, CREDENTIALS_BLOCK, ROLE_TITLE, DATELINE, GREETING_BLOCK, OPENING, PROFILE_INTRO, ACHIEVEMENTS_BLOCK, PROBLEMS_BLOCK, CLOSING_BLOCK, LANGUAGE_CLOSING_BLOCK, SIGNATURE_BLOCK, FOOTNOTES_BLOCK) and use only those
  3. If the token is intentionally literal text in the letter, rewrite it so it does not match /\{\{[A-Z_]+\}\}/ (e.g. lowercase or extra spacing)
  4. Re-run the generator after editing the template

Example fix

<!-- before -->
Dear {{HIRING_MANAGER}},
<!-- after -->
{{GREETING_BLOCK}}

<!-- or, for a literal double-brace example in text: -->
<!-- before --> use {{TOKEN}} syntax
<!-- after --> use &#123;&#123;TOKEN&#125;&#125; syntax
Defensive patterns

Strategy: validation

Validate before calling

const KNOWN = ['NAME','CONTACT_LINE','CREDENTIALS_BLOCK','ROLE_TITLE','DATELINE','GREETING_BLOCK','OPENING','PROFILE_INTRO','ACHIEVEMENTS_BLOCK','PROBLEMS_BLOCK','CLOSING_BLOCK','LANGUAGE_CLOSING_BLOCK','SIGNATURE_BLOCK','FOOTNOTES_BLOCK'];
const found = [...template.matchAll(/\{\{([A-Z_]+)\}\}/g)].map(m => m[1]);
const bad = found.filter(t => !KNOWN.includes(t));
if (bad.length) throw new Error(`Unsupported placeholders: ${bad.join(', ')}`);

Type guard

const isSupportedToken = (token) => typeof token === 'string' && Object.prototype.hasOwnProperty.call(replacements, token);

Try / catch

try {
  const html = buildHtml(payload);
} catch (err) {
  if (err.message.startsWith('Unresolved placeholders:')) {
    console.error('Fix these template tokens:', err.message.slice('Unresolved placeholders:'.length));
    process.exit(1);
  }
  throw err;
}

Prevention

When it happens

Trigger: Calling buildHtml (or the CLI with --format cover) against a cover-letter template that contains a {{TOKEN}} not in the replacements map: a typo like {{NAMEE}} or {{COMPANY}}, a hand-edited custom cover template in cv-templates.mjs with a token the renderer never fills, or an upstream template update adding a token before the renderer gained a replacement for it.

Common situations: Users customizing templates/cv-template variants for cover letters add a new section with a placeholder they invent; template and renderer versions drift after a career-ops update; a fat-fingered token name during template editing.

Related errors


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

Appendix: source

Thrown at generate-cover-letter.mjs:264

  // a custom cover-letter template (KINDS.cover in cv-templates.mjs) carrying a
  // typo'd or unsupported token. Collect those DURING the pass rather than
  // scanning the result: a scan of the output cannot tell a template token from
  // the same sequence appearing inside a substituted value, which is exactly
  // what the single pass above is careful to leave literal.
  const unresolved = new Set();
  const rendered = html.replace(/\{\{[A-Z_]+\}\}/g, (token) => {
    const value = replacements[token];
    if (value == null) {
      unresolved.add(token);
      return token;
    }
    return value;
  });

  // Fail loudly, matching build-cv-html.mjs and build-cv-latex.mjs. Shipping a
  // letter with a literal {{TOKEN}} in it is worse than not producing one.
  if (unresolved.size) {
    throw new Error(`Unresolved placeholders: ${[...unresolved].join(', ')}`);
  }
  return rendered;
}

/** Parse a payload, run the fact gate, and render the cover-letter PDF. */
async function main() {
  const { values: args } = parseArgs({
    options: {
      payload: { type: "string" },
      out:     { type: "string" },
      format:  { type: "string" },
      report:  { type: "string" },
      help:    { type: "boolean", short: "h" },
    },
    strict: false,
  });

  if (args.help || !args.payload) {

View on GitHub (pinned to aac998c7ed)