blob: 2d7aaaff4100356db346c28243a6931e18f1f9c0 [file]
#!/usr/bin/env python3
# Copyright 2026 The Emscripten Authors. All rights reserved.
# Emscripten is available under two separate licenses, the MIT license and the
# University of Illinois/NCSA Open Source License. Both these licenses can be
# found in the LICENSE file.
"""Verifies that changes to documentation files are visual/semantic no-ops.
Compares the rendered documentation output (HTML or text) of the current
working directory against a base git revision. For HTML output, it normalizes
whitespace in prose elements while preserving exact formatting in code blocks
and inline literals, eliminating false positives from line re-wrapping.
"""
import argparse
import difflib
import os
import shutil
import subprocess
import sys
from html.parser import HTMLParser
from pathlib import Path
script_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(os.path.dirname(script_dir))
class NormalizedHTMLParser(HTMLParser):
"""Parses HTML and normalizes whitespace outside of pre/code blocks."""
def __init__(self):
super().__init__()
self.lines = []
self.in_pre = 0
def handle_starttag(self, tag, attrs):
if tag in {'pre', 'code'}:
self.in_pre += 1
sorted_attrs = sorted((k, v) for k, v in attrs if k != 'href' or not v.startswith('#'))
attr_str = ' '.join(f'{k}="{v}"' if v is not None else k for k, v in sorted_attrs)
tag_str = f'<{tag} {attr_str}>' if attr_str else f'<{tag}>'
self.lines.append(tag_str)
def handle_endtag(self, tag):
if tag in {'pre', 'code'}:
self.in_pre -= 1
assert self.in_pre >= 0
self.lines.append(f'</{tag}>')
def handle_data(self, data):
if self.in_pre > 0:
self.lines.append(data)
else:
norm = ' '.join(data.split())
if norm:
self.lines.append(norm)
def normalize_html(html_content):
parser = NormalizedHTMLParser()
parser.feed(html_content)
return parser.lines
def run_command(cmd, **kwargs):
return subprocess.run(cmd, check=True, **kwargs)
def get_modified_rst_files(base_ref):
cmd = ['git', 'diff', '--name-only', base_ref, '--', 'site/source/*.rst', 'site/source/**/*.rst']
res = subprocess.run(cmd, stdout=subprocess.PIPE, text=True, check=True, cwd=root_dir)
files = [f.strip() for f in res.stdout.splitlines() if f.strip().endswith('.rst')]
return files
def rst_to_output_relpath(rst_file, builder_format):
rel = os.path.relpath(rst_file, os.path.join('site', 'source'))
ext = '.html' if builder_format == 'html' else '.txt'
return os.path.splitext(rel)[0] + ext
def build_docs(source_dir, output_dir, builder_format):
sphinx_build = os.environ.get('SPHINXBUILD', 'sphinx-build')
cmd = [sphinx_build, '-b', builder_format, '-q', source_dir, output_dir]
run_command(cmd)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--base', default='@{upstream}', help='Base git revision to compare against (default: @{upstream})')
parser.add_argument('--format', choices=['html', 'text'], default='html', help='Doc builder format to compare (default: html)')
parser.add_argument('files', nargs='*', help='Specific .rst files to check (default: all modified .rst files)')
args = parser.parse_args()
if args.files:
rst_files = [os.path.relpath(f, root_dir) for f in args.files]
else:
rst_files = get_modified_rst_files(args.base)
if not rst_files:
print(f'No modified .rst files found compared to {args.base}.')
return 0
print(f'Checking {len(rst_files)} documentation file(s) against {args.base} ({args.format} format)...')
out_dir = os.path.join(root_dir, 'out', 'check_docs')
curr_out = os.path.join(out_dir, 'curr')
base_out = os.path.join(out_dir, 'base')
base_worktree = os.path.join(out_dir, 'worktree')
if os.path.exists(base_worktree):
subprocess.run(['git', 'worktree', 'remove', '--force', base_worktree], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=root_dir)
if os.path.exists(out_dir):
shutil.rmtree(out_dir, ignore_errors=True)
os.makedirs(out_dir)
print('Building current working tree docs...')
build_docs(os.path.join(root_dir, 'site', 'source'), curr_out, args.format)
print(f'Checking out base revision ({args.base}) into worktree...')
run_command(['git', 'worktree', 'add', '-q', '--detach', base_worktree, args.base], cwd=root_dir)
print(f'Building base ({args.base}) docs...')
build_docs(os.path.join(base_worktree, 'site', 'source'), base_out, args.format)
has_diff = False
for rst_file in rst_files:
rel_out = rst_to_output_relpath(rst_file, args.format)
curr_file = os.path.join(curr_out, rel_out)
base_file = os.path.join(base_out, rel_out)
if not os.path.exists(curr_file) or not os.path.exists(base_file):
print(f'ERROR: Output file missing for {rst_file} ({rel_out})')
has_diff = True
continue
curr_content = Path(curr_file).read_text(encoding='utf-8')
base_content = Path(base_file).read_text(encoding='utf-8')
if args.format == 'html':
curr_lines = normalize_html(curr_content)
base_lines = normalize_html(base_content)
else:
curr_lines = curr_content.splitlines()
base_lines = base_content.splitlines()
if curr_lines != base_lines:
has_diff = True
print(f'\nFAIL: Rendered {args.format} output changed for {rst_file}:')
diff = difflib.unified_diff(
base_lines,
curr_lines,
fromfile=f'{args.base}:{rel_out}',
tofile=f'working_tree:{rel_out}',
lineterm='',
)
for line in diff:
print(line)
if os.path.exists(base_worktree):
subprocess.run(['git', 'worktree', 'remove', '--force', base_worktree], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=root_dir)
if os.path.exists(out_dir):
shutil.rmtree(out_dir, ignore_errors=True)
if has_diff:
print('\nDocumentation changes are NOT no-ops!')
return 1
print('\nSUCCESS: Rendered documentation output is identical (no-op).')
return 0
if __name__ == '__main__':
sys.exit(main())