docs: three dangling links, and a gate so they cannot accumulate
The vault is the project's source of truth, so a link to a page that does not
exist is a promise the repo cannot keep. Three were dangling:
- Dankclaw/Gitea.md pointed twice at dankclaw/compose.yaml — **which this
session retired**. The clean cutover obligation from my own change: the page
now describes the unit that publishes 3000 and the canonical Caddy unit, and
records why the compose file is gone.
- Dankclaw/Hermes-Dashboard.md linked [[LAN DNS]]; the page is [[LAN-DNS]].
- LLM/frank-medium-radiance-mxfp4.md linked [[Observability on Dankclaw]]; the
page is [[Observability]].
scripts/check-vault-links.py + a seam keep it that way. The checker models
Obsidian's rules, which took three corrections to get right — and each was a
false positive that would have sent me editing healthy docs:
1. Wikilinks are case-insensitive. A case-sensitive matcher reported 45
breaks; 39 were `[[qBittorrent]]` vs qbittorrent.md.
2. `[[folder/Note]]` resolves by path suffix, not by stem.
3. `%20` in a markdown link is URL-encoding, not a filename.
Validating the linter against the resolver it models is the prerequisite for
trusting any of its output — the same discipline that caught a bad assertion of
my own earlier tonight.
This commit is contained in:
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Report unresolved links in the docs vault (serverAppConfigs-docs).
|
||||
|
||||
The vault is the project's source of truth, so a link to a page that does not
|
||||
exist is a promise the repo cannot keep. This models **Obsidian's** resolution
|
||||
rules rather than a naive one — three separate false-positive classes were found
|
||||
while writing it, and a linter that cries wolf is worse than no linter:
|
||||
|
||||
1. Wikilinks are **case-insensitive** (`[[qBittorrent]]` -> `qbittorrent.md`).
|
||||
A case-sensitive matcher reported 45 breaks; 39 were this.
|
||||
2. A path-qualified wikilink (`[[folder/Note]]`) resolves by **path suffix**,
|
||||
not by stem, and must not be stem-matched. Treating the whole string as a
|
||||
stem reported the PP pages as missing when they exist one directory down.
|
||||
3. `%20` in a markdown link is URL-encoding. Dealing in raw text reported
|
||||
`Network%20Architecture.md` as missing beside the real file.
|
||||
|
||||
Exit status is 1 when any link is unresolved, so it gates a test.
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
WIKI = re.compile(r"\[\[([^\]\|#]+)(?:#[^\]\|]*)?(?:\|[^\]]*)?\]\]")
|
||||
MD = re.compile(r"(?<!\!)\[[^\]]*\]\(([^)]+)\)")
|
||||
EXTERNAL = ("http://", "https://", "mailto:", "#", "~")
|
||||
|
||||
|
||||
def strip_md(path: str) -> str:
|
||||
return path[:-3] if path.endswith(".md") else path
|
||||
|
||||
|
||||
def build_index(vault: pathlib.Path):
|
||||
pages = sorted(vault.rglob("*.md"))
|
||||
stems: dict[str, list[pathlib.Path]] = {}
|
||||
for page in pages:
|
||||
stems.setdefault(page.stem.casefold(), []).append(page)
|
||||
relative = [strip_md(str(p.relative_to(vault)).casefold()) for p in pages]
|
||||
return pages, stems, relative
|
||||
|
||||
|
||||
def wiki_resolves(target: str, stems, relative) -> bool:
|
||||
want = target.strip().rstrip("\\").casefold()
|
||||
if "/" in want:
|
||||
return any(r == want or r.endswith("/" + want) for r in relative)
|
||||
return want in stems
|
||||
|
||||
|
||||
def md_resolves(base: pathlib.Path, target: str) -> bool:
|
||||
text = urllib.parse.unquote(target.strip())
|
||||
if text.startswith(EXTERNAL):
|
||||
return True
|
||||
text = text.split("#")[0].strip()
|
||||
return not text or (base / text).exists()
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
vault = pathlib.Path(argv[1] if len(argv) > 1 else "serverAppConfigs-docs")
|
||||
pages, stems, relative = build_index(vault)
|
||||
broken: list[str] = []
|
||||
for page in pages:
|
||||
text = page.read_text(errors="replace")
|
||||
for target in WIKI.findall(text):
|
||||
if not wiki_resolves(target, stems, relative):
|
||||
broken.append(f"[wiki] {page.relative_to(vault)} -> {target.strip()}")
|
||||
for target in MD.findall(text):
|
||||
if not md_resolves(page.parent, target):
|
||||
broken.append(f"[md] {page.relative_to(vault)} -> {target.strip()}")
|
||||
print(f"{len(pages)} pages, {len(broken)} unresolved links")
|
||||
for line in broken:
|
||||
print(" " + line)
|
||||
return 1 if broken else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
Reference in New Issue
Block a user