Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
27 changes: 27 additions & 0 deletions .github/workflows/pr-link-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: 'PR Link Check'

on:
pull_request:
branches: [ main ]

jobs:
markdown-link-check:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'

- name: Install dependencies
run: npm install

- name: Generate file manifest
id: manifest
run: git ls-files > file-manifest.txt

- name: Run markdown-link-check
run: ./markdown-link-check --file-manifest file-manifest.txt .
30 changes: 30 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use strict';

const path = require('path');
const async = require('async');
const linkCheck = require('link-check');
const LinkCheckResult = require('link-check').LinkCheckResult;
Expand Down Expand Up @@ -199,6 +200,35 @@ module.exports = function markdownLinkCheck(markdown, opts, callback) {
return;
}

// Check for relative file paths if a file manifest is provided
if (opts.projectFiles && opts.sourceFile) {
const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z.+-]*:/.test(link);

if (!isAbsoluteUrl && !link.startsWith('#')) {
const sourceDir = path.dirname(opts.sourceFile);

// The link may have a hash/anchor at the end, which should be ignored for file path resolution
const hashIndex = link.indexOf('#');
const linkPath = hashIndex === -1 ? link : link.substring(0, hashIndex);

const resolvedPath = path.resolve(sourceDir, linkPath);
const projectRoot = process.cwd();
const relativePath = path.relative(projectRoot, resolvedPath);

// Normalize path separators to handle cross-platform differences
const normalizedRelativePath = relativePath.replace(/\\/g, '/');
const normalizedProjectFiles = opts.projectFiles.map(p => p.replace(/\\/g, '/'));

if (normalizedProjectFiles.includes(normalizedRelativePath)) {
// File exists in the project, so we'll treat the link as 'alive'
const result = new LinkCheckResult(opts, link, 200, undefined);
result.status = 'alive';
callback(null, result);
return; // Bypass the external linkCheck
}
}
}

linkCheck(link, opts, function (err, result) {
if (opts.showProgressBar) {
bar.tick();
Expand Down
21 changes: 18 additions & 3 deletions markdown-link-check
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ function getInputs() {
.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('--file-manifest <path>', 'path to a file containing a newline-separated list of project files for relative link validation')
.option('--projectBaseUrl <url>', 'the URL to use for {{BASEURL}} replacement')
.arguments('[filenamesOrDirectorynamesOrUrls...]')
.action(function (filenamesOrUrls) {
Expand Down Expand Up @@ -200,16 +201,30 @@ function getInputs() {
}

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

const opts = program.opts();
let projectFiles = [];
if (opts.fileManifest) {
try {
projectFiles = fs.readFileSync(opts.fileManifest, 'utf8').split('\n').filter(Boolean);
} catch (err) {
console.error(`\nERROR: Cannot read file manifest '${opts.fileManifest}'`);
process.exit(1);
}
}

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);
if (projectFiles.length > 0) {
input.opts.projectFiles = projectFiles;
}
input.opts.showProgressBar = (opts.progress === true); // force true or undefined to be true or false.
input.opts.quiet = (opts.quiet === true);
input.opts.verbose = (program.opts().verbose === true);
input.opts.retryOn429 = (program.opts().retry === true);
input.opts.aliveStatusCodes = program.opts().alive;
Expand Down
1 change: 1 addition & 0 deletions test-manifest.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
test/project-files-test.md\ntest/another-file.md
4 changes: 4 additions & 0 deletions test/another-file.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
This is another file.

## Section
Here is the content of the section.
41 changes: 41 additions & 0 deletions test/markdown-link-check.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -436,4 +436,45 @@ describe('markdown-link-check', function () {
done();
});
});

describe('project files', function () {
it('should pass for local file link when manifest is provided', function (done) {
const markdown = fs.readFileSync(path.join(dirname, 'project-files-test.md'), 'utf8');
const opts = {
projectFiles: [
'test/project-files-test.md',
'test/another-file.md'
],
sourceFile: path.resolve(path.join(dirname, 'project-files-test.md'))
};

markdownLinkCheck(markdown, opts, function (err, results) {
expect(err).to.be(null);
expect(results).to.be.an('array');
expect(results).to.have.length(2);
expect(results[0].status).to.be('alive');
expect(results[0].link).to.be('./another-file.md');
expect(results[1].status).to.be('alive');
expect(results[1].link).to.be('./another-file.md#section');
done();
});
});

it('should fail for local file link when manifest is not provided', function (done) {
const markdown = fs.readFileSync(path.join(dirname, 'project-files-test.md'), 'utf8');
const opts = {
baseUrl: 'http://localhost:1234' // Provide a dummy base URL
};

markdownLinkCheck(markdown, opts, function (err, results) {
expect(err).to.be(null);
expect(results).to.be.an('array');
expect(results).to.have.length(2);
// Without the manifest, it tries to resolve them as web links and fails
expect(results[0].status).to.be('dead');
expect(results[1].status).to.be('dead');
done();
});
});
});
});
5 changes: 5 additions & 0 deletions test/project-files-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
This file contains a link to another file within the project.

[Link to another file](./another-file.md)

[Link to another file with anchor](./another-file.md#section)