docs: versionner spec + guide de déploiement (hors portfolios-examples)

Guide de déploiement prod : VM Debian nue (sans Docker), service systemd,
déploiement par push Git (bare repo + post-receive), branchement rsync/SSH
en lecture seule à la prod Grav, reverse proxy HTTPS, dimensionnement VM.
This commit is contained in:
2026-07-06 13:48:31 +02:00
parent 48b7f97954
commit 1069bba2e4
3 changed files with 1067 additions and 3 deletions

6
.gitignore vendored
View File

@@ -4,9 +4,9 @@
generator/
grav-reference/
# Docs : PDF de référence + spec (déjà réalisée, désormais désynchro du code).
# Le Dockerfile de l'image Grav reste versionné (docker/grav/).
docs/
# Docs versionnées (spec, guide de déploiement…), SAUF les exemples volumineux
# (PDF de référence). Le Dockerfile de l'image Grav reste versionné (docker/grav/).
docs/portfolios-examples/
# Divers
.DS_Store

View File

@@ -0,0 +1,485 @@
# Déploiement en production
Ce document décrit comment déployer le **générateur de portfolios** sur une VM
**Debian nue** (sans Docker) et le brancher, en lecture seule, à la VM **Grav de
production** de Figures Libres.
Le déploiement se fait par **push Git** vers un dépôt *bare* sur la VM (même
principe que les autres projets Figures Libres :
<https://figureslibres.io/dokuwiki/developement:git_deployement>). L'app tourne
comme **service systemd** (serveur Nitro), derrière un **reverse proxy HTTPS**.
> ⚠️ **Exception à la règle « tout en Docker ».** Docker n'est utilisé qu'en
> développement local. En production, l'app tourne **nativement** sur Debian.
---
## 1. Architecture cible
```
Poste dev VM « portfolios » (Debian nue) VM Grav (prod)
┌───────────┐ git push prod ┌──────────────────────────────────────┐ rsync/SSH ┌──────────────┐
│ generator │ ─────────────────▶ │ bare repo .git ──post-receive──▶ deploy│ ─(lecture ────▶│ user/pages/ │
│ (repo) │ │ → checkout + npm ci + build │ seule) │ 02.projets/ │
└───────────┘ │ → systemctl restart │◀──── pull ──────│ (inchangé) │
│ │ └──────────────┘
│ systemd: node .output/server/index.mjs │
│ écoute 127.0.0.1:3000 │
│ données: /var/lib/portfolio-generator/ │
└──────────────┬─────────────────────────┘
│ 127.0.0.1:3000
┌────────▼─────────┐
│ nginx :443 (TLS) │ ◀── navigateurs (équipe + liens publics)
└──────────────────┘
```
Points clés :
- Le contenu ne circule que **dans un sens** : la VM portfolios *tire* les projets
depuis la prod Grav par `rsync`/SSH en **lecture seule**. La prod n'est jamais
modifiée et **ignore** l'existence de l'app.
- On **ne réimporte pas** `grav-reference` en prod (c'est un jeu d'exemple de dev).
Le contenu prod vit dans un cache local rafraîchi par le bouton « Rafraîchir ».
- Le rendu PDF appelle le serveur en interne sur **`localhost:3000`** : le port
Nitro **doit** rester `3000` (voir §6).
---
## 2. Dimensionnement de la VM
### RAM
| Poste | Empreinte |
|-------|-----------|
| Serveur Nitro au repos | ~150300 Mo |
| Pic à la **génération PDF** : Chromium (~300600 Mo) + traitement d'images `sharp` en 2× sur des pages A3 + Ghostscript | **12 Go** ponctuels |
- **Recommandé : 4 Go.** Plancher : 2 Go (risqué sur des portfolios très riches en
images — le processus de génération peut être tué par l'OOM killer).
- La génération est **séquentielle** (une seule instance Chromium à la fois via une
file d'attente) : la RAM ne monte pas linéairement avec le nombre d'utilisateurs.
### Disque
Ordres de grandeur mesurés / à prévoir (l'essentiel vit dans `data/`) :
| Élément | Taille | Note |
|---------|--------|------|
| Debian + Node + outils | ~34 Go | système |
| `node_modules` | ~11,5 Go | Nuxt + Playwright + sharp |
| Chromium (Playwright) | ~0,5 Go | navigateur de rendu |
| `.output` (build Nitro) | ~0,10,3 Go | régénéré à chaque déploiement |
| `data/content-cache/` | ~0,75 Go aujourd'hui | miroir de `02.projets` (croît avec le contenu) |
| `data/frozen/` | ~0,4 Go aujourd'hui | médias figés des portfolios générés (croît à l'usage) |
| `data/exports/` | ~0,015 Go | PDF (18 Mo pièce, régénérables) |
| `data/cache/` | transitoire | rendu/travail PDF |
| `data/portfolios/` | quelques Mo | **la donnée métier** (JSON) |
- **Recommandé : 30 Go** (confort + croissance du contenu et des instantanés).
Plancher : 20 Go.
### CPU
- **2 vCPU recommandés.** La génération PDF est *CPU-bound* (rendu Chromium +
ré-échantillonnage d'images + Ghostscript), par à-coups.
---
## 3. Prérequis logiciels (installation unique, en root)
Sur la **VM portfolios**, une seule fois :
```bash
apt update && apt install -y curl git rsync openssh-client ghostscript ffmpeg nginx
# Node.js 22 LTS (dépôt NodeSource)
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt install -y nodejs
node --version # v22.x
# Dépendances système de Chromium (pour Playwright) — installées plus tard
# par « npx playwright install-deps » (§7), qui nécessite root.
```
---
## 4. Utilisateur et arborescence
On dédie un utilisateur système `portfolio` qui **possède tout** (dépôt bare,
checkout, données, service). Cela évite tout jonglage de permissions.
```bash
# en root
adduser --disabled-password --gecos "" portfolio
# arborescences
install -d -o portfolio -g portfolio /home/portfolio/gitBareRepos
install -d -o portfolio -g portfolio /var/www/portfolio-generator
install -d -o portfolio -g portfolio /var/lib/portfolio-generator/data
install -d -o portfolio -g portfolio -m 700 /var/lib/portfolio-generator/.ssh
```
| Chemin | Rôle |
|--------|------|
| `/home/portfolio/gitBareRepos/portfolio-generator.git` | dépôt *bare* (cible du push) |
| `/var/www/portfolio-generator/public_html` | checkout de travail (build) |
| `/var/www/portfolio-generator/deploy.sh` | script de déploiement |
| `/var/lib/portfolio-generator/data` | **données persistantes** (`NUXT_DATA_DIR`) |
| `/var/lib/portfolio-generator/.ssh/portfolio_sync` | clé privée de synchro rsync |
| `/etc/portfolio-generator.env` | variables d'environnement du service |
---
## 5. Déploiement par push Git
> Tout le §5 s'exécute **en tant qu'utilisateur `portfolio`** (`sudo -iu portfolio`).
### 5.1 Dépôt *bare*
```bash
cd ~/gitBareRepos
git init --bare portfolio-generator.git
```
### 5.2 Hook `post-receive`
`~/gitBareRepos/portfolio-generator.git/hooks/post-receive` :
```bash
#!/usr/bin/env bash
PRODDIR="/var/www/portfolio-generator"
while read oldrev newrev refname; do
if [ "$refname" = "refs/heads/prod" ]; then
echo "===== DÉPLOIEMENT ====="
unset GIT_DIR
cd "$PRODDIR" || exit 1
. deploy.sh
echo "===== OK ====="
else
echo "Commit non déployé — pushez la branche prod."
fi
done
```
```bash
chmod +x ~/gitBareRepos/portfolio-generator.git/hooks/post-receive
```
### 5.3 Checkout de travail
Cloné une fois depuis le dépôt bare (il aura ainsi `origin` = bare) :
```bash
git clone ~/gitBareRepos/portfolio-generator.git /var/www/portfolio-generator/public_html
```
### 5.4 Script `deploy.sh`
`/var/www/portfolio-generator/deploy.sh` :
```bash
#!/usr/bin/env bash
set -euo pipefail
APPDIR="/var/www/portfolio-generator/public_html"
echo "→ mise à jour du code"
cd "$APPDIR"
git fetch origin
git reset --hard origin/prod
echo "→ dépendances + build Nitro"
npm ci
npm run build
echo "→ redémarrage du service"
sudo systemctl restart portfolio-generator
```
```bash
chmod +x /var/www/portfolio-generator/deploy.sh
```
`npm ci` télécharge automatiquement Chromium dans le cache de l'utilisateur
`portfolio` (les dépendances **système** de Chromium sont, elles, installées une
fois en root à l'étape §7).
### 5.5 Autoriser le redémarrage du service (sudoers)
`deploy.sh` a besoin de redémarrer le service ; on limite ce `sudo` au strict
nécessaire. `/etc/sudoers.d/portfolio-generator` (en root, `visudo -f …`) :
```
portfolio ALL=(root) NOPASSWD: /usr/bin/systemctl restart portfolio-generator
```
---
## 6. Service systemd et environnement
### 6.1 Fichier d'environnement
`/etc/portfolio-generator.env` (droits `600`, propriétaire `portfolio` — il
contient des secrets) :
```dotenv
# --- Serveur ---
# Le port DOIT rester 3000 : le rendu PDF appelle le serveur sur localhost:3000.
NITRO_HOST=127.0.0.1
NITRO_PORT=3000
# --- Secrets (valeurs aléatoires longues : openssl rand -hex 32) ---
NUXT_PORTFOLIO_PASSWORD=<mot-de-passe-partagé>
NUXT_SESSION_SECRET=<aléatoire-≥32-caractères>
NUXT_INTERNAL_TOKEN=<aléatoire-≥32-caractères>
# --- Données persistantes (hors du checkout, préservées aux déploiements) ---
NUXT_DATA_DIR=/var/lib/portfolio-generator/data
# --- Source de contenu : production (cache rsync) ---
NUXT_CONTENT_SOURCE=prod
NUXT_SYNC_REMOTE=portfolio-sync@<HOTE_GRAV>:/var/www/figureslibres/user/pages/02.projets/
NUXT_SYNC_SSH_KEY=/var/lib/portfolio-generator/.ssh/portfolio_sync
```
### 6.2 Unité systemd
`/etc/systemd/system/portfolio-generator.service` :
```ini
[Unit]
Description=Générateur de portfolios Figures Libres
After=network.target
[Service]
Type=simple
User=portfolio
Group=portfolio
WorkingDirectory=/var/www/portfolio-generator/public_html
EnvironmentFile=/etc/portfolio-generator.env
ExecStart=/usr/bin/node .output/server/index.mjs
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
```bash
systemctl daemon-reload
systemctl enable portfolio-generator # démarrage auto (ne pas encore start : voir §7)
```
---
## 7. Premier déploiement (bootstrap)
1. **Côté poste dev**, dans le dépôt `generator`, ajouter le distant et pousser la
branche `prod` (déclenche le hook) :
```bash
git remote add prod portfolio@<VM_PORTFOLIOS>:gitBareRepos/portfolio-generator.git
git push prod main:prod # pousse la branche locale main sur la branche prod distante
```
Le hook lance `deploy.sh` → `npm ci` + `npm run build`. (Au tout premier push,
`git reset --hard origin/prod` échoue si le checkout est vide : voir la note.)
2. **Côté VM (utilisateur `portfolio`)**, une fois le code présent dans
`public_html`, installer les **dépendances système de Chromium** (root) :
```bash
sudo npx --prefix /var/www/portfolio-generator/public_html playwright install-deps chromium
```
3. Démarrer le service :
```bash
sudo systemctl start portfolio-generator
sudo systemctl status portfolio-generator
journalctl -u portfolio-generator -f
```
> **Note premier push** : si `git reset --hard origin/prod` du `deploy.sh` bute sur
> un checkout vierge, faire une fois manuellement dans `public_html` :
> `git fetch origin && git checkout -B prod origin/prod`, puis relancer un push.
---
## 8. Brancher à la prod Grav (rsync/SSH en lecture seule)
### 8.1 Clé de synchronisation (VM portfolios, utilisateur `portfolio`)
```bash
ssh-keygen -t ed25519 -N "" \
-f /var/lib/portfolio-generator/.ssh/portfolio_sync -C "portfolio-sync"
cat /var/lib/portfolio-generator/.ssh/portfolio_sync.pub # ← à déposer côté Grav
```
### 8.2 Côté VM Grav : accès en lecture seule
Créer un utilisateur dédié et l'autoriser à **lire** les projets, sans shell ni
écriture :
```bash
sudo adduser --disabled-password --gecos "" portfolio-sync
sudo setfacl -R -m u:portfolio-sync:rX /var/www/figureslibres/user/pages/02.projets
sudo setfacl -R -d -m u:portfolio-sync:rX /var/www/figureslibres/user/pages/02.projets
```
Déposer la clé publique dans `~portfolio-sync/.ssh/authorized_keys` en la
**restreignant à rsync** (recommandé) via `rrsync` :
```
command="rrsync -ro /var/www/figureslibres/user/pages/02.projets",no-agent-forwarding,no-port-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA...portfolio-sync...
```
> `rrsync` accompagne rsync (`/usr/bin/rrsync` ou
> `/usr/share/doc/rsync/scripts/rrsync`). Il verrouille la clé à un `rsync` en
> lecture seule sur ce seul dossier. À défaut, une clé standard en lecture seule
> convient. La commande exécutée par l'app est :
> `rsync -az --delete -e "ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes -i <clé>" <remote>/ <cache>/`.
### 8.3 Réseau
- Ouvrir **SSH (22)** en sortie de la VM portfolios vers la VM Grav.
- Renseigner `NUXT_SYNC_REMOTE` avec le **chemin absolu** de `02.projets` sur la VM
Grav, terminé par `/` (voir §6.1).
- Tester la connexion une fois pour accepter la clé d'hôte :
```bash
sudo -u portfolio ssh -i /var/lib/portfolio-generator/.ssh/portfolio_sync \
portfolio-sync@<HOTE_GRAV> "echo ok"
```
---
## 9. Reverse proxy HTTPS (indispensable)
Le HTTPS est **requis**, pas seulement confortable :
- les **liens de partage publics** (`/view/<id>`) sont faits pour être ouverts hors
réseau local ;
- la **copie du lien dans le presse-papiers** n'est autorisée par les navigateurs
qu'en *contexte sécurisé* (HTTPS ou `localhost`) — en HTTP sur IP elle est bloquée.
`/etc/nginx/sites-available/portfolio-generator` :
```nginx
server {
listen 80;
server_name portfolios.figureslibres.io;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name portfolios.figureslibres.io;
# ssl_certificate ... ; # via certbot (Let's Encrypt)
# ssl_certificate_key ... ;
client_max_body_size 64m; # médias volumineux
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s; # la génération PDF peut être longue
}
}
```
```bash
ln -s /etc/nginx/sites-available/portfolio-generator /etc/nginx/sites-enabled/
apt install -y certbot python3-certbot-nginx
certbot --nginx -d portfolios.figureslibres.io
nginx -t && systemctl reload nginx
```
Le service Nitro écoutant sur `127.0.0.1:3000`, il **n'est pas** exposé
directement : seul le port 443 est public. Un pare-feu (`ufw allow 443,22`) durcit
l'ensemble.
---
## 10. Première synchronisation & vérification
1. Ouvrir `https://portfolios.figureslibres.io`.
2. Se connecter avec `NUXT_PORTFOLIO_PASSWORD`.
3. Cliquer **« Rafraîchir »** : premier `rsync` vers
`/var/lib/portfolio-generator/data/content-cache/`. Le toast indique le nombre
de fichiers et de projets synchronisés.
4. Créer un portfolio de test et **générer un PDF** pour valider toute la chaîne
(Chromium + Ghostscript).
---
## 11. Sauvegardes
L'état applicatif vit dans **`/var/lib/portfolio-generator/data/`** :
| Dossier | Contenu | Sauvegarder ? |
|---------|---------|---------------|
| `portfolios/` | portfolios (JSON) — **la donnée métier** | **Oui** |
| `frozen/` | instantanés figés (contenu + composition) | Oui |
| `exports/` | PDF générés | Optionnel (régénérables) |
| `content-cache/` | miroir rsync de la prod | Non (re-tirable) |
| `cache/` | cache de rendu | Non |
Sauvegarde à chaud (les écritures des portfolios sont atomiques) :
```bash
tar czf portfolios-backup-$(date +%F).tar.gz \
-C /var/lib/portfolio-generator/data portfolios frozen
```
---
## 12. Mises à jour (au quotidien)
Depuis le poste dev, une fois le travail prêt :
```bash
git push prod main:prod
```
Le hook redéploie tout seul (`git reset --hard` + `npm ci` + `npm run build` +
redémarrage). Les données de `/var/lib/portfolio-generator/data/` sont préservées.
Suivre : `journalctl -u portfolio-generator -f`.
---
## 13. Dépannage
| Symptôme | Piste |
|----------|-------|
| Le push ne déploie pas | Vérifier qu'on pousse bien vers la branche **`prod`** (`git push prod main:prod`) et que `post-receive` est exécutable. |
| `deploy.sh` échoue sur `systemctl restart` | Vérifier l'entrée `sudoers.d/portfolio-generator` (§5.5). |
| Service qui ne démarre pas | `journalctl -u portfolio-generator -e` ; souvent `.output` absent (build échoué) ou `EnvironmentFile` manquant. |
| Génération PDF en échec | Dépendances Chromium manquantes → `npx playwright install-deps chromium` (§7.2) ; sinon vérifier RAM (OOM) dans `journalctl`. |
| « Rafraîchir » échoue (« Échec rsync ») | Tester la commande SSH (§8.3) ; vérifier `NUXT_SYNC_REMOTE` (chemin absolu, `/` final), les droits de lecture et le pare-feu sortant (22). |
| Copie du lien public impossible | Accès en HTTP/IP → passer par l'URL **HTTPS** (§9). |
| PDF « à jour » ne se met pas à jour | La régénération réutilise l'instantané figé ; utiliser « Réactualiser depuis Grav » pour resynchroniser le contenu. |
---
## 14. Récapitulatif express
```bash
# ── VM portfolios (root, une fois) ──
apt update && apt install -y curl git rsync openssh-client ghostscript ffmpeg nginx
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt install -y nodejs
adduser --disabled-password --gecos "" portfolio
install -d -o portfolio -g portfolio /home/portfolio/gitBareRepos /var/www/portfolio-generator
install -d -o portfolio -g portfolio /var/lib/portfolio-generator/data
install -d -o portfolio -g portfolio -m 700 /var/lib/portfolio-generator/.ssh
# → créer bare repo + hook + deploy.sh (§5), env + service systemd (§6),
# sudoers (§5.5), clé de synchro (§8.1)
# ── Poste dev ──
git remote add prod portfolio@<VM>:gitBareRepos/portfolio-generator.git
git push prod main:prod
# ── VM portfolios (après 1er push) ──
sudo npx --prefix /var/www/portfolio-generator/public_html playwright install-deps chromium
sudo systemctl start portfolio-generator
# ── Côté Grav : déposer la clé publique en lecture seule (§8.2) ──
# ── nginx + certbot en façade (§9), puis « Rafraîchir » dans l'UI ──
```

View File

@@ -0,0 +1,579 @@
# Spécification technique — Générateur de portfolios PDF (Figures Libres)
> Document de spécification v1 — établi le 2026-07-01, révisé le 2026-07-02.
> Outil compagnon du site Grav « Figures Libres » permettant de composer et générer
> des portfolios PDF à partir des projets saisis dans Grav.
---
## 1. Contexte & objectif
Le collectif Figures Libres gère ses projets dans un site **Grav** (v1.7.52). Chaque projet
y est déjà décrit (titre, texte, médias, catégorie, tags, date, lien). L'objectif est de
**réutiliser ces contenus pour produire des portfolios PDF sur mesure**, sans ressaisie :
- sélectionner/désélectionner les projets à inclure ;
- ordonner les pages ;
- choisir, **pour chaque page**, quel projet est présenté avec quel **template** ;
- **curer les médias** affichés page par page (choix + ordre + disposition) ;
- intercaler des **pages libres** (couverture, intro, sommaire) personnalisables ;
- exporter un **PDF léger et net**, destiné à l'envoi par mail.
L'outil est **séparé de Grav** (pas un plugin) : une application web dédiée qui lit le
contenu Grav en lecture seule.
---
## 2. Décisions validées (récapitulatif des arbitrages)
| Sujet | Décision |
|---|---|
| Utilisateurs | **Interne au collectif** (quelques utilisateurs de confiance) |
| Qualité PDF | **Écran / partage** (RGB, pas de contrainte imprimeur) |
| Templates | **Définis en code** (pas d'éditeur visuel de template) |
| Composition | **1 projet / page + curation des médias** (sélection + ordre + layout, sans recadrage) |
| Sauvegarde | **Oui** : bibliothèque de portfolios rééditables |
| Pages non-projet | **Couverture, sommaire, pages libres d'intro** (personnalisables dans l'UI) ; séparateurs/4e de couv optionnels |
| Texte des pages | **Auto depuis Grav + override ponctuel par page** |
| Format de page | **Fixe, 16:9 « écran » paysage** (voir §9.4) |
| Fraîcheur des données | **Live + figé à la génération** (le portfolio archive le contenu utilisé) |
| Sections source | **Projets uniquement** (`user/pages/02.projets`) |
| Champs exploités | Titre, texte, médias, catégorie/tags, date, lien externe |
| Médias | UI en basse résolution ; génération en pleine résolution ; **post-traitement du PDF** pour redimensionner chaque image à sa taille d'affichage |
| Personnalisation à la génération | Titre/sous-titre couverture, nom du fichier PDF ; couverture + pages libres personnalisables |
| Langue | **FR uniquement** |
| Fonds de page | **Blanc / teinté / pleine couleur** (prop `background`), dès la v1 |
| Direction artistique | **Sobre** : pas de formes graphiques décoratives ni d'effets « fluo » |
| Stack | **Nuxt 3 + Nuxt UI + Playwright (Chromium) + Paged.js** |
| Auth | **Mot de passe partagé simple** |
| Stockage | **Fichiers JSON + PDF sur disque** (volume Docker) |
| Déploiement | **Serveur perso (Docker + Playwright)**, contenu tiré de la prod en **rsync/SSH lecture seule** |
| Synchro | **Bouton manuel « Rafraîchir »** |
| Templates de départ | **« Visuel dominant + texte »** et **« Grille / planche »** ; layouts d'images variables (§9.5) |
| Références d'inspiration | 2 books dans `docs/portfolios-examples/`**inspiration non-normative** (§17) |
---
## 3. Utilisateurs & cas d'usage
**Utilisateur type** : un membre du collectif préparant un portfolio pour une candidature,
un appel d'offres ou un envoi client.
**Parcours principal :**
1. Se connecter (mot de passe partagé).
2. (Optionnel) Cliquer **« Rafraîchir »** pour re-synchroniser le contenu depuis la prod.
3. Créer un nouveau portfolio (ou dupliquer un existant).
4. Parcourir la **bibliothèque de projets**, filtrer par catégorie/tag, cocher ceux à inclure.
5. Pour chaque page : choisir le **template**, **curer les médias**, ajuster le **texte** si besoin.
6. Insérer des **pages libres** (couverture, intro, sommaire) et les personnaliser.
7. **Réordonner** les pages (glisser-déposer).
8. **Prévisualiser** (rendu WYSIWYG identique au PDF).
9. **Générer le PDF**, renseigner le nom de fichier, télécharger.
10. Le portfolio reste **sauvegardé** et rééditable.
---
## 4. Architecture générale
```
┌──────────────────────────────────────────────────────────────┐
│ Serveur perso (Docker) │
│ │
│ ┌────────────────────┐ ┌──────────────────────────────┐ │
│ │ Nuxt 3 (UI + API) │ │ Cache de contenu (lecture) │ │
│ │ - Bibliothèque │◀────▶│ user/pages/02.projets/… │ │
│ │ - Composeur │ │ (md + médias) │ │
│ │ - Prévisualisation│ └──────────────▲───────────────┘ │
│ │ - Templates (Vue) │ │ rsync/SSH (RO) │
│ └─────────┬──────────┘ │ (bouton │
│ │ rendu HTML │ « Rafraîchir ») │
│ ▼ │ │
│ ┌────────────────────┐ ┌──────────────┴───────────────┐ │
│ │ Playwright/Chromium│ │ Stockage portfolios │ │
│ │ + Paged.js │ │ data/portfolios/*.json │ │
│ │ → PDF │─────▶│ data/exports/*.pdf │ │
│ │ + sharp + gs (opti)│ └──────────────────────────────┘ │
│ └────────────────────┘ │
└───────────────────────────────────┬──────────────────────────┘
│ (SSH lecture seule)
┌──────────▼───────────┐
│ Serveur PROD Grav │
│ figureslibres.cc │
│ (aucune charge PDF) │
└───────────────────────┘
```
**Principe clé** : la prod ne fait que **servir le site et exposer son contenu en SSH**.
Toute la charge lourde (Playwright, génération, optimisation PDF) tourne sur le serveur perso.
---
## 5. Topologie de déploiement & sources de contenu
L'outil supporte **deux sources de contenu** via une même interface d'abstraction
(`ContentSource`), sélectionnée par configuration :
### 5.1 Mode DEV (immédiat, sans prod)
- Source = **le Grav local présent dans ce dépôt** (`../grav-reference/user/pages/02.projets`).
- Lecture directe du système de fichiers (le dossier est déjà monté sur le serveur).
- **Permet de développer et tester tout l'outil dès maintenant**, avant tout accès prod.
### 5.2 Mode PROD (cible)
- Source = **cache local alimenté par rsync/SSH** depuis la prod Figures Libres.
- L'outil ne se connecte jamais en écriture à la prod.
Le passage dev → prod ne change **que la configuration de la source**, pas le modèle de
données ni les templates.
---
## 6. Synchronisation du contenu (mode PROD)
- **Mécanisme** : `rsync -az --delete -e ssh` tirant `user/pages/02.projets/` (Markdown +
médias) de la prod vers `data/content-cache/`.
- **Déclenchement** : **bouton manuel « Rafraîchir »** dans l'UI (endpoint API qui lance
la commande et renvoie un résumé : nb de fichiers mis à jour, date).
- **Incrémental** : rsync ne transfère que les fichiers modifiés → rapide même avec de
nombreux médias lourds.
- **Lecture seule** : aucune écriture côté prod ; identifiants SSH stockés en secret
(variable d'environnement / fichier non versionné).
- **Note git** : le contenu (`user/pages/*`) est exclu de git côté Grav — rsync est donc
le bon outil (git ne verrait pas les projets ni les médias).
---
## 7. Modèle de données
### 7.1 Lecture du contenu Grav
Structure observée dans le Grav Figures Libres :
```
user/pages/02.projets/
01.culturelle/ ← catégorie (dossier)
atlas-atterrissage/ ← projet (dossier, slug)
reader.md ← frontmatter YAML + corps Markdown
*.png / *.mp4 ← médias (+ variantes @2x/@3x/@4x)
02.en-francais-au-pluriel/
reader.md
02.publique/
03.sociale/
```
Frontmatter type d'un projet :
```yaml
title: 'En français au pluriel'
aura:
image: Screenshot_….png # visuel de couverture du projet
media_order: 'a.png,b.png,…' # ordre des médias
taxonomy:
category: ['Site web', 'Identité visuelle']
tag: ['Collectif Rivage']
date: '09-12-2024 22:19'
published: true
```
+ corps Markdown (description, éventuel lien externe en fin de texte).
### 7.2 Modèle interne (TypeScript, indicatif)
```ts
// --- Contenu importé de Grav (lecture seule) ---
interface Project {
id: string; // slug complet, ex: "02.projets/01.culturelle/atlas-atterrissage"
category: string; // "culturelle" | "publique" | "sociale"
title: string;
bodyMarkdown: string; // texte descriptif
coverImage?: string; // aura.image
media: MediaAsset[]; // dans l'ordre de media_order
categories: string[]; // taxonomy.category
tags: string[]; // taxonomy.tag
date?: string; // ISO
externalUrl?: string; // lien détecté dans le corps
published: boolean;
}
interface MediaAsset {
filename: string;
type: 'image' | 'video';
variants: { label: '1x'|'2x'|'3x'|'4x'; path: string; width?: number }[];
posterPath?: string; // pour les vidéos : image d'aperçu
}
// --- Composition (créée dans l'outil, sauvegardée) ---
interface Portfolio {
id: string;
name: string; // nom interne
cover: CoverConfig; // titre/sous-titre/… personnalisables
pages: Page[]; // ordre = ordre du PDF
createdAt: string;
updatedAt: string;
frozenContent?: FrozenSnapshot; // rempli à la génération (voir §7.3)
}
type Page =
| ProjectPage
| FreePage
| CoverPage
| TocPage;
// Fond de page commun à tous les types (voir §9.5)
type Background =
| { mode: 'white' }
| { mode: 'tint'; color: string } // aplat clair
| { mode: 'color'; color: string }; // pleine couleur
interface ProjectPage {
kind: 'project';
projectId: string;
template: 'hero-text' | 'grid'; // templates définis en code
layout: string; // disposition d'images (single|mosaic|grid-2|…), voir §9.5
mediaSelection: string[]; // filenames choisis + ordonnés (curation, sans recadrage)
subtitleOverride?: string; // sinon dérivé de taxonomy.category
textOverride?: string; // override ponctuel du texte (sinon = Grav)
background: Background;
options?: Record<string, unknown>; // réglages mineurs éventuels du template
}
interface FreePage { // page libre d'intro / texte
kind: 'free';
template: 'free-text';
title?: string;
body?: string; // markdown saisi dans l'UI
media?: string[]; // médias optionnels
background: Background;
}
interface CoverConfig { // page de couverture
title: string;
subtitle?: string;
recipient?: string; // « préparé pour … »
backgroundImage?: string;
background: Background;
}
interface TocPage { kind: 'toc'; title?: string; background: Background; }
```
### 7.3 « Live + figé à la génération »
- Pendant l'édition, l'outil lit le **contenu live** (cache synchronisé) → les projets
reflètent l'état courant de Grav.
- À la **génération du PDF**, l'outil écrit un **`frozenContent`** dans le portfolio
sauvegardé : copie des textes, listes de médias et fichiers réellement utilisés.
- Conséquence : régénérer un ancien portfolio produit **le même PDF**, même si Grav a
changé depuis. Un bouton « Réactualiser depuis Grav » permettra de re-synchroniser
volontairement un portfolio avec le contenu à jour.
---
## 8. Fonctionnalités détaillées
### 8.1 Bibliothèque de projets
- Liste des projets par catégorie, avec vignette (basse résolution), titre, tags, date.
- Recherche + filtres (catégorie, tag).
- Sélection/désélection pour ajout au portfolio.
### 8.2 Composeur de portfolio
- Colonne « pages du portfolio » (liste réordonnable en **glisser-déposer**).
- Ajout de pages : depuis un projet (choix du template) ou page spéciale (couverture,
sommaire, page libre).
- Panneau d'édition de la page sélectionnée :
- **choix du template** (parmi ceux définis en code) ;
- **curation des médias** : cocher/décocher + réordonner les visuels du projet
(aperçus basse résolution), **sans recadrage/zoom** ;
- **disposition (layout)** : choix parmi les layouts du template (§9.5) ;
- **texte** : pré-rempli depuis Grav, éditable (override local, sans toucher Grav) ;
- **fond de page** : blanc / teinté / pleine couleur (`background`) ;
- réglages mineurs du template le cas échéant.
### 8.3 Pages spéciales / « pages types »
- **Couverture** : titre, sous-titre, destinataire, image de fond — personnalisables.
- **Sommaire** : généré automatiquement depuis les pages-projet (titres + n° de page).
- **Page libre d'intro** : titre + texte Markdown + médias optionnels, saisis dans l'UI.
- (Optionnel v1+) séparateurs de section, 4e de couverture.
- Ces pages sont elles aussi des **templates définis en code**, personnalisables via l'UI.
### 8.4 Prévisualisation WYSIWYG
- Rendu à l'écran par **les mêmes composants Vue** que ceux utilisés pour le PDF.
- Ce que l'on voit = ce que produira le PDF (mêmes styles, même pagination logique).
- En prévisualisation, les images sont servies en **basse résolution** (fluidité).
### 8.5 Génération PDF
- Nom de fichier personnalisable ; métadonnées PDF (titre/auteur) renseignées.
- Pipeline détaillé au §10.
### 8.6 Gestion des portfolios sauvegardés
- Liste des portfolios (nom, date de MAJ, aperçu couverture).
- Actions : ouvrir, dupliquer, renommer, supprimer, régénérer le PDF.
---
## 9. Système de templates
### 9.1 Principe
- Templates = **composants Vue** dans `generator/components/templates/`.
- Un même composant sert **aperçu écran** et **rendu PDF** (source de vérité unique).
- Contrat d'entrée commun (props) pour être interchangeables dans le composeur.
### 9.2 Contrat d'interface (props)
```ts
interface TemplateProps {
project?: Project; // pour les pages-projet
subtitle?: string; // sous-titre "type de travail" (dérivé de taxonomy.category)
media: ResolvedMedia[]; // médias curés, déjà résolus à la bonne résolution
layout?: string; // disposition d'images choisie (voir §9.5)
text?: string; // texte effectif (override ou Grav)
background: Background; // fond de page (blanc / teinté / pleine couleur) — voir §7.2
page: { index: number; total: number };
theme: FiguresLibresTheme; // charte (couleurs, fonts) — voir §9.3
renderMode: 'preview' | 'print';
}
```
> **Sous-titre projet** : dans les books existants, chaque page-projet porte un sous-titre
> « type de travail » (ex. « Identité visuelle et rapports d'activités », « Édition /
> principe de la collection »). Il sera dérivé de `taxonomy.category` (joignables) ou
> éditable en override.
### 9.3 Charte Figures Libres (extraite du thème Grav `figureslibres-v2`)
- **Couleurs** (extraites du thème) : base claire `#f5f5f5`, encre `#0e1229` (bleu nuit).
Couleurs secondaires disponibles : `#ffaeab`, `#feff74`, `#71ff94`, `#fabbde`, `#4bffc9`,
à employer **avec parcimonie** (aplats de fond, filets). **Direction artistique sobre :
pas de formes graphiques décoratives ni d'effets « fluo ».**
- **Polices maison disponibles** (réutilisables, fichiers woff2 présents dans le thème) :
Avara (serif de titre), Moche, Syne, ManifontGrotesk, PlayfairDisplay, Rumeur, Lato.
- Les fonts seront embarquées dans l'app via `@font-face` pour un rendu identique en PDF.
### 9.4 Format de page (à valider)
- **Format « écran » 16:9 paysage.**
- Recommandation : **canvas de référence 1280×720 pt** (ratio 16:9), avec les **images
redimensionnées à 2× leur boîte d'affichage** (≈ 150200 dpi effectif) → netteté sans
surpoids. Le PDF garde du **texte vectoriel** (léger et net à tout zoom).
- `@page { size: 1280pt 720pt; margin: 0 }` côté CSS Paged.js (valeurs ajustables).
- 1920×1080 px reste la cible d'affichage logique ; la valeur pt ci-dessus en est
l'équivalent physique pratique pour le PDF.
### 9.5 Templates de départ (v1)
1. **`hero-text` — Visuel dominant + texte** : un grand visuel + bloc titre/sous-titre/
description (date, catégorie/tags, lien externe en pied). Le template « projet » de référence.
2. **`grid` — Grille / planche** : plusieurs médias d'un même projet en grille, titre discret.
**Dispositions d'images (layouts).** L'analyse des books existants (voir §17) montre que
la page-projet réclame des **compositions variables**, pas une grille unique. La curation
médias inclut donc le choix d'une **disposition** parmi un jeu prédéfini, ex. :
- `single` : une image plein cadre (bord à bord ou avec marge) ;
- `mosaic` : mosaïque asymétrique (1 grande + plusieurs petites) ;
- `grid-2` / `grid-3` / `grid-6` : grilles régulières ;
- `image-text` : une image + un bloc de texte latéral.
Ces layouts sont des variantes internes aux templates (props `layout` + `media`), pas des
templates séparés — ils partagent l'en-tête (titre + sous-titre) et la charte.
> Les deux **books de référence** sont désormais dans `docs/portfolios-examples/`. Ce sont
> des **inspirations libres**, **pas des gabarits à reproduire** : ils sont en A3/A4 paysage
> (impression), alors que la cible est le **16:9 écran**. On s'en sert pour le vocabulaire de
> pages (§17) et l'esprit graphique, pas pour un pixel-perfect.
---
## 10. Pipeline PDF & optimisation des images
Objectif : **PDF léger (envoyable par mail) ET images irréprochables**.
1. **Résolution des médias** : pour chaque image d'une page, calcul de sa **taille
d'affichage réelle** dans le template au format 16:9, puis génération (via **`sharp`**)
d'une version redimensionnée à `taille_affichage × 2` (retina). Les variantes
`@2x/@3x/@4x` de Grav servent de source haute résolution ; on **descend** vers la taille
utile plutôt que d'embarquer des images surdimensionnées.
2. **Rendu HTML** : Nuxt rend la page composée (mêmes composants que l'aperçu),
**Paged.js** gère la pagination (une page CSS = une page PDF).
3. **Impression** : **Playwright/Chromium** `page.pdf()` en mode `print`, format 16:9,
`printBackground: true`.
4. **Post-traitement** : passe **Ghostscript** (`gs`) pour recompresser/normaliser
(downsampling au DPI cible, compression JPEG maîtrisée) et alléger le fichier final.
Métadonnées PDF injectées (titre, auteur).
5. **Sortie** : `data/exports/<nom-fichier>.pdf`, proposé au téléchargement.
> Les vidéos `.mp4` sont représentées par un **poster** (image d'aperçu) puisqu'un PDF est
> statique.
---
## 11. Stack technique & arborescence
> **Principe d'infrastructure : tout est conteneurisé.** Aucun runtime (Node, etc.) ni
> binaire système n'est installé sur l'hôte, qui héberge déjà de nombreux services. Node,
> le build, Playwright/Chromium et Ghostscript s'exécutent dans des conteneurs Docker.
- **Front + API** : Nuxt 3 (Nitro pour les endpoints serveur), **Nuxt UI** pour les composants.
- **PDF** : Playwright (Chromium) + Paged.js.
- **Images** : sharp ; **Ghostscript** (dans le conteneur) pour l'optimisation PDF.
- **Parsing Grav** : lecture FS + parseur front-matter YAML + Markdown.
- **Synchro** : rsync/ssh (dans le conteneur), déclenché par un endpoint Nitro.
Arborescence cible (dans ce dépôt, à côté de `grav-reference/` = Grav) :
```
portfolio-maker/
grav-reference/ ← Grav de référence (existant, renommé depuis app/)
generator/ ← nouvelle app Nuxt (code)
components/
templates/ ← hero-text.vue, grid.vue, cover.vue, toc.vue, free-text.vue
composer/ ← UI de composition
server/
api/ ← endpoints (projects, portfolios, sync, generate)
lib/ ← grav-reader, media-resolver, pdf-pipeline, content-source
data/
content-cache/ ← contenu synchronisé (prod) — non versionné
portfolios/*.json ← compositions sauvegardées
exports/*.pdf ← PDF générés
nuxt.config.ts
docker/
grav/Dockerfile ← (existant) image du Grav de référence
generator/Dockerfile ← image du générateur (base image Playwright + Ghostscript)
docker-compose.yml ← services grav-reference + generator
docs/
spec-generateur-portfolios.md ← ce document
```
---
## 12. Sécurité & accès
- **Mot de passe partagé** unique (middleware d'auth simple + cookie de session).
- Accessible sur le LAN comme Grav (port dédié, ex. 3000/8090).
- Identifiants SSH de la prod stockés hors dépôt (env/secret), utilisés uniquement pour
rsync en lecture.
## 13. Stockage
- **Portfolios** : un fichier JSON par portfolio (`data/portfolios/<id>.json`).
- **Exports** : PDF dans `data/exports/`.
- **Cache contenu** : `data/content-cache/` (rsync).
- Tout dans un **volume Docker** → sauvegardable par simple copie de dossier.
---
## 14. Roadmap par phases
**Phase 0 — Fondations (dev sur Grav local)**
- App Nuxt + Nuxt UI, auth mot de passe.
- `ContentSource` mode DEV : lecture de `../grav-reference/user/pages/02.projets`.
- Parseur Grav (projets, médias, taxonomie, lien).
- Bibliothèque de projets (liste, filtres).
**Phase 1 — Composition & aperçu**
- Modèle Portfolio + sauvegarde JSON.
- Composeur : ajout/réordonnancement de pages, choix template, curation médias, override texte.
- Templates `hero-text` et `grid` + charte Figures Libres.
- Prévisualisation WYSIWYG.
**Phase 2 — Génération PDF**
- Pipeline Playwright + Paged.js au format 16:9.
- Optimisation images (sharp) + post-traitement Ghostscript.
- Pages spéciales : couverture, sommaire, page libre.
- Personnalisation (titre/sous-titre, nom de fichier, métadonnées).
- Gel du contenu à la génération (`frozenContent`).
**Phase 3 — Passage prod & finitions**
- `ContentSource` mode PROD : rsync/SSH + bouton « Rafraîchir ».
- Conteneur Docker + intégration au `docker-compose`.
- Raffinement des templates à partir des PDF d'exemple.
- Gestion de bibliothèque (dupliquer, renommer, supprimer, régénérer).
---
## 15. Décisions ouvertes / à confirmer
1. **Format de page** : valider `1280×720 pt` (16:9) et le facteur retina ×2 pour les images
(§9.4), ou fournir des dimensions précises attendues.
2. **Sommaire** : numérotation de pages souhaitée ? niveau de détail (par projet, par
section) ?
3. **Plusieurs pages par projet** : les books montrent souvent 1 projet = 1 page, mais
parfois un projet riche s'étale. Confirmer si on autorise plusieurs pages pour un même
projet (actuellement : non, 1 projet = 1 page).
4. **Sous-titre projet** : dériver automatiquement de `taxonomy.category`, ou champ dédié
éditable dans l'UI ? (défaut proposé : dérivé + override.)
5. **Accès prod** : confirmer l'accès **SSH** (et non seulement FTP) pour rsync, + le
chemin exact de `user/pages` sur la prod.
_Décidé_ : fonds de page **teintés/pleine couleur** proposés **dès la v1** (prop `background`
= `white` | `tint` | `color`). Curation médias = **sélection + ordre + choix de layout**,
**sans** recadrage/zoom d'image en v1._
---
## 16. Annexe — Rappel de la structure de contenu observée
- Projets sous `user/pages/02.projets/{01.culturelle,02.publique,03.sociale}/<slug>/reader.md`.
- Médias à côté du `.md`, variantes `@2x/@3x/@4x`, quelques `.mp4`.
- Frontmatter clés utiles : `title`, `aura.image`, `media_order`, `taxonomy.category`,
`taxonomy.tag`, `date`, `published`.
- Le corps Markdown contient la description et, souvent, l'URL externe du projet.
---
## 17. Annexe — Références d'inspiration (books existants)
> ⚠️ **Non-normatif.** Les deux PDF de `docs/portfolios-examples/`
> (`book_2025_Bagneux.pdf`, 17 p. A4 paysage ; `book2025_FiguresLibres_edition.pdf`,
> 16 p. A3 paysage) sont des **books faits main**, fournis comme **inspiration libre**.
> Ils donnent l'**esprit** et le **vocabulaire de pages** attendus — **pas** des gabarits à
> reproduire au pixel. La cible reste le **16:9 écran**.
### Taxonomie de pages observée (à couvrir par des "pages types")
| Type de page | Description observée |
|---|---|
| **Couverture** | Date + titre du dossier (client) + coordonnées collectif + grande image. Personnalisable. |
| **Présentation** | Grand titre (« 20 ans d'expérience… ») + colonnes de texte + image latérale pleine hauteur + logo en pastille. |
| **Équipe** | Grille 2 colonnes × N : portrait rond + nom/rôle en gras + bio. Peut couvrir 2 pages. |
| **Colonnes / infos** | Titre + 24 colonnes de texte à sous-titres, filet vertical coloré (« Outils et savoir-faire », « Commanditaires »). |
| **Intercalaire-titre** | Grand titre sur page quasi vide (« Portfolio Figures Libres »). |
| **Page-projet** | En-tête (titre + sous-titre "type de travail") + composition d'images variable (voir layouts §9.5) ± bloc texte ± fond teinté. |
| **4e de couverture** | Contact « Figures Libres.cc » + adresse + citation d'un membre. |
### Grammaire graphique retenue comme inspiration
- **Logo** en pastille (cercle noir ou couleur).
- **Titres** grotesques géométriques arrondis (Syne / Moche) ; **texte courant** en Lato.
- **Fonds** blancs, gris très clairs, beige, ou **pleine couleur** selon la page/projet.
- En-tête projet **titre + sous-titre** systématique ; pagination + mention « FIGURES LIBRES » en pied.
- Compositions d'images **asymétriques et respirées** (une dominante + satellites), pas seulement des grilles régulières.
---
## 18. Notes pour le développement autonome
**Par où commencer.** Démarrer par la **Phase 0** sur le Grav local. Le CMS de référence est
déjà servi (`docker compose up -d`, http://localhost:8089) et son contenu est lisible dans
`../grav-reference/user/pages/02.projets/`. Aucune connexion prod n'est nécessaire pour les
phases 0 à 2.
**Contraintes fermes.**
- **Ne jamais écrire dans `grav-reference/`** : c'est une source en **lecture seule**.
- **TOUT tourne dans des conteneurs Docker — rien n'est installé sur l'hôte.** Le serveur
héberge de nombreux services ; on n'y ajoute aucun runtime ni paquet. Node, npm/pnpm, le
build Nuxt, Playwright/Chromium et Ghostscript vivent **dans des conteneurs**. Toute
commande `node`/`npm`/`npx` s'exécute via un conteneur (ex. `docker compose run`), jamais
sur la machine.
- **Base recommandée** : image **officielle Playwright** (`mcr.microsoft.com/playwright`,
Node + Chromium + dépendances déjà présents), + Ghostscript ajouté dans l'image du service.
- **Ne rien installer sur le serveur ni lancer de `sudo` sans validation explicite de
l'utilisateur.**
- **FR uniquement.** **DA sobre** : pas de formes graphiques décoratives, pas d'effets « fluo ».
**Conventions.**
- Un **template = un composant Vue piloté uniquement par ses props** (`TemplateProps`,
§9.2) : aucun accès direct au système de fichiers depuis un template → testable et
identique en `preview` et `print`.
- Toute lecture de contenu passe par l'abstraction **`ContentSource`** (§5) : brancher DEV
d'abord, PROD ensuite sans toucher au reste.
- Ghostscript et rsync (dans le conteneur) ne servent qu'aux phases 23 ; ne pas bloquer
les phases 01 dessus.
**Outils disponibles pour l'agent.**
- **context7** (MCP branché) : doc à jour des libs (Nuxt 3, Nuxt UI, Playwright, Paged.js) —
s'en servir plutôt que deviner les APIs.
- **poppler-utils** : lire les PDF générés pour auto-vérifier le rendu et le comparer aux
books d'inspiration (§17).
**Definition of done — Phase 0.** La bibliothèque liste les projets réels du
`grav-reference` (les 3 catégories), avec vignette basse résolution, titre, tags et date,
filtrables ; l'auth par mot de passe protège l'accès ; rien n'est écrit côté Grav.