Skip to main content
hooksSource-backedReview first Safety · Privacy ·

Markdown Link Checker - Hooks

Validates all links in markdown files to detect broken links and references.

by JSONbored·added 2025-09-19·
Claude Code
HarnessClaude Code
Trigger:PostToolUse
Review first review before installing

Open the source and read safety notes before installing.

Schema details

Install type
cli
Reading time
2 min
Difficulty score
0
Troubleshooting
Yes
Breaking changes
No
Runtime and command metadata
Trigger
PostToolUse
Script language
bash
Script body
#!/usr/bin/env bash

# Read the tool input from stdin
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""')

if [ -z "$FILE_PATH" ]; then
  exit 0
fi

# Check if this is a markdown file
if [[ "$FILE_PATH" == *.md ]] || [[ "$FILE_PATH" == *.mdx ]] || [[ "$FILE_PATH" == *.markdown ]]; then
  echo "🔗 Markdown Link Validation for: $(basename "$FILE_PATH")" >&2
  
  # Initialize validation counters
  ERRORS=0
  WARNINGS=0
  VALIDATIONS_PASSED=0
  TOTAL_LINKS=0
  EXTERNAL_LINKS=0
  INTERNAL_LINKS=0
  
  # Function to report validation results
  report_validation() {
    local level="$1"
    local message="$2"
    
    case "$level" in
      "ERROR")
        echo "❌ ERROR: $message" >&2
        ERRORS=$((ERRORS + 1))
        ;;
      "WARNING")
        echo "⚠️ WARNING: $message" >&2
        WARNINGS=$((WARNINGS + 1))
        ;;
      "PASS")
        echo "✅ PASS: $message" >&2
        VALIDATIONS_PASSED=$((VALIDATIONS_PASSED + 1))
        ;;
      "INFO")
        echo "ℹ️ INFO: $message" >&2
        ;;
    esac
  }
  
  # Check if file exists and is readable
  if [ ! -f "$FILE_PATH" ]; then
    report_validation "ERROR" "Markdown file not found: $FILE_PATH"
    exit 1
  fi
  
  if [ ! -r "$FILE_PATH" ]; then
    report_validation "ERROR" "Markdown file is not readable: $FILE_PATH"
    exit 1
  fi
  
  # Get file information
  FILE_NAME="$(basename "$FILE_PATH")"
  FILE_DIR="$(dirname "$FILE_PATH")"
  FILE_SIZE=$(wc -c < "$FILE_PATH" 2>/dev/null || echo "0")
  LINE_COUNT=$(wc -l < "$FILE_PATH" 2>/dev/null || echo "0")
  
  echo "📊 Markdown file: $FILE_NAME ($(( FILE_SIZE / 1024 ))KB, $LINE_COUNT lines)" >&2
  
  # 1. Extract All Links from Markdown
  echo "🔍 Extracting links from markdown..." >&2
  
  # Create temporary files for link analysis
  TEMP_LINKS="/tmp/markdown_links_$$"
  TEMP_IMAGES="/tmp/markdown_images_$$"
  TEMP_ANCHORS="/tmp/markdown_anchors_$$"
  
  # Extract markdown links [text](url)
  grep -oE '\[([^\]]+)\]\(([^)]+)\)' "$FILE_PATH" | sed 's/\[.*\](\(.*\))/\1/' > "$TEMP_LINKS" 2>/dev/null || true
  
  # Extract image links ![alt](url)
  grep -oE '!\[([^\]]*)\]\(([^)]+)\)' "$FILE_PATH" | sed 's/!\[.*\](\(.*\))/\1/' > "$TEMP_IMAGES" 2>/dev/null || true
  
  # Extract reference-style links
  grep -oE '\[([^\]]+)\]\[([^\]]+)\]' "$FILE_PATH" | sed 's/\[.*\]\[\(.*\)\]/\1/' >> "$TEMP_LINKS" 2>/dev/null || true
  
  # Count total links
  TOTAL_LINKS=$(cat "$TEMP_LINKS" "$TEMP_IMAGES" 2>/dev/null | wc -l || echo "0")
  
  if [ "$TOTAL_LINKS" -eq 0 ]; then
    echo "   📋 No links found in markdown file" >&2
    report_validation "INFO" "No links to validate"
  else
    echo "   📊 Found $TOTAL_LINKS total links/images" >&2
  fi
  
  # 2. Validate External Links
  echo "🌐 Validating external links..." >&2
  
  # Try using markdown-link-check if available
  if command -v npx &> /dev/null; then
    echo "   🔍 Using markdown-link-check for comprehensive validation..." >&2
    
    # Create a temporary config if none exists
    CONFIG_FILE=".markdown-link-check.json"
    TEMP_CONFIG=false
    
    if [ ! -f "$CONFIG_FILE" ]; then
      TEMP_CONFIG=true
      CONFIG_FILE="/tmp/markdown_link_config_$$"
      cat > "$CONFIG_FILE" << 'EOF'
{
  "timeout": "30s",
  "retryOn429": true,
  "retryCount": 3,
  "fallbackProtocols": ["http", "https"],
  "ignorePatterns": [
    { "pattern": "^http://localhost" },
    { "pattern": "^https://localhost" },
    { "pattern": "^http://127.0.0.1" },
    { "pattern": "^#" }
  ]
}
EOF
    fi
    
    MLC_OUTPUT_FILE="/tmp/mlc_output_$$"
    if timeout 60s npx markdown-link-check "$FILE_PATH" --config "$CONFIG_FILE" > "$MLC_OUTPUT_FILE" 2>&1; then
      # Parse results
      DEAD_LINKS=$(grep -c '✖' "$MLC_OUTPUT_FILE" 2>/dev/null || echo "0")
      ALIVE_LINKS=$(grep -c '✓' "$MLC_OUTPUT_FILE" 2>/dev/null || echo "0")
      
      if [ "$DEAD_LINKS" -eq 0 ]; then
        report_validation "PASS" "All external links are valid ($ALIVE_LINKS checked)"
      else
        report_validation "ERROR" "Found $DEAD_LINKS dead external links"
        echo "   📝 Dead links details:" >&2
        grep '✖' "$MLC_OUTPUT_FILE" | head -5 | while read line; do
          echo "     $line" >&2
        done
      fi
    else
      # Fallback to basic URL validation
      echo "   ⚠️ markdown-link-check failed, using basic validation..." >&2
      
      # Extract HTTP/HTTPS URLs
      EXTERNAL_URLS=$(grep -oE 'https?://[^)]+' "$TEMP_LINKS" 2>/dev/null || true)
      
      if [ -n "$EXTERNAL_URLS" ]; then
        EXTERNAL_COUNT=$(echo "$EXTERNAL_URLS" | wc -l)
        echo "   🌐 Found $EXTERNAL_COUNT external URLs to validate" >&2
        
        # Basic URL validation using curl
        if command -v curl &> /dev/null; then
          EXTERNAL_ERRORS=0
          echo "$EXTERNAL_URLS" | head -10 | while read -r url; do
            if [ -n "$url" ]; then
              if curl -s --head --max-time 10 "$url" >/dev/null 2>&1; then
                echo "     ✅ Valid: $url" >&2
              else
                echo "     ❌ Invalid: $url" >&2
                EXTERNAL_ERRORS=$((EXTERNAL_ERRORS + 1))
              fi
            fi
          done
        else
          echo "   ⚠️ curl not available for URL validation" >&2
        fi
      else
        echo "   📋 No external URLs found" >&2
      fi
    fi
    
    # Clean up temporary config if created
    [ "$TEMP_CONFIG" = true ] && rm -f "$CONFIG_FILE"
    rm -f "$MLC_OUTPUT_FILE"
    
  else
    echo "   ⚠️ npx not available, using basic link validation" >&2
  fi
  
  # 3. Validate Internal Links
  echo "📁 Validating internal links and references..." >&2
  
  # Extract internal links (relative paths)
  INTERNAL_URLS=$(cat "$TEMP_LINKS" "$TEMP_IMAGES" 2>/dev/null | grep -E '^\./|^\.\./' || true)
  ABSOLUTE_PATHS=$(cat "$TEMP_LINKS" "$TEMP_IMAGES" 2>/dev/null | grep -E '^/' || true)
  
  INTERNAL_ERRORS=0
  
  # Check relative path links
  if [ -n "$INTERNAL_URLS" ]; then
    echo "   📂 Checking relative path links..." >&2
    
    echo "$INTERNAL_URLS" | while read -r link; do
      if [ -n "$link" ]; then
        # Remove anchor if present
        FILE_PART=$(echo "$link" | cut -d'#' -f1)
        ANCHOR_PART=$(echo "$link" | cut -d'#' -f2)
        
        if [ -n "$FILE_PART" ]; then
          # Resolve relative path
          RESOLVED_PATH="$(cd "$FILE_DIR" && realpath "$FILE_PART" 2>/dev/null || echo "$FILE_PART")"
          
          if [ -f "$RESOLVED_PATH" ] || [ -d "$RESOLVED_PATH" ]; then
            echo "     ✅ Valid: $link" >&2
          else
            echo "     ❌ Broken: $link (resolved to: $RESOLVED_PATH)" >&2
            INTERNAL_ERRORS=$((INTERNAL_ERRORS + 1))
          fi
        fi
        
        # Check anchor if present (simplified check)
        if [ "$link" != "$FILE_PART" ] && [ -n "$ANCHOR_PART" ]; then
          echo "     ℹ️ Anchor found: #$ANCHOR_PART" >&2
        fi
      fi
    done
  fi
  
  # Check absolute path links
  if [ -n "$ABSOLUTE_PATHS" ]; then
    echo "   📁 Checking absolute path links..." >&2
    
    echo "$ABSOLUTE_PATHS" | while read -r link; do
      if [ -n "$link" ]; then
        FILE_PART=$(echo "$link" | cut -d'#' -f1)
        
        if [ -f "$FILE_PART" ] || [ -d "$FILE_PART" ]; then
          echo "     ✅ Valid: $link" >&2
        else
          echo "     ❌ Broken: $link" >&2
          INTERNAL_ERRORS=$((INTERNAL_ERRORS + 1))
        fi
      fi
    done
  fi
  
  if [ "$INTERNAL_ERRORS" -eq 0 ]; then
    report_validation "PASS" "All internal links are valid"
  else
    report_validation "ERROR" "Found $INTERNAL_ERRORS broken internal links"
  fi
  
  # 4. Validate Image Links
  echo "🖼️ Validating image links..." >&2
  
  IMAGE_COUNT=$(cat "$TEMP_IMAGES" 2>/dev/null | wc -l || echo "0")
  
  if [ "$IMAGE_COUNT" -gt 0 ]; then
    echo "   📊 Found $IMAGE_COUNT image links" >&2
    
    IMAGE_ERRORS=0
    
    cat "$TEMP_IMAGES" | while read -r img_link; do
      if [ -n "$img_link" ]; then
        if [[ "$img_link" == http* ]]; then
          echo "     🌐 External image: $img_link" >&2
        else
          # Local image file
          if [[ "$img_link" == /* ]]; then
            IMG_PATH="$img_link"
          else
            IMG_PATH="$FILE_DIR/$img_link"
          fi
          
          if [ -f "$IMG_PATH" ]; then
            echo "     ✅ Valid image: $img_link" >&2
          else
            echo "     ❌ Missing image: $img_link" >&2
            IMAGE_ERRORS=$((IMAGE_ERRORS + 1))
          fi
        fi
      fi
    done
    
    if [ "$IMAGE_ERRORS" -eq 0 ]; then
      report_validation "PASS" "All local images found"
    else
      report_validation "ERROR" "$IMAGE_ERRORS local images missing"
    fi
  else
    echo "   📋 No image links found" >&2
  fi
  
  # 5. Validate Internal Anchors
  echo "⚓ Validating document anchors..." >&2
  
  # Extract anchor-only links (starting with #)
  ANCHOR_LINKS=$(cat "$TEMP_LINKS" | grep '^#' 2>/dev/null || true)
  
  if [ -n "$ANCHOR_LINKS" ]; then
    echo "   🔗 Found anchor links to validate" >&2
    
    # Extract headers from markdown to validate anchors
    HEADERS_FILE="/tmp/markdown_headers_$$"
    grep -E '^#{1,6} ' "$FILE_PATH" | sed 's/^#* *//' | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 -]//g' | sed 's/ /-/g' > "$HEADERS_FILE" 2>/dev/null || true
    
    ANCHOR_ERRORS=0
    
    echo "$ANCHOR_LINKS" | while read -r anchor; do
      if [ -n "$anchor" ]; then
        CLEAN_ANCHOR=$(echo "$anchor" | sed 's/^#//' | tr '[:upper:]' '[:lower:]')
        
        if grep -q "^$CLEAN_ANCHOR$" "$HEADERS_FILE" 2>/dev/null; then
          echo "     ✅ Valid anchor: $anchor" >&2
        else
          echo "     ❌ Invalid anchor: $anchor" >&2
          ANCHOR_ERRORS=$((ANCHOR_ERRORS + 1))
        fi
      fi
    done
    
    rm -f "$HEADERS_FILE"
    
    if [ "$ANCHOR_ERRORS" -eq 0 ]; then
      report_validation "PASS" "All document anchors valid"
    else
      report_validation "ERROR" "$ANCHOR_ERRORS invalid document anchors"
    fi
  else
    echo "   📋 No document anchors found" >&2
  fi
  
  # 6. Markdown Quality Checks
  echo "📝 Markdown quality and accessibility checks..." >&2
  
  # Check for alt text in images
  IMAGES_WITHOUT_ALT=$(grep -c '!\[\](' "$FILE_PATH" 2>/dev/null || echo "0")
  
  if [ "$IMAGES_WITHOUT_ALT" -gt 0 ]; then
    report_validation "WARNING" "$IMAGES_WITHOUT_ALT images missing alt text for accessibility"
  else
    if [ "$IMAGE_COUNT" -gt 0 ]; then
      report_validation "PASS" "All images have alt text"
    fi
  fi
  
  # Check for bare URLs (not wrapped in markdown links)
  BARE_URLS=$(grep -oE 'https?://[^\s\)\]]+' "$FILE_PATH" | grep -v '](http' | head -5 | wc -l || echo "0")
  
  if [ "$BARE_URLS" -gt 0 ]; then
    report_validation "WARNING" "Found $BARE_URLS bare URLs - consider wrapping in markdown links"
  fi
  
  # 7. Generate Validation Summary
  echo "" >&2
  echo "📋 Markdown Link Validation Summary:" >&2
  echo "===================================" >&2
  echo "   📄 File: $FILE_NAME" >&2
  echo "   📏 Size: $(( FILE_SIZE / 1024 ))KB, $LINE_COUNT lines" >&2
  echo "   🔗 Total links: $TOTAL_LINKS" >&2
  echo "   🖼️ Images: $IMAGE_COUNT" >&2
  echo "   ✅ Validations passed: $VALIDATIONS_PASSED" >&2
  echo "   ⚠️ Warnings: $WARNINGS" >&2
  echo "   ❌ Errors: $ERRORS" >&2
  
  if [ "$ERRORS" -eq 0 ]; then
    if [ "$WARNINGS" -eq 0 ]; then
      echo "   🎉 Status: EXCELLENT - All links are valid and accessible" >&2
    else
      echo "   ✅ Status: GOOD - Links are valid with minor accessibility recommendations" >&2
    fi
  else
    echo "   ❌ Status: ERRORS - Found broken links that must be fixed" >&2
  fi
  
  echo "" >&2
  echo "💡 Markdown Link Best Practices:" >&2
  echo "   • Use descriptive link text instead of 'click here'" >&2
  echo "   • Add alt text to all images for accessibility" >&2
  echo "   • Use relative paths for internal documentation" >&2
  echo "   • Validate external links regularly" >&2
  echo "   • Keep anchor links synchronized with headers" >&2
  echo "   • Consider using reference-style links for readability" >&2
  
  # Clean up temporary files
  rm -f "$TEMP_LINKS" "$TEMP_IMAGES" "$TEMP_ANCHORS"
  
  # Exit with error if there are critical link issues
  if [ "$ERRORS" -gt 0 ]; then
    echo "⚠️ Markdown link validation completed with errors" >&2
    exit 1
  fi
  
else
  # Not a markdown file
  exit 0
fi

exit 0
Full copyable content
{
  "hooks": {
    "postToolUse": {
      "script": "./.claude/hooks/markdown-link-checker.sh",
      "matchers": [
        "write",
        "edit"
      ]
    }
  }
}

About this resource

Features

  • Comprehensive markdown link validation using markdown-link-check with npx markdown-link-check for external URL validation (HTTP status code checking, timeout configuration, retry on 429 rate limit errors), curl fallback for basic URL validation when markdown-link-check unavailable, and configurable validation settings (timeout, retry count, ignore patterns)
  • Internal reference validation for local file paths and anchors with relative path resolution using realpath for accurate path resolution, absolute path validation with file/directory existence checking, anchor removal for file path validation (cut -d'#' -f1), and anchor validation with heading ID matching
  • External URL validation with configurable retry and timeout including HTTP status code checking (200 OK, 404 Not Found, etc.), retry on 429 rate limit errors with configurable retry count (default 3 retries), fallback protocol support (http, https) for protocol flexibility, and timeout configuration (default 30s, configurable)
  • Image link validation and accessibility checking with alt text validation for accessibility compliance (checking for empty alt text in alt patterns), local image file existence checking with path resolution, external image URL validation with HTTP status checking, and image accessibility reporting with missing alt text warnings
  • Anchor link validation within the same document with heading ID extraction from markdown headers (grep -E '^#{1,6} '), heading ID normalization (lowercase, dash-separated, special character removal), anchor matching with extracted heading IDs, and invalid anchor reporting with suggestions
  • Relative path resolution and validation with realpath for accurate relative path resolution, path resolution relative to markdown file directory (not repo root), directory/file existence checking, and broken path reporting with resolved path information
  • Custom configuration support for link checking rules including timeout configuration (30s default, configurable), retry settings (retryCount, retryOn429), ignore patterns for localhost/development URLs (^http://localhost, ^https://localhost, ^http://127.0.0.1), and fallback protocol configuration (http, https)
  • Detailed reporting with line numbers and link types including link type classification (external URLs, internal paths, images, anchors), error location reporting with file paths and line numbers, comprehensive validation summaries with pass/fail counts, and actionable recommendations with best practices

Use Cases

  • Documentation maintenance and quality assurance automation automatically validating documentation links, detecting broken links before publication, and ensuring documentation quality for reliable documentation workflows
  • Technical writing workflow integration with link validation automatically validating links during writing, detecting broken references, and ensuring link integrity for efficient technical writing workflows
  • Content management system link integrity checking automatically validating CMS content links, detecting broken references, and ensuring content quality for reliable CMS operations
  • Static site generation with automated link verification automatically validating links in static site generators (Jekyll, Hugo, Gatsby), detecting broken links before deployment, and ensuring site quality for reliable static site deployments
  • Multi-language documentation consistency validation automatically validating links across language versions, detecting broken cross-language references, and ensuring documentation consistency for reliable multi-language documentation
  • Development workflow integration seamlessly integrating markdown link validation into development workflows without manual validation steps or separate validation tools

Installation

  1. Create hooks directory: mkdir -p .claude/hooks
  2. Create hook file: touch .claude/hooks/markdown-link-checker.sh
  3. Make executable: chmod +x .claude/hooks/markdown-link-checker.sh
  4. Add configuration from Hook Configuration section above to .claude/settings.json or ~/.claude/settings.json
  5. Alternative: Use the interactive /hooks command in Claude Code

Config paths

  • Local (not committed): .claude/settings.local.json
  • User settings (global): ~/.claude/settings.json
  • Project-wide (committed): .claude/settings.json

Requirements

  • Claude Code CLI installed
  • Project directory initialized
  • Bash shell available
  • npx (optional, recommended for markdown-link-check)
  • curl (optional, for basic URL validation fallback)
  • realpath (optional, for accurate path resolution)

Hook Configuration

{
  "hooks": {
    "postToolUse": {
      "script": "./.claude/hooks/markdown-link-checker.sh",
      "matchers": ["write", "edit"]
    }
  }
}

Hook Script

#!/usr/bin/env bash

# Read the tool input from stdin
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""')

if [ -z "$FILE_PATH" ]; then
  exit 0
fi

# Check if this is a markdown file
if [[ "$FILE_PATH" == *.md ]] || [[ "$FILE_PATH" == *.mdx ]] || [[ "$FILE_PATH" == *.markdown ]]; then
  echo "🔗 Markdown Link Validation for: $(basename "$FILE_PATH")" >&2

  # Initialize validation counters
  ERRORS=0
  WARNINGS=0
  VALIDATIONS_PASSED=0
  TOTAL_LINKS=0
  EXTERNAL_LINKS=0
  INTERNAL_LINKS=0

  # Function to report validation results
  report_validation() {
    local level="$1"
    local message="$2"

    case "$level" in
      "ERROR")
        echo "❌ ERROR: $message" >&2
        ERRORS=$((ERRORS + 1))
        ;;
      "WARNING")
        echo "⚠️ WARNING: $message" >&2
        WARNINGS=$((WARNINGS + 1))
        ;;
      "PASS")
        echo "✅ PASS: $message" >&2
        VALIDATIONS_PASSED=$((VALIDATIONS_PASSED + 1))
        ;;
      "INFO")
        echo "ℹ️ INFO: $message" >&2
        ;;
    esac
  }

  # Check if file exists and is readable
  if [ ! -f "$FILE_PATH" ]; then
    report_validation "ERROR" "Markdown file not found: $FILE_PATH"
    exit 1
  fi

  if [ ! -r "$FILE_PATH" ]; then
    report_validation "ERROR" "Markdown file is not readable: $FILE_PATH"
    exit 1
  fi

  # Get file information
  FILE_NAME="$(basename "$FILE_PATH")"
  FILE_DIR="$(dirname "$FILE_PATH")"
  FILE_SIZE=$(wc -c < "$FILE_PATH" 2>/dev/null || echo "0")
  LINE_COUNT=$(wc -l < "$FILE_PATH" 2>/dev/null || echo "0")

  echo "📊 Markdown file: $FILE_NAME ($(( FILE_SIZE / 1024 ))KB, $LINE_COUNT lines)" >&2

  # 1. Extract All Links from Markdown
  echo "🔍 Extracting links from markdown..." >&2

  # Create temporary files for link analysis
  TEMP_LINKS="/tmp/markdown_links_$$"
  TEMP_IMAGES="/tmp/markdown_images_$$"
  TEMP_ANCHORS="/tmp/markdown_anchors_$$"

  # Extract markdown links [text](url)
  grep -oE '\[([^\]]+)\]\(([^)]+)\)' "$FILE_PATH" | sed 's/\[.*\](\(.*\))/\1/' > "$TEMP_LINKS" 2>/dev/null || true

  # Extract image links ![alt](url)
  grep -oE '!\[([^\]]*)\]\(([^)]+)\)' "$FILE_PATH" | sed 's/!\[.*\](\(.*\))/\1/' > "$TEMP_IMAGES" 2>/dev/null || true

  # Extract reference-style links
  grep -oE '\[([^\]]+)\]\[([^\]]+)\]' "$FILE_PATH" | sed 's/\[.*\]\[\(.*\)\]/\1/' >> "$TEMP_LINKS" 2>/dev/null || true

  # Count total links
  TOTAL_LINKS=$(cat "$TEMP_LINKS" "$TEMP_IMAGES" 2>/dev/null | wc -l || echo "0")

  if [ "$TOTAL_LINKS" -eq 0 ]; then
    echo "   📋 No links found in markdown file" >&2
    report_validation "INFO" "No links to validate"
  else
    echo "   📊 Found $TOTAL_LINKS total links/images" >&2
  fi

  # 2. Validate External Links
  echo "🌐 Validating external links..." >&2

  # Try using markdown-link-check if available
  if command -v npx &> /dev/null; then
    echo "   🔍 Using markdown-link-check for comprehensive validation..." >&2

    # Create a temporary config if none exists
    CONFIG_FILE=".markdown-link-check.json"
    TEMP_CONFIG=false

    if [ ! -f "$CONFIG_FILE" ]; then
      TEMP_CONFIG=true
      CONFIG_FILE="/tmp/markdown_link_config_$$"
      cat > "$CONFIG_FILE" << 'EOF'
{
  "timeout": "30s",
  "retryOn429": true,
  "retryCount": 3,
  "fallbackProtocols": ["http", "https"],
  "ignorePatterns": [
    { "pattern": "^http://localhost" },
    { "pattern": "^https://localhost" },
    { "pattern": "^http://127.0.0.1" },
    { "pattern": "^#" }
  ]
}
EOF
    fi

    MLC_OUTPUT_FILE="/tmp/mlc_output_$$"
    if timeout 60s npx markdown-link-check "$FILE_PATH" --config "$CONFIG_FILE" > "$MLC_OUTPUT_FILE" 2>&1; then
      # Parse results
      DEAD_LINKS=$(grep -c '✖' "$MLC_OUTPUT_FILE" 2>/dev/null || echo "0")
      ALIVE_LINKS=$(grep -c '✓' "$MLC_OUTPUT_FILE" 2>/dev/null || echo "0")

      if [ "$DEAD_LINKS" -eq 0 ]; then
        report_validation "PASS" "All external links are valid ($ALIVE_LINKS checked)"
      else
        report_validation "ERROR" "Found $DEAD_LINKS dead external links"
        echo "   📝 Dead links details:" >&2
        grep '✖' "$MLC_OUTPUT_FILE" | head -5 | while read line; do
          echo "     $line" >&2
        done
      fi
    else
      # Fallback to basic URL validation
      echo "   ⚠️ markdown-link-check failed, using basic validation..." >&2

      # Extract HTTP/HTTPS URLs
      EXTERNAL_URLS=$(grep -oE 'https?://[^)]+' "$TEMP_LINKS" 2>/dev/null || true)

      if [ -n "$EXTERNAL_URLS" ]; then
        EXTERNAL_COUNT=$(echo "$EXTERNAL_URLS" | wc -l)
        echo "   🌐 Found $EXTERNAL_COUNT external URLs to validate" >&2

        # Basic URL validation using curl
        if command -v curl &> /dev/null; then
          EXTERNAL_ERRORS=0
          echo "$EXTERNAL_URLS" | head -10 | while read -r url; do
            if [ -n "$url" ]; then
              if curl -s --head --max-time 10 "$url" >/dev/null 2>&1; then
                echo "     ✅ Valid: $url" >&2
              else
                echo "     ❌ Invalid: $url" >&2
                EXTERNAL_ERRORS=$((EXTERNAL_ERRORS + 1))
              fi
            fi
          done
        else
          echo "   ⚠️ curl not available for URL validation" >&2
        fi
      else
        echo "   📋 No external URLs found" >&2
      fi
    fi

    # Clean up temporary config if created
    [ "$TEMP_CONFIG" = true ] && rm -f "$CONFIG_FILE"
    rm -f "$MLC_OUTPUT_FILE"

  else
    echo "   ⚠️ npx not available, using basic link validation" >&2
  fi

  # 3. Validate Internal Links
  echo "📁 Validating internal links and references..." >&2

  # Extract internal links (relative paths)
  INTERNAL_URLS=$(cat "$TEMP_LINKS" "$TEMP_IMAGES" 2>/dev/null | grep -E '^\./|^\.\./' || true)
  ABSOLUTE_PATHS=$(cat "$TEMP_LINKS" "$TEMP_IMAGES" 2>/dev/null | grep -E '^/' || true)

  INTERNAL_ERRORS=0

  # Check relative path links
  if [ -n "$INTERNAL_URLS" ]; then
    echo "   📂 Checking relative path links..." >&2

    echo "$INTERNAL_URLS" | while read -r link; do
      if [ -n "$link" ]; then
        # Remove anchor if present
        FILE_PART=$(echo "$link" | cut -d'#' -f1)
        ANCHOR_PART=$(echo "$link" | cut -d'#' -f2)

        if [ -n "$FILE_PART" ]; then
          # Resolve relative path
          RESOLVED_PATH="$(cd "$FILE_DIR" && realpath "$FILE_PART" 2>/dev/null || echo "$FILE_PART")"

          if [ -f "$RESOLVED_PATH" ] || [ -d "$RESOLVED_PATH" ]; then
            echo "     ✅ Valid: $link" >&2
          else
            echo "     ❌ Broken: $link (resolved to: $RESOLVED_PATH)" >&2
            INTERNAL_ERRORS=$((INTERNAL_ERRORS + 1))
          fi
        fi

        # Check anchor if present (simplified check)
        if [ "$link" != "$FILE_PART" ] && [ -n "$ANCHOR_PART" ]; then
          echo "     ℹ️ Anchor found: #$ANCHOR_PART" >&2
        fi
      fi
    done
  fi

  # Check absolute path links
  if [ -n "$ABSOLUTE_PATHS" ]; then
    echo "   📁 Checking absolute path links..." >&2

    echo "$ABSOLUTE_PATHS" | while read -r link; do
      if [ -n "$link" ]; then
        FILE_PART=$(echo "$link" | cut -d'#' -f1)

        if [ -f "$FILE_PART" ] || [ -d "$FILE_PART" ]; then
          echo "     ✅ Valid: $link" >&2
        else
          echo "     ❌ Broken: $link" >&2
          INTERNAL_ERRORS=$((INTERNAL_ERRORS + 1))
        fi
      fi
    done
  fi

  if [ "$INTERNAL_ERRORS" -eq 0 ]; then
    report_validation "PASS" "All internal links are valid"
  else
    report_validation "ERROR" "Found $INTERNAL_ERRORS broken internal links"
  fi

  # 4. Validate Image Links
  echo "🖼️ Validating image links..." >&2

  IMAGE_COUNT=$(cat "$TEMP_IMAGES" 2>/dev/null | wc -l || echo "0")

  if [ "$IMAGE_COUNT" -gt 0 ]; then
    echo "   📊 Found $IMAGE_COUNT image links" >&2

    IMAGE_ERRORS=0

    cat "$TEMP_IMAGES" | while read -r img_link; do
      if [ -n "$img_link" ]; then
        if [[ "$img_link" == http* ]]; then
          echo "     🌐 External image: $img_link" >&2
        else
          # Local image file
          if [[ "$img_link" == /* ]]; then
            IMG_PATH="$img_link"
          else
            IMG_PATH="$FILE_DIR/$img_link"
          fi

          if [ -f "$IMG_PATH" ]; then
            echo "     ✅ Valid image: $img_link" >&2
          else
            echo "     ❌ Missing image: $img_link" >&2
            IMAGE_ERRORS=$((IMAGE_ERRORS + 1))
          fi
        fi
      fi
    done

    if [ "$IMAGE_ERRORS" -eq 0 ]; then
      report_validation "PASS" "All local images found"
    else
      report_validation "ERROR" "$IMAGE_ERRORS local images missing"
    fi
  else
    echo "   📋 No image links found" >&2
  fi

  # 5. Validate Internal Anchors
  echo "⚓ Validating document anchors..." >&2

  # Extract anchor-only links (starting with #)
  ANCHOR_LINKS=$(cat "$TEMP_LINKS" | grep '^#' 2>/dev/null || true)

  if [ -n "$ANCHOR_LINKS" ]; then
    echo "   🔗 Found anchor links to validate" >&2

    # Extract headers from markdown to validate anchors
    HEADERS_FILE="/tmp/markdown_headers_$$"
    grep -E '^#{1,6} ' "$FILE_PATH" | sed 's/^#* *//' | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 -]//g' | sed 's/ /-/g' > "$HEADERS_FILE" 2>/dev/null || true

    ANCHOR_ERRORS=0

    echo "$ANCHOR_LINKS" | while read -r anchor; do
      if [ -n "$anchor" ]; then
        CLEAN_ANCHOR=$(echo "$anchor" | sed 's/^#//' | tr '[:upper:]' '[:lower:]')

        if grep -q "^$CLEAN_ANCHOR$" "$HEADERS_FILE" 2>/dev/null; then
          echo "     ✅ Valid anchor: $anchor" >&2
        else
          echo "     ❌ Invalid anchor: $anchor" >&2
          ANCHOR_ERRORS=$((ANCHOR_ERRORS + 1))
        fi
      fi
    done

    rm -f "$HEADERS_FILE"

    if [ "$ANCHOR_ERRORS" -eq 0 ]; then
      report_validation "PASS" "All document anchors valid"
    else
      report_validation "ERROR" "$ANCHOR_ERRORS invalid document anchors"
    fi
  else
    echo "   📋 No document anchors found" >&2
  fi

  # 6. Markdown Quality Checks
  echo "📝 Markdown quality and accessibility checks..." >&2

  # Check for alt text in images
  IMAGES_WITHOUT_ALT=$(grep -c '!\[\](' "$FILE_PATH" 2>/dev/null || echo "0")

  if [ "$IMAGES_WITHOUT_ALT" -gt 0 ]; then
    report_validation "WARNING" "$IMAGES_WITHOUT_ALT images missing alt text for accessibility"
  else
    if [ "$IMAGE_COUNT" -gt 0 ]; then
      report_validation "PASS" "All images have alt text"
    fi
  fi

  # Check for bare URLs (not wrapped in markdown links)
  BARE_URLS=$(grep -oE 'https?://[^\s\)\]]+' "$FILE_PATH" | grep -v '](http' | head -5 | wc -l || echo "0")

  if [ "$BARE_URLS" -gt 0 ]; then
    report_validation "WARNING" "Found $BARE_URLS bare URLs - consider wrapping in markdown links"
  fi

  # 7. Generate Validation Summary
  echo "" >&2
  echo "📋 Markdown Link Validation Summary:" >&2
  echo "===================================" >&2
  echo "   📄 File: $FILE_NAME" >&2
  echo "   📏 Size: $(( FILE_SIZE / 1024 ))KB, $LINE_COUNT lines" >&2
  echo "   🔗 Total links: $TOTAL_LINKS" >&2
  echo "   🖼️ Images: $IMAGE_COUNT" >&2
  echo "   ✅ Validations passed: $VALIDATIONS_PASSED" >&2
  echo "   ⚠️ Warnings: $WARNINGS" >&2
  echo "   ❌ Errors: $ERRORS" >&2

  if [ "$ERRORS" -eq 0 ]; then
    if [ "$WARNINGS" -eq 0 ]; then
      echo "   🎉 Status: EXCELLENT - All links are valid and accessible" >&2
    else
      echo "   ✅ Status: GOOD - Links are valid with minor accessibility recommendations" >&2
    fi
  else
    echo "   ❌ Status: ERRORS - Found broken links that must be fixed" >&2
  fi

  echo "" >&2
  echo "💡 Markdown Link Best Practices:" >&2
  echo "   • Use descriptive link text instead of 'click here'" >&2
  echo "   • Add alt text to all images for accessibility" >&2
  echo "   • Use relative paths for internal documentation" >&2
  echo "   • Validate external links regularly" >&2
  echo "   • Keep anchor links synchronized with headers" >&2
  echo "   • Consider using reference-style links for readability" >&2

  # Clean up temporary files
  rm -f "$TEMP_LINKS" "$TEMP_IMAGES" "$TEMP_ANCHORS"

  # Exit with error if there are critical link issues
  if [ "$ERRORS" -gt 0 ]; then
    echo "⚠️ Markdown link validation completed with errors" >&2
    exit 1
  fi

else
  # Not a markdown file
  exit 0
fi

exit 0

Examples

Markdown Link Checker Hook Script

Complete hook script that performs markdown link validation

#!/usr/bin/env bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""')
if [ -z "$FILE_PATH" ]; then
  exit 0
fi
if [[ "$FILE_PATH" == *.md ]] || [[ "$FILE_PATH" == *.mdx ]]; then
  echo "🔗 Markdown Link Validation for: $(basename "$FILE_PATH")" >&2
  if command -v npx &> /dev/null; then
    if timeout 60s npx markdown-link-check "$FILE_PATH" --config .markdown-link-check.json 2>/dev/null; then
      echo "✅ All links valid" >&2
    else
      echo "❌ Broken links detected" >&2
      exit 1
    fi
  fi
fi
exit 0

Hook Configuration

Complete hook configuration for .claude/settings.json to enable markdown link validation

{
  "hooks": {
    "postToolUse": {
      "script": "./.claude/hooks/markdown-link-checker.sh",
      "matchers": ["write", "edit"]
    }
  }
}

markdown-link-check Configuration

Example .markdown-link-check.json configuration file for customizing link validation settings

{
  "timeout": "30s",
  "retryOn429": true,
  "retryCount": 3,
  "fallbackProtocols": ["http", "https"],
  "ignorePatterns": [
    { "pattern": "^http://localhost" },
    { "pattern": "^https://localhost" },
    { "pattern": "^http://127.0.0.1" },
    { "pattern": "^#" }
  ]
}

Internal Link Validation

Enhanced hook script for internal link validation with path resolution

#!/usr/bin/env bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""')
FILE_DIR="$(dirname "$FILE_PATH")"
if [[ "$FILE_PATH" == *.md ]]; then
  INTERNAL_LINKS=$(grep -oE '\\([^)]+\\)' "$FILE_PATH" | sed 's/[()]//g' | grep -E '^\./|^\.\./')
  INTERNAL_ERRORS=0
  echo "$INTERNAL_LINKS" | while read -r link; do
    if [ -n "$link" ]; then
      FILE_PART=$(echo "$link" | cut -d'#' -f1)
      RESOLVED_PATH="$(cd "$FILE_DIR" && realpath "$FILE_PART" 2>/dev/null || echo "$FILE_PART")"
      if [ ! -f "$RESOLVED_PATH" ] && [ ! -d "$RESOLVED_PATH" ]; then
        echo "❌ Broken: $link" >&2
        INTERNAL_ERRORS=$((INTERNAL_ERRORS + 1))
      fi
    fi
  done
fi
exit 0

Anchor Link Validation

Enhanced hook script for anchor link validation with heading ID matching

#!/usr/bin/env bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""')
if [[ "$FILE_PATH" == *.md ]]; then
  HEADERS=$(grep -E '^#{1,6} ' "$FILE_PATH" | sed 's/^#* *//' | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 -]//g' | sed 's/ /-/g')
  ANCHOR_LINKS=$(grep -oE '\\(#[^)]+\\)' "$FILE_PATH" | sed 's/[()]//g' | sed 's/^#//')
  ANCHOR_ERRORS=0
  echo "$ANCHOR_LINKS" | while read -r anchor; do
    if [ -n "$anchor" ]; then
      CLEAN_ANCHOR=$(echo "$anchor" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 -]//g' | sed 's/ /-/g')
      if ! echo "$HEADERS" | grep -q "^$CLEAN_ANCHOR$"; then
        echo "❌ Invalid anchor: #$anchor" >&2
        ANCHOR_ERRORS=$((ANCHOR_ERRORS + 1))
      fi
    fi
  done
fi
exit 0

Troubleshooting

Hook times out on markdown files with many links

Increase timeout in markdown-link-check config from 30s to 60s. Reduce retryCount from 3 to 1 for faster failures. Use --quiet flag or limit external link checking to critical links only. Verify timeout settings. Test with various file sizes.

False positives for localhost or development URLs

Add localhost patterns to ignorePatterns in .markdown-link-check.json config. Hook creates temp config with common exclusions but customize project config for development server URLs. Verify ignore patterns. Test with various URL patterns.

Anchor validation fails for generated heading IDs

Heading ID generation varies by markdown processor. Update anchor cleaning logic in hook to match your tool's slug generation (GitHub uses lowercase with dashes, Jekyll may differ). Verify heading ID format. Test with various markdown processors.

Image links show as broken but files exist

Check path resolution relative to markdown file location. Hook resolves paths from file directory, not repo root. Use absolute paths from repo root with leading slash for consistency. Verify path resolution. Test with various path formats.

External link validation causes rate limiting errors

Enable retryOn429 in config and increase timeout. Add rate-limited domains to ignorePatterns temporarily. Consider running full external validation in CI only, not on every file edit. Verify retry settings. Test with various external URLs.

Internal link validation fails for symlinked files

realpath resolves symlinks by default. Check if symlink target exists: readlink -f "$link" or use -s flag with realpath. Verify symlink handling. Test with various symlink configurations.

Reference-style links not detected

Hook extracts reference-style links with pattern: grep -oE '\[([^\]]+)\]\[([^\]]+)\]'. Verify link format matches expected pattern. Check for link definition section. Test with various reference-style link formats.

Image alt text validation shows false positives

Hook checks for empty alt text: grep -c '!\[\]('. Some images may legitimately have empty alt text (decorative images). Adjust validation logic or add exceptions. Verify alt text patterns. Test with various image formats.

#markdown#documentation#links#validation#broken-links

Source citations

Signals

Loading live community signals…

More like this, weekly

A short, calm digest of reviewed Claude resources. Unsubscribe any time.