New:Socket for Asana Is Now Available.Learn more
Get Started

@hrtips/cvx

Package Overview
Dependencies
Maintainers
1
Versions
17
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.9.2
to
1.10.0
+60
bin/stdoutJson.test.js
// RV10: the synchronous writer behind every `--json` envelope.
//
// It exists because `console.log` + `process.exit()` truncated the payload at
// the 64KiB pipe buffer whenever a caller consumed stdout through a pipe —
// which is every agent and every script. `test/jsonEnvelopeFlush.test.js`
// proves the end-to-end behaviour through a real subprocess; this covers the
// write loop itself, which every other test necessarily stubs out (it is the
// seam they capture the envelope through).
import { closeSync, mkdtempSync, openSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { stdoutJson } from './cvx.js'
/** @type {string[]} */
const dirs = []
afterAll(() => {
for (const d of dirs) rmSync(d, { recursive: true, force: true })
})
function tmpFd() {
const dir = mkdtempSync(path.join(tmpdir(), 'cvx-stdout-'))
dirs.push(dir)
const file = path.join(dir, 'out.json')
return { fd: openSync(file, 'w'), file }
}
describe('stdoutJson.write (RV10)', () => {
it('writes the whole payload, including one far larger than a pipe buffer', () => {
const { fd, file } = tmpFd()
// 512KiB — eight times the buffer that used to truncate the envelope.
const payload = `${JSON.stringify({ pad: 'x'.repeat(512 * 1024) })}\n`
stdoutJson.write(payload, fd)
closeSync(fd)
const written = readFileSync(file, 'utf8')
expect(written.length).toBe(payload.length)
expect(written).toBe(payload)
expect(JSON.parse(written).pad.length).toBe(512 * 1024)
})
it('writes multi-byte characters without splitting them', () => {
// The loop advances by BYTES, not characters, which is why it works on a
// Buffer rather than a string — a name like José is where a naive
// character-offset loop corrupts the output.
const { fd, file } = tmpFd()
const payload = `${JSON.stringify({ name: 'José Álvarez', note: '— em dash, ✓ check' })}\n`
stdoutJson.write(payload, fd)
closeSync(fd)
expect(readFileSync(file, 'utf8')).toBe(payload)
})
it('rethrows a real write error rather than looping forever', () => {
// Only EAGAIN is retried. Anything else — a closed or invalid fd — must
// propagate: silently spinning on it would hang the CLI.
const { fd } = tmpFd()
closeSync(fd)
expect(() => stdoutJson.write('{}\n', fd)).toThrow(/EBADF|bad file/i)
})
})
+164
-29

@@ -25,3 +25,4 @@ #!/usr/bin/env node

realpathSync,
writeFileSync
writeFileSync,
writeSync
} from 'node:fs'

@@ -43,17 +44,51 @@ import { homedir } from 'node:os'

const pkgRoot = process.env.CVX_ASSET_ROOT || join(dirname(fileURLToPath(import.meta.url)), '..')
const version = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')).version
/**
* N9: read defensively, because this runs at MODULE LOAD — outside `main()`'s
* try/catch and therefore outside the documented exit-code contract. With
* `CVX_ASSET_ROOT` pointing somewhere without a package.json (a caller-supplied
* value, and the standalone bundle sets it), the throw escaped as an unhandled
* exception: exit code 1 and a raw stack trace, where the header promises
* `0 ok · 2 validation · 3 render · 64 usage`.
*
* The version is banner text. Failing the whole CLI over it — before any
* command has been chosen — is the wrong trade; saying "unknown" is honest and
* keeps every real command working.
*/
const version = (() => {
try {
return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')).version
} catch {
return 'unknown'
}
})()
const EXIT = { ok: 0, validation: 2, render: 3, usage: 64 }
/**
* Which build-time defects `--strict` turns into a non-zero exit.
* Which build-time findings `--strict` turns into a non-zero exit: every one
* the engine itself classifies as `kind: 'defect'`.
*
* ONE set, used by `build` and by `build --all`, because they drifted apart
* the moment they were written separately: `--all` gated every `kind:'defect'`
* while `build` gated this code alone, so the same CV exited 0 from one
* command and 2 from the other (gate-7 re-review). Scoped narrowly on purpose
* — R-D rules on this defect, and `HELP` documents exactly this — so widening
* it to every defect stays a maintainer ruling that changes this one line.
* A PREDICATE, not an allowlist, and that is the point (maintainer ruling,
* 2026-08-18). It was `new Set(['physical-pages-exceed-plan'])` — one code —
* which meant every new defect code had to be remembered into this line or it
* was silently ungated. RV1 added one and demonstrated the failure mode
* immediately: the defect existed, `--json` reported it, and `--strict` walked
* past it until this line changed. Gating on the classification the engine
* already publishes makes the next one gated by construction.
*
* `kind` is exactly the right axis and exists for this: `defect` means
* something is wrong, `fact` means true and priced. Facts stay ungated —
* `page1-ends-early` fires on well-packed CVs and gating it would make
* `--strict` useless.
*
* The widening is user-visible and deliberate. `overflow`,
* `page1-no-experience` and `section-has-no-slot` now fail `build --strict`
* where they exited 0 before. `section-has-no-slot` is the one to watch: it
* fires on a legitimate setup (a populated `referees.yaml` the designed layout
* has no slot for), so anyone scripting `--strict` around that shape must
* either place the section or empty the file. R-D still holds for the default
* path — the PDF exists, so a defect is exit 0 unless the caller opts in.
*/
const STRICT_GATED_CODES = new Set(['physical-pages-exceed-plan'])
const isStrictGated = (/** @type {{ kind?: string }} */ w) => w.kind === 'defect'

@@ -74,5 +109,9 @@ const HELP = `cvx ${version} — config-driven CV generator

Options:
--strict validate: treat warnings (e.g. unknown keys) as errors
build: exit non-zero if the PDF has more sheets than
planned (also applies to build --all)
--strict validate: treat UNKNOWN-KEY warnings as errors (only
those — other warnings stay warnings on purpose; see
validateContent.js's severity model)
build: exit non-zero on ANY finding the engine marks
kind: "defect" — content missing from the PDF, more
sheets than planned, an over-budget page. Facts (e.g.
page1-ends-early) never gate. Also applies to build --all
--json Machine-readable result on stdout; logs on stderr

@@ -86,4 +125,65 @@ -h, --help Show this help

const emit = (/** @type {unknown} */ obj) => console.log(JSON.stringify(obj, null, 2))
/**
* Write the one JSON object this command's `--json` contract promises.
*
* RV10: this used `console.log`, and every command calls `process.exit()`
* immediately afterwards. `process.stdout` is ASYNCHRONOUS when stdout is a
* pipe — which is exactly what it is when an agent or a script consumes the
* output — so `exit()` discarded whatever had not drained. Measured: the
* payload arrived truncated at exactly 65536 bytes, the pipe buffer, and the
* JSON was unparseable, with no indication that anything was missing.
*
* Reachable rather than theoretical: the scaffold's `validate --json` is 472
* bytes and a 12-role CV's `build --json` is 19.7 KB, so the ceiling is about
* 3x a large real CV — but `validate`'s findings are unbounded (`allErrors:
* true`), and a content directory full of unknown keys is a very ordinary way
* for an assistant-written CV to fail.
*
* `writeSync` on fd 1 is not subject to that: it blocks until the bytes are
* handed over. The loop is for partial writes, which a full pipe can return.
*/
/**
* The one place `--json` output leaves this process.
*
* A named seam rather than a bare call, for two reasons. Production needs a
* SYNCHRONOUS write (see below); the in-process tests need to capture the
* envelope, and stubbing `fs.writeSync` globally is not an option — vitest's
* own worker writes through it and dies. So tests replace this one property.
*/
export const stdoutJson = {
/**
* RV10: this used `console.log`, and every command calls `process.exit()`
* immediately afterwards. `process.stdout` is ASYNCHRONOUS when stdout is a
* pipe — which is exactly what it is when an agent or a script consumes the
* output — so `exit()` discarded whatever had not drained. Measured: the
* payload arrived truncated at exactly 65536 bytes, the pipe buffer, and the
* JSON was unparseable, with nothing saying so.
*
* Reachable rather than theoretical: the scaffold's `validate --json` is 472
* bytes and a 12-role CV's `build --json` is 19.7 KB, so the ceiling is
* about 3x a large real CV — but `validate`'s findings are unbounded
* (`allErrors: true`), and a content directory full of unknown keys is an
* ordinary way for an assistant-written CV to fail, which is when the caller
* most needs to read them.
*
* `writeSync` blocks until the bytes are handed over. The loop covers a
* partial write, which a full pipe can return.
*/
write(/** @type {string} */ text, /** @type {number} */ fd = 1) {
const buf = Buffer.from(text, 'utf8')
let off = 0
while (off < buf.length) {
try {
off += writeSync(fd, buf, off, buf.length - off)
} catch (err) {
// EAGAIN: the pipe is momentarily full and the fd is non-blocking.
// Retry rather than dropping the tail — dropping it is the bug.
if (/** @type {NodeJS.ErrnoException} */ (err).code !== 'EAGAIN') throw err
}
}
}
}
const emit = (/** @type {unknown} */ obj) => stdoutJson.write(`${JSON.stringify(obj, null, 2)}\n`)
export async function init(/** @type {{ json?: boolean }} */ { json }) {

@@ -125,3 +225,3 @@ const dest = join(process.cwd(), 'cv-content')

) {
const { validateContent } = await import('../lib/pdf/validateContent.js')
const { validateContent, contentSchemaVersion } = await import('../lib/pdf/validateContent.js')
const result = validateContent(

@@ -139,3 +239,3 @@ /** @type {import('../src/pdf/types.js').ValidateOptions} */ ({

ok: result.ok,
schemaVersion: 1,
schemaVersion: contentSchemaVersion(),
strict,

@@ -186,3 +286,18 @@ errors: result.errors,

const layoutsDir = join(process.cwd(), 'cv-content', 'layouts')
const builtIn = ['two-column', 'single-column']
// N5: the ONE inventory, from the registry that resolves layouts.
const { BUILT_IN_LAYOUT_NAMES } = await import('../lib/pdf/defaultLayouts.js')
const builtIn = BUILT_IN_LAYOUT_NAMES
// N2: which file actually GOVERNS the build. `discoverLayouts` reads
// cv-content/layouts/ and `resolveDocument` takes `layout ?? LAYOUTS[name]`,
// so a workspace file SHADOWS the built-in of the same name — and `cvx init`
// scaffolds exactly such a file. Reporting it as `built-in` told the user,
// and any agent reading `list --json` or `get_schema`, that their own
// two-column.yaml was not in play while it was the only thing in play.
const workspace = new Set(
existsSync(layoutsDir)
? readdirSync(layoutsDir)
.filter((name) => name.endsWith('.yaml'))
.map((f) => f.replace(/\.yaml$/, ''))
: []
)
const names = new Set(builtIn)

@@ -192,10 +307,8 @@ const layouts = builtIn.map((name) => ({

default: name === 'two-column',
source: 'built-in'
source: workspace.has(name) ? 'cv-content/layouts' : 'built-in'
}))
if (existsSync(layoutsDir)) {
for (const f of readdirSync(layoutsDir).filter((name) => name.endsWith('.yaml'))) {
const name = f.replace(/\.yaml$/, '')
if (!names.has(name)) layouts.push({ name, default: false, source: 'cv-content/layouts' })
names.add(name)
}
for (const name of workspace) {
if (names.has(name)) continue
layouts.push({ name, default: false, source: 'cv-content/layouts' })
names.add(name)
}

@@ -332,5 +445,20 @@

})
for (const w of physical) {
// R-D: a defect reaches stderr in every mode. Facts never do — a normal
// page break is not shouted at (the existing page1-ends-early rule).
// R-D: a defect reaches stderr in every mode. Facts never do — a normal page
// break is not shouted at (the existing page1-ends-early rule).
//
// This used to iterate `physical` alone, which honoured R-D for exactly one
// code. Every OTHER defect — `section-has-no-slot` among them, shipped since
// 1.8.0 — was silent on stderr: it appeared in `--json` diagnostics and
// nowhere a human would see it, so a plain `cvx build` printed `✅` over a CV
// with a section missing from the PDF. Found while adding
// `slot-not-renderable`, whose silence was consistent with its siblings
// rather than a new hole (maintainer ruling, 2026-08-18: close it for all).
//
// Deduplicated by message: `attachPhysicalWarnings` merges its findings into
// the same diagnostics list it returns separately, so the two sources
// overlap and a naive concatenation would print the physical defect twice.
const seen = new Set()
for (const w of [...(diagnostics?.warnings ?? []), ...physical]) {
if (!isStrictGated(w) || seen.has(w.message)) continue
seen.add(w.message)
notices.push(w.message)

@@ -371,3 +499,10 @@ console.error(`⚠ ${w.message}`)

// (warnings become errors) so a scripted caller can opt into hard failure.
if (strict && physical.some((w) => STRICT_GATED_CODES.has(w.code))) {
//
// RV1: the gate reads the PLAN's warnings as well as the physical ones. It
// used to read `physical` alone, which was right while the only gated code
// came from `attachPhysicalWarnings` — but `slot-not-renderable` is derived
// from the layout's slots, so a gate that only looked at the physical list
// would have let the defect it exists for walk straight through.
const gated = [...(diagnostics?.warnings ?? []), ...physical].some(isStrictGated)
if (strict && gated) {
process.exit(EXIT.validation)

@@ -470,3 +605,3 @@ }

for (const w of res.diagnostics?.warnings ?? []) {
if (STRICT_GATED_CODES.has(w.code)) strictFailures.push(`${label}: ${w.message}`)
if (isStrictGated(w)) strictFailures.push(`${label}: ${w.message}`)
}

@@ -473,0 +608,0 @@ outputs.push({

@@ -18,3 +18,13 @@ // In-process tests for the cvx CLI. bin/cvx.js exports every command plus

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { build, buildAll, init, isRunAsMain, list, main, mcpInit, validate } from './cvx.js'
import {
build,
buildAll,
init,
isRunAsMain,
list,
main,
mcpInit,
stdoutJson,
validate
} from './cvx.js'

@@ -46,2 +56,4 @@ // The `mcp` (no subcommand) branch starts a blocking stdio server; mock it so

let errSpy
/** @type {import('vitest').MockInstance} */
let writeSpy

@@ -55,2 +67,5 @@ beforeEach(() => {

logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
// emit() writes the --json envelope straight to fd 1 (RV10). Swallow it in
// tests the way console.log is swallowed, and record it for jsonEmits().
writeSpy = vi.spyOn(stdoutJson, 'write').mockImplementation(() => {})
errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

@@ -64,2 +79,3 @@ })

errSpy.mockRestore()
writeSpy.mockRestore()
rmSync(tmp, { recursive: true, force: true })

@@ -81,6 +97,12 @@ })

// Every JSON object printed on stdout (emit()/--json), in order.
//
// RV10: emit() writes to fd 1 with `writeSync`, not `console.log`, because
// stdout is asynchronous over a pipe and every command calls process.exit()
// straight after — which truncated the envelope at the 64KiB pipe buffer for
// any caller consuming it. So the envelope is captured from the fd-1 spy;
// `logSpy` still covers the human-readable output.
function jsonEmits() {
return logSpy.mock.calls
.map((c) => c[0])
.filter((c) => typeof c === 'string' && c.trimStart().startsWith('{'))
return writeSpy.mock.calls
.map((c) => String(c[0]))
.filter((c) => c.trimStart().startsWith('{'))
.map((s) => JSON.parse(s))

@@ -167,2 +189,3 @@ }

logSpy.mockClear()
writeSpy.mockClear()
expect(await exitCode(init({ json: true }))).toBe(64)

@@ -188,2 +211,3 @@ expect(jsonOut()).toMatchObject({ ok: false, error: { code: 'already-exists' } })

logSpy.mockClear()
writeSpy.mockClear()
expect(await exitCode(validate({ strict: true, json: true }))).toBe(0)

@@ -198,2 +222,3 @@ const out = jsonOut()

logSpy.mockClear()
writeSpy.mockClear()
expect(await exitCode(validate({ strict: false, json: false }))).toBe(0)

@@ -207,2 +232,3 @@ expect(logText()).toContain('is valid')

logSpy.mockClear()
writeSpy.mockClear()
expect(await exitCode(validate({ json: true }))).toBe(2)

@@ -218,2 +244,3 @@ const out = jsonOut()

logSpy.mockClear()
writeSpy.mockClear()
expect(await exitCode(validate({ json: false }))).toBe(2)

@@ -226,2 +253,3 @@ expect(logText()).toContain('error')

logSpy.mockClear()
writeSpy.mockClear()
await exitCode(main(argv('validate', '--strict', '--json')))

@@ -240,2 +268,3 @@ expect(jsonEmits().some((j) => j.command === 'validate' && j.ok === true)).toBe(true)

logSpy.mockClear()
writeSpy.mockClear()
await exitCode(validate({ strict: true, json: false }))

@@ -259,2 +288,28 @@ expect(logText()).toMatch(/linkedin/i)

it('reports a layout as cv-content/layouts when the workspace shadows it (N2)', async () => {
// `init` scaffolds cv-content/layouts/two-column.yaml, and that file WINS:
// discoverLayouts reads the workspace and resolveDocument takes
// `layout ?? LAYOUTS[name]`. Reporting it as "built-in" told the user — and
// any agent reading list --json — that their own file was not in play while
// it was the only thing in play.
await init({ json: false })
logSpy.mockClear()
writeSpy.mockClear()
await list({ kind: 'layouts', json: true })
const out = jsonOut()
const twoCol = out.layouts.find((/** @type {{ name: string }} */ l) => l.name === 'two-column')
expect(twoCol.source).toBe('cv-content/layouts')
// A built-in the workspace does NOT override keeps its label.
const single = out.layouts.find(
(/** @type {{ name: string }} */ l) => l.name === 'single-column'
)
expect(single.source).toBe('cv-content/layouts')
})
it('reports built-in when there is no workspace layouts/ at all (N2)', async () => {
await list({ kind: 'layouts', json: true })
const out = jsonOut()
for (const l of out.layouts) expect(l.source).toBe('built-in')
})
it('lists just themes when kind=themes (--json)', async () => {

@@ -358,2 +413,3 @@ await list({ kind: 'themes', json: true })

logSpy.mockClear()
writeSpy.mockClear()
await build({ ats: false, json: true })

@@ -380,2 +436,3 @@ const out = jsonOut()

logSpy.mockClear()
writeSpy.mockClear()
await build({ ats: true, json: false })

@@ -393,2 +450,3 @@ expect(existsSync(join(tmp, 'bruce-wayne-ats.pdf'))).toBe(true)

logSpy.mockClear()
writeSpy.mockClear()
await build({ ats: true, json: true })

@@ -412,2 +470,3 @@ const out = jsonOut()

logSpy.mockClear()
writeSpy.mockClear()
await build({ ats: false, json: false })

@@ -440,2 +499,3 @@ expect(logText()).toMatch(/theme:.*layout:/)

logSpy.mockClear()
writeSpy.mockClear()
await buildAll({ json: true })

@@ -457,2 +517,3 @@ const out = jsonOut()

logSpy.mockClear()
writeSpy.mockClear()
await buildAll({ json: false })

@@ -469,2 +530,3 @@ expect(logText()).toContain('bruce-wayne.pdf')

logSpy.mockClear()
writeSpy.mockClear()
expect(await exitCode(buildAll({ json: true }))).toBe(2)

@@ -492,2 +554,3 @@ expect(jsonOut()).toMatchObject({

logSpy.mockClear()
writeSpy.mockClear()
await main(argv('build', '--all', '--json'))

@@ -494,0 +557,0 @@ expect(jsonOut()).toMatchObject({ all: true, ok: true })

+46
-34

@@ -55,24 +55,34 @@ # Generating your CV with an AI assistant

Some sandboxes have Node but no way to reach npm: **measured in an OpenAI container, `node -v` reports v22.16.0 while every hostname fails to resolve**, so `npx`, `npm install`, `curl` and `git clone` all fail identically. That is not a broken probe, and no amount of retrying fixes it.
A sandbox that cannot reach npm can usually still fetch a file. So when `npx` fails, **do not fall back to a handoff — fetch the bundle.** CVX ships as one self-contained file: schema, `init` template and fonts embedded, Node 20+ and nothing else. No `npm install`, no `node_modules`, no further downloads.
For exactly that case CVX ships **`cvx.bundle.js`** — one self-contained file, attached to every [release](https://github.com/hrtips/cvx/releases/latest). It needs Node 20+ and nothing else: no `npm install`, no `npx`, no `node_modules`, no network.
Run this exact block. Do not explore the filesystem first, and do not retry npm:
Each release attaches the same bytes under two names: `cvx-<version>.bundle.js`, which is the one to keep because its name says what it is, and `cvx.bundle.js`, which exists so that `https://github.com/hrtips/cvx/releases/latest/download/cvx.bundle.js` is a stable download URL. When looking for a copy in a sandbox, match **either** — glob `cvx*.bundle.js`. Run it wherever it happens to be:
```bash
mkdir -p .cvx && cd .cvx
curl -sSfLO https://hrtips.github.io/cvx/download/cvx.bundle.min.js.zip
unzip -o -q cvx.bundle.min.js.zip
node cvx.bundle.min.js --version
```
Then use it exactly like the CLI — every command, flag, exit code and `--json` envelope is identical, because it is the same engine, and it renders byte-identical PDFs:
```bash
node /mnt/data/cvx.bundle.js init
node /mnt/data/cvx.bundle.js validate --strict --json
node /mnt/data/cvx.bundle.js build --json # --ats, --all, list: all the same
node cvx.bundle.min.js init
node cvx.bundle.min.js validate --strict --json
node cvx.bundle.min.js build --json # --ats, --all, list: all the same
```
Every command, flag, exit code and `--json` envelope is identical to `npx @hrtips/cvx` — it is the same engine, and it renders byte-identical PDFs. It writes `cv-content/` and the PDF into the **current working directory**, so `cd` to where you want them first.
It writes `cv-content/` and the PDF into the **current working directory**, so `cd` first and stay there.
**You cannot download it in a sandbox with no DNS** — if `npx` failed for network reasons, fetching a URL will fail too. The bundle has to be *already there*:
**Running CVX is a file download, not a repository task.** That URL is a public ZIP served from the project site — fetch it with ordinary HTTP. Do not clone the repository, call the GitHub API, or route this through a GitHub connector: none of it is needed to run CVX, and a connector fails on binary assets in a way that reads as "this sandbox cannot download", which is false. **One tool failing is not evidence that the sandbox has no network** — work down the list below before concluding anything, and never ask the user to upload a file you have not yet failed to fetch several ways. The repository is for reading or changing CVX's own source, nothing else.
- a **Custom GPT Knowledge file**, which lands at `/mnt/data/cvx.bundle.js` — the whole point of the file's existence, and it needs no user action per conversation;
- a file the **user uploaded** to the conversation;
- anything pre-seeded into the image or workspace.
**If `curl` is blocked, try these in order and stop at the first that works** — one attempt each, and report one line rather than narrating every failure:
So look for it before you give up: check `/mnt/data`, the working directory, and anywhere else uploads land. If it is not there, ask the user to download it from the release page and upload it — one file, no install, and they need only a browser. If they would rather not, go to path 3.
1. Python: `python3 -c "import urllib.request;urllib.request.urlretrieve('https://hrtips.github.io/cvx/download/cvx.bundle.min.js.zip','cvx.bundle.min.js.zip')"`
2. any file-download tool your runtime gives you, on that same URL;
3. **a copy already on disk** — `ls /mnt/data | grep -i cvx`, matching `cvx*.bundle*.js*`, since a release attaches versioned and unversioned names holding the same bytes, and an upload or a Custom GPT action may have put one there;
4. ask the user to download it from the [latest release](https://github.com/hrtips/cvx/releases/latest) and upload it — one file, no install, they need only a browser.
Only if all of those fail is path 3 the answer. Which variant to fetch: **`cvx.bundle.min.js.zip` (0.92 MB)** is the smallest and the default above; `cvx.bundle.js` (5.3 MB, unminified) is worth it only when you need readable stack traces.
Two limits, both deliberate. **`cvx mcp` is not in the bundle** (it exits 64 and points at npm — an MCP client cannot connect to it in a sandbox anyway). And **drop-in `.js` theme files are ignored** next to the bundle, because that directory is the user's; the three built-in themes and your own `layouts/*.yaml` work normally.

@@ -231,14 +241,24 @@

If your assistant can execute commands in a workspace (e.g. ChatGPT's agent mode), you don't need anything installed locally — not even Node. Paste:
If your assistant can execute commands in a workspace (ChatGPT with Code Interpreter, agent mode, and similar), you need **nothing installed and nothing to upload** — not even Node. It fetches CVX itself. Paste:
```text
In your workspace, install Node if needed, then run:
npx @hrtips/cvx init
Replace the example content in cv-content/ with my CV below, following
the schema in cv-content/README.md. Keep every fact truthful to my
input — don't invent anything. Then run:
npx @hrtips/cvx build
and give me BOTH the finished PDF AND a zip of the cv-content folder
as downloads. I need the zip to keep my content for future updates.
Set up CVX first, by running exactly this and nothing else:
mkdir -p cvx && cd cvx
curl -sSfLO https://hrtips.github.io/cvx/download/cvx.bundle.min.js.zip
unzip -o -q cvx.bundle.min.js.zip
node cvx.bundle.min.js --version
Do not use npm or npx — CVX needs no installation, and that just wastes time.
Then: node cvx.bundle.min.js init, replace the example content in
cv-content/ with my CV below following cv-content/README.md, run
node cvx.bundle.min.js validate --strict --json after every edit, then
node cvx.bundle.min.js build --json and build --ats --json.
Keep every fact truthful to my input — don't invent anything.
Then OPEN the PDF you just made, render its pages to images, and check the
layout before showing me anything. Fix it and rebuild if it looks wrong.
Finally give me both PDFs AND a zip of the cv-content folder as downloads —
I need the zip to keep my content for future updates.
My CV:

@@ -248,16 +268,8 @@ <paste your old CV / LinkedIn profile text here>

Two things make that prompt worth pasting verbatim. It **names the exact setup commands**, because an assistant left to work it out will try `npx` first, wait for it to fail, and go round the houses. And it **tells the assistant to look at the PDF** — the step that separates this from a handoff, since it can then fix the layout before you ever see it.
The zip matters: agent workspaces are ephemeral, and your `cv-content/` folder is the durable asset. Next time, upload the zip back (or switch to any other route) and ask for the changes you need.
**If the assistant reports that `npx` or npm failed,** its sandbox has a Node runtime but no route to the registry — common, and not something retrying fixes. Download **`cvx.bundle.js`** from the [latest release](https://github.com/hrtips/cvx/releases/latest), upload it to the conversation, and say:
**If the download is blocked** in that sandbox, download `cvx.bundle.min.js.zip` (0.92 MB) from the [latest release](https://github.com/hrtips/cvx/releases/latest) yourself, upload it into the conversation, and tell the assistant to unzip it and run `node cvx.bundle.min.js` instead of the `curl` line. Everything after that is the same. You still need no Node and no terminal — just a browser, once.
```text
Use the cvx.bundle.js file I uploaded — it is the whole of CVX in one file
and needs no installation. Run it with plain node, e.g.
node /mnt/data/cvx.bundle.js init
node /mnt/data/cvx.bundle.js build --json
then open the PDF you produced, check the layout, and iterate.
```
You need nothing installed for this — no Node, no terminal, just a browser to download the file once. And because the assistant can open the PDF it just produced, it can check the layout and fix it before handing it back, which is the part a plain file handoff cannot do. (A published Custom GPT can carry the bundle as a Knowledge file, so even that upload disappears.)
**Privacy note:** CVX itself runs entirely locally and makes zero network calls — the bundle included, which is asserted by a test that fails the build if anything reaches the network. But in Route D (and any cloud assistant route) your CV content is processed on the assistant vendor's infrastructure, subject to their terms. If you want your data to never leave your machine, use Route A/C with a local model (e.g. via Ollama) or write the YAML yourself.

@@ -304,6 +316,6 @@

- **`totalPages` is planned pages, not sheets.** A page that overflows spills onto an extra physical sheet the numbering can't count — check `totals.overflowPt` before quoting a page count.
- **`fill` is column occupancy, and it is not a progress signal.** `(fixedPt + usedPt) / capacityPt` (`diagnostics.version: 4`) — the same measurement on every page, so page 1 and page 2 compare honestly. Above 1 exactly when the page is over budget, always alongside `overflowPt` and a warning. Never steer an edit by it: shortening content LOWERS fill until a block moves up, then it jumps (measured: six of eight shortening edits on a real CV lowered it before one worked). The number that moves monotonically with your edit is `blockedBy.shortByPt`.
- **`fill` is column occupancy, and it is not a progress signal.** `(fixedPt + usedPt) / capacityPt` (`diagnostics.version: 5`) — the same measurement on every page, so page 1 and page 2 compare honestly. Above 1 exactly when the page is over budget, always alongside `overflowPt` and a warning. Never steer an edit by it: shortening content LOWERS fill until a block moves up, then it jumps (measured: six of eight shortening edits on a real CV lowered it before one worked). The number that moves monotonically with your edit is `blockedBy.shortByPt`.
- **Each main-column entry now prices itself.** `heightPt` (the placed piece), `headPt` (the indivisible part before the first bullet) broken into `head.rolePt`/`metaPt`/`locationPt`/`descriptionPt`/`progressionPt`, and `bulletsPt` per bullet. Compare `blockedBy.shortByPt` against those terms and the edit falls out by subtraction rather than by rebuilding: a role blocked by 53.64pt whose `progressionPt` is 63.9 gets most of the way there on the table alone, and its 35.15pt description would not have been enough. (The table also splits at a row boundary now, so a blocked role may simply start with fewer rows instead of moving.)
- **Ranges are 0-based and end-exclusive.** `range: [6, 8)` of `of: 8` is the last two items; `items` already carries the count. Experience entries decompose the same way (`bulletRange` / `bullets` / `ofBullets`).
- **`emptyColumn` is a diagnostic, not a target** — it means **no ink in that column**. A page 1 carrying a summary is not reported empty (it was, before `version: 4`'s lineage, which is why older text explains the difference); chrome — the identity block and page badge — never counts as content. A final page whose sidebar outlasts the experience list is normal. CVX was measured against a packer tuned to eliminate those, and the result was worse CVs — sections fragmented across five pages, headings with a single bullet under them. Report the number; don't optimise it. The exception is page 1 with no roles on it, which is a real defect and arrives as its own `page1-no-experience` warning.
- **`emptyColumn` is a diagnostic, not a target** — it means **no ink in that column**. A page 1 carrying a summary is not reported empty (it was, before `version: 5`'s lineage, which is why older text explains the difference); chrome — the identity block and page badge — never counts as content. A final page whose sidebar outlasts the experience list is normal. CVX was measured against a packer tuned to eliminate those, and the result was worse CVs — sections fragmented across five pages, headings with a single bullet under them. Report the number; don't optimise it. The exception is page 1 with no roles on it, which is a real defect and arrives as its own `page1-no-experience` warning.
- **`plan_layout` is idempotent, so nothing changes between two calls.** The pagination follows the content. With a full experience list the pagination follows the content *and the template's spacing*: the two columns are independent flows, `summary` renders only from `first.main`, and themes are colour-only with identical geometry — but `cv-content/layouts/*.yaml` accepts a `spacing:` block (`entryGap` / `bulletGap` / `sectionGap`, multipliers of the theme's vertical whitespace, legible range 0.6–1.5, out-of-range is a validation error). `entryGap` is the strongest lever on page count and the one to try before proposing any cut: measured on a real CV, `entryGap: 0.8` turned 3 pages into 2 with no word changed. Horizontal spacing stays unexposed — it would change wrap widths and therefore every measurement. (The exception is an empty or very short experience list, where moving sections between columns is the strongest lever there is and costs no content edits — see the student-layout note in SKILL.md.) When the CV is longer than the user wants: **never drop content to fit** — surface the trade-off (*"we could drop publications, or trim the two oldest roles to 2 bullets — which would you prefer?"*) and let them choose what goes. Once they've chosen, making the edit is your job; report what you changed as you change it. Don't promise a page count for an edit you haven't planned: cuts don't map to pages the way they look like they should, because sidebar and main are independent flows and the page count is the longer of them — so removing main-column text can leave the total untouched. Make the edit, then re-plan. CVX renders 100% of the YAML and never clips or hides text to save a page.

@@ -310,0 +322,0 @@

{
"srcHash": "850b41580fe60a2d21891160e3a65822141bde839b3a2fe454061f7beccc0085",
"srcHash": "50345bf60d02784f91fbebc017157927514d6b41c41d99de4b375e9441126663",
"esbuild": "0.28.1",

@@ -4,0 +4,0 @@ "modules": 47,

@@ -10,3 +10,3 @@ // @ts-nocheck

import { discoverThemes } from "../pdf/themes/index.js";
import { validateContent } from "../pdf/validateContent.js";
import { contentSchemaVersion, validateContent } from "../pdf/validateContent.js";
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");

@@ -53,3 +53,4 @@ const workspace = (dir) => resolve(dir ?? process.cwd());

const layoutsDir = join(contentDirOf(dir), "layouts");
const builtIn = ["two-column", "single-column"];
const { BUILT_IN_LAYOUT_NAMES } = await import("../pdf/defaultLayouts.js");
const builtIn = BUILT_IN_LAYOUT_NAMES;
const names = new Set(builtIn);

@@ -64,7 +65,15 @@ const layouts = builtIn.map((name) => ({

const name = basename(f, ".yaml");
if (!names.has(name)) layouts.push({ name, default: false, source: "cv-content/layouts" });
const shadowed = layouts.find((l) => l.name === name);
if (shadowed) shadowed.source = "cv-content/layouts";
else layouts.push({ name, default: false, source: "cv-content/layouts" });
names.add(name);
}
}
return { schemaVersion: 1, schema, themes, layouts, guides: packagedGuides(guides) };
return {
schemaVersion: contentSchemaVersion(),
schema,
themes,
layouts,
guides: packagedGuides(guides)
};
}

@@ -101,3 +110,3 @@ async function initCv({ dir } = {}) {

ok: result.ok,
schemaVersion: 1,
schemaVersion: contentSchemaVersion(),
strict,

@@ -240,3 +249,3 @@ errors: result.errors,

title: "Render cv-content/ to a PDF",
description: 'Renders cv-content/ to a pixel-perfect CV PDF in the workspace folder, named after the person (e.g. jane-doe.pdf). Set ats: true for the ATS-safe single-column variant (machine-friendly, no colours; produces <name>-ats.pdf). Run validate_cv first \u2014 a build with invalid content can fail or render wrong. Returns the same layout diagnostics as plan_layout (page count, per-page column fills, which roles and sections landed on which page, overflow warnings) for the PDF it just wrote, so you can report the result without a second call. Two separate lists come back: `diagnostics.warnings` is the structured list of named conditions, each with a `code` and a `kind` \u2014 match on those, never the wording. kind: "defect" means wrong, act on it (`overflow`, `page1-no-experience`); kind: "fact" means true and priced, act only if the user wants what it prices (`page1-ends-early`: page 1 has roles but the next could not start there \u2014 its `shortByPt` is what an edit would need to free, and it fires on well-packed CVs too; `main-slot-unmeasured`: the layout puts a section other than the summary/experience in a main slot, where the planner does not measure it, so the page count and overflow figures exclude it; `experience-empty`: this CV has no experience entries at all \u2014 a student or first-job CV \u2014 carrying `fixedPt`, how much of page 1 its summary occupies; `main-column-empty`: a multi-page CV whose wide column renders nothing on ANY page, carrying `pages`; `section-has-no-slot`: a DEFECT \u2014 a populated content file that no slot in this layout renders, so it appears in the ATS PDF and not the designed one and your two deliverables differ, carrying `keys`). ONE defect exists only here and never in plan_layout, because only a build produces sheets to count: `physical-pages-exceed-plan` \u2014 the PDF has MORE sheets than the plan numbered, so content the planner never measured reached the page and react-pdf flowed it onto sheets the page badges do not count. A clean dry run does NOT clear it; if you see it, open the PDF and look at the last pages. `notices` is plain-text notes about the run. Note that `diagnostics` describes the designed two-column pagination and is null for ats: true.',
description: 'Renders cv-content/ to a pixel-perfect CV PDF in the workspace folder, named after the person (e.g. jane-doe.pdf). Set ats: true for the ATS-safe single-column variant (machine-friendly, no colours; produces <name>-ats.pdf). Run validate_cv first \u2014 a build with invalid content can fail or render wrong. Returns the same layout diagnostics as plan_layout (page count, per-page column fills, which roles and sections landed on which page, overflow warnings) for the PDF it just wrote, so you can report the result without a second call. Two separate lists come back: `diagnostics.warnings` is the structured list of named conditions, each with a `code` and a `kind` \u2014 match on those, never the wording. kind: "defect" means wrong, act on it (`overflow`, `page1-no-experience`); kind: "fact" means true and priced, act only if the user wants what it prices (`page1-ends-early`: page 1 has roles but the next could not start there \u2014 its `shortByPt` is what an edit would need to free, and it fires on well-packed CVs too; `main-slot-unmeasured`: the layout puts a section other than the summary/experience in a main slot, where the planner does not measure it, so the page count and overflow figures exclude it; `experience-empty`: this CV has no experience entries at all \u2014 a student or first-job CV \u2014 carrying `fixedPt`, how much of page 1 its summary occupies; `main-column-empty`: a multi-page CV whose wide column renders nothing on ANY page, carrying `pages`; `section-has-no-slot`: a DEFECT \u2014 a populated content file that no slot in this layout renders, so it appears in the ATS PDF and not the designed one and your two deliverables differ, carrying `keys`; `slot-not-renderable`: a DEFECT \u2014 a `main` slot names a key nothing can draw (a typo, or a `<section>:continued` form only `experience` implements), so that slot renders nothing and any content there is missing from the PDF; run validate_cv, which names the file and slot, carrying `keys`). ONE defect exists only here and never in plan_layout, because only a build produces sheets to count: `physical-pages-exceed-plan` \u2014 the PDF has MORE sheets than the plan numbered, so content the planner never measured reached the page and react-pdf flowed it onto sheets the page badges do not count. A clean dry run does NOT clear it; if you see it, open the PDF and look at the last pages. `notices` is plain-text notes about the run. Note that `diagnostics` describes the designed two-column pagination and is null for ats: true.',
inputSchema: {

@@ -262,3 +271,3 @@ type: "object",

title: "See how the CV will paginate \u2014 without rendering a PDF",
description: 'Dry run: packs cv-content/ and returns the pagination plan and layout diagnostics WITHOUT writing a PDF. Use it before build_pdf to tell the user which roles land on page 1, how many pages the CV takes, and whether anything overflows \u2014 the pre-build preview, with real numbers instead of a guess. It answers for the DESIGNED two-column variant only: the ATS variant is a single column react-pdf flows on its own, CVX never packs it, and its page count can differ \u2014 there is no dry run for it, so build it to find out. Returns per page: column fill as OCCUPANCY \u2014 (fixedPt + usedPt) / capacityPt, the same measurement on every page so pages compare honestly (diagnostics.version: 4; ABOVE 1 exactly when the page is over budget \u2014 see overflowPt); per-column blockedBy \u2014 why the next role/section could NOT start on that page, whose shortByPt is the one number that falls monotonically as the content above it is shortened \u2014 and note the blocked entry has a SECOND lever of its own: shrinking its description or progression rows shrinks the piece that has to fit (fill is a description, never a progress signal: shortening content LOWERS fill until a block moves up, then it jumps); the experience entries and sidebar sections placed there (item and bullet ranges are 0-based and end-exclusive: [6,8) of 8 is the last TWO items); overflow in points; what each placed piece COSTS \u2014 heightPt, the INDIVISIBLE headPt a piece must carry before its first bullet (broken out as head.rolePt/metaPt/locationPt/descriptionPt/progressionPt) and per-bullet bulletsPt, so you can price an edit by subtraction instead of rebuilding; and which column holds no ink (emptyColumn \u2014 a page 1 carrying a summary is NOT empty). totalPages counts PLANNED pages; an overflowing page spills onto an extra physical sheet the numbering does not count, so check totals.overflowPt before quoting a page count. IMPORTANT, and it is not a bug: this tool is IDEMPOTENT \u2014 pagination is a function of the content, so calling it twice without editing cv-content/ returns exactly the same answer. With a full experience list there are effectively no layout levers either; the exception is an empty or very short experience list, where moving sections between columns is the strongest lever there is and costs no content edits. emptyColumn/emptyColumnPages are DIAGNOSTICS, NOT TARGETS: a page whose sidebar outlasts the experience list is normal, and packing to remove one measurably produces worse CVs (thin, fragmented pages). warnings carry a code and a kind \u2014 "defect" (overflow; page1-no-experience, page 1 carrying no roles at all, worth raising) versus "fact" (page1-ends-early: a priced page-break that fires on well-packed CVs too, mention it only when the user wants page 1 fuller; main-slot-unmeasured: this layout puts a section other than the summary/experience in a main slot, which renders but is NOT measured, so the numbers below exclude it; experience-empty: the CV has no experience entries at all, so the experience-related codes cannot fire; main-column-empty: every page carries only its sidebar, which is different from the ordinary residual of a sidebar outlasting a short experience list) \u2014 plus the defect section-has-no-slot, populated content no slot renders, which is the one warning that means the designed and ATS PDFs contain different information. A DRY RUN CANNOT SEE PAPER: build_pdf can additionally return `physical-pages-exceed-plan` \u2014 more sheets in the PDF than the plan numbered \u2014 and this tool never can, because it renders nothing. A clean plan is not proof of a clean PDF; build, then look. CVX renders 100% of the YAML and never drops, clips, or hides text to fit; if the user wants fewer pages, surface the trade-off (shorter bullets, fewer roles, a section they agree to cut) and let them decide what goes \u2014 never drop content on your own initiative to hit a page count. Once they have chosen, making the edit is your job. These numbers price the layout \u2014 they do not tell you whether the page looks right. Open the PDF that build_pdf returns and look at it.',
description: 'Dry run: packs cv-content/ and returns the pagination plan and layout diagnostics WITHOUT writing a PDF. Use it before build_pdf to tell the user which roles land on page 1, how many pages the CV takes, and whether anything overflows \u2014 the pre-build preview, with real numbers instead of a guess. It answers for the DESIGNED two-column variant only: the ATS variant is a single column react-pdf flows on its own, CVX never packs it, and its page count can differ \u2014 there is no dry run for it, so build it to find out. Returns per page: column fill as OCCUPANCY \u2014 (fixedPt + usedPt) / capacityPt, the same measurement on every page so pages compare honestly (diagnostics.version: 5; ABOVE 1 exactly when the page is over budget \u2014 see overflowPt); per-column blockedBy \u2014 why the next role/section could NOT start on that page, whose shortByPt is the one number that falls monotonically as the content above it is shortened \u2014 and note the blocked entry has a SECOND lever of its own: shrinking its description or progression rows shrinks the piece that has to fit (fill is a description, never a progress signal: shortening content LOWERS fill until a block moves up, then it jumps); the experience entries and sidebar sections placed there (item and bullet ranges are 0-based and end-exclusive: [6,8) of 8 is the last TWO items); overflow in points; what each placed piece COSTS \u2014 heightPt, the INDIVISIBLE headPt a piece must carry before its first bullet (broken out as head.rolePt/metaPt/locationPt/descriptionPt/progressionPt) and per-bullet bulletsPt, so you can price an edit by subtraction instead of rebuilding; and which column holds no ink (emptyColumn \u2014 a page 1 carrying a summary is NOT empty). totalPages counts PLANNED pages; an overflowing page spills onto an extra physical sheet the numbering does not count, so check totals.overflowPt before quoting a page count. IMPORTANT, and it is not a bug: this tool is IDEMPOTENT \u2014 pagination is a function of the content, so calling it twice without editing cv-content/ returns exactly the same answer. With a full experience list there are effectively no layout levers either; the exception is an empty or very short experience list, where moving sections between columns is the strongest lever there is and costs no content edits. emptyColumn/emptyColumnPages are DIAGNOSTICS, NOT TARGETS: a page whose sidebar outlasts the experience list is normal, and packing to remove one measurably produces worse CVs (thin, fragmented pages). warnings carry a code and a kind \u2014 "defect" (overflow; page1-no-experience, page 1 carrying no roles at all, worth raising) versus "fact" (page1-ends-early: a priced page-break that fires on well-packed CVs too, mention it only when the user wants page 1 fuller; main-slot-unmeasured: this layout puts a section other than the summary/experience in a main slot, which renders but is NOT measured, so the numbers below exclude it; experience-empty: the CV has no experience entries at all, so the experience-related codes cannot fire; main-column-empty: every page carries only its sidebar, which is different from the ordinary residual of a sidebar outlasting a short experience list) \u2014 plus the defect section-has-no-slot, populated content no slot renders, which is the one warning that means the designed and ATS PDFs contain different information. A DRY RUN CANNOT SEE PAPER: build_pdf can additionally return `physical-pages-exceed-plan` \u2014 more sheets in the PDF than the plan numbered \u2014 and this tool never can, because it renders nothing. A clean plan is not proof of a clean PDF; build, then look. CVX renders 100% of the YAML and never drops, clips, or hides text to fit; if the user wants fewer pages, surface the trade-off (shorter bullets, fewer roles, a section they agree to cut) and let them decide what goes \u2014 never drop content on your own initiative to hit a page count. Once they have chosen, making the edit is your job. These numbers price the layout \u2014 they do not tell you whether the page looks right. Open the PDF that build_pdf returns and look at it.',
inputSchema: {

@@ -265,0 +274,0 @@ type: "object",

@@ -6,2 +6,3 @@ // @ts-nocheck

import { buildKeywords } from "./keywords.js";
import { bulletText } from "./layout.js";
import { ThemeContext, useTheme } from "./ThemeContext.js";

@@ -124,3 +125,3 @@ import { monoTheme } from "./themes/mono.js";

/* @__PURE__ */ jsx(Text, { style: s.bulletDash, children: "\u2013" }),
/* @__PURE__ */ jsx(Text, { style: s.bulletText, children: typeof b === "string" ? b : b.text })
/* @__PURE__ */ jsx(Text, { style: s.bulletText, children: bulletText(b) })
] }, i)

@@ -131,56 +132,74 @@ ))

/* @__PURE__ */ jsx(Text, { style: s.section, children: "Experience" }),
experience.map((e, i) => /* @__PURE__ */ jsxs(View, { children: [
i > 0 && /* @__PURE__ */ jsx(View, { style: s.entryGap }),
/* @__PURE__ */ jsx(Text, { style: s.role, children: e.role }),
/* @__PURE__ */ jsxs(View, { style: s.expMeta, children: [
/* @__PURE__ */ jsx(Text, { style: s.company, children: e.company }),
/* @__PURE__ */ jsx(Text, { style: s.period, children: e.period })
] }),
e.description && /* @__PURE__ */ jsx(Text, { style: s.desc, children: e.description }),
/** @type {import('./types.js').ProgressionStep[]} */
e.progression?.length > 0 && /* @__PURE__ */ jsx(View, {
style: s.progBlock,
experience.map((e, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: N6 — the content field alone is not unique by schema (two stints at one company, two awards in one year); this is a single-shot renderToBuffer with no reconciliation, so position is the stable identity
/* @__PURE__ */ jsxs(View, { children: [
i > 0 && /* @__PURE__ */ jsx(View, { style: s.entryGap }),
/* @__PURE__ */ jsx(Text, { style: s.role, children: e.role }),
/* @__PURE__ */ jsxs(View, { style: s.expMeta, children: [
/* @__PURE__ */ jsxs(Text, { style: s.company, children: [
e.company,
e.location ? ` \xB7 ${e.location}` : ""
] }),
/* @__PURE__ */ jsx(Text, { style: s.period, children: e.period })
] }),
e.description && /* @__PURE__ */ jsx(Text, { style: s.desc, children: e.description }),
/** @type {import('./types.js').ProgressionStep[]} */
children: e.progression.map(
(p) => /* @__PURE__ */ jsxs(View, { style: s.progRow, children: [
/* @__PURE__ */ jsx(Text, { style: s.progTitle, children: p.title }),
/* @__PURE__ */ jsx(Text, { style: s.progPeriod, children: p.period })
] }, p.title)
)
}),
e.bullets?.map((b, j) => (
// biome-ignore lint/suspicious/noArrayIndexKey: bullet text may repeat; index is the stable identity for this single-shot ATS render
/* @__PURE__ */ jsxs(View, { style: s.bulletRow, children: [
/* @__PURE__ */ jsx(Text, { style: s.bulletDash, children: "\u2013" }),
/* @__PURE__ */ jsx(Text, { style: s.bulletText, children: typeof b === "string" ? b : b.text })
] }, j)
))
] }, `${e.role}-${e.company}`))
e.progression?.length > 0 && /* @__PURE__ */ jsx(View, {
style: s.progBlock,
/** @type {import('./types.js').ProgressionStep[]} */
children: e.progression.map(
(p, pi) => (
// biome-ignore lint/suspicious/noArrayIndexKey: N6 — the content field alone is not unique by schema (two stints at one company, two awards in one year); this is a single-shot renderToBuffer with no reconciliation, so position is the stable identity
/* @__PURE__ */ jsxs(View, { style: s.progRow, children: [
/* @__PURE__ */ jsx(Text, { style: s.progTitle, children: p.title }),
/* @__PURE__ */ jsx(Text, { style: s.progPeriod, children: p.period })
] }, `${pi}-${p.title}`)
)
)
}),
e.bullets?.map((b, j) => (
// biome-ignore lint/suspicious/noArrayIndexKey: bullet text may repeat; index is the stable identity for this single-shot ATS render
/* @__PURE__ */ jsxs(View, { style: s.bulletRow, children: [
/* @__PURE__ */ jsx(Text, { style: s.bulletDash, children: "\u2013" }),
/* @__PURE__ */ jsx(Text, { style: s.bulletText, children: bulletText(b) })
] }, j)
))
] }, `${i}-${e.role}-${e.company}`)
))
] }),
education?.length > 0 && /* @__PURE__ */ jsxs(View, { children: [
/* @__PURE__ */ jsx(Text, { style: s.section, children: "Education" }),
education.map((edu, i) => /* @__PURE__ */ jsxs(View, { children: [
i > 0 && /* @__PURE__ */ jsx(View, { style: { height: 5 } }),
/* @__PURE__ */ jsx(Text, { style: s.degree, children: edu.degree }),
/* @__PURE__ */ jsxs(Text, { style: s.eduMeta, children: [
edu.institution,
edu.period ? ` | ${edu.period}` : ""
] })
] }, edu.degree))
education.map((edu, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: N6 — the content field alone is not unique by schema (two stints at one company, two awards in one year); this is a single-shot renderToBuffer with no reconciliation, so position is the stable identity
/* @__PURE__ */ jsxs(View, { children: [
i > 0 && /* @__PURE__ */ jsx(View, { style: { height: 5 } }),
/* @__PURE__ */ jsx(Text, { style: s.degree, children: edu.degree }),
/* @__PURE__ */ jsxs(Text, { style: s.eduMeta, children: [
edu.institution,
edu.period ? ` | ${edu.period}` : ""
] })
] }, `${i}-${edu.degree}`)
))
] }),
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(" | ") })
] }, c.name))
certifications.map((c, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: N6 — the content field alone is not unique by schema (two stints at one company, two awards in one year); this is a single-shot renderToBuffer with no reconciliation, so position is the stable identity
/* @__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}-${c.name}`)
))
] }),
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(" | ") })
] }, p.title))
publications.map((p, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: N6 — the content field alone is not unique by schema (two stints at one company, two awards in one year); this is a single-shot renderToBuffer with no reconciliation, so position is the stable identity
/* @__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}-${p.title}`)
))
] }),

@@ -187,0 +206,0 @@ competencies?.length > 0 && /* @__PURE__ */ jsxs(View, { children: [

@@ -44,8 +44,12 @@ // @ts-nocheck

function mainSlotKeys(layout, index, totalPages) {
if (index === 0) return layout.first.main;
const cont = layout.continuation?.main ?? layout.last?.main ?? layout.first.main;
const last = layout.last?.main;
const union = (...lists) => [...new Set(lists.flat())];
if (index === 0) {
if (totalPages === 1) return union(layout.first.main, cont, last ?? []);
return layout.first.main;
}
if (index !== totalPages - 1) return cont;
const last = layout.last?.main;
if (!last) return cont;
if (totalPages === 2) return [.../* @__PURE__ */ new Set([...cont, ...last])];
if (totalPages === 2) return union(cont, last);
return last;

@@ -52,0 +56,0 @@ }

@@ -45,5 +45,7 @@ // @ts-nocheck

};
const BUILT_IN_LAYOUT_NAMES = Object.freeze(Object.keys(LAYOUTS));
export {
BUILT_IN_LAYOUT_NAMES,
LAYOUTS,
TWO_COLUMN_LAYOUT
};

@@ -106,2 +106,6 @@ // @ts-nocheck

}
function bulletText(b) {
if (typeof b === "string") return b;
return `${b?.text ?? ""}${b?.link?.label ?? ""}${b?.suffix ?? ""}`;
}
function summaryH(summary, m, measure = void 0) {

@@ -111,3 +115,3 @@ if (summary.length === 0) return 0;

for (const b of summary) {
const txt = typeof b === "string" ? b : b.text;
const txt = bulletText(b);
h += countLines(measure, txt, m.bodySize, bulletWidth(m, measure), m.cw, BODY_STYLE) * lh(m.bodySize, m.bodyLeading);

@@ -125,10 +129,3 @@ }

const bulletsPt = visible.map(
(b) => countLines(
measure,
typeof b === "string" ? b : b.text,
m.bodySize,
bulletWidth(m, measure),
m.cw,
BODY_STYLE
) * lh(m.bodySize, m.bodyLeading)
(b) => countLines(measure, bulletText(b), m.bodySize, bulletWidth(m, measure), m.cw, BODY_STYLE) * lh(m.bodySize, m.bodyLeading)
);

@@ -185,2 +182,8 @@ const rolePt = e.isContinuation ? rowH(measure, `${e.role} ${CONTINUED_ROLE_SUFFIX}`, m.roleSize, m.innerW, m.cw, {

}
function progRowH(p, m, measure, pw) {
return m.progPy * 2 + Math.max(
rowH(measure, p.title ?? "", m.metaSize, pw, m.cw, {}),
rowH(measure, p.period ?? "", m.captionSize, pw, m.cw, {})
);
}
function entryH(e, m, measure = void 0) {

@@ -196,8 +199,3 @@ if (e.isContinuation) {

const pw = m.innerW - m.progPl - m.sectionBorderWidth;
for (const p of contProg) {
h2 += m.progPy * 2 + Math.max(
rowH(measure, p.title ?? "", m.metaSize, pw, m.cw, {}),
rowH(measure, p.period ?? "", m.captionSize, pw, m.cw, {})
);
}
for (const p of contProg) h2 += progRowH(p, m, measure, pw);
}

@@ -208,3 +206,3 @@ const visible = (e.bullets ?? []).slice(e.startBullet ?? 0, e.endBullet);

for (const b of visible) {
const txt = typeof b === "string" ? b : b.text;
const txt = bulletText(b);
h2 += countLines(measure, txt, m.bodySize, bulletWidth(m, measure), m.cw, BODY_STYLE) * lh(m.bodySize, m.bodyLeading);

@@ -235,8 +233,3 @@ }

const pw = m.innerW - m.progPl - m.sectionBorderWidth;
for (const p of headProg) {
h += m.progPy * 2 + Math.max(
rowH(measure, p.title ?? "", m.metaSize, pw, m.cw, {}),
rowH(measure, p.period ?? "", m.captionSize, pw, m.cw, {})
);
}
for (const p of headProg) h += progRowH(p, m, measure, pw);
}

@@ -247,3 +240,3 @@ const visibleBullets = (e.bullets ?? []).slice(e.startBullet ?? 0, e.endBullet);

for (const b of visibleBullets) {
const txt = typeof b === "string" ? b : b.text;
const txt = bulletText(b);
h += countLines(measure, txt, m.bodySize, bulletWidth(m, measure), m.cw, BODY_STYLE) * lh(m.bodySize, m.bodyLeading);

@@ -351,6 +344,6 @@ }

const b = (
/** @type {{ itemCount?: number, entry?: { bullets?: unknown[] }, split?: unknown }} */
/** @type {{ itemCount?: number, split?: unknown }} */
block
);
items += b.itemCount ?? b.entry?.bullets?.length ?? (b.split ? SPLITTABLE_PAGES_UNKNOWN : 0);
items += b.itemCount ?? (b.split ? SPLITTABLE_PAGES_UNKNOWN : 0);
}

@@ -367,3 +360,3 @@ return flow.length + items + 1;

function describeBlock(block, index) {
const key = block?.key ?? block?.entry?.role ?? block?.id;
const key = block?.key ?? block?.id;
return `${key == null ? "block" : `"${key}"`} at flow index ${index} (height ${block?.height})`;

@@ -420,2 +413,10 @@ }

const height = entryH(entry, m, measure);
const progAtoms = Math.max(
0,
(entry.endProg ?? entry.progression?.length ?? 0) - (entry.startProg ?? 0)
);
const bulletAtoms = Math.max(
0,
(entry.endBullet ?? entry.bullets?.length ?? 0) - (entry.startBullet ?? 0)
);
return {

@@ -425,2 +426,7 @@ entry,

gapBefore,
itemCount: progAtoms + bulletAtoms,
// A human-readable identity for packer error messages — `describeBlock`
// used to reach for `entry.role`, which is vocabulary shape inside the
// engine for the sake of one string.
id: entry.role,
split: (room, forceMinimum) => {

@@ -874,2 +880,7 @@ const prog = entry.progression ?? [];

const RENDERABLE_SECTION_KEYS = Object.freeze([...MEASURED_MAIN_KEYS, ...SIDEBAR_SECTION_KEYS]);
const MAIN_SLOT_KEYS = Object.freeze([
...RENDERABLE_SECTION_KEYS,
"header-ats",
"experience:continued"
]);
function unplacedSections(content, layout) {

@@ -910,2 +921,20 @@ const placed = /* @__PURE__ */ new Set();

}
function unrenderableMainKeys(layout) {
const seen = /* @__PURE__ */ new Set();
for (
const kind of
/** @type {const} */
["first", "continuation", "last"]
) {
for (const slot of layout?.[kind]?.main ?? []) {
const key = String(slot ?? "");
if (key === "") continue;
if (isIdentityKey(key)) continue;
if (key === "spacer" || key.startsWith("spacer:")) continue;
if (MAIN_SLOT_KEYS.includes(key)) continue;
seen.add(key);
}
}
return [...seen];
}
function sidebarSliceH(key, data, sm, measure = void 0, start = 0, end = void 0) {

@@ -1077,2 +1106,3 @@ const def = SIDEBAR_SECTIONS[key];

unmeasuredMainKeys: unmeasuredMainKeys(layout),
unrenderableMainKeys: unrenderableMainKeys(layout),
/**

@@ -1140,2 +1170,3 @@ * Populated content sections no slot in this layout renders — present in

CONTINUED_SUFFIX,
MAIN_SLOT_KEYS,
MEASURED_MAIN_KEYS,

@@ -1146,2 +1177,3 @@ NATURAL_LINE_HEIGHT,

bodyHeight,
bulletText,
bulletWidth,

@@ -1148,0 +1180,0 @@ contactRows,

@@ -98,2 +98,23 @@ // @ts-nocheck

}
function slotNotRenderable(plan) {
const keys = plan.unrenderableMainKeys ?? [];
if (keys.length === 0) return [];
const safe = keys.slice(0, 5).map((k) => String(k).replace(/\s+/g, " ").slice(0, 40));
const list = safe.join(", ") + (keys.length > safe.length ? `, and ${keys.length - safe.length} more` : "");
const one = keys.length === 1;
return [
{
code: (
/** @type {const} */
"slot-not-renderable"
),
kind: (
/** @type {const} */
"defect"
),
keys: [...keys],
message: `The layout places ${list} in a main slot, and nothing can draw ${one ? "it" : "them"}: ${one ? "that key is" : "those keys are"} not a section this renderer knows, so ${one ? "it renders" : "they render"} nothing and any content there is missing from the PDF. Run \`cvx validate\` \u2014 it names the file, the slot and the likely spelling.`
}
];
}
function mainColumnEmpty(pages, totalPages, unmeasuredMainKeys = []) {

@@ -273,4 +294,7 @@ if (totalPages < 2) return [];

// Defects before facts — section-has-no-slot is a defect, so it belongs in
// this group and not after page1EndsEarly, which is a priced fact.
// this group and not after page1EndsEarly, which is a priced fact. RV1's
// slot-not-renderable is a defect for the same reason and sits beside it:
// both name content that is in cv-content/ and absent from the PDF.
...sectionHasNoSlot(plan),
...slotNotRenderable(plan),
...page1EndsEarly(pages),

@@ -309,3 +333,11 @@ ...experienceEmpty(pages),

// question a version is for.
version: 4,
//
// 5 (RV1) = the `code` union gained `slot-not-renderable`, and `warnings`
// is a published field whose set of possible values therefore changed. A
// bump rather than a silent addition, for the reason v4 gives: two
// meanings never share a version (R-E), and a consumer that enumerates
// codes — which the tool descriptions tell it to do — is reading a
// different vocabulary than a v4 consumer was. Additive for anyone
// matching on `kind`, which is why `kind` exists.
version: 5,
totalPages: plan.totalPages,

@@ -312,0 +344,0 @@ mainPageCount: plan.mainPageCount,

@@ -12,3 +12,3 @@ // @ts-nocheck

}
if (typeof val === "object" && val.continued) {
if (val !== null && typeof val === "object" && val.continued) {
return `${key}:continued`;

@@ -15,0 +15,0 @@ }

@@ -32,5 +32,12 @@ // @ts-nocheck

}
const withoutControlChars = (s) => [...s].filter((ch) => {
const cp = (
/** @type {number} */
ch.codePointAt(0)
);
return cp > 31 && cp !== 127;
}).join("");
function deriveFilename(name, suffix) {
const base = name ? name.toLowerCase().replace(/\s+/g, "-") : "cv";
return `${base}${suffix}.pdf`;
const base = withoutControlChars((name ?? "").toLowerCase().replace(/\s+/g, "-")).split(/[/\\]/).filter((seg) => seg !== "" && seg !== "." && seg !== "..").join("-").replace(/^\.+/, "");
return `${base || "cv"}${suffix}.pdf`;
}

@@ -72,8 +79,6 @@ function tryCreateMeasurer(fontsDir, warn) {

const layouts = discoverLayouts(join(contentDir, "layouts"));
const themeName = config.theme ?? "teal";
const layoutName = config.layout ?? "two-column";
const theme = themes[themeName];
const layout = layouts[layoutName] ?? void 0;
if (!theme) {
throw new Error(`Unknown theme "${themeName}". Available: ${Object.keys(themes).join(", ")}`);
if (config.theme && !themes[config.theme]) {
throw new Error(`Unknown theme "${config.theme}". Available: ${Object.keys(themes).join(", ")}`);
}

@@ -85,3 +90,4 @@ if (layoutName && !layout) {

}
const resolved = resolveDocument({ config, theme, layout });
const resolved = resolveDocument({ config, themes, layout });
const { themeName, activeTheme: theme } = resolved;
const plan = resolved.isSingleColumn ? void 0 : planTwoColumn({

@@ -172,3 +178,3 @@ content: (

);
const suffix = layoutName === "single-column" ? "-ats" : "";
const suffix = ats ? "-ats" : "";
return {

@@ -175,0 +181,0 @@ buffer,

@@ -6,7 +6,11 @@ // @ts-nocheck

import { tealTheme } from "./themes/teal.js";
const LAYOUT_DEFAULT_THEME = {
"two-column": tealTheme,
"single-column": monoTheme
};
function resolveDocument({ config, theme, layout } = {}) {
const DEFAULT_THEME_NAME = (
/** @type {const} */
{
"two-column": "teal",
"single-column": "mono"
}
);
const BUILT_IN_THEMES = { teal: tealTheme, mono: monoTheme };
function resolveDocument({ config, theme, themes, layout } = {}) {
const layoutName = config?.layout ?? "two-column";

@@ -17,9 +21,15 @@ const activeLayout = (

);
const baseTheme = theme ?? LAYOUT_DEFAULT_THEME[activeLayout.template ?? layoutName] ?? tealTheme;
const template = activeLayout.template ?? layoutName;
const themeName = config?.theme ?? DEFAULT_THEME_NAME[
/** @type {keyof typeof DEFAULT_THEME_NAME} */
template
] ?? "teal";
const baseTheme = theme ?? themes?.[themeName] ?? BUILT_IN_THEMES[themeName] ?? tealTheme;
const activeTheme = applyLayoutSpacing(baseTheme, activeLayout);
return {
layoutName,
themeName,
activeLayout,
activeTheme,
isSingleColumn: (activeLayout.template ?? layoutName) === "single-column"
isSingleColumn: template === "single-column"
// Normalised to the shape the packer reads: explicit nulls, never undefined,

@@ -26,0 +36,0 @@ // so "unset" is one value rather than two.

@@ -29,6 +29,9 @@ // @ts-nocheck

/* @__PURE__ */ jsx(SectionTitle, { variant: "sidebar", children: sliceTitle("Achievements", slice) }),
items.map((item) => /* @__PURE__ */ jsxs(View, { style: s.item, children: [
/* @__PURE__ */ jsx(Text, { style: s.year, children: item.year }),
/* @__PURE__ */ jsx(Text, { style: s.text, children: item.text })
] }, item.year))
items.map((item, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: N6 — the content field alone is not unique by schema (two stints at one company, two awards in one year); this is a single-shot renderToBuffer with no reconciliation, so position is the stable identity
/* @__PURE__ */ jsxs(View, { style: s.item, children: [
/* @__PURE__ */ jsx(Text, { style: s.year, children: item.year }),
/* @__PURE__ */ jsx(Text, { style: s.text, children: item.text })
] }, `${i}-${item.year}`)
))
] });

@@ -35,0 +38,0 @@ }

@@ -35,7 +35,10 @@ // @ts-nocheck

/* @__PURE__ */ jsx(SectionTitle, { variant: "sidebar", children: sliceTitle("Certifications", slice) }),
items.map((c) => /* @__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 })
] }, c.name))
items.map((c, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: N6 — the content field alone is not unique by schema (two stints at one company, two awards in one year); this is a single-shot renderToBuffer with no reconciliation, so position is the stable identity
/* @__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}-${c.name}`)
))
] });

@@ -42,0 +45,0 @@ }

@@ -35,7 +35,10 @@ // @ts-nocheck

/* @__PURE__ */ jsx(SectionTitle, { variant: "sidebar", children: sliceTitle("Education", slice) }),
items.map((edu) => /* @__PURE__ */ jsxs(View, { style: s.item, children: [
/* @__PURE__ */ jsx(Text, { style: s.degree, children: edu.degree }),
/* @__PURE__ */ jsx(Text, { style: s.institution, children: edu.institution }),
edu.period && /* @__PURE__ */ jsx(Text, { style: s.period, children: edu.period })
] }, edu.degree))
items.map((edu, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: N6 — the content field alone is not unique by schema (two stints at one company, two awards in one year); this is a single-shot renderToBuffer with no reconciliation, so position is the stable identity
/* @__PURE__ */ jsxs(View, { style: s.item, children: [
/* @__PURE__ */ jsx(Text, { style: s.degree, children: edu.degree }),
/* @__PURE__ */ jsx(Text, { style: s.institution, children: edu.institution }),
edu.period && /* @__PURE__ */ jsx(Text, { style: s.period, children: edu.period })
] }, `${i}-${edu.degree}`)
))
] });

@@ -42,0 +45,0 @@ }

@@ -20,6 +20,9 @@ // @ts-nocheck

/* @__PURE__ */ jsx(SectionTitle, { children: label }),
entries.map((e, i) => /* @__PURE__ */ jsxs(View, { children: [
/* @__PURE__ */ jsx(ExpItem, { ...e }),
i < entries.length - 1 && /* @__PURE__ */ jsx(View, { style: s.divider })
] }, `${e.role}-${e.company}`))
entries.map((e, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: N6 — the content field alone is not unique by schema (two stints at one company, two awards in one year); this is a single-shot renderToBuffer with no reconciliation, so position is the stable identity
/* @__PURE__ */ jsxs(View, { children: [
/* @__PURE__ */ jsx(ExpItem, { ...e }),
i < entries.length - 1 && /* @__PURE__ */ jsx(View, { style: s.divider })
] }, `${i}-${e.role}-${e.company}`)
))
] });

@@ -26,0 +29,0 @@ }

@@ -29,6 +29,9 @@ // @ts-nocheck

/* @__PURE__ */ jsx(SectionTitle, { variant: "sidebar", children: sliceTitle("Languages", slice) }),
items.map((l) => /* @__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 })
] }, l.language))
items.map((l, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: N6 — the content field alone is not unique by schema (two stints at one company, two awards in one year); this is a single-shot renderToBuffer with no reconciliation, so position is the stable identity
/* @__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}-${l.language}`)
))
] });

@@ -35,0 +38,0 @@ }

@@ -29,8 +29,11 @@ // @ts-nocheck

/* @__PURE__ */ jsx(SectionTitle, { variant: "sidebar", children: sliceTitle("Publications", slice) }),
items.map((p) => {
items.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 })
] }, p.title);
return (
// biome-ignore lint/suspicious/noArrayIndexKey: N6 — the content field alone is not unique by schema (two stints at one company, two awards in one year); this is a single-shot renderToBuffer with no reconciliation, so position is the stable identity
/* @__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}-${p.title}`)
);
})

@@ -37,0 +40,0 @@ ] });

@@ -55,6 +55,9 @@ // @ts-nocheck

/* @__PURE__ */ jsx(SectionTitle, { variant: "sidebar", children: sliceTitle("Referees", slice) }),
referees.length > 0 ? referees.map((r, i) => /* @__PURE__ */ jsxs(View, { children: [
/* @__PURE__ */ jsx(Referee, { r, s }),
i < referees.length - 1 && /* @__PURE__ */ jsx(View, { style: s.divider })
] }, r.name)) : /* @__PURE__ */ jsx(Text, { style: s.empty, children: "References available upon request." })
referees.length > 0 ? referees.map((r, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: N6 — the content field alone is not unique by schema (two stints at one company, two awards in one year); this is a single-shot renderToBuffer with no reconciliation, so position is the stable identity
/* @__PURE__ */ jsxs(View, { children: [
/* @__PURE__ */ jsx(Referee, { r, s }),
i < referees.length - 1 && /* @__PURE__ */ jsx(View, { style: s.divider })
] }, `${i}-${r.name}`)
)) : /* @__PURE__ */ jsx(Text, { style: s.empty, children: "References available upon request." })
] });

@@ -61,0 +64,0 @@ }

@@ -7,3 +7,4 @@ // @ts-nocheck

import { load as loadYaml } from "js-yaml";
import { overflowWarnings, planTwoColumn, SIDEBAR_SECTION_KEYS } from "./layout.js";
import { BUILT_IN_LAYOUT_NAMES } from "./defaultLayouts.js";
import { MAIN_SLOT_KEYS, overflowWarnings, planTwoColumn, SIDEBAR_SECTION_KEYS } from "./layout.js";
import { normalizeLayout } from "./loadLayout.js";

@@ -30,3 +31,3 @@ import {

const SCHEMA_KEY = "cvx.schema.json";
const BUILT_IN_LAYOUTS = ["two-column", "single-column"];
const BUILT_IN_LAYOUTS = BUILT_IN_LAYOUT_NAMES;
const REQUIRED_FILES = ["personal", "summary", "experience"];

@@ -44,2 +45,6 @@ let ajv;

}
function contentSchemaVersion() {
getValidator("config");
return canonicalSchema?.$defs?.config?.properties?.schemaVersion?.const ?? 1;
}
function levenshtein(a, b) {

@@ -68,2 +73,3 @@ const m = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);

}
const authoredSlot = (raw) => raw === void 0 || typeof raw === "string" || raw !== null && typeof raw === "object" && !Array.isArray(raw) && Object.keys(raw).length === 1;
const jsonType = (v) => v === null ? "null" : Array.isArray(v) ? "array" : typeof v;

@@ -283,10 +289,3 @@ function mapAjvErrors(errors, doc) {

}
const resolved = resolveDocument({
config,
theme: THEMES[
/** @type {string} */
config.theme
],
layout: userLayout
});
const resolved = resolveDocument({ config, themes: THEMES, layout: userLayout });
const plan = planTwoColumn({

@@ -308,7 +307,9 @@ content: (

}
for (const w of overflowWarnings(plan)) {
add("warning", "summary.yaml", "page-overflow", {
message: w.message,
suggestion: `page ${w.page} of the render carries a single block taller than a whole page, which no pagination can fit`
});
if (!resolved.isSingleColumn) {
for (const w of overflowWarnings(plan)) {
add("warning", "summary.yaml", "page-overflow", {
message: w.message,
suggestion: `page ${w.page} of the render carries a single block taller than a whole page, which no pagination can fit`
});
}
}

@@ -406,5 +407,3 @@ } catch {

if (typeof key !== "string") return;
const raw = rawSlots[i];
const authored = typeof raw === "string" || raw !== null && typeof raw === "object";
if (raw !== void 0 && !authored) return;
if (!authoredSlot(rawSlots[i])) return;
if (key.startsWith("identity-") || key.startsWith("spacer:")) return;

@@ -419,2 +418,36 @@ if (SIDEBAR_SECTION_KEYS.includes(key.split(":")[0])) return;

}
for (const [pageKind, page] of Object.entries(normalizeLayout(doc) ?? {})) {
const main = (
/** @type {{ main?: string[] }} */
page?.main
);
if (!Array.isArray(main)) continue;
const pages = (
/** @type {Record<string, { main?: unknown[] }>} */
/** @type {{ pages?: unknown }} */
doc?.pages ?? doc
);
const rawSlots = pages?.[pageKind]?.main ?? [];
main.forEach((key, i) => {
if (typeof key !== "string") return;
if (!authoredSlot(rawSlots[i])) return;
if (key.startsWith("identity-")) return;
if (MAIN_SLOT_KEYS.includes(key)) return;
if (key === "spacer" || key.startsWith("spacer:")) {
const arg = key.startsWith("spacer:") ? key.slice("spacer:".length) : "";
if (arg !== "" && Number.isFinite(Number(arg))) return;
add("error", file, "slot-not-renderable", {
path: `/${pageKind}/main/${i}`,
message: `"${key}" is not a usable spacer \u2014 its height must be a number`,
suggestion: `write it as \`- spacer: 27\` (points), not "${key}"`
});
return;
}
add("error", file, "slot-not-renderable", {
path: `/${pageKind}/main/${i}`,
message: `"${key}" cannot render in a main slot \u2014 it would be dropped from the PDF without warning`,
suggestion: didYouMean(key, MAIN_SLOT_KEYS) ? `did you mean "${didYouMean(key, MAIN_SLOT_KEYS)}"?` : `use one of: ${MAIN_SLOT_KEYS.join(", ")}`
});
});
}
}

@@ -439,3 +472,4 @@ const imagesDir = join(contentDir, "images");

export {
contentSchemaVersion,
validateContent
};
{
"name": "@hrtips/cvx",
"version": "1.9.2",
"version": "1.10.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",

@@ -198,2 +198,10 @@ <p align="center">

### Build it into a ChatGPT GPT
ChatGPT's sandbox has a Node runtime but usually cannot reach the npm registry, so `npx` does not work there. CVX therefore also ships as **one self-contained file** — schema, template, fonts and all — attached to [every release](https://github.com/hrtips/cvx/releases/latest) as `cvx.bundle.min.js`. It needs nothing but Node 20+: no install, no `node_modules`, no further downloads.
**In most ChatGPT sandboxes it can fetch the file itself**, so you upload nothing — the [Route D prompt](docs/ai-guide.md#route-d--agent-mode-assistant-zero-local-setup) gives it the four setup commands verbatim, which matters because an assistant left to work it out will try `npx` first and waste your turn. If the download is blocked, download the 0.92 MB zip and upload it instead; everything after that is identical.
The GPT does what a chat assistant otherwise cannot: it renders the PDF, **opens it and looks at the pages**, then fixes the layout before you ever see it.
### Your photo

@@ -200,0 +208,0 @@

@@ -15,4 +15,21 @@ ---

## The loop
## Run order
Work through these in sequence. The sections below are the detail for each — read them when you get there, not up front.
| # | Do | Detail |
|---|---|---|
| 0 | **Get CVX running.** MCP tools if connected; else `npx`; else the bundle. Never hand off before trying all three. | [Getting CVX](#getting-cvx) |
| 1 | **Ask for source content _and_ the brief in ONE message**, then wait. Never invent facts. | [Ask about shape](#ask-about-shape-before-you-draft--once-with-examples) |
| 2 | **Scaffold, then replace every example value** with the user's real content. | [Getting CVX](#getting-cvx) |
| 3 | **Validate after every edit** — `validate --strict --json` — and fix what it names. | [Getting CVX](#getting-cvx) |
| 4 | **Review the content, batch 3–5 gap questions, then show a pre-build preview** and get an OK. | [Review, then brainstorm](#review-then-brainstorm--before-the-final-build) |
| 5 | **Build both variants** — designed and `--ats`. | [Getting CVX](#getting-cvx) |
| 6 | **Open the PDF and look at every page.** Iterate on what you see, not on numbers alone. | [Reading the layout](#reading-the-layout) |
| 7 | **Deliver both PDFs and the `cv-content/` folder** — the YAML is what the user keeps, and an ephemeral sandbox loses it otherwise. | — |
Two things go wrong most often, and both are avoidable: guessing at setup instead of running the block in step 0, and reporting a successful build without ever looking at the PDF. Step 6 is what makes you more useful than a YAML generator.
## Getting CVX
If the CVX MCP server is connected, use its tools: `get_schema` → `init_cv` → edit YAML → `validate_cv` → `plan_layout` (optional dry run — see below) → `build_pdf` (pass the workspace folder as `dir`, absolute path). Otherwise use the CLI:

@@ -31,12 +48,19 @@

If `npx` is unreachable (no network in your sandbox), **look for `cvx.bundle.js` before falling back** — CVX ships as one self-contained file that needs Node and nothing else:
If `npx` is unreachable, **get the standalone bundle instead of falling back to a handoff** — CVX ships as one self-contained file that needs Node and nothing else. Run this exact block; do not explore first:
```bash
node /mnt/data/cvx.bundle.js build --json # same commands, flags, exit codes, --json
mkdir -p .cvx && cd .cvx
curl -sSfLO https://hrtips.github.io/cvx/download/cvx.bundle.min.js.zip
unzip -o -q cvx.bundle.min.js.zip
node cvx.bundle.min.js --version # same commands, flags, exit codes, --json
```
Check `/mnt/data`, the working directory, and wherever uploads land — glob `cvx*.bundle.js`, since releases attach both `cvx.bundle.js` and `cvx-<version>.bundle.js` (same bytes). It is the same engine and renders byte-identical PDFs; it writes `cv-content/` and the PDF into the current working directory, so `cd` first. It cannot be downloaded in a sandbox with no network — it has to be there already (a Custom GPT Knowledge file, or a file the user uploaded). `cvx mcp` is not included in it, and `.js` theme files dropped next to it are ignored on purpose.
**Running CVX is a file download, not a repository task.** That URL is a public ZIP served from the project site — fetch it with ordinary HTTP. Do not clone the repository, call the GitHub API, or route this through a GitHub connector: none of it is needed to run CVX, and a connector fails on binary assets in a way that reads as "this sandbox cannot download", which is false. **One tool failing is not evidence that the sandbox has no network** — work down the list below before concluding anything, and never ask the user to upload a file you have not yet failed to fetch several ways. The repository is for reading or changing CVX's own source, nothing else.
Only if there is no bundle either: write the `cv-content/*.yaml` files from the schema and deliver them with the handoff from the AI guide's default flow (see below — it ships with CVX, so no network is needed to read it) — never substitute another PDF renderer. 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.
Same engine, byte-identical PDFs. If `curl` is blocked, try in order: Python's `urllib.request.urlretrieve` on that URL, your own download tool, a copy already on disk (`ls /mnt/data | grep -i cvx` — glob `cvx*.bundle*.js*`, since a release attaches versioned and unversioned names with the same bytes), then ask the user to upload it from [the latest release](https://github.com/hrtips/cvx/releases/latest). Stop at the first that works and say one line about it — never narrate a string of failed attempts.
It writes `cv-content/` and the PDF into the **current working directory**, so `cd` first and stay there. `cvx mcp` is not in the bundle, and `.js` theme files dropped beside it are ignored on purpose.
Only if neither `npx` nor the bundle can be had: write the `cv-content/*.yaml` files from the schema and deliver them with the handoff from the AI guide's default flow (see below — it ships with CVX, so no network is needed to read it) — never substitute another PDF renderer. 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.
## Ask about shape before you draft — once, with examples

@@ -91,3 +115,3 @@

- `totalPages` is the number of **planned** pages, not necessarily the sheet count of the PDF: an overflowing page spills onto an extra sheet the numbering can't count. Check `totals.overflowPt` before telling the user "your CV is 3 pages" — and know that `overflowPt` only prices the flows the planner *measures* (summary, experience, and the sidebar sections). A main slot carrying anything else (see "Student and first-job CVs" below) can spill extra sheets with `overflowPt: 0` and no warning. CVX now runs that check for you on every build: if the finished PDF has more sheets than the plan numbered, `build_pdf` (and `cvx build --json`) returns the **`physical-pages-exceed-plan`** defect naming both counts. It is the one warning a dry run can never produce — `plan_layout` renders nothing, so a clean plan is not proof of a clean PDF. When it fires, open the PDF and look at the last pages: a mismatch is usually a trailing margin spilling a **blank** sheet, which no text-extraction check will ever notice.
- Per page: `main.fill` / `sidebar.fill` — **column occupancy**: `(fixedPt + usedPt) / capacityPt`, the same measurement on every page so pages can be compared (`diagnostics.version: 4`; v1 divided by the residual budget, which made page 1 look far emptier than it was). Normally 0–1, and **above 1 exactly when that page is over budget**. Also per page: `blockedBy` — why the next role/section could not start there, with `shortByPt`, **the one number that falls monotonically as you shorten what is above it**. Fill is a description, not a progress signal: shortening content LOWERS fill until a block moves up, then it jumps — steer by `shortByPt`, never by fill. Plus the roles on that page, and the sidebar sections with their item ranges. Ranges are **0-based and end-exclusive**: `range: [6, 8)` of `of: 8` is the last *two* items — `items` already gives you the count, so you never have to do that arithmetic. `continued: true` = carried over from the previous page.
- Per page: `main.fill` / `sidebar.fill` — **column occupancy**: `(fixedPt + usedPt) / capacityPt`, the same measurement on every page so pages can be compared (`diagnostics.version: 5`; v1 divided by the residual budget, which made page 1 look far emptier than it was). Normally 0–1, and **above 1 exactly when that page is over budget**. Also per page: `blockedBy` — why the next role/section could not start there, with `shortByPt`, **the one number that falls monotonically as you shorten what is above it**. Fill is a description, not a progress signal: shortening content LOWERS fill until a block moves up, then it jumps — steer by `shortByPt`, never by fill. Plus the roles on that page, and the sidebar sections with their item ranges. Ranges are **0-based and end-exclusive**: `range: [6, 8)` of `of: 8` is the last *two* items — `items` already gives you the count, so you never have to do that arithmetic. `continued: true` = carried over from the previous page.
- **Per main-column entry: what it costs.** `heightPt` is the placed piece's measured height; `headPt` is the **indivisible** part — everything the piece must carry before its first bullet — broken out as `head.rolePt` / `metaPt` / `locationPt` / `descriptionPt` / `progressionPt`; and `bulletsPt` prices each bullet of the slice in order. This is what turns `shortByPt` into an edit **without rebuilding**: compare it against the terms and take the first one that covers it. Worked example — a role blocked with `shortByPt: 53.64` and `headPt: 124.35` whose `head` reads `{rolePt: 13, metaPt: 12.3, descriptionPt: 35.15, progressionPt: 63.9}`: the progression table alone (63.9) exceeds the shortfall, so dropping it starts the role on page 1, while the description (35.15) alone would not. Do that subtraction before you propose any prose cut.

@@ -98,2 +122,3 @@ - `diagnostics.warnings` is the list of **named conditions**, each with a `code` — match on that, not on the wording. Each carries a `kind`: `defect` (something is wrong — act) or `fact` (true and priced — act only if the user wants what it prices):

- `physical-pages-exceed-plan` (**defect**, builds only) — the rendered PDF has more sheets than the plan numbered: content the planner did not measure reached the page and react-pdf flowed it onto sheets the page badges do not count. Payload: `planned` and `physical`. Open the PDF and look at the last pages.
- `slot-not-renderable` (**defect**) — a `main` slot names a key nothing can draw: a typo (`experiance`), or a `<section>:continued` form only `experience` implements. That slot renders NOTHING, so whatever belonged there is missing from the PDF while the plan still prices it. Payload: `keys`. Distinguish it from its neighbour: `main-slot-unmeasured` means the ink reaches the page and the arithmetic excludes it; this means the ink never reaches the page. `cvx validate` names the file, the slot index and the likely spelling — run it rather than guessing.
- `main-slot-unmeasured` (**fact**) — this layout puts a section other than the summary/experience in a `main` slot. It renders correctly, but the planner does not measure it, so `totalPages` and `overflowPt` exclude it. Payload: `keys`. Expected on the student layout below; it is why the defect above exists.

@@ -104,3 +129,3 @@ - `main-column-empty` (**fact**) — a multi-page CV whose wide column renders nothing on any page: every page carries only its sidebar. Payload: `pages`. This is *not* the ordinary case of a sidebar outlasting a short experience list (that shape has content on page 1 and runs out later, and is fine). It usually means the layout should carry sections in `main` — see "Student and first-job CVs" below.

- **`--all` restructures stdout**: `cvx build --json` puts `diagnostics` at the top level, while `cvx build --all --json` returns `{outputs: [{filename, diagnostics, …}, …]}` — one entry per variant. A script written against one shape throws on the other, so read `outputs` when you pass `--all`. `notices` is a separate, plain-text list of notes about the run (a font with no glyph for some text, a layout that fell back to the default). It is not the same field as `diagnostics.warnings`.
- `emptyColumn` / `emptyColumnPages` are **diagnostics, not targets**. It means **no ink in that column** — a page-1 main column carrying a summary is *not* empty (it used to report `'main'` before `version: 4`'s lineage, which is why older docs explain the difference). A *last* page whose sidebar outlasts the experience list is normal and fine; packing to remove it measurably produces worse CVs (fragmented sections, near-empty pages). Report it if the user asks; don't chase it — but do READ it: a sidebar that ends early names exactly which sections the CV has nothing for, and "you have no languages or certifications listed — do you have any?" is usually the most useful question left. An underfilled sidebar is a content-gathering prompt, not a layout defect. The one case that is *not* fine is page 1 — and that one arrives as the `page1-no-experience` warning, so you never have to judge it from `emptyColumn` alone.
- `emptyColumn` / `emptyColumnPages` are **diagnostics, not targets**. It means **no ink in that column** — a page-1 main column carrying a summary is *not* empty (it used to report `'main'` before `version: 5`'s lineage, which is why older docs explain the difference). A *last* page whose sidebar outlasts the experience list is normal and fine; packing to remove it measurably produces worse CVs (fragmented sections, near-empty pages). Report it if the user asks; don't chase it — but do READ it: a sidebar that ends early names exactly which sections the CV has nothing for, and "you have no languages or certifications listed — do you have any?" is usually the most useful question left. An underfilled sidebar is a content-gathering prompt, not a layout defect. The one case that is *not* fine is page 1 — and that one arrives as the `page1-no-experience` warning, so you never have to judge it from `emptyColumn` alone.

@@ -107,0 +132,0 @@ **What the warnings price, and what you do about it.** The engine states

# yaml-language-server: $schema=https://raw.githubusercontent.com/hrtips/cvx/main/schema/v1/layout.schema.json
# ── Single-Column Layout (ATS) ────────────────────────────────────────────────
# Plain single-column layout optimised for Applicant Tracking Systems.
# No sidebar, no decorative elements. Maximum parsability.
# ── Single-Column Layout ─────────────────────────────────────────────────────
# Plain single-column layout: no sidebar, no decorative elements.
#
# THIS FILE DOES NOT CONTROL `build --ats`. That flag renders its own document
# (ATSDocument.jsx) and never reads a layout file, so editing the slots below
# will not change the ATS PDF. This layout applies when `config.yaml` sets
# `layout: single-column`, which is a *designed* single-column CV.
# ─────────────────────────────────────────────────────────────────────────────

@@ -6,0 +10,0 @@