From ab58a28a9c6d97b4c3dcc1ee5ddbc6f873d98b91 Mon Sep 17 00:00:00 2001 From: lou Date: Sat, 1 Aug 2026 13:51:37 +0300 Subject: [PATCH] docs: render repo Markdown as a Mintlify site with gated Pages deploy Adds a build script that assembles the Markdown already in this repo into a Mintlify site, plus a workflow that builds, validates and link-checks it on every docs change. Publishing is opt-in behind the DOCS_PAGES_DOMAIN repository variable. Pages currently serves the legal site from the `pages` branch as a legacy build, and Mintlify's export cannot be served from a subpath, so deploying without a custom domain would take privacy/terms offline and still render broken. Signed-off-by: lou --- .github/workflows/docs.yml | 118 +++++++++++++++++++ docs/site/.gitignore | 3 + docs/site/README.md | 35 ++++++ docs/site/build.py | 224 +++++++++++++++++++++++++++++++++++++ docs/site/docs.json | 21 ++++ 5 files changed, 401 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/site/.gitignore create mode 100644 docs/site/README.md create mode 100755 docs/site/build.py create mode 100644 docs/site/docs.json diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000000..436e8a639c --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,118 @@ +# Builds the Mintlify docs site from the repo's Markdown and, once a custom +# domain is configured, publishes it to GitHub Pages. +# +# Deploying is deliberately opt-in. Two things have to be true before it is +# safe to turn on: +# +# 1. Pages currently serves the legal site (index/privacy/support/terms) from +# the `pages` branch as a `legacy` build. Switching the source to GitHub +# Actions takes those pages offline, so this workflow stays build-only +# until a maintainer opts in. +# +# 2. Mintlify's static export hardcodes root-absolute asset paths and has no +# `basePath` option, so it cannot be served from `block.github.io/buzz/`. +# It needs a domain root. +# +# To enable publishing, set the repository variable DOCS_PAGES_DOMAIN to the +# hostname the docs will serve from (for example `docs.buzz.xyz`), point that +# hostname at GitHub Pages in DNS, and switch Pages' source to GitHub Actions. +# Until then every run builds, validates and link-checks the site without +# publishing, so breakage surfaces on the PR that causes it. + +name: Docs + +on: + push: + branches: [main] + paths: &docs_paths + - '*.md' + - 'docs/**' + - 'crates/buzz-acp/README.md' + - 'crates/buzz-agent/README.md' + - 'crates/buzz-cli/README.md' + - 'deploy/compose/README.md' + - '.github/workflows/docs.yml' + pull_request: + paths: *docs_paths + workflow_dispatch: + +concurrency: + group: docs-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + build: + name: Build and Validate + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install Mintlify CLI + run: npm install -g mint + + - name: Assemble site + run: python3 docs/site/build.py + + # `mint validate` reports a page dropped for an unparseable route as + # "file does not exist" rather than a parse error, so a silent drop still + # fails the build here instead of shipping a gap in the navigation. + - name: Validate + working-directory: docs/site/dist + run: mint validate + + - name: Check links + working-directory: docs/site/dist + run: mint broken-links + + - name: Export static site + working-directory: docs/site/dist + run: mint export + + - name: Unpack export + run: | + mkdir -p docs/site/static + unzip -q docs/site/dist/export.zip -d docs/site/static + if [ -n "${{ vars.DOCS_PAGES_DOMAIN }}" ]; then + echo "${{ vars.DOCS_PAGES_DOMAIN }}" > docs/site/static/CNAME + fi + + - name: Upload Pages artifact + if: github.event_name == 'push' && vars.DOCS_PAGES_DOMAIN != '' + uses: actions/upload-pages-artifact@v3 + with: + path: docs/site/static + + - name: Upload preview artifact + if: github.event_name != 'push' || vars.DOCS_PAGES_DOMAIN == '' + uses: actions/upload-artifact@v4 + with: + name: docs-site + path: docs/site/static + retention-days: 7 + + deploy: + name: Deploy to Pages + needs: build + # Publishing only happens once a maintainer sets DOCS_PAGES_DOMAIN. Without + # it this job is skipped and the legal site on the `pages` branch is + # untouched. + if: github.event_name == 'push' && vars.DOCS_PAGES_DOMAIN != '' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/docs/site/.gitignore b/docs/site/.gitignore new file mode 100644 index 0000000000..56517437ac --- /dev/null +++ b/docs/site/.gitignore @@ -0,0 +1,3 @@ +dist/ +static/ +export.zip diff --git a/docs/site/README.md b/docs/site/README.md new file mode 100644 index 0000000000..622e991087 --- /dev/null +++ b/docs/site/README.md @@ -0,0 +1,35 @@ +# Docs site + +Renders the Markdown already in this repo as a browsable documentation site, +using [Mintlify](https://mintlify.com). The Markdown stays the source of +truth — this directory only holds the site config and the script that +assembles it. + +## Local preview + +```bash +npm install -g mint +python3 docs/site/build.py +cd docs/site/dist && mint dev +``` + +## Adding a page + +Add the file to `PAGES` in `build.py`. Group placement follows the route +prefix (`architecture/`, `hosting/`, `agents/`, `nips/`, `vision/`, +`project/`), so a new `architecture/…` route joins the Architecture group +with no other change. + +## Two constraints worth knowing + +**Reserved routes.** Mintlify silently drops pages routed to `changelog` or +`contributing` — the build reports them as "file does not exist" with no +parse error. `CHANGELOG.md` and `CONTRIBUTING.md` are routed to +`project/release-notes` and `project/contributing-guide` for that reason. + +**The site must be served from a domain root.** Mintlify's static export +hardcodes root-absolute asset paths and has no `basePath` option, so serving +it from a subpath such as `block.github.io/buzz/` leaves the HTML loading but +React failing to hydrate — every page renders as "Application error: a +client-side exception has occurred". `deploy.yml` therefore requires a custom +domain; see the workflow header. diff --git a/docs/site/build.py b/docs/site/build.py new file mode 100755 index 0000000000..12b020a5e6 --- /dev/null +++ b/docs/site/build.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Assemble the Mintlify docs site from the Markdown already in this repo. + +The repo is the source of truth: docs live as plain Markdown next to the code +they describe. This script copies the subset listed in PAGES into a Mintlify +tree under docs/site/dist/, adding the frontmatter and navigation Mintlify +needs. Nothing here edits the source Markdown. + +Two transformations are applied to each page: + + sanitize() Mintlify parses Markdown through MDX, so a bare `<` or `{` + outside a code fence is read as JSX and aborts the build. Both + are escaped, and purely decorative HTML (the centred banner in + README.md, for example) is dropped. + + relink() Cross-document links are written for GitHub — `[Vision](VISION.md)` + resolves relative to the repo root. Those are rewritten to site + routes; links to paths that are not published as pages are + pointed back at GitHub so they still resolve. + +Usage: python3 docs/site/build.py [--out DIR] +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import shutil + +REPO = pathlib.Path(__file__).resolve().parents[2] +GITHUB_BLOB = "https://github.com/block/buzz/blob/main/" + +# (source path relative to repo root, site route, page title) +PAGES: list[tuple[str, str, str]] = [ + ("README.md", "index", "Buzz"), + + ("ARCHITECTURE.md", "architecture/system", "System Architecture"), + ("NOSTR.md", "architecture/nostr", "Nostr Event Kinds"), + ("docs/multi-tenant-relay.md", "architecture/multi-tenant", "Multi-Tenant Relay"), + ("docs/multi-tenant-conformance.md", "architecture/conformance", "Multi-Tenant Conformance"), + ("docs/bridge-channel-window.md", "architecture/bridge-window", "Bridge Channel Window"), + ("docs/git-on-object-storage.md", "architecture/git-storage", "Git on Object Storage"), + + ("deploy/compose/README.md", "hosting/compose", "Self-Hosting with Compose"), + ("docs/push-gateway-deployment.md", "hosting/push-gateway", "Push Gateway Deployment"), + ("docs/admin/README.md", "hosting/admin", "Admin"), + ("docs/linux-rendering-troubleshooting.md", "hosting/linux-rendering", "Linux Rendering"), + + ("VISION_AGENT.md", "agents/vision", "Agent Vision"), + ("VISION_REMOTE_AGENTS.md", "agents/remote", "Remote Agents"), + ("crates/buzz-acp/README.md", "agents/acp-harness", "ACP Harness (buzz-acp)"), + ("crates/buzz-agent/README.md", "agents/buzz-agent", "buzz-agent"), + ("crates/buzz-cli/README.md", "agents/buzz-cli", "buzz CLI"), + ("docs/MCP_DRIVEN_HOOKS.md", "agents/mcp-hooks", "MCP-Driven Hooks"), + ("docs/buzz-shared-compute-dev.md", "agents/shared-compute", "Shared Compute"), + ("docs/welcome-kickoff-silent-failures.md", "agents/kickoff-failures", "Kickoff Silent Failures"), + + ("VISION.md", "vision/overview", "Vision"), + ("VISION_SOVEREIGN.md", "vision/sovereign", "Sovereign"), + ("VISION_PROJECTS.md", "vision/projects", "Projects"), + ("VISION_ACTIVITY.md", "vision/activity", "Activity"), + ("VISION_MESH.md", "vision/mesh", "Mesh"), + ("VISION_MODERATION.md", "vision/moderation", "Moderation"), + + # `changelog` and `contributing` are reserved route names in Mintlify and are + # silently dropped from the build, so these two use different routes. + ("TESTING.md", "project/testing", "Testing"), + ("CONTRIBUTING.md", "project/contributing-guide", "Contributing"), + ("RELEASING.md", "project/releasing", "Releasing"), + ("SECURITY.md", "project/security", "Security"), + ("CHANGELOG.md", "project/release-notes", "Release Notes"), +] + +NIPS = { + "NIP-AA": "Agent Auth", + "NIP-AE": "Agent Engrams (Memory)", + "NIP-AM": "Agent Management", + "NIP-AO": "Agent Ownership", + "NIP-AP": "Agent Personas", + "NIP-CW": "Canvas & Workspaces", + "NIP-DV": "Device Verification", + "NIP-ER": "Event Retention", + "NIP-GS": "Group Sync", + "NIP-IA": "Identity Archive", + "NIP-MP": "Media Protocol", + "NIP-OA": "Owner Attestation", + "NIP-PL": "Presence Layer", + "NIP-RS": "Read State", + "NIP-WP": "Web Push", +} +PAGES += [ + (f"docs/nips/{nip}.md", f"nips/{nip.lower()}", f"{nip}: {title}") + for nip, title in sorted(NIPS.items()) +] + +DECORATIVE_HTML = re.compile( + r"]*/?>", + re.IGNORECASE, +) +FENCE = re.compile(r"^\s*(```|~~~)") +INLINE_CODE = re.compile(r"(`[^`]*`)") +MD_LINK = re.compile(r"(\]\()([^)\s]+?)(\)|\s)") + + +def sanitize(text: str) -> str: + """Escape MDX-hostile characters outside code fences and inline code.""" + out: list[str] = [] + in_fence = False + for line in text.split("\n"): + if FENCE.match(line): + in_fence = not in_fence + out.append(line) + continue + if in_fence: + out.append(line) + continue + parts = INLINE_CODE.split(line) + for i, part in enumerate(parts): + if part.startswith("`"): + continue + part = DECORATIVE_HTML.sub("", part) + part = part.replace("{", "{").replace("}", "}") + parts[i] = part.replace("<", "<") + out.append("".join(parts)) + return "\n".join(out) + + +def relink(text: str, routes: dict[str, str]) -> str: + """Rewrite repo-relative Markdown links to site routes or GitHub URLs.""" + + def replace(match: re.Match[str]) -> str: + prefix, target, suffix = match.group(1), match.group(2), match.group(3) + if re.match(r"^(https?:|mailto:|#|/)", target): + return match.group(0) + anchor = "" + if "#" in target: + target, _, fragment = target.partition("#") + anchor = "#" + fragment + path = target.lstrip("./").rstrip("/") + if not path: + return f"{prefix}/{anchor}{suffix}" + for candidate in (path, path + ".md"): + if candidate in routes: + return f"{prefix}{routes[candidate]}{anchor}{suffix}" + return f"{prefix}{GITHUB_BLOB}{path}{anchor}{suffix}" + + out: list[str] = [] + in_fence = False + for line in text.split("\n"): + if FENCE.match(line): + in_fence = not in_fence + out.append(line) + continue + out.append(line if in_fence else MD_LINK.sub(replace, line)) + return "\n".join(out) + + +def group(title: str, prefix: str, written: list[str]) -> dict: + return {"group": title, "pages": [p for p in written if p.startswith(prefix)]} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", default=str(pathlib.Path(__file__).parent / "dist")) + args = parser.parse_args() + + out = pathlib.Path(args.out) + if out.exists(): + shutil.rmtree(out) + out.mkdir(parents=True) + + routes = {} + for source, route, _ in PAGES: + routes[source] = "/" if route == "index" else "/" + route + routes[source[:-3]] = routes[source] # extensionless form + + written: list[str] = [] + missing: list[str] = [] + for source, route, title in PAGES: + path = REPO / source + if not path.exists(): + missing.append(source) + continue + body = path.read_text(encoding="utf-8") + body = re.sub(r"^#\s+.*\n", "", body, count=1) # title comes from frontmatter + page = out / f"{route}.md" + page.parent.mkdir(parents=True, exist_ok=True) + page.write_text( + f'---\ntitle: "{title}"\n---\n\n' + relink(sanitize(body), routes), + encoding="utf-8", + ) + written.append(route) + + config = json.loads((pathlib.Path(__file__).parent / "docs.json").read_text()) + config["navigation"] = { + "tabs": [ + { + "tab": "Docs", + "groups": [ + {"group": "Start Here", "pages": ["index"]}, + group("Architecture", "architecture/", written), + group("Self-Hosting", "hosting/", written), + group("Agents", "agents/", written), + group("Protocol (NIPs)", "nips/", written), + group("Vision", "vision/", written), + group("Project", "project/", written), + ], + } + ] + } + (out / "docs.json").write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") + + print(f"built {len(written)} pages into {out}") + if missing: + print("WARNING: missing sources: " + ", ".join(missing)) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/site/docs.json b/docs/site/docs.json new file mode 100644 index 0000000000..bad5cf6de7 --- /dev/null +++ b/docs/site/docs.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://mintlify.com/docs.json", + "theme": "mint", + "name": "Buzz", + "description": "A workspace where humans and agents build together, on a relay you own.", + "colors": { + "primary": "#F5A623", + "light": "#FFD166", + "dark": "#B8860B" + }, + "navbar": { + "links": [ + { "label": "GitHub", "href": "https://github.com/block/buzz" } + ] + }, + "footer": { + "socials": { + "github": "https://github.com/block/buzz" + } + } +}