Files
rea/scripts/website.py
T
Kaoru0822andClaude Opus 5.5 dabcd783d5 refactor(website): keep page text when a script message is missing
A page that omits a message attribute (a translation that drops one, or a
page without the shared footer) must not show empty or "undefined" text:

- Copy buttons still copy without #copy-status or its messages, keep their
  own label and leave the status empty.
- The star label is skipped when the header has no label for the count.
- The percent demo, dinosaur game and speed check leave the current text in
  place for a missing message; an unknown {placeholder} stays literal.

scripts/test-website-scripts.mjs runs main.js, home.js and dino-demo.js
against a small document model with node:test. `website.py check` runs it,
so the website CI job and the publisher's preparation job now set up the
repository's Node.js version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:09:16 +00:00

256 lines
11 KiB
Python

"""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 = (
("Showcases", "showcase/"),
("Blog", "blog/"),
("Get started", "get-started/"),
("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)
]
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' <a href="{base}{destination}"{attributes}>{label}</a>')
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),
}
templates = root / "website/templates"
return {
name: indent(Template((templates / f"{name}.html.tmpl").read_text(encoding="utf-8")).substitute(values), " ")
for name in ("sharing", "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" <!-- website:{name}:start -->\n"
end = f" <!-- website:{name}:end -->"
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" <!-- website:{name}:start -->\n", f" <!-- website:{name}:start -->\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 run_browser_script_tests(root):
"""Run the page-script regressions with the repository's Node.js."""
try:
subprocess.run(["node", str(root / "scripts/test-website-scripts.mjs")], cwd=root, check=True)
except FileNotFoundError as error:
raise OSError("The website script tests need Node.js; see website/README.md.") from error
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)
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 == "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)
run_browser_script_tests(root)
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/public", 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())