From d20cff50e4cf7c09ece7fa6d7345ae66d9ce3dec Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 23 Dec 2020 18:33:36 -0800 Subject: [PATCH] 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. --- README.md | 7 +-- src/cli.ts | 140 ++++++++++++++++++++++++++++++++++---------------- src/logger.ts | 49 ++++++++++++++++++ test/zcli.ts | 74 ++++++++++++++++++++++---- 4 files changed, 212 insertions(+), 58 deletions(-) create mode 100644 src/logger.ts diff --git a/README.md b/README.md index 8734ffcf..c61a53ad 100644 --- a/README.md +++ b/README.md @@ -64,14 +64,15 @@ $ linkinator LOCATIONS [ --arguments ] When scanning a locally directory, customize the location on disk where the server is started. Defaults to the path passed in [LOCATION]. - --silent - Only output broken links. - --skip, -s List of urls in regexy form to not include in the check. --timeout Request timeout in ms. Defaults to 0 (no timeout). + + --verbosity + Override the default verbosity for this command. Available options are + 'debug', 'info', 'warning', 'error', and 'none'. Defaults to 'warning'. ``` ### Command Examples diff --git a/src/cli.ts b/src/cli.ts index 8065396e..e9528aac 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,7 @@ import chalk = require('chalk'); import {LinkChecker, LinkState, LinkResult, CheckOptions} from './index'; import {promisify} from 'util'; import {Flags, getConfig} from './config'; +import {Format, Logger, LogLevel} from './logger'; // eslint-disable-next-line @typescript-eslint/no-var-requires const toCSV = promisify(require('jsonexport')); @@ -14,6 +15,8 @@ const toCSV = promisify(require('jsonexport')); const pkg = require('../../package.json'); updateNotifier({pkg}).notify(); +/* eslint-disable no-process-exit */ + const cli = meow( ` Usage @@ -45,18 +48,19 @@ const cli = meow( Recursively follow links on the same root domain. --server-root - When scanning a locally directory, customize the location on disk + When scanning a locally directory, customize the location on disk where the server is started. Defaults to the path passed in [LOCATION]. - --silent - Only output broken links - --skip, -s - List of urls in regexy form to not include in the check. + List of urls in regexy form to not include in the check. --timeout Request timeout in ms. Defaults to 0 (no timeout). + --verbosity + Override the default verbosity for this command. Available options are + 'debug', 'info', 'warning', 'error', and 'none'. Defaults to 'warning'. + Examples $ linkinator docs/ $ linkinator https://www.google.com @@ -75,6 +79,7 @@ const cli = meow( timeout: {type: 'number'}, markdown: {type: 'boolean'}, serverRoot: {type: 'string'}, + verbosity: {type: 'string'}, }, booleanDefault: undefined, } @@ -90,34 +95,29 @@ async function main() { flags = await getConfig(cli.flags); const start = Date.now(); + const verbosity = parseVerbosity(cli.flags); + const format = parseFormat(cli.flags); + const logger = new Logger(verbosity, format); + + logger.error(`🏊‍♂️ crawling ${cli.input}`); - if (!flags.silent) { - log(`🏊‍♂️ crawling ${cli.input}`); - } const checker = new LinkChecker(); - // checker.on('pagestart', url => { - // if (!flags.silent) { - // log(`\n Scanning ${chalk.grey(url)}`); - // } - // }); checker.on('link', (link: LinkResult) => { - if (flags.silent && link.state !== LinkState.BROKEN) { - return; - } - let state = ''; switch (link.state) { case LinkState.BROKEN: state = `[${chalk.red(link.status!.toString())}]`; + logger.error(`${state} ${chalk.gray(link.url)}`); break; case LinkState.OK: state = `[${chalk.green(link.status!.toString())}]`; + logger.warn(`${state} ${chalk.gray(link.url)}`); break; case LinkState.SKIPPED: state = `[${chalk.grey('SKP')}]`; + logger.info(`${state} ${chalk.gray(link.url)}`); break; } - log(`${state} ${chalk.gray(link.url)}`); }); const opts: CheckOptions = { path: cli.input, @@ -128,55 +128,78 @@ async function main() { serverRoot: flags.serverRoot, }; if (flags.skip) { - if (typeof flags.skip === 'string') { - opts.linksToSkip = flags.skip.split(' ').filter(x => !!x); - } else if (Array.isArray(flags.skip)) { - opts.linksToSkip = flags.skip; - } + opts.linksToSkip = flags.skip.split(' ').filter(x => !!x); } const result = await checker.check(opts); - log(); - - const format = flags.format ? flags.format.toLowerCase() : null; - if (format === 'json') { + if (format === Format.JSON) { console.log(JSON.stringify(result, null, 2)); return; - } else if (format === 'csv') { + } else if (format === Format.CSV) { const csv = await toCSV(result.links); console.log(csv); return; } else { + // Build a collection scanned links, collated by the parent link used in + // the scan. For example: + // { + // "./README.md": [ + // { + // url: "https://img.shields.io/npm/v/linkinator.svg", + // status: 200 + // .... + // } + // ], + // } const parents = result.links.reduce((acc, curr) => { - if (!flags.silent || curr.state === LinkState.BROKEN) { - const parent = curr.parent || ''; - if (!acc[parent]) { - acc[parent] = []; - } - acc[parent].push(curr); + const parent = curr.parent || ''; + if (!acc[parent]) { + acc[parent] = []; } + acc[parent].push(curr); return acc; }, {} as {[index: string]: LinkResult[]}); Object.keys(parents).forEach(parent => { - const links = parents[parent]; - log(chalk.blue(parent)); - links.forEach(link => { - if (flags.silent && link.state !== LinkState.BROKEN) { - return; + // prune links based on verbosity + const links = parents[parent].filter(link => { + if (verbosity === LogLevel.NONE) { + return false; + } + if (link.state === LinkState.BROKEN) { + return true; + } + if (link.state === LinkState.OK) { + if (verbosity <= LogLevel.WARNING) { + return true; + } } + if (link.state === LinkState.SKIPPED) { + if (verbosity <= LogLevel.INFO) { + return true; + } + } + return false; + }); + if (links.length === 0) { + return; + } + logger.error(chalk.blue(parent)); + links.forEach(link => { let state = ''; switch (link.state) { case LinkState.BROKEN: state = `[${chalk.red(link.status!.toString())}]`; + logger.error(` ${state} ${chalk.gray(link.url)}`); break; case LinkState.OK: state = `[${chalk.green(link.status!.toString())}]`; + logger.warn(` ${state} ${chalk.gray(link.url)}`); break; case LinkState.SKIPPED: state = `[${chalk.grey('SKP')}]`; + logger.info(` ${state} ${chalk.gray(link.url)}`); break; } - log(` ${state} ${chalk.gray(link.url)}`); }); }); } @@ -185,7 +208,7 @@ async function main() { if (!result.passed) { const borked = result.links.filter(x => x.state === LinkState.BROKEN); - console.error( + logger.error( chalk.bold( `${chalk.red('ERROR')}: Detected ${ borked.length @@ -194,11 +217,10 @@ async function main() { )} links in ${chalk.cyan(total.toString())} seconds.` ) ); - // eslint-disable-next-line no-process-exit process.exit(1); } - log( + logger.error( chalk.bold( `🤖 Successfully scanned ${chalk.green( result.links.length.toString() @@ -207,10 +229,38 @@ async function main() { ); } -function log(message = '\n') { +function parseVerbosity(flags: typeof cli.flags): LogLevel { + if (flags.silent && flags.verbosity) { + throw new Error( + 'The SILENT and VERBOSITY flags cannot both be defined. Please consider using VERBOSITY only.' + ); + } + if (flags.silent) { + return LogLevel.ERROR; + } + if (!flags.verbosity) { + return LogLevel.WARNING; + } + const verbosity = flags.verbosity.toUpperCase(); + const options = Object.values(LogLevel); + if (!options.includes(verbosity)) { + throw new Error( + `Invalid flag: VERBOSITY must be one of [${options.join(',')}]` + ); + } + return LogLevel[verbosity as keyof typeof LogLevel]; +} + +function parseFormat(flags: typeof cli.flags): Format { if (!flags.format) { - console.log(message); + return Format.TEXT; + } + flags.format = flags.format.toUpperCase(); + const options = Object.values(Format); + if (!options.includes(flags.format)) { + throw new Error("Invalid flag: FORMAT must be 'TEXT', 'JSON', or 'CSV'."); } + return Format[flags.format as keyof typeof Format]; } main(); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 00000000..fe855f3d --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,49 @@ +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARNING = 2, + ERROR = 3, + NONE = 4, +} + +export enum Format { + TEXT, + JSON, + CSV, +} + +export class Logger { + public level: LogLevel; + public format: Format; + + constructor(level: LogLevel, format: Format) { + this.level = level; + this.format = format; + } + + debug(message?: string) { + if (this.level <= LogLevel.DEBUG && this.format === Format.TEXT) { + console.debug(message); + } + } + + info(message?: string) { + if (this.level <= LogLevel.INFO && this.format === Format.TEXT) { + console.info(message); + } + } + + warn(message?: string) { + if (this.level <= LogLevel.WARNING && this.format === Format.TEXT) { + // note: this is `console.log` on purpose. `console.warn` maps to + // `console.error`, which would print these messages to stderr. + console.log(message); + } + } + + error(message?: string) { + if (this.level <= LogLevel.ERROR) { + console.error(message); + } + } +} diff --git a/test/zcli.ts b/test/zcli.ts index abd1148b..b22fc69f 100644 --- a/test/zcli.ts +++ b/test/zcli.ts @@ -17,20 +17,18 @@ describe('cli', () => { it('should pass successful markdown scan', async () => { const res = await execa('npx', [ 'linkinator', - '--markdown', 'test/fixtures/markdown/README.md', ]); - assert.include(res.stdout, 'Successfully scanned'); + assert.include(res.stderr, 'Successfully scanned'); }); it('should allow multiple paths', async () => { const res = await execa('npx', [ 'linkinator', - '--markdown', - 'README.md', + 'test/fixtures/markdown/unlinked.md', 'test/fixtures/markdown/README.md', ]); - assert.include(res.stdout, 'Successfully scanned'); + assert.include(res.stderr, 'Successfully scanned'); }); it('should show help if no params are provided', async () => { @@ -43,7 +41,8 @@ describe('cli', () => { it('should flag skipped links', async () => { const res = await execa('npx', [ 'linkinator', - '--markdown', + '--verbosity', + 'INFO', '--skip', 'LICENSE.md', 'test/fixtures/markdown/README.md', @@ -76,7 +75,6 @@ describe('cli', () => { it('should not show links if --silent', async () => { const res = await execa('npx', [ 'linkinator', - '--markdown', '--silent', 'test/fixtures/markdown/README.md', ]); @@ -91,16 +89,72 @@ describe('cli', () => { 'test/fixtures/markdown', 'README.md', ]); - assert.ok(res.stdout.includes('Successfully scanned')); + assert.ok(res.stderr.includes('Successfully scanned')); }); it('should accept globs', async () => { const res = await execa('npx', [ 'linkinator', - '--markdown', 'test/fixtures/markdown/*.md', 'test/fixtures/markdown/**/*.md', ]); - assert.ok(res.stdout.includes('Successfully scanned')); + assert.ok(res.stderr.includes('Successfully scanned')); + }); + + it('should throw on invalid format', async () => { + const res = await execa( + 'npx', + ['linkinator', './README.md', '--format', 'LOL'], + { + reject: false, + } + ); + assert.include(res.stderr, 'FORMAT must be'); + }); + + it('should throw on invalid format', async () => { + const res = await execa( + 'npx', + ['linkinator', './README.md', '--format', 'LOL'], + { + reject: false, + } + ); + assert.include(res.stderr, 'FORMAT must be'); + }); + + it('should throw on invalid verbosity', async () => { + const res = await execa( + 'npx', + ['linkinator', './README.md', '--VERBOSITY', 'LOL'], + { + reject: false, + } + ); + assert.include(res.stderr, 'VERBOSITY must be'); + }); + + it('should throw when verbosity and silent are flagged', async () => { + const res = await execa( + 'npx', + ['linkinator', './README.md', '--verbosity', 'DEBUG', '--silent'], + { + reject: false, + } + ); + assert.include(res.stderr, 'The SILENT and VERBOSITY flags'); + }); + + it('should show no output for verbosity=NONE', async () => { + const res = await execa( + 'npx', + ['linkinator', 'test/fixtures/basic', '--verbosity', 'NONE'], + { + reject: false, + } + ); + assert.strictEqual(res.exitCode, 1); + assert.strictEqual(res.stdout, ''); + assert.strictEqual(res.stderr, ''); }); });