From 571cf05724c9e8426348fd2567a26232e5e5e56f Mon Sep 17 00:00:00 2001 From: Christopher Whitley Date: Fri, 26 Jan 2024 17:05:16 -0500 Subject: [PATCH 1/9] Check for broken links (draft) --- .utilities/link-checker/index.js | 11 ++++ .utilities/link-checker/linkChecker.js | 85 ++++++++++++++++++++++++++ package.json | 3 +- 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 .utilities/link-checker/index.js create mode 100644 .utilities/link-checker/linkChecker.js diff --git a/.utilities/link-checker/index.js b/.utilities/link-checker/index.js new file mode 100644 index 00000000..c2634021 --- /dev/null +++ b/.utilities/link-checker/index.js @@ -0,0 +1,11 @@ +'use strict' +const linkChecker = require('./linkChecker'); +const path = require('path'); +const fs = require('fs'); + +const rootDir = path.join(process.cwd(), '_site', path.sep) +if(fs.existsSync(rootDir)) { + linkChecker(rootDir); +} else { + console.log(`Unable to find site directory at ${rootDir}. Did you forget to build the site first?`); +} diff --git a/.utilities/link-checker/linkChecker.js b/.utilities/link-checker/linkChecker.js new file mode 100644 index 00000000..29a08dc9 --- /dev/null +++ b/.utilities/link-checker/linkChecker.js @@ -0,0 +1,85 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const cheerio = require("cheerio"); +const https = require("https"); +const { URL } = require("url"); + +const alreadyChecked = new Set(); +const isValid = new Set(); +const errors = []; + +function markLinkInFileInvalid(file, link, lineNumber) { + errors.push({ + file: file, + link: link, + lineNumber: lineNumber, + }); +} + +function validateFile(rootDir, file) { + const absolutePath = path.join(rootDir, file); + const html = fs.readFileSync(absolutePath, "utf-8"); + const $ = cheerio.load(html, { withStartIndices: true }); + const links = $("a"); + + links.each((index, element) => { + const start = $(element).get(0).startIndex; + const lineNumber = html.substring(0, start).split("\n").length; + const href = $(element).attr("href"); + + // Has this link already been validated? + if (alreadyChecked.has(href)) { + // Was it valid? + if (isValid.has(href)) { + return; + } + + // Not valid, so store the info + markLinkInFileInvalid(file, href, lineNumber); + } + + // Is this an external or internal link? + const isExternal = href.startsWith("http"); + + if (isExternal) { + if (!href.startsWith("https")) { + markLinkInFileInvalid(file, href, lineNumber); + } else { + const url = new URL(href); + const req = https.request(url, (res) => { + if (res.statusCode === 404) { + markLinkInFileInvalid(file, href, lineNumber); + } + }); + } + } else { + const linkPath = path.join(rootDir, href); + + if (linkPath.endsWith("/")) { + linkPath += "index.html"; + } + + if (!fs.existsSync(linkPath)) { + markLinkInFileInvalid(file, href, lineNumber); + } + } + + isValid.add(href); + alreadyChecked.add(href); + }); +} + +function linkChecker(rootDir) { + const opts = { recursive: true }; + fs.readdirSync(rootDir, opts) + .filter((file) => file.endsWith(".html")) + .forEach((file) => validateFile(rootDir, file)); + + if (errors.length > 0) { + errors.forEach((err) => console.log(JSON.stringify(err))); + } +} + +module.exports = linkChecker; diff --git a/package.json b/package.json index a176267f..a810e70c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "debugbuild": "set DEBUG=Eleventy* & npx @11ty/eleventy", "debugdev": "set DEBUG=Eleventy* & npx @11ty/eleventy --serve", "benchmarkbuild": "set DEBUG=Eleventy:Benchmark* & npx @11ty/eleventy", - "benchmarkdev": "set DEBUG=Eleventy:Benchmark* & npx @11ty/eleventy --serve" + "benchmarkdev": "set DEBUG=Eleventy:Benchmark* & npx @11ty/eleventy --serve", + "checklinks": "node ./.utilities/link-checker/index.js" }, "devDependencies": { "@11ty/eleventy": "^2.0.1", From 8214e046f45bac0ecb4bd77d788dd17a90ae213d Mon Sep 17 00:00:00 2001 From: Christopher Whitley Date: Sun, 28 Jan 2024 01:21:38 -0500 Subject: [PATCH 2/9] ensure link is to /api/ --- .config/transforms/xref.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/transforms/xref.js b/.config/transforms/xref.js index 0ef24a4b..5fd7e973 100644 --- a/.config/transforms/xref.js +++ b/.config/transforms/xref.js @@ -5,7 +5,7 @@ function replaceXrefTags(content) { const parts = hrefAttribute.split('.'); const linkText = parts[parts.length - 1]; - return `${linkText}`; + return `${linkText}`; }); } From fc5aa20a6a42a74fed202f991cf00acd016ad2cb Mon Sep 17 00:00:00 2001 From: Christopher Whitley Date: Tue, 30 Jan 2024 00:11:26 -0500 Subject: [PATCH 3/9] Include ending '/' in category link. Discovered during invalid link testing --- _includes/macros/create_category_filter.njk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_includes/macros/create_category_filter.njk b/_includes/macros/create_category_filter.njk index 1b03a741..26567fbe 100644 --- a/_includes/macros/create_category_filter.njk +++ b/_includes/macros/create_category_filter.njk @@ -6,7 +6,7 @@

Select a category below to filter the results

{% for category in categories %} - = 0.8" + } + }, "node_modules/commander": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", @@ -868,6 +898,15 @@ "node": ">= 0.4" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dependency-graph": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", @@ -1156,6 +1195,40 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1830,6 +1903,27 @@ "node": ">=10.0.0" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2274,6 +2368,12 @@ "any-promise": "^0.1.0" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, "node_modules/prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", diff --git a/package.json b/package.json index a810e70c..cf2e37ba 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "clean-css": "^5.3.3", "eleventy-plugin-nesting-toc": "^1.3.0", "js-yaml": "^4.1.0", - "markdown-it-anchor": "^8.6.7" + "markdown-it-anchor": "^8.6.7", + "axios": "^1.6.7" } } From 4dcdf196dc9b241b3783d9e2ddb85fc8c5537cb2 Mon Sep 17 00:00:00 2001 From: Christopher Whitley Date: Tue, 30 Jan 2024 01:01:01 -0500 Subject: [PATCH 6/9] link check added, found issues in main repo documentation --- .utilities/link-checker/index.js | 35 ++- .utilities/link-checker/linkChecker.js | 411 +++++++++++++++++++++---- 2 files changed, 376 insertions(+), 70 deletions(-) diff --git a/.utilities/link-checker/index.js b/.utilities/link-checker/index.js index c2634021..85014f87 100644 --- a/.utilities/link-checker/index.js +++ b/.utilities/link-checker/index.js @@ -1,11 +1,34 @@ 'use strict' const linkChecker = require('./linkChecker'); const path = require('path'); -const fs = require('fs'); +//----------------------------------------------------------------------------- +// Root Directory is set based on the current working directory as this is +// expected to only be run in GitHub Actions CI environment to prevent build +// on errors. +// +// When running locally, ensure that your current working directory in your +// terminal is the project root directory. +//----------------------------------------------------------------------------- const rootDir = path.join(process.cwd(), '_site', path.sep) -if(fs.existsSync(rootDir)) { - linkChecker(rootDir); -} else { - console.log(`Unable to find site directory at ${rootDir}. Did you forget to build the site first?`); -} + +linkChecker(rootDir, function (result) { + // Log errors to console. + result.errors.forEach(error => { + console.error(`${error.reason} from ${error.source} to ${error.target}`) + }) + + // Log statistics to console + console.log('') + console.log('Stats:'); + console.log(` Internal Links: ${result.internalLinks}`); + console.log(` Internal Anchored Links: ${result.internalAnchoredLinks}`); + console.log(` Internal Parent Links: ${result.internalParentLinks}`); + console.log(` Internal Parent Anchored Links: ${result.internalParentAnchoredLinks}`); + console.log(` External Links: ${result.externalLinks}`); + console.log(` Error Count: ${result.errors.length}`); + + // Use the number of errors as the exit code. Anything other than 0 should tell the + // CI environment that the build cannot continue. + process.exit(result.errors.length); +}); diff --git a/.utilities/link-checker/linkChecker.js b/.utilities/link-checker/linkChecker.js index 29a08dc9..e75f73af 100644 --- a/.utilities/link-checker/linkChecker.js +++ b/.utilities/link-checker/linkChecker.js @@ -1,85 +1,368 @@ -"use strict"; - -const fs = require("fs"); -const path = require("path"); -const cheerio = require("cheerio"); -const https = require("https"); -const { URL } = require("url"); - -const alreadyChecked = new Set(); -const isValid = new Set(); -const errors = []; - -function markLinkInFileInvalid(file, link, lineNumber) { - errors.push({ - file: file, - link: link, - lineNumber: lineNumber, - }); +'use strict'; + +const fs = require('fs') +const path = require('path') +const debug = require('debug')('linkchecker') +const cheerio = require('cheerio') +const axios = require('axios'); + + +/** + * Checks whether a given href is an internal link. + * + * @param {string} href - The href to be checked. + * @returns {boolean} - True if the href is internal, false otherwise. + */ +function isInternalHref(href) { + return !href.startsWith('http://') && + !href.startsWith('https://'); +} + +/** + * Checks whether a given href is an external link. + * + * @param {string} href - The href to be checked. + * @returns {boolean} - True if the href is external, false otherwise. + */ +function isExternalHref(href) { + return !isInternalHref(href); +} + +/** + * Formats hrefs, making them consistent and handling special cases. + * + * @param {string} href - The href to be formatted. + * @returns {string} - The formatted href. + */ +function formatHref(href) { + // Internal hrefs end with '/' which points to an '/index.html' file, so we need + // to format the local hrefs to include the 'index.html' where appropriate. + if (isInternalHref(href)) { + if (href.split('').pop() === '/') { + href += 'index.html'; + } else if (href.substring(href.length - 2) === '..') { + href += '/index.html'; + } else if (href.indexOf('/#') >= 0) { + const index = href.indexOf('#'); + href = href.substring(0, index) + 'index.html' + href.substring(index); + } + } + + const split = href.split('#'); + if (split.length === 2) { + href = split[0] + '#' + decodeURIComponent(split[1]); + } + + + + return href; } -function validateFile(rootDir, file) { - const absolutePath = path.join(rootDir, file); - const html = fs.readFileSync(absolutePath, "utf-8"); - const $ = cheerio.load(html, { withStartIndices: true }); - const links = $("a"); - - links.each((index, element) => { - const start = $(element).get(0).startIndex; - const lineNumber = html.substring(0, start).split("\n").length; - const href = $(element).attr("href"); - - // Has this link already been validated? - if (alreadyChecked.has(href)) { - // Was it valid? - if (isValid.has(href)) { +/** + * @typedef {Object} ScanDirectoryResult + * @property {Map} internalLinks - Map of internal links. + * @property {Map} internalAnchoredLinks - Map of internal anchored links. + * @property {Map} internalParentLinks - Map of internal parent links. + * @property {Map} internalParentAnchoredLinks - Map of internal parent anchored links. + * @property {Map} externalLinks - Map of external links. + * @property {Map>} internalAnchors - Map of internal anchors. + * @property {Set} internalPages - Set of internal pages. + */ + +/** + * Scans a given directory for HTML files and extracts internal and external links. + * + * @param {string} directory - The directory path to scan. + * @returns {ScanDirectoryResult} - An object containing various maps and sets representing internal and external links. + */ +async function scanDirectory(directory) { + const internalLinks = new Map(); // Links to internal files, without a # anchor. + const internalAnchoredLinks = new Map(); // Links to internal files with a # anchor. + const internalParentLinks = new Map(); // + const internalParentAnchoredLinks = new Map(); // + const externalLinks = new Map(); // Links to external http(s) paths without a # anchor. + const externalAnchoredLinks = new Map(); // Links to external http(s) paths with a # anchor. + const internalAnchors = new Map(); // All # anchors for an internal file. + const internalPages = new Set(); + + debug('scanning directory... ', directory); + + fs.readdirSync(directory, { recursive: true }) + .forEach((file) => { + if (!file.endsWith('.html')) { return; } - // Not valid, so store the info - markLinkInFileInvalid(file, href, lineNumber); - } + const filePath = path.join(directory, file); + const fileContent = fs.readFileSync(filePath); + + internalPages.add(filePath); + + const $ = cheerio.load(fileContent); + + // Extract links from tags. + const aTags = $('body').find('a'); + aTags.each((index, element) => { + let href = ($(element).attr('href') || '').trim(); + // const $this = $(this); + // let href = ($this.attr('href') || '').trim(); - // Is this an external or internal link? - const isExternal = href.startsWith("http"); - - if (isExternal) { - if (!href.startsWith("https")) { - markLinkInFileInvalid(file, href, lineNumber); - } else { - const url = new URL(href); - const req = https.request(url, (res) => { - if (res.statusCode === 404) { - markLinkInFileInvalid(file, href, lineNumber); + if (!href || href === '') { return; } // Ignore anchor tags with no href attribute + if (href === '#') { return; } // Ignore hash only links + if (href === '.') { return; } // Ignore self referental hrefs + if (href.startsWith('mailto:')) { return; } // Ignore mailto: links + if (href.startsWith('javascript:')) { return; } // Ignore javascript: executable hrefs + + href = formatHref(href); + const absoluteHref = path.join(directory, href); + + if (isExternalHref(href)) { + if (href.indexOf('#') >= 0) { + externalAnchoredLinks.set(href, filePath); + } else { + externalLinks.set(href, filePath); } + } else if (absoluteHref.startsWith('..')) { + if (href.indexOf('#') >= 0) { + internalParentAnchoredLinks.set(absoluteHref, filePath); + } else { + internalParentLinks.set(absoluteHref, filePath); + } + } else if (href.indexOf('#') >= 0) { + const absoluteAnchoredHref = (href.indexOf('#') === 0 ? filePath + href : absoluteHref); + internalAnchoredLinks.set(absoluteAnchoredHref, filePath); + } else { + internalLinks.set(absoluteHref, filePath); + } + }); + + // Extract anchors (id or name) attributes from file + const anchors = $('html').find('[id], [name]'); + anchors.each((index, element) => { + const anchor = $(element).attr('id') || $(element).attr('name'); + const entry = internalAnchors.get(filePath) || new Set(); + entry.add(anchor); + internalAnchors.set(filePath, entry); + }) + }); + + return { + internalLinks: internalLinks, + internalAnchoredLinks: internalAnchoredLinks, + internalParentLinks: internalParentLinks, + internalParentAnchoredLinks: internalParentAnchoredLinks, + externalLinks: externalLinks, + internalAnchors: internalAnchors, + internalPages: internalPages, + }; +} + +/** + * Validates internal links against existing internal pages. + * + * @param {Map} internalLinks - Map of internal links. + * @param {Set} internalPages - Set of internal pages. + * @param {Array} errors - Array to store validation errors. + */ +async function validateInternalLinks(internalLinks, internalPages, errors) { + internalLinks.forEach((sourcePage, link) => { + if (!internalPages.has(link)) { + errors.push({ + type: 'page', + target: link, + source: sourcePage, + reason: 'page not found' + }); + } + }); +} + +/** + * Validates internal anchored links against existing anchors in internal pages. + * + * @param {Map} internalAnchoredLinks - Map of internal anchored links. + * @param {Map>} internalAnchors - Map of internal anchors. + * @param {Array} errors - Array to store validation errors. + */ +async function validateInternalAnchoredLinks(internalAnchoredLinks, internalAnchors, errors) { + internalAnchoredLinks.forEach((sourcePage, link) => { + const index = link.indexOf('#'); + const page = link.substring(0, index) || sourcePage; + const anchor = link.substring(index + 1); + const entry = internalAnchors.get(page) || new Set(); + if (!entry.has(anchor)) { + errors.push({ + type: 'anchor', + target: page + '#' + anchor, + anchor: anchor, + source: sourcePage, + reason: 'anchor not found' + }); + } + }); +} + +/** + * Validates internal parent links against existing pages in the specified directory. + * + * @param {string} directory - The directory path to validate against. + * @param {Map} internalParentLinks - Map of internal parent links. + * @param {Array} errors - Array to store validation errors. + */ +async function validateInternalParentLinks(directory, internalParentLinks, errors) { + const targets = Array.from(internalParentLinks.keys()); + + await Promise.all(targets.map((target) => { + return new Promise((resolve) => { + fs.exists(path.resolve(directory, target), (result) => { + resolve(result); + }); + }); + })).then((results) => { + results.forEach((result, index) => { + const target = targets[index]; + const source = internalParentLinks.get(target); + if (!result) { + errors.push({ + type: 'page', + target: target, + source: source, + reason: 'page not found' }); } - } else { - const linkPath = path.join(rootDir, href); + }); + }); +} - if (linkPath.endsWith("/")) { - linkPath += "index.html"; +/** + * Validates internal parent anchored links against existing pages and anchors. + * + * @param {string} directory - The directory path to validate against. + * @param {Map} internalParentAnchoredLinks - Map of internal parent anchored links. + * @param {Array} errors - Array to store validation errors. + */ +async function validateInternalParentAnchoredLinks(directory, internalParentAnchoredLinks, errors) { + const targets = Array.from(internalParentAnchoredLinks.keys()); + + await Promise.all(targets.map((target) => { + return new Promise((resolve, reject) => { + const filePath = target.split('#')[0]; + const absolutePath = path.resolve(directory, filePath); + fs.readFile(absolutePath, 'utf-8', resolve); + }); + })).then((results) => { + results.forEach((result, index) => { + const target = targets[index]; + const source = internalParentAnchoredLinks.get(target); + if (result instanceof Error) { + return errors.push({ + type: 'page', + target: target, + source: source, + reason: 'page not found' + }); } - if (!fs.existsSync(linkPath)) { - markLinkInFileInvalid(file, href, lineNumber); + if (!result) { + errors.push({ + type: 'anchor', + target: target, + source: source, + reason: 'anchor not found' + }); } - } + }); + }); + +} - isValid.add(href); - alreadyChecked.add(href); +/** + * Validates external links by making HEAD requests. + * + * @param {Map} externalLinks - Map of external links. + * @param {Array} errors - Array to store validation errors. + */ +async function validateExternalLinks(externalLinks, errors) { + const targets = Array.from(externalLinks.keys()); + + await Promise.all(targets.map((target) => { + return axios.head(target, { + validateStatus: (status) => { + return status !== 404; + } + }); + })).then((responses) => { + responses.forEach((response, index) => { + if (!response || !response.statusCode || !response.statusCode >= 200 || !response.statusCode < 300) { + const error = response; + const target = targets[index]; + const source = externalLinks.get(target); + errors.push({ + type: 'external-link', + target: target, + source: source, + reason: 'could not fetch external page: ' + error.toString() + ' (Code: ' + response.statusCode + ')' + }); + } + }) + }).catch((error) => { + console.log("An unexpected error has occured"); + console.log(error.toString()); }); } -function linkChecker(rootDir) { - const opts = { recursive: true }; - fs.readdirSync(rootDir, opts) - .filter((file) => file.endsWith(".html")) - .forEach((file) => validateFile(rootDir, file)); +/** + * Performs link checking for a given directory and provides a callback with the results. + * + * @param {string} directory - The directory path to perform link checking. + * @param {Function} callback - Callback function to receive the results. + */ +async function linkChecker(directory, callback) { + const { + internalLinks, + internalAnchoredLinks, + internalParentLinks, + internalParentAnchoredLinks, + externalLinks, + internalAnchors, + internalPages + } = await scanDirectory(directory); + + const errors = []; + + await validateInternalLinks(internalLinks, internalPages, errors) + await validateInternalAnchoredLinks(internalAnchoredLinks, internalAnchors, errors); + await validateInternalParentLinks(directory, internalParentLinks, errors); + await validateInternalParentAnchoredLinks(directory, internalParentAnchoredLinks, errors); + await validateExternalLinks(externalLinks, errors); + + callback({ + errors, + internalLinks: internalLinks.size, + internalAnchoredLinks: internalAnchoredLinks.size, + internalParentLinks: internalParentLinks.size, + internalParentAnchoredLinks: internalParentAnchoredLinks.size, + externalLinks: externalLinks.size + }); - if (errors.length > 0) { - errors.forEach((err) => console.log(JSON.stringify(err))); - } } +/** + * @typedef {Object} LinkCheckerResult + * @property {Array} errors - Array of validation errors. + * @property {number} internalLinks - Count of internal links. + * @property {number} internalAnchoredLinks - Count of internal anchored links. + * @property {number} internalParentLinks - Count of internal parent links. + * @property {number} internalParentAnchoredLinks - Count of internal parent anchored links. + * @property {number} externalLinks - Count of external links. + */ + +/** + * Exports the linkChecker function as the module's main export. + * + * @type {Function} + * @param {string} directory - The directory path to perform link checking. + * @param {Function} callback - Callback function to receive the results. + * @returns {LinkCheckerResult} - Results of the link checking. + */ module.exports = linkChecker; From edfc66572202feacc4353d887f13ce59574f75d1 Mon Sep 17 00:00:00 2001 From: Christopher Whitley Date: Wed, 31 Jan 2024 14:41:20 -0500 Subject: [PATCH 7/9] FIx broken links found --- content/articles/getting_started/3_understanding_the_code.md | 2 +- content/articles/getting_started/4_adding_content.md | 4 ++-- content/articles/index.md | 4 ++-- content/articles/migrate_38.md | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/content/articles/getting_started/3_understanding_the_code.md b/content/articles/getting_started/3_understanding_the_code.md index f1000962..02afefdc 100644 --- a/content/articles/getting_started/3_understanding_the_code.md +++ b/content/articles/getting_started/3_understanding_the_code.md @@ -7,7 +7,7 @@ description: A look at the code that is generated after creating a new project. This tutorial will go over the code that is generated when you start a blank project. -> For help with creating a project, please look at the Creating a New Project section of the [Getting Started guide](index.md). +> For help with creating a project, please look at the Creating a New Project section of the [Getting Started guide](/). Within the **Game.cs** class file, which is the core of any MonoGame project, you will find several critical sections necessary for your game to run: diff --git a/content/articles/getting_started/4_adding_content.md b/content/articles/getting_started/4_adding_content.md index 330b5170..662bf98d 100644 --- a/content/articles/getting_started/4_adding_content.md +++ b/content/articles/getting_started/4_adding_content.md @@ -7,7 +7,7 @@ description: Learn how to add content such as images or sounds to your game. This tutorial will go over adding content such as images or sounds to your game. -> For help with creating a project, please look at the [Creating a New Project](index.md) section of the Getting Started guide. +> For help with creating a project, please look at the [Creating a New Project](/) section of the Getting Started guide. ## MonoGame Content Builder Tool (MGCB Editor) @@ -29,7 +29,7 @@ Now open up your game project and look at the Solution Explorer window. Expand t You should now see the MGCB Editor window open up. If a text file opens instead, then right-click on **Content.mgcb** and select **Open With**, then select **MGCB Editor** in the list, click **Set as Default** and then click **OK**, then try again. -> If you do not see the **MGCB Editor** option when you right-click and select **Open With**, then please review the [Tools documentation](../tools/index.md) for installing the MGCB Editor tool for your operating system. +> If you do not see the **MGCB Editor** option when you right-click and select **Open With**, then please review the [Tools documentation](../tools/) for installing the MGCB Editor tool for your operating system. ![MGCB Editor](images/3_mgcb_editor_tool.png) diff --git a/content/articles/index.md b/content/articles/index.md index edd4a74b..4e0704fe 100644 --- a/content/articles/index.md +++ b/content/articles/index.md @@ -16,7 +16,7 @@ It is a re-implementation of the discontinued [Microsoft's XNA Framework](https: - Content building and optimization - Math library optimized for games -This documentation [helps you to get started](getting_started/index.md) by providing overviews of key features and tools, and a complete API reference. +This documentation [helps you to get started](getting_started/) by providing overviews of key features and tools, and a complete API reference. Please use the links at the top and left to navigate the documentation sections. @@ -30,7 +30,7 @@ If you are expecting a scene editor (like Unity or Unreal), MonoGame is not that If you love coding and understanding how things work under the hood, MonoGame might be what you are looking for. And fear not, getting a game running with MonoGame only takes a few minutes. -[Let's get started!](getting_started/index.md) +[Let's get started!](getting_started/) ## We Need Your Help! diff --git a/content/articles/migrate_38.md b/content/articles/migrate_38.md index bc5c36ed..e0a78459 100644 --- a/content/articles/migrate_38.md +++ b/content/articles/migrate_38.md @@ -7,7 +7,7 @@ description: A guide on migrating a MonoGame v3.8.0 project to the current versi Migrating from 3.8.0 should be straightforward for most platforms. -The major difference is that 3.8.1 now requires .NET 6 and Visual Studio 2022. You can follow the [environment setup tutorial](getting_started/index.md) to make sure that you are not missing any components. +The major difference is that 3.8.1 now requires .NET 6 and Visual Studio 2022. You can follow the [environment setup tutorial](getting_started/) to make sure that you are not missing any components. The MGCB Editor is no longer a global .NET tool and we recommend that you use the new Visual Studio 2022 extension which helps accessing it without the need of CLI commands. From a2f8a947fe545f0cd562bd18aa64369a6cd8f82a Mon Sep 17 00:00:00 2001 From: Christopher Whitley Date: Wed, 31 Jan 2024 14:49:20 -0500 Subject: [PATCH 8/9] Correct header name so navigation links work --- content/presskit.njk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/presskit.njk b/content/presskit.njk index 7dd7fe9d..5681ea9a 100644 --- a/content/presskit.njk +++ b/content/presskit.njk @@ -116,7 +116,7 @@ hidetoc: true
-

Logos & Icons

+

Logos & Icons

Please refer to the MonoGame Logos and Branding repository for information on logo and branding usage.

From 5199d7bc849c1d180e42a2a6d013cb062dd31049 Mon Sep 17 00:00:00 2001 From: Christopher Whitley Date: Wed, 31 Jan 2024 17:02:55 -0500 Subject: [PATCH 9/9] Move link validation to 11ty after build event. Api documentation failing --- .config/eleventy.config.events.js | 21 ++ .config/events/link-checker.js | 374 +++++++++++++++++++++++++ .eleventy.js | 2 + .utilities/link-checker/index.js | 34 --- .utilities/link-checker/linkChecker.js | 368 ------------------------ 5 files changed, 397 insertions(+), 402 deletions(-) create mode 100644 .config/eleventy.config.events.js create mode 100644 .config/events/link-checker.js delete mode 100644 .utilities/link-checker/index.js delete mode 100644 .utilities/link-checker/linkChecker.js diff --git a/.config/eleventy.config.events.js b/.config/eleventy.config.events.js new file mode 100644 index 00000000..c721c6d5 --- /dev/null +++ b/.config/eleventy.config.events.js @@ -0,0 +1,21 @@ +"use strict"; + +const linkChecker = require("./events/link-checker"); + +/** @param {import("@11ty/eleventy").UserConfig} config */ +module.exports = function (config) { + config.on("eleventy.after", ({ dir, results, runMode, outputMode }) => { + linkChecker(dir, results, runMode, outputMode, (result) => { + result.errors.forEach((error) => console.log(error)); + // Log statistics to console + console.log('') + console.log('Stats:'); + console.log(` Internal Links: ${result.internalLinks}`); + console.log(` Internal Anchored Links: ${result.internalAnchoredLinks}`); + console.log(` Internal Parent Links: ${result.internalParentLinks}`); + console.log(` Internal Parent Anchored Links: ${result.internalParentAnchoredLinks}`); + console.log(` External Links: ${result.externalLinks}`); + console.log(` Error Count: ${result.errors.length}`); + }); + }); +}; diff --git a/.config/events/link-checker.js b/.config/events/link-checker.js new file mode 100644 index 00000000..1ed4757b --- /dev/null +++ b/.config/events/link-checker.js @@ -0,0 +1,374 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const debug = require("debug")("link-checker"); +const cheerio = require("cheerio"); +const axios = require("axios"); + +/** + * Checks whether a given href is an internal link. + * + * @param {string} href - The href to be checked. + * @returns {boolean} - True if the href is internal, false otherwise. + */ +function isInternalHref(href) { + return !href.startsWith("http://") && !href.startsWith("https://"); +} + +/** + * Checks whether a given href is an external link. + * + * @param {string} href - The href to be checked. + * @returns {boolean} - True if the href is external, false otherwise. + */ +function isExternalHref(href) { + return !isInternalHref(href); +} + +/** + * Formats hrefs, making them consistent and handling special cases. + * + * @param {string} href - The href to be formatted. + * @returns {string} - The formatted href. + */ +function formatHref(href) { + // Internal hrefs end with '/' which points to an '/index.html' file, so we need + // to format the local hrefs to include the 'index.html' where appropriate. + if (isInternalHref(href) && href.indexOf("index.html") < 0) { + // Convert all '/' and '\' in the href to path.sep to make it easier to + // check based on operating system. + href = href.replace(/[\\/]/g, path.sep); + + // Check if the last character is the path separator. This is how 11ty + // generates urls (pretty urls). If it is, we'll need to append the + // 'index.html' to the end for the full file path. + if (href.split("").pop() === path.sep) { + href += "index.html"; + } + + // Check if the href contains an anchor. if it does, we'll need to + // split it to add the 'index.html' and append the anchor after. + else if (href.indexOf("#") >= 0) { + // else if (href.indexOf(path.sep + '#') >= 0) { + const index = href.indexOf("#"); + href = + href.substring(0, index) + "index.html" + href.substring(index); + } + + // Check if the final character of the href is not the path separator. + // if not, append the path separator and 'index.html' + else if (!href.endsWith(path.sep)) { + href += path.sep + "index.html"; + } + } + + const split = href.split("#"); + if (split.length === 2) { + href = split[0] + "#" + decodeURIComponent(split[1]); + } + + return href; +} + +/** + * Validates internal links against existing internal pages. + * + * @param {Map} internalLinks - Map of internal links. + * @param {Set} internalPages - Set of internal pages. + * @param {Array} errors - Array to store validation errors. + */ +async function validateInternalLinks(internalLinks, internalPages, errors) { + internalLinks.forEach((sourcePage, link) => { + if (!internalPages.has(link)) { + errors.push({ + type: "page", + target: link, + source: sourcePage, + reason: "page not found", + }); + } + }); +} + +/** + * Validates internal anchored links against existing anchors in internal pages. + * + * @param {Map} internalAnchoredLinks - Map of internal anchored links. + * @param {Map>} internalAnchors - Map of internal anchors. + * @param {Array} errors - Array to store validation errors. + */ +async function validateInternalAnchoredLinks( + internalAnchoredLinks, + internalAnchors, + errors +) { + internalAnchoredLinks.forEach((sourcePage, link) => { + if (link.indexOf("platform-profile") >= 0) { + console.log('here'); + } + const index = link.indexOf("#"); + const page = link.substring(0, index) || sourcePage; + const anchor = link.substring(index + 1); + const entry = internalAnchors.get(page) || new Set(); + if (!entry.has(anchor)) { + errors.push({ + type: "anchor", + target: page + "#" + anchor, + anchor: anchor, + source: sourcePage, + reason: "anchor not found", + }); + } + }); +} + +/** + * Validates internal parent links against existing pages in the specified directory. + * + * @param {string} directory - The directory path to validate against. + * @param {Map} internalParentLinks - Map of internal parent links. + * @param {Array} errors - Array to store validation errors. + */ +async function validateInternalParentLinks( + directory, + internalParentLinks, + errors +) { + const targets = Array.from(internalParentLinks.keys()); + + await Promise.all( + targets.map((target) => { + return new Promise((resolve) => { + fs.exists(path.resolve(directory, target), (result) => { + resolve(result); + }); + }); + }) + ).then((results) => { + results.forEach((result, index) => { + const target = targets[index]; + const source = internalParentLinks.get(target); + if (!result) { + errors.push({ + type: "page", + target: target, + source: source, + reason: "page not found", + }); + } + }); + }); +} + +/** + * Validates internal parent anchored links against existing pages and anchors. + * + * @param {string} directory - The directory path to validate against. + * @param {Map} internalParentAnchoredLinks - Map of internal parent anchored links. + * @param {Array} errors - Array to store validation errors. + */ +async function validateInternalParentAnchoredLinks( + directory, + internalParentAnchoredLinks, + errors +) { + const targets = Array.from(internalParentAnchoredLinks.keys()); + + await Promise.all( + targets.map((target) => { + return new Promise((resolve, reject) => { + const filePath = target.split("#")[0]; + const absolutePath = path.resolve(directory, filePath); + fs.readFile(absolutePath, "utf-8", resolve); + }); + }) + ).then((results) => { + results.forEach((result, index) => { + const target = targets[index]; + const source = internalParentAnchoredLinks.get(target); + if (result instanceof Error) { + return errors.push({ + type: "page", + target: target, + source: source, + reason: "page not found", + }); + } + + if (!result) { + errors.push({ + type: "anchor", + target: target, + source: source, + reason: "anchor not found", + }); + } + }); + }); +} + +/** + * Validates external links by making HEAD requests. + * + * @param {Map} externalLinks - Map of external links. + * @param {Array} errors - Array to store validation errors. + */ +async function validateExternalLinks(externalLinks, errors) { + const targets = Array.from(externalLinks.keys()); + + await Promise.all( + targets.map((target) => { + return axios.head(target, { + validateStatus: (status) => { + return status !== 404; + }, + }); + }) + ) + .then((responses) => { + responses.forEach((response, index) => { + if (!response || !response.statusCode || !response.statusCode >= 200 || !response.statusCode < 300) { + const error = response; + const target = targets[index]; + const source = externalLinks.get(target); + errors.push({ + type: "external-link", + target: target, + source: source, + reason: "could not fetch external page: " + error.toString() + " (Code: " + response.statusCode + ")", + }); + } + }); + }) + .catch((error) => { + console.log("An unexpected error has occurred"); + console.log(error.toString()); + }); +} + +/** + * Performs link checking for a given directory and provides a callback with the results. + * + * @param {string} directory - The directory path to perform link checking. + * @param {Function} callback - Callback function to receive the results. + */ +async function linkChecker(dir, results, runMode, outputMode, callback) { + // const rootDir = path.resolve(process.cwd(), dir.output); + const rootDir = process.cwd(); + + const internalLinks = new Map(); // Links to internal files, without a # anchor. + const internalAnchoredLinks = new Map(); // Links to internal files with a # anchor. + const internalParentLinks = new Map(); // + const internalParentAnchoredLinks = new Map(); // + const externalLinks = new Map(); // Links to external http(s) paths without a # anchor. + const externalAnchoredLinks = new Map(); // Links to external http(s) paths with a # anchor. + const internalAnchors = new Map(); // All # anchors for an internal file. + const internalPages = new Set(); + + results.forEach((entry) => { + + const filePath = path.resolve(rootDir, entry.outputPath); + internalPages.add(filePath); + + const $ = cheerio.load(entry.content); + + // Extract links from tags. + const aTags = $('body').find('a'); + aTags.each((index, element) => { + let href = ($(element).attr('href') || '').trim(); + + if (!href || href === '') { return; } // Ignore anchor tags with no href attribute. + if (href === '#') { return; } // Ignore hash only links. + if (href === '.') { return; } // Ignore self referential hrefs. + if (href.startsWith('mailto:')) { return; } // Ignore mailto: links + if (href.startsWith('javascript:')) { return; } // Ignore javascript: executable hrefs. + + // If the href is an external link, handle and return to continue + // iteration + if (isExternalHref(href)) { + if (href.indexOf('#') >= 0) { + externalAnchoredLinks.set(href, filePath); + } else { + externalLinks.set(href, filePath); + } + return; + } + + // Otherwise it is assumed to be an internal link. + if (href.indexOf("#foundation") >= 0) { + console.log('here'); + } + + let absoluteHref; + if (href.startsWith('#')) { + absoluteHref = filePath + href; + } else { + const formattedHref = formatHref(href); + absoluteHref = path.join(rootDir, dir.output, formattedHref); + } + + if (absoluteHref.startsWith('..')) { + if (href.indexOf('#') >= 0) { + internalParentAnchoredLinks.set(absoluteHref, filePath); + } else { + internalParentLinks.set(absoluteHref, filePath); + } + } else if (href.indexOf('#') >= 0) { + const absoluteAnchoredHref = href.indexOf('#') === 0 ? filePath + href : absoluteHref; + internalAnchoredLinks.set(absoluteAnchoredHref, filePath); + } else { + internalLinks.set(absoluteHref, filePath); + } + }); + + // Extract anchors (id or name) attributes from file. + const anchors = $('html').find('[id], [name]'); + anchors.each((index, element) => { + const anchor = $(element).attr('id') || $(element).attr('name'); + if (anchor === 'runtime-use') { + console.log('here'); + } + const entry = internalAnchors.get(filePath) || new Set(); + entry.add(anchor); + internalAnchors.set(filePath, entry); + }); + }); + + const errors = []; + + await validateInternalLinks(internalLinks, internalPages, errors); + await validateInternalAnchoredLinks(internalAnchoredLinks, internalAnchors, errors); + await validateInternalParentLinks(rootDir, internalParentLinks, errors); + await validateInternalParentAnchoredLinks(rootDir, internalParentAnchoredLinks, errors); + await validateExternalLinks(externalLinks, errors); + + callback({ + errors, + internalLinks: internalLinks.size, + internalAnchoredLinks: internalAnchoredLinks.size, + internalParentLinks: internalParentLinks.size, + internalParentAnchoredLinks: internalParentAnchoredLinks.size, + externalLinks: externalLinks.size, + }); +} + +/** + * @typedef {Object} LinkCheckerResult + * @property {Array} errors - Array of validation errors. + * @property {number} internalLinks - Count of internal links. + * @property {number} internalAnchoredLinks - Count of internal anchored links. + * @property {number} internalParentLinks - Count of internal parent links. + * @property {number} internalParentAnchoredLinks - Count of internal parent anchored links. + * @property {number} externalLinks - Count of external links. + */ + +/** + * Exports the linkChecker function as the module's main export. + * + * @type {Function} + * @param {string} directory - The directory path to perform link checking. + * @param {Function} callback - Callback function to receive the results. + * @returns {LinkCheckerResult} - Results of the link checking. + */ +module.exports = linkChecker; diff --git a/.eleventy.js b/.eleventy.js index 60e825b5..3371628f 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -9,6 +9,7 @@ const plugins = require('./.config/eleventy.config.plugins'); const libraries = require('./.config/eleventy.config.libraries'); const passThrough = require('./.config/eleventy.config.passthrough'); const transforms = require('./.config/eleventy.config.transforms'); +const events = require('./.config/eleventy.config.events'); /** @param {import("@11ty/eleventy").UserConfig} eleventyConfig */ function eleventy(eleventyConfig) { @@ -21,6 +22,7 @@ function eleventy(eleventyConfig) { libraries(eleventyConfig); passThrough(eleventyConfig); transforms(eleventyConfig); + events(eleventyConfig); return { // Which files Eleventy will process diff --git a/.utilities/link-checker/index.js b/.utilities/link-checker/index.js deleted file mode 100644 index 85014f87..00000000 --- a/.utilities/link-checker/index.js +++ /dev/null @@ -1,34 +0,0 @@ -'use strict' -const linkChecker = require('./linkChecker'); -const path = require('path'); - -//----------------------------------------------------------------------------- -// Root Directory is set based on the current working directory as this is -// expected to only be run in GitHub Actions CI environment to prevent build -// on errors. -// -// When running locally, ensure that your current working directory in your -// terminal is the project root directory. -//----------------------------------------------------------------------------- -const rootDir = path.join(process.cwd(), '_site', path.sep) - -linkChecker(rootDir, function (result) { - // Log errors to console. - result.errors.forEach(error => { - console.error(`${error.reason} from ${error.source} to ${error.target}`) - }) - - // Log statistics to console - console.log('') - console.log('Stats:'); - console.log(` Internal Links: ${result.internalLinks}`); - console.log(` Internal Anchored Links: ${result.internalAnchoredLinks}`); - console.log(` Internal Parent Links: ${result.internalParentLinks}`); - console.log(` Internal Parent Anchored Links: ${result.internalParentAnchoredLinks}`); - console.log(` External Links: ${result.externalLinks}`); - console.log(` Error Count: ${result.errors.length}`); - - // Use the number of errors as the exit code. Anything other than 0 should tell the - // CI environment that the build cannot continue. - process.exit(result.errors.length); -}); diff --git a/.utilities/link-checker/linkChecker.js b/.utilities/link-checker/linkChecker.js deleted file mode 100644 index e75f73af..00000000 --- a/.utilities/link-checker/linkChecker.js +++ /dev/null @@ -1,368 +0,0 @@ -'use strict'; - -const fs = require('fs') -const path = require('path') -const debug = require('debug')('linkchecker') -const cheerio = require('cheerio') -const axios = require('axios'); - - -/** - * Checks whether a given href is an internal link. - * - * @param {string} href - The href to be checked. - * @returns {boolean} - True if the href is internal, false otherwise. - */ -function isInternalHref(href) { - return !href.startsWith('http://') && - !href.startsWith('https://'); -} - -/** - * Checks whether a given href is an external link. - * - * @param {string} href - The href to be checked. - * @returns {boolean} - True if the href is external, false otherwise. - */ -function isExternalHref(href) { - return !isInternalHref(href); -} - -/** - * Formats hrefs, making them consistent and handling special cases. - * - * @param {string} href - The href to be formatted. - * @returns {string} - The formatted href. - */ -function formatHref(href) { - // Internal hrefs end with '/' which points to an '/index.html' file, so we need - // to format the local hrefs to include the 'index.html' where appropriate. - if (isInternalHref(href)) { - if (href.split('').pop() === '/') { - href += 'index.html'; - } else if (href.substring(href.length - 2) === '..') { - href += '/index.html'; - } else if (href.indexOf('/#') >= 0) { - const index = href.indexOf('#'); - href = href.substring(0, index) + 'index.html' + href.substring(index); - } - } - - const split = href.split('#'); - if (split.length === 2) { - href = split[0] + '#' + decodeURIComponent(split[1]); - } - - - - return href; -} - -/** - * @typedef {Object} ScanDirectoryResult - * @property {Map} internalLinks - Map of internal links. - * @property {Map} internalAnchoredLinks - Map of internal anchored links. - * @property {Map} internalParentLinks - Map of internal parent links. - * @property {Map} internalParentAnchoredLinks - Map of internal parent anchored links. - * @property {Map} externalLinks - Map of external links. - * @property {Map>} internalAnchors - Map of internal anchors. - * @property {Set} internalPages - Set of internal pages. - */ - -/** - * Scans a given directory for HTML files and extracts internal and external links. - * - * @param {string} directory - The directory path to scan. - * @returns {ScanDirectoryResult} - An object containing various maps and sets representing internal and external links. - */ -async function scanDirectory(directory) { - const internalLinks = new Map(); // Links to internal files, without a # anchor. - const internalAnchoredLinks = new Map(); // Links to internal files with a # anchor. - const internalParentLinks = new Map(); // - const internalParentAnchoredLinks = new Map(); // - const externalLinks = new Map(); // Links to external http(s) paths without a # anchor. - const externalAnchoredLinks = new Map(); // Links to external http(s) paths with a # anchor. - const internalAnchors = new Map(); // All # anchors for an internal file. - const internalPages = new Set(); - - debug('scanning directory... ', directory); - - fs.readdirSync(directory, { recursive: true }) - .forEach((file) => { - if (!file.endsWith('.html')) { - return; - } - - const filePath = path.join(directory, file); - const fileContent = fs.readFileSync(filePath); - - internalPages.add(filePath); - - const $ = cheerio.load(fileContent); - - // Extract links from tags. - const aTags = $('body').find('a'); - aTags.each((index, element) => { - let href = ($(element).attr('href') || '').trim(); - // const $this = $(this); - // let href = ($this.attr('href') || '').trim(); - - if (!href || href === '') { return; } // Ignore anchor tags with no href attribute - if (href === '#') { return; } // Ignore hash only links - if (href === '.') { return; } // Ignore self referental hrefs - if (href.startsWith('mailto:')) { return; } // Ignore mailto: links - if (href.startsWith('javascript:')) { return; } // Ignore javascript: executable hrefs - - href = formatHref(href); - const absoluteHref = path.join(directory, href); - - if (isExternalHref(href)) { - if (href.indexOf('#') >= 0) { - externalAnchoredLinks.set(href, filePath); - } else { - externalLinks.set(href, filePath); - } - } else if (absoluteHref.startsWith('..')) { - if (href.indexOf('#') >= 0) { - internalParentAnchoredLinks.set(absoluteHref, filePath); - } else { - internalParentLinks.set(absoluteHref, filePath); - } - } else if (href.indexOf('#') >= 0) { - const absoluteAnchoredHref = (href.indexOf('#') === 0 ? filePath + href : absoluteHref); - internalAnchoredLinks.set(absoluteAnchoredHref, filePath); - } else { - internalLinks.set(absoluteHref, filePath); - } - }); - - // Extract anchors (id or name) attributes from file - const anchors = $('html').find('[id], [name]'); - anchors.each((index, element) => { - const anchor = $(element).attr('id') || $(element).attr('name'); - const entry = internalAnchors.get(filePath) || new Set(); - entry.add(anchor); - internalAnchors.set(filePath, entry); - }) - }); - - return { - internalLinks: internalLinks, - internalAnchoredLinks: internalAnchoredLinks, - internalParentLinks: internalParentLinks, - internalParentAnchoredLinks: internalParentAnchoredLinks, - externalLinks: externalLinks, - internalAnchors: internalAnchors, - internalPages: internalPages, - }; -} - -/** - * Validates internal links against existing internal pages. - * - * @param {Map} internalLinks - Map of internal links. - * @param {Set} internalPages - Set of internal pages. - * @param {Array} errors - Array to store validation errors. - */ -async function validateInternalLinks(internalLinks, internalPages, errors) { - internalLinks.forEach((sourcePage, link) => { - if (!internalPages.has(link)) { - errors.push({ - type: 'page', - target: link, - source: sourcePage, - reason: 'page not found' - }); - } - }); -} - -/** - * Validates internal anchored links against existing anchors in internal pages. - * - * @param {Map} internalAnchoredLinks - Map of internal anchored links. - * @param {Map>} internalAnchors - Map of internal anchors. - * @param {Array} errors - Array to store validation errors. - */ -async function validateInternalAnchoredLinks(internalAnchoredLinks, internalAnchors, errors) { - internalAnchoredLinks.forEach((sourcePage, link) => { - const index = link.indexOf('#'); - const page = link.substring(0, index) || sourcePage; - const anchor = link.substring(index + 1); - const entry = internalAnchors.get(page) || new Set(); - if (!entry.has(anchor)) { - errors.push({ - type: 'anchor', - target: page + '#' + anchor, - anchor: anchor, - source: sourcePage, - reason: 'anchor not found' - }); - } - }); -} - -/** - * Validates internal parent links against existing pages in the specified directory. - * - * @param {string} directory - The directory path to validate against. - * @param {Map} internalParentLinks - Map of internal parent links. - * @param {Array} errors - Array to store validation errors. - */ -async function validateInternalParentLinks(directory, internalParentLinks, errors) { - const targets = Array.from(internalParentLinks.keys()); - - await Promise.all(targets.map((target) => { - return new Promise((resolve) => { - fs.exists(path.resolve(directory, target), (result) => { - resolve(result); - }); - }); - })).then((results) => { - results.forEach((result, index) => { - const target = targets[index]; - const source = internalParentLinks.get(target); - if (!result) { - errors.push({ - type: 'page', - target: target, - source: source, - reason: 'page not found' - }); - } - }); - }); -} - -/** - * Validates internal parent anchored links against existing pages and anchors. - * - * @param {string} directory - The directory path to validate against. - * @param {Map} internalParentAnchoredLinks - Map of internal parent anchored links. - * @param {Array} errors - Array to store validation errors. - */ -async function validateInternalParentAnchoredLinks(directory, internalParentAnchoredLinks, errors) { - const targets = Array.from(internalParentAnchoredLinks.keys()); - - await Promise.all(targets.map((target) => { - return new Promise((resolve, reject) => { - const filePath = target.split('#')[0]; - const absolutePath = path.resolve(directory, filePath); - fs.readFile(absolutePath, 'utf-8', resolve); - }); - })).then((results) => { - results.forEach((result, index) => { - const target = targets[index]; - const source = internalParentAnchoredLinks.get(target); - if (result instanceof Error) { - return errors.push({ - type: 'page', - target: target, - source: source, - reason: 'page not found' - }); - } - - if (!result) { - errors.push({ - type: 'anchor', - target: target, - source: source, - reason: 'anchor not found' - }); - } - }); - }); - -} - -/** - * Validates external links by making HEAD requests. - * - * @param {Map} externalLinks - Map of external links. - * @param {Array} errors - Array to store validation errors. - */ -async function validateExternalLinks(externalLinks, errors) { - const targets = Array.from(externalLinks.keys()); - - await Promise.all(targets.map((target) => { - return axios.head(target, { - validateStatus: (status) => { - return status !== 404; - } - }); - })).then((responses) => { - responses.forEach((response, index) => { - if (!response || !response.statusCode || !response.statusCode >= 200 || !response.statusCode < 300) { - const error = response; - const target = targets[index]; - const source = externalLinks.get(target); - errors.push({ - type: 'external-link', - target: target, - source: source, - reason: 'could not fetch external page: ' + error.toString() + ' (Code: ' + response.statusCode + ')' - }); - } - }) - }).catch((error) => { - console.log("An unexpected error has occured"); - console.log(error.toString()); - }); -} - -/** - * Performs link checking for a given directory and provides a callback with the results. - * - * @param {string} directory - The directory path to perform link checking. - * @param {Function} callback - Callback function to receive the results. - */ -async function linkChecker(directory, callback) { - const { - internalLinks, - internalAnchoredLinks, - internalParentLinks, - internalParentAnchoredLinks, - externalLinks, - internalAnchors, - internalPages - } = await scanDirectory(directory); - - const errors = []; - - await validateInternalLinks(internalLinks, internalPages, errors) - await validateInternalAnchoredLinks(internalAnchoredLinks, internalAnchors, errors); - await validateInternalParentLinks(directory, internalParentLinks, errors); - await validateInternalParentAnchoredLinks(directory, internalParentAnchoredLinks, errors); - await validateExternalLinks(externalLinks, errors); - - callback({ - errors, - internalLinks: internalLinks.size, - internalAnchoredLinks: internalAnchoredLinks.size, - internalParentLinks: internalParentLinks.size, - internalParentAnchoredLinks: internalParentAnchoredLinks.size, - externalLinks: externalLinks.size - }); - -} - -/** - * @typedef {Object} LinkCheckerResult - * @property {Array} errors - Array of validation errors. - * @property {number} internalLinks - Count of internal links. - * @property {number} internalAnchoredLinks - Count of internal anchored links. - * @property {number} internalParentLinks - Count of internal parent links. - * @property {number} internalParentAnchoredLinks - Count of internal parent anchored links. - * @property {number} externalLinks - Count of external links. - */ - -/** - * Exports the linkChecker function as the module's main export. - * - * @type {Function} - * @param {string} directory - The directory path to perform link checking. - * @param {Function} callback - Callback function to receive the results. - * @returns {LinkCheckerResult} - Results of the link checking. - */ -module.exports = linkChecker;