@sindhujaks/career-mcp 1.0.0 → 1.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/.env.example +7 -0
- package/README.md +34 -3
- package/dist/cli.js +25 -4
- package/dist/export.js +55 -0
- package/dist/index.js +27 -2
- package/dist/render/fonts.js +24 -0
- package/dist/render/markdown.js +87 -0
- package/dist/render/pdf.js +93 -0
- package/dist/render/themes.js +445 -0
- package/dist/storage.js +7 -0
- package/package.json +7 -2
package/.env.example
CHANGED
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
# Defaults to ~/.career-mcp
|
|
10
10
|
CAREER_MCP_DATA_DIR=
|
|
11
11
|
|
|
12
|
+
# ─── Optional — PDF export (both the MCP server and the CLI) ──────────────────
|
|
13
|
+
# Default theme and page size; each export can still override them.
|
|
14
|
+
# CAREER_MCP_PDF_THEME=navy # navy | minimal | classic
|
|
15
|
+
# CAREER_MCP_PAGE_SIZE=letter # letter | a4
|
|
16
|
+
# Only needed if your Chrome/Edge/Chromium/Brave isn't in a standard location.
|
|
17
|
+
# CAREER_MCP_CHROME_PATH=
|
|
18
|
+
|
|
12
19
|
# ─── Required for the standalone CLI only ─────────────────────────────────────
|
|
13
20
|
# Bring your own key — career-mcp never calls a shared/hosted key, so there's
|
|
14
21
|
# no cost or liability on anyone but you.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# career-mcp
|
|
2
2
|
|
|
3
|
-
An MCP server for the job-search loop: store a resume, drop in a job description, get a real match analysis, a tailored resume
|
|
3
|
+
An MCP server for the job-search loop: store a resume, drop in a job description, get a real match analysis, a tailored resume and cover letter (exported to PDF), a LinkedIn About section — plus a structured interview that captures your career stories so they're ready to reuse.
|
|
4
4
|
|
|
5
5
|
Local-first. Your resume and stories live in a folder on your machine (`~/.career-mcp` by default) — nothing is uploaded anywhere.
|
|
6
6
|
|
|
@@ -42,9 +42,38 @@ Then, in conversation: save your resume, save a JD, and ask your client to run a
|
|
|
42
42
|
| `get_active_application` | Bundles resume + JD + voice profile + relevant stories as context |
|
|
43
43
|
| `list_applications` | List all stored applications |
|
|
44
44
|
| `save_output` | Persist a generated match analysis / resume / cover letter / About section |
|
|
45
|
+
| `export_pdf` | Render a tailored resume, cover letter, or base resume to PDF (themes: `navy`, `minimal`, `classic`; page size: `letter` or `a4`) |
|
|
45
46
|
| `save_story` / `query_stories` | Save/retrieve career stories, filterable by tag |
|
|
46
47
|
| `start_story_interview` / `answer_story_question` | Guided, protocol-driven interview to capture one new story |
|
|
47
48
|
|
|
49
|
+
## PDF export
|
|
50
|
+
|
|
51
|
+
`export_pdf` turns a saved markdown resume or cover letter into a PDF that ATS systems can read (real, selectable text — not an image).
|
|
52
|
+
|
|
53
|
+
- **Uses the browser you already have.** It drives your installed Chrome, Edge, Chromium, or Brave in the background — nothing extra is downloaded. If yours lives somewhere unusual, set `CAREER_MCP_CHROME_PATH`. If no browser is found, you get an `.html` file instead: open it and use Print → Save as PDF.
|
|
54
|
+
- **Works offline.** Fonts are bundled with the package.
|
|
55
|
+
- **Three themes:** `navy` (default — modern sans-serif, navy accents), `minimal` (monochrome, compact), `classic` (serif, centered header).
|
|
56
|
+
- **Letter or A4.** Defaults to Letter; set `CAREER_MCP_PAGE_SIZE=a4` to change the default.
|
|
57
|
+
- **Cover letters only on request** — the default document is the tailored resume.
|
|
58
|
+
|
|
59
|
+
The PDF is written next to the markdown (e.g. `outputs/tailored_resume.pdf`). The markdown stays the source of truth: edit it, then export again.
|
|
60
|
+
|
|
61
|
+
Resumes render best in this shape (the MCP client is told this automatically):
|
|
62
|
+
|
|
63
|
+
```markdown
|
|
64
|
+
# Full Name
|
|
65
|
+
Target title · positioning
|
|
66
|
+
City · email · phone · linkedin.com/in/you
|
|
67
|
+
|
|
68
|
+
## Summary
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
## Experience
|
|
72
|
+
### Job Title — Company
|
|
73
|
+
*2021 – Present | City*
|
|
74
|
+
- Achievement bullet
|
|
75
|
+
```
|
|
76
|
+
|
|
48
77
|
## Use standalone (no MCP client)
|
|
49
78
|
|
|
50
79
|
```bash
|
|
@@ -53,8 +82,9 @@ cp .env.example .env # fill in CAREER_MCP_API_KEY (free Groq or Gemini key)
|
|
|
53
82
|
career-mcp-cli set-resume --file resume.txt
|
|
54
83
|
career-mcp-cli set-jd --company Acme --role "Senior Engineer" --file jd.txt
|
|
55
84
|
career-mcp-cli analyze
|
|
56
|
-
career-mcp-cli resume
|
|
57
|
-
career-mcp-cli cover-letter
|
|
85
|
+
career-mcp-cli resume # also writes the PDF
|
|
86
|
+
career-mcp-cli cover-letter --pdf # --pdf is optional
|
|
87
|
+
career-mcp-cli pdf --theme classic --page-size a4
|
|
58
88
|
career-mcp-cli about
|
|
59
89
|
career-mcp-cli interview # guided story capture
|
|
60
90
|
```
|
|
@@ -69,6 +99,7 @@ career-mcp-cli interview # guided story capture
|
|
|
69
99
|
└── applications/<company-role>/
|
|
70
100
|
├── jd.json
|
|
71
101
|
└── outputs/{match_analysis,tailored_resume,cover_letter,linkedin_about}.md
|
|
102
|
+
(+ tailored_resume.pdf / cover_letter.pdf once exported)
|
|
72
103
|
```
|
|
73
104
|
|
|
74
105
|
Override the location with `CAREER_MCP_DATA_DIR`.
|
package/dist/cli.js
CHANGED
|
@@ -5,6 +5,8 @@ import "dotenv/config";
|
|
|
5
5
|
import * as storage from "./storage.js";
|
|
6
6
|
import { startStoryInterview, answerStoryQuestion } from "./interview.js";
|
|
7
7
|
import { callLLM } from "./llm-fallback.js";
|
|
8
|
+
import { exportPdf, describeResult } from "./export.js";
|
|
9
|
+
import { RESUME_MARKDOWN_GUIDE } from "./render/markdown.js";
|
|
8
10
|
const SYSTEM_PROMPT = "You are a career writing assistant. Write in a clear, direct, honest voice — " +
|
|
9
11
|
"no buzzwords, no AI-sounding phrases ('in today's fast-paced world', 'as we navigate'), " +
|
|
10
12
|
"no fake inspiration. Prefer specific, grounded claims over impressive-sounding generalities. " +
|
|
@@ -72,6 +74,15 @@ async function cmdGenerate(outputType, instruction) {
|
|
|
72
74
|
await storage.saveOutput(ctx.application.slug, outputType, result);
|
|
73
75
|
console.log(`\n(saved to ~/.career-mcp/applications/${ctx.application.slug}/outputs/${outputType}.md)`);
|
|
74
76
|
}
|
|
77
|
+
async function cmdPdf(flags, document) {
|
|
78
|
+
const result = await exportPdf({
|
|
79
|
+
document: document ?? flags.document,
|
|
80
|
+
applicationSlug: flags.application,
|
|
81
|
+
theme: flags.theme,
|
|
82
|
+
pageSize: flags["page-size"],
|
|
83
|
+
});
|
|
84
|
+
console.log(describeResult(result));
|
|
85
|
+
}
|
|
75
86
|
async function cmdInterview() {
|
|
76
87
|
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
77
88
|
let step = startStoryInterview();
|
|
@@ -97,9 +108,15 @@ async function main() {
|
|
|
97
108
|
case "analyze":
|
|
98
109
|
return cmdGenerate("match_analysis", "Analyze how well this resume matches this job description. Call out genuine overlaps and honest gaps — no keyword stuffing.");
|
|
99
110
|
case "resume":
|
|
100
|
-
|
|
111
|
+
await cmdGenerate("tailored_resume", `Produce a tailored version of this resume for this job description, reordering and reframing existing content for relevance. Do not invent experience.\n\n${RESUME_MARKDOWN_GUIDE}`);
|
|
112
|
+
return cmdPdf(flags, "tailored_resume");
|
|
101
113
|
case "cover-letter":
|
|
102
|
-
|
|
114
|
+
await cmdGenerate("cover_letter", "Write a cover letter for this job description, in the voice profile's tone. A real letter: date, Dear Hiring Team, body, Sincerely + name. Output only the letter, as markdown.");
|
|
115
|
+
if (flags.pdf)
|
|
116
|
+
return cmdPdf(flags, "cover_letter");
|
|
117
|
+
return;
|
|
118
|
+
case "pdf":
|
|
119
|
+
return cmdPdf(flags);
|
|
103
120
|
case "about":
|
|
104
121
|
return cmdGenerate("linkedin_about", "Write a LinkedIn About section based on this resume and these stories, in the voice profile's tone.");
|
|
105
122
|
default:
|
|
@@ -112,9 +129,13 @@ async function main() {
|
|
|
112
129
|
" set-jd --company <name> --role <title> --file <path>",
|
|
113
130
|
" interview",
|
|
114
131
|
" analyze",
|
|
115
|
-
" resume",
|
|
116
|
-
" cover-letter",
|
|
132
|
+
" resume (also writes a PDF)",
|
|
133
|
+
" cover-letter [--pdf]",
|
|
117
134
|
" about",
|
|
135
|
+
" pdf [--document tailored_resume|cover_letter|base_resume] [--theme navy|minimal|classic]",
|
|
136
|
+
" [--page-size letter|a4] [--application <slug>]",
|
|
137
|
+
"",
|
|
138
|
+
" resume and cover-letter --pdf also accept --theme and --page-size.",
|
|
118
139
|
].join("\n"));
|
|
119
140
|
}
|
|
120
141
|
}
|
package/dist/export.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as storage from "./storage.js";
|
|
2
|
+
import { renderMarkdownToPdf } from "./render/pdf.js";
|
|
3
|
+
import { PAGE_SIZES } from "./render/markdown.js";
|
|
4
|
+
import { THEME_NAMES } from "./render/themes.js";
|
|
5
|
+
export const EXPORT_DOCUMENTS = ["tailored_resume", "cover_letter", "base_resume"];
|
|
6
|
+
function resolveTheme(theme) {
|
|
7
|
+
const value = (theme || process.env.CAREER_MCP_PDF_THEME || "navy").toLowerCase();
|
|
8
|
+
if (!THEME_NAMES.includes(value)) {
|
|
9
|
+
throw new Error(`Unknown theme "${value}". Choose one of: ${THEME_NAMES.join(", ")}.`);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function resolvePageSize(pageSize) {
|
|
14
|
+
const value = (pageSize || process.env.CAREER_MCP_PAGE_SIZE || "letter").toLowerCase();
|
|
15
|
+
if (!PAGE_SIZES.includes(value)) {
|
|
16
|
+
throw new Error(`Unknown page size "${value}". Choose one of: ${PAGE_SIZES.join(", ")}.`);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
export async function exportPdf(options = {}) {
|
|
21
|
+
const document = options.document ?? "tailored_resume";
|
|
22
|
+
if (!EXPORT_DOCUMENTS.includes(document)) {
|
|
23
|
+
throw new Error(`Unknown document "${document}". Choose one of: ${EXPORT_DOCUMENTS.join(", ")}.`);
|
|
24
|
+
}
|
|
25
|
+
const renderOptions = { theme: resolveTheme(options.theme), pageSize: resolvePageSize(options.pageSize) };
|
|
26
|
+
if (document === "base_resume") {
|
|
27
|
+
const resume = await storage.getResume();
|
|
28
|
+
if (!resume?.rawText)
|
|
29
|
+
throw new Error("No resume stored yet. Use save_resume first.");
|
|
30
|
+
return renderMarkdownToPdf(resume.rawText, storage.baseResumeOutputBasePath(), { ...renderOptions, title: "Resume", kind: "resume" });
|
|
31
|
+
}
|
|
32
|
+
let slug = options.applicationSlug;
|
|
33
|
+
if (!slug) {
|
|
34
|
+
const active = await storage.getActiveApplication();
|
|
35
|
+
if (!active)
|
|
36
|
+
throw new Error("No active application and no application_slug provided.");
|
|
37
|
+
slug = active.slug;
|
|
38
|
+
}
|
|
39
|
+
const application = await storage.getApplication(slug);
|
|
40
|
+
if (!application)
|
|
41
|
+
throw new Error(`No application found with slug "${slug}".`);
|
|
42
|
+
const markdown = await storage.getOutput(slug, document);
|
|
43
|
+
if (!markdown) {
|
|
44
|
+
throw new Error(`No ${document} saved for ${application.company} — ${application.role} yet. Generate it and save it with save_output first.`);
|
|
45
|
+
}
|
|
46
|
+
const isLetter = document === "cover_letter";
|
|
47
|
+
return renderMarkdownToPdf(markdown, storage.outputBasePath(slug, document), {
|
|
48
|
+
...renderOptions,
|
|
49
|
+
title: `${isLetter ? "Cover Letter" : "Resume"} — ${application.company}`,
|
|
50
|
+
kind: isLetter ? "letter" : "resume",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export function describeResult(result) {
|
|
54
|
+
return result.ok ? `PDF saved to ${result.pdfPath}` : `Could not create the PDF. ${result.reason}`;
|
|
55
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,9 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
5
|
import * as storage from "./storage.js";
|
|
6
6
|
import { startStoryInterview, answerStoryQuestion } from "./interview.js";
|
|
7
|
+
import { exportPdf, describeResult, EXPORT_DOCUMENTS } from "./export.js";
|
|
8
|
+
import { RESUME_MARKDOWN_GUIDE, PAGE_SIZES } from "./render/markdown.js";
|
|
9
|
+
import { THEME_NAMES } from "./render/themes.js";
|
|
7
10
|
const server = new Server({ name: "career-mcp", version: "1.0.0" }, { capabilities: { tools: {} } });
|
|
8
11
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
9
12
|
tools: [
|
|
@@ -55,7 +58,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
55
58
|
},
|
|
56
59
|
{
|
|
57
60
|
name: "get_active_application",
|
|
58
|
-
description:
|
|
61
|
+
description: `Returns the stored resume, voice profile, and the active job description together, as context for match analysis or content generation. Use this to: (1) run match analysis — identify genuine overlaps and honest gaps between the resume and JD, not keyword stuffing; (2) draft a tailored resume by reordering/reframing existing resume content for relevance, never fabricating experience; (3) draft a cover letter; (4) draft a LinkedIn About section. Match the voice profile's tone if one is set. Save any generated output with save_output.\n\n${RESUME_MARKDOWN_GUIDE}`,
|
|
59
62
|
inputSchema: { type: "object", properties: {} },
|
|
60
63
|
},
|
|
61
64
|
{
|
|
@@ -65,7 +68,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
65
68
|
},
|
|
66
69
|
{
|
|
67
70
|
name: "save_output",
|
|
68
|
-
description: "Persist generated content (match analysis, tailored resume, cover letter, or LinkedIn About draft) for an application. Defaults to the active application if application_slug is omitted.",
|
|
71
|
+
description: "Persist generated content (match analysis, tailored resume, cover letter, or LinkedIn About draft) for an application. Defaults to the active application if application_slug is omitted. After saving a tailored resume, offer to export it with export_pdf.",
|
|
69
72
|
inputSchema: {
|
|
70
73
|
type: "object",
|
|
71
74
|
properties: {
|
|
@@ -79,6 +82,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
79
82
|
required: ["output_type", "content"],
|
|
80
83
|
},
|
|
81
84
|
},
|
|
85
|
+
{
|
|
86
|
+
name: "export_pdf",
|
|
87
|
+
description: "Render a saved markdown document to a PDF that ATS systems can read, using the user's installed Chrome/Edge/Chromium/Brave. Defaults to the active application's tailored resume. Only export a cover letter when the user asks for one. If no browser is found, an HTML file is written instead and the reason is returned — relay it to the user.",
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: "object",
|
|
90
|
+
properties: {
|
|
91
|
+
document: {
|
|
92
|
+
type: "string",
|
|
93
|
+
enum: EXPORT_DOCUMENTS,
|
|
94
|
+
description: "tailored_resume (default), cover_letter, or base_resume (the stored base resume)",
|
|
95
|
+
},
|
|
96
|
+
application_slug: { type: "string", description: "Optional — defaults to the active application. Ignored for base_resume." },
|
|
97
|
+
theme: { type: "string", enum: THEME_NAMES, description: "navy (default: modern sans, navy accents), minimal (monochrome, compact), classic (serif, centered header)" },
|
|
98
|
+
page_size: { type: "string", enum: PAGE_SIZES, description: "letter (default) or a4" },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
82
102
|
{
|
|
83
103
|
name: "save_story",
|
|
84
104
|
description: "Save a career story directly (title, tags, content) without running the interview flow. Prefer start_story_interview for a guided capture.",
|
|
@@ -199,6 +219,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
199
219
|
const filePath = await storage.saveOutput(slug, output_type, content);
|
|
200
220
|
return textResult(`Saved ${output_type} to ${filePath}`);
|
|
201
221
|
}
|
|
222
|
+
case "export_pdf": {
|
|
223
|
+
const { document, application_slug, theme, page_size } = args;
|
|
224
|
+
const result = await exportPdf({ document, applicationSlug: application_slug, theme, pageSize: page_size });
|
|
225
|
+
return result.ok ? textResult(describeResult(result)) : { ...textResult(describeResult(result)), isError: true };
|
|
226
|
+
}
|
|
202
227
|
case "save_story": {
|
|
203
228
|
const { title, tags, content } = args;
|
|
204
229
|
const story = await storage.saveStory(title, tags, content);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
// Fonts ship as npm dependencies (@fontsource/*, all SIL OFL) and are inlined
|
|
5
|
+
// into the HTML as data URIs, so rendering never needs a network connection.
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
async function faceCss(face, weight, style) {
|
|
8
|
+
const pkgDir = path.dirname(require.resolve(`${face.pkg}/package.json`));
|
|
9
|
+
const file = path.join(pkgDir, "files", `${face.filePrefix}-latin-${weight}-${style}.woff2`);
|
|
10
|
+
const data = (await readFile(file)).toString("base64");
|
|
11
|
+
return `@font-face {
|
|
12
|
+
font-family: '${face.family}';
|
|
13
|
+
font-style: ${style};
|
|
14
|
+
font-weight: ${weight};
|
|
15
|
+
src: url(data:font/woff2;base64,${data}) format('woff2');
|
|
16
|
+
}`;
|
|
17
|
+
}
|
|
18
|
+
export async function fontFaceCss(face) {
|
|
19
|
+
const rules = await Promise.all([
|
|
20
|
+
...face.weights.map((w) => faceCss(face, w, "normal")),
|
|
21
|
+
...face.italicWeights.map((w) => faceCss(face, w, "italic")),
|
|
22
|
+
]);
|
|
23
|
+
return rules.join("\n");
|
|
24
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { marked } from "marked";
|
|
2
|
+
import { THEMES, SHARED_CSS } from "./themes.js";
|
|
3
|
+
import { fontFaceCss } from "./fonts.js";
|
|
4
|
+
export const PAGE_SIZES = ["letter", "a4"];
|
|
5
|
+
// Handed to the MCP client (and the CLI's LLM) so generated resumes match what the themes style.
|
|
6
|
+
export const RESUME_MARKDOWN_GUIDE = [
|
|
7
|
+
"Write the tailored resume as markdown in this exact shape so it renders cleanly to PDF:",
|
|
8
|
+
" # Full Name",
|
|
9
|
+
" One-line tagline (target title / positioning)",
|
|
10
|
+
" Contact line: City · email · phone · linkedin.com/in/... (one paragraph)",
|
|
11
|
+
" ## Summary (then a short paragraph)",
|
|
12
|
+
" ## Experience",
|
|
13
|
+
" ### Job Title — Company",
|
|
14
|
+
" *Start – End | Location*",
|
|
15
|
+
" - achievement bullets",
|
|
16
|
+
" ## Skills (bullets, or a two-column table: | Area | Skills |)",
|
|
17
|
+
" ## Education",
|
|
18
|
+
"Output only the resume itself — no commentary before or after it.",
|
|
19
|
+
].join("\n");
|
|
20
|
+
// LLM output often arrives fenced (```markdown ... ```) or with a chatty preamble,
|
|
21
|
+
// and resume files may carry private notes. Strip all of that before rendering.
|
|
22
|
+
export function cleanMarkdown(markdown) {
|
|
23
|
+
let text = markdown.trim();
|
|
24
|
+
const fenced = text.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```$/i);
|
|
25
|
+
if (fenced)
|
|
26
|
+
text = fenced[1].trim();
|
|
27
|
+
const lines = text.split("\n");
|
|
28
|
+
const firstH1 = lines.findIndex((line) => /^# /.test(line));
|
|
29
|
+
const start = firstH1 === -1 ? 0 : firstH1;
|
|
30
|
+
const notes = lines.findIndex((line, i) => i > start && /^## Notes for Customization/i.test(line));
|
|
31
|
+
const end = notes === -1 ? lines.length : notes;
|
|
32
|
+
return separateStructuralLines(lines.slice(start, end)).join("\n").trim();
|
|
33
|
+
}
|
|
34
|
+
// The themes style lines by position (h1 + p + p is the contact line, h3 + p the dates),
|
|
35
|
+
// but markdown folds consecutive lines into one paragraph. Give each plain line its own
|
|
36
|
+
// paragraph in the header block (between # Name and the first ##) and right after a ###/####.
|
|
37
|
+
function separateStructuralLines(lines) {
|
|
38
|
+
const isPlain = (line) => line.trim() !== "" && !/^\s*([-+]\s|\*\s|\d+\.\s|[|>#]|```)/.test(line);
|
|
39
|
+
const out = [];
|
|
40
|
+
let inHeaderBlock = false;
|
|
41
|
+
let afterSubheading = false;
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
if (/^# /.test(line))
|
|
44
|
+
inHeaderBlock = true;
|
|
45
|
+
else if (/^## /.test(line))
|
|
46
|
+
inHeaderBlock = false;
|
|
47
|
+
// A ###/#### opens a run of plain lines (dates, role description); bullets, tables, etc. end it.
|
|
48
|
+
if (/^#{3,4} /.test(line))
|
|
49
|
+
afterSubheading = true;
|
|
50
|
+
else if (line.trim() !== "" && !isPlain(line))
|
|
51
|
+
afterSubheading = false;
|
|
52
|
+
out.push(line);
|
|
53
|
+
if ((inHeaderBlock || afterSubheading) && isPlain(line))
|
|
54
|
+
out.push("");
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
export async function markdownToHtml(markdown, options) {
|
|
59
|
+
const theme = THEMES[options.theme];
|
|
60
|
+
// Letters keep single line breaks ("Sincerely,\nName"); resumes use standard markdown paragraphs.
|
|
61
|
+
const body = await marked.parse(cleanMarkdown(markdown), { breaks: options.kind === "letter" });
|
|
62
|
+
const fonts = await fontFaceCss(theme.font);
|
|
63
|
+
const pageSize = options.pageSize === "a4" ? "A4" : "letter";
|
|
64
|
+
return `<!DOCTYPE html>
|
|
65
|
+
<html lang="en">
|
|
66
|
+
<head>
|
|
67
|
+
<meta charset="UTF-8">
|
|
68
|
+
<title>${escapeHtml(options.title)}</title>
|
|
69
|
+
<style>
|
|
70
|
+
${fonts}
|
|
71
|
+
@page {
|
|
72
|
+
size: ${pageSize};
|
|
73
|
+
margin: 0.6in 0.75in;
|
|
74
|
+
}
|
|
75
|
+
${SHARED_CSS}
|
|
76
|
+
${theme.css}
|
|
77
|
+
</style>
|
|
78
|
+
</head>
|
|
79
|
+
<body class="${options.kind}">
|
|
80
|
+
${body}
|
|
81
|
+
</body>
|
|
82
|
+
</html>
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
85
|
+
function escapeHtml(text) {
|
|
86
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
87
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { access, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import puppeteer from "puppeteer-core";
|
|
5
|
+
import { markdownToHtml } from "./markdown.js";
|
|
6
|
+
// puppeteer-core drives a browser the user already has installed — it never
|
|
7
|
+
// downloads one. Set CAREER_MCP_CHROME_PATH to point at a browser we don't find.
|
|
8
|
+
function candidateBrowserPaths() {
|
|
9
|
+
const home = os.homedir();
|
|
10
|
+
switch (process.platform) {
|
|
11
|
+
case "darwin":
|
|
12
|
+
return [
|
|
13
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
14
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
15
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
16
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
17
|
+
path.join(home, "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
|
|
18
|
+
];
|
|
19
|
+
case "win32": {
|
|
20
|
+
const roots = [process.env["PROGRAMFILES"], process.env["PROGRAMFILES(X86)"], process.env["LOCALAPPDATA"]].filter((root) => Boolean(root));
|
|
21
|
+
return roots.flatMap((root) => [
|
|
22
|
+
path.join(root, "Google", "Chrome", "Application", "chrome.exe"),
|
|
23
|
+
path.join(root, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
24
|
+
path.join(root, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
|
25
|
+
]);
|
|
26
|
+
}
|
|
27
|
+
default:
|
|
28
|
+
return [
|
|
29
|
+
"/usr/bin/google-chrome",
|
|
30
|
+
"/usr/bin/google-chrome-stable",
|
|
31
|
+
"/usr/bin/chromium",
|
|
32
|
+
"/usr/bin/chromium-browser",
|
|
33
|
+
"/usr/bin/microsoft-edge",
|
|
34
|
+
"/usr/bin/brave-browser",
|
|
35
|
+
"/snap/bin/chromium",
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function exists(p) {
|
|
40
|
+
try {
|
|
41
|
+
await access(p);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function findBrowser() {
|
|
49
|
+
const override = process.env.CAREER_MCP_CHROME_PATH;
|
|
50
|
+
if (override) {
|
|
51
|
+
if (await exists(override))
|
|
52
|
+
return override;
|
|
53
|
+
throw new Error(`CAREER_MCP_CHROME_PATH is set to ${override}, but no file exists there.`);
|
|
54
|
+
}
|
|
55
|
+
for (const candidate of candidateBrowserPaths()) {
|
|
56
|
+
if (await exists(candidate))
|
|
57
|
+
return candidate;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
// Writes <outBase>.pdf. If no browser is available, writes <outBase>.html instead
|
|
62
|
+
// so the user can open it and Print → Save as PDF themselves.
|
|
63
|
+
export async function renderMarkdownToPdf(markdown, outBase, options) {
|
|
64
|
+
const html = await markdownToHtml(markdown, options);
|
|
65
|
+
const browserPath = await findBrowser();
|
|
66
|
+
if (!browserPath) {
|
|
67
|
+
const htmlPath = `${outBase}.html`;
|
|
68
|
+
await writeFile(htmlPath, html, "utf8");
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
htmlPath,
|
|
72
|
+
reason: "No Chrome, Edge, Chromium, or Brave install was found. Install one, or set CAREER_MCP_CHROME_PATH to your browser's executable. " +
|
|
73
|
+
`In the meantime, open ${htmlPath} in any browser and use Print → Save as PDF.`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const browser = await puppeteer.launch({
|
|
77
|
+
executablePath: browserPath,
|
|
78
|
+
headless: true,
|
|
79
|
+
// Chrome refuses to start as root (common in containers/CI) without --no-sandbox.
|
|
80
|
+
args: process.getuid?.() === 0 ? ["--no-sandbox", "--disable-dev-shm-usage"] : [],
|
|
81
|
+
});
|
|
82
|
+
try {
|
|
83
|
+
const page = await browser.newPage();
|
|
84
|
+
await page.setContent(html, { waitUntil: "load" });
|
|
85
|
+
await page.evaluateHandle("document.fonts.ready");
|
|
86
|
+
const pdfPath = `${outBase}.pdf`;
|
|
87
|
+
await page.pdf({ path: pdfPath, printBackground: true, preferCSSPageSize: true });
|
|
88
|
+
return { ok: true, pdfPath };
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await browser.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
export const THEME_NAMES = ["navy", "minimal", "classic"];
|
|
2
|
+
// Page-break rules shared by every theme: keep headings attached to what follows them.
|
|
3
|
+
export const SHARED_CSS = `
|
|
4
|
+
* { box-sizing: border-box; }
|
|
5
|
+
|
|
6
|
+
h2, h3, h4 {
|
|
7
|
+
page-break-after: avoid;
|
|
8
|
+
break-after: avoid;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
h2 + p, h2 + ul, h2 + table,
|
|
12
|
+
h3 + p, h3 + ul,
|
|
13
|
+
h4 + p, h4 + ul {
|
|
14
|
+
page-break-before: avoid;
|
|
15
|
+
break-before: avoid;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
li, p {
|
|
19
|
+
orphans: 3;
|
|
20
|
+
widows: 3;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
blockquote, table {
|
|
24
|
+
page-break-inside: avoid;
|
|
25
|
+
break-inside: avoid;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
thead { display: none; }
|
|
29
|
+
|
|
30
|
+
/* Cover letters reuse the theme but read as prose: roomier paragraphs. */
|
|
31
|
+
body.letter { line-height: 1.55; }
|
|
32
|
+
body.letter p { margin: 0 0 10pt 0; }
|
|
33
|
+
`;
|
|
34
|
+
// The original render-pdf.sh look: Inter, deep navy accents, tinted impact callouts.
|
|
35
|
+
const navy = {
|
|
36
|
+
font: {
|
|
37
|
+
family: "Inter",
|
|
38
|
+
pkg: "@fontsource/inter",
|
|
39
|
+
filePrefix: "inter",
|
|
40
|
+
weights: [400, 500, 600, 700],
|
|
41
|
+
italicWeights: [400],
|
|
42
|
+
},
|
|
43
|
+
css: `
|
|
44
|
+
body {
|
|
45
|
+
font-family: 'Inter', 'Calibri', 'Helvetica Neue', Arial, sans-serif;
|
|
46
|
+
font-size: 10.5pt;
|
|
47
|
+
color: #111827;
|
|
48
|
+
line-height: 1.45;
|
|
49
|
+
margin: 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
h1 {
|
|
53
|
+
font-size: 21pt;
|
|
54
|
+
font-weight: 700;
|
|
55
|
+
color: #0F172A;
|
|
56
|
+
margin: 0 0 3pt 0;
|
|
57
|
+
letter-spacing: -0.02em;
|
|
58
|
+
line-height: 1.1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
h1 + p {
|
|
62
|
+
font-size: 10.5pt;
|
|
63
|
+
font-weight: 500;
|
|
64
|
+
color: #1E3A6E;
|
|
65
|
+
margin: 0 0 2pt 0;
|
|
66
|
+
letter-spacing: 0.01em;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
h1 + p + p {
|
|
70
|
+
font-size: 9.5pt;
|
|
71
|
+
font-weight: 500;
|
|
72
|
+
color: #334155;
|
|
73
|
+
letter-spacing: 0.02em;
|
|
74
|
+
line-height: 1.7;
|
|
75
|
+
margin: 8pt 0 4pt 0;
|
|
76
|
+
padding-top: 6pt;
|
|
77
|
+
border-top: 0.75px solid #CBD5E1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
h1 + p + p a {
|
|
81
|
+
color: #1E3A6E;
|
|
82
|
+
font-weight: 600;
|
|
83
|
+
text-decoration: none;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
h2 {
|
|
87
|
+
font-size: 9pt;
|
|
88
|
+
font-weight: 600;
|
|
89
|
+
text-transform: uppercase;
|
|
90
|
+
letter-spacing: 0.13em;
|
|
91
|
+
color: #1E3A6E;
|
|
92
|
+
border-bottom: 1.5px solid #1E3A6E;
|
|
93
|
+
padding-bottom: 3pt;
|
|
94
|
+
margin: 17pt 0 7pt 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
h3 {
|
|
98
|
+
font-size: 11.5pt;
|
|
99
|
+
font-weight: 700;
|
|
100
|
+
color: #0F172A;
|
|
101
|
+
margin: 10pt 0 0 0;
|
|
102
|
+
line-height: 1.3;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
h3 + p {
|
|
106
|
+
font-size: 9.5pt;
|
|
107
|
+
font-style: italic;
|
|
108
|
+
color: #64748B;
|
|
109
|
+
margin: 1pt 0 4pt 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
h3 + p + p {
|
|
113
|
+
font-size: 10pt;
|
|
114
|
+
color: #475569;
|
|
115
|
+
margin: 2pt 0 5pt 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
h4 {
|
|
119
|
+
font-size: 10pt;
|
|
120
|
+
font-weight: 600;
|
|
121
|
+
font-style: italic;
|
|
122
|
+
color: #1E3A6E;
|
|
123
|
+
margin: 8pt 0 3pt 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
p { margin: 2pt 0 4pt 0; }
|
|
127
|
+
|
|
128
|
+
ul {
|
|
129
|
+
margin: 2pt 0 5pt 0;
|
|
130
|
+
padding-left: 15pt;
|
|
131
|
+
list-style-type: disc;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
li {
|
|
135
|
+
font-size: 10.5pt;
|
|
136
|
+
color: #111827;
|
|
137
|
+
line-height: 1.45;
|
|
138
|
+
margin-bottom: 3pt;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
strong { font-weight: 600; }
|
|
142
|
+
em { font-style: italic; }
|
|
143
|
+
a { color: #1E3A6E; }
|
|
144
|
+
|
|
145
|
+
hr {
|
|
146
|
+
border: none;
|
|
147
|
+
border-top: 1px solid #E2E8F0;
|
|
148
|
+
margin: 8pt 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
blockquote {
|
|
152
|
+
margin: 6pt 0 4pt 0;
|
|
153
|
+
padding: 6pt 10pt;
|
|
154
|
+
border-left: 3px solid #1E3A6E;
|
|
155
|
+
background: #EFF6FF;
|
|
156
|
+
border-radius: 0 3pt 3pt 0;
|
|
157
|
+
color: #1E293B;
|
|
158
|
+
font-size: 10pt;
|
|
159
|
+
font-weight: 500;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
blockquote p { margin: 0; }
|
|
163
|
+
|
|
164
|
+
table {
|
|
165
|
+
width: 100%;
|
|
166
|
+
border-collapse: collapse;
|
|
167
|
+
font-size: 10pt;
|
|
168
|
+
margin: 4pt 0 2pt 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
td {
|
|
172
|
+
padding: 4pt 8pt 4pt 0;
|
|
173
|
+
vertical-align: top;
|
|
174
|
+
border-bottom: 0.75px solid #E2E8F0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
td:first-child {
|
|
178
|
+
width: 25%;
|
|
179
|
+
font-weight: 600;
|
|
180
|
+
color: #1E3A6E;
|
|
181
|
+
font-size: 9.5pt;
|
|
182
|
+
padding-right: 12pt;
|
|
183
|
+
white-space: nowrap;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
td:last-child {
|
|
187
|
+
color: #334155;
|
|
188
|
+
font-size: 10pt;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
tr:last-child td { border-bottom: none; }
|
|
192
|
+
`,
|
|
193
|
+
};
|
|
194
|
+
// Monochrome and compact: IBM Plex Sans, no color, thin rules. Fits more on a page.
|
|
195
|
+
const minimal = {
|
|
196
|
+
font: {
|
|
197
|
+
family: "IBM Plex Sans",
|
|
198
|
+
pkg: "@fontsource/ibm-plex-sans",
|
|
199
|
+
filePrefix: "ibm-plex-sans",
|
|
200
|
+
weights: [400, 500, 600, 700],
|
|
201
|
+
italicWeights: [400],
|
|
202
|
+
},
|
|
203
|
+
css: `
|
|
204
|
+
body {
|
|
205
|
+
font-family: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;
|
|
206
|
+
font-size: 10pt;
|
|
207
|
+
color: #000000;
|
|
208
|
+
line-height: 1.4;
|
|
209
|
+
margin: 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
h1 {
|
|
213
|
+
font-size: 18pt;
|
|
214
|
+
font-weight: 600;
|
|
215
|
+
margin: 0 0 2pt 0;
|
|
216
|
+
line-height: 1.1;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
h1 + p {
|
|
220
|
+
font-size: 10pt;
|
|
221
|
+
color: #333333;
|
|
222
|
+
margin: 0 0 2pt 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
h1 + p + p {
|
|
226
|
+
font-size: 9pt;
|
|
227
|
+
color: #333333;
|
|
228
|
+
margin: 0 0 6pt 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
h1 + p + p a {
|
|
232
|
+
color: #000000;
|
|
233
|
+
text-decoration: none;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
h2 {
|
|
237
|
+
font-size: 9.5pt;
|
|
238
|
+
font-weight: 600;
|
|
239
|
+
text-transform: uppercase;
|
|
240
|
+
letter-spacing: 0.08em;
|
|
241
|
+
border-bottom: 0.75px solid #000000;
|
|
242
|
+
padding-bottom: 2pt;
|
|
243
|
+
margin: 13pt 0 5pt 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
h3 {
|
|
247
|
+
font-size: 10.5pt;
|
|
248
|
+
font-weight: 600;
|
|
249
|
+
margin: 8pt 0 0 0;
|
|
250
|
+
line-height: 1.3;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
h3 + p {
|
|
254
|
+
font-size: 9pt;
|
|
255
|
+
color: #555555;
|
|
256
|
+
margin: 0 0 3pt 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
h3 + p + p {
|
|
260
|
+
font-size: 9.5pt;
|
|
261
|
+
color: #333333;
|
|
262
|
+
margin: 1pt 0 3pt 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
h4 {
|
|
266
|
+
font-size: 9.5pt;
|
|
267
|
+
font-weight: 600;
|
|
268
|
+
margin: 6pt 0 2pt 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
p { margin: 2pt 0 3pt 0; }
|
|
272
|
+
|
|
273
|
+
ul {
|
|
274
|
+
margin: 1pt 0 4pt 0;
|
|
275
|
+
padding-left: 13pt;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
li {
|
|
279
|
+
margin-bottom: 2pt;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
strong { font-weight: 600; }
|
|
283
|
+
a { color: #000000; }
|
|
284
|
+
|
|
285
|
+
hr {
|
|
286
|
+
border: none;
|
|
287
|
+
border-top: 0.75px solid #CCCCCC;
|
|
288
|
+
margin: 6pt 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
blockquote {
|
|
292
|
+
margin: 4pt 0;
|
|
293
|
+
padding: 0 0 0 8pt;
|
|
294
|
+
border-left: 2px solid #000000;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
blockquote p { margin: 0; }
|
|
298
|
+
|
|
299
|
+
table {
|
|
300
|
+
width: 100%;
|
|
301
|
+
border-collapse: collapse;
|
|
302
|
+
margin: 2pt 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
td {
|
|
306
|
+
padding: 2pt 8pt 2pt 0;
|
|
307
|
+
vertical-align: top;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
td:first-child {
|
|
311
|
+
width: 22%;
|
|
312
|
+
font-weight: 600;
|
|
313
|
+
white-space: nowrap;
|
|
314
|
+
}
|
|
315
|
+
`,
|
|
316
|
+
};
|
|
317
|
+
// Traditional: Source Serif, centered header, small-caps section rules. Suits finance, law, academia.
|
|
318
|
+
const classic = {
|
|
319
|
+
font: {
|
|
320
|
+
family: "Source Serif 4",
|
|
321
|
+
pkg: "@fontsource/source-serif-4",
|
|
322
|
+
filePrefix: "source-serif-4",
|
|
323
|
+
weights: [400, 600, 700],
|
|
324
|
+
italicWeights: [400],
|
|
325
|
+
},
|
|
326
|
+
css: `
|
|
327
|
+
body {
|
|
328
|
+
font-family: 'Source Serif 4', Georgia, 'Times New Roman', serif;
|
|
329
|
+
font-size: 10.5pt;
|
|
330
|
+
color: #1A1A1A;
|
|
331
|
+
line-height: 1.4;
|
|
332
|
+
margin: 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
h1 {
|
|
336
|
+
font-size: 20pt;
|
|
337
|
+
font-weight: 600;
|
|
338
|
+
text-align: center;
|
|
339
|
+
letter-spacing: 0.04em;
|
|
340
|
+
margin: 0 0 3pt 0;
|
|
341
|
+
line-height: 1.1;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
h1 + p {
|
|
345
|
+
font-size: 10.5pt;
|
|
346
|
+
font-style: italic;
|
|
347
|
+
text-align: center;
|
|
348
|
+
margin: 0 0 2pt 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
h1 + p + p {
|
|
352
|
+
font-size: 9.5pt;
|
|
353
|
+
text-align: center;
|
|
354
|
+
margin: 0 0 6pt 0;
|
|
355
|
+
padding-bottom: 6pt;
|
|
356
|
+
border-bottom: 1px solid #1A1A1A;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
h1 + p + p a {
|
|
360
|
+
color: #1A1A1A;
|
|
361
|
+
text-decoration: none;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
h2 {
|
|
365
|
+
font-size: 11pt;
|
|
366
|
+
font-weight: 600;
|
|
367
|
+
font-variant: small-caps;
|
|
368
|
+
letter-spacing: 0.06em;
|
|
369
|
+
border-bottom: 0.5px solid #1A1A1A;
|
|
370
|
+
padding-bottom: 1pt;
|
|
371
|
+
margin: 14pt 0 6pt 0;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
h3 {
|
|
375
|
+
font-size: 11pt;
|
|
376
|
+
font-weight: 700;
|
|
377
|
+
margin: 9pt 0 0 0;
|
|
378
|
+
line-height: 1.3;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
h3 + p {
|
|
382
|
+
font-size: 10pt;
|
|
383
|
+
font-style: italic;
|
|
384
|
+
color: #444444;
|
|
385
|
+
margin: 0 0 3pt 0;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
h3 + p + p {
|
|
389
|
+
font-size: 10pt;
|
|
390
|
+
margin: 1pt 0 4pt 0;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
h4 {
|
|
394
|
+
font-size: 10.5pt;
|
|
395
|
+
font-weight: 600;
|
|
396
|
+
font-style: italic;
|
|
397
|
+
margin: 7pt 0 2pt 0;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
p { margin: 2pt 0 4pt 0; }
|
|
401
|
+
|
|
402
|
+
ul {
|
|
403
|
+
margin: 2pt 0 5pt 0;
|
|
404
|
+
padding-left: 15pt;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
li {
|
|
408
|
+
margin-bottom: 2pt;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
strong { font-weight: 600; }
|
|
412
|
+
a { color: #1A1A1A; }
|
|
413
|
+
|
|
414
|
+
hr {
|
|
415
|
+
border: none;
|
|
416
|
+
border-top: 0.5px solid #1A1A1A;
|
|
417
|
+
margin: 8pt 0;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
blockquote {
|
|
421
|
+
margin: 5pt 16pt;
|
|
422
|
+
font-style: italic;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
blockquote p { margin: 0; }
|
|
426
|
+
|
|
427
|
+
table {
|
|
428
|
+
width: 100%;
|
|
429
|
+
border-collapse: collapse;
|
|
430
|
+
margin: 3pt 0;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
td {
|
|
434
|
+
padding: 2pt 10pt 2pt 0;
|
|
435
|
+
vertical-align: top;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
td:first-child {
|
|
439
|
+
width: 25%;
|
|
440
|
+
font-weight: 600;
|
|
441
|
+
white-space: nowrap;
|
|
442
|
+
}
|
|
443
|
+
`,
|
|
444
|
+
};
|
|
445
|
+
export const THEMES = { navy, minimal, classic };
|
package/dist/storage.js
CHANGED
|
@@ -142,6 +142,13 @@ export async function saveOutput(slug, type, content) {
|
|
|
142
142
|
await writeFile(filePath, content, "utf8");
|
|
143
143
|
return filePath;
|
|
144
144
|
}
|
|
145
|
+
// Path without extension, so renderers can write <base>.pdf / <base>.html next to <base>.md.
|
|
146
|
+
export function outputBasePath(slug, type) {
|
|
147
|
+
return path.join(APPLICATIONS_DIR, slug, "outputs", type);
|
|
148
|
+
}
|
|
149
|
+
export function baseResumeOutputBasePath() {
|
|
150
|
+
return path.join(DATA_DIR, "resume");
|
|
151
|
+
}
|
|
145
152
|
export async function getOutput(slug, type) {
|
|
146
153
|
const filePath = path.join(APPLICATIONS_DIR, slug, "outputs", `${type}.md`);
|
|
147
154
|
if (!(await exists(filePath)))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sindhujaks/career-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "MCP server for resume/JD match analysis, tailored resumes and cover letters, a structured career-story interview, and LinkedIn About drafting. Local-first — your data never leaves your machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -45,9 +45,14 @@
|
|
|
45
45
|
"access": "public"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
+
"@fontsource/ibm-plex-sans": "^5.3.0",
|
|
49
|
+
"@fontsource/inter": "^5.3.0",
|
|
50
|
+
"@fontsource/source-serif-4": "^5.3.1",
|
|
48
51
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
49
52
|
"dotenv": "^16.4.0",
|
|
50
|
-
"
|
|
53
|
+
"marked": "^18.1.2",
|
|
54
|
+
"pdf-parse": "^2.4.5",
|
|
55
|
+
"puppeteer-core": "^25.16.0"
|
|
51
56
|
},
|
|
52
57
|
"devDependencies": {
|
|
53
58
|
"@types/node": "^22.0.0",
|