Skip to content

Commit

Permalink
modernize internal link check
Browse files Browse the repository at this point in the history
  • Loading branch information
fulldecent committed Jan 1, 2025
1 parent ed62636 commit b21979a
Show file tree
Hide file tree
Showing 2 changed files with 73 additions and 39 deletions.
9 changes: 4 additions & 5 deletions test/fixtures-html-validate-should-fail.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,12 @@ const requiredResults = {
{
ruleId: "pacific-medical-training/internal-links",
severity: 2,
message:
"Internal link /free-horses-on-1998-04-01-only.html is broken in file test/fixtures/internal-link-broken.html at line 9, column 6",
offset: 196,
message: 'internal link "/free-horses-on-1998-04-01-only.html" is broken.',
offset: 241,
line: 9,
column: 6,
column: 51,
size: 1,
selector: "html > body > a",
ruleUrl: "https://github.com/fulldecent/github-pages-template/#internal-links",
},
],
"test/fixtures/ensure-https.html": [
Expand Down Expand Up @@ -142,6 +140,7 @@ const requiredResults = {
selector: "html > head > script",
},
],
"test/fixtures/image-missing-alt.html": [],
};

const outcomes = Object.entries(requiredResults).map(async ([filePath, messages]) => {
Expand Down
103 changes: 69 additions & 34 deletions test/plugin.html-validate.internal-links.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,68 +3,103 @@ import { Rule } from "html-validate";
import path from "path";

export default class CheckInternalLinks extends Rule {
static ALTERNATIVE_EXTENSIONS = [".html", ".php"];
static EXTERNAL_LINK_PREFIXES = ["https://", "http://", "mailto:", "tel:"];
fileExistsCache = new Map();
webRoot;
alternativeExtensions;
indexFile;

documentation() {
static schema() {
return {
description: "Require all internal links (src and href attributes) to be live.",
url: "https://github.com/fulldecent/github-pages-template/#internal-links",
webRoot: {
type: "string",
description: "The root directory for resolving absolute links.",
},
alternativeExtensions: {
type: "array",
items: {
type: "string",
},
description: "List of alternative file extensions to check for internal links.",
},
indexFile: {
type: "string",
description: "The default file to look for when resolving directory paths (e.g., 'index.html').",
},
};
}

constructor(options) {
super(options);
this.webRoot = options?.webRoot || process.cwd() + "/build";
this.alternativeExtensions = options?.alternativeExtensions || [".html", ".php"];
this.indexFile = options?.indexFile || "index.html";
}

setup() {
this.on("dom:ready", this.domReady.bind(this));
this.on("tag:ready", this.tagReady.bind(this));
}

checkTheLink(internalLink, element) {
let decodedLink = internalLink.includes("%") ? decodeURIComponent(internalLink) : internalLink;
doesFileExist(path) {
if (this.fileExistsCache.has(path)) {
return this.fileExistsCache.get(path);
}

// Remove query string and fragment
decodedLink = decodedLink.split(/[?#]/)[0];
const exists = fs.existsSync(path);
this.fileExistsCache.set(path, exists);
return exists;
}

// If absolute path, prefix with the build directory
if (decodedLink.startsWith("/")) {
decodedLink = path.join(process.cwd(), "build", decodedLink);
checkTheLink(internalLink, element) {
let resolvedLink = internalLink;
// If absolute path, prefix with the web root
if (resolvedLink.startsWith("/")) {
resolvedLink = path.join(this.webRoot, resolvedLink);
}

// Resolve the path
const basePath = path.dirname(element.location.filename);
let resolvedPath = path.resolve(basePath, decodedLink);
let resolvedPath = path.resolve(basePath, resolvedLink);

// If it's a directory, append the index file
if (fs.existsSync(resolvedPath) && fs.lstatSync(resolvedPath).isDirectory()) {
resolvedPath = path.join(resolvedPath, this.indexFile);
}

// Pass if the URL matches a file or an alternative extension
if (
fs.existsSync(resolvedPath) ||
CheckInternalLinks.ALTERNATIVE_EXTENSIONS.some((ext) => fs.existsSync(`${resolvedPath}${ext}`))
this.doesFileExist(resolvedPath) ||
this.alternativeExtensions.some((ext) => this.doesFileExist(`${resolvedPath}${ext}`))
) {
return;
}

// Check if it is a directory and append index.html
const isDirectory = fs.existsSync(resolvedPath) && fs.lstatSync(resolvedPath).isDirectory();
if (isDirectory) {
resolvedPath = path.join(resolvedPath, "index.html");
}

// Report an error with the relative path
// Report an error with the resolved path
this.report({
node: element,
message: `Internal link ${internalLink} is broken in file ${element.location.filename} at line ${element.location.line}, column ${element.location.column}`,
message: `internal link "${internalLink}" is broken.`,
});
}

domReady({ document }) {
const elementsWithLink = document.querySelectorAll("[src], [href]");
tagReady({ target }) {
// Check if the element has a `src` or `href` attribute
let url = target.getAttribute("src")?.value || target.getAttribute("href")?.value;

elementsWithLink.forEach((element) => {
let url = element.getAttribute("src")?.value || element.getAttribute("href")?.value;
if (!url) {
return;
}

// Ignore empty or external links
if (!url || CheckInternalLinks.EXTERNAL_LINK_PREFIXES.some((prefix) => url.startsWith(prefix))) {
return;
}
url = decodeURIComponent(url);
url = url.split("#")[0].split("?")[0];

this.checkTheLink(url.split("#")[0], element);
});
if (!url) {
return;
}

// URL.parse returns null for internal links (because not absolutely resolvable)
if (URL.parse(url) !== null) {
return;
}

this.checkTheLink(url, target);
}
}

0 comments on commit b21979a

Please sign in to comment.