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:
commit
02cf82447b
18 changed files with 4302 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
24
|
||||
196
CONTRIBUER.md
Normal file
196
CONTRIBUER.md
Normal 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
226
README.md
Normal 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).
|
||||
23
deploy/apache-vhost.exemple.conf
Normal file
23
deploy/apache-vhost.exemple.conf
Normal 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
161
deploy/installer.sh
Executable 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 "────────────────────────────────────────────────────────────"
|
||||
17
deploy/presentations-webhook.service
Normal file
17
deploy/presentations-webhook.service
Normal 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
181
deploy/webhook.md
Normal 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
68
deploy/webhook.py
Normal 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
1818
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
19
package.json
Normal file
19
package.json
Normal 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
599
scripts/build.mjs
Normal 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('&', '&'))
|
||||
|
||||
/**
|
||||
* 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*(?:"|["'])?([^"')&]+)/gi),
|
||||
...adresses(html, /@import\s+(?:url\()?\s*(?:"|["'])([^"')&]+)/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('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
.replaceAll('"', '"').replaceAll("'", ''')
|
||||
|
||||
/** 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
40
scripts/deploy-presentations.sh
Executable 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"
|
||||
4
slides/_modele/images/exemple.svg
Normal file
4
slides/_modele/images/exemple.svg
Normal 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
126
slides/_modele/slides.md
Normal 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 :
|
||||
|
||||

|
||||
|
||||
<!--
|
||||
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
|
||||
37
slides/pdf-signer/images/parcours.svg
Normal file
37
slides/pdf-signer/images/parcours.svg
Normal 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
477
slides/pdf-signer/slides.md
Normal 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
|
||||
|
||||

|
||||
|
||||
- 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
297
theme/alpinux.css
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue