Phases 6 et 7 : application SvelteKit, PWA, systemd, documentation

Application — rendu serveur sur adapter-node, conforme au §3bis :
- tableau de bord, recherche à facettes, fiche détaillée, calendrier
- /a-propos et /methode prérendues ; tout le reste en rendu serveur
- API JSON ouverte : /api/textes, /api/textes/[slug], /api/echeances
- recherche FTS5 avec surlignage et classement bm25 pondéré (le titre pèse
  dix fois plus que les points clés)
- facettes recalculées sur le résultat filtré par les AUTRES facettes : cocher
  un thème doit recompter les statuts encore disponibles, sinon les compteurs
  mentent

Identité OKI sobrifiée : tokens en CSS vanilla, thème sombre par défaut et
clair opt-in, or comme seule couleur d'action, flag-bar une fois par écran,
polices Archivo/Inter auto-hébergées. Politique de sécurité de contenu
interdisant toute requête tierce. Les statuts ne reposent jamais sur la seule
couleur : libellé en toutes lettres et pastille de forme distincte.

Budget respecté très largement : 6,6 Ko de JavaScript sur l'accueil (3 Ko
compressés) pour un plafond de 100 Ko. Les faits clés — AFD à 500 €, saisine
2026-915 DC, statut — sont lisibles sans JavaScript.

Trois défauts trouvés en testant l'application, pas en relisant le code :
- v_textes est une vue, et une vue SQLite n'a pas de rowid : la jointure avec
  l'index plein texte échouait (migration 005)
- ppl-montagne remontait en « pertinence forte » alors que le corpus dit
  « sans portée pour la Guadeloupe » — la cotation comptait le mot sans lire
  la négation. Idem pour pjl-logement et accord-globe. Corrigé par une lecture
  du voisinage, avec quatre tests de non-régression.
- les libellés d'affichage étaient importés depuis /server dans un
  composant, ce que SvelteKit interdit à raison

PWA : service worker réseau-d'abord pour les pages, cache-d'abord pour le
coffre du build. Une veille législative ne doit pas servir une page périmée
quand le réseau répond — une date de promulgation change tout. Les réponses
issues du cache portent un en-tête qui le dit.

systemd : timer du pipeline (6 h, 18 h, dimanche 9 h, avec dispersion et
rattrapage), application activée par socket avec mise en sommeil à 300 s.
Unités durcies, validées par systemd-analyze.

Documentation : README d'installation sur Debian/Ubuntu vierge,
README-pipeline avec le piège des deux couples d'identifiants PISTE, et
scripts/verifier-conformite.sh qui contrôle mécaniquement le §3bis et le §8.

172 tests, npm run check à 0 erreur, make verifier au vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Cyber Mawonaj
2026-07-25 22:34:22 -04:00
co-authored by Claude Opus 5
parent feb5544599
commit 34d81c66a8
52 changed files with 7459 additions and 40 deletions
+209
View File
@@ -0,0 +1,209 @@
# Pipeline de veille — installation, clés, dépannage
Le pipeline fait deux choses : **remplir** la base depuis le corpus de recherche
(`make seed`), puis la **tenir à jour** depuis les sources officielles
(`make update`). Il n'écrit jamais dans `data/input/`, qui est en lecture seule.
---
## 1. Installation
```bash
uv venv --python 3.12
uv pip install -e ".[dev]"
cp .env.example .env # puis renseigner les clés, voir §2
make seed # remplit data/veille.db
```
`make seed` est idempotent : le relancer ne duplique rien. Pour repartir d'une
base vierge : `make reseed`.
Le compte-rendu affiché en fin de seed est le vrai livrable : il dit combien de
textes sont entrés, combien de citations ont été résolues, et — surtout — ce qui
manque.
---
## 2. Clés d'API
### Légifrance, via PISTE
Créez un compte sur [piste.gouv.fr](https://piste.gouv.fr), déclarez une
application, et **abonnez-la à l'API « Légifrance »**.
> **Le piège à connaître.** Une application PISTE affiche *deux* couples de
> valeurs. Seul le couple **OAuth** fonctionne ici :
>
> | Ce que PISTE affiche | À mettre dans `.env` |
> |---|---|
> | **Client ID** / **Client secret** | ✅ `LEGIFRANCE_CLIENT_ID` / `LEGIFRANCE_CLIENT_SECRET` |
> | API key / API key secret | ❌ produit `invalid_client` à la demande de jeton |
>
> Vérifié le 25 juillet 2026 sur un compte réel : la clé d'API est refusée par
> le point d'accès OAuth, quelle que soit la méthode d'authentification tentée
> (corps de requête, en-tête Basic, avec ou sans `scope`).
Second piège : **les identifiants de production ne fonctionnent pas en bac à
sable.** `sandbox` exige une application distincte, déclarée sur l'environnement
de test. Laissez `LEGIFRANCE_ENV=prod` sauf si vous avez explicitement créé
cette seconde application.
Vérification en une commande :
```bash
.venv/bin/python -c "
from pipeline.collecteurs.http import ClientHttp
from pipeline.collecteurs.legifrance import CollecteurLegifrance
with ClientHttp() as c:
col = CollecteurLegifrance(c, numeros_a_verifier=['2026-491'])
print(col.est_disponible())
print(col.collecter().textes)
"
```
### Aucune autre clé n'est nécessaire
Le Sénat et le Conseil constitutionnel sont interrogés sans authentification.
---
## 3. Ce qui marche sans clé PISTE
C'est le point important : **le pipeline reste utile sans Légifrance.**
| Collecteur | Sans clé PISTE | Avec clé PISTE |
|---|---|---|
| `legifrance` | ⛔ désactivé, motif affiché dans le rapport | ✅ vérifie chaque numéro de loi en base |
| `senat` | ✅ liste chronologique des lois promulguées | ✅ idem, sert de contrôle croisé |
| `conseil_constitutionnel` | ✅ affaires en instance + décisions rendues | ✅ idem |
Ce qui est **perdu** sans clé : la confirmation de l'intitulé officiel et de la
date de signature directement au Journal officiel, et l'identifiant JORF de
chaque texte. Ce qui est **conservé** : la détection des promulgations
nouvelles (par le Sénat) et le suivi des décisions constitutionnelles — soit
l'essentiel de ce qui fait bouger un statut.
Le rapport de run indique explicitement quand un collecteur est ignoré :
```
Collecteurs
legifrance ignoré — LEGIFRANCE_CLIENT_ID absent de .env — repli sur
l'Assemblée nationale, le Sénat et le Conseil constitutionnel
senat 31 texte(s), 0 décision(s) (0.4 s)
```
### Une URL du cahier des charges est morte
Le §5.1 indique `senat.fr/lois/index.html` : cette adresse renvoie **404** depuis
la refonte du site. L'équivalent fonctionnel, utilisé par le collecteur, est
`senat.fr/dossiers-legislatifs/lois-promulguees.html`.
De même, `assemblee-nationale.fr/dyn/actualites-accueil/promulgations-de-lois`
renvoie 404. La liste du Sénat couvrant l'intégralité des lois promulguées, elle
tient lieu de source unique pour les promulgations.
---
## 4. Utilisation
```bash
make update-dry # collecte réelle, aucune écriture, rapport des écarts
make update # collecte et applique
```
**Toujours commencer par `--dry-run`.** Le rapport liste chaque écart avec sa
nature, son ancienne et sa nouvelle valeur :
```
Écarts détectés : 11
ajouts : 7
modifications : 4
signalements : 0
[ajout ] (nouveau) 2026-103 · LOI n° 2026-103 du 19 février 2026 de finances
[decision_cc ] ppl-aide-a-mourir 2026-910 DC · date_saisine : — → 2026-07-16
[decision_cc ] loi-2026-650 2026-908 DC · date_decision : — → 2026-07-23
```
Options utiles :
| Option | Effet |
|---|---|
| `--dry-run` | collecte réelle, aucune écriture |
| `--hors-ligne` | n'utilise que le cache HTTP, sans accès réseau |
| `--verbeux` | journalisation détaillée, requête par requête |
---
## 5. Automatisation
### systemd (recommandé)
```bash
sudo cp systemd/veille-legislative.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now veille-legislative.timer
systemctl list-timers veille-legislative.timer
```
Cadence : 6 h et 18 h tous les jours, plus un passage le dimanche à 9 h pour
rattraper les publications de fin de semaine. `RandomizedDelaySec=900` évite de
frapper Légifrance à la seconde ronde. `Persistent=true` rattrape le passage
manqué si la machine était éteinte.
### cron (repli documenté)
```cron
0 6,18 * * * cd /opt/veille-legislative && .venv/bin/python -m pipeline.update >> /var/log/veille.log 2>&1
0 9 * * 0 cd /opt/veille-legislative && .venv/bin/python -m pipeline.update >> /var/log/veille.log 2>&1
```
Le repli cron perd le durcissement de sécurité des unités systemd et la reprise
des passages manqués.
---
## 6. Dépannage
| Symptôme | Cause probable | Remède |
|---|---|---|
| `invalid_client` sur le jeton | clé d'API utilisée au lieu du couple OAuth | voir §2 |
| `invalid_client` avec le bon couple | application non abonnée à l'API Légifrance | demander l'abonnement sur piste.gouv.fr, compter 24-48 h |
| 403 sur `/search` alors que le jeton est obtenu | abonnement en cours de propagation | réessayer plus tard |
| `Base introuvable` | seed jamais lancé | `make seed` |
| `no such column: t.rowid` | base créée avant la migration 005 | `make db-init` (les migrations s'appliquent seules) |
| Collecte lente | premier passage, cache vide | normal : ~25 s pour 27 numéros, quasi instantané ensuite |
| Un collecteur en échec | site indisponible ou structure changée | le run continue ; vérifier le rapport et les fixtures de `tests/fixtures/` |
### Le cache HTTP
Les réponses sont mises en cache dans `data/cache_http/` pendant
`VEILLE_CACHE_TTL_H` heures (6 par défaut). Pour forcer une collecte fraîche :
```bash
rm -rf data/cache_http
```
### Sauvegarde
La base est un fichier unique. Sauvegarder revient à le copier — mais **jamais
pendant un passage**, le mode WAL laissant des fichiers annexes :
```bash
sqlite3 data/veille.db ".backup data/veille-$(date +%F).db"
```
---
## 7. Tests
```bash
make test # pytest avec couverture
make lint # ruff
```
Aucun test ne touche au réseau : les collecteurs sont éprouvés sur des fixtures
HTML figées dans `tests/fixtures/`, capturées le 25 juillet 2026. **Si un test
de fixture échoue après une mise à jour, c'est le site officiel qui a changé de
structure** — c'est exactement ce que ces tests servent à détecter. Recapturez
alors la fixture et adaptez le parseur.
+245 -35
View File
@@ -1,49 +1,259 @@
# Veille législative Guadeloupe
# Veille législative Gwadloup
Plateforme de veille sur la production législative française — lois, projets et
propositions de loi, ordonnances, décisions du Conseil constitutionnel — analysée
sous l'angle des **libertés des individus, des associations et des entreprises**,
avec un zoom Guadeloupe et outre-mer.
Deux composantes indissociables :
1. **`pipeline/`** — automatisation Python. Ingère le corpus de recherche
(date d'arrêté : 25 juillet 2026, périmètre 1ᵉʳ mai → 30 septembre 2026) puis
met la base à jour depuis les sources officielles.
2. **`web/`** — application SvelteKit rendue côté serveur : recherche plein
texte, filtres à facettes partageables, calendrier des échéances, fiches
détaillées avec sources et extraits verbatim.
propositions de loi, ordonnances, décisions du Conseil constitutionnel — lue
sous l'angle des **libertés des individus, des associations et des
entreprises**, avec un zoom Guadeloupe et outre-mer.
> **Aucune donnée législative n'est inventée.** Chaque fait porte sa source
> (URL, date, extrait), son niveau de confiance et son fichier d'origine. Ce qui
> n'est pas établi est marqué « à vérifier » plutôt que deviné.
> (adresse, date, extrait verbatim), son niveau de confiance et son fichier
> d'origine. Ce qui n'est pas établi est marqué « à vérifier », avec son motif
> affiché sur la fiche.
## État d'avancement
---
| Phase | Objet | Statut |
|---|---|---|
| 0 | Lecture du corpus, plan d'implémentation | ✅ `PLAN-IMPLEMENTATION.md` |
| 1 | Socle : schéma SQLite + FTS5, modèles | ✅ |
| 2 | Parseur des tableaux récapitulatifs | ✅ 52 textes |
| 3 | Parseurs des rapports de dimension, citations | ✅ 360 sources |
| 4 | Enrichissement et tests d'acceptation | ✅ 7/7 critères §7 |
| 5 | Collecteurs officiels, run de mise à jour | 🚧 |
| 6 | Application web | ⏳ |
| 7 | PWA, systemd, documentation d'installation | ⏳ |
## Ce que contient la base après `make seed`
## Ce que contient la base
| | |
|---|---|
| Textes législatifs | **52** (27 promulgués, 7 devant le Conseil constitutionnel, 10 en navette, 5 déposés, 2 annoncés, 1 validé) |
| Sources vérifiables | **360**, toutes avec URL — aucun texte sans lien |
| Événements de timeline | 83 |
| Textes législatifs | **52** 27 promulgués, 7 devant le Conseil constitutionnel, 10 en navette, 5 déposés, 2 annoncés, 1 validé |
| Sources vérifiables | **360**, toutes avec adresse — aucun texte sans lien |
| Événements de parcours | 83 |
| Affaires du Conseil constitutionnel | 12 |
| Analyses transversales | 7 |
| Échéances du calendrier | 9 |
| Marqueurs de citation résolus | 100 % (332/332), dont 93 % avec un lien |
| Échéances de calendrier | 9 |
| Marqueurs de citation résolus | **100 %** (332/332), dont 93 % avec un lien |
Les valeurs déduites — 16 cotations Guadeloupe, 17 ventilations d'impact — sont
marquées « à vérifier » avec leur motif, et affichées comme telles.
signalées comme telles, avec leur motif.
L'installation complète sur Debian/Ubuntu vierge est documentée en phase 7.
---
## Architecture
```
veille_legislative_971/
├── data/input/ corpus de recherche — LECTURE SEULE, jamais modifié
├── pipeline/ Python 3.12 : parseurs, collecteurs, run de veille
├── web/ SvelteKit 2 + Svelte 5, rendu serveur sur adapter-node
├── systemd/ 4 unités : timer du pipeline, socket + service de l'app
├── tests/ pytest, fixtures HTML figées — aucun test ne va au réseau
└── scripts/ contrôles de conformité
```
Une seule base SQLite, `data/veille.db`. La recherche plein texte est assurée
par SQLite lui-même (FTS5). Aucun service externe n'est nécessaire au
fonctionnement : ni moteur de recherche, ni réseau de diffusion, ni traceur.
---
## Installation sur Debian ou Ubuntu vierge
Testé sur Debian 13 et Ubuntu 24.04.
### 1. Dépendances système
```bash
sudo apt update
sudo apt install -y git curl build-essential sqlite3 ca-certificates
```
### 2. Node.js ≥ 20.6
La version 20.6 est le minimum : l'application utilise `node --env-file`, qui
n'existe pas avant.
```bash
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
node --version # doit afficher v20.6 ou plus
```
### 3. Python 3.12 et uv
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
uv python install 3.12
```
### 4. Le projet
```bash
sudo mkdir -p /opt/veille-legislative
sudo chown "$USER:$USER" /opt/veille-legislative
git clone <adresse-du-depot> /opt/veille-legislative
cd /opt/veille-legislative
make install # environnement Python + dépendances npm
cp .env.example .env
$EDITOR .env # renseigner les identifiants PISTE — voir README-pipeline.md
```
### 5. Remplir la base et construire l'application
```bash
make seed # → 52 textes, 360 sources
cd web && npm run polices && cd .. # copie les polices depuis npm
make build
make verifier # contrôles de conformité
```
### 6. Vérifier localement
```bash
cd web
HOST=127.0.0.1 PORT=3971 VEILLE_DB=/opt/veille-legislative/data/veille.db node build
```
Puis, dans un autre terminal :
```bash
curl -sI http://127.0.0.1:3971/ | head -1 # HTTP/1.1 200 OK
curl -s http://127.0.0.1:3971/api/textes | head -c 200
```
---
## Mise en service
### Compte dédié
```bash
sudo useradd --system --home /opt/veille-legislative --shell /usr/sbin/nologin veille
sudo chown -R veille:veille /opt/veille-legislative
sudo chmod 600 /opt/veille-legislative/.env
```
### Unités systemd
```bash
sudo cp systemd/*.service systemd/*.timer systemd/*.socket /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now veille-web.socket # l'app se réveille à la demande
sudo systemctl enable --now veille-legislative.timer # collecte 6 h, 18 h, dimanche 9 h
```
L'application est **activée par socket** : systemd tient le port ouvert et ne
démarre Node qu'à la première requête ; sans trafic pendant `IDLE_TIMEOUT`
(300 s), elle s'arrête. Sur un site de veille peu fréquenté, l'empreinte mémoire
tombe à zéro entre deux visites — ce qui compte sur un serveur à 5 € par mois.
Avant de démarrer, ajustez `ORIGIN` dans `veille-web.service` : sans lui, les
soumissions de formulaire sont refusées derrière un reverse proxy.
### Reverse proxy
**Caddy** (le plus court, HTTPS automatique) :
```caddy
veille.example.org {
encode gzip zstd
reverse_proxy 127.0.0.1:3971
}
```
**nginx** :
```nginx
server {
listen 443 ssl http2;
server_name veille.example.org;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
location / {
proxy_pass http://127.0.0.1:3971;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
```
Avec nginx, remplacez `ORIGIN` par `PROTOCOL_HEADER=x-forwarded-proto` et
`HOST_HEADER=x-forwarded-host` dans le service.
### Vérifier
```bash
systemctl status veille-web.socket
systemctl list-timers veille-legislative.timer
journalctl -u veille-legislative -n 40
```
---
## Commandes
```
make install installe pipeline Python et application web
make seed remplit la base depuis data/input/
make reseed repart d'une base vierge
make update-dry collecte réelle, aucune écriture, rapport des écarts
make update collecte et applique
make test pytest avec couverture
make lint ruff
make dev serveur de développement (port 5971)
make build build de production
make check vérification TypeScript
make verifier contrôles de conformité au cahier des charges
```
---
## Dégradations documentées
| Situation | Conséquence |
|---|---|
| Sans identifiants PISTE | Le collecteur Légifrance se désactive et l'annonce dans le rapport. Le Sénat et le Conseil constitutionnel prennent le relais : les promulgations et les décisions restent suivies. Voir `README-pipeline.md` §3. |
| Une source indisponible | Le collecteur concerné échoue seul ; les autres poursuivent. L'erreur figure au rapport de run. |
| Réseau coupé | `make update --hors-ligne` travaille sur le cache HTTP. Côté navigateur, le service worker sert les pages déjà consultées, en signalant qu'elles peuvent être périmées. |
| Base absente | L'application refuse de démarrer avec un message indiquant les emplacements essayés. |
**Deux adresses du cahier des charges sont mortes** et ont été remplacées :
`senat.fr/lois/index.html` et
`assemblee-nationale.fr/dyn/actualites-accueil/promulgations-de-lois` renvoient
toutes deux 404. La liste `senat.fr/dossiers-legislatifs/lois-promulguees.html`
couvre l'ensemble des promulgations et tient lieu de source unique.
---
## Sauvegarde
La base est un fichier unique :
```bash
sqlite3 data/veille.db ".backup /sauvegardes/veille-$(date +%F).db"
```
Ne copiez jamais le fichier pendant un passage du pipeline : le mode WAL laisse
des fichiers annexes. `.backup` s'en charge correctement.
---
## Interface de programmation
Ouverte, sans clé :
| Route | Objet |
|---|---|
| `GET /api/textes` | liste filtrable — `q`, `statut`, `theme`, `type`, `impact`, `guadeloupe`, `confiance`, `from`, `to`, `tri`, `page` |
| `GET /api/textes/[identifiant]` | fiche complète, sources et parcours compris |
| `GET /api/echeances` | calendrier, avec `?jours=N` en option |
```bash
curl -s 'https://veille.example.org/api/textes?statut=saisie_cc&guadeloupe=forte' | jq '.total'
```
---
## Licence
AGPL-3.0-or-later. Le corpus de `data/input/` reste la propriété de ses auteurs.
Si vous réutilisez ces données, citez la source primaire — Journal officiel,
assemblées, Conseil constitutionnel — plutôt que cette plateforme : elle n'est
qu'un intermédiaire.
+75 -5
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
import functools
import re
from dataclasses import dataclass
from dataclasses import dataclass, field
import yaml
@@ -22,6 +22,33 @@ from pipeline import chemins
from pipeline.modeles import Pertinence
from pipeline.parseurs.dates_fr import sans_accents
# Tournures par lesquelles le corpus **écarte** un territoire qu'il vient de
# nommer : « PPL montagne — sans portée pour la Guadeloupe », « PJL logement —
# pas de volet outre-mer à ce stade ». Compter la mention sans lire la négation
# donnerait à ces textes la cotation la plus forte, à l'exact inverse de ce que
# la source affirme.
NEGATIONS = (
"sans portee",
"sans objet",
"sans incidence",
"sans specificite",
"sans volet",
"sans consequence",
"ne concerne pas",
"n'est pas concerne",
"pas concernee",
"pas de volet",
"pas d'incidence",
"non concerne",
"hors champ",
"exclue",
"exclus",
)
# Fenêtre de voisinage, en caractères, autour d'une mention de territoire.
_AVANT = 90
_APRES = 60
@dataclass(slots=True)
class Cotation:
@@ -30,12 +57,24 @@ class Cotation:
pertinence: Pertinence | None
score: int
expressions: list[str]
exclusions: list[str] = field(default_factory=list)
@property
def note(self) -> str | None:
if self.exclusions and not self.expressions:
return (
"Le rapport écarte explicitement ce territoire : "
+ ", ".join(f"« {e} »" for e in self.exclusions[:3])
)
if not self.expressions:
return None
return "Détecté d'après : " + ", ".join(self.expressions[:6])
note = "Détecté d'après : " + ", ".join(self.expressions[:6])
if self.exclusions:
note += " — mais le rapport écarte par ailleurs : " + ", ".join(
f"« {e} »" for e in self.exclusions[:2]
)
return note
@functools.lru_cache(maxsize=1)
@@ -60,18 +99,31 @@ def _motifs() -> list[tuple[re.Pattern[str], str, int]]:
def coter(*fragments: str | None) -> Cotation:
"""Cote la pertinence guadeloupéenne d'un ensemble de fragments de texte."""
"""Cote la pertinence guadeloupéenne d'un ensemble de fragments de texte.
Une mention niée ne compte pas. « Sans portée pour la Guadeloupe » nomme le
territoire pour l'écarter : la lire comme une pertinence forte inverserait
le sens de la source.
"""
plat = sans_accents(" ".join(f for f in fragments if f)).lower()
if not plat.strip():
return Cotation(pertinence=None, score=0, expressions=[])
score = 0
trouvees: list[str] = []
exclusions: list[str] = []
for motif, expression, poids in _motifs():
if motif.search(plat):
positions = [m.start() for m in motif.finditer(plat)]
if not positions:
continue
retenues = [p for p in positions if not _est_niee(plat, p)]
if retenues:
score += poids
trouvees.append(expression)
else:
exclusions.append(_extrait_autour(plat, positions[0]))
seuils = _configuration()["guadeloupe"]["seuils"]
if score >= seuils["forte"]:
@@ -80,10 +132,28 @@ def coter(*fragments: str | None) -> Cotation:
pertinence = Pertinence.MOYENNE
elif score >= seuils["faible"]:
pertinence = Pertinence.FAIBLE
elif exclusions:
# Le territoire est nommé, mais pour être écarté : la pertinence est
# faible, et le motif dit pourquoi.
pertinence = Pertinence.FAIBLE
else:
pertinence = None
return Cotation(pertinence=pertinence, score=score, expressions=trouvees)
return Cotation(
pertinence=pertinence, score=score, expressions=trouvees, exclusions=exclusions
)
def _est_niee(plat: str, position: int) -> bool:
"""Vrai si une tournure d'exclusion entoure la mention trouvée."""
voisinage = plat[max(0, position - _AVANT) : position + _APRES]
return any(negation in voisinage for negation in NEGATIONS)
def _extrait_autour(plat: str, position: int, largeur: int = 70) -> str:
debut = max(0, position - largeur)
fin = min(len(plat), position + largeur)
return " ".join(plat[debut:fin].split())
MOTIF_VERIFICATION = (
+34
View File
@@ -0,0 +1,34 @@
-- ============================================================================
-- Migration 005 — exposer le rowid dans la vue de consultation
--
-- L'index FTS5 est à contenu externe : il se joint à la table `textes` par
-- `rowid`. Or une vue SQLite n'a pas de rowid propre, et `v_textes.rowid`
-- échoue avec « no such column ».
--
-- Sans cette colonne, l'application ne peut pas à la fois filtrer sur les
-- champs dérivés de la vue (`devant_cc`) et joindre l'index plein texte — soit
-- exactement ce que demande la recherche à facettes.
-- ============================================================================
DROP VIEW v_textes;
CREATE VIEW v_textes AS
SELECT
t.rowid AS rowid,
t.*,
(SELECT COUNT(*) FROM sources s WHERE s.texte_id = t.id) AS nb_sources,
(SELECT COUNT(*) FROM evenements e WHERE e.texte_id = t.id) AS nb_evenements,
CASE
WHEN t.statut = 'saisie_cc' THEN 1
WHEN EXISTS (SELECT 1 FROM decisions_cc d
WHERE d.texte_id = t.id
AND d.date_decision IS NULL
AND COALESCE(d.resultat, 'en_instance') = 'en_instance') THEN 1
WHEN EXISTS (SELECT 1 FROM evenements e
WHERE e.texte_id = t.id AND e.type_etape = 'saisine_cc')
AND NOT EXISTS (SELECT 1 FROM evenements e
WHERE e.texte_id = t.id AND e.type_etape = 'decision_cc')
AND t.statut <> 'promulguee' THEN 1
ELSE 0
END AS devant_cc
FROM textes t;
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# Contrôles de conformité au cahier des charges (§3bis et §8).
#
# Ces vérifications sont mécaniques : elles constatent, elles n'interprètent
# pas. Un seul contrôle en échec fait échouer la commande.
# ─────────────────────────────────────────────────────────────────────────────
set -uo pipefail
cd "$(dirname "$0")/.."
echecs=0
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
ko() {
printf ' \033[31m✗\033[0m %s\n' "$1"
echecs=$((echecs + 1))
}
titre() { printf '\n\033[1m%s\033[0m\n' "$1"; }
titre "§3bis — architecture de rendu"
# Seule une déclaration exportée est lue par SvelteKit : chercher la chaîne
# brute ferait échouer le contrôle sur les commentaires qui la mentionnent,
# y compris ceux qui expliquent pourquoi on ne la pose pas.
MOTIF_SSR='^[[:space:]]*export[[:space:]]+const[[:space:]]+ssr[[:space:]]*=[[:space:]]*false'
MOTIF_CSR='^[[:space:]]*export[[:space:]]+const[[:space:]]+csr[[:space:]]*=[[:space:]]*false'
if grep -rnE "$MOTIF_SSR" web/src --include='*.ts' --include='*.js' --include='*.svelte' -q; then
ko "un fichier déclare export const ssr = false (le mode SPA est écarté)"
grep -rnE "$MOTIF_SSR" web/src --include='*.ts' --include='*.js' --include='*.svelte'
else
ok "aucun export const ssr = false : le rendu serveur est le défaut partout"
fi
if grep -rnE "$MOTIF_CSR" web/src/routes/+layout.ts web/src/routes/+layout.server.ts 2>/dev/null -q; then
ko "csr = false déclaré au layout racine"
else
ok "aucun csr = false global"
fi
for page in a-propos methode; do
if grep -q "prerender = true" "web/src/routes/$page/+page.ts" 2>/dev/null; then
ok "prerender = true sur /$page"
else
ko "prerender = true absent de /$page"
fi
done
grep -q "adapter-node" web/svelte.config.js &&
ok "adapter-node en place" || ko "adapter-node absent de svelte.config.js"
grep -q "precompress: true" web/svelte.config.js &&
ok "precompress activé" || ko "precompress non activé"
titre "§3 — souveraineté des ressources"
tiers=$(grep -rnE "https?://(fonts\.googleapis|fonts\.gstatic|cdn\.|unpkg|jsdelivr|googletagmanager|google-analytics)" \
web/src web/static 2>/dev/null | grep -v Binary || true)
if [ -z "$tiers" ]; then
ok "aucune ressource tierce référencée (polices, CDN, traceurs)"
else
ko "ressources tierces trouvées :"
echo "$tiers"
fi
nb_polices=$(find web/static/polices -name '*.woff2' 2>/dev/null | wc -l)
[ "$nb_polices" -gt 0 ] &&
ok "polices auto-hébergées ($nb_polices fichiers woff2)" ||
ko "polices non auto-hébergées — lancer npm run polices"
titre "§8 — livrables"
for chemin in pipeline web data systemd tests README.md README-pipeline.md .env.example Makefile; do
[ -e "$chemin" ] && ok "présent : $chemin" || ko "manquant : $chemin"
done
[ -f web/build/index.js ] &&
ok "build de production présent" || ko "build absent — lancer make build"
titre "§8.2 — intégrité des données"
if [ -f data/veille.db ]; then
total=$(sqlite3 data/veille.db "SELECT COUNT(*) FROM textes")
orphelins=$(sqlite3 data/veille.db \
"SELECT COUNT(*) FROM textes t WHERE NOT EXISTS (SELECT 1 FROM sources s WHERE s.texte_id = t.id)")
cc=$(sqlite3 data/veille.db "SELECT COUNT(*) FROM v_textes WHERE devant_cc = 1")
sans_url=$(sqlite3 data/veille.db \
"SELECT COUNT(*) FROM sources WHERE url NOT LIKE 'http%'")
[ "$total" -ge 49 ] && ok "$total textes (≥ 49 exigés)" || ko "$total textes, 49 exigés"
[ "$orphelins" -eq 0 ] && ok "aucun texte sans source URL" || ko "$orphelins textes sans source"
[ "$sans_url" -eq 0 ] && ok "toutes les sources ont une URL absolue" || ko "$sans_url sources sans URL"
[ "$cc" -ge 7 ] && ok "$cc textes devant le Conseil constitutionnel (≥ 7)" || ko "$cc textes, 7 attendus"
else
ko "base absente — lancer make seed"
fi
titre "Résultat"
if [ "$echecs" -eq 0 ]; then
printf ' \033[32mTous les contrôles passent.\033[0m\n\n'
else
printf ' \033[31m%d contrôle(s) en échec.\033[0m\n\n' "$echecs"
exit 1
fi
+47
View File
@@ -0,0 +1,47 @@
# Passage de veille : collecte les sources officielles et met la base à jour.
#
# Unité oneshot déclenchée par veille-legislative.timer. Installation :
# sudo cp systemd/veille-*.{service,timer,socket} /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable --now veille-legislative.timer
[Unit]
Description=Veille législative — passage de collecte
Documentation=file:///opt/veille-legislative/README-pipeline.md
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=veille
Group=veille
WorkingDirectory=/opt/veille-legislative
Environment=PYTHONUNBUFFERED=1
EnvironmentFile=/opt/veille-legislative/.env
ExecStart=/opt/veille-legislative/.venv/bin/python -m pipeline.update
# Un passage qui s'éternise a rencontré un problème réseau : on l'arrête.
TimeoutStartSec=900
# Durcissement : le pipeline lit le réseau et écrit un seul fichier.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/veille-legislative/data
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
StandardOutput=journal
StandardError=journal
SyslogIdentifier=veille-legislative
[Install]
WantedBy=multi-user.target
+26
View File
@@ -0,0 +1,26 @@
# Cadence de la veille : deux fois par jour, plus un passage renforcé le
# dimanche pour rattraper les publications de fin de semaine au Journal officiel.
#
# Les décisions du Conseil constitutionnel tombent en semaine, les promulgations
# suivent de un à trois jours : deux passages quotidiens suffisent largement, et
# ménagent des services publics gratuits.
[Unit]
Description=Déclenchement de la veille législative (6 h, 18 h, et dimanche 9 h)
Documentation=file:///opt/veille-legislative/README-pipeline.md
[Timer]
OnCalendar=*-*-* 06:00:00
OnCalendar=*-*-* 18:00:00
OnCalendar=Sun *-*-* 09:00:00
# Dispersion : inutile de frapper Légifrance à la seconde ronde en même temps
# que tous les autres services qui l'interrogent.
RandomizedDelaySec=900
# Rattrape le passage manqué si la machine était éteinte.
Persistent=true
Unit=veille-legislative.service
[Install]
WantedBy=timers.target
+52
View File
@@ -0,0 +1,52 @@
# Application web de la veille législative (SvelteKit, adapter-node).
#
# Démarrée par veille-web.socket à la première requête, elle s'arrête d'elle-même
# après IDLE_TIMEOUT secondes sans trafic, et systemd la réveille à la suivante.
#
# Node ≥ 20.6 requis : les variables ne sont PAS chargées automatiquement en
# production, d'où --env-file.
[Unit]
Description=Veille législative — application web
Documentation=file:///opt/veille-legislative/README.md
Requires=veille-web.socket
After=network.target veille-web.socket
[Service]
Type=simple
User=veille
Group=veille
WorkingDirectory=/opt/veille-legislative/web
ExecStart=/usr/bin/node --env-file=/opt/veille-legislative/.env build
Environment=NODE_ENV=production
Environment=VEILLE_DB=/opt/veille-legislative/data/veille.db
# Mise en sommeil après cinq minutes sans requête.
Environment=IDLE_TIMEOUT=300
# Obligatoire derrière un reverse proxy, sinon les soumissions de formulaire
# sont refusées (« Cross-site POST form submissions are forbidden »).
# Alternative : PROTOCOL_HEADER=x-forwarded-proto + HOST_HEADER=x-forwarded-host
Environment=ORIGIN=https://veille.example.org
Restart=on-failure
RestartSec=5
# Durcissement : l'application ne fait que lire la base.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadOnlyPaths=/opt/veille-legislative/data
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
LockPersonality=true
StandardOutput=journal
StandardError=journal
SyslogIdentifier=veille-web
[Install]
WantedBy=multi-user.target
+17
View File
@@ -0,0 +1,17 @@
# Activation par socket de l'application web.
#
# systemd tient le port ouvert et ne démarre l'application qu'à la première
# requête. Combinée à IDLE_TIMEOUT dans le service, l'application se met en
# sommeil sans trafic : l'empreinte mémoire d'un site de veille peu fréquenté
# tombe à zéro entre deux visites, ce qui compte sur un VPS à 5 €.
[Unit]
Description=Socket d'écoute de la veille législative
[Socket]
ListenStream=127.0.0.1:3971
# Le service hérite du descripteur : adapter-node lit LISTEN_PID et LISTEN_FDS.
Accept=no
[Install]
WantedBy=sockets.target
+55
View File
@@ -309,3 +309,58 @@ def test_rapport_lisible(base) -> None:
texte = rapport.en_texte()
assert "Rapport de veille" in texte
assert "conforme aux sources officielles" in texte
# ─────────────────────────────────────────────────────────────────────────────
# Cotation Guadeloupe : les négations
# ─────────────────────────────────────────────────────────────────────────────
def test_une_mention_niee_ne_vaut_pas_pertinence() -> None:
"""« Sans portée pour la Guadeloupe » nomme le territoire pour l'écarter.
Sans lecture de la négation, la PPL montagne recevait la cotation la plus
forte alors que le rapport dit l'inverse — et remontait dans la facette
« pertinence forte pour la Guadeloupe ».
"""
from pipeline.guadeloupe import coter
cotation = coter(
"PPL « pour une montagne vivante et souveraine ». Continuité de "
"l'urbanisation ; sans portée pour la Guadeloupe."
)
assert cotation.pertinence is Pertinence.FAIBLE
assert cotation.score == 0
assert cotation.exclusions
assert "écarte explicitement" in (cotation.note or "")
def test_pas_de_volet_outre_mer_est_une_negation() -> None:
from pipeline.guadeloupe import coter
cotation = coter("PJL logement. Véto des maires ; pas de volet outre-mer à ce stade.")
assert cotation.pertinence is Pertinence.FAIBLE
assert cotation.score == 0
def test_une_mention_affirmee_compte_toujours() -> None:
from pipeline.guadeloupe import coter
cotation = coter(
"Chlordécone : responsabilité de l'État. Guadeloupe et Martinique au cœur du texte."
)
assert cotation.pertinence is Pertinence.FORTE
assert "guadeloupe" in cotation.expressions
assert not cotation.exclusions
def test_la_negation_ne_porte_que_sur_son_voisinage() -> None:
"""Une exclusion lointaine ne doit pas annuler une mention affirmée."""
from pipeline.guadeloupe import coter
cotation = coter(
"Fouilles jusqu'à 40 km du littoral, soit la quasi-totalité de la Guadeloupe. "
+ "Texte de portée générale. " * 12
+ "Le dispositif est sans objet pour les collectivités du Pacifique."
)
assert cotation.pertinence is Pertinence.FORTE
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.svelte-kit/
build/
static/polices/
+3
View File
@@ -0,0 +1,3 @@
engine-strict=false
fund=false
audit=false
+2304
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "veille-legislative-971-web",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Interface de la veille législative Guadeloupe — SvelteKit, rendu serveur",
"license": "AGPL-3.0-or-later",
"scripts": {
"dev": "vite dev --port 5971",
"build": "vite build",
"preview": "vite preview",
"start": "node build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"polices": "node scripts/copier-polices.js"
},
"devDependencies": {
"@fontsource/archivo": "5.1.0",
"@fontsource/inter": "5.1.0",
"@sveltejs/adapter-node": "5.2.12",
"@sveltejs/kit": "2.17.2",
"@sveltejs/vite-plugin-svelte": "5.0.3",
"@types/better-sqlite3": "7.6.12",
"svelte": "5.20.2",
"svelte-check": "4.1.4",
"typescript": "5.7.3",
"vite": "6.1.0"
},
"dependencies": {
"better-sqlite3": "11.8.1"
}
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Copie les polices Archivo et Inter depuis node_modules vers static/polices/.
*
* Le self-hébergement n'est pas une préférence : la politique de sécurité de
* contenu déclarée dans svelte.config.js interdit toute requête vers un hôte
* tiers, Google Fonts compris.
*/
import { copyFileSync, mkdirSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const DESTINATION = 'static/polices';
mkdirSync(DESTINATION, { recursive: true });
const familles = [
{ paquet: '@fontsource/archivo', graisses: ['400', '600', '700', '900'] },
{ paquet: '@fontsource/inter', graisses: ['400', '500', '600', '700'] }
];
let copiees = 0;
for (const { paquet, graisses } of familles) {
const source = join('node_modules', paquet, 'files');
for (const fichier of readdirSync(source)) {
const estLatin = fichier.includes('-latin-') && fichier.endsWith('.woff2');
const estNormal = fichier.includes('-normal.woff2');
const graisseVoulue = graisses.some((g) => fichier.includes(`-${g}-`));
if (estLatin && estNormal && graisseVoulue) {
copyFileSync(join(source, fichier), join(DESTINATION, fichier));
copiees += 1;
}
}
}
console.log(`${copiees} fichiers de police copiés dans ${DESTINATION}`);
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="fr" %sveltekit.theme%>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#0D0D0D" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<link
rel="preload"
href="%sveltekit.assets%/polices/archivo-latin-700-normal.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="%sveltekit.assets%/polices/inter-latin-400-normal.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<!--
Restauration du thème avant le premier rendu, pour éviter le flash de
thème clair. Le script est externe : la politique de sécurité de contenu
interdit l'inline.
-->
<script src="%sveltekit.assets%/theme.js"></script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
@@ -0,0 +1,48 @@
<script lang="ts">
/** Cotation de pertinence guadeloupéenne, avec son motif quand il existe. */
interface Props {
pertinence: string | null;
note?: string | null;
aVerifier?: boolean;
}
const { pertinence, note = null, aVerifier = false }: Props = $props();
const LIBELLES: Record<string, string> = {
forte: 'Gwadloup : forte',
moyenne: 'Gwadloup : moyenne',
faible: 'Gwadloup : faible'
};
const OPACITES: Record<string, string> = { forte: '100%', moyenne: '70%', faible: '45%' };
</script>
{#if pertinence}
<span
class="badge"
style="--intensite: {OPACITES[pertinence] ?? '50%'}"
title={note ?? undefined}
>
{LIBELLES[pertinence] ?? pertinence}{#if aVerifier}<abbr title="Cotation déduite automatiquement, non relue">*</abbr>{/if}
</span>
{/if}
<style>
.badge {
display: inline-flex;
align-items: center;
font-size: 0.76rem;
font-weight: 600;
padding: 0.16em 0.6em;
border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--turquoise-caraibes) var(--intensite), transparent);
background: color-mix(in srgb, var(--turquoise-caraibes) 12%, transparent);
color: var(--blanc-creme);
white-space: nowrap;
}
abbr {
text-decoration: none;
color: var(--or-oki);
margin-left: 0.15em;
cursor: help;
}
</style>
+71
View File
@@ -0,0 +1,71 @@
<script lang="ts">
/**
* Badge de statut.
*
* L'accessibilité daltonienne interdit de faire porter l'information par la
* seule couleur : chaque statut porte donc son libellé en toutes lettres et
* un motif de bordure distinct. La couleur ne fait que renforcer.
*/
import { LIBELLES_STATUT } from '$lib/libelles';
interface Props {
statut: string;
devantCc?: boolean;
compact?: boolean;
}
const { statut, devantCc = false, compact = false }: Props = $props();
const COULEURS: Record<string, string> = {
promulguee: 'var(--statut-promulguee)',
validee_cc: 'var(--statut-attente)',
adoptee_non_promulguee: 'var(--statut-attente)',
saisie_cc: 'var(--statut-cc)',
censuree_partiellement: 'var(--statut-cc)',
navette: 'var(--statut-navette)',
deposee_non_examinee: 'var(--statut-projet)',
annoncee: 'var(--statut-projet)',
rejetee: 'var(--statut-rejetee)'
};
const couleur = $derived(COULEURS[statut] ?? 'var(--muted-fort)');
const libelle = $derived(LIBELLES_STATUT[statut] ?? statut);
</script>
<span class="badge" class:compact style="--teinte: {couleur}">
<span class="pastille" aria-hidden="true"></span>
{libelle}
</span>
{#if devantCc && statut !== 'saisie_cc'}
<span class="badge cc" style="--teinte: var(--statut-cc)">
<span class="pastille" aria-hidden="true"></span>
Devant le Conseil constitutionnel
</span>
{/if}
<style>
.badge {
display: inline-flex;
align-items: center;
gap: 0.4em;
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.01em;
padding: 0.16em 0.6em;
border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--teinte) 65%, transparent);
background: color-mix(in srgb, var(--teinte) 12%, transparent);
color: var(--blanc-creme);
white-space: nowrap;
}
.badge.compact {
font-size: 0.72rem;
}
.pastille {
width: 0.5em;
height: 0.5em;
background: var(--teinte);
border-radius: 1px;
flex: none;
}
</style>
+129
View File
@@ -0,0 +1,129 @@
<script lang="ts">
/** Carte compacte de résultat : titre, badges, date clé, résumé deux lignes. */
import BadgeGuadeloupe from './BadgeGuadeloupe.svelte';
import BadgeStatut from './BadgeStatut.svelte';
import { LIBELLES_THEME, LIBELLES_TYPE } from '$lib/libelles';
import { dateLongue, extrait } from '$lib/format';
import type { Texte } from '$lib/types';
const { texte }: { texte: Texte } = $props();
const dateCle = $derived(
texte.date_promulgation ?? texte.date_adoption ?? texte.statut_date ?? null
);
const libelleDate = $derived(
texte.date_promulgation
? `Promulguée le ${dateLongue(texte.date_promulgation)}`
: texte.date_adoption
? `Adoptée le ${dateLongue(texte.date_adoption)}`
: dateCle
? dateLongue(dateCle)
: ''
);
</script>
<article class="carte resultat">
<h3>
<a href="/textes/{texte.id}">
{#if texte.numero_officiel}<span class="numero">{texte.numero_officiel}</span>{/if}
{texte.titre_court}
</a>
</h3>
<div class="ligne badges">
<BadgeStatut statut={texte.statut} devantCc={texte.devant_cc === 1} compact />
<BadgeGuadeloupe
pertinence={texte.guadeloupe_pertinence}
note={texte.guadeloupe_note}
aVerifier={texte.a_verifier === 1}
/>
<span class="type">{LIBELLES_TYPE[texte.type] ?? texte.type}</span>
</div>
{#if libelleDate}<p class="date">{libelleDate}</p>{/if}
{#if texte.extrait}
<p class="resume">{@html texte.extrait}</p>
{:else if texte.resume}
<p class="resume">{extrait(texte.resume, 200)}</p>
{:else if texte.points_cles.length}
<p class="resume">{extrait(texte.points_cles[0], 200)}</p>
{/if}
{#if texte.themes.length}
<div class="ligne themes">
{#each texte.themes as theme (theme)}
<span class="tag">{LIBELLES_THEME[theme] ?? theme}</span>
{/each}
</div>
{/if}
<p class="meta">
{texte.nb_sources}
{texte.nb_sources > 1 ? 'sources' : 'source'}
{#if texte.a_verifier === 1}
· <span class="verifier">à vérifier</span>
{/if}
</p>
</article>
<style>
.resultat {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
h3 {
margin: 0;
font-size: 1rem;
line-height: 1.3;
text-transform: none;
}
h3 a {
color: var(--blanc-creme);
text-decoration: none;
}
h3 a:hover {
color: var(--or-oki);
text-decoration: underline;
}
.numero {
font-family: var(--font-mono);
font-size: 0.82em;
color: var(--or-oki);
margin-right: 0.35em;
}
.badges {
gap: 0.4rem;
}
.type {
font-size: 0.76rem;
color: var(--muted-fort);
}
.date {
margin: 0;
font-size: 0.82rem;
color: var(--muted);
}
.resume {
margin: 0;
font-size: 0.9rem;
color: var(--muted);
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.themes {
gap: 0.3rem;
}
.meta {
margin: 0;
font-size: 0.75rem;
color: var(--muted-fort);
}
.verifier {
color: var(--or-oki);
}
</style>
@@ -0,0 +1,159 @@
<script lang="ts">
/**
* Panneau de filtres à facettes.
*
* Chaque facette est un lien : la page fonctionne sans JavaScript, et l'état
* des filtres vit dans l'URL, donc se partage et se recharge à l'identique.
* Les compteurs sont recalculés sur le résultat filtré par les *autres*
* facettes — cocher un thème doit recompter les statuts encore disponibles.
*/
import type { Facette, Filtres, Resultats } from '$lib/types';
interface Props {
facettes: Resultats['facettes'];
filtres: Filtres;
lien: (champ: keyof Filtres, valeur: string) => string;
}
const { facettes, filtres, lien }: Props = $props();
const GROUPES: { champ: keyof Filtres; titre: string; cle: keyof Resultats['facettes'] }[] = [
{ champ: 'statut', titre: 'Statut', cle: 'statut' },
{ champ: 'guadeloupe', titre: 'Pertinence Guadeloupe', cle: 'guadeloupe' },
{ champ: 'impact', titre: 'Impact sur les libertés', cle: 'impact' },
{ champ: 'theme', titre: 'Thème', cle: 'theme' },
{ champ: 'type', titre: 'Type de texte', cle: 'type' },
{ champ: 'confiance', titre: 'Niveau de confiance', cle: 'confiance' }
];
function ouvertParDefaut(champ: keyof Filtres, liste: Facette[]): boolean {
return (filtres[champ] as string[]).length > 0 || liste.some((f) => f.actif) ||
champ === 'statut' || champ === 'guadeloupe';
}
</script>
<aside class="panneau-facettes" aria-label="Filtres">
{#each GROUPES as groupe (groupe.champ)}
{@const liste = facettes[groupe.cle]}
{#if liste.length}
<details class="groupe" open={ouvertParDefaut(groupe.champ, liste)}>
<summary>
{groupe.titre}
{#if (filtres[groupe.champ] as string[]).length}
<span class="compteur-actifs">{(filtres[groupe.champ] as string[]).length}</span>
{/if}
</summary>
<ul>
{#each liste as facette (facette.valeur)}
<li>
<!--
Ce sont des liens, pas des cases à cocher : `aria-pressed` n'est pas
valide sur le rôle `link`. L'état est donc porté par un texte
réservé aux lecteurs d'écran, qui dit aussi ce que fera le clic.
-->
<a
href={lien(groupe.champ, facette.valeur)}
class="facette"
class:actif={facette.actif}
rel="nofollow"
>
<span class="case" aria-hidden="true">{facette.actif ? '×' : '+'}</span>
<span class="libelle">{facette.libelle}</span>
<span class="nombre">{facette.nombre}</span>
<span class="visuellement-cache">
{facette.actif
? 'filtre actif, activer pour le retirer'
: `${facette.nombre} résultats, activer pour filtrer`}
</span>
</a>
</li>
{/each}
</ul>
</details>
{/if}
{/each}
</aside>
<style>
.panneau-facettes {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.groupe {
border: var(--border-card);
border-radius: var(--radius-md);
background: var(--card-bg);
}
summary {
cursor: pointer;
padding: 0.6rem 0.8rem;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.03em;
display: flex;
align-items: center;
gap: 0.5rem;
}
summary::marker {
color: var(--or-oki);
}
.compteur-actifs {
background: var(--or-oki);
color: var(--noir-oki);
border-radius: var(--radius-sm);
font-size: 0.7rem;
padding: 0 0.4em;
min-width: 1.3em;
text-align: center;
}
ul {
list-style: none;
margin: 0;
padding: 0 0.5rem 0.5rem;
display: flex;
flex-direction: column;
gap: 1px;
}
.facette {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.32rem 0.45rem;
border-radius: var(--radius-sm);
color: var(--blanc-creme);
text-decoration: none;
font-size: 0.85rem;
border: 1px solid transparent;
}
.facette:hover {
background: var(--card-bg-survol);
border-color: var(--line);
}
.facette.actif {
background: color-mix(in srgb, var(--or-oki) 14%, transparent);
border-color: color-mix(in srgb, var(--or-oki) 60%, transparent);
}
.case {
font-family: var(--font-mono);
color: var(--or-oki);
width: 1ch;
flex: none;
}
.libelle {
flex: 1;
}
.nombre {
color: var(--muted-fort);
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
</style>
+47
View File
@@ -0,0 +1,47 @@
/** Formatage des dates et des libellés, en français. */
const MOIS = [
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'
];
/** « 2026-06-12 » → « 12 juin 2026 ». */
export function dateLongue(iso: string | null | undefined): string {
if (!iso) return '';
const [a, m, j] = iso.slice(0, 10).split('-').map(Number);
if (!a || !m || !j) return iso;
return `${j === 1 ? '1ᵉʳ' : j} ${MOIS[m - 1]} ${a}`;
}
/** « 2026-06-12 » → « 12 juin ». */
export function dateCourte(iso: string | null | undefined): string {
if (!iso) return '';
const [, m, j] = iso.slice(0, 10).split('-').map(Number);
if (!m || !j) return iso;
return `${j === 1 ? '1ᵉʳ' : j} ${MOIS[m - 1]}`;
}
export function mois(iso: string): string {
const [a, m] = iso.slice(0, 10).split('-').map(Number);
return `${MOIS[m - 1]} ${a}`;
}
/** Nombre de jours entre deux dates ISO, positif dans le futur. */
export function joursRestants(iso: string, depuis = '2026-07-25'): number {
const cible = new Date(iso.slice(0, 10)).getTime();
const base = new Date(depuis).getTime();
return Math.round((cible - base) / 86_400_000);
}
export function pluriel(n: number, singulier: string, plurielMot?: string): string {
return n > 1 ? (plurielMot ?? `${singulier}s`) : singulier;
}
/** Tronque sur une frontière de mot, sans couper au milieu. */
export function extrait(texte: string | null | undefined, taille = 180): string {
if (!texte) return '';
if (texte.length <= taille) return texte;
const coupe = texte.slice(0, taille);
const espace = coupe.lastIndexOf(' ');
return `${coupe.slice(0, espace > 0 ? espace : taille)}`;
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Libellés d'affichage du vocabulaire métier.
*
* Ce module est partagé serveur et client : il ne touche ni à la base ni au
* système de fichiers. Les composants d'interface en ont besoin, et SvelteKit
* interdit — à raison — d'importer `$lib/server` depuis du code client.
*/
export const LIBELLES_STATUT: Record<string, string> = {
promulguee: 'Promulguée',
adoptee_non_promulguee: 'Adoptée, non promulguée',
saisie_cc: 'Devant le Conseil constitutionnel',
validee_cc: 'Validée, en attente de promulgation',
censuree_partiellement: 'Censurée partiellement',
navette: 'En navette',
deposee_non_examinee: 'Déposée, non examinée',
annoncee: 'Annoncée',
rejetee: 'Rejetée'
};
export const LIBELLES_THEME: Record<string, string> = {
justice: 'Justice',
securite: 'Sécurité',
numerique: 'Numérique',
social: 'Social',
fiscal: 'Fiscal',
environnement: 'Environnement',
agriculture: 'Agriculture',
sante: 'Santé',
memoire_patrimoine: 'Mémoire et patrimoine',
outre_mer: 'Outre-mer',
economie: 'Économie',
migration: 'Migration',
institutions: 'Institutions'
};
export const LIBELLES_TYPE: Record<string, string> = {
loi: 'Loi',
loi_organique: 'Loi organique',
pjl: 'Projet de loi',
ppl: 'Proposition de loi',
ordonnance: 'Ordonnance',
decision_cc: 'Décision du Conseil constitutionnel',
decret: 'Décret',
accord_international: 'Accord international'
};
export const LIBELLES_PERTINENCE: Record<string, string> = {
forte: 'Forte',
moyenne: 'Moyenne',
faible: 'Faible'
};
export const LIBELLES_CONFIANCE: Record<string, string> = {
high: 'Élevée',
medium: 'Moyenne',
low: 'Faible'
};
export const LIBELLES_IMPACT: Record<string, string> = {
'individus:positif': 'Individus — droits étendus',
'individus:negatif': 'Individus — droits restreints',
'individus:mixte': 'Individus — effet mixte',
'associations:positif': 'Associations — droits étendus',
'associations:negatif': 'Associations — droits restreints',
'associations:mixte': 'Associations — effet mixte',
'entreprises:positif': 'Entreprises — obligations allégées',
'entreprises:negatif': 'Entreprises — obligations renforcées',
'entreprises:mixte': 'Entreprises — effet mixte'
};
export const LIBELLES_ETAPE: Record<string, string> = {
depot: 'Dépôt',
adoption_1re_lecture: 'Adoption en première lecture',
adoption_definitive: 'Adoption définitive',
commission_mixte_paritaire: 'Commission mixte paritaire',
transmission: 'Transmission',
saisine_cc: 'Saisine du Conseil constitutionnel',
decision_cc: 'Décision du Conseil constitutionnel',
promulgation: 'Promulgation',
publication_jo: 'Publication au Journal officiel',
entree_vigueur: 'Entrée en vigueur',
rejet: 'Rejet',
annonce: 'Annonce',
autre: 'Étape'
};
export const LIBELLES_RESULTAT_CC: Record<string, string> = {
conforme: 'Conforme',
conforme_avec_reserves: 'Conforme avec réserves',
non_conformite_partielle: 'Non-conformité partielle',
non_conformite_totale: 'Non-conformité totale',
en_instance: 'En instance'
};
+53
View File
@@ -0,0 +1,53 @@
/**
* Connexion SQLite partagée.
*
* La base est ouverte une seule fois, en lecture seule : l'application web ne
* modifie jamais les données, c'est le rôle du pipeline. Le mode WAL permet au
* pipeline d'écrire pendant que l'application lit, sans blocage.
*/
import Database from 'better-sqlite3';
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ICI = dirname(fileURLToPath(import.meta.url));
/** Emplacements possibles de la base, du plus explicite au plus conventionnel. */
function trouverLaBase(): string {
const candidats = [
process.env.VEILLE_DB,
resolve(process.cwd(), '../data/veille.db'),
resolve(process.cwd(), 'data/veille.db'),
resolve(ICI, '../../../../data/veille.db')
].filter(Boolean) as string[];
for (const candidat of candidats) {
if (existsSync(candidat)) return candidat;
}
throw new Error(
`Base introuvable. Essayés : ${candidats.join(', ')}. ` +
'Lancer `make seed` à la racine du dépôt, ou définir VEILLE_DB.'
);
}
let instance: Database.Database | null = null;
export function base(): Database.Database {
if (instance) return instance;
instance = new Database(trouverLaBase(), { readonly: true, fileMustExist: true });
instance.pragma('journal_mode = WAL');
// Les requêtes de facettes lisent beaucoup et écrivent rien : un cache
// généreux évite de relire les mêmes pages à chaque requête HTTP.
instance.pragma('cache_size = -16000');
return instance;
}
export function fermer(): void {
instance?.close();
instance = null;
}
/** Date d'arrêté du corpus de recherche, affichée partout où c'est utile. */
export const DATE_ARRETE_CORPUS = '2026-07-25';
+573
View File
@@ -0,0 +1,573 @@
/**
* Requêtes de consultation : recherche plein texte, facettes, fiches, calendrier.
*
* Tout se joue en SQL. La recherche plein texte passe par l'index FTS5 à
* tokenizer `unicode61 remove_diacritics 2` — « chlordecone » retrouve
* « chlordécone » — et le classement par `bm25()` pondère les colonnes : un mot
* dans le titre pèse dix fois plus que dans les points clés.
*
* Les facettes sont calculées sur le résultat filtré **par les autres
* facettes**, pas sur la table entière : cocher « santé » doit recompter les
* statuts disponibles parmi les textes de santé, sinon les compteurs mentent.
*/
import { base } from '$serveur/db';
import {
LIBELLES_CONFIANCE,
LIBELLES_IMPACT,
LIBELLES_PERTINENCE,
LIBELLES_STATUT,
LIBELLES_THEME,
LIBELLES_TYPE
} from '$lib/libelles';
import type {
Echeance,
Facette,
Filtres,
Insight,
Resultats,
Texte,
TexteDetaille
} from '$lib/types';
const PAR_PAGE = 20;
/** Pondération bm25 : titre court, titre officiel, résumé, points clés, note, numéro. */
const POIDS_BM25 = '10.0, 5.0, 3.0, 2.0, 2.0, 1.0';
// ─────────────────────────────────────────────────────────────────────────────
// Lecture des filtres depuis l'URL
// ─────────────────────────────────────────────────────────────────────────────
export function lireFiltres(parametres: URLSearchParams): Filtres {
const multiple = (nom: string) =>
parametres
.getAll(nom)
.flatMap((valeur) => valeur.split(','))
.map((valeur) => valeur.trim())
.filter(Boolean);
const page = Number.parseInt(parametres.get('page') ?? '1', 10);
return {
q: (parametres.get('q') ?? '').trim(),
statut: multiple('statut'),
theme: multiple('theme'),
type: multiple('type'),
impact: multiple('impact'),
guadeloupe: multiple('guadeloupe'),
confiance: multiple('confiance'),
from: parametres.get('from'),
to: parametres.get('to'),
tri: parametres.get('tri') === 'date' ? 'date' : 'pertinence',
page: Number.isFinite(page) && page > 0 ? page : 1
};
}
/** Reconstruit une URL de recherche en basculant une valeur de facette. */
export function basculerFacette(
filtres: Filtres,
champ: keyof Filtres,
valeur: string
): string {
const parametres = new URLSearchParams();
if (filtres.q) parametres.set('q', filtres.q);
for (const nom of ['statut', 'theme', 'type', 'impact', 'guadeloupe', 'confiance'] as const) {
let valeurs = [...filtres[nom]];
if (nom === champ) {
valeurs = valeurs.includes(valeur)
? valeurs.filter((v) => v !== valeur)
: [...valeurs, valeur];
}
for (const v of valeurs) parametres.append(nom, v);
}
if (filtres.from) parametres.set('from', filtres.from);
if (filtres.to) parametres.set('to', filtres.to);
if (filtres.tri !== 'pertinence') parametres.set('tri', filtres.tri);
const chaine = parametres.toString();
return chaine ? `/recherche?${chaine}` : '/recherche';
}
// ─────────────────────────────────────────────────────────────────────────────
// Construction du SQL
// ─────────────────────────────────────────────────────────────────────────────
interface Clause {
sql: string;
parametres: unknown[];
}
/**
* Conditions issues des filtres, hors recherche plein texte.
*
* `exclure` permet de recalculer les facettes d'un champ en ignorant ses
* propres sélections — c'est ce qui rend les compteurs honnêtes.
*/
function conditions(filtres: Filtres, exclure?: string): Clause {
const morceaux: string[] = [];
const parametres: unknown[] = [];
if (filtres.statut.length && exclure !== 'statut') {
// `saisie_cc` n'est pas seulement un statut : c'est aussi l'état des
// textes dont une affaire est en instance devant le Conseil. La vue
// `v_textes` expose cette lecture combinée sous `devant_cc`.
const statutsSimples = filtres.statut.filter((s) => s !== 'saisie_cc');
const clauses: string[] = [];
if (statutsSimples.length) {
clauses.push(`statut IN (${statutsSimples.map(() => '?').join(', ')})`);
parametres.push(...statutsSimples);
}
if (filtres.statut.includes('saisie_cc')) clauses.push('devant_cc = 1');
morceaux.push(`(${clauses.join(' OR ')})`);
}
if (filtres.theme.length && exclure !== 'theme') {
const clauses = filtres.theme.map(
() => `EXISTS (SELECT 1 FROM json_each(themes) WHERE json_each.value = ?)`
);
morceaux.push(`(${clauses.join(' OR ')})`);
parametres.push(...filtres.theme);
}
if (filtres.type.length && exclure !== 'type') {
morceaux.push(`type IN (${filtres.type.map(() => '?').join(', ')})`);
parametres.push(...filtres.type);
}
if (filtres.guadeloupe.length && exclure !== 'guadeloupe') {
morceaux.push(
`guadeloupe_pertinence IN (${filtres.guadeloupe.map(() => '?').join(', ')})`
);
parametres.push(...filtres.guadeloupe);
}
if (filtres.confiance.length && exclure !== 'confiance') {
morceaux.push(`confiance IN (${filtres.confiance.map(() => '?').join(', ')})`);
parametres.push(...filtres.confiance);
}
if (filtres.impact.length && exclure !== 'impact') {
// Un filtre d'impact s'écrit « public:sens », par exemple
// « associations:negatif ». Les deux moitiés doivent correspondre
// ensemble, d'où l'accès direct par clé JSON.
const clauses = filtres.impact.map(() => `json_extract(impacts, ?) = ?`);
morceaux.push(`(${clauses.join(' OR ')})`);
for (const valeur of filtres.impact) {
const [pub, sens] = valeur.split(':');
parametres.push(`$.${pub}.sens`, sens);
}
}
if (filtres.from) {
morceaux.push(`COALESCE(date_promulgation, date_adoption, statut_date) >= ?`);
parametres.push(filtres.from);
}
if (filtres.to) {
morceaux.push(`COALESCE(date_promulgation, date_adoption, statut_date) <= ?`);
parametres.push(filtres.to);
}
return { sql: morceaux.length ? morceaux.join(' AND ') : '1 = 1', parametres };
}
/**
* Traduit une saisie libre en requête FTS5.
*
* Chaque terme est cité pour neutraliser la syntaxe FTS (`NEAR`, `*`, `:`)
* qu'un visiteur ne saisit pas volontairement, et suffixé d'une étoile pour
* accepter les préfixes : « chlord » trouve « chlordécone ».
*/
function requeteFts(saisie: string): string {
const termes = saisie
.replace(/["^*:()]/g, ' ')
.split(/\s+/)
.map((terme) => terme.trim())
.filter((terme) => terme.length > 1);
if (!termes.length) return '';
return termes.map((terme) => `"${terme}"*`).join(' AND ');
}
// ─────────────────────────────────────────────────────────────────────────────
// Recherche
// ─────────────────────────────────────────────────────────────────────────────
export function rechercher(filtres: Filtres): Resultats {
const db = base();
const where = conditions(filtres);
const fts = filtres.q ? requeteFts(filtres.q) : '';
const source = fts
? `v_textes t JOIN textes_fts f ON f.rowid = t.rowid AND textes_fts MATCH ?`
: `v_textes t`;
const parametresSource = fts ? [fts] : [];
const ordre = fts
? filtres.tri === 'date'
? `COALESCE(t.date_promulgation, t.date_adoption, t.statut_date) DESC`
: `bm25(textes_fts, ${POIDS_BM25})`
: `COALESCE(t.date_promulgation, t.date_adoption, t.statut_date) DESC, t.id`;
const total = (
db
.prepare(`SELECT COUNT(*) AS n FROM ${source} WHERE ${where.sql}`)
.get(...parametresSource, ...where.parametres) as { n: number }
).n;
const extrait = fts
? `snippet(textes_fts, -1, '<mark>', '</mark>', '…', 24) AS extrait`
: `NULL AS extrait`;
const lignes = db
.prepare(
`SELECT t.*, ${extrait}
FROM ${source}
WHERE ${where.sql}
ORDER BY ${ordre}
LIMIT ? OFFSET ?`
)
.all(
...parametresSource,
...where.parametres,
PAR_PAGE,
(filtres.page - 1) * PAR_PAGE
) as Record<string, unknown>[];
return {
textes: lignes.map(hydrater),
total,
page: filtres.page,
pages: Math.max(1, Math.ceil(total / PAR_PAGE)),
facettes: {
statut: facetteStatut(filtres, fts),
theme: facetteTheme(filtres, fts),
type: facetteSimple(filtres, fts, 'type', LIBELLES_TYPE),
guadeloupe: facetteSimple(
filtres,
fts,
'guadeloupe_pertinence',
LIBELLES_PERTINENCE,
'guadeloupe'
),
impact: facetteImpact(filtres, fts),
confiance: facetteSimple(filtres, fts, 'confiance', LIBELLES_CONFIANCE)
}
};
}
function sourceEtParametres(fts: string): [string, unknown[]] {
return fts
? [`v_textes t JOIN textes_fts f ON f.rowid = t.rowid AND textes_fts MATCH ?`, [fts]]
: [`v_textes t`, []];
}
function facetteSimple(
filtres: Filtres,
fts: string,
colonne: string,
libelles: Record<string, string>,
champFiltre?: keyof Filtres
): Facette[] {
const champ = (champFiltre ?? colonne) as keyof Filtres;
const where = conditions(filtres, champ as string);
const [source, parametresSource] = sourceEtParametres(fts);
const lignes = base()
.prepare(
`SELECT ${colonne} AS valeur, COUNT(*) AS nombre
FROM ${source}
WHERE ${where.sql} AND ${colonne} IS NOT NULL
GROUP BY ${colonne}
ORDER BY nombre DESC`
)
.all(...parametresSource, ...where.parametres) as { valeur: string; nombre: number }[];
const selection = filtres[champ] as string[];
return lignes.map((ligne) => ({
valeur: ligne.valeur,
libelle: libelles[ligne.valeur] ?? ligne.valeur,
nombre: ligne.nombre,
actif: selection.includes(ligne.valeur)
}));
}
function facetteStatut(filtres: Filtres, fts: string): Facette[] {
const facettes = facetteSimple(filtres, fts, 'statut', LIBELLES_STATUT);
// Facette dérivée : « devant le Conseil constitutionnel » recouvre à la
// fois le statut `saisie_cc` et les textes ayant une affaire en instance.
const where = conditions(filtres, 'statut');
const [source, parametresSource] = sourceEtParametres(fts);
const nombre = (
base()
.prepare(`SELECT COUNT(*) AS n FROM ${source} WHERE ${where.sql} AND devant_cc = 1`)
.get(...parametresSource, ...where.parametres) as { n: number }
).n;
if (nombre > 0 && !facettes.some((f) => f.valeur === 'saisie_cc')) {
facettes.unshift({
valeur: 'saisie_cc',
libelle: LIBELLES_STATUT.saisie_cc,
nombre,
actif: filtres.statut.includes('saisie_cc')
});
}
return facettes;
}
function facetteTheme(filtres: Filtres, fts: string): Facette[] {
const where = conditions(filtres, 'theme');
const [source, parametresSource] = sourceEtParametres(fts);
const lignes = base()
.prepare(
`SELECT json_each.value AS valeur, COUNT(*) AS nombre
FROM ${source}, json_each(t.themes)
WHERE ${where.sql}
GROUP BY json_each.value
ORDER BY nombre DESC`
)
.all(...parametresSource, ...where.parametres) as { valeur: string; nombre: number }[];
return lignes.map((ligne) => ({
valeur: ligne.valeur,
libelle: LIBELLES_THEME[ligne.valeur] ?? ligne.valeur,
nombre: ligne.nombre,
actif: filtres.theme.includes(ligne.valeur)
}));
}
function facetteImpact(filtres: Filtres, fts: string): Facette[] {
const where = conditions(filtres, 'impact');
const [source, parametresSource] = sourceEtParametres(fts);
const lignes = base()
.prepare(
`SELECT json_each.key || ':' || json_extract(json_each.value, '$.sens') AS valeur,
COUNT(*) AS nombre
FROM ${source}, json_each(t.impacts)
WHERE ${where.sql}
GROUP BY valeur
ORDER BY nombre DESC`
)
.all(...parametresSource, ...where.parametres) as { valeur: string; nombre: number }[];
return lignes
.filter((ligne) => !ligne.valeur.endsWith(':neutre'))
.map((ligne) => ({
valeur: ligne.valeur,
libelle: LIBELLES_IMPACT[ligne.valeur] ?? ligne.valeur,
nombre: ligne.nombre,
actif: filtres.impact.includes(ligne.valeur)
}));
}
// ─────────────────────────────────────────────────────────────────────────────
// Fiche détaillée
// ─────────────────────────────────────────────────────────────────────────────
export function texte(slug: string): TexteDetaille | null {
const db = base();
const ligne = db.prepare(`SELECT * FROM v_textes WHERE id = ?`).get(slug) as
| Record<string, unknown>
| undefined;
if (!ligne) return null;
const detaille = hydrater(ligne) as TexteDetaille;
detaille.sources = db
.prepare(
`SELECT url, titre, editeur, date_publication, tier, extrait_verbatim,
contexte, confiance, marqueur, fichier_origine
FROM sources WHERE texte_id = ?
ORDER BY tier ASC, date_publication DESC NULLS LAST, id`
)
.all(slug) as TexteDetaille['sources'];
detaille.evenements = db
.prepare(
`SELECT date_evenement, type_etape, description, source_url, previsionnel
FROM evenements WHERE texte_id = ?
ORDER BY date_evenement ASC, id`
)
.all(slug) as TexteDetaille['evenements'];
detaille.decisions_cc = db
.prepare(`SELECT * FROM decisions_cc WHERE texte_id = ? ORDER BY numero_affaire`)
.all(slug) as TexteDetaille['decisions_cc'];
return detaille;
}
export function tousLesSlugs(): string[] {
return (base().prepare(`SELECT id FROM textes ORDER BY id`).all() as { id: string }[]).map(
(l) => l.id
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Tableau de bord
// ─────────────────────────────────────────────────────────────────────────────
export interface Compteurs {
total: number;
parStatut: { valeur: string; libelle: string; nombre: number }[];
devantCc: number;
guadeloupeForte: number;
aVerifier: number;
sources: number;
}
export function compteurs(): Compteurs {
const db = base();
const un = (sql: string) => (db.prepare(sql).get() as { n: number }).n;
return {
total: un(`SELECT COUNT(*) AS n FROM textes`),
parStatut: (
db
.prepare(
`SELECT statut AS valeur, COUNT(*) AS nombre FROM textes
GROUP BY statut ORDER BY nombre DESC`
)
.all() as { valeur: string; nombre: number }[]
).map((l) => ({ ...l, libelle: LIBELLES_STATUT[l.valeur] ?? l.valeur })),
devantCc: un(`SELECT COUNT(*) AS n FROM v_textes WHERE devant_cc = 1`),
guadeloupeForte: un(
`SELECT COUNT(*) AS n FROM textes WHERE guadeloupe_pertinence = 'forte'`
),
aVerifier: un(`SELECT COUNT(*) AS n FROM textes WHERE a_verifier = 1`),
sources: un(`SELECT COUNT(*) AS n FROM sources`)
};
}
/** Échéances des `jours` prochains jours, textes et repères nationaux mêlés. */
export function prochainesEcheances(jours = 60, aPartirDe?: string): Echeance[] {
const depart = aPartirDe ?? '2026-07-25';
const fin = new Date(depart);
fin.setDate(fin.getDate() + jours);
const limite = fin.toISOString().slice(0, 10);
const db = base();
const nationales = db
.prepare(
`SELECT * FROM echeances
WHERE date_echeance >= ? AND date_echeance <= ?
ORDER BY date_echeance`
)
.all(depart, limite) as Echeance[];
const desTextes = db
.prepare(
`SELECT NULL AS id, 'texte-' || id AS code, prochaine_echeance AS date_echeance,
titre_court AS libelle, prochaine_echeance_label AS description,
'texte' AS portee, id AS texte_id,
CASE WHEN guadeloupe_pertinence = 'forte' THEN 1 ELSE NULL END
AS concerne_guadeloupe,
guadeloupe_note AS note_guadeloupe, 1 AS previsionnel,
NULL AS source_url, NULL AS source_extrait
FROM textes
WHERE prochaine_echeance IS NOT NULL
AND prochaine_echeance >= ? AND prochaine_echeance <= ?
ORDER BY prochaine_echeance`
)
.all(depart, limite) as Echeance[];
return [...nationales, ...desTextes].sort((a, b) =>
a.date_echeance.localeCompare(b.date_echeance)
);
}
/** Toutes les échéances, pour la page calendrier. */
export function calendrier(): Echeance[] {
return prochainesEcheances(400, '2026-07-01');
}
export function decisionsAttendues(): (Echeance & { numero_affaire: string })[] {
return base()
.prepare(
`SELECT d.numero_affaire, d.date_decision_attendue AS date_echeance,
COALESCE(t.titre_court, d.resume) AS libelle, d.resume AS description,
d.texte_id, t.guadeloupe_pertinence
FROM decisions_cc d
LEFT JOIN textes t ON t.id = d.texte_id
WHERE d.date_decision IS NULL
ORDER BY COALESCE(d.date_decision_attendue, '9999'), d.numero_affaire`
)
.all() as (Echeance & { numero_affaire: string })[];
}
export function insights(): Insight[] {
return (
base().prepare(`SELECT * FROM insights ORDER BY numero`).all() as Record<
string,
unknown
>[]
).map((ligne) => ({
numero: ligne.numero as number,
titre: ligne.titre as string,
corps: ligne.corps as string,
implications: ligne.implications as string | null,
confiance: ligne.confiance as string,
derive_de: JSON.parse((ligne.derive_de as string) || '[]')
}));
}
export interface Changement {
horodatage: string;
mode: string;
texte_id: string | null;
titre: string | null;
nature: string;
champ: string | null;
ancienne_valeur: string | null;
nouvelle_valeur: string | null;
description: string | null;
}
/** Derniers changements détectés par le pipeline, pour l'accueil. */
export function derniersChangements(limite = 12): Changement[] {
return base()
.prepare(
`SELECT v.horodatage, v.mode, c.texte_id, t.titre_court AS titre,
c.nature, c.champ, c.ancienne_valeur, c.nouvelle_valeur, c.description
FROM veille_changements c
JOIN veille_log v ON v.id = c.run_id
LEFT JOIN textes t ON t.id = c.texte_id
ORDER BY v.horodatage DESC, c.id DESC
LIMIT ?`
)
.all(limite) as Changement[];
}
export interface Run {
horodatage: string;
mode: string;
duree_s: number | null;
ajouts: number;
modifications: number;
echecs: number;
rapport: string | null;
}
export function dernierRun(): Run | null {
return (
(base()
.prepare(
`SELECT horodatage, mode, duree_s, ajouts, modifications, echecs, rapport
FROM veille_log ORDER BY horodatage DESC, id DESC LIMIT 1`
)
.get() as Run | undefined) ?? null
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Hydratation
// ─────────────────────────────────────────────────────────────────────────────
function hydrater(ligne: Record<string, unknown>): Texte {
return {
...(ligne as unknown as Texte),
themes: JSON.parse((ligne.themes as string) || '[]'),
impacts: JSON.parse((ligne.impacts as string) || '{}'),
points_cles: JSON.parse((ligne.points_cles as string) || '[]')
};
}
+342
View File
@@ -0,0 +1,342 @@
/**
* Socle : polices auto-hébergées, réinitialisation, éléments communs.
*
* Aucune requête tierce. Les `@font-face` pointent vers `static/polices/`,
* copiées depuis npm par `npm run polices`.
*/
/* ── Polices ─────────────────────────────────────────────────────────────── */
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/polices/archivo-latin-400-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+2000-206F, U+20AC;
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/polices/archivo-latin-600-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/polices/archivo-latin-700-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/polices/inter-latin-400-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/polices/inter-latin-500-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/polices/inter-latin-600-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/polices/inter-latin-700-normal.woff2') format('woff2');
}
/* ── Réinitialisation ────────────────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
}
body {
margin: 0;
background: var(--noir-oki);
color: var(--blanc-creme);
font-family: var(--font-body);
font-size: 1rem;
line-height: 1.55;
font-weight: 400;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1,
h2,
h3,
h4 {
font-family: var(--font-display);
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.15;
margin: 0 0 var(--space-2);
text-wrap: balance;
}
h1 {
font-size: clamp(1.55rem, 3.4vw, 2.3rem);
text-transform: uppercase;
}
h2 {
font-size: clamp(1.2rem, 2.4vw, 1.55rem);
text-transform: uppercase;
}
h3 {
font-size: 1.05rem;
}
h4 {
font-size: 0.95rem;
}
p {
margin: 0 0 var(--space-2);
text-wrap: pretty;
}
a {
color: var(--or-oki);
text-decoration-thickness: 1px;
text-underline-offset: 0.18em;
}
a:hover {
color: var(--or-clair);
}
:focus-visible {
outline: 3px solid var(--or-oki);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
img,
svg {
max-width: 100%;
height: auto;
}
mark {
background: color-mix(in srgb, var(--or-oki) 26%, transparent);
color: inherit;
padding: 0 0.15em;
border-radius: 2px;
font-weight: 600;
}
hr {
border: 0;
border-top: 1px solid var(--line);
margin: var(--space-4) 0;
}
/* ── Mise en page ────────────────────────────────────────────────────────── */
.contenant {
width: min(100% - 2rem, var(--container));
margin-inline: auto;
}
.section {
padding-block: var(--space-4);
}
.pile {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.ligne {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
align-items: center;
}
.grille {
display: grid;
gap: var(--space-2);
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
}
/* ── Signature de marque ─────────────────────────────────────────────────── */
.flag-bar {
height: 6px;
background: linear-gradient(
to right,
#0d0d0d 0 25%,
var(--or-oki) 25% 50%,
var(--vert-oki) 50% 75%,
var(--rouge-oki) 75% 100%
);
}
:root.light-theme .flag-bar {
background: linear-gradient(
to right,
#000 0 25%,
var(--or-oki) 25% 50%,
var(--vert-oki) 50% 75%,
var(--rouge-oki) 75% 100%
);
}
/* ── Carte canonique ─────────────────────────────────────────────────────── */
.carte {
background: var(--card-bg);
border: var(--border-card);
border-left: 4px solid var(--or-oki);
border-radius: var(--radius-md);
padding: var(--space-2);
transition:
transform var(--dur-tanbou) var(--ease-ka),
border-color var(--dur-tanbou) var(--ease-ka),
background var(--dur-tanbou) var(--ease-ka);
}
.carte:hover {
background: var(--card-bg-survol);
transform: translateY(-2px);
}
/* ── Tag canonique ───────────────────────────────────────────────────────── */
.tag {
display: inline-flex;
align-items: center;
gap: 0.35em;
background: color-mix(in srgb, var(--or-oki) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--or-oki) 55%, transparent);
border-radius: var(--radius-sm);
color: var(--blanc-creme);
font-size: 0.78rem;
padding: 0.15em 0.55em;
text-decoration: none;
white-space: nowrap;
}
/* ── Bouton canonique ────────────────────────────────────────────────────── */
.bouton {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5em;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.55em 1.1em;
border: var(--border-btn);
border-radius: var(--radius-sm);
background: transparent;
color: var(--or-oki);
cursor: pointer;
text-decoration: none;
overflow: hidden;
transition: color var(--dur-tanbou) var(--ease-syncope);
}
.bouton::after {
content: '';
position: absolute;
inset: 0;
background: var(--or-oki);
transform: scaleX(0);
transform-origin: left;
transition: transform var(--dur-tanbou) var(--ease-syncope);
z-index: -1;
}
.bouton:hover {
color: var(--noir-oki);
}
.bouton:hover::after {
transform: scaleX(1);
}
.bouton > * {
position: relative;
z-index: 1;
}
.bouton-discret {
border-color: var(--line);
color: var(--blanc-creme);
}
/* ── Accessibilité ───────────────────────────────────────────────────────── */
.lien-evitement {
position: absolute;
left: -9999px;
top: 0;
background: var(--or-oki);
color: var(--noir-oki);
padding: var(--space-1) var(--space-2);
font-weight: 700;
z-index: 100;
}
.lien-evitement:focus {
left: 0;
}
.visuellement-cache {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* Une seule garde, au niveau global : sans mouvement, tout reste lisible. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.carte:hover {
transform: none;
}
}
/* ── Impression : la veille se transmet aussi sur papier ─────────────────── */
@media print {
:root {
--noir-oki: #fff;
--blanc-creme: #000;
--card-bg: transparent;
--line: #999;
--muted: #444;
}
.entete-site,
.pied-site,
.panneau-facettes,
.bouton {
display: none !important;
}
a[href^='http']::after {
content: ' (' attr(href) ')';
font-size: 0.75em;
word-break: break-all;
}
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Tokens OKI — Organisation KA Internationale.
*
* Valeurs mesurées sur o-k-i.net, reprises telles quelles de la charte. Le
* thème sombre est l'identité par défaut ; le clair est une variante opt-in qui
* assombrit les accents pour rester au contraste AA.
*
* Trois règles ne se négocient pas :
* · l'or porte toute l'interaction — c'est la seule couleur d'action ;
* · rouge = signaler, vert = valider, or = agir : jamais permuter ;
* · angles nets partout, 3 px sur les petits éléments, 6 px sur les cartes.
*
* Usage propre à cette application : c'est un outil de veille, pas une vitrine.
* La densité informationnelle prime, le motion est réduit au strict minimum, et
* la flag-bar n'apparaît qu'une fois par écran.
*/
:root {
/* ── Noyau ── */
--noir-oki: #0d0d0d;
--noir-profond: #1a0f1a;
--blanc-creme: #fff8e7;
--line: rgba(255, 255, 255, 0.1);
--or-oki: #fdb813;
--rouge-oki: #ff1654;
/* ── Étendue, avec parcimonie ── */
--vert-oki: #00d66c;
--turquoise-caraibes: #00ced1;
--jaune-soleil: #ffd700;
--orange-flamme: #ff6b35;
--violet-nuit: #6b2d5c;
--bleu-ocean: #0077be;
--or-clair: #ffe066;
--gris-sombre: #2d1b2e;
/* ── Sémantique dérivée ── */
--muted: color-mix(in srgb, var(--blanc-creme) 70%, transparent);
--muted-fort: color-mix(in srgb, var(--blanc-creme) 55%, transparent);
--card-bg: rgba(255, 255, 255, 0.03);
--card-bg-survol: rgba(255, 255, 255, 0.06);
/* ── Typographie ── */
--font-display: 'Archivo', 'Arial Black', sans-serif;
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: ui-monospace, 'SFMono-Regular', 'Menlo', monospace;
/* ── Formes et espacements ── */
--radius-sm: 3px;
--radius-md: 6px;
--border-card: 1px solid var(--line);
--border-btn: 2px solid var(--or-oki);
--space-1: 0.75rem;
--space-2: 1rem;
--space-3: 1.5rem;
--space-4: 2rem;
--space-5: 4rem;
--container: 1200px;
/* ── Motion : les cadences gwoka ── */
--ease-ka: cubic-bezier(0.22, 1, 0.36, 1);
--ease-syncope: cubic-bezier(0.65, 0, 0.35, 1);
--dur-tanbou: 120ms;
--dur-mesure: 480ms;
--dur-phrase: 960ms;
/* ── Sémantique métier ──
* Les statuts sont d'abord distingués par leur libellé et leur forme, la
* couleur ne fait que renforcer : un daltonien doit pouvoir lire la page.
*/
--statut-promulguee: var(--vert-oki);
--statut-attente: var(--or-oki);
--statut-cc: var(--orange-flamme);
--statut-navette: var(--turquoise-caraibes);
--statut-projet: var(--muted-fort);
--statut-rejetee: var(--rouge-oki);
--impact-positif: var(--vert-oki);
--impact-negatif: var(--rouge-oki);
--impact-mixte: var(--jaune-soleil);
--impact-neutre: var(--muted-fort);
}
/* Thème clair — opt-in, accents assombris pour le contraste AA. */
:root.light-theme {
--noir-oki: #fff8e7;
--blanc-creme: #0d0d0d;
--noir-profond: #f5f0e8;
--gris-sombre: #e8ddd0;
--or-oki: #8f5c00;
--vert-oki: #006b3d;
--rouge-oki: #a01030;
--turquoise-caraibes: #006b75;
--bleu-ocean: #004b7f;
--or-clair: #a86e00;
--jaune-soleil: #7a5300;
--orange-flamme: #b34700;
--violet-nuit: #4a1f40;
--line: rgba(0, 0, 0, 0.14);
--card-bg: rgba(0, 0, 0, 0.025);
--card-bg-survol: rgba(0, 0, 0, 0.05);
--muted: color-mix(in srgb, var(--blanc-creme) 72%, transparent);
--muted-fort: color-mix(in srgb, var(--blanc-creme) 58%, transparent);
}
+180
View File
@@ -0,0 +1,180 @@
/** Vocabulaire du domaine, aligné sur le schéma SQLite et les modèles pydantic. */
export type Statut =
| 'promulguee'
| 'adoptee_non_promulguee'
| 'saisie_cc'
| 'validee_cc'
| 'censuree_partiellement'
| 'navette'
| 'deposee_non_examinee'
| 'annoncee'
| 'rejetee';
export type TypeTexte =
| 'loi'
| 'loi_organique'
| 'pjl'
| 'ppl'
| 'ordonnance'
| 'decision_cc'
| 'decret'
| 'accord_international';
export type Theme =
| 'justice'
| 'securite'
| 'numerique'
| 'social'
| 'fiscal'
| 'environnement'
| 'agriculture'
| 'sante'
| 'memoire_patrimoine'
| 'outre_mer'
| 'economie'
| 'migration'
| 'institutions';
export type Public = 'individus' | 'associations' | 'entreprises';
export type SensImpact = 'positif' | 'negatif' | 'mixte' | 'neutre';
export type Pertinence = 'forte' | 'moyenne' | 'faible';
export type Confiance = 'high' | 'medium' | 'low';
export type RegimeLibertes = 'droits_plus' | 'controle_plus' | 'mixte' | 'neutre';
export interface Impact {
sens: SensImpact;
note: string;
}
export interface Source {
url: string;
titre: string | null;
editeur: string | null;
date_publication: string | null;
tier: 'T1' | 'T2';
extrait_verbatim: string | null;
contexte: string | null;
confiance: Confiance | null;
marqueur: string | null;
fichier_origine: string | null;
}
export interface Evenement {
date_evenement: string;
type_etape: string;
description: string;
source_url: string | null;
previsionnel: 0 | 1;
}
export interface DecisionCC {
numero_affaire: string;
date_saisine: string | null;
date_decision: string | null;
resultat: string | null;
saisissants: string | null;
resume: string | null;
url: string | null;
date_decision_attendue: string | null;
}
export interface Texte {
id: string;
numero_officiel: string | null;
type: TypeTexte;
titre_court: string;
titre_officiel: string | null;
statut: Statut;
statut_date: string | null;
date_depot: string | null;
date_adoption: string | null;
date_promulgation: string | null;
date_entree_vigueur: string | null;
prochaine_echeance: string | null;
prochaine_echeance_label: string | null;
themes: Theme[];
impacts: Partial<Record<Public, Impact>>;
regime_libertes: RegimeLibertes | null;
regime_libertes_note: string | null;
guadeloupe_pertinence: Pertinence | null;
guadeloupe_note: string | null;
resume: string | null;
points_cles: string[];
confiance: Confiance;
source_seed: string | null;
a_verifier: 0 | 1;
motif_verification: string | null;
derniere_verif: string | null;
nb_sources: number;
nb_evenements: number;
devant_cc: 0 | 1;
/** Extrait surligné, présent uniquement sur les résultats de recherche. */
extrait?: string | null;
}
export interface TexteDetaille extends Texte {
sources: Source[];
evenements: Evenement[];
decisions_cc: DecisionCC[];
}
export interface Echeance {
id: number;
code: string;
date_echeance: string;
libelle: string;
description: string | null;
portee: 'nationale' | 'texte';
texte_id: string | null;
concerne_guadeloupe: 0 | 1 | null;
note_guadeloupe: string | null;
previsionnel: 0 | 1;
source_url: string | null;
source_extrait: string | null;
}
export interface Insight {
numero: number;
titre: string;
corps: string;
implications: string | null;
confiance: string;
derive_de: string[];
}
export interface Facette {
valeur: string;
libelle: string;
nombre: number;
actif: boolean;
}
export interface Filtres {
q: string;
statut: string[];
theme: string[];
type: string[];
impact: string[];
guadeloupe: string[];
confiance: string[];
from: string | null;
to: string | null;
tri: 'date' | 'pertinence';
page: number;
}
export interface Resultats {
textes: Texte[];
total: number;
page: number;
pages: number;
facettes: {
statut: Facette[];
theme: Facette[];
type: Facette[];
guadeloupe: Facette[];
impact: Facette[];
confiance: Facette[];
};
}
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
import { page } from '$app/state';
</script>
<svelte:head>
<title>{page.status} · Veille législative Gwadloup</title>
<meta name="robots" content="noindex" />
</svelte:head>
<div class="erreur">
<p class="code">{page.status}</p>
{#if page.status === 404}
<h1>Pa ni ayen la</h1>
<p class="explication">
Cette page n'existe pas — ou n'existe plus. Le texte que vous cherchez a peut-être un
autre identifiant.
</p>
{:else}
<h1>Sa pa maché</h1>
<p class="explication">{page.error?.message ?? 'Une erreur est survenue.'}</p>
{/if}
<div class="ligne actions">
<a class="bouton" href="/"><span>Tableau de bord</span></a>
<a class="bouton bouton-discret" href="/recherche"><span>Rechercher un texte</span></a>
</div>
<div class="flag-bar" aria-hidden="true"></div>
</div>
<style>
.erreur {
max-width: 48ch;
margin: var(--space-5) auto;
text-align: center;
}
.code {
font-family: var(--font-display);
font-size: 4rem;
font-weight: 900;
line-height: 1;
color: var(--or-oki);
margin: 0;
}
h1 {
margin-top: var(--space-1);
}
.explication {
color: var(--muted);
}
.actions {
justify-content: center;
margin-block: var(--space-3);
}
.flag-bar {
max-width: 56px;
margin-inline: auto;
}
</style>
+9
View File
@@ -0,0 +1,9 @@
/**
* Le rendu serveur est le défaut sur toute l'application.
*
* On ne pose JAMAIS `ssr = false` ici : le mode SPA est explicitement écarté
* par l'architecture de rendu (§3bis), pour le référencement comme pour la
* lisibilité sans JavaScript. Le prérendu est activé page par page, uniquement
* sur celles dont le contenu est figé (`/a-propos`, `/methode`).
*/
export const prerender = false;
+220
View File
@@ -0,0 +1,220 @@
<script lang="ts">
import '$lib/styles/oki-tokens.css';
import '$lib/styles/base.css';
import { page } from '$app/state';
const { children } = $props();
const NAVIGATION = [
{ href: '/', libelle: 'Tableau de bord' },
{ href: '/recherche', libelle: 'Recherche' },
{ href: '/calendrier', libelle: 'Calendrier' },
{ href: '/methode', libelle: 'Méthode' },
{ href: '/a-propos', libelle: 'À propos' }
];
const actif = (href: string) =>
href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
</script>
<a class="lien-evitement" href="#contenu">Aller au contenu</a>
<header class="entete-site">
<div class="contenant barre">
<a class="marque" href="/">
<span class="monogramme" aria-hidden="true">VL</span>
<span class="titre">
<strong>Veille législative</strong>
<small>Gwadloup · libertés, associations, entreprises</small>
</span>
</a>
<nav aria-label="Navigation principale">
<ul>
{#each NAVIGATION as entree (entree.href)}
<li>
<a href={entree.href} aria-current={actif(entree.href) ? 'page' : undefined}>
{entree.libelle}
</a>
</li>
{/each}
</ul>
</nav>
<button
type="button"
class="bascule-theme"
aria-label="Basculer entre thème sombre et thème clair"
onclick={() => {
const racine = document.documentElement;
const clair = racine.classList.toggle('light-theme');
try {
localStorage.setItem('theme', clair ? 'clair' : 'sombre');
} catch {
/* stockage indisponible : le choix ne survivra pas au rechargement */
}
}}
>
<span aria-hidden="true"></span>
</button>
</div>
<!-- Signature de marque : une seule occurrence pleine largeur par écran. -->
<div class="flag-bar"></div>
</header>
<main id="contenu" class="contenant">
{@render children()}
</main>
<footer class="pied-site">
<div class="contenant">
<p class="doctrine">
Aucune donnée législative n'est inventée. Chaque fait porte sa source, sa date et son
niveau de confiance. Ce qui n'est pas établi est marqué « à vérifier ».
</p>
<p class="federe">
<span class="monogramme petit" aria-hidden="true">OKI</span>
Un service libre opéré par
<a href="https://o-k-i.net" rel="noopener">ORGANISATION KA INTERNATIONALE</a>
· o-k-i.net
</p>
</div>
</footer>
<style>
.entete-site {
position: sticky;
top: 0;
z-index: 20;
background: var(--noir-oki);
border-bottom: 1px solid var(--line);
}
.barre {
display: flex;
align-items: center;
gap: var(--space-2);
padding-block: 0.7rem;
flex-wrap: wrap;
}
.marque {
display: flex;
align-items: center;
gap: 0.6rem;
text-decoration: none;
color: var(--blanc-creme);
margin-right: auto;
}
.monogramme {
font-family: var(--font-display);
font-weight: 900;
font-size: 0.95rem;
letter-spacing: -0.02em;
color: var(--noir-oki);
background: var(--or-oki);
padding: 0.28em 0.45em;
border-radius: var(--radius-sm);
flex: none;
}
.monogramme.petit {
font-size: 0.7rem;
padding: 0.2em 0.35em;
}
.titre {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.titre strong {
font-family: var(--font-display);
font-weight: 700;
font-size: 0.98rem;
text-transform: uppercase;
letter-spacing: -0.01em;
}
.titre small {
font-size: 0.72rem;
color: var(--muted-fort);
}
nav ul {
list-style: none;
display: flex;
gap: 0.15rem;
margin: 0;
padding: 0;
flex-wrap: wrap;
}
nav a {
display: block;
padding: 0.35rem 0.65rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
color: var(--blanc-creme);
text-decoration: none;
border: 1px solid transparent;
}
nav a:hover {
color: var(--or-oki);
border-color: var(--line);
}
nav a[aria-current='page'] {
color: var(--or-oki);
border-color: color-mix(in srgb, var(--or-oki) 55%, transparent);
background: color-mix(in srgb, var(--or-oki) 10%, transparent);
}
.bascule-theme {
background: transparent;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--blanc-creme);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0.35rem 0.55rem;
}
.bascule-theme:hover {
color: var(--or-oki);
border-color: var(--or-oki);
}
main {
min-height: 60vh;
padding-block: var(--space-3) var(--space-5);
}
.pied-site {
border-top: 1px solid var(--line);
padding-block: var(--space-3);
font-size: 0.82rem;
color: var(--muted-fort);
}
.doctrine {
max-width: 62ch;
margin-bottom: var(--space-2);
}
.federe {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
margin: 0;
}
@media (max-width: 640px) {
.marque {
margin-right: 0;
}
nav ul {
gap: 0;
}
nav a {
padding: 0.3rem 0.45rem;
font-size: 0.8rem;
}
}
</style>
+26
View File
@@ -0,0 +1,26 @@
import {
compteurs,
derniersChangements,
dernierRun,
decisionsAttendues,
insights,
prochainesEcheances
} from '$serveur/requetes';
import { DATE_ARRETE_CORPUS } from '$serveur/db';
import type { PageServerLoad } from './$types';
/**
* Tableau de bord — rendu serveur, sans prérendu.
*
* Les compteurs et les derniers changements doivent refléter le dernier
* passage du pipeline sans qu'on ait à reconstruire le site.
*/
export const load: PageServerLoad = async () => ({
compteurs: compteurs(),
echeances: prochainesEcheances(45),
decisionsAttendues: decisionsAttendues(),
changements: derniersChangements(8),
run: dernierRun(),
insights: insights(),
dateArrete: DATE_ARRETE_CORPUS
});
+362
View File
@@ -0,0 +1,362 @@
<script lang="ts">
import BadgeStatut from '$composants/BadgeStatut.svelte';
import { dateLongue, joursRestants } from '$lib/format';
import type { PageData } from './$types';
const { data }: { data: PageData } = $props();
// L'encart « point de veille » tourne sur les sept analyses transversales.
let analyseCourante = $state(0);
const analyse = $derived(data.insights[analyseCourante]);
</script>
<svelte:head>
<title>Veille législative Gwadloup — tableau de bord</title>
<meta
name="description"
content="Suivi des lois françaises et de leurs effets sur les libertés des individus, des associations et des entreprises, avec un zoom Guadeloupe."
/>
</svelte:head>
<section class="hero">
<h1>Ki lwa ka chanjé lavi an nou ?</h1>
<p class="accroche">
{data.compteurs.total} textes suivis, {data.compteurs.sources} sources vérifiables.
Données arrêtées au {dateLongue(data.dateArrete)}.
</p>
<form class="recherche" action="/recherche" method="get" role="search">
<label class="visuellement-cache" for="q">Rechercher dans les textes suivis</label>
<input
id="q"
name="q"
type="search"
placeholder="chlordécone, associations, facturation électronique…"
autocomplete="off"
/>
<button class="bouton" type="submit"><span>Rechercher</span></button>
</form>
</section>
<section class="section">
<h2>Où en sont les textes</h2>
<div class="compteurs">
{#each data.compteurs.parStatut as ligne (ligne.valeur)}
<a class="compteur" href="/recherche?statut={ligne.valeur}">
<strong>{ligne.nombre}</strong>
<span>{ligne.libelle}</span>
</a>
{/each}
</div>
<div class="ligne raccourcis">
<a class="bouton bouton-discret" href="/recherche?statut=saisie_cc">
<span>{data.compteurs.devantCc} devant le Conseil constitutionnel</span>
</a>
<a class="bouton bouton-discret" href="/recherche?guadeloupe=forte">
<span>{data.compteurs.guadeloupeForte} de pertinence forte pour la Guadeloupe</span>
</a>
<a class="bouton bouton-discret" href="/recherche?confiance=low">
<span>{data.compteurs.aVerifier} à vérifier</span>
</a>
</div>
</section>
<div class="deux-colonnes">
<section class="section">
<h2>Prochaines échéances</h2>
{#if data.echeances.length}
<ol class="echeances">
{#each data.echeances as echeance (echeance.code)}
{@const jours = joursRestants(echeance.date_echeance, data.dateArrete)}
<li class="carte echeance" class:gwada={echeance.concerne_guadeloupe === 1}>
<div class="quand">
<time datetime={echeance.date_echeance}>{dateLongue(echeance.date_echeance)}</time>
{#if jours >= 0}<span class="dans">dans {jours} j</span>{/if}
</div>
<h3>
{#if echeance.texte_id}
<a href="/textes/{echeance.texte_id}">{echeance.libelle}</a>
{:else}
{echeance.libelle}
{/if}
</h3>
{#if echeance.description}<p>{echeance.description}</p>{/if}
{#if echeance.concerne_guadeloupe === 0}
<p class="hors-gwada">Ne concerne pas la Guadeloupe.</p>
{/if}
{#if echeance.note_guadeloupe}
<p class="note-gwada">{echeance.note_guadeloupe}</p>
{/if}
</li>
{/each}
</ol>
<p><a href="/calendrier">Voir tout le calendrier →</a></p>
{:else}
<p class="vide">Aucune échéance dans les 45 prochains jours.</p>
{/if}
</section>
<div class="colonne-secondaire">
<section class="section">
<h2>Point de veille</h2>
{#if analyse}
<article class="carte analyse">
<h3>{analyse.titre}</h3>
<p>{analyse.corps.slice(0, 460)}{analyse.corps.length > 460 ? '…' : ''}</p>
{#if analyse.implications}
<p class="implications"><strong>Implications —</strong> {analyse.implications}</p>
{/if}
<div class="ligne navigation-analyse">
<span class="position">{analyseCourante + 1} / {data.insights.length}</span>
<button
type="button"
class="bouton bouton-discret"
onclick={() =>
(analyseCourante = (analyseCourante + 1) % data.insights.length)}
>
<span>Analyse suivante</span>
</button>
</div>
</article>
{/if}
</section>
<section class="section">
<h2>Décisions attendues</h2>
<ul class="decisions">
{#each data.decisionsAttendues as decision (decision.numero_affaire)}
<li>
<span class="affaire">{decision.numero_affaire}</span>
{#if decision.texte_id}
<a href="/textes/{decision.texte_id}">{decision.libelle}</a>
{:else}
<span>{decision.libelle}</span>
{/if}
{#if decision.date_echeance}
<span class="quand-decision">{dateLongue(decision.date_echeance)}</span>
{/if}
</li>
{/each}
</ul>
</section>
<section class="section">
<h2>Derniers changements détectés</h2>
{#if data.changements.length}
<ul class="changements">
{#each data.changements as changement, i (i)}
<li>
{#if changement.texte_id}
<a href="/textes/{changement.texte_id}">{changement.titre ?? changement.texte_id}</a>
{:else}
<span>{changement.description}</span>
{/if}
{#if changement.champ}
<span class="diff">
{changement.champ} : {changement.ancienne_valeur ?? '—'}
<strong>{changement.nouvelle_valeur}</strong>
</span>
{/if}
</li>
{/each}
</ul>
{:else}
<p class="vide">
Aucun changement enregistré. Le pipeline n'a pas encore tourné en mode écriture —
lancer <code>make update</code>.
</p>
{/if}
{#if data.run}
<p class="run">
Dernier passage : {data.run.horodatage} ({data.run.mode}),
{data.run.ajouts} ajout(s), {data.run.modifications} modification(s).
</p>
{/if}
</section>
</div>
</div>
<style>
.hero {
padding-block: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--line);
}
.accroche {
color: var(--muted);
max-width: 60ch;
}
.recherche {
display: flex;
gap: 0.5rem;
max-width: 720px;
margin-top: var(--space-2);
}
.recherche input {
flex: 1;
background: var(--card-bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--blanc-creme);
font: inherit;
padding: 0.6rem 0.8rem;
}
.recherche input:focus {
border-color: var(--or-oki);
}
.compteurs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.6rem;
}
.compteur {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.7rem 0.85rem;
background: var(--card-bg);
border: var(--border-card);
border-left: 4px solid var(--or-oki);
border-radius: var(--radius-md);
color: var(--blanc-creme);
text-decoration: none;
}
.compteur:hover {
background: var(--card-bg-survol);
}
.compteur strong {
font-family: var(--font-display);
font-size: 1.6rem;
line-height: 1;
color: var(--or-oki);
}
.compteur span {
font-size: 0.8rem;
color: var(--muted);
}
.raccourcis {
margin-top: var(--space-2);
}
.deux-colonnes {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
gap: var(--space-3);
}
@media (max-width: 900px) {
.deux-colonnes {
grid-template-columns: 1fr;
}
}
.echeances {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.echeance {
border-left-color: var(--muted-fort);
}
.echeance.gwada {
border-left-color: var(--turquoise-caraibes);
}
.echeance h3 {
font-size: 0.95rem;
text-transform: none;
margin: 0.2rem 0 0.3rem;
}
.echeance p {
font-size: 0.85rem;
color: var(--muted);
margin: 0 0 0.3rem;
}
.quand {
display: flex;
gap: 0.6rem;
align-items: baseline;
font-size: 0.8rem;
}
.quand time {
color: var(--or-oki);
font-weight: 600;
}
.dans {
color: var(--muted-fort);
}
.hors-gwada {
color: var(--rouge-oki) !important;
font-weight: 600;
}
.note-gwada {
color: var(--turquoise-caraibes) !important;
}
.analyse h3 {
text-transform: none;
font-size: 1rem;
}
.analyse p {
font-size: 0.87rem;
color: var(--muted);
}
.implications {
border-left: 2px solid var(--or-oki);
padding-left: 0.7rem;
}
.navigation-analyse {
justify-content: space-between;
}
.position {
font-size: 0.78rem;
color: var(--muted-fort);
font-variant-numeric: tabular-nums;
}
.decisions,
.changements {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
font-size: 0.85rem;
}
.decisions li,
.changements li {
padding-bottom: 0.45rem;
border-bottom: 1px solid var(--line);
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: baseline;
}
.affaire {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--orange-flamme);
flex: none;
}
.quand-decision,
.diff {
color: var(--muted-fort);
font-size: 0.78rem;
}
.vide,
.run {
font-size: 0.82rem;
color: var(--muted-fort);
}
code {
font-family: var(--font-mono);
font-size: 0.85em;
background: var(--card-bg);
padding: 0.1em 0.3em;
border-radius: 2px;
}
</style>
+121
View File
@@ -0,0 +1,121 @@
<svelte:head>
<title>À propos · Veille législative Gwadloup</title>
<meta
name="description"
content="Qui édite cette veille législative, avec quels moyens, sous quelle licence, et comment la reprendre."
/>
</svelte:head>
<h1>À propos</h1>
<div class="prose">
<p class="chapeau">
Cette plateforme suit la production législative française et ce qu'elle change aux libertés
des individus, des associations et des entreprises — avec un zoom Guadeloupe et outre-mer.
</p>
<h2>Pourquoi</h2>
<p>
Les textes qui restreignent ou étendent des libertés se lisent rarement dans un seul journal.
Ils arrivent par morceaux : un article dans une loi de simplification, un cavalier censuré
dans une loi de finances, un décret d'application six mois plus tard. Suivre cela demande du
temps que peu d'associations et de petites structures ont.
</p>
<p>
Cette veille rassemble ce travail au même endroit, avec les sources en clair, et signale
explicitement ce qui n'est pas établi.
</p>
<h2>Ce que la plateforme ne fait pas</h2>
<ul>
<li>Elle ne donne pas de conseil juridique. Un texte cité ici ne remplace pas un avocat.</li>
<li>
Elle n'invente rien. Quand une information manque, elle manque : la fiche le dit, plutôt
que de combler le vide.
</li>
<li>
Elle ne suit pas votre navigation. Aucun traceur, aucun cookie de mesure, aucune requête
vers un service tiers — polices comprises.
</li>
</ul>
<h2>Technique</h2>
<p>
Tout est libre et auto-hébergeable sur un serveur modeste : un pipeline Python qui lit les
sources officielles, une base SQLite unique, une application SvelteKit rendue côté serveur.
La recherche plein texte est assurée par SQLite lui-même. Aucun service externe n'est
nécessaire au fonctionnement.
</p>
<p>
Le code est sous licence AGPL. La sauvegarde consiste à copier un fichier.
</p>
<h2>Reprendre les données</h2>
<p>
Une interface de programmation en lecture est ouverte, sans clé ni inscription :
</p>
<ul class="api">
<li><code>GET /api/textes</code> — liste filtrable</li>
<li><code>GET /api/textes/[identifiant]</code> — fiche complète avec sources</li>
<li><code>GET /api/echeances</code> — calendrier</li>
</ul>
<p>
Si vous réutilisez ces données, citez la source primaire plutôt que cette plateforme : elle
n'est qu'un intermédiaire.
</p>
<h2>Contact</h2>
<p>
Une erreur, un texte manquant, une source cassée ? Signalez-le — une veille se corrige à
plusieurs. <a href="mailto:cyber.mawonaj@gmail.com">cyber.mawonaj@gmail.com</a>
</p>
<p class="lien-methode">
<a class="bouton" href="/methode"><span>Lire la méthode en détail</span></a>
</p>
</div>
<style>
.prose {
max-width: 68ch;
}
.chapeau {
font-size: 1.05rem;
color: var(--blanc-creme);
}
.prose h2 {
font-size: 0.85rem;
letter-spacing: 0.05em;
color: var(--or-oki);
border-bottom: 1px solid var(--line);
padding-bottom: 0.35rem;
margin-top: var(--space-4);
}
.prose p,
.prose li {
color: var(--muted);
}
.prose ul {
padding-left: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.prose li::marker {
color: var(--or-oki);
}
code {
font-family: var(--font-mono);
font-size: 0.88em;
background: var(--card-bg);
border: 1px solid var(--line);
padding: 0.1em 0.35em;
border-radius: 2px;
}
.api {
font-size: 0.9rem;
}
.lien-methode {
margin-top: var(--space-4);
}
</style>
+5
View File
@@ -0,0 +1,5 @@
/**
* Page figée : aucun contenu dynamique, donc exclue du manifeste du rendu
* serveur et servie en statique (§3bis de l'architecture de rendu).
*/
export const prerender = true;
+26
View File
@@ -0,0 +1,26 @@
import { json } from '@sveltejs/kit';
import { calendrier, decisionsAttendues, prochainesEcheances } from '$serveur/requetes';
import type { RequestHandler } from './$types';
/**
* `GET /api/echeances` — calendrier.
*
* `?jours=N` restreint aux N prochains jours ; sans paramètre, tout le
* calendrier est rendu, décisions attendues comprises.
*/
export const GET: RequestHandler = async ({ url }) => {
const jours = Number.parseInt(url.searchParams.get('jours') ?? '', 10);
return json(
{
echeances: Number.isFinite(jours) && jours > 0 ? prochainesEcheances(jours) : calendrier(),
decisions_attendues: decisionsAttendues()
},
{
headers: {
'cache-control': 'public, max-age=300',
'access-control-allow-origin': '*'
}
}
);
};
+36
View File
@@ -0,0 +1,36 @@
import { json } from '@sveltejs/kit';
import { lireFiltres, rechercher } from '$serveur/requetes';
import type { RequestHandler } from './$types';
/**
* `GET /api/textes` — liste filtrable.
*
* Mêmes paramètres que la page de recherche : `q`, `statut`, `theme`, `type`,
* `impact`, `guadeloupe`, `confiance`, `from`, `to`, `tri`, `page`. Les
* paramètres multivalués acceptent la répétition (`?theme=sante&theme=social`)
* comme la virgule (`?theme=sante,social`).
*
* Ouverte sans clé : ces données sont publiques et proviennent de sources
* publiques. Le cache est court — le pipeline peut écrire à tout moment.
*/
export const GET: RequestHandler = async ({ url }) => {
const filtres = lireFiltres(url.searchParams);
const resultats = rechercher(filtres);
return json(
{
filtres,
total: resultats.total,
page: resultats.page,
pages: resultats.pages,
textes: resultats.textes,
facettes: resultats.facettes
},
{
headers: {
'cache-control': 'public, max-age=300',
'access-control-allow-origin': '*'
}
}
);
};
@@ -0,0 +1,16 @@
import { error, json } from '@sveltejs/kit';
import { texte } from '$serveur/requetes';
import type { RequestHandler } from './$types';
/** `GET /api/textes/[slug]` — fiche complète, sources et timeline comprises. */
export const GET: RequestHandler = async ({ params }) => {
const fiche = texte(params.slug);
if (!fiche) error(404, { message: `Texte inconnu : ${params.slug}` });
return json(fiche, {
headers: {
'cache-control': 'public, max-age=300',
'access-control-allow-origin': '*'
}
});
};
+22
View File
@@ -0,0 +1,22 @@
import { calendrier, decisionsAttendues } from '$serveur/requetes';
import { DATE_ARRETE_CORPUS } from '$serveur/db';
import type { PageServerLoad } from './$types';
/** Calendrier — rendu serveur : les échéances vivent avec la base. */
export const load: PageServerLoad = async () => {
const echeances = calendrier();
// Regroupement par mois, dans l'ordre chronologique.
const parMois = new Map<string, typeof echeances>();
for (const echeance of echeances) {
const cle = echeance.date_echeance.slice(0, 7);
if (!parMois.has(cle)) parMois.set(cle, []);
parMois.get(cle)!.push(echeance);
}
return {
mois: [...parMois.entries()].map(([cle, liste]) => ({ cle, echeances: liste })),
decisions: decisionsAttendues(),
dateArrete: DATE_ARRETE_CORPUS
};
};
+209
View File
@@ -0,0 +1,209 @@
<script lang="ts">
import { dateLongue, joursRestants, mois as libelleMois } from '$lib/format';
import type { PageData } from './$types';
const { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>Calendrier des échéances · Veille législative Gwadloup</title>
<meta
name="description"
content="Décisions attendues du Conseil constitutionnel, entrées en vigueur du 1er septembre 2026, sénatoriales du 27 septembre, projet de loi de finances 2027."
/>
</svelte:head>
<h1>Calendrier</h1>
<p class="intro">
Échéances de la fenêtre de veille. Les dates postérieures au
{dateLongue(data.dateArrete)} sont <strong>prévisionnelles</strong> : elles indiquent ce qui est
attendu, jamais ce qui est acquis.
</p>
{#if data.decisions.length}
<section class="section">
<h2>Décisions attendues du Conseil constitutionnel</h2>
<ul class="decisions">
{#each data.decisions as decision (decision.numero_affaire)}
<li class="carte">
<span class="affaire">{decision.numero_affaire}</span>
<div>
{#if decision.texte_id}
<a href="/textes/{decision.texte_id}">{decision.libelle}</a>
{:else}
<span>{decision.libelle}</span>
{/if}
{#if decision.date_echeance}
<p class="quand">Attendue vers le {dateLongue(decision.date_echeance)}</p>
{/if}
</div>
</li>
{/each}
</ul>
</section>
{/if}
{#each data.mois as bloc (bloc.cle)}
<section class="section mois">
<h2>{libelleMois(bloc.cle + '-01')}</h2>
<ol class="liste">
{#each bloc.echeances as echeance (echeance.code)}
{@const jours = joursRestants(echeance.date_echeance, data.dateArrete)}
<li
class="carte entree"
class:gwada={echeance.concerne_guadeloupe === 1}
class:hors-gwada={echeance.concerne_guadeloupe === 0}
class:passe={jours < 0}
>
<div class="date">
<time datetime={echeance.date_echeance}>{dateLongue(echeance.date_echeance)}</time>
{#if echeance.previsionnel === 1}
<span class="prevu">prévisionnel</span>
{/if}
</div>
<div class="contenu">
<h3>
{#if echeance.texte_id}
<a href="/textes/{echeance.texte_id}">{echeance.libelle}</a>
{:else}
{echeance.libelle}
{/if}
</h3>
{#if echeance.description}<p>{echeance.description}</p>{/if}
{#if echeance.concerne_guadeloupe === 0}
<p class="mention-gwada negative">Ne concerne pas la Guadeloupe</p>
{:else if echeance.concerne_guadeloupe === 1}
<p class="mention-gwada positive">Concerne la Guadeloupe</p>
{/if}
{#if echeance.note_guadeloupe}<p class="note">{echeance.note_guadeloupe}</p>{/if}
{#if echeance.source_extrait}
<blockquote>{echeance.source_extrait}</blockquote>
{/if}
{#if echeance.source_url}
<a class="source" href={echeance.source_url} rel="noopener nofollow" target="_blank">
Source
</a>
{/if}
</div>
</li>
{/each}
</ol>
</section>
{/each}
<style>
.intro {
max-width: 70ch;
color: var(--muted);
}
.mois h2,
.section h2 {
font-size: 0.85rem;
letter-spacing: 0.05em;
color: var(--or-oki);
border-bottom: 1px solid var(--line);
padding-bottom: 0.35rem;
}
.liste,
.decisions {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.decisions li {
display: flex;
gap: 0.8rem;
align-items: baseline;
border-left-color: var(--orange-flamme);
}
.affaire {
font-family: var(--font-mono);
font-size: 0.82rem;
color: var(--orange-flamme);
flex: none;
}
.quand {
font-size: 0.8rem;
color: var(--muted-fort);
margin: 0.2rem 0 0;
}
.entree {
display: grid;
grid-template-columns: minmax(150px, auto) 1fr;
gap: var(--space-2);
border-left-color: var(--muted-fort);
}
.entree.gwada {
border-left-color: var(--turquoise-caraibes);
}
.entree.hors-gwada {
border-left-color: var(--rouge-oki);
}
.entree.passe {
opacity: 0.65;
}
.date {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
time {
color: var(--or-oki);
font-weight: 600;
font-size: 0.85rem;
}
.prevu {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted-fort);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0 0.35em;
align-self: flex-start;
}
.contenu h3 {
font-size: 0.95rem;
text-transform: none;
margin-bottom: 0.3rem;
}
.contenu p {
font-size: 0.85rem;
color: var(--muted);
margin: 0 0 0.35rem;
}
.mention-gwada {
font-weight: 600;
font-size: 0.82rem !important;
}
.mention-gwada.negative {
color: var(--rouge-oki) !important;
}
.mention-gwada.positive {
color: var(--turquoise-caraibes) !important;
}
.note {
color: var(--turquoise-caraibes) !important;
}
blockquote {
margin: 0.4rem 0;
padding-left: 0.8rem;
border-left: 2px solid var(--line);
font-size: 0.82rem;
color: var(--muted-fort);
font-style: italic;
}
.source {
font-size: 0.78rem;
}
@media (max-width: 620px) {
.entree {
grid-template-columns: 1fr;
gap: 0.4rem;
}
}
</style>
+206
View File
@@ -0,0 +1,206 @@
<svelte:head>
<title>Méthode · Veille législative Gwadloup</title>
<meta
name="description"
content="Comment cette veille distingue promulgué, adopté et en navette ; d'où viennent les données ; ce qu'elles ne disent pas."
/>
</svelte:head>
<h1>Méthode</h1>
<div class="prose">
<p class="chapeau">
Une veille législative ne vaut que par ce qu'elle refuse d'affirmer. Cette page dit d'où
viennent les données, comment elles sont qualifiées, et où sont leurs limites.
</p>
<h2>La règle d'or : le statut prime sur l'intitulé</h2>
<p>
Un texte « voté » n'est pas un texte applicable. Entre le vote et l'entrée en vigueur, il
peut être censuré, amputé, ou attendre des décrets pendant des mois. La confusion entre ces
états est la première source d'erreur dans le débat public — et elle change tout pour qui
veut savoir ce qui s'applique aujourd'hui.
</p>
<dl class="statuts">
<dt>Promulguée</dt>
<dd>
Le texte porte un numéro de loi et a été publié au Journal officiel. Il produit ses
effets, sous réserve de ses propres dates d'entrée en vigueur différées.
</dd>
<dt>Adoptée, non promulguée</dt>
<dd>
Le Parlement a voté définitivement, mais le texte n'est pas signé. Il n'a pas de numéro.
Il ne s'applique pas. Une saisine du Conseil constitutionnel suspend la promulgation
pendant un mois.
</dd>
<dt>Devant le Conseil constitutionnel</dt>
<dd>
Une affaire est enregistrée et la décision n'est pas rendue. Le texte peut en sortir
intact, amputé, ou annulé. Tant que la décision n'est pas rendue, son contenu applicable
est <em>inconnu</em>.
</dd>
<dt>Validée, en attente de promulgation</dt>
<dd>
Le Conseil a statué favorablement ; il ne manque que la signature. La promulgation est
très probable mais pas encore intervenue.
</dd>
<dt>En navette</dt>
<dd>
Le texte fait l'aller-retour entre l'Assemblée et le Sénat. Son contenu bouge encore.
Rien de ce qu'il contient n'est acquis.
</dd>
<dt>Déposée, non examinée · Annoncée</dt>
<dd>
Le texte existe sur le papier — ou seulement dans une déclaration. Il documente une
intention politique, pas le droit positif.
</dd>
</dl>
<h2>D'où viennent les données</h2>
<p>
Le socle est un corpus de recherche arrêté au <strong>25 juillet 2026</strong>, couvrant la
production législative du 1<sup>er</sup> mai au 30 septembre 2026 : un rapport consolidé de
onze chapitres, douze rapports de dimension thématique, une bibliographie de 481 références,
et un fichier d'arbitrage des désaccords tranchés sur sources primaires.
</p>
<p>
Chaque fait importé conserve sa citation d'origine — adresse, date, extrait verbatim — et son
niveau de confiance. Les marqueurs de citation du rapport sont résolus vers leur source :
aucune affirmation n'entre en base sans que l'on puisse remonter à ce qui l'atteste.
</p>
<h2>Comment la base se met à jour</h2>
<p>
Un pipeline interroge régulièrement les sources officielles : Légifrance par son interface de
programmation publique, la liste chronologique des lois promulguées du Sénat, et le registre
des affaires du Conseil constitutionnel. Chaque source est interrogée séparément ; si l'une
est indisponible, les autres continuent.
</p>
<p>
Un texte collecté est rapproché de la base par son numéro officiel, à défaut par similarité
de titre. Entre deux seuils, le rapprochement est <strong>signalé mais pas appliqué</strong> :
mieux vaut une vérification humaine qu'une fusion erronée. En cas de contradiction entre
sources, la source primaire l'emporte — Journal officiel et Légifrance devant les assemblées,
les assemblées devant la presse.
</p>
<h2>Ce qui est déduit, et signalé comme tel</h2>
<p>
Certaines informations ne figurent nulle part explicitement et sont déduites. Elles portent
alors la mention <strong>« à vérifier »</strong> et un motif lisible sur la fiche :
</p>
<ul>
<li>
<strong>Pertinence Guadeloupe des textes non promulgués.</strong> Le rapport ne cote que
les lois promulguées ; pour les autres, la cotation vient d'une détection de vocabulaire.
</li>
<li>
<strong>Sens des impacts par public.</strong> Le rapport consacre un chapitre aux
individus, un aux associations, un aux entreprises : le placement d'un texte dans l'un de
ces chapitres est un choix éditorial explicite, et sert de base à la ventilation. Le sens
— favorable, défavorable, mixte — vient de la cotation manuelle quand elle existe, du
vocabulaire sinon.
</li>
<li>
<strong>Thèmes.</strong> Déduits par mots-clés sur l'intitulé et le résumé.
</li>
<li>
<strong>Textes ajoutés par le pipeline.</strong> Ils entrent avec une confiance basse et
le drapeau de revue : la machine propose, un humain valide.
</li>
</ul>
<h2>Les échéances sont des prévisions</h2>
<p>
Toute date postérieure au 25 juillet 2026 est prévisionnelle, et affichée comme telle. Une
décision « attendue vers le 24 août » peut tomber avant, après, ou être reportée. Les
libellés du corpus — « fin août », « automne 2026 » — sont conservés tels quels ; la date
associée ne sert qu'au tri et au filtrage.
</p>
<h2>Limites connues</h2>
<ul>
<li>
Le corpus s'arrête au 25 juillet 2026. Tout ce qui suit dépend des passages du pipeline.
</li>
<li>
Certaines références du corpus n'ont pas d'adresse consultable — surtout dans la
dimension consacrée aux associations. Elles sont conservées comme références
bibliographiques, sans lien.
</li>
<li>
Le contenu du titre « adaptation outre-mer » de la loi « Riposte » n'était pas public à
la date d'arrêté. Les effets ultramarins de ce texte sont donc incomplets.
</li>
<li>
La veille suit les lois, pas les décrets d'application. Or c'est souvent au niveau
réglementaire que se jouent les effets concrets — les décrets chlordécone en sont
l'exemple.
</li>
</ul>
<h2>Corriger une erreur</h2>
<p>
Si une fiche est fausse, elle doit être corrigée, pas défendue.
<a href="mailto:cyber.mawonaj@gmail.com">Signalez-la</a> en indiquant l'identifiant du texte
et la source qui vous donne raison.
</p>
</div>
<style>
.prose {
max-width: 70ch;
}
.chapeau {
font-size: 1.05rem;
color: var(--blanc-creme);
}
.prose h2 {
font-size: 0.85rem;
letter-spacing: 0.05em;
color: var(--or-oki);
border-bottom: 1px solid var(--line);
padding-bottom: 0.35rem;
margin-top: var(--space-4);
}
.prose p,
.prose li,
.prose dd {
color: var(--muted);
}
.prose ul {
padding-left: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prose li::marker {
color: var(--or-oki);
}
.statuts {
display: grid;
gap: 0.3rem 0;
margin: var(--space-2) 0;
}
.statuts dt {
font-family: var(--font-display);
font-weight: 700;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--blanc-creme);
margin-top: var(--space-2);
}
.statuts dd {
margin: 0;
padding-left: 0.9rem;
border-left: 2px solid var(--line);
font-size: 0.9rem;
}
</style>
+2
View File
@@ -0,0 +1,2 @@
/** Page figée, prérendue au build — voir a-propos/+page.ts. */
export const prerender = true;
+34
View File
@@ -0,0 +1,34 @@
import { basculerFacette, lireFiltres, rechercher } from '$serveur/requetes';
import type { PageServerLoad } from './$types';
/**
* Recherche à facettes — rendu serveur, jamais prérendue.
*
* Les facettes vivent dans `url.searchParams` : une URL de recherche est
* partageable et se recharge à l'identique. Le prérendu interdit d'y accéder,
* c'est pourquoi cette route reste en rendu serveur (§3bis).
*/
export const load: PageServerLoad = async ({ url }) => {
const filtres = lireFiltres(url.searchParams);
const resultats = rechercher(filtres);
return {
filtres,
resultats,
// Les liens de facette sont calculés côté serveur : la page fonctionne
// entièrement sans JavaScript.
liens: Object.fromEntries(
(['statut', 'theme', 'type', 'impact', 'guadeloupe', 'confiance'] as const).map(
(champ) => [
champ,
Object.fromEntries(
resultats.facettes[champ === 'guadeloupe' ? 'guadeloupe' : champ].map((f) => [
f.valeur,
basculerFacette(filtres, champ, f.valeur)
])
)
]
)
)
};
};
+215
View File
@@ -0,0 +1,215 @@
<script lang="ts">
import CarteTexte from '$composants/CarteTexte.svelte';
import PanneauFacettes from '$composants/PanneauFacettes.svelte';
import { pluriel } from '$lib/format';
import type { Filtres } from '$lib/types';
import type { PageData } from './$types';
const { data }: { data: PageData } = $props();
const lien = (champ: keyof Filtres, valeur: string) =>
(data.liens as Record<string, Record<string, string>>)[champ as string]?.[valeur] ??
'/recherche';
const chipsActifs = $derived(
(['statut', 'guadeloupe', 'impact', 'theme', 'type', 'confiance'] as const).flatMap(
(champ) =>
data.resultats.facettes[champ]
.filter((f) => f.actif)
.map((f) => ({ champ, ...f }))
)
);
function urlPage(numero: number): string {
const parametres = new URLSearchParams(
typeof window === 'undefined' ? '' : window.location.search
);
parametres.set('page', String(numero));
return `/recherche?${parametres.toString()}`;
}
</script>
<svelte:head>
<title>
{data.filtres.q ? `« ${data.filtres.q} » recherche` : 'Recherche'} · Veille législative
</title>
<meta name="robots" content="noindex, follow" />
</svelte:head>
<h1>Recherche</h1>
<form class="recherche" action="/recherche" method="get" role="search">
<label class="visuellement-cache" for="q">Rechercher</label>
<input id="q" name="q" type="search" value={data.filtres.q} placeholder="Mot-clé, numéro de loi…" />
{#each ['statut', 'theme', 'type', 'impact', 'guadeloupe', 'confiance'] as champ (champ)}
{#each data.filtres[champ as keyof Filtres] as string[] as valeur (valeur)}
<input type="hidden" name={champ} value={valeur} />
{/each}
{/each}
<button class="bouton" type="submit"><span>Chercher</span></button>
</form>
{#if chipsActifs.length}
<div class="ligne chips">
<span class="etiquette">Filtres actifs :</span>
{#each chipsActifs as chip (chip.champ + chip.valeur)}
<a class="chip" href={lien(chip.champ as keyof Filtres, chip.valeur)} rel="nofollow">
{chip.libelle}
<span aria-hidden="true">×</span>
<span class="visuellement-cache">Retirer ce filtre</span>
</a>
{/each}
<a class="chip effacer" href={data.filtres.q ? `/recherche?q=${encodeURIComponent(data.filtres.q)}` : '/recherche'}>
Tout effacer
</a>
</div>
{/if}
<div class="mise-en-page">
<PanneauFacettes facettes={data.resultats.facettes} filtres={data.filtres} {lien} />
<div class="resultats">
<p class="total" role="status">
<strong>{data.resultats.total}</strong>
{pluriel(data.resultats.total, 'texte')}
{#if data.filtres.q}pour « {data.filtres.q} »{/if}
</p>
{#if data.resultats.textes.length}
<div class="liste">
{#each data.resultats.textes as texte (texte.id)}
<CarteTexte {texte} />
{/each}
</div>
{#if data.resultats.pages > 1}
<nav class="pagination" aria-label="Pagination">
{#if data.resultats.page > 1}
<a class="bouton bouton-discret" href={urlPage(data.resultats.page - 1)}>
<span>← Précédent</span>
</a>
{/if}
<span class="position">Page {data.resultats.page} sur {data.resultats.pages}</span>
{#if data.resultats.page < data.resultats.pages}
<a class="bouton bouton-discret" href={urlPage(data.resultats.page + 1)}>
<span>Suivant →</span>
</a>
{/if}
</nav>
{/if}
{:else}
<div class="carte vide">
<h2>Aucun résultat</h2>
<p>
Aucun texte ne correspond à cette combinaison de filtres. Essayez de retirer un
critère, ou de chercher sur un mot plus général.
</p>
<a class="bouton" href="/recherche"><span>Repartir de zéro</span></a>
</div>
{/if}
</div>
</div>
<style>
.recherche {
display: flex;
gap: 0.5rem;
margin-bottom: var(--space-2);
max-width: 720px;
}
.recherche input[type='search'] {
flex: 1;
background: var(--card-bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--blanc-creme);
font: inherit;
padding: 0.55rem 0.8rem;
}
.recherche input:focus {
border-color: var(--or-oki);
}
.chips {
margin-bottom: var(--space-2);
gap: 0.35rem;
}
.etiquette {
font-size: 0.78rem;
color: var(--muted-fort);
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4em;
background: color-mix(in srgb, var(--or-oki) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--or-oki) 55%, transparent);
border-radius: var(--radius-sm);
color: var(--blanc-creme);
font-size: 0.78rem;
padding: 0.18em 0.6em;
text-decoration: none;
}
.chip:hover {
background: color-mix(in srgb, var(--or-oki) 22%, transparent);
}
.chip.effacer {
background: transparent;
border-color: var(--line);
color: var(--muted);
}
.mise-en-page {
display: grid;
grid-template-columns: minmax(210px, 260px) minmax(0, 1fr);
gap: var(--space-3);
align-items: start;
}
@media (max-width: 820px) {
.mise-en-page {
grid-template-columns: 1fr;
}
}
.total {
font-size: 0.9rem;
color: var(--muted);
margin-bottom: var(--space-2);
}
.total strong {
color: var(--or-oki);
font-size: 1.1rem;
}
.liste {
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
margin-top: var(--space-3);
}
.position {
font-size: 0.82rem;
color: var(--muted-fort);
}
.vide {
text-align: center;
padding: var(--space-4) var(--space-2);
border-left-color: var(--muted-fort);
}
.vide h2 {
font-size: 1.05rem;
}
.vide p {
color: var(--muted);
max-width: 46ch;
margin-inline: auto;
}
</style>
@@ -0,0 +1,19 @@
import { error } from '@sveltejs/kit';
import { texte } from '$serveur/requetes';
import { DATE_ARRETE_CORPUS } from '$serveur/db';
import type { PageServerLoad } from './$types';
/**
* Fiche détaillée — rendu serveur.
*
* Pas de prérendu : la fiche doit être fraîche dès que le pipeline écrit dans
* SQLite, sans reconstruction du site. C'est aussi la page la plus utile au
* référencement, donc celle qui gagne le plus au rendu serveur.
*/
export const load: PageServerLoad = async ({ params }) => {
const fiche = texte(params.slug);
if (!fiche) {
error(404, `Aucun texte suivi ne porte l'identifiant « ${params.slug} ».`);
}
return { texte: fiche, dateArrete: DATE_ARRETE_CORPUS };
};
+571
View File
@@ -0,0 +1,571 @@
<script lang="ts">
import BadgeGuadeloupe from '$composants/BadgeGuadeloupe.svelte';
import BadgeStatut from '$composants/BadgeStatut.svelte';
import { dateLongue } from '$lib/format';
import {
LIBELLES_CONFIANCE,
LIBELLES_ETAPE,
LIBELLES_IMPACT,
LIBELLES_RESULTAT_CC,
LIBELLES_THEME,
LIBELLES_TYPE
} from '$lib/libelles';
import type { PageData } from './$types';
const { data }: { data: PageData } = $props();
const t = $derived(data.texte);
const PUBLICS = ['individus', 'associations', 'entreprises'] as const;
let copie = $state(false);
function citation(): string {
const nom = t.numero_officiel ? `Loi n° ${t.numero_officiel}` : t.titre_court;
const date = t.date_promulgation
? `, ${dateLongue(t.date_promulgation)}`
: t.date_adoption
? `, adoptée le ${dateLongue(t.date_adoption)}`
: '';
const url =
typeof window === 'undefined' ? `/textes/${t.id}` : window.location.href;
return `${nom}${t.titre_court}${date}. Veille législative Gwadloup, consulté le ${dateLongue(
new Date().toISOString()
)}. ${url}`;
}
async function copierCitation() {
try {
await navigator.clipboard.writeText(citation());
copie = true;
setTimeout(() => (copie = false), 2500);
} catch {
/* presse-papiers refusé : la citation reste sélectionnable à la main */
}
}
</script>
<svelte:head>
<title>{t.titre_court} · Veille législative Gwadloup</title>
<meta name="description" content={(t.resume ?? t.titre_court).slice(0, 180)} />
</svelte:head>
<nav class="fil" aria-label="Fil d'Ariane">
<a href="/">Tableau de bord</a>
<a href="/recherche">Recherche</a>
<span aria-current="page">{t.numero_officiel ? `Loi n° ${t.numero_officiel}` : t.titre_court}</span>
</nav>
<article class="fiche">
<header>
{#if t.numero_officiel}<p class="numero">Loi n° {t.numero_officiel}</p>{/if}
<h1>{t.titre_court}</h1>
{#if t.titre_officiel && t.titre_officiel !== t.titre_court}
<p class="titre-officiel">{t.titre_officiel}</p>
{/if}
<div class="ligne badges">
<BadgeStatut statut={t.statut} devantCc={t.devant_cc === 1} />
<BadgeGuadeloupe
pertinence={t.guadeloupe_pertinence}
note={t.guadeloupe_note}
aVerifier={t.a_verifier === 1}
/>
<span class="tag">{LIBELLES_TYPE[t.type] ?? t.type}</span>
<span class="tag confiance" data-niveau={t.confiance}>
Confiance : {LIBELLES_CONFIANCE[t.confiance] ?? t.confiance}
</span>
</div>
{#if t.a_verifier === 1 && t.motif_verification}
<aside class="avertissement" role="note">
<strong>À vérifier —</strong>
{t.motif_verification}
</aside>
{/if}
</header>
<div class="corps">
<div class="principal">
{#if t.resume}
<section>
<h2>Résumé</h2>
<p class="resume">{t.resume}</p>
</section>
{/if}
{#if t.points_cles.length}
<section>
<h2>Points clés</h2>
<ul class="points">
{#each t.points_cles as point (point)}
<li>{point}</li>
{/each}
</ul>
</section>
{/if}
{#if Object.keys(t.impacts).length}
<section>
<h2>Effets sur les libertés</h2>
<div class="impacts">
{#each PUBLICS as pub (pub)}
{@const impact = t.impacts[pub]}
{#if impact}
<div class="impact" data-sens={impact.sens}>
<h3>{pub}</h3>
<p class="sens">{LIBELLES_IMPACT[`${pub}:${impact.sens}`] ?? impact.sens}</p>
{#if impact.note}<p class="note">{impact.note}</p>{/if}
</div>
{/if}
{/each}
</div>
</section>
{/if}
{#if t.evenements.length}
<section>
<h2>Parcours du texte</h2>
<ol class="timeline">
{#each t.evenements as evenement (evenement.date_evenement + evenement.type_etape)}
<li class:previsionnel={evenement.previsionnel === 1}>
<time datetime={evenement.date_evenement}>
{dateLongue(evenement.date_evenement)}
</time>
<div>
<strong>{LIBELLES_ETAPE[evenement.type_etape] ?? evenement.type_etape}</strong>
{#if evenement.previsionnel === 1}
<span class="prevu">prévisionnel</span>
{/if}
<p>{evenement.description}</p>
</div>
</li>
{/each}
</ol>
</section>
{/if}
{#if t.decisions_cc.length}
<section>
<h2>Conseil constitutionnel</h2>
{#each t.decisions_cc as decision (decision.numero_affaire)}
<div class="carte decision">
<h3>Affaire n° {decision.numero_affaire}</h3>
<dl>
{#if decision.date_saisine}
<dt>Saisine</dt>
<dd>{dateLongue(decision.date_saisine)}</dd>
{/if}
{#if decision.date_decision}
<dt>Décision</dt>
<dd>{dateLongue(decision.date_decision)}</dd>
{:else if decision.date_decision_attendue}
<dt>Décision attendue</dt>
<dd>{dateLongue(decision.date_decision_attendue)}</dd>
{/if}
{#if decision.resultat}
<dt>Résultat</dt>
<dd>{LIBELLES_RESULTAT_CC[decision.resultat] ?? decision.resultat}</dd>
{/if}
{#if decision.saisissants}
<dt>Saisissants</dt>
<dd>{decision.saisissants}</dd>
{/if}
</dl>
{#if decision.resume}<p>{decision.resume}</p>{/if}
</div>
{/each}
</section>
{/if}
<section>
<h2>Sources ({t.sources.length})</h2>
<p class="avant-sources">
Chaque source est citée telle qu'elle a été consultée. Les sources primaires
(Journal officiel, assemblées, Conseil constitutionnel, Conseil d'État) sont
marquées T1, la presse et les analyses T2.
</p>
<ol class="sources">
{#each t.sources as source (source.url + (source.marqueur ?? ''))}
<li class="carte source" data-tier={source.tier}>
<div class="ligne entete-source">
<span class="tier">{source.tier}</span>
{#if source.editeur}<span class="editeur">{source.editeur}</span>{/if}
{#if source.date_publication}
<time datetime={source.date_publication}>
{dateLongue(source.date_publication)}
</time>
{/if}
</div>
{#if source.titre}<p class="titre-source">{source.titre}</p>{/if}
{#if source.extrait_verbatim}
<blockquote>{source.extrait_verbatim}</blockquote>
{/if}
{#if source.contexte}<p class="contexte">{source.contexte}</p>{/if}
<a class="url" href={source.url} rel="noopener nofollow" target="_blank">
{source.url}
</a>
</li>
{/each}
</ol>
</section>
</div>
<aside class="lateral">
<div class="carte encart">
<h2>Repères</h2>
<dl>
{#if t.date_depot}
<dt>Dépôt</dt>
<dd>{dateLongue(t.date_depot)}</dd>
{/if}
{#if t.date_adoption}
<dt>Adoption</dt>
<dd>{dateLongue(t.date_adoption)}</dd>
{/if}
{#if t.date_promulgation}
<dt>Promulgation</dt>
<dd>{dateLongue(t.date_promulgation)}</dd>
{/if}
{#if t.date_entree_vigueur}
<dt>Entrée en vigueur</dt>
<dd>{dateLongue(t.date_entree_vigueur)}</dd>
{/if}
{#if t.prochaine_echeance_label}
<dt>Prochaine étape</dt>
<dd>{t.prochaine_echeance_label}</dd>
{/if}
</dl>
</div>
{#if t.guadeloupe_note}
<div class="carte encart gwada">
<h2>Guadeloupe</h2>
<p>{t.guadeloupe_note}</p>
</div>
{/if}
{#if t.themes.length}
<div class="carte encart">
<h2>Thèmes</h2>
<div class="ligne">
{#each t.themes as theme (theme)}
<a class="tag" href="/recherche?theme={theme}">
{LIBELLES_THEME[theme] ?? theme}
</a>
{/each}
</div>
</div>
{/if}
<div class="carte encart">
<h2>Citer cette fiche</h2>
<button type="button" class="bouton" onclick={copierCitation}>
<span>{copie ? 'Copié' : 'Copier la citation'}</span>
</button>
{#if t.source_seed}
<p class="provenance">Origine : <code>{t.source_seed}</code></p>
{/if}
{#if t.derniere_verif}
<p class="provenance">Dernière vérification : {t.derniere_verif}</p>
{/if}
</div>
</aside>
</div>
</article>
<style>
.fil {
font-size: 0.78rem;
color: var(--muted-fort);
margin-bottom: var(--space-2);
}
.fil a {
color: var(--muted);
}
.numero {
font-family: var(--font-mono);
color: var(--or-oki);
margin: 0 0 0.2rem;
font-size: 0.9rem;
}
.titre-officiel {
color: var(--muted);
font-size: 0.92rem;
max-width: 72ch;
}
.badges {
margin-bottom: var(--space-2);
}
.confiance[data-niveau='low'] {
border-color: color-mix(in srgb, var(--rouge-oki) 55%, transparent);
background: color-mix(in srgb, var(--rouge-oki) 10%, transparent);
}
.avertissement {
border: 1px solid color-mix(in srgb, var(--or-oki) 55%, transparent);
background: color-mix(in srgb, var(--or-oki) 9%, transparent);
border-radius: var(--radius-md);
padding: 0.7rem 0.9rem;
font-size: 0.86rem;
margin-bottom: var(--space-2);
}
.corps {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(240px, 300px);
gap: var(--space-3);
align-items: start;
}
@media (max-width: 900px) {
.corps {
grid-template-columns: 1fr;
}
}
section {
margin-bottom: var(--space-4);
}
section h2 {
font-size: 0.85rem;
letter-spacing: 0.05em;
color: var(--or-oki);
border-bottom: 1px solid var(--line);
padding-bottom: 0.35rem;
margin-bottom: var(--space-2);
}
.resume {
font-size: 1rem;
max-width: 70ch;
}
.points {
margin: 0;
padding-left: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
font-size: 0.92rem;
max-width: 74ch;
}
.points li::marker {
color: var(--or-oki);
}
.impacts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.7rem;
}
.impact {
border: var(--border-card);
border-left: 4px solid var(--impact-neutre);
border-radius: var(--radius-md);
padding: 0.7rem 0.85rem;
background: var(--card-bg);
}
.impact[data-sens='positif'] {
border-left-color: var(--impact-positif);
}
.impact[data-sens='negatif'] {
border-left-color: var(--impact-negatif);
}
.impact[data-sens='mixte'] {
border-left-color: var(--impact-mixte);
}
.impact h3 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted-fort);
margin-bottom: 0.2rem;
}
.impact .sens {
font-weight: 600;
font-size: 0.88rem;
margin-bottom: 0.3rem;
}
.impact .note {
font-size: 0.82rem;
color: var(--muted);
margin: 0;
}
.timeline {
list-style: none;
margin: 0;
padding: 0 0 0 1rem;
border-left: 2px solid var(--line);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.timeline li {
position: relative;
display: grid;
grid-template-columns: minmax(120px, auto) 1fr;
gap: 0.8rem;
font-size: 0.88rem;
}
.timeline li::before {
content: '';
position: absolute;
left: -1.35rem;
top: 0.4rem;
width: 8px;
height: 8px;
background: var(--or-oki);
border-radius: 1px;
}
.timeline li.previsionnel::before {
background: transparent;
border: 2px solid var(--or-oki);
}
.timeline time {
color: var(--or-oki);
font-weight: 600;
font-size: 0.82rem;
}
.timeline p {
margin: 0.15rem 0 0;
color: var(--muted);
font-size: 0.85rem;
}
.prevu {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted-fort);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0 0.35em;
margin-left: 0.35em;
}
.decision {
border-left-color: var(--orange-flamme);
margin-bottom: 0.6rem;
}
.decision h3 {
font-family: var(--font-mono);
font-size: 0.9rem;
text-transform: none;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.8rem;
margin: 0 0 0.5rem;
font-size: 0.85rem;
}
dt {
color: var(--muted-fort);
}
dd {
margin: 0;
}
.avant-sources {
font-size: 0.82rem;
color: var(--muted-fort);
max-width: 68ch;
}
.sources {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.source {
border-left-color: var(--muted-fort);
font-size: 0.86rem;
}
.source[data-tier='T1'] {
border-left-color: var(--vert-oki);
}
.entete-source {
gap: 0.5rem;
font-size: 0.76rem;
color: var(--muted-fort);
}
.tier {
font-family: var(--font-mono);
font-weight: 700;
color: var(--vert-oki);
}
.source[data-tier='T2'] .tier {
color: var(--muted-fort);
}
.editeur {
font-weight: 600;
color: var(--blanc-creme);
}
.titre-source {
margin: 0.3rem 0;
}
blockquote {
margin: 0.4rem 0;
padding-left: 0.8rem;
border-left: 2px solid var(--or-oki);
color: var(--muted);
font-style: italic;
}
.contexte {
font-size: 0.82rem;
color: var(--muted-fort);
margin: 0.3rem 0;
}
.url {
font-size: 0.75rem;
word-break: break-all;
font-family: var(--font-mono);
}
.lateral {
display: flex;
flex-direction: column;
gap: 0.7rem;
position: sticky;
top: 5.5rem;
}
.encart h2 {
font-size: 0.75rem;
border: 0;
padding: 0;
margin-bottom: 0.5rem;
}
.encart.gwada {
border-left-color: var(--turquoise-caraibes);
}
.encart p {
font-size: 0.85rem;
color: var(--muted);
margin: 0;
}
.provenance {
font-size: 0.74rem !important;
color: var(--muted-fort) !important;
margin-top: 0.5rem !important;
}
code {
font-family: var(--font-mono);
font-size: 0.9em;
word-break: break-all;
}
@media (max-width: 900px) {
.lateral {
position: static;
}
}
@media (max-width: 560px) {
.timeline li {
grid-template-columns: 1fr;
gap: 0.15rem;
}
}
</style>
+121
View File
@@ -0,0 +1,121 @@
/// <reference types="@sveltejs/kit" />
/// <reference lib="webworker" />
/**
* Service worker minimal — consultation hors ligne des dernières données.
*
* Deux stratégies, et pas une de plus :
*
* - **Le coffre du build** (JS, CSS, polices, icônes) est mis en cache à
* l'installation et servi depuis le cache. Ces fichiers portent une empreinte
* dans leur nom : ils ne changent jamais sous le même nom.
* - **Les pages et l'API** passent par le réseau d'abord, avec repli sur le
* cache. Une veille législative doit montrer l'état le plus récent qu'elle
* peut atteindre ; servir une page périmée alors que le réseau répond serait
* trompeur sur un sujet où une date de promulgation change tout.
*
* Le hors-ligne n'est donc pas un mode de consultation normal : c'est un filet
* pour les connexions instables, fréquentes en zone rurale guadeloupéenne. La
* page servie depuis le cache le signale à l'utilisateur via l'en-tête ajouté
* `X-Depuis-Cache`.
*/
import { build, files, version } from '$service-worker';
const sw = self as unknown as ServiceWorkerGlobalScope;
const CACHE_COFFRE = `coffre-${version}`;
const CACHE_PAGES = `pages-${version}`;
// Les polices et le manifeste valent d'être précachés ; pas les gros binaires.
const A_PRECACHER = [...build, ...files.filter((f) => !f.endsWith('.map'))];
sw.addEventListener('install', (evenement) => {
evenement.waitUntil(
caches.open(CACHE_COFFRE).then((cache) => cache.addAll(A_PRECACHER))
);
// Le nouveau worker prend la main sans attendre la fermeture des onglets :
// une correction de données ne doit pas rester bloquée derrière un onglet
// ouvert depuis une semaine.
sw.skipWaiting();
});
sw.addEventListener('activate', (evenement) => {
evenement.waitUntil(
(async () => {
const noms = await caches.keys();
await Promise.all(
noms
.filter((nom) => nom !== CACHE_COFFRE && nom !== CACHE_PAGES)
.map((nom) => caches.delete(nom))
);
await sw.clients.claim();
})()
);
});
sw.addEventListener('fetch', (evenement) => {
const requete = evenement.request;
if (requete.method !== 'GET') return;
const url = new URL(requete.url);
// Rien qui ne vienne de cette origine : la politique de sécurité de contenu
// interdit déjà les tiers, le worker ne les mettra pas en cache non plus.
if (url.origin !== location.origin) return;
// Coffre du build : cache d'abord, sans condition.
if (A_PRECACHER.includes(url.pathname)) {
evenement.respondWith(
caches
.open(CACHE_COFFRE)
.then((cache) => cache.match(url.pathname))
.then((reponse) => reponse ?? fetch(requete))
);
return;
}
// Pages et API : réseau d'abord, cache en repli.
evenement.respondWith(reseauPuisCache(requete));
});
async function reseauPuisCache(requete: Request): Promise<Response> {
const cache = await caches.open(CACHE_PAGES);
try {
const reponse = await fetch(requete);
if (reponse.ok) {
cache.put(requete, reponse.clone());
}
return reponse;
} catch {
const enCache = await cache.match(requete);
if (enCache) {
// On signale explicitement que la réponse peut être périmée.
const entetes = new Headers(enCache.headers);
entetes.set('X-Depuis-Cache', 'true');
return new Response(enCache.body, {
status: enCache.status,
statusText: enCache.statusText,
headers: entetes
});
}
return new Response(
`<!doctype html><html lang="fr"><head><meta charset="utf-8">
<title>Hors ligne</title>
<style>
body{background:#0D0D0D;color:#FFF8E7;font-family:system-ui,sans-serif;
display:grid;place-items:center;min-height:100vh;margin:0;text-align:center;padding:2rem}
h1{font-size:1.4rem;text-transform:uppercase;letter-spacing:-0.01em}
p{color:#B6B1A6;max-width:40ch}
.bar{height:6px;width:56px;margin:1.5rem auto 0;background:linear-gradient(to right,
#0D0D0D 0 25%,#FDB813 25% 50%,#00D66C 50% 75%,#FF1654 75% 100%)}
</style></head><body><div>
<h1>Pa ni rézo</h1>
<p>Cette page n'a pas encore été consultée et le réseau est indisponible.
Les pages déjà visitées restent accessibles.</p>
<div class="bar"></div>
</div></body></html>`,
{ status: 503, headers: { 'content-type': 'text/html; charset=utf-8' } }
);
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Veille législative Gwadloup">
<rect width="64" height="64" fill="#0D0D0D"/>
<rect x="0" y="52" width="16" height="12" fill="#0D0D0D"/>
<rect x="16" y="52" width="16" height="12" fill="#FDB813"/>
<rect x="32" y="52" width="16" height="12" fill="#00D66C"/>
<rect x="48" y="52" width="16" height="12" fill="#FF1654"/>
<path d="M14 12 L24 44 L34 12" fill="none" stroke="#FDB813" stroke-width="7" stroke-linejoin="miter"/>
<rect x="40" y="12" width="7" height="32" fill="#FDB813"/>
<rect x="40" y="37" width="14" height="7" fill="#FDB813"/>
</svg>

After

Width:  |  Height:  |  Size: 643 B

+17
View File
@@ -0,0 +1,17 @@
{
"name": "Veille législative Gwadloup",
"short_name": "Vèy Lwa",
"description": "Lois françaises et effets sur les libertés des individus, des associations et des entreprises — zoom Guadeloupe.",
"lang": "fr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#0D0D0D",
"theme_color": "#0D0D0D",
"icons": [
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "maskable" }
],
"categories": ["news", "government", "reference"]
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Applique le thème choisi avant le premier rendu.
*
* Le thème sombre est l'identité par défaut : on n'ajoute la classe que si le
* visiteur a explicitement demandé le clair, ou si son système le préfère sans
* qu'il ait exprimé de choix.
*/
(function () {
try {
var choix = localStorage.getItem('theme');
var systemeClair =
window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
if (choix === 'clair' || (choix === null && systemeClair)) {
document.documentElement.classList.add('light-theme');
}
} catch (e) {
/* stockage indisponible : on garde le thème sombre par défaut */
}
})();
+39
View File
@@ -0,0 +1,39 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/**
* Architecture de rendu : « transitional app » en rendu serveur sur
* adapter-node, avec prérendu ciblé des seules pages figées.
*
* Le rendu serveur est le défaut partout : les fiches doivent être fraîches dès
* que le pipeline écrit dans SQLite, et les facettes de recherche vivent dans
* `url.searchParams`, ce que le prérendu interdit.
*
* `precompress` génère les variantes .gz et .br au build ; la négociation est
* faite par le reverse proxy.
*/
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({ precompress: true }),
alias: {
$composants: 'src/lib/composants',
$serveur: 'src/lib/server'
},
csp: {
// Aucune ressource tierce : ni police, ni script, ni image externe.
directives: {
'default-src': ['self'],
'img-src': ['self', 'data:'],
'style-src': ['self', 'unsafe-inline'],
'font-src': ['self'],
'connect-src': ['self'],
'frame-ancestors': ['none'],
'base-uri': ['self'],
'form-action': ['self']
}
}
}
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
+9
View File
@@ -0,0 +1,9 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
// better-sqlite3 est un module natif : il ne doit jamais être empaqueté.
ssr: { external: ['better-sqlite3'] },
build: { target: 'es2022' }
});