diff --git a/.github/workflows/pr-link-check.yml b/.github/workflows/pr-link-check.yml new file mode 100644 index 00000000..7ac1015c --- /dev/null +++ b/.github/workflows/pr-link-check.yml @@ -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 . diff --git a/index.js b/index.js index 167e9d47..698b79cb 100644 --- a/index.js +++ b/index.js @@ -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; @@ -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(); diff --git a/markdown-link-check b/markdown-link-check index 3849a012..f4110e1c 100755 --- a/markdown-link-check +++ b/markdown-link-check @@ -126,6 +126,7 @@ function getInputs() { .option('-a, --alive ', '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 ', 'specify reporters to use', commaSeparatedReportersList) + .option('--file-manifest ', 'path to a file containing a newline-separated list of project files for relative link validation') .option('--projectBaseUrl ', 'the URL to use for {{BASEURL}} replacement') .arguments('[filenamesOrDirectorynamesOrUrls...]') .action(function (filenamesOrUrls) { @@ -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; diff --git a/test-manifest.txt b/test-manifest.txt new file mode 100644 index 00000000..4da4afb7 --- /dev/null +++ b/test-manifest.txt @@ -0,0 +1 @@ +test/project-files-test.md\ntest/another-file.md diff --git a/test/another-file.md b/test/another-file.md new file mode 100644 index 00000000..4eb4b832 --- /dev/null +++ b/test/another-file.md @@ -0,0 +1,4 @@ +This is another file. + +## Section +Here is the content of the section. diff --git a/test/markdown-link-check.test.js b/test/markdown-link-check.test.js index fdcfca24..e37a20c1 100644 --- a/test/markdown-link-check.test.js +++ b/test/markdown-link-check.test.js @@ -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(); + }); + }); + }); }); diff --git a/test/project-files-test.md b/test/project-files-test.md new file mode 100644 index 00000000..3ec7fc06 --- /dev/null +++ b/test/project-files-test.md @@ -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)