"""
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 "
" + "\n".join(items) + "
"
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
"""
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'