106 lines
4.6 KiB
Python
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()
|