#!/usr/bin/env python3 """ Sync MANUAL.md from readme.html. readme.html is the canonical user manual — bundled inside RemSound and opened by F1. MANUAL.md is the GitHub-facing Markdown rendition shown on the repository page. This script reads readme.html, converts it to Markdown via html2text, and post-processes the output to fix two things html2text doesn't do well on this document: * Table of contents links. The HTML uses custom anchors like `#what-it-does` which html2text strips. We rebuild the TOC after conversion using GitHub's auto-generated heading anchors (lowercased, punctuation stripped, spaces → hyphens). * Cosmetic backslash escapes before periods after numbers in headings (`## 1\. ...` instead of `## 1. ...`). GitHub renders both the same but the un-escaped version looks cleaner in raw source. The script is invoked automatically by build-release.ps1 as the first step before packaging a release, so MANUAL.md can never get out of sync with the bundled help. It can also be run by hand from the repo root: `python sync-manual.py`. """ from __future__ import annotations import html2text import re import sys from pathlib import Path REPO_ROOT = Path(__file__).parent README_HTML = REPO_ROOT / "readme.html" MANUAL_MD = REPO_ROOT / "MANUAL.md" def github_anchor(heading_text: str) -> str: """Replicate GitHub's auto-anchor rule for a Markdown heading. Rules (per https://gist.github.com/asabaylus/3071099): * Lowercase everything. * Replace spaces with hyphens. * Strip all punctuation EXCEPT hyphens and underscores. * Collapse runs of hyphens (though GitHub does NOT — it keeps them). """ s = heading_text.strip().lower() # Replace whitespace with hyphens s = re.sub(r"\s+", "-", s) # Strip punctuation except hyphens and underscores s = re.sub(r"[^\w\-]", "", s) return s def convert_html_to_markdown(html: str) -> str: # Strip entirely — none of its content belongs in the Markdown html = re.sub(r".*?", "", html, flags=re.DOTALL) # Render every
callout (note / warn / etc.) as a blockquote — # Markdown's native callout. The wildcard catches all classes in one rule so a new # callout class added later doesn't silently break the structure (which it did for # the original `
`-only rule when `class="warn"` was introduced). html = re.sub(r'', "
", html) html = re.sub(r"
", "", html) h = html2text.HTML2Text() h.body_width = 0 # never hard-wrap; let the rendering layer reflow h.ignore_links = False h.unicode_snob = True h.use_automatic_links = True h.bypass_tables = False h.escape_snob = False md = h.handle(html) # Replace any leftover U+FFFD with the em-dash that almost always belongs there md = md.replace("�", "—") # Strip cosmetic backslash escapes before periods after numbers in headings md = re.sub( r"^(##+ \d+)\\\.", lambda m: m.group(1) + ".", md, flags=re.MULTILINE, ) # Trim trailing whitespace on every line md = "\n".join(line.rstrip() for line in md.splitlines()) + "\n" return md def rebuild_toc(md: str) -> str: """Rebuild the Table of contents section using GitHub-style anchor links. The HTML version of readme.html uses a hand-written `
    ` with `` items where `id` matches the `id=""` attribute on later `

    ` elements. html2text drops those links entirely on conversion. Here we regenerate the list from the actual `## ` headings in the post-conversion Markdown, so the TOC always reflects the real document. """ # Collect all top-level (##) headings in document order, skipping the TOC heading itself headings: list[str] = [] for line in md.splitlines(): m = re.match(r"^##\s+(.+?)\s*$", line) if not m: continue text = m.group(1).strip() if text.lower() == "table of contents": continue headings.append(text) if not headings: return md # Build the new TOC: numbered list with anchor links to each heading toc_lines = [] for h_text in headings: # Strip the leading "N. " from headings like "1. What RemSound does" so the displayed # link text in the TOC reads naturally. The anchor still references the FULL heading. display = re.sub(r"^\d+\.\s*", "", h_text) anchor = github_anchor(h_text) toc_lines.append(f" 1. [{display}](#{anchor})") new_toc_block = "\n".join(toc_lines) + "\n" # Replace the existing TOC block (everything between "## Table of contents" and the next "## ") # with the regenerated one. Using a callback to keep the surrounding markers intact. def replace_toc(match: re.Match[str]) -> str: return match.group(1) + "\n\n" + new_toc_block + "\n" pattern = re.compile( r"(##\s+Table of contents\s*\n)(?:.*?)(?=\n##\s)", flags=re.DOTALL, ) if not pattern.search(md): # Defensive: TOC section wasn't found in the expected shape — leave the doc alone. return md return pattern.sub(replace_toc, md, count=1) def main() -> int: if not README_HTML.exists(): print(f"ERROR: {README_HTML} not found", file=sys.stderr) return 1 html = README_HTML.read_text(encoding="utf-8") md = convert_html_to_markdown(html) md = rebuild_toc(md) MANUAL_MD.write_text(md, encoding="utf-8", newline="\n") print(f"OK - MANUAL.md regenerated from readme.html ({len(md):,} bytes)") return 0 if __name__ == "__main__": sys.exit(main())