Compare commits
21 Commits
docs/ops-m
...
sync/etape
| Author | SHA1 | Date | |
|---|---|---|---|
| e2468be522 | |||
| dc2826df08 | |||
| 3e4df18b88 | |||
| a2d1df427d | |||
| ab63511d81 | |||
| e5d831cb61 | |||
| cab9e9cf2d | |||
| c7704ada8a | |||
| 010601be63 | |||
| add688602a | |||
| 3f3c717185 | |||
| b5f32da0c8 | |||
| 6e7ed8e041 | |||
| 90f79a7ee7 | |||
| b5663891a1 | |||
| a74b95e775 | |||
| b78eb4fc7b | |||
| 80c047369f | |||
| d7c158a0fc | |||
| 30f0ef4164 | |||
| d2963673c9 |
3
.env
3
.env
@@ -1,3 +0,0 @@
|
||||
PUBLIC_GITEA_BASE=https://gitea.archicratie.trans-hands.synology.me
|
||||
PUBLIC_GITEA_OWNER=Archicratia
|
||||
PUBLIC_GITEA_REPO=archicratie-edition
|
||||
@@ -1,4 +0,0 @@
|
||||
PUBLIC_GITEA_BASE=https://gitea.archicratie.trans-hands.synology.me
|
||||
PUBLIC_GITEA_OWNER=Archicratia
|
||||
PUBLIC_GITEA_REPO=archicratie-edition
|
||||
PUBLIC_SITE=https://archicratie.trans-hands.synology.me
|
||||
@@ -1,5 +0,0 @@
|
||||
FORGE_API=http://192.168.1.20:3000
|
||||
FORGE_BASE=https://gitea.archicratie.trans-hands.synology.me
|
||||
FORGE_TOKEN=aW73wpfJ4MiN2!3UU69qL*vWF9$9V7f@2
|
||||
PUBLIC_GITEA_OWNER=Archicratia
|
||||
PUBLIC_GITEA_REPO=archicratie-edition
|
||||
@@ -1,6 +0,0 @@
|
||||
PUBLIC_SITE=https://archicratie.trans-hands.synology.me
|
||||
PUBLIC_RELEASE=0.1.0
|
||||
|
||||
PUBLIC_GITEA_BASE=https://gitea.archicratie.trans-hands.synology.me
|
||||
PUBLIC_GITEA_OWNER=Archicratia
|
||||
PUBLIC_GITEA_REPO=archicratie-edition
|
||||
102
astro.config.mjs
102
astro.config.mjs
@@ -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),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,6 +10,15 @@ Si un seul de ces 3 paramètres est faux → on obtient :
|
||||
- 404 / redirect login inattendu
|
||||
- ou un repo/owner incorrect
|
||||
|
||||
|
||||
# Diagnostic — “Proposer” (résumé)
|
||||
|
||||
**Symptôme :** clic “Proposer” → 404 / login / mauvais repo
|
||||
**Cause la plus fréquente :** `PUBLIC_GITEA_OWNER` (casse sensible) ou `PUBLIC_GITEA_REPO` faux.
|
||||
|
||||
➡️ Procédure complète (pas-à-pas + commandes) : voir `docs/TROUBLESHOOTING.md#proposer-404`.
|
||||
|
||||
|
||||
## 1) Variables utilisées (publique, côté build Astro)
|
||||
|
||||
- `PUBLIC_GITEA_BASE`
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Déploiement production (Synology DS220+ / DSM 7.3) — Astro → Nginx statique
|
||||
|
||||
> ✅ **CANONIQUE** — Procédure de référence “prod DS220+ / DSM 7.3”.
|
||||
|
||||
> Toute modif de déploiement doit être faite **ici**, via PR sur Gitea/main (pas d’édition à la main en prod).
|
||||
|
||||
> Périmètre : build Docker (Node→Nginx), blue/green 8081/8082, Reverse Proxy DSM, smoke, rollback.
|
||||
|
||||
> Dépendances critiques : variables PUBLIC_GITEA_* (sinon “Proposer” part en 404/login loop).
|
||||
|
||||
> Voir aussi : OPS-REFERENCE.md (index), OPS_COCKPIT.md (checklist), TROUBLESHOOTING.md (incidents).
|
||||
|
||||
Dernière mise à jour : 2026-02-01
|
||||
|
||||
Ce document décrit une mise en place stable sur NAS :
|
||||
@@ -73,6 +83,17 @@ for f in .env .env.local .env.production .env.production.local; do
|
||||
[ -f "$f" ] && echo "---- $f" && grep -nE '^PUBLIC_GITEA_(BASE|OWNER|REPO)=' "$f" || true
|
||||
done
|
||||
|
||||
En cas d’échec :
|
||||
- 404 / login loop / mauvais repo → `docs/TROUBLESHOOTING.md#proposer-404`
|
||||
- double onglet → `docs/TROUBLESHOOTING.md#proposer-double-onglet`
|
||||
|
||||
## Diagnostic — “Proposer” (résumé)
|
||||
|
||||
**Symptôme :** clic “Proposer” → 404 / login / mauvais repo
|
||||
**Cause la plus fréquente :** `PUBLIC_GITEA_OWNER` (casse sensible) ou `PUBLIC_GITEA_REPO` faux.
|
||||
|
||||
➡️ Procédure complète (pas-à-pas + commandes) : voir `docs/TROUBLESHOOTING.md#proposer-404`.
|
||||
|
||||
## 5) Reverse Proxy DSM (le point clé)
|
||||
|
||||
### DSM 7.3 :
|
||||
|
||||
@@ -43,61 +43,15 @@ Le flow ne doit jamais ouvrir deux onglets.
|
||||
- un seul `a.target="_blank"` (ou équivalent) déclenché
|
||||
- sur click : handler doit neutraliser les propagations parasites
|
||||
|
||||
### Vérification (NAS)
|
||||
en sh :
|
||||
curl -fsS http://127.0.0.1:8082/archicratie/archicrat-ia/chapitre-4/ > /tmp/page.html
|
||||
grep -n "window.open" /tmp/page.html | head
|
||||
## Diagnostic (canonique)
|
||||
|
||||
Doit retourner 0 ligne.
|
||||
Le diagnostic détaillé est centralisé dans `docs/TROUBLESHOOTING.md` pour éviter les doublons.
|
||||
|
||||
## 3) Diagnostic “trace ouverture onglet” (navigateur)
|
||||
- 404 / non autorisé / redirect login :
|
||||
- voir : `TROUBLESHOOTING.md#proposer-404`
|
||||
- cause la plus fréquente : `PUBLIC_GITEA_OWNER/REPO` faux (souvent casse)
|
||||
|
||||
Dans la console, tu peux surcharger temporairement les mécanismes d’ouverture pour tracer :
|
||||
- Double onglet :
|
||||
- voir : `TROUBLESHOOTING.md#proposer-double-onglet`
|
||||
- cause la plus fréquente : double handler (bubbling) ou `window.open` + `a.click()`
|
||||
|
||||
si un window.open survient,
|
||||
|
||||
ou si un a.click target _blank est appelé.
|
||||
|
||||
But : prouver qu’il n’y a qu’un seul événement d’ouverture.
|
||||
|
||||
## 4) URL attendue (forme)
|
||||
|
||||
L’onglet doit ressembler à :
|
||||
|
||||
{PUBLIC_GITEA_BASE}/{OWNER}/{REPO}/issues/new?title=...&body=...
|
||||
|
||||
Important : owner et repo doivent être exactement ceux du repo canonique.
|
||||
|
||||
## 5) Pré-requis d’accès
|
||||
|
||||
L’utilisateur doit être loggé sur Gitea pour accéder à /issues/new
|
||||
|
||||
Si non loggé : redirect vers /user/login (comportement normal)
|
||||
|
||||
## 6) Tests fonctionnels (checklist)
|
||||
|
||||
Ouvrir une page chapitre (ex chapitre 4)
|
||||
|
||||
Clic Proposer (sur un paragraphe)
|
||||
|
||||
Choix 1 puis choix 2
|
||||
|
||||
Vérifier :
|
||||
|
||||
1 seul onglet
|
||||
|
||||
URL du repo correct
|
||||
|
||||
formulaire new issue visible
|
||||
|
||||
title/body pré-remplis (chemin + ancre + texte actuel)
|
||||
|
||||
Créer l’issue → vérifier le traitement CI/runner
|
||||
|
||||
## 7) Pannes typiques + causes
|
||||
|
||||
404 sur issue/new : PUBLIC_GITEA_OWNER/REPO faux (souvent casse)
|
||||
|
||||
2 onglets : double handler (bubbling + ouverture multiple)
|
||||
|
||||
pas de favicon : cache ou absence dans public/ → rebuild
|
||||
|
||||
@@ -1,6 +1,46 @@
|
||||
OPS — Déploiement Archicratie Web Edition (Mac Studio → DS220+)
|
||||
# OPS — Déploiement Archicratie Web Edition (Mac Studio → DS220+)
|
||||
Objectif : déployer une nouvelle version du site sur le NAS (DS220+) sans jamais casser la prod, en utilisant un schéma blue/green piloté par DSM Reverse Proxy, avec une procédure robuste même quand docker compose build est instable sur le NAS.
|
||||
|
||||
> 🟧 **LEGACY / HISTORIQUE** — Ce document n’est plus la source de vérité.
|
||||
|
||||
> Référence actuelle : docs/DEPLOY_PROD_SYNOLOGY_DS220.md (canonique).
|
||||
|
||||
> Statut : gelé (on n’édite plus que pour ajouter un lien vers le canonique, si nécessaire).
|
||||
|
||||
> Raison : doublon → risque de divergence → risque d’erreur en prod.
|
||||
|
||||
> Si tu lis ceci pour déployer : stop → ouvre le canonique.
|
||||
|
||||
> ⚠️ LEGACY — Ne pas suivre pour déployer.
|
||||
> Doc conservé pour historique.
|
||||
> Canon : `DEPLOY_PROD_SYNOLOGY_DS220.md` + `OPS-SYNC-TRIPLE-SOURCE.md`.
|
||||
|
||||
## Pourquoi ce doc existe encore
|
||||
|
||||
- Historique : il capture des repères (domaines, ports, logique blue/green) tels qu’ils ont été consolidés pendant la phase d’implémentation.
|
||||
- Sécurité : éviter la divergence documentaire (un seul pas-à-pas officiel).
|
||||
- Maintenance : si tu dois déployer, tu suis le canonique ; ici tu ne viens que pour comprendre “d’où ça vient”.
|
||||
|
||||
## Ce qu’il faut faire aujourd’hui (canonique)
|
||||
|
||||
➡️ Déploiement = `docs/DEPLOY_PROD_SYNOLOGY_DS220.md` (procédure détaillée, à jour).
|
||||
|
||||
## Schéma (résumé, sans commandes)
|
||||
|
||||
- Ne jamais toucher au slot live.
|
||||
- Construire/tester sur l’autre slot.
|
||||
- Smoke test.
|
||||
- Bascule DSM Reverse Proxy (8081 ↔ 8082).
|
||||
- Rollback DSM si besoin.
|
||||
|
||||
<details>
|
||||
|
||||
> 🚫 NE PAS UTILISER POUR PROD — ARCHIVE UNIQUEMENT
|
||||
|
||||
<summary>Archive — ancien pas-à-pas (NE PAS SUIVRE)</summary>
|
||||
|
||||
> ⚠️ Archive. Ce contenu est conservé pour mémoire.
|
||||
|
||||
## 0) Repères essentiels
|
||||
Noms & domaines
|
||||
• Site public (prod) : https://archicratie.trans-hands.synology.me
|
||||
@@ -383,3 +423,5 @@ Fix standard (dans le vrai dossier site/) :
|
||||
rm -rf node_modules .astro dist
|
||||
npm ci
|
||||
npm run dev
|
||||
|
||||
</details>
|
||||
|
||||
@@ -4,7 +4,7 @@ Document “pivot” : liens, invariants, conventions, commandes réflexes.
|
||||
|
||||
## 0) Invariants (à ne pas casser)
|
||||
|
||||
- **Source de vérité Git** : `origin/main` sur :contentReference[oaicite:0]{index=0}.
|
||||
- **Source de vérité Git** : origin/main (repo Archicratia/archicratie-edition sur Gitea).
|
||||
- **Prod** : conteneur `archicratie-web-*` (nginx) derrière reverse proxy DSM.
|
||||
- **Config “Proposer”** : dépend de `PUBLIC_GITEA_BASE`, `PUBLIC_GITEA_OWNER`, `PUBLIC_GITEA_REPO` injectés au build.
|
||||
- **Branches** : `main` = travail ; `master` = legacy/compat (alignée mais protégée).
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# OPS Runbook — Archicratie Web (NAS Synology DS220 + Gitea)
|
||||
|
||||
> 🟦 **ALIAS (résumé)** — Runbook 1-page pour opérer vite sans se tromper.
|
||||
|
||||
> La procédure détaillée **canonique** est : docs/DEPLOY_PROD_SYNOLOGY_DS220.md.
|
||||
|
||||
> Source Git : Gitea/main ; déploiement = rebuild depuis main ; pas de hotfix non versionné.
|
||||
|
||||
> Déploiement : build sur slot inactif → smoke → bascule DSM → rollback si besoin.
|
||||
|
||||
> Incidents connus : voir docs/TROUBLESHOOTING.md.
|
||||
|
||||
## 0. Objectif
|
||||
Ce document décrit la procédure **exacte** pour :
|
||||
- maintenir un état cohérent entre **Local (Mac Studio)**, **Gitea**, **NAS (prod)** ;
|
||||
|
||||
122
docs/RUNBOOK-PR-AUTO-GITEA.md
Normal file
122
docs/RUNBOOK-PR-AUTO-GITEA.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# RUNBOOK — Créer une Demande d’ajout (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 d’ajout” (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 n’affiche rien : tu n’as 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 d’ajout”.
|
||||
|
||||
# Chemin B — via l’UI Gitea (si tu veux le bandeau vert)
|
||||
|
||||
Va sur le dépôt
|
||||
|
||||
Onglet “Demandes d’ajout”
|
||||
|
||||
Clique “Nouvelle demande d’ajout”
|
||||
|
||||
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 n’es pas sur la bonne branche
|
||||
|
||||
# Diagnostic :
|
||||
|
||||
git branch --show-current
|
||||
|
||||
Tu regardes l’UI au mauvais endroit
|
||||
|
||||
Solution : utilise le bouton “Nouvelle demande d’ajout” 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.
|
||||
|
||||
@@ -15,6 +15,7 @@ Toujours isoler : **Local**, **Gitea**, **NAS**, **Navigateur**.
|
||||
|
||||
---
|
||||
|
||||
<a id="proposer-404"></a>
|
||||
## 1) “Proposer” ouvre Gitea mais retourne 404 / non autorisé
|
||||
|
||||
### Symptôme
|
||||
@@ -38,6 +39,7 @@ Puis rebuild + restart du container + smoke.
|
||||
|
||||
---
|
||||
|
||||
<a id="proposer-double-onglet"></a>
|
||||
## 2) Double onglet à la validation du flow “Proposer”
|
||||
|
||||
### Symptôme
|
||||
@@ -64,7 +66,9 @@ garder un seul mécanisme d’ouverture
|
||||
|
||||
sur click : preventDefault() + stopImmediatePropagation()
|
||||
|
||||
<a id="Favicon-504-erreurs"></a>
|
||||
## 3) Favicon 504 / erreurs console sur favicon
|
||||
|
||||
# Symptôme
|
||||
|
||||
Console navigateur : GET /favicon.ico 504
|
||||
|
||||
@@ -63,7 +63,7 @@ Si l’ID exact n’existe plus :
|
||||
But : éviter les “liens morts” historiques quand une régénération d’IDs a eu lieu.
|
||||
|
||||
Limite : c’est un fallback de dernier recours (moins déterministe qu’un alias explicite).
|
||||
Le mécanisme recommandé reste : `docs/anchor-aliases.json` + injection au build.
|
||||
Le mécanisme recommandé reste : `src/anchors/anchor-aliases.json` + injection au build.
|
||||
|
||||
_______________________________________
|
||||
|
||||
@@ -87,7 +87,7 @@ Les IDs d’ancres générés (ou dérivés) peuvent changer :
|
||||
|
||||
## 2) Le mapping d’alias
|
||||
|
||||
- Fichier versionné (ex) : `docs/anchor-aliases.json`
|
||||
- Fichier versionné (ex) : `src/anchors/anchor-aliases.json`
|
||||
- Format : `oldId -> newId` par page
|
||||
|
||||
Ex en json :
|
||||
|
||||
201
docs/auth-stack.md
Normal file
201
docs/auth-stack.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Auth Stack — LLDAP + Authelia + Redis (DSM 7.3 / Synology DS220+)
|
||||
|
||||
## Objectif
|
||||
Fournir une pile d’authentification 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é d’Authelia (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 d’abord 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 l’environnement 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 qu’on 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 n’est pas configuré en OIDC vers Authelia, ce n’est pas du SSO.
|
||||
|
||||
Authelia protège l’accès, mais ne crée pas de session Gitea.
|
||||
|
||||
67
docs/gitea-pr-main-protege.md
Normal file
67
docs/gitea-pr-main-protege.md
Normal 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 d’abord qu’il 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 l’UI de la Pull Request (ou via un mainteneur).
|
||||
|
||||
69
docs/proposer-whoami-gate.md
Normal file
69
docs/proposer-whoami-gate.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# “Proposer” protégé par groupe (whoami / editors)
|
||||
|
||||
## But
|
||||
Le bouton **Proposer** (création d’issue 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 d’auth (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” n’existe 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.
|
||||
82
docs/runbook-deploiement-web-blue-green.md
Normal file
82
docs/runbook-deploiement-web-blue-green.md
Normal 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 : s’assurer qu’un 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
|
||||
71
docs/runbook-gitea-branches-pr.md
Normal file
71
docs/runbook-gitea-branches-pr.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Runbook — Gitea : Branches, PR, Merge (sans se faire piéger)
|
||||
|
||||
## Règle n°1 (hyper importante)
|
||||
Une PR n’apparaî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 l’UI 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”)
|
||||
|
||||
# C’est 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 l’interdit.
|
||||
|
||||
Conclusion : la voie “pro” = PR + merge UI.
|
||||
67
docs/runbook-proposer-gitea.md
Normal file
67
docs/runbook-proposer-gitea.md
Normal 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 qu’aucune erreur JS n’empêche l’injection des outils paragraphe
|
||||
@@ -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
134
scripts/dedupe-ids-dist.mjs
Normal 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 d’id : 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 l’ouverture de tag en supprimant l’attribut 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);
|
||||
});
|
||||
59
src/annotations/archicratie/archicrat-ia/prologue.yml
Normal file
59
src/annotations/archicratie/archicrat-ia/prologue.yml
Normal 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 l’on 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: []
|
||||
@@ -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>
|
||||
|
||||
1064
src/components/SidePanel.astro
Normal file
1064
src/components/SidePanel.astro
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 l’accessibilité */
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user