Sign In

@hrtips/cvx

Package Overview
Dependencies
Maintainers
1
Versions
11
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@hrtips/cvx - npm Package Compare versions

Comparing version
1.4.0
to
1.5.0
+26
lib/pdf/sections/CertificationsSection.js
import { jsx, jsxs } from "react/jsx-runtime";
import { View, Text, StyleSheet } from "@react-pdf/renderer";
import { useStyles } from "../ThemeContext.js";
import SectionTitle from "../components/SectionTitle.js";
const makeStyles = (t) => StyleSheet.create({
wrap: { marginBottom: t.spacing.sectionGap },
item: { marginBottom: 12 },
name: { fontSize: t.typography.degree.size, fontWeight: t.typography.degree.weight, color: t.palette.accent, lineHeight: 1.3 },
issuer: { fontSize: t.typography.institution.size, color: t.palette.textBody, marginTop: t.spacing.entryMetaMt, paddingLeft: t.spacing.itemPl },
year: { fontSize: t.typography.caption.size, color: t.palette.textMuted, marginTop: 0.75, paddingLeft: t.spacing.itemPl }
});
function CertificationsSection({ data }) {
const s = useStyles(makeStyles);
if (!data.certifications?.length) return null;
return /* @__PURE__ */ jsxs(View, { style: s.wrap, children: [
/* @__PURE__ */ jsx(SectionTitle, { variant: "sidebar", children: "Certifications" }),
data.certifications.map((c, i) => /* @__PURE__ */ jsxs(View, { style: s.item, children: [
/* @__PURE__ */ jsx(Text, { style: s.name, children: c.name }),
c.issuer && /* @__PURE__ */ jsx(Text, { style: s.issuer, children: c.issuer }),
c.year && /* @__PURE__ */ jsx(Text, { style: s.year, children: c.year })
] }, i))
] });
}
export {
CertificationsSection as default
};
import { jsx, jsxs } from "react/jsx-runtime";
import { View, Text, StyleSheet } from "@react-pdf/renderer";
import { useStyles } from "../ThemeContext.js";
import SectionTitle from "../components/SectionTitle.js";
const makeStyles = (t) => StyleSheet.create({
wrap: { marginBottom: t.spacing.sectionGap },
item: { marginBottom: 7 },
lang: { fontSize: t.typography.degree.size, fontWeight: t.typography.degree.weight, color: t.palette.accent, lineHeight: 1.3 },
prof: { fontSize: t.typography.caption.size, color: t.palette.textMuted, marginTop: 0.75, paddingLeft: t.spacing.itemPl }
});
function LanguagesSection({ data }) {
const s = useStyles(makeStyles);
if (!data.languages?.length) return null;
return /* @__PURE__ */ jsxs(View, { style: s.wrap, children: [
/* @__PURE__ */ jsx(SectionTitle, { variant: "sidebar", children: "Languages" }),
data.languages.map((l, i) => /* @__PURE__ */ jsxs(View, { style: s.item, children: [
/* @__PURE__ */ jsx(Text, { style: s.lang, children: l.language }),
l.proficiency && /* @__PURE__ */ jsx(Text, { style: s.prof, children: l.proficiency })
] }, i))
] });
}
export {
LanguagesSection as default
};
import { jsx, jsxs } from "react/jsx-runtime";
import { View, Text, StyleSheet } from "@react-pdf/renderer";
import { useStyles } from "../ThemeContext.js";
import SectionTitle from "../components/SectionTitle.js";
const makeStyles = (t) => StyleSheet.create({
wrap: { marginBottom: t.spacing.sectionGap },
item: { marginBottom: 12 },
title: { fontSize: t.typography.degree.size, fontWeight: t.typography.degree.weight, color: t.palette.accent, lineHeight: 1.3 },
meta: { fontSize: t.typography.institution.size, color: t.palette.textBody, marginTop: t.spacing.entryMetaMt, paddingLeft: t.spacing.itemPl }
});
function PublicationsSection({ data }) {
const s = useStyles(makeStyles);
if (!data.publications?.length) return null;
return /* @__PURE__ */ jsxs(View, { style: s.wrap, children: [
/* @__PURE__ */ jsx(SectionTitle, { variant: "sidebar", children: "Publications" }),
data.publications.map((p, i) => {
const meta = [p.venue, p.year].filter(Boolean).join(" \xB7 ");
return /* @__PURE__ */ jsxs(View, { style: s.item, children: [
/* @__PURE__ */ jsx(Text, { style: s.title, children: p.title }),
meta && /* @__PURE__ */ jsx(Text, { style: s.meta, children: meta })
] }, i);
})
] });
}
export {
PublicationsSection as default
};
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/certifications.schema.json",
"$ref": "cvx.schema.json#/$defs/certifications"
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/languages.schema.json",
"$ref": "cvx.schema.json#/$defs/languages"
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/publications.schema.json",
"$ref": "cvx.schema.json#/$defs/publications"
}
# yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/certifications.schema.json
- name: "Wilderness Survival & Escapology"
issuer: League of Shadows
year: "2003"
- name: "Advanced Forensic Investigation"
issuer: Gotham University
year: "1998"
# yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/languages.schema.json
- language: English
proficiency: Native
- language: Japanese
proficiency: Professional
- language: Russian
proficiency: Conversational
# yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/publications.schema.json
- title: "Applied Deterrence: Non-Lethal Intervention at Scale"
venue: Gotham Journal of Criminology
year: "2021"
- title: "Urban Resilience After Systemic Collapse"
venue: Wayne Foundation Report
year: "2019"
+53
-1

@@ -23,2 +23,3 @@ #!/usr/bin/env node

import { parseArgs } from 'node:util'
import { execFileSync } from 'node:child_process'

@@ -37,2 +38,3 @@ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..')

cvx build --ats Render the ATS-safe single-column variant
cvx build --all Validate, then render both the designed and ATS PDFs
cvx list [themes|layouts] Show available themes and layouts

@@ -197,2 +199,50 @@ cvx mcp Run the MCP stdio server (4 tools, fully offline)

// build --all: validate first (errors block), then render both variants.
// One command instead of validate + build + build --ats, so an agent has
// fewer steps to stall on and always produces both PDFs.
//
// Each variant renders in its OWN child process. @react-pdf/renderer leaks
// font-subset state across renderToBuffer() calls in a single process, which
// corrupts the 2nd PDF's ToUnicode/text layer — the ATS variant would look
// correct on screen but extract as garbled text, breaking exactly the ATS
// parsers it exists for. Separate processes keep every PDF's text layer clean
// (regression-guarded by the layout harness's content oracle).
async function buildAll({ json }) {
const contentDir = join(process.cwd(), 'cv-content')
const { validateContent } = await import('../lib/pdf/validateContent.js')
const vr = validateContent({ contentDir, strict: false })
if (!vr.ok) {
if (json) emit({ command: 'build', all: true, ok: false, error: { code: 'validation-failed', message: 'validation failed — fix errors before building' }, errors: vr.errors, warnings: vr.warnings })
else {
console.error('✖ validation failed — fix these before building:')
for (const f of vr.errors) console.error(` ✖ cv-content/${f.file ?? ''}${f.path && f.path !== '(root)' ? ` ${f.path}:` : ''} ${f.message}`)
}
process.exit(EXIT.validation)
}
const cliPath = fileURLToPath(import.meta.url)
const outputs = []
for (const ats of [false, true]) {
const label = ats ? 'ATS' : 'designed'
let res
try {
const stdout = execFileSync(process.execPath, [cliPath, 'build', ...(ats ? ['--ats'] : []), '--json'],
{ cwd: process.cwd(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] })
res = JSON.parse(stdout)
} catch (err) {
if (json) emit({ command: 'build', all: true, ok: false, error: { code: 'render-failed', message: `${label} variant failed: ${err.message}` } })
else console.error(`Build failed for the ${label} variant: ${err.message}`)
process.exit(EXIT.render)
}
if (!res?.ok) {
if (json) emit({ command: 'build', all: true, ok: false, error: res?.error ?? { code: 'render-failed', message: `${label} variant failed` } })
else console.error(`Build failed for the ${label} variant.`)
process.exit(EXIT.render)
}
outputs.push({ filename: res.filename, bytes: res.bytes, ats, theme: res.theme, layout: res.layout, warnings: res.warnings ?? [] })
if (!json) console.log(`✅ ${res.filename} (${(res.bytes / 1024).toFixed(0)} KB, ${ats ? 'ATS' : `theme: ${res.theme}, layout: ${res.layout}`})`)
}
if (json) emit({ command: 'build', all: true, ok: true, outputs })
}
let command = null

@@ -204,2 +254,3 @@ let jsonMode = false

ats: { type: 'boolean', default: false },
all: { type: 'boolean', default: false },
strict: { type: 'boolean', default: false },

@@ -244,3 +295,4 @@ json: { type: 'boolean', default: false },

} else if (command === 'build') {
await build(values)
if (values.all) await buildAll(values)
else await build(values)
} else {

@@ -247,0 +299,0 @@ if (jsonMode) emit({ command, ok: false, error: { code: 'unknown-command', message: `unknown command: ${command}` } })

@@ -59,5 +59,6 @@ import { jsx, jsxs } from "react/jsx-runtime";

});
function ATSContent({ personal, summary, experience, achievements, education, competencies, referees, profilePhoto }) {
function ATSContent({ personal, summary, experience, achievements, education, certifications, publications, languages, competencies, referees, profilePhoto }) {
const theme = useTheme();
const s = useMemo(() => makeStyles(theme), [theme]);
const linkParts = (personal.links ?? []).map((l) => l.label ? `${l.label}: ${l.href}` : l.href);
const contactParts = [

@@ -67,3 +68,4 @@ personal.phone,

personal.linkedin,
personal.location
personal.location,
...linkParts
].filter(Boolean);

@@ -120,2 +122,18 @@ return /* @__PURE__ */ jsxs(Page, { size: "A4", style: s.page, children: [

] }),
certifications?.length > 0 && /* @__PURE__ */ jsxs(View, { children: [
/* @__PURE__ */ jsx(Text, { style: s.section, children: "Certifications" }),
certifications.map((c, i) => /* @__PURE__ */ jsxs(View, { children: [
i > 0 && /* @__PURE__ */ jsx(View, { style: { height: 5 } }),
/* @__PURE__ */ jsx(Text, { style: s.degree, children: c.name }),
(c.issuer || c.year) && /* @__PURE__ */ jsx(Text, { style: s.eduMeta, children: [c.issuer, c.year].filter(Boolean).join(" | ") })
] }, i))
] }),
publications?.length > 0 && /* @__PURE__ */ jsxs(View, { children: [
/* @__PURE__ */ jsx(Text, { style: s.section, children: "Publications" }),
publications.map((p, i) => /* @__PURE__ */ jsxs(View, { children: [
i > 0 && /* @__PURE__ */ jsx(View, { style: { height: 5 } }),
/* @__PURE__ */ jsx(Text, { style: s.degree, children: p.title }),
(p.venue || p.year) && /* @__PURE__ */ jsx(Text, { style: s.eduMeta, children: [p.venue, p.year].filter(Boolean).join(" | ") })
] }, i))
] }),
competencies?.length > 0 && /* @__PURE__ */ jsxs(View, { children: [

@@ -125,2 +143,6 @@ /* @__PURE__ */ jsx(Text, { style: s.section, children: "Core Competencies" }),

] }),
languages?.length > 0 && /* @__PURE__ */ jsxs(View, { children: [
/* @__PURE__ */ jsx(Text, { style: s.section, children: "Languages" }),
/* @__PURE__ */ jsx(Text, { style: s.compText, children: languages.map((l) => l.proficiency ? `${l.language} (${l.proficiency})` : l.language).join(" \xB7 ") })
] }),
achievements?.length > 0 && /* @__PURE__ */ jsxs(View, { children: [

@@ -154,2 +176,5 @@ /* @__PURE__ */ jsx(Text, { style: s.section, children: "Achievements" }),

education,
certifications,
publications,
languages,
competencies,

@@ -184,2 +209,5 @@ referees,

education,
certifications,
publications,
languages,
competencies,

@@ -186,0 +214,0 @@ referees,

+7
-3

@@ -18,3 +18,3 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";

continuation: {
sidebar: ["identity-compact", "education", "competencies"],
sidebar: ["identity-compact", "education", "certifications", "competencies", "languages", "publications"],
main: ["experience:continued"]

@@ -30,3 +30,3 @@ },

first: {
main: ["header-ats", "summary", "experience", "education", "competencies", "achievements", "referees"]
main: ["header-ats", "summary", "experience", "education", "certifications", "publications", "competencies", "languages", "achievements", "referees"]
}

@@ -114,2 +114,5 @@ };

education,
certifications,
publications,
languages,
competencies,

@@ -131,3 +134,3 @@ referees,

};
const data = { personal, summary, experience, achievements, education, competencies, referees, profilePhoto };
const data = { personal, summary, experience, achievements, education, certifications, publications, languages, competencies, referees, profilePhoto };
const isSingleColumn = (activeLayout.template ?? layoutName) === "single-column";

@@ -151,3 +154,4 @@ const keywordString = buildKeywords({ keywords, competencies, experience, personal }, config);

export {
TWO_COLUMN_LAYOUT,
CVDocument as default
};

@@ -203,5 +203,9 @@ import { tealTheme } from "./themes/teal.js";

PAGE1_OVERFLOW_WARN_THRESHOLD,
deriveMetrics,
entryH,
estimatePage1Overflow,
lineCount,
packExperiences,
resolveFirstSidebar
resolveFirstSidebar,
summaryH
};

@@ -10,3 +10,4 @@ import { jsx, jsxs } from "react/jsx-runtime";

facebook: { viewBox: "0 0 320 512", d: "M279.14 288l14.22-92.66h-88.91v-60.13c0-25.35 12.42-50.06 52.24-50.06h40.42V6.26S260.43 0 225.36 0c-73.22 0-121.08 44.38-121.08 124.72v70.62H22.89V288h81.39v224h100.17V288z" },
location: { viewBox: "0 0 384 512", d: "M172.268 501.67C26.97 291.031 0 269.413 0 192 0 85.961 85.961 0 192 0s192 85.961 192 192c0 77.413-26.97 99.031-172.268 309.67-9.535 13.774-29.93 13.773-39.464 0zM192 272c44.183 0 80-35.817 80-80s-35.817-80-80-80-80 35.817-80 80 35.817 80 80 80z" }
location: { viewBox: "0 0 384 512", d: "M172.268 501.67C26.97 291.031 0 269.413 0 192 0 85.961 85.961 0 192 0s192 85.961 192 192c0 77.413-26.97 99.031-172.268 309.67-9.535 13.774-29.93 13.773-39.464 0zM192 272c44.183 0 80-35.817 80-80s-35.817-80-80-80-80 35.817-80 80 35.817 80 80 80z" },
link: { viewBox: "0 0 512 512", d: "M326.612 185.391c59.747 59.809 58.927 155.698.36 214.59-.11.12-.24.25-.36.37l-67.2 67.2c-59.27 59.27-155.699 59.262-214.96 0-59.27-59.26-59.27-155.7 0-214.96l37.106-37.106c9.84-9.84 26.786-3.3 27.294 10.606.648 17.722 3.826 35.527 9.69 52.721 1.986 5.822.567 12.262-3.783 16.612l-13.087 13.087c-28.026 28.026-28.905 73.66-1.155 101.96 28.024 28.579 74.086 28.749 102.325.51l67.2-67.19c28.191-28.191 28.073-73.757 0-101.83-3.701-3.694-7.429-6.564-10.341-8.569a16.037 16.037 0 0 1-6.947-12.606c-.396-10.567 3.348-21.456 11.698-29.806l21.054-21.055c5.521-5.521 14.182-6.199 20.583-1.582a152.475 152.475 0 0 1 20.522 17.72zM467.547 44.449c-59.261-59.262-155.69-59.27-214.96 0l-67.2 67.2c-.12.12-.25.25-.36.37-58.566 58.892-59.387 154.781.36 214.59a152.454 152.454 0 0 0 20.521 17.72c6.402 4.617 15.064 3.939 20.583-1.583l21.054-21.054c8.35-8.35 12.094-19.239 11.698-29.806a16.037 16.037 0 0 0-6.947-12.606c-2.912-2.005-6.64-4.875-10.341-8.569-28.073-28.073-28.191-73.639 0-101.83l67.2-67.19c28.239-28.239 74.3-28.069 102.325.51 27.75 28.3 26.872 73.934-1.155 101.96l-13.087 13.087c-4.35 4.35-5.769 10.79-3.783 16.612 5.864 17.194 9.042 34.999 9.69 52.721.509 13.906 17.456 20.446 27.294 10.606l37.106-37.106c59.271-59.259 59.271-155.699.001-214.959z" }
};

@@ -34,10 +35,11 @@ const makeStyles = (t) => StyleSheet.create({

{ icon: "facebook", value: personal.facebook, href: personal.facebookHref },
{ icon: "location", value: personal.location }
{ icon: "location", value: personal.location },
...(personal.links ?? []).map((l) => ({ icon: "link", value: l.label || l.href, href: l.href }))
].filter((r) => r.value);
return /* @__PURE__ */ jsxs(View, { style: s.wrap, children: [
/* @__PURE__ */ jsx(SectionTitle, { variant: "sidebar", children: "Contact" }),
rows.map(({ icon, value, href }) => /* @__PURE__ */ jsxs(View, { style: s.row, children: [
rows.map(({ icon, value, href }, i) => /* @__PURE__ */ jsxs(View, { style: s.row, children: [
/* @__PURE__ */ jsx(Icon, { name: icon, color: theme.palette.accent, spacing: theme.spacing }),
href ? /* @__PURE__ */ jsx(Link, { src: href, style: s.link, children: value }) : /* @__PURE__ */ jsx(Text, { style: s.text, children: value })
] }, icon))
] }, i))
] });

@@ -44,0 +46,0 @@ }

@@ -15,3 +15,4 @@ import { jsx, jsxs } from "react/jsx-runtime";

const { personal, profilePhoto } = data;
const contactParts = [personal.phone, personal.email, personal.linkedin, personal.location].filter(Boolean);
const linkParts = (personal.links ?? []).map((l) => l.label ? `${l.label}: ${l.href}` : l.href);
const contactParts = [personal.phone, personal.email, personal.linkedin, personal.location, ...linkParts].filter(Boolean);
return /* @__PURE__ */ jsxs(View, { style: s.headerRow, children: [

@@ -18,0 +19,0 @@ /* @__PURE__ */ jsxs(View, { style: s.headerText, children: [

@@ -8,2 +8,5 @@ import { createElement } from "react";

import EducationSection from "./EducationSection.js";
import CertificationsSection from "./CertificationsSection.js";
import PublicationsSection from "./PublicationsSection.js";
import LanguagesSection from "./LanguagesSection.js";
import CompetenciesSection from "./CompetenciesSection.js";

@@ -20,2 +23,5 @@ import RefereesSection from "./RefereesSection.js";

"education": EducationSection,
"certifications": CertificationsSection,
"publications": PublicationsSection,
"languages": LanguagesSection,
"competencies": CompetenciesSection,

@@ -22,0 +28,0 @@ "referees": RefereesSection,

{
"name": "@hrtips/cvx",
"version": "1.4.0",
"version": "1.5.0",
"description": "CVX — structured input, professional output. YAML content in, pixel-perfect CV PDFs out; swappable themes and layouts, no headless browser. MCP server included. Formerly makecv.",

@@ -5,0 +5,0 @@ "mcpName": "io.github.hrtips/cvx",

@@ -47,2 +47,18 @@ {

},
"linkEntry": {
"type": "object",
"description": "A labelled hyperlink used by personal.links.",
"properties": {
"label": {
"$ref": "#/$defs/nonEmptyString",
"description": "Visible link text, e.g. \"Portfolio\" or \"Blog\". Optional — falls back to the URL if omitted."
},
"href": {
"$ref": "#/$defs/nonEmptyString",
"description": "Destination URL. Required."
}
},
"required": ["href"],
"additionalProperties": false
},
"personal": {

@@ -96,2 +112,7 @@ "title": "personal.yaml",

"description": "City / country line. Plain text, no link."
},
"links": {
"type": "array",
"description": "Extra labelled links beyond the built-in linkedin/facebook rows — portfolio, blog, GitHub, personal site, etc. Each item is { label, href }.",
"items": { "$ref": "#/$defs/linkEntry" }
}

@@ -191,2 +212,73 @@ },

},
"certificationEntry": {
"type": "object",
"properties": {
"name": {
"$ref": "#/$defs/nonEmptyString",
"description": "Certification name, e.g. \"LFD259: Kubernetes for Developers\". Required. Also feeds ATS keyword derivation."
},
"issuer": {
"type": "string",
"description": "Awarding body, e.g. \"The Linux Foundation\"."
},
"year": {
"type": "string",
"description": "Free-text year or date earned. Omit to hide the date line."
}
},
"required": ["name"],
"additionalProperties": false
},
"certifications": {
"title": "certifications.yaml",
"description": "Professional certifications as a list of entries, most recent first. Kept distinct from achievements.yaml (awards and recognitions).",
"type": "array",
"items": { "$ref": "#/$defs/certificationEntry" }
},
"publicationEntry": {
"type": "object",
"properties": {
"title": {
"$ref": "#/$defs/nonEmptyString",
"description": "Publication title. Required."
},
"venue": {
"type": "string",
"description": "Where it appeared — journal, conference, publisher, or site."
},
"year": {
"type": "string",
"description": "Free-text year or date. Omit to hide the date line."
}
},
"required": ["title"],
"additionalProperties": false
},
"publications": {
"title": "publications.yaml",
"description": "Publications and written work as a list of entries, most recent first.",
"type": "array",
"items": { "$ref": "#/$defs/publicationEntry" }
},
"languageEntry": {
"type": "object",
"properties": {
"language": {
"$ref": "#/$defs/nonEmptyString",
"description": "Language name, e.g. \"English\". Required."
},
"proficiency": {
"type": "string",
"description": "Free-text proficiency, e.g. \"Native\", \"Professional\", \"Conversational\". Omit to show just the language."
}
},
"required": ["language"],
"additionalProperties": false
},
"languages": {
"title": "languages.yaml",
"description": "Languages spoken as a list of entries.",
"type": "array",
"items": { "$ref": "#/$defs/languageEntry" }
},
"competencies": {

@@ -193,0 +285,0 @@ "title": "competencies.yaml",

@@ -22,3 +22,3 @@ ---

npx @hrtips/cvx validate --strict --json # every problem at once: file + field paths + fixes
npx @hrtips/cvx build --json # writes <name>.pdf; add --ats for the ATS-safe variant
npx @hrtips/cvx build --json # writes <name>.pdf; --ats for the ATS variant, --all for both
npx @hrtips/cvx list --json # available themes and layouts

@@ -33,6 +33,29 @@ ```

## Review, then brainstorm — before the final build
After converting the user's material into YAML (and passing validation), review the *content* and bring your findings to the user as a short brainstorm — never silently ship the first draft:
1. **Grammar and prose.** Strong action verbs, verb-first bullets (*managed*, *shipped*, *cut* — not *responsible for*); consistent tense (past for former roles, present for the current one); no typos, no filler words; quantify only where the source supports it. Translate insider jargon into plain-language impact a non-specialist recruiter understands. Fix what's unambiguous silently; list notable rewrites for the user to approve.
2. **Gaps and quality.** Missing dates, roles with no outcomes or metrics, thin one-line descriptions, unexplained employment gaps, an unprofessional email address (flag it, suggest `first.last@…`), a `competencies` list that is all hard or all soft skills (aim for a truthful mix, weighted to the target role), and sections the source hints at but the draft lacks (certifications, publications, languages). Turn these into **3–5 targeted questions, batched into one message** — e.g. *"Your CEO role lists no outcomes — any truthful numbers on users, revenue, or funding? And your LinkedIn mentions three publications; want them on the CV?"*
3. **Conflicts.** Contradictory titles or dates between sources — present both options and ask which is correct; never pick silently.
4. **Pre-build preview.** Before calling `build_pdf` (or running `build`), show the user a plain-language rundown of exactly what the CV will contain and get their OK: each section with its entries — roles with companies and periods (and which land on page 1), education, the competency pills, achievements, referees or *"available upon request"*, which keywords go into the (invisible) ATS metadata — plus the theme, layout, and photo status. Summarize the YAML; don't dump it. Nothing goes on the CV the user hasn't seen.
Apply the answers, re-validate, then build both variants. A truthful thin bullet always beats an embellished one — never pad with invented metrics.
## Tailoring to a job posting
If the user gives you a target job description, tailor **truthfully** — never invent experience to match it:
- Mirror the posting's own wording for skills and duties the user genuinely has; recruiters and ATS match on exact terms. Add real, posting-relevant skills to `competencies.yaml` and truthful terms to `keywords.yaml`.
- Lead with and expand the experience bullets most relevant to the role; keep the rest but trim them.
- Point `summary.yaml` at the target role.
- Recent graduate or career-changer with thin experience: make `education.yaml` and `competencies.yaml` do more of the work, and keep the experience section tight.
Keep the base `cv-content/` intact and tailor a copy — the durable YAML is reusable for the next application.
## Rules that are not optional
1. **Never invent facts.** Every entry must be truthful to the user's real history. This matters most for `keywords.yaml`: ATS parsers cross-check keywords against the CV body, and stuffing false terms gets CVs auto-rejected.
2. **Don't rename the YAML files.** Sections are discovered by filename: `personal.yaml`, `summary.yaml`, `experience.yaml`, `education.yaml`, `competencies.yaml`, `achievements.yaml`, `referees.yaml`, `keywords.yaml`, `config.yaml`.
2. **Don't rename the YAML files.** Sections are discovered by filename: `personal.yaml`, `summary.yaml`, `experience.yaml`, `education.yaml`, `certifications.yaml`, `publications.yaml`, `languages.yaml`, `competencies.yaml`, `achievements.yaml`, `referees.yaml`, `keywords.yaml`, `config.yaml`.
3. **Quote strings containing colons** (`"Director: Operations"`). Date ranges are free text (`2019 – Present`).

@@ -45,9 +68,12 @@ 4. The photo goes at `cv-content/images/profile.jpg` (or `.jpeg`/`.png`/`.webp`) — ask the user for it; it cannot be generated.

- `personal.yaml` (object): `name` (required — drives the output filename), `title`, `company`, `phone`+`phoneHref`, `email`, `linkedin`+`linkedinHref`, `facebook`+`facebookHref`, `location`.
- `personal.yaml` (object): `name` (required — drives the output filename), `title`, `company`, `phone`+`phoneHref`, `email`, `linkedin`+`linkedinHref`, `facebook`+`facebookHref`, `location`, `links` (list of `{label, href}` for a blog/portfolio; `label` optional).
- `summary.yaml`: list of 3–6 single-sentence bullets. A bullet may also be `{text, link: {href, label}, suffix}` to embed a clickable link (same form works in experience bullets).
- `experience.yaml`: list of roles, most recent first — `role` (required), `company`, `period`, `location`, `description`, `progression` (list of `{title, period}`), `bullets` (verb-first, quantified, truthful).
- `education.yaml`: list of `{degree, institution, period}`.
- `certifications.yaml`: list of `{name, issuer, year}` — only `name` required; kept separate from achievements.
- `publications.yaml`: list of `{title, venue, year}` — only `title` required.
- `languages.yaml`: list of `{language, proficiency}` — `proficiency` is free text (Native, Professional, …).
- `competencies.yaml`: 6–12 short skill strings.
- `achievements.yaml`: list of `{year, text}` — `year` is the bold headline (often the award name), `text` the attribution.
- `referees.yaml`: list of `{name, title, company, email, phone}`, or `[]` for "available upon request".
- `referees.yaml`: list of `{name, title, company, email, phone}`, or `[]` to print "References available upon request." Modern guidance treats even that line as filler — offer to drop the section (a layout without the `referees` slot) and reclaim the space.
- `keywords.yaml` (optional): extra truthful ATS keywords not already covered by competencies/titles; embedded in PDF metadata, never printed.

@@ -54,0 +80,0 @@ - `config.yaml`: `schemaVersion: 1`, `theme` (`teal`|`coral`|`mono`), `layout` (`two-column`|`single-column`|custom filename); pagination keys only if page 1 overflows.

@@ -24,5 +24,6 @@ # Working with this cv-content/ folder

1. **Never invent facts.** Every entry must be truthful to the person's real history. This especially matters for `keywords.yaml` — ATS parsers cross-check keywords against the CV body, and stuffing gets CVs auto-rejected. A linkedin.com URL is unfetchable even when public — ask for the profile's **More → Save to PDF** export or pasted text instead of inferring.
2. **Don't rename the YAML files.** Sections are discovered by filename (`personal.yaml`, `summary.yaml`, `experience.yaml`, `education.yaml`, `competencies.yaml`, `achievements.yaml`, `referees.yaml`, `keywords.yaml`, `config.yaml`).
2. **Don't rename the YAML files.** Sections are discovered by filename (`personal.yaml`, `summary.yaml`, `experience.yaml`, `education.yaml`, `certifications.yaml`, `publications.yaml`, `languages.yaml`, `competencies.yaml`, `achievements.yaml`, `referees.yaml`, `keywords.yaml`, `config.yaml`).
3. **Quote strings containing colons** (`"Director: Operations"`). Date ranges are free text (`2019 – Present`).
4. The profile photo goes at `images/profile.jpg` (or `.jpeg`/`.png`/`.webp`) — ask the user for it early; it can't be generated. The scaffolded `images/profile.jpg` is Bruce Wayne's example photo: replace it with the user's or delete it before building (the CV renders fine without one).
5. `config.yaml` usually needs only `theme` + `layout`; add pagination keys only if page 1 overflows.
6. **Review, then preview, then build.** After drafting: check grammar/prose (verb-first bullets, consistent tense) and gaps (missing dates, metrics, certifications/publications/languages the source mentions), and bring findings to the user as a few batched questions. Before building, show a plain-language rundown of everything the CV will contain — sections, entries, page-1 split, ATS keywords, theme/photo — and get an OK. Nothing ships that the user hasn't seen; never pad with invented metrics.

@@ -20,4 +20,7 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/layout.schema.json

- education
- certifications
- publications
- competencies
- languages
- achievements
- referees

@@ -32,3 +32,6 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/layout.schema.json

- education
- certifications
- competencies
- languages
- publications
main:

@@ -35,0 +38,0 @@ - experience:continued

@@ -10,1 +10,4 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/personal.schema.json

linkedinHref: "https://www.linkedin.com/in/brucewayne"
links:
- label: Wayne Foundation
href: "https://www.wayne-foundation.org"

@@ -7,6 +7,9 @@ # Your CV content

|---|---|
| `personal.yaml` | Name, title, company, and the contact block (phone, email, LinkedIn) |
| `personal.yaml` | Name, title, company, and the contact block (phone, email, LinkedIn, extra `links`) |
| `summary.yaml` | The bullet list at the top of page 1 |
| `experience.yaml` | Work history — one entry per role; `progression:` is an optional title history, `bullets:` are your impact points |
| `education.yaml` | Degrees, institutions, years |
| `certifications.yaml` | Professional certifications — name, issuer, year |
| `publications.yaml` | Publications and written work — title, venue, year |
| `languages.yaml` | Languages spoken and proficiency |
| `competencies.yaml` | Skill pills in the sidebar |

@@ -25,2 +28,3 @@ | `achievements.yaml` | Awards and recognitions (sidebar) |

npx @hrtips/cvx build --ats # plain single-column PDF for job portals
npx @hrtips/cvx build --all # validate, then build both variants in one step
```

@@ -40,6 +44,9 @@

- **personal.yaml** (object): `name` (required — also names the output PDF), `title`, `company`, `phone` + `phoneHref`, `email` (auto-`mailto:`), `linkedin` + `linkedinHref`, `facebook` + `facebookHref`, `location`. Contact rows render only for keys present.
- **personal.yaml** (object): `name` (required — also names the output PDF), `title`, `company`, `phone` + `phoneHref`, `email` (auto-`mailto:`), `linkedin` + `linkedinHref`, `facebook` + `facebookHref`, `location`, `links` (list of `{label, href}` for blog/portfolio/GitHub; `label` optional, falls back to the URL). Contact rows render only for keys present.
- **summary.yaml**: list of strings (3–6 single-sentence bullets).
- **experience.yaml**: list of entries, most recent first — `role` (required), `company`, `period` (free text, e.g. `2005 – Present`), `location` (optional), `description` (optional italic one-liner), `progression` (optional list of `{title, period}` for promotions), `bullets` (list of strings; verb-first, quantified, truthful).
- **education.yaml**: list of `{degree, institution, period}`.
- **certifications.yaml**: list of `{name, issuer, year}` — only `name` required; kept separate from achievements.
- **publications.yaml**: list of `{title, venue, year}` — only `title` required.
- **languages.yaml**: list of `{language, proficiency}` — `proficiency` is free text (Native, Professional, …).
- **competencies.yaml**: list of short strings (1–3 words; rendered as pills).

@@ -50,3 +57,3 @@ - **achievements.yaml**: list of `{year, text}` — `year` is the bold headline (usually the award name), `text` the attribution, e.g. `"— 2024, Gotham Gazette"`.

- **config.yaml**: `theme` (`teal`|`coral`|`mono`), `layout` (`two-column`|`single-column`|custom layout filename), `page1ExperienceCount` (int, optional), `page1SplitBullets` (int, optional), `atsKeywords: {enabled, autoDerive, max}`.
- **layouts/*.yaml** (optional): `template` + `pages: {first, continuation, last}`, each with `sidebar`/`main` lists of section keys (`identity-photo`, `identity-compact`, `contact`, `achievements`, `education`, `competencies`, `referees`, `summary`, `experience`, `experience:continued`, `header-ats`, `spacer: N`).
- **layouts/*.yaml** (optional): `template` + `pages: {first, continuation, last}`, each with `sidebar`/`main` lists of section keys (`identity-photo`, `identity-compact`, `contact`, `achievements`, `education`, `certifications`, `publications`, `languages`, `competencies`, `referees`, `summary`, `experience`, `experience:continued`, `header-ats`, `spacer: N`).
- **images/profile.<ext>**: square photo ≥400×400px; `jpg`/`jpeg`/`png`/`webp` (that precedence). Ask the user to supply it — don't fabricate.

@@ -53,0 +60,0 @@