Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
node_modules
.tern-port
junit.xml

# stryker files
.stryker-tmp
reports/mutation
326 changes: 4 additions & 322 deletions markdown-link-check
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stryker cannot figure out what to do with this file because it has no extension.

I have moved the contents of this into a src directory to follow a more conventional file structure and allow for breaking things into smaller bites along the lines of the single responsibility principal.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming we are happy with the initial file re-org, I might move that out to a separate pull request that the resulting commits when we squash and merge cover one concern each.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another reason for having the file extension mentioned in #415.

Original file line number Diff line number Diff line change
Expand Up @@ -2,326 +2,8 @@

'use strict';
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mutation testing could not deal with this file not having a .js extension, so I moved it to src/markdown-link-check.js and this file just executes that code now.

Also removed some unused variables etc. in the new file now that it's being checked by the linter because it has a .js extension.


let chalk;
const fs = require('fs');
const { promisify } = require('util');
const markdownLinkCheck = promisify(require('.'));
const needle = require('needle');
const path = require('path');
const pkg = require('./package.json');
const { Command } = require('commander');
const program = new Command();
const { ProxyAgent } = require('proxy-agent');
const { main } = require('./src/markdown-link-check');

const reporters = {
default: async function defaultReporter(err, results, opts, filenameForOutput) {
const chalk = (await import('chalk')).default

const statusLabels = {
alive: chalk.green('✓'),
dead: chalk.red('✖'),
ignored: chalk.gray('/'),
error: chalk.yellow('⚠'),
};

if (err) {
console.error(chalk.red("\n ERROR: something went wrong!"));
console.error(err.stack);
}

if (results.length === 0 && !opts.quiet) {
console.log(chalk.yellow(" No hyperlinks found!"));
}
results.forEach(function (result) {
// Skip messages for non-deadlinks in quiet mode.
if (opts.quiet && result.status !== "dead") {
return;
}

if (opts.verbose) {
if (result.err) {
console.log(
" [%s] %s → Status: %s %s",
statusLabels[result.status],
result.link,
result.statusCode,
result.err
);
} else {
console.log(" [%s] %s → Status: %s", statusLabels[result.status], result.link, result.statusCode);
}
} else if (!opts.quiet) {
console.log(" [%s] %s", statusLabels[result.status], result.link);
}
});

if (!opts.quiet) {
console.log("\n %s links checked.", results.length);
}

if (results.some((result) => result.status === "dead")) {
let deadLinks = results.filter((result) => {
return result.status === "dead";
});
if (!opts.quiet) {
console.error(chalk.red("\n ERROR: %s dead links found!"), deadLinks.length);
} else {
console.error(chalk.red("\n ERROR: %s dead links found in %s !"), deadLinks.length, filenameForOutput);
}
deadLinks.forEach(function (result) {
console.log(" [%s] %s → Status: %s", statusLabels[result.status], result.link, result.statusCode);
});
}
},
};

class Input {
constructor(filenameForOutput, stream, opts) {
this.filenameForOutput = filenameForOutput;
this.stream = stream;
this.opts = opts;
}
}

function commaSeparatedPathsList(value) {
return value.split(',');
}

function commaSeparatedCodesList(value, dummyPrevious) {
return value.split(',').map(function(item) {
return parseInt(item, 10);
});
}

/**
* Load all files in the rootFolder and all subfolders that end with .md
*/
function loadAllMarkdownFiles(rootFolder = '.') {
const files = [];
fs.readdirSync(rootFolder).forEach(file => {
const fullPath = path.join(rootFolder, file);
if (fs.lstatSync(fullPath).isDirectory()) {
files.push(...loadAllMarkdownFiles(fullPath));
} else if (fullPath.endsWith('.md')) {
files.push(fullPath);
}
});
return files;
}

function commaSeparatedReportersList(value) {
return value.split(',').map((reporter) => reporters[reporter] ?? reporters.default);
}

function getInputs() {
const inputs = [];

program
.version(pkg.version)
.option('-p, --progress', 'show progress bar')
.option('-c, --config [config]', 'apply a config file (JSON), holding e.g. url specific header configuration')
.option('-q, --quiet', 'displays errors only')
.option('-v, --verbose', 'displays detailed error information')
.option('-i, --ignore <paths>', 'ignore input paths including an ignore path', commaSeparatedPathsList)
.option('-a, --alive <code>', 'comma separated list of HTTP codes to be considered as alive', commaSeparatedCodesList)
.option('-r, --retry', 'retry after the duration indicated in \'retry-after\' header when HTTP code is 429')
.option('--reporters <names>', 'specify reporters to use', commaSeparatedReportersList)
.option('--projectBaseUrl <url>', 'the URL to use for {{BASEURL}} replacement')
.arguments('[filenamesOrDirectorynamesOrUrls...]')
.action(function (filenamesOrUrls) {
let filenameForOutput;
let stream;

if (!filenamesOrUrls.length) {
// read from stdin unless a filename is given
inputs.push(new Input(filenameForOutput, process.stdin, {}));
}

function onError(error) {
console.error(chalk.red('\nERROR: Unable to connect! Please provide a valid URL as an argument.'));
process.exit(1);
}
function onResponse(response) {
if (response.statusCode === 404) {
console.error(chalk.red('\nERROR: 404 - File not found! Please provide a valid URL as an argument.'));
process.exit(1);
}
}

const { ignore } = program.opts();

for (const filenameOrUrl of filenamesOrUrls) {
filenameForOutput = filenameOrUrl;
let baseUrl = '';
// remote file
if (/https?:/.test(filenameOrUrl)) {
stream = needle.get(
filenameOrUrl, { agent: new ProxyAgent(), use_proxy_from_env_var: false }
);
stream.on('error', onError);
stream.on('response', onResponse);
try { // extract baseUrl from supplied URL
const parsed = new URL(filenameOrUrl);
parsed.search = '';
parsed.hash = '';
if (parsed.pathname.lastIndexOf('/') !== -1) {
parsed.pathname = parsed.pathname.substring(0, parsed.pathname.lastIndexOf('/') + 1);
}
baseUrl = parsed.toString();
inputs.push(new Input(filenameForOutput, stream, {baseUrl: baseUrl}));
} catch (err) {
/* ignore error */
}
} else {
// local file or directory
let files = [];

if (fs.statSync(filenameOrUrl).isDirectory()){
files = loadAllMarkdownFiles(filenameOrUrl)
} else {
files = [filenameOrUrl]
}

for (let file of files) {
filenameForOutput = file;
const resolved = path.resolve(filenameForOutput);

// skip paths given if it includes a path to ignore.
// todo: allow ignore paths to be glob or regex instead of just includes?
if (ignore && ignore.some((ignorePath) => resolved.includes(ignorePath))) {
continue;
}

if (process.platform === 'win32') {
baseUrl = 'file://' + path.dirname(resolved).replace(/\\/g, '/');
}
else {
baseUrl = 'file://' + path.dirname(resolved);
}

stream = fs.createReadStream(filenameForOutput);
inputs.push(new Input(filenameForOutput, stream, {baseUrl: baseUrl}));
}
}
}
}
).parse(process.argv);

for (const input of inputs) {
input.opts.showProgressBar = (program.opts().progress === true); // force true or undefined to be true or false.
input.opts.quiet = (program.opts().quiet === true);
input.opts.verbose = (program.opts().verbose === true);
input.opts.retryOn429 = (program.opts().retry === true);
input.opts.aliveStatusCodes = program.opts().alive;
input.opts.reporters = program.opts().reporters ?? [ reporters.default ];
const config = program.opts().config;
if (config) {
input.opts.config = config.trim();
}

if (program.projectBaseUrl) {
input.opts.projectBaseUrl = `file://${program.projectBaseUrl}`;
} else {
// set the default projectBaseUrl to the current working directory, so that `{{BASEURL}}` can be resolved to the project root.
if (process.platform === 'win32') {
input.opts.projectBaseUrl = `file:///${process.cwd().replace(/\\/g, '/')}`;
}
else {
input.opts.projectBaseUrl = `file://${process.cwd()}`;
}
}
}

return inputs;
}

async function loadConfig(config) {
return new Promise((resolve, reject) => {
fs.access(config, (fs.constants || fs).R_OK, function (err) {
if (!err) {
let configStream = fs.createReadStream(config);
let configData = '';

configStream.on('data', function (chunk) {
configData += chunk.toString();
}).on('end', function () {
resolve(JSON.parse(configData));
});
}
else {
console.error(chalk.red('\nERROR: Config file not accessible.'));
process.exit(1);
}
});
});
}

async function processInput(filenameForOutput, stream, opts) {
let markdown = ''; // collect the markdown data, then process it

stream.on('error', function(error) {
if (error.code === 'ENOENT') {
console.error(chalk.red('\nERROR: File not found! Please provide a valid filename as an argument.'));
} else {
console.error(chalk.red(error));
}
return process.exit(1);
});

for await (const chunk of stream) {
markdown += chunk.toString();
}

if (!opts.quiet && filenameForOutput) {
console.log(chalk.cyan('\nFILE: ' + filenameForOutput));
}

if (opts.config) {
let config = await loadConfig(opts.config);

opts.ignorePatterns = config.ignorePatterns;
opts.replacementPatterns = config.replacementPatterns;
opts.httpHeaders = config.httpHeaders;
opts.timeout = config.timeout;
opts.ignoreDisable = config.ignoreDisable;
opts.retryOn429 = config.retryOn429;
opts.retryCount = config.retryCount;
opts.fallbackRetryDelay = config.fallbackRetryDelay;
opts.aliveStatusCodes = config.aliveStatusCodes;
opts.reporters = config.reporters ?? opts.reporters;
}

await runMarkdownLinkCheck(filenameForOutput, markdown, opts);
}

async function runMarkdownLinkCheck(filenameForOutput, markdown, opts) {
const [err, results] = await markdownLinkCheck(markdown, opts)
.then(res => [null, res]).catch(err => [err]);

await Promise.allSettled(
opts.reporters.map(reporter => reporter(err, results, opts, filenameForOutput)
));

if (err) throw null;
else if (results.some((result) => result.status === 'dead')) return;
else return;
}

async function main() {
chalk = (await import('chalk')).default;

const inputs = getInputs();

let isOk = true;
for await (const input of inputs) {
try {
await processInput(input.filenameForOutput, input.stream, input.opts);
} catch (err) {
isOk = false;
}
}

process.exit(isOk ? 0 : 1);
}

main();
(async() => {
await main();
})();
Loading
Loading