Skip to content

Commit d20cff5

Browse files
authoredDec 24, 2020
feat: add verbosity flag to CLI (#214)
This adds a --verbosity flag, which defaults to WARNING. Skipped links now are hidden by default, unless verbosity is set to INFO or DEBUG.
1 parent 7c84936 commit d20cff5

File tree

4 files changed

+212
-58
lines changed

4 files changed

+212
-58
lines changed
 

‎README.md

+4-3
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,15 @@ $ linkinator LOCATIONS [ --arguments ]
6464
When scanning a locally directory, customize the location on disk
6565
where the server is started. Defaults to the path passed in [LOCATION].
6666
67-
--silent
68-
Only output broken links.
69-
7067
--skip, -s
7168
List of urls in regexy form to not include in the check.
7269
7370
--timeout
7471
Request timeout in ms. Defaults to 0 (no timeout).
72+
73+
--verbosity
74+
Override the default verbosity for this command. Available options are
75+
'debug', 'info', 'warning', 'error', and 'none'. Defaults to 'warning'.
7576
```
7677

7778
### Command Examples

‎src/cli.ts

+95-45
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import chalk = require('chalk');
66
import {LinkChecker, LinkState, LinkResult, CheckOptions} from './index';
77
import {promisify} from 'util';
88
import {Flags, getConfig} from './config';
9+
import {Format, Logger, LogLevel} from './logger';
910

1011
// eslint-disable-next-line @typescript-eslint/no-var-requires
1112
const toCSV = promisify(require('jsonexport'));
@@ -14,6 +15,8 @@ const toCSV = promisify(require('jsonexport'));
1415
const pkg = require('../../package.json');
1516
updateNotifier({pkg}).notify();
1617

18+
/* eslint-disable no-process-exit */
19+
1720
const cli = meow(
1821
`
1922
Usage
@@ -45,18 +48,19 @@ const cli = meow(
4548
Recursively follow links on the same root domain.
4649
4750
--server-root
48-
When scanning a locally directory, customize the location on disk
51+
When scanning a locally directory, customize the location on disk
4952
where the server is started. Defaults to the path passed in [LOCATION].
5053
51-
--silent
52-
Only output broken links
53-
5454
--skip, -s
55-
List of urls in regexy form to not include in the check.
55+
List of urls in regexy form to not include in the check.
5656
5757
--timeout
5858
Request timeout in ms. Defaults to 0 (no timeout).
5959
60+
--verbosity
61+
Override the default verbosity for this command. Available options are
62+
'debug', 'info', 'warning', 'error', and 'none'. Defaults to 'warning'.
63+
6064
Examples
6165
$ linkinator docs/
6266
$ linkinator https://www.google.com
@@ -75,6 +79,7 @@ const cli = meow(
7579
timeout: {type: 'number'},
7680
markdown: {type: 'boolean'},
7781
serverRoot: {type: 'string'},
82+
verbosity: {type: 'string'},
7883
},
7984
booleanDefault: undefined,
8085
}
@@ -90,34 +95,29 @@ async function main() {
9095
flags = await getConfig(cli.flags);
9196

9297
const start = Date.now();
98+
const verbosity = parseVerbosity(cli.flags);
99+
const format = parseFormat(cli.flags);
100+
const logger = new Logger(verbosity, format);
101+
102+
logger.error(`🏊‍♂️ crawling ${cli.input}`);
93103

94-
if (!flags.silent) {
95-
log(`🏊‍♂️ crawling ${cli.input}`);
96-
}
97104
const checker = new LinkChecker();
98-
// checker.on('pagestart', url => {
99-
// if (!flags.silent) {
100-
// log(`\n Scanning ${chalk.grey(url)}`);
101-
// }
102-
// });
103105
checker.on('link', (link: LinkResult) => {
104-
if (flags.silent && link.state !== LinkState.BROKEN) {
105-
return;
106-
}
107-
108106
let state = '';
109107
switch (link.state) {
110108
case LinkState.BROKEN:
111109
state = `[${chalk.red(link.status!.toString())}]`;
110+
logger.error(`${state} ${chalk.gray(link.url)}`);
112111
break;
113112
case LinkState.OK:
114113
state = `[${chalk.green(link.status!.toString())}]`;
114+
logger.warn(`${state} ${chalk.gray(link.url)}`);
115115
break;
116116
case LinkState.SKIPPED:
117117
state = `[${chalk.grey('SKP')}]`;
118+
logger.info(`${state} ${chalk.gray(link.url)}`);
118119
break;
119120
}
120-
log(`${state} ${chalk.gray(link.url)}`);
121121
});
122122
const opts: CheckOptions = {
123123
path: cli.input,
@@ -128,55 +128,78 @@ async function main() {
128128
serverRoot: flags.serverRoot,
129129
};
130130
if (flags.skip) {
131-
if (typeof flags.skip === 'string') {
132-
opts.linksToSkip = flags.skip.split(' ').filter(x => !!x);
133-
} else if (Array.isArray(flags.skip)) {
134-
opts.linksToSkip = flags.skip;
135-
}
131+
opts.linksToSkip = flags.skip.split(' ').filter(x => !!x);
136132
}
137133
const result = await checker.check(opts);
138-
log();
139-
140-
const format = flags.format ? flags.format.toLowerCase() : null;
141-
if (format === 'json') {
134+
if (format === Format.JSON) {
142135
console.log(JSON.stringify(result, null, 2));
143136
return;
144-
} else if (format === 'csv') {
137+
} else if (format === Format.CSV) {
145138
const csv = await toCSV(result.links);
146139
console.log(csv);
147140
return;
148141
} else {
142+
// Build a collection scanned links, collated by the parent link used in
143+
// the scan. For example:
144+
// {
145+
// "./README.md": [
146+
// {
147+
// url: "https://img.shields.io/npm/v/linkinator.svg",
148+
// status: 200
149+
// ....
150+
// }
151+
// ],
152+
// }
149153
const parents = result.links.reduce((acc, curr) => {
150-
if (!flags.silent || curr.state === LinkState.BROKEN) {
151-
const parent = curr.parent || '';
152-
if (!acc[parent]) {
153-
acc[parent] = [];
154-
}
155-
acc[parent].push(curr);
154+
const parent = curr.parent || '';
155+
if (!acc[parent]) {
156+
acc[parent] = [];
156157
}
158+
acc[parent].push(curr);
157159
return acc;
158160
}, {} as {[index: string]: LinkResult[]});
159161

160162
Object.keys(parents).forEach(parent => {
161-
const links = parents[parent];
162-
log(chalk.blue(parent));
163-
links.forEach(link => {
164-
if (flags.silent && link.state !== LinkState.BROKEN) {
165-
return;
163+
// prune links based on verbosity
164+
const links = parents[parent].filter(link => {
165+
if (verbosity === LogLevel.NONE) {
166+
return false;
167+
}
168+
if (link.state === LinkState.BROKEN) {
169+
return true;
170+
}
171+
if (link.state === LinkState.OK) {
172+
if (verbosity <= LogLevel.WARNING) {
173+
return true;
174+
}
166175
}
176+
if (link.state === LinkState.SKIPPED) {
177+
if (verbosity <= LogLevel.INFO) {
178+
return true;
179+
}
180+
}
181+
return false;
182+
});
183+
if (links.length === 0) {
184+
return;
185+
}
186+
logger.error(chalk.blue(parent));
187+
links.forEach(link => {
167188
let state = '';
168189
switch (link.state) {
169190
case LinkState.BROKEN:
170191
state = `[${chalk.red(link.status!.toString())}]`;
192+
logger.error(` ${state} ${chalk.gray(link.url)}`);
171193
break;
172194
case LinkState.OK:
173195
state = `[${chalk.green(link.status!.toString())}]`;
196+
logger.warn(` ${state} ${chalk.gray(link.url)}`);
174197
break;
175198
case LinkState.SKIPPED:
176199
state = `[${chalk.grey('SKP')}]`;
200+
logger.info(` ${state} ${chalk.gray(link.url)}`);
177201
break;
178202
}
179-
log(` ${state} ${chalk.gray(link.url)}`);
180203
});
181204
});
182205
}
@@ -185,7 +208,7 @@ async function main() {
185208

186209
if (!result.passed) {
187210
const borked = result.links.filter(x => x.state === LinkState.BROKEN);
188-
console.error(
211+
logger.error(
189212
chalk.bold(
190213
`${chalk.red('ERROR')}: Detected ${
191214
borked.length
@@ -194,11 +217,10 @@ async function main() {
194217
)} links in ${chalk.cyan(total.toString())} seconds.`
195218
)
196219
);
197-
// eslint-disable-next-line no-process-exit
198220
process.exit(1);
199221
}
200222

201-
log(
223+
logger.error(
202224
chalk.bold(
203225
`🤖 Successfully scanned ${chalk.green(
204226
result.links.length.toString()
@@ -207,10 +229,38 @@ async function main() {
207229
);
208230
}
209231

210-
function log(message = '\n') {
232+
function parseVerbosity(flags: typeof cli.flags): LogLevel {
233+
if (flags.silent && flags.verbosity) {
234+
throw new Error(
235+
'The SILENT and VERBOSITY flags cannot both be defined. Please consider using VERBOSITY only.'
236+
);
237+
}
238+
if (flags.silent) {
239+
return LogLevel.ERROR;
240+
}
241+
if (!flags.verbosity) {
242+
return LogLevel.WARNING;
243+
}
244+
const verbosity = flags.verbosity.toUpperCase();
245+
const options = Object.values(LogLevel);
246+
if (!options.includes(verbosity)) {
247+
throw new Error(
248+
`Invalid flag: VERBOSITY must be one of [${options.join(',')}]`
249+
);
250+
}
251+
return LogLevel[verbosity as keyof typeof LogLevel];
252+
}
253+
254+
function parseFormat(flags: typeof cli.flags): Format {
211255
if (!flags.format) {
212-
console.log(message);
256+
return Format.TEXT;
257+
}
258+
flags.format = flags.format.toUpperCase();
259+
const options = Object.values(Format);
260+
if (!options.includes(flags.format)) {
261+
throw new Error("Invalid flag: FORMAT must be 'TEXT', 'JSON', or 'CSV'.");
213262
}
263+
return Format[flags.format as keyof typeof Format];
214264
}
215265

216266
main();

‎src/logger.ts

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
export enum LogLevel {
2+
DEBUG = 0,
3+
INFO = 1,
4+
WARNING = 2,
5+
ERROR = 3,
6+
NONE = 4,
7+
}
8+
9+
export enum Format {
10+
TEXT,
11+
JSON,
12+
CSV,
13+
}
14+
15+
export class Logger {
16+
public level: LogLevel;
17+
public format: Format;
18+
19+
constructor(level: LogLevel, format: Format) {
20+
this.level = level;
21+
this.format = format;
22+
}
23+
24+
debug(message?: string) {
25+
if (this.level <= LogLevel.DEBUG && this.format === Format.TEXT) {
26+
console.debug(message);
27+
}
28+
}
29+
30+
info(message?: string) {
31+
if (this.level <= LogLevel.INFO && this.format === Format.TEXT) {
32+
console.info(message);
33+
}
34+
}
35+
36+
warn(message?: string) {
37+
if (this.level <= LogLevel.WARNING && this.format === Format.TEXT) {
38+
// note: this is `console.log` on purpose. `console.warn` maps to
39+
// `console.error`, which would print these messages to stderr.
40+
console.log(message);
41+
}
42+
}
43+
44+
error(message?: string) {
45+
if (this.level <= LogLevel.ERROR) {
46+
console.error(message);
47+
}
48+
}
49+
}

‎test/zcli.ts

+64-10
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,18 @@ describe('cli', () => {
1717
it('should pass successful markdown scan', async () => {
1818
const res = await execa('npx', [
1919
'linkinator',
20-
'--markdown',
2120
'test/fixtures/markdown/README.md',
2221
]);
23-
assert.include(res.stdout, 'Successfully scanned');
22+
assert.include(res.stderr, 'Successfully scanned');
2423
});
2524

2625
it('should allow multiple paths', async () => {
2726
const res = await execa('npx', [
2827
'linkinator',
29-
'--markdown',
30-
'README.md',
28+
'test/fixtures/markdown/unlinked.md',
3129
'test/fixtures/markdown/README.md',
3230
]);
33-
assert.include(res.stdout, 'Successfully scanned');
31+
assert.include(res.stderr, 'Successfully scanned');
3432
});
3533

3634
it('should show help if no params are provided', async () => {
@@ -43,7 +41,8 @@ describe('cli', () => {
4341
it('should flag skipped links', async () => {
4442
const res = await execa('npx', [
4543
'linkinator',
46-
'--markdown',
44+
'--verbosity',
45+
'INFO',
4746
'--skip',
4847
'LICENSE.md',
4948
'test/fixtures/markdown/README.md',
@@ -76,7 +75,6 @@ describe('cli', () => {
7675
it('should not show links if --silent', async () => {
7776
const res = await execa('npx', [
7877
'linkinator',
79-
'--markdown',
8078
'--silent',
8179
'test/fixtures/markdown/README.md',
8280
]);
@@ -91,16 +89,72 @@ describe('cli', () => {
9189
'test/fixtures/markdown',
9290
'README.md',
9391
]);
94-
assert.ok(res.stdout.includes('Successfully scanned'));
92+
assert.ok(res.stderr.includes('Successfully scanned'));
9593
});
9694

9795
it('should accept globs', async () => {
9896
const res = await execa('npx', [
9997
'linkinator',
100-
'--markdown',
10198
'test/fixtures/markdown/*.md',
10299
'test/fixtures/markdown/**/*.md',
103100
]);
104-
assert.ok(res.stdout.includes('Successfully scanned'));
101+
assert.ok(res.stderr.includes('Successfully scanned'));
102+
});
103+
104+
it('should throw on invalid format', async () => {
105+
const res = await execa(
106+
'npx',
107+
['linkinator', './README.md', '--format', 'LOL'],
108+
{
109+
reject: false,
110+
}
111+
);
112+
assert.include(res.stderr, 'FORMAT must be');
113+
});
114+
115+
it('should throw on invalid format', async () => {
116+
const res = await execa(
117+
'npx',
118+
['linkinator', './README.md', '--format', 'LOL'],
119+
{
120+
reject: false,
121+
}
122+
);
123+
assert.include(res.stderr, 'FORMAT must be');
124+
});
125+
126+
it('should throw on invalid verbosity', async () => {
127+
const res = await execa(
128+
'npx',
129+
['linkinator', './README.md', '--VERBOSITY', 'LOL'],
130+
{
131+
reject: false,
132+
}
133+
);
134+
assert.include(res.stderr, 'VERBOSITY must be');
135+
});
136+
137+
it('should throw when verbosity and silent are flagged', async () => {
138+
const res = await execa(
139+
'npx',
140+
['linkinator', './README.md', '--verbosity', 'DEBUG', '--silent'],
141+
{
142+
reject: false,
143+
}
144+
);
145+
assert.include(res.stderr, 'The SILENT and VERBOSITY flags');
146+
});
147+
148+
it('should show no output for verbosity=NONE', async () => {
149+
const res = await execa(
150+
'npx',
151+
['linkinator', 'test/fixtures/basic', '--verbosity', 'NONE'],
152+
{
153+
reject: false,
154+
}
155+
);
156+
assert.strictEqual(res.exitCode, 1);
157+
assert.strictEqual(res.stdout, '');
158+
assert.strictEqual(res.stderr, '');
105159
});
106160
});

0 commit comments

Comments
 (0)
Please sign in to comment.