Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/docs-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Docs check

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Validate markdown docs (links, tables)
run: python3 scripts/check_docs.py
100 changes: 100 additions & 0 deletions scripts/check_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Validate the project's markdown docs: link integrity and table shape.

Checks every README.md / docs/*.md file for:
- balanced [] and () (catches broken/truncated markdown links)
- relative links that resolve to an existing file
- markdown tables where every row has the same column count as the header

Exits non-zero if any file has issues, printing a report to stdout.
"""
import os
import re
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def find_docs():
docs = [os.path.join(ROOT, "README.md")]
docs_dir = os.path.join(ROOT, "docs")
if os.path.isdir(docs_dir):
for name in sorted(os.listdir(docs_dir)):
if name.endswith(".md"):
docs.append(os.path.join(docs_dir, name))
return [d for d in docs if os.path.isfile(d)]


def check_brackets(text):
issues = []
if text.count("[") != text.count("]"):
issues.append(f"bracket mismatch: {text.count('[')} [ vs {text.count(']')} ]")
if text.count("(") != text.count(")"):
issues.append(f"paren mismatch: {text.count('(')} ( vs {text.count(')')} )")
return issues


def check_links(path, text):
issues = []
base = os.path.dirname(path)
for m in re.finditer(r"\[[^\]]*\]\(([^)]+)\)", text):
target = m.group(1).strip()
if target.startswith("http://") or target.startswith("https://") or target.startswith("#") or target.startswith("mailto:"):
continue
target_path = target.split("#", 1)[0]
resolved = os.path.normpath(os.path.join(base, target_path))
if not os.path.exists(resolved):
issues.append(f"broken link: {target} -> {os.path.relpath(resolved, ROOT)}")
return issues


def check_tables(text):
issues = []
lines = text.split("\n")
i = 0
while i < len(lines):
line = lines[i]
if line.strip().startswith("|"):
header_cols = line.count("|")
block_start = i
j = i + 1
row_num = 1
while j < len(lines) and lines[j].strip().startswith("|"):
cols = lines[j].count("|")
if cols != header_cols:
issues.append(
f"table column mismatch at line {j + 1} (row {row_num} of block starting line {block_start + 1}): "
f"expected {header_cols} pipes, got {cols}"
)
row_num += 1
j += 1
i = j
else:
i += 1
return issues


def main():
docs = find_docs()
if not docs:
print("No markdown docs found.")
return 0

had_issues = False
for path in docs:
rel = os.path.relpath(path, ROOT)
text = open(path, encoding="utf-8").read()
issues = check_brackets(text) + check_links(path, text) + check_tables(text)
if issues:
had_issues = True
print(f"FAIL {rel}")
for issue in issues:
print(f" - {issue}")
else:
print(f"OK {rel}")

return 1 if had_issues else 0


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