resumectl 0.0.0-stage → 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/GLOSSARY.md +19 -0
- package/LICENSE +21 -0
- package/README.md +63 -2
- package/docs/adr/0001-domain-first-organization.md +32 -0
- package/docs/agents/domain.md +12 -0
- package/docs/agents/issue-tracker.md +10 -0
- package/docs/agents/triage-labels.md +11 -0
- package/docs/api.md +16 -0
- package/docs/configuration.md +83 -0
- package/docs/quality.md +36 -0
- package/docs/rendering.md +68 -0
- package/docs/sans.png +0 -0
- package/docs/serif.png +0 -0
- package/docs/setup.md +31 -0
- package/package.json +48 -4
- package/resume.config.schema.json +85 -0
- package/resume.config.yaml +65 -0
- package/skills/resumectl/SKILL.md +143 -0
- package/src/cli/main.ts +108 -0
- package/src/cli.ts +14 -0
- package/src/content/tex.ts +107 -0
- package/src/errors.ts +3 -0
- package/src/index.ts +22 -0
- package/src/project/config.ts +265 -0
- package/src/project/doctor.ts +76 -0
- package/src/project/init.ts +78 -0
- package/src/quality/gates.ts +113 -0
- package/src/quality/pdf.ts +84 -0
- package/src/rendering/assets.ts +4 -0
- package/src/rendering/compile.ts +101 -0
- package/src/rendering/font-metrics.ts +89 -0
- package/src/rendering/font-width.ts +11 -0
- package/src/rendering/measurements.ts +81 -0
- package/src/rendering/metrics/sans.json +122 -0
- package/src/rendering/metrics/serif.json +121 -0
- package/template/fonts/Inter-430.ttf +0 -0
- package/template/fonts/Inter-500.ttf +0 -0
- package/template/fonts/Inter-600.ttf +0 -0
- package/template/fonts/OFL-Inter.txt +93 -0
- package/template/resume-measure.tex +74 -0
- package/template/resume.cls +203 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: resumectl
|
|
3
|
+
description: Write, rewrite, tailor, score, or review résumé/CV bullets, summaries, sections, or a complete résumé against a target role or job description. Use with resumectl to turn verified career evidence into concise LaTeX content and validate the rendered PDF.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Resume Methodology
|
|
7
|
+
|
|
8
|
+
## Inputs and evidence
|
|
9
|
+
|
|
10
|
+
Identify the target role or job description (JD), the candidate's career evidence, and the `.tex` file to edit. Use information already provided. If the JD is missing, proceed with a general review and state that the résumé has not yet been tailored to a specific role.
|
|
11
|
+
|
|
12
|
+
- Every employer, responsibility, ownership claim, technology and metric must be traceable to the user's materials.
|
|
13
|
+
- Never guess a metric. Use verified scope or observable outcomes; ask the user when an essential fact is missing.
|
|
14
|
+
- Placeholders such as `[N endpoints]` and `[latency %]` belong only in drafts awaiting confirmation. Replace or remove them before delivery.
|
|
15
|
+
- Examples in this skill illustrate writing structure. They are not facts about the candidate.
|
|
16
|
+
- Treat the user-designated `.tex` file and its `\input` files as the content source. Do not maintain a separate Markdown résumé that can drift out of sync.
|
|
17
|
+
|
|
18
|
+
## Writing workflow
|
|
19
|
+
|
|
20
|
+
### 1. Map the role requirements
|
|
21
|
+
|
|
22
|
+
Group the JD into four categories and identify the candidate's evidence for each requirement:
|
|
23
|
+
|
|
24
|
+
| Category | Extract |
|
|
25
|
+
|---|---|
|
|
26
|
+
| Required capabilities | Technologies, systems skills and domain knowledge |
|
|
27
|
+
| Business problems | Cost, reliability, latency, automation and user experience |
|
|
28
|
+
| Expected proof | Production delivery, scale, personal ownership and customer impact |
|
|
29
|
+
| Interview opportunities | Projects with design decisions, tradeoffs and results the candidate can explain |
|
|
30
|
+
|
|
31
|
+
Record `JD requirement → available evidence → gap`. Keep unsupported requirements as gaps; do not present them as demonstrated abilities.
|
|
32
|
+
|
|
33
|
+
### 2. Select the strongest evidence
|
|
34
|
+
|
|
35
|
+
Evaluate each item by whether it helps a recruiter judge the candidate's fit within 30 seconds:
|
|
36
|
+
|
|
37
|
+
- Lead with the most relevant accomplishments and clearest evidence of contribution.
|
|
38
|
+
- Remove statements that only describe participation, duties or tools. Rewrite them around outcomes when evidence supports doing so.
|
|
39
|
+
- When two bullets prove the same capability, keep the stronger one.
|
|
40
|
+
- Keep coursework and routine projects only when they fill a gap in evidence for the target role.
|
|
41
|
+
- Do not add weak content just to fill a page.
|
|
42
|
+
|
|
43
|
+
Choose the emphasis for the role without changing the facts: AI Engineer roles prioritize agents, RAG, evaluations and production reliability; SWE roles prioritize backend systems, system design, performance and engineering quality; Data roles prioritize pipelines, experiments, data quality and decision impact.
|
|
44
|
+
|
|
45
|
+
### 3. Write verifiable bullets
|
|
46
|
+
|
|
47
|
+
Default structure: **Action + technical mechanism + result + business or user impact**. Alternatively, compress STAR into context, action and result.
|
|
48
|
+
|
|
49
|
+
- Start with a concrete action verb: Built, Designed, Shipped, Optimized, Automated, Reduced, Migrated or Instrumented.
|
|
50
|
+
- Use past tense for completed accomplishments. Make personal contributions explicit without implying sole ownership of a team's result.
|
|
51
|
+
- Avoid openings that describe only duties, such as Responsible for, Participated in, Helped or Worked on.
|
|
52
|
+
- Use the JD's terminology when it accurately describes the work. Place relevant keywords in the first five words when natural, without keyword stuffing.
|
|
53
|
+
- Explain the technical choice or mechanism instead of merely listing tools.
|
|
54
|
+
- Support impact with verified metrics, scope or observable results. A missing number is not a reason to invent one.
|
|
55
|
+
|
|
56
|
+
Example structure, with illustrative numbers:
|
|
57
|
+
|
|
58
|
+
> Built REST API caching across 12 endpoints, reducing p95 latency 38% and improving checkout reliability.
|
|
59
|
+
|
|
60
|
+
Compress in this order: preserve the action, mechanism and result → remove empty adjectives and repeated context → use clear technical abbreviations → keep one impact clause. Split a bullet only when the resulting bullets prove distinct capabilities.
|
|
61
|
+
|
|
62
|
+
**Target 1–2 rendered lines with a sufficiently full final line.** Do not substitute character counts or estimated Times font widths for PDF measurements. Shared defaults allow at most two lines and require at least 80% final-line fill; use the effective configuration for the actual thresholds. Never add unsupported scope, metrics or filler to fill a line.
|
|
63
|
+
|
|
64
|
+
### 4. Write a role-specific summary
|
|
65
|
+
|
|
66
|
+
When the user needs a summary, use **Identity → Proof → Target/Vision**:
|
|
67
|
+
|
|
68
|
+
1. Identity: a role description aligned with the JD.
|
|
69
|
+
2. Proof: the strongest relevant accomplishment or evidence.
|
|
70
|
+
3. Target: the work the candidate wants to continue doing, aligned with the role's business goals.
|
|
71
|
+
|
|
72
|
+
Template: `[Role identity] with [relevant strengths]; shipped [verified proof]; focused on [JD-aligned goal].`
|
|
73
|
+
|
|
74
|
+
Use one or two sentences. Remove generic labels such as passionate, fast learner and results-driven. The summary should establish positioning beyond what the experience section already says; suggest omitting it when it adds no such value. Enforce a character limit only when the user requests one. Otherwise, check the SUMMARY section's line and fill limits and the document's page budget. A fixed character count does not imply a fixed rendered length.
|
|
75
|
+
|
|
76
|
+
### 5. Review and score
|
|
77
|
+
|
|
78
|
+
Score each bullet from 0 to 2 on each dimension:
|
|
79
|
+
|
|
80
|
+
| Dimension | 0 | 1 | 2 |
|
|
81
|
+
|---|---|---|---|
|
|
82
|
+
| Role relevance | Generic | Partly relevant | Directly maps to the JD |
|
|
83
|
+
| Ownership | Unclear | Partly explicit | Personal action is explicit |
|
|
84
|
+
| Technical mechanism | Missing | Tool named | Design or implementation explained |
|
|
85
|
+
| Result evidence | Missing | Vague | Concrete number or observable result |
|
|
86
|
+
| Impact | Missing | Implied | Clear user, business or team benefit |
|
|
87
|
+
|
|
88
|
+
Prioritize bullets scoring 8–10. For a score of 7, address the weakest dimension. Rewrite or remove bullets below 7. Scores help select content; they do not replace fact checking or rendered validation.
|
|
89
|
+
|
|
90
|
+
## resumectl workflow
|
|
91
|
+
|
|
92
|
+
Run the CLI as `bunx resumectl@0.1.0 <command>`. Bun downloads it on first use, so nothing needs cloning. Inside the resumectl repository, `bun run resumectl <command>` is equivalent.
|
|
93
|
+
|
|
94
|
+
### Set up once
|
|
95
|
+
|
|
96
|
+
Run `bunx resumectl@0.1.0 doctor` before writing anything, and fix failures with the user:
|
|
97
|
+
|
|
98
|
+
- **`bunx` not found, or Bun below 1.3:** ask the user to install Bun (`curl -fsSL https://bun.sh/install | bash`) or run `bun upgrade`.
|
|
99
|
+
- **`TeX engine` missing:** recommend Tectonic, a ~16 MB program that downloads only the TeX packages it needs. Install it with `brew install tectonic` on macOS, or `curl --proto '=https' --tlsv1.2 -fsSL https://drop-sh.fullyjustified.net | sh` elsewhere ([other options](https://tectonic-typesetting.github.io/install.html)). Ask before installing anything.
|
|
100
|
+
- **`TeX dependencies` missing:** the user already has TeX Live or TinyTeX. Run `tlmgr install titlesec enumitem pgf tex-gyre` with their approval.
|
|
101
|
+
|
|
102
|
+
Repeat `doctor` until it passes. With Tectonic, the first build needs network access and can take a few minutes while packages download; run it with a timeout of at least 5 minutes. Later builds take seconds.
|
|
103
|
+
|
|
104
|
+
### Build and revise
|
|
105
|
+
|
|
106
|
+
For a new résumé, run `bunx resumectl@0.1.0 init <directory> --style sans`, then replace the placeholder content. `init` refuses to overwrite existing résumé source or configuration. Use `--style serif` only if the user asks for it.
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
bunx resumectl@0.1.0 config show path/to/resume.tex --json
|
|
110
|
+
bunx resumectl@0.1.0 build path/to/resume.tex --json
|
|
111
|
+
bunx resumectl@0.1.0 check path/to/resume.tex --json
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Read `config show` before editing:
|
|
115
|
+
|
|
116
|
+
- `sections.<title>.editable: true` permits rewriting that section's body while preserving facts.
|
|
117
|
+
- `false` or an unlisted section provides no editing permission through configuration. Edit it only when the user explicitly authorizes the change.
|
|
118
|
+
- `checks: false` disables that section's length checks; it does not grant editing permission.
|
|
119
|
+
- Do not lower thresholds, shrink fonts or change the page budget merely to make checks pass without the user's authorization.
|
|
120
|
+
|
|
121
|
+
List items are measured automatically. Wrap standalone paragraphs and `tabularx` X-column descriptions in `\resumeentry{...}`. Do not wrap `\item` again or nest lists or entries.
|
|
122
|
+
|
|
123
|
+
Rebuild after every content or layout change. Use each failing entry's `section`, `index`, `lines`, `lastLineFill` and `status` to guide revision:
|
|
124
|
+
|
|
125
|
+
1. Too many lines: follow the compression order and preserve the strongest evidence.
|
|
126
|
+
2. Sparse final line: first try compressing to one line. Alternatively, add a mechanism or result already supported by the user's materials; never invent content.
|
|
127
|
+
3. Overflow or excess pages: remove weaker content and inspect long links, unbreakable text and tables. Do not make the résumé difficult to read just to pass.
|
|
128
|
+
4. Private-use character mappings: inspect the font or template; do not ignore broken text copying.
|
|
129
|
+
5. Missing measurements or mismatched hashes: rebuild rather than editing `.resumectl.json` by hand.
|
|
130
|
+
|
|
131
|
+
All three quality gates must pass: actual rendered content length and overflow, page count, and PDF private-use character mappings. `check` uses the existing PDF and its matching `.resumectl.json` manifest. Content or layout changes require a rebuild; changes only to checking thresholds can be rechecked directly.
|
|
132
|
+
|
|
133
|
+
## Delivery checks
|
|
134
|
+
|
|
135
|
+
- Support the role's priorities with real evidence and list any unresolved factual gaps.
|
|
136
|
+
- Give each bullet an action, mechanism and result, with the strongest evidence first.
|
|
137
|
+
- Do not alter dates, job titles, schools or other facts to make the layout fit.
|
|
138
|
+
- Remove all draft placeholders from the final file.
|
|
139
|
+
- Require a fresh build with exit code 0 and all three gates reporting PASS. Record the PDF path and verification result.
|
|
140
|
+
- Inspect the actual PDF for hierarchy, spacing, reading order, and the final-line fill of one- or two-line bullets. If no visual inspection was performed, state that explicitly.
|
|
141
|
+
- Deliver the main changes, factual gaps and generated files. Passing the gates does not guarantee acceptance or compatibility with every ATS parser.
|
|
142
|
+
|
|
143
|
+
[Source](https://www.youtube.com/watch?v=4eYzTGxZazw)
|
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { basename, join, resolve } from "node:path";
|
|
3
|
+
import { cleanIntermediates, compile, tectonicCacheReady, texEngine } from "../rendering/compile.ts";
|
|
4
|
+
import { resolveConfig, type ConfigInput } from "../project/config.ts";
|
|
5
|
+
import { formatReport, runGates } from "../quality/gates.ts";
|
|
6
|
+
import { InputError } from "../errors.ts";
|
|
7
|
+
import { initConfig, initProject } from "../project/init.ts";
|
|
8
|
+
import { doctor, formatDoctor } from "../project/doctor.ts";
|
|
9
|
+
|
|
10
|
+
const USAGE = `resumectl — configure, render and check a résumé
|
|
11
|
+
|
|
12
|
+
Usage
|
|
13
|
+
resumectl init [directory] [--style <serif|sans>] [--paper <a4|letter>] [--json]
|
|
14
|
+
resumectl doctor [--json]
|
|
15
|
+
resumectl config init [--config <path>]
|
|
16
|
+
resumectl config show [resume.tex] [--config <path>] [--json]
|
|
17
|
+
resumectl build <resume.tex> [options]
|
|
18
|
+
resumectl check <resume.tex> [options]
|
|
19
|
+
|
|
20
|
+
Options
|
|
21
|
+
--config <path> use this YAML config; otherwise discover resume.config.yaml
|
|
22
|
+
--style <serif|sans> override the rendered style
|
|
23
|
+
--paper <a4|letter> override the paper size
|
|
24
|
+
--section <name> check only this section (default: all configured sections)
|
|
25
|
+
--max-lines <n> override maximum rendered lines for selected sections
|
|
26
|
+
--min-last-line-fill <0–1> override minimum last-line fill for selected sections
|
|
27
|
+
--max-pages <n> page limit (default: 1)
|
|
28
|
+
--out-dir <dir> output directory, relative to the current directory
|
|
29
|
+
--pdf <path> existing PDF to check (check only)
|
|
30
|
+
--keep-logs keep compiler intermediates after a passing build
|
|
31
|
+
--no-keep-logs override keepLogs: true from config
|
|
32
|
+
--json machine-readable report, including effective config
|
|
33
|
+
-h, --help
|
|
34
|
+
|
|
35
|
+
Precedence: CLI > config > documentclass > defaults.
|
|
36
|
+
Exit codes: 0 success · 1 quality/compile/dependency failure · 2 invalid input.`;
|
|
37
|
+
|
|
38
|
+
export async function main(args: string[]): Promise<number> {
|
|
39
|
+
const { values: v, positionals } = parseArgs({
|
|
40
|
+
args, allowPositionals: true,
|
|
41
|
+
options: {
|
|
42
|
+
config: { type: "string" }, pdf: { type: "string" }, "out-dir": { type: "string" },
|
|
43
|
+
style: { type: "string" }, paper: { type: "string" }, section: { type: "string" },
|
|
44
|
+
"max-lines": { type: "string" }, "min-last-line-fill": { type: "string" }, "max-pages": { type: "string" },
|
|
45
|
+
"keep-logs": { type: "boolean" }, "no-keep-logs": { type: "boolean" }, json: { type: "boolean" }, help: { type: "boolean", short: "h" },
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const [command, arg, texArg] = positionals;
|
|
49
|
+
if (v.help || !command) { console.log(USAGE); return v.help ? 0 : 2; }
|
|
50
|
+
if (v["keep-logs"] && v["no-keep-logs"]) throw new InputError("choose either --keep-logs or --no-keep-logs");
|
|
51
|
+
if (command === "init") {
|
|
52
|
+
if (positionals.length > 2 || Object.keys(v).some(k => !["style", "paper", "json"].includes(k))) {
|
|
53
|
+
throw new InputError("init accepts a directory, --style, --paper and --json");
|
|
54
|
+
}
|
|
55
|
+
const result = await initProject(arg, {
|
|
56
|
+
...(v.style !== undefined ? { style: v.style as "serif" | "sans" } : {}),
|
|
57
|
+
...(v.paper !== undefined ? { paper: v.paper as "a4" | "letter" } : {}),
|
|
58
|
+
});
|
|
59
|
+
console.log(v.json ? JSON.stringify(result, null, 2)
|
|
60
|
+
: `Created ${result.texPath} and ${result.configPath}\nReplace the placeholders, then run resumectl build "${result.texPath}".`);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
if (command === "doctor") {
|
|
64
|
+
if (positionals.length > 1 || Object.keys(v).some(k => k !== "json")) throw new InputError("doctor accepts only --json");
|
|
65
|
+
const report = await doctor();
|
|
66
|
+
console.log(v.json ? JSON.stringify(report, null, 2) : formatDoctor(report));
|
|
67
|
+
return report.pass ? 0 : 1;
|
|
68
|
+
}
|
|
69
|
+
if (command === "config" && arg === "init") {
|
|
70
|
+
if (positionals.length > 2 || Object.keys(v).some(k => !["config", "json"].includes(k))) throw new InputError("config init accepts only --config and --json");
|
|
71
|
+
const result = await initConfig(v.config);
|
|
72
|
+
console.log(v.json ? JSON.stringify(result, null, 2) : `Created ${result.configPath}`);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
const show = command === "config" && arg === "show";
|
|
76
|
+
if (!show && command !== "build" && command !== "check") throw new InputError(`unknown command; use --help`);
|
|
77
|
+
if (positionals.length > (show ? 3 : 2)) throw new InputError("too many positional arguments");
|
|
78
|
+
const texPath = show ? texArg : arg;
|
|
79
|
+
if (!show && !texPath) throw new InputError(`${command} needs a .tex file`);
|
|
80
|
+
if (v.pdf && command !== "check") throw new InputError("--pdf is only supported by check");
|
|
81
|
+
const overrides: ConfigInput = { layout: {}, checks: { bullets: {} }, build: {} };
|
|
82
|
+
if (v.style !== undefined) overrides.layout!.style = v.style as "serif" | "sans";
|
|
83
|
+
if (v.paper !== undefined) overrides.layout!.paper = v.paper as "a4" | "letter";
|
|
84
|
+
if (v.section !== undefined) overrides.checks!.section = v.section;
|
|
85
|
+
if (v["max-pages"] !== undefined) overrides.checks!.maxPages = Number(v["max-pages"]);
|
|
86
|
+
if (v["max-lines"] !== undefined) overrides.checks!.bullets!.maxLines = Number(v["max-lines"]);
|
|
87
|
+
if (v["min-last-line-fill"] !== undefined) overrides.checks!.bullets!.minLastLineFill = v["min-last-line-fill"].trim() ? Number(v["min-last-line-fill"]) : NaN;
|
|
88
|
+
if (v["out-dir"] !== undefined) overrides.build!.outDir = v["out-dir"];
|
|
89
|
+
if (v["keep-logs"] !== undefined) overrides.build!.keepLogs = v["keep-logs"];
|
|
90
|
+
if (v["no-keep-logs"]) overrides.build!.keepLogs = false;
|
|
91
|
+
const resolved = await resolveConfig({ texPath, configPath: v.config, overrides });
|
|
92
|
+
if (show) { console.log(JSON.stringify(resolved, null, 2)); return 0; }
|
|
93
|
+
const { config } = resolved;
|
|
94
|
+
let pdfPath = v.pdf ? resolve(v.pdf) : join(config.build.outDir!, basename(texPath!).replace(/\.tex$/i, "") + ".pdf");
|
|
95
|
+
if (command === "build") {
|
|
96
|
+
const engine = texEngine();
|
|
97
|
+
if (!v.json) console.log(`[1/2] ${engine ?? "compile"} ${texPath}`);
|
|
98
|
+
// stderr keeps --json output parseable while telling agents not to time out.
|
|
99
|
+
if (engine === "tectonic" && !tectonicCacheReady()) console.error("First Tectonic build: downloading TeX packages (about 45 MB). This can take a few minutes.");
|
|
100
|
+
pdfPath = (await compile(texPath!, { outDir: config.build.outDir, layout: config.layout })).pdfPath;
|
|
101
|
+
if (!v.json) console.log("[2/2] quality gates\n");
|
|
102
|
+
}
|
|
103
|
+
const report = await runGates(texPath!, pdfPath, { ...config.checks, sections: config.sections }, config.layout);
|
|
104
|
+
console.log(v.json ? JSON.stringify({ pdf: pdfPath, ...resolved, ...report }, null, 2) : formatReport(report));
|
|
105
|
+
if (command === "build" && report.pass && !config.build.keepLogs) await cleanIntermediates(pdfPath);
|
|
106
|
+
if (command === "build" && report.pass && !v.json) console.log(`\nresumectl: built ${pdfPath}`);
|
|
107
|
+
return report.pass ? 0 : 1;
|
|
108
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { main } from "./cli/main.ts";
|
|
3
|
+
import { BuildError } from "./rendering/compile.ts";
|
|
4
|
+
import { InputError } from "./errors.ts";
|
|
5
|
+
|
|
6
|
+
try { process.exit(await main(Bun.argv.slice(2))); }
|
|
7
|
+
catch (error) {
|
|
8
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
9
|
+
const message = code === "ENOENT" ? `file or executable not found: ${(error as Error).message}. Run resumectl doctor to check the TeX engine.` : (error as Error).message;
|
|
10
|
+
console.error(`resumectl: ${message}`);
|
|
11
|
+
if (error instanceof BuildError) process.exit(1);
|
|
12
|
+
if (error instanceof InputError || code === "ENOENT" || code?.startsWith("ERR_PARSE_ARGS")) process.exit(2);
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Reading the parts of a résumé .tex the gates care about.
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { InputError } from "../errors.ts";
|
|
4
|
+
|
|
5
|
+
export type Style = "serif" | "sans";
|
|
6
|
+
|
|
7
|
+
export interface ClassOptions {
|
|
8
|
+
style: Style;
|
|
9
|
+
/** Font scale relative to the 11pt body (sans only; serif is always 1). */
|
|
10
|
+
scale: number;
|
|
11
|
+
/** Letter spacing in % of the em (sans only; serif is always 0). */
|
|
12
|
+
tracking: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_CLASS_OPTIONS: ClassOptions = { style: "serif", scale: 1, tracking: 0 };
|
|
16
|
+
export const sectionKey = (s: string) => stripToVisible(s).replace(/\s+/g, " ").trim().toUpperCase();
|
|
17
|
+
const SANS_DEFAULTS = { scale: 0.86, tracking: -1.5 };
|
|
18
|
+
|
|
19
|
+
export function stripComments(tex: string): string {
|
|
20
|
+
return tex.replace(/(^|[^\\])%[^\n]*/g, "$1");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Inline \input{x} / \include{x} relative to the including file, so split sources check as one. */
|
|
24
|
+
export async function resolveInputs(tex: string, texPath: string, depth = 0): Promise<string> {
|
|
25
|
+
if (depth > 8) throw new InputError(`\\input nesting deeper than 8 levels in ${texPath}`);
|
|
26
|
+
const src = stripComments(tex);
|
|
27
|
+
let out = "";
|
|
28
|
+
let last = 0;
|
|
29
|
+
for (const m of src.matchAll(/\\(?:input|include)\s*\{([^}]+)\}/g)) {
|
|
30
|
+
const name = m[1].trim();
|
|
31
|
+
const path = join(dirname(texPath), name.endsWith(".tex") ? name : `${name}.tex`);
|
|
32
|
+
const file = Bun.file(path);
|
|
33
|
+
if (!await file.exists()) throw new InputError(`\\input target not found: ${path}`);
|
|
34
|
+
out += src.slice(last, m.index) + await resolveInputs(await file.text(), path, depth + 1);
|
|
35
|
+
last = m.index! + m[0].length;
|
|
36
|
+
}
|
|
37
|
+
return out + src.slice(last);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read style/scale/tracking from \documentclass[...]{resume}. Missing keys fall back to the class defaults. */
|
|
41
|
+
export function readClassOptions(tex: string): ClassOptions {
|
|
42
|
+
const m = stripComments(tex).match(/\\documentclass\s*(?:\[([^\]]*)\])?\s*\{\s*resume\s*\}/);
|
|
43
|
+
if (!m) return { ...DEFAULT_CLASS_OPTIONS };
|
|
44
|
+
const kv = new Map<string, string>();
|
|
45
|
+
for (const part of (m[1] ?? "").split(",")) {
|
|
46
|
+
const [k, v] = part.split("=").map(s => s?.trim());
|
|
47
|
+
if (k) kv.set(k, v ?? "");
|
|
48
|
+
}
|
|
49
|
+
if (kv.get("style") !== "sans") return { ...DEFAULT_CLASS_OPTIONS };
|
|
50
|
+
const num = (key: "scale" | "tracking") => {
|
|
51
|
+
const raw = kv.get(key);
|
|
52
|
+
const n = raw === undefined ? NaN : Number(raw);
|
|
53
|
+
return Number.isFinite(n) ? n : SANS_DEFAULTS[key];
|
|
54
|
+
};
|
|
55
|
+
return { style: "sans", scale: num("scale"), tracking: num("tracking") };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Raw \item bodies of the named \section, up to the next \section. */
|
|
59
|
+
export function extractSectionItems(tex: string, section: string): string[] {
|
|
60
|
+
const block = sectionBody(tex, section);
|
|
61
|
+
const itemRe = /\\item\b([\s\S]*?)(?=\\item\b|\\end\{(?:itemize|plainlist)\}|\\datedsubsection|$)/g;
|
|
62
|
+
return [...block.matchAll(itemRe)].map(m => m[1].trim());
|
|
63
|
+
}
|
|
64
|
+
function sectionBody(tex: string, section: string): string {
|
|
65
|
+
tex = stripComments(tex);
|
|
66
|
+
const headings = [...tex.matchAll(/\\section\*?\s*(?:\[[^\]]*\])?\s*\{((?:[^{}]|\{[^{}]*\})*)\}/g)];
|
|
67
|
+
return headings.flatMap((h, i) => sectionKey(h[1]) === sectionKey(section)
|
|
68
|
+
? [tex.slice(h.index! + h[0].length, headings[i + 1]?.index)] : []).join("\n");
|
|
69
|
+
}
|
|
70
|
+
/** Source snippets for measured lists and explicit paragraph/table entries, in render order. */
|
|
71
|
+
export function extractSectionEntries(tex: string, section: string): string[] {
|
|
72
|
+
const block = sectionBody(tex, section);
|
|
73
|
+
const entries = [...block.matchAll(/\\item\b([\s\S]*?)(?=\\item\b|\\end\{(?:itemize|plainlist)\}|\\datedsubsection|$)/g)]
|
|
74
|
+
.map(m => ({ index: m.index!, text: m[1].trim() }));
|
|
75
|
+
for (const m of block.matchAll(/\\resumeentry\s*\{/g)) {
|
|
76
|
+
const start = m.index! + m[0].length;
|
|
77
|
+
let depth = 1, end = start;
|
|
78
|
+
for (; end < block.length && depth > 0; end++) {
|
|
79
|
+
if (block[end] === "\\") { end++; continue; }
|
|
80
|
+
if (block[end] === "{") depth++;
|
|
81
|
+
if (block[end] === "}") depth--;
|
|
82
|
+
}
|
|
83
|
+
if (depth === 0) entries.push({ index: m.index!, text: block.slice(start, end - 1) });
|
|
84
|
+
}
|
|
85
|
+
return entries.sort((a, b) => a.index - b.index).map(e => e.text);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const MATH_SYMBOLS: Record<string, string> = { rightarrow: "→", to: "→", leftarrow: "←", times: "×", pm: "±", approx: "≈", leq: "≤", geq: "≥", sim: "~" };
|
|
89
|
+
|
|
90
|
+
/** Reduce LaTeX markup to the characters that end up on the page. */
|
|
91
|
+
export function stripToVisible(raw: string): string {
|
|
92
|
+
let s = raw
|
|
93
|
+
.replace(/\$\\(\w+)\$/g, (_, cmd: string) => MATH_SYMBOLS[cmd] ?? "")
|
|
94
|
+
.replace(/\\href\s*\{[^}]*\}\s*\{([^}]*)\}/g, "$1")
|
|
95
|
+
.replace(/\\([%&$_#{}])/g, "$1")
|
|
96
|
+
.replace(/---/g, "—")
|
|
97
|
+
.replace(/--/g, "–")
|
|
98
|
+
.replace(/(^|[^\\])~/g, "$1 ");
|
|
99
|
+
const cmdBrace = /\\[a-zA-Z@]+\*?\s*(?:\[[^\]]*\])?\s*\{([^{}]*)\}/g;
|
|
100
|
+
while (cmdBrace.test(s)) s = s.replace(cmdBrace, "$1");
|
|
101
|
+
return s
|
|
102
|
+
.replace(/\\\\/g, "")
|
|
103
|
+
.replace(/\\[a-zA-Z@]+\*?/g, "")
|
|
104
|
+
.replace(/[{}$]/g, "")
|
|
105
|
+
.replace(/\s+/g, " ")
|
|
106
|
+
.trim();
|
|
107
|
+
}
|
package/src/errors.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { compile, cleanIntermediates, BuildError, TEMPLATE_DIR, FONT_DIR, type BuildResult, type BuildOptions } from "./rendering/compile.ts";
|
|
2
|
+
export { resolveConfig, validateConfig, DEFAULT_CHECKS, DEFAULT_LAYOUT, type ResumeConfig, type ConfigInput, type LayoutOptions, type CheckOptions, type BulletOptions, type SectionOptions, type SectionInput, type ResolvedConfig } from "./project/config.ts";
|
|
3
|
+
export {
|
|
4
|
+
runGates,
|
|
5
|
+
checkBullets,
|
|
6
|
+
formatReport,
|
|
7
|
+
parseLogPageCount,
|
|
8
|
+
DEFAULT_GATE_OPTIONS,
|
|
9
|
+
type GateOptions,
|
|
10
|
+
type GateOverrides,
|
|
11
|
+
type GateReport,
|
|
12
|
+
type BulletResult,
|
|
13
|
+
type SectionResult,
|
|
14
|
+
} from "./quality/gates.ts";
|
|
15
|
+
export { InputError } from "./errors.ts";
|
|
16
|
+
export { type RenderedBullet, type BuildManifest } from "./rendering/measurements.ts";
|
|
17
|
+
export { countPdfPages } from "./quality/pdf.ts";
|
|
18
|
+
export { readClassOptions, extractSectionItems, extractSectionEntries, stripToVisible, resolveInputs, type ClassOptions, type Style } from "./content/tex.ts";
|
|
19
|
+
|
|
20
|
+
export { typographicWidth } from "./rendering/font-width.ts";
|
|
21
|
+
export { initConfig, initProject } from "./project/init.ts";
|
|
22
|
+
export { doctor, formatDoctor, type DoctorReport, type Diagnostic } from "./project/doctor.ts";
|