Files
RemSound/sync-manual.py
EdnunpandClaude Opus 4.7 0189b12668 Docs: rewrite README.md for users, add MANUAL.md, sync from readme.html on every release
People landing on the repo page were having to install RemSound just to read what
it does and how to use it. Two doc changes fix that:

1) README.md rewritten as a plain-English landing page. Drops the developer-focused
   highlights / build-from-source / project-layout sections in favour of what
   RemSound is, who it's for, how to install it, and a prominent link to the manual.
   No jargon, no command lines, no NuGet / SDK / ASIO-protocol talk. The dev-side
   information that used to live here (build commands, source layout, relay setup)
   is still discoverable for anyone who wants it — the source itself is on the same
   page, and the relay docs are under server/README.md.

2) MANUAL.md added at the repo root as the GitHub-rendered version of the F1 help.
   Markdown derived directly from readme.html via sync-manual.py (new), so visitors
   can read the manual inline on the repo page with no download. readme.html stays
   exactly where it was (bundled inside RemSound, opened by F1) — it remains the
   canonical source of the manual content; MANUAL.md is auto-generated from it.

The sync-manual.py script is invoked automatically from build-release.ps1 as step 0,
before any other release work. It regenerates MANUAL.md from readme.html and then
checks `git diff` on MANUAL.md — if the file changed, the release is paused with a
message asking the user to commit the updated MANUAL.md alongside the release commit.
That makes it structurally impossible to ship a release with a stale GitHub-facing
manual: forgetting to commit MANUAL.md after editing the bundled help triggers a
deliberate release-time stop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 23:48:35 +01:00

146 lines
5.6 KiB
Python

#!/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 <head> entirely — none of its content belongs in the Markdown
html = re.sub(r"<head>.*?</head>", "", html, flags=re.DOTALL)
# Render every <div class="..."> 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 `<div class="note">`-only rule when `class="warn"` was introduced).
html = re.sub(r'<div\s+class="[^"]*">', "<blockquote>", html)
html = re.sub(r"</div>", "</blockquote>", 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 `<ol>` with `<a href="#id">` items
where `id` matches the `id=""` attribute on later `<h2>` 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())