{label}
' f'{badge}{path}
' f'Open {badge}#!/usr/bin/env python3 """Build static documentation site into ./site for zapier.georgelambert.org. Markdown is rendered to colored HTML *and* PDF. The home page (index.html) defaults to PDF links; index-md.html is the Markdown/HTML index. """ from __future__ import annotations import os import shutil import subprocess from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone from pathlib import Path from urllib.parse import unquote ROOT = Path(__file__).resolve().parents[1] SITE = ROOT / "site" CSS = ROOT / "scripts" / "docs-print.css" # Display label only. Catalog file links are relative so the tree can move hosts. DOCS_PUBLIC_HOST = os.environ.get("DOCS_PUBLIC_HOST", "zapier.georgelambert.org") # If set (e.g. https://docs.verae-time.net), PDF annotations become absolute. # Empty (default) writes path-relative URIs that work on any hostname at site root. DOCS_PUBLIC_URL = os.environ.get("DOCS_PUBLIC_URL", "").rstrip("/") SECTIONS = [ ( "Start here", [ ("packages/verae-ops/GETTING-STARTED.md", "Bring the system online (install, first-up, monitor)"), ("packages/overview/README.md", "What the system is (plain language)"), ("packages/verae-fleet/docs/CONSOLE.md", "Operator console (Fleet · Trace · Docs on :3850)"), ], ), ( "Architecture", [ ("docs/02-architecture/composition.md", "Zappier + middleware composition"), ("docs/02-architecture/overview.md", "Middleware overview"), ("docs/02-architecture/nats-gateway.md", "NATS on NS1"), ("docs/02-architecture/nats-subjects.md", "NATS subjects"), ("docs/02-architecture/archive-nats.md", "Archive NATS, bloom, multi-receipt"), ("docs/02-architecture/modules-and-nats.md", "Module catalog and NATS addresses"), ("docs/02-architecture/tree-nodes.md", "Tree nodes and bulk Merkle summaries"), ("docs/02-architecture/fleet.md", "Fleet replica floors and monitor"), ("packages/overview/03-nats-cluster.md", "NATS.IO 3-server cluster"), ("packages/overview/04-uptime.md", "Uptime / replica floors"), ("packages/overview/05-network-failures.md", "Local network failures"), ("packages/overview/06-address-routing.md", "Address routing"), ("packages/overview/08-diagrams.md", "Architectural diagrams"), ("packages/overview/10-docs-sync.md", "Docs sync: portal, IAM, keep, bootstrap"), ("packages/overview/11-hostnames.md", "Service → machine and DNS host names"), ], ), ( "Per-module README (public PDFs)", [ ("packages/zappier/README.md", "zappier-edge (billing + users)"), ("packages/verae-zapier-middleware/README.md", "verae-middleware"), ("packages/verae-zapier/README.md", "verae-zapier-app"), ("packages/verae-activate/README.md", "verae-activate"), ("packages/verae-request-splitter/README.md", "verae-request-splitter"), ("packages/verae-archive-worm/README.md", "verae-archive-worm"), ("packages/verae-archive-aggregator/README.md", "verae-archive-aggregator"), ("packages/verae-tree-node/README.md", "verae-tree-node"), ("packages/verae-fleet/README.md", "verae-fleet"), ("packages/verae-zapier-simulator/README.md", "verae-zapier-simulator"), ("packages/zapier-user-docs/README.md", "zapier-user-docs"), ("packages/docs-master/README.md", "zapier-docs-master"), ("packages/verae-nats-process/README.md", "verae-nats-process"), ("packages/verae-staff-iam/README.md", "verae-staff-iam"), ("packages/verae-staff-session/README.md", "verae-staff-session"), ("packages/verae-staff-ui/README.md", "verae-staff-ui"), ("packages/verae-keep/README.md", "verae-keep"), ("packages/verae-bootstrap/README.md", "verae-bootstrap (clone + deps per server type)"), ], ), ( "Billing, users, portal (zappier-edge)", [ ("packages/zappier/README.md", "zappier-edge README (the billing/user platform)"), ("packages/zappier/docs/USER-MANUAL.md", "Operations & usage"), ("packages/zappier/docs/USER-MANAGEMENT.md", "Admin users, pricing, customer types"), ("packages/zappier/docs/CUSTOMER-PORTAL.md", "Signup, 2FA, API keys, reloads"), ("packages/zappier/docs/ACCOUNTING.md", "Invoices, PO billing, reports"), ("packages/zappier/docs/DEVELOPER.md", "Developer reference"), ("packages/zappier-account-balance/README.md", "Account balance (NATS SoT)"), ("packages/zappier-customer-service/README.md", "Customer-service credits API"), ("packages/zappier-sales-pricing/README.md", "Sales per-customer pricing API"), ("packages/zappier-accounting-export/README.md", "QuickBooks / accounting export"), ("packages/verae-access-authz/README.md", "Access-plane authorization"), ("packages/verae-access-web/README.md", "Direct web access plane"), ("packages/verae-access-api/README.md", "Direct customer API plane"), ("packages/verae-access-leaf/README.md", "S2S NATS leaf plane"), ("packages/verae-access-zapier/README.md", "Zapier-only access plane"), ("packages/verae-access-staff/README.md", "Staff access plane"), ("packages/zappier-identity/README.md", "veraeUserId identity mailbox"), ("packages/verae-jobs-events/README.md", "jobs.events mailbox"), ("packages/verae-nats-accounts/README.md", "NATS leaf/internal policy"), ("packages/zapier-decisions/README.md", "Decisions log"), ("packages/ui-docs/README.md", "UI walkthrough, screenshots, review PDF"), ], ), ( "Install (detail)", [ ("packages/verae-ops/GETTING-STARTED.md", "Bring online (start here)"), ("packages/verae-ops/02-docker.md", "Docker Compose"), ("packages/verae-ops/03-proxmox.md", "Proxmox"), ("packages/verae-ops/05-dedicated-hardware.md", "Dedicated hardware"), ("packages/verae-ops/06-linking-services.md", "Linking services"), ], ), ( "Samples", [ ("packages/zappier/zapier-app/README.md", "Sample Zapier app (metered zappier API)"), ("packages/verae-activate/README.md", "Activate-now Add Numbers"), ("packages/verae-zapier-simulator/README.md", "Hop-trace simulator"), ], ), ( "User guide (signup → lookup)", [ ("packages/zapier-user-docs/README.md", "User guide index"), ("packages/zapier-user-docs/02-signup-zappier-portal.md", "Sign up"), ("packages/zapier-user-docs/04-register-a-hash.md", "Register a SHA-256"), ("packages/zapier-user-docs/06-lookup-central-chain.md", "Central chain lookup"), ("packages/zapier-user-docs/09-lookup-tree-nodes.md", "Tree-node lookup"), ], ), ( "Product", [ ("docs/01-product/features-a-m.md", "Features a–m"), ("docs/01-product/api-gap-analysis.md", "Verae OpenAPI gaps"), ("docs/01-product/billing-and-keys.md", "Billing and API keys"), ("docs/00-sources/workspace-brief.md", "Original brief"), ("docs/00-sources/provenance.md", "Provenance"), ], ), ( "API specs", [ ("docs/api/middleware-openapi.yaml", "Middleware OpenAPI"), ("packages/zappier/openapi.yaml", "Zappier OpenAPI"), ("docs/00-sources/veraetime-openapi.yaml", "Verae Timestamping OpenAPI snapshot"), ], ), ] def rel(p: Path) -> str: return p.relative_to(ROOT).as_posix() def copy_tree(src: Path, dest: Path, ignore=None) -> None: if not src.exists(): return dest.parent.mkdir(parents=True, exist_ok=True) if src.is_file(): shutil.copy2(src, dest) return shutil.copytree(src, dest, dirs_exist_ok=True, ignore=ignore) def site_href_for(path: str, src: Path) -> tuple[str, str]: """Return (pdf_or_native_href, md_or_html_href) relative to SITE.""" suffix = src.suffix.lower() if suffix == ".md": pdf = Path(path).with_suffix(".pdf").as_posix() html = Path(path).with_suffix(".html").as_posix() return pdf, html return path, path def render_md(src: Path, html_dest: Path, pdf_dest: Path, title: str) -> str: """Pandoc markdown → colored HTML + WeasyPrint PDF. Returns '' on success.""" html_dest.parent.mkdir(parents=True, exist_ok=True) pdf_dest.parent.mkdir(parents=True, exist_ok=True) header = html_dest.with_suffix(".hdr.html") banner = html_dest.with_suffix(".ban.html") rel_src = src.relative_to(ROOT).as_posix() if src.is_relative_to(ROOT) else src.name css_text = CSS.read_text(encoding="utf-8") header.write_text(f"\n", encoding="utf-8") banner.write_text( f'
\n", encoding="utf-8", ) resource = str(src.parent) r = subprocess.run( [ "pandoc", str(src), "-o", str(html_dest), "--standalone", f"--resource-path={resource}", "--syntax-highlighting=breezedark", f"--metadata=title={title}", f"--include-in-header={header}", f"--include-before-body={banner}", f"--lua-filter={ROOT / 'scripts' / 'pdf-links.lua'}", ], capture_output=True, text=True, ) header.unlink(missing_ok=True) banner.unlink(missing_ok=True) if r.returncode != 0: return f"pandoc {src}: {r.stderr[-400:]}" w = subprocess.run( ["weasyprint", str(html_dest), str(pdf_dest)], capture_output=True, text=True, ) if w.returncode != 0: return f"weasyprint {src}: {w.stderr[-400:]}" return "" def _file_index(site: Path) -> dict[str, list[str]]: idx: dict[str, list[str]] = {} for p in site.rglob("*"): if p.is_file(): rel = p.relative_to(site).as_posix() idx.setdefault(p.name, []).append(rel) return idx _PREFERRED_PREFIXES = ( "overview/", "packages/overview/", "packages/verae-ops/", "packages/verae-fleet/docs/", "user-docs/", "packages/zapier-user-docs/", "docs/02-architecture/", "docs/modules/", "docs-master/", "packages/zapier-decisions/", "docs/modules-pdf/", "docs/models-pdf/", "sphinx/", ) def _pick_hit(hits: list[str]) -> str: for prefix in _PREFERRED_PREFIXES: for h in hits: if h.startswith(prefix): return h return hits[0] def _web_target(site: Path, rel: str, index: dict[str, list[str]] | None = None) -> str: """Map a site-relative path to a file that actually exists.""" rel = unquote(rel).split("#", 1)[0].lstrip("/") if rel.endswith(".html.pdf"): rel = rel[:-4] aliases = { "index-md.pdf": "index-md.html", "index-md.md": "index-md.html", "docs/architecture/overview.pdf": "docs/02-architecture/overview.pdf", "docs/architecture/nats-subjects.pdf": "docs/02-architecture/nats-subjects.pdf", "docs/architecture/nats-gateway.pdf": "docs/02-architecture/nats-gateway.pdf", "docs/architecture/composition.pdf": "docs/02-architecture/composition.pdf", "docs/architecture/fleet.pdf": "docs/02-architecture/fleet.pdf", "docs/sphinx/_build/html/index.pdf": "sphinx/index.html", "docs/sphinx/_build/html/index.html": "sphinx/index.html", } rel = aliases.get(rel, rel) candidates = [rel] if rel.endswith(".pdf"): candidates.append(rel[:-4] + ".html") candidates.append(rel[:-4] + ".md") if rel.endswith(".md"): candidates.append(rel[:-3] + ".pdf") candidates.append(rel[:-3] + ".html") stem = Path(rel).name if not stem or stem in {".", "/"}: return rel or "index.html" stem_pdf = Path(stem).with_suffix(".pdf").as_posix() stem_html = Path(stem).with_suffix(".html").as_posix() for folder in ( "overview", "packages/overview", "packages/verae-ops", "packages/verae-fleet/docs", "user-docs", "packages/zapier-user-docs", "docs/02-architecture", "docs/modules-pdf", "docs/models-pdf", "packages/zapier-decisions", "docs-master", "sphinx", ): candidates.append(f"{folder}/{stem_pdf}") candidates.append(f"{folder}/{stem_html}") for c in candidates: if (site / c).is_file(): return c if (site / c).is_dir(): for name in ("index.html", "index.pdf", "README.pdf", "README.html"): if (site / c / name).is_file(): return f"{c.rstrip('/')}/{name}" if index: names = [stem, stem_pdf, stem_html] if stem.endswith(".md"): names.append(Path(stem).with_suffix(".pdf").as_posix()) names.append(Path(stem).with_suffix(".html").as_posix()) for name in names: hits = index.get(name) or [] if hits: return _pick_hit(hits) return rel _DOCS_ORIGINS = ( "https://zapier.georgelambert.org/", "http://zapier.georgelambert.org/", ) def _href_for(pdf: Path, site: Path, target: str, frag: str = "") -> str: target = (target or "index.html").lstrip("/") or "index.html" dest = site / target if DOCS_PUBLIC_URL: return DOCS_PUBLIC_URL + "/" + target + frag rel = os.path.relpath(dest.resolve(), pdf.parent.resolve()) return Path(rel).as_posix() + frag def rewrite_pdf_uris(site: Path) -> None: """Turn WeasyPrint file:///… annotations into host-relative catalog paths. Default is a path relative to the PDF (works on any hostname at the site root). Set DOCS_PUBLIC_URL to emit absolute URLs instead. """ try: from pypdf import PdfReader, PdfWriter from pypdf.generic import NameObject, create_string_object except ImportError: print("pypdf missing; PDF URI rewrite skipped") return site = site.resolve() prefix = site.as_uri().rstrip("/") + "/" origins = list(_DOCS_ORIGINS) if DOCS_PUBLIC_URL: origins.append(DOCS_PUBLIC_URL.rstrip("/") + "/") index = _file_index(site) def resolve(pdf: Path, raw: str) -> str: frag = "" if "#" in raw: raw, frag = raw.split("#", 1) frag = "#" + frag target = None if raw.startswith(prefix): target = _web_target(site, raw[len(prefix) :], index) elif raw.startswith("file:"): name = unquote(raw.rsplit("/", 1)[-1] if "/" in raw else raw) if not name or name in {".", "/", "file:"}: target = "index.html" else: hit = _web_target(site, name, index) if (site / hit).exists(): target = hit elif "index-md" in raw: target = "index-md.html" else: target = "index.html" else: for origin in origins: if raw.startswith(origin): target = _web_target(site, raw[len(origin) :], index) break if target is None: return raw + frag return _href_for(pdf, site, target, frag) n_pdf = 0 n_fix = 0 for pdf in site.rglob("*.pdf"): try: reader = PdfReader(str(pdf)) except Exception: continue changed = False for page in reader.pages: annots = page.get("/Annots") if not annots: continue for annot in annots: obj = annot.get_object() action = obj.get("/A") if not action: continue uri = action.get("/URI") if not uri: continue raw = str(uri) new = resolve(pdf, raw) if new != raw: action[NameObject("/URI")] = create_string_object(new) changed = True n_fix += 1 if changed: writer = PdfWriter(clone_from=reader) tmp = pdf.with_suffix(".pdf.tmp") with tmp.open("wb") as fh: writer.write(fh) tmp.replace(pdf) n_pdf += 1 mode = DOCS_PUBLIC_URL + "/" if DOCS_PUBLIC_URL else "relative paths" print(f"rewrote {n_fix} PDF URIs in {n_pdf} files → {mode}") def convert_all_markdown(copied: list[tuple[Path, Path, str]]) -> list[str]: errors = [] n = len(copied) print(f"rendering {n} markdown files to HTML + PDF…") with ThreadPoolExecutor(max_workers=6) as pool: futs = { pool.submit(render_md, src, html, pdf, title): src for src, html, pdf, title in ( ( src, dest.with_suffix(".html"), dest.with_suffix(".pdf"), title, ) for src, dest, title in copied ) } done = 0 for fut in as_completed(futs): err = fut.result() done += 1 if err: errors.append(err) print(f" [{done}/{n}] FAIL {futs[fut].name}") elif done % 25 == 0 or done == n: print(f" [{done}/{n}]") return errors def format_switch(to_md: bool) -> str: if to_md: href, label = "/index-md.html", "Markdown indexes" else: href, label = "/", "PDF catalog" return ( f'{label}' ) def page_shell(now: str, body: str, switch_to_md: bool, title: str) -> str: switch = format_switch(switch_to_md) return f"""Project reference: setup, architecture, NATS, module APIs, user guide, and operator console. Catalog defaults to colored PDFs. Generated {now}.
{badge}{path}
' f'Open {badge}Screenshot atlas with usage notes: ' 'UI-REVIEW.pdf.
' ) return ( f'Each name is on Let’s Encrypt via Caddy on " f"NS1.GEORGELAMBERT.ORG. NATS, keep, and archive workers stay on loopback.
" f"{review}" f'Clone: ssh://git@git.georgelambert.org:2223/marchon/<name>.git (SSH port 2223). Branches main and master.
Clone: ssh://git@git.georgelambert.org:2223/marchon/<name>.git (SSH port 2223).