Site personnel courcellematthi.eu

This commit is contained in:
lrdlg
2026-08-05 22:40:03 +02:00
commit df12d21165
39 changed files with 4503 additions and 0 deletions
+56
View File
@@ -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
+18
View File
@@ -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/
+103
View File
@@ -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
+8
View File
@@ -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
+379
View File
@@ -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
```
+79
View File
@@ -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;"]
+343
View File
@@ -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é.
+181
View File
@@ -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;
}
}
}
+455
View File
@@ -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);
}
}
+330
View File
@@ -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);
}
}
}
+225
View File
@@ -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; }
+146
View File
@@ -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;
}
}
+67
View File
@@ -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; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+6
View File
@@ -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

+74
View File
@@ -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();
}
+58
View File
@@ -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);
}
+64
View File
@@ -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");
}
});
}
+107
View File
@@ -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();
}
+92
View File
@@ -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));
}
+148
View File
@@ -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;
}
+96
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+23
View File
@@ -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

+15
View File
@@ -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)
+930
View File
@@ -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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>&copy; 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>
+85
View File
@@ -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).
+212
View File
@@ -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;
}
+50
View File
@@ -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
+40
View File
@@ -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"
}
]
}
+17
View File
@@ -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>
+96
View File
@@ -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