Créer le site des présentations d'Alpinux

Un dossier par présentation en Markdown, converti en diaporama HTML autonome
par Marp CLI, et une page d'accueil générée qui les liste toutes.

- scripts/build.mjs : build strict (fiche validée, images vérifiées, aucune
  ressource externe hormis le logo), page d'accueil filtrable,
  presentations.json et .htaccess pour l'en-tête CORS
- theme/alpinux.css : thème commun pour la vidéoprojection, classes titre et demo
- slides/_modele : modèle commenté, non publié
- slides/pdf-signer : « Lire, remplir et signer ses PDF », avec notes d'orateur
- scripts/deploy-presentations.sh et deploy/ : déploiement par webhook Gitea,
  sur le modèle du wiki et de www

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M713yNBL8cssBA1Zri5QLT
This commit is contained in:
Cédrix 2026-10-10 21:44:32 +02:00
commit 02cf82447b
18 changed files with 4302 additions and 0 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
# Dépendances et sortie du build : tout se régénère
node_modules/
public/
.build-*/
# Exports faits à la main
*.pdf
# Jetons et secrets : jamais versionnés
.gitea-token
*.secret
.env

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
24

196
CONTRIBUER.md Normal file
View file

@ -0,0 +1,196 @@
# Proposer une présentation
Vous présentez un sujet à une réunion d'Alpinux ? Votre diaporama a sa place ici. Il
s'écrit dans un simple fichier texte, en Markdown : pas de logiciel de présentation à
maîtriser, et le résultat s'affiche dans n'importe quel navigateur.
Vous n'avez jamais utilisé Git ? Ce n'est pas un obstacle : la [méthode sans rien
installer](#la-méthode-sans-rien-installer) se fait entièrement depuis le site de la
forge. Et si vous bloquez, demandez de l'aide en réunion ou sur la liste de diffusion —
quelqu'un mettra votre fichier en ligne avec vous.
---
## En bref
1. **Copier** le dossier `slides/_modele` sous un nouveau nom.
2. **Écrire** vos diapos dans `slides.md`.
3. **Prévisualiser** sur votre ordinateur.
4. **Ouvrir une pull request** : un mainteneur relit, fusionne, et le site se met à
jour tout seul.
---
## 1. Copier le modèle
Chaque présentation vit dans son dossier, sous `slides/`. Le nom du dossier devient
l'adresse du diaporama : `slides/pdf-signer/` est publié sur
`presentations.alpinux.org/pdf-signer/`.
Choisissez un nom court, en **minuscules, sans accent ni espace**, avec des tirets :
`sauvegardes`, `nextcloud-quotidien`, `kicad-debuter`.
```bash
cp -r slides/_modele slides/mon-sujet
```
Vous obtenez :
```
slides/mon-sujet/
├── slides.md votre diaporama
└── images/ vos images (supprimez exemple.svg)
```
---
## 2. Écrire
Ouvrez `slides/mon-sujet/slides.md` dans un éditeur de texte. Le modèle est commenté :
il montre une diapo de titre, une liste, du code, un tableau, une image et une diapo de
démonstration.
### La fiche, en haut du fichier
Le bloc entre les deux lignes `---` décrit votre présentation. C'est lui qui alimente
la page d'accueil du site.
| Champ | Obligatoire | Ce qu'on y met |
|---------------|:-----------:|-------------------------------------------------------------|
| `title` | oui | Le titre |
| `description` | oui | Une phrase : ce qu'on saura faire en sortant |
| `auteur` | oui | Votre nom, ou un pseudonyme |
| `date` | oui | La date de la séance, `AAAA-MM-JJ` — par exemple `2026-10-15` |
| `niveau` | oui | `debutant`, `intermediaire` ou `confirme` |
| `format` | oui | `eclair`, `court` ou `long` |
| `tags` | oui | Des mots-clés : `[pdf, bureautique]` |
| `wiki` | non | L'adresse de la fiche du wiki, s'il y en a une |
| `brouillon` | non | `true` tant que le diaporama n'est pas prêt à être publié |
Laissez tels quels `marp`, `theme` et `paginate`. Et pensez à **retirer
`brouillon: true`** quand vous avez fini : le modèle le contient, et une présentation
en brouillon n'est pas publiée.
### Les diapos
- Trois tirets seuls sur une ligne (`---`) séparent deux diapos.
- `## Un titre` donne le titre de la diapo.
- Un commentaire `<!-- … -->` est une **note d'orateur** : invisible à l'écran,
affichée dans la vue présentateur.
- `<!-- _class: titre -->` en tête d'une diapo lui donne la mise en page de titre ;
`<!-- _class: demo -->` annonce une démonstration.
### Quelques conseils pour la vidéoprojection
- **Peu de texte.** Trois à cinq lignes par diapo. Ce que vous direz va dans les notes.
- **Pas plus de six ou sept lignes de code** par diapo.
- **Des tableaux étroits** : deux ou trois colonnes.
- **Des images à vous**, ou sous licence libre, rangées dans `images/`. Une image
chargée depuis un autre site est refusée : le diaporama doit fonctionner sans réseau
dans la salle.
- **Pas de données personnelles** dans les captures d'écran : adresse, nom de
machine, courriel.
---
## 3. Prévisualiser
Il faut [Node.js](https://nodejs.org/fr) (version 22 ou plus récente). Une seule fois,
à la racine du dépôt :
```bash
npm ci
```
Puis, à chaque séance de travail :
```bash
npm run dev
```
Ouvrez <http://localhost:8080>, cliquez sur votre dossier puis sur `slides.md`. La page
se **recharge toute seule** à chaque enregistrement du fichier. <kbd>Ctrl</kbd> +
<kbd>C</kbd> dans le terminal arrête l'aperçu.
Dans le diaporama : les flèches pour avancer, <kbd>F</kbd> pour le plein écran,
<kbd>P</kbd> pour la vue présentateur avec vos notes.
Avant de proposer votre travail, lancez le build complet :
```bash
npm run build
```
Il vérifie votre fiche et vos images, et dit précisément ce qui ne va pas :
```
ÉCHEC du build — 1 erreur(s) dans les sources :
- slides/mon-sujet/slides.md : « niveau » invalide : « expert » — attendu debutant | intermediaire | confirme
```
Le site complet est alors dans `public/` : ouvrez `public/index.html` pour voir votre
présentation dans la liste. Si elle n'y est pas, c'est qu'il reste `brouillon: true`.
> Vous utilisez VS Code ou VSCodium ? L'extension *Marp for VS Code* affiche l'aperçu
> à côté du texte. Indiquez-lui le thème dans les réglages
> (`markdown.marp.themes` : `./theme/alpinux.css`).
---
## 4. Ouvrir une pull request
Une *pull request* est une proposition de modification : vous ne touchez pas
directement au site, un mainteneur relit d'abord.
1. Créez un compte sur <https://gitea.alpinux.org> si vous n'en avez pas.
2. Sur la page du dépôt `alpinux-presentations`, cliquez sur **Bifurcation** (*Fork*) :
vous obtenez votre copie du dépôt.
3. Récupérez-la, et créez une branche pour votre présentation :
```bash
git clone https://gitea.alpinux.org/VOTRE-COMPTE/alpinux-presentations.git
cd alpinux-presentations
git switch -c mon-sujet
```
4. Faites votre travail (étapes 1 à 3), puis enregistrez-le et envoyez-le :
```bash
git add slides/mon-sujet
git commit -m "Ajouter la présentation « Mon sujet »"
git push origin mon-sujet
```
5. Retournez sur la forge : un bandeau propose **Nouvelle demande d'ajout**. Validez,
en visant la branche `main` du dépôt d'Alpinux.
Un mainteneur relit, vous répond dans la pull request si quelque chose est à
reprendre, puis fusionne. Le site est à jour dans la minute qui suit.
Le guide [Git chez Alpinux](https://wiki.alpinux.org/guides/git-alpinux/) du wiki
détaille chacune de ces étapes.
### La méthode sans rien installer
Tout peut se faire depuis le navigateur, sur <https://gitea.alpinux.org> :
1. Faites une **bifurcation** du dépôt.
2. Dans votre copie, ouvrez `slides/_modele/slides.md`, copiez son contenu.
3. **Ajouter un fichier → Nouveau fichier**, nommez-le `slides/mon-sujet/slides.md`
(taper les `/` crée les dossiers), collez, adaptez.
4. Pour les images : **Ajouter un fichier → Téléverser**, dans
`slides/mon-sujet/images/`.
5. En bas de la page, choisissez « Créer une nouvelle branche », puis ouvrez la
**demande d'ajout**.
Vous n'aurez pas d'aperçu : dites-le dans la pull request, un mainteneur vérifiera le
rendu pour vous.
---
## Corriger une présentation existante
Une faute, un lien mort, une commande qui a changé ? Même chemin : modifiez le
`slides.md` concerné et ouvrez une pull request. Sur la forge, le crayon en haut du
fichier suffit pour une petite correction.

226
README.md Normal file
View file

@ -0,0 +1,226 @@
# Présentations d'Alpinux
Les diaporamas des réunions d'[Alpinux](https://alpinux.org), le groupe d'utilisateurs
de logiciels libres de Savoie — publiés sur <https://presentations.alpinux.org> et
projetés depuis ce site le soir de la réunion.
Chaque présentation est un fichier Markdown. [Marp](https://marp.app) en fait un
diaporama HTML autonome, et le build génère la page d'accueil qui les liste toutes.
**Vous voulez proposer une présentation ?** Tout est dans
[CONTRIBUER.md](CONTRIBUER.md).
---
## Je veux…
| Ce que vous voulez faire | Ce que vous faites |
|-------------------------------------------|-----------------------------------------------------------------|
| Proposer une présentation | Suivre [CONTRIBUER.md](CONTRIBUER.md) |
| Voir mes diapos pendant que j'écris | `npm run dev`, puis <http://localhost:8080> |
| Vérifier que tout est bon avant de pousser | `npm run build` |
| Obtenir un PDF d'un diaporama | `npm run pdf -- pdf-signer` |
| Mettre en ligne | Fusionner sur `main` — le déploiement suit, en une minute |
| Comprendre pourquoi ce n'est pas en ligne | `tail -30 /var/log/presentations-deploy.log` sur le serveur |
| Installer ou réparer le déploiement | Lire [deploy/webhook.md](deploy/webhook.md) |
---
## Installation
Il faut **Node.js 24 LTS** — c'est la version du serveur, indiquée dans `.nvmrc`. La
22 LTS convient aussi. Rien d'autre : ni Python, ni navigateur pour le build.
```bash
git clone git@gitea.alpinux.org:alpinux.cedrica5l/alpinux-presentations.git
cd alpinux-presentations
npm ci
```
Deux dépendances, aux versions figées dans `package.json` et `package-lock.json` :
- [`@marp-team/marp-cli`](https://github.com/marp-team/marp-cli) — convertit le
Markdown en diaporama ;
- [`js-yaml`](https://github.com/nodeca/js-yaml) — lit la fiche de chaque
présentation. Marp l'embarque déjà : elle n'ajoute rien à l'installation.
---
## Commandes
### `npm run dev` — aperçu local
Sert `slides/` sur <http://localhost:8080> avec le thème commun, et recharge la page à
chaque enregistrement. Les brouillons et le modèle y sont visibles.
### `npm run build` — build complet
```bash
npm run build # vers public/
npm run build -- /un/chemin # vers un autre répertoire
```
1. Parcourt `slides/*/slides.md`, en ignorant les dossiers qui commencent par `_` et
les présentations marquées `brouillon: true`.
2. Valide la fiche de chacune.
3. Génère `<sortie>/<identifiant>/index.html` et copie son dossier `images/`.
4. Génère `<sortie>/index.html`, la page d'accueil.
5. Génère `<sortie>/presentations.json` et `<sortie>/.htaccess`.
Le build est **strict**, comme `mkdocs build --strict` pour le wiki. Il échoue, avec un
message qui nomme le fichier et le défaut, sur :
- un champ obligatoire manquant, une valeur invalide, un champ inconnu (une faute de
frappe sur `brouillon` publierait sinon un diaporama inachevé) ;
- une image introuvable, ou rangée ailleurs que dans `images/` ;
- une ressource chargée depuis un autre site — image, police, script, feuille de style.
En cas d'échec, le répertoire de sortie n'est pas touché : tout est construit à part,
puis mis en place d'un seul coup.
### `npm run pdf -- <identifiant>` — export PDF
```bash
npm run pdf -- pdf-signer # écrit public/pdf-signer/pdf-signer.pdf
```
À lancer sur un **poste de travail** : Marp pilote un navigateur installé (Chromium,
Chrome ou Firefox) pour imprimer le diaporama, et le serveur n'en a pas. Le PDF n'est
ni versionné ni publié par le déploiement.
---
## Organisation du dépôt
```
alpinux-presentations/
├── README.md
├── CONTRIBUER.md le guide du contributeur
├── package.json dépendances, versions figées
├── theme/alpinux.css le thème Marp commun
├── slides/
│ ├── _modele/ le modèle à copier — jamais publié
│ └── pdf-signer/
│ ├── slides.md le diaporama
│ └── images/ ses images
├── scripts/
│ ├── build.mjs le build
│ └── deploy-presentations.sh ce que le serveur exécute après un push
├── deploy/ la chaîne de déploiement, versionnée avec le site
│ ├── webhook.md mise en place côté serveur
│ ├── installer.sh pose tout sur le serveur (root)
│ ├── webhook.py service d'écoute, 127.0.0.1:9878
│ ├── presentations-webhook.service
│ └── apache-vhost.exemple.conf
└── public/ sortie locale du build, ignorée par git
```
---
## La fiche d'une présentation
Chaque `slides.md` commence par un front matter : les réglages de Marp, puis la fiche.
```yaml
---
marp: true
theme: alpinux
paginate: true
title: Lire, remplir et signer ses PDF
description: Remplir, signer, assembler et alléger un dossier PDF sous Linux Mint.
auteur: Alpinux
date: 2026-10-15 # date de la séance
niveau: debutant # debutant | intermediaire | confirme
format: court # eclair | court | long
tags: [pdf, bureautique]
wiki: https://wiki.alpinux.org/… # facultatif
brouillon: true # facultatif : exclut de la publication
---
```
---
## Le thème
`theme/alpinux.css` est pensé pour la vidéoprojection : gros corps de texte, fort
contraste, logo et numéro de page en pied de diapo. Les polices sont celles du
système ; rien n'est téléchargé.
Deux classes, à poser sur une diapo avec `<!-- _class: nom -->` :
| Classe | Usage |
|---------|--------------------------------------------------------------|
| `titre` | La diapo d'ouverture, les intercalaires, la diapo de fin |
| `demo` | L'annonce d'une démonstration en direct |
Les citations (`> …`) servent d'encadrés « à retenir » ou « piège », et `<kbd>` dessine
une touche de clavier.
---
## Ce que le build publie pour alpinux.org
`presentations.json`, à la racine du site, liste toutes les présentations publiées, de
la plus récente à la plus ancienne :
```json
{
"genere": "2026-10-10T19:39:31.061Z",
"site": "https://presentations.alpinux.org/",
"presentations": [
{
"id": "pdf-signer",
"titre": "Lire, remplir et signer ses PDF",
"description": "…",
"auteur": "Alpinux",
"date": "2026-10-15",
"niveau": "debutant",
"format": "court",
"tags": ["pdf", "bureautique"],
"url": "https://presentations.alpinux.org/pdf-signer/",
"wiki": null
}
]
}
```
Le `.htaccess` généré pose `Access-Control-Allow-Origin` sur **ce seul fichier**, pour
qu'alpinux.org puisse le lire depuis le navigateur du visiteur — comme le wiki le fait
avec `derniers-articles.json`.
---
## Vie privée et autonomie
Aucun traceur, aucune police ni aucun script chargés depuis un service tiers. Une fois
la page chargée, un diaporama fonctionne **sans réseau** : son style et son code sont
dans le fichier HTML, ses images à côté.
La seule ressource prise ailleurs est le logo, servi par `static.alpinux.org` — un
serveur de l'association. Le build refuse toute autre adresse externe.
---
## Déploiement
```
git push (sur main)
│
Gitea ── webhook ──▶ https://presentations.alpinux.org/deploy
│ service d'écoute, signature HMAC vérifiée
▼
deploy-presentations.sh : git pull → npm ci → build dans un staging → rsync
▼
https://presentations.alpinux.org
```
Les contributions arrivent par pull request sur `main`. À la fusion, le serveur
construit le site dans un répertoire de staging et ne le copie vers le DocumentRoot
que si le build a réussi : une fiche invalide ne casse pas le site en ligne.
Le journal est dans `/var/log/presentations-deploy.log` ; une exécution réussie y est
encadrée par `Deploy started` et `Deploy done`.
L'installation côté serveur, la déclaration du webhook et les pièges à connaître sont
décrits dans [deploy/webhook.md](deploy/webhook.md).

View file

@ -0,0 +1,23 @@
# Relais Apache du webhook de presentations.alpinux.org.
#
# Deux lignes, à placer dans le VirtualHost *:443 du site. Elles transmettent
# https://presentations.alpinux.org/deploy au service d'écoute, qui ne répond que sur
# la boucle locale (voir webhook.py et presentations-webhook.service).
#
# Où les écrire : dans ISPConfig, Sites → presentations.alpinux.org → Options →
# « Directives Apache ». ISPConfig régénère le fichier
# /etc/apache2/sites-available/presentations.alpinux.org.vhost à chaque modification
# du site : une ligne ajoutée à la main dans ce fichier disparaît ce jour-là, alors
# qu'une directive saisie dans ISPConfig est réécrite avec le reste.
#
# installer.sh les pose directement dans le vhost pour que le webhook marche tout de
# suite ; les recopier dans ISPConfig les rend durables.
#
# Modules requis : proxy et proxy_http (déjà actifs sur owni, le wiki s'en sert).
ProxyPass /deploy http://127.0.0.1:9878/deploy
ProxyPassReverse /deploy http://127.0.0.1:9878/deploy
# Le reste du site n'a besoin de rien ici : le .htaccess généré par le build pose
# l'en-tête Access-Control-Allow-Origin sur presentations.json, ce qui suppose
# « AllowOverride All » (réglage par défaut d'ISPConfig) et le module headers.

161
deploy/installer.sh Executable file
View file

@ -0,0 +1,161 @@
#!/bin/bash
# Installe la chaîne de déploiement de presentations.alpinux.org sur owni. À lancer en
# root — relancer ne casse rien, chaque étape vérifie d'abord ce qui existe.
#
# sudo bash deploy/installer.sh
#
# Ce que ça met en place : Node.js dans /opt/presentations-alpinux/node, une clé de
# déploiement en lecture seule, un clone du dépôt, le service d'écoute du webhook sur
# 127.0.0.1:9878, le relais Apache /deploy, et le droit d'écriture de l'utilisateur du
# service sur le DocumentRoot.
#
# Le script s'arrête à l'étape 3 tant que la clé de déploiement n'est pas déclarée
# dans Gitea : la déclarer, puis relancer.
set -euo pipefail
BASE="/opt/presentations-alpinux"
REPO_DIR="$BASE/repo"
UTILISATEUR="abonnelc"
CIBLE="/var/www/clients/client1/web22/web"
VHOST="/etc/apache2/sites-available/presentations.alpinux.org.vhost"
SECRET_FICHIER="/etc/default/presentations-webhook"
JOURNAL="/var/log/presentations-deploy.log"
UNITE="/etc/systemd/system/presentations-webhook.service"
PORT="9878"
# Node.js : la version LTS, figée. Pour en changer, modifier ce numéro et relancer.
NODE_VERSION="v24.21.0"
NODE_ARCHIVE="node-$NODE_VERSION-linux-x64.tar.xz"
NODE_URL="https://nodejs.org/dist/$NODE_VERSION"
# Le dépôt est tiré par SSH, avec une clé qui ne sert qu'à lui.
CLE="/home/$UTILISATEUR/.ssh/id_ed25519_presentations_deploy"
ALIAS_SSH="gitea-presentations"
DEPOT_URL="$ALIAS_SSH:alpinux.cedrica5l/alpinux-presentations.git"
SOURCE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
[ "$(id -u)" -eq 0 ] || { echo "À lancer en root : sudo bash $0" >&2; exit 1; }
echo "==> 1/7 Node.js $NODE_VERSION dans $BASE/node"
install -d -o "$UTILISATEUR" -g "$UTILISATEUR" "$BASE"
if [ -x "$BASE/node/bin/node" ] && [ "$("$BASE/node/bin/node" --version)" = "$NODE_VERSION" ]; then
echo " (déjà là)"
else
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/$NODE_ARCHIVE" "$NODE_URL/$NODE_ARCHIVE"
curl -fsSL -o "$tmp/SHASUMS256.txt" "$NODE_URL/SHASUMS256.txt"
(cd "$tmp" && grep " $NODE_ARCHIVE\$" SHASUMS256.txt | sha256sum -c -)
rm -rf "$BASE/node"
install -d "$BASE/node"
tar -xJf "$tmp/$NODE_ARCHIVE" -C "$BASE/node" --strip-components=1 --no-same-owner
rm -rf "$tmp"
echo " $("$BASE/node/bin/node" --version) installé"
fi
echo "==> 2/7 Clé de déploiement"
if [ ! -f "$CLE" ]; then
sudo -u "$UTILISATEUR" ssh-keygen -q -t ed25519 -N "" -C "deploy@presentations.alpinux.org" -f "$CLE"
echo " clé créée"
fi
CONFIG_SSH="/home/$UTILISATEUR/.ssh/config"
if ! grep -q "^Host $ALIAS_SSH\$" "$CONFIG_SSH" 2>/dev/null; then
sudo -u "$UTILISATEUR" tee -a "$CONFIG_SSH" > /dev/null <<EOF
Host $ALIAS_SSH
HostName alpinux.org
Port 3022
User git
IdentityFile $CLE
IdentitiesOnly yes
EOF
chmod 600 "$CONFIG_SSH"
echo " alias SSH « $ALIAS_SSH » ajouté"
fi
echo "==> 3/7 Dépôt dans $REPO_DIR"
if [ -d "$REPO_DIR/.git" ]; then
sudo -u "$UTILISATEUR" git -C "$REPO_DIR" pull --ff-only origin main
elif ! sudo -u "$UTILISATEUR" git clone "$DEPOT_URL" "$REPO_DIR"; then
echo
echo "Le serveur n'a pas pu cloner le dépôt. Déclarer cette clé publique dans Gitea :" >&2
echo " dépôt alpinux-presentations → Paramètres → Clés de déploiement → Ajouter" >&2
echo " (sans cocher l'accès en écriture)" >&2
echo
cat "$CLE.pub"
echo
echo "Puis relancer : sudo bash $0" >&2
exit 1
fi
echo "==> 4/7 Scripts et journal"
# Le serveur exécute ces copies, pas les fichiers du dépôt : une modification de
# deploy/ ou de scripts/deploy-presentations.sh ne prend effet qu'en relançant
# installer.sh. Un push ne peut donc pas changer ce que le service exécute.
install -o "$UTILISATEUR" -g "$UTILISATEUR" -m 755 "$SOURCE/scripts/deploy-presentations.sh" "$BASE/deploy-presentations.sh"
install -o "$UTILISATEUR" -g "$UTILISATEUR" -m 755 "$SOURCE/deploy/webhook.py" "$BASE/webhook.py"
touch "$JOURNAL" && chown "$UTILISATEUR":"$UTILISATEUR" "$JOURNAL"
echo "==> 5/7 Droit d'écriture sur $CIBLE"
# Le DocumentRoot appartient à web22 ; une ACL suffit à y laisser écrire le service,
# sans toucher au propriétaire ni aux droits qu'attend ISPConfig.
setfacl -m "u:$UTILISATEUR:rwx" "$CIBLE"
echo "==> 6/7 Secret du webhook et service systemd"
if [ -f "$SECRET_FICHIER" ]; then
echo " (secret déjà là, inchangé)"
else
(umask 077; printf 'PRESENTATIONS_WEBHOOK_SECRET=%s\n' "$(openssl rand -hex 24)" > "$SECRET_FICHIER")
fi
chmod 600 "$SECRET_FICHIER"
install -m 644 "$SOURCE/deploy/presentations-webhook.service" "$UNITE"
systemctl daemon-reload
systemctl enable --now presentations-webhook
systemctl restart presentations-webhook
systemctl is-active --quiet presentations-webhook && echo " service actif"
echo "==> 7/7 Relais Apache /deploy"
if grep -q "127.0.0.1:$PORT" "$VHOST"; then
echo " déjà relayé"
else
cp -a "$VHOST" "$VHOST.bak-$(date +%Y%m%d%H%M)"
awk -v port="$PORT" '
{ print }
!fait && /^[[:space:]]*<VirtualHost[^>]*:443>/ {
print "\t\tProxyPass /deploy http://127.0.0.1:" port "/deploy"
print "\t\tProxyPassReverse /deploy http://127.0.0.1:" port "/deploy"
fait = 1
}
' "$VHOST" > "$VHOST.tmp"
# Recopié dans le fichier plutôt que déplacé par-dessus : un « mv » remplacerait
# l'original par un fichier neuf, avec d'autres droits.
cat "$VHOST.tmp" > "$VHOST" && rm -f "$VHOST.tmp"
grep -q "127.0.0.1:$PORT" "$VHOST" || { echo " ÉCHEC : relais non posé dans $VHOST" >&2; exit 1; }
echo " relais ajouté"
fi
if apache2ctl configtest; then
systemctl reload apache2
else
echo "Configuration Apache invalide : rien n'a été rechargé." >&2
echo "La sauvegarde .bak-* est à côté du vhost." >&2
exit 1
fi
echo
echo "────────────────────────────────────────────────────────────"
echo "Reste à faire à la main :"
echo
echo "1. Déclarer le webhook dans Gitea"
echo " dépôt alpinux-presentations → Paramètres → Webhooks → Ajouter → Gitea"
echo " URL : https://presentations.alpinux.org/deploy"
echo " Méthode : POST, type de contenu : application/json"
echo " Secret : sudo cat $SECRET_FICHIER"
echo " Événement : Push seulement, filtre de branche : main"
echo
echo "2. Recopier le relais dans ISPConfig pour qu'il survive à une régénération"
echo " du vhost : Sites → presentations.alpinux.org → Options → Directives Apache"
echo " (les deux lignes de deploy/apache-vhost.exemple.conf)"
echo
echo "Première mise en ligne, sans attendre un push :"
echo " sudo -u $UTILISATEUR $BASE/deploy-presentations.sh; tail -5 $JOURNAL"
echo "────────────────────────────────────────────────────────────"

View file

@ -0,0 +1,17 @@
[Unit]
Description=presentations.alpinux.org webhook deploy server
After=network.target
[Service]
Type=simple
User=abonnelc
Group=abonnelc
WorkingDirectory=/opt/presentations-alpinux
ExecStart=/usr/bin/python3 /opt/presentations-alpinux/webhook.py
# Le secret vit hors du dépôt : ce fichier est écrit par installer.sh, en 0600.
EnvironmentFile=/etc/default/presentations-webhook
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

181
deploy/webhook.md Normal file
View file

@ -0,0 +1,181 @@
# Déploiement automatique : mise en place côté serveur
Cette page s'adresse aux mainteneurs qui ont un accès SSH à `alpinux.org`. Les
contributeurs n'ont rien à faire ici : leur travail s'arrête à la pull request.
La chaîne est la même que celle du wiki et de www.alpinux.org :
```
push sur main → webhook Gitea → https://presentations.alpinux.org/deploy
│ Apache relaie vers 127.0.0.1:9878
▼
service d'écoute (signature HMAC vérifiée)
▼
deploy-presentations.sh : git pull → npm ci → build dans le staging → rsync
▼
https://presentations.alpinux.org
```
Si le build échoue — front matter invalide, image manquante — rien n'est copié : le
site en ligne reste celui du dernier build réussi.
---
## Un second service plutôt qu'une nouvelle route
Le service d'écoute du wiki (`/opt/wiki-mkdocs/webhook.py`) ne connaît qu'un secret et
qu'un script. Y ajouter une route aurait demandé de le réécrire, de faire cohabiter
deux secrets dans la même unité systemd, et de redémarrer le déploiement du wiki à
chaque retouche de celui des présentations.
Ce dépôt apporte donc **son propre service**, comme www.alpinux.org l'a fait avant
lui. Les trois sont des copies du même petit programme :
| Site | Service systemd | Port | Secret |
|----------------|--------------------------|-------|-----------------------------------------|
| wiki | `wiki-webhook` | 9876 | dans l'unité systemd |
| www | `www-webhook` | 9877 | `/etc/default/www-webhook` |
| présentations | `presentations-webhook` | 9878 | `/etc/default/presentations-webhook` |
Chaque dépôt a **son** secret : celui des présentations n'ouvre pas le déploiement du
wiki, et inversement.
---
## Ce qui est installé, et où
| Quoi | Où |
|-----------------------------------|----------------------------------------------------------|
| Clone du dépôt | `/opt/presentations-alpinux/repo` |
| Répertoire de staging | `/opt/presentations-alpinux/staging` |
| Node.js (LTS, version figée) | `/opt/presentations-alpinux/node` |
| Script de déploiement (copie) | `/opt/presentations-alpinux/deploy-presentations.sh` |
| Service d'écoute (copie) | `/opt/presentations-alpinux/webhook.py` |
| Unité systemd | `/etc/systemd/system/presentations-webhook.service` |
| Secret HMAC (`0600`, root) | `/etc/default/presentations-webhook` |
| Clé de déploiement, lecture seule | `~abonnelc/.ssh/id_ed25519_presentations_deploy` |
| DocumentRoot | `/var/www/clients/client1/web22/web` |
| Journal | `/var/log/presentations-deploy.log` |
Fichiers fournis par ce dossier : [`installer.sh`](installer.sh),
[`webhook.py`](webhook.py), [`presentations-webhook.service`](presentations-webhook.service),
[`apache-vhost.exemple.conf`](apache-vhost.exemple.conf), et
[`../scripts/deploy-presentations.sh`](../scripts/deploy-presentations.sh).
**Aucun secret n'est dans le dépôt.** Le secret du webhook est tiré au hasard sur le
serveur par `installer.sh` ; la clé privée de déploiement y est créée et n'en sort pas.
---
## Installation
Tout tient dans `installer.sh`, à lancer en root. Il est sans danger de le relancer :
chaque étape vérifie d'abord ce qui existe.
```bash
sudo bash deploy/installer.sh
```
1. **Node.js** — téléchargé depuis nodejs.org, somme SHA-256 vérifiée, déposé dans
`/opt/presentations-alpinux/node`. Rien n'est ajouté aux dépôts apt du serveur, et
pas de Chromium : le build HTML de Marp n'en a pas besoin.
2. **Clé de déploiement** — une clé SSH propre à ce dépôt, et un alias
`gitea-presentations` dans `~abonnelc/.ssh/config`.
3. **Clone du dépôt.** La première fois, le script s'arrête ici et affiche la clé
publique : la déclarer dans Gitea, *Paramètres → Clés de déploiement* du dépôt,
**sans** l'accès en écriture, puis relancer.
4. **Scripts** — copie de `deploy-presentations.sh` et de `webhook.py` dans
`/opt/presentations-alpinux`, création du journal.
5. **Droit d'écriture** sur le DocumentRoot, par une ACL pour `abonnelc`.
6. **Secret et service** — secret tiré au hasard s'il n'existe pas, unité systemd
installée et démarrée.
7. **Relais Apache** — les deux lignes `ProxyPass` dans le vhost, puis rechargement
d'Apache si la configuration est valide.
### Déclarer le webhook dans Gitea
Dans le dépôt `alpinux-presentations` : *Paramètres → Webhooks → Ajouter un webhook →
Gitea*.
| Champ | Valeur |
|---------------------|-----------------------------------------------------|
| URL cible | `https://presentations.alpinux.org/deploy` |
| Méthode HTTP | `POST` |
| Type de contenu | `application/json` |
| Secret | la valeur lue par `sudo cat /etc/default/presentations-webhook` |
| Déclencheur | *Push* seulement |
| Filtre de branche | `main` |
Le filtre de branche évite des appels inutiles ; le service ignore de toute façon tout
ce qui ne vise pas `refs/heads/main`.
### Rendre le relais Apache durable
Le vhost est écrit par ISPConfig, qui le **régénère** à chaque modification du site
(certificat, alias, version de PHP…). Les deux lignes posées par `installer.sh`
disparaissent ce jour-là.
Pour qu'elles tiennent : dans ISPConfig, *Sites → presentations.alpinux.org → Options →
Directives Apache*, coller les deux lignes de
[`apache-vhost.exemple.conf`](apache-vhost.exemple.conf). ISPConfig les réécrira alors
avec le reste du vhost.
---
## Vérifier
```bash
# Le service tourne et n'écoute que sur la boucle locale
systemctl status presentations-webhook
ss -ltn | grep 9878
# Sans signature valable, le relais répond 403 — c'est la bonne réponse
curl -s -o /dev/null -w '%{http_code}\n' -X POST -d '{}' https://presentations.alpinux.org/deploy
# Le dernier déploiement
tail -20 /var/log/presentations-deploy.log
```
Une exécution réussie encadre le build par une ligne `Deploy started` et une ligne
`Deploy done`. Une ligne `ABANDON` signale un échec : la cause est juste au-dessus, et
le site en ligne n'a pas bougé.
Dans Gitea, la page du webhook liste les envois récents avec la réponse reçue, et son
bouton *Tester l'envoi* rejoue un push.
---
## Au quotidien
| Je veux… | Je fais… |
|--------------------------------------------|--------------------------------------------------------------------------|
| Savoir pourquoi ce n'est pas en ligne | `tail -30 /var/log/presentations-deploy.log` |
| Republier sans nouveau commit | `sudo -u abonnelc /opt/presentations-alpinux/deploy-presentations.sh` |
| Appliquer une modification de `deploy/` | `sudo bash /opt/presentations-alpinux/repo/deploy/installer.sh` |
| Changer de version de Node.js | modifier `NODE_VERSION` dans `installer.sh`, pousser, relancer `installer.sh` |
| Changer le secret | supprimer `/etc/default/presentations-webhook`, relancer `installer.sh`, mettre à jour le webhook dans Gitea |
---
## Les pièges à connaître
**Le serveur exécute des copies.** `deploy-presentations.sh` et `webhook.py` sont
copiés dans `/opt/presentations-alpinux` par `installer.sh`. Les modifier dans le dépôt
ne change rien tant que `installer.sh` n'a pas été relancé. C'est voulu : une pull
request fusionnée ne peut pas, à elle seule, changer ce que le service exécute.
**Mais le build, lui, exécute le dépôt.** `scripts/build.mjs` et les dépendances de
`package-lock.json` tournent sur le serveur à chaque push, sous le compte du service.
Une pull request qui touche à `scripts/`, à `package.json` ou à `package-lock.json` se
relit donc comme du code qui s'exécutera sur le serveur — pas comme une diapo.
**`rsync --delete` épargne deux dossiers.** `error/` et `stats/` appartiennent à
ISPConfig et sont exclus de la synchronisation. Tout autre fichier déposé à la main
dans le DocumentRoot est supprimé au déploiement suivant.
**Le droit d'écriture tient à une ACL.** Si ISPConfig réinitialise les droits du site,
le déploiement échoue sur une erreur de permission : relancer `installer.sh` la repose.
**Node.js ne se met pas à jour tout seul.** Il est installé hors d'apt. Suivre les
versions LTS sur <https://nodejs.org/fr/about/previous-releases> et changer
`NODE_VERSION` de temps en temps.

68
deploy/webhook.py Normal file
View file

@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Service d'écoute du webhook Gitea pour presentations.alpinux.org.
N'écoute que sur la boucle locale : c'est Apache qui lui transmet les requêtes reçues
sur https://presentations.alpinux.org/deploy. La signature HMAC envoyée par Gitea est
vérifiée avant de lancer quoi que ce soit, et seuls les push sur main déclenchent un
déploiement.
Calqué sur les services du wiki et de www (/opt/wiki-mkdocs/webhook.py,
/opt/www-alpinux/webhook.py), à trois différences près : le port, le script appelé,
et un secret qui lui est propre, lu dans un fichier d'environnement — ce dépôt est
public.
"""
import hashlib
import hmac
import http.server
import json
import os
import subprocess
import threading
SECRET = os.environ.get("PRESENTATIONS_WEBHOOK_SECRET", "")
DEPLOY_SCRIPT = "/opt/presentations-alpinux/deploy-presentations.sh"
LOG = "/var/log/presentations-deploy.log"
PORT = 9878
class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/deploy":
self.send_response(404); self.end_headers(); return
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
signature = self.headers.get("X-Gitea-Signature", "")
attendue = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
if not SECRET or not hmac.compare_digest(attendue, signature):
self.send_response(403); self.end_headers()
self.wfile.write(b"Forbidden"); return
try:
ref = json.loads(body).get("ref", "")
except (ValueError, AttributeError):
self.send_response(400); self.end_headers()
self.wfile.write(b"Bad request"); return
if ref != "refs/heads/main":
self.send_response(200); self.end_headers()
self.wfile.write(b"Skipped: not main"); return
# Déploiement en tâche de fond : Gitea a sa réponse tout de suite.
threading.Thread(
target=subprocess.run,
args=([DEPLOY_SCRIPT],),
kwargs={"capture_output": True},
daemon=True,
).start()
self.send_response(200); self.end_headers()
self.wfile.write(b"Deploy triggered")
def log_message(self, fmt, *args):
with open(LOG, "a") as journal:
journal.write(self.log_date_time_string() + " " + fmt % args + "\n")
if __name__ == "__main__":
http.server.HTTPServer(("127.0.0.1", PORT), Handler).serve_forever()

1818
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

19
package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "alpinux-presentations",
"version": "1.0.0",
"description": "Diaporamas des réunions d'Alpinux, écrits en Markdown et publiés sur presentations.alpinux.org",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"build": "node scripts/build.mjs",
"dev": "marp --server --theme-set theme/alpinux.css -- slides",
"pdf": "node scripts/build.mjs --pdf"
},
"dependencies": {
"@marp-team/marp-cli": "4.5.1",
"js-yaml": "5.4.3"
}
}

599
scripts/build.mjs Normal file
View file

@ -0,0 +1,599 @@
#!/usr/bin/env node
// Construit le site des présentations d'Alpinux.
//
// node scripts/build.mjs [sortie] build complet (sortie : public/ par défaut)
// node scripts/build.mjs --pdf <identifiant> [sortie]
// un PDF, sur un poste qui a un navigateur
//
// Le build est strict, comme « mkdocs build --strict » pour le wiki : un front matter
// incomplet, une image introuvable ou une ressource chargée depuis un autre site le
// font échouer avant d'avoir écrit quoi que ce soit dans le répertoire de sortie.
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { marpCli } from '@marp-team/marp-cli'
import { CORE_SCHEMA, load } from 'js-yaml'
const RACINE = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const SOURCES = join(RACINE, 'slides')
const THEME = join(RACINE, 'theme', 'alpinux.css')
const NOM_DU_THEME = 'alpinux'
// Adresse publique du site : elle ne sert qu'aux URL absolues de presentations.json.
const URL_SITE = (process.env.URL_SITE ?? 'https://presentations.alpinux.org').replace(/\/+$/, '')
const URL_LOGO = 'https://static.alpinux.org/logo/alpinux-logo-512.png'
// Seules adresses qu'une page générée a le droit de charger depuis un autre site.
const RESSOURCES_EXTERNES_ADMISES = new Set([URL_LOGO])
const NIVEAUX = { debutant: 'Débutant', intermediaire: 'Intermédiaire', confirme: 'Confirmé' }
const FORMATS = { eclair: 'Éclair', court: 'Court', long: 'Long' }
const CHAMPS_OBLIGATOIRES = ['title', 'description', 'auteur', 'date', 'niveau', 'format', 'tags']
const CHAMPS_FACULTATIFS = ['wiki', 'brouillon']
// Directives de Marp admises dans le front matter. Toute autre clé est refusée : une
// faute de frappe sur « brouillon » publierait sinon une présentation inachevée.
const DIRECTIVES_MARP = [
'marp', 'theme', 'paginate', 'header', 'footer', 'class', 'size', 'style', 'lang', 'math',
'headingDivider', 'transition', 'color', 'backgroundColor', 'backgroundImage',
'backgroundPosition', 'backgroundRepeat', 'backgroundSize',
]
const CLES_CONNUES = new Set([...CHAMPS_OBLIGATOIRES, ...CHAMPS_FACULTATIFS, ...DIRECTIVES_MARP])
const FORME_IDENTIFIANT = /^[a-z0-9]+(-[a-z0-9]+)*$/
class ErreurDeBuild extends Error {}
// ── Lecture et validation des sources ──────────────────────────────────────
function lireFrontMatter(texte) {
const trouve = /^?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(\r?\n|$)/.exec(texte)
if (!trouve) {
throw new ErreurDeBuild('pas de front matter : le fichier doit commencer par un bloc encadré de « --- »')
}
let donnees
try {
// CORE_SCHEMA : les dates restent des chaînes, telles qu'elles sont écrites.
donnees = load(trouve[1], { schema: CORE_SCHEMA })
} catch (erreur) {
throw new ErreurDeBuild(`front matter illisible (YAML) : ${erreur.reason ?? erreur.message}` +
(erreur.mark ? `, ligne ${erreur.mark.line + 2}` : ''))
}
if (donnees === null || typeof donnees !== 'object' || Array.isArray(donnees)) {
throw new ErreurDeBuild('front matter vide ou mal formé : une liste de « clé: valeur » est attendue')
}
return donnees
}
const estTexte = (valeur) => typeof valeur === 'string' && valeur.trim() !== ''
function estUneDate(valeur) {
if (typeof valeur !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(valeur)) return false
const date = new Date(`${valeur}T00:00:00Z`)
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === valeur
}
function estUneAdresseWeb(valeur) {
if (typeof valeur !== 'string') return false
try {
return ['http:', 'https:'].includes(new URL(valeur).protocol)
} catch {
return false
}
}
/** Renvoie la liste des erreurs du front matter — vide s'il est bon. */
function validerFrontMatter(fm) {
const erreurs = []
const choix = (valeurs) => Object.keys(valeurs).join(' | ')
for (const champ of CHAMPS_OBLIGATOIRES) {
if (fm[champ] === undefined || fm[champ] === null || fm[champ] === '') {
erreurs.push(`champ obligatoire manquant : « ${champ} »`)
}
}
for (const cle of Object.keys(fm)) {
if (!CLES_CONNUES.has(cle)) {
erreurs.push(`champ inconnu : « ${cle} » (faute de frappe ? champs admis : ${[...CHAMPS_OBLIGATOIRES, ...CHAMPS_FACULTATIFS].join(', ')})`)
}
}
const present = (champ) => fm[champ] !== undefined && fm[champ] !== null && fm[champ] !== ''
for (const champ of ['title', 'description', 'auteur']) {
if (present(champ) && !estTexte(fm[champ])) erreurs.push(`« ${champ} » doit être un texte`)
}
if (present('date') && !estUneDate(fm.date)) {
erreurs.push(`« date » invalide : « ${fm.date} » — attendu AAAA-MM-JJ, la date de la séance (exemple : 2026-10-15)`)
}
if (present('niveau') && !Object.hasOwn(NIVEAUX, fm.niveau)) {
erreurs.push(`« niveau » invalide : « ${fm.niveau} » — attendu ${choix(NIVEAUX)}`)
}
if (present('format') && !Object.hasOwn(FORMATS, fm.format)) {
erreurs.push(`« format » invalide : « ${fm.format} » — attendu ${choix(FORMATS)}`)
}
if (present('tags')) {
if (!Array.isArray(fm.tags) || fm.tags.length === 0 || !fm.tags.every(estTexte)) {
erreurs.push('« tags » doit être une liste d\'au moins un mot-clé (exemple : tags: [pdf, bureautique])')
}
}
if (present('wiki') && !estUneAdresseWeb(fm.wiki)) {
erreurs.push(`« wiki » invalide : « ${fm.wiki} » — attendu une adresse complète, en https://`)
}
if (fm.theme !== undefined && fm.theme !== NOM_DU_THEME) {
erreurs.push(`« theme » invalide : « ${fm.theme} » — seul le thème commun « ${NOM_DU_THEME} » est publié`)
}
return erreurs
}
/**
* Parcourt slides/ et renvoie les présentations à publier, de la plus récente à la
* plus ancienne. Lève une ErreurDeBuild qui détaille tous les défauts trouvés.
*/
function collecterLesPresentations() {
if (!existsSync(SOURCES)) throw new ErreurDeBuild(`répertoire introuvable : ${SOURCES}`)
const presentations = []
const brouillons = []
const defauts = []
const dossiers = readdirSync(SOURCES, { withFileTypes: true })
.filter((entree) => entree.isDirectory() && !entree.name.startsWith('_') && !entree.name.startsWith('.'))
.map((entree) => entree.name)
.sort()
for (const id of dossiers) {
const source = join(SOURCES, id, 'slides.md')
const chemin = relative(RACINE, source)
const signaler = (...messages) => defauts.push(...messages.map((message) => `${chemin} : ${message}`))
if (!FORME_IDENTIFIANT.test(id)) {
signaler(`nom de dossier « ${id} » invalide — minuscules sans accent, chiffres et tirets seulement (exemple : pdf-signer)`)
continue
}
if (!existsSync(source)) {
signaler('fichier absent — chaque dossier de slides/ doit contenir un slides.md')
continue
}
let fm
try {
fm = lireFrontMatter(readFileSync(source, 'utf8'))
} catch (erreur) {
if (!(erreur instanceof ErreurDeBuild)) throw erreur
signaler(erreur.message)
continue
}
if (fm.brouillon !== undefined && typeof fm.brouillon !== 'boolean') {
signaler(`« brouillon » invalide : « ${fm.brouillon} » — attendu true ou false`)
continue
}
if (fm.brouillon === true) {
brouillons.push(id)
continue
}
const erreurs = validerFrontMatter(fm)
if (erreurs.length > 0) {
signaler(...erreurs)
continue
}
presentations.push({
id,
source,
titre: fm.title.trim(),
description: fm.description.trim(),
auteur: fm.auteur.trim(),
date: fm.date,
niveau: fm.niveau,
format: fm.format,
tags: fm.tags.map((tag) => tag.trim()),
wiki: fm.wiki ?? null,
langue: typeof fm.lang === 'string' ? fm.lang : 'fr',
})
}
if (defauts.length > 0) {
throw new ErreurDeBuild(`${defauts.length} erreur(s) dans les sources :\n` + defauts.map((defaut) => ` - ${defaut}`).join('\n'))
}
presentations.sort((a, b) => b.date.localeCompare(a.date) || a.titre.localeCompare(b.titre, 'fr'))
return { presentations, brouillons }
}
// ── Diaporamas ─────────────────────────────────────────────────────────────
/** Lance Marp CLI en gardant sa sortie pour nous : elle n'est montrée qu'en cas d'échec. */
async function lancerMarp(arguments_) {
const journal = []
const originaux = { log: console.log, info: console.info, warn: console.warn, error: console.error }
for (const methode of Object.keys(originaux)) {
console[methode] = (...morceaux) => journal.push(morceaux.join(' '))
}
let code
try {
code = await marpCli(['--no-stdin', '--no-config-file', ...arguments_])
} catch (erreur) {
journal.push(String(erreur?.message ?? erreur))
code = 1
} finally {
Object.assign(console, originaux)
}
return { code, journal: journal.join('\n').trim() }
}
const adresses = (html, motif) => [...html.matchAll(motif)].map((trouve) => trouve[1].replaceAll('&amp;', '&'))
/**
* Vérifie ce que le diaporama généré charge : rien d'un autre site hormis le logo, et
* des images locales qui existent bien dans le dossier images/ de la présentation.
*/
function controlerLesRessources(html, presentation) {
const erreurs = []
const chargees = new Set([
...adresses(html, /<(?:img|script|source|video|audio|iframe|embed|track)\b[^>]*?\ssrc=["']([^"']+)["']/gi),
...adresses(html, /<link\b[^>]*?\shref=["']([^"']+)["']/gi),
...adresses(html, /url\(\s*(?:&quot;|["'])?([^"')&]+)/gi),
...adresses(html, /@import\s+(?:url\()?\s*(?:&quot;|["'])([^"')&]+)/gi),
])
for (const adresse of chargees) {
if (adresse.startsWith('data:') || adresse.startsWith('#')) continue
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(adresse)) {
if (!RESSOURCES_EXTERNES_ADMISES.has(adresse)) {
erreurs.push(`ressource externe interdite : ${adresse} — copiez le fichier dans images/ et citez-le par son chemin relatif`)
}
continue
}
const chemin = decodeURI(adresse.split(/[?#]/)[0])
if (!chemin.startsWith('images/') || chemin.split('/').includes('..')) {
erreurs.push(`fichier hors du dossier images/ : ${adresse} — seul images/ est publié avec le diaporama`)
} else if (!existsSync(join(dirname(presentation.source), chemin))) {
erreurs.push(`image introuvable : ${adresse}`)
}
}
return erreurs
}
async function genererLeDiaporama(presentation, sortie) {
const chemin = relative(RACINE, presentation.source)
const dossier = join(sortie, presentation.id)
const page = join(dossier, 'index.html')
mkdirSync(dossier, { recursive: true })
const { code, journal } = await lancerMarp([
presentation.source, '-o', page, '--theme-set', THEME, '--theme', NOM_DU_THEME,
])
if (code !== 0 || !existsSync(page)) {
throw new ErreurDeBuild(`${chemin} : Marp n'a pas pu générer le diaporama.\n${journal}`)
}
let html = readFileSync(page, 'utf8')
const erreurs = controlerLesRessources(html, presentation)
if (erreurs.length > 0) {
throw new ErreurDeBuild(erreurs.map((erreur) => `${chemin} : ${erreur}`).join('\n'))
}
// Marp écrit lang="en" faute de directive : le contenu est en français.
html = html.replace(/<html\b[^>]*>/i, (balise) => /\slang=/.test(balise)
? balise.replace(/\slang=["'][^"']*["']/, ` lang="${presentation.langue}"`)
: balise.replace(/<html/i, `<html lang="${presentation.langue}"`))
writeFileSync(page, html)
const images = join(dirname(presentation.source), 'images')
if (existsSync(images)) cpSync(images, join(dossier, 'images'), { recursive: true })
}
// ── Page d'accueil, JSON et .htaccess ──────────────────────────────────────
const echapper = (texte) => String(texte)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
.replaceAll('"', '&quot;').replaceAll("'", '&#39;')
/** Minuscules sans accents : « Débutant » et « debutant » se retrouvent. */
const normaliser = (texte) => texte.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
const dateLisible = (date) => new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long', timeZone: 'UTC' })
.format(new Date(`${date}T00:00:00Z`))
function carte(presentation) {
const p = presentation
const recherche = normaliser([p.titre, ...p.tags].join(' '))
const tags = p.tags.map((tag) => `<li>${echapper(tag)}</li>`).join('')
const lienWiki = p.wiki
? `\n <a class="lien" href="${echapper(p.wiki)}">Fiche sur le wiki<span class="lu-seulement"> : ${echapper(p.titre)}</span></a>`
: ''
return ` <li class="carte" data-niveau="${p.niveau}" data-format="${p.format}" data-recherche="${echapper(recherche)}">
<article>
<h2><a href="${p.id}/">${echapper(p.titre)}</a></h2>
<p class="meta"><time datetime="${p.date}">${dateLisible(p.date)}</time> · ${echapper(p.auteur)}</p>
<p class="description">${echapper(p.description)}</p>
<ul class="etiquettes" aria-label="Niveau, format et mots-clés">
<li class="niveau">${NIVEAUX[p.niveau]}</li><li class="format">${FORMATS[p.format]}</li>${tags}
</ul>
<a class="lien principal" href="${p.id}/">Ouvrir le diaporama<span class="lu-seulement"> : ${echapper(p.titre)}</span></a>${lienWiki}
</article>
</li>`
}
const options = (valeurs) => Object.entries(valeurs)
.map(([valeur, libelle]) => `<option value="${valeur}">${libelle}</option>`).join('')
function pageAccueil(presentations) {
const total = presentations.length
const compte = total === 0 ? 'Aucune présentation' : total === 1 ? '1 présentation' : `${total} présentations`
return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Présentations d'Alpinux</title>
<meta name="description" content="Les diaporamas des réunions d'Alpinux, le groupe d'utilisateurs de logiciels libres de Savoie.">
<meta name="color-scheme" content="light dark">
<link rel="icon" href="${URL_LOGO}">
<link rel="alternate" type="application/json" href="presentations.json" title="Liste des présentations">
<style>
:root {
--fond: #f3f6fb; --surface: #ffffff; --texte: #1a1a2e; --texte-doux: #4a5568;
--bleu: #1a6bbf; --bleu-fonce: #0f4e8f; --trait: #c9d6e8; --lien: #0f4e8f;
--pastille: #e4ecf7; --pastille-texte: #0b2e55; --focus: #b45309;
}
@media (prefers-color-scheme: dark) {
:root {
--fond: #0e1726; --surface: #172338; --texte: #eef3fa; --texte-doux: #b4c2d6;
--trait: #2c3e5a; --lien: #9fd0ff; --pastille: #24456e; --pastille-texte: #eef3fa;
--focus: #ffc857;
}
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0; min-height: 100vh; display: flex; flex-direction: column;
font-family: "Segoe UI", system-ui, -apple-system, "Noto Sans", "DejaVu Sans", sans-serif;
font-size: 1.05rem; line-height: 1.55; background: var(--fond); color: var(--texte);
}
a { color: var(--lien); }
:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; border-radius: 4px; }
.lu-seulement {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip-path: inset(50%); white-space: nowrap;
}
.evitement {
position: absolute; left: .5rem; top: -4rem; padding: .5rem .9rem; border-radius: 6px;
background: #fff; color: #0f4e8f; font-weight: 600; z-index: 1;
}
.evitement:focus { top: .5rem; }
header.bandeau { background: var(--bleu); color: #fff; padding: 1.6rem 1.2rem; }
.bandeau .cadre { display: flex; align-items: center; gap: 1rem; }
.bandeau img { width: 64px; height: 64px; border-radius: 10px; background: #fff; flex: none; }
.bandeau h1 { margin: 0; font-size: clamp(1.5rem, 5vw, 2.1rem); line-height: 1.1; letter-spacing: -.01em; }
.bandeau p { margin: .25rem 0 0; color: #e6f0fb; }
.cadre { width: 100%; max-width: 62rem; margin: 0 auto; }
main { flex: 1; padding: 1.4rem 1.2rem 2.5rem; }
form.filtres {
display: grid; grid-template-columns: 2fr 1fr 1fr; gap: .9rem; margin-bottom: 1rem;
padding: 1rem; background: var(--surface); border: 1px solid var(--trait); border-radius: 12px;
}
.filtres label { display: block; font-size: .9rem; font-weight: 600; margin-bottom: .25rem; }
.filtres input, .filtres select {
width: 100%; min-height: 2.75rem; padding: .45rem .7rem; font: inherit;
color: var(--texte); background: var(--fond); border: 2px solid var(--trait); border-radius: 8px;
}
.compte { margin: 0 0 1rem; color: var(--texte-doux); }
ul.cartes { list-style: none; margin: 0; padding: 0; display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr)); }
.carte article {
height: 100%; display: flex; flex-direction: column; gap: .55rem; padding: 1.1rem 1.2rem 1.2rem;
background: var(--surface); border: 1px solid var(--trait); border-top: 5px solid var(--bleu); border-radius: 12px;
}
.carte h2 { margin: 0; font-size: 1.25rem; line-height: 1.25; }
.carte h2 a { text-decoration: none; }
.carte h2 a:hover { text-decoration: underline; }
.carte p { margin: 0; }
.meta { color: var(--texte-doux); font-size: .92rem; }
.description { flex: 1; }
.etiquettes { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: .35rem; font-size: .85rem; }
.etiquettes li { padding: .1rem .6rem; border-radius: 999px; background: var(--pastille); color: var(--pastille-texte); }
.etiquettes .niveau, .etiquettes .format { background: var(--bleu-fonce); color: #fff; font-weight: 600; }
.lien { font-weight: 600; }
.lien.principal {
align-self: flex-start; margin-top: .3rem; padding: .5rem 1rem; border-radius: 8px;
background: var(--bleu-fonce); color: #fff; text-decoration: none;
}
.lien.principal:hover { background: var(--bleu); }
.vide { padding: 2rem 1rem; text-align: center; background: var(--surface); border: 1px dashed var(--trait); border-radius: 12px; }
footer.pied { padding: 1.2rem; border-top: 1px solid var(--trait); color: var(--texte-doux); font-size: .92rem; text-align: center; }
[hidden] { display: none !important; }
@media (max-width: 40rem) { form.filtres { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<a class="evitement" href="#liste">Aller à la liste des présentations</a>
<header class="bandeau">
<div class="cadre">
<img src="${URL_LOGO}" alt="" width="64" height="64">
<div>
<h1>Présentations d'Alpinux</h1>
<p>Les diaporamas des réunions du groupe d'utilisateurs de logiciels libres de Savoie.</p>
</div>
</div>
</header>
<main>
<div class="cadre">
<form class="filtres" id="filtres" role="search" aria-label="Filtrer les présentations" hidden>
<div>
<label for="recherche">Rechercher dans les titres et les mots-clés</label>
<input type="search" id="recherche" autocomplete="off" spellcheck="false">
</div>
<div>
<label for="niveau">Niveau</label>
<select id="niveau"><option value="">Tous les niveaux</option>${options(NIVEAUX)}</select>
</div>
<div>
<label for="format">Format</label>
<select id="format"><option value="">Tous les formats</option>${options(FORMATS)}</select>
</div>
</form>
<p class="compte" id="compte" role="status">${compte}, de la plus récente à la plus ancienne.</p>
<ul class="cartes" id="liste" tabindex="-1">
${presentations.map(carte).join('\n')}
</ul>
<p class="vide" id="vide"${total === 0 ? '' : ' hidden'}>${total === 0 ? 'Aucune présentation n\'est encore publiée.' : 'Aucune présentation ne correspond à ces critères.'}</p>
</div>
</main>
<footer class="pied">
<a href="https://alpinux.org">alpinux.org</a> ·
<a href="https://wiki.alpinux.org">Le wiki</a> ·
<a href="https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-presentations">Proposer une présentation</a>
</footer>
<script>
// Filtres de la liste. Sans JavaScript, la page reste complète : le formulaire est
// simplement masqué.
(function () {
var formulaire = document.getElementById('filtres');
var cartes = Array.prototype.slice.call(document.querySelectorAll('.carte'));
if (!formulaire || cartes.length === 0) return;
var recherche = document.getElementById('recherche');
var niveau = document.getElementById('niveau');
var format = document.getElementById('format');
var compte = document.getElementById('compte');
var vide = document.getElementById('vide');
function normaliser(texte) {
return texte.normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase();
}
function filtrer() {
var mots = normaliser(recherche.value).split(/\\s+/).filter(Boolean);
var visibles = 0;
cartes.forEach(function (carte) {
var garde = (!niveau.value || carte.dataset.niveau === niveau.value)
&& (!format.value || carte.dataset.format === format.value)
&& mots.every(function (mot) { return carte.dataset.recherche.indexOf(mot) !== -1; });
carte.hidden = !garde;
if (garde) visibles += 1;
});
vide.hidden = visibles !== 0;
compte.textContent = visibles === cartes.length
? (visibles === 1 ? '1 présentation' : visibles + ' présentations') + ', de la plus récente à la plus ancienne.'
: visibles + ' sur ' + cartes.length + (visibles > 1 ? ' affichées.' : ' affichée.');
}
formulaire.hidden = false;
formulaire.addEventListener('submit', function (evenement) { evenement.preventDefault(); });
formulaire.addEventListener('input', filtrer);
formulaire.addEventListener('change', filtrer);
})();
</script>
</body>
</html>
`
}
function fichierJson(presentations) {
return JSON.stringify({
genere: new Date().toISOString(),
site: `${URL_SITE}/`,
presentations: presentations.map((p) => ({
id: p.id,
titre: p.titre,
description: p.description,
auteur: p.auteur,
date: p.date,
niveau: p.niveau,
format: p.format,
tags: p.tags,
url: `${URL_SITE}/${p.id}/`,
wiki: p.wiki,
})),
}, null, 2) + '\n'
}
const HTACCESS = `# Généré par scripts/build.mjs — ne pas modifier sur le serveur, le prochain
# déploiement l'écraserait.
AddDefaultCharset utf-8
# presentations.json est lu par alpinux.org depuis le navigateur du visiteur : sans cet
# en-tête, un autre domaine n'a pas le droit de le lire. Rien d'autre n'est ouvert.
<IfModule mod_headers.c>
<Files "presentations.json">
Header set Access-Control-Allow-Origin "*"
</Files>
</IfModule>
`
// ── Enchaînement ───────────────────────────────────────────────────────────
/** Refuse un répertoire de sortie dont le nettoyage emporterait les sources. */
function verifierLaSortie(sortie) {
const contient = (parent, enfant) => enfant === parent || enfant.startsWith(parent + sep)
if (contient(sortie, RACINE) || ['slides', 'theme', 'scripts', 'deploy', 'node_modules', '.git']
.some((dossier) => contient(join(RACINE, dossier), sortie))) {
throw new ErreurDeBuild(`répertoire de sortie refusé : ${sortie} — il serait vidé alors qu'il contient des sources`)
}
}
async function construire(sortie) {
verifierLaSortie(sortie)
const { presentations, brouillons } = collecterLesPresentations()
for (const id of brouillons) console.log(` (brouillon, non publié) ${id}`)
// Tout est généré à part, puis mis en place d'un coup : un échec en cours de route
// laisse le répertoire de sortie tel qu'il était.
mkdirSync(dirname(sortie), { recursive: true })
const chantier = mkdtempSync(join(dirname(sortie), '.build-'))
chmodSync(chantier, 0o755)
try {
for (const presentation of presentations) {
await genererLeDiaporama(presentation, chantier)
console.log(` diaporama ${presentation.id}/index.html`)
}
writeFileSync(join(chantier, 'index.html'), pageAccueil(presentations))
writeFileSync(join(chantier, 'presentations.json'), fichierJson(presentations))
writeFileSync(join(chantier, '.htaccess'), HTACCESS)
console.log(' accueil index.html\n données presentations.json\n serveur .htaccess')
rmSync(sortie, { recursive: true, force: true })
renameSync(chantier, sortie)
} finally {
rmSync(chantier, { recursive: true, force: true })
}
console.log(`Build réussi : ${presentations.length} présentation(s) dans ${relative(process.cwd(), sortie) || '.'}/`)
}
async function exporterEnPdf(id, sortie) {
if (!id) throw new ErreurDeBuild('identifiant manquant — usage : npm run pdf -- <identifiant>')
const source = join(SOURCES, id, 'slides.md')
if (!FORME_IDENTIFIANT.test(id) && id !== '_modele' || !existsSync(source)) {
throw new ErreurDeBuild(`présentation introuvable : slides/${id}/slides.md`)
}
const pdf = join(sortie, id, `${id}.pdf`)
mkdirSync(dirname(pdf), { recursive: true })
// Marp pilote un navigateur déjà installé (Chromium, Chrome, Edge ou Firefox) pour
// imprimer le diaporama : c'est pourquoi cet export ne se fait pas sur le serveur.
const { code, journal } = await lancerMarp([
source, '-o', pdf, '--pdf', '--allow-local-files', '--theme-set', THEME, '--theme', NOM_DU_THEME,
])
if (code !== 0 || !existsSync(pdf)) {
throw new ErreurDeBuild(`l'export PDF de « ${id} » a échoué. Un navigateur (Chromium ou Firefox) ` +
`doit être installé sur ce poste.\n${journal}`)
}
console.log(`PDF généré : ${relative(process.cwd(), pdf)}`)
}
const args = process.argv.slice(2)
try {
if (args[0] === '--pdf') {
await exporterEnPdf(args[1], resolve(args[2] ?? join(RACINE, 'public')))
} else if (args[0]?.startsWith('-')) {
throw new ErreurDeBuild(`option inconnue : ${args[0]} — usage : node scripts/build.mjs [sortie] | --pdf <identifiant> [sortie]`)
} else {
await construire(resolve(args[0] ?? join(RACINE, 'public')))
}
} catch (erreur) {
if (!(erreur instanceof ErreurDeBuild)) throw erreur
console.error(`\nÉCHEC du build — ${erreur.message}\n`)
process.exit(1)
}

40
scripts/deploy-presentations.sh Executable file
View file

@ -0,0 +1,40 @@
#!/bin/bash
# Met en ligne presentations.alpinux.org depuis le dépôt alpinux-presentations.
# Appelé par le service webhook après chaque push sur main, ou à la main en secours.
# Calqué sur deploy-wiki.sh : le serveur exécute sa copie, posée par deploy/installer.sh.
set -e
REPO_DIR="/opt/presentations-alpinux/repo"
STAGING="/opt/presentations-alpinux/staging"
TARGET="/var/www/clients/client1/web22/web"
NODE_BIN="/opt/presentations-alpinux/node/bin"
LOG="/var/log/presentations-deploy.log"
export PATH="$NODE_BIN:$PATH"
# Deux push rapprochés : le second déploiement attend la fin du premier au lieu de
# construire dans le même staging.
exec 9> "/tmp/presentations-deploy.lock"
flock 9
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy started" >> "$LOG"
trap 'echo "[$(date "+%Y-%m-%d %H:%M:%S")] ABANDON : le site en ligne est inchangé" >> "$LOG"' ERR
cd "$REPO_DIR"
git pull origin main >> "$LOG" 2>&1
# --ignore-scripts : aucune dépendance n'a besoin d'exécuter du code à l'installation,
# et aucune n'en a donc le droit.
npm ci --ignore-scripts --no-audit --no-fund >> "$LOG" 2>&1
# Build dans un répertoire de staging : le site en ligne reste intact si le build
# échoue (front matter invalide, image manquante, ressource externe).
node scripts/build.mjs "$STAGING" >> "$LOG" 2>&1
# Bascule vers le DocumentRoot une fois le build valide. Les deux dossiers exclus
# appartiennent à ISPConfig (pages d'erreur, statistiques) : --delete les épargne.
rsync -rltD --no-perms --omit-dir-times --delete \
--exclude=/error/ --exclude=/stats/ \
"$STAGING/" "$TARGET/" >> "$LOG" 2>&1
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy done" >> "$LOG"

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 200" width="640" height="200" role="img" aria-label="Image d'exemple">
<rect width="640" height="200" rx="16" fill="#f3f6fb" stroke="#1a6bbf" stroke-width="4"/>
<text x="320" y="112" text-anchor="middle" font-family="sans-serif" font-size="34" fill="#0f4e8f">images/exemple.svg</text>
</svg>

After

Width:  |  Height:  |  Size: 353 B

126
slides/_modele/slides.md Normal file
View file

@ -0,0 +1,126 @@
---
# ── Réglages de Marp : à laisser tels quels ────────────────────────────────
marp: true
theme: alpinux
paginate: true
# ── Fiche de la présentation : à remplir ───────────────────────────────────
# Tous ces champs sont obligatoires, sauf « wiki » et « brouillon ».
# Le build refuse un champ manquant, mal orthographié ou une valeur inattendue.
title: Le titre de votre présentation
description: Une phrase qui dit ce qu'on saura faire en sortant de la séance.
auteur: Prénom Nom
date: 2026-01-15 # date de la séance, au format AAAA-MM-JJ
niveau: debutant # debutant | intermediaire | confirme
format: court # eclair | court | long
tags: [exemple, modele] # mots-clés, en minuscules ; ils servent à la recherche
# Adresse de la fiche correspondante sur le wiki — supprimez la ligne s'il n'y en a pas.
wiki: https://wiki.alpinux.org/
# Tant que cette ligne vaut « true », la présentation n'est pas publiée.
# Supprimez-la (ou passez-la à « false ») quand le diaporama est prêt.
brouillon: true
---
<!-- _class: titre -->
<!-- _paginate: false -->
# Le titre de votre présentation
## Un sous-titre, si besoin
Prénom Nom — Alpinux, jeudi 15 janvier 2026
<!--
Ceci est une note d'orateur : un commentaire HTML placé dans une diapo.
Elle n'apparaît pas à l'écran, seulement dans la vue présentateur (touche P).
Les deux commentaires du haut, eux, sont des réglages : « _class: titre » applique
la mise en page de titre à cette diapo, « _paginate: false » en retire le numéro.
-->
---
## Une diapo ordinaire
Trois tirets seuls sur une ligne séparent deux diapos.
- Une idée par ligne, pas une phrase entière
- **Du gras** pour le mot qui compte
- Trois à cinq lignes : au-delà, on ne lit plus depuis le fond de la salle
> Une citation fait un encadré : pratique pour « à retenir » ou « piège ».
<!--
Écrivez ici ce que vous direz à voix haute. La diapo, elle, ne porte que les repères.
-->
---
## Du code et des raccourcis
Une commande courte se lit bien dans un bloc :
```bash
sudo apt install xournalpp
```
Dans une phrase : `du -h fichier.pdf`, ou une touche <kbd>Ctrl</kbd> + <kbd>S</kbd>.
<!--
Pas plus de six ou sept lignes de code par diapo.
-->
---
## Un tableau
| Je veux… | J'utilise… |
|---------------------|-------------|
| Lire un document | Xreader |
| Signer un document | Xournal++ |
<!--
Deux ou trois colonnes au plus : un tableau large devient illisible une fois projeté.
-->
---
## Une image
Rangez vos images dans le dossier `images/`, à côté de ce fichier :
![Description de l'image, pour qui ne la voit pas](images/exemple.svg)
<!--
Seul le dossier images/ est publié avec le diaporama. Une image prise ailleurs sur
Internet est refusée par le build : téléchargez-la et rangez-la ici, en vérifiant que
sa licence le permet.
-->
---
<!-- _class: demo -->
## Ce que je vais montrer
1. Première manipulation
2. Deuxième manipulation
3. Ce qu'il faut regarder à l'écran
<!--
« _class: demo » annonce qu'on quitte le diaporama pour une démonstration en direct.
Notez ici le déroulé exact, et le plan B si la démonstration échoue.
-->
---
<!-- _class: titre -->
# Merci !
## Des questions ?
Diaporama : presentations.alpinux.org

View file

@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1120 170" width="1120" height="170" role="img" aria-labelledby="titre">
<title id="titre">Le parcours du dossier : remplir, signer, assembler, alléger, envoyer</title>
<defs>
<marker id="fleche" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto">
<path d="M0 0 L10 5 L0 10 z" fill="#0f4e8f"/>
</marker>
</defs>
<g font-family="Inter, 'Noto Sans', Ubuntu, 'DejaVu Sans', sans-serif" text-anchor="middle">
<g fill="#f3f6fb" stroke="#1a6bbf" stroke-width="4">
<rect x="4" y="20" width="184" height="130" rx="16"/>
<rect x="236" y="20" width="184" height="130" rx="16"/>
<rect x="468" y="20" width="184" height="130" rx="16"/>
<rect x="700" y="20" width="184" height="130" rx="16"/>
</g>
<rect x="932" y="20" width="184" height="130" rx="16" fill="#1a6bbf" stroke="#0f4e8f" stroke-width="4"/>
<g stroke="#0f4e8f" stroke-width="5" marker-end="url(#fleche)">
<path d="M192 85 H230"/>
<path d="M424 85 H462"/>
<path d="M656 85 H694"/>
<path d="M888 85 H926"/>
</g>
<g font-size="30" font-weight="700" fill="#0f4e8f">
<text x="96" y="78">Remplir</text>
<text x="328" y="78">Signer</text>
<text x="560" y="78">Assembler</text>
<text x="792" y="78">Alléger</text>
</g>
<text x="1024" y="78" font-size="30" font-weight="700" fill="#ffffff">Envoyer</text>
<g font-size="22" fill="#1a1a2e">
<text x="96" y="116">Okular</text>
<text x="328" y="116">Xournal++</text>
<text x="560" y="116">PDF Arranger</text>
<text x="792" y="116">Ghostscript</text>
</g>
<text x="1024" y="116" font-size="22" fill="#ffffff">un seul PDF</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

477
slides/pdf-signer/slides.md Normal file
View file

@ -0,0 +1,477 @@
---
marp: true
theme: alpinux
paginate: true
title: Lire, remplir et signer ses PDF
description: Remplir, signer, assembler et alléger un dossier PDF sous Linux Mint, sans imprimante, sans scanner et sans service en ligne.
auteur: Alpinux
date: 2026-10-15
niveau: debutant
format: court
tags: [pdf, bureautique, linux-mint, xreader, okular, xournalpp, pdfarranger, libreoffice]
---
<!-- _class: titre -->
<!-- _paginate: false -->
# Lire, remplir et signer ses PDF
## Sans imprimante, sans scanner, sans service en ligne
Alpinux — sous Linux Mint, 20 minutes
<!--
Accroche : « Qui a déjà imprimé un formulaire, l'a signé au stylo, puis l'a scanné
pour le renvoyer par mail ? » Laisser les mains se lever.
Objectif de la séance : ne plus jamais faire ça.
Avant de commencer, vérifier : les quatre fichiers de démonstration sont sur le bureau
(formulaire à champs, formulaire scanné, photo du justificatif, signature en PNG) et
Okular, Xournal++ et PDF Arranger sont installés. Couper les notifications.
-->
---
## Le fil rouge : un dossier d'inscription
![Le parcours du dossier : recevoir le formulaire, le remplir, le signer, joindre le justificatif, alléger, envoyer](images/parcours.svg)
- Un **formulaire** à remplir et à signer
- Une **pièce justificative** à joindre
- Le tout en **un seul PDF léger**, envoyé par mail
<!--
Poser le décor : une association, une école ou une mairie nous envoie un formulaire
d'inscription. Elle veut le recevoir rempli, signé, avec un justificatif, par mail.
Chaque outil de ce soir résout une étape du parcours, dans l'ordre.
Piège à signaler tout de suite : beaucoup de messageries refusent les pièces jointes
trop lourdes. Un scan de quelques pages peut peser plus de 20 Mo : d'où la dernière
étape, alléger.
-->
---
## Cinq outils, tous libres
| Étape | Outil | Dans Linux Mint ? |
|----------------------|------------------|--------------------|
| Lire, surligner | **Xreader** | Déjà installé |
| Remplir, annoter | **Okular** | À installer |
| Signer | **Xournal++** | À installer |
| Assembler | **PDF Arranger** | À installer |
| Créer, alléger | **LibreOffice** | Déjà installé |
```bash
sudo apt install okular xournalpp pdfarranger
```
<!--
Les trois logiciels à installer sont aussi dans la Logithèque : chercher « Okular »,
« Xournal++ » et « PDF Arranger ». À recommander à ceux que le terminal intimide.
Piège : dans la Logithèque, un même logiciel apparaît parfois deux fois, en paquet
système et en Flatpak. Les deux fonctionnent ; prendre le paquet système pour rester
simple.
Okular vient de l'environnement KDE : il installe pas mal de bibliothèques avec lui.
C'est normal, et sans conséquence sur le reste du système.
-->
---
## 1. Xreader : lire, chercher, surligner
Le lecteur de Linux Mint : un double-clic sur le PDF suffit.
- **Panneau latéral** : vignettes des pages, sommaire
- **Chercher** un mot : <kbd>Ctrl</kbd> + <kbd>F</kbd>
- **Surligner** et ajouter une note : panneau *Annotations*
- **Remplir** un champ de formulaire simple, directement
> Pour lire, et pour un formulaire simple : **rien à installer**.
<!--
Le panneau latéral s'affiche avec F9. Son menu déroulant permet de passer des
vignettes au sommaire puis aux annotations.
Pour surligner : panneau latéral → Annotations → onglet « Ajouter » → choisir le
surligneur, puis sélectionner le texte.
Piège : la recherche ne trouve rien dans un document scanné. Un scan est une
photographie du texte, pas du texte. Si Ctrl+F ne trouve pas un mot pourtant visible à
l'écran, c'est le signe qu'on a affaire à un scan — et qu'il n'aura pas de champs à
remplir non plus. (La reconnaissance de caractères est un autre sujet : voir la fiche
Tesseract du wiki.)
-->
---
## Xreader : le piège de la fermeture
Xreader **n'enregistre pas** dans le fichier ouvert.
- *Fichier → Enregistrer une copie…*
- Donner un **nouveau nom** : `inscription-remplie.pdf`
- Rouvrir la copie pour **vérifier**
> **Piège** — fermer la fenêtre sans « Enregistrer une copie », c'est perdre les
> annotations et tout ce qu'on a saisi.
<!--
C'est LE piège de Xreader : pas de bouton « Enregistrer », pas de Ctrl+S qui écrase le
fichier. On croit avoir fini, on ferme, et tout est perdu.
Bonne habitude à installer dès maintenant, valable toute la soirée : on ne travaille
jamais sur l'original. À chaque étape, un nouveau nom — inscription.pdf, puis
inscription-remplie.pdf, puis inscription-signee.pdf. Si une étape rate, on repart de
la précédente.
-->
---
<!-- _class: demo -->
## Xreader
1. Ouvrir `inscription.pdf` par double-clic
2. <kbd>Ctrl</kbd> + <kbd>F</kbd> : chercher « justificatif »
3. Surligner la liste des pièces demandées
4. *Enregistrer une copie…*, puis rouvrir la copie
<!--
Deux minutes, pas plus.
Montrer d'abord le piège : surligner, fermer sans enregistrer, rouvrir — le surlignage
a disparu. Puis refaire correctement avec « Enregistrer une copie ».
Plan B si le panneau d'annotations est introuvable sur la machine de démonstration :
passer directement à Okular, qui fait la même chose avec une barre d'outils visible.
-->
---
## 2. Okular : remplir un formulaire
- Bandeau **« Afficher les formulaires »** en haut du document
- Saisie des **champs**, des **cases à cocher**, des listes
- **Annotations** : <kbd>F6</kbd> — surligneur, note, trait à main levée
- Enregistrer : <kbd>Ctrl</kbd> + <kbd>S</kbd> — le texte est **dans** le PDF
> S'il ne fallait installer qu'un seul outil, ce serait celui-ci.
<!--
Okular détecte les champs et propose de les afficher. Si le bandeau a été fermé :
menu Affichage → Afficher les formulaires.
Pour prouver que la saisie est bien dans le fichier : enregistrer, puis rouvrir le PDF
dans Xreader. Le texte y est. C'est ce que verra le destinataire.
Piège : enregistrer sous un nouveau nom (Fichier → Enregistrer sous) pour garder le
formulaire vierge. On en aura besoin l'an prochain.
Piège : certains formulaires officiels sont prévus pour Adobe Reader (format « XFA »)
et n'affichent qu'un message « veuillez patienter » ou une page vide. Ce n'est pas une
panne de Linux : aucun lecteur libre ne les lit correctement. Chercher une autre
version du formulaire sur le site de l'organisme, ou faire la démarche en ligne.
-->
---
## Okular : écrire là où il n'y a pas de champ
Un formulaire **scanné** n'a pas de champs : c'est une image.
- Barre d'annotations (<kbd>F6</kbd>) → **Machine à écrire**
- Cliquer à l'endroit voulu, taper le texte
- Déplacer et ajuster le texte ensuite, si besoin
- Cocher une case : taper un **X** au bon endroit
> **Piège** — un PDF protégé peut refuser la saisie.
> Dans ce cas : passer à Xournal++.
<!--
C'est le cas le plus fréquent en pratique : le formulaire a été scanné, ou exporté
sans champs. La « machine à écrire » pose du texte sans cadre ni fond, comme si on
tapait sur la feuille.
À ne pas confondre avec l'outil « Note intégrée », qui dessine un cadre jaune autour
du texte : sur un formulaire, ça fait brouillon.
Piège : la taille du texte par défaut est souvent trop grande pour les cases. Clic
droit sur l'outil dans la barre → Configurer, pour choisir une police plus petite
avant de commencer plutôt que de corriger chaque zone après coup.
Les intitulés exacts des outils changent un peu d'une version d'Okular à l'autre : les
repérer sur la machine de démonstration avant la séance.
-->
---
<!-- _class: demo -->
## Okular
1. Ouvrir le formulaire **à champs** : afficher les formulaires, remplir
2. Ouvrir le formulaire **scanné** : machine à écrire
3. *Enregistrer sous* `inscription-remplie.pdf`
4. Rouvrir dans Xreader : le texte est bien là
<!--
Quatre minutes. C'est la démonstration centrale : prendre le temps de taper
réellement un nom, une adresse, une date.
Ne remplir que trois ou quatre champs : le public a compris le principe au deuxième.
Plan B si le formulaire à champs ne s'affiche pas correctement : ne garder que la
version scannée et la machine à écrire. Elle marche sur n'importe quel PDF.
-->
---
## 3. Xournal++ : signer
- *Fichier → Annoter un PDF* : ouvrir le formulaire rempli
- Outil **Image** : insérer sa signature en **PNG**
- La **redimensionner**, la placer sur la ligne
- Ajouter la **date** avec l'outil Texte
> Une signature en image convient à beaucoup de démarches courantes.
> Ce n'est **pas** une signature électronique qualifiée.
<!--
Préparer sa signature une fois pour toutes : signer au feutre noir sur une feuille
blanche, la photographier bien à plat et en pleine lumière, recadrer, enregistrer en
PNG. Idéalement, rendre le fond transparent (dans GIMP : Couleurs → Couleur vers
alpha), sinon un rectangle blanc masquera la ligne du formulaire.
On peut aussi signer à la souris avec l'outil stylo : le montrer avec humour, le
résultat est rarement présentable. Avec une tablette graphique ou un écran tactile,
en revanche, c'est très bien.
Piège : pour un acte important (banque, notaire, marché public), l'organisme impose
son propre service de signature. L'image collée n'a pas cette valeur.
Conseil de sécurité : le PNG de sa signature se range dans un dossier discret, pas sur
le bureau ni sur une clé USB qui traîne. Quiconque récupère ce fichier peut signer à
notre place — et l'image reste récupérable dans le PDF qu'on envoie.
-->
---
## Xournal++ : l'erreur numéro un
| Commande | Produit | Le destinataire… |
|------------------------------|-----------------|-------------------------|
| *Fichier → Enregistrer* | un `.xopp` | **ne peut pas l'ouvrir** |
| *Fichier → Exporter en PDF* | un `.pdf` | le lit normalement |
> **Piège** — « Enregistrer » ne modifie pas le PDF. Pour l'envoyer, il faut
> **exporter en PDF**.
<!--
C'est l'erreur que tout le monde fait la première fois : Ctrl+S, on joint le fichier
au mail… et le destinataire reçoit un .xopp qu'aucun de ses logiciels ne sait ouvrir.
Le .xopp est le fichier de travail de Xournal++ : il contient nos ajouts et un simple
renvoi vers le PDF d'origine. Il sert à reprendre le travail plus tard, pas à être
envoyé.
Piège associé : en quittant, Xournal++ propose d'enregistrer. Si on a déjà exporté le
PDF et qu'on ne compte pas y revenir, on peut répondre non — l'export est fait.
Vérification systématique : avant de joindre, regarder l'extension du fichier. Elle
doit être .pdf.
-->
---
## 4. PDF Arranger : assembler
- **Glisser** le formulaire signé et le justificatif dans la fenêtre
- **Réordonner** les pages à la souris
- **Pivoter** une page scannée de travers
- **Supprimer** une page blanche
- **Extraire** : sélectionner des pages → *Exporter la sélection*
> Il accepte aussi les **images** : une photo devient une page du PDF.
<!--
PDF Arranger montre chaque page comme une vignette qu'on déplace. C'est très visuel :
c'est l'outil que le public adopte le plus vite.
Piège : utiliser « Enregistrer sous » avec un nouveau nom — dossier-inscription.pdf —
pour ne pas écraser le formulaire signé.
Piège : les champs de formulaire encore modifiables et les annotations peuvent mal
survivre à l'assemblage. C'est une raison de plus pour assembler en dernier, une fois
le formulaire rempli, signé et exporté par Xournal++.
Extraire sert aussi dans l'autre sens : n'envoyer que la page 3 d'un relevé de douze
pages, plutôt que le relevé entier.
-->
---
<!-- _class: demo -->
## Signer, puis assembler
1. **Xournal++** : *Annoter un PDF* → `inscription-remplie.pdf`
2. Insérer la signature, ajouter la date
3. *Exporter en PDF* → `inscription-signee.pdf`
4. **PDF Arranger** : glisser le PDF signé et la photo du justificatif
5. Pivoter la photo, *Enregistrer sous* `dossier-inscription.pdf`
<!--
Cinq minutes pour les deux outils.
Dans Xournal++, montrer volontairement le piège : faire Ctrl+S, montrer le .xopp dans
le gestionnaire de fichiers, puis faire l'export en PDF.
Dans PDF Arranger, prendre une photo de justificatif volontairement couchée : la
rotation devient évidente.
Plan B si l'insertion de l'image pose problème : signer à la souris avec l'outil
stylo. C'est laid, mais la suite du parcours reste démontrable.
-->
---
## 5. LibreOffice : créer un PDF léger
*Fichier → Exporter vers → Exporter au format PDF…*
- **Compression JPEG** : une qualité de 80 % suffit pour l'écran
- **Réduire la résolution des images** : 150 DPI
- Comparer le poids **avant et après** dans le gestionnaire de fichiers
> Option **PDF hybride** : le PDF embarque le document d'origine et reste
> modifiable dans LibreOffice.
<!--
Cas d'usage : la lettre d'accompagnement, ou un document qu'on a rédigé soi-même avec
des photos dedans.
Piège : le bouton « Exporter directement au format PDF » de la barre d'outils ne
montre pas les options et reprend les derniers réglages utilisés. Pour choisir la
compression, passer par le menu.
Piège : 150 DPI convient à la lecture à l'écran et à une impression ordinaire. Pour un
document destiné à un imprimeur, garder 300 DPI.
Le PDF hybride est plus lourd, puisqu'il contient deux fois le document : à réserver
aux fichiers qu'on veut pouvoir reprendre, pas à ceux qu'on envoie.
-->
---
## Alléger un scan : Ghostscript
Le PDF existe déjà et il est trop lourd pour le mail :
```bash
gs -sDEVICE=pdfwrite -dPDFSETTINGS=/ebook \
-dNOPAUSE -dBATCH \
-sOutputFile=leger.pdf lourd.pdf
```
- `/ebook` : images à 150 DPI — le bon compromis
- `/screen` : encore plus léger, souvent **illisible**
<!--
Ghostscript est déjà installé sous Linux Mint : c'est lui qui sert à l'impression.
Montrer le résultat en chiffres : ls -lh lourd.pdf leger.pdf, ou les propriétés des
deux fichiers dans le gestionnaire. Un scan passe couramment de 20 Mo à 2 Mo.
Piège : le fichier de sortie doit porter un AUTRE nom que le fichier d'entrée. Avec le
même nom, on détruit l'original.
Piège : toujours rouvrir le fichier allégé et zoomer sur les petits caractères avant
de l'envoyer. Un justificatif illisible sera refusé, et il faudra tout recommencer.
Piège : ne pas attendre de miracle sur un PDF qui ne contient que du texte. Il est
déjà léger ; le gain vient des images.
Pour ceux que la ligne de commande rebute : rouvrir le scan dans PDF Arranger ne
réduit pas le poids. C'est bien Ghostscript qu'il faut — la commande se copie depuis
le diaporama en ligne.
-->
---
## Et les sites de compression en ligne ?
Ils fonctionnent. Mais on y envoie, à un inconnu :
- sa **pièce d'identité**
- son **avis d'imposition**
- son **RIB**, sa **signature**
> Tout ce qui a été montré ce soir **reste sur l'ordinateur**.
<!--
C'est l'argument de fond de la séance, au-delà du confort.
Un site « gratuit » qui compresse, fusionne ou signe des PDF reçoit une copie du
document. On ne sait ni où elle est stockée, ni combien de temps, ni qui y accède.
Ne pas culpabiliser ceux qui l'ont déjà fait : le but est de montrer qu'il existe une
solution aussi simple, et qu'elle est déjà sur leur ordinateur.
-->
---
## Mémo : je veux… j'utilise…
| Je veux… | J'utilise… |
|---------------------------------------------|------------------|
| Lire, chercher, surligner | **Xreader** |
| Remplir un formulaire, écrire sur un PDF | **Okular** |
| Signer | **Xournal++** |
| Fusionner, pivoter, extraire des pages | **PDF Arranger** |
| Créer un PDF léger | **LibreOffice** |
| Alléger un scan | **Ghostscript** |
<!--
Laisser cette diapo affichée pendant les questions : c'est celle qu'on photographie.
Rappeler le parcours du fil rouge, dans l'ordre : remplir (Okular), signer
(Xournal++), assembler (PDF Arranger), alléger (Ghostscript), envoyer.
Les trois pièges à répéter une dernière fois :
1. Xreader : « Enregistrer une copie », sinon tout est perdu.
2. Xournal++ : « Exporter en PDF », pas « Enregistrer ».
3. Toujours un nouveau nom de fichier, et toujours rouvrir le PDF final avant d'envoyer.
-->
---
<!-- _class: titre -->
# Merci !
## Des questions ?
Diaporama et mémo : **presentations.alpinux.org**
<!--
Questions fréquentes à anticiper :
« Et sur téléphone ? » — Hors sujet ce soir, mais le PDF produit se lit partout.
« Peut-on modifier le texte existant d'un PDF ? » — Pas vraiment : un PDF est un
format d'arrivée, pas de travail. LibreOffice Draw ouvre les PDF simples, avec un
résultat inégal. Le mieux reste de demander le document d'origine.
« Et la vraie signature électronique ? » — Okular sait apposer une signature par
certificat, mais il faut d'abord posséder un certificat reconnu : c'est un sujet à
part entière, pour une autre séance.
Proposer à ceux qui le souhaitent de refaire le parcours sur leur propre machine après
la présentation.
-->

297
theme/alpinux.css Normal file
View file

@ -0,0 +1,297 @@
/* @theme alpinux */
/* @auto-scaling true */
/* @size 16:9 1280px 720px */
/* @size 4:3 960px 720px */
/*
* Thème Marp des présentations d'Alpinux.
*
* Pensé pour la vidéoprojection : gros corps de texte, fort contraste, aucune
* ressource externe hormis le logo, servi par static.alpinux.org. Les polices sont
* celles du système — rien n'est téléchargé.
*
* Deux classes à poser sur une diapo avec <!-- _class: nom --> :
* titre la diapo d'ouverture (et les intercalaires de partie) ;
* demo une diapo qui annonce une démonstration en direct.
*/
:root {
--bleu: #1a6bbf;
--bleu-fonce: #0f4e8f;
--nuit: #12263f;
--encre: #1a1a2e;
--encre-douce: #4a5568;
--papier: #ffffff;
--papier-teinte: #f3f6fb;
--trait: #c9d6e8;
--alerte: #b45309;
--logo: url("https://static.alpinux.org/logo/alpinux-logo-512.png");
}
/* ── La diapo ─────────────────────────────────────────────────────────── */
section {
width: 1280px;
height: 720px;
box-sizing: border-box;
padding: 48px 72px 92px;
display: flex;
flex-direction: column;
justify-content: flex-start;
background: var(--papier);
color: var(--encre);
font-family: "Inter", "Noto Sans", "Ubuntu", "DejaVu Sans", "Segoe UI", system-ui, sans-serif;
font-size: 32px;
line-height: 1.4;
letter-spacing: 0.005em;
}
section > *:first-child { margin-top: 0; }
section > *:last-child { margin-bottom: 0; }
/* ── Titres ───────────────────────────────────────────────────────────── */
h1, h2, h3, h4 {
margin: 0 0 0.55em;
line-height: 1.15;
font-weight: 700;
color: var(--bleu-fonce);
}
h1 { font-size: 1.75em; }
h2 {
font-size: 1.4em;
padding-bottom: 0.25em;
border-bottom: 4px solid var(--bleu);
}
h3 { font-size: 1.1em; color: var(--encre); }
h4 { font-size: 1em; color: var(--encre-douce); }
/* ── Texte ────────────────────────────────────────────────────────────── */
p, ul, ol, blockquote, pre, table { margin: 0 0 0.6em; }
ul, ol { padding-left: 1.2em; }
li { margin-bottom: 0.25em; }
li > ul, li > ol { margin: 0.2em 0 0; font-size: 0.9em; }
ul > li::marker { color: var(--bleu); }
ol > li::marker { color: var(--bleu); font-weight: 700; }
strong { color: var(--bleu-fonce); font-weight: 700; }
em { font-style: italic; }
a { color: var(--bleu); text-decoration: underline; text-underline-offset: 0.15em; }
mark { background: #ffe066; color: var(--encre); padding: 0 0.15em; border-radius: 4px; }
/* Une citation sert d'encadré « à retenir » ou « piège ». */
blockquote {
padding: 0.5em 0.9em;
border-left: 10px solid var(--bleu);
background: var(--papier-teinte);
border-radius: 0 10px 10px 0;
font-size: 0.92em;
}
blockquote > *:last-child { margin-bottom: 0; }
kbd {
display: inline-block;
padding: 0.05em 0.4em;
border: 2px solid var(--encre-douce);
border-bottom-width: 4px;
border-radius: 8px;
background: var(--papier);
font-family: inherit;
font-size: 0.82em;
line-height: 1.3;
white-space: nowrap;
}
img { max-width: 100%; max-height: 100%; }
/* ── Code ─────────────────────────────────────────────────────────────── */
code {
font-family: "JetBrains Mono", "DejaVu Sans Mono", "Ubuntu Mono", "Liberation Mono", monospace;
font-size: 0.86em;
padding: 0.08em 0.3em;
border-radius: 6px;
background: #e4ecf7;
color: #0b2e55;
}
pre {
padding: 0.7em 0.9em;
border-radius: 12px;
border-left: 10px solid var(--bleu);
background: var(--nuit);
color: #f1f5fb;
font-size: 0.8em;
line-height: 1.45;
overflow: hidden;
}
pre code {
padding: 0;
background: none;
color: inherit;
font-size: 1em;
}
/* Coloration syntaxique (highlight.js) — teintes claires sur fond nuit, toutes
au-dessus de 7:1 de contraste. */
.hljs-comment, .hljs-quote { color: #a8b8cc; font-style: italic; }
.hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-name, .hljs-tag { color: #8cc4ff; }
.hljs-string, .hljs-attr, .hljs-symbol, .hljs-bullet, .hljs-addition { color: #a5e3a0; }
.hljs-number, .hljs-literal, .hljs-variable, .hljs-template-variable { color: #ffc98a; }
.hljs-title, .hljs-section, .hljs-type, .hljs-meta { color: #f7e08a; }
.hljs-deletion { color: #ffb0a8; }
.hljs-emphasis { font-style: italic; }
.hljs-strong { font-weight: 700; }
/* ── Tableaux ─────────────────────────────────────────────────────────── */
table {
width: 100%;
border-collapse: collapse;
font-size: 0.86em;
}
th, td {
padding: 0.32em 0.7em;
text-align: left;
vertical-align: top;
border-bottom: 2px solid var(--trait);
}
thead th {
background: var(--bleu-fonce);
color: #fff;
font-weight: 700;
border-bottom: none;
}
thead th:first-child { border-radius: 10px 0 0 0; }
thead th:last-child { border-radius: 0 10px 0 0; }
tbody tr:nth-child(even) { background: var(--papier-teinte); }
tbody td:first-child { font-weight: 600; }
/* ── Pied de diapo : logo, texte libre, numéro de page ────────────────── */
section::before {
content: "";
position: absolute;
left: 40px;
bottom: 22px;
width: 52px;
height: 52px;
background: var(--logo) center / contain no-repeat;
}
header, footer {
position: absolute;
left: 108px;
right: 160px;
font-size: 0.55em;
color: var(--encre-douce);
}
header { top: 22px; left: 72px; }
footer { bottom: 36px; }
section::after {
content: attr(data-marpit-pagination) " / " attr(data-marpit-pagination-total);
position: absolute;
right: 44px;
bottom: 34px;
/* Marpit donne à ce pseudo-élément le remplissage de la diapo : on l'annule. */
padding: 0;
font-size: 0.6em;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--encre-douce);
}
/* ── Classe « titre » : ouverture et intercalaires ────────────────────── */
section.titre {
justify-content: center;
padding: 72px 96px 120px;
background: var(--bleu);
color: #fff;
}
section.titre h1,
section.titre h2,
section.titre h3,
section.titre strong { color: #fff; }
section.titre h1 { font-size: 2.3em; margin-bottom: 0.3em; }
section.titre h2 {
border-bottom: none;
padding-bottom: 0;
font-size: 1.25em;
font-weight: 400;
}
section.titre p { font-size: 0.82em; color: #e6f0fb; }
section.titre a { color: #fff; }
section.titre code { background: var(--bleu-fonce); color: #fff; }
section.titre footer,
section.titre header,
section.titre::after { color: #e6f0fb; }
/* Le logo est bleu : sur fond bleu, on le pose sur une pastille blanche. */
section.titre::before {
width: 72px;
height: 72px;
left: 96px;
bottom: 40px;
background-color: #fff;
background-size: 84%;
border-radius: 14px;
}
section.titre footer { left: 190px; bottom: 62px; }
/* ── Classe « demo » : on quitte le diaporama pour montrer ────────────── */
section.demo {
justify-content: center;
background: var(--nuit);
color: #f1f5fb;
}
section.demo h1,
section.demo h2,
section.demo h3 { color: #fff; }
section.demo h2 { border-bottom-color: #ffc857; }
section.demo strong { color: #ffc857; }
section.demo a { color: #9fd0ff; }
section.demo ul > li::marker,
section.demo ol > li::marker { color: #ffc857; }
section.demo code { background: #24456e; color: #fff; }
section.demo pre { background: #0a1728; border-left-color: #ffc857; }
section.demo blockquote { background: #1c3a5e; border-left-color: #ffc857; color: #f1f5fb; }
section.demo kbd { background: #1c3a5e; border-color: #9fb3cc; color: #fff; }
section.demo footer,
section.demo header,
section.demo::after { color: #c5d3e6; }
section.demo h2::before {
content: "Démonstration";
display: block;
width: fit-content;
margin-bottom: 0.5em;
padding: 0.15em 0.7em;
border-radius: 999px;
background: #ffc857;
color: var(--nuit);
font-size: 0.5em;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
section.demo::before {
background-color: #fff;
background-size: 84%;
border-radius: 10px;
}