Skip to content

Commit 51fc856

Browse files
committed
Merge branch 'master' into html_anchors
2 parents 181d704 + 014ff95 commit 51fc856

13 files changed

+1196
-1082
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
node_modules
22
.tern-port
3+
junit.xml

.pre-commit-hooks.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
entry: markdown-link-check
55
language: node
66
types: [markdown]
7-
stages: [commit, push, manual]
7+
stages: [pre-commit, pre-push, manual]

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changes
22

3+
## Version 3.12.2
4+
5+
- fix status badge in README by @dklimpel in https://github.com/tcort/markdown-link-check/pull/303
6+
- enable skipped tests for hash links by @dklimpel in https://github.com/tcort/markdown-link-check/pull/306
7+
- chore: Upgrade to ESLint 9 by @nschonni in https://github.com/tcort/markdown-link-check/pull/318
8+
- Check GitHub markdown section links by @rkitover in https://github.com/tcort/markdown-link-check/pull/312
9+
- docs: add example for GitLab pipeline by @dklimpel in https://github.com/tcort/markdown-link-check/pull/309
10+
- ci: Use matrix for cross-OS testing by @nschonni in https://github.com/tcort/markdown-link-check/pull/307
11+
312
## Version 3.12.1
413

514
- fix: fix crash #297 @CanadaHonk

README.md

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ linkchecker:
5959
name: ghcr.io/tcort/markdown-link-check:3.11.2
6060
entrypoint: ["/bin/sh", "-c"]
6161
script:
62-
- find . -name \*.md -print0 | xargs -0 -n1 markdown-link-check
62+
- markdown-link-check ./docs
6363
rules:
6464
- changes:
6565
- "**/*.md"
@@ -169,19 +169,22 @@ markdown-link-check ./README.md
169169

170170
#### Check links from a local markdown folder (recursive)
171171

172-
Avoid using `find -exec` because it will swallow the error from each consecutive run.
173-
Instead, use `xargs`:
172+
This checks all files in folder `./docs` with file extension `*.md`:
173+
174174
```shell
175-
find . -name \*.md -print0 | xargs -0 -n1 markdown-link-check
175+
markdown-link-check ./docs
176176
```
177177

178-
There is an [open issue](https://github.com/tcort/markdown-link-check/issues/78) for allowing the tool to specify
179-
multiple files on the command line.
178+
The files can also be searched for and filtered manually:
179+
180+
```shell
181+
find . -name \*.md -print0 | xargs -0 -n1 markdown-link-check
182+
```
180183

181184
#### Usage
182185

183186
```shell
184-
Usage: markdown-link-check [options] [filenameOrUrl]
187+
Usage: markdown-link-check [options] [filenameOrDirectorynameOrUrl]
185188
186189
Options:
187190
-p, --progress show progress bar
@@ -200,7 +203,7 @@ Options:
200203
`config.json`:
201204

202205
* `ignorePatterns`: An array of objects holding regular expressions which a link is checked against and skipped for checking in case of a match.
203-
* `replacementPatterns`: An array of objects holding regular expressions which are replaced in a link with their corresponding replacement string. This behavior allows (for example) to adapt to certain platform conventions hosting the Markdown. The special replacement `{{BASEURL}}` can be used to dynamically link to the current working directory (for example that `/` points to the root of your current working directory).
206+
* `replacementPatterns`: An array of objects holding regular expressions which are replaced in a link with their corresponding replacement string. This behavior allows (for example) to adapt to certain platform conventions hosting the Markdown. The special replacement `{{BASEURL}}` can be used to dynamically link to the current working directory (for example that `/` points to the root of your current working directory). This parameter supports named regex groups the same way as `string.replace` [method](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_the_replacement) in node.
204207
* `httpHeaders`: The headers are only applied to links where the link **starts with** one of the supplied URLs in the `urls` section.
205208
* `timeout` timeout in [zeit/ms](https://www.npmjs.com/package/ms) format. (e.g. `"2000ms"`, `20s`, `1m`). Default `10s`.
206209
* `retryOn429` if this is `true` then retry request when response is an HTTP code 429 after the duration indicated by `retry-after` header.
@@ -232,6 +235,10 @@ Options:
232235
"pattern": "%20",
233236
"replacement": "-",
234237
"global": true
238+
},
239+
{
240+
"pattern": "images/(?<filename>.*)",
241+
"replacement": "assets/$<filename>"
235242
}
236243
],
237244
"httpHeaders": [

index.js

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
'use strict';
22

3-
const _ = require('lodash');
43
const async = require('async');
54
const linkCheck = require('link-check');
65
const LinkCheckResult = require('link-check').LinkCheckResult;
@@ -73,7 +72,29 @@ function extractSections(markdown) {
7372
const sectionTitles = markdown.match(/^#+ .*$/gm) || [];
7473

7574
const sections = sectionTitles.map(section =>
76-
section.replace(/^\W+/, '').replace(/\W+$/, '').replace(/[^\w\s-]+/g, '').replace(/\s+/g, '-').toLowerCase()
75+
// The links are compared with the headings (simple text comparison).
76+
// However, the links are url-encoded beforehand, so the headings
77+
// have to also be encoded so that they can also be matched.
78+
encodeURIComponent(
79+
section
80+
// replace links, the links can start with "./", "/", "http://", "https://" or "#"
81+
// and keep the value of the text ($1)
82+
.replace(/\[(.+)\]\(((?:\.?\/|https?:\/\/|#)[\w\d./?=#-]+)\)/, "$1")
83+
// make everything (Unicode-aware) lower case
84+
.toLowerCase()
85+
// remove white spaces and "#" at the beginning
86+
.replace(/^#+\s*/, '')
87+
// remove everything that is NOT a (Unicode) Letter, (Unicode) Number decimal,
88+
// (Unicode) Number letter, white space, underscore or hyphen
89+
// https://ruby-doc.org/3.3.2/Regexp.html#class-Regexp-label-Unicode+Character+Categories
90+
.replace(/[^\p{L}\p{Nd}\p{Nl}\s_\-`]/gu, "")
91+
// remove sequences of *
92+
.replace(/\*(?=.*)/gu, "")
93+
// remove leftover backticks
94+
.replace(/`/gu, "")
95+
// Now replace remaining blanks with '-'
96+
.replace(/\s/gu, "-")
97+
)
7798
);
7899

79100
var uniq = {};
@@ -109,7 +130,7 @@ module.exports = function markdownLinkCheck(markdown, opts, callback) {
109130

110131
const links = markdownLinkExtractor(markdown);
111132
const sections = extractSections(markdown).concat(extractHtmlSections(markdown));
112-
const linksCollection = _.uniq(links);
133+
const linksCollection = [...new Set(links)]
113134
const bar = (opts.showProgressBar) ?
114135
new ProgressBar('Checking... [:bar] :percent', {
115136
complete: '=',

markdown-link-check

Lines changed: 65 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44

55
let chalk;
66
const fs = require('fs');
7-
const markdownLinkCheck = require('./');
7+
const { promisify } = require('util');
8+
const markdownLinkCheck = promisify(require('./'));
89
const needle = require('needle');
910
const path = require('path');
1011
const pkg = require('./package.json');
1112
const { Command } = require('commander');
1213
const program = new Command();
13-
const url = require('url');
1414
const { ProxyAgent } = require('proxy-agent');
1515

1616
class Input {
@@ -31,6 +31,26 @@ function commaSeparatedCodesList(value, dummyPrevious) {
3131
});
3232
}
3333

34+
/**
35+
* Load all files in the rootFolder and all subfolders that end with .md
36+
*/
37+
function loadAllMarkdownFiles(rootFolder = '.') {
38+
const files = [];
39+
fs.readdirSync(rootFolder).forEach(file => {
40+
const fullPath = path.join(rootFolder, file);
41+
if (fs.lstatSync(fullPath).isDirectory()) {
42+
files.push(...loadAllMarkdownFiles(fullPath));
43+
} else if (fullPath.endsWith('.md')) {
44+
files.push(fullPath);
45+
}
46+
});
47+
return files;
48+
}
49+
50+
function commaSeparatedReportersList(value) {
51+
return value.split(',').map((reporter) => require(path.resolve('reporters', reporter)));
52+
}
53+
3454
function getInputs() {
3555
const inputs = [];
3656

@@ -40,11 +60,12 @@ function getInputs() {
4060
.option('-c, --config [config]', 'apply a config file (JSON), holding e.g. url specific header configuration')
4161
.option('-q, --quiet', 'displays errors only')
4262
.option('-v, --verbose', 'displays detailed error information')
43-
.option('-i --ignore <paths>', 'ignore input paths including an ignore path', commaSeparatedPathsList)
63+
.option('-i, --ignore <paths>', 'ignore input paths including an ignore path', commaSeparatedPathsList)
4464
.option('-a, --alive <code>', 'comma separated list of HTTP codes to be considered as alive', commaSeparatedCodesList)
4565
.option('-r, --retry', 'retry after the duration indicated in \'retry-after\' header when HTTP code is 429')
66+
.option('--reporters <names>', 'specify reporters to use', commaSeparatedReportersList)
4667
.option('--projectBaseUrl <url>', 'the URL to use for {{BASEURL}} replacement')
47-
.arguments('[filenamesOrUrls...]')
68+
.arguments('[filenamesOrDirectorynamesOrUrls...]')
4869
.action(function (filenamesOrUrls) {
4970
let filenameForOutput;
5071
let stream;
@@ -70,6 +91,7 @@ function getInputs() {
7091
for (const filenameOrUrl of filenamesOrUrls) {
7192
filenameForOutput = filenameOrUrl;
7293
let baseUrl = '';
94+
// remote file
7395
if (/https?:/.test(filenameOrUrl)) {
7496
stream = needle.get(
7597
filenameOrUrl, { agent: new ProxyAgent(), use_proxy_from_env_var: false }
@@ -81,37 +103,44 @@ function getInputs() {
81103
parsed.search = '';
82104
parsed.hash = '';
83105
if (parsed.pathname.lastIndexOf('/') !== -1) {
84-
parsed.pathname = parsed.pathname.substr(0, parsed.pathname.lastIndexOf('/') + 1);
106+
parsed.pathname = parsed.pathname.substring(0, parsed.pathname.lastIndexOf('/') + 1);
85107
}
86108
baseUrl = parsed.toString();
87-
} catch (err) { /* ignore error */
88-
}
109+
inputs.push(new Input(filenameForOutput, stream, {baseUrl: baseUrl}));
110+
} catch (err) {
111+
/* ignore error */
112+
}
89113
} else {
90-
const stats = fs.statSync(filenameOrUrl);
91-
if (stats.isDirectory()){
92-
console.error(chalk.red('\nERROR: ' + filenameOrUrl + ' is a directory! Please provide a valid filename as an argument.'));
93-
process.exit(1);
114+
// local file or directory
115+
let files = [];
116+
117+
if (fs.statSync(filenameOrUrl).isDirectory()){
118+
files = loadAllMarkdownFiles(filenameOrUrl)
119+
} else {
120+
files = [filenameOrUrl]
94121
}
95122

96-
const resolved = path.resolve(filenameOrUrl);
123+
for (let file of files) {
124+
filenameForOutput = file;
125+
const resolved = path.resolve(filenameForOutput);
97126

98-
// skip paths given if it includes a path to ignore.
99-
// todo: allow ignore paths to be glob or regex instead of just includes?
100-
if (ignore && ignore.some((ignorePath) => resolved.includes(ignorePath))) {
101-
continue;
102-
}
127+
// skip paths given if it includes a path to ignore.
128+
// todo: allow ignore paths to be glob or regex instead of just includes?
129+
if (ignore && ignore.some((ignorePath) => resolved.includes(ignorePath))) {
130+
continue;
131+
}
103132

104-
if (process.platform === 'win32') {
105-
baseUrl = 'file://' + path.dirname(resolved).replace(/\\/g, '/');
106-
}
107-
else {
108-
baseUrl = 'file://' + path.dirname(resolved);
109-
}
133+
if (process.platform === 'win32') {
134+
baseUrl = 'file://' + path.dirname(resolved).replace(/\\/g, '/');
135+
}
136+
else {
137+
baseUrl = 'file://' + path.dirname(resolved);
138+
}
110139

111-
stream = fs.createReadStream(filenameOrUrl);
140+
stream = fs.createReadStream(filenameForOutput);
141+
inputs.push(new Input(filenameForOutput, stream, {baseUrl: baseUrl}));
142+
}
112143
}
113-
114-
inputs.push(new Input(filenameForOutput, stream, {baseUrl: baseUrl}));
115144
}
116145
}
117146
).parse(process.argv);
@@ -122,6 +151,7 @@ function getInputs() {
122151
input.opts.verbose = (program.opts().verbose === true);
123152
input.opts.retryOn429 = (program.opts().retry === true);
124153
input.opts.aliveStatusCodes = program.opts().alive;
154+
input.opts.reporters = program.opts().reporters ?? [require(path.resolve('reporters', 'default.js'))];
125155
const config = program.opts().config;
126156
if (config) {
127157
input.opts.config = config.trim();
@@ -196,68 +226,23 @@ async function processInput(filenameForOutput, stream, opts) {
196226
opts.retryCount = config.retryCount;
197227
opts.fallbackRetryDelay = config.fallbackRetryDelay;
198228
opts.aliveStatusCodes = config.aliveStatusCodes;
229+
opts.reporters = config.reporters;
199230
}
200231

201232
await runMarkdownLinkCheck(filenameForOutput, markdown, opts);
202233
}
203234

204235
async function runMarkdownLinkCheck(filenameForOutput, markdown, opts) {
205-
const statusLabels = {
206-
alive: chalk.green('✓'),
207-
dead: chalk.red('✖'),
208-
ignored: chalk.gray('/'),
209-
error: chalk.yellow('⚠'),
210-
};
236+
const [err, results] = await markdownLinkCheck(markdown, opts)
237+
.then(res => [null, res]).catch(err => [err]);
211238

212-
return new Promise((resolve, reject) => {
213-
markdownLinkCheck(markdown, opts, function (err, results) {
214-
if (err) {
215-
console.error(chalk.red('\n ERROR: something went wrong!'));
216-
console.error(err.stack);
217-
reject();
218-
}
219-
220-
if (results.length === 0 && !opts.quiet) {
221-
console.log(chalk.yellow(' No hyperlinks found!'));
222-
}
223-
results.forEach(function (result) {
224-
// Skip messages for non-deadlinks in quiet mode.
225-
if (opts.quiet && result.status !== 'dead') {
226-
return;
227-
}
228-
229-
if (opts.verbose) {
230-
if (result.err) {
231-
console.log(' [%s] %s → Status: %s %s', statusLabels[result.status], result.link, result.statusCode, result.err);
232-
} else {
233-
console.log(' [%s] %s → Status: %s', statusLabels[result.status], result.link, result.statusCode);
234-
}
235-
}
236-
else if(!opts.quiet) {
237-
console.log(' [%s] %s', statusLabels[result.status], result.link);
238-
}
239-
});
239+
await Promise.allSettled(
240+
opts.reporters.map(reporter => reporter(err, results, opts, filenameForOutput)
241+
));
240242

241-
if(!opts.quiet){
242-
console.log('\n %s links checked.', results.length);
243-
}
244-
245-
if (results.some((result) => result.status === 'dead')) {
246-
let deadLinks = results.filter(result => { return result.status === 'dead'; });
247-
if(!opts.quiet){
248-
console.error(chalk.red('\n ERROR: %s dead links found!'), deadLinks.length);
249-
} else {
250-
console.error(chalk.red('\n ERROR: %s dead links found in %s !'), deadLinks.length, filenameForOutput);
251-
}
252-
deadLinks.forEach(function (result) {
253-
console.log(' [%s] %s → Status: %s', statusLabels[result.status], result.link, result.statusCode);
254-
});
255-
reject();
256-
}
257-
258-
resolve();
259-
});
260-
});
243+
if (err) throw null;
244+
else if (results.some((result) => result.status === 'dead')) return;
245+
else return;
261246
}
262247

263248
async function main() {

0 commit comments

Comments
 (0)