@mpelka/resumegenerator 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Marek Pelka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # resumegenerator
2
+
3
+ Markdown-to-PDF resume generator. Write your resume in markdown, get a styled, ATS-friendly PDF.
4
+
5
+ ## Install
6
+
7
+ Requires [Bun](https://bun.sh).
8
+
9
+ ```bash
10
+ bun i -g @mpelka/resumegenerator
11
+ ```
12
+
13
+ This installs the CLI tool and Chromium browser automatically. Fonts are downloaded from Google Fonts on first run and cached locally.
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ resumegenerator --filename resume.md
19
+ ```
20
+
21
+ | Flag | Description |
22
+ |------|-------------|
23
+ | `--filename` | Path to the markdown resume (required) |
24
+ | `--template` | Template name (default: `modern`) |
25
+ | `--initials` | Override auto-derived monogram initials |
26
+ | `--output-filename` | Override the output PDF filename |
27
+ | `--spacing` | Spacing multiplier (e.g. `0.8` = 80% of default gaps) |
28
+
29
+ The PDF is written next to the source `.md` file. Initials for the monogram are auto-derived from the `h1` name.
30
+
31
+ ## Markdown format
32
+
33
+ ```markdown
34
+ # Full Name
35
+
36
+ **Job Title**
37
+
38
+ Location • email@example.com • linkedin.com/in/handle
39
+
40
+ Summary paragraph with optional **bold** keywords.
41
+
42
+ ## Work Experience
43
+
44
+ ### Company Name
45
+ **Role Title** | Start Date - End Date
46
+
47
+ * Achievement with **keyword** highlights
48
+
49
+ *Technologies used: Tech1, Tech2, Tech3.*
50
+
51
+ ## Education
52
+
53
+ ### University Name
54
+ **Degree** | Start Year - End Year
55
+
56
+ ## Skills
57
+
58
+ - **Category:** Item1 • Item2 • Item3
59
+ ```
60
+
61
+ YAML frontmatter is supported and stripped before rendering.
62
+
63
+ ## Templates
64
+
65
+ Each template defines its own fonts, colors, and layout features. Pass `--template <name>` to switch.
66
+
67
+ | Template | Description |
68
+ |----------|-------------|
69
+ | `modern` (default) | Clean sans-serif (IBM Plex Sans) with monogram badge and section divider lines |
70
+ | `technical` | Monospace (IBM Plex Mono) with outlined circle monogram and minimal section labels |
71
+
72
+ Templates live in `templates/<name>/` with two files:
73
+ - `style.css` — layout, margins (`@page`), and visual styling
74
+ - `template.js` — config (fonts, colors, feature flags)
75
+
76
+ ## Customizing templates
77
+
78
+ To create a new template, add a directory under `templates/` with:
79
+
80
+ **`template.js`** — exports a config object:
81
+
82
+ ```js
83
+ export default {
84
+ fonts: { primary: "IBM Plex Sans", secondary: "IBM Plex Mono" },
85
+ colors: {
86
+ body: "#323336",
87
+ subtitle: "#707678",
88
+ sectionLabel: "#a6aaad",
89
+ accent: "#42f398",
90
+ border: "#e0e0e0",
91
+ },
92
+ features: {
93
+ monogram: true, // render initials badge
94
+ sectionDividers: true, // "EXPERIENCE ————" style dividers
95
+ },
96
+ };
97
+ ```
98
+
99
+ **`style.css`** — template-specific styles, including page margins via `@page`. Uses CSS variables (`--font-primary`, `--font-secondary`, `--color-*`) injected from the config at build time.
100
+
101
+ Font families must be present in the `GOOGLE_FONTS_URL` map in `src/utils.js`.
102
+
103
+ ## How it works
104
+
105
+ The CLI parses your markdown, renders it to semantic HTML with configurable templates, downloads and caches Google Fonts as TTF files, then uses Playwright's Chromium to generate a tagged PDF. The result is an ATS-optimized document with proper text selection, heading structure, and PDF link annotations.
106
+
107
+ ## ATS optimization
108
+
109
+ The generated PDFs are optimized for Applicant Tracking Systems and AI-based resume screening:
110
+
111
+ - **Tagged PDF** — embeds a structural tag tree (headings, paragraphs, lists) for correct semantic parsing
112
+ - **PDF metadata** — title is set to `Name - Resume` (not the temp filename)
113
+ - **Single-column layout** — no tables, columns, or complex layouts that break ATS parsers
114
+ - **Real text** — all content is selectable text, not images
115
+ - **Semantic HTML** — clean heading hierarchy (h1/h2/h3) with standard section names
116
+ - **Hyperlinks** — email, LinkedIn, and GitHub are embedded as proper PDF link annotations
117
+
118
+ ## Font strategy
119
+
120
+ The CLI downloads full TTF files from Google Fonts rather than using CDN `<link>` tags. Google Fonts CDN serves WOFF2 files split into `unicode-range` subsets, and when Playwright embeds these into a PDF the character-to-glyph mapping fragments — text looks correct but copy-paste produces garbled output, breaking ATS parsers. Fetching with a basic Linux User-Agent returns un-subsetted TTF URLs that produce clean character maps. A temp HTML file is written so the page loads with a `file://` origin, which is required for Chromium to access the locally cached font files.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@mpelka/resumegenerator",
3
+ "version": "1.0.0",
4
+ "description": "Markdown-to-PDF resume generator with templates and ATS optimization",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "resumegenerator": "src/cli.js"
9
+ },
10
+ "files": [
11
+ "src/cli.js",
12
+ "src/render.js",
13
+ "src/utils.js",
14
+ "src/base.css",
15
+ "templates/"
16
+ ],
17
+ "scripts": {
18
+ "generate": "bun src/cli.js",
19
+ "test": "bun test",
20
+ "check": "bunx @biomejs/biome check --write .",
21
+ "lint": "bunx @biomejs/biome lint .",
22
+ "postinstall": "npx playwright install chromium"
23
+ },
24
+ "keywords": [
25
+ "resume",
26
+ "pdf",
27
+ "markdown",
28
+ "cli",
29
+ "ats"
30
+ ],
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/mpelka/resumegenerator"
34
+ },
35
+ "engines": {
36
+ "bun": ">=1.0"
37
+ },
38
+ "dependencies": {
39
+ "commander": "^14.0.3",
40
+ "playwright": "^1.58.2"
41
+ },
42
+ "devDependencies": {
43
+ "@biomejs/biome": "^2.4.6"
44
+ }
45
+ }
package/src/base.css ADDED
@@ -0,0 +1,48 @@
1
+ /* Preflight (subset of Tailwind's reset relevant to this document) */
2
+ *,
3
+ ::after,
4
+ ::before {
5
+ box-sizing: border-box;
6
+ margin: 0;
7
+ padding: 0;
8
+ border: 0 solid;
9
+ }
10
+
11
+ html {
12
+ line-height: 1.5;
13
+ -webkit-text-size-adjust: 100%;
14
+ tab-size: 4;
15
+ }
16
+
17
+ h1,
18
+ h2,
19
+ h3,
20
+ h4,
21
+ h5,
22
+ h6 {
23
+ font-size: inherit;
24
+ font-weight: inherit;
25
+ }
26
+
27
+ a {
28
+ color: inherit;
29
+ text-decoration: inherit;
30
+ }
31
+
32
+ b,
33
+ strong {
34
+ font-weight: bolder;
35
+ }
36
+
37
+ ol,
38
+ ul {
39
+ list-style: none;
40
+ }
41
+
42
+ /* Base */
43
+ body {
44
+ font-family: var(--font-primary);
45
+ color: var(--color-body);
46
+ background: #fff;
47
+ -webkit-font-smoothing: antialiased;
48
+ }
package/src/cli.js ADDED
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { program } from "commander";
6
+ import { chromium } from "playwright";
7
+ import { renderResume } from "./render.js";
8
+ import { buildHtml, buildPdfName, deriveInitials, GOOGLE_FONTS_URL, parseFontFaces, parseFrontmatter } from "./utils.js";
9
+
10
+ if (typeof Bun === "undefined") {
11
+ console.error("This tool requires Bun. Install it at https://bun.sh");
12
+ process.exit(1);
13
+ }
14
+
15
+ const ROOT = resolve(import.meta.dirname, "..");
16
+ const FONTS_DIR = resolve(ROOT, ".fonts");
17
+
18
+ async function ensureFont(fontFamily) {
19
+ const url = GOOGLE_FONTS_URL[fontFamily];
20
+ if (!url) return null;
21
+
22
+ mkdirSync(FONTS_DIR, { recursive: true });
23
+
24
+ // Return cached CSS if available
25
+ const cacheFile = resolve(FONTS_DIR, `${fontFamily}.css`);
26
+ if (existsSync(cacheFile)) {
27
+ return readFileSync(cacheFile, "utf-8");
28
+ }
29
+
30
+ console.log(`Downloading fonts for ${fontFamily} (one-time)...`);
31
+
32
+ // Fetch with basic UA to get un-subsetted TTF URLs (no unicode-range splitting)
33
+ const res = await fetch(url, {
34
+ headers: { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64)" },
35
+ });
36
+ const cssText = await res.text();
37
+ const faces = parseFontFaces(cssText, fontFamily);
38
+
39
+ const cssRules = [];
40
+ for (const face of faces) {
41
+ const filePath = resolve(FONTS_DIR, face.filename);
42
+
43
+ if (!existsSync(filePath)) {
44
+ const fontRes = await fetch(face.fileUrl);
45
+ writeFileSync(filePath, Buffer.from(await fontRes.arrayBuffer()));
46
+ }
47
+
48
+ cssRules.push(`@font-face {
49
+ font-family: '${fontFamily}';
50
+ src: url('${pathToFileURL(filePath).href}') format('truetype');
51
+ font-weight: ${face.weight};
52
+ font-style: ${face.style};
53
+ }`);
54
+ }
55
+
56
+ const result = cssRules.join("\n");
57
+ writeFileSync(cacheFile, result);
58
+ return result;
59
+ }
60
+
61
+ async function ensureFonts(families) {
62
+ const results = [];
63
+ for (const family of families) {
64
+ const css = await ensureFont(family);
65
+ if (css) results.push(css);
66
+ }
67
+ return results.join("\n") || null;
68
+ }
69
+
70
+ program
71
+ .description("Generate a styled PDF resume from markdown")
72
+ .requiredOption("--filename <path>", "path to the markdown resume")
73
+ .option("--template <name>", "template name", "modern")
74
+ .option("--initials <letters>", "override auto-derived monogram initials")
75
+ .option("--output-filename <name>", "override the output PDF filename")
76
+ .option("--spacing <multiplier>", "scale vertical gaps (e.g. 0.8 = 80%)")
77
+ .parse();
78
+
79
+ const opts = program.opts();
80
+ const filename = opts.filename;
81
+ const outputFilename = opts.outputFilename;
82
+
83
+ // Load template
84
+ const templateName = opts.template;
85
+ const templateDir = resolve(ROOT, "templates", templateName);
86
+
87
+ if (!existsSync(templateDir)) {
88
+ const available = readdirSync(resolve(ROOT, "templates"), { withFileTypes: true })
89
+ .filter((d) => d.isDirectory())
90
+ .map((d) => d.name);
91
+ console.error(`Template "${templateName}" not found. Available templates: ${available.join(", ")}`);
92
+ process.exit(1);
93
+ }
94
+
95
+ const templateConfig = (await import(resolve(templateDir, "template.js"))).default;
96
+ const templateCSS = readFileSync(resolve(templateDir, "style.css"), "utf-8");
97
+ const baseCSS = readFileSync(resolve(ROOT, "src", "base.css"), "utf-8");
98
+
99
+ // Read and validate markdown
100
+ const mdPath = resolve(process.cwd(), filename);
101
+ const outputDir = dirname(mdPath);
102
+
103
+ let raw;
104
+ try {
105
+ raw = readFileSync(mdPath, "utf-8");
106
+ } catch {
107
+ console.error(`File not found: ${mdPath}`);
108
+ process.exit(1);
109
+ }
110
+
111
+ const { markdown } = parseFrontmatter(raw);
112
+
113
+ const nameMatch = markdown.match(/^#\s+(.+)$/m);
114
+ if (!nameMatch) {
115
+ console.error("Error: markdown must contain an h1 heading (# Name)");
116
+ process.exit(1);
117
+ }
118
+
119
+ // Derive initials from h1 or use CLI override
120
+ const initials = opts.initials || deriveInitials(nameMatch[1]);
121
+
122
+ // Render markdown to HTML
123
+ const bodyHtml = renderResume(markdown, { initials, features: templateConfig.features });
124
+
125
+ // Download/cache fonts
126
+ const fontFamilies = Object.values(templateConfig.fonts);
127
+ const fontFaceCSS = await ensureFonts(fontFamilies);
128
+
129
+ // Build spacing overrides if provided (multiplier, e.g. 0.8 = 80% of default gaps)
130
+ let spacingCSS = "";
131
+ const spacing = opts.spacing ? parseFloat(opts.spacing) : null;
132
+ if (spacing != null) {
133
+ const s = spacing;
134
+ spacingCSS = [
135
+ `header { margin-bottom: ${20 * s}px !important; }`,
136
+ `.section-divider { margin-top: ${28 * s}px !important; margin-bottom: ${20 * s}px !important; }`,
137
+ `h3 { margin-top: ${32 * s}px !important; }`,
138
+ `ul { margin-top: ${6 * s}px !important; }`,
139
+ `ul + p { margin-top: ${6 * s}px !important; }`,
140
+ ].join("\n");
141
+ }
142
+
143
+ // Build CSS: base + template + spacing overrides
144
+ const css = `${baseCSS}\n${templateCSS}\n${spacingCSS}`;
145
+
146
+ let varsCSS = ":root {";
147
+ varsCSS += ` --font-primary: '${templateConfig.fonts.primary}', sans-serif;`;
148
+ if (templateConfig.fonts.secondary) {
149
+ varsCSS += ` --font-secondary: '${templateConfig.fonts.secondary}', serif;`;
150
+ }
151
+ for (const [key, value] of Object.entries(templateConfig.colors)) {
152
+ const prop = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
153
+ varsCSS += ` --color-${prop}: ${value};`;
154
+ }
155
+ varsCSS += " }";
156
+
157
+ const pdfTitle = `${nameMatch[1]} - Resume`;
158
+ const fullHtml = buildHtml({ bodyHtml, fontFaceCSS, css, fontOverride: varsCSS, pdfTitle });
159
+
160
+ // Generate PDF with Playwright
161
+ const tmpHtml = resolve(outputDir, ".tmp-resume.html");
162
+ writeFileSync(tmpHtml, fullHtml);
163
+
164
+ const primaryFont = templateConfig.fonts.primary;
165
+ console.log(
166
+ `Generating PDF (template: ${templateName}, font: ${primaryFont}, source: ${fontFaceCSS ? "Google Fonts (cached)" : "system fallback"})...`,
167
+ );
168
+ let browser;
169
+ try {
170
+ browser = await chromium.launch();
171
+ } catch {
172
+ console.error("Chromium not found. Run: npx playwright install chromium");
173
+ unlinkSync(tmpHtml);
174
+ process.exit(1);
175
+ }
176
+ const page = await browser.newPage();
177
+ await page.goto(pathToFileURL(tmpHtml).href, { waitUntil: "networkidle" });
178
+ await page.evaluateHandle("document.fonts.ready");
179
+
180
+ const pdfName = buildPdfName(filename, outputFilename);
181
+ const pdfPath = resolve(outputDir, pdfName);
182
+
183
+ await page.pdf({
184
+ path: pdfPath,
185
+ format: "A4",
186
+ printBackground: true,
187
+ tagged: true,
188
+ });
189
+
190
+ await browser.close();
191
+ unlinkSync(tmpHtml);
192
+ console.log(`PDF saved to: ${pdfPath}`);
package/src/render.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Takes raw resume markdown and returns an HTML string.
3
+ * Uses Bun.markdown.html() for parsing, then post-processes
4
+ * headings (header with initials, section dividers).
5
+ * Everything else uses standard GFM HTML output, styled via CSS.
6
+ *
7
+ * @param {string} markdown
8
+ * @param {{ initials?: string, features?: { monogram?: boolean, sectionDividers?: boolean } }} options
9
+ */
10
+ export function renderResume(markdown, { initials, features = {} } = {}) {
11
+ let html = Bun.markdown.html(markdown);
12
+
13
+ // Transform h1 into header, optionally with monogram
14
+ let inHeader = true;
15
+ html = html.replace(/<h1>(.*?)<\/h1>/s, (_, content) => {
16
+ const monogram =
17
+ features.monogram !== false && initials
18
+ ? `<div class="initials"><span>${initials[0]}</span><span>${initials[1]}</span></div>`
19
+ : "";
20
+ return `<header>${monogram}<h1>${content}</h1>`;
21
+ });
22
+
23
+ // Transform h2 — section dividers or plain headings depending on features
24
+ html = html.replace(/<h2>(.*?)<\/h2>/g, (_, content) => {
25
+ const closeHeader = inHeader ? "</header>" : "";
26
+ inHeader = false;
27
+ if (features.sectionDividers === false) {
28
+ return `${closeHeader}<h2>${content}</h2>`;
29
+ }
30
+ return `${closeHeader}
31
+ <div class="section-divider">
32
+ <span>${content}</span>
33
+ <div class="divider-line"></div>
34
+ </div>`;
35
+ });
36
+
37
+ const closingTag = inHeader ? "</header>" : "";
38
+ return `<div class="resume-page">${html}${closingTag}</div>`;
39
+ }
package/src/utils.js ADDED
@@ -0,0 +1,75 @@
1
+ import { basename } from "node:path";
2
+
3
+ export function deriveInitials(name) {
4
+ return name
5
+ .split(/\s+/)
6
+ .map((w) => w[0])
7
+ .join("")
8
+ .toUpperCase();
9
+ }
10
+
11
+ export function parseFrontmatter(raw) {
12
+ const match = raw.match(/^---\n([\s\S]+?)\n---\n([\s\S]*)$/);
13
+ if (!match) return { meta: {}, markdown: raw };
14
+ return { meta: Bun.YAML.parse(match[1]), markdown: match[2] };
15
+ }
16
+
17
+ export const GOOGLE_FONTS_URL = {
18
+ "Source Sans 3":
19
+ "https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400&display=swap",
20
+ "IBM Plex Sans":
21
+ "https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400&display=swap",
22
+ "Fira Sans":
23
+ "https://fonts.googleapis.com/css2?family=Fira+Sans:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400&display=swap",
24
+ Lato: "https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,300;0,400;0,700;1,300;1,400&display=swap",
25
+ Inter: "https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400&display=swap",
26
+ "DM Sans":
27
+ "https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400&display=swap",
28
+ "IBM Plex Mono":
29
+ "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400&display=swap",
30
+ };
31
+
32
+ export function buildPdfName(filename, outputFilename) {
33
+ if (outputFilename) return outputFilename;
34
+ return `${basename(filename, ".md")}.pdf`;
35
+ }
36
+
37
+ export function buildHtml({ bodyHtml, fontFaceCSS, css, fontOverride, pdfTitle }) {
38
+ return `<!DOCTYPE html>
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="UTF-8">
42
+ <title>${pdfTitle}</title>
43
+ <style>${fontFaceCSS ?? ""}</style>
44
+ <style>${css}</style>
45
+ <style>${fontOverride}</style>
46
+ </head>
47
+ <body>
48
+ ${bodyHtml}
49
+ </body>
50
+ </html>`;
51
+ }
52
+
53
+
54
+ export function parseFontFaces(cssText, fontFamily) {
55
+ const faces = [];
56
+ const faceRegex = /@font-face\s*\{([^}]+)\}/g;
57
+
58
+ for (const match of cssText.matchAll(faceRegex)) {
59
+ const block = match[1];
60
+ const urlMatch = block.match(/url\(([^)]+)\)/);
61
+ const weightMatch = block.match(/font-weight:\s*(\d+)/);
62
+ const styleMatch = block.match(/font-style:\s*(\w+)/);
63
+
64
+ if (!urlMatch) continue;
65
+
66
+ faces.push({
67
+ fileUrl: urlMatch[1],
68
+ weight: weightMatch?.[1] || "400",
69
+ style: styleMatch?.[1] || "normal",
70
+ filename: `${fontFamily}-${weightMatch?.[1] || "400"}-${styleMatch?.[1] || "normal"}.ttf`,
71
+ });
72
+ }
73
+
74
+ return faces;
75
+ }
@@ -0,0 +1,168 @@
1
+ /* Page container */
2
+ .resume-page {
3
+ max-width: 210mm;
4
+ margin: 0 auto;
5
+ padding: 56px 96px;
6
+ font-size: 13px;
7
+ line-height: 1.55;
8
+ }
9
+
10
+ /* Header */
11
+ header {
12
+ position: relative;
13
+ margin-bottom: 20px;
14
+ }
15
+
16
+ .initials {
17
+ position: absolute;
18
+ left: -15mm;
19
+ top: 0;
20
+ width: 36px;
21
+ height: 36px;
22
+ background: var(--color-accent);
23
+ display: flex;
24
+ flex-direction: column;
25
+ align-items: center;
26
+ justify-content: center;
27
+ color: #000;
28
+ font-size: 10px;
29
+ font-weight: 700;
30
+ line-height: 1.2;
31
+ }
32
+
33
+ header h1 {
34
+ font-size: 26px;
35
+ font-weight: 700;
36
+ letter-spacing: -0.025em;
37
+ line-height: 1.2;
38
+ margin-bottom: 8px;
39
+ text-transform: uppercase;
40
+ }
41
+
42
+ /* Title (first p after h1) — markdown wraps in <strong>, unbold it */
43
+ header p:nth-of-type(1) {
44
+ font-size: 14px;
45
+ color: var(--color-body);
46
+ margin-bottom: 6px;
47
+ }
48
+
49
+ header p:nth-of-type(1) strong {
50
+ font-weight: normal;
51
+ }
52
+
53
+ /* Contact (second p) */
54
+ header p:nth-of-type(2) {
55
+ font-size: 11px;
56
+ color: var(--color-subtitle);
57
+ margin-bottom: 14px;
58
+ }
59
+
60
+ /* Summary (third p) */
61
+ header p:nth-of-type(3) {
62
+ font-size: 12.5px;
63
+ line-height: 1.4;
64
+ color: var(--color-body);
65
+ }
66
+
67
+ /* Section dividers */
68
+ .section-divider {
69
+ display: flex;
70
+ align-items: center;
71
+ gap: 12px;
72
+ margin-top: 28px;
73
+ margin-bottom: 20px;
74
+ }
75
+
76
+ .section-divider span {
77
+ font-family: var(--font-secondary, var(--font-primary));
78
+ font-size: 10px;
79
+ font-weight: 600;
80
+ letter-spacing: 0.1em;
81
+ text-transform: uppercase;
82
+ color: var(--color-section-label);
83
+ white-space: nowrap;
84
+ }
85
+
86
+ .divider-line {
87
+ flex: 1;
88
+ border-top: 1px solid var(--color-border);
89
+ }
90
+
91
+ /* Entry headings (h3 = company) */
92
+ h3 {
93
+ font-size: 14px;
94
+ font-weight: 700;
95
+ text-transform: uppercase;
96
+ letter-spacing: 0.05em;
97
+ margin-top: 32px;
98
+ }
99
+
100
+ /* First h3 after section divider needs no top margin */
101
+ .section-divider + h3 {
102
+ margin-top: 0;
103
+ }
104
+
105
+ /* Role & dates (p right after h3) — markdown wraps role in <strong>, unbold it */
106
+ h3 + p {
107
+ font-size: 12px;
108
+ color: var(--color-subtitle);
109
+ margin-top: 2px;
110
+ }
111
+
112
+ h3 + p strong {
113
+ font-weight: normal;
114
+ }
115
+
116
+ /* Bullet lists */
117
+ ul {
118
+ list-style: disc outside;
119
+ margin-left: 20px;
120
+ margin-top: 6px;
121
+ font-size: 12.5px;
122
+ line-height: 1.4;
123
+ }
124
+
125
+ li {
126
+ margin-bottom: 2px;
127
+ }
128
+
129
+ /* Technologies line (italic paragraphs) */
130
+ ul + p,
131
+ li em {
132
+ font-size: 12px;
133
+ font-weight: 300;
134
+ color: var(--color-subtitle);
135
+ }
136
+
137
+ ul + p {
138
+ margin-top: 6px;
139
+ }
140
+
141
+ /* Skills section — no bullets, tighter layout */
142
+ .section-divider:last-of-type + ul {
143
+ list-style: none;
144
+ margin-left: 0;
145
+ }
146
+
147
+ /* Entry grouping */
148
+ h3 ~ ul {
149
+ break-inside: avoid;
150
+ }
151
+
152
+ /* Print styles */
153
+ @media print {
154
+ @page {
155
+ size: A4;
156
+ margin: 20mm 0;
157
+ }
158
+
159
+ body {
160
+ print-color-adjust: exact;
161
+ -webkit-print-color-adjust: exact;
162
+ }
163
+
164
+ .resume-page {
165
+ max-width: none;
166
+ padding: 0 25mm;
167
+ }
168
+ }
@@ -0,0 +1,14 @@
1
+ export default {
2
+ fonts: { primary: "IBM Plex Sans", secondary: "IBM Plex Mono" },
3
+ colors: {
4
+ body: "#323336",
5
+ subtitle: "#707678",
6
+ sectionLabel: "#a6aaad",
7
+ accent: "#42f398",
8
+ border: "#e0e0e0",
9
+ },
10
+ features: {
11
+ monogram: true,
12
+ sectionDividers: true,
13
+ },
14
+ };
@@ -0,0 +1,168 @@
1
+ /* Page container */
2
+ .resume-page {
3
+ max-width: 210mm;
4
+ margin: 0 auto;
5
+ padding: 56px 96px;
6
+ font-size: 12px;
7
+ line-height: 1.5;
8
+ }
9
+
10
+ /* Header */
11
+ header {
12
+ position: relative;
13
+ margin-bottom: 20px;
14
+ }
15
+
16
+ .initials {
17
+ position: absolute;
18
+ left: -18mm;
19
+ top: 0;
20
+ width: 40px;
21
+ height: 40px;
22
+ background: transparent;
23
+ border: 1.5px solid var(--color-accent);
24
+ border-radius: 50%;
25
+ display: flex;
26
+ flex-direction: column;
27
+ align-items: center;
28
+ justify-content: center;
29
+ color: var(--color-accent);
30
+ font-size: 10px;
31
+ font-weight: 400;
32
+ line-height: 1.2;
33
+ }
34
+
35
+ header h1 {
36
+ font-size: 24px;
37
+ font-weight: 700;
38
+ letter-spacing: 0.15em;
39
+ line-height: 1.2;
40
+ margin-bottom: 8px;
41
+ text-transform: uppercase;
42
+ }
43
+
44
+ /* Title (first p after h1) — markdown wraps in <strong>, unbold it */
45
+ header p:nth-of-type(1) {
46
+ font-size: 13px;
47
+ color: var(--color-body);
48
+ margin-bottom: 6px;
49
+ }
50
+
51
+ header p:nth-of-type(1) strong {
52
+ font-weight: normal;
53
+ }
54
+
55
+ /* Contact (second p) */
56
+ header p:nth-of-type(2) {
57
+ font-size: 10.5px;
58
+ color: var(--color-subtitle);
59
+ margin-bottom: 14px;
60
+ }
61
+
62
+ /* Summary (third p) */
63
+ header p:nth-of-type(3) {
64
+ font-size: 11.5px;
65
+ line-height: 1.45;
66
+ color: var(--color-body);
67
+ }
68
+
69
+ /* Section dividers — label only, no line */
70
+ .section-divider {
71
+ display: flex;
72
+ align-items: center;
73
+ gap: 12px;
74
+ margin-top: 28px;
75
+ margin-bottom: 20px;
76
+ }
77
+
78
+ .section-divider span {
79
+ font-size: 10px;
80
+ font-weight: 400;
81
+ letter-spacing: 0.15em;
82
+ text-transform: uppercase;
83
+ color: var(--color-section-label);
84
+ white-space: nowrap;
85
+ }
86
+
87
+ .divider-line {
88
+ display: none;
89
+ }
90
+
91
+ /* Entry headings (h3 = company) */
92
+ h3 {
93
+ font-size: 12.5px;
94
+ font-weight: 700;
95
+ text-transform: uppercase;
96
+ letter-spacing: 0.05em;
97
+ margin-top: 26px;
98
+ break-after: avoid;
99
+ }
100
+
101
+ /* First h3 after section divider needs no top margin */
102
+ .section-divider + h3 {
103
+ margin-top: 0;
104
+ }
105
+
106
+ /* Role & dates (p right after h3) — markdown wraps role in <strong>, unbold it */
107
+ h3 + p {
108
+ font-size: 11.5px;
109
+ color: var(--color-subtitle);
110
+ margin-top: 2px;
111
+ break-after: avoid;
112
+ }
113
+
114
+ h3 + p strong {
115
+ font-weight: normal;
116
+ }
117
+
118
+ /* Bullet lists */
119
+ ul {
120
+ list-style: disc outside;
121
+ margin-left: 18px;
122
+ margin-top: 6px;
123
+ font-size: 11.5px;
124
+ line-height: 1.45;
125
+ }
126
+
127
+ li {
128
+ margin-bottom: 3px;
129
+ }
130
+
131
+ /* Technologies line (italic paragraphs) */
132
+ ul + p,
133
+ li em {
134
+ font-size: 11px;
135
+ font-weight: 300;
136
+ color: var(--color-subtitle);
137
+ }
138
+
139
+ ul + p {
140
+ margin-top: 6px;
141
+ }
142
+
143
+ /* Skills section — no bullets, tighter layout */
144
+ .section-divider:last-of-type + ul {
145
+ list-style: none;
146
+ margin-left: 0;
147
+ }
148
+
149
+ /* Print styles */
150
+ @media print {
151
+ @page {
152
+ size: A4;
153
+ margin-top: 20mm;
154
+ margin-bottom: 20mm;
155
+ margin-left: 0;
156
+ margin-right: 0;
157
+ }
158
+
159
+ body {
160
+ print-color-adjust: exact;
161
+ -webkit-print-color-adjust: exact;
162
+ }
163
+
164
+ .resume-page {
165
+ max-width: none;
166
+ padding: 0 25mm;
167
+ }
168
+ }
@@ -0,0 +1,14 @@
1
+ export default {
2
+ fonts: { primary: "IBM Plex Mono" },
3
+ colors: {
4
+ body: "#323336",
5
+ subtitle: "#636669",
6
+ sectionLabel: "#a6aaad",
7
+ accent: "rgb(79, 106, 246)",
8
+ border: "#e0e0e0",
9
+ },
10
+ features: {
11
+ monogram: true,
12
+ sectionDividers: true,
13
+ },
14
+ };