@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.
@@ -0,0 +1,188 @@
1
+ import { BOAST, WEAK_VERBS, SCALE_HINTS, PRESENT_VERBS, TECH_TOKENS, HEAVY_TOKENS, IC_TITLES, PEOPLE_MGMT } from "./rules.js";
2
+ const BULLET_RE = /^\s*[*-]\s+(.*)$/;
3
+ const H2_RE = /^##\s+(.*)$/;
4
+ const H3_RE = /^###\s+(.*)$/;
5
+ const META_RE = /^\*\*(.+?)\*\*\s*\|\s*(.*)$/;
6
+ /** A finished date range: no Present/Now marker. */
7
+ // 'hiện tại' is Vietnamese for 'present' - kept so Vietnamese-language CVs work too.
8
+ const ENDED_RE = /\d{4}\s*[-–—]\s*(?!.*(present|now|current|hiện tại))/i;
9
+ const strip = (s) => s.replace(/\*\*/g, "").replace(/`/g, "").trim();
10
+ const lower = (s) => strip(s).toLowerCase();
11
+ export function lint(raw) {
12
+ const lines = raw.split("\n");
13
+ const findings = [];
14
+ const ctx = { section: "", jobTitle: "", jobEnded: false };
15
+ const bullets = [];
16
+ const skillTokens = new Map();
17
+ const evidence = new Set();
18
+ // Evidence can appear on ANY line outside SKILLS — including the prose in
19
+ // PROJECT HIGHLIGHTS, not just bullets.
20
+ {
21
+ let sec = "";
22
+ for (const line of lines) {
23
+ const h = line.match(H2_RE);
24
+ if (h) {
25
+ sec = strip(h[1]).toUpperCase();
26
+ continue;
27
+ }
28
+ if (sec.includes("SKILL"))
29
+ continue;
30
+ const flat = lower(line);
31
+ for (const t of TECH_TOKENS)
32
+ if (flat.includes(t))
33
+ evidence.add(t);
34
+ }
35
+ }
36
+ lines.forEach((line, idx) => {
37
+ const no = idx + 1;
38
+ const h2 = line.match(H2_RE);
39
+ if (h2) {
40
+ ctx.section = strip(h2[1]).toUpperCase();
41
+ ctx.jobTitle = "";
42
+ ctx.jobEnded = false;
43
+ if (/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(line)) {
44
+ findings.push({
45
+ rule: "ats-emoji", severity: "warn", line: no, excerpt: strip(h2[1]),
46
+ message: "Emoji in a heading can break fonts or confuse ATS parsers.",
47
+ suggestion: "Remove the emoji from the heading.",
48
+ });
49
+ }
50
+ return;
51
+ }
52
+ const h3 = line.match(H3_RE);
53
+ if (h3) {
54
+ ctx.jobTitle = strip(h3[1]);
55
+ return;
56
+ }
57
+ const meta = line.match(META_RE);
58
+ if (meta) {
59
+ ctx.jobTitle = strip(meta[1]);
60
+ ctx.jobEnded = ENDED_RE.test(line);
61
+ return;
62
+ }
63
+ const bullet = line.match(BULLET_RE);
64
+ if (!bullet)
65
+ return;
66
+ const text = bullet[1];
67
+ const flat = lower(text);
68
+ bullets.push({ line: no, text: strip(text), section: ctx.section });
69
+ if (ctx.section.includes("SKILL")) {
70
+ for (const t of TECH_TOKENS)
71
+ if (flat.includes(t))
72
+ skillTokens.set(t, no);
73
+ }
74
+ // 1. overselling
75
+ for (const w of BOAST) {
76
+ if (flat.includes(w)) {
77
+ findings.push({
78
+ rule: "over-claim", severity: "warn", line: no, excerpt: w,
79
+ message: `"${w}" is unverifiable self-praise — readers skip straight past it.`,
80
+ suggestion: "Replace it with a fact: a number, a scale, or a before/after result.",
81
+ });
82
+ }
83
+ }
84
+ // 2. underselling: a weak verb next to real scale
85
+ const weak = WEAK_VERBS.find((w) => flat.startsWith(w) || flat.includes(` ${w} `));
86
+ if (weak && SCALE_HINTS.some((h) => flat.includes(h)) && /\d/.test(flat)) {
87
+ findings.push({
88
+ rule: "under-claim", severity: "error", line: no, excerpt: weak,
89
+ message: `"${weak}" understates the scale described on the same line.`,
90
+ suggestion: "If you actually did or led it, say Led / Built / Designed.",
91
+ });
92
+ }
93
+ // 3. tense: a finished job written in the present
94
+ if (ctx.jobEnded) {
95
+ const first = flat.replace(/^\*\*[^*]+:\*\*\s*/, "").split(/\s+/)[0]?.replace(/[^a-z]/g, "");
96
+ const firstAfterLabel = strip(text).replace(/^[^:]+:\s*/, "").split(/\s+/)[0]?.toLowerCase();
97
+ const verb = PRESENT_VERBS.find((v) => v === first || v === firstAfterLabel);
98
+ if (verb) {
99
+ findings.push({
100
+ rule: "tense", severity: "error", line: no, excerpt: verb,
101
+ message: `"${ctx.jobTitle}" has ended, but this bullet is in the present tense ("${verb}").`,
102
+ suggestion: "Switch it to past tense.",
103
+ });
104
+ }
105
+ }
106
+ // 4. experience bullet with no number at all
107
+ if ((ctx.section.includes("EXPERIENCE") || ctx.section.includes("PROJECT")) && !/\d/.test(flat) && flat.length > 60) {
108
+ findings.push({
109
+ rule: "no-metric", severity: "info", line: no, excerpt: strip(text).slice(0, 60),
110
+ message: "A long bullet with no number in it.",
111
+ suggestion: "Add a scale or a before/after result if you have one.",
112
+ });
113
+ }
114
+ // 5. bullet too long
115
+ if (strip(text).length > 320) {
116
+ findings.push({
117
+ rule: "long-bullet", severity: "info", line: no, excerpt: `${strip(text).length} chars`,
118
+ message: "Long enough that a skimming reader will skip it.",
119
+ suggestion: "Split it in two, or move the detail into a project section.",
120
+ });
121
+ }
122
+ // 6. role mismatch: IC title, people-management wording
123
+ const titleLower = ctx.jobTitle.toLowerCase();
124
+ if (IC_TITLES.some((t) => titleLower.includes(t)) && !/(lead|manager|head|director)/.test(titleLower)) {
125
+ const mgmt = PEOPLE_MGMT.find((m) => flat.includes(m));
126
+ if (mgmt && /\d/.test(flat)) {
127
+ findings.push({
128
+ rule: "role-mismatch", severity: "warn", line: no, excerpt: mgmt,
129
+ message: `"${ctx.jobTitle}" is an IC title, but this bullet reads like people management.`,
130
+ suggestion: "Make the technical/advisory nature explicit, or drop the headcount.",
131
+ });
132
+ }
133
+ }
134
+ });
135
+ // 7. skill listed under SKILLS with no supporting experience
136
+ for (const [token, line] of skillTokens) {
137
+ if (!evidence.has(token)) {
138
+ const heavy = HEAVY_TOKENS.includes(token);
139
+ findings.push({
140
+ rule: "unsupported-skill",
141
+ severity: heavy ? "warn" : "info",
142
+ line, excerpt: token,
143
+ message: heavy
144
+ ? `"${token}" appears only under SKILLS — this is one interviewers dig into.`
145
+ : `"${token}" appears only under SKILLS (harmless for a commodity technology).`,
146
+ suggestion: heavy
147
+ ? "Add an experience line that backs it up, or drop it from SKILLS."
148
+ : "Safe to ignore unless the JD leans on it.",
149
+ });
150
+ }
151
+ }
152
+ // 8. two bullets saying the same thing.
153
+ // SKILLS is excluded: a stack list naturally shares words with experience bullets,
154
+ // which is not repetition.
155
+ const comparable = bullets.filter((b) => !b.section.includes("SKILL"));
156
+ for (let i = 0; i < comparable.length; i++) {
157
+ for (let j = i + 1; j < comparable.length; j++) {
158
+ const sim = similarity(comparable[i].text, comparable[j].text);
159
+ if (sim >= 0.6) {
160
+ findings.push({
161
+ rule: "duplicate", severity: "warn", line: comparable[j].line,
162
+ excerpt: comparable[j].text.slice(0, 60),
163
+ message: `Says the same thing as line ${comparable[i].line} (${Math.round(sim * 100)}%).`,
164
+ suggestion: "Merge them, or drop one.",
165
+ });
166
+ }
167
+ }
168
+ }
169
+ // 9. missing basic contact details
170
+ const head = raw.slice(0, 1200).toLowerCase();
171
+ if (!/@/.test(head)) {
172
+ findings.push({ rule: "contact", severity: "error", line: 1, excerpt: "email",
173
+ message: "No email address found in the CV header.", suggestion: "Add an **Email:** line." });
174
+ }
175
+ return findings.sort((a, b) => a.line - b.line);
176
+ }
177
+ /** Similarity over meaningful words, ignoring short filler words. */
178
+ function similarity(a, b) {
179
+ const words = (s) => new Set(s.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 3));
180
+ const A = words(a), B = words(b);
181
+ if (A.size < 4 || B.size < 4)
182
+ return 0;
183
+ let shared = 0;
184
+ for (const w of A)
185
+ if (B.has(w))
186
+ shared++;
187
+ return shared / Math.min(A.size, B.size);
188
+ }
@@ -0,0 +1,10 @@
1
+ import type { CvDoc } from "../types.js";
2
+ export declare function parseCv(raw: string): CvDoc;
3
+ /**
4
+ * Markdown -> page body HTML.
5
+ * The header (name + contact lines) is wrapped in .header so the portrait can be
6
+ * positioned against it, and contact lines are grouped into .contacts so they can
7
+ * be laid out as one block.
8
+ */
9
+ export declare function renderBody(raw: string, photoHtml: string): string;
10
+ export declare function buildHtml(body: string, css: string, title?: string): string;
@@ -0,0 +1,76 @@
1
+ import MarkdownIt from "markdown-it";
2
+ const md = new MarkdownIt({ html: false, linkify: false, typographer: false });
3
+ /** A header contact line: `**Label:** value` (no `|`, unlike a job-title line). */
4
+ const CONTACT_RE = /^<p><strong>[^<]+:<\/strong>[^|]*<\/p>$/;
5
+ /** A job-title line: `**Job Title** | 2020 - 2021`. */
6
+ const META_RE = /^<p><strong>.*\|.*<\/p>$/;
7
+ export function parseCv(raw) {
8
+ const lines = raw.split("\n");
9
+ const sections = [];
10
+ lines.forEach((line, i) => {
11
+ if (line.startsWith("## ")) {
12
+ if (sections.length)
13
+ sections[sections.length - 1].end = i - 1;
14
+ sections.push({ title: line.slice(3).trim(), start: i, end: lines.length - 1 });
15
+ }
16
+ });
17
+ return { raw, lines, sections };
18
+ }
19
+ /**
20
+ * Markdown -> page body HTML.
21
+ * The header (name + contact lines) is wrapped in .header so the portrait can be
22
+ * positioned against it, and contact lines are grouped into .contacts so they can
23
+ * be laid out as one block.
24
+ */
25
+ export function renderBody(raw, photoHtml) {
26
+ const html = md.render(raw);
27
+ const blocks = html.split("\n").filter((l) => l.trim() !== "");
28
+ const out = [];
29
+ let headerOpen = false;
30
+ let contactsOpen = false;
31
+ const closeContacts = () => {
32
+ if (contactsOpen) {
33
+ out.push("</div>");
34
+ contactsOpen = false;
35
+ }
36
+ };
37
+ for (const block of blocks) {
38
+ const isContact = headerOpen && CONTACT_RE.test(block);
39
+ if (!isContact)
40
+ closeContacts();
41
+ if (block.startsWith("<h1>")) {
42
+ out.push('<div class="header">', photoHtml, block);
43
+ headerOpen = true;
44
+ continue;
45
+ }
46
+ if (block.startsWith("<h2>") && headerOpen) {
47
+ out.push("</div>"); // close .header before the first section
48
+ headerOpen = false;
49
+ }
50
+ if (isContact) {
51
+ if (!contactsOpen) {
52
+ out.push('<div class="contacts">');
53
+ contactsOpen = true;
54
+ }
55
+ out.push(block.replace("<p>", '<p class="contact">'));
56
+ continue;
57
+ }
58
+ if (META_RE.test(block)) {
59
+ out.push(block.replace("<p>", '<p class="meta">'));
60
+ continue;
61
+ }
62
+ out.push(block);
63
+ }
64
+ closeContacts();
65
+ if (headerOpen)
66
+ out.push("</div>");
67
+ return out.join("\n");
68
+ }
69
+ export function buildHtml(body, css, title = "CV") {
70
+ return [
71
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">',
72
+ `<title>${title}</title><style>${css}</style></head><body>`,
73
+ body,
74
+ "</body></html>",
75
+ ].join("");
76
+ }
@@ -0,0 +1,12 @@
1
+ export interface PhotoResult {
2
+ html: string;
3
+ file: string | null;
4
+ bytes: number;
5
+ warning?: string;
6
+ }
7
+ /**
8
+ * Return the portrait as a base64-embedded tag, or an empty placeholder box.
9
+ * Embedding keeps the HTML self-contained — it survives being moved or emailed,
10
+ * and Chrome's PDF export never depends on a relative path.
11
+ */
12
+ export declare function photoBlock(mdPath: string, explicit?: string | null): PhotoResult;
@@ -0,0 +1,42 @@
1
+ import { readFileSync, existsSync, statSync } from "node:fs";
2
+ import { dirname, extname, resolve } from "node:path";
3
+ const CANDIDATES = ["photo.jpg", "photo.jpeg", "photo.png", "photo.webp", "avatar.jpg", "avatar.png"];
4
+ const MIME = {
5
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp",
6
+ };
7
+ /** Above this size the photo pushes the PDF past what job portals accept. */
8
+ const WARN_BYTES = 400 * 1024;
9
+ /**
10
+ * Return the portrait as a base64-embedded tag, or an empty placeholder box.
11
+ * Embedding keeps the HTML self-contained — it survives being moved or emailed,
12
+ * and Chrome's PDF export never depends on a relative path.
13
+ */
14
+ export function photoBlock(mdPath, explicit) {
15
+ if (explicit === null)
16
+ return { html: "", file: null, bytes: 0 }; // explicitly disabled
17
+ const folder = dirname(resolve(mdPath));
18
+ const found = explicit
19
+ ? (existsSync(explicit) ? explicit : null)
20
+ : CANDIDATES.map((n) => resolve(folder, n)).find(existsSync) ?? null;
21
+ if (!found) {
22
+ return {
23
+ html: '<div class="photo-empty">PHOTO</div>',
24
+ file: null,
25
+ bytes: 0,
26
+ };
27
+ }
28
+ const bytes = statSync(found).size;
29
+ const mime = MIME[extname(found).toLowerCase()] ?? "image/jpeg";
30
+ const b64 = readFileSync(found).toString("base64");
31
+ const result = {
32
+ html: `<img class="photo" src="data:${mime};base64,${b64}" alt="">`,
33
+ file: found,
34
+ bytes,
35
+ };
36
+ if (bytes > WARN_BYTES) {
37
+ result.warning =
38
+ `Photo is ${(bytes / 1024 / 1024).toFixed(1)}MB and will bloat the PDF. ` +
39
+ `Compress it first: sips -Z 800 -s format jpeg -s formatOptions 88 <photo> --out photo.jpg`;
40
+ }
41
+ return result;
42
+ }
@@ -0,0 +1,28 @@
1
+ import type { RenderOptions } from "../types.js";
2
+ export interface PageBox {
3
+ widthPx: number;
4
+ heightPx: number;
5
+ }
6
+ /** Read the theme's `@page { margin: ... }` to derive the real printable area. */
7
+ export declare function pageBoxFromCss(css: string): PageBox;
8
+ export declare function loadTheme(name?: string): string;
9
+ /** Measurements taken in the browser at the printable width. */
10
+ export interface RawMeasure {
11
+ contentHeight: number;
12
+ blocks: {
13
+ tag: string;
14
+ title: string;
15
+ top: number;
16
+ clusterHeight: number;
17
+ }[];
18
+ }
19
+ export interface RenderResult {
20
+ htmlPath: string;
21
+ pdfPath?: string;
22
+ pageBox: PageBox;
23
+ measure: RawMeasure;
24
+ pdfPages?: number;
25
+ photoFile: string | null;
26
+ photoWarning?: string;
27
+ }
28
+ export declare function render(opts: RenderOptions): Promise<RenderResult>;
@@ -0,0 +1,104 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
2
+ import { dirname, resolve, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { tmpdir } from "node:os";
5
+ import puppeteer from "puppeteer-core";
6
+ import { renderBody, buildHtml } from "./markdown.js";
7
+ import { photoBlock } from "./photo.js";
8
+ import { findChrome } from "./browser.js";
9
+ const HERE = dirname(fileURLToPath(import.meta.url));
10
+ const MM_TO_PX = 96 / 25.4;
11
+ const A4 = { widthMm: 210, heightMm: 297 };
12
+ /** Read the theme's `@page { margin: ... }` to derive the real printable area. */
13
+ export function pageBoxFromCss(css) {
14
+ const at = css.match(/@page\s*{([^}]*)}/)?.[1] ?? "";
15
+ const margin = at.match(/margin:\s*([^;]+);/)?.[1]?.trim() ?? "10mm";
16
+ const parts = margin.split(/\s+/).map((v) => parseFloat(v) || 0);
17
+ const [top, right, bottom, left] = parts.length === 1 ? [parts[0], parts[0], parts[0], parts[0]]
18
+ : parts.length === 2 ? [parts[0], parts[1], parts[0], parts[1]]
19
+ : parts.length === 3 ? [parts[0], parts[1], parts[2], parts[1]]
20
+ : [parts[0], parts[1], parts[2], parts[3]];
21
+ return {
22
+ widthPx: Math.round((A4.widthMm - left - right) * MM_TO_PX),
23
+ heightPx: Math.round((A4.heightMm - top - bottom) * MM_TO_PX),
24
+ };
25
+ }
26
+ export function loadTheme(name = "classic") {
27
+ const candidates = [
28
+ resolve(HERE, "../../themes", `${name}.css`),
29
+ resolve(HERE, "../../../themes", `${name}.css`),
30
+ resolve(process.cwd(), "themes", `${name}.css`),
31
+ resolve(name),
32
+ ];
33
+ const found = candidates.find(existsSync);
34
+ if (!found)
35
+ throw new Error(`Theme "${name}" not found. Available: classic, compact.`);
36
+ return readFileSync(found, "utf8");
37
+ }
38
+ function countPdfPages(path) {
39
+ const buf = readFileSync(path);
40
+ const matches = buf.toString("latin1").match(/\/Type\s*\/Page[^s]/g);
41
+ return matches ? matches.length : 0;
42
+ }
43
+ export async function render(opts) {
44
+ const mdPath = resolve(opts.input);
45
+ const raw = readFileSync(mdPath, "utf8");
46
+ const css = loadTheme(opts.theme);
47
+ const pageBox = pageBoxFromCss(css);
48
+ const photo = photoBlock(mdPath, opts.photo);
49
+ const html = buildHtml(renderBody(raw, photo.html), css);
50
+ const htmlPath = opts.html
51
+ ? resolve(opts.html)
52
+ : join(tmpdir(), `markcv-${Date.now()}.html`);
53
+ mkdirSync(dirname(htmlPath), { recursive: true });
54
+ writeFileSync(htmlPath, html, "utf8");
55
+ let browser;
56
+ try {
57
+ browser = await puppeteer.launch({
58
+ executablePath: findChrome(),
59
+ headless: true,
60
+ args: ["--no-sandbox", "--disable-gpu", "--font-render-hinting=none"],
61
+ });
62
+ const page = await browser.newPage();
63
+ // Measure at the printable width, otherwise text wraps differently than it prints.
64
+ await page.setViewport({ width: pageBox.widthPx, height: pageBox.heightPx });
65
+ await page.goto(`file://${htmlPath}`, { waitUntil: "networkidle0" });
66
+ const measure = await page.evaluate(() => {
67
+ const blocks = [...document.querySelectorAll("h1, h2, h3")].map((el) => {
68
+ const top = el.getBoundingClientRect().top + window.scrollY;
69
+ // h2/h3 use break-after: avoid, so they stay glued to the element after them.
70
+ const next = el.nextElementSibling;
71
+ const bottom = next
72
+ ? next.getBoundingClientRect().bottom + window.scrollY
73
+ : el.getBoundingClientRect().bottom + window.scrollY;
74
+ return {
75
+ tag: el.tagName,
76
+ title: (el.textContent ?? "").trim().slice(0, 48),
77
+ top: Math.round(top),
78
+ clusterHeight: Math.round(bottom - top),
79
+ };
80
+ });
81
+ return { contentHeight: Math.round(document.body.scrollHeight), blocks };
82
+ });
83
+ let pdfPages;
84
+ let pdfPath;
85
+ if (opts.pdf) {
86
+ pdfPath = resolve(opts.pdf);
87
+ mkdirSync(dirname(pdfPath), { recursive: true });
88
+ await page.pdf({
89
+ path: pdfPath,
90
+ printBackground: true,
91
+ preferCSSPageSize: true,
92
+ displayHeaderFooter: false,
93
+ });
94
+ pdfPages = countPdfPages(pdfPath);
95
+ }
96
+ return {
97
+ htmlPath, pdfPath, pageBox, measure, pdfPages,
98
+ photoFile: photo.file, photoWarning: photo.warning,
99
+ };
100
+ }
101
+ finally {
102
+ await browser?.close();
103
+ }
104
+ }
@@ -0,0 +1,20 @@
1
+ /** Word lists for lint. Kept separate so they can be tuned without touching logic. */
2
+ /** Self-praise adjectives — readers skip them, interviewers distrust them. */
3
+ export declare const BOAST: string[];
4
+ /** Understating verbs — dangerous next to work of real scale. */
5
+ export declare const WEAK_VERBS: string[];
6
+ /** Markers of scale — suspicious when paired with a weak verb. */
7
+ export declare const SCALE_HINTS: string[];
8
+ /** Base-form verbs that often start a bullet — used to catch present-tense slips. */
9
+ export declare const PRESENT_VERBS: string[];
10
+ /** Tech keywords commonly listed under SKILLS with nothing backing them. */
11
+ export declare const TECH_TOKENS: string[];
12
+ /** Individual-contributor titles — people-management wording next to them signals a mismatch. */
13
+ export declare const IC_TITLES: string[];
14
+ export declare const PEOPLE_MGMT: string[];
15
+ /**
16
+ * Keywords an interviewer will dig into. Claiming these without evidence falls apart
17
+ * under questioning. The rest (MySQL, Redis, Docker…) are perfectly normal to list
18
+ * under SKILLS and not worth warning about.
19
+ */
20
+ export declare const HEAVY_TOKENS: string[];
@@ -0,0 +1,53 @@
1
+ /** Word lists for lint. Kept separate so they can be tuned without touching logic. */
2
+ /** Self-praise adjectives — readers skip them, interviewers distrust them. */
3
+ export const BOAST = [
4
+ "spearheaded", "championed", "rigorous", "rigorously", "comprehensive", "world-class",
5
+ "cutting-edge", "state-of-the-art", "best-in-class", "excellence", "seamlessly",
6
+ "significantly", "dramatically", "highly skilled", "expertly", "passionate",
7
+ "guru", "ninja", "rockstar", "visionary", "unparalleled", "exceptional",
8
+ "extensive experience", "proven track record", "results-driven", "synergy",
9
+ ];
10
+ /** Understating verbs — dangerous next to work of real scale. */
11
+ export const WEAK_VERBS = [
12
+ "assisted", "helped", "participated in", "involved in", "took part in",
13
+ "advised on", "supported", "contributed to", "worked on", "was responsible for",
14
+ "responsible for", "familiar with", "exposure to",
15
+ ];
16
+ /** Markers of scale — suspicious when paired with a weak verb. */
17
+ export const SCALE_HINTS = [
18
+ "services", "engineers", "developers", "squads", "users", "staff", "events",
19
+ "microservices", "million", "requests", "customers", "merchants", "teams",
20
+ ];
21
+ /** Base-form verbs that often start a bullet — used to catch present-tense slips. */
22
+ export const PRESENT_VERBS = [
23
+ "mentor", "advise", "lead", "build", "manage", "review", "run", "own", "drive",
24
+ "design", "maintain", "support", "coordinate", "deliver", "define", "set",
25
+ "introduce", "improve", "handle", "track", "report", "work",
26
+ ];
27
+ /** Tech keywords commonly listed under SKILLS with nothing backing them. */
28
+ export const TECH_TOKENS = [
29
+ "kubernetes", "docker", "kafka", "rabbitmq", "spark", "redis", "postgresql", "mysql",
30
+ "mongodb", "graphql", "react", "next.js", "vue", "angular", "svelte", "typescript",
31
+ "golang", "python", "java", "spring boot", "nestjs", "node.js", "php", "laravel",
32
+ "symfony", "flutter", "react native", "terraform", "aws", "gcp", "azure",
33
+ "opentelemetry", "grafana", "prometheus", "elk", "elasticsearch", "playwright",
34
+ "cypress", "jest", "vitest", "oauth", "oidc", "jwt", "ddd", "clean architecture",
35
+ "event-driven", "microservices", "helm", "ci/cd",
36
+ ];
37
+ /** Individual-contributor titles — people-management wording next to them signals a mismatch. */
38
+ export const IC_TITLES = ["engineer", "developer", "programmer", "architect", "specialist"];
39
+ export const PEOPLE_MGMT = [
40
+ "managed a team of", "team of", "direct reports", "headcount", "performance review",
41
+ "hiring", "interviewing",
42
+ ];
43
+ /**
44
+ * Keywords an interviewer will dig into. Claiming these without evidence falls apart
45
+ * under questioning. The rest (MySQL, Redis, Docker…) are perfectly normal to list
46
+ * under SKILLS and not worth warning about.
47
+ */
48
+ export const HEAVY_TOKENS = [
49
+ "kubernetes", "kafka", "spark", "terraform", "opentelemetry", "playwright",
50
+ "cypress", "event-driven", "microservices", "ddd", "clean architecture",
51
+ "oauth", "oidc", "graphql", "spring boot", "flutter", "react native", "helm",
52
+ "aws", "gcp", "azure", "elasticsearch", "prometheus",
53
+ ];
@@ -0,0 +1,10 @@
1
+ import type { TailorReport } from "../types.js";
2
+ /**
3
+ * Compare a CV against a job description.
4
+ *
5
+ * The goal is not keyword stuffing. It surfaces three things: what the JD asks for
6
+ * with no evidence in the CV, which keywords live only under SKILLS (those fall apart
7
+ * in a deep interview), and which bullets are unrelated to the JD — so you cut those
8
+ * before cutting anything valuable.
9
+ */
10
+ export declare function tailor(cvRaw: string, jdRaw: string): TailorReport;
@@ -0,0 +1,82 @@
1
+ import { TECH_TOKENS } from "./rules.js";
2
+ const BULLET_RE = /^\s*[*-]\s+(.*)$/;
3
+ const H2_RE = /^##\s+(.*)$/;
4
+ /** Multi-word phrases that must match as a whole. */
5
+ const PHRASES = [
6
+ "clean architecture", "event-driven", "domain-driven", "spring boot", "next.js",
7
+ "react native", "ci/cd", "unit test", "integration test", "e2e", "end-to-end",
8
+ "code review", "system design", "distributed systems", "message queue",
9
+ "observability", "monitoring", "incident", "agile", "scrum", "mentoring",
10
+ ];
11
+ const norm = (s) => s.toLowerCase().replace(/\*\*/g, "").replace(/`/g, "");
12
+ /**
13
+ * Compare a CV against a job description.
14
+ *
15
+ * The goal is not keyword stuffing. It surfaces three things: what the JD asks for
16
+ * with no evidence in the CV, which keywords live only under SKILLS (those fall apart
17
+ * in a deep interview), and which bullets are unrelated to the JD — so you cut those
18
+ * before cutting anything valuable.
19
+ */
20
+ export function tailor(cvRaw, jdRaw) {
21
+ const cv = norm(cvRaw);
22
+ const jd = norm(jdRaw);
23
+ const lines = cvRaw.split("\n");
24
+ const wanted = new Set();
25
+ for (const t of [...TECH_TOKENS, ...PHRASES])
26
+ if (jd.includes(t))
27
+ wanted.add(t);
28
+ // Where each keyword shows up: under SKILLS, or in real experience.
29
+ const inSkills = new Map();
30
+ const inEvidence = new Map();
31
+ let section = "";
32
+ lines.forEach((line, idx) => {
33
+ const h2 = line.match(H2_RE);
34
+ if (h2) {
35
+ section = h2[1].toUpperCase();
36
+ return;
37
+ }
38
+ const flat = norm(line);
39
+ const target = section.includes("SKILL") ? inSkills : inEvidence;
40
+ for (const t of wanted)
41
+ if (flat.includes(t) && !target.has(t))
42
+ target.set(t, idx + 1);
43
+ });
44
+ const missing = [...wanted]
45
+ .filter((t) => !inSkills.has(t) && !inEvidence.has(t))
46
+ .map((keyword) => ({
47
+ keyword,
48
+ hint: `The JD mentions "${keyword}" but the CV does not. Add a bullet if you genuinely have it; otherwise leave it out rather than stuffing it in.`,
49
+ }));
50
+ const unsupported = [...wanted]
51
+ .filter((t) => inSkills.has(t) && !inEvidence.has(t))
52
+ .map((keyword) => ({
53
+ keyword,
54
+ hint: `"${keyword}" appears only under SKILLS. Interviewers will dig into it — it needs an experience line behind it.`,
55
+ }));
56
+ const covered = [...wanted]
57
+ .filter((t) => inEvidence.has(t))
58
+ .map((keyword) => ({ keyword, evidenceLine: inEvidence.get(keyword) }));
59
+ // Experience bullets that touch none of the JD keywords.
60
+ const irrelevant = [];
61
+ section = "";
62
+ lines.forEach((line, idx) => {
63
+ const h2 = line.match(H2_RE);
64
+ if (h2) {
65
+ section = h2[1].toUpperCase();
66
+ return;
67
+ }
68
+ if (!section.includes("EXPERIENCE"))
69
+ return;
70
+ const m = line.match(BULLET_RE);
71
+ if (!m)
72
+ return;
73
+ const flat = norm(m[1]);
74
+ if (flat.length < 40)
75
+ return;
76
+ const touches = [...wanted].some((t) => flat.includes(t));
77
+ if (!touches)
78
+ irrelevant.push({ line: idx + 1, excerpt: m[1].replace(/\*\*/g, "").slice(0, 70) });
79
+ });
80
+ const score = wanted.size === 0 ? 0 : Math.round((covered.length / wanted.size) * 100);
81
+ return { missing, unsupported, irrelevant, covered, score };
82
+ }
@@ -0,0 +1,16 @@
1
+ /** Start a tailored copy from a master file. It only copies; trimming is up to you or the agent. */
2
+ export declare function newVariant(master: string, name: string): string;
3
+ export interface VariantInfo {
4
+ file: string;
5
+ name: string;
6
+ lines: number;
7
+ bullets: number;
8
+ sections: string[];
9
+ }
10
+ export declare function listVariants(dir: string): VariantInfo[];
11
+ /** Compare two versions: which bullets exist on only one side. */
12
+ export declare function diffVariants(a: string, b: string): {
13
+ onlyInA: string[];
14
+ onlyInB: string[];
15
+ shared: number;
16
+ };