Skip to content
Closed
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
25 changes: 25 additions & 0 deletions .github/workflows/validate-links.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
name: Validate links

on:
push:
branches:
- main
pull_request:
types:
- opened
- synchronize
- reopened

jobs:
build:
name: Validate links
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4
- name: npm install
run: npm install
- name: Run the checker
run: node dist/validateLinks.js
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/dist/
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodejs 23.11.0
35 changes: 35 additions & 0 deletions dist/parse.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import mit from "markdown-it";
export const parse = (content) => {
const parser = mit();
const tokens = parser.parse(content, {});
const parsedLinks = [];
const parsedImages = [];
const scan = (tokens) => {
tokens.forEach((token, index) => {
if (token.type === "link_open") {
const indexOfNextClose = tokens.findIndex((t2, i2) => i2 > index && t2.type === "link_close");
if (indexOfNextClose > index) {
parsedLinks.push({
target: token.attrGet("href"),
content: tokens
.slice(index + 1, indexOfNextClose)
.map((t) => t.content)
.join(""),
});
}
}
if (token.type === "image")
parsedImages.push({
src: token.attrGet("src"),
alt: token.content,
});
if (token.children)
scan(token.children);
});
};
scan(tokens);
return {
links: parsedLinks,
images: parsedImages,
};
};
87 changes: 87 additions & 0 deletions dist/validateLinks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { exec } from "node:child_process";
import { readFile, stat } from "node:fs/promises";
import { parse } from "./parse.js";
import path, { dirname, normalize } from "node:path/posix";
import { isAbsolute } from "node:path";
const findMarkdownFilesInGit = async () => {
return await new Promise((resolve, reject) => {
exec("git ls-files -z", (error, stdout, stderr) => {
if (error)
reject(error);
if (stderr)
reject(new Error(`git ls-files outputted on stderr: ${stderr}`));
else
resolve(stdout.split("\0").filter((s) => s.endsWith(".md")));
});
});
};
const findMarkdownFiles = async () => {
const ignorePattern = /^(README|LICENSE|contributing\/)/;
return (await findMarkdownFilesInGit()).filter((f) => !ignorePattern.test(f));
};
const scanForLinks = async (filenames) => {
return Promise.all(filenames.map(async (filename) => {
const content = await readFile(filename, "utf-8");
return { filename, ...parse(content) };
}));
};
const externalLinkPattern = /^\w+:/;
const isExternalLink = (t) => externalLinkPattern.test(t);
const main = async () => {
const markdownFilenames = await findMarkdownFiles();
const parsedFiles = await scanForLinks(markdownFilenames);
let errors = 0;
for (const parsedFile of parsedFiles) {
for (const img of parsedFile.images) {
if (!isExternalLink(img.src)) {
const resolved = path.join(dirname(parsedFile.filename), img.src);
const exists = await stat(resolved).then(() => true, () => false);
if (!exists) {
console.log(`error BROKEN-INTERNAL-IMAGE ${parsedFile.filename}:0 Broken internal image reference ${img.src}`);
++errors;
}
}
}
for (const link of parsedFile.links) {
if (link.target.startsWith("#")) {
// Already checked by the linter
continue;
}
if (!isExternalLink(link.target)) {
const target = link.target.split("#")[0];
let resolved;
if (isAbsolute(target)) {
resolved = normalize(`./${target}`);
}
else {
resolved = normalize(path.join(dirname(parsedFile.filename), target));
}
const stats = await stat(resolved).catch(() => undefined);
if (stats?.isDirectory()) {
const readmeExists = await stat(`${resolved}/README.md`).catch(() => undefined);
if (readmeExists) {
// console.log(
// `info LINK-TO-DIR-WITH-README ${parsedFile.filename}:0 Link to a directory, which has a README: ${target}`
// );
}
else {
console.log(`error LINK-TO-RAW-DIR ${parsedFile.filename}:0 Link to a directory, which has no README: ${target}`);
++errors;
}
}
else if (stats === undefined) {
console.log(`error BROKEN-INTERNAL-LINK ${parsedFile.filename}:0 Link target does not exist: ${target}`);
++errors;
}
}
}
}
if (errors > 0) {
console.error("Link validation found errors");
process.exit(1);
}
};
main().catch((error) => {
console.error(error);
process.exit(1);
});
61 changes: 61 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,11 @@
"dependencies": {
"markdownlint-cli": "^0.44.0",
"prettier": "^3.5.3"
},
"type": "module",
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.14.1",
"typescript": "^5.8.3"
}
}
60 changes: 60 additions & 0 deletions parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import mit from "markdown-it";
import type { Token } from "markdown-it/index.js";

export type ParsedLink = {
readonly target: string;
readonly content: string;
};

export type ParsedImage = {
readonly src: string;
readonly alt: string;
};

export type ParseResult = {
readonly links: readonly ParsedLink[];
readonly images: readonly ParsedImage[];
};

export const parse = (content: string): ParseResult => {
const parser = mit();
const tokens = parser.parse(content, {});

const parsedLinks: ParsedLink[] = [];
const parsedImages: ParsedImage[] = [];

const scan = (tokens: Token[]) => {
tokens.forEach((token, index) => {
if (token.type === "link_open") {
const indexOfNextClose = tokens.findIndex(
(t2, i2) => i2 > index && t2.type === "link_close",
);

if (indexOfNextClose > index) {
parsedLinks.push({
target: token.attrGet("href") as string,
content: tokens
.slice(index + 1, indexOfNextClose)
.map((t) => t.content)
.join(""),
});
}
}

if (token.type === "image")
parsedImages.push({
src: token.attrGet("src") as string,
alt: token.content,
});

if (token.children) scan(token.children);
});
};

scan(tokens);

return {
links: parsedLinks,
images: parsedImages,
};
};
Loading
Loading