- scripts/check-wiki-links.py: validates all [[...]] links source-relative (matching Forgejo behavior)
- scripts/fix-wiki-links.py: auto-rewrites broken links as source-relative paths
- scripts/.check-wiki-links-wrapper.sh: pre-commit hook wrapper
- .git/hooks/pre-commit: installs wrapper as Git hook
- Fixed 47 wiki files with broken links (mostly path-confusion and wiki/-prefix bugs)
- Total: 691 valid wiki links, 0 broken, 0 path confusion
- Manual fix: Wikipedia: Sleeper agent link → proper Markdown URL
- Manual fix: non-existent decision link → decisions-index
- Code-span and template-placeholder handling (ignores `[[example]]` and `[[{slug}]]`)
Trigger: k9ert noted https://.../concepts/llm/concepts/llm/... was 404 in wiki-catalog
194 lines
No EOL
6.6 KiB
Python
194 lines
No EOL
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Fix broken wiki links in knowledge-base repo by rewriting them as proper
|
|
source-relative paths (the format Forgejo/Gitea Markdown rendering expects).
|
|
|
|
Bug classes handled:
|
|
A. [[wiki/...]] → strip 'wiki/' prefix, then resolve source-relative
|
|
B. [[concepts/...]] from wrong directory → resolve to ../concepts/...
|
|
C. [[concepts/...]] from same directory → resolve to basename
|
|
D. [[plain-name.md]] from wrong directory → resolve to ../.../plain-name.md
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
WIKI_DIR = REPO_ROOT / "wiki"
|
|
LINK_RE = re.compile(r'\[\[([^\]|]+)(?:\|([^\]]+))?\]\]')
|
|
|
|
|
|
def find_wiki_files():
|
|
files = []
|
|
for root, dirs, filenames in os.walk(WIKI_DIR):
|
|
for f in filenames:
|
|
if f.endswith('.md'):
|
|
files.append(Path(root) / f)
|
|
return sorted(files)
|
|
|
|
|
|
def find_target(link_path: str):
|
|
"""
|
|
Given a link path as written, find the actual target file.
|
|
Supports wiki/, raw/, and root-relative interpretations.
|
|
Returns absolute Path to the target file, or None if not found.
|
|
"""
|
|
basename = Path(link_path).name
|
|
if not basename.endswith('.md'):
|
|
basename_candidate = basename + '.md'
|
|
else:
|
|
basename_candidate = basename
|
|
|
|
candidates = []
|
|
|
|
# Interpretation 1: path starts with "wiki/" — strip and look in WIKI_DIR
|
|
if link_path.startswith('wiki/'):
|
|
stripped = link_path[5:]
|
|
if not stripped.endswith('.md'):
|
|
stripped += '.md'
|
|
target = WIKI_DIR / stripped
|
|
if target.exists():
|
|
candidates.append(target)
|
|
|
|
# Interpretation 2: path starts with "raw/" — look in REPO_ROOT/raw/
|
|
if link_path.startswith('raw/'):
|
|
target = REPO_ROOT / link_path
|
|
if not target.exists() and not link_path.endswith('.md'):
|
|
target = REPO_ROOT / (link_path + '.md')
|
|
if target.exists():
|
|
candidates.append(target)
|
|
|
|
# Interpretation 3: path contains "/" — interpret as root-relative (but skip if starts with ../)
|
|
if '/' in link_path and not link_path.startswith('../'):
|
|
# Try as wiki/-root-relative
|
|
if not link_path.endswith('.md'):
|
|
root_relative = WIKI_DIR / (link_path + '.md')
|
|
else:
|
|
root_relative = WIKI_DIR / link_path
|
|
candidates.append(root_relative)
|
|
# Also try as repo-root-relative
|
|
if not link_path.endswith('.md'):
|
|
candidates.append(REPO_ROOT / (link_path + '.md'))
|
|
else:
|
|
candidates.append(REPO_ROOT / link_path)
|
|
|
|
# Interpretation 4: plain basename — find by basename
|
|
if '/' not in link_path:
|
|
matches = [m for m in WIKI_DIR.rglob(basename_candidate) if 'test-fixtures' not in str(m)]
|
|
# Also check raw/
|
|
matches += [m for m in (REPO_ROOT / 'raw').rglob(basename_candidate) if 'test-fixtures' not in str(m)]
|
|
matches += [m for m in REPO_ROOT.rglob(basename_candidate) if 'test-fixtures' not in str(m)]
|
|
if len(matches) == 1:
|
|
candidates.append(matches[0])
|
|
elif len(matches) > 1:
|
|
non_fixture = [m for m in matches if 'test-fixtures' not in str(m)]
|
|
if len(non_fixture) == 1:
|
|
candidates.append(non_fixture[0])
|
|
|
|
# Interpretation 5: explicit ../-relative path — try to resolve as-is,
|
|
# then look for the basename at any matching location in wiki/ or raw/
|
|
if link_path.startswith('../'):
|
|
# Find the actual basename (last component, with .md suffix if needed)
|
|
path_parts = Path(link_path).parts
|
|
search_basename = path_parts[-1]
|
|
if not search_basename.endswith('.md'):
|
|
search_basename += '.md'
|
|
# Find all files with this name in wiki/
|
|
matches = [m for m in WIKI_DIR.rglob(search_basename) if 'test-fixtures' not in str(m)]
|
|
if len(matches) == 1:
|
|
candidates.append(matches[0])
|
|
elif len(matches) > 1:
|
|
non_fixture = [m for m in matches if 'test-fixtures' not in str(m)]
|
|
if len(non_fixture) == 1:
|
|
candidates.append(non_fixture[0])
|
|
|
|
# Try each candidate
|
|
for c in candidates:
|
|
if c.exists() and 'test-fixtures' not in str(c):
|
|
return c
|
|
|
|
return None
|
|
|
|
|
|
def make_source_relative(from_file: Path, to_file: Path) -> str:
|
|
"""Make a relative path from from_file's directory to to_file."""
|
|
rel = os.path.relpath(to_file, start=from_file.parent)
|
|
return rel
|
|
|
|
|
|
def fix_link(link_path: str, source_file: Path) -> str:
|
|
"""Fix a broken wiki link. Returns the corrected link path."""
|
|
if link_path.startswith('http'):
|
|
return link_path
|
|
|
|
# Strip anchor
|
|
anchor = ''
|
|
if '#' in link_path:
|
|
link_path, anchor = link_path.split('#', 1)
|
|
anchor = '#' + anchor
|
|
|
|
# Already valid? (source-relative resolves correctly)
|
|
target = (source_file.parent / link_path).resolve()
|
|
if target.exists():
|
|
return link_path + anchor
|
|
|
|
# Try with .md appended
|
|
if not link_path.endswith('.md'):
|
|
target = (source_file.parent / (link_path + '.md')).resolve()
|
|
if target.exists():
|
|
return link_path + '.md' + anchor
|
|
|
|
# Find the actual target file by interpreting the link in different ways
|
|
actual_target = find_target(link_path)
|
|
if actual_target and actual_target != source_file:
|
|
corrected = make_source_relative(source_file, actual_target)
|
|
return corrected + anchor
|
|
|
|
return link_path + anchor # no fix found
|
|
|
|
|
|
def process_file(source_file: Path, dry_run=False):
|
|
content = source_file.read_text(encoding='utf-8')
|
|
|
|
def repl(match):
|
|
link_path = match.group(1).strip()
|
|
label = match.group(2)
|
|
fixed = fix_link(link_path, source_file)
|
|
if fixed != link_path:
|
|
if label:
|
|
return f'[[{fixed}|{label}]]'
|
|
return f'[[{fixed}]]'
|
|
return match.group(0)
|
|
|
|
new_content = LINK_RE.sub(repl, content)
|
|
|
|
if new_content != content:
|
|
if not dry_run:
|
|
source_file.write_text(new_content, encoding='utf-8')
|
|
return True
|
|
return False
|
|
|
|
|
|
def main():
|
|
dry_run = '--dry-run' in sys.argv
|
|
files = find_wiki_files()
|
|
fixed_count = 0
|
|
fixed_files = []
|
|
|
|
for f in files:
|
|
if process_file(f, dry_run=dry_run):
|
|
fixed_count += 1
|
|
fixed_files.append(f.relative_to(REPO_ROOT))
|
|
|
|
print(f"{'Would fix' if dry_run else 'Fixed'}: {fixed_count} file(s)")
|
|
if fixed_files:
|
|
for rel in fixed_files:
|
|
print(f" - {rel}")
|
|
if dry_run and fixed_count > 0:
|
|
print("\nRun without --dry-run to apply changes.")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main() |