diff --git a/CLAUDE.md b/CLAUDE.md
index 4211517..a9462da 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -14,6 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
| 4 | `bggpipe diff` | diff approved matches against the existing BGG collection | working |
| 5 | `bggpipe upload` | add games via a logged-in Playwright session | working — all browser flows verified live 2026-08-06 (62 adds + 36 version updates landed) |
| 6 | `bggpipe enrich` | fetch full game/version metadata into `games.json` | working |
+| + | `bggpipe export` | render the library as self-contained static pages (covers downloaded, never hotlinked; shelf photos never included; Powered-by-BGG badge slot) | working |
Full design lives in `docs/spec.md` (read it before changing pipeline semantics); the upload-stage walkthrough is in `docs/bgg-upload-flow.md`.
diff --git a/README.md b/README.md
index 50b5707..4d74c64 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ BGG has no bulk import and no write API. Cataloging a few hundred games by hand
3. **review** — A local review step for ambiguous matches: pick the right game/version, or leave the version blank. Wrong guesses never reach your collection.
4. **diff** — Your existing BGG collection is fetched and compared, per copy (owning one edition of a game doesn't hide a second edition you also own).
5. **upload** — A Playwright browser session logs into your BGG account and adds each game (with its version, when known) politely and slowly. Dry-run mode, per-game logging, and resumability included.
-6. **enrich** — Full metadata for every game (designers, player counts, weight, rank, mechanics, artwork, version details) lands in `data/games.json`, feeding a browsable library of your shelves.
+6. **enrich** — Full metadata for every game (designers, player counts, weight, rank, mechanics, artwork, version details) lands in `data/games.json`, feeding a browsable library of your shelves — which `bggpipe export` can publish as [static pages on your own site](docs/guide.md#publishing-your-library-on-your-own-site).
Everything runs locally, every stage survives being killed mid-run, and all artifacts are flat CSV/JSON files you can inspect and edit. You can drive it from the terminal or from a local web app:
diff --git a/docs/guide.md b/docs/guide.md
index e088f44..2a4e1f7 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -109,6 +109,29 @@ BGG application approval can take a week or more. Until then: `extract` works im
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1`
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1&subtype=boardgameexpansion`
+## Publishing your library on your own site
+
+```sh
+bggpipe export --out ~/my-site/static/library --title "My Game Shelves"
+```
+
+Writes the library as self-contained static pages: an index with search, one
+page per game, and a local copy of every cover (public pages must not hotlink
+BGG's image CDN). No server, no build step — drop the directory into any
+static host, Hugo/Jekyll `static/` folder included. Re-runs are idempotent:
+already-downloaded covers are kept, failed downloads are retried, and page
+URLs stay stable.
+
+What's included follows a privacy rule: cover art, stats, your editions, and
+your hand-added local games — **never your shelf photos** (they picture the
+inside of your home; they stay in the private app).
+
+BGG's API policy asks public-facing apps to display the "Powered by BGG"
+badge. The footer carries a text badge automatically; to show the official
+logo, save it from your registered application page at
+[boardgamegeek.com/applications](https://boardgamegeek.com/applications) as
+`data/powered-by-bgg.png` and re-export.
+
## Keeping your data safe from git
The [README's quick start](../README.md#quick-start) — installed as a tool, run in a directory of your own — is the only supported way to use bggpipe on your collection. Everything the pipeline produces lives where you run it, and `uv tool upgrade bggpipe` picks up fixes without going anywhere near your data.
diff --git a/src/bggpipe/cli.py b/src/bggpipe/cli.py
index 6a8cc6a..1e13bab 100644
--- a/src/bggpipe/cli.py
+++ b/src/bggpipe/cli.py
@@ -178,6 +178,25 @@ def upload(
)
+@app.command()
+def export(
+ out: Annotated[
+ Path,
+ typer.Option("--out", help="Directory to write the static site into"),
+ ],
+ title: Annotated[
+ str,
+ typer.Option("--title", help="Site title shown on every page"),
+ ] = "My Game Library",
+ config: ConfigOpt = None,
+) -> None:
+ """Export the library as self-contained static pages (for a blog etc.)."""
+ from bggpipe.export import run_export
+
+ cfg = load_config(config)
+ run_export(cfg, out, title=title)
+
+
@app.command()
def enrich(
refresh: Annotated[
diff --git a/src/bggpipe/export.py b/src/bggpipe/export.py
new file mode 100644
index 0000000..6c53673
--- /dev/null
+++ b/src/bggpipe/export.py
@@ -0,0 +1,431 @@
+"""Export the library as self-contained static pages for any web host.
+
+Reads games.json (the enrich output) and writes an index page, one page
+per game, and a local copy of every cover image — no server, no build
+step, no external requests from the published pages. Cover art is
+downloaded once from BGG's CDN (public pages must not hotlink it) and
+re-runs skip images already on disk, so the export is idempotent and
+resumable like every other stage.
+
+Shelf photos are deliberately NOT exported: they picture the inside of
+the owner's home. Covers and hand-added local art only.
+"""
+
+from __future__ import annotations
+
+import html
+import json
+import re
+import shutil
+import time
+from pathlib import Path
+from urllib.parse import urlsplit
+
+import httpx
+import typer
+
+from bggpipe.config import Config
+
+_SLUG_RE = re.compile(r"[^a-z0-9]+")
+
+# BGG's API policy: public-facing apps must display the "Powered by BGG"
+# logo linking back. The official asset comes from the owner's registered
+# application page — save it as data/powered-by-bgg.png and the export
+# ships the image badge; until then, a text badge keeps the link.
+BADGE_NAME = "powered-by-bgg.png"
+
+
+def _powered_by(rel: str, badge_present: bool) -> str:
+ inner = (
+ f''
+ if badge_present
+ else "Powered by BGG"
+ )
+ return f'{inner}'
+
+
+def _slug(name: str) -> str:
+ return _SLUG_RE.sub("-", (name or "game").casefold()).strip("-") or "game"
+
+
+def _slugs(games: dict[str, dict]) -> dict[str, str]:
+ """Deterministic, unique, human-readable page names. Collisions (two
+ editions of one game share a name) get -2, -3... in sorted-key order
+ so re-exports keep every URL stable."""
+ out: dict[str, str] = {}
+ taken: set[str] = set()
+ for key in sorted(games):
+ base = _slug(games[key].get("name", ""))
+ slug, n = base, 2
+ while slug in taken:
+ slug = f"{base}-{n}"
+ n += 1
+ taken.add(slug)
+ out[key] = slug
+ return out
+
+
+def _art_name(key: str, url: str, *, thumb: bool) -> str:
+ ext = Path(urlsplit(url).path).suffix.lower() or ".jpg"
+ safe = _SLUG_RE.sub("-", key.casefold()).strip("-")
+ return f"{safe}-thumb{ext}" if thumb else f"{safe}{ext}"
+
+
+def _fetch_art(
+ games: dict[str, dict],
+ art_dir: Path,
+ cfg: Config,
+ fetch: httpx.Client,
+ sleep,
+) -> tuple[dict[str, dict[str, str]], int, list[str]]:
+ """Local filename per game for thumb/full art. Downloads only what is
+ missing; a failed download is reported and the page ships without
+ that image — never a broken half-written file."""
+ art: dict[str, dict[str, str]] = {}
+ downloaded = 0
+ failures: list[str] = []
+ art_dir.mkdir(parents=True, exist_ok=True)
+ for key, game in games.items():
+ entry: dict[str, str] = {}
+ image = game.get("image") or ""
+ thumb = game.get("thumbnail") or ""
+ if image.startswith("/local-art/"):
+ # hand-added cover: already on disk, just carried across
+ src = cfg.local_art_dir / Path(image).name
+ if src.exists():
+ shutil.copyfile(src, art_dir / src.name)
+ entry["full"] = entry["thumb"] = src.name
+ else:
+ for url, is_thumb in ((image, False), (thumb, True)):
+ if not url.startswith("http"):
+ continue
+ name = _art_name(key, url, thumb=is_thumb)
+ target = art_dir / name
+ if not target.exists():
+ try:
+ resp = fetch.get(url)
+ resp.raise_for_status()
+ except httpx.HTTPError as err:
+ failures.append(f"{game.get('name', key)}: {err}")
+ continue
+ tmp = target.with_suffix(target.suffix + ".part")
+ tmp.write_bytes(resp.content)
+ tmp.replace(target)
+ downloaded += 1
+ sleep(0.3) # a guest on BGG's CDN, not a crawler
+ entry["thumb" if is_thumb else "full"] = name
+ if "thumb" not in entry and "full" in entry:
+ entry["thumb"] = entry["full"]
+ art[key] = entry
+ return art, downloaded, failures
+
+
+_CSS = """
+:root { --board:#faf3e3; --ink:#2a2438; --ink-soft:#6b6478; --line:#d8cdb4;
+ --sky:#cfe4f5; --navy:#2f3d5c; --accent:#7a4a9e; }
+* { box-sizing:border-box; }
+body { margin:0; font:16px/1.55 system-ui, sans-serif; color:var(--ink);
+ background:var(--sky); }
+main { max-width:72rem; margin:0 auto; padding:1.2rem; }
+header.site { background:var(--navy); color:#fff; padding:.9rem 1.2rem; }
+header.site a { color:#fff; text-decoration:none; font-weight:700; }
+header.site .sub { color:#cfe4f5; font-size:.85rem; }
+.controls { display:flex; gap:.5rem; flex-wrap:wrap; margin:1rem 0; }
+.controls input[type=search] { flex:1 1 14rem; padding:.45rem .6rem;
+ border:2px solid var(--line); border-radius:.5rem; font:inherit; }
+.controls select, .controls button { font:inherit; padding:.4rem .7rem;
+ border:2px solid var(--line); border-radius:.5rem; background:var(--board);
+ cursor:pointer; }
+.controls button[aria-pressed=true] { background:var(--accent); color:#fff; }
+.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(11.5rem,1fr));
+ gap:1rem; }
+a.game { background:var(--board); border:1px solid var(--line);
+ border-radius:.8rem; overflow:hidden; text-decoration:none; color:inherit;
+ display:flex; flex-direction:column; box-shadow:0 2px 5px rgba(42,36,56,.15); }
+a.game:hover { outline:3px solid var(--accent); }
+a.game img { width:100%; aspect-ratio:1; object-fit:cover; display:block;
+ background:#e8ddc5; }
+a.game .noart { width:100%; aspect-ratio:1; display:flex; align-items:center;
+ justify-content:center; font-size:3rem; font-weight:700; color:var(--navy);
+ background:#e8ddc5; }
+a.game .info { padding:.55rem .7rem .7rem; }
+.gname { font-weight:700; }
+.meta { color:var(--ink-soft); font-size:.82rem; }
+.chip { display:inline-block; font-size:.7rem; border:1px solid var(--accent);
+ color:var(--accent); border-radius:1rem; padding:0 .5rem; }
+.detail { display:flex; gap:1.4rem; flex-wrap:wrap; align-items:flex-start;
+ margin-top:1rem; }
+.detail .art { flex:0 0 clamp(11rem,26vw,17rem); }
+.detail .art img { width:100%; border-radius:.8rem; border:1px solid var(--line); }
+.facts { flex:1 1 18rem; background:var(--board); border:1px solid var(--line);
+ border-radius:.8rem; padding:.9rem 1.1rem; }
+.factrow { margin:.45rem 0; }
+.factrow b { font-size:.72rem; text-transform:uppercase; letter-spacing:.06em;
+ color:var(--ink-soft); margin-right:.4rem; }
+.card { background:var(--board); border:1px solid var(--line);
+ border-radius:.8rem; padding:.9rem 1.1rem; margin-top:1rem; }
+.desc { white-space:pre-wrap; }
+footer { margin:2rem 0 1rem; color:var(--ink-soft); font-size:.8rem;
+ text-align:center; }
+footer a { color:var(--accent); }
+a.pbb { font-weight:700; }
+"""
+
+
+def _chips(label: str, values: list | None) -> str:
+ if not values:
+ return ""
+ spans = " ".join(f'{html.escape(str(v))}' for v in values)
+ return f'