Skip to content

Commit

Permalink
Check GitHub markdown section links
Browse files Browse the repository at this point in the history
Extract markdown heading lines and convert to section link names, check
all section links against this list, first removing any code blocks, and
return a 404 for any that do not have a heading.

Check for `baseUrl` option prepended section links as well.

Make some minor adjustments for the tests to pass on Windows.

Fix tcort#250

Signed-off-by: Rafael Kitover <rkitover@gmail.com>
  • Loading branch information
rkitover committed Apr 6, 2024
1 parent 2b0095f commit a005469
Show file tree
Hide file tree
Showing 3 changed files with 63 additions and 13 deletions.
40 changes: 39 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,29 @@ function performSpecialReplacements(str, opts) {
return str;
}

function extractSections(markdown) {
// First remove code blocks.
markdown = markdown.replace(/^```[\S\s]+?^```$/mg, '');

const sectionTitles = markdown.match(/^#+ .*$/gm) || [];

const sections = sectionTitles.map(section =>
section.replace(/^\W+/, '').replace(/\W+$/, '').replace(/[^\w\s-]+/g, '').replace(/\s+/g, '-').toLowerCase()
);

var uniq = {};
for (var section of sections) {
if (section in uniq) {
uniq[section]++;
section = section + '-' + uniq[section];
}
uniq[section] = 0;
}
const uniqueSections = Object.keys(uniq) ?? [];

return uniqueSections;
}

module.exports = function markdownLinkCheck(markdown, opts, callback) {
if (arguments.length === 2 && typeof opts === 'function') {
// optional 'opts' not supplied.
Expand All @@ -62,6 +85,7 @@ module.exports = function markdownLinkCheck(markdown, opts, callback) {
}

const links = markdownLinkExtractor(markdown);
const sections = extractSections(markdown);
const linksCollection = _.uniq(links);
const bar = (opts.showProgressBar) ?
new ProgressBar('Checking... [:bar] :percent', {
Expand Down Expand Up @@ -114,8 +138,22 @@ module.exports = function markdownLinkCheck(markdown, opts, callback) {
}
}

linkCheck(link, opts, function (err, result) {
let sectionLink = null;

if (link.startsWith('#')) {
sectionLink = link;
}
else if ('baseUrl' in opts && link.startsWith(opts.baseUrl + '#')) {
sectionLink = link.replace(/^[^#]+/, '');
}

if (sectionLink) {
const result = new LinkCheckResult(opts, sectionLink, sections.includes(sectionLink.substring(1)) ? 200 : 404, undefined);
callback(null, result);
return;
}

linkCheck(link, opts, function (err, result) {
if (opts.showProgressBar) {
bar.tick();
}
Expand Down
15 changes: 13 additions & 2 deletions markdown-link-check
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,13 @@ function getInputs() {
continue;
}

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

stream = fs.createReadStream(filenameOrUrl);
}

Expand All @@ -124,7 +130,12 @@ function getInputs() {
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.
input.opts.projectBaseUrl = `file://${process.cwd()}`;
if (process.platform === 'win32') {
input.opts.projectBaseUrl = `file:///${process.cwd().replace(/\\/g, '/')}`;
}
else {
input.opts.projectBaseUrl = `file://${process.cwd()}`;
}
}
}

Expand Down
21 changes: 11 additions & 10 deletions test/markdown-link-check.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const expect = require('expect.js');
const http = require('http');
const express = require('express');
const markdownLinkCheck = require('../');
const dirname = process.platform === 'win32' ? __dirname.replace(/\\/g, '/') : __dirname;

describe('markdown-link-check', function () {
const MAX_RETRY_COUNT = 5;
Expand Down Expand Up @@ -66,7 +67,7 @@ describe('markdown-link-check', function () {

app.get('/hello.jpg', function (req, res) {
res.sendFile('hello.jpg', {
root: __dirname,
root: dirname,
dotfiles: 'deny'
});
});
Expand All @@ -76,7 +77,7 @@ describe('markdown-link-check', function () {
});

const server = http.createServer(app);
server.listen(0 /* random open port */, 'localhost', function serverListen(err) {
server.listen(0 /* random open port */, '127.0.0.1', function serverListen(err) {
if (err) {
done(err);
return;
Expand All @@ -88,7 +89,7 @@ describe('markdown-link-check', function () {

it('should check the links in sample.md', function (done) {
markdownLinkCheck(
fs.readFileSync(path.join(__dirname, 'sample.md')).toString().replace(/%%BASE_URL%%/g, baseUrl),
fs.readFileSync(path.join(dirname, 'sample.md')).toString().replace(/%%BASE_URL%%/g, baseUrl),
{
baseUrl: baseUrl,
ignorePatterns: [{ pattern: /not-working-and-ignored/ }],
Expand Down Expand Up @@ -172,7 +173,7 @@ describe('markdown-link-check', function () {
});

it('should check the links in file.md', function (done) {
markdownLinkCheck(fs.readFileSync(path.join(__dirname, 'file.md')).toString().replace(/%%BASE_URL%%/g, 'file://' + __dirname), { baseUrl: baseUrl }, function (err, results) {
markdownLinkCheck(fs.readFileSync(path.join(dirname, 'file.md')).toString().replace(/%%BASE_URL%%/g, 'file://' + dirname), { baseUrl: baseUrl }, function (err, results) {
expect(err).to.be(null);
expect(results).to.be.an('array');

Expand All @@ -194,7 +195,7 @@ describe('markdown-link-check', function () {
});

it('should check the links in local-file.md', function (done) {
markdownLinkCheck(fs.readFileSync(path.join(__dirname, 'local-file.md')).toString().replace(/%%BASE_URL%%/g, 'file://' + __dirname), {baseUrl: 'file://' + __dirname, projectBaseUrl: 'file://' + __dirname + "/..",replacementPatterns: [{ pattern: '^/', replacement: "{{BASEURL}}/"}]}, function (err, results) {
markdownLinkCheck(fs.readFileSync(path.join(dirname, 'local-file.md')).toString().replace(/%%BASE_URL%%/g, 'file://' + dirname), {baseUrl: 'file://' + dirname, projectBaseUrl: 'file://' + dirname + "/..",replacementPatterns: [{ pattern: '^/', replacement: "{{BASEURL}}/"}]}, function (err, results) {
expect(err).to.be(null);
expect(results).to.be.an('array');

Expand Down Expand Up @@ -254,7 +255,7 @@ describe('markdown-link-check', function () {
it('should enrich http headers with environment variables', function (done) {
process.env.BASIC_AUTH_TOKEN = 'Zm9vOmJhcg==';
markdownLinkCheck(
fs.readFileSync(path.join(__dirname, 'sample.md')).toString().replace(/%%BASE_URL%%/g, baseUrl),
fs.readFileSync(path.join(dirname, 'sample.md')).toString().replace(/%%BASE_URL%%/g, baseUrl),
{
baseUrl: baseUrl,
httpHeaders: [
Expand All @@ -274,8 +275,8 @@ describe('markdown-link-check', function () {
});

it('should enrich pattern replacement strings with environment variables', function (done) {
process.env.WORKSPACE = 'file://' + __dirname + '/..';
markdownLinkCheck(fs.readFileSync(path.join(__dirname, 'local-file.md')).toString().replace(/%%BASE_URL%%/g, 'file://' + __dirname), {baseUrl: 'file://' + __dirname, projectBaseUrl: 'file://' + __dirname + "/..",replacementPatterns: [{ pattern: '^/', replacement: "{{env.WORKSPACE}}/"}]}, function (err, results) {
process.env.WORKSPACE = 'file://' + dirname + '/..';
markdownLinkCheck(fs.readFileSync(path.join(dirname, 'local-file.md')).toString().replace(/%%BASE_URL%%/g, 'file://' + dirname), {baseUrl: 'file://' + dirname, projectBaseUrl: 'file://' + dirname + "/..",replacementPatterns: [{ pattern: '^/', replacement: "{{env.WORKSPACE}}/"}]}, function (err, results) {
expect(err).to.be(null);
expect(results).to.be.an('array');

Expand Down Expand Up @@ -306,7 +307,7 @@ describe('markdown-link-check', function () {
process.env.lowercase = 'hello.jpg';
process.env['WITH-Special_Characters-123'] = 'hello.jpg';

markdownLinkCheck(fs.readFileSync(path.join(__dirname, 'special-replacements.md')).toString().replace(/%%BASE_URL%%/g, 'file://' + __dirname), {baseUrl: 'file://' + __dirname, projectBaseUrl: 'file://' + __dirname + "/..",replacementPatterns: [
markdownLinkCheck(fs.readFileSync(path.join(dirname, 'special-replacements.md')).toString().replace(/%%BASE_URL%%/g, 'file://' + dirname), {baseUrl: 'file://' + dirname, projectBaseUrl: 'file://' + dirname + "/..",replacementPatterns: [
{pattern: '^/', replacement: "{{BASEURL}}/"},
{pattern: '%%ENVVAR_MIXEDCASE_TEST%%', replacement: "{{env.MixedCase}}"},
{pattern: '%%ENVVAR_UPPERCASE_TEST%%', replacement: "{{env.UPPERCASE}}"},
Expand Down Expand Up @@ -341,7 +342,7 @@ describe('markdown-link-check', function () {
});
});
it('check hash links', function (done) {
markdownLinkCheck(fs.readFileSync(path.join(__dirname, 'hash-links.md')).toString(), {}, function (err, result) {
markdownLinkCheck(fs.readFileSync(path.join(dirname, 'hash-links.md')).toString(), {}, function (err, result) {
expect(err).to.be(null);
expect(result).to.eql([
{ link: '#foo', statusCode: 200, err: null, status: 'alive' },
Expand Down

0 comments on commit a005469

Please sign in to comment.