resume-cli 3.2.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/builder.js +11 -1
- package/build/export-resume.js +66 -9
- 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 +16 -2
- package/build/main.test.js +96 -13
- package/build/render-html.js +3 -2
- package/build/theme-errors.js +35 -0
- 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/builder.js
CHANGED
|
@@ -5,6 +5,10 @@ const fs = require('fs');
|
|
|
5
5
|
const request = require('superagent');
|
|
6
6
|
const chalk = require('chalk');
|
|
7
7
|
const renderHtml = require('./render-html').default;
|
|
8
|
+
const {
|
|
9
|
+
ThemeNotFoundError,
|
|
10
|
+
formatThemeNotFound
|
|
11
|
+
} = require('./theme-errors');
|
|
8
12
|
const denormalizeTheme = value => {
|
|
9
13
|
return value.match(/jsonresume-theme-(.*)/)[1];
|
|
10
14
|
};
|
|
@@ -45,7 +49,13 @@ module.exports = function resumeBuilder(theme, dir, resumeFilename, cb) {
|
|
|
45
49
|
});
|
|
46
50
|
cb(null, html);
|
|
47
51
|
} catch (err) {
|
|
48
|
-
|
|
52
|
+
if (err instanceof ThemeNotFoundError) {
|
|
53
|
+
// Theme isn't installed locally. Show an actionable message, then fall
|
|
54
|
+
// back to the hosted theme server (preserving existing behavior).
|
|
55
|
+
console.log(formatThemeNotFound(err.theme));
|
|
56
|
+
} else {
|
|
57
|
+
console.log(err);
|
|
58
|
+
}
|
|
49
59
|
console.log(chalk.yellow('Could not run the render function from local theme.'));
|
|
50
60
|
sendExportHTML(resumeJson, theme, cb);
|
|
51
61
|
}
|
package/build/export-resume.js
CHANGED
|
@@ -8,6 +8,32 @@ const writeFile = (0, _util.promisify)(_fs.default.writeFile);
|
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const puppeteer = require('puppeteer');
|
|
10
10
|
const btoa = require('btoa');
|
|
11
|
+
const {
|
|
12
|
+
ThemeNotFoundError
|
|
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
|
+
};
|
|
11
37
|
module.exports = ({
|
|
12
38
|
resume: resumeJson,
|
|
13
39
|
fileName,
|
|
@@ -21,17 +47,29 @@ module.exports = ({
|
|
|
21
47
|
const fileNameAndFormat = getFileNameAndFormat(fileName, format);
|
|
22
48
|
fileName = fileNameAndFormat.fileName;
|
|
23
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
|
+
}
|
|
24
60
|
const formatToUse = '.' + fileFormatToUse;
|
|
25
61
|
if (formatToUse === '.html') {
|
|
26
62
|
createHtml(resumeJson, fileName, theme, formatToUse, error => {
|
|
27
|
-
|
|
63
|
+
// ThemeNotFoundError is reported as a friendly message by the caller;
|
|
64
|
+
// don't dump the raw object here.
|
|
65
|
+
if (error && !(error instanceof ThemeNotFoundError)) {
|
|
28
66
|
console.error(error, '`createHtml` errored out');
|
|
29
67
|
}
|
|
30
68
|
callback(error, fileName, formatToUse);
|
|
31
69
|
});
|
|
32
70
|
} else if (formatToUse === '.pdf') {
|
|
33
71
|
createPdf(resumeJson, fileName, theme, formatToUse, error => {
|
|
34
|
-
if (error) {
|
|
72
|
+
if (error && !(error instanceof ThemeNotFoundError)) {
|
|
35
73
|
console.error(error, '`createPdf` errored out');
|
|
36
74
|
}
|
|
37
75
|
callback(error, fileName, formatToUse);
|
|
@@ -58,16 +96,24 @@ const getThemePkg = theme => {
|
|
|
58
96
|
const themePkg = require(theme);
|
|
59
97
|
return themePkg;
|
|
60
98
|
} catch (err) {
|
|
61
|
-
// Theme not installed
|
|
62
|
-
|
|
63
|
-
|
|
99
|
+
// Theme not installed. Throw a typed error so the caller can render an
|
|
100
|
+
// actionable, stack-trace-free message and exit with a non-zero code.
|
|
101
|
+
throw new ThemeNotFoundError(theme);
|
|
64
102
|
}
|
|
65
103
|
};
|
|
66
104
|
async function createHtml(resumeJson, fileName, themePath, format, callback) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
105
|
+
let html;
|
|
106
|
+
try {
|
|
107
|
+
html = await (0, _renderHtml.default)({
|
|
108
|
+
resume: resumeJson,
|
|
109
|
+
themePath
|
|
110
|
+
});
|
|
111
|
+
} catch (err) {
|
|
112
|
+
// Surface theme-resolution (and other render) failures to the caller so
|
|
113
|
+
// they can be reported as an actionable message rather than an
|
|
114
|
+
// unhandled-rejection stack trace.
|
|
115
|
+
return callback(err);
|
|
116
|
+
}
|
|
71
117
|
const pathToStream = path.resolve(process.cwd(), fileName + format);
|
|
72
118
|
await writeFile(pathToStream, ''); // workaround for https://github.com/streamich/unionfs/issues/428
|
|
73
119
|
const stream = _fs.default.createWriteStream(pathToStream);
|
|
@@ -75,6 +121,17 @@ async function createHtml(resumeJson, fileName, themePath, format, callback) {
|
|
|
75
121
|
stream.close(callback);
|
|
76
122
|
});
|
|
77
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
|
+
}
|
|
78
135
|
const createPdf = (resumeJson, fileName, theme, format, callback) => {
|
|
79
136
|
(async () => {
|
|
80
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
|
@@ -13,6 +13,10 @@ const serve = require('./serve');
|
|
|
13
13
|
const program = require('commander');
|
|
14
14
|
const chalk = require('chalk');
|
|
15
15
|
const path = require('path');
|
|
16
|
+
const {
|
|
17
|
+
ThemeNotFoundError,
|
|
18
|
+
formatThemeNotFound
|
|
19
|
+
} = require('./theme-errors');
|
|
16
20
|
const normalizeTheme = (value, defaultValue) => {
|
|
17
21
|
const theme = value || defaultValue;
|
|
18
22
|
// TODO - This is not great, but bypasses this function if it is a relative path
|
|
@@ -22,7 +26,7 @@ const normalizeTheme = (value, defaultValue) => {
|
|
|
22
26
|
return theme.match('jsonresume-theme-.*') ? theme : `jsonresume-theme-${theme}`;
|
|
23
27
|
};
|
|
24
28
|
(async () => {
|
|
25
|
-
program.name('resume').usage('[command] [options]').version(pkg.version).option('-F, --force', 'Used by `publish` and `export` - bypasses schema testing.').option('-t, --theme <theme name>', '
|
|
29
|
+
program.name('resume').usage('[command] [options]').version(pkg.version).option('-F, --force', 'Used by `publish` and `export` - bypasses schema testing.').option('-t, --theme <theme name>', 'Theme used by `export` and `serve` (browse themes at https://jsonresume.org/themes/), or a path starting with . (use . for current directory or ../some/other/dir)', normalizeTheme, 'jsonresume-theme-elegant').option('-f, --format <file type extension>', 'Used by `export`.').option('-r, --resume <resume filename>', "path to the resume in json format. Use '-' to read from stdin", 'resume.json').option('-p, --port <port>', 'Used by `serve` (default: 4000)', 4000).option('-s, --silent', 'Used by `serve` to tell it if open browser auto or not.', false).option('-d, --dir <path>', 'Used by `serve` to indicate a public directory path.', 'public').option('--schema <relativePath>', 'Used by `validate` to validate against a custom schema.');
|
|
26
30
|
program.command('init').description('Initialize a resume.json file').action(async () => {
|
|
27
31
|
await (0, _init.default)({
|
|
28
32
|
resumePath: program.resume
|
|
@@ -40,12 +44,13 @@ const normalizeTheme = (value, defaultValue) => {
|
|
|
40
44
|
resume,
|
|
41
45
|
schema
|
|
42
46
|
});
|
|
47
|
+
console.log(chalk.green(`✓ ${program.resume} is valid`));
|
|
43
48
|
} catch (e) {
|
|
44
49
|
console.error(e.message);
|
|
45
50
|
process.exitCode = 1;
|
|
46
51
|
}
|
|
47
52
|
});
|
|
48
|
-
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 => {
|
|
49
54
|
const resume = await (0, _getResume.default)({
|
|
50
55
|
path: program.resume
|
|
51
56
|
});
|
|
@@ -54,6 +59,15 @@ const normalizeTheme = (value, defaultValue) => {
|
|
|
54
59
|
resume,
|
|
55
60
|
fileName
|
|
56
61
|
}, (err, fileName, format) => {
|
|
62
|
+
if (err) {
|
|
63
|
+
if (err instanceof ThemeNotFoundError) {
|
|
64
|
+
console.error(formatThemeNotFound(err.theme));
|
|
65
|
+
} else {
|
|
66
|
+
console.error(err.message || err);
|
|
67
|
+
}
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
57
71
|
console.log(chalk.green('\nDone! Find your new', format, 'resume at:\n', path.resolve(process.cwd(), fileName + format)));
|
|
58
72
|
});
|
|
59
73
|
});
|
package/build/main.test.js
CHANGED
|
@@ -8,12 +8,15 @@ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e
|
|
|
8
8
|
const exec = (0, _util.promisify)(_child_process.exec);
|
|
9
9
|
const run = async (argv, {
|
|
10
10
|
waitForVolumeExport = true,
|
|
11
|
-
stdin = ''
|
|
11
|
+
stdin = '',
|
|
12
|
+
captureStderr = false
|
|
12
13
|
} = {}) => {
|
|
13
14
|
let volume;
|
|
14
15
|
let exitCode;
|
|
15
16
|
const child = (0, _child_process.spawn)(process.execPath, ['build/test-utils/cli-test-entry.js', ...argv], {
|
|
16
|
-
|
|
17
|
+
// When captureStderr is set, pipe fd 2 so the test can read the
|
|
18
|
+
// friendly error output; otherwise inherit it (existing behavior).
|
|
19
|
+
stdio: ['pipe', 'pipe', captureStderr ? 'pipe' : 2, 'ipc']
|
|
17
20
|
});
|
|
18
21
|
const allChecks = Promise.all([waitForVolumeExport ? new Promise(volumeSet => {
|
|
19
22
|
child.on('message', async message => {
|
|
@@ -30,12 +33,16 @@ const run = async (argv, {
|
|
|
30
33
|
})]);
|
|
31
34
|
child.stdin.write(stdin);
|
|
32
35
|
child.stdin.end();
|
|
33
|
-
const
|
|
36
|
+
const stdoutPromise = (0, _streamToString.default)(child.stdout);
|
|
37
|
+
const stderrPromise = captureStderr ? (0, _streamToString.default)(child.stderr) : Promise.resolve('');
|
|
38
|
+
const stdout = await stdoutPromise;
|
|
39
|
+
const stderr = await stderrPromise;
|
|
34
40
|
await allChecks;
|
|
35
41
|
return {
|
|
36
42
|
volume,
|
|
37
43
|
code: exitCode,
|
|
38
|
-
stdout
|
|
44
|
+
stdout,
|
|
45
|
+
stderr
|
|
39
46
|
};
|
|
40
47
|
};
|
|
41
48
|
describe('cli configuration', () => {
|
|
@@ -53,10 +60,11 @@ describe('cli configuration', () => {
|
|
|
53
60
|
-V, --version output the version number
|
|
54
61
|
-F, --force Used by \`publish\` and \`export\` - bypasses
|
|
55
62
|
schema testing.
|
|
56
|
-
-t, --theme <theme name>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
-t, --theme <theme name> Theme used by \`export\` and \`serve\`
|
|
64
|
+
(browse themes at
|
|
65
|
+
https://jsonresume.org/themes/), or a
|
|
66
|
+
path starting with . (use . for current
|
|
67
|
+
directory or ../some/other/dir) (default:
|
|
60
68
|
"jsonresume-theme-elegant")
|
|
61
69
|
-f, --format <file type extension> Used by \`export\`.
|
|
62
70
|
-r, --resume <resume filename> path to the resume in json format. Use
|
|
@@ -75,9 +83,13 @@ describe('cli configuration', () => {
|
|
|
75
83
|
Commands:
|
|
76
84
|
init Initialize a resume.json file
|
|
77
85
|
validate Validate your resume's schema
|
|
78
|
-
export [fileName] Export locally to .html
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
92
|
+
(https://jsonresume.org/themes/).
|
|
81
93
|
serve Serve resume at http://localhost:4000/
|
|
82
94
|
help [command] display help for command
|
|
83
95
|
"
|
|
@@ -88,16 +100,37 @@ describe('cli configuration', () => {
|
|
|
88
100
|
const {
|
|
89
101
|
stdout
|
|
90
102
|
} = await run(['validate', '--schema', '/test-resumes/only-number-schema.json', '--resume', '/test-resumes/only-number.json']);
|
|
91
|
-
expect(stdout).toMatchInlineSnapshot(`
|
|
103
|
+
expect(stdout).toMatchInlineSnapshot(`
|
|
104
|
+
"✓ /test-resumes/only-number.json is valid
|
|
105
|
+
"
|
|
106
|
+
`);
|
|
92
107
|
});
|
|
93
108
|
it('should fail when trying to validate an invalid resume specified by the --resume option', async () => {
|
|
94
109
|
expect((await run(['validate', '--resume', '/test-resumes/invalid-resume.json'])).code).toEqual(1);
|
|
95
110
|
});
|
|
111
|
+
it('should print the per-field error list (not a success line) when validation fails', async () => {
|
|
112
|
+
const {
|
|
113
|
+
code,
|
|
114
|
+
stdout,
|
|
115
|
+
stderr
|
|
116
|
+
} = await run(['validate', '--resume', '/test-resumes/invalid-resume.json'], {
|
|
117
|
+
waitForVolumeExport: false,
|
|
118
|
+
captureStderr: true
|
|
119
|
+
});
|
|
120
|
+
expect(code).toEqual(1);
|
|
121
|
+
expect(stderr).toContain('Invalid resume:');
|
|
122
|
+
expect(stderr).toContain('data/basics/name must be string');
|
|
123
|
+
// The success line must not appear for an invalid resume.
|
|
124
|
+
expect(stdout).not.toContain('is valid');
|
|
125
|
+
});
|
|
96
126
|
it('should validate a resume specified by the --resume option', async () => {
|
|
97
127
|
const {
|
|
98
128
|
stdout
|
|
99
129
|
} = await run(['validate', '--resume', '/test-resumes/resume.json']);
|
|
100
|
-
expect(stdout).toMatchInlineSnapshot(`
|
|
130
|
+
expect(stdout).toMatchInlineSnapshot(`
|
|
131
|
+
"✓ /test-resumes/resume.json is valid
|
|
132
|
+
"
|
|
133
|
+
`);
|
|
101
134
|
});
|
|
102
135
|
});
|
|
103
136
|
describe('export', () => {
|
|
@@ -139,5 +172,55 @@ describe('cli configuration', () => {
|
|
|
139
172
|
"
|
|
140
173
|
`);
|
|
141
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
|
+
});
|
|
210
|
+
it('should print a friendly, actionable message and exit non-zero when the theme is not installed', async () => {
|
|
211
|
+
const {
|
|
212
|
+
code,
|
|
213
|
+
stderr
|
|
214
|
+
} = await run(['export', '/test-resumes/exported-resume.html', '--resume', '/test-resumes/resume.json', '--theme', 'nonexistent-xyz'], {
|
|
215
|
+
waitForVolumeExport: false,
|
|
216
|
+
captureStderr: true
|
|
217
|
+
});
|
|
218
|
+
expect(code).toEqual(1);
|
|
219
|
+
expect(stderr).toContain("Theme 'nonexistent-xyz' not found.");
|
|
220
|
+
expect(stderr).toContain('npm install jsonresume-theme-nonexistent-xyz');
|
|
221
|
+
expect(stderr).toContain('https://jsonresume.org/themes/');
|
|
222
|
+
// The raw stack trace must not leak to the user.
|
|
223
|
+
expect(stderr).not.toContain('at _default');
|
|
224
|
+
});
|
|
142
225
|
});
|
|
143
226
|
});
|
package/build/render-html.js
CHANGED
|
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
6
|
exports.default = void 0;
|
|
7
|
+
var _themeErrors = require("./theme-errors");
|
|
7
8
|
const tryResolve = (...args) => {
|
|
8
9
|
try {
|
|
9
10
|
return require.resolve(...args);
|
|
@@ -22,7 +23,7 @@ var _default = async ({
|
|
|
22
23
|
paths: [cwd]
|
|
23
24
|
});
|
|
24
25
|
if (!path) {
|
|
25
|
-
throw new
|
|
26
|
+
throw new _themeErrors.ThemeNotFoundError(themePath);
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
if (!path) {
|
|
@@ -36,7 +37,7 @@ var _default = async ({
|
|
|
36
37
|
});
|
|
37
38
|
}
|
|
38
39
|
if (!path) {
|
|
39
|
-
throw new
|
|
40
|
+
throw new _themeErrors.ThemeNotFoundError(themePath);
|
|
40
41
|
}
|
|
41
42
|
const theme = require(path);
|
|
42
43
|
if (typeof theme?.render !== 'function') {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
|
|
5
|
+
// Strip the `jsonresume-theme-` prefix (and any relative-path noise) so the
|
|
6
|
+
// message we show the user matches what they would type with `--theme`.
|
|
7
|
+
const shortThemeName = theme => {
|
|
8
|
+
if (!theme) {
|
|
9
|
+
return theme;
|
|
10
|
+
}
|
|
11
|
+
const base = theme.replace(/^.*jsonresume-theme-/, '');
|
|
12
|
+
return base.replace(/[/\\].*$/, '');
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Error raised when a `--theme` cannot be resolved. Carries the requested
|
|
16
|
+
// theme name so the CLI can render an actionable, stack-trace-free message.
|
|
17
|
+
class ThemeNotFoundError extends Error {
|
|
18
|
+
constructor(theme) {
|
|
19
|
+
super(`Theme not found: ${theme}`);
|
|
20
|
+
this.name = 'ThemeNotFoundError';
|
|
21
|
+
this.theme = theme;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Build the user-facing, actionable message for a missing theme.
|
|
26
|
+
const formatThemeNotFound = theme => {
|
|
27
|
+
const short = shortThemeName(theme);
|
|
28
|
+
const pkg = short.startsWith('jsonresume-theme-') ? short : `jsonresume-theme-${short}`;
|
|
29
|
+
return [chalk.red(`Theme '${short}' not found.`), `Install it: ${chalk.cyan(`npm install ${pkg}`)}`, `Browse themes: ${chalk.cyan('https://jsonresume.org/themes/')}`].join('\n');
|
|
30
|
+
};
|
|
31
|
+
module.exports = {
|
|
32
|
+
ThemeNotFoundError,
|
|
33
|
+
formatThemeNotFound,
|
|
34
|
+
shortThemeName
|
|
35
|
+
};
|