resume-cli 3.4.0 → 3.6.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 +51 -1
- package/build/list-themes.js +139 -0
- package/build/list-themes.test.js +168 -0
- package/build/main.js +31 -2
- package/build/main.test.js +29 -2
- 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
|
@@ -25,6 +25,7 @@ npm install -g resume-cli
|
|
|
25
25
|
| init | Initialize a `resume.json` file. |
|
|
26
26
|
| validate | Schema validation test your `resume.json`. |
|
|
27
27
|
| export path/to/file.html | Export to `.html`, `.pdf`, `.md` or `.txt`. |
|
|
28
|
+
| themes | List the JSON Resume themes installed in `node_modules`. |
|
|
28
29
|
| serve | Serve resume at `http://localhost:4000/`. |
|
|
29
30
|
|
|
30
31
|
### `resume --help`
|
|
@@ -39,7 +40,35 @@ Complete the `resume.json` with your text editor. Be sure to follow the schema (
|
|
|
39
40
|
|
|
40
41
|
### `resume validate`
|
|
41
42
|
|
|
42
|
-
Validates your `resume.json` against our schema to ensure it complies with the standard.
|
|
43
|
+
Validates your `resume.json` against our schema to ensure it complies with the standard.
|
|
44
|
+
|
|
45
|
+
On success it prints a one-line summary with the candidate name and a count of
|
|
46
|
+
each populated section:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
✓ resume.json is valid (Ada Lovelace — 1 work, 1 education, 2 skills)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
On failure it exits non-zero and prints one annotated block per problem, naming
|
|
53
|
+
the exact JSON path, the failing rule, the offending value, and a one-line hint:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
Invalid resume: 2 problems found
|
|
57
|
+
|
|
58
|
+
✖ data/basics/email must match format "email"
|
|
59
|
+
at: basics.email
|
|
60
|
+
rule: format (expected email)
|
|
61
|
+
found: "nope" (string)
|
|
62
|
+
hint: "basics.email" must be a valid email address, e.g. "you@example.com".
|
|
63
|
+
|
|
64
|
+
✖ data/work/0/startDate must match pattern "..."
|
|
65
|
+
at: work[0].startDate
|
|
66
|
+
rule: pattern
|
|
67
|
+
found: "13-2020" (string)
|
|
68
|
+
hint: "work[0].startDate" must be an ISO-8601 date: YYYY, YYYY-MM, or YYYY-MM-DD.
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Validate against a custom schema with `--schema <path>`.
|
|
43
72
|
|
|
44
73
|
### `resume export [fileName]`
|
|
45
74
|
|
|
@@ -76,6 +105,27 @@ Options:
|
|
|
76
105
|
- `--format <file type>` Example: `--format pdf`
|
|
77
106
|
- `--theme <name>` Example: `--theme even` (only used for `.html` / `.pdf`)
|
|
78
107
|
|
|
108
|
+
### `resume themes`
|
|
109
|
+
|
|
110
|
+
Lists the JSON Resume themes installed in `node_modules` — both in your project
|
|
111
|
+
and globally — so you don't have to know a theme slug ahead of time. It prints
|
|
112
|
+
the slug to pass to `--theme` for `export` / `serve`:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
$ resume themes
|
|
116
|
+
Installed themes (2) — pass the slug to --theme:
|
|
117
|
+
elegant v1.16.1
|
|
118
|
+
even v0.6.0
|
|
119
|
+
|
|
120
|
+
Use it: resume export resume.html --theme elegant
|
|
121
|
+
Browse the full gallery: https://jsonresume.org/themes/
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
If none are installed yet, it points you at how to install one. Discovery is
|
|
125
|
+
local and network-free; browse the full gallery (with screenshots) at
|
|
126
|
+
https://jsonresume.org/themes/ and `npm install jsonresume-theme-<slug>` the one
|
|
127
|
+
you want.
|
|
128
|
+
|
|
79
129
|
### `resume serve`
|
|
80
130
|
|
|
81
131
|
Starts a web server that serves your local `resume.json`. It will live reload when you make changes to your `resume.json`.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
const THEME_PREFIX = 'jsonresume-theme-';
|
|
7
|
+
|
|
8
|
+
// Read a theme package's `package.json` (best effort). Returns null if the
|
|
9
|
+
// directory is not a readable npm package — keeps discovery dependency-free
|
|
10
|
+
// and resilient to half-installed/broken folders.
|
|
11
|
+
const readThemePkg = themeDir => {
|
|
12
|
+
try {
|
|
13
|
+
const raw = fs.readFileSync(path.join(themeDir, 'package.json'), 'utf8');
|
|
14
|
+
return JSON.parse(raw);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// List the immediate child directories of a node_modules folder that look like
|
|
21
|
+
// JSON Resume theme packages (`jsonresume-theme-*`). Returns one entry per
|
|
22
|
+
// theme with its slug (the name passed to `--theme`), full package name,
|
|
23
|
+
// version and description. Missing/unreadable folders yield an empty list.
|
|
24
|
+
const listThemesInDir = nodeModulesDir => {
|
|
25
|
+
let entries;
|
|
26
|
+
try {
|
|
27
|
+
entries = fs.readdirSync(nodeModulesDir, {
|
|
28
|
+
withFileTypes: true
|
|
29
|
+
});
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
const themes = [];
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
// Tolerate environments where `withFileTypes` is unavailable.
|
|
36
|
+
const name = entry.name || entry;
|
|
37
|
+
if (typeof name !== 'string' || !name.startsWith(THEME_PREFIX)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
// Ignore the prefix itself with nothing after it.
|
|
41
|
+
const slug = name.slice(THEME_PREFIX.length);
|
|
42
|
+
if (!slug) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const pkg = readThemePkg(path.join(nodeModulesDir, name));
|
|
46
|
+
if (!pkg) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
themes.push({
|
|
50
|
+
slug,
|
|
51
|
+
name,
|
|
52
|
+
version: typeof pkg.version === 'string' ? pkg.version : null,
|
|
53
|
+
description: typeof pkg.description === 'string' ? pkg.description : null
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return themes;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Build the list of `node_modules` directories to scan: every `node_modules`
|
|
60
|
+
// from `cwd` up to the filesystem root (covers local + monorepo installs),
|
|
61
|
+
// plus any directories supplied via `extraNodeModules` (e.g. the global root).
|
|
62
|
+
const candidateNodeModules = (cwd, extraNodeModules) => {
|
|
63
|
+
const dirs = [];
|
|
64
|
+
let current = path.resolve(cwd);
|
|
65
|
+
// Walk up the tree collecting node_modules at each level.
|
|
66
|
+
// eslint-disable-next-line no-constant-condition
|
|
67
|
+
while (true) {
|
|
68
|
+
dirs.push(path.join(current, 'node_modules'));
|
|
69
|
+
const parent = path.dirname(current);
|
|
70
|
+
if (parent === current) {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
current = parent;
|
|
74
|
+
}
|
|
75
|
+
for (const extra of extraNodeModules) {
|
|
76
|
+
if (extra) {
|
|
77
|
+
dirs.push(extra);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return dirs;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Discover installed JSON Resume themes.
|
|
84
|
+
//
|
|
85
|
+
// Scans `node_modules` from the current working directory up the tree plus any
|
|
86
|
+
// `extraNodeModules` (the global install root by default) for
|
|
87
|
+
// `jsonresume-theme-*` packages. Returns a list of unique themes sorted by
|
|
88
|
+
// slug. The first occurrence of a slug wins, so a project-local install
|
|
89
|
+
// shadows a global one — matching how `require.resolve` would pick a theme.
|
|
90
|
+
//
|
|
91
|
+
// Pure and network-free: callers inject `cwd` and `extraNodeModules` in tests.
|
|
92
|
+
const discoverThemes = ({
|
|
93
|
+
cwd = process.cwd(),
|
|
94
|
+
extraNodeModules = []
|
|
95
|
+
} = {}) => {
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
const themes = [];
|
|
98
|
+
for (const dir of candidateNodeModules(cwd, extraNodeModules)) {
|
|
99
|
+
for (const theme of listThemesInDir(dir)) {
|
|
100
|
+
if (seen.has(theme.slug)) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
seen.add(theme.slug);
|
|
104
|
+
themes.push(theme);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
themes.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
108
|
+
return themes;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Render the discovered themes as a clean, copy-pasteable block. Each line
|
|
112
|
+
// shows the slug to pass to `--theme`. When nothing is installed we point the
|
|
113
|
+
// user at how to install a theme; either way we link the full gallery.
|
|
114
|
+
const formatThemesList = themes => {
|
|
115
|
+
const lines = [];
|
|
116
|
+
if (themes.length === 0) {
|
|
117
|
+
lines.push(chalk.yellow('No JSON Resume themes found in node_modules.'));
|
|
118
|
+
lines.push(`Install one: ${chalk.cyan('npm install jsonresume-theme-even')}`);
|
|
119
|
+
} else {
|
|
120
|
+
const count = themes.length;
|
|
121
|
+
lines.push(chalk.bold(`Installed themes (${count}) — pass the slug to ${chalk.cyan('--theme')}:`));
|
|
122
|
+
const width = themes.reduce((max, t) => Math.max(max, t.slug.length), 0);
|
|
123
|
+
for (const theme of themes) {
|
|
124
|
+
const slug = chalk.green(theme.slug.padEnd(width));
|
|
125
|
+
const version = theme.version ? chalk.dim(` v${theme.version}`) : '';
|
|
126
|
+
lines.push(` ${slug}${version}`);
|
|
127
|
+
}
|
|
128
|
+
lines.push('');
|
|
129
|
+
lines.push(`Use it: ${chalk.cyan(`resume export resume.html --theme ${themes[0].slug}`)}`);
|
|
130
|
+
}
|
|
131
|
+
lines.push(`Browse the full gallery: ${chalk.cyan('https://jsonresume.org/themes/')}`);
|
|
132
|
+
return lines.join('\n');
|
|
133
|
+
};
|
|
134
|
+
module.exports = {
|
|
135
|
+
THEME_PREFIX,
|
|
136
|
+
discoverThemes,
|
|
137
|
+
listThemesInDir,
|
|
138
|
+
formatThemesList
|
|
139
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _fs = _interopRequireDefault(require("fs"));
|
|
4
|
+
var _os = _interopRequireDefault(require("os"));
|
|
5
|
+
var _path = _interopRequireDefault(require("path"));
|
|
6
|
+
var _listThemes = require("./list-themes");
|
|
7
|
+
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
8
|
+
// Build a throwaway project directory on the real filesystem whose
|
|
9
|
+
// `node_modules` mirrors a typical install: a couple of real themes, a broken
|
|
10
|
+
// theme folder (no package.json), and unrelated packages that must be ignored.
|
|
11
|
+
const buildFixtureProject = () => {
|
|
12
|
+
const root = _fs.default.mkdtempSync(_path.default.join(_os.default.tmpdir(), 'resume-cli-themes-'));
|
|
13
|
+
const nodeModules = _path.default.join(root, 'node_modules');
|
|
14
|
+
const writePkg = (dirName, pkg) => {
|
|
15
|
+
const dir = _path.default.join(nodeModules, dirName);
|
|
16
|
+
_fs.default.mkdirSync(dir, {
|
|
17
|
+
recursive: true
|
|
18
|
+
});
|
|
19
|
+
if (pkg) {
|
|
20
|
+
_fs.default.writeFileSync(_path.default.join(dir, 'package.json'), JSON.stringify(pkg));
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Two valid themes (out of alphabetical order to prove sorting).
|
|
25
|
+
writePkg('jsonresume-theme-even', {
|
|
26
|
+
name: 'jsonresume-theme-even',
|
|
27
|
+
version: '0.6.0',
|
|
28
|
+
description: 'A flat JSON Resume theme.'
|
|
29
|
+
});
|
|
30
|
+
writePkg('jsonresume-theme-elegant', {
|
|
31
|
+
name: 'jsonresume-theme-elegant',
|
|
32
|
+
version: '1.16.1'
|
|
33
|
+
});
|
|
34
|
+
// A theme-prefixed folder with no package.json — half-installed/broken;
|
|
35
|
+
// must be skipped, not crash discovery.
|
|
36
|
+
writePkg('jsonresume-theme-broken', null);
|
|
37
|
+
// Non-theme packages that must never appear in the list.
|
|
38
|
+
writePkg('chalk', {
|
|
39
|
+
name: 'chalk',
|
|
40
|
+
version: '4.1.0'
|
|
41
|
+
});
|
|
42
|
+
writePkg('jsonresume-other', {
|
|
43
|
+
name: 'jsonresume-other',
|
|
44
|
+
version: '1.0.0'
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
root,
|
|
48
|
+
nodeModules
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
describe('list-themes discovery', () => {
|
|
52
|
+
let fixture;
|
|
53
|
+
beforeAll(() => {
|
|
54
|
+
fixture = buildFixtureProject();
|
|
55
|
+
});
|
|
56
|
+
afterAll(() => {
|
|
57
|
+
_fs.default.rmSync(fixture.root, {
|
|
58
|
+
recursive: true,
|
|
59
|
+
force: true
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
it('lists installed jsonresume-theme-* packages by slug, sorted', () => {
|
|
63
|
+
const themes = (0, _listThemes.discoverThemes)({
|
|
64
|
+
cwd: fixture.root
|
|
65
|
+
});
|
|
66
|
+
expect(themes.map(t => t.slug)).toEqual(['elegant', 'even']);
|
|
67
|
+
});
|
|
68
|
+
it('ignores non-theme packages and broken theme folders', () => {
|
|
69
|
+
const slugs = (0, _listThemes.discoverThemes)({
|
|
70
|
+
cwd: fixture.root
|
|
71
|
+
}).map(t => t.slug);
|
|
72
|
+
expect(slugs).not.toContain('broken');
|
|
73
|
+
expect(slugs).not.toContain('other');
|
|
74
|
+
expect(slugs).not.toContain('chalk');
|
|
75
|
+
});
|
|
76
|
+
it('captures the package name, version and description of each theme', () => {
|
|
77
|
+
const themes = (0, _listThemes.discoverThemes)({
|
|
78
|
+
cwd: fixture.root
|
|
79
|
+
});
|
|
80
|
+
const even = themes.find(t => t.slug === 'even');
|
|
81
|
+
expect(even).toEqual({
|
|
82
|
+
slug: 'even',
|
|
83
|
+
name: 'jsonresume-theme-even',
|
|
84
|
+
version: '0.6.0',
|
|
85
|
+
description: 'A flat JSON Resume theme.'
|
|
86
|
+
});
|
|
87
|
+
// A theme without a description still resolves, with description null.
|
|
88
|
+
const elegant = themes.find(t => t.slug === 'elegant');
|
|
89
|
+
expect(elegant.version).toEqual('1.16.1');
|
|
90
|
+
expect(elegant.description).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
it('returns an empty list when node_modules is missing', () => {
|
|
93
|
+
const empty = _fs.default.mkdtempSync(_path.default.join(_os.default.tmpdir(), 'resume-cli-empty-'));
|
|
94
|
+
try {
|
|
95
|
+
expect((0, _listThemes.discoverThemes)({
|
|
96
|
+
cwd: empty
|
|
97
|
+
})).toEqual([]);
|
|
98
|
+
} finally {
|
|
99
|
+
_fs.default.rmSync(empty, {
|
|
100
|
+
recursive: true,
|
|
101
|
+
force: true
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
it('de-duplicates a slug installed in multiple node_modules (local wins)', () => {
|
|
106
|
+
// A second node_modules (e.g. a global root) also ships `even`, plus a
|
|
107
|
+
// theme not present locally. The local `even` should win; `global-only`
|
|
108
|
+
// should be added.
|
|
109
|
+
const globalRoot = _fs.default.mkdtempSync(_path.default.join(_os.default.tmpdir(), 'resume-cli-global-'));
|
|
110
|
+
try {
|
|
111
|
+
const writeGlobalPkg = (dirName, pkg) => {
|
|
112
|
+
const dir = _path.default.join(globalRoot, dirName);
|
|
113
|
+
_fs.default.mkdirSync(dir, {
|
|
114
|
+
recursive: true
|
|
115
|
+
});
|
|
116
|
+
_fs.default.writeFileSync(_path.default.join(dir, 'package.json'), JSON.stringify(pkg));
|
|
117
|
+
};
|
|
118
|
+
writeGlobalPkg('jsonresume-theme-even', {
|
|
119
|
+
name: 'jsonresume-theme-even',
|
|
120
|
+
version: '9.9.9'
|
|
121
|
+
});
|
|
122
|
+
writeGlobalPkg('jsonresume-theme-global-only', {
|
|
123
|
+
name: 'jsonresume-theme-global-only',
|
|
124
|
+
version: '2.0.0'
|
|
125
|
+
});
|
|
126
|
+
const themes = (0, _listThemes.discoverThemes)({
|
|
127
|
+
cwd: fixture.root,
|
|
128
|
+
extraNodeModules: [globalRoot]
|
|
129
|
+
});
|
|
130
|
+
const slugs = themes.map(t => t.slug);
|
|
131
|
+
expect(slugs).toEqual(['elegant', 'even', 'global-only']);
|
|
132
|
+
// Local install shadows the global one (version stays 0.6.0).
|
|
133
|
+
expect(themes.find(t => t.slug === 'even').version).toEqual('0.6.0');
|
|
134
|
+
} finally {
|
|
135
|
+
_fs.default.rmSync(globalRoot, {
|
|
136
|
+
recursive: true,
|
|
137
|
+
force: true
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
it('listThemesInDir returns [] for a non-existent directory', () => {
|
|
142
|
+
expect((0, _listThemes.listThemesInDir)('/no/such/dir/node_modules')).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
describe('formatThemesList', () => {
|
|
146
|
+
it('prints each slug and a usage example when themes are installed', () => {
|
|
147
|
+
const output = (0, _listThemes.formatThemesList)([{
|
|
148
|
+
slug: 'elegant',
|
|
149
|
+
name: 'jsonresume-theme-elegant',
|
|
150
|
+
version: '1.16.1'
|
|
151
|
+
}, {
|
|
152
|
+
slug: 'even',
|
|
153
|
+
name: 'jsonresume-theme-even',
|
|
154
|
+
version: '0.6.0'
|
|
155
|
+
}]);
|
|
156
|
+
expect(output).toContain('Installed themes (2)');
|
|
157
|
+
expect(output).toContain('elegant');
|
|
158
|
+
expect(output).toContain('even');
|
|
159
|
+
expect(output).toContain('--theme elegant');
|
|
160
|
+
expect(output).toContain('https://jsonresume.org/themes/');
|
|
161
|
+
});
|
|
162
|
+
it('prints an install hint and the gallery link when nothing is installed', () => {
|
|
163
|
+
const output = (0, _listThemes.formatThemesList)([]);
|
|
164
|
+
expect(output).toContain('No JSON Resume themes found');
|
|
165
|
+
expect(output).toContain('npm install jsonresume-theme-even');
|
|
166
|
+
expect(output).toContain('https://jsonresume.org/themes/');
|
|
167
|
+
});
|
|
168
|
+
});
|
package/build/main.js
CHANGED
|
@@ -17,6 +17,28 @@ const {
|
|
|
17
17
|
ThemeNotFoundError,
|
|
18
18
|
formatThemeNotFound
|
|
19
19
|
} = require('./theme-errors');
|
|
20
|
+
const {
|
|
21
|
+
formatOkSummary
|
|
22
|
+
} = require('./validate-errors');
|
|
23
|
+
const {
|
|
24
|
+
discoverThemes,
|
|
25
|
+
formatThemesList
|
|
26
|
+
} = require('./list-themes');
|
|
27
|
+
|
|
28
|
+
// Best-effort guess at the global `node_modules` root so `resume themes` also
|
|
29
|
+
// surfaces globally-installed themes (e.g. `npm install -g`). Derived from the
|
|
30
|
+
// node executable path; no network or extra dependency required.
|
|
31
|
+
const globalNodeModules = () => {
|
|
32
|
+
try {
|
|
33
|
+
// <prefix>/bin/node -> <prefix>/lib/node_modules (POSIX),
|
|
34
|
+
// <prefix>/node.exe -> <prefix>/node_modules (Windows).
|
|
35
|
+
const binDir = path.dirname(process.execPath);
|
|
36
|
+
const candidates = [path.join(binDir, '..', 'lib', 'node_modules'), path.join(binDir, 'node_modules')];
|
|
37
|
+
return candidates;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
};
|
|
20
42
|
const normalizeTheme = (value, defaultValue) => {
|
|
21
43
|
const theme = value || defaultValue;
|
|
22
44
|
// TODO - This is not great, but bypasses this function if it is a relative path
|
|
@@ -44,9 +66,9 @@ const normalizeTheme = (value, defaultValue) => {
|
|
|
44
66
|
resume,
|
|
45
67
|
schema
|
|
46
68
|
});
|
|
47
|
-
console.log(chalk.green(
|
|
69
|
+
console.log(chalk.green(formatOkSummary(resume, program.resume)));
|
|
48
70
|
} catch (e) {
|
|
49
|
-
console.error(e.message);
|
|
71
|
+
console.error(chalk.red(e.message));
|
|
50
72
|
process.exitCode = 1;
|
|
51
73
|
}
|
|
52
74
|
});
|
|
@@ -71,6 +93,13 @@ const normalizeTheme = (value, defaultValue) => {
|
|
|
71
93
|
console.log(chalk.green('\nDone! Find your new', format, 'resume at:\n', path.resolve(process.cwd(), fileName + format)));
|
|
72
94
|
});
|
|
73
95
|
});
|
|
96
|
+
program.command('themes').description('List JSON Resume themes installed in node_modules (the slug to pass to --theme). Browse the full gallery at https://jsonresume.org/themes/.').action(async () => {
|
|
97
|
+
const themes = discoverThemes({
|
|
98
|
+
cwd: process.cwd(),
|
|
99
|
+
extraNodeModules: globalNodeModules()
|
|
100
|
+
});
|
|
101
|
+
console.log(formatThemesList(themes));
|
|
102
|
+
});
|
|
74
103
|
program.command('serve').description('Serve resume at http://localhost:4000/').action(async () => {
|
|
75
104
|
serve({
|
|
76
105
|
...program,
|
package/build/main.test.js
CHANGED
|
@@ -90,6 +90,10 @@ describe('cli configuration', () => {
|
|
|
90
90
|
need no theme; pick a theme for
|
|
91
91
|
.html/.pdf with --theme
|
|
92
92
|
(https://jsonresume.org/themes/).
|
|
93
|
+
themes List JSON Resume themes installed in
|
|
94
|
+
node_modules (the slug to pass to
|
|
95
|
+
--theme). Browse the full gallery at
|
|
96
|
+
https://jsonresume.org/themes/.
|
|
93
97
|
serve Serve resume at http://localhost:4000/
|
|
94
98
|
help [command] display help for command
|
|
95
99
|
"
|
|
@@ -108,7 +112,7 @@ describe('cli configuration', () => {
|
|
|
108
112
|
it('should fail when trying to validate an invalid resume specified by the --resume option', async () => {
|
|
109
113
|
expect((await run(['validate', '--resume', '/test-resumes/invalid-resume.json'])).code).toEqual(1);
|
|
110
114
|
});
|
|
111
|
-
it('should print
|
|
115
|
+
it('should print precise, path-pointed errors (not a success line) when validation fails', async () => {
|
|
112
116
|
const {
|
|
113
117
|
code,
|
|
114
118
|
stdout,
|
|
@@ -119,7 +123,13 @@ describe('cli configuration', () => {
|
|
|
119
123
|
});
|
|
120
124
|
expect(code).toEqual(1);
|
|
121
125
|
expect(stderr).toContain('Invalid resume:');
|
|
126
|
+
// The classic data-path phrasing is preserved...
|
|
122
127
|
expect(stderr).toContain('data/basics/name must be string');
|
|
128
|
+
// ...alongside the new annotated path/rule/value/hint lines.
|
|
129
|
+
expect(stderr).toContain('at: basics.name');
|
|
130
|
+
expect(stderr).toContain('rule: type (expected string)');
|
|
131
|
+
expect(stderr).toContain('found: 123 (number)');
|
|
132
|
+
expect(stderr).toContain('must be of type string');
|
|
123
133
|
// The success line must not appear for an invalid resume.
|
|
124
134
|
expect(stdout).not.toContain('is valid');
|
|
125
135
|
});
|
|
@@ -128,11 +138,28 @@ describe('cli configuration', () => {
|
|
|
128
138
|
stdout
|
|
129
139
|
} = await run(['validate', '--resume', '/test-resumes/resume.json']);
|
|
130
140
|
expect(stdout).toMatchInlineSnapshot(`
|
|
131
|
-
"✓ /test-resumes/resume.json is valid
|
|
141
|
+
"✓ /test-resumes/resume.json is valid (thomas)
|
|
132
142
|
"
|
|
133
143
|
`);
|
|
134
144
|
});
|
|
135
145
|
});
|
|
146
|
+
describe('themes', () => {
|
|
147
|
+
it('lists installed themes by slug and links the gallery', async () => {
|
|
148
|
+
const {
|
|
149
|
+
stdout,
|
|
150
|
+
code
|
|
151
|
+
} = await run(['themes'], {
|
|
152
|
+
waitForVolumeExport: false
|
|
153
|
+
});
|
|
154
|
+
expect(code).toEqual(0);
|
|
155
|
+
// The CLI bundles the `elegant` and `even` themes as dependencies, so
|
|
156
|
+
// they are always discoverable from the package's node_modules.
|
|
157
|
+
expect(stdout).toContain('elegant');
|
|
158
|
+
expect(stdout).toContain('even');
|
|
159
|
+
expect(stdout).toContain('--theme');
|
|
160
|
+
expect(stdout).toContain('https://jsonresume.org/themes/');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
136
163
|
describe('export', () => {
|
|
137
164
|
it('should read from stdin when path is a dash', async () => {
|
|
138
165
|
const {
|
|
@@ -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
|
});
|