// Shared optional-section stripping for the CV builders (build-cv-html.mjs, // build-cv-latex.mjs). // // Core competencies, work experience, projects, education, certifications, // awards, interests, and skills are the genuinely optional CV sections: a // competency tag row is often redundant with the summary and experience // bullets that prove the same claims, a student or career-switcher has no // professional history to list yet, a candidate's projects are often already // covered under Work Experience, not every candidate has a degree, not every // application carries a certification worth listing, most candidates have no // award to name, most candidates don't add a personal-interests line, and // plenty of candidates list no skills section at all. The templates wrap all // eight unconditionally, so a payload with no entries renders a bare section // header with nothing under it. The builders' buildCompetencies()/ // buildExperience()/buildProjects()/buildEducation()/buildCertifications()/ // buildAwards()/buildInterests()/buildSkills() correctly return '' — nothing // removes the surrounding wrapper, which is what this module does. // // Work experience (#2504) is optional for the people this tool is aimed at — // new graduates, career changers, and anyone leading with projects or // education — not because the section is unimportant. Note that the payload // key being optional says nothing about the template placeholder: // cv-templates.mjs still requires `{{EXPERIENCE}}` to be present in any custom // template, exactly as before. What changed is that an empty `experience` no // longer leaves the wrapper behind. // // Certifications and Interests have no marker in the LaTeX template // (cv-template.tex has neither section at all), so PATTERNS.tex has no // `certifications`/`interests` key — stripEmptySections skips a section // silently when the active format has no pattern for it, rather than trying // to match against `undefined`. Awards, by contrast, is defined for both // formats. // // The professional summary (#5019) is optional too: some house styles leave it // out and open on the experience bullets. It is the one section held as text // rather than as a list of entries, so isEmptySection() treats blank and // whitespace-only text as empty. The LaTeX template has no summary, so it is // html-only. It is also the first section in every shipped HTML template, so // it uses the marker-only boundary described for Skills below. In a template // with no marker after the summary, an end-of-input fallback would take the // whole CV with it. // // ── The Skills sentinel: part of the template contract ─────────────────────── // // Skills is the LAST section in every shipped template, which makes it the one // optional section that may have no following section marker to stop at. Given // the shared `…|$` boundary the others use, stripping an empty trailing Skills // section falls through to true end-of-file and takes the closing // `` (`\end{document}` in LaTeX) with it — a truncated, // unopenable document, which is far worse than the bare header this module // exists to remove. // // So the Skills patterns below deliberately do NOT use the shared boundary. // They use the same marker shapes with the `|$` branch removed, so they stop at // the next marker and never at end-of-input. Because `END` is itself a marker, // that is the `` (`%%%% END %%%%` in LaTeX) sentinel when Skills is // last, and the following section's marker when a custom template puts Skills // somewhere else. Matching the sentinel *only* would be wrong for that second // case: the lazy body would run past every section between Skills and the // sentinel and delete them along with the empty header, which is silent data // loss in a populated CV. Two consequences, both intentional: // // 1. **The sentinel is part of the template contract.** A template that ends // on its Skills section must place `` / `%%%% END %%%%` // immediately after it. All four shipped templates do; do not remove it // when editing a template's tail. This is documented for custom-template // authors in templates/README.md. // 2. **A template with no marker after Skills FAILS SAFE.** The pattern // simply does not match, `String.replace` is a no-op, and the template // comes out untouched — the Skills section renders as a bare header. That is // the original cosmetic bug, and it is the deliberate choice: a bare // header beats a truncated CV by a wide margin, and the person it lands // on (a third-party template pack with no sentinel and no skills listed) // did nothing wrong. cv-templates.mjs validates custom templates against // `required: ['NAME', 'EXPERIENCE', 'EDUCATION']` and does not — and // need not — require the sentinel, precisely because its absence is // survivable. Never "fix" this by giving the Skills patterns an `|$` // fallback; that trades a cosmetic bug for a destructive one. // // The section body is delimited by markers rather than parsed, so the boundary // pattern carries the whole correctness burden and is easy to get subtly wrong: // // - Stopping at any capitalized comment would also stop at an ordinary // comment inside a section body, truncating the strip and leaving markup // behind. Markers are therefore matched as all-caps only. // - Omitting the end-of-input branch would silently keep a section that // happens to be last in the template. (Skills is the deliberate exception // above — for it, keeping the section is the desired fail-safe.) // - Naming the expected successor ("projects is followed by education") // couples the two strips to each other and to template ordering: once an // empty education block is removed, a named lookahead for it stops matching // and the projects header survives. // // Each of those failure modes reintroduces the bare header this module exists // to remove, and does it silently, so they are covered in // tests/cv-optional-sections.test.mjs. // HTML: ``, all-caps. LaTeX: `%%%% Name %%%%` banners. const HTML_BOUNDARY = String.raw`(?=|$)`; const TEX_BOUNDARY = String.raw`(?=%{4,}\s|$)`; // Marker-only boundaries for Skills — the same marker shapes as the shared // boundaries above, but with NO end-of-input alternative, so a template lacking // any following marker is left untouched rather than truncated. `END` is itself // a marker, so these stop at the `` / `%%%% END %%%%` sentinel when // Skills is last and at the next section's marker when it is not. See "The // Skills sentinel" above before changing these, and never add an `|$` branch. // The HTML summary uses the same marker-only shape for the same reason: it sits // first, so a template with no marker after it must keep its bare header. It // differs in one respect, HTML_SECTION_ONLY below, which does not stop at END. // // The LaTeX one anchors the banner to the start of a line (hence the `m` flag on // the pattern that uses it). Without `^`, dropping the `|$` branch lets the // engine backtrack the opening banner's own greedy trailing `%{4,}`: with no // following banner to stop at it gives back `%` until the leftovers themselves // satisfy the lookahead, matching half the banner and leaving a stray `%%%%` // behind instead of no-opping. Only banners wider than 8 `%` can backtrack that // far, so a narrow fixture will not catch a regression here — the fixtures in // tests/cv-optional-sections.test.mjs use the shipped 28-wide banner on purpose. const HTML_END_SENTINEL = String.raw`(?=)`; // The summary's own boundary: a following SECTION marker only. `` // is a marker but not a section, and a template may put it after an unmarked // {{EXPERIENCE}}, so stopping there would strip the experience with the summary. const HTML_SECTION_ONLY = String.raw`(?=)[A-Z][A-Z ]*-->)`; const TEX_END_SENTINEL = String.raw`(?=^%{4,}\s)`; const PATTERNS = { html: { summary: new RegExp(String.raw`[\s\S]*?` + HTML_SECTION_ONLY), competencies: new RegExp(String.raw`[\s\S]*?` + HTML_BOUNDARY), experience: new RegExp(String.raw`[\s\S]*?` + HTML_BOUNDARY), projects: new RegExp(String.raw`[\s\S]*?` + HTML_BOUNDARY), education: new RegExp(String.raw`[\s\S]*?` + HTML_BOUNDARY), certifications: new RegExp(String.raw`[\s\S]*?` + HTML_BOUNDARY), awards: new RegExp(String.raw`[\s\S]*?` + HTML_BOUNDARY), skills: new RegExp(String.raw`[\s\S]*?` + HTML_END_SENTINEL), interests: new RegExp(String.raw`[\s\S]*?` + HTML_BOUNDARY), }, tex: { // The LaTeX banner is `%%%% Experience %%%%` — mixed case, and not the // "WORK EXPERIENCE" the HTML templates use. experience: new RegExp(String.raw`%{4,}\s+Experience\s+%{4,}[\s\S]*?` + TEX_BOUNDARY), projects: new RegExp(String.raw`%{4,}\s+PROJECTS\s+%{4,}[\s\S]*?` + TEX_BOUNDARY), education: new RegExp(String.raw`%{4,}\s+Education\s+%{4,}[\s\S]*?` + TEX_BOUNDARY), awards: new RegExp(String.raw`%{4,}\s+AWARDS\s+%{4,}[\s\S]*?` + TEX_BOUNDARY), skills: new RegExp(String.raw`%{4,}\s+Technical Skills\s+%{4,}[\s\S]*?` + TEX_END_SENTINEL, 'm'), }, }; export const OPTIONAL_SECTIONS = ['summary', 'competencies', 'experience', 'projects', 'education', 'certifications', 'awards', 'interests', 'skills']; // Sections whose payload value is text rather than a list of entries. const TEXT_SECTIONS = new Set(['summary']); // A list is empty when it has no entries. stripEmptyRenderedSections() hands // every section in as a list, text sections included, so that check runs first. // Text is empty when it would render nothing: absent, blank, whitespace-only, // or a structural value escapeHtml() prints as ''. A number still renders, 0 // included, so it counts as content. export function isEmptySection(payload, section) { const value = payload?.[section]; if (Array.isArray(value)) return value.length === 0; if (TEXT_SECTIONS.has(section)) return value == null || typeof value === 'object' || String(value).trim() === ''; return true; } // Remove every optional section that has no entries in `payload`. Returns the // template unchanged when both are populated. export function stripEmptySections(template, payload, format) { const patterns = PATTERNS[format]; if (!patterns) throw new Error(`Unknown template format: ${format}`); let out = template; for (const section of OPTIONAL_SECTIONS) { const pattern = patterns[section]; if (!pattern) continue; // this format's template has no marker for this section if (isEmptySection(payload, section)) { // A non-matching pattern is a no-op here by design — see the Skills // sentinel note above: no sentinel means no strip, never a fallback to // a looser boundary. out = out.replace(pattern, ''); } } return out; } // ── Content-driven emptiness, for callers holding rendered HTML ────────────── // // isEmptySection answers from the payload the template was filled from. The // web pdf path has no payload to ask: since #2185 the agent emits finished // HTML and the backend writes it straight to disk, so neither builder — and // therefore neither stripEmptySections call site — is on that path, and every // optional section the agent left empty reaches the renderer as a bare header. // // The two exports below answer the same question from the rendered document // instead. They read the section body back out with the PATTERNS.html entry // that stripEmptySections removes with, so a marker or boundary change moves // both halves together and there is still one definition of where a section // starts and ends. // A section's own title is present whether or not the section has content, so // it must not count as content. This matches the shipped templates' // `
`, or a plain heading tag for a custom template // that uses one. Either quote style, matching generate-pdf.mjs's own // SECTION_TITLE_RE: a hand-written or third-party template may use single // quotes, and reading its title as content would keep every empty section. // Applied once — a later heading inside a populated body // keeps its text and correctly reads as content. const SECTION_HEADING_RE = new RegExp( String.raw`<(div|h[1-6])\b[^>]*class=["'][^"']*\bsection-title\b[^"']*["'][^>]*>[\s\S]*?<\/\1>` + String.raw`|<(h[1-6])\b[^>]*>[\s\S]*?<\/\2>`, 'i', ); // Does this section render as a bare header — a title and nothing under it? // // False when the format has no pattern for the section, and false when the // document has no such section at all: both mean there is nothing to strip, // and reporting "empty" for a section that is simply absent would invite a // caller to act on markup that was never there. export function isEmptyRenderedSection(html, section) { const pattern = PATTERNS.html[section]; if (!pattern) return false; const block = html.match(pattern); if (!block) return false; const text = block[0] .replace(//g, ' ') .replace(SECTION_HEADING_RE, ' ') .replace(/<[^>]*>/g, ' ') .replace(/ |\u00a0/g, ' '); return text.trim() === ''; } // Remove every optional section that rendered as a bare header. The emptiness // map is derived from the document as it arrives, before anything is removed, // so one strip cannot change the verdict on the next. export function stripEmptyRenderedSections(html) { const derived = {}; for (const section of OPTIONAL_SECTIONS) { derived[section] = isEmptyRenderedSection(html, section) ? [] : [section]; } return stripEmptySections(html, derived, 'html'); }