HookStack
Back to catalogue
DocumentationStop

Dead link checker

No broken links in Markdown docs you ship — checked automatically at session end

Full-repo scan of all .md and .mdx files for broken relative links (file existence check). Covers pre-existing doc debt, not just files touched in the current session. Pure Node.js — no network requests, no external dependencies. Skips node_modules, .git, .next and .claude directories. Non-blocking warning that lists every broken reference so it can be fixed before pushing.

What does the Dead link checker hook do?

Dead link checker is a Claude Code Stop hook. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. No broken links in Markdown docs you ship — checked automatically at session end.

As a Stop hook it runs after the action, reacting to what just happened rather than blocking it. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.

Use cases

  • Catch dead links in README and documentation generated or updated by the agent before pushing
  • Ensure internal anchor links (#section) still exist after refactoring headings
  • Prevent broken external URLs in changelogs, guides, or API docs from reaching the main branch

Tags

#documentation#markdown#links#quality

settings.json fragment

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/stop-dead-link-checker.mjs",
            "type": "command"
          }
        ]
      }
    ]
  }
}

Script · .claude/hooks/stop-dead-link-checker.mjs

#!/usr/bin/env node
// @hookstack stop-dead-link-checker
// Vérifie les liens relatifs cassés dans tous les fichiers Markdown du repo (Stop).
// Scan complet — couvre la dette existante, pas seulement les fichiers de la session.
// Purement Node.js (fs + path), sans réseau ni dépendance externe.
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const SKIP_DIRS = new Set([
	"node_modules",
	".git",
	"dist",
	"build",
	".next",
	"out",
	".claude",
]);
// Capture [text](href) — exclut les images ![alt](src) incluses dans la même syntaxe
const LINK_RE = /(?<!!)\[([^\]]*)\]\(([^)]+)\)/g;

function isRelative(href) {
	return (
		!href.startsWith("http") &&
		!href.startsWith("#") &&
		!href.startsWith("mailto:")
	);
}

function stripAnchor(href) {
	return href.split("#")[0].trim();
}

// Ignore les liens d'exemple à l'intérieur des blocs de code (```...```), ce ne sont
// pas de vrais liens à résoudre (ex. templates documentant la syntaxe).
function stripFences(text) {
	let inFence = false;
	return text
		.split("\n")
		.filter((line) => {
			if (/^(```|~~~)/.test(line.trim())) {
				inFence = !inFence;
				return false;
			}
			return !inFence;
		})
		.join("\n");
}

// Liens commençant par "/" : convention OKF v0.1, relatifs à la racine du bundle okf/
// (même résolution que resolveTarget() dans scripts/okf.mjs), pas au système de fichiers.
function resolveAbs(file, target, projectDir) {
	if (target.startsWith("/")) return join(projectDir, "okf", target.slice(1));
	return resolve(dirname(file), target);
}

function walkMd(dir, { readdir = readdirSync, exists = existsSync } = {}) {
	if (!exists(dir)) return [];
	const results = [];
	for (const entry of readdir(dir, { withFileTypes: true })) {
		if (SKIP_DIRS.has(entry.name)) continue;
		const full = join(dir, entry.name);
		if (entry.isDirectory()) results.push(...walkMd(full, { readdir, exists }));
		else if (/\.mdx?$/.test(entry.name)) results.push(full);
	}
	return results;
}

export function run(
	_input,
	{
		projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd(),
		readFile = readFileSync,
		exists = existsSync,
		readdir = readdirSync,
	} = {},
) {
	const mdFiles = walkMd(projectDir, { readdir, exists });
	if (!mdFiles.length) return null;

	const broken = [];
	for (const file of mdFiles) {
		let content;
		try {
			content = readFile(file, "utf8");
		} catch {
			continue;
		}

		for (const [, , href] of stripFences(content).matchAll(LINK_RE)) {
			if (!isRelative(href)) continue;
			const target = stripAnchor(href);
			if (!target) continue; // lien ancre pure (#section)
			const abs = resolveAbs(file, target, projectDir);
			if (!exists(abs)) {
				broken.push(`${file.replace(`${projectDir}/`, "")}  →  ${href}`);
			}
		}
	}

	if (!broken.length) return null;

	return {
		message:
			`[dead-link-checker] ${broken.length} broken relative link(s) across docs:\n` +
			broken.map((b) => `  - ${b}`).join("\n") +
			"\n",
	};
}

/* v8 ignore next 5 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
	const input = JSON.parse(readFileSync(0, "utf8"));
	const result = run(input);
	if (result) process.stderr.write(JSON.stringify(result));
}

Learn more

Related hooks