diff --git a/flake.nix b/flake.nix
index 89da51e..5761ba1 100644
--- a/flake.nix
+++ b/flake.nix
@@ -32,6 +32,10 @@
click
rich # Terminal formatting
+ # Webinterface
+ jinja2 # HTML templates
+ markdown # Markdown → HTML rendering
+
# Testing
pytest
pytest-cov
diff --git a/src/wetgit/api/app.py b/src/wetgit/api/app.py
index b65880f..fe50282 100644
--- a/src/wetgit/api/app.py
+++ b/src/wetgit/api/app.py
@@ -47,6 +47,15 @@ app.add_middleware(
store = RegelingStore(REPO_PATH)
+# --- Webinterface ---
+from pathlib import Path as _Path
+from fastapi.staticfiles import StaticFiles
+from wetgit.web.routes import router as web_router, init_routes
+
+app.mount("/static", StaticFiles(directory=str(_Path(__file__).parent.parent / "web" / "static")), name="static")
+init_routes(store)
+app.include_router(web_router)
+
# --- Health ---
diff --git a/src/wetgit/web/__init__.py b/src/wetgit/web/__init__.py
new file mode 100644
index 0000000..f46cac8
--- /dev/null
+++ b/src/wetgit/web/__init__.py
@@ -0,0 +1 @@
+"""WetGit webinterface."""
diff --git a/src/wetgit/web/routes.py b/src/wetgit/web/routes.py
new file mode 100644
index 0000000..7968d7e
--- /dev/null
+++ b/src/wetgit/web/routes.py
@@ -0,0 +1,198 @@
+"""Web routes — server-rendered HTML pagina's."""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+import markdown
+from fastapi import APIRouter, Query, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+
+from wetgit.api.data import RegelingStore
+
+router = APIRouter()
+templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
+
+BWB_GRONDWET = "BWBR0001840"
+
+# Store wordt gezet vanuit app.py
+_store: RegelingStore | None = None
+
+
+def init_routes(store: RegelingStore) -> None:
+ """Initialiseer de routes met een RegelingStore."""
+ global _store
+ _store = store
+
+
+def _get_store() -> RegelingStore:
+ if _store is None:
+ raise RuntimeError("Store niet geinitialiseerd")
+ return _store
+
+
+@router.get("/", response_class=HTMLResponse)
+def homepage(request: Request) -> HTMLResponse:
+ """Homepage met zoekbalk en Grondwet overzicht."""
+ store = _get_store()
+ tekst = store.get_tekst(BWB_GRONDWET) or ""
+
+ # Tel artikelen en hoofdstukken
+ artikelen = len(re.findall(r"^### Artikel", tekst, re.MULTILINE))
+ versies = store.get_versies(BWB_GRONDWET)
+
+ # Extraheer hoofdstukken voor de inhoudsopgave
+ hoofdstukken = []
+ for match in re.finditer(r"^## (.+)$", tekst, re.MULTILINE):
+ titel = match.group(1).strip()
+ anchor = re.sub(r"[^\w\s-]", "", titel.lower()).replace(" ", "-")
+ hoofdstukken.append({"titel": titel, "anchor": anchor})
+
+ return templates.TemplateResponse("index.html", {
+ "request": request,
+ "stats": {
+ "artikelen": artikelen,
+ "versies": len(versies),
+ "regelingen": len(store.list_regelingen()),
+ },
+ "hoofdstukken": hoofdstukken,
+ })
+
+
+@router.get("/grondwet", response_class=HTMLResponse)
+def grondwet(request: Request) -> HTMLResponse:
+ """De Grondwet als leesbare pagina."""
+ store = _get_store()
+ tekst = store.get_tekst(BWB_GRONDWET)
+ if not tekst:
+ return HTMLResponse("
Niet gevonden
", status_code=404)
+
+ # Strip frontmatter
+ if tekst.startswith("---"):
+ _, _, tekst = tekst.split("---", 2)
+
+ # Markdown → HTML met anchor IDs op artikelen
+ content_html = markdown.markdown(tekst, extensions=["toc"])
+
+ # Voeg ID's toe aan artikelen
+ content_html = re.sub(
+ r"Artikel (\S+)",
+ r'Artikel \1',
+ content_html,
+ )
+
+ regeling = store.get_regeling(BWB_GRONDWET) or {}
+ versies = store.get_versies(BWB_GRONDWET)
+ summary_path = Path(store.repo_path) / (regeling.get("pad", "") + "/summary.md") if regeling.get("pad") else None
+
+ return templates.TemplateResponse("regeling.html", {
+ "request": request,
+ "titel": regeling.get("titel", "Grondwet"),
+ "bwb_id": BWB_GRONDWET,
+ "type": regeling.get("type", "wet"),
+ "versies": len(versies),
+ "has_summary": summary_path and summary_path.exists() if summary_path else False,
+ "content_html": content_html,
+ })
+
+
+@router.get("/grondwet/samenvatting", response_class=HTMLResponse)
+def samenvatting(request: Request) -> HTMLResponse:
+ """AI-samenvatting van de Grondwet."""
+ store = _get_store()
+ regeling = store.get_regeling(BWB_GRONDWET) or {}
+ pad = regeling.get("pad", "")
+
+ summary_path = store.repo_path / pad / "summary.md"
+ if not summary_path.exists():
+ return HTMLResponse("Samenvatting niet beschikbaar
", status_code=404)
+
+ tekst = summary_path.read_text(encoding="utf-8")
+ content_html = markdown.markdown(tekst)
+
+ return templates.TemplateResponse("samenvatting.html", {
+ "request": request,
+ "content_html": content_html,
+ })
+
+
+@router.get("/zoeken", response_class=HTMLResponse)
+def zoeken_page(
+ request: Request,
+ q: str = Query("", description="Zoekterm"),
+ mode: str = Query("keyword", description="Zoekmodus"),
+) -> HTMLResponse:
+ """Zoekpagina."""
+ results = []
+
+ if q and len(q) >= 2:
+ import os
+ from wetgit.api.search import MeiliSearch
+ from wetgit.ai.semantic import SemanticSearch
+
+ meili_url = os.environ.get("MEILI_URL", "http://127.0.0.1:7700")
+ qdrant_url = os.environ.get("QDRANT_URL", "http://127.0.0.1:6333")
+
+ if mode == "semantic":
+ sem = SemanticSearch(qdrant_url=qdrant_url)
+ if sem.health():
+ results = sem.search(q, limit=20)
+ else:
+ meili = MeiliSearch(url=meili_url)
+ if meili.health():
+ raw = meili.search(q, limit=20)
+ results = [
+ {
+ "bwb_id": h["bwb_id"],
+ "titel": h.get("regeling_titel", ""),
+ "artikel": f"Artikel {h.get('artikel_nummer', '?')}",
+ "context": h.get("tekst", "")[:200],
+ }
+ for h in raw.get("hits", [])
+ ]
+
+ return templates.TemplateResponse("zoeken.html", {
+ "request": request,
+ "query": q,
+ "mode": mode,
+ "results": results,
+ })
+
+
+@router.get("/historie", response_class=HTMLResponse)
+def historie(request: Request) -> HTMLResponse:
+ """Tijdlijn van versies."""
+ store = _get_store()
+ versies = store.get_versies(BWB_GRONDWET)
+
+ return templates.TemplateResponse("historie.html", {
+ "request": request,
+ "versies": versies,
+ })
+
+
+@router.get("/diff", response_class=HTMLResponse)
+def diff_page(
+ request: Request,
+ van: str = Query(..., description="Van datum"),
+ tot: str = Query(..., description="Tot datum"),
+) -> HTMLResponse:
+ """Diff pagina."""
+ store = _get_store()
+ result = store.get_diff(BWB_GRONDWET, van, tot)
+
+ if not result:
+ return HTMLResponse("Geen diff beschikbaar
", status_code=404)
+
+ diff_lines = result["diff"].split("\n")
+
+ return templates.TemplateResponse("diff.html", {
+ "request": request,
+ "van": van,
+ "tot": tot,
+ "added": result["regels_toegevoegd"],
+ "removed": result["regels_verwijderd"],
+ "diff_lines": diff_lines,
+ })
diff --git a/src/wetgit/web/static/style.css b/src/wetgit/web/static/style.css
new file mode 100644
index 0000000..92dbb52
--- /dev/null
+++ b/src/wetgit/web/static/style.css
@@ -0,0 +1,164 @@
+/* WetGit — sober, leesbaar, overheidsstijl */
+
+:root {
+ --color-bg: #ffffff;
+ --color-text: #1a1a2e;
+ --color-muted: #6b7280;
+ --color-border: #e5e7eb;
+ --color-accent: #1e40af;
+ --color-accent-light: #dbeafe;
+ --color-green: #166534;
+ --color-green-bg: #dcfce7;
+ --color-red: #991b1b;
+ --color-red-bg: #fee2e2;
+ --color-warn-bg: #fef3c7;
+ --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ --font-mono: "SF Mono", "Fira Code", monospace;
+ --max-width: 800px;
+}
+
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+body {
+ font-family: var(--font-body);
+ color: var(--color-text);
+ background: var(--color-bg);
+ line-height: 1.7;
+ font-size: 17px;
+}
+
+a { color: var(--color-accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* Layout */
+.container { max-width: var(--max-width); margin: 0 auto; padding: 0 1.5rem; }
+
+header {
+ border-bottom: 1px solid var(--color-border);
+ padding: 1rem 0;
+ margin-bottom: 2rem;
+}
+header .container { display: flex; align-items: center; justify-content: space-between; }
+header h1 { font-size: 1.3rem; font-weight: 700; }
+header h1 a { color: var(--color-text); }
+header nav a { margin-left: 1.5rem; color: var(--color-muted); font-size: 0.9rem; }
+header nav a:hover { color: var(--color-text); }
+
+footer {
+ border-top: 1px solid var(--color-border);
+ padding: 2rem 0;
+ margin-top: 3rem;
+ color: var(--color-muted);
+ font-size: 0.85rem;
+}
+
+/* Search */
+.search-box {
+ display: flex;
+ gap: 0.5rem;
+ margin: 1.5rem 0;
+}
+.search-box input[type="text"] {
+ flex: 1;
+ padding: 0.7rem 1rem;
+ border: 2px solid var(--color-border);
+ border-radius: 6px;
+ font-size: 1rem;
+ font-family: var(--font-body);
+}
+.search-box input:focus { outline: none; border-color: var(--color-accent); }
+.search-box select {
+ padding: 0.7rem;
+ border: 2px solid var(--color-border);
+ border-radius: 6px;
+ font-size: 0.9rem;
+ background: white;
+}
+.search-box button {
+ padding: 0.7rem 1.5rem;
+ background: var(--color-accent);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 1rem;
+ cursor: pointer;
+}
+.search-box button:hover { background: #1e3a8a; }
+
+/* Content */
+h1 { font-size: 2rem; margin-bottom: 0.5rem; }
+h2 { font-size: 1.5rem; margin: 2rem 0 0.8rem; border-bottom: 1px solid var(--color-border); padding-bottom: 0.3rem; }
+h3 { font-size: 1.15rem; margin: 1.5rem 0 0.5rem; }
+
+.subtitle { color: var(--color-muted); margin-bottom: 2rem; }
+.tag {
+ display: inline-block;
+ background: var(--color-accent-light);
+ color: var(--color-accent);
+ padding: 0.15rem 0.6rem;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+/* Disclaimer */
+.disclaimer {
+ background: var(--color-warn-bg);
+ border-left: 4px solid #f59e0b;
+ padding: 0.8rem 1rem;
+ margin: 1.5rem 0;
+ font-size: 0.9rem;
+ border-radius: 0 4px 4px 0;
+}
+
+/* Artikelen */
+.artikel { margin: 1.5rem 0; }
+.artikel h3 a { color: var(--color-text); }
+.artikel-tekst { padding-left: 0; }
+
+/* Zoekresultaten */
+.result { border-bottom: 1px solid var(--color-border); padding: 1rem 0; }
+.result:last-child { border-bottom: none; }
+.result-title { font-weight: 600; }
+.result-context { color: var(--color-muted); font-size: 0.95rem; margin-top: 0.3rem; }
+.result-score { font-size: 0.8rem; color: var(--color-muted); }
+mark { background: #fef08a; padding: 0.1rem 0.2rem; border-radius: 2px; }
+
+/* Diff */
+.diff-line { font-family: var(--font-mono); font-size: 0.85rem; white-space: pre-wrap; padding: 0.1rem 0.5rem; }
+.diff-add { background: var(--color-green-bg); color: var(--color-green); }
+.diff-del { background: var(--color-red-bg); color: var(--color-red); }
+.diff-hunk { color: var(--color-accent); font-weight: 600; margin-top: 1rem; }
+
+/* Versie-tijdlijn */
+.timeline { list-style: none; border-left: 3px solid var(--color-accent-light); padding-left: 1.5rem; }
+.timeline li { margin: 1rem 0; position: relative; }
+.timeline li::before {
+ content: "";
+ position: absolute;
+ left: -1.85rem;
+ top: 0.4rem;
+ width: 10px;
+ height: 10px;
+ background: var(--color-accent);
+ border-radius: 50%;
+}
+.timeline-date { font-weight: 700; color: var(--color-accent); }
+
+/* Hero (homepage) */
+.hero { text-align: center; padding: 3rem 0 2rem; }
+.hero h1 { font-size: 2.5rem; }
+.hero .subtitle { font-size: 1.2rem; }
+.hero blockquote {
+ font-style: italic;
+ color: var(--color-muted);
+ margin: 1.5rem auto;
+ max-width: 500px;
+ font-size: 0.95rem;
+}
+
+/* Stats */
+.stats { display: flex; gap: 2rem; justify-content: center; margin: 2rem 0; flex-wrap: wrap; }
+.stat { text-align: center; }
+.stat-number { font-size: 2rem; font-weight: 700; color: var(--color-accent); }
+.stat-label { font-size: 0.85rem; color: var(--color-muted); }
diff --git a/src/wetgit/web/templates/base.html b/src/wetgit/web/templates/base.html
new file mode 100644
index 0000000..c226017
--- /dev/null
+++ b/src/wetgit/web/templates/base.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+ {% block title %}WetGit{% endblock %} — Nederlandse wetgeving als code
+
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+
+
+
diff --git a/src/wetgit/web/templates/diff.html b/src/wetgit/web/templates/diff.html
new file mode 100644
index 0000000..b772495
--- /dev/null
+++ b/src/wetgit/web/templates/diff.html
@@ -0,0 +1,21 @@
+{% extends "base.html" %}
+{% block title %}Diff: {{ van }} → {{ tot }}{% endblock %}
+
+{% block content %}
+Diff: Grondwet
+
{{ van }} → {{ tot }} | +{{ added }} / -{{ removed }} regels
+
+
+{% for line in diff_lines %}
+{% if line.startswith('+') and not line.startswith('+++') %}
+
{{ line }}
+{% elif line.startswith('-') and not line.startswith('---') %}
+
{{ line }}
+{% elif line.startswith('@@') %}
+
{{ line }}
+{% elif not line.startswith('diff ') and not line.startswith('index ') %}
+
{{ line }}
+{% endif %}
+{% endfor %}
+
+{% endblock %}
diff --git a/src/wetgit/web/templates/historie.html b/src/wetgit/web/templates/historie.html
new file mode 100644
index 0000000..63830fd
--- /dev/null
+++ b/src/wetgit/web/templates/historie.html
@@ -0,0 +1,18 @@
+{% extends "base.html" %}
+{% block title %}Historie — Grondwet{% endblock %}
+
+{% block content %}
+Historie: Grondwet
+{{ versies|length }} versies beschikbaar. Klik op twee versies om een diff te zien.
+
+
+{% for v in versies %}
+-
+ {{ v.datum }}
+ {% if not loop.last %}
+ — diff met vorige
+ {% endif %}
+
+{% endfor %}
+
+{% endblock %}
diff --git a/src/wetgit/web/templates/index.html b/src/wetgit/web/templates/index.html
new file mode 100644
index 0000000..27b0495
--- /dev/null
+++ b/src/wetgit/web/templates/index.html
@@ -0,0 +1,47 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
WetGit
+
Nederlandse wetgeving als code.
+ Elke wet een Markdown-bestand. Elke wijziging een Git-commit.
+
+
In de geest van Dirk Volkertzoon Coornhert (1522-1590) — humanist,
+ graveur en voorvechter van een transparant rechtssysteem.
+
+
+
+
+
+
+
{{ stats.artikelen }}
+
artikelen
+
+
+
{{ stats.versies }}
+
historische versies
+
+
+
{{ stats.regelingen }}
+
regelingen
+
+
+
+Grondwet voor het Koninkrijk der Nederlanden
+
+
+ Dit is een AI-verrijkte weergave van de Grondwet. Raadpleeg altijd
+
wetten.overheid.nl voor de officiele tekst.
+
+
+{% for h in hoofdstukken %}
+
+{% endfor %}
+{% endblock %}
diff --git a/src/wetgit/web/templates/regeling.html b/src/wetgit/web/templates/regeling.html
new file mode 100644
index 0000000..f66732a
--- /dev/null
+++ b/src/wetgit/web/templates/regeling.html
@@ -0,0 +1,20 @@
+{% extends "base.html" %}
+{% block title %}{{ titel }}{% endblock %}
+
+{% block content %}
+{{ titel }}
+
+ {{ type }}
+ BWB-ID: {{ bwb_id }} |
+ Officiele tekst |
+ {{ versies }} versies
+ {% if has_summary %} | AI-samenvatting{% endif %}
+
+
+
+
+{{ content_html | safe }}
+{% endblock %}
diff --git a/src/wetgit/web/templates/samenvatting.html b/src/wetgit/web/templates/samenvatting.html
new file mode 100644
index 0000000..e7d61ba
--- /dev/null
+++ b/src/wetgit/web/templates/samenvatting.html
@@ -0,0 +1,18 @@
+{% extends "base.html" %}
+{% block title %}Samenvatting — Grondwet{% endblock %}
+
+{% block content %}
+Samenvatting: Grondwet
+AI-gegenereerde samenvatting in begrijpelijk Nederlands (B1-niveau)
+
+
+ Dit is een AI-gegenereerde samenvatting en geen juridisch advies.
+ Raadpleeg altijd
wetten.overheid.nl.
+
+
+{{ content_html | safe }}
+
+
+ Terug naar de volledige tekst
+
+{% endblock %}
diff --git a/src/wetgit/web/templates/zoeken.html b/src/wetgit/web/templates/zoeken.html
new file mode 100644
index 0000000..7284072
--- /dev/null
+++ b/src/wetgit/web/templates/zoeken.html
@@ -0,0 +1,35 @@
+{% extends "base.html" %}
+{% block title %}Zoeken{% endblock %}
+
+{% block content %}
+Zoeken
+
+
+
+{% if query %}
+{{ results|length }} resultaten voor "{{ query }}"
+{% if mode == 'semantic' %} (semantisch zoeken){% endif %}
+
+{% for r in results %}
+
+
+
{{ r.artikel }}
+ — {{ r.titel }}
+ {% if r.score %}
({{ "%.1f"|format(r.score * 100) }}% match){% endif %}
+
+
{{ r.context[:200] }}
+
+{% endfor %}
+
+{% if not results %}
+Geen resultaten gevonden.
+{% endif %}
+{% endif %}
+{% endblock %}