A CI gate for the markdown twin of your docs site.
Many docs sites now publish a machine edition alongside the HTML: a .md twin
for every page and an llms.txt index, so AI agents can read the docs as plain
text. The twin is generated from the same source as the HTML, and usually
nothing reads it in CI. When the generator drops something, the HTML stays
perfect and the text edition silently loses content. No person opens the .md
files. The only reader who hits the bug is a machine, and machines do not file
bug reports.
mdgate crawls your llms.txt and asserts three things:
- No empty blocks. A code fence or multilang block that renders five
install commands in HTML should not come out as six blank lines in the
.md. - No unresolved values. If the
.mdsays "created with up to key columns" and the rendered page says "up to 4 key columns", a value component was stripped. mdgate finds the gap, then confirms it against the rendered page and reports the value the HTML shows. - The text path of every internal doc link resolves. mdgate checks the
.mdside of every internal link and, when it fails, fetches the page side to name the exact failure. The sharpest case: redirects live in the web app's route layer, and static.mdfiles never see them. A person following a stale link gets redirected; an agent following the same link with.mdgets a 404, even when the content exists at the new path.
Exit codes: 0 clean, 1 findings, 2 the index itself could not be read. Transient trouble (network errors, 429, 5xx) is reported as a warning, and warnings never fail the build. Only a 404 or 410 proves a twin is missing.
No dependencies. Python 3.10+.
python3 mdgate.py https://example.com
python3 mdgate.py https://example.com --index /llms.txt --delay 0.15 --json
--max-pages caps the scan (default 200 indexed pages).
Example output:
https://example.com/docs/index.md
x line 63 broken-link https://example.com/docs/old-name.md is 404. A person is redirected https://example.com/docs/old-name -> https://example.com/docs/new-name and https://example.com/docs/new-name.md exists. The redirect lives only in the HTML route layer; the text path dead-ends
https://example.com/docs/quickstart.md
x line 26 empty-block empty multilang block
https://example.com/docs/write.md
x line 616 unresolved-value gap in 'An index can be created with up to key columns.'; the rendered page shows '4' here
41 pages scanned, 86 links checked: 3 failures, 0 warnings
# .github/workflows/docs-text-path.yml
name: docs text path
on:
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
jobs:
mdgate:
runs-on: ubuntu-latest
steps:
- run: curl -fsSLO https://raw.githubusercontent.com/yylerbrown/mdgate/main/mdgate.py
- run: python3 mdgate.py https://your-site.comA scheduled run like this catches a lossy conversion within a day. To block
the merge that introduces one, run the same command against your deploy
preview URL in the docs repo's pull-request pipeline (or vendor mdgate.py
into that repo).
python3 -m unittest test_mdgate -v
- Anchors (
#fragment) are not checked. - Only inline
[text](target)links are checked. Reference-style links, autolinks, and raw HTML<a>tags are skipped. Links inside code blocks are deliberately ignored, and so are images and other assets (.png,.json, ...), which have no.mdtwin. - The unresolved-value check is a heuristic (mid-sentence double spaces, confirmed against the rendered page). A stripped value that leaves no gap at all is invisible to it.
- The multilang half of check 1 matches the
<!-- multilang -->comment convention. Sites that mark tabbed code groups differently need a one-line pattern tweak. - Only links under the indexed docs prefixes are checked, and only on the same host.
- Requests are rate limited (
--delay, default 0.15s) and sent with an identifying user agent. Be polite: run it against your own site, or keep the delay generous.
MIT