Ctrl K
ComunidadGuías…

lib/cv-payload-schema.mjs

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

Archivo original ↗Preguntar a KiBot
/**
 * Shared CV payload key contract for build-cv-html.mjs and build-cv-latex.mjs.
 *
 * Both builders render each list section from a fixed set of keys. A payload
 * that names them differently produced an empty block while the JSON report
 * still said {"valid": true} — an education section written as
 * {institution, degree, dates} against the HTML builder rendered nothing at
 * all, and CVs went out with no education section (#3523).
 *
 * The two formats do NOT share one schema, which is how the confusion starts:
 * the LaTeX template's education entry really is {institution, degree, location,
 * dates, coursework}, while the HTML template's is {title, org, location, year,
 * description}. Keeping both tables side by side here makes the divergence
 * explicit and lets each builder reject the other's vocabulary by name instead
 * of silently dropping it.
 */

// Per format, per section: the keys the builder reads (`known`) and the ones
// without which the entry renders no visible line (`required`).
export const ENTRY_FIELD_SPECS = {
  html: {
    experience: {
      required: ['company', 'role'],
      known: ['company', 'role', 'location', 'context', 'dates', 'period', 'bullets'],
    },
    projects: {
      required: ['name'],
      known: ['name', 'badge', 'description', 'bullets', 'tech', 'url'],
    },
    education: {
      required: ['title'],
      known: ['title', 'org', 'location', 'year', 'description'],
    },
    certifications: { required: ['title'], known: ['title', 'org', 'year'] },
    awards: { required: ['title'], known: ['title', 'org', 'year'] },
    // category is optional — buildSkills() renders the line without its prefix.
    skills: { required: ['items'], known: ['category', 'items'], rules: { items: hasRenderableItems } },
  },
  tex: {
    experience: {
      required: ['company', 'role'],
      known: ['company', 'role', 'location', 'dates', 'bullets'],
    },
    projects: {
      required: ['name'],
      known: ['name', 'context', 'dates', 'bullets', 'url'],
    },
    education: {
      required: ['institution', 'degree'],
      known: ['institution', 'degree', 'location', 'dates', 'coursework'],
    },
    awards: { required: ['title'], known: ['title', 'org', 'year'] },
    skills: { required: ['items'], known: ['category', 'items'], rules: { items: hasRenderableItems } },
  },
};

// Every root key a builder reads. validatePayload() iterates the spec table, so
// without this it can only see sections it already knows: a payload whose
// section is misspelled ("educations") matches no spec, is never visited, and
// validates clean while the section silently vanishes from the output — the
// same failure as a mistyped field name, one level up (#3523).
export const KNOWN_ROOT_KEYS = {
  html: [
    'lang', 'page_format', 'country', 'candidate', 'sections', 'summary', 'competencies',
    'experience', 'projects', 'education', 'certifications', 'awards',
    'interests', 'skills', 'consent',
  ],
  tex: [
    'name', 'contact_line', 'email', 'linkedin', 'github',
    'education', 'experience', 'projects', 'awards', 'skills',
    // The sheet is a rendering concern, not a section: it selects the paper
    // size in build-cv-latex.mjs exactly as it does in build-cv-html.mjs, so a
    // payload shared between the two formats carries the same keys.
    'page_format', 'country',
  ],
};

// Sections a format has no template block for. Passing them is not an error —
// the payload may be shared across formats — but the entries vanish, so say so
// rather than dropping them in silence.
export const UNRENDERED_SECTIONS = {
  html: [],
  tex: ['certifications', 'competencies', 'interests', 'summary'],
};

/**
 * Populated payload sections the chosen TEMPLATE has no slot for.
 *
 * UNRENDERED_SECTIONS says what a format never renders. Whether a section
 * renders in HTML or Markdown is decided by the template instead: the builder
 * fills only the slots it finds, so a section without one vanishes. Of the
 * shipped HTML templates only cv-template.html has an interests slot, and the
 * ATS and zh-minimal templates have no consent slot, so a populated section
 * there was dropped with warnings: [] and valid: true.
 *
 * @param {object} payload
 * @param {string} template - Text of the template the build fills.
 * @param {Record<string, string>} slots - Payload section → the {{TOKEN}} that renders it.
 * @param {string} [templateName] - Names the template in the message.
 * @returns {string[]}
 */
export function templateSlotWarnings(payload, template, slots, templateName = 'the template') {
  const warnings = [];
  for (const [section, token] of Object.entries(slots)) {
    const what = populatedContent(payload?.[section]);
    if (what && !String(template).includes(`{{${token}}}`)) {
      warnings.push(`${section}: ${templateName} has no ${section} section — ${what} will not appear in the output`);
    }
  }
  return warnings;
}

/**
 * A required field counts as present only when it carries non-blank text, so
 * {"title": ""} fails exactly like an absent key.
 *
 * Strings only, deliberately. escapeHtml() renders '' for an object or array
 * and escapeLatex() renders '' for anything that is not a string, so a
 * {"title": {}} would otherwise pass validation and then write the very empty
 * block this module exists to prevent. escapeHtml()'s support for numeric
 * scalars (a payload with year: 2024) is unaffected: it applies to optional
 * fields, which this predicate does not gate.
 */
export function hasText(value) {
  return typeof value === 'string' && value.trim() !== '';
}

/**
 * `skills[].items` is the one required field that is not plain text: both
 * builders accept a comma-separated string OR an array of strings (joinItems()
 * in the HTML builder, the Array.isArray() branch in the LaTeX one). An empty
 * array, or an array of blanks, renders nothing — so it fails like an absent
 * key, while hasText() stays strict for every other field.
 *
 * EVERY element must be text, not merely one of them: both builders join the
 * whole array, so ['JavaScript', {}] reaches the CV as
 * "JavaScript, [object Object]". A quantifier of `some` would let exactly the
 * junk this module exists to catch through on the strength of its neighbours.
 */
export function hasRenderableItems(value) {
  if (Array.isArray(value)) return value.length > 0 && value.every(item => hasText(item));
  return hasText(value);
}

/**
 * What a root key carries, phrased for a warning — '' when it carries nothing.
 *
 * Arrays, plain objects, text and bare scalars are all shapes a mistyped or
 * unsupported section arrives in: {"educations": [...]}, {"educations": {...}},
 * {"summary": "..."} and {"summary": 2026} are the same mistake wearing
 * different clothes, and a check that only counts arrays sees one of four.
 *
 * Note this deliberately does NOT reuse hasText() for scalars. hasText() is
 * string-only because a required FIELD must render as text, but the question
 * here is different — "did someone put something here that will not render?" —
 * and a number or a boolean answers yes. Sharing the predicate across both
 * questions is what let {"summary": 2026} through in silence.
 */
function populatedContent(value) {
  if (Array.isArray(value)) {
    return value.length > 0 ? `${value.length} entr${value.length > 1 ? 'ies' : 'y'}` : '';
  }
  if (value && typeof value === 'object') {
    return Object.keys(value).length > 0 ? 'its content' : '';
  }
  if (value === null || value === undefined) return '';
  // Any remaining scalar (number, boolean, bigint, symbol) was put there on
  // purpose and will not render; a blank or whitespace-only string was not.
  return typeof value === 'string' ? (value.trim() !== '' ? 'its content' : '') : 'its content';
}

// A required field is present when its own rule says so; hasText is the default.
function fieldPresent(entry, field, spec) {
  const rule = (spec.rules && spec.rules[field]) || hasText;
  return rule(entry[field]);
}

/**
 * Used by the builders as their entry filter: an entry that cannot produce a
 * visible line is dropped rather than emitted as an empty block. The CLIs stop
 * on these before rendering, so this only matters for direct builder calls.
 */
export function hasRequiredFields(entry, section, format) {
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
  const spec = (ENTRY_FIELD_SPECS[format] || {})[section];
  if (!spec) return true;
  return spec.required.every(field => fieldPresent(entry, field, spec));
}

/**
 * Validate a CV payload against one format's key contract.
 *
 * @returns {{errors: string[], warnings: string[]}} — errors are fatal (the
 *   caller must not render); warnings describe keys or sections that will be
 *   ignored.
 */
export function validatePayload(payload, format) {
  const errors = [];
  const warnings = [];
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
    errors.push('expected the CV payload to be an object, got '
      + (Array.isArray(payload) ? 'an array' : payload === null ? 'null' : typeof payload));
    return { errors, warnings };
  }
  const specs = ENTRY_FIELD_SPECS[format];
  if (!specs) {
    errors.push(`unknown payload format: ${format}`);
    return { errors, warnings };
  }
  const plural = (list) => (list.length > 1 ? 's' : '');

  for (const [section, spec] of Object.entries(specs)) {
    const entries = payload ? payload[section] : undefined;
    if (entries === undefined || entries === null) continue;
    if (!Array.isArray(entries)) {
      errors.push(`${section}: expected an array, got ${typeof entries}`);
      continue;
    }
    entries.forEach((entry, i) => {
      const where = `${section}[${i}]`;
      if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
        errors.push(`${where}: expected an object with keys ${spec.known.join(', ')}`);
        return;
      }
      const missing = spec.required.filter(field => !fieldPresent(entry, field, spec));
      const unknown = Object.keys(entry).filter(key => !spec.known.includes(key));
      if (missing.length) {
        errors.push(
          `${where}: missing required field${plural(missing)} ${missing.join(', ')}`
          + (unknown.length
            ? ` — unrecognised key${plural(unknown)} present: ${unknown.join(', ')}`
            : '')
          + ` (known keys: ${spec.known.join(', ')})`
        );
      } else if (unknown.length) {
        warnings.push(
          `${where}: unrecognised key${plural(unknown)} ${unknown.join(', ')} `
          + `will be ignored (known keys: ${spec.known.join(', ')})`
        );
      }
    });
  }

  // An unrecognised root key is a warning, not an error: a payload may be
  // shared across formats and legitimately carry keys this builder ignores.
  // Only a populated one is worth reporting — an empty array or a blank string
  // would have rendered nothing anyway.
  const knownRoots = KNOWN_ROOT_KEYS[format] || [];
  // A section this format declares as unrendered gets the specific message from
  // the loop below — "the template has no such section" is a different fact from
  // "this looks like a typo", and emitting both for one key is noise in the
  // channel that has to stay readable to be read at all.
  const declaredUnrendered = new Set(UNRENDERED_SECTIONS[format] || []);
  for (const key of Object.keys(payload)) {
    if (knownRoots.includes(key) || declaredUnrendered.has(key)) continue;
    const what = populatedContent(payload[key]);
    if (!what) continue;
    const near = knownRoots.filter(k => k.startsWith(key.slice(0, 4)) || key.startsWith(k.slice(0, 4)));
    warnings.push(
      `${key}: not a section the ${format} builder reads, so ${what} will not `
      + `appear in the output`
      + (near.length ? ` — did you mean ${near.join(' or ')}?` : '')
      + ` (known keys: ${knownRoots.join(', ')})`
    );
  }

  for (const section of UNRENDERED_SECTIONS[format] || []) {
    const what = populatedContent(payload[section]);
    if (what) {
      warnings.push(
        `${section}: the ${format} template has no ${section} section — `
        + `${what} will not appear in the output`
      );
    }
  }

  return { errors, warnings };
}