jsonresume-theme-data-driven 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/package.json +36 -0
- package/src/index.jsx +36 -0
- package/src/ui/Resume.jsx +562 -0
- package/tests/theme.test.js +178 -0
- package/vitest.config.js +11 -0
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Data-Driven JSON Resume Theme
|
|
2
|
+
|
|
3
|
+
An analytical and metrics-focused theme for JSON Resume, designed to highlight quantifiable achievements and data-driven results.
|
|
4
|
+
|
|
5
|
+
## Design Principles
|
|
6
|
+
|
|
7
|
+
**Vibe**: Analytical and factual
|
|
8
|
+
**Layout**: Single column with strong headings and bullet hierarchy
|
|
9
|
+
**Typography**: Geometric sans-serif; compact
|
|
10
|
+
**Accent Color**: Sky blue (#0ea5e9)
|
|
11
|
+
**Focus**: Metrics and measurable outcomes
|
|
12
|
+
|
|
13
|
+
## Key Features
|
|
14
|
+
|
|
15
|
+
- **Metrics Emphasis**: Automatically bolds numbers, percentages, and dollar amounts in highlights and descriptions
|
|
16
|
+
- **Strong Visual Hierarchy**: Uppercase section titles, clear heading structure
|
|
17
|
+
- **Compact Layout**: Space-efficient design maximizing content density
|
|
18
|
+
- **Geometric Typography**: Clean, modern sans-serif fonts (DM Sans, Inter)
|
|
19
|
+
- **Bullet Hierarchy**: Clear visual structure drawing attention to key results
|
|
20
|
+
- **Data-First Approach**: Every section feels quantified and analytical
|
|
21
|
+
|
|
22
|
+
## Perfect For
|
|
23
|
+
|
|
24
|
+
- Data analysts and scientists
|
|
25
|
+
- Product managers tracking metrics
|
|
26
|
+
- Engineers highlighting performance improvements
|
|
27
|
+
- Anyone with quantifiable achievements
|
|
28
|
+
- Roles requiring analytical thinking
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```javascript
|
|
33
|
+
import { render } from 'jsonresume-theme-data-driven';
|
|
34
|
+
|
|
35
|
+
const html = render(resumeData);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Styling
|
|
39
|
+
|
|
40
|
+
The theme uses sky blue (#0ea5e9) as the primary accent color, creating a professional, analytical appearance. Numbers and metrics are automatically emphasized with bold formatting to draw the reader's eye to measurable outcomes.
|
|
41
|
+
|
|
42
|
+
## Built With
|
|
43
|
+
|
|
44
|
+
This theme is built using [@resume/core](../../core) primitives, ensuring consistency with the JSON Resume ecosystem.
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jsonresume-theme-data-driven",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Data-driven analytical theme with sky blue accent - built with @resume/core",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.jsx",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.jsx"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"jsonresume",
|
|
12
|
+
"theme",
|
|
13
|
+
"resume",
|
|
14
|
+
"cv",
|
|
15
|
+
"data-driven",
|
|
16
|
+
"analytical",
|
|
17
|
+
"metrics",
|
|
18
|
+
"compact"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"react": "^19.2.0",
|
|
22
|
+
"react-dom": "^19.2.0",
|
|
23
|
+
"styled-components": "^6.1.19",
|
|
24
|
+
"@resume/core": "0.1.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"vitest": "^1.6.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"styled-components": "^6"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"test:watch": "vitest"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/index.jsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Resume Data-Driven Theme
|
|
3
|
+
* Analytical design with metrics-focused layout and sky blue accent
|
|
4
|
+
* Built with @resume/core primitives and React SSR
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { renderToString } from 'react-dom/server';
|
|
8
|
+
import { ServerStyleSheet } from 'styled-components';
|
|
9
|
+
import Resume from './ui/Resume.jsx';
|
|
10
|
+
|
|
11
|
+
export const render = (resume) => {
|
|
12
|
+
const sheet = new ServerStyleSheet();
|
|
13
|
+
try {
|
|
14
|
+
const html = renderToString(
|
|
15
|
+
sheet.collectStyles(<Resume resume={resume} />)
|
|
16
|
+
);
|
|
17
|
+
const styles = sheet.getStyleTags();
|
|
18
|
+
return `<!DOCTYPE html>
|
|
19
|
+
<html lang="en">
|
|
20
|
+
<head>
|
|
21
|
+
<meta charset="UTF-8">
|
|
22
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
23
|
+
<title>${resume.basics?.name || 'Resume'}</title>
|
|
24
|
+
${styles}
|
|
25
|
+
</head>
|
|
26
|
+
<body>
|
|
27
|
+
${html}
|
|
28
|
+
</body>
|
|
29
|
+
</html>`;
|
|
30
|
+
} finally {
|
|
31
|
+
sheet.seal();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export { Resume };
|
|
36
|
+
export default { render };
|
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import styled, { createGlobalStyle } from 'styled-components';
|
|
2
|
+
import { formatDateRange } from '@resume/core';
|
|
3
|
+
|
|
4
|
+
const GlobalStyle = createGlobalStyle`
|
|
5
|
+
* {
|
|
6
|
+
margin: 0;
|
|
7
|
+
padding: 0;
|
|
8
|
+
box-sizing: border-box;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
:root {
|
|
12
|
+
--resume-color-primary: #0f172a;
|
|
13
|
+
--resume-color-secondary: #475569;
|
|
14
|
+
--resume-color-accent: #0ea5e9;
|
|
15
|
+
--resume-color-accent-dark: #0284c7;
|
|
16
|
+
--resume-color-border: #e2e8f0;
|
|
17
|
+
--resume-color-bg: #ffffff;
|
|
18
|
+
--resume-font-geometric: 'DM Sans', 'Inter', 'Helvetica Neue', Arial, sans-serif;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
body {
|
|
22
|
+
font-family: var(--resume-font-geometric);
|
|
23
|
+
font-size: 11px;
|
|
24
|
+
line-height: 1.5;
|
|
25
|
+
color: var(--resume-color-primary);
|
|
26
|
+
background: var(--resume-color-bg);
|
|
27
|
+
padding: 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@media print {
|
|
31
|
+
body {
|
|
32
|
+
padding: 0;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
const Container = styled.div`
|
|
38
|
+
max-width: 750px;
|
|
39
|
+
margin: 0 auto;
|
|
40
|
+
padding: 32px 40px;
|
|
41
|
+
|
|
42
|
+
@media print {
|
|
43
|
+
padding: 16px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@media (max-width: 600px) {
|
|
47
|
+
padding: 20px;
|
|
48
|
+
}
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
const Header = styled.header`
|
|
52
|
+
margin-bottom: 28px;
|
|
53
|
+
padding-bottom: 16px;
|
|
54
|
+
border-bottom: 2px solid var(--resume-color-accent);
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
const Name = styled.h1`
|
|
58
|
+
font-size: 28px;
|
|
59
|
+
font-weight: 700;
|
|
60
|
+
color: var(--resume-color-primary);
|
|
61
|
+
margin-bottom: 4px;
|
|
62
|
+
letter-spacing: -0.03em;
|
|
63
|
+
text-transform: uppercase;
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
const Label = styled.div`
|
|
67
|
+
font-size: 13px;
|
|
68
|
+
color: var(--resume-color-accent);
|
|
69
|
+
font-weight: 600;
|
|
70
|
+
margin-bottom: 10px;
|
|
71
|
+
letter-spacing: 0.02em;
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
const Contact = styled.div`
|
|
75
|
+
display: flex;
|
|
76
|
+
flex-wrap: wrap;
|
|
77
|
+
gap: 12px;
|
|
78
|
+
font-size: 10px;
|
|
79
|
+
color: var(--resume-color-secondary);
|
|
80
|
+
font-weight: 500;
|
|
81
|
+
|
|
82
|
+
a {
|
|
83
|
+
color: var(--resume-color-accent);
|
|
84
|
+
text-decoration: none;
|
|
85
|
+
}
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
const Summary = styled.p`
|
|
89
|
+
margin-top: 12px;
|
|
90
|
+
font-size: 11px;
|
|
91
|
+
line-height: 1.6;
|
|
92
|
+
color: var(--resume-color-secondary);
|
|
93
|
+
`;
|
|
94
|
+
|
|
95
|
+
const Section = styled.section`
|
|
96
|
+
margin-bottom: 24px;
|
|
97
|
+
|
|
98
|
+
@media print {
|
|
99
|
+
page-break-inside: avoid;
|
|
100
|
+
}
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
const SectionTitle = styled.h2`
|
|
104
|
+
font-size: 14px;
|
|
105
|
+
font-weight: 700;
|
|
106
|
+
color: var(--resume-color-primary);
|
|
107
|
+
margin-bottom: 12px;
|
|
108
|
+
text-transform: uppercase;
|
|
109
|
+
letter-spacing: 0.05em;
|
|
110
|
+
border-bottom: 1px solid var(--resume-color-border);
|
|
111
|
+
padding-bottom: 4px;
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
const Item = styled.div`
|
|
115
|
+
margin-bottom: 16px;
|
|
116
|
+
padding-left: 0;
|
|
117
|
+
|
|
118
|
+
@media print {
|
|
119
|
+
page-break-inside: avoid;
|
|
120
|
+
}
|
|
121
|
+
`;
|
|
122
|
+
|
|
123
|
+
const ItemHeader = styled.div`
|
|
124
|
+
display: flex;
|
|
125
|
+
justify-content: space-between;
|
|
126
|
+
align-items: baseline;
|
|
127
|
+
margin-bottom: 3px;
|
|
128
|
+
|
|
129
|
+
@media (max-width: 600px) {
|
|
130
|
+
flex-direction: column;
|
|
131
|
+
align-items: flex-start;
|
|
132
|
+
}
|
|
133
|
+
`;
|
|
134
|
+
|
|
135
|
+
const ItemTitle = styled.div`
|
|
136
|
+
font-size: 12px;
|
|
137
|
+
font-weight: 700;
|
|
138
|
+
color: var(--resume-color-primary);
|
|
139
|
+
`;
|
|
140
|
+
|
|
141
|
+
const ItemSubtitle = styled.div`
|
|
142
|
+
font-size: 11px;
|
|
143
|
+
color: var(--resume-color-secondary);
|
|
144
|
+
margin-bottom: 2px;
|
|
145
|
+
font-weight: 600;
|
|
146
|
+
`;
|
|
147
|
+
|
|
148
|
+
const ItemMeta = styled.div`
|
|
149
|
+
display: flex;
|
|
150
|
+
gap: 12px;
|
|
151
|
+
font-size: 10px;
|
|
152
|
+
color: var(--resume-color-secondary);
|
|
153
|
+
margin-bottom: 6px;
|
|
154
|
+
font-weight: 500;
|
|
155
|
+
`;
|
|
156
|
+
|
|
157
|
+
const Description = styled.p`
|
|
158
|
+
margin-bottom: 6px;
|
|
159
|
+
color: var(--resume-color-secondary);
|
|
160
|
+
line-height: 1.5;
|
|
161
|
+
font-size: 10px;
|
|
162
|
+
`;
|
|
163
|
+
|
|
164
|
+
const Highlights = styled.ul`
|
|
165
|
+
list-style: none;
|
|
166
|
+
margin-top: 4px;
|
|
167
|
+
|
|
168
|
+
li {
|
|
169
|
+
position: relative;
|
|
170
|
+
padding-left: 12px;
|
|
171
|
+
margin-bottom: 3px;
|
|
172
|
+
color: var(--resume-color-secondary);
|
|
173
|
+
font-size: 10px;
|
|
174
|
+
line-height: 1.5;
|
|
175
|
+
|
|
176
|
+
&::before {
|
|
177
|
+
content: '▪';
|
|
178
|
+
position: absolute;
|
|
179
|
+
left: 0;
|
|
180
|
+
color: var(--resume-color-accent);
|
|
181
|
+
font-weight: 700;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
strong,
|
|
185
|
+
b {
|
|
186
|
+
color: var(--resume-color-primary);
|
|
187
|
+
font-weight: 700;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
`;
|
|
191
|
+
|
|
192
|
+
const SkillGroup = styled.div`
|
|
193
|
+
margin-bottom: 10px;
|
|
194
|
+
|
|
195
|
+
strong {
|
|
196
|
+
display: block;
|
|
197
|
+
margin-bottom: 6px;
|
|
198
|
+
color: var(--resume-color-primary);
|
|
199
|
+
font-size: 11px;
|
|
200
|
+
font-weight: 700;
|
|
201
|
+
text-transform: uppercase;
|
|
202
|
+
letter-spacing: 0.02em;
|
|
203
|
+
}
|
|
204
|
+
`;
|
|
205
|
+
|
|
206
|
+
const BadgeList = styled.div`
|
|
207
|
+
display: flex;
|
|
208
|
+
flex-wrap: wrap;
|
|
209
|
+
gap: 6px;
|
|
210
|
+
margin-top: 8px;
|
|
211
|
+
`;
|
|
212
|
+
|
|
213
|
+
const Badge = styled.span`
|
|
214
|
+
display: inline-block;
|
|
215
|
+
padding: 3px 10px;
|
|
216
|
+
border-radius: 3px;
|
|
217
|
+
font-size: 9px;
|
|
218
|
+
font-weight: 600;
|
|
219
|
+
background: transparent;
|
|
220
|
+
color: var(--resume-color-accent);
|
|
221
|
+
border: 1px solid var(--resume-color-accent);
|
|
222
|
+
text-transform: uppercase;
|
|
223
|
+
letter-spacing: 0.03em;
|
|
224
|
+
`;
|
|
225
|
+
|
|
226
|
+
// Bold numbers and percentages for data-driven emphasis
|
|
227
|
+
function boldNumbers(text) {
|
|
228
|
+
if (!text) return text;
|
|
229
|
+
// Match: $1.5M, 42%, 1,200+, 99.9%, etc.
|
|
230
|
+
// Unified regex to avoid nested <strong> tags
|
|
231
|
+
return text.replace(
|
|
232
|
+
/(\$)?(\d+(?:,\d{3})*(?:\.\d+)?)([KkMmBb])?([+\-])?(%)?/g,
|
|
233
|
+
(match, dollar, number, suffix, plusminus, percent) => {
|
|
234
|
+
// Only bold if it has special markers (dollar, suffix, plusminus, percent, comma, or decimal)
|
|
235
|
+
const hasMarker =
|
|
236
|
+
dollar ||
|
|
237
|
+
suffix ||
|
|
238
|
+
plusminus ||
|
|
239
|
+
percent ||
|
|
240
|
+
number.includes(',') ||
|
|
241
|
+
number.includes('.');
|
|
242
|
+
if (!hasMarker) return match;
|
|
243
|
+
|
|
244
|
+
const parts = [dollar, number, suffix, plusminus, percent]
|
|
245
|
+
.filter(Boolean)
|
|
246
|
+
.join('');
|
|
247
|
+
return `<strong>${parts}</strong>`;
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function Resume({ resume }) {
|
|
253
|
+
const {
|
|
254
|
+
basics = {},
|
|
255
|
+
work = [],
|
|
256
|
+
education = [],
|
|
257
|
+
skills = [],
|
|
258
|
+
projects = [],
|
|
259
|
+
volunteer = [],
|
|
260
|
+
awards = [],
|
|
261
|
+
} = resume;
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<>
|
|
265
|
+
<GlobalStyle />
|
|
266
|
+
<Container>
|
|
267
|
+
{/* Hero */}
|
|
268
|
+
<Header>
|
|
269
|
+
{basics.name && <Name>{basics.name}</Name>}
|
|
270
|
+
{basics.label && <Label>{basics.label}</Label>}
|
|
271
|
+
{(basics.email ||
|
|
272
|
+
basics.phone ||
|
|
273
|
+
basics.url ||
|
|
274
|
+
basics.location ||
|
|
275
|
+
basics.profiles?.length > 0) && (
|
|
276
|
+
<Contact>
|
|
277
|
+
{basics.email && (
|
|
278
|
+
<a href={`mailto:${basics.email}`}>{basics.email}</a>
|
|
279
|
+
)}
|
|
280
|
+
{basics.phone && <span>{basics.phone}</span>}
|
|
281
|
+
{basics.url && (
|
|
282
|
+
<a href={basics.url} target="_blank" rel="noopener noreferrer">
|
|
283
|
+
{basics.url.replace(/^https?:\/\//, '')}
|
|
284
|
+
</a>
|
|
285
|
+
)}
|
|
286
|
+
{basics.location && (
|
|
287
|
+
<span>
|
|
288
|
+
{[basics.location.city, basics.location.region]
|
|
289
|
+
.filter(Boolean)
|
|
290
|
+
.join(', ')}
|
|
291
|
+
</span>
|
|
292
|
+
)}
|
|
293
|
+
{basics.profiles?.map((profile, i) => (
|
|
294
|
+
<a
|
|
295
|
+
key={i}
|
|
296
|
+
href={profile.url}
|
|
297
|
+
target="_blank"
|
|
298
|
+
rel="noopener noreferrer"
|
|
299
|
+
>
|
|
300
|
+
{profile.network}
|
|
301
|
+
</a>
|
|
302
|
+
))}
|
|
303
|
+
</Contact>
|
|
304
|
+
)}
|
|
305
|
+
{basics.summary && (
|
|
306
|
+
<Summary
|
|
307
|
+
dangerouslySetInnerHTML={{ __html: boldNumbers(basics.summary) }}
|
|
308
|
+
/>
|
|
309
|
+
)}
|
|
310
|
+
</Header>
|
|
311
|
+
|
|
312
|
+
{/* Work Experience */}
|
|
313
|
+
{work.length > 0 && (
|
|
314
|
+
<Section>
|
|
315
|
+
<SectionTitle>Professional Experience</SectionTitle>
|
|
316
|
+
{work.map((job, i) => (
|
|
317
|
+
<Item key={i}>
|
|
318
|
+
<ItemHeader>
|
|
319
|
+
<ItemTitle>{job.position}</ItemTitle>
|
|
320
|
+
</ItemHeader>
|
|
321
|
+
{job.name && <ItemSubtitle>{job.name}</ItemSubtitle>}
|
|
322
|
+
{(job.startDate || job.location) && (
|
|
323
|
+
<ItemMeta>
|
|
324
|
+
{job.startDate && (
|
|
325
|
+
<span>
|
|
326
|
+
{formatDateRange({
|
|
327
|
+
startDate: job.startDate,
|
|
328
|
+
endDate: job.endDate,
|
|
329
|
+
})}
|
|
330
|
+
</span>
|
|
331
|
+
)}
|
|
332
|
+
{job.location && <span>{job.location}</span>}
|
|
333
|
+
</ItemMeta>
|
|
334
|
+
)}
|
|
335
|
+
{job.summary && (
|
|
336
|
+
<Description
|
|
337
|
+
dangerouslySetInnerHTML={{
|
|
338
|
+
__html: boldNumbers(job.summary),
|
|
339
|
+
}}
|
|
340
|
+
/>
|
|
341
|
+
)}
|
|
342
|
+
{job.highlights?.length > 0 && (
|
|
343
|
+
<Highlights>
|
|
344
|
+
{job.highlights.map((highlight, j) => (
|
|
345
|
+
<li
|
|
346
|
+
key={j}
|
|
347
|
+
dangerouslySetInnerHTML={{
|
|
348
|
+
__html: boldNumbers(highlight),
|
|
349
|
+
}}
|
|
350
|
+
/>
|
|
351
|
+
))}
|
|
352
|
+
</Highlights>
|
|
353
|
+
)}
|
|
354
|
+
</Item>
|
|
355
|
+
))}
|
|
356
|
+
</Section>
|
|
357
|
+
)}
|
|
358
|
+
|
|
359
|
+
{/* Education */}
|
|
360
|
+
{education.length > 0 && (
|
|
361
|
+
<Section>
|
|
362
|
+
<SectionTitle>Education</SectionTitle>
|
|
363
|
+
{education.map((edu, i) => {
|
|
364
|
+
const title = [edu.studyType, edu.area]
|
|
365
|
+
.filter(Boolean)
|
|
366
|
+
.join(' in ');
|
|
367
|
+
const highlights = [];
|
|
368
|
+
if (edu.score)
|
|
369
|
+
highlights.push(`GPA: <strong>${edu.score}</strong>`);
|
|
370
|
+
if (edu.courses?.length > 0)
|
|
371
|
+
highlights.push(`Courses: ${edu.courses.join(', ')}`);
|
|
372
|
+
|
|
373
|
+
return (
|
|
374
|
+
<Item key={i}>
|
|
375
|
+
<ItemHeader>
|
|
376
|
+
<ItemTitle>{title || edu.institution}</ItemTitle>
|
|
377
|
+
</ItemHeader>
|
|
378
|
+
{title && <ItemSubtitle>{edu.institution}</ItemSubtitle>}
|
|
379
|
+
{edu.startDate && (
|
|
380
|
+
<ItemMeta>
|
|
381
|
+
<span>
|
|
382
|
+
{formatDateRange({
|
|
383
|
+
startDate: edu.startDate,
|
|
384
|
+
endDate: edu.endDate,
|
|
385
|
+
})}
|
|
386
|
+
</span>
|
|
387
|
+
</ItemMeta>
|
|
388
|
+
)}
|
|
389
|
+
{highlights.length > 0 && (
|
|
390
|
+
<Highlights>
|
|
391
|
+
{highlights.map((highlight, j) => (
|
|
392
|
+
<li
|
|
393
|
+
key={j}
|
|
394
|
+
dangerouslySetInnerHTML={{ __html: highlight }}
|
|
395
|
+
/>
|
|
396
|
+
))}
|
|
397
|
+
</Highlights>
|
|
398
|
+
)}
|
|
399
|
+
</Item>
|
|
400
|
+
);
|
|
401
|
+
})}
|
|
402
|
+
</Section>
|
|
403
|
+
)}
|
|
404
|
+
|
|
405
|
+
{/* Skills */}
|
|
406
|
+
{skills.length > 0 && (
|
|
407
|
+
<Section>
|
|
408
|
+
<SectionTitle>Technical Skills</SectionTitle>
|
|
409
|
+
{skills.map((skillGroup, i) => (
|
|
410
|
+
<SkillGroup key={i}>
|
|
411
|
+
{skillGroup.name && <strong>{skillGroup.name}</strong>}
|
|
412
|
+
{skillGroup.keywords?.length > 0 && (
|
|
413
|
+
<BadgeList>
|
|
414
|
+
{skillGroup.keywords.map((keyword, j) => (
|
|
415
|
+
<Badge key={j}>{keyword}</Badge>
|
|
416
|
+
))}
|
|
417
|
+
</BadgeList>
|
|
418
|
+
)}
|
|
419
|
+
</SkillGroup>
|
|
420
|
+
))}
|
|
421
|
+
</Section>
|
|
422
|
+
)}
|
|
423
|
+
|
|
424
|
+
{/* Projects */}
|
|
425
|
+
{projects.length > 0 && (
|
|
426
|
+
<Section>
|
|
427
|
+
<SectionTitle>Key Projects</SectionTitle>
|
|
428
|
+
{projects.map((project, i) => (
|
|
429
|
+
<Item key={i}>
|
|
430
|
+
<ItemHeader>
|
|
431
|
+
<ItemTitle>{project.name}</ItemTitle>
|
|
432
|
+
</ItemHeader>
|
|
433
|
+
{project.startDate && (
|
|
434
|
+
<ItemMeta>
|
|
435
|
+
<span>
|
|
436
|
+
{formatDateRange({
|
|
437
|
+
startDate: project.startDate,
|
|
438
|
+
endDate: project.endDate,
|
|
439
|
+
})}
|
|
440
|
+
</span>
|
|
441
|
+
</ItemMeta>
|
|
442
|
+
)}
|
|
443
|
+
{project.description && (
|
|
444
|
+
<Description
|
|
445
|
+
dangerouslySetInnerHTML={{
|
|
446
|
+
__html: boldNumbers(project.description),
|
|
447
|
+
}}
|
|
448
|
+
/>
|
|
449
|
+
)}
|
|
450
|
+
{project.url && (
|
|
451
|
+
<Description>
|
|
452
|
+
<a
|
|
453
|
+
href={project.url}
|
|
454
|
+
target="_blank"
|
|
455
|
+
rel="noopener noreferrer"
|
|
456
|
+
>
|
|
457
|
+
{project.url}
|
|
458
|
+
</a>
|
|
459
|
+
</Description>
|
|
460
|
+
)}
|
|
461
|
+
{project.keywords?.length > 0 && (
|
|
462
|
+
<BadgeList>
|
|
463
|
+
{project.keywords.map((keyword, j) => (
|
|
464
|
+
<Badge key={j}>{keyword}</Badge>
|
|
465
|
+
))}
|
|
466
|
+
</BadgeList>
|
|
467
|
+
)}
|
|
468
|
+
{project.highlights?.length > 0 && (
|
|
469
|
+
<Highlights>
|
|
470
|
+
{project.highlights.map((highlight, j) => (
|
|
471
|
+
<li
|
|
472
|
+
key={j}
|
|
473
|
+
dangerouslySetInnerHTML={{
|
|
474
|
+
__html: boldNumbers(highlight),
|
|
475
|
+
}}
|
|
476
|
+
/>
|
|
477
|
+
))}
|
|
478
|
+
</Highlights>
|
|
479
|
+
)}
|
|
480
|
+
</Item>
|
|
481
|
+
))}
|
|
482
|
+
</Section>
|
|
483
|
+
)}
|
|
484
|
+
|
|
485
|
+
{/* Volunteer */}
|
|
486
|
+
{volunteer.length > 0 && (
|
|
487
|
+
<Section>
|
|
488
|
+
<SectionTitle>Volunteer Work</SectionTitle>
|
|
489
|
+
{volunteer.map((vol, i) => (
|
|
490
|
+
<Item key={i}>
|
|
491
|
+
<ItemHeader>
|
|
492
|
+
<ItemTitle>{vol.position}</ItemTitle>
|
|
493
|
+
</ItemHeader>
|
|
494
|
+
{vol.organization && (
|
|
495
|
+
<ItemSubtitle>{vol.organization}</ItemSubtitle>
|
|
496
|
+
)}
|
|
497
|
+
{vol.startDate && (
|
|
498
|
+
<ItemMeta>
|
|
499
|
+
<span>
|
|
500
|
+
{formatDateRange({
|
|
501
|
+
startDate: vol.startDate,
|
|
502
|
+
endDate: vol.endDate,
|
|
503
|
+
})}
|
|
504
|
+
</span>
|
|
505
|
+
</ItemMeta>
|
|
506
|
+
)}
|
|
507
|
+
{vol.summary && (
|
|
508
|
+
<Description
|
|
509
|
+
dangerouslySetInnerHTML={{
|
|
510
|
+
__html: boldNumbers(vol.summary),
|
|
511
|
+
}}
|
|
512
|
+
/>
|
|
513
|
+
)}
|
|
514
|
+
{vol.highlights?.length > 0 && (
|
|
515
|
+
<Highlights>
|
|
516
|
+
{vol.highlights.map((highlight, j) => (
|
|
517
|
+
<li
|
|
518
|
+
key={j}
|
|
519
|
+
dangerouslySetInnerHTML={{
|
|
520
|
+
__html: boldNumbers(highlight),
|
|
521
|
+
}}
|
|
522
|
+
/>
|
|
523
|
+
))}
|
|
524
|
+
</Highlights>
|
|
525
|
+
)}
|
|
526
|
+
</Item>
|
|
527
|
+
))}
|
|
528
|
+
</Section>
|
|
529
|
+
)}
|
|
530
|
+
|
|
531
|
+
{/* Awards */}
|
|
532
|
+
{awards.length > 0 && (
|
|
533
|
+
<Section>
|
|
534
|
+
<SectionTitle>Awards & Recognition</SectionTitle>
|
|
535
|
+
{awards.map((award, i) => (
|
|
536
|
+
<Item key={i}>
|
|
537
|
+
<ItemHeader>
|
|
538
|
+
<ItemTitle>{award.title}</ItemTitle>
|
|
539
|
+
</ItemHeader>
|
|
540
|
+
{award.awarder && <ItemSubtitle>{award.awarder}</ItemSubtitle>}
|
|
541
|
+
{award.date && (
|
|
542
|
+
<ItemMeta>
|
|
543
|
+
<span>{award.date}</span>
|
|
544
|
+
</ItemMeta>
|
|
545
|
+
)}
|
|
546
|
+
{award.summary && (
|
|
547
|
+
<Description
|
|
548
|
+
dangerouslySetInnerHTML={{
|
|
549
|
+
__html: boldNumbers(award.summary),
|
|
550
|
+
}}
|
|
551
|
+
/>
|
|
552
|
+
)}
|
|
553
|
+
</Item>
|
|
554
|
+
))}
|
|
555
|
+
</Section>
|
|
556
|
+
)}
|
|
557
|
+
</Container>
|
|
558
|
+
</>
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export default Resume;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render } from '../src/index.jsx';
|
|
3
|
+
|
|
4
|
+
const completeResume = {
|
|
5
|
+
basics: {
|
|
6
|
+
name: 'Sarah Martinez',
|
|
7
|
+
label: 'Data Analytics Manager',
|
|
8
|
+
email: 'sarah.martinez@example.com',
|
|
9
|
+
phone: '+1 (555) 987-6543',
|
|
10
|
+
url: 'https://sarahmartinez.io',
|
|
11
|
+
summary:
|
|
12
|
+
'Analytics leader with 10+ years driving data-driven decision making. Delivered $5M in cost savings through optimization initiatives.',
|
|
13
|
+
location: {
|
|
14
|
+
city: 'Austin',
|
|
15
|
+
region: 'TX',
|
|
16
|
+
},
|
|
17
|
+
profiles: [
|
|
18
|
+
{ network: 'LinkedIn', url: 'https://linkedin.com/in/sarahmartinez' },
|
|
19
|
+
{ network: 'GitHub', url: 'https://github.com/smartinez' },
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
work: [
|
|
23
|
+
{
|
|
24
|
+
name: 'DataCorp Analytics',
|
|
25
|
+
position: 'Senior Analytics Manager',
|
|
26
|
+
location: 'Austin, TX',
|
|
27
|
+
startDate: '2020-06-01',
|
|
28
|
+
endDate: null,
|
|
29
|
+
summary: 'Leading team of 8 analysts across 3 business units',
|
|
30
|
+
highlights: [
|
|
31
|
+
'Increased revenue forecasting accuracy by 42% through ML model implementation',
|
|
32
|
+
'Reduced operational costs by $2.3M annually via process optimization',
|
|
33
|
+
'Built real-time dashboard serving 500+ daily users with 99.9% uptime',
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
education: [
|
|
38
|
+
{
|
|
39
|
+
institution: 'University of Texas at Austin',
|
|
40
|
+
area: 'Statistics',
|
|
41
|
+
studyType: 'Master of Science',
|
|
42
|
+
startDate: '2014-09-01',
|
|
43
|
+
endDate: '2016-05-31',
|
|
44
|
+
score: '3.9',
|
|
45
|
+
courses: ['Machine Learning', 'Statistical Modeling', 'Data Mining'],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
skills: [
|
|
49
|
+
{
|
|
50
|
+
name: 'Analytics',
|
|
51
|
+
keywords: ['Python', 'R', 'SQL', 'Tableau', 'Power BI'],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
projects: [
|
|
55
|
+
{
|
|
56
|
+
name: 'Predictive Analytics Platform',
|
|
57
|
+
description:
|
|
58
|
+
'Built ML-powered forecasting system processing 50GB daily data',
|
|
59
|
+
highlights: [
|
|
60
|
+
'Achieved 87% prediction accuracy (15% improvement over baseline)',
|
|
61
|
+
'Deployed to 1,200+ users across 5 business units',
|
|
62
|
+
],
|
|
63
|
+
keywords: ['Python', 'TensorFlow', 'PostgreSQL', 'Docker'],
|
|
64
|
+
startDate: '2021-01-01',
|
|
65
|
+
endDate: '2022-12-31',
|
|
66
|
+
url: 'https://github.com/smartinez/predictive-analytics',
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
describe('Data-Driven Theme', () => {
|
|
72
|
+
it('renders complete HTML document', () => {
|
|
73
|
+
const html = render(completeResume);
|
|
74
|
+
|
|
75
|
+
expect(html).toContain('<!DOCTYPE html>');
|
|
76
|
+
expect(html).toContain('<html lang="en">');
|
|
77
|
+
expect(html).toContain('</html>');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('applies data-driven sky blue accent', () => {
|
|
81
|
+
const html = render(completeResume);
|
|
82
|
+
|
|
83
|
+
// Sky blue accent color in CSS
|
|
84
|
+
expect(html).toContain('#0ea5e9');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('renders basics section', () => {
|
|
88
|
+
const html = render(completeResume);
|
|
89
|
+
|
|
90
|
+
expect(html).toContain('Sarah Martinez');
|
|
91
|
+
expect(html).toContain('Data Analytics Manager');
|
|
92
|
+
expect(html).toContain('sarah.martinez@example.com');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('renders work experience', () => {
|
|
96
|
+
const html = render(completeResume);
|
|
97
|
+
|
|
98
|
+
expect(html).toContain('Professional Experience');
|
|
99
|
+
expect(html).toContain('DataCorp Analytics');
|
|
100
|
+
expect(html).toContain('Senior Analytics Manager');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('bolds numbers in summary', () => {
|
|
104
|
+
const html = render(completeResume);
|
|
105
|
+
|
|
106
|
+
// Numbers in summary should be wrapped in <strong> tags
|
|
107
|
+
expect(html).toContain('<strong>10+</strong>');
|
|
108
|
+
expect(html).toContain('<strong>$5M</strong>');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('bolds numbers in work highlights', () => {
|
|
112
|
+
const html = render(completeResume);
|
|
113
|
+
|
|
114
|
+
// Metrics should be emphasized
|
|
115
|
+
expect(html).toContain('<strong>42%</strong>');
|
|
116
|
+
expect(html).toContain('<strong>$2.3M</strong>');
|
|
117
|
+
expect(html).toContain('<strong>99.9%</strong>');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('renders education section', () => {
|
|
121
|
+
const html = render(completeResume);
|
|
122
|
+
|
|
123
|
+
expect(html).toContain('Education');
|
|
124
|
+
expect(html).toContain('University of Texas at Austin');
|
|
125
|
+
expect(html).toContain('Statistics');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('renders skills section', () => {
|
|
129
|
+
const html = render(completeResume);
|
|
130
|
+
|
|
131
|
+
expect(html).toContain('Technical Skills');
|
|
132
|
+
expect(html).toContain('Python');
|
|
133
|
+
expect(html).toContain('SQL');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('renders projects section', () => {
|
|
137
|
+
const html = render(completeResume);
|
|
138
|
+
|
|
139
|
+
expect(html).toContain('Key Projects');
|
|
140
|
+
expect(html).toContain('Predictive Analytics Platform');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('bolds numbers in project highlights', () => {
|
|
144
|
+
const html = render(completeResume);
|
|
145
|
+
|
|
146
|
+
expect(html).toContain('<strong>87%</strong>');
|
|
147
|
+
expect(html).toContain('<strong>1,200+</strong>');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('includes styled-components CSS', () => {
|
|
151
|
+
const html = render(completeResume);
|
|
152
|
+
|
|
153
|
+
// Styled-components generates style tags
|
|
154
|
+
expect(html).toContain('<style');
|
|
155
|
+
expect(html).toContain('data-styled');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('uses geometric sans-serif font', () => {
|
|
159
|
+
const html = render(completeResume);
|
|
160
|
+
|
|
161
|
+
expect(html).toContain('DM Sans');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('handles minimal data gracefully', () => {
|
|
165
|
+
const minimalResume = {
|
|
166
|
+
basics: {
|
|
167
|
+
name: 'John Doe',
|
|
168
|
+
email: 'john@example.com',
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const html = render(minimalResume);
|
|
173
|
+
|
|
174
|
+
expect(html).toContain('John Doe');
|
|
175
|
+
expect(html).toContain('john@example.com');
|
|
176
|
+
expect(html).toContain('</html>');
|
|
177
|
+
});
|
|
178
|
+
});
|