@phuthuycoding/markcv 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +188 -0
- package/dist/core/browser.d.ts +5 -0
- package/dist/core/browser.js +43 -0
- package/dist/core/fit.d.ts +11 -0
- package/dist/core/fit.js +69 -0
- package/dist/core/lint.d.ts +2 -0
- package/dist/core/lint.js +188 -0
- package/dist/core/markdown.d.ts +10 -0
- package/dist/core/markdown.js +76 -0
- package/dist/core/photo.d.ts +12 -0
- package/dist/core/photo.js +42 -0
- package/dist/core/render.d.ts +28 -0
- package/dist/core/render.js +104 -0
- package/dist/core/rules.d.ts +20 -0
- package/dist/core/rules.js +53 -0
- package/dist/core/tailor.d.ts +10 -0
- package/dist/core/tailor.js +82 -0
- package/dist/core/variants.d.ts +16 -0
- package/dist/core/variants.js +42 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +130 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.js +2 -0
- package/dist/ui.d.ts +13 -0
- package/dist/ui.js +16 -0
- package/package.json +66 -0
- package/themes/classic.css +40 -0
- package/themes/compact.css +30 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ta Manh Quyen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# markcv
|
|
2
|
+
|
|
3
|
+
[](https://github.com/phuthuycoding/markcv/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@phuthuycoding/markcv)
|
|
5
|
+
|
|
6
|
+
<p align="center">
|
|
7
|
+
<img src="docs/screenshots/engineering-lead.png" width="640" alt="A CV rendered by markcv">
|
|
8
|
+
<br>
|
|
9
|
+
<sub><code>examples/engineering-lead.md</code> — plain Markdown in, two-page A4 PDF out</sub>
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
Build a CV from Markdown — a CLI **and** an MCP server for AI agents.
|
|
13
|
+
|
|
14
|
+
Two things set it apart from ordinary markdown→PDF tools:
|
|
15
|
+
|
|
16
|
+
- **`fit`** tells you *why* your CV does not fit on 2 pages. "Content too long" and "bad page break" are different illnesses with opposite cures — trimming words while the real culprit is a heading sitting 31px from the bottom of page 1 just wastes your time.
|
|
17
|
+
- **`lint`** checks *content*, not formatting. It catches overselling **and underselling** — claiming less than you did is a mistake too, and it costs you something while gaining nothing.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
git clone https://github.com/phuthuycoding/markcv.git
|
|
23
|
+
cd markcv
|
|
24
|
+
npm install # builds dist/ via the prepare script
|
|
25
|
+
npm link # puts `markcv` and `markcv-mcp` on your PATH
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or from npm:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install -g @phuthuycoding/markcv
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The package is scoped, but the commands are not — you still type `markcv` and `markcv-mcp`.
|
|
35
|
+
|
|
36
|
+
Requires a Chromium-based browser already on your machine (Chrome, Chromium, Edge, Brave). markcv deliberately does **not** download its own Chromium — it uses `puppeteer-core`, so the install stays small. If your browser lives somewhere unusual, point at it with `MARKCV_CHROME=/path/to/chrome`.
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
markcv render cv.md -o Output.pdf # build the PDF, report the page count
|
|
42
|
+
markcv fit cv.md --pages 2 # why it does not fit yet
|
|
43
|
+
markcv lint cv.md # audit the content
|
|
44
|
+
markcv tailor cv.md --jd jd.txt # compare against a job description
|
|
45
|
+
markcv new techlead --from cv-master.md
|
|
46
|
+
markcv list # every CV in the folder
|
|
47
|
+
markcv diff cv-a.md cv-b.md # what a tailored copy dropped
|
|
48
|
+
markcv build --pages 2 # build every cv-*.md
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### `fit` — page-break diagnosis
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
✗ 3 pages (target 2)
|
|
55
|
+
· content 1986px / 2080px available
|
|
56
|
+
· 31px wasted by page breaks
|
|
57
|
+
|
|
58
|
+
Blocks pushed to a new page:
|
|
59
|
+
PROJECT HIGHLIGHTS (H2, y=1009) → wastes 31px at the end of page 1
|
|
60
|
+
|
|
61
|
+
Suggestions:
|
|
62
|
+
Content HAS ROOM (94px to spare) — length is not the problem, the page break is.
|
|
63
|
+
"PROJECT HIGHLIGHTS" at y=1009 has only 31px left before the end of page 1, so the whole block moved down.
|
|
64
|
+
Fix: reorder sections, cut ~3 lines above it, or use --theme compact.
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### `lint` — rules
|
|
68
|
+
|
|
69
|
+
| Rule | What it catches |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `over-claim` | `spearheaded`, `rigorous`, `comprehensive`, `excellence`… — self-praise nobody can verify |
|
|
72
|
+
| `under-claim` | `advised on` / `worked on` next to real scale — you are probably selling yourself short |
|
|
73
|
+
| `tense` | a finished job still described in the present tense |
|
|
74
|
+
| `unsupported-skill` | a skill listed under SKILLS with no experience line backing it |
|
|
75
|
+
| `duplicate` | two bullets saying the same thing |
|
|
76
|
+
| `role-mismatch` | an IC job title paired with people-management language |
|
|
77
|
+
| `no-metric` | a long bullet with no number in it |
|
|
78
|
+
| `ats-emoji` | emoji in a heading — risky for ATS parsers |
|
|
79
|
+
| `long-bullet` | a bullet long enough that skimmers will skip it |
|
|
80
|
+
|
|
81
|
+
## Examples
|
|
82
|
+
|
|
83
|
+
Three complete CVs in [`examples/`](examples), written for different roles so you can see
|
|
84
|
+
how the same format stretches. Each one builds with the default theme and no photo.
|
|
85
|
+
|
|
86
|
+
<table>
|
|
87
|
+
<tr>
|
|
88
|
+
<td width="33%"><a href="docs/screenshots/backend-engineer.png"><img src="docs/screenshots/backend-engineer.png" alt="Backend engineer CV"></a></td>
|
|
89
|
+
<td width="33%"><a href="docs/screenshots/engineering-lead.png"><img src="docs/screenshots/engineering-lead.png" alt="Engineering lead CV"></a></td>
|
|
90
|
+
<td width="33%"><a href="docs/screenshots/data-scientist.png"><img src="docs/screenshots/data-scientist.png" alt="Data scientist CV"></a></td>
|
|
91
|
+
</tr>
|
|
92
|
+
<tr>
|
|
93
|
+
<td align="center"><a href="examples/backend-engineer.md">backend-engineer.md</a><br><sub>1 page</sub></td>
|
|
94
|
+
<td align="center"><a href="examples/engineering-lead.md">engineering-lead.md</a><br><sub>2 pages</sub></td>
|
|
95
|
+
<td align="center"><a href="examples/data-scientist.md">data-scientist.md</a><br><sub>1 page</sub></td>
|
|
96
|
+
</tr>
|
|
97
|
+
</table>
|
|
98
|
+
|
|
99
|
+
### With a portrait photo
|
|
100
|
+
|
|
101
|
+
Markets differ: a CV in Berlin or Toronto normally carries no photo, while one in
|
|
102
|
+
Vietnam, Japan or Germany's more traditional employers usually does. Drop a
|
|
103
|
+
`photo.jpg` next to the markdown file and it lands in the top-right corner.
|
|
104
|
+
|
|
105
|
+
<p align="center">
|
|
106
|
+
<a href="docs/screenshots/fullstack-engineer-photo.png">
|
|
107
|
+
<img src="docs/screenshots/fullstack-engineer-photo.png" width="440" alt="CV with a portrait photo">
|
|
108
|
+
</a>
|
|
109
|
+
<br>
|
|
110
|
+
<sub><a href="examples/with-photo/fullstack-engineer.md">examples/with-photo/fullstack-engineer.md</a> — one page, photo auto-detected</sub>
|
|
111
|
+
</p>
|
|
112
|
+
|
|
113
|
+
The photo lives in its own folder because detection is per-directory: any `.md`
|
|
114
|
+
file next to a `photo.*` picks it up. Keep photo-less CVs in a separate folder,
|
|
115
|
+
or pass `--no-photo`.
|
|
116
|
+
|
|
117
|
+
Build them yourself:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
markcv build examples --pages 2
|
|
121
|
+
markcv render examples/with-photo/fullstack-engineer.md --pages 1
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Note what the bullets in those samples have in common: a number, or a before and after.
|
|
125
|
+
`lint` exists to push a CV in that direction — the samples are what it is aiming at, and
|
|
126
|
+
[`test/fixtures/bad-cv.md`](test/fixtures/bad-cv.md) is what it is aiming away from.
|
|
127
|
+
|
|
128
|
+
## MCP server
|
|
129
|
+
|
|
130
|
+
Lets an AI agent (Claude Code, Claude Desktop, Cursor…) build and audit CVs on its own.
|
|
131
|
+
|
|
132
|
+
Install it first (see [Install](#install) above), then point a client at `markcv-mcp`.
|
|
133
|
+
|
|
134
|
+
Check the server starts (it waits for JSON-RPC on stdin and prints nothing — that is
|
|
135
|
+
correct; Ctrl+C to quit):
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
markcv-mcp
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Register it with a client
|
|
142
|
+
|
|
143
|
+
After `npm link` the command is simply `markcv-mcp`. Without it, use an **absolute path**
|
|
144
|
+
to `dist/mcp/server.js`. The repo ships `.mcp.json.example` to copy from.
|
|
145
|
+
|
|
146
|
+
**Claude Code** — add to `.mcp.json` in your project (shared with the team), or `~/.claude.json` (just you):
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"mcpServers": {
|
|
151
|
+
"markcv": {
|
|
152
|
+
"command": "markcv-mcp",
|
|
153
|
+
"cwd": "/path/to/your/cv/folder"
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Or add it from the command line:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
claude mcp add markcv -- markcv-mcp
|
|
163
|
+
claude mcp list # confirm it connected
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Restart the client afterwards so it picks the server up.
|
|
167
|
+
|
|
168
|
+
**Claude Desktop** — `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows), same `mcpServers` shape, then restart the app.
|
|
169
|
+
|
|
170
|
+
**Cursor** — `.cursor/mcp.json` in the project, same shape.
|
|
171
|
+
|
|
172
|
+
### File paths in tool arguments
|
|
173
|
+
|
|
174
|
+
Every tool takes a file path. Relative paths resolve against the **server process's working directory**, so either pass absolute paths or give the server a `cwd`:
|
|
175
|
+
|
|
176
|
+
```json
|
|
177
|
+
{
|
|
178
|
+
"mcpServers": {
|
|
179
|
+
"markcv": {
|
|
180
|
+
"command": "node",
|
|
181
|
+
"args": ["/path/to/markcv/dist/mcp/server.js"],
|
|
182
|
+
"cwd": "/path/to/your/cv/folder"
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Tools
|
|
189
|
+
|
|
190
|
+
| Tool | Purpose |
|
|
191
|
+
|---|---|
|
|
192
|
+
| `render_cv` | build the PDF, return the real page count plus layout numbers |
|
|
193
|
+
| `check_fit` | why it does not fit in N pages: too long, or bad page breaks |
|
|
194
|
+
| `lint_cv` | audit content (overselling, underselling, tense, unbacked claims…) |
|
|
195
|
+
| `tailor_to_jd` | compare against a job description (`jd_file` or `jd_text`) |
|
|
196
|
+
| `list_variants` | list every CV in a folder |
|
|
197
|
+
| `new_variant` | start a tailored copy from a master file |
|
|
198
|
+
| `diff_variants` | compare two versions, see what a tailored copy dropped |
|
|
199
|
+
|
|
200
|
+
Every tool returns **structured JSON**, not prose — so an agent can loop on it: edit the markdown → `check_fit` → read `slackPx` and `culprits` → edit again, until it fits.
|
|
201
|
+
|
|
202
|
+
### Requirements
|
|
203
|
+
|
|
204
|
+
Node >= 18 and a Chromium-based browser. If it is in a non-standard location:
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
{ "mcpServers": { "markcv": { "command": "node", "args": ["..."],
|
|
208
|
+
"env": { "MARKCV_CHROME": "/path/to/chrome" } } } }
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## CV format
|
|
212
|
+
|
|
213
|
+
Plain Markdown. The only convention lives in the header:
|
|
214
|
+
|
|
215
|
+
```markdown
|
|
216
|
+
# Your Name
|
|
217
|
+
|
|
218
|
+
**Email:** you@example.com
|
|
219
|
+
**Phone:** +84 9xx xxx xxx
|
|
220
|
+
|
|
221
|
+
***
|
|
222
|
+
|
|
223
|
+
## OBJECTIVE
|
|
224
|
+
...
|
|
225
|
+
|
|
226
|
+
## WORK EXPERIENCE
|
|
227
|
+
|
|
228
|
+
### Company Name
|
|
229
|
+
|
|
230
|
+
**Job Title** | Jan 2020 - Dec 2023
|
|
231
|
+
|
|
232
|
+
* Bullet...
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`**Label:** value` lines directly under `# Your Name` become the contact block. A line containing `|` is read as job title + dates — and `lint` uses those dates to know whether a job has ended.
|
|
236
|
+
|
|
237
|
+
**Portrait photo:** drop `photo.jpg` (or `photo.png`, `avatar.jpg`) next to the `.md` file and it is embedded in the top-right corner. Without one you get an empty placeholder box. Photos over 400KB trigger a warning, because they push the PDF past the upload limit many job portals enforce.
|
|
238
|
+
|
|
239
|
+
**Skip a file in `markcv build`:** put `<!-- markcv:no-build -->` near the top. Useful for a master file that is a content store rather than something you submit.
|
|
240
|
+
|
|
241
|
+
## Themes
|
|
242
|
+
|
|
243
|
+
`classic` (default) and `compact`. Both are single-column, emoji-free, with a real text layer — safe for ATS parsers.
|
|
244
|
+
|
|
245
|
+
## License
|
|
246
|
+
|
|
247
|
+
MIT
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
4
|
+
import { resolve, basename, join } from "node:path";
|
|
5
|
+
import { render } from "./core/render.js";
|
|
6
|
+
import { analyseFit } from "./core/fit.js";
|
|
7
|
+
import { lint } from "./core/lint.js";
|
|
8
|
+
import { tailor } from "./core/tailor.js";
|
|
9
|
+
import { newVariant, listVariants, diffVariants } from "./core/variants.js";
|
|
10
|
+
import { c, ok, bad, warn, info } from "./ui.js";
|
|
11
|
+
const program = new Command();
|
|
12
|
+
program.name("markcv").description("Build, fit and audit a CV written in Markdown").version("0.1.0");
|
|
13
|
+
const pdfNameFor = (md) => md.replace(/\.md$/, ".pdf");
|
|
14
|
+
function printFit(r) {
|
|
15
|
+
const head = r.fits ? ok(`${r.pages} page(s)`) : bad(`${r.pages} pages (target ${r.targetPages})`);
|
|
16
|
+
console.log(head);
|
|
17
|
+
console.log(info(`content ${r.contentHeight}px / ${r.usablePerPage * (r.targetPages ?? r.pages)}px available`));
|
|
18
|
+
if (r.wastedByBreaksPx > 0)
|
|
19
|
+
console.log(info(`${r.wastedByBreaksPx}px wasted by page breaks`));
|
|
20
|
+
if (r.culprits.length) {
|
|
21
|
+
console.log(c.bold("\nBlocks pushed to a new page:"));
|
|
22
|
+
for (const cu of r.culprits) {
|
|
23
|
+
console.log(` ${c.yellow(cu.title)} ${c.dim(`(${cu.tag}, y=${cu.top})`)} → wastes ${c.red(String(cu.wastedPx) + "px")} at the end of page ${cu.page}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (r.suggestions.length) {
|
|
27
|
+
console.log(c.bold("\nSuggestions:"));
|
|
28
|
+
for (const s of r.suggestions)
|
|
29
|
+
console.log(` ${s}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function printLint(fs) {
|
|
33
|
+
if (!fs.length)
|
|
34
|
+
return console.log(ok("no issues found"));
|
|
35
|
+
const bySeverity = { error: 0, warn: 0, info: 0 };
|
|
36
|
+
for (const f of fs) {
|
|
37
|
+
bySeverity[f.severity]++;
|
|
38
|
+
const tag = f.severity === "error" ? c.red("error") : f.severity === "warn" ? c.yellow("warn ") : c.dim("info ");
|
|
39
|
+
console.log(`${tag} ${c.dim(`L${String(f.line).padStart(3)}`)} ${c.cyan(f.rule)} ${f.message}`);
|
|
40
|
+
if (f.excerpt)
|
|
41
|
+
console.log(` ${c.dim("→ " + f.excerpt)}`);
|
|
42
|
+
if (f.suggestion)
|
|
43
|
+
console.log(` ${c.green("fix:")} ${f.suggestion}`);
|
|
44
|
+
}
|
|
45
|
+
console.log(`\n${bySeverity.error} error · ${bySeverity.warn} warn · ${bySeverity.info} info`);
|
|
46
|
+
}
|
|
47
|
+
program
|
|
48
|
+
.command("render <file>")
|
|
49
|
+
.description("Build a PDF (and HTML) from a markdown file")
|
|
50
|
+
.option("-o, --pdf <path>", "output PDF path")
|
|
51
|
+
.option("--html <path>", "keep the intermediate HTML")
|
|
52
|
+
.option("-t, --theme <name>", "theme: classic | compact", "classic")
|
|
53
|
+
.option("-p, --pages <n>", "target page count", (v) => parseInt(v, 10))
|
|
54
|
+
.option("--no-photo", "do not embed the portrait photo")
|
|
55
|
+
.action(async (file, o) => {
|
|
56
|
+
const res = await render({
|
|
57
|
+
input: file,
|
|
58
|
+
pdf: o.pdf ?? pdfNameFor(file),
|
|
59
|
+
html: o.html,
|
|
60
|
+
theme: o.theme,
|
|
61
|
+
photo: o.photo === false ? null : undefined,
|
|
62
|
+
targetPages: o.pages,
|
|
63
|
+
});
|
|
64
|
+
console.log(ok(`PDF ${res.pdfPath}`));
|
|
65
|
+
if (o.html)
|
|
66
|
+
console.log(info(`HTML ${res.htmlPath}`));
|
|
67
|
+
const photoNote = o.photo === false ? "disabled" : res.photoFile ? basename(res.photoFile) : "none (placeholder box)";
|
|
68
|
+
console.log(info(`photo ${photoNote}`));
|
|
69
|
+
if (res.photoWarning)
|
|
70
|
+
console.log(warn(res.photoWarning));
|
|
71
|
+
const fit = analyseFit(res.measure, res.pageBox, res.pdfPages ?? 0, o.pages);
|
|
72
|
+
printFit(fit);
|
|
73
|
+
});
|
|
74
|
+
program
|
|
75
|
+
.command("fit <file>")
|
|
76
|
+
.description("Why the CV does not fit the target page count")
|
|
77
|
+
.option("-p, --pages <n>", "target page count", (v) => parseInt(v, 10), 2)
|
|
78
|
+
.option("-t, --theme <name>", "theme", "classic")
|
|
79
|
+
.option("--json", "output JSON")
|
|
80
|
+
.action(async (file, o) => {
|
|
81
|
+
const tmpPdf = join(process.env.TMPDIR ?? "/tmp", `markcv-fit-${Date.now()}.pdf`);
|
|
82
|
+
const res = await render({ input: file, pdf: tmpPdf, theme: o.theme, targetPages: o.pages });
|
|
83
|
+
const report = analyseFit(res.measure, res.pageBox, res.pdfPages ?? 0, o.pages);
|
|
84
|
+
if (o.json)
|
|
85
|
+
return console.log(JSON.stringify(report, null, 2));
|
|
86
|
+
printFit(report);
|
|
87
|
+
process.exitCode = report.fits ? 0 : 1;
|
|
88
|
+
});
|
|
89
|
+
program
|
|
90
|
+
.command("lint <file>")
|
|
91
|
+
.description("Audit content: overselling, underselling, tense, unbacked claims, duplicates")
|
|
92
|
+
.option("--json", "output JSON")
|
|
93
|
+
.option("--strict", "treat warnings as failures")
|
|
94
|
+
.action((file, o) => {
|
|
95
|
+
const findings = lint(readFileSync(resolve(file), "utf8"));
|
|
96
|
+
if (o.json)
|
|
97
|
+
return console.log(JSON.stringify(findings, null, 2));
|
|
98
|
+
printLint(findings);
|
|
99
|
+
const fail = findings.some((f) => f.severity === "error" || (o.strict && f.severity === "warn"));
|
|
100
|
+
process.exitCode = fail ? 1 : 0;
|
|
101
|
+
});
|
|
102
|
+
program
|
|
103
|
+
.command("tailor <file>")
|
|
104
|
+
.description("Compare the CV against a job description")
|
|
105
|
+
.requiredOption("--jd <path>", "job description file (txt/md)")
|
|
106
|
+
.option("--json", "output JSON")
|
|
107
|
+
.action((file, o) => {
|
|
108
|
+
const report = tailor(readFileSync(resolve(file), "utf8"), readFileSync(resolve(o.jd), "utf8"));
|
|
109
|
+
if (o.json)
|
|
110
|
+
return console.log(JSON.stringify(report, null, 2));
|
|
111
|
+
console.log(c.bold(`Match: ${report.score}% (${report.covered.length}/${report.covered.length + report.missing.length + report.unsupported.length} JD keywords)`));
|
|
112
|
+
if (report.missing.length) {
|
|
113
|
+
console.log(c.bold("\nAsked for by the JD, missing from the CV:"));
|
|
114
|
+
for (const m of report.missing)
|
|
115
|
+
console.log(` ${bad(m.keyword)} ${c.dim(m.hint)}`);
|
|
116
|
+
}
|
|
117
|
+
if (report.unsupported.length) {
|
|
118
|
+
console.log(c.bold("\nOnly under SKILLS, with no evidence:"));
|
|
119
|
+
for (const u of report.unsupported)
|
|
120
|
+
console.log(` ${warn(u.keyword)} ${c.dim(u.hint)}`);
|
|
121
|
+
}
|
|
122
|
+
if (report.irrelevant.length) {
|
|
123
|
+
console.log(c.bold("\nBullets unrelated to the JD (cut these first):"));
|
|
124
|
+
for (const i of report.irrelevant.slice(0, 8))
|
|
125
|
+
console.log(` ${c.dim(`L${i.line}`)} ${i.excerpt}`);
|
|
126
|
+
}
|
|
127
|
+
if (report.covered.length) {
|
|
128
|
+
console.log(c.bold("\nBacked by evidence:"));
|
|
129
|
+
console.log(" " + report.covered.map((x) => c.green(x.keyword)).join(", "));
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
program
|
|
133
|
+
.command("new <name>")
|
|
134
|
+
.description("Start a tailored version from a master file")
|
|
135
|
+
.requiredOption("--from <master>", "master CV file")
|
|
136
|
+
.action((name, o) => console.log(ok(`created ${newVariant(o.from, name)}`)));
|
|
137
|
+
program
|
|
138
|
+
.command("list [dir]")
|
|
139
|
+
.description("List every CV in a folder")
|
|
140
|
+
.action((dir = ".") => {
|
|
141
|
+
for (const v of listVariants(dir)) {
|
|
142
|
+
console.log(`${c.bold(v.name.padEnd(28))} ${String(v.lines).padStart(4)} lines ${String(v.bullets).padStart(3)} bullets ${c.dim(v.sections.join(" · "))}`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
program
|
|
146
|
+
.command("diff <a> <b>")
|
|
147
|
+
.description("Compare two CVs bullet by bullet")
|
|
148
|
+
.action((a, b) => {
|
|
149
|
+
const d = diffVariants(a, b);
|
|
150
|
+
console.log(c.bold(`${d.shared} bullets in common`));
|
|
151
|
+
if (d.onlyInA.length) {
|
|
152
|
+
console.log(c.bold(`\nOnly in ${basename(a)}:`));
|
|
153
|
+
for (const l of d.onlyInA)
|
|
154
|
+
console.log(` ${c.green("+")} ${l.slice(0, 100)}`);
|
|
155
|
+
}
|
|
156
|
+
if (d.onlyInB.length) {
|
|
157
|
+
console.log(c.bold(`\nOnly in ${basename(b)}:`));
|
|
158
|
+
for (const l of d.onlyInB)
|
|
159
|
+
console.log(` ${c.blue("+")} ${l.slice(0, 100)}`);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
program
|
|
163
|
+
.command("build [dir]")
|
|
164
|
+
.description("Build a PDF for every cv-*.md in a folder")
|
|
165
|
+
.option("-p, --pages <n>", "target page count", (v) => parseInt(v, 10), 2)
|
|
166
|
+
.option("-t, --theme <name>", "theme", "classic")
|
|
167
|
+
.action(async (dir = ".", o) => {
|
|
168
|
+
const all = readdirSync(resolve(dir)).filter((f) => f.startsWith("cv-") && f.endsWith(".md"));
|
|
169
|
+
// A file marked <!-- markcv:no-build --> is a content store, not something you
|
|
170
|
+
// submit — skip it in batch builds.
|
|
171
|
+
const files = all.filter((f) => {
|
|
172
|
+
const head = readFileSync(join(resolve(dir), f), "utf8").slice(0, 400);
|
|
173
|
+
const skip = /markcv:no-build/i.test(head);
|
|
174
|
+
if (skip)
|
|
175
|
+
console.log(`${c.dim("–")} ${f.padEnd(34)} ${c.dim("skipped (content store)")}`);
|
|
176
|
+
return !skip;
|
|
177
|
+
});
|
|
178
|
+
if (!files.length)
|
|
179
|
+
return console.log(warn("nothing to build"));
|
|
180
|
+
for (const f of files) {
|
|
181
|
+
const src = join(resolve(dir), f);
|
|
182
|
+
const res = await render({ input: src, pdf: pdfNameFor(src), theme: o.theme, targetPages: o.pages });
|
|
183
|
+
const fit = analyseFit(res.measure, res.pageBox, res.pdfPages ?? 0, o.pages);
|
|
184
|
+
const mark = fit.fits ? c.green("✓") : c.red("✗");
|
|
185
|
+
console.log(`${mark} ${f.padEnd(34)} ${fit.pages} page(s) ${c.dim(fit.fits ? "" : fit.suggestions[0] ?? "")}`);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
program.parseAsync(process.argv);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { platform } from "node:os";
|
|
3
|
+
/** Common Chrome/Edge/Chromium locations, per platform. */
|
|
4
|
+
const PATHS = {
|
|
5
|
+
darwin: [
|
|
6
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
7
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
8
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
9
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
10
|
+
],
|
|
11
|
+
linux: [
|
|
12
|
+
"/usr/bin/google-chrome",
|
|
13
|
+
"/usr/bin/chromium",
|
|
14
|
+
"/usr/bin/chromium-browser",
|
|
15
|
+
"/usr/bin/microsoft-edge",
|
|
16
|
+
"/snap/bin/chromium",
|
|
17
|
+
],
|
|
18
|
+
win32: [
|
|
19
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
20
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
21
|
+
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Locate an installed Chrome. markcv deliberately does NOT ship its own Chromium
|
|
26
|
+
* (puppeteer-core, not puppeteer) so the install stays small.
|
|
27
|
+
*/
|
|
28
|
+
export function findChrome(explicit) {
|
|
29
|
+
if (explicit) {
|
|
30
|
+
if (!existsSync(explicit))
|
|
31
|
+
throw new Error(`No browser found at: ${explicit}`);
|
|
32
|
+
return explicit;
|
|
33
|
+
}
|
|
34
|
+
const fromEnv = process.env.MARKCV_CHROME ?? process.env.CHROME_PATH;
|
|
35
|
+
if (fromEnv && existsSync(fromEnv))
|
|
36
|
+
return fromEnv;
|
|
37
|
+
const found = (PATHS[platform()] ?? []).find(existsSync);
|
|
38
|
+
if (!found) {
|
|
39
|
+
throw new Error("No Chrome/Chromium/Edge found on this machine.\n" +
|
|
40
|
+
"Install Chrome, or point at it with MARKCV_CHROME=/path/to/chrome");
|
|
41
|
+
}
|
|
42
|
+
return found;
|
|
43
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FitReport } from "../types.js";
|
|
2
|
+
import type { RawMeasure, PageBox } from "./render.js";
|
|
3
|
+
/**
|
|
4
|
+
* Explain why a CV does not fit the target page count.
|
|
5
|
+
*
|
|
6
|
+
* The key distinction: "content too long" and "bad page break" are TWO different
|
|
7
|
+
* problems with opposite cures. Trimming words while the real culprit is a heading
|
|
8
|
+
* sitting just above a page boundary wastes effort — that one needs a reorder or
|
|
9
|
+
* tighter spacing instead.
|
|
10
|
+
*/
|
|
11
|
+
export declare function analyseFit(measure: RawMeasure, pageBox: PageBox, pdfPages: number, targetPages?: number): FitReport;
|
package/dist/core/fit.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explain why a CV does not fit the target page count.
|
|
3
|
+
*
|
|
4
|
+
* The key distinction: "content too long" and "bad page break" are TWO different
|
|
5
|
+
* problems with opposite cures. Trimming words while the real culprit is a heading
|
|
6
|
+
* sitting just above a page boundary wastes effort — that one needs a reorder or
|
|
7
|
+
* tighter spacing instead.
|
|
8
|
+
*/
|
|
9
|
+
export function analyseFit(measure, pageBox, pdfPages, targetPages) {
|
|
10
|
+
const usable = pageBox.heightPx;
|
|
11
|
+
const { contentHeight } = measure;
|
|
12
|
+
const culprits = [];
|
|
13
|
+
for (const b of measure.blocks) {
|
|
14
|
+
// Bottom edge of the page this block sits on. floor+1, not ceil: a block at
|
|
15
|
+
// y=0 belongs to page 1 and still has the whole page below it.
|
|
16
|
+
const pageEnd = (Math.floor(b.top / usable) + 1) * usable;
|
|
17
|
+
const room = pageEnd - b.top;
|
|
18
|
+
// The cluster (heading + the element glued to it) does not fit -> it gets pushed down.
|
|
19
|
+
if (b.clusterHeight > room) {
|
|
20
|
+
culprits.push({
|
|
21
|
+
title: b.title,
|
|
22
|
+
tag: b.tag,
|
|
23
|
+
top: b.top,
|
|
24
|
+
wastedPx: Math.round(room),
|
|
25
|
+
page: Math.floor(b.top / usable) + 1,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
culprits.sort((a, b) => b.wastedPx - a.wastedPx);
|
|
30
|
+
const wastedByBreaksPx = culprits.reduce((sum, c) => sum + c.wastedPx, 0);
|
|
31
|
+
const target = targetPages ?? pdfPages;
|
|
32
|
+
const budget = usable * target;
|
|
33
|
+
const slackPx = Math.round(budget - contentHeight);
|
|
34
|
+
const contentWouldFit = contentHeight <= budget;
|
|
35
|
+
const fits = pdfPages <= target;
|
|
36
|
+
const suggestions = [];
|
|
37
|
+
if (!fits) {
|
|
38
|
+
if (contentWouldFit) {
|
|
39
|
+
const worst = culprits[0];
|
|
40
|
+
suggestions.push(`Content HAS ROOM (${slackPx}px to spare) - length is not the problem, the page break is.`);
|
|
41
|
+
if (worst) {
|
|
42
|
+
suggestions.push(`"${worst.title}" at y=${worst.top} has only ${worst.wastedPx}px left before the end of ` +
|
|
43
|
+
`page ${worst.page}, so the whole block moved down.`);
|
|
44
|
+
suggestions.push(`Fix: reorder sections, cut ~${Math.ceil(worst.wastedPx / 15)} lines above it, ` +
|
|
45
|
+
`or use --theme compact.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
const over = Math.abs(slackPx);
|
|
50
|
+
suggestions.push(`Content is ~${over}px longer than ${target} page(s) (~${Math.ceil(over / 15)} lines).`);
|
|
51
|
+
suggestions.push(`Cut ~${Math.ceil(over / 15)} lines, or use --theme compact.`);
|
|
52
|
+
if (wastedByBreaksPx > 0) {
|
|
53
|
+
suggestions.push(`On top of that, ${wastedByBreaksPx}px is wasted by page breaks - see culprits.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
pages: pdfPages,
|
|
59
|
+
targetPages,
|
|
60
|
+
fits,
|
|
61
|
+
contentHeight,
|
|
62
|
+
usablePerPage: usable,
|
|
63
|
+
slackPx,
|
|
64
|
+
contentWouldFit,
|
|
65
|
+
wastedByBreaksPx,
|
|
66
|
+
culprits: culprits.slice(0, 5),
|
|
67
|
+
suggestions,
|
|
68
|
+
};
|
|
69
|
+
}
|