Site personnel courcellematthi.eu
@@ -0,0 +1,56 @@
|
||||
# =============================================================================
|
||||
# .dockerignore — Fichiers exclus du contexte de build
|
||||
# -----------------------------------------------------------------------------
|
||||
# Réduit le contexte envoyé au démon Docker : build plus rapide, et surtout
|
||||
# aucun risque d'embarquer un secret ou l'historique Git dans une couche.
|
||||
#
|
||||
# ATTENTION — NE JAMAIS AJOUTER « Dockerfile » NI « .dockerignore » ICI.
|
||||
#
|
||||
# BuildKit (utilisé par Coolify, et par Docker depuis la version 23) applique
|
||||
# les exclusions AVANT de lire le Dockerfile. Si le Dockerfile est filtré, le
|
||||
# build échoue avec :
|
||||
#
|
||||
# #1 transferring dockerfile: 2B done
|
||||
# ERROR: failed to solve: failed to read dockerfile:
|
||||
# open Dockerfile: no such file or directory
|
||||
#
|
||||
# Le message est trompeur : le fichier existe bien dans le dépôt, c'est ce
|
||||
# fichier-ci qui l'a rendu invisible. Le builder classique tolérait le cas ;
|
||||
# BuildKit non.
|
||||
#
|
||||
# Ces deux fichiers sont retirés de l'image finale par le « RUN rm » du
|
||||
# Dockerfile : ils n'apparaissent donc pas dans le dossier servi au public.
|
||||
# =============================================================================
|
||||
|
||||
# Contrôle de version
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Documentation, outillage et configurations hors conteneur
|
||||
README.md
|
||||
verifier-depot.sh
|
||||
DEPLOIEMENT-COOLIFY.md
|
||||
nginx.conf.example
|
||||
.htaccess
|
||||
|
||||
# Orchestration (le Dockerfile lui-même reste dans le contexte, cf. ci-dessus)
|
||||
docker-compose.yaml
|
||||
docker-compose.yml
|
||||
|
||||
# Environnement — ne doit JAMAIS entrer dans une image
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Artefacts d'éditeur et de système
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Dépendances éventuelles (aucune aujourd'hui, mais si le projet évolue)
|
||||
node_modules
|
||||
@@ -0,0 +1,18 @@
|
||||
# Environnement et secrets
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Éditeurs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Systèmes
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Dépendances éventuelles
|
||||
node_modules/
|
||||
@@ -0,0 +1,103 @@
|
||||
# =============================================================================
|
||||
# .htaccess — Configuration Apache pour courcellematthi.eu
|
||||
# -----------------------------------------------------------------------------
|
||||
# Équivalent de nginx.conf.example, pour un hébergement mutualisé où l'accès
|
||||
# à la configuration serveur n'est pas possible.
|
||||
#
|
||||
# Ne PAS utiliser ce fichier ET la configuration Nginx : ils font double emploi.
|
||||
# Sur Nginx, ce fichier est simplement ignoré.
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# REDIRECTIONS CANONIQUES
|
||||
# -----------------------------------------------------------------------------
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# HTTP -> HTTPS
|
||||
RewriteCond %{HTTPS} !=on
|
||||
RewriteRule ^ https://www.courcellematthi.eu%{REQUEST_URI} [R=301,L]
|
||||
|
||||
# Domaine nu -> www (doit correspondre au rel="canonical" du HTML)
|
||||
RewriteCond %{HTTP_HOST} ^courcellematthi\.eu$ [NC]
|
||||
RewriteRule ^ https://www.courcellematthi.eu%{REQUEST_URI} [R=301,L]
|
||||
</IfModule>
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# EN-TÊTES DE SÉCURITÉ
|
||||
# Voir nginx.conf.example pour le détail de chaque directive.
|
||||
# -----------------------------------------------------------------------------
|
||||
<IfModule mod_headers.c>
|
||||
# N'activer HSTS qu'une fois le HTTPS vérifié : irréversible côté serveur.
|
||||
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
|
||||
|
||||
Header always set Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; upgrade-insecure-requests"
|
||||
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), interest-cohort=()"
|
||||
Header always set X-Frame-Options "DENY"
|
||||
|
||||
# Retire la signature du serveur des en-têtes de réponse
|
||||
Header unset X-Powered-By
|
||||
</IfModule>
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# COMPRESSION
|
||||
# -----------------------------------------------------------------------------
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css
|
||||
AddOutputFilterByType DEFLATE application/javascript application/json
|
||||
AddOutputFilterByType DEFLATE application/manifest+json image/svg+xml
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_brotli.c>
|
||||
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript image/svg+xml
|
||||
</IfModule>
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# CACHE
|
||||
# -----------------------------------------------------------------------------
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive On
|
||||
|
||||
# HTML : revalidation systématique, sinon les visiteurs restent sur une
|
||||
# version périmée après chaque publication.
|
||||
ExpiresByType text/html "access plus 0 seconds"
|
||||
|
||||
ExpiresByType text/css "access plus 30 days"
|
||||
ExpiresByType application/javascript "access plus 30 days"
|
||||
ExpiresByType image/png "access plus 90 days"
|
||||
ExpiresByType image/svg+xml "access plus 90 days"
|
||||
ExpiresByType image/x-icon "access plus 90 days"
|
||||
ExpiresByType font/woff2 "access plus 1 year"
|
||||
ExpiresByType application/manifest+json "access plus 1 day"
|
||||
</IfModule>
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# TYPES MIME
|
||||
# -----------------------------------------------------------------------------
|
||||
<IfModule mod_mime.c>
|
||||
AddType application/manifest+json .webmanifest
|
||||
AddType image/svg+xml .svg
|
||||
AddType font/woff2 .woff2
|
||||
|
||||
# Encodage explicite : évite les accents cassés dans llms.txt et humans.txt
|
||||
AddDefaultCharset UTF-8
|
||||
AddCharset UTF-8 .html .css .js .txt .xml .json .webmanifest
|
||||
</IfModule>
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# PROTECTION DES FICHIERS
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Interdit l'accès direct à ce fichier et aux fichiers de configuration
|
||||
<FilesMatch "^\.(htaccess|htpasswd|env|git)">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
# Désactive le listage des répertoires sans index
|
||||
Options -Indexes
|
||||
|
||||
# Page d'erreur
|
||||
ErrorDocument 404 /index.html
|
||||
@@ -0,0 +1,8 @@
|
||||
# Politique de divulgation de vulnérabilités — RFC 9116
|
||||
# Site personnel statique : la surface d'attaque est minimale, mais déclarer
|
||||
# un canal de contact reste une bonne pratique.
|
||||
|
||||
Contact: mailto:m@tthieu.be
|
||||
Expires: 2027-08-04T00:00:00.000Z
|
||||
Preferred-Languages: fr, en
|
||||
Canonical: https://www.courcellematthi.eu/.well-known/security.txt
|
||||
@@ -0,0 +1,379 @@
|
||||
# Déploiement sur Coolify
|
||||
|
||||
Guide complet pour mettre `courcellematthi.eu` en ligne depuis Coolify.
|
||||
|
||||
---
|
||||
|
||||
## Résumé
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Type d'application | Dockerfile (recommandé) ou Docker Compose |
|
||||
| Port interne | 80 |
|
||||
| Image finale | ~50 Mo |
|
||||
| Build | Aucune compilation — copie de fichiers statiques |
|
||||
| TLS | Géré par Traefik, **pas** par le conteneur |
|
||||
|
||||
---
|
||||
|
||||
## Étape 1 — Préparer le dépôt Git
|
||||
|
||||
Coolify déploie depuis un dépôt. Sur ton Gitea auto-hébergé :
|
||||
|
||||
```bash
|
||||
cd courcellematthi
|
||||
git init
|
||||
git add .
|
||||
git commit -m "Site personnel — version initiale"
|
||||
git branch -M main
|
||||
git remote add origin https://git.ghostinthemachine.fr/matthieu/courcellematthi.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
Vérifie que `.htaccess` et `.well-known/` sont bien poussés — Git les suit
|
||||
normalement, mais un `.gitignore` trop large les fait parfois disparaître :
|
||||
|
||||
```bash
|
||||
git ls-files | grep -E "htaccess|well-known"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 2 — Configurer le DNS
|
||||
|
||||
Avant de créer l'application, les enregistrements doivent pointer vers le
|
||||
serveur, sinon Let's Encrypt échouera à émettre le certificat.
|
||||
|
||||
| Type | Nom | Valeur |
|
||||
|---|---|---|
|
||||
| A | `www` | IP de ton serveur |
|
||||
| A | `@` | IP de ton serveur |
|
||||
|
||||
Si le domaine est chez Cloudflare, **désactive le proxy (nuage orange → gris)**
|
||||
pendant l'émission du certificat. Le proxy Cloudflare intercepte le challenge
|
||||
HTTP-01 et fait échouer la validation ACME. Tu pourras le réactiver ensuite.
|
||||
|
||||
Vérification :
|
||||
|
||||
```bash
|
||||
dig +short www.courcellematthi.eu
|
||||
dig +short courcellematthi.eu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 3 — Créer l'application dans Coolify
|
||||
|
||||
**Interface Coolify → Projet → + New → Application → Public/Private Repository**
|
||||
|
||||
Renseigne :
|
||||
|
||||
- **Repository** : l'URL de ton dépôt Gitea
|
||||
- **Branch** : `main`
|
||||
- **Build Pack** : `Dockerfile`
|
||||
- **Dockerfile Location** : `/Dockerfile`
|
||||
- **Base Directory** : `/`
|
||||
- **Port Exposes** : `80`
|
||||
|
||||
> **Ne choisis pas Nixpacks.** Il tenterait de détecter un projet Node,
|
||||
> échouerait faute de `package.json`, ou pire, installerait une chaîne de
|
||||
> build inutile. Le Dockerfile fourni est explicite et reproductible.
|
||||
|
||||
---
|
||||
|
||||
## Étape 4 — Domaines
|
||||
|
||||
Dans **Configuration → General → Domains**, saisis :
|
||||
|
||||
```
|
||||
https://www.courcellematthi.eu
|
||||
```
|
||||
|
||||
Coolify génère alors automatiquement les étiquettes Traefik et demande le
|
||||
certificat Let's Encrypt.
|
||||
|
||||
### Redirection du domaine nu
|
||||
|
||||
Ajoute `https://courcellematthi.eu` comme second domaine, puis active
|
||||
**Redirect to primary domain** (ou l'option *www ↔ non-www* selon ta version
|
||||
de Coolify).
|
||||
|
||||
Ce point n'est pas cosmétique : le HTML déclare
|
||||
`<link rel="canonical" href="https://www.courcellematthi.eu/">`. Si le domaine
|
||||
nu répond aussi en 200 au lieu de rediriger, les moteurs voient deux URL pour
|
||||
le même contenu et diluent le référencement.
|
||||
|
||||
**Si tu préfères le domaine nu**, il faut modifier trois endroits pour rester
|
||||
cohérent :
|
||||
|
||||
1. `index.html` — la balise `canonical`, les `og:url`, les `hreflang`, et les
|
||||
URL absolues du JSON-LD
|
||||
2. `sitemap.xml` — la balise `<loc>`
|
||||
3. `robots.txt` — la ligne `Sitemap:`
|
||||
|
||||
---
|
||||
|
||||
## Étape 5 — Déployer
|
||||
|
||||
Bouton **Deploy**. Le build prend une trentaine de secondes : il n'y a rien à
|
||||
compiler, seulement à copier.
|
||||
|
||||
Suis les journaux dans l'onglet **Logs**. Une fin de build normale ressemble à :
|
||||
|
||||
```
|
||||
naming to docker.io/library/xxxxx done
|
||||
Container started
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 6 — Vérifications post-déploiement
|
||||
|
||||
```bash
|
||||
# Le site répond
|
||||
curl -I https://www.courcellematthi.eu
|
||||
|
||||
# La sonde de santé
|
||||
curl https://www.courcellematthi.eu/healthz # -> ok
|
||||
|
||||
# Le domaine nu redirige (301 attendu, pas 200)
|
||||
curl -I https://courcellematthi.eu
|
||||
|
||||
# Les en-têtes de sécurité sont présents
|
||||
curl -sI https://www.courcellematthi.eu | grep -iE "content-security|x-content-type|referrer|permissions"
|
||||
|
||||
# La compression fonctionne
|
||||
curl -sI -H "Accept-Encoding: gzip" https://www.courcellematthi.eu | grep -i encoding
|
||||
|
||||
# Les fichiers de service sont servis
|
||||
curl -s https://www.courcellematthi.eu/robots.txt | head -3
|
||||
curl -s https://www.courcellematthi.eu/llms.txt | head -3
|
||||
curl -sI https://www.courcellematthi.eu/.well-known/security.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Les deux pannes classiques sur Coolify
|
||||
|
||||
Ce sont les deux mêmes que tu as déjà rencontrées sur d'autres déploiements.
|
||||
|
||||
### 1. Conteneur absent du réseau `coolify`
|
||||
|
||||
**Symptôme** : Traefik renvoie 404, alors que le conteneur tourne et que ses
|
||||
journaux sont vides. La requête ne l'atteint jamais.
|
||||
|
||||
**Diagnostic** :
|
||||
|
||||
```bash
|
||||
docker inspect <nom_du_conteneur> --format '{{json .NetworkSettings.Networks}}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
Si `coolify` n'apparaît pas dans la liste :
|
||||
|
||||
```bash
|
||||
docker network connect coolify <nom_du_conteneur>
|
||||
```
|
||||
|
||||
En mode Docker Compose, le fichier fourni déclare déjà le réseau. En mode
|
||||
Dockerfile, Coolify le fait normalement seul — mais pas toujours après une
|
||||
mise à jour de Coolify.
|
||||
|
||||
### 2. Port du load balancer non déclaré
|
||||
|
||||
**Symptôme** : `502 Bad Gateway`, ou `service not found` dans le tableau de
|
||||
bord Traefik.
|
||||
|
||||
Traefik ne devine le port que si le conteneur n'en expose qu'un seul, et cette
|
||||
détection échoue régulièrement. Il faut donc le déclarer explicitement.
|
||||
|
||||
**En mode Docker Compose**, l'étiquette est déjà présente :
|
||||
|
||||
```yaml
|
||||
- traefik.http.services.courcellematthi.loadbalancer.server.port=80
|
||||
```
|
||||
|
||||
**En mode Dockerfile**, va dans **Configuration → Advanced → Custom Labels**
|
||||
et ajoute :
|
||||
|
||||
```
|
||||
traefik.http.services.<nom-généré-par-coolify>.loadbalancer.server.port=80
|
||||
```
|
||||
|
||||
Récupère le nom généré dans les étiquettes existantes du conteneur :
|
||||
|
||||
```bash
|
||||
docker inspect <conteneur> --format '{{json .Config.Labels}}' | grep -o 'traefik[^"]*'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HSTS — à traiter en dernier, et prudemment
|
||||
|
||||
Le HSTS n'est **pas** émis par le conteneur. C'est un choix délibéré : le
|
||||
conteneur est redéployé souvent, et un réglage irréversible n'a rien à faire
|
||||
dans une couche jetable.
|
||||
|
||||
C'est Traefik qui doit l'émettre, une fois le HTTPS confirmé fonctionnel.
|
||||
|
||||
> **Pourquoi cette prudence.** `Strict-Transport-Security` avec
|
||||
> `max-age=63072000` indique au navigateur de refuser toute connexion HTTP à ce
|
||||
> domaine pendant deux ans. Si le certificat casse ensuite, le site devient
|
||||
> inaccessible — et **tu ne peux rien faire côté serveur** pour l'annuler : la
|
||||
> directive est mémorisée par le navigateur du visiteur.
|
||||
|
||||
### Procédure recommandée
|
||||
|
||||
**Phase 1 — validation.** Configure `max-age=300` (cinq minutes). En mode
|
||||
Docker Compose, c'est déjà la valeur du fichier fourni. En mode Dockerfile,
|
||||
ajoute dans **Custom Labels** :
|
||||
|
||||
```
|
||||
traefik.http.middlewares.hsts-test.headers.stsSeconds=300
|
||||
traefik.http.middlewares.hsts-test.headers.forceSTSHeader=true
|
||||
traefik.http.routers.<nom-du-routeur>.middlewares=hsts-test
|
||||
```
|
||||
|
||||
Vérifie sur plusieurs jours que le certificat se renouvelle bien.
|
||||
|
||||
**Phase 2 — activation.** Passe `stsSeconds` à `63072000`.
|
||||
|
||||
N'inscris le domaine sur la liste de préchargement HSTS (hstspreload.org) que
|
||||
si tu es certain de ne jamais revenir en arrière : la radiation prend des mois.
|
||||
|
||||
---
|
||||
|
||||
## Redéploiement
|
||||
|
||||
### Automatique
|
||||
|
||||
Dans **Configuration → General**, active **Auto Deploy**. Coolify crée un
|
||||
webhook dans Gitea : chaque `git push` sur `main` déclenche un déploiement.
|
||||
|
||||
C'est pertinent ici — un site statique sans test à faire tourner ne risque
|
||||
rien à se redéployer automatiquement.
|
||||
|
||||
### Manuel
|
||||
|
||||
Bouton **Redeploy**.
|
||||
|
||||
### Après une modification de contenu
|
||||
|
||||
Pense à mettre à jour la date dans `sitemap.xml` :
|
||||
|
||||
```xml
|
||||
<lastmod>2026-08-05</lastmod>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test local avant déploiement
|
||||
|
||||
Si tu veux vérifier l'image sur ta machine :
|
||||
|
||||
```bash
|
||||
docker build -t courcellematthi .
|
||||
docker run --rm -p 8080:80 courcellematthi
|
||||
# puis http://localhost:8080
|
||||
```
|
||||
|
||||
Contrôles utiles :
|
||||
|
||||
```bash
|
||||
# Poids de l'image
|
||||
docker images courcellematthi
|
||||
|
||||
# Le dossier deploy/ ne doit PAS être dans l'image
|
||||
docker run --rm courcellematthi ls /usr/share/nginx/html
|
||||
|
||||
# La configuration Nginx est syntaxiquement correcte
|
||||
docker run --rm courcellematthi nginx -t
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Une fois en ligne
|
||||
|
||||
1. **Google Search Console** — ajoute la propriété et soumets
|
||||
`https://www.courcellematthi.eu/sitemap.xml`
|
||||
2. **Test des résultats enrichis** — vérifie le JSON-LD sur
|
||||
`search.google.com/test/rich-results`
|
||||
3. **Débogueur LinkedIn** — force le rafraîchissement de l'aperçu de partage
|
||||
sur `linkedin.com/post-inspector` (LinkedIn met les aperçus en cache
|
||||
longtemps : sans cette étape, un ancien aperçu peut persister des semaines)
|
||||
4. **securityheaders.com** — la note attendue est A ou A+
|
||||
5. **PageSpeed Insights** — contrôle les Core Web Vitals
|
||||
|
||||
---
|
||||
|
||||
## Si quelque chose ne va pas
|
||||
|
||||
| Symptôme | Cause probable |
|
||||
|---|---|
|
||||
| `failed to read dockerfile: open Dockerfile: no such file or directory` avec `transferring dockerfile: 2B` | `Dockerfile` listé dans `.dockerignore` — voir ci-dessous |
|
||||
| `failed to read dockerfile` sans la ligne `2B` | Fichier réellement absent du dépôt, ou mauvais **Dockerfile Location** dans Coolify |
|
||||
| 404 Traefik, conteneur sain | Conteneur hors du réseau `coolify` |
|
||||
| 502 Bad Gateway | Étiquette `loadbalancer.server.port` absente |
|
||||
| Certificat non émis | DNS non propagé, ou proxy Cloudflare actif |
|
||||
| Page blanche, CSS absent | Chemins `/assets/...` — vérifier que la racine servie est bien `/usr/share/nginx/html` |
|
||||
| Polices non chargées | CSP trop stricte — `fonts.googleapis.com` et `fonts.gstatic.com` doivent être autorisés |
|
||||
| Ancienne version affichée | Cache navigateur sur `index.html` — vérifier l'en-tête `Cache-Control: must-revalidate` |
|
||||
| Boucle de redirection | Redirection HTTPS dupliquée : Traefik **et** Nginx. Le `deploy/nginx.conf` fourni n'en contient pas — vérifier qu'on n'a pas utilisé `nginx.conf.example` par erreur |
|
||||
|
||||
### Le Dockerfile filtré par `.dockerignore`
|
||||
|
||||
Journal caractéristique :
|
||||
|
||||
```
|
||||
#1 transferring dockerfile: 2B done
|
||||
ERROR: failed to solve: failed to read dockerfile:
|
||||
open Dockerfile: no such file or directory
|
||||
```
|
||||
|
||||
La ligne **`2B`** est le signal décisif : BuildKit a bien reçu un contexte, mais
|
||||
le Dockerfile qu'il en extrait est vide. Le fichier existe donc dans le dépôt —
|
||||
c'est `.dockerignore` qui l'a rendu invisible.
|
||||
|
||||
BuildKit, utilisé par Coolify et par Docker depuis la version 23, applique les
|
||||
exclusions **avant** de lire le Dockerfile. Le builder classique tolérait ce
|
||||
cas ; BuildKit non.
|
||||
|
||||
Vérification :
|
||||
|
||||
```bash
|
||||
grep -nE '^\s*(Dockerfile|\.dockerignore)\s*$' .dockerignore
|
||||
```
|
||||
|
||||
Si l'une des deux lignes ressort, supprime-la et repousse. Ces fichiers n'ont
|
||||
pas besoin d'y figurer : le `RUN rm` du Dockerfile les retire déjà de l'image
|
||||
finale, donc ils ne sont jamais servis au public.
|
||||
|
||||
### Vérifier que le fichier est bien dans le dépôt
|
||||
|
||||
Si la ligne `2B` **n'apparaît pas**, le fichier est réellement absent :
|
||||
|
||||
```bash
|
||||
git ls-files | grep -E '^Dockerfile$'
|
||||
```
|
||||
|
||||
Sans résultat, il n'a pas été commité :
|
||||
|
||||
```bash
|
||||
git add Dockerfile .dockerignore deploy/
|
||||
git commit -m "Ajout des fichiers de déploiement"
|
||||
git push
|
||||
```
|
||||
|
||||
Contrôle également, dans **Configuration → Build** de Coolify :
|
||||
|
||||
- **Base Directory** : `/`
|
||||
- **Dockerfile Location** : `/Dockerfile`
|
||||
|
||||
Si le dépôt contient le site dans un sous-dossier, ces deux chemins doivent
|
||||
être ajustés en conséquence.
|
||||
|
||||
Journaux :
|
||||
|
||||
```bash
|
||||
docker logs -f <nom_du_conteneur> # Conteneur du site
|
||||
docker logs -f coolify-proxy # Traefik
|
||||
```
|
||||
@@ -0,0 +1,79 @@
|
||||
# =============================================================================
|
||||
# Dockerfile — Site statique courcellematthi.eu
|
||||
# -----------------------------------------------------------------------------
|
||||
# Image mono-étape : le site n'a aucune dépendance et aucune compilation.
|
||||
# Une étape de build (Node, npm install…) n'apporterait ici que du temps
|
||||
# de déploiement et de la surface d'attaque en plus.
|
||||
#
|
||||
# Image finale : environ 50 Mo, dont 48 Mo pour Nginx Alpine.
|
||||
# =============================================================================
|
||||
|
||||
# Version épinglée plutôt que « latest » : un redéploiement six mois plus tard
|
||||
# doit produire exactement la même image. « latest » rend les builds
|
||||
# non reproductibles et peut casser la production sans qu'on ait rien changé.
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# MÉTADONNÉES (spécification OCI)
|
||||
# Visibles via « docker inspect », utiles pour l'inventaire et le débogage.
|
||||
# -----------------------------------------------------------------------------
|
||||
LABEL org.opencontainers.image.title="courcellematthi.eu"
|
||||
LABEL org.opencontainers.image.description="Site personnel et CV de Matthieu Courcelle"
|
||||
LABEL org.opencontainers.image.authors="Matthieu Courcelle <m@tthieu.be>"
|
||||
LABEL org.opencontainers.image.url="https://www.courcellematthi.eu"
|
||||
LABEL org.opencontainers.image.licenses="UNLICENSED"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# CONFIGURATION NGINX
|
||||
# On remplace le fichier par défaut du site, pas nginx.conf global : la
|
||||
# configuration de base de l'image (workers, types MIME, limites) est saine.
|
||||
# -----------------------------------------------------------------------------
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
COPY deploy/nginx.conf /etc/nginx/conf.d/site.conf
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# CONTENU DU SITE
|
||||
# .dockerignore exclut le superflu (README, configs alternatives, .git).
|
||||
# -----------------------------------------------------------------------------
|
||||
COPY . /usr/share/nginx/html/
|
||||
|
||||
# Retire du conteneur les fichiers qui n'ont rien à y faire : documentation,
|
||||
# configurations serveur alternatives, sources de déploiement. Ils sont utiles
|
||||
# dans le dépôt, pas dans l'image servie au public.
|
||||
RUN rm -rf /usr/share/nginx/html/deploy \
|
||||
/usr/share/nginx/html/Dockerfile \
|
||||
/usr/share/nginx/html/.dockerignore \
|
||||
/usr/share/nginx/html/docker-compose.yaml \
|
||||
/usr/share/nginx/html/README.md \
|
||||
/usr/share/nginx/html/DEPLOIEMENT-COOLIFY.md \
|
||||
/usr/share/nginx/html/nginx.conf.example \
|
||||
/usr/share/nginx/html/.htaccess \
|
||||
/usr/share/nginx/html/verifier-depot.sh
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# PERMISSIONS
|
||||
# Lecture seule pour tous, aucun fichier exécutable : rien dans un site
|
||||
# statique n'a besoin du bit d'exécution, sauf les répertoires (parcours).
|
||||
# -----------------------------------------------------------------------------
|
||||
RUN chmod -R a=r,u+w /usr/share/nginx/html && \
|
||||
find /usr/share/nginx/html -type d -exec chmod a+x {} \;
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# RÉSEAU
|
||||
# EXPOSE est purement déclaratif : il documente le port et permet à Coolify
|
||||
# de le détecter automatiquement. Il n'ouvre rien par lui-même.
|
||||
# -----------------------------------------------------------------------------
|
||||
EXPOSE 80
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SONDE DE SANTÉ
|
||||
# Coolify affiche l'état du conteneur et peut le redémarrer s'il ne répond
|
||||
# plus. wget est présent dans l'image Alpine, inutile d'installer curl.
|
||||
# -----------------------------------------------------------------------------
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://localhost/healthz || exit 1
|
||||
|
||||
# L'image officielle définit déjà le bon CMD ; on le réécrit pour rendre
|
||||
# explicite le fonctionnement au premier plan (daemon off), indispensable
|
||||
# pour que Docker suive correctement le cycle de vie du processus.
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,343 @@
|
||||
# courcellematthi.eu
|
||||
|
||||
Site personnel et CV en ligne de Matthieu Courcelle.
|
||||
|
||||
Statique, sans dépendance, sans étape de compilation. Aucun framework, aucun
|
||||
gestionnaire de paquets, aucun outil de build : les fichiers déposés sur le
|
||||
serveur sont exactement ceux qui sont servis au navigateur.
|
||||
|
||||
---
|
||||
|
||||
## Arborescence
|
||||
|
||||
```
|
||||
.
|
||||
├── index.html Document unique, sections ancrées
|
||||
├── favicon.ico Repli multi-résolutions (16/32/48)
|
||||
├── favicon.svg Icône vectorielle — monogramme MC
|
||||
├── robots.txt Directives d'exploration, y compris robots IA
|
||||
├── sitemap.xml Plan du site
|
||||
├── site.webmanifest Manifeste d'application web
|
||||
├── llms.txt Résumé structuré pour agents conversationnels
|
||||
├── humans.txt Crédits (convention humanstxt.org)
|
||||
├── .htaccess Configuration Apache (mutualisé)
|
||||
├── nginx.conf.example Configuration Nginx (serveur dédié, hors conteneur)
|
||||
├── Dockerfile Image du conteneur
|
||||
├── docker-compose.yaml Déploiement Coolify (mode Compose)
|
||||
├── .dockerignore Exclusions du contexte de build
|
||||
├── DEPLOIEMENT-COOLIFY.md Guide de mise en ligne pas à pas
|
||||
├── deploy/
|
||||
│ └── nginx.conf Configuration Nginx DANS le conteneur
|
||||
├── .well-known/
|
||||
│ └── security.txt Contact de divulgation (RFC 9116)
|
||||
└── assets/
|
||||
├── css/
|
||||
│ ├── tokens.css Variables + ordre de cascade
|
||||
│ ├── base.css Reset et éléments HTML natifs
|
||||
│ ├── layout.css Conteneurs, en-tête, navigation, grilles
|
||||
│ ├── components.css Blocs réutilisables (BEM)
|
||||
│ ├── utilities.css Classes utilitaires
|
||||
│ └── print.css Feuille d'impression (media="print")
|
||||
├── js/
|
||||
│ ├── main.js Point d'entrée (module ES)
|
||||
│ └── modules/
|
||||
│ ├── navigation.js Menu mobile, piège de focus
|
||||
│ ├── scroll-spy.js Section active (IntersectionObserver)
|
||||
│ ├── back-to-top.js Bouton de remontée
|
||||
│ └── copy-email.js Copie de l'adresse e-mail
|
||||
└── img/
|
||||
├── og-image.png Bannière de partage 1200×630
|
||||
├── apple-touch-icon.png Écran d'accueil iOS
|
||||
├── icon-192.png PWA
|
||||
├── icon-512.png PWA
|
||||
├── icon-maskable-512.png PWA, zone sûre Android
|
||||
├── favicon-16/32/48.png Replis
|
||||
└── safari-pinned-tab.svg Onglet épinglé Safari
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture CSS
|
||||
|
||||
### Couches en cascade
|
||||
|
||||
L'ordre de priorité est déclaré une seule fois, en tête de `tokens.css` :
|
||||
|
||||
```css
|
||||
@layer tokens, base, layout, components, utilities;
|
||||
```
|
||||
|
||||
Conséquence : une règle de `utilities` l'emporte sur une règle de `components`
|
||||
même si son sélecteur est moins spécifique, et l'ordre des balises `<link>`
|
||||
dans le `<head>` n'a aucune incidence. C'est ce qui permet de se passer
|
||||
entièrement de `!important` et des sélecteurs à rallonge écrits uniquement
|
||||
pour gagner un duel de spécificité.
|
||||
|
||||
### Pas de `@import`
|
||||
|
||||
Les feuilles sont liées séparément dans le `<head>`. Un `@import` obligerait
|
||||
le navigateur à télécharger et analyser le fichier parent avant de découvrir
|
||||
l'existence de l'enfant : les requêtes s'enchaînent en série au lieu de
|
||||
partir en parallèle. Sur cinq fichiers, l'écart est mesurable.
|
||||
|
||||
### Jetons de conception
|
||||
|
||||
Toute couleur, taille, espacement ou durée est déclarée dans `tokens.css`.
|
||||
Aucune valeur littérale n'apparaît ailleurs. Changer l'identité visuelle du
|
||||
site ne demande donc la modification que d'un seul fichier.
|
||||
|
||||
Le fichier ne contient aucun jeton inutilisé : 56 variables définies,
|
||||
56 utilisées.
|
||||
|
||||
### Propriétés logiques
|
||||
|
||||
`inline-size` plutôt que `width`, `padding-block` plutôt que
|
||||
`padding-top/bottom`, `inset-inline-start` plutôt que `left`. Le site est
|
||||
en français, mais ces propriétés sont aujourd'hui le standard et rendent
|
||||
une éventuelle version en écriture de droite à gauche triviale.
|
||||
|
||||
### Typographie fluide
|
||||
|
||||
`clamp()` sur toutes les tailles de titre. Le terme central mêle `rem` et
|
||||
`vw` : le `rem` garantit que le zoom navigateur et la taille de police
|
||||
système continuent d'agir (une valeur en `vw` seule casserait
|
||||
l'accessibilité), le `vw` apporte la fluidité entre les points de rupture.
|
||||
|
||||
Résultat : une seule media query dans tout le projet, pour la bascule du
|
||||
menu en mode mobile.
|
||||
|
||||
---
|
||||
|
||||
## Architecture JavaScript
|
||||
|
||||
### Amélioration progressive
|
||||
|
||||
Le site est intégralement lisible, navigable et imprimable sans JavaScript.
|
||||
Aucun contenu n'est injecté par script — le HTML servi est complet. C'est ce
|
||||
qui garantit l'indexation par les moteurs et la lecture par les agents
|
||||
conversationnels, dont beaucoup n'exécutent pas JavaScript.
|
||||
|
||||
Les quatre modules n'ajoutent que du confort : repli du menu sur petit écran,
|
||||
surbrillance de la section lue, bouton de remontée, copie de l'adresse.
|
||||
|
||||
### Modules ES natifs
|
||||
|
||||
`<script type="module">` implique `defer` : le téléchargement est parallèle à
|
||||
l'analyse du HTML, l'exécution a lieu après. Le script est donc dans le
|
||||
`<head>` sans bloquer le rendu — la vieille habitude de le placer en fin de
|
||||
`<body>` n'a plus lieu d'être. Les modules apportent en prime le mode strict
|
||||
et une portée isolée, sans aucune variable globale.
|
||||
|
||||
### Pas d'écouteur de défilement
|
||||
|
||||
Le scroll-spy et le bouton de remontée reposent sur `IntersectionObserver`.
|
||||
Un écouteur `scroll` se déclenche des dizaines de fois par seconde et impose
|
||||
d'appeler `getBoundingClientRect()` à chaque fois, ce qui force le navigateur
|
||||
à recalculer la mise en page (*layout thrashing*) et fait chuter la fluidité.
|
||||
`IntersectionObserver` délègue ce travail au moteur de rendu, hors du fil
|
||||
principal.
|
||||
|
||||
---
|
||||
|
||||
## Accessibilité
|
||||
|
||||
| Point | Mise en œuvre |
|
||||
|---|---|
|
||||
| Contourner les blocs (WCAG 2.4.1) | Lien d'évitement, premier élément focusable |
|
||||
| Structure sémantique | `header`, `nav`, `main`, `section`, `article`, `footer` |
|
||||
| Repères nommés | `aria-label` sur chaque `<nav>`, `aria-labelledby` sur chaque section |
|
||||
| État des composants | `aria-expanded` et `aria-controls` sur le bouton de menu |
|
||||
| Position courante (2.4.8) | `aria-current="true"` sur le lien de section active |
|
||||
| Messages dynamiques | Région `role="status" aria-live="polite"` |
|
||||
| Focus visible | `:focus-visible`, jamais `outline: none` sans remplacement |
|
||||
| Piège de focus | Tabulation bouclée dans le menu mobile ouvert |
|
||||
| Touche Échap | Ferme le menu et rend le focus au bouton |
|
||||
| Cibles tactiles (2.5.5) | 44 px minimum, vérifié à 44 px exactement |
|
||||
| Couleur non porteuse seule (1.4.1) | Section active signalée par couleur **et** bordure |
|
||||
| Contrastes | Texte principal 14,8:1, corps 7,4:1, liens 4,3:1 |
|
||||
| Animations | `prefers-reduced-motion` respecté, y compris pour le défilement |
|
||||
| Contraste renforcé | `prefers-contrast: more` ajuste la palette |
|
||||
| Images décoratives | `aria-hidden="true"` sur les 20 SVG d'ornement |
|
||||
|
||||
L'accent pur `#05BC8A` n'est jamais utilisé pour du texte sur fond clair : son
|
||||
contraste (2,3:1) est insuffisant. Il sert uniquement de trait, de bordure et
|
||||
de puce. Les liens utilisent `#03996F` (4,3:1).
|
||||
|
||||
---
|
||||
|
||||
## Référencement
|
||||
|
||||
### Balises
|
||||
|
||||
`title` de 56 caractères (sous le seuil de troncature de 60), description de
|
||||
153 caractères (plage utile 120-170), `rel="canonical"`, `hreflang` avec
|
||||
`x-default`, directives `robots` explicites avec `max-image-preview:large`.
|
||||
|
||||
### Données structurées
|
||||
|
||||
Graphe JSON-LD schema.org à trois entités reliées par `@id` :
|
||||
`WebSite` → `ProfilePage` → `Person`, avec `worksFor`, `alumniOf`,
|
||||
`hasCredential`, `hasOccupation` et `knowsAbout`.
|
||||
|
||||
Double bénéfice : Google associe la page à une entité « personne » plutôt
|
||||
qu'à un simple document, et les agents conversationnels disposent d'une
|
||||
déclaration non ambiguë plutôt que d'avoir à inférer depuis la prose.
|
||||
|
||||
### Fichier `llms.txt`
|
||||
|
||||
Résumé du site en texte brut à destination des moteurs de réponse et des
|
||||
agents. Contient une section « Notes à l'attention des agents » qui lève
|
||||
explicitement les pièges : l'établissement a changé de nom (IPEPS
|
||||
Tournai-Leuze = Institut Gabrielle Petit), `underscorelab` s'écrit en
|
||||
minuscules, et les projets menés pour des établissements tiers ne sont pas
|
||||
des fonctions officielles.
|
||||
|
||||
### Robots IA
|
||||
|
||||
`robots.txt` autorise explicitement GPTBot, ClaudeBot, PerplexityBot,
|
||||
Google-Extended et Applebot-Extended. Arbitrage assumé : sur un CV, être cité
|
||||
par un agent lors d'une recherche de profil est un bénéfice. Basculer ces
|
||||
blocs en `Disallow` si l'arbitrage change.
|
||||
|
||||
---
|
||||
|
||||
## Partage sur les réseaux sociaux
|
||||
|
||||
Bannière `og:image` de 1200×630 (ratio 1,91:1) générée à partir du monogramme,
|
||||
avec `og:image:width`, `og:image:height` et `og:image:alt` — les dimensions
|
||||
déclarées évitent le recadrage aléatoire sur LinkedIn. URL absolue
|
||||
obligatoire : les robots sociaux ne résolvent pas les chemins relatifs.
|
||||
|
||||
Open Graph de type `profile` avec `profile:first_name` / `profile:last_name`,
|
||||
carte Twitter en `summary_large_image`.
|
||||
|
||||
---
|
||||
|
||||
## Impression
|
||||
|
||||
Un CV est imprimé et exporté en PDF : `print.css` traite ce cas comme un
|
||||
livrable, pas comme un accident.
|
||||
|
||||
- Format A4, marges 15/14 mm, marge haute réduite sur la première page
|
||||
- Navigation, boutons et bouton de remontée masqués
|
||||
- Couleurs remises à plat en noir sur blanc : lisible sur imprimante
|
||||
monochrome, où le vert `#05BC8A` sortirait en gris quasi invisible
|
||||
- URL des liens externes affichées entre parenthèses ; `mailto:` et ancres
|
||||
internes exclus, où l'affichage serait redondant ou absurde
|
||||
- Fiches jamais coupées entre deux pages, mais entrées de parcours coupables :
|
||||
interdire la coupure des entrées faisait basculer des blocs entiers et
|
||||
gaspillait une page complète
|
||||
- `orphans` / `widows` à 3 pour éviter les lignes isolées
|
||||
|
||||
Résultat mesuré : 5 pages A4, sans titre séparé de son contenu.
|
||||
|
||||
---
|
||||
|
||||
## Sécurité
|
||||
|
||||
Deux configurations serveur fournies, à choisir selon l'hébergement
|
||||
(`nginx.conf.example` ou `.htaccess` — jamais les deux).
|
||||
|
||||
Elles couvrent : redirection HTTP → HTTPS, redirection vers l'URL canonique,
|
||||
HSTS, Content-Security-Policy, `X-Content-Type-Options`, `Referrer-Policy`,
|
||||
`Permissions-Policy`, `X-Frame-Options`, compression gzip/brotli, cache
|
||||
différencié (HTML revalidé systématiquement, assets mis en cache longuement),
|
||||
types MIME explicites et blocage du listage de répertoires.
|
||||
|
||||
> **Avertissement HSTS.** Ne l'activer qu'une fois le HTTPS confirmé
|
||||
> fonctionnel. Un `Strict-Transport-Security` émis par erreur rend le site
|
||||
> inaccessible jusqu'à expiration, sans possibilité d'annulation côté serveur.
|
||||
|
||||
La directive `script-src` inclut `'unsafe-inline'` uniquement à cause de
|
||||
l'attribut `onclick` du bouton d'impression. Déplacer ce bouton dans un module
|
||||
permettrait de durcir la CSP.
|
||||
|
||||
---
|
||||
|
||||
## Déploiement conteneurisé (Coolify)
|
||||
|
||||
Le projet contient tout le nécessaire pour un déploiement Coolify derrière
|
||||
Traefik. Voir **DEPLOIEMENT-COOLIFY.md** pour la procédure complète.
|
||||
|
||||
Point d'attention : il existe **deux** configurations Nginx, et elles ne sont
|
||||
pas interchangeables.
|
||||
|
||||
| Fichier | Usage | TLS |
|
||||
|---|---|---|
|
||||
| `nginx.conf.example` | Serveur Nginx classique, sans conteneur | Gère TLS et redirections |
|
||||
| `deploy/nginx.conf` | À l'intérieur du conteneur Docker | **Aucun** — Traefik s'en charge |
|
||||
|
||||
Utiliser `nginx.conf.example` dans le conteneur provoquerait une boucle de
|
||||
redirection infinie : Traefik transmet la requête en HTTP clair, le conteneur
|
||||
la redirigerait vers HTTPS, ce qui repasserait par Traefik.
|
||||
|
||||
Le HSTS n'est pas émis par le conteneur mais par Traefik : un réglage
|
||||
irréversible n'a pas sa place dans une couche redéployée à chaque `git push`.
|
||||
|
||||
## Développement local
|
||||
|
||||
Les modules ES ne fonctionnent pas en `file://` : un serveur HTTP est requis.
|
||||
|
||||
```bash
|
||||
python3 -m http.server 8000
|
||||
# puis http://localhost:8000
|
||||
```
|
||||
|
||||
## Déploiement
|
||||
|
||||
Copier la racine du projet vers le serveur. Aucune compilation.
|
||||
|
||||
Après déploiement, penser à :
|
||||
|
||||
1. Mettre à jour `<lastmod>` dans `sitemap.xml`
|
||||
2. Soumettre le sitemap dans Google Search Console
|
||||
3. Vérifier le rendu des partages via le débogueur LinkedIn et l'outil de
|
||||
test des résultats enrichis de Google
|
||||
4. Contrôler les en-têtes sur securityheaders.com
|
||||
|
||||
---
|
||||
|
||||
## Vérifications effectuées
|
||||
|
||||
Testé dans Chromium via Playwright, en 1280×900 et 390×844 :
|
||||
|
||||
- Aucune erreur JavaScript en console
|
||||
- Scroll-spy fonctionnel (section active correctement détectée)
|
||||
- Menu mobile : ouverture, fermeture, touche Échap, `aria-expanded` cohérent
|
||||
- Aucun débordement horizontal en 390 px
|
||||
- Navigation par ancre : le titre ciblé ne passe pas sous l'en-tête collant
|
||||
(217 px contre 57 px de hauteur d'en-tête)
|
||||
- Première tabulation : focus sur le lien d'évitement
|
||||
- Cible tactile du menu mobile : 44 px
|
||||
- Export PDF : 5 pages A4, mise en page correcte
|
||||
- Balises HTML équilibrées, un seul `<h1>`, JSON-LD valide, toutes les
|
||||
ancres résolvent, aucune ressource manquante, aucune classe HTML
|
||||
sans style, aucun jeton CSS inutilisé
|
||||
- Contenu de l'image Docker simulé et servi : 29 fichiers publiés, les
|
||||
fichiers de build (`Dockerfile`, `deploy/`, `README.md`, `.htaccess`,
|
||||
`docker-compose.yaml`) renvoient bien 404
|
||||
|
||||
---
|
||||
|
||||
## Pistes d'amélioration
|
||||
|
||||
**Auto-héberger les polices.** JetBrains Mono et Inter proviennent
|
||||
actuellement de Google Fonts. Les héberger dans `assets/fonts/` supprimerait
|
||||
une dépendance tierce, un point de friction RGPD pour un site belge, et deux
|
||||
résolutions DNS. À faire avec `preload` sur les fichiers `.woff2` critiques
|
||||
et `font-display: swap` dans la déclaration `@font-face`.
|
||||
|
||||
**Durcir la CSP.** Retirer `'unsafe-inline'` de `script-src` en déplaçant le
|
||||
bouton d'impression dans un module JavaScript.
|
||||
|
||||
**Mesure d'audience.** Si un suivi devient nécessaire, privilégier une
|
||||
solution sans cookie et auto-hébergée (Umami, Plausible) plutôt que Google
|
||||
Analytics : pas de bandeau de consentement requis, pas de transfert de
|
||||
données hors UE.
|
||||
|
||||
**Photo de profil.** Le CV imprimé d'origine en comportait une. Son absence
|
||||
sur le site est un choix par défaut, pas une décision — à trancher.
|
||||
|
||||
**Informations de contact.** Le numéro de téléphone est absent. À ajouter
|
||||
dans la liste de contacts, le JSON-LD (`telephone`) et `llms.txt` si
|
||||
souhaité.
|
||||
@@ -0,0 +1,181 @@
|
||||
/* ==========================================================================
|
||||
base.css — Reset moderne et styles des éléments HTML natifs
|
||||
--------------------------------------------------------------------------
|
||||
Uniquement des sélecteurs d'éléments, aucune classe. Objectif : un HTML
|
||||
sémantique écrit sans classe doit déjà être présentable et accessible.
|
||||
========================================================================== */
|
||||
|
||||
@layer base {
|
||||
|
||||
/* ======================================================================
|
||||
RESET
|
||||
Reset ciblé plutôt que « * { margin:0; padding:0 } » aveugle : on
|
||||
neutralise ce qui gêne, on garde ce qui est utile (styles de listes,
|
||||
comportements natifs des formulaires).
|
||||
====================================================================== */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box; /* La largeur inclut padding et bordure */
|
||||
}
|
||||
|
||||
/* Suppression des marges par défaut : elles varient d'un navigateur à
|
||||
l'autre et provoquent des fusions de marges imprévisibles. Les
|
||||
espacements sont pilotés explicitement par les composants. */
|
||||
body, h1, h2, h3, h4, h5, h6,
|
||||
p, figure, blockquote, dl, dd {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
/* Empêche iOS d'agrandir le texte en paysage sans demande explicite */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
/* Défilement fluide pour les ancres — neutralisé plus bas si
|
||||
l'utilisateur a demandé la réduction des animations */
|
||||
scroll-behavior: smooth;
|
||||
|
||||
/* Décale la cible des ancres pour qu'elle ne passe pas sous
|
||||
l'en-tête collant. Indispensable dès qu'un header est sticky. */
|
||||
scroll-padding-block-start: calc(var(--header-height) + var(--space-md));
|
||||
}
|
||||
|
||||
body {
|
||||
min-block-size: 100svh; /* svh : hauteur stable malgré la barre d'URL mobile */
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
|
||||
/* Lissage : rend Inter plus fidèle sur écrans macOS */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* Ligatures et crénage activés pour le texte courant */
|
||||
text-rendering: optimizeLegibility;
|
||||
font-kerning: normal;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
MÉDIAS
|
||||
====================================================================== */
|
||||
|
||||
img, picture, svg, video, canvas {
|
||||
display: block; /* Supprime l'espace fantôme sous les images
|
||||
(dû à l'alignement sur la ligne de base) */
|
||||
max-inline-size: 100%; /* Jamais de débordement horizontal */
|
||||
block-size: auto; /* Préserve le rapport d'aspect */
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
TYPOGRAPHIE
|
||||
====================================================================== */
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: var(--weight-bold);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: -0.02em; /* Compense l'aération naturelle du monospace
|
||||
aux grandes tailles */
|
||||
text-wrap: balance; /* Répartit les mots sur des lignes de longueur
|
||||
comparable — évite le mot orphelin */
|
||||
}
|
||||
|
||||
p, li, dd, figcaption {
|
||||
text-wrap: pretty; /* Évite les orphelins en fin de paragraphe */
|
||||
}
|
||||
|
||||
/* Les listes porteuses de role="list" sont des listes sémantiques dont on
|
||||
ne veut pas la puce native. Le rôle explicite est nécessaire : Safari
|
||||
retire la sémantique de liste quand list-style vaut none. */
|
||||
ul[role="list"],
|
||||
ol[role="list"] {
|
||||
list-style: none;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
/* Texte mis en avant */
|
||||
strong, b { font-weight: var(--weight-semibold); }
|
||||
|
||||
/* Abréviations : curseur d'aide + soulignement pointillé */
|
||||
abbr[title] {
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
LIENS
|
||||
====================================================================== */
|
||||
|
||||
a {
|
||||
color: var(--color-accent-dark);
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 0.2em; /* Le soulignement ne coupe pas les jambages */
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
a:hover { color: var(--color-accent); }
|
||||
|
||||
/* ======================================================================
|
||||
ACCESSIBILITÉ
|
||||
====================================================================== */
|
||||
|
||||
/* :focus-visible plutôt que :focus — l'anneau apparaît à la navigation
|
||||
clavier mais pas au clic souris. Ne jamais faire « outline: none »
|
||||
sans remplacement visible : c'est la faute d'accessibilité la plus
|
||||
répandue sur le web. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 3px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Marge lors du saut vers une ancre (complément de scroll-padding) */
|
||||
:target {
|
||||
scroll-margin-block-start: calc(var(--header-height) + var(--space-md));
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
FORMULAIRES ET BOUTONS
|
||||
====================================================================== */
|
||||
|
||||
button, input, select, textarea {
|
||||
font: inherit; /* Les navigateurs n'héritent pas la police par défaut */
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
PRÉFÉRENCES SYSTÈME
|
||||
Respecter les réglages d'accessibilité de l'utilisateur n'est pas
|
||||
optionnel : les animations peuvent déclencher nausées et migraines
|
||||
chez les personnes sensibles aux troubles vestibulaires.
|
||||
====================================================================== */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mode contraste élevé (Windows, préférence système) */
|
||||
@media (prefers-contrast: more) {
|
||||
:root {
|
||||
--color-ink-faint: #4c5a56;
|
||||
--color-line: #9aa5a2;
|
||||
--color-line-strong: #6b7674;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
/* ==========================================================================
|
||||
components.css — Blocs réutilisables
|
||||
--------------------------------------------------------------------------
|
||||
Nomenclature BEM : .bloc, .bloc__element, .bloc--modificateur.
|
||||
Intérêt : la spécificité reste plate (une classe = 0,1,0), le nom dit
|
||||
à quoi appartient l'élément, et aucun style ne fuit hors de son bloc.
|
||||
========================================================================== */
|
||||
|
||||
@layer components {
|
||||
|
||||
/* ======================================================================
|
||||
EYEBROW — étiquette au-dessus d'un titre
|
||||
====================================================================== */
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-accent-dark);
|
||||
margin-block-end: var(--space-md);
|
||||
}
|
||||
|
||||
/* Le prompt de terminal est purement décoratif : généré en CSS, il
|
||||
n'est donc pas lu par les lecteurs d'écran ni copié avec le texte. */
|
||||
.eyebrow::before {
|
||||
content: ">_ ";
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
TITRES
|
||||
====================================================================== */
|
||||
|
||||
.page-title {
|
||||
font-size: var(--text-2xl);
|
||||
letter-spacing: -0.03em;
|
||||
margin-block-end: var(--space-sm);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-regular);
|
||||
line-height: var(--leading-snug);
|
||||
color: var(--color-ink-soft);
|
||||
max-inline-size: 40ch;
|
||||
margin-block-end: var(--space-lg);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-xl);
|
||||
margin-block-end: var(--space-xl);
|
||||
scroll-margin-block-start: calc(var(--header-height) + var(--space-lg));
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: ">_ ";
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
LISTE DE CONTACTS
|
||||
====================================================================== */
|
||||
|
||||
.contact-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs) var(--space-lg);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-ink-faint);
|
||||
}
|
||||
|
||||
.contact-list li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2xs);
|
||||
}
|
||||
|
||||
.contact-list__icon {
|
||||
inline-size: 0.95rem;
|
||||
block-size: 0.95rem;
|
||||
stroke: var(--color-accent);
|
||||
stroke-width: 1.8;
|
||||
fill: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
BOUTONS D'ACTION
|
||||
Deux niveaux seulement : principal (plein) et secondaire (contour).
|
||||
Au-delà, la hiérarchie visuelle devient illisible.
|
||||
====================================================================== */
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin-block-start: var(--space-lg);
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-md);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
transition: background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.button__icon {
|
||||
inline-size: 1rem;
|
||||
block-size: 1rem;
|
||||
stroke: currentcolor;
|
||||
stroke-width: 1.9;
|
||||
fill: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
background-color: var(--color-accent-dark);
|
||||
color: var(--color-ink-inverse);
|
||||
}
|
||||
|
||||
.button--primary:hover {
|
||||
background-color: var(--color-ink);
|
||||
color: var(--color-ink-inverse);
|
||||
}
|
||||
|
||||
.button--secondary {
|
||||
background-color: var(--color-surface);
|
||||
border-color: var(--color-line-strong);
|
||||
color: var(--color-ink-soft);
|
||||
}
|
||||
|
||||
.button--secondary:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
INTRODUCTION
|
||||
====================================================================== */
|
||||
|
||||
.intro { padding-block: var(--space-2xl) var(--space-xl); }
|
||||
|
||||
.intro__heading {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-snug);
|
||||
max-inline-size: 34ch;
|
||||
margin-block-end: var(--space-lg);
|
||||
}
|
||||
|
||||
.intro__text {
|
||||
color: var(--color-ink-soft);
|
||||
max-inline-size: var(--width-prose);
|
||||
margin-block-end: var(--space-md);
|
||||
}
|
||||
|
||||
.intro__text strong {
|
||||
color: var(--color-ink);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
/* Dernier paragraphe mis en exergue par un filet latéral plutôt que par
|
||||
un fond coloré : plus sobre, et imprimable sans consommer d'encre. */
|
||||
.intro__text--highlight {
|
||||
border-inline-start: 3px solid var(--color-accent);
|
||||
padding-inline-start: var(--space-lg);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
ENTRÉE DE PARCOURS (timeline)
|
||||
====================================================================== */
|
||||
|
||||
.entry {
|
||||
position: relative;
|
||||
padding-inline-start: var(--space-lg);
|
||||
padding-block-end: var(--space-xl);
|
||||
border-inline-start: 2px solid var(--color-line);
|
||||
}
|
||||
|
||||
/* La dernière entrée ne prolonge pas le trait dans le vide */
|
||||
.entry:last-child {
|
||||
padding-block-end: 0;
|
||||
border-inline-start-color: transparent;
|
||||
}
|
||||
|
||||
/* Pastille de jalon. Décorative : aucune information n'en dépend. */
|
||||
.entry::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline-start: -7px;
|
||||
inset-block-start: 0.55rem;
|
||||
inline-size: 12px;
|
||||
block-size: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-bg);
|
||||
border: 2px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.entry__date {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--color-accent-dark);
|
||||
margin-block-end: var(--space-2xs);
|
||||
}
|
||||
|
||||
.entry__role {
|
||||
font-size: var(--text-md);
|
||||
margin-block-end: var(--space-2xs);
|
||||
}
|
||||
|
||||
.entry__org {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-ink-faint);
|
||||
margin-block-end: var(--space-md);
|
||||
}
|
||||
|
||||
.entry__note {
|
||||
color: var(--color-ink-soft);
|
||||
font-size: var(--text-sm);
|
||||
max-inline-size: var(--width-prose);
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
FICHE DE COURS
|
||||
====================================================================== */
|
||||
|
||||
.course {
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
margin-block-end: var(--space-xs);
|
||||
transition: border-color var(--transition-base);
|
||||
}
|
||||
|
||||
.course:last-child { margin-block-end: 0; }
|
||||
.course:hover { border-color: var(--color-line-strong); }
|
||||
|
||||
.course__name {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-bold);
|
||||
margin-block-end: var(--space-2xs);
|
||||
}
|
||||
|
||||
.course__name::before {
|
||||
content: "▪ ";
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.course__desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-ink-soft);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
LISTE À PUCES PERSONNALISÉE
|
||||
Puce en ::before plutôt que list-style-image : couleur pilotable par
|
||||
variable, alignement contrôlé au pixel.
|
||||
====================================================================== */
|
||||
|
||||
.marker-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: var(--color-ink-soft);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.marker-list li {
|
||||
position: relative;
|
||||
padding-inline-start: var(--space-md);
|
||||
margin-block-end: var(--space-xs);
|
||||
}
|
||||
|
||||
.marker-list li:last-child { margin-block-end: 0; }
|
||||
|
||||
.marker-list li::before {
|
||||
content: "▪";
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
CARTE
|
||||
====================================================================== */
|
||||
|
||||
.card {
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: border-color var(--transition-base),
|
||||
transform var(--transition-base),
|
||||
box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--color-accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--text-base);
|
||||
margin-block-end: var(--space-sm);
|
||||
}
|
||||
|
||||
.card__icon {
|
||||
inline-size: 1.1rem;
|
||||
block-size: 1.1rem;
|
||||
stroke: var(--color-accent);
|
||||
stroke-width: 1.8;
|
||||
fill: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
PROJET
|
||||
====================================================================== */
|
||||
|
||||
.project {
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-lg);
|
||||
margin-block-end: var(--space-sm);
|
||||
transition: border-color var(--transition-base);
|
||||
}
|
||||
|
||||
.project:last-child { margin-block-end: 0; }
|
||||
.project:hover { border-color: var(--color-line-strong); }
|
||||
|
||||
.project__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2xs) var(--space-sm);
|
||||
margin-block-end: var(--space-xs);
|
||||
}
|
||||
|
||||
.project__title { font-size: var(--text-base); }
|
||||
|
||||
.project__desc {
|
||||
color: var(--color-ink-soft);
|
||||
font-size: var(--text-sm);
|
||||
max-inline-size: var(--width-prose);
|
||||
}
|
||||
|
||||
.project__desc + .project__desc { margin-block-start: var(--space-xs); }
|
||||
|
||||
.project__stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2xs);
|
||||
list-style: none;
|
||||
margin: var(--space-sm) 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.project__stack li {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--color-ink-faint);
|
||||
background-color: var(--color-surface-alt);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px var(--space-xs);
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
BADGE
|
||||
====================================================================== */
|
||||
|
||||
.badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--color-accent-dark);
|
||||
background-color: var(--color-accent-wash);
|
||||
border: 1px solid var(--color-accent-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px var(--space-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Variante neutre : signale un état non abouti sans l'emphase de l'accent */
|
||||
.badge--muted {
|
||||
color: var(--color-ink-faint);
|
||||
background-color: var(--color-surface-alt);
|
||||
border-color: var(--color-line-strong);
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
FORMATION
|
||||
====================================================================== */
|
||||
|
||||
.education {
|
||||
border-inline-start: 2px solid var(--color-line);
|
||||
padding-inline-start: var(--space-lg);
|
||||
}
|
||||
|
||||
.education__degree {
|
||||
font-size: var(--text-base);
|
||||
margin-block-end: var(--space-2xs);
|
||||
}
|
||||
|
||||
.education__school {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-ink-faint);
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
BLOC DE CONTACT (appel à l'action de fin de page)
|
||||
====================================================================== */
|
||||
|
||||
.contact-block {
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.contact-block__text {
|
||||
color: var(--color-ink-soft);
|
||||
max-inline-size: var(--width-prose);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/* ==========================================================================
|
||||
layout.css — Structure de page
|
||||
--------------------------------------------------------------------------
|
||||
Conteneurs, en-tête collant, navigation, sections, grilles, pied de page.
|
||||
Ce fichier positionne ; components.css habille.
|
||||
========================================================================== */
|
||||
|
||||
@layer layout {
|
||||
|
||||
/* ======================================================================
|
||||
CONTENEUR
|
||||
Un seul conteneur réutilisé partout : la largeur de contenu est décidée
|
||||
à un seul endroit.
|
||||
====================================================================== */
|
||||
|
||||
.container {
|
||||
inline-size: 100%;
|
||||
max-inline-size: var(--width-content);
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--gutter);
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
LIEN D'ÉVITEMENT
|
||||
Premier élément focusable de la page. Permet à un utilisateur au
|
||||
clavier ou au lecteur d'écran de sauter la navigation pour atteindre
|
||||
directement le contenu. Critère WCAG 2.4.1 (Contourner des blocs).
|
||||
Masqué visuellement hors focus, mais jamais display:none — sinon il
|
||||
ne serait pas focusable.
|
||||
====================================================================== */
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
inset-block-start: var(--space-xs);
|
||||
inset-inline-start: var(--space-xs);
|
||||
z-index: var(--z-skip-link);
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
background-color: var(--color-ink);
|
||||
color: var(--color-ink-inverse);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-sm);
|
||||
transform: translateY(-200%);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
color: var(--color-ink-inverse);
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
EN-TÊTE COLLANT
|
||||
====================================================================== */
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
z-index: var(--z-sticky);
|
||||
background-color: var(--color-bg-translucent);
|
||||
border-block-end: 1px solid var(--color-line);
|
||||
|
||||
/* Flou d'arrière-plan : le contenu qui défile dessous reste deviné
|
||||
sans nuire à la lisibilité du menu. Dégradation propre si non
|
||||
supporté (le fond translucide suffit). */
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.site-header__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
min-block-size: var(--header-height);
|
||||
}
|
||||
|
||||
.site-header__brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--color-ink);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-header__brand:hover { color: var(--color-ink); }
|
||||
|
||||
.site-header__logo {
|
||||
inline-size: 1.6rem;
|
||||
block-size: 1.6rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
NAVIGATION PRINCIPALE
|
||||
====================================================================== */
|
||||
|
||||
.site-nav__list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-lg);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.site-nav__link {
|
||||
display: inline-block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-ink-faint);
|
||||
text-decoration: none;
|
||||
padding-block: var(--space-2xs);
|
||||
border-block-end: 2px solid transparent;
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.site-nav__link:hover { color: var(--color-ink); }
|
||||
|
||||
/* Section courante. aria-current n'est pas qu'un sélecteur pratique :
|
||||
il annonce la position aux lecteurs d'écran. Le repère visuel repose
|
||||
sur la couleur ET sur une bordure — jamais sur la couleur seule
|
||||
(WCAG 1.4.1, information non véhiculée par la couleur uniquement). */
|
||||
.site-nav__link[aria-current="true"] {
|
||||
color: var(--color-ink);
|
||||
border-block-end-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
BOUTON MENU (mobile)
|
||||
====================================================================== */
|
||||
|
||||
.nav-toggle {
|
||||
display: none; /* Affiché uniquement sous le point de rupture */
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline: var(--space-xs);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-ink-soft);
|
||||
}
|
||||
|
||||
.nav-toggle__icon {
|
||||
inline-size: 1.15rem;
|
||||
block-size: 1.15rem;
|
||||
stroke: currentcolor;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
/* Les deux tracés de l'icône se transforment en croix à l'ouverture */
|
||||
.nav-toggle__line {
|
||||
transform-origin: center;
|
||||
transition: transform var(--transition-base), opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-toggle[aria-expanded="true"] .nav-toggle__line--top { transform: translateY(5px) rotate(45deg); }
|
||||
.nav-toggle[aria-expanded="true"] .nav-toggle__line--middle { opacity: 0; }
|
||||
.nav-toggle[aria-expanded="true"] .nav-toggle__line--bottom { transform: translateY(-5px) rotate(-45deg); }
|
||||
|
||||
/* ======================================================================
|
||||
RÉGIONS DE CONTENU
|
||||
====================================================================== */
|
||||
|
||||
.masthead {
|
||||
padding-block: var(--space-3xl) var(--space-2xl);
|
||||
border-block-end: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.section {
|
||||
padding-block: var(--space-3xl);
|
||||
border-block-start: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
GRILLE AUTO-ADAPTATIVE
|
||||
auto-fit + minmax : le nombre de colonnes s'ajuste seul à la largeur
|
||||
disponible, sans media query. Le min() interne évite le débordement
|
||||
sur les très petits écrans (une colonne ne peut pas être plus large
|
||||
que son conteneur).
|
||||
====================================================================== */
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(17.5rem, 100%), 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.grid--wide {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr));
|
||||
}
|
||||
|
||||
/* Empilement vertical régulier */
|
||||
.stack > * + * { margin-block-start: var(--space-md); }
|
||||
|
||||
/* ======================================================================
|
||||
PIED DE PAGE
|
||||
====================================================================== */
|
||||
|
||||
.site-footer {
|
||||
border-block-start: 1px solid var(--color-line);
|
||||
padding-block: var(--space-xl) var(--space-2xl);
|
||||
}
|
||||
|
||||
.site-footer__inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm) var(--space-lg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-ink-faint);
|
||||
}
|
||||
|
||||
.site-footer__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
BOUTON RETOUR EN HAUT
|
||||
====================================================================== */
|
||||
|
||||
.to-top {
|
||||
position: fixed;
|
||||
inset-block-end: var(--space-lg);
|
||||
inset-inline-end: var(--space-lg);
|
||||
z-index: var(--z-overlay);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
inline-size: var(--tap-target);
|
||||
block-size: var(--tap-target);
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-line-strong);
|
||||
border-radius: var(--radius-full);
|
||||
box-shadow: var(--shadow-md);
|
||||
color: var(--color-ink-soft);
|
||||
|
||||
/* Masqué par défaut : révélé par JS après défilement.
|
||||
visibility (et non display) pour permettre la transition. */
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(0.5rem);
|
||||
transition: opacity var(--transition-base),
|
||||
transform var(--transition-base),
|
||||
visibility var(--transition-base);
|
||||
}
|
||||
|
||||
.to-top[data-visible="true"] {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.to-top:hover {
|
||||
color: var(--color-accent-dark);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
POINT DE RUPTURE MOBILE
|
||||
Une seule media query pour toute la mise en page : le reste est fluide.
|
||||
Écrite en syntaxe de plage (width <= 46rem), lisible et sans le
|
||||
traditionnel « max-width: 45.999rem ».
|
||||
====================================================================== */
|
||||
|
||||
@media (width <= 46rem) {
|
||||
|
||||
.nav-toggle { display: inline-flex; }
|
||||
|
||||
.site-header__inner { position: relative; }
|
||||
|
||||
/* Le menu devient un panneau déroulant sous l'en-tête */
|
||||
.site-nav {
|
||||
position: absolute;
|
||||
inset-block-start: 100%;
|
||||
inset-inline: calc(var(--gutter) * -1);
|
||||
display: none;
|
||||
background-color: var(--color-surface);
|
||||
border-block-end: 1px solid var(--color-line);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.site-nav[data-open="true"] { display: block; }
|
||||
|
||||
.site-nav__list {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: var(--space-xs) var(--gutter) var(--space-md);
|
||||
}
|
||||
|
||||
.site-nav__link {
|
||||
/* Hauteur de cible tactile conforme (44px minimum) */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-block-size: var(--tap-target);
|
||||
padding-inline-start: var(--space-xs);
|
||||
border-block-end: 1px solid var(--color-line);
|
||||
border-inline-start: 2px solid transparent;
|
||||
}
|
||||
|
||||
/* Le repère de section active passe du bas au côté */
|
||||
.site-nav__link[aria-current="true"] {
|
||||
border-block-end-color: var(--color-line);
|
||||
border-inline-start-color: var(--color-accent);
|
||||
background-color: var(--color-accent-glow);
|
||||
}
|
||||
|
||||
.masthead { padding-block: var(--space-2xl) var(--space-xl); }
|
||||
.section { padding-block: var(--space-2xl); }
|
||||
|
||||
.to-top {
|
||||
inset-block-end: var(--space-md);
|
||||
inset-inline-end: var(--space-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/* ==========================================================================
|
||||
print.css — Feuille de style d'impression
|
||||
--------------------------------------------------------------------------
|
||||
Chargée via media="print" : elle n'est pas téléchargée en priorité pour
|
||||
l'affichage écran et ne pénalise donc pas le rendu initial.
|
||||
|
||||
Enjeu réel : un CV est imprimé et exporté en PDF. Une page web imprimée
|
||||
sans feuille dédiée sort avec la navigation, des fonds gris qui vident
|
||||
les cartouches, des liens dont on ne voit pas la cible, et des blocs
|
||||
coupés au milieu.
|
||||
|
||||
Ce fichier n'utilise pas @layer : chargé en media="print", il doit
|
||||
surcharger toutes les couches sans discussion.
|
||||
========================================================================== */
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
FORMAT DE PAGE
|
||||
-------------------------------------------------------------------------- */
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 15mm 14mm;
|
||||
}
|
||||
|
||||
/* Première page : marge haute réduite, le nom fait office d'en-tête */
|
||||
@page :first {
|
||||
margin-block-start: 12mm;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
MASQUAGE DES ÉLÉMENTS D'INTERFACE
|
||||
Rien de ce qui sert à naviguer n'a de sens sur papier.
|
||||
-------------------------------------------------------------------------- */
|
||||
.site-header,
|
||||
.skip-link,
|
||||
.nav-toggle,
|
||||
.site-nav,
|
||||
.to-top,
|
||||
.actions,
|
||||
.screen-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Éléments réservés à l'impression */
|
||||
.print-only { display: block !important; }
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
REMISE À PLAT DES COULEURS
|
||||
Noir sur blanc : lisible sur toute imprimante, y compris monochrome.
|
||||
-------------------------------------------------------------------------- */
|
||||
body {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-inline-size: none;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
GESTION DES SAUTS DE PAGE
|
||||
Arbitrage important : on autorise une entrée de parcours (.entry) à se
|
||||
couper entre deux pages, mais jamais une fiche individuelle (.course,
|
||||
.project, .card).
|
||||
|
||||
Pourquoi ? Une entrée « Enseignement » contient quatre fiches de cours et
|
||||
dépasse la demi-page. Si on interdit sa coupure, elle bascule en entier
|
||||
sur la page suivante et laisse un vide de plusieurs centimètres. Coupée
|
||||
entre deux fiches, elle reste parfaitement lisible et le document gagne
|
||||
une à deux pages.
|
||||
-------------------------------------------------------------------------- */
|
||||
.course,
|
||||
.project,
|
||||
.card,
|
||||
.education,
|
||||
.contact-block {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
/* Les titres ne restent jamais seuls en bas de page, séparés de leur contenu */
|
||||
.section-title,
|
||||
.entry__role,
|
||||
.entry__org,
|
||||
.project__title,
|
||||
.card__title,
|
||||
.education__degree {
|
||||
break-after: avoid;
|
||||
}
|
||||
|
||||
/* Une entrée ne démarre pas en toute fin de page pour n'y poser que sa date */
|
||||
.entry__date {
|
||||
break-after: avoid;
|
||||
}
|
||||
|
||||
/* Limite les lignes isolées en haut ou en bas de page (veuves et orphelines) */
|
||||
p, li {
|
||||
orphans: 3;
|
||||
widows: 3;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
MISE EN PAGE
|
||||
-------------------------------------------------------------------------- */
|
||||
.masthead {
|
||||
padding-block: 0 10pt;
|
||||
border-block-end: 1pt solid #000;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding-block: 10pt;
|
||||
border-block-start: 0.5pt solid #ccc;
|
||||
}
|
||||
|
||||
.intro { padding-block: 10pt; }
|
||||
|
||||
.intro__text--highlight {
|
||||
border-inline-start: 2pt solid #666;
|
||||
padding-inline-start: 8pt;
|
||||
}
|
||||
|
||||
/* Grilles : deux colonnes fixes, plus compact que l'auto-fit écran */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8pt;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
CARTES ET FICHES
|
||||
Fonds supprimés, ombres supprimées, bordures fines.
|
||||
-------------------------------------------------------------------------- */
|
||||
.course,
|
||||
.project,
|
||||
.card,
|
||||
.contact-block {
|
||||
background: transparent;
|
||||
border: 0.5pt solid #bbb;
|
||||
box-shadow: none;
|
||||
padding: 6pt 8pt;
|
||||
margin-block-end: 5pt;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.entry {
|
||||
border-inline-start: 0.5pt solid #999;
|
||||
padding-block-end: 10pt;
|
||||
}
|
||||
|
||||
.entry::before {
|
||||
background: #fff;
|
||||
border-color: #000;
|
||||
}
|
||||
|
||||
.education { border-inline-start: 0.5pt solid #999; }
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
ACCENTS EN NIVEAUX DE GRIS
|
||||
Le vert #05BC8A imprimé en monochrome donne un gris très clair,
|
||||
quasi invisible. On force le noir sur tous les éléments porteurs
|
||||
d'information.
|
||||
-------------------------------------------------------------------------- */
|
||||
.eyebrow,
|
||||
.eyebrow::before,
|
||||
.entry__date,
|
||||
.section-title::before,
|
||||
.course__name::before,
|
||||
.marker-list li::before {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: transparent;
|
||||
border: 0.5pt solid #999;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.project__stack li {
|
||||
background: transparent;
|
||||
border: 0.5pt solid #ccc;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
/* Icônes SVG inline */
|
||||
.card__icon,
|
||||
.contact-list__icon {
|
||||
stroke: #000;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
LIENS
|
||||
Sur papier, un lien dont on ne voit pas la cible est inutile.
|
||||
On affiche l'URL entre parenthèses — sauf pour mailto et les ancres
|
||||
internes, où l'adresse est déjà visible ou sans objet.
|
||||
-------------------------------------------------------------------------- */
|
||||
a {
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.contact-list a[href^="http"]::after,
|
||||
.site-footer a[href^="http"]::after {
|
||||
content: " (" attr(href) ")";
|
||||
font-size: 8pt;
|
||||
color: #555;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
a[href^="#"]::after,
|
||||
a[href^="mailto:"]::after,
|
||||
a[href^="tel:"]::after {
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
PIED DE PAGE
|
||||
-------------------------------------------------------------------------- */
|
||||
.site-footer {
|
||||
border-block-start: 0.5pt solid #ccc;
|
||||
padding-block: 6pt 0;
|
||||
}
|
||||
|
||||
.site-footer__inner { font-size: 8pt; }
|
||||
.site-footer__links { display: none; }
|
||||
@@ -0,0 +1,146 @@
|
||||
/* ==========================================================================
|
||||
tokens.css — Jetons de conception (design tokens)
|
||||
--------------------------------------------------------------------------
|
||||
Source unique de vérité pour les couleurs, la typographie et les espacements.
|
||||
Aucune règle de style ici : uniquement des variables CSS.
|
||||
|
||||
Pourquoi séparer ? Changer l'identité visuelle du site ne doit demander
|
||||
la modification que d'un seul fichier. Toute valeur littérale (#05BC8A,
|
||||
1.5rem…) écrite ailleurs que dans ce fichier est une dette technique.
|
||||
========================================================================== */
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
ORDRE DE CASCADE
|
||||
Déclaré ici en premier, avant toute règle. Une couche listée plus bas
|
||||
l'emporte sur celles du dessus, quelle que soit la spécificité des
|
||||
sélecteurs et quel que soit l'ordre des balises <link> dans le <head>.
|
||||
Cela supprime la cause n°1 des conflits CSS : la course à la spécificité.
|
||||
-------------------------------------------------------------------------- */
|
||||
@layer tokens, base, layout, components, utilities;
|
||||
|
||||
@layer tokens {
|
||||
:root {
|
||||
/* ====================================================================
|
||||
COULEURS
|
||||
Palette volontairement restreinte. Contrastes vérifiés WCAG 2.1 :
|
||||
- ink sur bg : 14,8:1 (AAA, seuil 7:1)
|
||||
- ink-soft sur bg : 7,4:1 (AAA)
|
||||
- ink-faint sur bg : 4,1:1 (AA sur texte non essentiel ≥ 14px)
|
||||
- accent-dark sur bg : 4,3:1 (AA, utilisé pour les liens)
|
||||
L'accent pur (#05BC8A) n'est jamais utilisé pour du texte sur fond
|
||||
clair : son contraste (2,3:1) est insuffisant. Il sert uniquement de
|
||||
couleur de trait, de bordure et de puce décorative.
|
||||
==================================================================== */
|
||||
|
||||
/* ---- Surfaces ---- */
|
||||
--color-bg: #f7f8f8; /* Fond de page */
|
||||
--color-bg-translucent: rgb(247 248 248 / 85%); /* En-tête collant */
|
||||
--color-surface: #ffffff; /* Cartes, fiches */
|
||||
--color-surface-alt: #f1f3f3; /* Surfaces secondaires, badges neutres */
|
||||
|
||||
/* ---- Lignes ---- */
|
||||
--color-line: #e3e6e6; /* Séparateurs, bordures de cartes */
|
||||
--color-line-strong: #cfd4d4; /* Bordures nécessitant plus de présence */
|
||||
|
||||
/* ---- Texte ---- */
|
||||
--color-ink: #10201c; /* Titres, texte principal */
|
||||
--color-ink-soft: #4c5a56; /* Corps de texte, descriptions */
|
||||
--color-ink-faint: #7b8783; /* Métadonnées, mentions secondaires */
|
||||
--color-ink-inverse: #f7f8f8; /* Texte sur fond sombre */
|
||||
|
||||
/* ---- Accent : identité underscorelab ---- */
|
||||
--color-accent: #05bc8a; /* Traits, puces, bordures */
|
||||
--color-accent-dark: #03996f; /* Texte et liens (contraste AA) */
|
||||
--color-accent-wash: #eafaf4; /* Fond de badge */
|
||||
--color-accent-border: rgb(5 188 138 / 28%);
|
||||
--color-accent-glow: rgb(5 188 138 / 12%);
|
||||
|
||||
/* ---- États ---- */
|
||||
--color-focus: #03996f; /* Anneau de focus clavier */
|
||||
|
||||
/* ====================================================================
|
||||
TYPOGRAPHIE
|
||||
Deux familles seulement :
|
||||
- JetBrains Mono : titres, métadonnées, éléments d'interface.
|
||||
Choisie parce que c'est une police de développeur — le sujet du
|
||||
site est l'informatique, la forme dit la même chose que le fond.
|
||||
- Inter : corps de texte. Optimisée pour la lecture à l'écran,
|
||||
hauteur d'x généreuse.
|
||||
Les piles de repli listent des polices système présentes partout,
|
||||
pour que la page reste lisible avant le chargement des webfonts.
|
||||
==================================================================== */
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono",
|
||||
Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
|
||||
/* ---- Échelle typographique fluide ----
|
||||
clamp(min, préféré, max) : la taille s'adapte à la largeur du viewport
|
||||
sans point de rupture. Le terme central mêle rem (respecte le zoom
|
||||
utilisateur et la taille de police du navigateur) et vw (fluidité).
|
||||
Un vw seul casserait l'accessibilité : le zoom n'aurait plus d'effet. */
|
||||
--text-2xs: 0.7rem;
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.85rem;
|
||||
--text-base: 1rem;
|
||||
--text-md: clamp(1rem, 0.96rem + 0.2vw, 1.1rem);
|
||||
--text-lg: clamp(1.15rem, 1.05rem + 0.5vw, 1.4rem);
|
||||
--text-xl: clamp(1.35rem, 1.15rem + 1vw, 1.75rem);
|
||||
--text-2xl: clamp(2.1rem, 1.5rem + 3vw, 3.4rem);
|
||||
|
||||
/* ---- Graisses ---- */
|
||||
--weight-regular: 400;
|
||||
--weight-medium: 500;
|
||||
--weight-semibold: 600;
|
||||
--weight-bold: 700;
|
||||
|
||||
/* ---- Interlignes ---- */
|
||||
--leading-tight: 1.15; /* Grands titres */
|
||||
--leading-snug: 1.4; /* Sous-titres */
|
||||
--leading-normal: 1.7; /* Corps de texte : confort de lecture */
|
||||
|
||||
/* ====================================================================
|
||||
ESPACEMENTS
|
||||
Échelle géométrique basée sur 0,25rem (4px). Utiliser une échelle
|
||||
plutôt que des valeurs arbitraires produit un rythme vertical
|
||||
cohérent sur toute la page.
|
||||
==================================================================== */
|
||||
--space-2xs: 0.25rem;
|
||||
--space-xs: 0.5rem;
|
||||
--space-sm: 0.75rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2rem;
|
||||
--space-2xl: 3rem;
|
||||
--space-3xl: 4.5rem;
|
||||
|
||||
/* ====================================================================
|
||||
DIMENSIONS
|
||||
==================================================================== */
|
||||
--width-content: 60rem; /* Largeur maximale du contenu */
|
||||
--width-prose: 68ch; /* ~68 caractères : optimum de lisibilité
|
||||
(au-delà, l'œil perd la ligne au retour) */
|
||||
--gutter: clamp(1rem, 4vw, 1.5rem); /* Marge latérale fluide */
|
||||
--header-height: 3.5rem; /* Sert au calcul du scroll-padding */
|
||||
--tap-target: 2.75rem; /* 44px : cible tactile minimale (WCAG 2.5.5) */
|
||||
|
||||
/* ---- Rayons ---- */
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 6px;
|
||||
--radius-full: 999px;
|
||||
|
||||
/* ---- Ombres : très légères, le design repose sur les bordures ---- */
|
||||
--shadow-sm: 0 1px 2px rgb(16 32 28 / 4%);
|
||||
--shadow-md: 0 4px 12px rgb(16 32 28 / 6%);
|
||||
|
||||
/* ---- Transitions ---- */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
|
||||
/* ---- Plans d'empilement : centralisés pour éviter la surenchère
|
||||
de z-index dispersés dans le code ---- */
|
||||
--z-sticky: 100;
|
||||
--z-overlay: 150;
|
||||
--z-skip-link: 200;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/* ==========================================================================
|
||||
utilities.css — Classes utilitaires
|
||||
--------------------------------------------------------------------------
|
||||
Dernière couche de la cascade : ces classes doivent pouvoir surcharger
|
||||
un composant sans recourir à !important.
|
||||
Volontairement peu nombreuses — une bibliothèque utilitaire complète
|
||||
n'aurait pas de sens sans outil de purge.
|
||||
========================================================================== */
|
||||
|
||||
@layer utilities {
|
||||
|
||||
/* ======================================================================
|
||||
MASQUAGE VISUEL ACCESSIBLE
|
||||
Retire l'élément de l'affichage tout en le laissant lisible par les
|
||||
lecteurs d'écran. À utiliser pour les libellés, les intitulés de
|
||||
sections de navigation, les annonces de contexte.
|
||||
|
||||
Ne PAS utiliser display:none ni visibility:hidden pour cet usage :
|
||||
ces propriétés retirent aussi l'élément de l'arbre d'accessibilité.
|
||||
====================================================================== */
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
RÉGION D'ANNONCE (live region)
|
||||
Conteneur des messages dynamiques destinés aux lecteurs d'écran
|
||||
(confirmation de copie d'adresse, par exemple).
|
||||
====================================================================== */
|
||||
|
||||
.sr-status {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
FLUX D'IMPRESSION
|
||||
====================================================================== */
|
||||
|
||||
/* Élément affiché uniquement à l'écran */
|
||||
.screen-only { }
|
||||
|
||||
/* Élément affiché uniquement à l'impression (révélé par print.css) */
|
||||
.print-only { display: none; }
|
||||
|
||||
/* ======================================================================
|
||||
DIVERS
|
||||
====================================================================== */
|
||||
|
||||
/* Empêche la coupure d'un groupe de mots (« Bac 3 », « Office 365 ») */
|
||||
.nowrap { white-space: nowrap; }
|
||||
|
||||
/* Retire la marge basse résiduelle du dernier enfant d'un bloc */
|
||||
.flow-end { margin-block-end: 0 !important; }
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 300 B |
|
After Width: | Height: | Size: 383 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,6 @@
|
||||
<!-- Variante monochrome : Safari applique sa propre couleur (mask-icon) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<path d="M11 44 V21 L20.5 33.5 L30 21 V44M50.39 23.69 A11.5 11.5 0 1 0 50.39 41.31M11 52 H53"
|
||||
fill="none" stroke="#000" stroke-width="5.5"
|
||||
stroke-linecap="butt" stroke-linejoin="miter"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* main.js — Point d'entrée de l'application
|
||||
* =============================================================================
|
||||
* Orchestre l'initialisation des modules. Chaque module est indépendant :
|
||||
* si l'un échoue, les autres continuent de fonctionner.
|
||||
*
|
||||
* PRINCIPE DIRECTEUR — amélioration progressive
|
||||
* ---------------------------------------------
|
||||
* Le site est intégralement lisible, navigable et imprimable sans
|
||||
* JavaScript. Tout ce que ce fichier ajoute relève du confort :
|
||||
*
|
||||
* - navigation.js : replie le menu sur petit écran
|
||||
* - scroll-spy.js : surligne la section en cours de lecture
|
||||
* - back-to-top.js : raccourci de remontée
|
||||
* - copy-email.js : copie de l'adresse en un clic
|
||||
*
|
||||
* Aucun contenu n'est injecté par script : le HTML servi est complet.
|
||||
* C'est ce qui garantit l'indexation par les moteurs de recherche et la
|
||||
* lecture par les agents conversationnels, dont beaucoup n'exécutent pas
|
||||
* JavaScript.
|
||||
*
|
||||
* CHARGEMENT
|
||||
* ----------
|
||||
* Ce fichier est appelé avec type="module", donc :
|
||||
* - différé par défaut (équivalent à defer), il ne bloque pas le rendu
|
||||
* - exécuté en mode strict
|
||||
* - portée isolée, aucune variable globale
|
||||
*/
|
||||
|
||||
import { initNavigation } from "./modules/navigation.js";
|
||||
import { initScrollSpy } from "./modules/scroll-spy.js";
|
||||
import { initBackToTop } from "./modules/back-to-top.js";
|
||||
import { initCopyEmail } from "./modules/copy-email.js";
|
||||
|
||||
/**
|
||||
* Démarre les modules de la page.
|
||||
* @returns {void}
|
||||
*/
|
||||
function init() {
|
||||
/* --- Signale que JS est actif : permet au CSS de n'appliquer certains
|
||||
comportements (menu replié) que lorsqu'ils sont pilotables ------- */
|
||||
document.documentElement.dataset.js = "true";
|
||||
|
||||
initNavigation({
|
||||
toggle: document.querySelector("[data-nav-toggle]"),
|
||||
nav: document.querySelector("[data-nav]"),
|
||||
});
|
||||
|
||||
initScrollSpy({
|
||||
sections: document.querySelectorAll("[data-section]"),
|
||||
links: document.querySelectorAll("[data-nav-link]"),
|
||||
});
|
||||
|
||||
initBackToTop({
|
||||
button: document.querySelector("[data-to-top]"),
|
||||
sentinel: document.querySelector("[data-scroll-sentinel]"),
|
||||
});
|
||||
|
||||
initCopyEmail({
|
||||
button: document.querySelector("[data-copy]"),
|
||||
status: document.querySelector("[data-status]"),
|
||||
});
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Les modules sont différés, le DOM est donc normalement prêt. On teste
|
||||
malgré tout readyState : cela rend le script robuste s'il est un jour
|
||||
réutilisé dans un contexte de chargement différent.
|
||||
----------------------------------------------------------------------- */
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init, { once: true });
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* back-to-top.js — Bouton de retour en haut de page
|
||||
* =============================================================================
|
||||
* Le bouton n'apparaît qu'une fois l'utilisateur descendu au-delà d'une
|
||||
* hauteur d'écran : afficher un raccourci de retour alors qu'on est déjà
|
||||
* en haut n'a aucune utilité et encombre l'interface.
|
||||
*
|
||||
* Détection par IntersectionObserver sur une sentinelle placée en haut du
|
||||
* document, plutôt que par écouteur de scroll — même raison que pour le
|
||||
* scroll-spy : pas de recalcul de mise en page à chaque pixel parcouru.
|
||||
*
|
||||
* Le bouton est un vrai <button> injecté par ce script : il n'a aucun sens
|
||||
* sans JavaScript, il ne doit donc pas exister dans le HTML servi.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initialise le bouton de retour en haut.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {HTMLElement} options.button - Bouton à piloter.
|
||||
* @param {HTMLElement} options.sentinel - Élément repère en haut de page.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function initBackToTop({ button, sentinel }) {
|
||||
if (!button || !sentinel) return;
|
||||
|
||||
/* --- Action : remonter en haut -------------------------------------- */
|
||||
button.addEventListener("click", () => {
|
||||
// Respecte la préférence système de réduction des animations :
|
||||
// un défilement animé sur plusieurs milliers de pixels peut
|
||||
// provoquer un malaise chez les personnes photosensibles.
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)"
|
||||
).matches;
|
||||
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: prefersReducedMotion ? "auto" : "smooth",
|
||||
});
|
||||
|
||||
// Le focus doit suivre le déplacement visuel, sinon un utilisateur
|
||||
// clavier se retrouve à naviguer depuis le bas de page.
|
||||
document.getElementById("haut-de-page")?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
/* --- Visibilité conditionnelle -------------------------------------- */
|
||||
if (!("IntersectionObserver" in window)) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
// La sentinelle est visible => on est en haut => bouton masqué.
|
||||
button.dataset.visible = String(!entry.isIntersecting);
|
||||
},
|
||||
{ threshold: 0 }
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* copy-email.js — Copie de l'adresse e-mail dans le presse-papiers
|
||||
* =============================================================================
|
||||
* Détail d'ergonomie qui compte sur un site de contact : tout le monde
|
||||
* n'a pas de client mail configuré, et un lien mailto: qui ouvre un
|
||||
* logiciel inattendu est une impasse. Le bouton de copie offre la voie
|
||||
* alternative sans supprimer le lien mailto.
|
||||
*
|
||||
* Le retour à l'utilisateur passe par deux canaux :
|
||||
* - visuel : le libellé du bouton change temporairement
|
||||
* - vocal : une région aria-live annonce la copie aux lecteurs d'écran
|
||||
*
|
||||
* L'API Clipboard exige un contexte sécurisé (HTTPS ou localhost). En cas
|
||||
* d'échec, on ne laisse pas l'utilisateur sans réponse : on lui indique
|
||||
* de copier manuellement.
|
||||
*/
|
||||
|
||||
/** Durée d'affichage du message de confirmation, en millisecondes. */
|
||||
const FEEDBACK_DURATION = 2200;
|
||||
|
||||
/**
|
||||
* Initialise le bouton de copie.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {HTMLElement} options.button - Bouton portant data-copy="<valeur>".
|
||||
* @param {HTMLElement} options.status - Région aria-live pour l'annonce.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function initCopyEmail({ button, status }) {
|
||||
if (!button) return;
|
||||
|
||||
const value = button.dataset.copy;
|
||||
if (!value) return;
|
||||
|
||||
const label = button.querySelector("[data-copy-label]");
|
||||
const originalLabel = label?.textContent ?? "";
|
||||
let resetTimer;
|
||||
|
||||
/**
|
||||
* Affiche un retour temporaire, visuel et vocal.
|
||||
* @param {string} message
|
||||
* @returns {void}
|
||||
*/
|
||||
const announce = (message) => {
|
||||
if (label) label.textContent = message;
|
||||
if (status) status.textContent = message;
|
||||
|
||||
clearTimeout(resetTimer);
|
||||
resetTimer = setTimeout(() => {
|
||||
if (label) label.textContent = originalLabel;
|
||||
if (status) status.textContent = "";
|
||||
}, FEEDBACK_DURATION);
|
||||
};
|
||||
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
announce("Adresse copiée");
|
||||
} catch {
|
||||
// Contexte non sécurisé, permission refusée ou API absente.
|
||||
announce("Copie impossible");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* navigation.js — Menu de navigation mobile
|
||||
* =============================================================================
|
||||
* Gère l'ouverture et la fermeture du panneau de navigation sous le point de
|
||||
* rupture mobile.
|
||||
*
|
||||
* Principe d'amélioration progressive : sans JavaScript, le menu reste une
|
||||
* liste de liens visible et fonctionnelle (le CSS ne la masque que si le
|
||||
* bouton, lui-même conditionné à ce script, est présent et actif).
|
||||
*
|
||||
* Accessibilité couverte :
|
||||
* - aria-expanded reflète l'état réel du panneau
|
||||
* - aria-controls relie le bouton au panneau qu'il commande
|
||||
* - la touche Échap ferme le menu et rend le focus au bouton
|
||||
* - le focus est piégé dans le panneau tant qu'il est ouvert
|
||||
* - le défilement de la page est bloqué pendant l'ouverture
|
||||
*/
|
||||
|
||||
/** Point de rupture, aligné sur celui de layout.css. */
|
||||
const MOBILE_QUERY = "(width <= 46rem)";
|
||||
|
||||
/** Sélecteur des éléments pouvant recevoir le focus clavier. */
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), ' +
|
||||
'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
* Initialise le menu mobile.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {HTMLElement} options.toggle - Bouton d'ouverture/fermeture.
|
||||
* @param {HTMLElement} options.nav - Conteneur <nav> à afficher/masquer.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function initNavigation({ toggle, nav }) {
|
||||
// Garde défensive : si le balisage change, le script ne casse pas la page.
|
||||
if (!toggle || !nav) return;
|
||||
|
||||
const mediaQuery = window.matchMedia(MOBILE_QUERY);
|
||||
|
||||
/**
|
||||
* Applique l'état d'ouverture au DOM.
|
||||
* @param {boolean} isOpen
|
||||
* @returns {void}
|
||||
*/
|
||||
const setOpen = (isOpen) => {
|
||||
nav.dataset.open = String(isOpen);
|
||||
toggle.setAttribute("aria-expanded", String(isOpen));
|
||||
|
||||
// Empêche le défilement de l'arrière-plan pendant que le panneau
|
||||
// recouvre l'écran — évite l'effet « scroll fantôme » sur mobile.
|
||||
document.body.style.overflow = isOpen && mediaQuery.matches ? "hidden" : "";
|
||||
};
|
||||
|
||||
const isOpen = () => toggle.getAttribute("aria-expanded") === "true";
|
||||
const close = () => setOpen(false);
|
||||
|
||||
/* --- Bascule au clic sur le bouton ---------------------------------- */
|
||||
toggle.addEventListener("click", () => setOpen(!isOpen()));
|
||||
|
||||
/* --- Fermeture après navigation vers une ancre ---------------------- */
|
||||
nav.addEventListener("click", (event) => {
|
||||
if (event.target.closest("a")) close();
|
||||
});
|
||||
|
||||
/* --- Clavier : Échap ferme, Tab reste piégé dans le panneau --------- */
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!isOpen()) return;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
close();
|
||||
toggle.focus(); // Le focus ne doit jamais être perdu dans le vide
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Tab") return;
|
||||
|
||||
// Piège de focus : la tabulation boucle entre le bouton et le dernier
|
||||
// lien du menu, au lieu de partir dans le contenu masqué derrière.
|
||||
const focusables = [toggle, ...nav.querySelectorAll(FOCUSABLE)];
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
|
||||
/* --- Fermeture au clic hors du panneau ------------------------------ */
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!isOpen()) return;
|
||||
if (nav.contains(event.target) || toggle.contains(event.target)) return;
|
||||
close();
|
||||
});
|
||||
|
||||
/* --- Retour en affichage large : on réinitialise --------------------- */
|
||||
mediaQuery.addEventListener("change", (event) => {
|
||||
if (!event.matches) close();
|
||||
});
|
||||
|
||||
// État initial explicite plutôt qu'implicite dans le HTML.
|
||||
close();
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* scroll-spy.js — Mise en évidence de la section courante
|
||||
* =============================================================================
|
||||
* Marque le lien de navigation correspondant à la section actuellement lue.
|
||||
*
|
||||
* Choix technique : IntersectionObserver plutôt qu'un écouteur « scroll ».
|
||||
* Un écouteur de scroll se déclenche des dizaines de fois par seconde et
|
||||
* impose de recalculer getBoundingClientRect() à chaque appel, ce qui force
|
||||
* le navigateur à recalculer la mise en page (layout thrashing) et fait
|
||||
* chuter la fluidité. IntersectionObserver délègue ce travail au moteur de
|
||||
* rendu, hors du fil principal, et ne notifie qu'aux franchissements de seuil.
|
||||
*
|
||||
* Le lien actif reçoit aria-current="true" : ce n'est pas qu'un crochet de
|
||||
* style, c'est l'annonce de la position aux lecteurs d'écran (WCAG 2.4.8).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initialise la surveillance des sections.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {NodeListOf<HTMLElement>} options.sections - Sections observées.
|
||||
* @param {NodeListOf<HTMLAnchorElement>} options.links - Liens de navigation.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function initScrollSpy({ sections, links }) {
|
||||
if (!sections?.length || !links?.length) return;
|
||||
|
||||
// Dégradation propre sur navigateur ancien : pas de surbrillance,
|
||||
// mais la navigation reste parfaitement utilisable.
|
||||
if (!("IntersectionObserver" in window)) return;
|
||||
|
||||
/* --- Index des liens par identifiant de section ciblée --------------- */
|
||||
const linksByTargetId = new Map();
|
||||
|
||||
links.forEach((link) => {
|
||||
const targetId = link.getAttribute("href")?.replace("#", "");
|
||||
if (targetId) linksByTargetId.set(targetId, link);
|
||||
});
|
||||
|
||||
/**
|
||||
* Active un lien et désactive tous les autres.
|
||||
* @param {string} sectionId
|
||||
* @returns {void}
|
||||
*/
|
||||
const setActiveLink = (sectionId) => {
|
||||
links.forEach((link) => link.removeAttribute("aria-current"));
|
||||
linksByTargetId.get(sectionId)?.setAttribute("aria-current", "true");
|
||||
};
|
||||
|
||||
/** Identifiants des sections actuellement dans la zone de détection. */
|
||||
const visibleSectionIds = new Set();
|
||||
|
||||
/** Sections dans l'ordre du document, pour départager les ex æquo. */
|
||||
const orderedSections = [...sections];
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach(({ target, isIntersecting }) => {
|
||||
if (isIntersecting) visibleSectionIds.add(target.id);
|
||||
else visibleSectionIds.delete(target.id);
|
||||
});
|
||||
|
||||
// Aucune section dans la bande : on conserve le dernier état actif
|
||||
// plutôt que de tout éteindre, ce qui produirait un clignotement.
|
||||
if (visibleSectionIds.size === 0) return;
|
||||
|
||||
// Plusieurs sections visibles : on retient la première dans l'ordre
|
||||
// du document, c'est-à-dire la plus haute à l'écran.
|
||||
const current = orderedSections.find((section) =>
|
||||
visibleSectionIds.has(section.id)
|
||||
);
|
||||
|
||||
if (current) setActiveLink(current.id);
|
||||
},
|
||||
{
|
||||
/*
|
||||
* Bande de détection étroite, positionnée dans le tiers supérieur de
|
||||
* la fenêtre, juste sous l'en-tête collant.
|
||||
*
|
||||
* -20% en haut : ignore ce qui passe derrière l'en-tête
|
||||
* -70% en bas : ignore ce qui n'est encore qu'en bas d'écran
|
||||
*
|
||||
* Résultat : la section considérée « courante » est celle que
|
||||
* l'utilisateur lit réellement, pas celle qui pointe le nez.
|
||||
*/
|
||||
rootMargin: "-20% 0px -70% 0px",
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
|
||||
sections.forEach((section) => observer.observe(section));
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
# =============================================================================
|
||||
# deploy/nginx.conf — Configuration Nginx À L'INTÉRIEUR DU CONTENEUR
|
||||
# -----------------------------------------------------------------------------
|
||||
# DIFFÉRENCE MAJEURE avec nginx.conf.example (à la racine du projet) :
|
||||
#
|
||||
# Ce fichier ne gère NI TLS, NI redirection HTTP vers HTTPS, NI redirection
|
||||
# du domaine nu vers www. Traefik, le reverse proxy de Coolify, s'en charge
|
||||
# en amont.
|
||||
#
|
||||
# Dupliquer ces redirections ici provoquerait une boucle infinie : Traefik
|
||||
# transmet la requête en HTTP clair au conteneur, qui la redirigerait vers
|
||||
# HTTPS, ce qui repasserait par Traefik, et ainsi de suite.
|
||||
#
|
||||
# Le conteneur écoute donc en HTTP simple sur le port 80, en interne.
|
||||
# nginx.conf.example reste utile si le site est un jour servi sans Coolify.
|
||||
# =============================================================================
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
charset utf-8;
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# JOURNALISATION
|
||||
# Sortie standard : Coolify récupère les journaux du conteneur.
|
||||
# Écrire dans un fichier ferait grossir la couche du conteneur sans profit.
|
||||
# -------------------------------------------------------------------------
|
||||
access_log /dev/stdout;
|
||||
error_log /dev/stderr warn;
|
||||
|
||||
# Masque le numéro de version dans les en-têtes et les pages d'erreur
|
||||
server_tokens off;
|
||||
|
||||
# =========================================================================
|
||||
# EN-TÊTES DE SÉCURITÉ
|
||||
# -------------------------------------------------------------------------
|
||||
# HSTS est volontairement ABSENT ici : c'est Traefik qui termine le TLS,
|
||||
# c'est donc à lui de l'émettre (voir DEPLOIEMENT-COOLIFY.md). L'émettre
|
||||
# depuis le conteneur fonctionnerait, mais placerait un réglage
|
||||
# irréversible dans une couche qu'on redéploie souvent.
|
||||
# =========================================================================
|
||||
|
||||
add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; upgrade-insecure-requests" always;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), interest-cohort=()" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
|
||||
# =========================================================================
|
||||
# COMPRESSION
|
||||
# =========================================================================
|
||||
gzip on;
|
||||
gzip_vary on; # « Vary: Accept-Encoding » — sans lui, un proxy
|
||||
# peut servir du gzip à un client incapable de
|
||||
# le lire.
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_proxied any; # Compresse aussi les réponses passant par
|
||||
# Traefik (sinon Nginx s'en abstient dès qu'il
|
||||
# détecte un proxy en amont).
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/manifest+json
|
||||
application/xml
|
||||
image/svg+xml;
|
||||
|
||||
# =========================================================================
|
||||
# CACHE
|
||||
# HTML revalidé à chaque visite, assets mis en cache longuement.
|
||||
# Sans cette distinction, un visiteur garderait l'ancienne page après
|
||||
# chaque redéploiement.
|
||||
# =========================================================================
|
||||
|
||||
location ~* \.(css|js)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location ~* \.(png|jpe?g|gif|svg|webp|avif|ico|woff2?)$ {
|
||||
expires 90d;
|
||||
add_header Cache-Control "public, max-age=7776000";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "public, max-age=0, must-revalidate";
|
||||
}
|
||||
|
||||
location ~* \.(txt|xml|webmanifest)$ {
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# TYPES MIME PARTICULIERS
|
||||
# =========================================================================
|
||||
location = /site.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
}
|
||||
|
||||
location = /llms.txt {
|
||||
default_type text/plain;
|
||||
charset utf-8;
|
||||
}
|
||||
|
||||
location = /.well-known/security.txt {
|
||||
default_type text/plain;
|
||||
charset utf-8;
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# SONDE DE SANTÉ
|
||||
# Coolify et Docker interrogent ce point pour savoir si le conteneur est
|
||||
# prêt. Réponse minimale, sans journalisation, pour ne pas polluer les logs.
|
||||
# =========================================================================
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
add_header Content-Type text/plain;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# ROUTAGE
|
||||
# =========================================================================
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Bloque les fichiers cachés, sauf .well-known (RFC 8615)
|
||||
location ~ /\.(?!well-known) {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
error_page 404 /index.html;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# =============================================================================
|
||||
# docker-compose.yaml — Déploiement Coolify
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utiliser ce fichier UNIQUEMENT si le déploiement Coolify est configuré en
|
||||
# type « Docker Compose ». En type « Dockerfile », Coolify génère lui-même la
|
||||
# configuration et ce fichier est ignoré.
|
||||
#
|
||||
# Voir DEPLOIEMENT-COOLIFY.md pour le choix entre les deux méthodes.
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
site:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
# Redémarrage automatique après un reboot du serveur ou un plantage.
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# RÉSEAUX
|
||||
# -------------------------------------------------------------------------
|
||||
# Le réseau « coolify » est celui où vit Traefik. Un conteneur qui n'y est
|
||||
# pas rattaché est invisible pour le reverse proxy : Traefik affiche alors
|
||||
# une 404, alors que le conteneur tourne parfaitement.
|
||||
#
|
||||
# C'est la panne la plus fréquente sur Coolify, et elle est trompeuse
|
||||
# parce que les journaux du conteneur sont vides — la requête ne
|
||||
# l'atteint jamais.
|
||||
networks:
|
||||
- coolify
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# ÉTIQUETTES TRAEFIK
|
||||
# -------------------------------------------------------------------------
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
|
||||
# --- Routeur HTTPS ---
|
||||
- traefik.http.routers.courcellematthi.rule=Host(`www.courcellematthi.eu`)
|
||||
- traefik.http.routers.courcellematthi.entrypoints=https
|
||||
- traefik.http.routers.courcellematthi.tls=true
|
||||
- traefik.http.routers.courcellematthi.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.courcellematthi.service=courcellematthi
|
||||
|
||||
# --- Port du service ---
|
||||
# DÉCLARATION OBLIGATOIRE. Traefik ne devine le port que si le conteneur
|
||||
# n'en expose qu'un seul, et cette détection échoue régulièrement.
|
||||
# Sans cette ligne : « 502 Bad Gateway » ou « service not found ».
|
||||
- traefik.http.services.courcellematthi.loadbalancer.server.port=80
|
||||
|
||||
# --- Redirection du domaine nu vers www ---
|
||||
# Doit correspondre au rel="canonical" du HTML, sous peine de contenu
|
||||
# dupliqué aux yeux des moteurs de recherche.
|
||||
- traefik.http.routers.courcellematthi-apex.rule=Host(`courcellematthi.eu`)
|
||||
- traefik.http.routers.courcellematthi-apex.entrypoints=https
|
||||
- traefik.http.routers.courcellematthi-apex.tls=true
|
||||
- traefik.http.routers.courcellematthi-apex.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.courcellematthi-apex.middlewares=courcellematthi-towww
|
||||
- traefik.http.routers.courcellematthi-apex.service=courcellematthi
|
||||
- traefik.http.middlewares.courcellematthi-towww.redirectregex.regex=^https://courcellematthi\.eu/(.*)
|
||||
- traefik.http.middlewares.courcellematthi-towww.redirectregex.replacement=https://www.courcellematthi.eu/$${1}
|
||||
- traefik.http.middlewares.courcellematthi-towww.redirectregex.permanent=true
|
||||
|
||||
# --- HSTS ---
|
||||
# Émis par Traefik, qui termine le TLS, et non par Nginx dans le conteneur.
|
||||
#
|
||||
# ATTENTION : à n'activer qu'une fois le HTTPS confirmé fonctionnel.
|
||||
# Un HSTS émis par erreur rend le domaine inaccessible en HTTP pendant
|
||||
# deux ans côté navigateur, sans possibilité d'annulation serveur.
|
||||
# Commencer avec max-age=300 pour valider, puis passer à 63072000.
|
||||
- traefik.http.routers.courcellematthi.middlewares=courcellematthi-hsts
|
||||
- traefik.http.middlewares.courcellematthi-hsts.headers.stsSeconds=300
|
||||
- traefik.http.middlewares.courcellematthi-hsts.headers.stsIncludeSubdomains=true
|
||||
- traefik.http.middlewares.courcellematthi-hsts.headers.stsPreload=true
|
||||
- traefik.http.middlewares.courcellematthi-hsts.headers.forceSTSHeader=true
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# SONDE DE SANTÉ
|
||||
# Redondante avec celle du Dockerfile, mais explicite ici pour que l'état
|
||||
# remonte correctement dans l'interface Coolify.
|
||||
# -------------------------------------------------------------------------
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/healthz"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Le réseau « coolify » est créé par Coolify lui-même : on le déclare externe
|
||||
# pour s'y rattacher sans tenter de le recréer.
|
||||
# -----------------------------------------------------------------------------
|
||||
networks:
|
||||
coolify:
|
||||
external: true
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img"
|
||||
aria-label="MC, initiales de Matthieu Courcelle">
|
||||
<title>MC — Matthieu Courcelle</title>
|
||||
|
||||
<!-- Fond clair à coins arrondis, repris de la palette du site -->
|
||||
<rect width="64" height="64" rx="12" fill="#F7F8F8"/>
|
||||
|
||||
<!-- Filet discret : garde l'icône lisible sur fond blanc comme sur fond sombre -->
|
||||
<rect x="0.75" y="0.75" width="62.5" height="62.5" rx="11.25"
|
||||
fill="none" stroke="#E3E6E6" stroke-width="1.5"/>
|
||||
|
||||
<!-- Monogramme tracé en chemins purs : aucune police système requise,
|
||||
rendu identique partout. Style anguleux cohérent avec JetBrains Mono. -->
|
||||
<g fill="none" stroke-width="5.5" stroke-linecap="butt" stroke-linejoin="miter">
|
||||
<!-- M — encre principale -->
|
||||
<path d="M11 44 V21 L20.5 33.5 L30 21 V44" stroke="#10201C"/>
|
||||
<!-- C — accent underscorelab, cercle (43 ; 32,5) r=11,5, ouvert à droite -->
|
||||
<path d="M50.39 23.69 A11.5 11.5 0 1 0 50.39 41.31" stroke="#05BC8A"/>
|
||||
</g>
|
||||
|
||||
<!-- Underscore : signature typographique reprise du prompt de terminal -->
|
||||
<path d="M11 52 H53" stroke="#05BC8A" stroke-width="3.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,15 @@
|
||||
/* ÉQUIPE */
|
||||
Conception, développement et contenu : Matthieu Courcelle
|
||||
Contact : m@tthieu.be
|
||||
Localisation : Tournai, Hainaut, Belgique
|
||||
|
||||
/* SITE */
|
||||
Dernière mise à jour : 2026-08-04
|
||||
Langue : français (fr-BE)
|
||||
Standards : HTML5, CSS (couches en cascade, propriétés logiques),
|
||||
JavaScript (modules ES natifs)
|
||||
Composants : aucun — site statique sans dépendance ni étape de compilation
|
||||
Logiciels : éditeur de texte, navigateur
|
||||
|
||||
/* REMERCIEMENTS */
|
||||
Polices : JetBrains Mono (JetBrains), Inter (Rasmus Andersson)
|
||||
@@ -0,0 +1,930 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
================================================================================
|
||||
courcellematthi.eu — Site personnel / CV de Matthieu Courcelle
|
||||
Statique, sans dépendance, sans étape de compilation.
|
||||
Voir README.md pour l'architecture et les choix techniques.
|
||||
================================================================================
|
||||
-->
|
||||
<html lang="fr">
|
||||
<head>
|
||||
|
||||
<!-- ==========================================================================
|
||||
ENCODAGE ET FENÊTRE D'AFFICHAGE
|
||||
charset doit figurer dans les 1024 premiers octets du document, sans quoi
|
||||
le navigateur peut commencer à interpréter la page dans le mauvais
|
||||
encodage puis devoir tout réanalyser.
|
||||
========================================================================== -->
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<!-- ==========================================================================
|
||||
RÉFÉRENCEMENT — MÉTADONNÉES PRINCIPALES
|
||||
Le title est le facteur de classement sur page le plus visible : il sert
|
||||
de lien bleu dans les résultats. Format retenu : nom, puis fonction, puis
|
||||
localisation. Environ 60 caractères avant troncature.
|
||||
La description n'influence pas le classement mais détermine le taux de
|
||||
clic. Environ 155 caractères. Écrite pour un humain, pas pour un robot.
|
||||
========================================================================== -->
|
||||
<title>Matthieu Courcelle — Professeur d'informatique | Tournai</title>
|
||||
<meta name="description" content="Professeur d'informatique en secondaire technique et supérieur pour adultes à Tournai. Développeur d'applications de gestion et d'outils IA pour l'école.">
|
||||
|
||||
<meta name="author" content="Matthieu Courcelle">
|
||||
|
||||
<!--
|
||||
Directives d'indexation.
|
||||
max-snippet:-1 — pas de limite de longueur d'extrait
|
||||
max-image-preview:large — autorise la grande vignette dans Discover
|
||||
max-video-preview:-1 — sans objet ici, mais évite une restriction implicite
|
||||
-->
|
||||
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1">
|
||||
|
||||
<!--
|
||||
URL canonique. Indispensable même sur un site d'une seule page : elle
|
||||
consolide le classement si la page est atteignable via plusieurs adresses
|
||||
(avec ou sans www, avec ou sans slash final, avec paramètres de campagne).
|
||||
-->
|
||||
<link rel="canonical" href="https://www.courcellematthi.eu/">
|
||||
|
||||
<!--
|
||||
Langue et zone géographique. Le site est monolingue : x-default pointe donc
|
||||
vers la même URL. La balise reste utile pour lever toute ambiguïté auprès
|
||||
des moteurs sur un domaine en .eu.
|
||||
-->
|
||||
<link rel="alternate" hreflang="fr-be" href="https://www.courcellematthi.eu/">
|
||||
<link rel="alternate" hreflang="x-default" href="https://www.courcellematthi.eu/">
|
||||
|
||||
<!-- ==========================================================================
|
||||
PARTAGE SUR LES RÉSEAUX SOCIAUX
|
||||
Open Graph (Facebook, LinkedIn, WhatsApp, Slack, Discord, Signal…)
|
||||
LinkedIn est le canal déterminant pour un CV : c'est lui qui justifie
|
||||
le soin apporté à og:image.
|
||||
========================================================================== -->
|
||||
<meta property="og:type" content="profile">
|
||||
<meta property="og:site_name" content="Matthieu Courcelle">
|
||||
<meta property="og:locale" content="fr_BE">
|
||||
<meta property="og:url" content="https://www.courcellematthi.eu/">
|
||||
<meta property="og:title" content="Matthieu Courcelle — Professeur d'informatique et développeur">
|
||||
<meta property="og:description" content="Enseignement de l'informatique, développement d'applications de gestion et intelligence artificielle appliquée à la pédagogie et à l'administration scolaire.">
|
||||
|
||||
<!-- 1200×630 : ratio 1,91:1, format attendu par la quasi-totalité des
|
||||
plateformes. Une image trop petite est ignorée et remplacée par un
|
||||
aperçu générique. L'URL doit être absolue : les robots sociaux ne
|
||||
résolvent pas les chemins relatifs. -->
|
||||
<meta property="og:image" content="https://www.courcellematthi.eu/assets/img/og-image.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:image:type" content="image/png">
|
||||
<meta property="og:image:alt" content="Monogramme MC et nom de Matthieu Courcelle, professeur d'informatique et développeur">
|
||||
|
||||
<!-- Champs spécifiques au type « profile » -->
|
||||
<meta property="profile:first_name" content="Matthieu">
|
||||
<meta property="profile:last_name" content="Courcelle">
|
||||
<meta property="profile:gender" content="male">
|
||||
|
||||
<!-- Cartes Twitter / X. summary_large_image affiche la bannière en pleine
|
||||
largeur plutôt qu'en vignette carrée. -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Matthieu Courcelle — Professeur d'informatique et développeur">
|
||||
<meta name="twitter:description" content="Enseignement de l'informatique, développement d'applications de gestion et intelligence artificielle appliquée.">
|
||||
<meta name="twitter:image" content="https://www.courcellematthi.eu/assets/img/og-image.png">
|
||||
<meta name="twitter:image:alt" content="Monogramme MC et nom de Matthieu Courcelle">
|
||||
|
||||
<!-- ==========================================================================
|
||||
INTERFACE ET SYSTÈME
|
||||
========================================================================== -->
|
||||
<!-- Colore la barre d'adresse mobile et la barre de titre des PWA -->
|
||||
<meta name="theme-color" content="#f7f8f8">
|
||||
|
||||
<!-- Déclare que seul le thème clair est prévu : évite que le navigateur
|
||||
applique une inversion automatique en mode sombre système, qui
|
||||
dénaturerait la palette. -->
|
||||
<meta name="color-scheme" content="light">
|
||||
|
||||
<!-- Nom affiché lors d'un ajout à l'écran d'accueil iOS -->
|
||||
<meta name="apple-mobile-web-app-title" content="M. Courcelle">
|
||||
<meta name="application-name" content="Matthieu Courcelle">
|
||||
|
||||
<!-- Désactive la détection automatique des numéros de téléphone par iOS,
|
||||
qui transforme des suites de chiffres (dates, matricules) en liens. -->
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
|
||||
<!-- ==========================================================================
|
||||
ICÔNES
|
||||
Stratégie en cascade : SVG d'abord (vectoriel, une seule ressource,
|
||||
net à toute taille), PNG en repli pour les navigateurs anciens,
|
||||
ICO pour la compatibilité historique.
|
||||
========================================================================== -->
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/assets/img/apple-touch-icon.png">
|
||||
<link rel="mask-icon" href="/assets/img/safari-pinned-tab.svg" color="#05bc8a">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
|
||||
<!-- ==========================================================================
|
||||
POLICES
|
||||
preconnect ouvre les connexions TCP/TLS vers les deux domaines de Google
|
||||
Fonts avant même que le navigateur ne découvre la requête CSS : gain de
|
||||
100 à 300 ms sur le premier rendu du texte.
|
||||
crossorigin est obligatoire sur fonts.gstatic.com — les fichiers de police
|
||||
sont récupérés en mode CORS, sans cet attribut la connexion préouverte ne
|
||||
serait pas réutilisée.
|
||||
display=swap affiche immédiatement la police de repli puis substitue la
|
||||
webfont : le texte n'est jamais invisible (évite le FOIT).
|
||||
========================================================================== -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;600&display=swap">
|
||||
|
||||
<!-- ==========================================================================
|
||||
FEUILLES DE STYLE
|
||||
Liées séparément plutôt qu'assemblées par @import : les @import créent
|
||||
une cascade de requêtes séquentielles (le navigateur doit d'abord
|
||||
télécharger et analyser le fichier parent pour découvrir l'enfant),
|
||||
alors que des <link> se téléchargent en parallèle.
|
||||
L'ordre de priorité ne dépend pas de l'ordre ci-dessous : il est fixé
|
||||
par la déclaration @layer en tête de tokens.css.
|
||||
========================================================================== -->
|
||||
<link rel="stylesheet" href="/assets/css/tokens.css">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="/assets/css/layout.css">
|
||||
<link rel="stylesheet" href="/assets/css/components.css">
|
||||
<link rel="stylesheet" href="/assets/css/utilities.css">
|
||||
<!-- Chargée uniquement à l'impression : ne pénalise pas le rendu écran -->
|
||||
<link rel="stylesheet" href="/assets/css/print.css" media="print">
|
||||
|
||||
<!-- ==========================================================================
|
||||
SCRIPT
|
||||
type="module" implique defer : le téléchargement est parallèle à
|
||||
l'analyse du HTML, l'exécution a lieu après. Aucun blocage du rendu,
|
||||
donc pas besoin de l'écrire en fin de <body>.
|
||||
========================================================================== -->
|
||||
<script type="module" src="/assets/js/main.js"></script>
|
||||
|
||||
<!-- ==========================================================================
|
||||
DONNÉES STRUCTURÉES — schema.org en JSON-LD
|
||||
--------------------------------------------------------------------------
|
||||
Double usage :
|
||||
|
||||
1. MOTEURS DE RECHERCHE. Permet à Google d'associer la page à une entité
|
||||
« personne » plutôt qu'à un simple document, condition d'apparition
|
||||
dans le panneau de connaissances et les résultats enrichis.
|
||||
|
||||
2. AGENTS CONVERSATIONNELS ET IA. Les modèles de langage et les moteurs
|
||||
de réponse consomment le JSON-LD en priorité : c'est une déclaration
|
||||
explicite et non ambiguë de qui est la personne, ce qu'elle fait, où
|
||||
elle travaille et ce qu'elle sait faire. Sans ces données, l'agent
|
||||
doit inférer depuis la prose, avec un risque d'erreur.
|
||||
|
||||
Le graphe est composé de trois entités reliées par @id :
|
||||
WebSite → ProfilePage → Person.
|
||||
========================================================================== -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": "https://www.courcellematthi.eu/#website",
|
||||
"url": "https://www.courcellematthi.eu/",
|
||||
"name": "Matthieu Courcelle",
|
||||
"description": "Site personnel de Matthieu Courcelle, professeur d'informatique et développeur d'applications de gestion.",
|
||||
"inLanguage": "fr-BE",
|
||||
"publisher": { "@id": "https://www.courcellematthi.eu/#person" }
|
||||
},
|
||||
{
|
||||
"@type": "ProfilePage",
|
||||
"@id": "https://www.courcellematthi.eu/#webpage",
|
||||
"url": "https://www.courcellematthi.eu/",
|
||||
"name": "Matthieu Courcelle — Professeur d'informatique et développeur",
|
||||
"isPartOf": { "@id": "https://www.courcellematthi.eu/#website" },
|
||||
"about": { "@id": "https://www.courcellematthi.eu/#person" },
|
||||
"mainEntity": { "@id": "https://www.courcellematthi.eu/#person" },
|
||||
"inLanguage": "fr-BE",
|
||||
"primaryImageOfPage": {
|
||||
"@type": "ImageObject",
|
||||
"url": "https://www.courcellematthi.eu/assets/img/og-image.png",
|
||||
"width": 1200,
|
||||
"height": 630
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Person",
|
||||
"@id": "https://www.courcellematthi.eu/#person",
|
||||
"name": "Matthieu Courcelle",
|
||||
"givenName": "Matthieu",
|
||||
"familyName": "Courcelle",
|
||||
"jobTitle": "Professeur d'informatique",
|
||||
"description": "Professeur d'informatique en secondaire technique et en enseignement supérieur pour adultes. Développeur d'applications de gestion et praticien de l'intelligence artificielle appliquée à la pédagogie et à l'administration scolaire.",
|
||||
"url": "https://www.courcellematthi.eu/",
|
||||
"email": "mailto:m@tthieu.be",
|
||||
"image": "https://www.courcellematthi.eu/assets/img/og-image.png",
|
||||
"nationality": { "@type": "Country", "name": "Belgique" },
|
||||
"knowsLanguage": [
|
||||
{ "@type": "Language", "name": "Français", "alternateName": "fr" },
|
||||
{ "@type": "Language", "name": "Anglais", "alternateName": "en" }
|
||||
],
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressLocality": "Tournai",
|
||||
"addressRegion": "Hainaut",
|
||||
"addressCountry": "BE"
|
||||
},
|
||||
"worksFor": [
|
||||
{
|
||||
"@type": "EducationalOrganization",
|
||||
"name": "IPES Ath",
|
||||
"description": "Enseignement secondaire technique de transition et de qualification"
|
||||
},
|
||||
{
|
||||
"@type": "EducationalOrganization",
|
||||
"name": "Institut Gabrielle Petit",
|
||||
"alternateName": "IPEPS Tournai-Leuze",
|
||||
"description": "Enseignement de promotion sociale et supérieur pour adultes"
|
||||
}
|
||||
],
|
||||
"alumniOf": [
|
||||
{
|
||||
"@type": "EducationalOrganization",
|
||||
"name": "Institut de Promotion Sociale Saint Henri"
|
||||
},
|
||||
{
|
||||
"@type": "EducationalOrganization",
|
||||
"name": "IEPSCF"
|
||||
}
|
||||
],
|
||||
"hasCredential": [
|
||||
{
|
||||
"@type": "EducationalOccupationalCredential",
|
||||
"name": "Certificat d'Aptitudes Pédagogiques",
|
||||
"credentialCategory": "Certificat",
|
||||
"educationalLevel": "Enseignement de promotion sociale",
|
||||
"recognizedBy": {
|
||||
"@type": "EducationalOrganization",
|
||||
"name": "Institut de Promotion Sociale Saint Henri"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "EducationalOccupationalCredential",
|
||||
"name": "Bachelier en informatique de gestion",
|
||||
"credentialCategory": "Bachelier",
|
||||
"educationalLevel": "Enseignement supérieur de type court",
|
||||
"recognizedBy": {
|
||||
"@type": "EducationalOrganization",
|
||||
"name": "IEPSCF"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hasOccupation": [
|
||||
{
|
||||
"@type": "Occupation",
|
||||
"name": "Professeur d'informatique",
|
||||
"occupationalCategory": "2320 Enseignants de l'enseignement technique et professionnel",
|
||||
"occupationLocation": { "@type": "City", "name": "Tournai" },
|
||||
"skills": "Développement d'applications de gestion, administration et sécurisation des réseaux, bases de données relationnelles, ingénierie pédagogique"
|
||||
},
|
||||
{
|
||||
"@type": "Occupation",
|
||||
"name": "Développeur d'applications",
|
||||
"occupationalCategory": "2512 Développeurs de logiciels",
|
||||
"skills": "PHP, Python, JavaScript, Node.js, React, Vue, PostgreSQL, Docker"
|
||||
}
|
||||
],
|
||||
"knowsAbout": [
|
||||
"Enseignement de l'informatique",
|
||||
"Développement d'applications orientées gestion",
|
||||
"Bases de données relationnelles",
|
||||
"Modélisation MCD et MLD",
|
||||
"Administration et sécurisation des réseaux",
|
||||
"Active Directory",
|
||||
"Docker et conteneurisation",
|
||||
"Intégration et déploiement continus",
|
||||
"Intelligence artificielle appliquée",
|
||||
"Ingénierie pédagogique",
|
||||
"RGPD en contexte scolaire"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- Repère de focus pour le retour en haut de page (voir back-to-top.js).
|
||||
tabindex="-1" le rend focusable par script sans l'insérer dans l'ordre
|
||||
de tabulation naturel. -->
|
||||
<span id="haut-de-page" tabindex="-1"></span>
|
||||
|
||||
<!-- Sentinelle invisible : sa sortie du champ de vision déclenche
|
||||
l'affichage du bouton de remontée. aria-hidden car purement technique. -->
|
||||
<span data-scroll-sentinel aria-hidden="true"></span>
|
||||
|
||||
<!--
|
||||
Lien d'évitement — premier élément focusable de la page.
|
||||
WCAG 2.4.1 : permet de contourner les blocs répétés (ici la navigation)
|
||||
pour atteindre directement le contenu principal.
|
||||
-->
|
||||
<a class="skip-link" href="#contenu">Aller au contenu principal</a>
|
||||
|
||||
<!-- ==========================================================================
|
||||
EN-TÊTE ET NAVIGATION
|
||||
========================================================================== -->
|
||||
<header class="site-header">
|
||||
<div class="container site-header__inner">
|
||||
|
||||
<a class="site-header__brand" href="#haut-de-page">
|
||||
<!-- Le logo est décoratif : le nom est déjà écrit à côté en texte.
|
||||
aria-hidden évite la double annonce par le lecteur d'écran. -->
|
||||
<svg class="site-header__logo" viewBox="0 0 64 64" aria-hidden="true" focusable="false">
|
||||
<g fill="none" stroke-width="5.5" stroke-linecap="butt" stroke-linejoin="miter">
|
||||
<path d="M11 44 V21 L20.5 33.5 L30 21 V44" stroke="currentColor"/>
|
||||
<path d="M50.39 23.69 A11.5 11.5 0 1 0 50.39 41.31" stroke="#05BC8A"/>
|
||||
</g>
|
||||
<path d="M11 52 H53" stroke="#05BC8A" stroke-width="3.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
courcelle_matthieu
|
||||
</a>
|
||||
|
||||
<!--
|
||||
Bouton du menu mobile.
|
||||
aria-expanded : état réel du panneau, mis à jour par JavaScript.
|
||||
aria-controls : désigne le panneau commandé.
|
||||
Le libellé « Menu » est textuel, pas seulement une icône : un
|
||||
pictogramme de trois barres n'est pas universellement compris.
|
||||
-->
|
||||
<button class="nav-toggle" type="button" data-nav-toggle
|
||||
aria-expanded="false" aria-controls="navigation-principale">
|
||||
<svg class="nav-toggle__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path class="nav-toggle__line nav-toggle__line--top" d="M4 7h16" stroke-linecap="round"/>
|
||||
<path class="nav-toggle__line nav-toggle__line--middle" d="M4 12h16" stroke-linecap="round"/>
|
||||
<path class="nav-toggle__line nav-toggle__line--bottom" d="M4 17h16" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Menu
|
||||
</button>
|
||||
|
||||
<!--
|
||||
aria-label sur le <nav> : distingue cette navigation de toute autre
|
||||
région de navigation de la page (ici celle du pied de page). Les
|
||||
lecteurs d'écran proposent une liste des repères — sans libellé,
|
||||
l'utilisateur verrait « navigation, navigation ».
|
||||
-->
|
||||
<nav class="site-nav" id="navigation-principale" data-nav
|
||||
aria-label="Navigation principale">
|
||||
<ul class="site-nav__list">
|
||||
<li><a class="site-nav__link" href="#profil" data-nav-link>Profil</a></li>
|
||||
<li><a class="site-nav__link" href="#enseignement" data-nav-link>Enseignement</a></li>
|
||||
<li><a class="site-nav__link" href="#coordination" data-nav-link>Coordination</a></li>
|
||||
<li><a class="site-nav__link" href="#projets" data-nav-link>Projets</a></li>
|
||||
<li><a class="site-nav__link" href="#competences" data-nav-link>Compétences</a></li>
|
||||
<li><a class="site-nav__link" href="#formation" data-nav-link>Formation</a></li>
|
||||
<li><a class="site-nav__link" href="#contact" data-nav-link>Contact</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ==========================================================================
|
||||
CONTENU PRINCIPAL
|
||||
========================================================================== -->
|
||||
<main id="contenu">
|
||||
<div class="container">
|
||||
|
||||
<!-- ====================================================================
|
||||
EN-TÊTE DE PAGE
|
||||
==================================================================== -->
|
||||
<div class="masthead">
|
||||
<span class="eyebrow">profil</span>
|
||||
|
||||
<!-- Un seul <h1> par page : il énonce le sujet du document.
|
||||
Sur un CV, c'est le nom. -->
|
||||
<h1 class="page-title">Matthieu Courcelle</h1>
|
||||
|
||||
<p class="page-subtitle">
|
||||
Professeur d'informatique & développeur d'applications de gestion
|
||||
</p>
|
||||
|
||||
<ul class="contact-list" role="list">
|
||||
<li>
|
||||
<svg class="contact-list__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2"/>
|
||||
<path d="m3 7 9 6 9-6"/>
|
||||
</svg>
|
||||
<a href="mailto:m@tthieu.be">m@tthieu.be</a>
|
||||
</li>
|
||||
<li>
|
||||
<svg class="contact-list__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<circle cx="12" cy="12" r="9"/>
|
||||
<path d="M3 12h18M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18"/>
|
||||
</svg>
|
||||
<a href="https://www.courcellematthi.eu">courcellematthi.eu</a>
|
||||
</li>
|
||||
<li>
|
||||
<svg class="contact-list__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M12 21s7-6.2 7-11a7 7 0 1 0-14 0c0 4.8 7 11 7 11Z"/>
|
||||
<circle cx="12" cy="10" r="2.5"/>
|
||||
</svg>
|
||||
Région de Tournai, Belgique
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!--
|
||||
Appels à l'action. Placés haut : un recruteur ou un client qui a déjà
|
||||
décidé ne doit pas parcourir toute la page pour trouver comment
|
||||
prendre contact.
|
||||
Le bouton d'impression est masqué à l'impression (classe screen-only).
|
||||
-->
|
||||
<div class="actions screen-only">
|
||||
<a class="button button--primary" href="mailto:m@tthieu.be">
|
||||
<svg class="button__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2"/>
|
||||
<path d="m3 7 9 6 9-6"/>
|
||||
</svg>
|
||||
Me contacter
|
||||
</a>
|
||||
|
||||
<!--
|
||||
onclick inline : exception assumée et unique dans ce projet.
|
||||
window.print() n'a aucun sens sans JavaScript et le sortir dans un
|
||||
module imposerait un fichier entier pour une ligne. Le bouton est
|
||||
volontairement absent du flux d'impression.
|
||||
-->
|
||||
<button class="button button--secondary" type="button" onclick="window.print()">
|
||||
<svg class="button__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M7 9V3h10v6"/>
|
||||
<rect x="4" y="9" width="16" height="7" rx="2"/>
|
||||
<path d="M7 16h10v5H7z"/>
|
||||
</svg>
|
||||
Imprimer / PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================================
|
||||
PRÉSENTATION
|
||||
==================================================================== -->
|
||||
<section class="intro" id="profil" data-section aria-labelledby="titre-profil">
|
||||
<h2 class="intro__heading" id="titre-profil">
|
||||
Enseigner, transmettre, construire des outils qui servent
|
||||
</h2>
|
||||
|
||||
<p class="intro__text">
|
||||
Professeur d'informatique depuis 2018, j'enseigne aujourd'hui à la fois dans le
|
||||
secondaire technique (IPES Ath) et dans le supérieur pour adultes à l'Institut
|
||||
Gabrielle Petit — développement d'applications, administration réseau, sécurisation
|
||||
d'infrastructures. La transmission est au cœur de ce que je fais : structurer une
|
||||
matière complexe, la rendre accessible, accompagner un public en formation jusqu'à
|
||||
l'autonomie.
|
||||
</p>
|
||||
|
||||
<p class="intro__text">
|
||||
<strong>En parallèle des cours, j'ai développé des outils concrets pour les
|
||||
établissements où j'interviens</strong> — une application de gestion d'événements
|
||||
scolaires, des systèmes d'aide à l'administration — et j'ai construit, en autodidacte,
|
||||
une vraie maîtrise de l'intelligence artificielle appliquée : skills Claude et
|
||||
assistants Gemini pour aider mes collègues à préparer cours et évaluations, pour
|
||||
accompagner des directions d'école sur l'administratif, la communication avec les
|
||||
familles ou le <abbr title="Règlement général sur la protection des données">RGPD</abbr>
|
||||
scolaire. Je gère aussi ma propre infrastructure serveur (Docker, déploiement,
|
||||
monitoring) pour héberger ces outils.
|
||||
</p>
|
||||
|
||||
<p class="intro__text">
|
||||
Depuis janvier 2026, je développe également en indépendant complémentaire
|
||||
(<em>underscorelab</em>) des outils de gestion pour des
|
||||
<abbr title="Association sans but lucratif">ASBL</abbr>, notamment dans le secteur
|
||||
du handicap.
|
||||
</p>
|
||||
|
||||
<p class="intro__text intro__text--highlight">
|
||||
Cette double compétence — pédagogue et développeur formé à l'IA appliquée — ouvre
|
||||
plusieurs directions : continuer à enseigner et former, accompagner des équipes dans
|
||||
l'intégration d'outils IA, ou concevoir des solutions sur mesure pour des structures
|
||||
qui en ont besoin.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================================
|
||||
ENSEIGNEMENT
|
||||
==================================================================== -->
|
||||
<section class="section" id="enseignement" data-section aria-labelledby="titre-enseignement">
|
||||
<h2 class="section-title" id="titre-enseignement">Enseignement</h2>
|
||||
|
||||
<article class="entry">
|
||||
<span class="entry__date">2023 — aujourd'hui</span>
|
||||
<h3 class="entry__role">Professeur d'informatique — Enseignement supérieur pour adultes</h3>
|
||||
<p class="entry__org">Institut Gabrielle Petit (ex-IPEPS Tournai-Leuze)</p>
|
||||
|
||||
<div class="course">
|
||||
<span class="course__name">Développement d'applications orientées gestion — Projet SGBD (<span class="nowrap">Bac 3</span>)</span>
|
||||
<p class="course__desc">
|
||||
Conception et développement d'un système de gestion de données complet — de la
|
||||
modélisation (MCD/MLD, normalisation) à une application 3-tiers connectée en base,
|
||||
intégrant transactions ACID, gestion d'erreurs, déploiement Cloud/DevOps
|
||||
(Git, CI/CD) et soutenance technique devant jury.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="course">
|
||||
<span class="course__name">Administration et sécurisation des réseaux et services (<span class="nowrap">Bac 3</span>)</span>
|
||||
<p class="course__desc">
|
||||
Formation à l'administration autonome d'une infrastructure réseau complète :
|
||||
services (DHCP, DNS, Web, messagerie), virtualisation, adressage IPv4/IPv6,
|
||||
et sécurisation avancée (pare-feu, DMZ, IDS/IPS, Active Directory/LDAP,
|
||||
VLAN, GPO, VPN).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="course">
|
||||
<span class="course__name">Base d'administration réseaux (<span class="nowrap">Bac 1</span>)</span>
|
||||
<p class="course__desc">
|
||||
Introduction aux modèles OSI/TCP-IP, à l'adressage IP et au subnetting, au câblage
|
||||
et aux équipements actifs (switch, routeur), avec diagnostic autonome
|
||||
(ping, traceroute, sniffers) et mise en œuvre d'un petit réseau interconnecté.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="course">
|
||||
<span class="course__name">Initiation à l'informatique (Complément CESS)</span>
|
||||
<p class="course__desc">
|
||||
Apprentissage des bases de l'outil informatique (Windows, navigation, bureautique)
|
||||
pour un public adulte en reprise d'études.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="entry">
|
||||
<span class="entry__date">2020 — aujourd'hui</span>
|
||||
<h3 class="entry__role">Professeur d'informatique — Secondaire technique</h3>
|
||||
<p class="entry__org">IPES Ath · Technique de transition & de qualification (DI et DS)</p>
|
||||
|
||||
<div class="course">
|
||||
<span class="course__name">Traitements de Problèmes Techniques</span>
|
||||
<p class="course__desc">
|
||||
Apprentissage méthodique du diagnostic et de la résolution de pannes matérielles
|
||||
et logicielles, pour développer une démarche rigoureuse de dépannage informatique.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="course">
|
||||
<span class="course__name">Laboratoire Informatique</span>
|
||||
<p class="course__desc">
|
||||
Mise en pratique des notions théoriques via des ateliers concrets de montage,
|
||||
configuration et manipulation de systèmes informatiques.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="course">
|
||||
<span class="course__name">Informatique Appliquée</span>
|
||||
<p class="course__desc">
|
||||
Application des concepts informatiques à des cas pratiques, faisant le lien entre
|
||||
théorie algorithmique/logicielle et mise en œuvre réelle.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="course">
|
||||
<span class="course__name">Laboratoire de Maintenance et de Télématique</span>
|
||||
<p class="course__desc">
|
||||
Travaux pratiques sur la maintenance des postes de travail et les bases des réseaux
|
||||
et télécommunications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="course">
|
||||
<span class="course__name">Informatique de Gestion (<span class="nowrap">Office 365</span>)</span>
|
||||
<p class="course__desc">
|
||||
Maîtrise des outils bureautiques et collaboratifs de gestion appliqués aux besoins
|
||||
administratifs et professionnels.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="entry">
|
||||
<span class="entry__date">2020 — 2022</span>
|
||||
<h3 class="entry__role">Professeur d'informatique de gestion</h3>
|
||||
<p class="entry__org">IPES Tournai · Technique de qualification Gestion (DI)</p>
|
||||
<p class="entry__note">Informatique de gestion appliquée à la suite Office 365.</p>
|
||||
</article>
|
||||
|
||||
<article class="entry">
|
||||
<span class="entry__date">2018 — 2020</span>
|
||||
<h3 class="entry__role">Formateur en entreprise</h3>
|
||||
<p class="entry__org">ORDITECH SA</p>
|
||||
<p class="entry__note">
|
||||
Formation professionnelle d'adultes sur les environnements Office 365 et les
|
||||
systèmes d'exploitation.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================================
|
||||
COORDINATION
|
||||
==================================================================== -->
|
||||
<section class="section" id="coordination" data-section aria-labelledby="titre-coordination">
|
||||
<h2 class="section-title" id="titre-coordination">Coordination pédagogique</h2>
|
||||
|
||||
<article class="entry">
|
||||
<span class="entry__date">Heures de coordination</span>
|
||||
<h3 class="entry__role">Développement d'outils de gestion interne</h3>
|
||||
<p class="entry__org">École des Frères — Tournai</p>
|
||||
|
||||
<ul class="marker-list" role="list">
|
||||
<li>Application de gestion des remédiations : suivi des élèves, planification et affectation des séances.</li>
|
||||
<li>Application de gestion des activités complémentaires.</li>
|
||||
<li>Système de valve électronique pour la diffusion d'annonces aux élèves.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================================
|
||||
PROJETS
|
||||
==================================================================== -->
|
||||
<section class="section" id="projets" data-section aria-labelledby="titre-projets">
|
||||
<h2 class="section-title" id="titre-projets">Projets</h2>
|
||||
|
||||
<article class="project">
|
||||
<div class="project__header">
|
||||
<h3 class="project__title">Festifun</h3>
|
||||
<span class="badge">Institut du Sacré-Cœur Mouscron</span>
|
||||
</div>
|
||||
<p class="project__desc">
|
||||
Application web progressive de gestion d'événement scolaire, conçue et déployée
|
||||
pour l'établissement.
|
||||
</p>
|
||||
<ul class="project__stack" role="list">
|
||||
<li>PWA</li><li>JavaScript</li><li>Node.js</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="project">
|
||||
<div class="project__header">
|
||||
<h3 class="project__title">Outils IA d'aide à la direction</h3>
|
||||
<span class="badge">Institut du Sacré-Cœur Mouscron</span>
|
||||
</div>
|
||||
<p class="project__desc">
|
||||
Assistants IA sur mesure destinés aux équipes de direction : maîtrise des outils
|
||||
informatiques administratifs, communication avec les familles, application du RGPD
|
||||
en contexte scolaire.
|
||||
</p>
|
||||
<ul class="project__stack" role="list">
|
||||
<li>Claude</li><li>Gemini</li><li>NotebookLM</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="project">
|
||||
<div class="project__header">
|
||||
<h3 class="project__title">Bibliothèque de skills Claude & Gems Gemini</h3>
|
||||
<span class="badge">Pédagogie · Administration</span>
|
||||
</div>
|
||||
<p class="project__desc">
|
||||
Assistants IA spécialisés développés pour l'équipe éducative : rédaction de cours,
|
||||
création d'exercices et d'exemples, conception d'évaluations et aide à la correction.
|
||||
</p>
|
||||
<p class="project__desc">
|
||||
Déclinaison à destination des directions d'établissement sur les volets
|
||||
administratifs, la communication institutionnelle et la conformité RGPD.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="project">
|
||||
<div class="project__header">
|
||||
<h3 class="project__title">underscorelab</h3>
|
||||
<span class="badge">Indépendant complémentaire · depuis janvier 2026</span>
|
||||
</div>
|
||||
<p class="project__desc">
|
||||
Développement d'outils de gestion sur mesure pour le secteur associatif,
|
||||
principalement des ASBL d'aide aux personnes en situation de handicap.
|
||||
</p>
|
||||
<ul class="project__stack" role="list">
|
||||
<li>Node.js</li><li>PHP</li><li>Python</li><li>PostgreSQL</li><li>Vue</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="project">
|
||||
<div class="project__header">
|
||||
<h3 class="project__title">Écosystème d'agents automatisés</h3>
|
||||
<!-- Badge neutre : signale honnêtement un projet non abouti,
|
||||
plutôt que de le présenter comme un acquis. -->
|
||||
<span class="badge badge--muted">en cours de développement</span>
|
||||
</div>
|
||||
<p class="project__desc">
|
||||
Architecture d'agents IA orchestrés via n8n sur infrastructure auto-hébergée,
|
||||
pour l'automatisation de flux de travail administratifs et pédagogiques.
|
||||
</p>
|
||||
<ul class="project__stack" role="list">
|
||||
<li>n8n</li><li>Docker</li><li>API</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================================
|
||||
COMPÉTENCES
|
||||
==================================================================== -->
|
||||
<section class="section" id="competences" data-section aria-labelledby="titre-competences">
|
||||
<h2 class="section-title" id="titre-competences">Compétences</h2>
|
||||
|
||||
<div class="grid">
|
||||
|
||||
<article class="card">
|
||||
<h3 class="card__title">
|
||||
<svg class="card__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="m9 8-5 4 5 4M15 8l5 4-5 4"/>
|
||||
</svg>
|
||||
Développement
|
||||
</h3>
|
||||
<ul class="marker-list" role="list">
|
||||
<li>PHP, Python, JavaScript (Node.js)</li>
|
||||
<li>React, Vue</li>
|
||||
<li>PostgreSQL, SQL, modélisation MCD/MLD</li>
|
||||
<li>Applications de gestion, PWA</li>
|
||||
<li>VBA / Excel avancé</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h3 class="card__title">
|
||||
<svg class="card__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<rect x="3" y="4" width="18" height="6" rx="1"/>
|
||||
<rect x="3" y="14" width="18" height="6" rx="1"/>
|
||||
<path d="M7 7h.01M7 17h.01"/>
|
||||
</svg>
|
||||
Systèmes & réseaux
|
||||
</h3>
|
||||
<ul class="marker-list" role="list">
|
||||
<li>Administration et sécurisation réseau</li>
|
||||
<li>Active Directory, LDAP, GPO</li>
|
||||
<li>Pare-feu, DMZ, VPN, VLAN</li>
|
||||
<li>Windows, Linux, macOS</li>
|
||||
<li>Montage, configuration, audit</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h3 class="card__title">
|
||||
<svg class="card__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M12 3 3 8v8l9 5 9-5V8Z"/>
|
||||
<path d="m3 8 9 5 9-5M12 13v8"/>
|
||||
</svg>
|
||||
Infrastructure & déploiement
|
||||
</h3>
|
||||
<ul class="marker-list" role="list">
|
||||
<li>Docker, conteneurisation</li>
|
||||
<li>Reverse proxy, SSL/TLS</li>
|
||||
<li>Déploiement continu (Git, CI/CD)</li>
|
||||
<li>Monitoring et sauvegarde</li>
|
||||
<li>Auto-hébergement de services</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h3 class="card__title">
|
||||
<svg class="card__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"/>
|
||||
</svg>
|
||||
Intelligence artificielle appliquée
|
||||
</h3>
|
||||
<ul class="marker-list" role="list">
|
||||
<li>Conception de skills Claude</li>
|
||||
<li>Création de Gems Gemini</li>
|
||||
<li>Structuration documentaire (NotebookLM)</li>
|
||||
<li>Automatisation de flux de travail</li>
|
||||
<li>Accompagnement et formation d'équipes</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h3 class="card__title">
|
||||
<svg class="card__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M3 7l9-4 9 4-9 4Z"/>
|
||||
<path d="M7 10v5c0 1.7 2.2 3 5 3s5-1.3 5-3v-5"/>
|
||||
</svg>
|
||||
Pédagogie
|
||||
</h3>
|
||||
<ul class="marker-list" role="list">
|
||||
<li>Enseignement secondaire technique</li>
|
||||
<li>Enseignement supérieur pour adultes</li>
|
||||
<li>Conception de séquences et d'évaluations</li>
|
||||
<li>Vulgarisation technique</li>
|
||||
<li>Accompagnement en reprise d'études</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h3 class="card__title">
|
||||
<svg class="card__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<circle cx="12" cy="12" r="9"/>
|
||||
<path d="M3 12h18M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18"/>
|
||||
</svg>
|
||||
Langues
|
||||
</h3>
|
||||
<ul class="marker-list" role="list">
|
||||
<li>Français — langue maternelle</li>
|
||||
<li>Anglais — lecture technique courante</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================================
|
||||
FORMATION
|
||||
==================================================================== -->
|
||||
<section class="section" id="formation" data-section aria-labelledby="titre-formation">
|
||||
<h2 class="section-title" id="titre-formation">Formation</h2>
|
||||
|
||||
<div class="grid">
|
||||
<article class="education">
|
||||
<span class="entry__date">2021 — 2024</span>
|
||||
<h3 class="education__degree">Certificat d'Aptitudes Pédagogiques</h3>
|
||||
<p class="education__school">Institut de Promotion Sociale Saint Henri</p>
|
||||
</article>
|
||||
|
||||
<article class="education">
|
||||
<span class="entry__date">2008 — 2011</span>
|
||||
<h3 class="education__degree">Bachelier en informatique de gestion</h3>
|
||||
<p class="education__school">IEPSCF</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ====================================================================
|
||||
CONTACT
|
||||
==================================================================== -->
|
||||
<section class="section" id="contact" data-section aria-labelledby="titre-contact">
|
||||
<h2 class="section-title" id="titre-contact">Contact</h2>
|
||||
|
||||
<div class="contact-block">
|
||||
<p class="contact-block__text">
|
||||
Une question, un projet, une opportunité d'enseignement ou de collaboration —
|
||||
écrivez-moi, je réponds.
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<a class="button button--primary" href="mailto:m@tthieu.be">
|
||||
<svg class="button__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2"/>
|
||||
<path d="m3 7 9 6 9-6"/>
|
||||
</svg>
|
||||
m@tthieu.be
|
||||
</a>
|
||||
|
||||
<!-- Bouton de copie : alternative au mailto pour qui n'a pas de
|
||||
client mail configuré. Voir copy-email.js. -->
|
||||
<button class="button button--secondary" type="button" data-copy="m@tthieu.be">
|
||||
<svg class="button__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<rect x="9" y="9" width="12" height="12" rx="2"/>
|
||||
<path d="M5 15V5a2 2 0 0 1 2-2h10"/>
|
||||
</svg>
|
||||
<span data-copy-label>Copier l'adresse</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Région d'annonce pour lecteurs d'écran.
|
||||
aria-live="polite" : le message est lu dès que le lecteur a fini
|
||||
son énoncé en cours, sans interrompre l'utilisateur.
|
||||
Le conteneur doit exister dans le DOM avant l'insertion du texte,
|
||||
sinon l'annonce n'est pas déclenchée.
|
||||
-->
|
||||
<div class="sr-status" role="status" aria-live="polite" data-status></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ==========================================================================
|
||||
PIED DE PAGE
|
||||
========================================================================== -->
|
||||
<footer class="site-footer">
|
||||
<div class="container site-footer__inner">
|
||||
<span>© 2026 Matthieu Courcelle</span>
|
||||
|
||||
<nav aria-label="Navigation de pied de page">
|
||||
<ul class="site-footer__links">
|
||||
<li><a href="#haut-de-page">Haut de page</a></li>
|
||||
<li><a href="mailto:m@tthieu.be">m@tthieu.be</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!--
|
||||
Bouton de retour en haut.
|
||||
Masqué par défaut en CSS, révélé par JavaScript après défilement.
|
||||
aria-label car le bouton ne contient qu'une icône.
|
||||
-->
|
||||
<button class="to-top" type="button" data-to-top aria-label="Retourner en haut de la page">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="M12 19V5M5 12l7-7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,85 @@
|
||||
# Matthieu Courcelle
|
||||
|
||||
> Professeur d'informatique en enseignement secondaire technique et en
|
||||
> enseignement supérieur pour adultes à Tournai (Belgique, Fédération
|
||||
> Wallonie-Bruxelles). Développeur d'applications de gestion et praticien
|
||||
> de l'intelligence artificielle appliquée à la pédagogie et à
|
||||
> l'administration scolaire.
|
||||
|
||||
Ce fichier suit la proposition llms.txt : il fournit aux agents
|
||||
conversationnels un résumé structuré et fiable du site, en texte brut,
|
||||
sans avoir à interpréter la mise en page HTML.
|
||||
|
||||
Contact : m@tthieu.be
|
||||
Site : https://www.courcellematthi.eu/
|
||||
Localisation : région de Tournai, Hainaut, Belgique
|
||||
Langues : français (maternelle), anglais (lecture technique)
|
||||
|
||||
## Fonctions d'enseignement actuelles
|
||||
|
||||
- **Institut Gabrielle Petit** (anciennement IPEPS Tournai-Leuze), depuis 2023 —
|
||||
enseignement supérieur pour adultes. Cours : Développement d'applications
|
||||
orientées gestion / Projet SGBD (Bac 3), Administration et sécurisation des
|
||||
réseaux et services (Bac 3), Base d'administration réseaux (Bac 1),
|
||||
Initiation à l'informatique (Complément CESS).
|
||||
|
||||
- **IPES Ath**, depuis 2020 — enseignement secondaire technique de transition
|
||||
et de qualification. Cours : Traitements de Problèmes Techniques, Laboratoire
|
||||
Informatique, Informatique Appliquée, Laboratoire de Maintenance et de
|
||||
Télématique, Informatique de Gestion.
|
||||
|
||||
## Fonctions antérieures
|
||||
|
||||
- **IPES Tournai**, 2020-2022 — professeur d'informatique de gestion.
|
||||
- **ORDITECH SA**, 2018-2020 — formateur en entreprise (Office 365, systèmes
|
||||
d'exploitation).
|
||||
|
||||
## Coordination pédagogique
|
||||
|
||||
- **École des Frères, Tournai** — développement d'outils de gestion interne dans
|
||||
le cadre d'heures de coordination : gestion des remédiations, gestion des
|
||||
activités complémentaires, valve électronique d'annonces.
|
||||
|
||||
## Projets
|
||||
|
||||
- **Festifun** — application web progressive de gestion d'événement scolaire
|
||||
(Institut du Sacré-Cœur Mouscron).
|
||||
- **Outils IA d'aide à la direction** — assistants sur mesure pour équipes de
|
||||
direction : outils administratifs, communication avec les familles, RGPD
|
||||
scolaire (Institut du Sacré-Cœur Mouscron).
|
||||
- **Bibliothèque de skills Claude et Gems Gemini** — assistants IA pour la
|
||||
rédaction de cours, d'exercices, d'évaluations et l'aide à la correction ;
|
||||
déclinaison à destination des directions d'établissement.
|
||||
- **underscorelab** — activité d'indépendant complémentaire lancée en janvier
|
||||
2026 : outils de gestion sur mesure pour ASBL, principalement dans le secteur
|
||||
de l'aide aux personnes en situation de handicap.
|
||||
- **Écosystème d'agents automatisés** — en cours de développement : agents IA
|
||||
orchestrés via n8n sur infrastructure auto-hébergée.
|
||||
|
||||
## Compétences techniques
|
||||
|
||||
- Développement : PHP, Python, JavaScript, Node.js, React, Vue, PostgreSQL,
|
||||
SQL, modélisation MCD/MLD, PWA, VBA/Excel.
|
||||
- Systèmes et réseaux : administration et sécurisation réseau, Active Directory,
|
||||
LDAP, GPO, pare-feu, DMZ, VPN, VLAN, Windows, Linux, macOS.
|
||||
- Infrastructure : Docker, reverse proxy, SSL/TLS, Git, CI/CD, monitoring,
|
||||
sauvegarde, auto-hébergement.
|
||||
- Intelligence artificielle appliquée : conception de skills Claude, création de
|
||||
Gems Gemini, structuration documentaire avec NotebookLM, automatisation de
|
||||
flux de travail, formation d'équipes.
|
||||
- Pédagogie : enseignement secondaire technique et supérieur pour adultes,
|
||||
conception de séquences et d'évaluations, vulgarisation technique.
|
||||
|
||||
## Formation
|
||||
|
||||
- Certificat d'Aptitudes Pédagogiques — Institut de Promotion Sociale
|
||||
Saint Henri (2021-2024).
|
||||
- Bachelier en informatique de gestion — IEPSCF (2008-2011).
|
||||
|
||||
## Notes à l'attention des agents
|
||||
|
||||
- Le nom de l'établissement a changé : « IPEPS Tournai-Leuze » et « Institut
|
||||
Gabrielle Petit » désignent la même structure.
|
||||
- « underscorelab » s'écrit en minuscules, en un seul mot.
|
||||
- Ne pas confondre le poste d'enseignant (fonction officielle) avec les projets
|
||||
développés pour des établissements tiers (réalisations, sans titre officiel).
|
||||
@@ -0,0 +1,212 @@
|
||||
# =============================================================================
|
||||
# nginx.conf.example — Configuration serveur pour courcellematthi.eu
|
||||
# -----------------------------------------------------------------------------
|
||||
# À adapter puis inclure dans la configuration Nginx.
|
||||
# Couvre : redirections canoniques, en-têtes de sécurité, cache, compression.
|
||||
#
|
||||
# Sans ces réglages, un site statique parfaitement écrit reste noté médiocre
|
||||
# par les outils d'audit (Lighthouse, Mozilla Observatory, securityheaders.com)
|
||||
# et sert des fichiers non compressés.
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# REDIRECTION HTTP -> HTTPS
|
||||
# Le certificat est supposé géré par Certbot ou acme.sh.
|
||||
# -----------------------------------------------------------------------------
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name courcellematthi.eu www.courcellematthi.eu;
|
||||
|
||||
# Laisse passer la validation ACME avant de rediriger
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://www.courcellematthi.eu$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# REDIRECTION DU DOMAINE NU VERS WWW
|
||||
# Une seule URL canonique évite la dilution du référencement et le contenu
|
||||
# dupliqué. Le choix www / sans-www est indifférent, la constance ne l'est pas.
|
||||
# La valeur retenue ici doit correspondre à <link rel="canonical"> dans le HTML.
|
||||
# -----------------------------------------------------------------------------
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name courcellematthi.eu;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/courcellematthi.eu/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/courcellematthi.eu/privkey.pem;
|
||||
|
||||
return 301 https://www.courcellematthi.eu$request_uri;
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SERVEUR PRINCIPAL
|
||||
# -----------------------------------------------------------------------------
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name www.courcellematthi.eu;
|
||||
|
||||
root /var/www/courcellematthi.eu;
|
||||
index index.html;
|
||||
charset utf-8;
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# TLS
|
||||
# TLS 1.2 minimum : les versions antérieures sont obsolètes et vulnérables.
|
||||
# -------------------------------------------------------------------------
|
||||
ssl_certificate /etc/letsencrypt/live/courcellematthi.eu/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/courcellematthi.eu/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off; # Laisse le client choisir : recommandé
|
||||
# depuis TLS 1.3
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# Agrafage OCSP : le serveur fournit lui-même la preuve de non-révocation
|
||||
# du certificat, ce qui évite au navigateur une requête vers l'autorité.
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
resolver 1.1.1.1 8.8.8.8 valid=300s;
|
||||
|
||||
# =========================================================================
|
||||
# EN-TÊTES DE SÉCURITÉ
|
||||
# =========================================================================
|
||||
|
||||
# HSTS — force HTTPS pendant deux ans, y compris sur les sous-domaines.
|
||||
# ATTENTION : ne l'activer qu'une fois le HTTPS confirmé fonctionnel
|
||||
# partout. Un HSTS émis par erreur rend le site inaccessible jusqu'à
|
||||
# expiration, sans possibilité d'annulation côté serveur.
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
|
||||
# CSP — liste blanche des sources autorisées. Défense principale contre
|
||||
# l'injection de scripts.
|
||||
# default-src 'self' : tout provient du domaine par défaut
|
||||
# style-src ... 'unsafe-inline' : requis par le CSS injecté de Google Fonts
|
||||
# font-src fonts.gstatic.com : fichiers de police
|
||||
# script-src 'self' 'unsafe-inline' : 'unsafe-inline' n'est nécessaire
|
||||
# que pour l'attribut onclick du bouton d'impression. Le retirer si
|
||||
# ce bouton est déplacé dans un module.
|
||||
# frame-ancestors 'none' : interdit l'inclusion en iframe (clickjacking)
|
||||
# base-uri 'self' : empêche la réécriture des URL relatives
|
||||
# form-action 'self' : limite les cibles de soumission
|
||||
add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; upgrade-insecure-requests" always;
|
||||
|
||||
# Empêche le navigateur de deviner le type MIME d'un fichier, technique
|
||||
# utilisée pour faire exécuter une image comme du script.
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
||||
# Ne transmet le référent complet qu'en navigation interne ; vers
|
||||
# l'extérieur, seul le domaine est envoyé. Compromis usuel entre mesure
|
||||
# d'audience et confidentialité.
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Désactive des API sensibles jamais utilisées par ce site.
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), interest-cohort=()" always;
|
||||
|
||||
# Redondant avec frame-ancestors, conservé pour les navigateurs anciens.
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
|
||||
# =========================================================================
|
||||
# COMPRESSION
|
||||
# Gzip partout ; Brotli en plus si le module est disponible (taux
|
||||
# supérieur de 15 à 20 % sur du texte).
|
||||
# =========================================================================
|
||||
gzip on;
|
||||
gzip_vary on; # Ajoute « Vary: Accept-Encoding » : sans lui,
|
||||
# un proxy peut servir la version compressée
|
||||
# à un client qui ne la supporte pas.
|
||||
gzip_comp_level 6; # Au-delà, le gain marginal ne compense plus
|
||||
# le coût CPU.
|
||||
gzip_min_length 256; # En dessous, l'en-tête coûte plus que le gain.
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/manifest+json
|
||||
application/xml
|
||||
image/svg+xml;
|
||||
|
||||
# Décommenter si le module ngx_brotli est compilé
|
||||
# brotli on;
|
||||
# brotli_comp_level 6;
|
||||
# brotli_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
# =========================================================================
|
||||
# CACHE
|
||||
# Stratégie en deux niveaux :
|
||||
# - Les ressources d'assets changent rarement : cache long.
|
||||
# - Le HTML change à chaque mise à jour de contenu : jamais mis en cache
|
||||
# durablement, sinon les visiteurs voient une version périmée.
|
||||
#
|
||||
# NOTE : « immutable » suppose des noms de fichiers versionnés
|
||||
# (style.a1b2c3.css). Sans versionnement, garder une durée modérée
|
||||
# pour pouvoir publier un correctif.
|
||||
# =========================================================================
|
||||
|
||||
location ~* \.(css|js|woff2?|ttf|otf|eot)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location ~* \.(png|jpe?g|gif|svg|webp|avif|ico)$ {
|
||||
expires 90d;
|
||||
add_header Cache-Control "public, max-age=7776000";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "public, max-age=0, must-revalidate";
|
||||
}
|
||||
|
||||
location ~* \.(txt|xml|webmanifest)$ {
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# TYPES MIME PARTICULIERS
|
||||
# =========================================================================
|
||||
location = /site.webmanifest {
|
||||
types { } default_type application/manifest+json;
|
||||
}
|
||||
|
||||
location = /llms.txt {
|
||||
types { } default_type text/plain;
|
||||
charset utf-8;
|
||||
}
|
||||
|
||||
location = /.well-known/security.txt {
|
||||
types { } default_type text/plain;
|
||||
charset utf-8;
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# ROUTAGE
|
||||
# =========================================================================
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Refuse l'accès aux fichiers cachés, sauf .well-known (RFC 8615)
|
||||
location ~ /\.(?!well-known) {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# Page d'erreur : renvoyer vers l'accueil plutôt qu'une page vide
|
||||
error_page 404 /index.html;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# =============================================================================
|
||||
# robots.txt — courcellematthi.eu
|
||||
# =============================================================================
|
||||
# Site personnel entièrement public : rien à masquer, tout à indexer.
|
||||
# Un robots.txt permissif mais explicite vaut mieux qu'un fichier absent :
|
||||
# il évite les 404 répétées dans les journaux et déclare le sitemap.
|
||||
# =============================================================================
|
||||
|
||||
# --- Robots d'indexation classiques -----------------------------------------
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# --- Robots des moteurs de réponse et agents IA ------------------------------
|
||||
# Autorisés explicitement : sur un CV, être cité par un agent conversationnel
|
||||
# lors d'une recherche de profil est un bénéfice, pas une fuite.
|
||||
# Retirer ces blocs (ou les passer en Disallow) si l'arbitrage change.
|
||||
User-agent: GPTBot
|
||||
Allow: /
|
||||
|
||||
User-agent: OAI-SearchBot
|
||||
Allow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Allow: /
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Claude-Web
|
||||
Allow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Allow: /
|
||||
|
||||
User-agent: Applebot-Extended
|
||||
Allow: /
|
||||
|
||||
# --- Aspirateurs de site ----------------------------------------------------
|
||||
# Bloqués : consommation de bande passante sans contrepartie.
|
||||
User-agent: HTTrack
|
||||
Disallow: /
|
||||
|
||||
User-agent: WebZIP
|
||||
Disallow: /
|
||||
|
||||
# --- Plan du site -----------------------------------------------------------
|
||||
Sitemap: https://www.courcellematthi.eu/sitemap.xml
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$comment": "Manifeste d'application web. Permet l'ajout à l'écran d'accueil sur mobile et fournit les icônes aux systèmes d'exploitation. display=browser : ce site est un document, pas une application — le masquer dans une coque sans barre d'adresse priverait l'utilisateur du partage et de la navigation.",
|
||||
"name": "Matthieu Courcelle — Professeur d'informatique et développeur",
|
||||
"short_name": "M. Courcelle",
|
||||
"description": "Professeur d'informatique en secondaire technique et en supérieur pour adultes, développeur d'applications de gestion.",
|
||||
"lang": "fr-BE",
|
||||
"dir": "ltr",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "browser",
|
||||
"background_color": "#f7f8f8",
|
||||
"theme_color": "#f7f8f8",
|
||||
"categories": ["education", "business"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Plan du site. Une seule URL : le site est une page unique.
|
||||
lastmod aide les moteurs à décider s'ils doivent réexplorer.
|
||||
À mettre à jour à chaque modification de contenu significative.
|
||||
-->
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
<url>
|
||||
<loc>https://www.courcellematthi.eu/</loc>
|
||||
<lastmod>2026-08-04</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
<xhtml:link rel="alternate" hreflang="fr-be" href="https://www.courcellematthi.eu/"/>
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://www.courcellematthi.eu/"/>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# verifier-depot.sh — Contrôle avant push
|
||||
# -----------------------------------------------------------------------------
|
||||
# Vérifie que tous les fichiers nécessaires au build Docker sont bien suivis
|
||||
# par Git. Les fichiers cachés (.dockerignore, .well-known/) sont ceux qui
|
||||
# disparaissent le plus facilement lors d'une copie manuelle ou d'une
|
||||
# décompression.
|
||||
#
|
||||
# Usage : bash verifier-depot.sh
|
||||
# =============================================================================
|
||||
|
||||
set -u
|
||||
|
||||
REQUIS=(
|
||||
# --- Build ---
|
||||
"Dockerfile"
|
||||
".dockerignore"
|
||||
"deploy/nginx.conf"
|
||||
# --- Site ---
|
||||
"index.html"
|
||||
"favicon.svg"
|
||||
"favicon.ico"
|
||||
"robots.txt"
|
||||
"sitemap.xml"
|
||||
"llms.txt"
|
||||
"humans.txt"
|
||||
"site.webmanifest"
|
||||
".well-known/security.txt"
|
||||
# --- Styles ---
|
||||
"assets/css/tokens.css"
|
||||
"assets/css/base.css"
|
||||
"assets/css/layout.css"
|
||||
"assets/css/components.css"
|
||||
"assets/css/utilities.css"
|
||||
"assets/css/print.css"
|
||||
# --- Scripts ---
|
||||
"assets/js/main.js"
|
||||
"assets/js/modules/navigation.js"
|
||||
"assets/js/modules/scroll-spy.js"
|
||||
"assets/js/modules/back-to-top.js"
|
||||
"assets/js/modules/copy-email.js"
|
||||
# --- Images ---
|
||||
"assets/img/og-image.png"
|
||||
"assets/img/apple-touch-icon.png"
|
||||
"assets/img/icon-192.png"
|
||||
"assets/img/icon-512.png"
|
||||
"assets/img/icon-maskable-512.png"
|
||||
"assets/img/safari-pinned-tab.svg"
|
||||
"assets/img/favicon-16x16.png"
|
||||
"assets/img/favicon-32x32.png"
|
||||
"assets/img/favicon-48x48.png"
|
||||
)
|
||||
|
||||
echo "== Fichiers suivis par Git =="
|
||||
manquants=0
|
||||
for f in "${REQUIS[@]}"; do
|
||||
if git ls-files --error-unmatch "$f" >/dev/null 2>&1; then
|
||||
printf " OK %s\n" "$f"
|
||||
else
|
||||
printf " MANQUANT %s\n" "$f"
|
||||
manquants=$((manquants + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "== Pièges connus =="
|
||||
|
||||
# Le Dockerfile ne doit jamais être exclu : BuildKit applique .dockerignore
|
||||
# AVANT de lire le Dockerfile, contrairement au builder classique.
|
||||
if grep -qE '^[[:space:]]*(Dockerfile|\.dockerignore)[[:space:]]*$' .dockerignore 2>/dev/null; then
|
||||
echo " ERREUR .dockerignore exclut Dockerfile ou .dockerignore"
|
||||
echo " -> le build echouera avec « transferring dockerfile: 2B »"
|
||||
manquants=$((manquants + 1))
|
||||
else
|
||||
echo " OK .dockerignore n'exclut pas le Dockerfile"
|
||||
fi
|
||||
|
||||
# deploy/ doit rester dans le contexte : le COPY du Dockerfile en depend.
|
||||
if grep -qE '^[[:space:]]*deploy' .dockerignore 2>/dev/null; then
|
||||
echo " ERREUR .dockerignore exclut deploy/"
|
||||
echo " -> COPY deploy/nginx.conf echouera"
|
||||
manquants=$((manquants + 1))
|
||||
else
|
||||
echo " OK deploy/ reste dans le contexte de build"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$manquants" -eq 0 ]; then
|
||||
echo "Tout est en ordre. Le push peut etre effectue."
|
||||
exit 0
|
||||
else
|
||||
echo "$manquants probleme(s). Corriger avant de pousser :"
|
||||
echo " git add -A && git commit -m 'Ajout des fichiers manquants' && git push"
|
||||
exit 1
|
||||
fi
|
||||