@hrtips/cvx
Advanced tools
| 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, |
@@ -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, |
+1
-1
| { | ||
| "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", |
+30
-4
@@ -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 @@ |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
672888
3.13%80
12.68%3014
10.36%14
16.67%1
Infinity%