jsonresume-theme-academic 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 +21 -0
- package/README.md +98 -0
- package/dist/index.cjs +390 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +135 -0
- package/dist/index.js +390 -0
- package/dist/index.js.map +1 -0
- package/dist/index.umd.cjs +394 -0
- package/dist/index.umd.cjs.map +1 -0
- package/package.json +84 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ebenezer
|
|
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,98 @@
|
|
|
1
|
+
# jsonresume-theme-academic
|
|
2
|
+
|
|
3
|
+
> Academic serif theme for [JSON Resume](https://jsonresume.org) — EB Garamond, small-caps headings, gold accents. Print-first, PDF-ready.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/jsonresume-theme-academic)
|
|
6
|
+
[](https://github.com/ebenezer-isaac/jsonresume-theme-academic/actions/workflows/ci.yml)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- Academic serif typography (EB Garamond)
|
|
14
|
+
- A4 print-first layout with precise margins
|
|
15
|
+
- Built-in HTML sanitization (XSS-safe)
|
|
16
|
+
- Customizable section headings via `meta.headings`
|
|
17
|
+
- Zero runtime dependencies
|
|
18
|
+
- ESM + CJS + UMD builds with TypeScript declarations
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
### With resumed (recommended)
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install jsonresume-theme-academic
|
|
26
|
+
npx resumed render resume.json --theme jsonresume-theme-academic
|
|
27
|
+
# → resume.html ready
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### With resume-cli
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install -g resume-cli jsonresume-theme-academic
|
|
34
|
+
resume export resume.pdf --theme academic
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Programmatic Usage
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
import { render, pdfRenderOptions } from 'jsonresume-theme-academic';
|
|
41
|
+
|
|
42
|
+
const html = render(resumeJson);
|
|
43
|
+
// html is a self-contained HTML document
|
|
44
|
+
|
|
45
|
+
// For Puppeteer PDF rendering:
|
|
46
|
+
await page.pdf(pdfRenderOptions);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Customization
|
|
50
|
+
|
|
51
|
+
### Section Headings
|
|
52
|
+
|
|
53
|
+
Override default headings via `meta.headings`:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"meta": {
|
|
58
|
+
"headings": {
|
|
59
|
+
"experience": "Professional Experience",
|
|
60
|
+
"skills": "Technical Skills",
|
|
61
|
+
"education": "Academic Background"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Available keys: `skills`, `experience`, `projects`, `education`, `volunteer`, `certifications`, `additional`
|
|
68
|
+
|
|
69
|
+
### PDF Rendering Options
|
|
70
|
+
|
|
71
|
+
The exported `pdfRenderOptions` provides optimized settings for Puppeteer/Gotenberg:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
{
|
|
75
|
+
mediaType: 'print',
|
|
76
|
+
format: 'A4',
|
|
77
|
+
margin: { top: '12mm', right: '14mm', bottom: '12mm', left: '14mm' }
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
git clone https://github.com/ebenezer-isaac/jsonresume-theme-academic.git
|
|
85
|
+
cd jsonresume-theme-academic
|
|
86
|
+
npm install
|
|
87
|
+
npm run dev # Start dev server
|
|
88
|
+
npm test # Run tests
|
|
89
|
+
npm run build # Build ESM + CJS + UMD
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Contributing
|
|
93
|
+
|
|
94
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const MONTHS = [
|
|
4
|
+
"January",
|
|
5
|
+
"February",
|
|
6
|
+
"March",
|
|
7
|
+
"April",
|
|
8
|
+
"May",
|
|
9
|
+
"June",
|
|
10
|
+
"July",
|
|
11
|
+
"August",
|
|
12
|
+
"September",
|
|
13
|
+
"October",
|
|
14
|
+
"November",
|
|
15
|
+
"December"
|
|
16
|
+
];
|
|
17
|
+
const COUNTRY_NAMES = {
|
|
18
|
+
GB: "United Kingdom",
|
|
19
|
+
US: "United States",
|
|
20
|
+
CA: "Canada",
|
|
21
|
+
AU: "Australia",
|
|
22
|
+
DE: "Germany",
|
|
23
|
+
FR: "France",
|
|
24
|
+
IN: "India",
|
|
25
|
+
JP: "Japan",
|
|
26
|
+
CN: "China",
|
|
27
|
+
BR: "Brazil",
|
|
28
|
+
NL: "Netherlands",
|
|
29
|
+
IT: "Italy",
|
|
30
|
+
ES: "Spain",
|
|
31
|
+
SE: "Sweden",
|
|
32
|
+
CH: "Switzerland",
|
|
33
|
+
SG: "Singapore",
|
|
34
|
+
NZ: "New Zealand",
|
|
35
|
+
IE: "Ireland",
|
|
36
|
+
AE: "UAE"
|
|
37
|
+
};
|
|
38
|
+
const DEFAULT_HEADINGS = {
|
|
39
|
+
skills: "Core Skills",
|
|
40
|
+
experience: "Experience",
|
|
41
|
+
projects: "Selected Projects",
|
|
42
|
+
education: "Education",
|
|
43
|
+
volunteer: "Leadership & Volunteering",
|
|
44
|
+
certifications: "Credentials",
|
|
45
|
+
additional: "Additional"
|
|
46
|
+
};
|
|
47
|
+
function decodeEntities(text) {
|
|
48
|
+
return text.replace(/&#(\d+);/g, (_, c) => String.fromCharCode(Number(c))).replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCharCode(parseInt(h, 16))).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'").replace(/ /g, " ").replace(/—/g, "—").replace(/–/g, "–").replace(/…/g, "…");
|
|
49
|
+
}
|
|
50
|
+
function esc(text) {
|
|
51
|
+
if (text == null) return "";
|
|
52
|
+
return String(text).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
53
|
+
}
|
|
54
|
+
function formatDate(dateStr) {
|
|
55
|
+
if (!dateStr) return "";
|
|
56
|
+
const parts = String(dateStr).split("-");
|
|
57
|
+
if (parts.length === 1) return parts[0];
|
|
58
|
+
const year = parts[0];
|
|
59
|
+
const month = MONTHS[parseInt(parts[1], 10) - 1] || "";
|
|
60
|
+
return `${month} ${year}`;
|
|
61
|
+
}
|
|
62
|
+
function dateRange(start, end) {
|
|
63
|
+
const s = formatDate(start);
|
|
64
|
+
const e = end ? formatDate(end) : "";
|
|
65
|
+
if (!s) return "";
|
|
66
|
+
if (!e) return s;
|
|
67
|
+
return `${s} to ${e}`;
|
|
68
|
+
}
|
|
69
|
+
function regionName(code) {
|
|
70
|
+
if (!code) return "";
|
|
71
|
+
return COUNTRY_NAMES[code.toUpperCase()] || code;
|
|
72
|
+
}
|
|
73
|
+
function profileIcon(network) {
|
|
74
|
+
if (!network) return '<i class="fa-solid fa-link"></i>';
|
|
75
|
+
const n = network.toLowerCase();
|
|
76
|
+
if (n === "linkedin") return '<i class="fa-brands fa-linkedin-in"></i>';
|
|
77
|
+
if (n === "github") return '<i class="fa-brands fa-github"></i>';
|
|
78
|
+
if (n === "twitter" || n === "x") return '<i class="fa-brands fa-x-twitter"></i>';
|
|
79
|
+
if (n === "stackoverflow") return '<i class="fa-brands fa-stack-overflow"></i>';
|
|
80
|
+
return '<i class="fa-solid fa-link"></i>';
|
|
81
|
+
}
|
|
82
|
+
const BLOCK_TAGS = /* @__PURE__ */ new Set(["p", "ul", "ol", "li", "blockquote"]);
|
|
83
|
+
const INLINE_TAGS = /* @__PURE__ */ new Set([
|
|
84
|
+
"br",
|
|
85
|
+
"b",
|
|
86
|
+
"strong",
|
|
87
|
+
"i",
|
|
88
|
+
"em",
|
|
89
|
+
"u",
|
|
90
|
+
"s",
|
|
91
|
+
"mark",
|
|
92
|
+
"a",
|
|
93
|
+
"sub",
|
|
94
|
+
"sup",
|
|
95
|
+
"code"
|
|
96
|
+
]);
|
|
97
|
+
const ALLOWED_TAGS = /* @__PURE__ */ new Set([...BLOCK_TAGS, ...INLINE_TAGS]);
|
|
98
|
+
const SAFE_URL_SCHEMES = /^(?:https?|mailto|tel):/i;
|
|
99
|
+
const RELATIVE_URL = /^[#/?.]/;
|
|
100
|
+
function isSafeUrl(href) {
|
|
101
|
+
const trimmed = href.trim();
|
|
102
|
+
if (!trimmed) return false;
|
|
103
|
+
return SAFE_URL_SCHEMES.test(trimmed) || RELATIVE_URL.test(trimmed);
|
|
104
|
+
}
|
|
105
|
+
function sanitizeHtml(html, { inline = false } = {}) {
|
|
106
|
+
if (!html) return "";
|
|
107
|
+
const allowed = inline ? INLINE_TAGS : ALLOWED_TAGS;
|
|
108
|
+
return String(html).replace(/<a\s+[^>]*href="([^"]*)"[^>]*>/gi, (_, href) => {
|
|
109
|
+
if (!isSafeUrl(href)) return "";
|
|
110
|
+
return `<a href="${href.replace(/"/g, """)}" target="_blank" rel="noopener">`;
|
|
111
|
+
}).replace(
|
|
112
|
+
/<mark\s+[^>]*class="([^"]*)"[^>]*>/gi,
|
|
113
|
+
(_, cls) => `<mark class="${cls.replace(/"/g, """)}">`
|
|
114
|
+
).replace(/<(\/?)([a-z][a-z0-9]*)\s*[^>]*?(\/?)>/gi, (match, slash, tag, selfClose) => {
|
|
115
|
+
const lower = tag.toLowerCase();
|
|
116
|
+
if (lower === "a" && !slash && match.includes("href=")) return match;
|
|
117
|
+
if (lower === "mark" && !slash && match.includes("class=")) return match;
|
|
118
|
+
if (allowed.has(lower)) return `<${slash}${lower}${selfClose}>`;
|
|
119
|
+
if (inline && BLOCK_TAGS.has(lower)) return slash ? " " : "";
|
|
120
|
+
return "";
|
|
121
|
+
}).replace(/<p>\s*<\/p>/gi, "").replace(/\s{2,}/g, " ").trim();
|
|
122
|
+
}
|
|
123
|
+
function stripHtml(text) {
|
|
124
|
+
if (!text) return "";
|
|
125
|
+
return decodeEntities(
|
|
126
|
+
String(text).replace(/<br\s*\/?>/gi, " ").replace(/<\/?(p|div|li)[^>]*>/gi, " ").replace(/<[^>]+>/g, "")
|
|
127
|
+
).replace(/\s{2,}/g, " ").trim();
|
|
128
|
+
}
|
|
129
|
+
const isHtml = (text) => /<[a-z][\s\S]*>/i.test(text);
|
|
130
|
+
const hasEntities = (text) => /&(?:amp|lt|gt|quot|#\d+|#x[0-9a-f]+);/i.test(text);
|
|
131
|
+
function richText(text, { block = false } = {}) {
|
|
132
|
+
if (text == null) return "";
|
|
133
|
+
const s = String(text);
|
|
134
|
+
if (isHtml(s)) return sanitizeHtml(s, { inline: !block });
|
|
135
|
+
if (hasEntities(s)) return esc(decodeEntities(s));
|
|
136
|
+
return esc(s);
|
|
137
|
+
}
|
|
138
|
+
function has(arr) {
|
|
139
|
+
return Array.isArray(arr) && arr.length > 0;
|
|
140
|
+
}
|
|
141
|
+
function renderHeader(basics) {
|
|
142
|
+
if (!basics) return "";
|
|
143
|
+
const location = basics.location ? [basics.location.city, regionName(basics.location.region)].filter(Boolean).join(", ") : "";
|
|
144
|
+
const lines = [];
|
|
145
|
+
if (location)
|
|
146
|
+
lines.push({ icon: '<i class="fa-solid fa-location-dot"></i>', text: location });
|
|
147
|
+
if (basics.phone)
|
|
148
|
+
lines.push({ icon: '<i class="fa-solid fa-square-phone"></i>', text: basics.phone });
|
|
149
|
+
if (basics.email)
|
|
150
|
+
lines.push({ icon: '<i class="fa-solid fa-envelope"></i>', text: basics.email });
|
|
151
|
+
if (basics.url) {
|
|
152
|
+
const display = basics.url.replace(/^https?:\/\//, "");
|
|
153
|
+
lines.push({ icon: '<i class="fa-solid fa-globe"></i>', text: display });
|
|
154
|
+
}
|
|
155
|
+
if (has(basics.profiles)) {
|
|
156
|
+
for (const p of basics.profiles) {
|
|
157
|
+
lines.push({ icon: profileIcon(p.network), text: p.username || p.url || "" });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return `
|
|
161
|
+
<header class="header">
|
|
162
|
+
<div class="header-left">
|
|
163
|
+
<h1 class="name">${esc(basics.name)}</h1>
|
|
164
|
+
<div class="label">${esc(basics.label)}</div>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="contact-info">
|
|
167
|
+
${lines.map((l) => `<div class="contact-line">${esc(l.text)} ${l.icon}</div>`).join("\n ")}
|
|
168
|
+
</div>
|
|
169
|
+
</header>
|
|
170
|
+
<hr class="header-rule" />`;
|
|
171
|
+
}
|
|
172
|
+
function renderSummary(summary) {
|
|
173
|
+
if (!summary) return "";
|
|
174
|
+
const html = richText(summary, { block: true });
|
|
175
|
+
if (!html) return "";
|
|
176
|
+
return `<div class="summary">${html}</div>`;
|
|
177
|
+
}
|
|
178
|
+
function sectionTitle(title) {
|
|
179
|
+
return `<h2 class="section-title">${esc(title)}</h2>`;
|
|
180
|
+
}
|
|
181
|
+
function renderSkills(skills, heading) {
|
|
182
|
+
if (!has(skills)) return "";
|
|
183
|
+
return `
|
|
184
|
+
${sectionTitle(heading)}
|
|
185
|
+
<div class="section-body">
|
|
186
|
+
${skills.map(
|
|
187
|
+
(s) => `
|
|
188
|
+
<div class="bullet-item">
|
|
189
|
+
<span class="skill-name">${esc(s.name)}:</span> ${esc((s.keywords || []).join(", "))}
|
|
190
|
+
</div>`
|
|
191
|
+
).join("")}
|
|
192
|
+
</div>`;
|
|
193
|
+
}
|
|
194
|
+
function parseWorkMeta(summary) {
|
|
195
|
+
if (!summary) return { techStack: "", client: "" };
|
|
196
|
+
const text = stripHtml(summary).replace(/\n/g, " ").trim();
|
|
197
|
+
const clientMatch = text.match(/Client:\s*(.+)$/i);
|
|
198
|
+
const client = clientMatch ? clientMatch[1].trim() : "";
|
|
199
|
+
const techPart = clientMatch ? text.slice(0, clientMatch.index).trim() : text;
|
|
200
|
+
const techMatch = techPart.match(/Tech-stack:\s*(.+)/i);
|
|
201
|
+
const techStack = techMatch ? techMatch[1].trim() : "";
|
|
202
|
+
return { techStack, client };
|
|
203
|
+
}
|
|
204
|
+
function renderWorkEntry(entry) {
|
|
205
|
+
const { techStack, client } = parseWorkMeta(entry.summary);
|
|
206
|
+
const duration = dateRange(entry.startDate, entry.endDate);
|
|
207
|
+
return `
|
|
208
|
+
<div class="work-entry">
|
|
209
|
+
<div class="work-header">
|
|
210
|
+
<div class="work-title">${esc(entry.name)} - ${esc(entry.position)}</div>
|
|
211
|
+
<div class="work-duration">${duration ? `Duration: ${esc(duration)}` : ""}</div>
|
|
212
|
+
</div>
|
|
213
|
+
${techStack || client ? `
|
|
214
|
+
<div class="work-meta">
|
|
215
|
+
<div class="work-tech">${techStack ? `Tech-stack: ${esc(techStack)}` : ""}</div>
|
|
216
|
+
<div class="work-client">${client ? `Client: ${esc(client)}` : ""}</div>
|
|
217
|
+
</div>` : ""}
|
|
218
|
+
${has(entry.highlights) ? `
|
|
219
|
+
<ul class="work-highlights">
|
|
220
|
+
${entry.highlights.map((h) => `<li>${richText(h)}</li>`).join("\n ")}
|
|
221
|
+
</ul>` : ""}
|
|
222
|
+
</div>`;
|
|
223
|
+
}
|
|
224
|
+
function renderWork(work, heading) {
|
|
225
|
+
if (!has(work)) return "";
|
|
226
|
+
return `
|
|
227
|
+
${sectionTitle(heading)}
|
|
228
|
+
<div class="section-body">
|
|
229
|
+
${work.map(renderWorkEntry).join("")}
|
|
230
|
+
</div>`;
|
|
231
|
+
}
|
|
232
|
+
function renderProjects(projects, heading) {
|
|
233
|
+
if (!has(projects)) return "";
|
|
234
|
+
return `
|
|
235
|
+
${sectionTitle(heading)}
|
|
236
|
+
<div class="section-body">
|
|
237
|
+
${projects.map((p) => {
|
|
238
|
+
const desc = richText(p.description);
|
|
239
|
+
return `
|
|
240
|
+
<div class="bullet-item">
|
|
241
|
+
<span class="project-name">${esc(p.name)}:</span> ${desc}
|
|
242
|
+
</div>`;
|
|
243
|
+
}).join("")}
|
|
244
|
+
</div>`;
|
|
245
|
+
}
|
|
246
|
+
function renderEducationEntry(entry) {
|
|
247
|
+
const degree = [entry.studyType, entry.area].filter(Boolean).join(" ");
|
|
248
|
+
let yearDisplay = "";
|
|
249
|
+
if (entry.endDate) {
|
|
250
|
+
yearDisplay = formatDate(entry.endDate);
|
|
251
|
+
} else if (entry.startDate) {
|
|
252
|
+
const year = parseInt(String(entry.startDate).split("-")[0], 10);
|
|
253
|
+
const currentYear = (/* @__PURE__ */ new Date()).getFullYear();
|
|
254
|
+
yearDisplay = year >= currentYear ? `Expected ${formatDate(entry.startDate)}` : formatDate(entry.startDate);
|
|
255
|
+
}
|
|
256
|
+
const instParts = [entry.institution];
|
|
257
|
+
if (entry.score) instParts.push(entry.score);
|
|
258
|
+
const instLine = instParts.filter(Boolean).join(" | ");
|
|
259
|
+
return `
|
|
260
|
+
<div class="edu-entry">
|
|
261
|
+
<div class="edu-header">
|
|
262
|
+
<div class="edu-degree">${esc(degree)}</div>
|
|
263
|
+
<div class="edu-year">${esc(yearDisplay)}</div>
|
|
264
|
+
</div>
|
|
265
|
+
${entry.institution ? `<div class="edu-institution">${esc(instLine)}</div>` : ""}
|
|
266
|
+
${has(entry.courses) ? `
|
|
267
|
+
<div class="edu-courses">
|
|
268
|
+
${entry.courses.map((c) => `<div>${richText(c)}</div>`).join("")}
|
|
269
|
+
</div>` : ""}
|
|
270
|
+
</div>`;
|
|
271
|
+
}
|
|
272
|
+
function renderEducation(education, heading) {
|
|
273
|
+
if (!has(education)) return "";
|
|
274
|
+
return `
|
|
275
|
+
${sectionTitle(heading)}
|
|
276
|
+
<div class="section-body">
|
|
277
|
+
${education.map(renderEducationEntry).join("")}
|
|
278
|
+
</div>`;
|
|
279
|
+
}
|
|
280
|
+
function renderVolunteer(volunteer, heading) {
|
|
281
|
+
if (!has(volunteer)) return "";
|
|
282
|
+
return `
|
|
283
|
+
${sectionTitle(heading)}
|
|
284
|
+
<div class="section-body">
|
|
285
|
+
${volunteer.map((v) => {
|
|
286
|
+
const years = v.endDate ? `${v.startDate}–${v.endDate}` : v.startDate || "";
|
|
287
|
+
const title = v.position ? `${v.position} – ${v.organization}` : v.organization;
|
|
288
|
+
const summary = richText(v.summary);
|
|
289
|
+
return `
|
|
290
|
+
<div class="bullet-item volunteer-item">
|
|
291
|
+
<span class="vol-title">${esc(title)}${years ? ` (${esc(years)})` : ""}:</span> ${summary}
|
|
292
|
+
</div>`;
|
|
293
|
+
}).join("")}
|
|
294
|
+
</div>`;
|
|
295
|
+
}
|
|
296
|
+
function renderCertificates(certificates, heading) {
|
|
297
|
+
if (!has(certificates)) return "";
|
|
298
|
+
return `
|
|
299
|
+
${sectionTitle(heading)}
|
|
300
|
+
<div class="section-body">
|
|
301
|
+
${certificates.map(
|
|
302
|
+
(c) => `
|
|
303
|
+
<div class="bullet-item">${richText(c.name)}</div>`
|
|
304
|
+
).join("")}
|
|
305
|
+
</div>`;
|
|
306
|
+
}
|
|
307
|
+
function renderAdditional(resume, heading) {
|
|
308
|
+
const parts = [];
|
|
309
|
+
if (has(resume.interests)) {
|
|
310
|
+
for (const interest of resume.interests) {
|
|
311
|
+
const keywords = has(interest.keywords) ? interest.keywords.join(", ") : "";
|
|
312
|
+
const label = interest.name || "Interests";
|
|
313
|
+
if (keywords) {
|
|
314
|
+
parts.push(
|
|
315
|
+
`<div class="additional-line"><span class="additional-label">${esc(label)}:</span> ${esc(keywords)}</div>`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (has(resume.languages)) {
|
|
321
|
+
const langStr = resume.languages.map((l) => `${l.language}${l.fluency ? ` (${l.fluency})` : ""}`).join(", ");
|
|
322
|
+
parts.push(
|
|
323
|
+
`<div class="additional-line"><span class="additional-label">Languages:</span> ${esc(langStr)}</div>`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
if (has(resume.references)) {
|
|
327
|
+
parts.push(
|
|
328
|
+
`<div class="additional-line"><span class="additional-label">Referees:</span> Available on request</div>`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
if (parts.length === 0) return "";
|
|
332
|
+
return `
|
|
333
|
+
${sectionTitle(heading)}
|
|
334
|
+
<div class="section-body">
|
|
335
|
+
${parts.join("\n ")}
|
|
336
|
+
</div>`;
|
|
337
|
+
}
|
|
338
|
+
const css = "@page {\n size: A4;\n margin: 12mm 14mm;\n}\n\n* {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n}\n\nhtml, body {\n font-family: 'EB Garamond', Georgia, 'Palatino Linotype', 'Times New Roman', serif;\n font-size: 10pt;\n line-height: 1.32;\n color: #000;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n}\n\n/* Browser-only padding (overridden by @page margin in print/PDF) */\nbody {\n padding: 12mm 14mm;\n}\n@media print {\n body { padding: 0; }\n}\n\n/* ─── Header ─── */\n\n.header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n gap: 16px;\n}\n\n.header-left {\n flex: 1;\n min-width: 0;\n}\n\n.name {\n font-size: 32pt;\n font-weight: 400;\n line-height: 1.05;\n color: #000;\n letter-spacing: 0.3px;\n}\n\n.label {\n font-style: italic;\n font-size: 10.5pt;\n margin-top: 4px;\n color: #222;\n line-height: 1.3;\n}\n\n.contact-info {\n text-align: right;\n font-size: 8.5pt;\n line-height: 1.65;\n flex-shrink: 0;\n padding-top: 4px;\n}\n\n.contact-line {\n white-space: nowrap;\n}\n\n.contact-line i {\n display: inline-block;\n width: 11px;\n text-align: center;\n margin-left: 5px;\n font-size: 7.5pt;\n}\n\n.header-rule {\n border: none;\n border-top: 0.5px solid #666;\n margin: 6px 0 3px;\n}\n\n/* ─── Summary ─── */\n\n.summary {\n font-style: italic;\n font-size: 9pt;\n line-height: 1.38;\n margin-bottom: 2px;\n text-align: justify;\n}\n\n/* ─── Section Headings ─── */\n\n.section-title {\n font-variant: small-caps;\n font-size: 12.5pt;\n font-weight: 400;\n letter-spacing: 1.5px;\n border-bottom: 1.5px solid #C4962C;\n padding-bottom: 1px;\n margin-top: 8px;\n margin-bottom: 5px;\n color: #000;\n page-break-after: avoid;\n}\n\n/* ─── Bullet Items (shared) ─── */\n\n.bullet-item {\n position: relative;\n padding-left: 14px;\n margin-bottom: 2px;\n}\n\n.bullet-item::before {\n content: '\\25CB';\n position: absolute;\n left: 0;\n font-size: 7pt;\n top: 2.5px;\n}\n\n/* ─── Skills ─── */\n\n.skill-name {\n font-weight: 700;\n}\n\n/* ─── Work Experience ─── */\n\n.work-entry {\n margin-bottom: 7px;\n page-break-inside: avoid;\n}\n\n.work-header {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n gap: 8px;\n}\n\n.work-title {\n font-weight: 700;\n font-size: 10pt;\n}\n\n.work-duration {\n font-size: 9.5pt;\n white-space: nowrap;\n flex-shrink: 0;\n}\n\n.work-meta {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n gap: 8px;\n}\n\n.work-tech {\n font-style: italic;\n font-size: 9.5pt;\n}\n\n.work-client {\n font-style: italic;\n font-size: 9.5pt;\n white-space: nowrap;\n flex-shrink: 0;\n}\n\n.work-highlights {\n list-style: none;\n padding-left: 0;\n margin-top: 3px;\n}\n\n.work-highlights li {\n position: relative;\n padding-left: 14px;\n margin-bottom: 3px;\n text-align: justify;\n}\n\n.work-highlights li::before {\n content: '\\25CB';\n position: absolute;\n left: 0;\n font-size: 7pt;\n top: 2.5px;\n}\n\n/* ─── Education ─── */\n\n.edu-entry {\n margin-bottom: 5px;\n page-break-inside: avoid;\n}\n\n.edu-header {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n gap: 8px;\n}\n\n.edu-degree {\n font-weight: 700;\n font-size: 10pt;\n}\n\n.edu-year {\n font-size: 10pt;\n font-weight: 700;\n white-space: nowrap;\n flex-shrink: 0;\n}\n\n.edu-institution {\n font-size: 9.5pt;\n}\n\n.edu-courses {\n font-size: 9.5pt;\n}\n\n/* ─── Projects ─── */\n\n.project-name {\n font-weight: 700;\n}\n\n/* ─── Volunteer ─── */\n\n.volunteer-item {\n text-align: justify;\n margin-bottom: 4px;\n}\n\n.vol-title {\n font-weight: 700;\n}\n\n/* ─── Additional ─── */\n\n.additional-line {\n margin-bottom: 1px;\n}\n\n.additional-label {\n font-weight: 700;\n font-style: italic;\n}\n\n/* ─── Print ─── */\n\n@media print {\n .work-entry { page-break-inside: avoid; }\n .edu-entry { page-break-inside: avoid; }\n .section-title { page-break-after: avoid; }\n}\n";
|
|
339
|
+
function render(resume) {
|
|
340
|
+
var _a;
|
|
341
|
+
const h = { ...DEFAULT_HEADINGS };
|
|
342
|
+
const metaHeadings = (_a = resume == null ? void 0 : resume.meta) == null ? void 0 : _a.headings;
|
|
343
|
+
if (metaHeadings) {
|
|
344
|
+
for (const [key, value] of Object.entries(metaHeadings)) {
|
|
345
|
+
if (value && key in h) {
|
|
346
|
+
h[key] = value;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const basics = (resume == null ? void 0 : resume.basics) || {};
|
|
351
|
+
return `<!DOCTYPE html>
|
|
352
|
+
<html lang="en">
|
|
353
|
+
<head>
|
|
354
|
+
<meta charset="UTF-8" />
|
|
355
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
356
|
+
<title>${esc(basics.name || "Resume")}</title>
|
|
357
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
358
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
359
|
+
<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&display=swap" rel="stylesheet" />
|
|
360
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
|
|
361
|
+
<style>${css}</style>
|
|
362
|
+
</head>
|
|
363
|
+
<body>
|
|
364
|
+
<div class="resume">
|
|
365
|
+
${renderHeader(basics)}
|
|
366
|
+
${renderSummary(basics.summary)}
|
|
367
|
+
${renderSkills(resume.skills, h.skills)}
|
|
368
|
+
${renderWork(resume.work, h.experience)}
|
|
369
|
+
${renderProjects(resume.projects, h.projects)}
|
|
370
|
+
${renderEducation(resume.education, h.education)}
|
|
371
|
+
${renderVolunteer(resume.volunteer, h.volunteer)}
|
|
372
|
+
${renderCertificates(resume.certificates, h.certifications)}
|
|
373
|
+
${renderAdditional(resume, h.additional)}
|
|
374
|
+
</div>
|
|
375
|
+
</body>
|
|
376
|
+
</html>`;
|
|
377
|
+
}
|
|
378
|
+
const pdfRenderOptions = {
|
|
379
|
+
mediaType: "print",
|
|
380
|
+
format: "A4",
|
|
381
|
+
margin: {
|
|
382
|
+
top: "12mm",
|
|
383
|
+
right: "14mm",
|
|
384
|
+
bottom: "12mm",
|
|
385
|
+
left: "14mm"
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
exports.pdfRenderOptions = pdfRenderOptions;
|
|
389
|
+
exports.render = render;
|
|
390
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","sources":["../src/constants.ts","../src/utils/escape.ts","../src/utils/dates.ts","../src/utils/icons.ts","../src/utils/sanitize.ts","../src/utils/text.ts","../src/sections/header.ts","../src/sections/summary.ts","../src/sections/shared.ts","../src/sections/skills.ts","../src/sections/work.ts","../src/sections/projects.ts","../src/sections/education.ts","../src/sections/volunteer.ts","../src/sections/certificates.ts","../src/sections/additional.ts","../src/render.ts","../src/index.ts"],"sourcesContent":["export const MONTHS: readonly string[] = [\n 'January',\n 'February',\n 'March',\n 'April',\n 'May',\n 'June',\n 'July',\n 'August',\n 'September',\n 'October',\n 'November',\n 'December',\n];\n\nexport const COUNTRY_NAMES: Readonly<Record<string, string>> = {\n GB: 'United Kingdom',\n US: 'United States',\n CA: 'Canada',\n AU: 'Australia',\n DE: 'Germany',\n FR: 'France',\n IN: 'India',\n JP: 'Japan',\n CN: 'China',\n BR: 'Brazil',\n NL: 'Netherlands',\n IT: 'Italy',\n ES: 'Spain',\n SE: 'Sweden',\n CH: 'Switzerland',\n SG: 'Singapore',\n NZ: 'New Zealand',\n IE: 'Ireland',\n AE: 'UAE',\n};\n\nexport const DEFAULT_HEADINGS: Readonly<Record<string, string>> = {\n skills: 'Core Skills',\n experience: 'Experience',\n projects: 'Selected Projects',\n education: 'Education',\n volunteer: 'Leadership & Volunteering',\n certifications: 'Credentials',\n additional: 'Additional',\n};\n","/** Decode common HTML entities to literal characters. */\nexport function decodeEntities(text: string): string {\n return text\n .replace(/&#(\\d+);/g, (_, c) => String.fromCharCode(Number(c)))\n .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCharCode(parseInt(h, 16)))\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n .replace(/'/g, \"'\")\n .replace(/ /g, ' ')\n .replace(/—/g, '\\u2014')\n .replace(/–/g, '\\u2013')\n .replace(/…/g, '\\u2026');\n}\n\n/** HTML-escape for safe output (plain text fields like names, dates). */\nexport function esc(text: string | null | undefined): string {\n if (text == null) return '';\n return String(text)\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n","import { MONTHS, COUNTRY_NAMES } from '../constants.js';\n\nexport function formatDate(dateStr: string | null | undefined): string {\n if (!dateStr) return '';\n const parts = String(dateStr).split('-');\n if (parts.length === 1) return parts[0];\n const year = parts[0];\n const month = MONTHS[parseInt(parts[1], 10) - 1] || '';\n return `${month} ${year}`;\n}\n\nexport function dateRange(start: string | null | undefined, end: string | null | undefined): string {\n const s = formatDate(start);\n const e = end ? formatDate(end) : '';\n if (!s) return '';\n if (!e) return s;\n return `${s} to ${e}`;\n}\n\nexport function regionName(code: string | null | undefined): string {\n if (!code) return '';\n return COUNTRY_NAMES[code.toUpperCase()] || code;\n}\n","export function profileIcon(network: string | null | undefined): string {\n if (!network) return '<i class=\"fa-solid fa-link\"></i>';\n const n = network.toLowerCase();\n if (n === 'linkedin') return '<i class=\"fa-brands fa-linkedin-in\"></i>';\n if (n === 'github') return '<i class=\"fa-brands fa-github\"></i>';\n if (n === 'twitter' || n === 'x') return '<i class=\"fa-brands fa-x-twitter\"></i>';\n if (n === 'stackoverflow') return '<i class=\"fa-brands fa-stack-overflow\"></i>';\n return '<i class=\"fa-solid fa-link\"></i>';\n}\n","/** Tags allowed through sanitization — safe inline/block formatting. */\nconst BLOCK_TAGS = new Set(['p', 'ul', 'ol', 'li', 'blockquote']);\nconst INLINE_TAGS = new Set([\n 'br', 'b', 'strong', 'i', 'em', 'u', 's', 'mark', 'a', 'sub', 'sup', 'code',\n]);\nconst ALLOWED_TAGS = new Set([...BLOCK_TAGS, ...INLINE_TAGS]);\n\n/** URL schemes safe for <a href>. Everything else (javascript:, data:, vbscript:) is stripped. */\nconst SAFE_URL_SCHEMES = /^(?:https?|mailto|tel):/i;\nconst RELATIVE_URL = /^[#/?.]/;\n\nfunction isSafeUrl(href: string): boolean {\n const trimmed = href.trim();\n if (!trimmed) return false;\n return SAFE_URL_SCHEMES.test(trimmed) || RELATIVE_URL.test(trimmed);\n}\n\ninterface SanitizeOptions {\n readonly inline?: boolean;\n}\n\n/**\n * Sanitize HTML — keep allowed formatting tags, strip everything else.\n * Attributes are stripped from all tags except <a href> (safe schemes only) and <mark class>.\n * If inline=true, block tags (p, ul, ol, li, blockquote) are also stripped.\n */\nexport function sanitizeHtml(html: string | null | undefined, { inline = false }: SanitizeOptions = {}): string {\n if (!html) return '';\n const allowed = inline ? INLINE_TAGS : ALLOWED_TAGS;\n\n return String(html)\n .replace(/<a\\s+[^>]*href=\"([^\"]*)\"[^>]*>/gi, (_, href: string) => {\n if (!isSafeUrl(href)) return '';\n return `<a href=\"${href.replace(/\"/g, '"')}\" target=\"_blank\" rel=\"noopener\">`;\n })\n .replace(/<mark\\s+[^>]*class=\"([^\"]*)\"[^>]*>/gi, (_, cls: string) =>\n `<mark class=\"${cls.replace(/\"/g, '"')}\">`,\n )\n .replace(/<(\\/?)([a-z][a-z0-9]*)\\s*[^>]*?(\\/?)>/gi, (match, slash: string, tag: string, selfClose: string) => {\n const lower = tag.toLowerCase();\n if (lower === 'a' && !slash && match.includes('href=')) return match;\n if (lower === 'mark' && !slash && match.includes('class=')) return match;\n if (allowed.has(lower)) return `<${slash}${lower}${selfClose}>`;\n if (inline && BLOCK_TAGS.has(lower)) return slash ? ' ' : '';\n return '';\n })\n .replace(/<p>\\s*<\\/p>/gi, '')\n .replace(/\\s{2,}/g, ' ')\n .trim();\n}\n","import { decodeEntities, esc } from './escape.js';\nimport { sanitizeHtml } from './sanitize.js';\n\n/** Strip HTML tags → plain text (for fields parsed as data, not displayed). */\nexport function stripHtml(text: string | null | undefined): string {\n if (!text) return '';\n return decodeEntities(\n String(text)\n .replace(/<br\\s*\\/?>/gi, ' ')\n .replace(/<\\/?(p|div|li)[^>]*>/gi, ' ')\n .replace(/<[^>]+>/g, ''),\n )\n .replace(/\\s{2,}/g, ' ')\n .trim();\n}\n\nconst isHtml = (text: string): boolean => /<[a-z][\\s\\S]*>/i.test(text);\nconst hasEntities = (text: string): boolean => /&(?:amp|lt|gt|quot|#\\d+|#x[0-9a-f]+);/i.test(text);\n\n/**\n * Render user-provided rich text → safe HTML output.\n * block=true: preserve <p>, <ul>, <ol> etc.\n * block=false (default): inline mode — strip block tags, keep only inline formatting.\n */\nexport function richText(text: string | null | undefined, { block = false }: { block?: boolean } = {}): string {\n if (text == null) return '';\n const s = String(text);\n if (isHtml(s)) return sanitizeHtml(s, { inline: !block });\n if (hasEntities(s)) return esc(decodeEntities(s));\n return esc(s);\n}\n\n/** Check if an array is non-empty. */\nexport function has<T>(arr: readonly T[] | null | undefined): arr is readonly T[] & { length: number } {\n return Array.isArray(arr) && arr.length > 0;\n}\n","import type { ResumeBasics } from '../types/resume.js';\nimport { esc } from '../utils/escape.js';\nimport { regionName } from '../utils/dates.js';\nimport { profileIcon } from '../utils/icons.js';\nimport { has } from '../utils/text.js';\n\ninterface ContactLine {\n readonly icon: string;\n readonly text: string;\n}\n\nexport function renderHeader(basics: ResumeBasics | undefined): string {\n if (!basics) return '';\n\n const location = basics.location\n ? [basics.location.city, regionName(basics.location.region)].filter(Boolean).join(', ')\n : '';\n\n const lines: ContactLine[] = [];\n if (location)\n lines.push({ icon: '<i class=\"fa-solid fa-location-dot\"></i>', text: location });\n if (basics.phone)\n lines.push({ icon: '<i class=\"fa-solid fa-square-phone\"></i>', text: basics.phone });\n if (basics.email)\n lines.push({ icon: '<i class=\"fa-solid fa-envelope\"></i>', text: basics.email });\n if (basics.url) {\n const display = basics.url.replace(/^https?:\\/\\//, '');\n lines.push({ icon: '<i class=\"fa-solid fa-globe\"></i>', text: display });\n }\n if (has(basics.profiles)) {\n for (const p of basics.profiles) {\n lines.push({ icon: profileIcon(p.network), text: p.username || p.url || '' });\n }\n }\n\n return `\n <header class=\"header\">\n <div class=\"header-left\">\n <h1 class=\"name\">${esc(basics.name)}</h1>\n <div class=\"label\">${esc(basics.label)}</div>\n </div>\n <div class=\"contact-info\">\n ${lines.map((l) => `<div class=\"contact-line\">${esc(l.text)} ${l.icon}</div>`).join('\\n ')}\n </div>\n </header>\n <hr class=\"header-rule\" />`;\n}\n","import { richText } from '../utils/text.js';\n\nexport function renderSummary(summary: string | undefined): string {\n if (!summary) return '';\n const html = richText(summary, { block: true });\n if (!html) return '';\n return `<div class=\"summary\">${html}</div>`;\n}\n","import { esc } from '../utils/escape.js';\n\nexport function sectionTitle(title: string): string {\n return `<h2 class=\"section-title\">${esc(title)}</h2>`;\n}\n","import type { ResumeSkill } from '../types/resume.js';\nimport { esc } from '../utils/escape.js';\nimport { has } from '../utils/text.js';\nimport { sectionTitle } from './shared.js';\n\nexport function renderSkills(skills: readonly ResumeSkill[] | undefined, heading: string): string {\n if (!has(skills)) return '';\n return `\n ${sectionTitle(heading)}\n <div class=\"section-body\">\n ${skills\n .map(\n (s) => `\n <div class=\"bullet-item\">\n <span class=\"skill-name\">${esc(s.name)}:</span> ${esc((s.keywords || []).join(', '))}\n </div>`,\n )\n .join('')}\n </div>`;\n}\n","import type { ResumeWorkEntry } from '../types/resume.js';\nimport { esc } from '../utils/escape.js';\nimport { dateRange } from '../utils/dates.js';\nimport { has, richText, stripHtml } from '../utils/text.js';\nimport { sectionTitle } from './shared.js';\n\n/** Extract tech-stack and client from a work summary string. */\nfunction parseWorkMeta(summary: string | undefined): { techStack: string; client: string } {\n if (!summary) return { techStack: '', client: '' };\n const text = stripHtml(summary).replace(/\\n/g, ' ').trim();\n\n const clientMatch = text.match(/Client:\\s*(.+)$/i);\n const client = clientMatch ? clientMatch[1].trim() : '';\n\n const techPart = clientMatch ? text.slice(0, clientMatch.index).trim() : text;\n const techMatch = techPart.match(/Tech-stack:\\s*(.+)/i);\n const techStack = techMatch ? techMatch[1].trim() : '';\n\n return { techStack, client };\n}\n\nfunction renderWorkEntry(entry: ResumeWorkEntry): string {\n const { techStack, client } = parseWorkMeta(entry.summary);\n const duration = dateRange(entry.startDate, entry.endDate);\n\n return `\n <div class=\"work-entry\">\n <div class=\"work-header\">\n <div class=\"work-title\">${esc(entry.name)} - ${esc(entry.position)}</div>\n <div class=\"work-duration\">${duration ? `Duration: ${esc(duration)}` : ''}</div>\n </div>\n ${\n techStack || client\n ? `\n <div class=\"work-meta\">\n <div class=\"work-tech\">${techStack ? `Tech-stack: ${esc(techStack)}` : ''}</div>\n <div class=\"work-client\">${client ? `Client: ${esc(client)}` : ''}</div>\n </div>`\n : ''\n }\n ${\n has(entry.highlights)\n ? `\n <ul class=\"work-highlights\">\n ${entry.highlights.map((h) => `<li>${richText(h)}</li>`).join('\\n ')}\n </ul>`\n : ''\n }\n </div>`;\n}\n\nexport function renderWork(work: readonly ResumeWorkEntry[] | undefined, heading: string): string {\n if (!has(work)) return '';\n return `\n ${sectionTitle(heading)}\n <div class=\"section-body\">\n ${work.map(renderWorkEntry).join('')}\n </div>`;\n}\n","import type { ResumeProject } from '../types/resume.js';\nimport { esc } from '../utils/escape.js';\nimport { has, richText } from '../utils/text.js';\nimport { sectionTitle } from './shared.js';\n\nexport function renderProjects(\n projects: readonly ResumeProject[] | undefined,\n heading: string,\n): string {\n if (!has(projects)) return '';\n return `\n ${sectionTitle(heading)}\n <div class=\"section-body\">\n ${projects\n .map((p) => {\n const desc = richText(p.description);\n return `\n <div class=\"bullet-item\">\n <span class=\"project-name\">${esc(p.name)}:</span> ${desc}\n </div>`;\n })\n .join('')}\n </div>`;\n}\n","import type { ResumeEducationEntry } from '../types/resume.js';\nimport { esc } from '../utils/escape.js';\nimport { formatDate } from '../utils/dates.js';\nimport { has, richText } from '../utils/text.js';\nimport { sectionTitle } from './shared.js';\n\nfunction renderEducationEntry(entry: ResumeEducationEntry): string {\n const degree = [entry.studyType, entry.area].filter(Boolean).join(' ');\n\n let yearDisplay = '';\n if (entry.endDate) {\n yearDisplay = formatDate(entry.endDate);\n } else if (entry.startDate) {\n const year = parseInt(String(entry.startDate).split('-')[0], 10);\n const currentYear = new Date().getFullYear();\n yearDisplay = year >= currentYear ? `Expected ${formatDate(entry.startDate)}` : formatDate(entry.startDate);\n }\n\n const instParts = [entry.institution];\n if (entry.score) instParts.push(entry.score);\n const instLine = instParts.filter(Boolean).join(' | ');\n\n return `\n <div class=\"edu-entry\">\n <div class=\"edu-header\">\n <div class=\"edu-degree\">${esc(degree)}</div>\n <div class=\"edu-year\">${esc(yearDisplay)}</div>\n </div>\n ${entry.institution ? `<div class=\"edu-institution\">${esc(instLine)}</div>` : ''}\n ${\n has(entry.courses)\n ? `\n <div class=\"edu-courses\">\n ${entry.courses.map((c) => `<div>${richText(c)}</div>`).join('')}\n </div>`\n : ''\n }\n </div>`;\n}\n\nexport function renderEducation(\n education: readonly ResumeEducationEntry[] | undefined,\n heading: string,\n): string {\n if (!has(education)) return '';\n return `\n ${sectionTitle(heading)}\n <div class=\"section-body\">\n ${education.map(renderEducationEntry).join('')}\n </div>`;\n}\n","import type { ResumeVolunteerEntry } from '../types/resume.js';\nimport { esc } from '../utils/escape.js';\nimport { has, richText } from '../utils/text.js';\nimport { sectionTitle } from './shared.js';\n\nexport function renderVolunteer(\n volunteer: readonly ResumeVolunteerEntry[] | undefined,\n heading: string,\n): string {\n if (!has(volunteer)) return '';\n return `\n ${sectionTitle(heading)}\n <div class=\"section-body\">\n ${volunteer\n .map((v) => {\n const years = v.endDate\n ? `${v.startDate}\\u2013${v.endDate}`\n : v.startDate || '';\n const title = v.position\n ? `${v.position} \\u2013 ${v.organization}`\n : v.organization;\n const summary = richText(v.summary);\n\n return `\n <div class=\"bullet-item volunteer-item\">\n <span class=\"vol-title\">${esc(title)}${years ? ` (${esc(years)})` : ''}:</span> ${summary}\n </div>`;\n })\n .join('')}\n </div>`;\n}\n","import type { ResumeCertificate } from '../types/resume.js';\nimport { has, richText } from '../utils/text.js';\nimport { sectionTitle } from './shared.js';\n\nexport function renderCertificates(\n certificates: readonly ResumeCertificate[] | undefined,\n heading: string,\n): string {\n if (!has(certificates)) return '';\n return `\n ${sectionTitle(heading)}\n <div class=\"section-body\">\n ${certificates\n .map(\n (c) => `\n <div class=\"bullet-item\">${richText(c.name)}</div>`,\n )\n .join('')}\n </div>`;\n}\n","import type { ResumeSchema } from '../types/resume.js';\nimport { esc } from '../utils/escape.js';\nimport { has } from '../utils/text.js';\nimport { sectionTitle } from './shared.js';\n\nexport function renderAdditional(resume: ResumeSchema, heading: string): string {\n const parts: string[] = [];\n\n if (has(resume.interests)) {\n for (const interest of resume.interests) {\n const keywords = has(interest.keywords) ? interest.keywords.join(', ') : '';\n const label = interest.name || 'Interests';\n if (keywords) {\n parts.push(\n `<div class=\"additional-line\"><span class=\"additional-label\">${esc(label)}:</span> ${esc(keywords)}</div>`,\n );\n }\n }\n }\n\n if (has(resume.languages)) {\n const langStr = resume.languages\n .map((l) => `${l.language}${l.fluency ? ` (${l.fluency})` : ''}`)\n .join(', ');\n parts.push(\n `<div class=\"additional-line\"><span class=\"additional-label\">Languages:</span> ${esc(langStr)}</div>`,\n );\n }\n\n if (has(resume.references)) {\n parts.push(\n `<div class=\"additional-line\"><span class=\"additional-label\">Referees:</span> Available on request</div>`,\n );\n }\n\n if (parts.length === 0) return '';\n\n return `\n ${sectionTitle(heading)}\n <div class=\"section-body\">\n ${parts.join('\\n ')}\n </div>`;\n}\n","import type { ResumeSchema } from './types/resume.js';\nimport { DEFAULT_HEADINGS } from './constants.js';\nimport { esc } from './utils/escape.js';\nimport { renderHeader } from './sections/header.js';\nimport { renderSummary } from './sections/summary.js';\nimport { renderSkills } from './sections/skills.js';\nimport { renderWork } from './sections/work.js';\nimport { renderProjects } from './sections/projects.js';\nimport { renderEducation } from './sections/education.js';\nimport { renderVolunteer } from './sections/volunteer.js';\nimport { renderCertificates } from './sections/certificates.js';\nimport { renderAdditional } from './sections/additional.js';\nimport css from './styles/academic.css?inline';\n\nexport function render(resume: ResumeSchema): string {\n const h = { ...DEFAULT_HEADINGS };\n\n const metaHeadings = resume?.meta?.headings;\n if (metaHeadings) {\n for (const [key, value] of Object.entries(metaHeadings)) {\n if (value && key in h) {\n h[key] = value;\n }\n }\n }\n\n const basics = resume?.basics || {};\n\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>${esc(basics.name || 'Resume')}</title>\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n <link href=\"https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&display=swap\" rel=\"stylesheet\" />\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css\" />\n <style>${css}</style>\n</head>\n<body>\n <div class=\"resume\">\n ${renderHeader(basics)}\n ${renderSummary(basics.summary)}\n ${renderSkills(resume.skills, h.skills)}\n ${renderWork(resume.work, h.experience)}\n ${renderProjects(resume.projects, h.projects)}\n ${renderEducation(resume.education, h.education)}\n ${renderVolunteer(resume.volunteer, h.volunteer)}\n ${renderCertificates(resume.certificates, h.certifications)}\n ${renderAdditional(resume, h.additional)}\n </div>\n</body>\n</html>`;\n}\n","export { render } from './render.js';\nexport type { ResumeSchema } from './types/resume.js';\n\n/** PDF rendering options for Puppeteer/Gotenberg. */\nexport const pdfRenderOptions = {\n mediaType: 'print' as const,\n format: 'A4' as const,\n margin: {\n top: '12mm',\n right: '14mm',\n bottom: '12mm',\n left: '14mm',\n },\n};\n"],"names":[],"mappings":"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"}
|