resume-cli 3.3.0 → 3.4.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 +25 -4
- package/build/export-resume.js +44 -0
- package/build/formatters/markdown.js +118 -0
- package/build/formatters/markdown.test.js +63 -0
- package/build/formatters/text.js +144 -0
- package/build/formatters/text.test.js +65 -0
- package/build/formatters/utils.js +53 -0
- package/build/main.js +1 -1
- package/build/main.test.js +41 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ npm install -g resume-cli
|
|
|
24
24
|
|---|---|
|
|
25
25
|
| init | Initialize a `resume.json` file. |
|
|
26
26
|
| validate | Schema validation test your `resume.json`. |
|
|
27
|
-
| export path/to/file.html | Export to `.html`. |
|
|
27
|
+
| export path/to/file.html | Export to `.html`, `.pdf`, `.md` or `.txt`. |
|
|
28
28
|
| serve | Serve resume at `http://localhost:4000/`. |
|
|
29
29
|
|
|
30
30
|
### `resume --help`
|
|
@@ -43,9 +43,30 @@ Validates your `resume.json` against our schema to ensure it complies with the s
|
|
|
43
43
|
|
|
44
44
|
### `resume export [fileName]`
|
|
45
45
|
|
|
46
|
-
Exports your resume
|
|
46
|
+
Exports your resume to one of four formats:
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
- `.html` / `.pdf` — stylized output rendered through a theme.
|
|
49
|
+
- `.md` — clean Markdown (one heading per section). No theme required.
|
|
50
|
+
- `.txt` — readable plain text. No theme required.
|
|
51
|
+
|
|
52
|
+
The format is inferred from the file extension, or set it explicitly with
|
|
53
|
+
`--format`:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
resume export resume.md # Markdown (inferred)
|
|
57
|
+
resume export resume.txt --format text # plain text (explicit)
|
|
58
|
+
resume export resume.html --theme even # themed HTML
|
|
59
|
+
resume export resume.pdf --theme even # themed PDF
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`--format` accepts `html`, `pdf`, `markdown` (or `md`) and `text` (or `txt`).
|
|
63
|
+
|
|
64
|
+
The Markdown and text formats render every JSON Resume section that is present
|
|
65
|
+
(basics, work, volunteer, education, awards, publications, skills, languages,
|
|
66
|
+
interests, references, projects) and skip any that are missing — so no theme
|
|
67
|
+
install is needed.
|
|
68
|
+
|
|
69
|
+
A list of available themes (for `.html` / `.pdf`) can be found here:
|
|
49
70
|
https://jsonresume.org/themes/
|
|
50
71
|
|
|
51
72
|
Please npm install the theme you wish to use before attempting to export it.
|
|
@@ -53,7 +74,7 @@ Please npm install the theme you wish to use before attempting to export it.
|
|
|
53
74
|
Options:
|
|
54
75
|
|
|
55
76
|
- `--format <file type>` Example: `--format pdf`
|
|
56
|
-
- `--theme <name>` Example: `--theme even`
|
|
77
|
+
- `--theme <name>` Example: `--theme even` (only used for `.html` / `.pdf`)
|
|
57
78
|
|
|
58
79
|
### `resume serve`
|
|
59
80
|
|
package/build/export-resume.js
CHANGED
|
@@ -11,6 +11,29 @@ const btoa = require('btoa');
|
|
|
11
11
|
const {
|
|
12
12
|
ThemeNotFoundError
|
|
13
13
|
} = require('./theme-errors');
|
|
14
|
+
const toMarkdown = require('./formatters/markdown');
|
|
15
|
+
const toText = require('./formatters/text');
|
|
16
|
+
|
|
17
|
+
// Theme-less formatters keyed by their normalized format. The value pairs the
|
|
18
|
+
// pure renderer with the file extension used on disk.
|
|
19
|
+
const THEMELESS_FORMATTERS = {
|
|
20
|
+
md: {
|
|
21
|
+
render: toMarkdown,
|
|
22
|
+
ext: '.md'
|
|
23
|
+
},
|
|
24
|
+
markdown: {
|
|
25
|
+
render: toMarkdown,
|
|
26
|
+
ext: '.md'
|
|
27
|
+
},
|
|
28
|
+
txt: {
|
|
29
|
+
render: toText,
|
|
30
|
+
ext: '.txt'
|
|
31
|
+
},
|
|
32
|
+
text: {
|
|
33
|
+
render: toText,
|
|
34
|
+
ext: '.txt'
|
|
35
|
+
}
|
|
36
|
+
};
|
|
14
37
|
module.exports = ({
|
|
15
38
|
resume: resumeJson,
|
|
16
39
|
fileName,
|
|
@@ -24,6 +47,16 @@ module.exports = ({
|
|
|
24
47
|
const fileNameAndFormat = getFileNameAndFormat(fileName, format);
|
|
25
48
|
fileName = fileNameAndFormat.fileName;
|
|
26
49
|
const fileFormatToUse = fileNameAndFormat.fileFormatToUse;
|
|
50
|
+
const themeless = THEMELESS_FORMATTERS[(fileFormatToUse || '').toLowerCase()];
|
|
51
|
+
if (themeless) {
|
|
52
|
+
createThemeless(resumeJson, fileName, themeless, error => {
|
|
53
|
+
if (error) {
|
|
54
|
+
console.error(error, '`createThemeless` errored out');
|
|
55
|
+
}
|
|
56
|
+
callback(error, fileName, themeless.ext);
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
27
60
|
const formatToUse = '.' + fileFormatToUse;
|
|
28
61
|
if (formatToUse === '.html') {
|
|
29
62
|
createHtml(resumeJson, fileName, theme, formatToUse, error => {
|
|
@@ -88,6 +121,17 @@ async function createHtml(resumeJson, fileName, themePath, format, callback) {
|
|
|
88
121
|
stream.close(callback);
|
|
89
122
|
});
|
|
90
123
|
}
|
|
124
|
+
// Render a resume with a pure (theme-less) formatter and write it to disk.
|
|
125
|
+
async function createThemeless(resumeJson, fileName, formatter, callback) {
|
|
126
|
+
try {
|
|
127
|
+
const output = formatter.render(resumeJson);
|
|
128
|
+
const pathToStream = path.resolve(process.cwd(), fileName + formatter.ext);
|
|
129
|
+
await writeFile(pathToStream, output);
|
|
130
|
+
callback(null);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
callback(err);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
91
135
|
const createPdf = (resumeJson, fileName, theme, format, callback) => {
|
|
92
136
|
(async () => {
|
|
93
137
|
const themePkg = getThemePkg(theme);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Pure Markdown formatter for a JSON Resume. No external theme required.
|
|
4
|
+
// Renders every JSON Resume schema section that is present, and silently
|
|
5
|
+
// skips sections that are missing or empty so partial resumes work too.
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
isNonEmptyArray,
|
|
9
|
+
dateRange,
|
|
10
|
+
contactParts,
|
|
11
|
+
section
|
|
12
|
+
} = require('./utils');
|
|
13
|
+
const link = (label, url) => url ? `[${label}](${url})` : label;
|
|
14
|
+
const sec = (title, items, renderItem) => section([`## ${title}`], items, renderItem);
|
|
15
|
+
const highlights = items => isNonEmptyArray(items) ? items.map(h => `- ${h}`) : [];
|
|
16
|
+
const csv = keywords => isNonEmptyArray(keywords) ? [keywords.join(', ')] : [];
|
|
17
|
+
const italic = text => text ? [`*${text}*`] : [];
|
|
18
|
+
const basics = resume => {
|
|
19
|
+
const b = resume.basics;
|
|
20
|
+
if (!b) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const lines = [];
|
|
24
|
+
if (b.name) {
|
|
25
|
+
lines.push(`# ${b.name}`);
|
|
26
|
+
}
|
|
27
|
+
if (b.label) {
|
|
28
|
+
lines.push(`> ${b.label}`);
|
|
29
|
+
}
|
|
30
|
+
const contact = contactParts(b);
|
|
31
|
+
if (contact.length) {
|
|
32
|
+
lines.push(contact.join(' | '));
|
|
33
|
+
}
|
|
34
|
+
if (isNonEmptyArray(b.profiles)) {
|
|
35
|
+
const profiles = b.profiles.map(p => link(p.network || p.username || p.url, p.url)).filter(Boolean);
|
|
36
|
+
if (profiles.length) {
|
|
37
|
+
lines.push(profiles.join(' | '));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (b.summary) {
|
|
41
|
+
lines.push('', b.summary);
|
|
42
|
+
}
|
|
43
|
+
return lines;
|
|
44
|
+
};
|
|
45
|
+
const work = resume => sec('Work', resume.work, w => {
|
|
46
|
+
const heading = [w.position, w.name || w.company].filter(Boolean).join(', ');
|
|
47
|
+
const lines = [`### ${link(heading || 'Role', w.url)}`];
|
|
48
|
+
lines.push(...italic(dateRange(w.startDate, w.endDate)));
|
|
49
|
+
if (w.summary) {
|
|
50
|
+
lines.push('', w.summary);
|
|
51
|
+
}
|
|
52
|
+
return lines.concat(highlights(w.highlights));
|
|
53
|
+
});
|
|
54
|
+
const volunteer = resume => sec('Volunteer', resume.volunteer, v => {
|
|
55
|
+
const heading = [v.position, v.organization].filter(Boolean).join(', ');
|
|
56
|
+
const lines = [`### ${link(heading || 'Volunteer', v.url)}`];
|
|
57
|
+
lines.push(...italic(dateRange(v.startDate, v.endDate)));
|
|
58
|
+
if (v.summary) {
|
|
59
|
+
lines.push('', v.summary);
|
|
60
|
+
}
|
|
61
|
+
return lines.concat(highlights(v.highlights));
|
|
62
|
+
});
|
|
63
|
+
const education = resume => sec('Education', resume.education, e => {
|
|
64
|
+
const study = [e.studyType, e.area].filter(Boolean).join(', ');
|
|
65
|
+
const title = [e.institution, study].filter(Boolean).join(' — ');
|
|
66
|
+
const lines = [`### ${link(title || 'Education', e.url)}`];
|
|
67
|
+
lines.push(...italic(dateRange(e.startDate, e.endDate)));
|
|
68
|
+
if (e.score) {
|
|
69
|
+
lines.push(`Score: ${e.score}`);
|
|
70
|
+
}
|
|
71
|
+
if (isNonEmptyArray(e.courses)) {
|
|
72
|
+
lines.push('', ...e.courses.map(c => `- ${c}`));
|
|
73
|
+
}
|
|
74
|
+
return lines;
|
|
75
|
+
});
|
|
76
|
+
const awards = resume => sec('Awards', resume.awards, a => {
|
|
77
|
+
const lines = [`### ${a.title || 'Award'}`];
|
|
78
|
+
lines.push(...italic([a.awarder, a.date].filter(Boolean).join(' — ')));
|
|
79
|
+
if (a.summary) {
|
|
80
|
+
lines.push('', a.summary);
|
|
81
|
+
}
|
|
82
|
+
return lines;
|
|
83
|
+
});
|
|
84
|
+
const publications = resume => sec('Publications', resume.publications, p => {
|
|
85
|
+
const lines = [`### ${link(p.name || 'Publication', p.url)}`];
|
|
86
|
+
lines.push(...italic([p.publisher, p.releaseDate].filter(Boolean).join(' — ')));
|
|
87
|
+
if (p.summary) {
|
|
88
|
+
lines.push('', p.summary);
|
|
89
|
+
}
|
|
90
|
+
return lines;
|
|
91
|
+
});
|
|
92
|
+
const skills = resume => sec('Skills', resume.skills, s => {
|
|
93
|
+
const heading = [s.name, s.level].filter(Boolean).join(' — ');
|
|
94
|
+
return [`### ${heading || 'Skill'}`].concat(csv(s.keywords));
|
|
95
|
+
});
|
|
96
|
+
const languages = resume => sec('Languages', resume.languages, l => [`- ${[l.language, l.fluency].filter(Boolean).join(' — ')}`]);
|
|
97
|
+
const interests = resume => sec('Interests', resume.interests, i => [`### ${i.name || 'Interest'}`].concat(csv(i.keywords)));
|
|
98
|
+
const references = resume => sec('References', resume.references, r => {
|
|
99
|
+
const lines = [`### ${r.name || 'Reference'}`];
|
|
100
|
+
if (r.reference) {
|
|
101
|
+
lines.push('', `> ${r.reference}`);
|
|
102
|
+
}
|
|
103
|
+
return lines;
|
|
104
|
+
});
|
|
105
|
+
const projects = resume => sec('Projects', resume.projects, p => {
|
|
106
|
+
const lines = [`### ${link(p.name || 'Project', p.url)}`];
|
|
107
|
+
lines.push(...italic(dateRange(p.startDate, p.endDate)));
|
|
108
|
+
if (p.description) {
|
|
109
|
+
lines.push('', p.description);
|
|
110
|
+
}
|
|
111
|
+
return lines.concat(highlights(p.highlights)).concat(csv(p.keywords));
|
|
112
|
+
});
|
|
113
|
+
const toMarkdown = (resume = {}) => {
|
|
114
|
+
const lines = [...basics(resume), ...work(resume), ...volunteer(resume), ...education(resume), ...awards(resume), ...publications(resume), ...skills(resume), ...languages(resume), ...interests(resume), ...references(resume), ...projects(resume)];
|
|
115
|
+
return `${lines.join('\n').trim()}\n`;
|
|
116
|
+
};
|
|
117
|
+
module.exports = toMarkdown;
|
|
118
|
+
module.exports.toMarkdown = toMarkdown;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _markdown = _interopRequireDefault(require("./markdown"));
|
|
4
|
+
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
5
|
+
const sample = require('@jsonresume/schema/sample.resume.json');
|
|
6
|
+
describe('markdown formatter', () => {
|
|
7
|
+
describe('against the bundled sample resume', () => {
|
|
8
|
+
let md;
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
md = (0, _markdown.default)(sample);
|
|
11
|
+
});
|
|
12
|
+
it('returns a non-empty string ending in a newline', () => {
|
|
13
|
+
expect(typeof md).toBe('string');
|
|
14
|
+
expect(md.length).toBeGreaterThan(0);
|
|
15
|
+
expect(md.endsWith('\n')).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
it('renders the person name as the top-level heading', () => {
|
|
18
|
+
expect(md).toContain('# Richard Hendriks');
|
|
19
|
+
});
|
|
20
|
+
it('renders a heading for every populated section', () => {
|
|
21
|
+
['## Work', '## Volunteer', '## Education', '## Awards', '## Publications', '## Skills', '## Languages', '## Interests', '## References', '## Projects'].forEach(heading => {
|
|
22
|
+
expect(md).toContain(heading);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
it('renders entry details, highlights and keywords', () => {
|
|
26
|
+
expect(md).toContain('Pied Piper');
|
|
27
|
+
expect(md).toContain('CEO/President');
|
|
28
|
+
expect(md).toContain('- Successfully won Techcrunch Disrupt');
|
|
29
|
+
expect(md).toContain('University of Oklahoma');
|
|
30
|
+
expect(md).toContain('Miss Direction');
|
|
31
|
+
expect(md).toContain('HTML, CSS, Javascript');
|
|
32
|
+
expect(md).toContain('English — Native speaker');
|
|
33
|
+
});
|
|
34
|
+
it('links urls using markdown link syntax', () => {
|
|
35
|
+
expect(md).toContain('](http://piedpiper.example.com)');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe('with missing / partial sections', () => {
|
|
39
|
+
it('handles an empty resume without throwing', () => {
|
|
40
|
+
expect(() => (0, _markdown.default)({})).not.toThrow();
|
|
41
|
+
expect(() => (0, _markdown.default)()).not.toThrow();
|
|
42
|
+
});
|
|
43
|
+
it('omits sections that are absent', () => {
|
|
44
|
+
const md = (0, _markdown.default)({
|
|
45
|
+
basics: {
|
|
46
|
+
name: 'Jane Doe'
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
expect(md).toContain('# Jane Doe');
|
|
50
|
+
expect(md).not.toContain('## Work');
|
|
51
|
+
expect(md).not.toContain('## Skills');
|
|
52
|
+
});
|
|
53
|
+
it('omits sections that are present but empty', () => {
|
|
54
|
+
const md = (0, _markdown.default)({
|
|
55
|
+
basics: {
|
|
56
|
+
name: 'Jane Doe'
|
|
57
|
+
},
|
|
58
|
+
work: []
|
|
59
|
+
});
|
|
60
|
+
expect(md).not.toContain('## Work');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Pure plain-text formatter for a JSON Resume. No external theme required.
|
|
4
|
+
// Renders every JSON Resume schema section that is present, and silently
|
|
5
|
+
// skips sections that are missing or empty so partial resumes work too.
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
isNonEmptyArray,
|
|
9
|
+
dateRange,
|
|
10
|
+
contactParts,
|
|
11
|
+
section
|
|
12
|
+
} = require('./utils');
|
|
13
|
+
const heading = text => [text.toUpperCase(), '='.repeat(text.length)];
|
|
14
|
+
const sec = (title, items, renderItem) => section(heading(title), items, renderItem);
|
|
15
|
+
const bullets = items => isNonEmptyArray(items) ? items.map(i => ` * ${i}`) : [];
|
|
16
|
+
const csv = keywords => isNonEmptyArray(keywords) ? [` ${keywords.join(', ')}`] : [];
|
|
17
|
+
const basics = resume => {
|
|
18
|
+
const b = resume.basics;
|
|
19
|
+
if (!b) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
const lines = [];
|
|
23
|
+
if (b.name) {
|
|
24
|
+
lines.push(b.name);
|
|
25
|
+
}
|
|
26
|
+
if (b.label) {
|
|
27
|
+
lines.push(b.label);
|
|
28
|
+
}
|
|
29
|
+
const contact = contactParts(b);
|
|
30
|
+
if (contact.length) {
|
|
31
|
+
lines.push(contact.join(' | '));
|
|
32
|
+
}
|
|
33
|
+
if (isNonEmptyArray(b.profiles)) {
|
|
34
|
+
b.profiles.forEach(p => {
|
|
35
|
+
const label = [p.network, p.username].filter(Boolean).join(' ');
|
|
36
|
+
const parts = [label, p.url].filter(Boolean);
|
|
37
|
+
if (parts.length) {
|
|
38
|
+
lines.push(parts.join(': '));
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (b.summary) {
|
|
43
|
+
lines.push('', b.summary);
|
|
44
|
+
}
|
|
45
|
+
return lines;
|
|
46
|
+
};
|
|
47
|
+
const work = resume => sec('Work', resume.work, w => {
|
|
48
|
+
const title = [w.position, w.name || w.company].filter(Boolean).join(' @ ');
|
|
49
|
+
const lines = [title || 'Role'];
|
|
50
|
+
const range = dateRange(w.startDate, w.endDate);
|
|
51
|
+
if (range) {
|
|
52
|
+
lines.push(range);
|
|
53
|
+
}
|
|
54
|
+
if (w.url) {
|
|
55
|
+
lines.push(w.url);
|
|
56
|
+
}
|
|
57
|
+
if (w.summary) {
|
|
58
|
+
lines.push(w.summary);
|
|
59
|
+
}
|
|
60
|
+
return lines.concat(bullets(w.highlights));
|
|
61
|
+
});
|
|
62
|
+
const volunteer = resume => sec('Volunteer', resume.volunteer, v => {
|
|
63
|
+
const title = [v.position, v.organization].filter(Boolean).join(' @ ');
|
|
64
|
+
const lines = [title || 'Volunteer'];
|
|
65
|
+
const range = dateRange(v.startDate, v.endDate);
|
|
66
|
+
if (range) {
|
|
67
|
+
lines.push(range);
|
|
68
|
+
}
|
|
69
|
+
if (v.summary) {
|
|
70
|
+
lines.push(v.summary);
|
|
71
|
+
}
|
|
72
|
+
return lines.concat(bullets(v.highlights));
|
|
73
|
+
});
|
|
74
|
+
const education = resume => sec('Education', resume.education, e => {
|
|
75
|
+
const study = [e.studyType, e.area].filter(Boolean).join(', ');
|
|
76
|
+
const title = [e.institution, study].filter(Boolean).join(' - ');
|
|
77
|
+
const lines = [title || 'Education'];
|
|
78
|
+
const range = dateRange(e.startDate, e.endDate);
|
|
79
|
+
if (range) {
|
|
80
|
+
lines.push(range);
|
|
81
|
+
}
|
|
82
|
+
if (e.score) {
|
|
83
|
+
lines.push(`Score: ${e.score}`);
|
|
84
|
+
}
|
|
85
|
+
return lines.concat(bullets(e.courses));
|
|
86
|
+
});
|
|
87
|
+
const awards = resume => sec('Awards', resume.awards, a => {
|
|
88
|
+
const lines = [a.title || 'Award'];
|
|
89
|
+
const meta = [a.awarder, a.date].filter(Boolean).join(' - ');
|
|
90
|
+
if (meta) {
|
|
91
|
+
lines.push(meta);
|
|
92
|
+
}
|
|
93
|
+
if (a.summary) {
|
|
94
|
+
lines.push(a.summary);
|
|
95
|
+
}
|
|
96
|
+
return lines;
|
|
97
|
+
});
|
|
98
|
+
const publications = resume => sec('Publications', resume.publications, p => {
|
|
99
|
+
const lines = [p.name || 'Publication'];
|
|
100
|
+
const meta = [p.publisher, p.releaseDate].filter(Boolean).join(' - ');
|
|
101
|
+
if (meta) {
|
|
102
|
+
lines.push(meta);
|
|
103
|
+
}
|
|
104
|
+
if (p.url) {
|
|
105
|
+
lines.push(p.url);
|
|
106
|
+
}
|
|
107
|
+
if (p.summary) {
|
|
108
|
+
lines.push(p.summary);
|
|
109
|
+
}
|
|
110
|
+
return lines;
|
|
111
|
+
});
|
|
112
|
+
const skills = resume => sec('Skills', resume.skills, s => {
|
|
113
|
+
const title = [s.name, s.level].filter(Boolean).join(' - ');
|
|
114
|
+
return [title || 'Skill'].concat(csv(s.keywords));
|
|
115
|
+
});
|
|
116
|
+
const languages = resume => sec('Languages', resume.languages, l => [` * ${[l.language, l.fluency].filter(Boolean).join(' - ')}`]);
|
|
117
|
+
const interests = resume => sec('Interests', resume.interests, i => [i.name || 'Interest'].concat(csv(i.keywords)));
|
|
118
|
+
const references = resume => sec('References', resume.references, r => {
|
|
119
|
+
const lines = [r.name || 'Reference'];
|
|
120
|
+
if (r.reference) {
|
|
121
|
+
lines.push(r.reference);
|
|
122
|
+
}
|
|
123
|
+
return lines;
|
|
124
|
+
});
|
|
125
|
+
const projects = resume => sec('Projects', resume.projects, p => {
|
|
126
|
+
const lines = [p.name || 'Project'];
|
|
127
|
+
const range = dateRange(p.startDate, p.endDate);
|
|
128
|
+
if (range) {
|
|
129
|
+
lines.push(range);
|
|
130
|
+
}
|
|
131
|
+
if (p.url) {
|
|
132
|
+
lines.push(p.url);
|
|
133
|
+
}
|
|
134
|
+
if (p.description) {
|
|
135
|
+
lines.push(p.description);
|
|
136
|
+
}
|
|
137
|
+
return lines.concat(bullets(p.highlights)).concat(csv(p.keywords));
|
|
138
|
+
});
|
|
139
|
+
const toText = (resume = {}) => {
|
|
140
|
+
const lines = [...basics(resume), ...work(resume), ...volunteer(resume), ...education(resume), ...awards(resume), ...publications(resume), ...skills(resume), ...languages(resume), ...interests(resume), ...references(resume), ...projects(resume)];
|
|
141
|
+
return `${lines.join('\n').trim()}\n`;
|
|
142
|
+
};
|
|
143
|
+
module.exports = toText;
|
|
144
|
+
module.exports.toText = toText;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _text = _interopRequireDefault(require("./text"));
|
|
4
|
+
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
5
|
+
const sample = require('@jsonresume/schema/sample.resume.json');
|
|
6
|
+
describe('text formatter', () => {
|
|
7
|
+
describe('against the bundled sample resume', () => {
|
|
8
|
+
let txt;
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
txt = (0, _text.default)(sample);
|
|
11
|
+
});
|
|
12
|
+
it('returns a non-empty string ending in a newline', () => {
|
|
13
|
+
expect(typeof txt).toBe('string');
|
|
14
|
+
expect(txt.length).toBeGreaterThan(0);
|
|
15
|
+
expect(txt.endsWith('\n')).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
it('renders the person name', () => {
|
|
18
|
+
expect(txt).toContain('Richard Hendriks');
|
|
19
|
+
});
|
|
20
|
+
it('renders an underlined heading for every populated section', () => {
|
|
21
|
+
['WORK', 'VOLUNTEER', 'EDUCATION', 'AWARDS', 'PUBLICATIONS', 'SKILLS', 'LANGUAGES', 'INTERESTS', 'REFERENCES', 'PROJECTS'].forEach(heading => {
|
|
22
|
+
expect(txt).toContain(heading);
|
|
23
|
+
});
|
|
24
|
+
// headings are underlined with '=' rules
|
|
25
|
+
expect(txt).toContain('====');
|
|
26
|
+
});
|
|
27
|
+
it('renders entry details, highlights and keywords', () => {
|
|
28
|
+
expect(txt).toContain('CEO/President @ Pied Piper');
|
|
29
|
+
expect(txt).toContain(' * Successfully won Techcrunch Disrupt');
|
|
30
|
+
expect(txt).toContain('University of Oklahoma');
|
|
31
|
+
expect(txt).toContain('Miss Direction');
|
|
32
|
+
expect(txt).toContain('HTML, CSS, Javascript');
|
|
33
|
+
expect(txt).toContain('English - Native speaker');
|
|
34
|
+
});
|
|
35
|
+
it('does not contain markdown markup', () => {
|
|
36
|
+
expect(txt).not.toContain('# ');
|
|
37
|
+
expect(txt).not.toContain('](');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('with missing / partial sections', () => {
|
|
41
|
+
it('handles an empty resume without throwing', () => {
|
|
42
|
+
expect(() => (0, _text.default)({})).not.toThrow();
|
|
43
|
+
expect(() => (0, _text.default)()).not.toThrow();
|
|
44
|
+
});
|
|
45
|
+
it('omits sections that are absent', () => {
|
|
46
|
+
const txt = (0, _text.default)({
|
|
47
|
+
basics: {
|
|
48
|
+
name: 'Jane Doe'
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
expect(txt).toContain('Jane Doe');
|
|
52
|
+
expect(txt).not.toContain('WORK');
|
|
53
|
+
expect(txt).not.toContain('SKILLS');
|
|
54
|
+
});
|
|
55
|
+
it('omits sections that are present but empty', () => {
|
|
56
|
+
const txt = (0, _text.default)({
|
|
57
|
+
basics: {
|
|
58
|
+
name: 'Jane Doe'
|
|
59
|
+
},
|
|
60
|
+
work: []
|
|
61
|
+
});
|
|
62
|
+
expect(txt).not.toContain('WORK');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Shared helpers for the theme-less resume formatters (markdown + text).
|
|
4
|
+
|
|
5
|
+
const isNonEmptyArray = value => Array.isArray(value) && value.length > 0;
|
|
6
|
+
const dateRange = (startDate, endDate) => {
|
|
7
|
+
if (!startDate && !endDate) {
|
|
8
|
+
return '';
|
|
9
|
+
}
|
|
10
|
+
return `${startDate || ''} - ${endDate || 'Present'}`;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Flatten a contact line out of basics: email, phone, url and a one-line
|
|
14
|
+
// location. Returns an array of fragments (caller decides the separator).
|
|
15
|
+
const contactParts = (basics = {}) => {
|
|
16
|
+
const parts = [];
|
|
17
|
+
if (basics.email) {
|
|
18
|
+
parts.push(basics.email);
|
|
19
|
+
}
|
|
20
|
+
if (basics.phone) {
|
|
21
|
+
parts.push(basics.phone);
|
|
22
|
+
}
|
|
23
|
+
if (basics.url) {
|
|
24
|
+
parts.push(basics.url);
|
|
25
|
+
}
|
|
26
|
+
const loc = basics.location;
|
|
27
|
+
if (loc) {
|
|
28
|
+
const locParts = [loc.address, loc.city, loc.region, loc.postalCode, loc.countryCode].filter(Boolean);
|
|
29
|
+
if (locParts.length) {
|
|
30
|
+
parts.push(locParts.join(', '));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return parts;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Build a section block: returns [] when there are no items, otherwise the
|
|
37
|
+
// `headerLines` followed by each rendered item (separated by a blank line).
|
|
38
|
+
const section = (headerLines, items, renderItem) => {
|
|
39
|
+
if (!isNonEmptyArray(items)) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const lines = ['', ...headerLines];
|
|
43
|
+
items.forEach(item => {
|
|
44
|
+
lines.push('', ...renderItem(item));
|
|
45
|
+
});
|
|
46
|
+
return lines;
|
|
47
|
+
};
|
|
48
|
+
module.exports = {
|
|
49
|
+
isNonEmptyArray,
|
|
50
|
+
dateRange,
|
|
51
|
+
contactParts,
|
|
52
|
+
section
|
|
53
|
+
};
|
package/build/main.js
CHANGED
|
@@ -50,7 +50,7 @@ const normalizeTheme = (value, defaultValue) => {
|
|
|
50
50
|
process.exitCode = 1;
|
|
51
51
|
}
|
|
52
52
|
});
|
|
53
|
-
program.command('export [fileName]').description('Export locally to .html or .
|
|
53
|
+
program.command('export [fileName]').description('Export locally to .html, .pdf, .md (markdown) or .txt (text). Supply a --format <file format> flag and argument to specify export format. .md and .txt need no theme; pick a theme for .html/.pdf with --theme (https://jsonresume.org/themes/).').action(async fileName => {
|
|
54
54
|
const resume = await (0, _getResume.default)({
|
|
55
55
|
path: program.resume
|
|
56
56
|
});
|
package/build/main.test.js
CHANGED
|
@@ -83,10 +83,12 @@ describe('cli configuration', () => {
|
|
|
83
83
|
Commands:
|
|
84
84
|
init Initialize a resume.json file
|
|
85
85
|
validate Validate your resume's schema
|
|
86
|
-
export [fileName] Export locally to .html
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
export [fileName] Export locally to .html, .pdf, .md
|
|
87
|
+
(markdown) or .txt (text). Supply a
|
|
88
|
+
--format <file format> flag and argument
|
|
89
|
+
to specify export format. .md and .txt
|
|
90
|
+
need no theme; pick a theme for
|
|
91
|
+
.html/.pdf with --theme
|
|
90
92
|
(https://jsonresume.org/themes/).
|
|
91
93
|
serve Serve resume at http://localhost:4000/
|
|
92
94
|
help [command] display help for command
|
|
@@ -170,6 +172,41 @@ describe('cli configuration', () => {
|
|
|
170
172
|
"
|
|
171
173
|
`);
|
|
172
174
|
});
|
|
175
|
+
it('should export markdown without requiring a theme', async () => {
|
|
176
|
+
const {
|
|
177
|
+
stdout,
|
|
178
|
+
volume
|
|
179
|
+
} = await run(['export', '/test-resumes/exported-resume.md', '--resume', '-', '--format', 'markdown'], {
|
|
180
|
+
stdin: JSON.stringify({
|
|
181
|
+
basics: {
|
|
182
|
+
name: 'thomas-md'
|
|
183
|
+
},
|
|
184
|
+
skills: [{
|
|
185
|
+
name: 'JS',
|
|
186
|
+
keywords: ['Node']
|
|
187
|
+
}]
|
|
188
|
+
})
|
|
189
|
+
});
|
|
190
|
+
const output = volume['/test-resumes/exported-resume.md'];
|
|
191
|
+
expect(output).toEqual(expect.stringContaining('# thomas-md'));
|
|
192
|
+
expect(output).toEqual(expect.stringContaining('## Skills'));
|
|
193
|
+
expect(stdout).toContain('.md resume at:');
|
|
194
|
+
});
|
|
195
|
+
it('should export plain text without requiring a theme', async () => {
|
|
196
|
+
const {
|
|
197
|
+
stdout,
|
|
198
|
+
volume
|
|
199
|
+
} = await run(['export', '/test-resumes/exported-resume.txt', '--resume', '-', '--format', 'text'], {
|
|
200
|
+
stdin: JSON.stringify({
|
|
201
|
+
basics: {
|
|
202
|
+
name: 'thomas-txt'
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
});
|
|
206
|
+
const output = volume['/test-resumes/exported-resume.txt'];
|
|
207
|
+
expect(output).toEqual(expect.stringContaining('thomas-txt'));
|
|
208
|
+
expect(stdout).toContain('.txt resume at:');
|
|
209
|
+
});
|
|
173
210
|
it('should print a friendly, actionable message and exit non-zero when the theme is not installed', async () => {
|
|
174
211
|
const {
|
|
175
212
|
code,
|