#!/usr/bin/env python3 """Validate the authoritative Gitea wiki without generating or publishing files.""" from html import unescape from pathlib import Path import re import sys from urllib.parse import unquote, urlsplit PROJECT = Path(__file__).resolve().parents[2] WIKI = PROJECT / 'fds-os.wiki' SOURCE_URL = 'http://gitea.home.arpa/felis/fds-os/src/branch/main/' WIKI_URL = 'http://gitea.home.arpa/felis/fds-os/wiki/' FENCE = re.compile(r'^ {0,3}(`{3,}|~{3,})(.*)$') LINK = re.compile(r'!?\[[^\]]*\]\((<[^>\n]+>|[^\s)]+)(?:\s+["\'][^\n]*?["\'])?\)') REFERENCE = re.compile(r'^ {0,3}\[[^\]\n]+\]:[ \t]*(<[^>\n]+>|\S+)', re.M) def prose(text): lines, fence = [], None for line in text.splitlines(keepends=True): found = FENCE.match(line) if fence: if found and found[1][0] == fence[0] and len(found[1]) >= len(fence) and not found[2].strip(): fence = None continue if found: fence = found[1] elif not line.startswith((' ', '\t')): lines.append(line) return ''.join(lines) def anchors(text): result, counts = set(), {} for heading in re.findall(r'^#{1,6}\s+(.+?)\s*#*$', prose(text), re.M): heading = re.sub(r'\[([^\]]+)\]\([^)]*\)', r'\1', heading) heading = re.sub(r'<[^>]*>', '', heading) slug = re.sub(r'[^\w\- ]', '', unescape(heading).lower()).replace(' ', '-') count = counts.get(slug, 0) counts[slug] = count + 1 result.add(slug + (f'-{count}' if count else '')) return result def links(text): text = re.sub(r'(`+)(?!`)[^\n]*?(?') for pattern in (LINK, REFERENCE) for m in pattern.finditer(text)] def main(): if not (WIKI / 'Home.md').is_file(): sys.exit('ERROR: missing wiki; run git submodule update --init fds-os.wiki') pages = {p.stem: p for p in WIKI.glob('*.md')} errors = [] require = lambda condition, message: errors.append(message) if not condition else None require(len({name.casefold() for name in pages}) == len(pages), 'Page names collide ignoring case') for name in ('Home', 'Overview', 'Page-Index', '_Sidebar', '_Footer'): require(name in pages, f'Missing required page: {name}.md') for name, page in pages.items(): require(bool(re.fullmatch(r'(?:[A-Z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*|_Sidebar|_Footer)', name)), f'Invalid page name: {page.name}') if not name.startswith('_'): require(page.read_text().startswith('---\ninclude_toc: true\n---\n'), f'{page.name}: missing table-of-contents frontmatter') page_anchors = {name: anchors(page.read_text()) for name, page in pages.items()} index_links = set(links(pages['Page-Index'].read_text())) if 'Page-Index' in pages else set() for name in pages.keys() - {'Page-Index', '_Sidebar', '_Footer'}: require(name in index_links, f'{name}.md is missing from Page-Index.md') checked = 0 for page in [*pages.values(), PROJECT / 'README.md']: for target in links(page.read_text()): url = urlsplit(target) if target.startswith(SOURCE_URL): relative = unquote(url.path.removeprefix(urlsplit(SOURCE_URL).path)) path = (PROJECT / relative).resolve() require(path.is_relative_to(PROJECT) and path.is_file(), f'{page.name}: missing source file: {target}') checked += 1 continue if target.startswith(WIKI_URL): url = urlsplit(target[len(WIKI_URL):]) elif url.scheme or url.netloc: continue name = unquote(url.path) if name: require('/' not in name and name != '..', f'{page.name}: non-flat wiki link: {target}') if Path(name).suffix: require(Path(name).suffix != '.md', f'{page.name}: wiki page links must omit .md: {target}') require((WIKI / name).is_file(), f'{page.name}: missing asset: {target}') checked += 1 continue else: name = page.stem require(name in pages, f'{page.name}: missing wiki page: {target}') if url.fragment: require(unquote(url.fragment) in page_anchors.get(name, set()), f'{page.name}: missing section: {target}') checked += 1 if errors: sys.exit('\n'.join('ERROR: ' + message for message in errors)) print(f'PASS: {len(pages)} wiki pages; {checked} page, section, asset and source links') if __name__ == '__main__': main()