diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..df01064 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + lint: + runs-on: linux + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Markdown lint + run: | + podman run --rm \ + -v "${{ github.workspace }}:/work:Z" \ + docker.io/davidanson/markdownlint-cli2:v0.17.2 \ + "**/*.md" + + link-check: + runs-on: linux + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check internal links + run: | + podman run --rm \ + -v "${{ github.workspace }}:/work:Z" \ + -w /work \ + docker.io/library/python:3.12-slim \ + python3 scripts/check-links.py diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml new file mode 100644 index 0000000..7f90aed --- /dev/null +++ b/.markdownlint-cli2.yaml @@ -0,0 +1,29 @@ +config: + # Allow long lines in tables and code blocks + MD013: + line_length: 200 + tables: false + code_blocks: false + + # Allow duplicate headings across files (common in howtos) + MD024: + siblings_only: true + + # Allow trailing punctuation in headings (e.g. "~/.ssh/config") + MD026: false + + # Allow inline HTML (rare but sometimes needed) + MD033: false + + # Allow bare URLs in markdown + MD034: false + + # Allow multiple blank lines + MD012: false + +globs: + - "**/*.md" + +ignores: + - ".git" + - ".venv" diff --git a/scripts/check-links.py b/scripts/check-links.py new file mode 100644 index 0000000..2cffc5c --- /dev/null +++ b/scripts/check-links.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Check internal cross-references between topic files. + +Scans all .md files for See Also links to other topics (backtick-quoted names) +and verifies that matching files exist under topics/. + +Exit codes: + 0 — all links valid + 1 — broken links found +""" + +import re +import sys +from pathlib import Path + +TOPICS_DIR = Path("topics") +DOCS_DIR = Path("docs") +SEARCH_DIRS = [Path("."), TOPICS_DIR, DOCS_DIR] +EXCLUDE = {"docs/TEMPLATE.md"} + + +def find_md_files() -> list[Path]: + files = [] + for d in SEARCH_DIRS: + if d.exists(): + files.extend(d.glob("*.md")) + return sorted(set(files)) + + +def extract_topic_refs(path: Path) -> list[tuple[int, str]]: + """Extract backtick-quoted topic references from See Also sections.""" + refs = [] + in_see_also = False + for lineno, line in enumerate(path.read_text().splitlines(), start=1): + if re.match(r"^##\s+See Also", line, re.IGNORECASE): + in_see_also = True + continue + if in_see_also and re.match(r"^##\s+", line): + break + if in_see_also: + for match in re.finditer(r"`([a-z][a-z0-9-]*)`", line): + refs.append((lineno, match.group(1))) + return refs + + +def check_links() -> int: + md_files = find_md_files() + topic_names = {p.stem for p in TOPICS_DIR.glob("*.md")} if TOPICS_DIR.exists() else set() + + errors = 0 + for path in [p for p in md_files if str(p) not in EXCLUDE]: + for lineno, ref in extract_topic_refs(path): + if ref not in topic_names: + print(f" {path}:{lineno} broken ref `{ref}` — no topics/{ref}.md") + errors += 1 + + if errors: + print(f"\n {errors} broken link(s) found") + return 1 + + print(f" {len(md_files)} files checked, all internal links valid") + return 0 + + +if __name__ == "__main__": + sys.exit(check_links())