santifer/career-ops · error

Template missing required placeholders: }}`).join(', ')}

Error message

Template ${fileFor(chosen)} missing required placeholders: ${v.missing.map((m) => `{{${m}}}`).join(', ')}

What it means

For HTML templates, resolveTemplate() runs validateTemplate(), which reads the file and checks it literally contains each required placeholder token: {{NAME}}, {{EXPERIENCE}}, {{EDUCATION}} for kind 'cv'; {{NAME}}, {{ROLE_TITLE}}, {{OPENING}} for kind 'cover'. The check is a plain substring match for the exact uppercase `{{TOKEN}}` spelling — any missing token is listed in the error. TeX templates (format: 'tex') skip this validation entirely, so the throw only happens on the html path.

Solutions

  1. Add each missing token listed in the message verbatim, uppercase, with double braces (e.g. {{EXPERIENCE}}) at the right spot in the template body
  2. Copy templates/cv-template.html / cover-letter-template base file and restyle around its existing placeholders instead of starting from a foreign layout
  3. Pre-check custom templates with validateTemplate(path, kind) — it returns { ok, missing } without throwing
  4. If the token set genuinely changed for your fork, update the `required` array for that kind in KINDS (system-layer change)

Example fix

<!-- before -->
<h1>Jane Doe</h1>
<section class="jobs">…hard-coded…</section>

<!-- after -->
<h1>{{NAME}}</h1>
<section class="jobs">{{EXPERIENCE}}</section>
<section class="edu">{{EDUCATION}}</section>
Defensive patterns

Strategy: validation

Validate before calling

import { validateTemplate, KINDS } from './cv-templates.mjs';
const v = validateTemplate('templates/cv-template.fancy.html', 'cv');
if (!v.ok) {
  throw new Error(`custom template lacks placeholders: ${v.missing.join(', ')}`);
}

Type guard

import { validateTemplate } from './cv-templates.mjs';
const templateIsValid = (path, kind) => validateTemplate(path, kind).ok;

Try / catch

try {
  resolveTemplate('cv', 'fancy');
} catch (err) {
  if (err.message.includes('missing required placeholders')) {
    // fall back to standard (which always has them) while the custom one is fixed
    resolveTemplate('cv', 'standard');
  } else throw err;
}

Prevention

When it happens

Trigger: A custom cv-template.custom.html that renders experience with a different token like {{JOBS}} or {{experience}} (lowercase); a cover-letter template missing {{ROLE_TITLE}}; placeholders renamed when a designer handed over the HTML; template edited through a tool that HTML-escaped the double braces ({{ → &#123;&#123;).

Common situations: Bringing in a NiceCV/Canva-exported HTML layout that never had career-ops tokens; casing drift after hand-editing; someone 'cleaning up unused variables' from the template; copying a cover template as the base for a cv (each kind requires a different token set).

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@60398d6549 (2026-08-20). Data as JSON: /api/errors/bb331937ea8dfce5. Report an issue: GitHub.

Appendix: source

Thrown at cv-templates.mjs:154

  const explicit = Boolean(name && String(name).trim());
  let chosen = kebab(explicit ? name : loadProfileDefault(kind, { profilePath }) || 'standard');
  const fileFor = (n) => (n === 'standard' ? `${cfg.prefix}.${format}` : `${cfg.prefix}.${n}.${format}`);

  let path = resolve(dir, fileFor(chosen));
  if (!existsSync(path)) {
    if (fallback && chosen !== 'standard') {
      chosen = 'standard';
      path = resolve(dir, fileFor(chosen));
    }
    if (!existsSync(path)) {
      throw new Error(`Template not found for kind=${kind} name=${chosen} (${fileFor(chosen)})`);
    }
  }
  if (format === 'html') {
    const v = validateTemplate(path, kind);
    if (!v.ok) {
      throw new Error(
        `Template ${fileFor(chosen)} missing required placeholders: ${v.missing.map((m) => `{{${m}}}`).join(', ')}`
      );
    }
  }
  return path;
}

// ---- CLI ----
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
  const argv = process.argv.slice(2);
  const cmd = argv[0];
  const kind = argv[1];
  const flags = Object.fromEntries(
    argv.filter((a) => a.startsWith('--')).map((a) => {
      const [k, v] = a.replace(/^--/, '').split('=');
      return [k, v ?? true];
    })

View on GitHub (pinned to 60398d6549)