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 :
@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
#05BC8Asortirait 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.
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 à :
- Mettre à jour
<lastmod>danssitemap.xml - Soumettre le sitemap dans Google Search Console
- Vérifier le rendu des partages via le débogueur LinkedIn et l'outil de test des résultats enrichis de Google
- 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-expandedcohé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é.