resume-cli 3.5.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 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`
@@ -104,6 +105,27 @@ Options:
104
105
  - `--format <file type>` Example: `--format pdf`
105
106
  - `--theme <name>` Example: `--theme even` (only used for `.html` / `.pdf`)
106
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
+
107
129
  ### `resume serve`
108
130
 
109
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
@@ -20,6 +20,25 @@ const {
20
20
  const {
21
21
  formatOkSummary
22
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
+ };
23
42
  const normalizeTheme = (value, defaultValue) => {
24
43
  const theme = value || defaultValue;
25
44
  // TODO - This is not great, but bypasses this function if it is a relative path
@@ -74,6 +93,13 @@ const normalizeTheme = (value, defaultValue) => {
74
93
  console.log(chalk.green('\nDone! Find your new', format, 'resume at:\n', path.resolve(process.cwd(), fileName + format)));
75
94
  });
76
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
+ });
77
103
  program.command('serve').description('Serve resume at http://localhost:4000/').action(async () => {
78
104
  serve({
79
105
  ...program,
@@ -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
  "
@@ -139,6 +143,23 @@ describe('cli configuration', () => {
139
143
  `);
140
144
  });
141
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
+ });
142
163
  describe('export', () => {
143
164
  it('should read from stdin when path is a dash', async () => {
144
165
  const {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resume-cli",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "description": "The JSON Resume command line interface",
5
5
  "main": "build/main.js",
6
6
  "engines": {