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