resume-cli 3.3.0 → 3.5.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 +54 -5
- 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 +6 -3
- package/build/main.test.js +49 -6
- package/build/validate-errors.js +192 -0
- package/build/validate-errors.test.js +150 -0
- package/build/validate.js +5 -11
- package/build/validate.test.js +55 -5
- 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`
|
|
@@ -39,13 +39,62 @@ Complete the `resume.json` with your text editor. Be sure to follow the schema (
|
|
|
39
39
|
|
|
40
40
|
### `resume validate`
|
|
41
41
|
|
|
42
|
-
Validates your `resume.json` against our schema to ensure it complies with the standard.
|
|
42
|
+
Validates your `resume.json` against our schema to ensure it complies with the standard.
|
|
43
|
+
|
|
44
|
+
On success it prints a one-line summary with the candidate name and a count of
|
|
45
|
+
each populated section:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
✓ resume.json is valid (Ada Lovelace — 1 work, 1 education, 2 skills)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
On failure it exits non-zero and prints one annotated block per problem, naming
|
|
52
|
+
the exact JSON path, the failing rule, the offending value, and a one-line hint:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
Invalid resume: 2 problems found
|
|
56
|
+
|
|
57
|
+
✖ data/basics/email must match format "email"
|
|
58
|
+
at: basics.email
|
|
59
|
+
rule: format (expected email)
|
|
60
|
+
found: "nope" (string)
|
|
61
|
+
hint: "basics.email" must be a valid email address, e.g. "you@example.com".
|
|
62
|
+
|
|
63
|
+
✖ data/work/0/startDate must match pattern "..."
|
|
64
|
+
at: work[0].startDate
|
|
65
|
+
rule: pattern
|
|
66
|
+
found: "13-2020" (string)
|
|
67
|
+
hint: "work[0].startDate" must be an ISO-8601 date: YYYY, YYYY-MM, or YYYY-MM-DD.
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Validate against a custom schema with `--schema <path>`.
|
|
43
71
|
|
|
44
72
|
### `resume export [fileName]`
|
|
45
73
|
|
|
46
|
-
Exports your resume
|
|
74
|
+
Exports your resume to one of four formats:
|
|
75
|
+
|
|
76
|
+
- `.html` / `.pdf` — stylized output rendered through a theme.
|
|
77
|
+
- `.md` — clean Markdown (one heading per section). No theme required.
|
|
78
|
+
- `.txt` — readable plain text. No theme required.
|
|
79
|
+
|
|
80
|
+
The format is inferred from the file extension, or set it explicitly with
|
|
81
|
+
`--format`:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
resume export resume.md # Markdown (inferred)
|
|
85
|
+
resume export resume.txt --format text # plain text (explicit)
|
|
86
|
+
resume export resume.html --theme even # themed HTML
|
|
87
|
+
resume export resume.pdf --theme even # themed PDF
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`--format` accepts `html`, `pdf`, `markdown` (or `md`) and `text` (or `txt`).
|
|
91
|
+
|
|
92
|
+
The Markdown and text formats render every JSON Resume section that is present
|
|
93
|
+
(basics, work, volunteer, education, awards, publications, skills, languages,
|
|
94
|
+
interests, references, projects) and skip any that are missing — so no theme
|
|
95
|
+
install is needed.
|
|
47
96
|
|
|
48
|
-
A list of available themes can be found here:
|
|
97
|
+
A list of available themes (for `.html` / `.pdf`) can be found here:
|
|
49
98
|
https://jsonresume.org/themes/
|
|
50
99
|
|
|
51
100
|
Please npm install the theme you wish to use before attempting to export it.
|
|
@@ -53,7 +102,7 @@ Please npm install the theme you wish to use before attempting to export it.
|
|
|
53
102
|
Options:
|
|
54
103
|
|
|
55
104
|
- `--format <file type>` Example: `--format pdf`
|
|
56
|
-
- `--theme <name>` Example: `--theme even`
|
|
105
|
+
- `--theme <name>` Example: `--theme even` (only used for `.html` / `.pdf`)
|
|
57
106
|
|
|
58
107
|
### `resume serve`
|
|
59
108
|
|
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
|
@@ -17,6 +17,9 @@ const {
|
|
|
17
17
|
ThemeNotFoundError,
|
|
18
18
|
formatThemeNotFound
|
|
19
19
|
} = require('./theme-errors');
|
|
20
|
+
const {
|
|
21
|
+
formatOkSummary
|
|
22
|
+
} = require('./validate-errors');
|
|
20
23
|
const normalizeTheme = (value, defaultValue) => {
|
|
21
24
|
const theme = value || defaultValue;
|
|
22
25
|
// TODO - This is not great, but bypasses this function if it is a relative path
|
|
@@ -44,13 +47,13 @@ const normalizeTheme = (value, defaultValue) => {
|
|
|
44
47
|
resume,
|
|
45
48
|
schema
|
|
46
49
|
});
|
|
47
|
-
console.log(chalk.green(
|
|
50
|
+
console.log(chalk.green(formatOkSummary(resume, program.resume)));
|
|
48
51
|
} catch (e) {
|
|
49
|
-
console.error(e.message);
|
|
52
|
+
console.error(chalk.red(e.message));
|
|
50
53
|
process.exitCode = 1;
|
|
51
54
|
}
|
|
52
55
|
});
|
|
53
|
-
program.command('export [fileName]').description('Export locally to .html or .
|
|
56
|
+
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
57
|
const resume = await (0, _getResume.default)({
|
|
55
58
|
path: program.resume
|
|
56
59
|
});
|
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
|
|
@@ -106,7 +108,7 @@ describe('cli configuration', () => {
|
|
|
106
108
|
it('should fail when trying to validate an invalid resume specified by the --resume option', async () => {
|
|
107
109
|
expect((await run(['validate', '--resume', '/test-resumes/invalid-resume.json'])).code).toEqual(1);
|
|
108
110
|
});
|
|
109
|
-
it('should print
|
|
111
|
+
it('should print precise, path-pointed errors (not a success line) when validation fails', async () => {
|
|
110
112
|
const {
|
|
111
113
|
code,
|
|
112
114
|
stdout,
|
|
@@ -117,7 +119,13 @@ describe('cli configuration', () => {
|
|
|
117
119
|
});
|
|
118
120
|
expect(code).toEqual(1);
|
|
119
121
|
expect(stderr).toContain('Invalid resume:');
|
|
122
|
+
// The classic data-path phrasing is preserved...
|
|
120
123
|
expect(stderr).toContain('data/basics/name must be string');
|
|
124
|
+
// ...alongside the new annotated path/rule/value/hint lines.
|
|
125
|
+
expect(stderr).toContain('at: basics.name');
|
|
126
|
+
expect(stderr).toContain('rule: type (expected string)');
|
|
127
|
+
expect(stderr).toContain('found: 123 (number)');
|
|
128
|
+
expect(stderr).toContain('must be of type string');
|
|
121
129
|
// The success line must not appear for an invalid resume.
|
|
122
130
|
expect(stdout).not.toContain('is valid');
|
|
123
131
|
});
|
|
@@ -126,7 +134,7 @@ describe('cli configuration', () => {
|
|
|
126
134
|
stdout
|
|
127
135
|
} = await run(['validate', '--resume', '/test-resumes/resume.json']);
|
|
128
136
|
expect(stdout).toMatchInlineSnapshot(`
|
|
129
|
-
"✓ /test-resumes/resume.json is valid
|
|
137
|
+
"✓ /test-resumes/resume.json is valid (thomas)
|
|
130
138
|
"
|
|
131
139
|
`);
|
|
132
140
|
});
|
|
@@ -170,6 +178,41 @@ describe('cli configuration', () => {
|
|
|
170
178
|
"
|
|
171
179
|
`);
|
|
172
180
|
});
|
|
181
|
+
it('should export markdown without requiring a theme', async () => {
|
|
182
|
+
const {
|
|
183
|
+
stdout,
|
|
184
|
+
volume
|
|
185
|
+
} = await run(['export', '/test-resumes/exported-resume.md', '--resume', '-', '--format', 'markdown'], {
|
|
186
|
+
stdin: JSON.stringify({
|
|
187
|
+
basics: {
|
|
188
|
+
name: 'thomas-md'
|
|
189
|
+
},
|
|
190
|
+
skills: [{
|
|
191
|
+
name: 'JS',
|
|
192
|
+
keywords: ['Node']
|
|
193
|
+
}]
|
|
194
|
+
})
|
|
195
|
+
});
|
|
196
|
+
const output = volume['/test-resumes/exported-resume.md'];
|
|
197
|
+
expect(output).toEqual(expect.stringContaining('# thomas-md'));
|
|
198
|
+
expect(output).toEqual(expect.stringContaining('## Skills'));
|
|
199
|
+
expect(stdout).toContain('.md resume at:');
|
|
200
|
+
});
|
|
201
|
+
it('should export plain text without requiring a theme', async () => {
|
|
202
|
+
const {
|
|
203
|
+
stdout,
|
|
204
|
+
volume
|
|
205
|
+
} = await run(['export', '/test-resumes/exported-resume.txt', '--resume', '-', '--format', 'text'], {
|
|
206
|
+
stdin: JSON.stringify({
|
|
207
|
+
basics: {
|
|
208
|
+
name: 'thomas-txt'
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
});
|
|
212
|
+
const output = volume['/test-resumes/exported-resume.txt'];
|
|
213
|
+
expect(output).toEqual(expect.stringContaining('thomas-txt'));
|
|
214
|
+
expect(stdout).toContain('.txt resume at:');
|
|
215
|
+
});
|
|
173
216
|
it('should print a friendly, actionable message and exit non-zero when the theme is not installed', async () => {
|
|
174
217
|
const {
|
|
175
218
|
code,
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Turns raw Ajv (draft-07) error objects into precise, actionable output.
|
|
4
|
+
//
|
|
5
|
+
// For each failure we surface four things:
|
|
6
|
+
// - the JSON path in dot/bracket notation (e.g. work[2].startDate)
|
|
7
|
+
// - the failing rule (the Ajv keyword, lightly humanised)
|
|
8
|
+
// - the offending value (with its JS type)
|
|
9
|
+
// - a one-line, human hint on how to fix it
|
|
10
|
+
//
|
|
11
|
+
// The first physical line of each error is kept as `<dataPath> <message>`
|
|
12
|
+
// (e.g. `data/basics/name must be string`) so existing tooling that greps the
|
|
13
|
+
// output for that classic phrasing keeps working.
|
|
14
|
+
|
|
15
|
+
// "/work/1/startDate" -> "work[1].startDate"; the root is "(root)".
|
|
16
|
+
const toDotPath = instancePath => {
|
|
17
|
+
if (!instancePath) {
|
|
18
|
+
return '(root)';
|
|
19
|
+
}
|
|
20
|
+
const segments = instancePath.split('/').filter(Boolean);
|
|
21
|
+
return segments.map(segment => /^\d+$/.test(segment) ? `[${segment}]` : `.${segment}`).join('').replace(/^\./, '');
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// "data" + instancePath, matching Ajv's classic dataPath phrasing.
|
|
25
|
+
const toDataPath = instancePath => instancePath ? `data${instancePath}` : 'data';
|
|
26
|
+
const previewValue = value => {
|
|
27
|
+
if (value === undefined) {
|
|
28
|
+
return 'undefined';
|
|
29
|
+
}
|
|
30
|
+
let serialised;
|
|
31
|
+
try {
|
|
32
|
+
serialised = JSON.stringify(value);
|
|
33
|
+
} catch {
|
|
34
|
+
serialised = String(value);
|
|
35
|
+
}
|
|
36
|
+
if (serialised === undefined) {
|
|
37
|
+
serialised = String(value);
|
|
38
|
+
}
|
|
39
|
+
if (serialised.length > 80) {
|
|
40
|
+
serialised = `${serialised.slice(0, 77)}...`;
|
|
41
|
+
}
|
|
42
|
+
const type = Array.isArray(value) ? 'array' : typeof value;
|
|
43
|
+
return `${serialised} (${type})`;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Resolve the value the error points at by walking the instancePath.
|
|
47
|
+
const valueAtPath = (root, instancePath) => {
|
|
48
|
+
if (!instancePath) {
|
|
49
|
+
return root;
|
|
50
|
+
}
|
|
51
|
+
const segments = instancePath.split('/').filter(Boolean);
|
|
52
|
+
let current = root;
|
|
53
|
+
for (const segment of segments) {
|
|
54
|
+
if (current == null) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
// Ajv JSON-pointer-escapes "/" as "~1" and "~" as "~0".
|
|
58
|
+
const key = segment.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
59
|
+
current = current[key];
|
|
60
|
+
}
|
|
61
|
+
return current;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Map an Ajv keyword to a short human description of the rule it enforces.
|
|
65
|
+
const describeRule = error => {
|
|
66
|
+
const {
|
|
67
|
+
keyword,
|
|
68
|
+
params
|
|
69
|
+
} = error;
|
|
70
|
+
switch (keyword) {
|
|
71
|
+
case 'type':
|
|
72
|
+
return `type (expected ${params.type})`;
|
|
73
|
+
case 'format':
|
|
74
|
+
return `format (expected ${params.format})`;
|
|
75
|
+
case 'pattern':
|
|
76
|
+
return 'pattern';
|
|
77
|
+
case 'required':
|
|
78
|
+
return `required (${params.missingProperty})`;
|
|
79
|
+
case 'additionalProperties':
|
|
80
|
+
return `additionalProperties (${params.additionalProperty})`;
|
|
81
|
+
case 'enum':
|
|
82
|
+
return `enum (one of ${JSON.stringify(params.allowedValues)})`;
|
|
83
|
+
case 'minimum':
|
|
84
|
+
case 'maximum':
|
|
85
|
+
case 'minLength':
|
|
86
|
+
case 'maxLength':
|
|
87
|
+
case 'minItems':
|
|
88
|
+
case 'maxItems':
|
|
89
|
+
return `${keyword} (${params.limit})`;
|
|
90
|
+
default:
|
|
91
|
+
return keyword;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// A short, plain-language fix for the failing field.
|
|
96
|
+
const hintFor = (error, dotPath) => {
|
|
97
|
+
const field = dotPath === '(root)' ? 'the resume' : `"${dotPath}"`;
|
|
98
|
+
const {
|
|
99
|
+
keyword,
|
|
100
|
+
params
|
|
101
|
+
} = error;
|
|
102
|
+
switch (keyword) {
|
|
103
|
+
case 'type':
|
|
104
|
+
return `${field} must be of type ${params.type}.`;
|
|
105
|
+
case 'format':
|
|
106
|
+
if (params.format === 'email') {
|
|
107
|
+
return `${field} must be a valid email address, e.g. "you@example.com".`;
|
|
108
|
+
}
|
|
109
|
+
if (params.format === 'uri') {
|
|
110
|
+
return `${field} must be a full URL, e.g. "https://example.com".`;
|
|
111
|
+
}
|
|
112
|
+
if (params.format === 'date') {
|
|
113
|
+
return `${field} must be an ISO date, e.g. "2024-01-31".`;
|
|
114
|
+
}
|
|
115
|
+
return `${field} must match the "${params.format}" format.`;
|
|
116
|
+
case 'pattern':
|
|
117
|
+
// The JSON Resume date fields share one ISO-8601 pattern.
|
|
118
|
+
if (/iso8601/.test(error.schemaPath || '')) {
|
|
119
|
+
return `${field} must be an ISO-8601 date: YYYY, YYYY-MM, or YYYY-MM-DD.`;
|
|
120
|
+
}
|
|
121
|
+
return `${field} does not match the required pattern.`;
|
|
122
|
+
case 'required':
|
|
123
|
+
return `add the missing "${params.missingProperty}" property to ${field}.`;
|
|
124
|
+
case 'additionalProperties':
|
|
125
|
+
return `remove the unknown property "${params.additionalProperty}" from ${field} (or check for a typo).`;
|
|
126
|
+
case 'enum':
|
|
127
|
+
return `${field} must be one of: ${params.allowedValues.map(v => JSON.stringify(v)).join(', ')}.`;
|
|
128
|
+
case 'minLength':
|
|
129
|
+
return `${field} must be at least ${params.limit} character(s) long.`;
|
|
130
|
+
case 'maxLength':
|
|
131
|
+
return `${field} must be at most ${params.limit} character(s) long.`;
|
|
132
|
+
case 'minItems':
|
|
133
|
+
return `${field} must contain at least ${params.limit} item(s).`;
|
|
134
|
+
case 'minimum':
|
|
135
|
+
return `${field} must be >= ${params.limit}.`;
|
|
136
|
+
case 'maximum':
|
|
137
|
+
return `${field} must be <= ${params.limit}.`;
|
|
138
|
+
default:
|
|
139
|
+
return `${field} ${error.message}.`;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Format a single Ajv error into a multi-line, annotated block.
|
|
144
|
+
const formatSingleError = (error, root) => {
|
|
145
|
+
const dataPath = toDataPath(error.instancePath);
|
|
146
|
+
const dotPath = toDotPath(error.instancePath);
|
|
147
|
+
// For `required`/`additionalProperties` the offending value is the *parent*
|
|
148
|
+
// object; show that, since the named property itself is missing/extra.
|
|
149
|
+
const value = valueAtPath(root, error.instancePath);
|
|
150
|
+
const lines = [
|
|
151
|
+
// Kept verbatim so `data/basics/name must be string`-style greps work.
|
|
152
|
+
`✖ ${dataPath} ${error.message}`, ` at: ${dotPath}`, ` rule: ${describeRule(error)}`];
|
|
153
|
+
if (error.keyword !== 'required') {
|
|
154
|
+
lines.push(` found: ${previewValue(value)}`);
|
|
155
|
+
}
|
|
156
|
+
lines.push(` hint: ${hintFor(error, dotPath)}`);
|
|
157
|
+
return lines.join('\n');
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Public: build the full failure message.
|
|
161
|
+
// formatErrors(ajvErrors, resume) -> string
|
|
162
|
+
const formatErrors = (errors = [], root) => {
|
|
163
|
+
const count = errors.length;
|
|
164
|
+
const heading = count === 1 ? 'Invalid resume: 1 problem found' : `Invalid resume: ${count} problems found`;
|
|
165
|
+
const blocks = errors.map(error => formatSingleError(error, root));
|
|
166
|
+
return `${heading}\n\n ${blocks.join('\n\n ')}`;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Build a one-line OK summary that counts the populated array sections.
|
|
170
|
+
const formatOkSummary = (resume, label) => {
|
|
171
|
+
const sections = ['work', 'volunteer', 'education', 'awards', 'certificates', 'publications', 'skills', 'languages', 'interests', 'references', 'projects'];
|
|
172
|
+
const counts = sections.filter(key => Array.isArray(resume && resume[key]) && resume[key].length).map(key => `${resume[key].length} ${key}`);
|
|
173
|
+
const name = resume && resume.basics && typeof resume.basics.name === 'string' ? resume.basics.name : null;
|
|
174
|
+
const parts = [];
|
|
175
|
+
if (name) {
|
|
176
|
+
parts.push(name);
|
|
177
|
+
}
|
|
178
|
+
if (counts.length) {
|
|
179
|
+
parts.push(counts.join(', '));
|
|
180
|
+
}
|
|
181
|
+
const detail = parts.length ? ` (${parts.join(' — ')})` : '';
|
|
182
|
+
const subject = label ? `${label} is valid` : 'resume is valid';
|
|
183
|
+
return `✓ ${subject}${detail}`;
|
|
184
|
+
};
|
|
185
|
+
module.exports = {
|
|
186
|
+
formatErrors,
|
|
187
|
+
formatOkSummary,
|
|
188
|
+
// exported for unit testing of the path/value/hint helpers
|
|
189
|
+
toDotPath,
|
|
190
|
+
previewValue,
|
|
191
|
+
hintFor
|
|
192
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
formatErrors,
|
|
5
|
+
formatOkSummary,
|
|
6
|
+
toDotPath,
|
|
7
|
+
previewValue
|
|
8
|
+
} = require('./validate-errors');
|
|
9
|
+
|
|
10
|
+
// A representative slice of real Ajv (draft-07) error objects, as produced by
|
|
11
|
+
// `ajv.compile(schema)` against a resume with several distinct violations.
|
|
12
|
+
const sampleAjvErrors = [{
|
|
13
|
+
instancePath: '/basics/name',
|
|
14
|
+
schemaPath: '#/properties/basics/properties/name/type',
|
|
15
|
+
keyword: 'type',
|
|
16
|
+
params: {
|
|
17
|
+
type: 'string'
|
|
18
|
+
},
|
|
19
|
+
message: 'must be string'
|
|
20
|
+
}, {
|
|
21
|
+
instancePath: '/basics/email',
|
|
22
|
+
schemaPath: '#/properties/basics/properties/email/format',
|
|
23
|
+
keyword: 'format',
|
|
24
|
+
params: {
|
|
25
|
+
format: 'email'
|
|
26
|
+
},
|
|
27
|
+
message: 'must match format "email"'
|
|
28
|
+
}, {
|
|
29
|
+
instancePath: '/work/2/startDate',
|
|
30
|
+
schemaPath: '#/definitions/iso8601/pattern',
|
|
31
|
+
keyword: 'pattern',
|
|
32
|
+
params: {
|
|
33
|
+
pattern: '^(...)$'
|
|
34
|
+
},
|
|
35
|
+
message: 'must match pattern "..."'
|
|
36
|
+
}];
|
|
37
|
+
const sampleResume = {
|
|
38
|
+
basics: {
|
|
39
|
+
name: 123,
|
|
40
|
+
email: 'not-an-email'
|
|
41
|
+
},
|
|
42
|
+
work: [{}, {}, {
|
|
43
|
+
startDate: '13-2020'
|
|
44
|
+
}]
|
|
45
|
+
};
|
|
46
|
+
describe('toDotPath', () => {
|
|
47
|
+
it('converts a JSON pointer to dot/bracket notation', () => {
|
|
48
|
+
expect(toDotPath('/work/2/startDate')).toBe('work[2].startDate');
|
|
49
|
+
expect(toDotPath('/basics/name')).toBe('basics.name');
|
|
50
|
+
});
|
|
51
|
+
it('labels the document root', () => {
|
|
52
|
+
expect(toDotPath('')).toBe('(root)');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('previewValue', () => {
|
|
56
|
+
it('shows the value with its JS type', () => {
|
|
57
|
+
expect(previewValue(123)).toBe('123 (number)');
|
|
58
|
+
expect(previewValue('hi')).toBe('"hi" (string)');
|
|
59
|
+
expect(previewValue([1, 2])).toBe('[1,2] (array)');
|
|
60
|
+
});
|
|
61
|
+
it('reports undefined for missing values', () => {
|
|
62
|
+
expect(previewValue(undefined)).toBe('undefined');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('formatErrors', () => {
|
|
66
|
+
const output = formatErrors(sampleAjvErrors, sampleResume);
|
|
67
|
+
it('counts the problems in the heading', () => {
|
|
68
|
+
expect(output).toContain('Invalid resume: 3 problems found');
|
|
69
|
+
});
|
|
70
|
+
it('names the right JSON path for each error', () => {
|
|
71
|
+
expect(output).toContain('at: basics.name');
|
|
72
|
+
expect(output).toContain('at: basics.email');
|
|
73
|
+
expect(output).toContain('at: work[2].startDate');
|
|
74
|
+
});
|
|
75
|
+
it('reports the failing rule', () => {
|
|
76
|
+
expect(output).toContain('rule: type (expected string)');
|
|
77
|
+
expect(output).toContain('rule: format (expected email)');
|
|
78
|
+
expect(output).toContain('rule: pattern');
|
|
79
|
+
});
|
|
80
|
+
it('shows the offending value with its type', () => {
|
|
81
|
+
expect(output).toContain('found: 123 (number)');
|
|
82
|
+
expect(output).toContain('found: "not-an-email" (string)');
|
|
83
|
+
expect(output).toContain('found: "13-2020" (string)');
|
|
84
|
+
});
|
|
85
|
+
it('gives a one-line human hint per error', () => {
|
|
86
|
+
expect(output).toContain('"basics.name" must be of type string.');
|
|
87
|
+
expect(output).toContain('must be a valid email address');
|
|
88
|
+
expect(output).toContain('must be an ISO-8601 date');
|
|
89
|
+
});
|
|
90
|
+
it('preserves the classic data-path phrasing for greppability', () => {
|
|
91
|
+
expect(output).toContain('data/basics/name must be string');
|
|
92
|
+
});
|
|
93
|
+
it('uses the singular "problem" for a single error', () => {
|
|
94
|
+
const single = formatErrors([sampleAjvErrors[0]], sampleResume);
|
|
95
|
+
expect(single).toContain('Invalid resume: 1 problem found');
|
|
96
|
+
});
|
|
97
|
+
it('handles required + additionalProperties without throwing', () => {
|
|
98
|
+
const out = formatErrors([{
|
|
99
|
+
instancePath: '/work/0',
|
|
100
|
+
schemaPath: '#/.../required',
|
|
101
|
+
keyword: 'required',
|
|
102
|
+
params: {
|
|
103
|
+
missingProperty: 'name'
|
|
104
|
+
},
|
|
105
|
+
message: "must have required property 'name'"
|
|
106
|
+
}, {
|
|
107
|
+
instancePath: '/basics',
|
|
108
|
+
schemaPath: '#/.../additionalProperties',
|
|
109
|
+
keyword: 'additionalProperties',
|
|
110
|
+
params: {
|
|
111
|
+
additionalProperty: 'nme'
|
|
112
|
+
},
|
|
113
|
+
message: 'must NOT have additional properties'
|
|
114
|
+
}], {
|
|
115
|
+
work: [{}],
|
|
116
|
+
basics: {
|
|
117
|
+
nme: 'x'
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
expect(out).toContain('rule: required (name)');
|
|
121
|
+
expect(out).toContain('add the missing "name" property');
|
|
122
|
+
expect(out).toContain('rule: additionalProperties (nme)');
|
|
123
|
+
expect(out).toContain('remove the unknown property "nme"');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe('formatOkSummary', () => {
|
|
127
|
+
it('summarises populated array sections and the candidate name', () => {
|
|
128
|
+
const summary = formatOkSummary({
|
|
129
|
+
basics: {
|
|
130
|
+
name: 'Thomas'
|
|
131
|
+
},
|
|
132
|
+
work: [{}, {}],
|
|
133
|
+
education: [{}],
|
|
134
|
+
skills: [{}, {}, {}]
|
|
135
|
+
}, 'resume.json');
|
|
136
|
+
expect(summary).toContain('✓ resume.json is valid');
|
|
137
|
+
expect(summary).toContain('Thomas');
|
|
138
|
+
expect(summary).toContain('2 work');
|
|
139
|
+
expect(summary).toContain('1 education');
|
|
140
|
+
expect(summary).toContain('3 skills');
|
|
141
|
+
});
|
|
142
|
+
it('omits the detail block when there is nothing to count', () => {
|
|
143
|
+
expect(formatOkSummary({
|
|
144
|
+
basics: {}
|
|
145
|
+
}, 'resume.json')).toBe('✓ resume.json is valid');
|
|
146
|
+
});
|
|
147
|
+
it('handles a non-object resume (custom schema override)', () => {
|
|
148
|
+
expect(formatOkSummary(123, 'only-number.json')).toBe('✓ only-number.json is valid');
|
|
149
|
+
});
|
|
150
|
+
});
|
package/build/validate.js
CHANGED
|
@@ -6,16 +6,8 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
6
6
|
exports.default = void 0;
|
|
7
7
|
var _ajv = _interopRequireDefault(require("ajv"));
|
|
8
8
|
var _ajvFormats = _interopRequireDefault(require("ajv-formats"));
|
|
9
|
+
var _validateErrors = require("./validate-errors");
|
|
9
10
|
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
10
|
-
// Format a single Ajv error into a readable `field message` line.
|
|
11
|
-
const formatError = error => {
|
|
12
|
-
// instancePath is like "/basics/name"; the root is an empty string.
|
|
13
|
-
const field = error.instancePath ? `data${error.instancePath}` : 'data';
|
|
14
|
-
if (error.keyword === 'additionalProperties') {
|
|
15
|
-
return `${field} ${error.message} (${error.params.additionalProperty})`;
|
|
16
|
-
}
|
|
17
|
-
return `${field} ${error.message}`;
|
|
18
|
-
};
|
|
19
11
|
var _default = async ({
|
|
20
12
|
resume,
|
|
21
13
|
schema
|
|
@@ -32,7 +24,9 @@ var _default = async ({
|
|
|
32
24
|
if (validateFn(resume)) {
|
|
33
25
|
return true;
|
|
34
26
|
}
|
|
35
|
-
const
|
|
36
|
-
|
|
27
|
+
const error = new Error((0, _validateErrors.formatErrors)(validateFn.errors || [], resume));
|
|
28
|
+
// Expose the raw Ajv errors so callers can build machine-readable output.
|
|
29
|
+
error.validationErrors = validateFn.errors || [];
|
|
30
|
+
throw error;
|
|
37
31
|
};
|
|
38
32
|
exports.default = _default;
|
package/build/validate.test.js
CHANGED
|
@@ -16,7 +16,7 @@ describe('validate', () => {
|
|
|
16
16
|
schema: defaultSchema
|
|
17
17
|
});
|
|
18
18
|
});
|
|
19
|
-
it('should throw a
|
|
19
|
+
it('should throw a precise, path-pointed error for an invalid resume object', async () => {
|
|
20
20
|
await expect((0, _validate.default)({
|
|
21
21
|
resume: {
|
|
22
22
|
basics: {
|
|
@@ -25,10 +25,55 @@ describe('validate', () => {
|
|
|
25
25
|
},
|
|
26
26
|
schema: defaultSchema
|
|
27
27
|
})).rejects.toMatchInlineSnapshot(`
|
|
28
|
-
[Error: Invalid resume:
|
|
29
|
-
|
|
28
|
+
[Error: Invalid resume: 1 problem found
|
|
29
|
+
|
|
30
|
+
✖ data/basics/name must be string
|
|
31
|
+
at: basics.name
|
|
32
|
+
rule: type (expected string)
|
|
33
|
+
found: 123 (number)
|
|
34
|
+
hint: "basics.name" must be of type string.]
|
|
30
35
|
`);
|
|
31
36
|
});
|
|
37
|
+
it('should report every distinct violation with its own JSON path', async () => {
|
|
38
|
+
let thrown;
|
|
39
|
+
try {
|
|
40
|
+
await (0, _validate.default)({
|
|
41
|
+
resume: {
|
|
42
|
+
basics: {
|
|
43
|
+
name: 123,
|
|
44
|
+
email: 'not-an-email'
|
|
45
|
+
},
|
|
46
|
+
work: [{
|
|
47
|
+
name: 'Acme',
|
|
48
|
+
startDate: '13-2020'
|
|
49
|
+
}]
|
|
50
|
+
},
|
|
51
|
+
schema: defaultSchema
|
|
52
|
+
});
|
|
53
|
+
} catch (e) {
|
|
54
|
+
thrown = e;
|
|
55
|
+
}
|
|
56
|
+
expect(thrown).toBeDefined();
|
|
57
|
+
expect(thrown.message).toContain('Invalid resume: 3 problems found');
|
|
58
|
+
// Each error names the right JSON path in dot/bracket notation.
|
|
59
|
+
expect(thrown.message).toContain('at: basics.name');
|
|
60
|
+
expect(thrown.message).toContain('at: basics.email');
|
|
61
|
+
expect(thrown.message).toContain('at: work[0].startDate');
|
|
62
|
+
// ...the failing rule...
|
|
63
|
+
expect(thrown.message).toContain('rule: type (expected string)');
|
|
64
|
+
expect(thrown.message).toContain('rule: format (expected email)');
|
|
65
|
+
// ...the offending value...
|
|
66
|
+
expect(thrown.message).toContain('found: 123 (number)');
|
|
67
|
+
expect(thrown.message).toContain('found: "not-an-email" (string)');
|
|
68
|
+
// ...and a human hint.
|
|
69
|
+
expect(thrown.message).toContain('must be a valid email address');
|
|
70
|
+
expect(thrown.message).toContain('must be an ISO-8601 date');
|
|
71
|
+
// The classic `data/...` phrasing is preserved for greppability.
|
|
72
|
+
expect(thrown.message).toContain('data/basics/name must be string');
|
|
73
|
+
// Raw Ajv errors are exposed for machine-readable callers.
|
|
74
|
+
expect(Array.isArray(thrown.validationErrors)).toBe(true);
|
|
75
|
+
expect(thrown.validationErrors).toHaveLength(3);
|
|
76
|
+
});
|
|
32
77
|
it('should accept a schema override', async () => {
|
|
33
78
|
await (0, _validate.default)({
|
|
34
79
|
resume: 123,
|
|
@@ -42,8 +87,13 @@ describe('validate', () => {
|
|
|
42
87
|
type: 'number'
|
|
43
88
|
}
|
|
44
89
|
})).rejects.toMatchInlineSnapshot(`
|
|
45
|
-
[Error: Invalid resume:
|
|
46
|
-
|
|
90
|
+
[Error: Invalid resume: 1 problem found
|
|
91
|
+
|
|
92
|
+
✖ data must be number
|
|
93
|
+
at: (root)
|
|
94
|
+
rule: type (expected number)
|
|
95
|
+
found: "thomas" (string)
|
|
96
|
+
hint: the resume must be of type number.]
|
|
47
97
|
`);
|
|
48
98
|
});
|
|
49
99
|
});
|