Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions docs/site/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist/
static/
export.zip
35 changes: 35 additions & 0 deletions docs/site/README.md
Original file line number Diff line number Diff line change
@@ -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.
224 changes: 224 additions & 0 deletions docs/site/build.py
Original file line number Diff line number Diff line change
@@ -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"</?(?:h[1-6]|p|div|img|a|br|hr|center|picture|source|span|b|i|em|strong"
r"|sub|sup|small|font|table|thead|tbody|tr|td|th|ul|ol|li|blockquote)\b[^>]*/?>",
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("{", "&#123;").replace("}", "&#125;")
parts[i] = part.replace("<", "&lt;")
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())
Loading