PROMPT

Markdown Link Checker

Prompts👁 0
PROMPT CONTENT
---
name: markdown-link-checker
description: Checks a Markdown docs folder for links that break readers - relative links to missing or renamed files, anchors that match no heading, duplicate heading anchors, file-name case that only works on macOS, local absolute paths, reference-style links with missing, unused or duplicate definitions, images without alt text and bare URLs. Works offline and lists external URLs to check separately. Includes a stdlib Python checker with --self-test.
---

# Markdown Link Checker

You help maintainers, technical writers and developers keep documentation navigable. You find every link inside a docs folder that leads nowhere (a moved file, a renamed heading, a typo in an anchor, a reference label that was never defined), explain why it broke, and return exact fixes. You work offline first: internal links and anchors are checked from the files themselves, and external URLs are only listed, so a docs check never depends on the network or hammers other people's servers.

## Files in this skill

- `scripts/check_md_links.py` - walks a folder of .md/.markdown/.mdx files, resolves relative and root-relative links, builds GitHub-style heading anchors and reports broken links, missing anchors, duplicates, reference-definition problems, missing alt text and bare URLs, with an external URL list on request (Python 3 standard library only)
- `references/anchors-and-slugs.md` - how GitHub, GitLab, MkDocs, Docusaurus and Hugo turn headings into anchors, duplicates, explicit anchors and the edge cases that break links
- `references/finding-codes.md` - every finding code, its severity, why it matters and the usual fix, plus how to check external URLs politely
- `templates/link-report.md` - the report to hand back
- `examples/README.md` - a small project README with deliberate link problems
- `examples/setup-guide.md` - the guide it links to, with more problems (wrong-case file name, anchor case, missing reference, local path)

## Workflow

### 1. Scope the check
Ask which folder is the docs root, how the docs are published (GitHub/GitLab rendering, MkDocs, Docusaurus, Hugo, a plain repo browse), and whether links like `/guide/a.md` mean "from the repo root" or "from the site root". The anchor rules differ per generator (see `references/anchors-and-slugs.md`); default to GitHub rules and say so.

### 2. Run the checker
```
python3 scripts/check_md_links.py examples --external
python3 scripts/check_md_links.py docs README.md --root . --min-severity MEDIUM
python3 scripts/check_md_links.py docs --ignore BARE_URL,UNUSED_REF_DEF --json > links.json
python3 scripts/check_md_links.py --self-test
```
Exit 0 means no HIGH findings, 1 means at least one broken link, missing anchor or missing reference definition (use it as a CI gate), 2 means bad input. Code blocks and inline code are skipped; GitHub line anchors (`#L10-L20`) and `mailto:` links are accepted.

### 3. Triage and fix
Work HIGH first. For BROKEN_LINK, search the repo for the file name (`git log --diff-filter=R --name-status` shows renames) and link to where it lives now. For MISSING_ANCHOR, read the target headings: the script suggests the closest existing anchor; decide whether to change the link or the heading (changing a heading breaks inbound links from elsewhere, so prefer adding an explicit anchor if the old one is linked externally). For DUPLICATE_ANCHOR, make the headings unique. For CASE_MISMATCH, use the exact file name. Then the MEDIUM and LOW items: alt text, empty links, unused definitions, bare URLs.

### 4. External URLs
With `--external` the script lists every external URL and where it is used, without making requests. If the user wants them checked, do it separately and politely (HEAD then GET, a few requests per host, a timeout, treat 429/403 as "check by hand", not as broken). See `references/finding-codes.md`.

### 5. Deliver
Fill `templates/link-report.md`: scope and rules used, counts, a fix table with file:line, old target, new target, and the external URL list. If asked for corrected files, return them with only the link changes, nothing else rewritten.

## Example result
`examples/` (2 files): 13 findings, 6 HIGH. README.md links `setup-guide.md#configuration`, but the heading is "Configure the team folder" (#configure-the-team-folder, suggested by the script); CHANGELOG.md, ../CONTRIBUTING.md, docs/style.md and the diagram image do not exist; "Usage" appears twice so the second is #usage-1; [roadmap] is defined but unused. setup-guide.md links `readme.md` (only README.md exists: works on a Mac, breaks on GitHub), uses `#Usage` (anchors are lower-case), points at /Users/ana/... and uses [support] without a definition. 4 external URLs listed, none fetched.

## Rules

- Never fetch external URLs unless the user asks; when you do, rate-limit and do not report 403/429 as broken.
- Fix the link, not the content: do not rewrite prose while fixing paths.
- Prefer relative links between docs; root-relative only if the publishing setup supports it.
- When a heading must change, keep the old anchor working with an explicit `<a id="old-anchor"></a>` if other sites link to it.
- State which anchor rules you used (GitHub by default) and re-run the checker after fixing; the result must be exit 0.
- Write real alt text for images (what it shows and why it is there); for a full accessibility review use a dedicated accessibility check.
FILE:references/anchors-and-slugs.md
# Heading anchors and slugs

A link like `guide.md#install` works only if the renderer generated exactly `install` as the id of a heading in guide.md. Each renderer has its own slug rules, so first find out where the docs are read.

## GitHub (default used by the script)
1. Take the heading text as rendered: inline markup removed (`code` keeps its text, `**bold**` becomes bold, `[text](url)` becomes text, HTML tags dropped).
2. Lower-case it.
3. Remove every character that is not a letter, digit, space, hyphen or underscore (letters include accented and non-Latin letters; emoji and punctuation go).
4. Turn each space into a hyphen. Spaces are NOT collapsed: "C++ & Rust" becomes `c--rust`.
5. Duplicates get `-1`, `-2`, ... in order of appearance: the second "Usage" is `#usage-1`.

| Heading | Anchor |
|---|---|
| `## What's new? (v2.0)` | `#whats-new-v20` |
| ``## The `--dry-run` flag`` | `#the---dry-run-flag` |
| `## Über uns` | `#über-uns` (GitHub also accepts the percent-encoded form) |
| `## C++ & Rust: 100% safe?` | `#c--rust-100-safe` |
| `## snake_case_name` | `#snake_case_name` |
| `## Usage` (second time) | `#usage-1` |

The script's slug function matches the github-slugger library on these cases.

## Other renderers
- **GitLab**: like GitHub, but consecutive hyphens are collapsed into one (`#c-rust-100-safe`), so links copied between GitHub and GitLab can break.
- **MkDocs (Python-Markdown toc)**: drops non-ASCII by default, collapses separators; `{#custom-id}` is available with attr_list. Check `markdown_extensions: toc: slugify` in mkdocs.yml.
- **Docusaurus**: GitHub-like slugs; supports `## Heading {#custom-id}`.
- **Hugo (goldmark)**: GitHub-like by default; `autoHeadingIDType` can be `github`, `github-ascii` or `blackfriday`.
- **VS Code preview / most IDEs**: GitHub-like.

If the published site and the repo view disagree, prefer explicit anchors for anything linked from outside.

## Explicit anchors
- `<a id="install"></a>` or `<a name="install"></a>` just before the heading (works everywhere HTML is allowed).
- `## Install {#install}` where the renderer supports attribute lists (MkDocs attr_list, Docusaurus, Hugo, Pandoc). On GitHub the `{#...}` shows up as literal text.

## What breaks anchors
- Renaming a heading ("Configuration" -> "Configure the team folder").
- Duplicates: links to `#usage` always land on the first one.
- Capital letters in the link (`#Usage`): GitHub anchors are lower-case; some browsers forgive it, many renderers do not.
- Copying the anchor from another renderer (GitLab vs GitHub hyphens).
- Headings generated by includes or templates the checker cannot see; mark these with `--ignore MISSING_ANCHOR` for that folder only, and say so.
- Line anchors (`#L10`, `#L10-L20`) are GitHub file-view features; they are accepted but break if the file moves or lines shift.
FILE:references/finding-codes.md
# Finding codes

| Code | Severity | Why it matters | Usual fix |
|---|---|---|---|
| BROKEN_LINK | HIGH | relative link to a file or folder that does not exist (moved, renamed, typo) | link to the current path; `git log --diff-filter=R --name-status -- docs/` shows renames |
| BROKEN_IMAGE | HIGH | image path does not exist; readers see a broken icon | fix the path or commit the image |
| MISSING_ANCHOR | HIGH | #fragment matches no heading or explicit anchor in the target file | use the suggested anchor, or add an explicit anchor |
| MISSING_REF_DEF | HIGH | `[text][label]` has no `[label]: url` definition; it renders as plain bracketed text | add the definition or fix the label (labels are case-insensitive) |
| DUPLICATE_ANCHOR | MEDIUM | two headings produce the same slug; the second gets `-1` and links land on the first | make headings unique, e.g. "Usage (CLI)" and "Usage (API)" |
| DUPLICATE_REF_DEF | MEDIUM | the same label is defined twice; only the first counts | delete or rename one |
| CASE_MISMATCH | MEDIUM | file exists only with different case; fine on macOS/Windows, broken on Linux, GitHub Pages and most CI | use the exact name |
| ANCHOR_CASE | MEDIUM | anchor only matches when lower-cased | write anchors in lower case |
| LOCAL_ABSOLUTE_PATH | MEDIUM | link to /Users/..., /home/..., C:\ or file:// works only on the author's machine | relative path, or remove |
| EMPTY_LINK | MEDIUM | `[text]()` or empty href | fill in or remove |
| EMPTY_LINK_TEXT | MEDIUM | `[](url)`: nothing to click or read aloud | add link text |
| MISSING_ALT | MEDIUM | image without alt text; screen readers say "image" or the file name | describe what it shows and why it is there |
| UNUSED_REF_DEF | LOW | definition never used; usually left over after an edit | delete, or link to it if the link was lost |
| BARE_URL | LOW | raw URL in text; not clickable in some renderers, read character by character | `[descriptive text](url)` or `<url>` |
| OUTSIDE_ROOT | LOW | relative link leaves the docs root; breaks if docs are published alone | link to the published URL |

Exit codes: 0 = no HIGH, 1 = HIGH present, 2 = bad input. Use `--min-severity` and `--ignore` to tune a CI gate, and record any ignore in the repo with a reason.

## Link forms the script understands
- Inline: `[text](path.md#anchor "title")`, `[text](<path with spaces.md>)`, `[text](my%20notes.md)`
- Images: `![alt](img.png)`, `<img src="img.png" alt="...">`
- Reference: `[text][label]`, `[label][]`, `[label]` with `[label]: url "title"` anywhere in the same file
- Autolinks `<https://...>`, HTML `<a href="...">`, explicit anchors `<a id>`, `<a name>`, `{#id}`
- Root-relative `/guide/a.md` resolves from `--root` (default: the first folder argument)
- Skipped: fenced and indented code blocks, inline code, `mailto:`/`tel:`, GitHub line anchors, task list boxes `[ ]` `[x]`

## Checking external URLs (only when asked)
- Deduplicate first: `--external` lists each URL once with every place it is used.
- Use HEAD, fall back to GET when the server rejects HEAD (405) or lies about it.
- One or two requests per host at a time, a 10 s timeout, a clear User-Agent.
- 200-299 OK; 301/308 update the link to the new location; 404/410 broken; 403, 429 and timeouts are "check by hand" (bot protection, rate limits).
- Anchors on external pages cannot be checked reliably offline; note them, do not guess.
- Prefer links to stable, versioned pages (tagged docs, permalinks) over "latest".
FILE:templates/link-report.md
# Docs link check: <repo or folder>, <date>

**Scope:** <docs/ and README.md, 42 files>   **Anchor rules:** <GitHub>   **Root for /links:** <repo root>
**Command:** `python3 scripts/check_md_links.py <docs README.md --root . --external>`
**Result:** <13 findings: HIGH 6, MEDIUM 5, LOW 2 -> after fixes: exit 0>

## Must fix (HIGH)
| # | File:Line | Code | Link as written | Problem | Fix (new target) |
|---|---|---|---|---|---|
| 1 | <README.md:11> | <MISSING_ANCHOR> | <setup-guide.md#configuration> | <heading renamed to "Configure the team folder"> | <setup-guide.md#configure-the-team-folder> |

## Should fix (MEDIUM)
| # | File:Line | Code | Problem | Fix |
|---|---|---|---|---|
| 1 | <setup-guide.md:25> | <CASE_MISMATCH> | <readme.md vs README.md> | <README.md#contents> |

## Clean-up (LOW)
- <unused [roadmap] definition in README.md:42 - delete or link it>
- <bare URL README.md:21 -> [Tidepool documentation](https://docs.example.com/tidepool)>

## Images and alt text
| File:Line | Image | Alt text to use |
|---|---|---|
| <README.md:5> | <images/sync-diagram.png (missing)> | <"Diagram: phone app sends notes to the sync server, which writes them to the team folder"> |

## External URLs (not fetched)
| URL | Used at | Status (if checked) |
|---|---|---|
| <https://docs.example.com/tidepool> | <README.md:21> | <not checked> |

## Prevent it next time
- <run the checker in CI on every docs change (exit 1 fails the build)>
- <when renaming a heading that others link to, keep an explicit anchor with the old id>
FILE:examples/README.md
# Tidepool

Tidepool syncs field notes from a phone to a shared team folder.

![](images/sync-diagram.png)

## Contents

- [Quick start](#quick-start)
- [Install](setup-guide.md#install)
- [Configuration](setup-guide.md#configuration)
- [Troubleshooting](setup-guide.md#troubleshooting)
- [FAQ](#faq)

## Quick start

1. Install the app (see [the setup guide][setup]).
2. Sign in and pick a team folder.
3. Read the [release notes](CHANGELOG.md) before upgrading.

Full documentation lives at https://docs.example.com/tidepool and the API
reference is at <https://api.example.com/tidepool/v2>.

## Usage

Run `tidepool sync` to push new notes. Links inside code are not checked:

```
[not a link](missing.md)
```

## Usage

See [Contributing](../CONTRIBUTING.md) and the [style guide][style].

## Faq

**Can I sync offline?** Notes queue until the phone is online again.

[setup]: setup-guide.md
[style]: docs/style.md
[roadmap]: https://example.com/tidepool/roadmap
FILE:examples/setup-guide.md
# Setup guide

## Install

Download the installer from the [releases page](https://github.com/example/tidepool/releases)
or build from source (see [Building](#building-from-source)).

![Settings screen with the Team folder field highlighted](https://example.com/tidepool/img/settings.png)

## Configure the team folder

Open **Settings > Team folder** and paste the folder link. The folder must
already be shared with you (see [Sharing](./README.md#Usage)).

## Building from source

Clone the repository and run `make build`, then continue with the
[Quick start](README.md#quick-start).

## Troubleshooting

If sync stalls, check the log at [/Users/ana/tidepool/logs/sync.log](/Users/ana/tidepool/logs/sync.log)
or ask in the [support channel][support].

Back to the [README](readme.md#contents).
FILE:scripts/check_md_links.py
#!/usr/bin/env python3
"""Markdown link checker (Python 3 standard library only, fully offline).

Scans a docs folder (or single files) and reports link problems that break readers
before anyone clicks: relative links to files that do not exist, #anchors that match
no heading in the target file, duplicate heading anchors, file-name case mismatches
that only work on macOS/Windows, links to absolute local paths, empty links,
reference-style links whose definition is missing, unused or duplicate reference
definitions, images without alt text and bare URLs. It never touches the network;
use --external to list every external URL (with where it is used) so you can check
them separately.

Anchors follow GitHub's slug rules: heading text with inline markup removed,
lower-cased, punctuation dropped (letters, digits, spaces, "-" and "_" kept), each
space turned into "-", duplicates suffixed -1, -2, ... Explicit anchors from
<a name="x">, <a id="x">, id="x" on HTML tags and {#x} heading attributes count too.

Usage:
  python3 check_md_links.py PATH [PATH ...] [--root DIR] [--min-severity LOW|MEDIUM|HIGH]
        [--ignore CODE,CODE] [--external] [--json]
  python3 check_md_links.py --self-test
Exit codes: 0 = no HIGH findings, 1 = HIGH findings (broken links, missing anchors or
reference definitions), 2 = bad input (path not found, no Markdown files).
"""
import argparse, json, os, re, sys, tempfile, unicodedata
from urllib.parse import unquote

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
MD_EXT = (".md", ".markdown", ".mdx")
SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "_site", "site", "build", "dist"}

FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
ATX_RE = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$")
SETEXT_RE = re.compile(r"^ {0,3}(=+|-+)[ \t]*$")
REFDEF_RE = re.compile(r"^ {0,3}\[([^\]]+)\]:[ \t]*(<[^>]*>|\S+)(?:[ \t]+(\"[^\"]*\"|'[^']*'|\([^)]*\)))?[ \t]*$")
INLINE_RE = re.compile(r"(!?)\[((?:[^\[\]\\]|\\.|\[[^\]]*\])*)\]\(\s*(<[^>]*>|[^\s()]*(?:\([^\s()]*\)[^\s()]*)*)(?:\s+(\"[^\"]*\"|'[^']*'|\([^)]*\)))?\s*\)")
REFLINK_RE = re.compile(r"(!?)\[((?:[^\[\]\\]|\\.)*)\]\[([^\]]*)\]")
SHORTCUT_RE = re.compile(r"(?<![\]\\!])\[([^\[\]]+)\](?![\[(:])")
AUTOLINK_RE = re.compile(r"<((?:https?|ftp)://[^>\s]+|mailto:[^>\s]+)>")
BARE_URL_RE = re.compile(r"(?<![<(\"'=\w/])(https?://[^\s<>)\]\"']+)")
HTML_ANCHOR_RE = re.compile(r"<[a-zA-Z][^>]*?\b(?:id|name)\s*=\s*[\"']([^\"']+)[\"']")
HTML_HREF_RE = re.compile(r"<a\b[^>]*?\bhref\s*=\s*[\"']([^\"']*)[\"']", re.I)
HTML_IMG_RE = re.compile(r"<img\b([^>]*)>", re.I)
ATTR_ID_RE = re.compile(r"\s*\{#([\w\-:.]+)[^}]*\}\s*$")
SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*:")
WIN_ABS_RE = re.compile(r"^[a-zA-Z]:[\\/]")


def finding(sev, code, path, line, snippet, problem, fix):
    return {"severity": sev, "code": code, "file": path, "line": line,
            "snippet": snippet.strip()[:120], "problem": problem, "fix": fix}


def strip_inline_markup(text):
    t = re.sub(r"`([^`]*)`", r"\1", text)
    t = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", t)
    t = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", t)
    t = re.sub(r"\[([^\]]*)\]\[[^\]]*\]", r"\1", t)
    t = re.sub(r"<[^>]+>", "", t)
    t = re.sub(r"(\*\*|\*|~~)(?=\S)(.+?)(?<=\S)\1", r"\2", t)
    t = re.sub(r"(?<!\w)(__|_)(?=\S)(.+?)(?<=\S)\1(?!\w)", r"\2", t)  # _x_ only at word edges
    return t


def github_slug(text):
    t = strip_inline_markup(text).strip().lower()
    out = []
    for ch in t:
        cat = unicodedata.category(ch)
        if ch in "-_ " or cat[0] in "LN" or cat == "Mn":
            out.append("-" if ch == " " else ch)
    return "".join(out)


def mask_code(lines):
    """Return (block-masked lines, fully masked lines): fenced/indented code blocks blanked;
    the second list also masks inline code spans (headings keep their code text)."""
    out, fence = [], None
    prev_blank = True
    for line in lines:
        m = FENCE_RE.match(line)
        if fence:
            if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence) and not line.strip()[len(m.group(1)):].strip():
                fence = None
            out.append("")
            continue
        if m:
            fence = m.group(1)
            out.append("")
            continue
        if prev_blank and (line.startswith("    ") or line.startswith("\t")) and line.strip():
            out.append("")
            continue
        prev_blank = not line.strip()
        out.append(line)
    inline = [re.sub(r"(`+)(.+?)\1", lambda mm: mm.group(1) + "x" * len(mm.group(2)) + mm.group(1), l) for l in out]
    return out, inline


def parse_file(text):
    """Return dict with anchors (slug->line), duplicates, headings, links, refdefs, images, bare urls."""
    raw = text.splitlines()
    blocks, lines = mask_code(raw)
    anchors, dups, counts = {}, [], {}
    refdefs, links, bare, html_imgs = {}, [], [], []
    dup_defs = []
    for i, line in enumerate(lines, 1):
        heading = None
        m = ATX_RE.match(blocks[i - 1])
        if m:
            heading = m.group(2)
        elif i >= 2 and SETEXT_RE.match(line) and blocks[i - 2].strip() and not ATX_RE.match(blocks[i - 2]) \
                and not blocks[i - 2].lstrip().startswith(("-", "*", ">", "|")) and not REFDEF_RE.match(blocks[i - 2]):
            if line.strip().startswith("=") or (i >= 3 and not blocks[i - 3].strip()) or i == 2:
                heading = blocks[i - 2].strip()
        if heading is not None:
            am = ATTR_ID_RE.search(heading)
            if am:
                anchors.setdefault(am.group(1), i)
                heading = heading[:am.start()]
            base = github_slug(heading)
            n = counts.get(base, 0)
            counts[base] = n + 1
            slug = base if n == 0 else f"{base}-{n}"
            if n > 0:
                dups.append((i, heading.strip(), base, slug))
            anchors.setdefault(slug, i)
        for am in HTML_ANCHOR_RE.finditer(blocks[i - 1]):
            anchors.setdefault(am.group(1), i)
        d = REFDEF_RE.match(line)
        if d:
            label = norm_label(d.group(1))
            target = d.group(2).strip("<>")
            if label in refdefs:
                dup_defs.append((i, d.group(1), refdefs[label][1]))
            else:
                refdefs[label] = (target, i, d.group(1))
            continue
        spans = []
        for lm in INLINE_RE.finditer(line):
            target = lm.group(3).strip()
            if target.startswith("<") and target.endswith(">"):
                target = target[1:-1]
            links.append({"kind": "image" if lm.group(1) else "link", "text": lm.group(2), "target": target,
                          "line": i, "snippet": lm.group(0), "ref": None})
            spans.append(lm.span())
        for rm in REFLINK_RE.finditer(line):
            if any(a <= rm.start() < b for a, b in spans):
                continue
            label = rm.group(3) or rm.group(2)
            links.append({"kind": "image" if rm.group(1) else "link", "text": rm.group(2), "target": None,
                          "line": i, "snippet": rm.group(0), "ref": norm_label(label), "explicit": True})
            spans.append(rm.span())
        for sm in SHORTCUT_RE.finditer(line):
            if any(a <= sm.start() < b for a, b in spans):
                continue
            if re.match(r"^[ xX]$", sm.group(1)) or line[sm.end():sm.end() + 1] == "]":
                continue  # task list box or nested
            links.append({"kind": "image" if sm.start() > 0 and line[sm.start() - 1] == "!" else "link",
                          "text": sm.group(1), "target": None, "line": i, "snippet": sm.group(0),
                          "ref": norm_label(sm.group(1)), "explicit": False})
            spans.append(sm.span())
        for hm in HTML_HREF_RE.finditer(line):
            links.append({"kind": "link", "text": "", "target": hm.group(1), "line": i, "snippet": hm.group(0), "ref": None, "html": True})
        for im in HTML_IMG_RE.finditer(line):
            attrs = im.group(1)
            src = re.search(r"\bsrc\s*=\s*[\"']([^\"']*)[\"']", attrs, re.I)
            alt = re.search(r"\balt\s*=\s*[\"']([^\"']*)[\"']", attrs, re.I)
            links.append({"kind": "image", "text": alt.group(1) if alt else None, "target": src.group(1) if src else "",
                          "line": i, "snippet": im.group(0), "ref": None, "html": True})
        cleaned = AUTOLINK_RE.sub(" ", line)
        cleaned = INLINE_RE.sub(" ", cleaned)
        cleaned = re.sub(r"<[^>]+>", " ", cleaned)
        for bm in BARE_URL_RE.finditer(cleaned):
            bare.append((i, bm.group(1).rstrip(".,;:!?")))
        for am in AUTOLINK_RE.finditer(line):
            links.append({"kind": "autolink", "text": am.group(1), "target": am.group(1), "line": i, "snippet": am.group(0), "ref": None})
    return {"anchors": anchors, "dups": dups, "refdefs": refdefs, "dup_defs": dup_defs, "links": links, "bare": bare}


def norm_label(s):
    return re.sub(r"\s+", " ", s.strip()).casefold()


def is_external(target):
    return bool(SCHEME_RE.match(target)) and not WIN_ABS_RE.match(target)


def exists_exact_case(path):
    """True/False for exact-case existence, plus the real name if only a case-insensitive match exists."""
    if os.path.exists(path):
        head, tail = os.path.split(os.path.abspath(path))
        try:
            names = os.listdir(head or ".")
        except OSError:
            return True, None
        if tail in names or not tail:
            return True, None
        for n in names:
            if n.lower() == tail.lower():
                return False, n
        return True, None
    head, tail = os.path.split(os.path.abspath(path))
    try:
        for n in os.listdir(head):
            if n.lower() == tail.lower():
                return False, n
    except OSError:
        pass
    return False, None


class Checker:
    def __init__(self, root, ignore=()):
        self.root = os.path.abspath(root)
        self.ignore = set(ignore)
        self.cache = {}
        self.findings = []
        self.external = {}

    def parsed(self, path):
        path = os.path.abspath(path)
        if path not in self.cache:
            with open(path, encoding="utf-8", errors="replace") as fh:
                self.cache[path] = parse_file(fh.read())
        return self.cache[path]

    def add(self, *a):
        f = finding(*a)
        if f["code"] not in self.ignore:
            self.findings.append(f)

    def rel(self, p):
        return os.path.relpath(p, self.root)

    def check_file(self, path):
        info = self.parsed(path)
        rp = self.rel(path)
        for line, heading, base, slug in info["dups"]:
            self.add("MEDIUM", "DUPLICATE_ANCHOR", rp, line, heading,
                     f"heading '{heading}' repeats an earlier anchor #{base}; this one becomes #{slug}, so links to #{base} land on the first one",
                     "make the heading unique (add context) or add an explicit anchor")
        for line, label, first in info["dup_defs"]:
            self.add("MEDIUM", "DUPLICATE_REF_DEF", rp, line, f"[{label}]:",
                     f"reference [{label}] is defined again (first definition on line {first}); only the first one is used",
                     "delete or rename the duplicate definition")
        used = set()
        for ln in info["links"]:
            if ln["ref"] is not None:
                d = info["refdefs"].get(ln["ref"])
                if d is None:
                    if ln.get("explicit"):
                        self.add("HIGH", "MISSING_REF_DEF", rp, ln["line"], ln["snippet"],
                                 f"reference-style link uses [{ln['ref']}] but no '[{ln['ref']}]: URL' definition exists, so it renders as plain text",
                                 "add the definition at the bottom of the file or fix the label")
                    continue
                used.add(ln["ref"])
                target = d[0]
            else:
                target = ln["target"]
            self.check_link(path, rp, ln, target)
        for label, (target, line, orig) in info["refdefs"].items():
            if label not in used:
                self.add("LOW", "UNUSED_REF_DEF", rp, line, f"[{orig}]: {target}",
                         f"reference definition [{orig}] is never used", "delete it or link to it")
        for line, url in info["bare"]:
            self.add("LOW", "BARE_URL", rp, line, url,
                     "bare URL: not clickable in some renderers and read out character by character by screen readers",
                     f"use [descriptive text]({url}) or <{url}>")
            self.external.setdefault(url, []).append(f"{rp}:{line}")

    def check_link(self, path, rp, ln, target):
        kind = ln["kind"]
        if kind == "image":
            alt = ln["text"]
            if alt is None or not alt.strip():
                self.add("MEDIUM", "MISSING_ALT", rp, ln["line"], ln["snippet"],
                         "image has no alt text", "describe what the image shows or why it is there (alt=\"\" only for purely decorative images in HTML)")
        if target is None:
            return
        if not target.strip():
            self.add("MEDIUM", "EMPTY_LINK", rp, ln["line"], ln["snippet"], f"{kind} has an empty target", "add the URL or path, or remove the brackets")
            return
        if kind == "link" and not ln.get("html") and not strip_inline_markup(ln["text"]).strip():
            self.add("MEDIUM", "EMPTY_LINK_TEXT", rp, ln["line"], ln["snippet"], "link has no visible text", "put words inside the [ ]")
        if target.startswith("mailto:") or target.startswith("tel:"):
            return
        if target.startswith("file:") or WIN_ABS_RE.match(target) or re.match(r"^/(home|Users|tmp|var|mnt|opt)/", target):
            self.add("MEDIUM", "LOCAL_ABSOLUTE_PATH", rp, ln["line"], ln["snippet"],
                     f"'{target}' points at a path on the author's machine; it is broken for everyone else",
                     "use a path relative to this file")
            return
        if is_external(target):
            if target.startswith(("http://", "https://", "ftp://")):
                self.external.setdefault(target, []).append(f"{rp}:{ln['line']}")
            return
        target_path, _, frag = target.partition("#")
        target_path = unquote(target_path.split("?")[0])
        frag = unquote(frag)
        if target_path:
            if target_path.startswith("/"):
                full = os.path.normpath(os.path.join(self.root, target_path.lstrip("/")))
            else:
                full = os.path.normpath(os.path.join(os.path.dirname(path), target_path))
            ok, real = exists_exact_case(full)
            if not ok and real:
                self.add("MEDIUM", "CASE_MISMATCH", rp, ln["line"], ln["snippet"],
                         f"'{target_path}' only matches '{real}' with different letter case; works on macOS/Windows, breaks on Linux and GitHub Pages",
                         f"use the exact name '{real}'")
                full = os.path.join(os.path.dirname(full), real)
            elif not ok:
                code = "BROKEN_IMAGE" if kind == "image" else "BROKEN_LINK"
                self.add("HIGH", code, rp, ln["line"], ln["snippet"],
                         f"'{target_path}' does not exist" + (f" (resolved to {self.rel(full)})" if self.rel(full) != os.path.normpath(target_path) else ""),
                         "fix the path (moved or renamed file?) or remove the link")
                return
            if not os.path.abspath(full).startswith(self.root + os.sep) and os.path.abspath(full) != self.root:
                self.add("LOW", "OUTSIDE_ROOT", rp, ln["line"], ln["snippet"],
                         f"'{target_path}' leaves the docs root; it breaks if the docs are published on their own",
                         "link to the published URL or copy the file into the docs")
        else:
            full = path
        if frag:
            if os.path.isdir(full) or not full.lower().endswith(MD_EXT):
                return
            info = self.parsed(full)
            if frag in info["anchors"]:
                return
            if frag.lower() in info["anchors"]:
                self.add("MEDIUM", "ANCHOR_CASE", rp, ln["line"], ln["snippet"],
                         f"#{frag} only matches #{frag.lower()} case-insensitively; GitHub anchors are lower-case",
                         f"use #{frag.lower()}")
                return
            if re.match(r"^L\d+(-L\d+)?$", frag):
                return  # GitHub line anchors
            key = frag.lower().replace("-", "")
            close = [a for a in info["anchors"] if a.replace("-", "") == key] or \
                    [a for a in info["anchors"] if len(key) >= 5 and key[:6] in a.replace("-", "")]
            hint = f" Did you mean #{close[0]}?" if close else ""
            self.add("HIGH", "MISSING_ANCHOR", rp, ln["line"], ln["snippet"],
                     f"#{frag} matches no heading or anchor in {self.rel(full)}.{hint}",
                     "link to an existing heading slug, or add the heading / an explicit anchor")


def collect(paths):
    files = []
    for p in paths:
        if os.path.isdir(p):
            for dirpath, dirnames, filenames in os.walk(p):
                dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith("."))
                files += [os.path.join(dirpath, f) for f in sorted(filenames) if f.lower().endswith(MD_EXT)]
        elif os.path.isfile(p):
            files.append(p)
        else:
            raise FileNotFoundError(p)
    return files


def run(paths, root=None, ignore=(), min_sev="LOW"):
    files = collect(paths)
    if root is None:
        root = paths[0] if os.path.isdir(paths[0]) else os.path.dirname(os.path.abspath(paths[0]))
    ck = Checker(root, ignore)
    for f in files:
        ck.check_file(os.path.abspath(f))
    lim = SEV_ORDER[min_sev]
    found = [f for f in ck.findings if SEV_ORDER[f["severity"]] <= lim]
    found.sort(key=lambda f: (f["file"], f["line"], SEV_ORDER[f["severity"]]))
    return files, found, ck.external


def report(files, found, external, show_external, roots):
    counts = {s: sum(1 for f in found if f["severity"] == s) for s in SEV_ORDER}
    out = [f"# Markdown link check: {', '.join(roots)}", "",
           f"Files: {len(files)}   Findings: {len(found)} (HIGH {counts['HIGH']}, MEDIUM {counts['MEDIUM']}, LOW {counts['LOW']})   External URLs: {len(external)} (not checked, offline)", ""]
    if not found:
        out.append("No findings.")
    else:
        out += ["| Sev | Code | File:Line | Problem | Fix |", "|---|---|---|---|---|"]
        for f in found:
            out.append(f"| {f['severity']} | {f['code']} | {f['file']}:{f['line']} | {f['problem'].replace('|', '/')} | {f['fix'].replace('|', '/')} |")
    if show_external and external:
        out += ["", "## External URLs to check (no network calls were made)", "", "| URL | Used at |", "|---|---|"]
        for url in sorted(external):
            out.append(f"| {url} | {', '.join(external[url])} |")
    return "\n".join(out)


def self_test():
    cases = []

    def case(name, files, expect, absent=(), target="README.md"):
        cases.append((name, files, expect, absent, target))

    case("clean file", {"README.md": "# Title\n\nSee [setup](setup.md#install).\n", "setup.md": "# Setup\n\n## Install\n"}, [], ["BROKEN_LINK", "MISSING_ANCHOR"])
    case("broken relative link", {"README.md": "[x](docs/missing.md)\n"}, ["BROKEN_LINK"])
    case("broken image", {"README.md": "![logo](img/logo.png)\n"}, ["BROKEN_IMAGE"])
    case("missing anchor other file", {"README.md": "[x](a.md#nope)\n", "a.md": "# Yes\n"}, ["MISSING_ANCHOR"])
    case("missing anchor same file", {"README.md": "# Intro\n[x](#usage)\n"}, ["MISSING_ANCHOR"])
    case("anchor with punctuation", {"README.md": "## What's new? (v2.0)\n[x](#whats-new-v20)\n"}, [], ["MISSING_ANCHOR"])
    case("anchor with inline code", {"README.md": "## The `--dry-run` flag\n[x](#the---dry-run-flag)\n"}, [], ["MISSING_ANCHOR"])
    case("underscores inside words kept", {"README.md": "## snake_case_name and _emphasis_\n[x](#snake_case_name-and-emphasis)\n"}, [], ["MISSING_ANCHOR"])
    case("unicode heading anchor", {"README.md": "## Über uns\n[x](#über-uns)\n"}, [], ["MISSING_ANCHOR"])
    case("duplicate heading", {"README.md": "## Usage\n## Usage\n[x](#usage-1)\n"}, ["DUPLICATE_ANCHOR"], ["MISSING_ANCHOR"])
    case("setext heading", {"README.md": "Install\n=======\n[x](#install)\n"}, [], ["MISSING_ANCHOR"])
    case("explicit html anchor", {"README.md": '<a id="faq"></a>\n[x](#faq)\n'}, [], ["MISSING_ANCHOR"])
    case("attribute id anchor", {"README.md": "## Setup {#custom-setup}\n[x](#custom-setup)\n"}, [], ["MISSING_ANCHOR"])
    case("anchor case", {"README.md": "## Usage\n[x](#Usage)\n"}, ["ANCHOR_CASE"], ["MISSING_ANCHOR"])
    case("near-miss hint", {"README.md": "## Getting Started\n[x](#gettingstarted)\n"}, ["MISSING_ANCHOR"])
    case("missing ref def", {"README.md": "See [the guide][guide].\n"}, ["MISSING_REF_DEF"])
    case("unused ref def", {"README.md": "Text.\n\n[old]: https://example.com\n"}, ["UNUSED_REF_DEF"])
    case("used ref def shortcut", {"README.md": "See [guide].\n\n[guide]: setup.md\n", "setup.md": "# S\n"}, [], ["UNUSED_REF_DEF", "BROKEN_LINK"])
    case("ref def case-insensitive", {"README.md": "See [Guide][G].\n\n[g]: setup.md\n", "setup.md": "# S\n"}, [], ["MISSING_REF_DEF", "UNUSED_REF_DEF"])
    case("ref def broken target", {"README.md": "See [g][].\n\n[g]: nope.md\n"}, ["BROKEN_LINK"])
    case("duplicate ref def", {"README.md": "[a][x]\n\n[x]: https://a.example\n[x]: https://b.example\n"}, ["DUPLICATE_REF_DEF"])
    case("image without alt", {"README.md": "![](shot.png)\n", "shot.png": "png"}, ["MISSING_ALT"], ["BROKEN_IMAGE"])
    case("html img without alt", {"README.md": '<img src="shot.png" width="300">\n', "shot.png": "png"}, ["MISSING_ALT"])
    case("bare url", {"README.md": "Docs at https://example.com/docs today.\n"}, ["BARE_URL"])
    case("autolink is not bare", {"README.md": "Docs at <https://example.com/docs>.\n"}, [], ["BARE_URL"])
    case("code block ignored", {"README.md": "```\n[x](missing.md) https://x.example\n```\n"}, [], ["BROKEN_LINK", "BARE_URL"])
    case("inline code ignored", {"README.md": "Use `[x](missing.md)` syntax.\n"}, [], ["BROKEN_LINK"])
    case("case mismatch", {"README.md": "[c](CONTRIBUTING.MD)\n", "CONTRIBUTING.md": "# C\n"}, ["CASE_MISMATCH"], ["BROKEN_LINK"])
    case("local absolute path", {"README.md": "[x](/Users/ana/repo/docs/a.md)\n"}, ["LOCAL_ABSOLUTE_PATH"])
    case("empty target", {"README.md": "[todo]()\n"}, ["EMPTY_LINK"])
    case("url-encoded path", {"README.md": "[x](my%20notes.md)\n", "my notes.md": "# N\n"}, [], ["BROKEN_LINK"])
    case("angle-bracket path with spaces", {"README.md": "[x](<my notes.md>)\n", "my notes.md": "# N\n"}, [], ["BROKEN_LINK"])
    case("directory link ok", {"README.md": "[api](api/)\n", "api/index.md": "# API\n"}, [], ["BROKEN_LINK"])
    case("root-relative link", {"README.md": "[x](/guide/a.md)\n", "guide/a.md": "# A\n"}, [], ["BROKEN_LINK"])
    case("github line anchor", {"README.md": "[x](setup.md#L10)\n", "setup.md": "# S\n"}, [], ["MISSING_ANCHOR"])
    case("task list not a link", {"README.md": "- [ ] write docs\n- [x] done\n"}, [], ["MISSING_REF_DEF"])
    case("mailto ignored", {"README.md": "[mail](mailto:[email protected])\n"}, [], ["BROKEN_LINK"])
    case("nested subdir relative", {"README.md": "x", "guide/a.md": "[b](../b.md#top)\n", "b.md": "# Top\n"}, [], ["BROKEN_LINK", "MISSING_ANCHOR"], "guide/a.md")

    passed = 0
    for name, files, expect, absent, target in cases:
        with tempfile.TemporaryDirectory() as d:
            for rel, body in files.items():
                p = os.path.join(d, rel)
                os.makedirs(os.path.dirname(p), exist_ok=True)
                with open(p, "w", encoding="utf-8") as fh:
                    fh.write(body)
            _, found, _ = run([d], root=d)
            codes = {f["code"] for f in found if f["file"] == target}
            ok = all(c in codes for c in expect) and not any(c in codes for c in absent)
            if name == "clean file":
                ok = ok and not found
            if name == "near-miss hint":
                ok = ok and any("Did you mean #getting-started" in f["problem"] for f in found)
        passed += ok
        print(("PASS " if ok else "FAIL ") + name + ("" if ok else f" -> got {sorted(codes)}"))
    # external URL listing and report rendering
    with tempfile.TemporaryDirectory() as d:
        with open(os.path.join(d, "a.md"), "w") as fh:
            fh.write("[x](https://example.com/a) and [y](https://example.com/a)\n")
        files, found, ext = run([d])
        ok = list(ext) == ["https://example.com/a"] and len(ext["https://example.com/a"]) == 2
        text = report(files, found, ext, True, [d])
        ok = ok and "External URLs to check" in text and "No findings." in text
        passed += ok
        print(("PASS " if ok else "FAIL ") + "external URLs listed once with locations, no network")
    total = len(cases) + 1
    print(f"{passed}/{total} passed")
    return 0 if passed == total else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Offline Markdown link and anchor checker")
    ap.add_argument("paths", nargs="*")
    ap.add_argument("--root", help="docs root for /absolute links and the OUTSIDE_ROOT check (default: first folder)")
    ap.add_argument("--min-severity", default="LOW", choices=list(SEV_ORDER))
    ap.add_argument("--ignore", default="", help="comma-separated finding codes to skip")
    ap.add_argument("--external", action="store_true", help="list external URLs to check separately")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.paths:
        ap.print_usage(sys.stderr)
        return 2
    try:
        files, found, ext = run(a.paths, a.root, [c.strip() for c in a.ignore.split(",") if c.strip()], a.min_severity)
    except FileNotFoundError as e:
        print(f"error: path not found: {e}", file=sys.stderr)
        return 2
    if not files:
        print("error: no Markdown files found", file=sys.stderr)
        return 2
    if a.json:
        print(json.dumps({"files": len(files), "findings": found, "external": ext if a.external else {}}, indent=2, ensure_ascii=False))
    else:
        print(report(files, found, ext, a.external, a.paths))
    return 1 if any(f["severity"] == "HIGH" for f in found) else 0


if __name__ == "__main__":
    sys.exit(main())
source →

RELATED PROMPTS

COMMENTS

or sign in to comment under your account

No comments yet — be the first.