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:
co-authored by
Claude Opus 5
parent
feb5544599
commit
34d81c66a8
@@ -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.
|
||||
@@ -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
@@ -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 = (
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.svelte-kit/
|
||||
build/
|
||||
static/polices/
|
||||
@@ -0,0 +1,3 @@
|
||||
engine-strict=false
|
||||
fund=false
|
||||
audit=false
|
||||
Generated
+2304
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">n° {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>
|
||||
@@ -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)}…`;
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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) || '[]')
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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': '*'
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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': '*'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Page figée, prérendue au build — voir a-propos/+page.ts. */
|
||||
export const prerender = true;
|
||||
@@ -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)
|
||||
])
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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' } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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"]
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
})();
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
});
|
||||
Reference in New Issue
Block a user