Ctrl K
ComunidadGuías…

lib/cv-markdown.mjs

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

Archivo original ↗Preguntar a KiBot
/**
 * lib/cv-markdown.mjs — read cv.md's structure through Pandoc-flavored
 * Markdown (#4879).
 *
 * cv-title-check.mjs, verify-cv-structure.mjs and jd-skill-gap.mjs each find
 * cv.md's sections and entries with regexes. Those regexes were written
 * against examples/cv-example.md, and a cv.md converted from a Word file with
 * Pandoc carries the same content in a different shape:
 *
 *   ## **[Professional Experience]{.smallcaps}**
 *
 *   ### **Acme Health, Inc.** --- New York, NY (Remote)
 *
 *   **Senior Director, Platform Engineering**\
 *   **09/2021 -- Present**
 *
 * Every check read that as "no Experience section" and degraded without a
 * word: cv-title-check compared 0 entries and still printed ✅. This module is
 * the one place that knows how to see through that shape, so the three checks
 * cannot drift apart again.
 *
 * Normalization is deliberately split in two:
 *   - normalizePandocLine() keeps structure: it drops hard breaks, unwraps
 *     `[text]{.class}` spans and turns Pandoc's `---` / `--` into the em / en
 *     dash they render as, but leaves `**bold**` and backslash escapes alone,
 *     so a caller can still ask "is this whole line bold?".
 *   - toPlainText() is for a value the caller has already located (a heading,
 *     a title, a dates line): it removes emphasis markers and unescapes.
 * Unescaping last matters: `\*\*` is a literal pair of asterisks, and turning
 * it into `**` before the bold test would invent emphasis that isn't there.
 *
 * Imports nothing, so doctor.mjs can use it without pulling in a check.
 */

// A whole line of three or more `-`, `*` or `_` is a thematic break (or YAML
// front matter), and a line starting with `|` is a table row. Neither is prose
// Pandoc would have smartened, so their dashes are left alone.
const THEMATIC_BREAK_RE = /^\s*([-*_])(?:\s*\1){2,}\s*$/;
const TABLE_ROW_RE = /^\s*\|/;

/**
 * Normalize one line of Pandoc Markdown without losing its structure.
 *
 * @param {string} line
 * @returns {string}
 */
export function normalizePandocLine(line) {
  let s = String(line ?? '').replace(/\r$/, '');
  // Hard line break: a single trailing backslash. `\\` at the end is an
  // escaped literal backslash, not a break.
  s = s.replace(/(?<!\\)\\\s*$/, '');
  // Bracketed span with attributes: `[Experience]{.smallcaps}` → `Experience`.
  s = s.replace(/\[([^\]\n]*)\]\{[^}\n]*\}/g, '$1');
  if (!THEMATIC_BREAK_RE.test(s) && !TABLE_ROW_RE.test(s)) {
    // Pandoc's smart typography: `---` is an em dash, `--` an en dash.
    s = s.replace(/(?<!-)---(?!-)/g, '—').replace(/(?<!-)--(?!-)/g, '–');
  }
  return s;
}

/**
 * Plain text of an already-located value: emphasis markers removed, backslash
 * escapes (`\|`, `\$`, `\*`, ...) resolved, whitespace collapsed.
 *
 * @param {string} text
 * @returns {string}
 */
export function toPlainText(text) {
  return String(text ?? '')
    .replace(/(?<!\\)(\*\*|__)/g, '')
    .replace(/\\([!-/:-@[-`{-~])/g, '$1')
    .replace(/\s+/g, ' ')
    .trim();
}

/**
 * The plain text of an ATX heading of exactly `level`, or null when `line` is
 * not one. Pandoc's trailing attribute block (`{#experience .unnumbered}`) and
 * optional closing hashes are dropped.
 *
 * @param {string} line a line already passed through normalizePandocLine()
 * @param {number} level
 * @returns {string|null}
 */
export function headingText(line, level) {
  const m = String(line ?? '').match(new RegExp(`^#{${level}}\\s+(.+?)\\s*$`));
  if (!m) return null;
  const text = m[1]
    .replace(/\s*\{[^}]*\}\s*$/, '')
    .replace(/\s+#+\s*$/, '');
  return toPlainText(text);
}

/**
 * The plain text of a line that is bold from end to end (`**Job Title**`), or
 * null. A line that is only partly bold (`**Title** · 2020`) is not a title
 * line.
 *
 * @param {string} line a line already passed through normalizePandocLine()
 * @returns {string|null}
 */
export function boldLineText(line) {
  const m = String(line ?? '').match(/^(\*\*|__)(.+?)\1\s*$/);
  if (!m) return null;
  // The lazy group grows past a closing marker to reach the last one on the
  // line, so `**Title** · **2020**` matched as a single span. An unescaped
  // marker of the same kind inside means the line holds several bold runs.
  if (new RegExp(`(?<!\\\\)${m[1] === '**' ? '\\*\\*' : '__'}`).test(m[2])) return null;
  return toPlainText(m[2]);
}

// The Experience section names the checks accept. Matched against the plain
// heading text, so `## **[Professional Experience]{.smallcaps}**` qualifies.
//
// The Spanish aliases close the same gap the English list once had for the
// Spanish market: a cv.md whose Experience section is `## Experiencia Laboral`
// was read as "no Experience section" by every check built on this module
// (cv-title-check, verify-cv-structure), which degraded them to a silent
// non-comparison. `modes/es/` makes the Spanish market a supported one, so
// the section name a Spanish CV actually uses belongs here.
const EXPERIENCE_HEADING_RE = /^(?:(?:work|professional)\s+)?experience$|^(?:employment|work)\s+history$|^(?:experiencia(?:\s+(?:laboral|profesional))?)$/i;

/** Human-readable list of the accepted names, for warnings and usage text. */
export const EXPERIENCE_HEADING_NAMES = [
  'Experience', 'Work Experience', 'Professional Experience', 'Employment History', 'Work History',
  'Experiencia', 'Experiencia Laboral', 'Experiencia Profesional',
];

/**
 * @param {string} text plain heading text
 * @returns {boolean}
 */
export function isExperienceHeading(text) {
  return EXPERIENCE_HEADING_RE.test(String(text ?? '').trim());
}

/**
 * Split a `### Company {—|–|--|-} Location[ · descriptor]` entry heading into
 * its parts, or null when the heading has no separator. `line` is a raw
 * normalized line; anything that is not a level-3 heading returns null.
 *
 * @param {string} line a line already passed through normalizePandocLine()
 * @returns {{company: string, location: string}|null}
 */
export function parseCompanyHeading(line) {
  const header = headingText(line, 3);
  if (header === null) return null;
  const match = header.match(/^(.+?)\s+(?:—|–|--|-)\s+(.+)$/);
  return match ? { company: match[1].trim(), location: match[2].trim() } : null;
}

/**
 * Every recognized `## Experience`-style section of cv.md, as arrays of
 * normalized lines (heading excluded). A section runs to the next level-2
 * heading; `###` entries and level-1 headings do not end it.
 *
 * @param {string} cvText raw cv.md content
 * @returns {string[][]}
 */
export function findExperienceSections(cvText) {
  const lines = String(cvText ?? '').replace(/\r\n/g, '\n').split('\n').map(normalizePandocLine);
  const sections = [];
  let current = null;
  for (const line of lines) {
    const h2 = headingText(line, 2);
    if (h2 !== null) {
      current = isExperienceHeading(h2) ? [] : null;
      if (current) sections.push(current);
      continue;
    }
    if (current) current.push(line);
  }
  return sections;
}