Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: JustinBeckwith/linkinator
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v2.8.2
Choose a base ref
...
head repository: JustinBeckwith/linkinator
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v2.9.0
Choose a head ref
  • 1 commit
  • 4 files changed
  • 1 contributor

Commits on Dec 24, 2020

  1. 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.
    JustinBeckwith authored Dec 24, 2020
    Copy the full SHA
    d20cff5 View commit details
Showing with 212 additions and 58 deletions.
  1. +4 −3 README.md
  2. +95 −45 src/cli.ts
  3. +49 −0 src/logger.ts
  4. +64 −10 test/zcli.ts
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
140 changes: 95 additions & 45 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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();
49 changes: 49 additions & 0 deletions src/logger.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
74 changes: 64 additions & 10 deletions test/zcli.ts
Original file line number Diff line number Diff line change
@@ -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, '');
});
});