Skip to content

Commit

Permalink
Merge pull request #312 from rkitover/section-links
Browse files Browse the repository at this point in the history
Check GitHub markdown section links
  • Loading branch information
tcort authored May 21, 2024
2 parents 4936908 + b1da4b1 commit 7e9668f
Show file tree
Hide file tree
Showing 4 changed files with 130 additions and 13 deletions.
42 changes: 41 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,24 @@ 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)) {
if (link.substring(opts.baseUrl.length).match(/^\/*#/)) {
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
50 changes: 40 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 @@ -251,10 +252,39 @@ describe('markdown-link-check', function () {
});
});

it('should check section links to headers in section-links.md', function (done) {
markdownLinkCheck(fs.readFileSync(path.join(dirname, 'section-links.md')).toString(), { baseUrl: 'https://BASEURL' }, function (err, results) {
expect(err).to.be(null);
expect(results).to.be.an('array');

const expected = [
{ statusCode: 200, status: 'alive' },
{ statusCode: 404, status: 'dead' },
{ statusCode: 200, status: 'alive' },
{ statusCode: 200, status: 'alive' },
{ statusCode: 200, status: 'alive' },
{ statusCode: 404, status: 'dead' },
{ statusCode: 404, status: 'dead' },
{ statusCode: 200, status: 'alive' },
{ statusCode: 200, status: 'alive' },
{ statusCode: 200, status: 'alive' }
];

expect(results.length).to.be(expected.length);

for (let i = 0; i < results.length; i++) {
expect(results[i].statusCode).to.be(expected[i].statusCode);
expect(results[i].status).to.be(expected[i].status);
}

done();
});
});

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 +304,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 +336,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 +371,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
36 changes: 36 additions & 0 deletions test/section-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# section links test

We will test GitHub markdown links to headings. Correct links should resolve
while misspellings should result in a 404.

## Level two heading

#### Level four heading

### Test Same Name repeating

##### Test same Name Repeating

```bash
# This is a comment in a code block
```

Link to [Level two heading](#level-two-heading) should work.

Link to [Misspelled Level two heading](#level-two-headingg) should 404.

Link to [Level four heading](#level-four-heading) should work.

Link to [Test Same Name repeating](#test-same-name-repeating) should work.

Link to [Second Test same Name Repeating](#test-same-name-repeating-1) should work.

Link to [Third nonexistent Test same Name Repeating](#test-same-name-repeating-2) should 404.

Link to [comment in code block](#this-is-a-comment-in-a-code-block) should 404.

Link to [Level two heading using baseurl](https://BASEURL#level-two-heading) should work.

Link to [Level two heading using baseurl with slash](https://BASEURL/#level-two-heading) should work.

Link to [Level two heading using baseurl with slashes](https://BASEURL////#level-two-heading) should work.

0 comments on commit 7e9668f

Please # to comment.