// 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>` -> `