Files
2026-08-24 09:36:25 +02:00

16 KiB
Raw Permalink Blame History

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.

L'écran et le papier ne servent pas le même usage. À l'écran, le visiteur parcourt et approfondit : les descriptions de cours, le texte de présentation et les projets ont leur place. Sur papier, un recruteur trie une pile en quelques secondes — il veut les postes, les compétences, les diplômes et de quoi rappeler.

La feuille d'impression ne masque donc pas « un peu » de contenu : elle recompose le document en CV classique sur une seule page A4.

Conservé Retiré
Nom, fonction, coordonnées Texte de présentation
Postes, dates, établissements Descriptions de cours
Intitulés de cours (en ligne) Projets (option, voir ci-dessous)
Coordination pédagogique Navigation, boutons, pied de page
Compétences (3 colonnes) Section contact (doublon de l'en-tête)
Formations

Mises en œuvre notables : marges à 12 mm, corps à 9,1 pt avec un interligne de 1,42, trait de chronologie supprimé (6 mm de marge gauche récupérés pour un gain de lisibilité nul sur papier), dates flottées à droite, et surtout les neuf fiches de cours converties en énumération courante séparée par des points médians — empilées elles occupaient 40 mm, en ligne elles tiennent en 12 mm.

L'aération a été calibrée par itérations mesurées : la version la plus dense laissait un quart de page vide, la plus aérée débordait de 30 mm sur une deuxième page. Le réglage retenu remplit la page en gardant une réserve d'environ 8 % en pied, marge nécessaire pour absorber les variations de rendu entre navigateurs et pilotes d'impression.

Les couleurs sont remises à plat en noir sur blanc : lisible sur imprimante monochrome, où le vert #05BC8A sortirait en gris quasi invisible.

Les projets sont masqués par défaut mais réactivables. Un bloc commenté en fin de print.css donne la marche à suivre. Vérifié en test : la version avec projets tient encore sur une page et remplit le quart de page actuellement vide. L'arbitrage dépend du destinataire — underscorelab, Festifun et les outils IA sont ce qui distingue ce CV d'un profil d'enseignant classique, mais un jury de recrutement statutaire n'en a pas l'usage.

Résultat mesuré : 1 page A4, dans les deux configurations.


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 à :

  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é.