// cv-experience-order.mjs — reverse-chronological guard for rendered CVs. // // Split out of generate-pdf.mjs (which already sits near the repo's practical // file-size ceiling) following the existing *-core.mjs convention used by // liveness-core.mjs and cv-sections-core.mjs. const MONTHS = new Map([ ['jan', 1], ['feb', 2], ['mar', 3], ['apr', 4], ['may', 5], ['jun', 6], ['jul', 7], ['aug', 8], ['sep', 9], ['oct', 10], ['nov', 11], ['dec', 12], ]); // Any element whose class list includes job-period: template packs may change // tag names inside the ENTRY zone of their experience partial (the ATS pack // renders the period in a
, the default pack in a ). A start tag is // read the way HTML parses it: a quoted attribute value runs to its closing // quote, so a `>` inside one (title="a > b") does not end the tag. Its class // attribute is then read the ways HTML allows it to be written: either quote // style or none, and whitespace around `=` or inside the quotes. const START_TAG_RE = /<([a-z][a-z0-9]*)\b((?:[^>"']|"[^"]*"|'[^']*')*)>/gi; const ATTRIBUTE_RE = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; const DISPLAY_PERIOD_MAX = 60; /** * Parse the START of a rendered job-period string. * * Only the start date matters: reverse-chronological ordering is defined by * when each role began. Returns null when no year is present, and a null month * when the period has none or names it in a language MONTHS does not know * ("Ago 2021", "Dez 2022"), so unparseable parts are skipped rather than * guessed at — the same "don't penalize missing data" discipline the location * and country filters use. * * @param {string} period - e.g. "Jan 2021 - Apr 2022", "09/2021 - 04/2022", "2005 - 2008". * @returns {{ year: number, month: number|null }|null} null when no year is found. */ export function parseExperienceStart(period) { if (typeof period !== 'string') return null; // A dash stays a dash: blanked with the other entities, "Jun – Aug 2022" // stops being a range. const text = period .replace(/&(?:[nm]dash|#821[12]|#x201[34]);/gi, '-') .replace(/&(?:[a-z]+|#\d+|#x[0-9a-f]+);/gi, ' ') .trim(); const year = text.match(/\b(19|20)\d{2}\b/); if (!year) return null; const before = text.slice(0, year.index); // "Jun - Aug 2022" names both months ahead of the one year, and the role // started in the first of them. const range = before.match(/\b([a-z]{3})[a-z]*\.?\s*(?:[-\u2013\u2014]|\bto\b)\s*([a-z]+)\.?,?\s*$/i); const month = range ?? before.match(/\b([a-z]{3})[a-z]*\.?,?\s*$/i); const first = month ? (MONTHS.get(month[1].toLowerCase()) ?? null) : null; const last = range ? (MONTHS.get(range[2].slice(0, 3).toLowerCase()) ?? null) : null; // "Nov - Feb 2023" ran past New Year, so the year it names is the end's. const crossed = first !== null && last !== null && first > last; return { year: Number(year[0]) - (crossed ? 1 : 0), month: first ?? numericMonth(before, text.slice(year.index + year[0].length)), }; } /** * The month of "09/2024", "09.2024" or "2024-09", from the text either side of * the year. A full date written day or month first ("03/04/2024") stays unread, * because which number is the month depends on the country. * * @param {string} before - The period's text ahead of the year. * @param {string} after - The period's text after the year. * @returns {number|null} */ function numericMonth(before, after) { const monthFirst = before.match(/(? 0 && /\p{M}/u.test(kept[kept.length - 1])) kept.pop(); return `${kept.join('')}…`; } /** * Whether role `a` provably starts later than role `b`: a later year, or the * same year with both months known. An unknown month never decides. */ function startsLater(a, b) { if (a.year !== b.year) return a.year > b.year; return a.month !== null && b.month !== null && a.month > b.month; } /** Whether a start tag's attribute text gives it the job-period class. */ function hasJobPeriodClass(attributes) { for (const [, name, doubleQuoted, singleQuoted, unquoted] of attributes.matchAll(ATTRIBUTE_RE)) { const value = doubleQuoted ?? singleQuoted ?? unquoted ?? ''; if (name.toLowerCase() === 'class' && value.toLowerCase().split(/\s+/).includes('job-period')) return true; } return false; } /** * The inner HTML of every job-period element, in document order. The scan * resumes after each element's closing tag, and an element that is never * closed is skipped. * * @param {string} html - Rendered CV HTML. * @returns {string[]} */ function jobPeriodContents(html) { const contents = []; const tags = new RegExp(START_TAG_RE); // a fresh lastIndex for every call let tag; while ((tag = tags.exec(html)) !== null) { if (!hasJobPeriodClass(tag[2])) continue; // Count nested elements with the same tag name, so a child's closing tag // does not end the job-period. const sameTag = new RegExp(`<(/?)${tag[1]}\\b((?:[^>"']|"[^"]*"|'[^']*')*)>`, 'gi'); sameTag.lastIndex = tags.lastIndex; let depth = 1; let end; while (depth > 0 && (end = sameTag.exec(html)) !== null) { if (end[1]) depth--; else if (!end[2].trimEnd().endsWith('/')) depth++; } if (depth > 0) continue; contents.push(html.slice(tags.lastIndex, end.index)); tags.lastIndex = sameTag.lastIndex; } return contents; } /** * Enforce reverse-chronological ordering of Work Experience entries. * * Agents tailoring a CV toward a JD are tempted to promote "the most relevant * role" to the top. That is a functional-resume technique and it backfires: * it buries the candidate's most recent senior title, and both ATS parsers and * human recruiters expect newest-first, so deviation reads as concealment. * Tailor via the summary, the competencies block, and bullet selection within * each role instead — never via ordering. * * @param {string} html - Rendered CV HTML. * @param {{ allowNonChronological?: boolean }} [options] - When set, downgrades * a detected inversion from a thrown error to a console warning, for the rare * deliberately non-chronological CV. * @returns {void} */ export function validateCvExperienceOrder(html, { allowNonChronological = false } = {}) { if (typeof html !== 'string') return; const entries = []; for (const content of jobPeriodContents(html)) { // Strip tags to a fixed point: one pass can leave text that re-forms a // tag (`ipt>` -> `