Skip to content
Merged
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
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,
};
};
80 changes: 80 additions & 0 deletions dist/validateLinks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { exec } from "node:child_process";
import { readFile } from "node:fs/promises";
import { parse } from "./parse.js";
import path, { dirname, normalize } from "node:path/posix";
import { isAbsolute } from "node:path";
const findAllFilesInGit = 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(Boolean));
});
});
};
const findMarkdownFiles = (files) => {
const ignorePattern = /^(README|LICENSE|contributing\/)/;
return files.filter((f) => f.toLocaleLowerCase().endsWith(".md") && !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 gitFiles = await findAllFilesInGit();
// For now, we assume that there are no case clashes
const lowercaseGitFiles = gitFiles.map((s) => s.toLocaleLowerCase());
const markdownFilenames = findMarkdownFiles(gitFiles);
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 = lowercaseGitFiles.includes(resolved.toLocaleLowerCase());
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 isFile = lowercaseGitFiles.includes(resolved.toLocaleLowerCase());
const resolvedWithTrailingSlash = resolved.endsWith("/")
? resolved.toLocaleLowerCase()
: `${resolved.toLocaleLowerCase()}/`;
const isDirectory = lowercaseGitFiles.some((s) => s.startsWith(resolvedWithTrailingSlash));
if (!isFile && !isDirectory) {
console.log(`error BROKEN-INTERNAL-LINK ${parsedFile.filename}:0 Link target does not exist: ${target}`);
++errors;
}
}
}
}
if (errors > 0)
process.exit(1);
};
main().catch((error) => {
console.error(error);
process.exit(1);
});
29 changes: 27 additions & 2 deletions lint
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,31 @@ else
fi
' 0

npm exec -- markdownlint --ignore 'node_modules/' '**/*.md'
npm exec -- prettier --check .
rc=0

echo "validateLinks: ..."
if npm exec -- node dist/validateLinks.js ; then
echo validateLinks: PASS
else
echo validateLinks: FAIL
rc=1
fi

echo "markdownlint: ..."
if npm exec -- markdownlint --ignore 'node_modules/' '**/*.md' ; then
echo "markdownlint: PASS"
else
echo "markdownlint: FAIL"
rc=1
fi

echo "prettier: ..."
if npm exec -- prettier --check . ; then
echo "prettier: PASS"
else
echo "prettier: FAIL"
rc=1
fi

exit $rc
fi
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,
};
};
12 changes: 12 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "nodenext",
"outDir": "./dist/",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
Loading