"""Maintain shared HTML and run the static website's contributor commands.""" import argparse from functools import partial from html import escape from html.parser import HTMLParser from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path, PurePosixPath from string import Template import subprocess import sys from textwrap import indent from urllib.parse import quote, urlsplit SITE_ORIGIN = "https://rea.tools" NOTES_FIXTURES = {"examples/notes-electron/index.html", "examples/notes-web/index.html"} NAVIGATION = ( ("Get started", "get-started/"), ("Showcases", "showcase/"), ("Blog", "blog/"), ("Guides", "guides/"), ("FAQ", "faq/"), ) def authored_markdown(root): """Discover website documentation while excluding local environments and vendors.""" site = root / "website" return [ path for path in sorted(site.rglob("*.md")) if not any(part.startswith(".") or part in ("node_modules", "__pycache__") for part in path.relative_to(site).parts) and "_site" not in path.relative_to(site).parts ] class PageMetadata(HTMLParser): """Read the authored title, description and indexing policy from the head.""" def __init__(self): super().__init__() self.in_head = False self.in_title = False self.titles = [] self.descriptions = [] self.noindex = False def handle_starttag(self, tag, attrs): if tag == "head": self.in_head = True if not self.in_head: return if tag == "title": self.in_title = True self.titles.append("") if tag == "meta": attributes = dict(attrs) name = (attributes.get("name") or "").lower() content = attributes.get("content") or "" if name == "description": self.descriptions.append(content) if name in ("robots", "googlebot"): self.noindex |= bool( set(content.lower().replace(",", " ").split()) & {"noindex", "none"} ) def handle_endtag(self, tag): if tag == "head": self.in_head = False if tag == "title": self.in_title = False def handle_data(self, data): if self.in_title: self.titles[-1] += data def page_route(site, path): """Map an HTML file to its public route without a separate page registry.""" route = path.relative_to(site).as_posix() return route.removesuffix("index.html") if path.name == "index.html" else route def page_url(site, path): """Return the canonical public URL of an authored HTML file.""" return f"{SITE_ORIGIN}/{quote(page_route(site, path), safe='/')}" def shared_blocks(root, path, source): """Render shared regions while keeping the page's body and metadata authored.""" site = root / "website/public" metadata = PageMetadata() metadata.feed(source) for name, values in (("title", metadata.titles), ("description", metadata.descriptions)): if len(values) != 1 or not values[0].strip(): raise ValueError(f"{path.relative_to(site)}: needs one nonempty head {name}") depth = len(path.relative_to(site).parent.parts) base = "../" * depth or "./" route = page_route(site, path) links = [] for label, destination in NAVIGATION: attributes = ' class="nav-start"' if destination == "get-started/" else "" if route.startswith(destination): attributes += ' aria-current="page"' links.append(f' {label}') values = { "base": base, "navigation": "\n" + "\n".join(links) + "\n ", "canonical": escape(page_url(site, path), quote=True), "origin": SITE_ORIGIN, "title": escape(metadata.titles[0].strip(), quote=True), "description": escape(metadata.descriptions[0].strip(), quote=True), } from website_i18n import language_markup selector, alternates = language_markup(root, path.relative_to(site).as_posix()) values["language_picker"] = indent(selector, " ") if not metadata.noindex else "" values["alternates"] = alternates if not metadata.noindex else "" templates = root / "website/templates" return { name: indent(Template((templates / f"{name}.html.tmpl").read_text(encoding="utf-8")).substitute(values), " ") for name in ("sharing", "theme", "header", "footer") } def sync_pages(root, check=False): """Update shared regions, or reject drift without changing any authored file.""" site = root / "website/public" changes = [] for path in sorted(site.rglob("*.html")): if path.relative_to(site).as_posix() in NOTES_FIXTURES: continue original = path.read_text(encoding="utf-8") source = original for name, block in shared_blocks(root, path, source).items(): start = f" \n" end = f" " if source.count(start) != 1 or source.count(end) != 1: raise ValueError(f"{path.relative_to(site)}: needs one {name} region; use new-page for a new reading page") before, _, remaining = source.partition(start) _, found, after = remaining.partition(end) if not found: raise ValueError(f"{path.relative_to(site)}: {name} region is out of order") source = before + start + block + end + after if source != original: changes.append((path, source)) if check and changes: names = ", ".join(str(path.relative_to(site)) for path, _ in changes) raise ValueError(f"Shared HTML is stale: {names}. Run python3 scripts/website.py sync and commit the updated pages.") if not check: for path, source in changes: path.write_text(source, encoding="utf-8") print(f"{'Checked' if check else 'Synced'} shared HTML ({len(changes)} pages changed).") return [path for path, _ in changes] def new_page(root, route, title, description): """Create a draft reading page with correct depth, metadata and shared regions.""" parts = PurePosixPath(route) if not route or parts.is_absolute() or ".." in parts.parts or "\\" in route or parts.suffix: raise ValueError("Use a relative directory route, such as blog/my-article.") site = root / "website/public" path = site.joinpath(*parts.parts, "index.html") if not path.resolve().is_relative_to(site.resolve()): raise ValueError("The new page must stay inside website/public.") if path.exists(): raise ValueError(f"Page already exists: {path.relative_to(site)}") base = "../" * len(parts.parts) or "./" source = Template((root / "website/templates/page.html").read_text(encoding="utf-8")).substitute( title=escape(title, quote=True), description=escape(description, quote=True), base=base, ) # Render before writing, so invalid input cannot leave an incomplete page. blocks = shared_blocks(root, path, source) for name, block in blocks.items(): source = source.replace(f" \n", f" \n{block}") path.parent.mkdir(parents=True, exist_ok=True) path.write_text(source, encoding="utf-8") print(f"Created {path.relative_to(root)}. Write the page, add its index link, then remove the Draft label and noindex directive when ready.") return path def run_script(root, name, arguments=()): """Use the active Python environment for the existing preparation and checks.""" subprocess.run([sys.executable, str(root / "scripts" / name), *arguments], cwd=root, check=True) def preview_handler(site, prefix): """Serve one static directory at root or a Pages prefix, preserving redirects.""" class Handler(SimpleHTTPRequestHandler): def send_head(self): if not urlsplit(self.path).path.startswith(prefix): self.send_error(404) return None return super().send_head() def translate_path(self, path): return super().translate_path("/" + path[len(prefix):]) return partial(Handler, directory=str(site)) def main(): """Expose one small command for synchronizing, checking and previewing the site.""" parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest="command", required=True) sync = commands.add_parser("sync", help="Update shared HTML after editing templates or page metadata") sync.add_argument("--check", action="store_true", help="Report drift without rewriting pages") commands.add_parser("check", help="Check shared HTML, prepare assets, verify links and run regression tests") links = commands.add_parser("links", help="Audit external targets using gh and HTTP; separate from offline checks") links.add_argument("--report", help="Write the full external-link JSON report") create = commands.add_parser("new-page", help="Create a draft reading page") create.add_argument("route") create.add_argument("--title", required=True) create.add_argument("--description", required=True) translations = commands.add_parser("translations", help="Maintain gettext catalogs") translations.add_argument("action", choices=("extract", "status", "check")) translations.add_argument("--language", help="Check one language, such as zh-CN") translations.add_argument("--strict", action="store_true", help="Fail on missing or fuzzy translations") serve = commands.add_parser("serve", help="Prepare and preview the static site") serve.add_argument("--port", type=int, default=4173) serve.add_argument("--base-path", choices=("/", "/rea/"), default="/") args = parser.parse_args() root = Path(__file__).resolve().parent.parent try: if args.command == "translations": from website_i18n import checked_catalog, extract_catalogs, languages template = extract_catalogs(root, check=args.action != "extract") if args.action != "extract": inventory = languages(root)[1:] if args.language: inventory = [item for item in inventory if item["code"] == args.language] if not inventory: raise ValueError(f"Unknown translation language: {args.language}") for language in inventory: messages, missing = checked_catalog(root, language, template, strict=args.strict) print(f"{language['code']}: {len(messages)}/{len(template)} translated; {len(missing)} missing or fuzzy") elif args.command == "new-page": new_page(root, args.route, args.title, args.description) elif args.command == "sync": sync_pages(root, check=args.check) elif args.command == "check": sync_pages(root, check=True) for name in ("prepare-website.py", "verify-website.py", "test-website.py"): run_script(root, name) elif args.command == "links": run_script(root, "audit-website-links.py", ["--report", args.report] if args.report else []) else: sync_pages(root) run_script(root, "prepare-website.py") prefix = args.base_path handler = preview_handler(root / "website/_site", prefix) with ThreadingHTTPServer(("127.0.0.1", args.port), handler) as server: print(f"Preview: http://127.0.0.1:{server.server_port}{prefix}", flush=True) server.serve_forever() except (OSError, ValueError, KeyError) as error: print(str(error), file=sys.stderr) return 1 except subprocess.CalledProcessError as error: return error.returncode except KeyboardInterrupt: return 0 return 0 if __name__ == "__main__": sys.exit(main())