Files
2026-09-24 13:54:28 +08:00

106 lines
4.6 KiB
Python

#!/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]*?(?<!`)\1(?!`)', '', prose(text))
return [m[1].strip('<>') 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()