Skip to content

Commit

Permalink
feat: add verbosity flag to CLI (#214)
Browse files Browse the repository at this point in the history
This adds a --verbosity flag, which defaults to WARNING. Skipped links now are hidden by default, unless verbosity is set to INFO or DEBUG.
  • Loading branch information
JustinBeckwith committed Dec 24, 2020
1 parent 7c84936 commit d20cff5
Show file tree
Hide file tree
Showing 4 changed files with 212 additions and 58 deletions.
7 changes: 4 additions & 3 deletions README.md
Expand Up @@ -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
Expand Down
140 changes: 95 additions & 45 deletions src/cli.ts
Expand Up @@ -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'));
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -75,6 +79,7 @@ const cli = meow(
timeout: {type: 'number'},
markdown: {type: 'boolean'},
serverRoot: {type: 'string'},
verbosity: {type: 'string'},
},
booleanDefault: undefined,
}
Expand All @@ -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,
Expand All @@ -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)}`);
});
});
}
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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();
49 changes: 49 additions & 0 deletions 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);
}
}
}

0 comments on commit d20cff5

Please sign in to comment.