HookStack
Back to catalogue
DocumentationStopStopWhen the agent finishes its task· non-blocking

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 { readFileSync, existsSync, readdirSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { fileURLToPath } from '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();
}

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 content.matchAll(LINK_RE)) {
      if (!isRelative(href)) continue;
      const target = stripAnchor(href);
      if (!target) continue; // lien ancre pure (#section)
      const abs = resolve(dirname(file), target);
      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