Skip to content

Repository files navigation

Bash Markdown Link Validator

Fast, zero-dependency Markdown link validator with smart anchor resolution. Pure Bash.

GitHub Release License: MIT Bash 4.0+ Platform CI ShellCheck Dependencies Maintenance Last Commit


⚡ TL;DR

Checks that every [text](file.md) link in a documentation tree points at a file that exists, and that every #anchor matches a real heading. Pure Bash, nothing to install beyond the shell you already have. It never touches the network: external URLs are counted and skipped, not fetched. What else it leaves alone is listed under What it does not check.


✨ Features

  • Zero external dependencies - Bash 4.0+ plus grep, sed and find. No npm, pip or cargo.
  • Anchor resolution beyond exact match - Lowercasing, umlaut transliteration and GitHub's punctuation rule, then exact match, numbered-section match (#25-x finds #2-5-x) and suffix match (#prepared-statements finds #2-3-prepared-statements)
  • Directory links resolve to the landing page - [systemd](systemd/) is checked against systemd/README.md, the file GitHub shows for such a link. A directory without one is reported as broken, with Directory has no README.md rather than File not found
  • Code blocks are skipped - Links inside fenced blocks and inline code spans are not validated, so a documented example never fails the run
  • Parallel processing - -j N, default 2
  • JSON output - --output-format=json for CI pipelines; quotes, backslashes and control characters in paths are escaped
  • Batch fixing - --fix-pattern=OLD:NEW rewrites matching links in place
  • Wrapper system - One shared core, one small wrapper script per documentation area

🚫 What it does not check

The scanner extracts links whose target contains .md. Everything outside that pattern is not validated:

  • External URLs are counted, not fetched. http://, https://, ftp:// and mailto: links are skipped and reported as "Skipped external URLs". No request ever leaves the machine, so a dead web link stays invisible here. A tool that does fetch them, lychee for instance, runs alongside this one without conflict.
  • Links whose target is not a Markdown file never reach the validator. [script](install.sh) and [logo](logo.png) are dropped by LINK_PATTERN, which admits two shapes: a target containing .md, and a directory link ending in /. This README is its own example: a run over this repository counts four links in it, the ones pointing at docs/.
  • A missing anchor is a warning, not a failure. If the file exists but the heading does not, the link counts as valid and exit_with_status() still returns 0. Only a missing file produces exit code 1. If CI should fail on anchors too, grep the output for "Anchor not found".
  • There is no test suite. CI runs ShellCheck and bash -n on every push; everything beyond syntax rests on production use, not on unit tests.

🚀 Quick Start

Installation

git clone https://github.com/fidpa/bash-markdown-link-validator.git
cd bash-markdown-link-validator
./install.sh

install.sh checks the Bash version and the required tools, then copies src/validate-links-core.sh to ~/.local/lib/bash-markdown-link-validator/ and writes a validate-links-sample.sh next to it to copy and adapt. It refuses to continue below Bash 4.0. Cloning alone is enough if you would rather point your wrapper at src/validate-links-core.sh in the checkout.

Basic Usage

Create a wrapper script in your docs directory:

#!/bin/bash
set -uo pipefail

AREA_NAME="docs"
AREA_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
EXCLUDE_DIRS="archive|deprecated"

source "$HOME/.local/lib/bash-markdown-link-validator/validate-links-core.sh" || exit 2

parse_args "$@"
setup_colors
print_validation_header
mapfile -t md_files < <(find_markdown_files "$AREA_DIR" "$EXCLUDE_DIRS")
[[ $PARALLEL_JOBS -eq 1 ]] && validate_sequential "${md_files[@]}" || validate_parallel "${md_files[@]}"
print_summary_report
exit_with_status

EXCLUDE_DIRS is a regular expression matched against /directory/, so alternations such as archive|deprecated|node_modules work and an empty string excludes nothing.

Run It

./validate-links.sh              # Basic validation
./validate-links.sh -v           # Verbose output
./validate-links.sh -j 4         # 4 parallel jobs
./validate-links.sh --output-format=json  # CI/CD integration

📊 Sample Output

A run with -j 1 over a directory with one deleted target and one mistyped anchor:

========================================
Link Validation Report - docs
========================================

Scanning: guide/INSTALL.md
Scanning: README.md
  ❌ Line 3: File not found: guide/OLD.md
  ⚠️  Line 5: Anchor not found: #no-such-heading in guide/INSTALL.md
  ✗ 4 links, 1 broken (75% valid)

========================================
Summary
========================================
Total files scanned: 2
Total links found: 4
  Internal links: 4
  External links: 0
Valid links: 3
  Internal valid: 3
  External valid: 0
Broken links: 1
Warnings: 1
Success rate: 75%
========================================

The mistyped anchor is the warning; the deleted file is what makes the run exit with 1. Parallel runs, which is what the default -j 2 does, print the same findings but mark the per-file lines SCANNING: and SUMMARY: instead.


🎯 Smart Anchor Resolution

Every anchor is normalized before matching: lowercased, umlauts transliterated (ä to a, ß to ss), punctuation removed and spaces turned into hyphens, which is the rule GitHub itself applies to headings. Three matching strategies then run in order:

Pattern Example Resolution
Exact Match #api-reference Matches the normalized heading, so #API-Reference finds it too
Numbered Sections #25-troubleshooting Matches #2-5-troubleshooting
Suffix Match #prepared-statements Matches #2-3-prepared-statements, that is, a heading carrying a section number
Umlaut Normalization #größe Matches #grosse

Suffix matching applies only to anchors without their own section number and only against headings numbered N-N-; it is deliberately narrow, so that #setup does not silently match #advanced-setup.


📈 Comparison

Tool Language Dependencies Parallel Anchor matching External URLs
bash-markdown-link-validator Bash None exact + numbered + suffix ❌ not checked
markdown-link-check JavaScript Node.js + npm exact ✅ checked
lychee Rust Binary exact ✅ checked
linkchecker Python Python + deps exact ✅ checked
remark-validate-links JavaScript Node.js + npm exact ❌ not checked

The last column is the trade-off, not an oversight: this validator is fast and dependency-free because it never leaves the filesystem. Entries for the other tools reflect their documentation as of August 2026; corrections by issue are welcome.


🔧 CLI Options

Quoted from ./validate-links.sh --help:

OPTIONS:
    -v, --verbose                Show detailed output for all links
    --no-color                   Disable colored output
    -j N, --parallel-jobs=N      Run N parallel jobs (default: 2)
    --output-format=FORMAT       Output format: text (default) or json
    --fix-pattern=OLD:NEW        Auto-fix links matching OLD pattern to NEW
    --auto-todo                  Mark missing files as TODO (no git history check)
    --no-deep-path-warning       Disable deep path warnings
    --max-path-depth=N           Max ../ levels before warning (default: 5)
    --warm-cache                 Pre-build anchor cache for all files

    -V, --version                Print the library version and exit
    -h, --help                   Show this help message

EXIT CODES:
    0 - All links valid
    1 - Broken links found
    2 - Script error

Every option has an environment-variable equivalent (VERBOSE, PARALLEL_JOBS, OUTPUT_FORMAT, MAX_PATH_DEPTH and so on), which is what wrapper scripts set.


📚 Documentation


📁 Examples

Example Use Case
basic-wrapper.sh Single directory (e.g., docs/)
multi-area-wrapper.sh Multiple areas (e.g., DIATAXIS: tutorial, how-to, reference, explanation)

🔗 Requirements

  • Bash 4.0+ (for associative arrays and ${var,,} lowercasing)
  • Standard Unix Tools: grep, sed, find
  • Optional: realpath for tidier relative paths in the report, git for the history check behind --auto-todo

🖥️ Platform Support

  • Linux, WSL2: Fully supported. CI runs ShellCheck and a syntax check on ubuntu-latest for every push.
  • macOS: Supported since v1.1.0, when the GNU-specific calls (grep -oP, sed -i, readlink -f) were replaced by POSIX equivalents. It needs a Bash from Homebrew: Apple ships Bash 3.2, which cannot run this code, and install.sh stops with that message. Not covered by CI, so verified by use rather than by a pipeline.
  • Bash 3.x and other shells: Not supported. Associative arrays and ${var,,} have no fallback here.

📜 License

MIT License - See LICENSE for details.


🙏 Acknowledgments

Written for a private documentation repository that had grown past the point where broken links could be found by reading, and used there daily since August 2025. Public since December 2025.

About

Fast Markdown link validator for AI-assisted development. Smart anchor resolution, parallel processing, CI/CD ready. Pure Bash, zero dependencies.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages