diff --git a/.config/collections/apiToc.js b/.config/collections/apiToc.js index 552c62ff..4641b00c 100644 --- a/.config/collections/apiToc.js +++ b/.config/collections/apiToc.js @@ -18,13 +18,13 @@ function filterElementsWithoutHref(element) { function removeMDExtension(json) { json.forEach((element) => { if (element.href) { - element.href = element.href.replace(/\.md$/, ''); + element.href = element.href.replace(/\.md$/, '/'); } if (element.items) { element.items.forEach((item) => { if (item.href) { - item.href = item.href.replace(/\.md$/, ''); + item.href = item.href.replace(/\.md$/, '/'); } }); removeMDExtension(element.items); 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/.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}`; }); } 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/_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 %} -

Logos & Icons

+

Logos & Icons

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

diff --git a/package-lock.json b/package-lock.json index f6091ff7..54b1605e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "@11ty/eleventy-navigation": "^0.3.5", "@11ty/eleventy-plugin-rss": "^1.2.0", "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0", + "axios": "^1.6.7", "bootstrap": "^5.3.2", "bootstrap-icons": "^1.11.2", "clean-css": "^5.3.3", @@ -507,6 +508,23 @@ "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", "dev": true }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, + "node_modules/axios": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.15.4", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-walk": { "version": "3.0.0-canary-5", "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", @@ -770,6 +788,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 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 a176267f..cf2e37ba 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", @@ -18,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" } }