feat: MVP webinterface

Server-rendered HTML via Jinja2 templates in FastAPI:
- Homepage: zoekbalk, statistieken, inhoudsopgave Grondwet
- /grondwet: volledige tekst als leesbare pagina
- /grondwet/samenvatting: AI-samenvatting (B1-niveau)
- /zoeken: keyword (Meilisearch) + semantisch (Qdrant)
- /historie: tijdlijn van versies
- /diff: visuele vergelijking tussen twee versies
- Sober, responsive CSS (overheidsstijl)
- Disclaimer op elke pagina

flake.nix: jinja2 + markdown packages toegevoegd

Sluit #39
This commit is contained in:
Coornhert 2026-03-30 10:48:36 +02:00
parent b655f56f8c
commit 6db0f6afc3
12 changed files with 570 additions and 0 deletions

View file

@ -32,6 +32,10 @@
click click
rich # Terminal formatting rich # Terminal formatting
# Webinterface
jinja2 # HTML templates
markdown # Markdown → HTML rendering
# Testing # Testing
pytest pytest
pytest-cov pytest-cov

View file

@ -47,6 +47,15 @@ app.add_middleware(
store = RegelingStore(REPO_PATH) 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 --- # --- Health ---

View file

@ -0,0 +1 @@
"""WetGit webinterface."""

198
src/wetgit/web/routes.py Normal file
View file

@ -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("<h1>Niet gevonden</h1>", 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"<h3>Artikel (\S+)",
r'<h3 id="artikel-\1">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("<h1>Samenvatting niet beschikbaar</h1>", 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("<h1>Geen diff beschikbaar</h1>", 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,
})

View file

@ -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); }

View file

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}WetGit{% endblock %} — Nederlandse wetgeving als code</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<div class="container">
<h1><a href="/">WetGit</a></h1>
<nav>
<a href="/grondwet">Grondwet</a>
<a href="/zoeken">Zoeken</a>
<a href="/historie">Historie</a>
<a href="/api/docs">API</a>
</nav>
</div>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
<footer>
<div class="container">
<p>WetGit is geen officiele bron van wetgeving. Raadpleeg altijd
<a href="https://wetten.overheid.nl">wetten.overheid.nl</a> voor de authentieke tekst.</p>
<p>Wetteksten: CC0 (publiek domein) | Tooling: MIT |
<a href="https://git.wetgit.nl/wetgit">git.wetgit.nl</a></p>
</div>
</footer>
</body>
</html>

View file

@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Diff: {{ van }} → {{ tot }}{% endblock %}
{% block content %}
<h1>Diff: Grondwet</h1>
<p class="subtitle">{{ van }} → {{ tot }} | +{{ added }} / -{{ removed }} regels</p>
<div style="background: #f8f9fa; border-radius: 6px; padding: 1rem; overflow-x: auto;">
{% for line in diff_lines %}
{% if line.startswith('+') and not line.startswith('+++') %}
<div class="diff-line diff-add">{{ line }}</div>
{% elif line.startswith('-') and not line.startswith('---') %}
<div class="diff-line diff-del">{{ line }}</div>
{% elif line.startswith('@@') %}
<div class="diff-line diff-hunk">{{ line }}</div>
{% elif not line.startswith('diff ') and not line.startswith('index ') %}
<div class="diff-line">{{ line }}</div>
{% endif %}
{% endfor %}
</div>
{% endblock %}

View file

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}Historie — Grondwet{% endblock %}
{% block content %}
<h1>Historie: Grondwet</h1>
<p class="subtitle">{{ versies|length }} versies beschikbaar. Klik op twee versies om een diff te zien.</p>
<ul class="timeline">
{% for v in versies %}
<li>
<span class="timeline-date">{{ v.datum }}</span>
{% if not loop.last %}
<a href="/diff?van={{ versies[loop.index].datum }}&tot={{ v.datum }}">diff met vorige</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% endblock %}

View file

@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block content %}
<div class="hero">
<h1>WetGit</h1>
<p class="subtitle">Nederlandse wetgeving als code.<br>
Elke wet een Markdown-bestand. Elke wijziging een Git-commit.</p>
<blockquote>In de geest van Dirk Volkertzoon Coornhert (1522-1590) — humanist,
graveur en voorvechter van een transparant rechtssysteem.</blockquote>
</div>
<form class="search-box" action="/zoeken" method="get">
<input type="text" name="q" placeholder="Zoek in de Grondwet... bijv. 'privacy' of 'mag mijn baas mijn e-mail lezen?'" autofocus>
<select name="mode">
<option value="keyword">Keyword</option>
<option value="semantic">Semantisch</option>
</select>
<button type="submit">Zoek</button>
</form>
<div class="stats">
<div class="stat">
<div class="stat-number">{{ stats.artikelen }}</div>
<div class="stat-label">artikelen</div>
</div>
<div class="stat">
<div class="stat-number">{{ stats.versies }}</div>
<div class="stat-label">historische versies</div>
</div>
<div class="stat">
<div class="stat-number">{{ stats.regelingen }}</div>
<div class="stat-label">regelingen</div>
</div>
</div>
<h2>Grondwet voor het Koninkrijk der Nederlanden</h2>
<div class="disclaimer">
Dit is een AI-verrijkte weergave van de Grondwet. Raadpleeg altijd
<a href="https://wetten.overheid.nl/BWBR0001840">wetten.overheid.nl</a> voor de officiele tekst.
</div>
{% for h in hoofdstukken %}
<h3><a href="/grondwet#{{ h.anchor }}">{{ h.titel }}</a></h3>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}{{ titel }}{% endblock %}
{% block content %}
<h1>{{ titel }}</h1>
<p class="subtitle">
<span class="tag">{{ type }}</span>
BWB-ID: {{ bwb_id }} |
<a href="https://wetten.overheid.nl/{{ bwb_id }}">Officiele tekst</a> |
<a href="/historie">{{ versies }} versies</a>
{% if has_summary %} | <a href="/grondwet/samenvatting">AI-samenvatting</a>{% endif %}
</p>
<div class="disclaimer">
Dit is geen officiele bron. Raadpleeg altijd
<a href="https://wetten.overheid.nl/{{ bwb_id }}">wetten.overheid.nl</a>.
</div>
{{ content_html | safe }}
{% endblock %}

View file

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}Samenvatting — Grondwet{% endblock %}
{% block content %}
<h1>Samenvatting: Grondwet</h1>
<p class="subtitle">AI-gegenereerde samenvatting in begrijpelijk Nederlands (B1-niveau)</p>
<div class="disclaimer">
Dit is een AI-gegenereerde samenvatting en geen juridisch advies.
Raadpleeg altijd <a href="https://wetten.overheid.nl/BWBR0001840">wetten.overheid.nl</a>.
</div>
{{ content_html | safe }}
<p style="margin-top: 2rem;">
<a href="/grondwet">Terug naar de volledige tekst</a>
</p>
{% endblock %}

View file

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Zoeken{% endblock %}
{% block content %}
<h1>Zoeken</h1>
<form class="search-box" action="/zoeken" method="get">
<input type="text" name="q" value="{{ query }}" placeholder="Zoek in alle wetgeving..." autofocus>
<select name="mode">
<option value="keyword" {% if mode == 'keyword' %}selected{% endif %}>Keyword</option>
<option value="semantic" {% if mode == 'semantic' %}selected{% endif %}>Semantisch</option>
</select>
<button type="submit">Zoek</button>
</form>
{% if query %}
<p class="subtitle">{{ results|length }} resultaten voor "{{ query }}"
{% if mode == 'semantic' %} (semantisch zoeken){% endif %}</p>
{% for r in results %}
<div class="result">
<div class="result-title">
<a href="/grondwet#artikel-{{ r.artikel.split(' ')[-1] }}">{{ r.artikel }}</a>
— {{ r.titel }}
{% if r.score %}<span class="result-score">({{ "%.1f"|format(r.score * 100) }}% match)</span>{% endif %}
</div>
<div class="result-context">{{ r.context[:200] }}</div>
</div>
{% endfor %}
{% if not results %}
<p>Geen resultaten gevonden.</p>
{% endif %}
{% endif %}
{% endblock %}