#!/usr/bin/env node const spinner = require('char-spinner') const {gray, green, yellow, red, bold} = require('colorette') const {SiteChecker} = require('broken-link-checker') const yargs = require('yargs') .option('excluded-schemes', {type: String, alias: 's', default: ['dash-feed', 'mailto']}) .option('filter-level', {type: Number, alias: 'L', default: 3}) .option('max-sockets-per-host', {type: Number, alias: 'm', default: 1}) .option('verbose', {type: Boolean, alias: 'v', default: false}) const options = yargs.argv const args = options._ const VERBOSE = options.verbose const pages = [] const seen = new Set() const excepted = new Map() const OK = ' ✓ ' const NOT_OK = ' ✘ ' const TAG_LENGTH = 3 const URL = args[0] if (URL) { log(green('go!'), bold(URL)) } else { log('err', 'you must provide a URL') process.exit(1) } let page = {url: URL, links: []} const exceptions = { 'GitHub private repo': url => [ // this is a list of known GitHub private repos 'https://github.com/github/accessibility', 'https://github.com/github/design', 'https://github.com/github/design-systems', 'https://github.com/github/github', 'https://github.com/github/sentinel' ].some(repo => url.startsWith(repo)) } const checker = new SiteChecker(options, { page(error, url) { if (error) { log(red('ERR'), `${url} (${error.code})`) } else if (page) { const {url, response, links = []} = page const num = String(links.length).padEnd(TAG_LENGTH) let message = `${bold(num)}${pages.length ? ' unique' : ''} links` if (!VERBOSE) message = `${message} on ${yellow(url)}` log(OK, message) if (links.length) { pages.push(page) } } }, html(tree, robots, response, url) { if (VERBOSE) log(yellow('get'), url) page = {url, links: []} }, junk(result) { const url = result.url.resolved || result.url.original if (!url || seen.has(url)) { return } else if (VERBOSE) { log(' '.repeat(TAG_LENGTH), gray(`skip ${shorten(url)}`)) } else if (result.excluded && url.indexOf(URL) !== 0) { log(yellow('---'), gray(`excluded: ${url}`)) } seen.add(url) }, link(result) { const url = result.url.resolved || result.url.original if (VERBOSE && !seen.has(url)) log(' + ', gray('link'), shorten(url)) for (const [reason, test] of Object.entries(exceptions)) { if (test(url)) { log(yellow('---'), gray(`skip ${url}`), yellow(reason)) excepted.set(url, reason) seen.add(url) return } } if (!seen.has(url)) page.links.push(result) seen.add(url) }, end() { const allBroken = [] for (const page of pages) { const broken = page.links.filter(link => link.broken) allBroken.push(...broken) if (!broken.length && !VERBOSE) { continue } else { const num = broken.length ? red(` ${broken.length}`.padEnd(TAG_LENGTH)) : green(' 0').padEnd(TAG_LENGTH) log(bold(num), `broken links on ${bold(page.url)}`) } for (const link of page.links) { if (!link.broken && !VERBOSE) continue const tag = link.broken ? red(NOT_OK) : green(OK) const reason = link.broken ? link.brokenReason.replace(/HTTP_/, '') : '' const url = link.url.resolved || link.url.original log(tag, shorten(url), yellow(reason)) link.source = url link.reason = reason } log('') } if (excepted.size) { log(yellow(OK), `Excepted ${excepted.size} links:`) const exceptedURLs = Array.from(excepted.keys()).sort() for (const url of exceptedURLs) { log(yellow(OK), `${yellow(excepted.get(url))} ${gray(url)}`) } log('') } if (allBroken.length) { log(red(NOT_OK), `${red(allBroken.length)} broken links:`) for (const link of allBroken) { log(red(NOT_OK), red(link.reason), link.url.original, gray('from'), shorten(link.source)) } log('') process.exitCode = 1 } else { log(green(OK), bold('0'), 'broken links') } log('') } }) spinner() checker.enqueue(URL) checker.resume() function log(tag, ...args) { spinner.clear() console.log(tag ? gray(`[${tag}]`) : '', ...args) } function shorten(url) { return String(url).indexOf(URL) === 0 ? gray(URL) + (url.substr(URL.length) || '/') : url }