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 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 in a stylized HTML or PDF format.
46
+ Exports your resume to one of four formats:
47
47
 
48
- A list of available themes can be found here:
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
- console.log(err);
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
  }
@@ -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
- if (error) {
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
- console.log('You have to install this theme relative to the folder to use it e.g. `npm install ' + theme + '`');
63
- process.exit();
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
- const html = await (0, _renderHtml.default)({
68
- resume: resumeJson,
69
- themePath
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>', 'Specify theme used by `export` and `serve` or specify 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.');
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 .pdf. Supply a --format <file format> flag and argument to specify export format.').action(async fileName => {
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
  });
@@ -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
- stdio: ['pipe', 'pipe', 2, 'ipc']
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 stdout = await (0, _streamToString.default)(child.stdout);
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> Specify theme used by \`export\` and
57
- \`serve\` or specify a path starting with .
58
- (use . for current directory or
59
- ../some/other/dir) (default:
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 or .pdf. Supply
79
- a --format <file format> flag and
80
- argument to specify export format.
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
  });
@@ -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 Error(`Theme ${themePath} could not be resolved relative to ${cwd}`);
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 Error(`theme path ${themePath} could not be resolved from current working directory`);
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resume-cli",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "description": "The JSON Resume command line interface",
5
5
  "main": "build/main.js",
6
6
  "engines": {