""" Static-site exporter for the wiki vault — Quartz-style 'graphify'. Takes any folder of markdown (.md / .qmd) + the cluster catalog and produces a self-contained, deployable HTML site: out/ index.html interactive 3D graph + searchable note list notes/.html one rendered page per note with backlinks static/graph.json {nodes, links} payload for the 3D viz static/style.css dark theme Why: Obsidian-Publish costs $$ and wants its own format. Quartz is great but needs Node. This script is pure Python + jinja-free template strings, runs in 2 seconds, output uploads anywhere static (gh-pages, Netlify, S3). Usage: python3 scripts/export_quartz_site.py python3 scripts/export_quartz_site.py --vault wiki/concepts --out site python3 scripts/export_quartz_site.py --serve # serve on :8516 after build """ from __future__ import annotations import argparse import json import re import shutil import sys from pathlib import Path PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT)) WIKILINK_RE = re.compile(r"\[\[([^\[\]\|\n]+?)(?:\|([^\[\]\n]+))?\]\]") TAG_RE = re.compile(r"(? str: return re.sub(r"[^a-z0-9_-]+", "_", s.lower()).strip("_") def _norm(s: str) -> str: return re.sub(r"[^a-z0-9]+", "_", s.lower()).strip("_") def _md_to_html(body: str, link_resolver) -> str: """Tiny markdown → HTML. Good enough for our wiki notes; not full CommonMark.""" # Strip frontmatter body = FRONTMATTER_RE.sub("", body, count=1) # Code fences (replace before other markup) code_blocks: list[str] = [] def _fence(m): lang = m.group(1) or "" code = m.group(2).strip() # Escape HTML inside code code = (code.replace("&", "&").replace("<", "<") .replace(">", ">")) idx = len(code_blocks) code_blocks.append( f'
{code}
' ) return f"\x00CODEBLOCK{idx}\x00" body = CODE_FENCE_RE.sub(_fence, body) # Wikilinks def _wl(m): target = re.split(r"[#\^|]", m.group(1).strip(), 1)[0].rsplit("/", 1)[-1] alias = m.group(2) or m.group(1) href = link_resolver(target) if href is None: return f'{alias}' return f'{alias}' body = WIKILINK_RE.sub(_wl, body) # Tags body = TAG_RE.sub(r'#\1', body) # Headings (top-down so '######' matches before '#') body = re.sub(r"^######\s+(.+)$", r"
\1
", body, flags=re.MULTILINE) body = re.sub(r"^#####\s+(.+)$", r"
\1
", body, flags=re.MULTILINE) body = re.sub(r"^####\s+(.+)$", r"

\1

", body, flags=re.MULTILINE) body = re.sub(r"^###\s+(.+)$", r"

\1

", body, flags=re.MULTILINE) body = re.sub(r"^##\s+(.+)$", r"

\1

", body, flags=re.MULTILINE) body = re.sub(r"^#\s+(.+)$", r"

\1

", body, flags=re.MULTILINE) # Lists (very simple — single-level) def _list(m): items = [] for line in m.group(0).splitlines(): mm = LIST_RE.match(line) if mm: items.append(f"
  • {mm.group(2)}
  • ") return "" body = re.sub(r"(?:^[\s]*[-*•]\s+.+\n?)+", _list, body, flags=re.MULTILINE) # Inline body = BOLD_RE.sub(r"\1", body) body = ITALIC_RE.sub(r"\1", body) body = INLINE_CODE_RE.sub(r"\1", body) # Paragraphs parts = [] for chunk in re.split(r"\n\s*\n", body): c = chunk.strip() if not c: continue if c.startswith("<") and not c.startswith("{c}

    ") html = "\n\n".join(parts) # Restore code blocks for i, blk in enumerate(code_blocks): html = html.replace(f"\x00CODEBLOCK{i}\x00", blk) return html def _write_index(out: Path, notes: dict, clusters: list, graph_json: dict): """Index.html: top-bar search + 3D force graph + note list.""" cluster_summary = [ {"id": c.cluster_id, "name": c.name, "members": len(c.members)} for c in (clusters or []) ] # Pre-render note metadata as JSON for client-side filter note_meta = [ {"stem": s, "title": s.replace("_", " "), "href": f"notes/{_slug(s)}.html", "tags": list(n.get("tags") or [])[:6], "para": n.get("para", "Resources")} for s, n in sorted(notes.items()) ] payload = { "graph": graph_json, "notes": note_meta, "clusters": cluster_summary, } index_html = (INDEX_TPL .replace("__PAYLOAD__", json.dumps(payload)) ) (out / "index.html").write_text(index_html) INDEX_TPL = """ Brain — vault explorer
    🧠 Brain
    """ STYLE_CSS = """ :root { --bg:#0F1733; --fg:#FAFBFE; --accent:#F96167; --side:#1E2761; --link:#7DD3FC; } *{box-sizing:border-box} body{margin:0;font-family:-apple-system,system-ui,Segoe UI,sans-serif; background:var(--bg);color:var(--fg);} header{display:flex;align-items:center;gap:14px;padding:10px 16px; background:#0a0f24;border-bottom:1px solid #1E2761;} header .brand{font-weight:700;color:var(--accent);} header input{flex:1;padding:8px 12px;border:1px solid #3B5BA5;background:#07091F; color:var(--fg);border-radius:6px;font-size:14px} header #counts{font-size:12px;color:var(--link);} main{display:flex;height:calc(100vh - 50px);} aside{width:260px;background:#07091F;padding:14px;overflow-y:auto; border-right:1px solid #1E2761;} aside h3{margin:0 0 6px 0;font-size:12px;letter-spacing:0.05em; text-transform:uppercase;color:var(--link);} aside ul{margin:0;padding:0;list-style:none;font-size:13px;} aside li{padding:3px 0;} aside a{color:var(--fg);text-decoration:none;} aside a:hover{color:var(--accent);} #graph{flex:1;} .note-page{max-width:780px;margin:24px auto;padding:0 24px;} .note-page h1{color:var(--accent);} .note-page h2,.note-page h3{color:var(--link);} .note-page a.wikilink{color:var(--link);text-decoration:none;border-bottom:1px dashed #7DD3FC;} .note-page a.wikilink:hover{color:var(--accent);} .note-page .stub{color:#888;text-decoration:line-through;} .note-page .tag{color:#FBB036;font-size:0.85em;} .note-page pre.code{background:#07091F;padding:12px;border-radius:6px; overflow-x:auto;border:1px solid #1E2761;} .note-page code{background:#1E2761;padding:1px 6px;border-radius:3px;} .note-page .meta{color:#888;font-size:13px;margin-bottom:18px; padding-bottom:10px;border-bottom:1px solid #1E2761;} .backlinks{margin-top:30px;padding-top:14px;border-top:1px solid #1E2761;} .backlinks h3{color:var(--link);font-size:13px; text-transform:uppercase;letter-spacing:0.05em;} .backlinks ul{padding-left:18px;} .backlinks a{color:var(--fg);} """ NOTE_TPL = """ __TITLE__ — Brain vault
    🧠 Brain
    ← back to graph

    __TITLE__

    __META__
    __BODY__
    """ def main(): ap = argparse.ArgumentParser() ap.add_argument("--vault", default=str(PROJECT_ROOT / "wiki" / "concepts")) ap.add_argument("--out", default=str(PROJECT_ROOT / "site")) ap.add_argument("--serve", action="store_true", help="After build, run http.server on :8516") ap.add_argument("--include-qmd", action="store_true", help="Also export .qmd files (Quarto)") ap.add_argument("--port", type=int, default=8516) args = ap.parse_args() vault = Path(args.vault).expanduser() out = Path(args.out) if not vault.is_dir(): sys.exit(f"✗ vault not found: {vault}") print(f"[1/5] Reading vault: {vault}") extensions = (".md", ".qmd") if args.include_qmd else (".md",) notes: dict[str, dict] = {} for ext in extensions: for f in vault.rglob(f"*{ext}"): if any(seg.startswith(".") for seg in f.relative_to(vault).parts): continue stem = f.stem if stem in notes: continue try: body = f.read_text(errors="ignore") except Exception: continue tags = list(set(TAG_RE.findall(body))) notes[stem] = { "stem": stem, "path": str(f.relative_to(vault)), "body": body, "tags": tags, } print(f" {len(notes)} notes") print("[2/5] Building wikilink + backlink index") norm_to_stem = {_norm(s): s for s in notes} backlinks: dict[str, list[str]] = {s: [] for s in notes} edges: list[tuple[str, str]] = [] for stem, n in notes.items(): body = n["body"] seen_targets = set() for m in WIKILINK_RE.finditer(body): t = re.split(r"[#\^|]", m.group(1).strip(), 1)[0].rsplit("/", 1)[-1] real = norm_to_stem.get(_norm(t)) if real and real != stem and real not in seen_targets: edges.append((stem, real)) backlinks.setdefault(real, []).append(stem) seen_targets.add(real) print(f" {len(edges)} edges, {sum(len(v) for v in backlinks.values())} backlinks") print("[3/5] Loading clusters (best-effort)") clusters = [] node_to_cluster: dict[str, dict] = {} try: from graph.clusters import load_clusters, build_clusters clusters = load_clusters() or build_clusters(force=False) for c in clusters: for m in c.members: node_to_cluster[m] = {"id": c.cluster_id, "name": c.name} print(f" {len(clusters)} clusters") except Exception as e: print(f" skip clusters: {e}") print(f"[4/5] Writing site to {out}") out.mkdir(parents=True, exist_ok=True) (out / "static").mkdir(exist_ok=True) (out / "notes").mkdir(exist_ok=True) (out / "static" / "style.css").write_text(STYLE_CSS) # Per-note html def _resolver(target_stem: str) -> str | None: real = norm_to_stem.get(_norm(target_stem)) if real is None: return None return f"../notes/{_slug(real)}.html" degree: dict[str, int] = {s: 0 for s in notes} for a, b in edges: degree[a] = degree.get(a, 0) + 1 degree[b] = degree.get(b, 0) + 1 for stem, n in notes.items(): html_body = _md_to_html(n["body"], _resolver) bl_links = "".join( f'
  • {s.replace("_", " ")}
  • ' for s in list(dict.fromkeys(backlinks.get(stem, [])))[:30] ) or "
  • no incoming links
  • " cluster_meta = node_to_cluster.get(stem, {}) meta_bits = [] if cluster_meta: meta_bits.append(f"cluster: {cluster_meta.get('name','?')}") if n["tags"]: meta_bits.append("tags: " + " ".join(f"#{t}" for t in n["tags"])) meta_bits.append(f"degree: {degree.get(stem, 0)}") html = (NOTE_TPL .replace("__TITLE__", stem.replace("_", " ")) .replace("__BODY__", html_body) .replace("__BACKLINKS__", bl_links) .replace("__META__", " · ".join(meta_bits)) ) (out / "notes" / f"{_slug(stem)}.html").write_text(html) # Graph payload (3D) graph_json = { "nodes": [ { "id": s, "label": s.replace("_", " "), "cluster": node_to_cluster.get(s, {}).get("id", -1), "cluster_name": node_to_cluster.get(s, {}).get("name", ""), "degree": degree.get(s, 0), "href": f"notes/{_slug(s)}.html", } for s in notes ], "links": [{"source": a, "target": b} for a, b in edges], } (out / "static" / "graph.json").write_text(json.dumps(graph_json)) _write_index(out, notes, clusters, graph_json) print(f"[5/5] Done → {out}") print(f" index: {out}/index.html") print(f" notes: {len(notes)} pages") print(f" size: {sum(p.stat().st_size for p in out.rglob('*') if p.is_file()) / 1e6:.1f} MB") if args.serve: import http.server import socketserver import os as _os _os.chdir(out) with socketserver.TCPServer(("", args.port), http.server.SimpleHTTPRequestHandler) as httpd: print(f"\n serving http://localhost:{args.port}/ (Ctrl-C to stop)") try: httpd.serve_forever() except KeyboardInterrupt: print("\n stopped") if __name__ == "__main__": main()