Compare commits

...

11 Commits

Author SHA1 Message Date
e2468be522 fix(etape8): resync hotfix edition depuis NAS (2026-02-19)
All checks were successful
CI / build-and-anchors (push) Successful in 1m50s
SMOKE / smoke (push) Successful in 18s
2026-02-19 23:00:58 +01:00
dc2826df08 Merge pull request 'build: fix astro mdx/rehype config + dedupe duplicate ids in dist' (#82) from chore/fix-astro-ts-mdx-types into main
All checks were successful
CI / build-and-anchors (push) Successful in 2m6s
SMOKE / smoke (push) Successful in 23s
Reviewed-on: #82
2026-02-15 15:19:57 +01:00
3e4df18b88 build: fix astro mdx/rehype config + dedupe duplicate ids in dist
All checks were successful
CI / build-and-anchors (push) Successful in 2m25s
SMOKE / smoke (push) Successful in 23s
2026-02-15 15:19:05 +01:00
a2d1df427d Merge pull request 'docs: RUNBOOK-PR-AUTO-BITEA' (#81) from docs/RUNBOOK-PR-AUTO-GITEA into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m30s
SMOKE / smoke (push) Successful in 12s
Reviewed-on: #81
2026-02-13 20:11:46 +01:00
ab63511d81 docs: RUNBOOK-PR-AUTO-BITEA
All checks were successful
CI / build-and-anchors (push) Successful in 1m39s
SMOKE / smoke (push) Successful in 16s
2026-02-13 20:10:05 +01:00
e5d831cb61 Merge pull request 'docs: add auth stack + main protected PR workflow' (#80) from docs/runbooks-sync-2026-02-13-FIX into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m37s
SMOKE / smoke (push) Successful in 19s
Reviewed-on: #80
2026-02-13 19:31:36 +01:00
cab9e9cf2d docs: add auth stack + main protected PR workflow
All checks were successful
CI / build-and-anchors (push) Successful in 1m27s
SMOKE / smoke (push) Successful in 16s
2026-02-13 19:17:35 +01:00
c7704ada8a Merge pull request 'docs: add runbooks (proposer/whoami gate, blue-green deploy, gitea PR workflow)' (#79) from docs/runbooks-sync-2026-02-13-FIX into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m28s
SMOKE / smoke (push) Successful in 15s
Reviewed-on: #79
2026-02-13 19:06:14 +01:00
010601be63 docs: add runbooks (proposer/whoami gate, blue-green deploy, gitea PR workflow)
All checks were successful
CI / build-and-anchors (push) Successful in 1m30s
SMOKE / smoke (push) Successful in 15s
2026-02-13 17:33:33 +01:00
add688602a Merge pull request 'Fix: Proposer gate non-destructive + show/hide consistent' (#75) from fix/proposer-non-destructif into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m29s
SMOKE / smoke (push) Successful in 14s
Reviewed-on: #75
2026-02-12 15:37:28 +01:00
3f3c717185 Fix: Proposer gate non-destructive + show/hide consistent
All checks were successful
CI / build-and-anchors (push) Successful in 1m39s
SMOKE / smoke (push) Successful in 11s
2026-02-12 15:28:58 +01:00
15 changed files with 3327 additions and 523 deletions

View File

@@ -10,41 +10,101 @@ import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeDetailsSections from "./scripts/rehype-details-sections.mjs";
import rehypeParagraphIds from "./src/plugins/rehype-paragraph-ids.js";
const must = (name, fn) => {
if (typeof fn !== "function") {
throw new Error(`[astro.config] rehype plugin "${name}" is not a function (export default vs named?)`);
}
return fn;
};
/**
* Cast minimal pour satisfaire @ts-check sans dépendre de types internes Astro/Unified.
* @param {unknown} x
* @returns {any}
*/
const asAny = (x) => /** @type {any} */ (x);
/**
* @param {any} node
* @param {string} cls
* @returns {boolean}
*/
function hasClass(node, cls) {
const cn = node?.properties?.className;
if (Array.isArray(cn)) return cn.includes(cls);
if (typeof cn === "string") return cn.split(/\s+/).includes(cls);
return false;
}
/**
* Rehype plugin: retire les ids dupliqués en gardant en priorité:
* 1) span.details-anchor
* 2) h1..h6
* 3) sinon: premier rencontré
* @returns {(tree: any) => void}
*/
function rehypeDedupeIds() {
/** @param {any} tree */
return (tree) => {
/** @type {Map<string, Array<{node:any, pref:number, idx:number}>>} */
const occ = new Map();
let idx = 0;
/** @param {any} node */
const walk = (node) => {
if (!node || typeof node !== "object") return;
if (node.type === "element") {
const id = node.properties?.id;
if (typeof id === "string" && id) {
let pref = 2;
if (node.tagName === "span" && hasClass(node, "details-anchor")) pref = 0;
else if (/^h[1-6]$/.test(String(node.tagName || ""))) pref = 1;
const arr = occ.get(id) || [];
arr.push({ node, pref, idx: idx++ });
occ.set(id, arr);
}
const children = node.children;
if (Array.isArray(children)) for (const c of children) walk(c);
} else if (Array.isArray(node.children)) {
for (const c of node.children) walk(c);
}
};
walk(tree);
for (const [id, items] of occ.entries()) {
if (items.length <= 1) continue;
items.sort((a, b) => (a.pref - b.pref) || (a.idx - b.idx));
const keep = items[0];
for (let i = 1; i < items.length; i++) {
const n = items[i].node;
if (n?.properties?.id === id) delete n.properties.id;
}
// safety: on s'assure qu'un seul garde bien l'id
if (keep?.node?.properties) keep.node.properties.id = id;
}
};
}
export default defineConfig({
output: "static",
trailingSlash: "always",
site: process.env.PUBLIC_SITE ?? "http://localhost:4321",
integrations: [
mdx(),
// Important: MDX hérite du pipeline markdown (ids p-… + autres plugins)
mdx({ extendMarkdownConfig: true }),
sitemap({
filter: (page) => !page.includes("/api/") && !page.endsWith("/robots.txt"),
}),
],
// ✅ Plugins appliqués AU MDX
mdx: {
// ✅ MDX hérite déjà de markdown.rehypePlugins
// donc ici on ne met QUE le spécifique MDX
rehypePlugins: [
must("rehype-details-sections", rehypeDetailsSections),
],
},
// ✅ Plugins appliqués au Markdown non-MDX
markdown: {
rehypePlugins: [
must("rehype-slug", rehypeSlug),
[must("rehype-autolink-headings", rehypeAutolinkHeadings), { behavior: "append" }],
must("rehype-paragraph-ids", rehypeParagraphIds),
asAny(rehypeSlug),
[asAny(rehypeAutolinkHeadings), { behavior: "append" }],
asAny(rehypeDetailsSections),
asAny(rehypeParagraphIds),
asAny(rehypeDedupeIds),
],
},
});

View File

@@ -0,0 +1,122 @@
# RUNBOOK — Créer une Demande dajout (PR) “automatique” depuis un push (Gitea)
## Objectif
Pousser une branche depuis le Mac vers Gitea et obtenir le workflow standard :
1) branche dédiée
2) push
3) suggestion “Nouvelle demande dajout” (bandeau vert) OU lien terminal
4) création PR via UI
5) merge (main protégé)
> Important : Gitea ne crée pas une PR automatiquement.
> Il affiche une *suggestion* (bandeau vert) ou imprime un lien “Create a new pull request” lors du push.
---
## Pré-check (obligatoire, 10 secondes)
en bash :
git status -sb
git fetch origin --prune
git branch --show-current
Procédure standard (zéro surprise)
### 1) Se remettre propre sur main
git checkout main
git pull --ff-only
### 2) Créer une branche AVANT de modifier / ajouter des fichiers
git switch -c docs/<YYYY-MM-DD>-<sujet-court>
### 3) Ajouter/modifier tes fichiers dans docs/
Exemple :
docs/auth-stack.md
docs/runbook-....
### 4) Vérifier ce qui va partir
git status -sb
git diff
### 5) Commit
git add docs/
git commit -m "docs: <résumé clair>"
### 6) Vérifier que ta branche a bien des commits “devant” main (SINON pas de PR possible)
git fetch origin
git log --oneline origin/main..HEAD
Si ça naffiche rien : tu nas rien à proposer (branche identique à main).
### 7) Push (méthode la plus robuste)
git push -u origin HEAD
### 8) Créer la PR (2 chemins fiables)
# Chemin A — le plus simple : utiliser le lien imprimé dans le terminal
Après le push, Gitea affiche généralement :
“Create a new pull request for '<ta-branche>': <URL>”
➡️ Ouvre cette URL, clique “Créer la demande dajout”.
# Chemin B — via lUI Gitea (si tu veux le bandeau vert)
Va sur le dépôt
Onglet “Demandes dajout”
Clique “Nouvelle demande dajout”
Source branch = ta branche, Target = main
Créer
## Pourquoi le bandeau vert peut ne PAS apparaître (et ce que ça signifie)
Ta branche est identique à main
# Diagnostic :
git fetch origin
git diff --name-status origin/main..HEAD
Si vide => normal, pas de suggestion.
Tu nes pas sur la bonne branche
# Diagnostic :
git branch --show-current
Tu regardes lUI au mauvais endroit
Solution : utilise le bouton “Nouvelle demande dajout” ou le lien du terminal (chemin A).
Anti-bêtise (optionnel mais recommandé)
Empêcher de commit sur main par erreur (hook local)
# Créer .git/hooks/pre-commit :
#!/bin/sh
b="$(git branch --show-current)"
if [ "$b" = "main" ]; then
echo "❌ Refus: commit interdit sur main. Crée une branche."
exit 1
fi
Puis :
chmod +x .git/hooks/pre-commit
## Rappel : main protégé
Si main est protégé, tu ne merges PAS par git push origin main.
Tu merges via la PR (UI), après CI verte.

201
docs/auth-stack.md Normal file
View File

@@ -0,0 +1,201 @@
# Auth Stack — LLDAP + Authelia + Redis (DSM 7.3 / Synology DS220+)
## Objectif
Fournir une pile dauthentification robuste (anti-lockout) pour protéger des services web via reverse-proxy :
- Annuaire utilisateurs : **LLDAP**
- Portail / SSO / MFA : **Authelia**
- Cache/sessions (optionnel selon config) : **Redis**
- Exposition publique : **Reverse proxy** (Synology / Nginx / Traefik) vers Authelia
---
## Architecture
### Composants
- **LLDAP**
- UI admin (HTTP) : `127.0.0.1:17170`
- LDAP : `127.0.0.1:3890`
- Base : sqlite dans `/volume2/docker/auth/data/lldap`
- **Authelia**
- API/portal : `127.0.0.1:9091`
- Stockage : sqlite dans `/volume2/docker/auth/data/authelia/db.sqlite3`
- Accès externe : via reverse proxy -> `https://auth.<domaine>`
- **Redis**
- Local uniquement : `127.0.0.1:6379`
- (peut servir plus tard à sessions/rate-limit selon config)
### Exposition réseau (principe de sécurité)
- Tous les services **bindés sur 127.0.0.1** (loopback NAS)
- Seul le **reverse proxy** expose `https://auth.<domaine>` vers `127.0.0.1:9091`
---
## Fichiers de référence
### 1) docker-compose.auth.yml
- Déploie redis + lldap + authelia.
- Recommandation DSM : **network_mode: host** + bind sur localhost.
- Supprime les aléas “bridge + DNS + subnets”
- Évite les timeouts LDAP sporadiques.
### 2) /volume2/docker/auth/compose/.env
Variables attendues :
#### LLDAP
- `LLDAP_JWT_SECRET=...` (random 32+)
- `LLDAP_KEY_SEED=...` (random 32+)
- `LLDAP_LDAP_USER_PASS=...` (mot de passe admin LLDAP)
#### Authelia
- `AUTHELIA_JWT_SECRET=...` (utilisé ici comme source pour reset_password)
- `AUTHELIA_SESSION_SECRET=...`
- `AUTHELIA_STORAGE_ENCRYPTION_KEY=...`
> Ne jamais committer `.env`. Stocker dans DSM / secrets.
### 3) /volume2/docker/auth/config/authelia/configuration.yml
- LDAP address en mode robuste : `ldap://127.0.0.1:3890`
- Cookie domain : `archicratie.trans-hands.synology.me`
- `authelia_url` : `https://auth.archicratie.trans-hands.synology.me`
- `default_redirection_url` : service principal (ex: gitea)
---
## Procédures opératoires
### Restart safe (redémarrage propre)
en bash :
cd /volume2/docker/auth/compose
sudo docker compose --env-file .env -f docker-compose.auth.yml down --remove-orphans
sudo docker compose --env-file .env -f docker-compose.auth.yml up -d --force-recreate
### Tests santé (sans dépendances DSM)
curl -fsS http://127.0.0.1:17170/ >/dev/null && echo "LLDAP UI OK"
curl -fsS http://127.0.0.1:9091/api/health && echo "AUTHELIA LOCAL OK"
curl -kfsS https://auth.archicratie.trans-hands.synology.me/api/health && echo "AUTHELIA HTTPS OK"
### Test TCP LDAP :
sudo docker run --rm --network host nicolaka/netshoot:latest sh -lc 'nc -vz -w2 127.0.0.1 3890'
### Rotate secrets (rotation)
# Principes :
Rotation = redémarrage forcé dAuthelia (sessions invalidées)
Rotation de LLDAP_KEY_SEED est sensible : peut affecter chiffrement des mots de passe.
# Procédure conseillée :
Sauvegarder DBs :
/volume2/docker/auth/data/lldap/users.db
/volume2/docker/auth/data/authelia/db.sqlite3
Changer dabord secrets Authelia (AUTHELIA_SESSION_SECRET, AUTHELIA_STORAGE_ENCRYPTION_KEY)
docker compose up -d --force-recreate authelia
Vérifier /api/health + login.
Reset admin LLDAP (break-glass)
# Si tu perds le mot de passe admin :
Activer temporairement LLDAP_FORCE_LDAP_USER_PASS_RESET=true dans lenvironnement LLDAP
Redémarrer LLDAP une seule fois
Désactiver immédiatement après.
⚠️ Ne jamais laisser ce flag en permanence : il force le reset à chaque boot.
## Checklist anti-lockout (indispensable)
### 1) Accès direct local (bypass)
LLDAP UI accessible en local : http://127.0.0.1:17170
Authelia health local : http://127.0.0.1:9091/api/health
### 2) Règle Authelia : domaine auth en bypass
Dans configuration.yml :
access_control:
rules:
- domain: "auth.<domaine>"
policy: bypass
But : pouvoir charger le portail même si les règles des autres domaines cassent.
### 3) Route de secours reverse-proxy
Prévoir une route non protégée (ou protégée différemment) pour pouvoir corriger :
ex: https://admin.<domaine>/ ou un vhost interne LAN-only.
### 4) Fenêtre privée pour tester
Toujours tester login/authelia dans un onglet privé pour éviter cookies “fantômes”.
## Troubleshooting (ce quon a rencontré et résolu)
### A) YAML/Compose cassé (tabs, doublons)
# Symptômes :
mapping key "ports" already defined
found character that cannot start any token
# Fix :
supprimer tabs
supprimer doublons (volumes/ports/networks)
valider : docker compose ... config
### B) Substitution foireuse des variables dans healthcheck
# Problème :
$VAR évalué par compose au parse-time
# Fix :
utiliser $$VAR dans CMD-SHELL si nécessaire.
### C) /config monté read-only
# Symptômes :
chown: /config/... Read-only file system
# Fix :
monter /config en :rw si Authelia doit écrire des backups/keys.
### D) Timeouts LDAP aléatoires en bridge
# Symptômes :
dial tcp <ip>:3890: i/o timeout
IP Docker “surprise” (subnet 192.168.32.0/20 etc.)
# Fix robuste DSM :
passer en network_mode: host + bind 127.0.0.1
Authelia -> ldap://127.0.0.1:3890
### E) “Authelia OK mais Gitea redemande login”
# Normal :
tant que Gitea nest pas configuré en OIDC vers Authelia, ce nest pas du SSO.
Authelia protège laccès, mais ne crée pas de session Gitea.

View File

@@ -0,0 +1,67 @@
# Workflow Git/Gitea — main protégé (PR only)
## Objectif
Éviter toute casse de `main` : on travaille **toujours** via branche + Pull Request.
## 1) Démarrer propre (local)
en bash :
git fetch origin --prune
git checkout main
git reset --hard origin/main
git clean -fd
## 2) Créer une branche
git checkout -b fix/ma-modif
## 3) Modifier, tester, commit
npm test
git add -A
git commit -m "Mon changement"
## 4) Push (création branche distante)
git push -u origin fix/ma-modif
## 5) Créer la Pull Request (UI Gitea)
Gitea → repository → Pull Requests → New Pull Request
base : main
compare : fix/ma-modif
Si “je ne vois pas de PR”
Vérifie dabord quil y a un diff réel :
git log --oneline origin/main..HEAD
Si la commande ne sort rien : ta branche ne contient aucun commit différent → PR inutile/invisible.
## 6) Conflits
Ne merge pas en local vers main (push refusé si main protégé).
On met à jour la branche de PR :
Option A (simple) : merge main dans la branche
git fetch origin
git merge origin/main
# résoudre conflits
npm test
git push
Option B (plus propre) : rebase
git fetch origin
git rebase origin/main
# résoudre conflits, puis:
npm test
git push --force-with-lease
## 7) Merge
Toujours depuis lUI de la Pull Request (ou via un mainteneur).

View File

@@ -0,0 +1,69 @@
# “Proposer” protégé par groupe (whoami / editors)
## But
Le bouton **Proposer** (création dissue Gitea pré-remplie) doit être :
- visible **uniquement** pour les membres du groupe `editors`,
- **absent** pour les autres utilisateurs,
- robuste (fail-closed), mais **non-collant** (pas de “bloqué” après un échec transitoire).
## Pré-requis (build-time)
Les variables publiques Astro doivent être injectées au build :
- `PUBLIC_GITEA_BASE`
- `PUBLIC_GITEA_OWNER`
- `PUBLIC_GITEA_REPO`
Si une seule manque → `giteaReady=false` → Proposer est désactivé.
### Vérification NAS (slots blue/green)
Exemple :
- blue : http://127.0.0.1:8081/...
- green : http://127.0.0.1:8082/...
Commande (ex) :
`curl -sS http://127.0.0.1:8081/archicratie/archicrat-ia/chapitre-4/ | grep -n "const GITEA_" | head`
## Signal dauth (runtime) : `/_auth/whoami`
Le site appelle `/_auth/whoami` (same-origin) pour récupérer :
- `Remote-User`
- `Remote-Groups`
Ces headers sont injectés par la chaîne edge (Traefik → Authelia forward-auth).
### Appel robuste
- cache-bust : `?_=${Date.now()}`
- `cache: "no-store"`
- `credentials: "include"`
### Critère
`groups.includes("editors")`
## Comportement attendu (UX)
- utilisateur editors : le bouton “Proposer” est visible, ouvre la modal, puis ouvre Gitea.
- utilisateur non editors : le bouton “Proposer” nexiste pas (retiré du DOM).
## Pièges connus
1) Tester en direct 8081/8082 ne reflète pas toujours la chaîne Traefik+Authelia.
2) Un gate “collant” peut rester OFF si léchec est mis en cache trop agressivement.
3) Si “Proposer” est caché via `style.display="none"`, il faut le réafficher via `style.display=""` (pas via `hidden=false`).
## Debug rapide (console navigateur)
en js :
(async () => {
const r = await fetch("/_auth/whoami?_=" + Date.now(), {
credentials: "include",
cache: "no-store",
redirect: "follow",
});
const t = await r.text();
const groups = (t.match(/^Remote-Groups:\s*(.*)$/mi)?.[1] || "")
.split(",").map(s => s.trim()).filter(Boolean);
console.log({ ok: r.ok, status: r.status, groups, raw: t.slice(0, 220) + "..." });
})();
## Définition “done”
Archicratia (editors) voit Proposer et peut ouvrir un ticket.
s-FunX (non editors) ne voit pas Proposer.
Les deux slots blue/green injectent les constantes Gitea dans le HTML.

View File

@@ -0,0 +1,82 @@
# Runbook — Déploiement Archicratie Web Édition (Blue/Green)
## Arborescence NAS (repère)
- `/volume2/docker/archicratie-web/current/` : état courant (Dockerfile, docker-compose.yml, dist buildé en image)
- `/volume2/docker/archicratie-web/releases/` : historiques éventuels
- `/volume2/docker/edge/` : Traefik + config dynamique
> Important : les commandes `docker compose -f ...` doivent viser le **docker-compose.yml présent dans `current/`**.
---
## Pré-requis Synology
Sur NAS, les commandes ont été exécutées avec :
en bash
sudo env DOCKER_API_VERSION=1.43 docker ...
(contexte DSM / compat API)
### 1) Variables de build (Gitea)
Dans /volume2/docker/archicratie-web/current créer/maintenir :
cat > .env <<'EOF'
PUBLIC_GITEA_BASE=https://gitea.archicratie.trans-hands.synology.me
PUBLIC_GITEA_OWNER=Archicratia
PUBLIC_GITEA_REPO=archicratie-edition
EOF
### 2) Build images (blue + green) — méthode robuste
cd /volume2/docker/archicratie-web/current
sudo env DOCKER_API_VERSION=1.43 docker compose -f docker-compose.yml build --no-cache web_blue web_green
Puis recréer les conteneurs sans rebuild :
sudo env DOCKER_API_VERSION=1.43 docker compose -f docker-compose.yml up -d --force-recreate --no-build web_blue web_green
### 3) Vérifier que les deux slots sont OK
curl -sS -D- http://127.0.0.1:8081/ | head -n 12
curl -sS -D- http://127.0.0.1:8082/ | head -n 12
Attendu :
HTTP/1.1 200 OK
Server: nginx/...
### 4) Traefik : sassurer quun seul backend est actif
Fichier :
/volume2/docker/edge/config/dynamic/20-archicratie-backend.yml
Attendu : une seule URL (8081 OU 8082)
http:
services:
archicratie_web:
loadBalancer:
servers:
- url: "http://127.0.0.1:8081"
### 5) Smoke via Traefik (entrée réelle)
curl -sS -H 'Host: archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ | head -n 20
Attendu :
si non loggé : 302 vers Authelia
si loggé : HTML du site
### 6) Piège classique : conflit de nom de conteneur
Si :
Conflict. The container name "/archicratie-web-blue" is already in use...
Faire :
sudo docker rm -f archicratie-web-blue
sudo env DOCKER_API_VERSION=1.43 docker compose -f docker-compose.yml up -d --force-recreate --no-build web_blue

View File

@@ -0,0 +1,71 @@
# Runbook — Gitea : Branches, PR, Merge (sans se faire piéger)
## Règle n°1 (hyper importante)
Une PR napparaît dans Gitea que si la branche contient **au moins 1 commit différent de `main`**.
Symptôme typique :
- `git push -u origin fix/xxx`
- et tu vois : `Total 0 ...`
→ ça veut dire : **aucun nouveau commit** → la branche est identique à main → pas de vraie PR à proposer.
---
## Workflow “propre” (pas à pas)
### 1) Remettre `main` propre
en bash
git checkout main
git pull --ff-only
### 2) Créer une branche de travail
git checkout -b fix/mon-fix
### 3) Faire un changement réel
Modifier le fichier (ex : src/layouts/EditionLayout.astro)
Vérifier :
git status -sb
→ doit montrer un fichier modifié.
### 4) Tester
npm test
### 5) Commit
git add src/layouts/EditionLayout.astro
git commit -m "Fix: ..."
### 6) Push
git push -u origin fix/mon-fix
### 7) Créer la PR dans lUI Gitea
# Aller dans Pull Requests
# New Pull Request
Base : main
Compare : fix/mon-fix
Branch protection (si “Not allowed to push to protected branch main”)
# Cest normal si main est protégé :
On ne pousse jamais directement sur main.
On merge via PR (UI), avec un compte autorisé.
Si Gitea refuse de merger automatiquement :
soit tu actives le réglage côté Gitea “manual merge detection” (admin),
soit tu fais le merge localement MAIS tu ne pourras pas pousser sur main si la protection linterdit.
Conclusion : la voie “pro” = PR + merge UI.

View File

@@ -0,0 +1,67 @@
# Runbook — Bouton “Proposer” (site → Gitea issue) + Gate Authelia
## Objectif
Permettre une proposition de correction éditoriale depuis un paragraphe du site, en créant une *issue* Gitea pré-remplie, uniquement pour les membres du groupe `editors`.
---
## Pré-requis
- Traefik (edge) en front
- Authelia (forwardAuth) opérationnel
- Router `/_auth/whoami` exposé (whoami)
- Variables `PUBLIC_GITEA_*` injectées au build du site
---
## Vérification rapide (navigateur)
### 1) Qui suis-je ? (groupes)
Dans la console :
en js :
await fetch("/_auth/whoami?_=" + Date.now(), {
credentials: "include",
cache: "no-store",
}).then(r => r.text());
Attendu (extraits) :
Remote-User: <login>
Remote-Groups: ...,editors,... pour un éditeur
### 2) Le bouton existe ?
document.querySelectorAll(".para-propose").length
> 0 si editors
0 si non-editor
## Vérification côté NAS (build vars)
### 1) Blue et Green contiennent les constantes ?
P="/archicratie/archicrat-ia/chapitre-4/"
curl -sS "http://127.0.0.1:8081$P" | grep -n "const GITEA_BASE" | head -n 2
curl -sS "http://127.0.0.1:8082$P" | grep -n "const GITEA_BASE" | head -n 2
### 2) Si une des deux est vide → rebuild propre
Dans /volume2/docker/archicratie-web/current :
cat > .env <<'EOF'
PUBLIC_GITEA_BASE=https://gitea.archicratie.trans-hands.synology.me
PUBLIC_GITEA_OWNER=Archicratia
PUBLIC_GITEA_REPO=archicratie-edition
EOF
sudo env DOCKER_API_VERSION=1.43 docker compose -f docker-compose.yml build --no-cache web_blue web_green
sudo env DOCKER_API_VERSION=1.43 docker compose -f docker-compose.yml up -d --force-recreate --no-build web_blue web_green
## Dépannage (si Proposer “disparaît”)
Vérifier groupes via /_auth/whoami
Vérifier const GITEA_BASE via curl sur le slot actif
Vérifier que Traefik sert bien le slot actif (grep via curl -H Host: ... http://127.0.0.1:18080/...)
Ouvrir la console : vérifier quaucune erreur JS nempêche linjection des outils paragraphe

View File

@@ -12,7 +12,7 @@
"build": "astro build",
"build:clean": "npm run clean && npm run build",
"postbuild": "node scripts/inject-anchor-aliases.mjs && npx pagefind --site dist",
"postbuild": "node scripts/inject-anchor-aliases.mjs && node scripts/dedupe-ids-dist.mjs && npx pagefind --site dist",
"import": "node scripts/import-docx.mjs",
"apply:ticket": "node scripts/apply-ticket.mjs",

134
scripts/dedupe-ids-dist.mjs Normal file
View File

@@ -0,0 +1,134 @@
import { promises as fs } from "node:fs";
import path from "node:path";
const DIST_DIR = path.resolve("dist");
/** @param {string} dir */
async function walkHtml(dir) {
/** @type {string[]} */
const out = [];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) out.push(...(await walkHtml(p)));
else if (e.isFile() && p.endsWith(".html")) out.push(p);
}
return out;
}
/** @param {string} attrs */
function getClass(attrs) {
const m = attrs.match(/\bclass="([^"]*)"/i);
return m ? m[1] : "";
}
/** @param {{tag:string,id:string,cls:string}} occ */
function score(occ) {
// plus petit = mieux (on garde)
if (occ.tag === "span" && /\bdetails-anchor\b/.test(occ.cls)) return 0;
if (/^h[1-6]$/.test(occ.tag)) return 1;
if (occ.tag === "p" && occ.id.startsWith("p-")) return 2;
return 10; // tout le reste (toc, nav, etc.)
}
async function main() {
let changedFiles = 0;
let removed = 0;
const files = await walkHtml(DIST_DIR);
for (const file of files) {
let html = await fs.readFile(file, "utf8");
// capture: <tag ... id="X" ...>
const re = /<([A-Za-z][\w:-]*)([^>]*?)\s+id="([^"]+)"([^>]*?)>/g;
/** @type {Array<{id:string,tag:string,pre:string,post:string,start:number,end:number,cls:string,idx:number}>} */
const occs = [];
let m;
let idx = 0;
while ((m = re.exec(html)) !== null) {
const tag = m[1].toLowerCase();
const pre = m[2] || "";
const id = m[3] || "";
const post = m[4] || "";
const fullAttrs = `${pre}${post}`;
const cls = getClass(fullAttrs);
occs.push({
id,
tag,
pre,
post,
start: m.index,
end: m.index + m[0].length,
cls,
idx: idx++,
});
}
if (occs.length === 0) continue;
/** @type {Map<string, Array<typeof occs[number]>>} */
const byId = new Map();
for (const o of occs) {
if (!o.id) continue;
const arr = byId.get(o.id) || [];
arr.push(o);
byId.set(o.id, arr);
}
/** @type {Array<{start:number,end:number,repl:string}>} */
const edits = [];
for (const [id, arr] of byId.entries()) {
if (arr.length <= 1) continue;
// choisir le “meilleur” porteur did : details-anchor > h2/h3... > p-... > reste
const sorted = [...arr].sort((a, b) => {
const sa = score(a);
const sb = score(b);
if (sa !== sb) return sa - sb;
return a.idx - b.idx; // stable: premier
});
const keep = sorted[0];
for (const o of sorted.slice(1)) {
// remplacer louverture de tag en supprimant lattribut id
// <tag{pre} id="X"{post}> ==> <tag{pre}{post}>
const repl = `<${o.tag}${o.pre}${o.post}>`;
edits.push({ start: o.start, end: o.end, repl });
removed++;
}
// sécurité: on “force” l'id sur le keep (au cas où il aurait été modifié plus haut)
// (on ne touche pas au keep ici, juste on ne le retire pas)
void keep;
void id;
}
if (edits.length === 0) continue;
// appliquer de la fin vers le début
edits.sort((a, b) => b.start - a.start);
for (const e of edits) {
html = html.slice(0, e.start) + e.repl + html.slice(e.end);
}
await fs.writeFile(file, html, "utf8");
changedFiles++;
}
if (changedFiles > 0) {
console.log(`✅ dedupe-ids-dist: files_changed=${changedFiles} ids_removed=${removed}`);
} else {
console.log(" dedupe-ids-dist: no duplicates found");
}
}
main().catch((err) => {
console.error("❌ dedupe-ids-dist failed:", err);
process.exit(1);
});

View File

@@ -0,0 +1,59 @@
schema: 1
# optionnel (si présent, doit matcher le chemin du fichier)
page: archicratie/archicrat-ia/prologue
paras:
p-0-d7974f88:
refs:
- label: "Happycratie — (Cabanas & Illouz) via Cairn"
url: "https://shs.cairn.info/revue-ethnologie-francaise-2019-4-page-813?lang=fr"
kind: "article"
- label: "Techno-féodalisme — Variations (OpenEdition)"
url: "https://journals.openedition.org/variations/2290"
kind: "article"
authors:
- "Eva Illouz"
- "Yanis Varoufakis"
quotes:
- text: "Dans Happycratie, Edgar Cabanas et Eva Illouz..."
source: "Happycratie, p.1"
- text: "En eux-mêmes, les actifs ne sont ni féodaux ni capitalistes..."
source: "Entretien Morozov/Varoufakis — techno-féodalisme"
media:
- type: "image"
src: "/public/media/archicratie/archicrat-ia/prologue/p-0-d7974f88/schema-1.svg"
caption: "Tableau explicatif"
credit: "ChatGPT"
- type: "image"
src: "/public/media/archicratie/archicrat-ia/prologue/p-0-d7974f88/schema-2.svg"
caption: "Diagramme dévolution"
credit: "Yanis Varoufakis"
comments_editorial:
- text: "TODO: nuancer / préciser — commentaire éditorial versionné (pas public)."
status: "draft"
p-1-2ef25f29:
refs:
- label: "Kafka et le pouvoir — Bernard Lahire (Cairn)"
url: "https://shs.cairn.info/franz-kafka--9782707159410-page-475?lang=fr"
kind: "book"
authors:
- "Bernard Lahire"
quotes:
- text: "Si lon voulait chercher quelque chose comme une vision du monde chez Kafka..."
source: "Bernard Lahire, Franz Kafka, p.475+"
media:
- type: "video"
src: "/media/prologue/p-1-2ef25f29/bien_commun.mp4"
caption: "Entretien avec Bernard Lahire"
credit: "Cairn.info"
comments_editorial: []

View File

@@ -1,35 +1,128 @@
---
// src/components/LevelToggle.astro
const { initialLevel = 1 } = Astro.props;
---
<div class="level-toggle" role="group" aria-label="Niveau de lecture">
<button type="button" class="lvl-btn" data-level="1" aria-pressed="true">Niveau 1</button>
<button type="button" class="lvl-btn" data-level="2" aria-pressed="false">Niveau 2</button>
<button type="button" class="lvl-btn" data-level="3" aria-pressed="false">Niveau 3</button>
<div class="level-toggle" role="group" aria-label="Mode dédition">
<button type="button" class="level-btn" data-level="1">Propos</button>
<button type="button" class="level-btn" data-level="2">Références</button>
<button type="button" class="level-btn" data-level="3">Illustrations</button>
<button type="button" class="level-btn" data-level="4">Commentaires</button>
</div>
<script is:inline>
<script is:inline define:vars={{ initialLevel }}>
(() => {
const KEY = "archicratie.readingLevel";
const buttons = Array.from(document.querySelectorAll(".lvl-btn"));
const BODY = document.body;
function apply(level) {
document.body.setAttribute("data-reading-level", String(level));
buttons.forEach((b) => b.setAttribute("aria-pressed", b.dataset.level === String(level) ? "true" : "false"));
const wrap = document.querySelector(".level-toggle");
if (!wrap) return;
const buttons = Array.from(wrap.querySelectorAll("button[data-level]"));
if (!buttons.length) return;
const KEY = "archicratie:readingLevel";
function clampLevel(n) {
const x = Number.parseInt(String(n), 10);
if (!Number.isFinite(x)) return 1;
return Math.min(4, Math.max(1, x));
}
// Valeur par défaut : si rien n'est stocké, on met 1 (citoyen).
// Si JS est absent/casse, le site reste lisible (tout s'affiche).
const stored = Number(localStorage.getItem(KEY));
const level = (stored === 1 || stored === 2 || stored === 3) ? stored : 1;
function setActiveUI(lvl) {
for (const b of buttons) {
const on = String(b.dataset.level) === String(lvl);
b.classList.toggle("is-active", on);
b.setAttribute("aria-pressed", on ? "true" : "false");
}
}
apply(level);
function captureBeforeLevelSwitch() {
const paraId =
window.__archiCurrentParaId ||
window.__archiLastParaId ||
String(location.hash || "").replace(/^#/, "") ||
"";
buttons.forEach((b) => {
b.addEventListener("click", () => {
const lvl = Number(b.dataset.level);
localStorage.setItem(KEY, String(lvl));
apply(lvl);
});
window.__archiLevelSwitchCtx = {
paraId,
hash: location.hash || "",
scrollY: window.scrollY || 0,
t: Date.now(),
};
}
function applyLevel(lvl, { persist = true } = {}) {
const v = clampLevel(lvl);
if (BODY) BODY.dataset.readingLevel = String(v);
setActiveUI(v);
if (persist) {
try { localStorage.setItem(KEY, String(v)); } catch {}
}
try {
window.dispatchEvent(
new CustomEvent("archicratie:readingLevel", { detail: { level: v } })
);
} catch {}
}
// init : storage > initialLevel
let start = clampLevel(initialLevel);
try {
const stored = localStorage.getItem(KEY);
if (stored) start = clampLevel(stored);
} catch {}
applyLevel(start, { persist: false });
// clicks
wrap.addEventListener("click", (ev) => {
const btn = ev.target?.closest?.("button[data-level]");
if (!btn) return;
ev.preventDefault();
// ✅ crucial : on capture la position AVANT le reflow lié au changement de niveau
captureBeforeLevelSwitch();
applyLevel(btn.dataset.level);
});
})();
</script>
<style>
.level-toggle{
display: inline-flex;
gap: 8px;
align-items: center;
}
.level-btn{
border: 1px solid rgba(127,127,127,0.40);
background: rgba(127,127,127,0.08);
border-radius: 999px;
padding: 6px 10px;
font-size: 13px;
cursor: pointer;
user-select: none;
transition: filter .12s ease, transform .12s ease, background .12s ease, border-color .12s ease;
}
.level-btn:hover{
filter: brightness(1.08);
}
.level-btn.is-active{
border-color: rgba(160,160,255,0.95);
background: rgba(140,140,255,0.18);
font-weight: 900;
}
.level-btn.is-active:hover{
filter: brightness(1.12);
}
.level-btn:active{
transform: translateY(1px);
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -80,6 +80,12 @@ main { padding: 0; }
border-top: 1px dashed rgba(127,127,127,0.35);
font-size: 14px;
}
/* Edition-bar: cacher des badges (non destructif) */
.edition-bar [data-badge="edition"],
.edition-bar [data-badge="status"],
.edition-bar [data-badge="version"]{
display: none;
}
.badge {
padding: 2px 8px;
@@ -95,7 +101,34 @@ main { padding: 0; }
padding: 5px 12px;
}
/* Toggle niveaux */
/* Jump by paragraph id */
.jump-form{
display: inline-flex;
gap: 6px;
align-items: center;
}
.jump-input{
border: 1px solid rgba(127,127,127,0.55);
background: transparent;
padding: 4px 10px;
border-radius: 999px;
font-size: 13px;
width: 320px;
}
.jump-input.is-error{
outline: 2px solid rgba(127,127,127,0.55);
outline-offset: 2px;
}
.jump-btn{
border: 1px solid rgba(127,127,127,0.55);
background: transparent;
padding: 4px 10px;
border-radius: 999px;
cursor: pointer;
font-size: 13px;
}
/* Toggle niveaux (legacy, non bloquant) */
.level-toggle { display: inline-flex; gap: 6px; }
.lvl-btn {
border: 1px solid rgba(127,127,127,0.55);
@@ -112,14 +145,22 @@ main { padding: 0; }
/* Règles niveaux */
body[data-reading-level="1"] .level-2,
body[data-reading-level="1"] .level-3 { display: none; }
body[data-reading-level="2"] .level-3 { display: none; }
body[data-reading-level="1"] .level-3,
body[data-reading-level="1"] .level-4 { display: none; }
body[data-reading-level="2"] .level-3,
body[data-reading-level="2"] .level-4 { display: none; }
body[data-reading-level="3"] .level-2,
body[data-reading-level="3"] .level-4 { display: none; }
body[data-reading-level="4"] .level-2,
body[data-reading-level="4"] .level-3 { display: none; }
/* ==========================
Scroll offset (anchors / headings / paras)
========================== */
/* Paragraph tools + bookmark */
.reading p[id]{
position: relative;
padding-right: 14rem;
@@ -183,6 +224,14 @@ body[data-reading-level="2"] .level-3 { display: none; }
}
.para-bookmark:hover{ text-decoration: underline; }
/* Highlight (jump / resume / arrivée hash) */
.para-highlight{
background: rgba(127,127,127,0.10);
border-radius: 10px;
box-shadow: 0 0 0 2px rgba(127,127,127,0.35);
transition: box-shadow 160ms ease;
}
.build-stamp {
margin-top: 28px;
padding-top: 14px;
@@ -196,15 +245,51 @@ body[data-reading-level="2"] .level-3 { display: none; }
border-radius: 16px;
padding: 10px 12px;
margin: 14px 0;
position: relative;
}
.details-summary {
cursor: pointer;
font-weight: 650;
/* ✅ Handle minimal pour sections fermées : pas de titre visible, mais ouvrable */
.details-summary{
list-style: none;
cursor: pointer;
user-select: none;
border: 1px dashed rgba(127,127,127,.25);
border-radius: 999px;
padding: 6px 10px;
margin: 10px 0;
background: rgba(127,127,127,0.06);
position: relative;
/* cache le texte réel (souvent le titre), sans casser laccessibilité */
color: transparent;
}
.details-summary::-webkit-details-marker { display: none; }
.details-summary a { text-decoration: none; }
.details-summary a:hover { text-decoration: underline; }
.details-summary::before{
content: "▸ Ouvrir la section";
color: rgba(127,127,127,0.85);
font-size: 12px;
font-weight: 850;
}
@media (prefers-color-scheme: dark){
.details-summary::before{ color: rgba(220,220,220,0.82); }
}
details[open] > .details-summary{
/* une fois ouvert, on le rend “SR-only” pour éviter le doublon visuel */
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.details-body { margin-top: 10px; }
/* Smooth scroll */
@@ -224,7 +309,6 @@ html{ scroll-behavior: smooth; }
width: var(--reading-width);
right: auto;
/* colle au header */
top: var(--sticky-header-h);
z-index: 60;
@@ -247,7 +331,7 @@ html{ scroll-behavior: smooth; }
box-sizing: border-box;
padding: 8px 12px;
padding-right: 84px; /* réserve pour les boutons */
padding-right: 84px;
border: 1px solid rgba(127,127,127,.20);
border-top: 0;
@@ -259,7 +343,7 @@ html{ scroll-behavior: smooth; }
box-shadow: 0 10px 22px rgba(0,0,0,.06);
position: relative; /* pour rf-actions */
position: relative;
}
@media (prefers-color-scheme: dark){
@@ -278,7 +362,6 @@ html{ scroll-behavior: smooth; }
cursor: pointer;
margin: 0;
}
.rf-line[hidden]{ display: none !important; }
.rf-h1{
@@ -298,7 +381,6 @@ html{ scroll-behavior: smooth; }
font-weight: var(--rf-h3-fw);
opacity: .92;
}
.rf-line:hover{ text-decoration: underline; }
/* Actions */
@@ -327,3 +409,14 @@ html{ scroll-behavior: smooth; }
background: rgba(127,127,127,0.16);
transform: translateY(-1px);
}
/* ==========================
PATCH CRUCIAL : éviter les “rectangles vides”
(details fermés + summary handle minimal)
========================== */
.reading details.details-section:not([open]){
border: 0;
padding: 0;
background: transparent;
}