Role from Awesome ChatGPT Prompts (
prompts.csv, CC0-1.0). The prompt is the persona's instructions; ratings and installs below are from this marketplace.
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 linksreferences/finding-codes.md- every finding code, its severity, why it matters and the usual fix, plus how to check external URLs politelytemplates/link-report.md- the report to hand backexamples/README.md- a small project README with deliberate link problemsexamples/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)
- Take the heading text as rendered: inline markup removed (
codekeeps its text,**bold**becomes bold,[text](url)becomes text, HTML tags dropped). - Lower-case it.
- Remove every character that is not a letter, digit, space, hyphen or underscore (letters include accented and non-Latin letters; emoji and punctuation go).
- Turn each space into a hyphen. Spaces are NOT collapsed: "C++ & Rust" becomes
c--rust. - 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. Checkmarkdown_extensions: toc: slugifyin mkdocs.yml. - Docusaurus: GitHub-like slugs; supports
## Heading {#custom-id}. - Hugo (goldmark): GitHub-like by default;
autoHeadingIDTypecan begithub,github-asciiorblackfriday. - 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
#usagealways 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_ANCHORfor 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:
,<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.mdresolves 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:
--externallists 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: ,
Scope: <docs/ and README.md, 42 files> Anchor rules: Root for /links:
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>
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 |
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.
Contents
Quick start
- Install the app (see the setup guide).
- Sign in and pick a team folder.
- Read the release notes 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 and the style guide.
Faq
Can I sync offline? Notes queue until the phone is online again.
FILE:examples/setup-guide.md
Setup guide
Install
Download the installer from the releases page or build from source (see Building).
Settings screen with the Team folder field highlighted
Configure the team folder
Open Settings > Team folder and paste the folder link. The folder must already be shared with you (see Sharing).
Building from source
Clone the repository and run make build, then continue with the
Quick start.
Troubleshooting
If sync stalls, check the log at /Users/ana/tidepool/logs/sync.log or ask in the [support channel][support].
Back to the README. 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 , , 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": "\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": "\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:team@example.com)\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())
