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 + + + +
+
+

WetGit

+ +
+
+ +
+ {% 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.

+ + +{% 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 %} +

{{ h.titel }}

+{% 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 %} +

+ +
+ Dit is geen officiele bron. Raadpleeg altijd + wetten.overheid.nl. +
+ +{{ 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 %}