tasty-bdd-0.1.0.2: tools/embed-haddock.py
"""Embed the checked Hackage rendering, adapting only its hosting links."""
import argparse
import html
from functools import cache
from html.parser import HTMLParser
import pathlib
import re
import tarfile
from urllib.parse import unquote, urljoin, urlsplit
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('archive', type=pathlib.Path)
parser.add_argument('destination', type=pathlib.Path)
parser.add_argument('--check', action='store_true')
args = parser.parse_args()
rendered = {}
with tarfile.open(args.archive) as archive:
for member in archive.getmembers():
if not member.isfile():
continue
parts = pathlib.PurePosixPath(member.name).parts
assert len(parts) > 1 and '..' not in parts and not member.name.startswith('/')
relative = pathlib.Path(*parts[1:])
# Haddock includes extra-doc-files; MkDocs owns those Markdown pages.
if relative.suffix == '.md':
continue
data = archive.extractfile(member).read()
if relative.suffix == '.html':
text = data.decode()
package = parts[0].removesuffix('-docs')
text = text.replace(f'href="/package/{package}"', 'href="index.html"')
text = text.replace('href="/package/', 'href="https://hackage.haskell.org/package/')
# Haddock emits orphan-instance self-links without target IDs.
anchors = set(re.findall(r'\bid="([^"]+)"', text))
for fragment in re.findall(r'<a href="#([^"]+)" class="selflink">', text):
if not {fragment, unquote(fragment)} & anchors:
old = f'<a href="#{fragment}" class="selflink">'
text = text.replace(old, f'<a id="{fragment}" href="#{fragment}" class="selflink">', 1)
anchors.add(fragment)
for fragment in re.findall(r'<p class="src"><a href="#([^"]+)">', text):
if not {fragment, unquote(fragment)} & anchors:
old = f'<p class="src"><a href="#{fragment}">'
text = text.replace(old, f'<p class="src"><a id="{fragment}" href="#{fragment}">', 1)
anchors.add(fragment)
data = text.encode()
rendered[relative] = data
modules = sorted(p for p in rendered if len(p.parts) == 1 and re.fullmatch(r'[A-Z][\w-]+\.html', p.name))
assert len(modules) == 4, 'All four public module renderings must be present'
assert pathlib.Path('doc-index.html') in rendered
links = ''.join(f'<li><a href="{p.name}">{html.escape(p.stem.replace("-", "."))}</a></li>' for p in modules)
rendered[pathlib.Path('index.html')] = (
'<!doctype html><html lang="en"><meta charset="utf-8">'
'<title>tasty-bdd API</title><link rel="stylesheet" href="linuwial.css">'
f'<body><h1>{html.escape(package)} API</h1><ul>{links}</ul>'
'<p><a href="doc-index.html">Symbol index</a> · '
'<a href="../api/" target="_top">Documentation</a></p></body></html>'
).encode()
for relative, expected in rendered.items():
destination = args.destination / relative
if args.check:
assert destination.is_file(), f'Missing embedded Haddock: {relative}'
assert destination.read_bytes() == expected, f'Stale embedded Haddock: {relative}'
else:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(expected)
if args.check:
class Links(HTMLParser):
def __init__(self, text):
super().__init__()
self.links, self.anchors = [], set()
self.feed(text)
def handle_starttag(self, tag, attrs):
for key, value in attrs:
if key in ('href', 'src') and value:
self.links.append(value)
if key == 'id' and value:
self.anchors.add(value)
@cache
def document(path):
return Links(path.read_text())
for relative, data in rendered.items():
if relative.suffix != '.html':
continue
for link in Links(data.decode()).links:
target = urlsplit(urljoin('https://docs.invalid/haddock/' + relative.as_posix(), link))
if target.scheme not in ('http', 'https') or target.netloc != 'docs.invalid':
continue
path = args.destination.parent / unquote(target.path).lstrip('/')
if target.path.endswith('/'):
path /= 'index.html'
assert path.is_file(), f'Broken API link in {relative}: {link}'
if target.fragment and path.suffix == '.html':
anchors = document(path).anchors
assert {target.fragment, unquote(target.fragment)} & anchors, f'Broken API anchor in {relative}: {link}'
print(f'{"Verified" if args.check else "Embedded"} {len(rendered)} Haddock files from {args.archive.name}')