Ce site

Comment ce site est construit, et ce que contient le dépôt qui le produit.

Dépôt

762 fichiers répartis dans 284 dossiers
apps
web
src
content.config.ts
.gitignore
AGENTS.md
astro.config.mjs
eslint.config.js
package.json
README.md
tsconfig.json
turbo.json
vitest.config.ts
.gitignore
.mcp.json
.npmrc
.nvmrc
.prettierignore
AGENTS.md
CLAUDE.md
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
prettier.config.js
README.md
renovate.json
turbo.json

Ce site est un portfolio statique en quatre langues construit avec Astro, accompagné d'un CMS Strapi qui l'alimente en contenu. Tout ce que vous voyez est produit depuis un seul dépôt, et l'arborescence à gauche est ce dépôt — générée depuis git ls-files, elle liste donc exactement ce qui est versionné, et rien d'autre.

Deux applications, un workspace

C'est un monorepo pnpm + Turbo avec deux applications, trois paquets de configuration partagés et un générateur d'assets :

  • apps/web — le site Astro 7 / React 19 / Tailwind v4 que vous lisez.
  • apps/admin — un CMS Strapi 5 (épinglé en 5.52.3, sur Postgres).
  • packages/typescript-config, packages/eslint-config et packages/tailwind-config — les fondations partagées de TypeScript, d'ESLint et des tokens de design.
  • packages/logo-assets — dessine la marque du site en SVG et en PNG. Il lit ses couleurs dans packages/tailwind-config/theme.css plutôt que d'en contenir, et rien dans le build n'en dépend.

Les deux applications ne partagent délibérément que des données. apps/admin fait tourner son propre panneau d'administration React 18, son propre tsconfig.json et sa propre configuration Prettier ; elle n'importe ni composants ni types depuis apps/web, et n'utilise pas l'alias de chemin $/* du site. Le seul contrat entre les deux est un contrat généré : pnpm --filter admin generate lit le schéma depuis un Strapi en cours d'exécution et écrit un client typé dans apps/web/src/.generated/strapi-client/, que le site importe via l'alias $strapi. Modifiez un type de contenu, régénérez, et les erreurs de type vous disent quoi corriger. Rien d'autre ne franchit la frontière.

Le rôle de Turbo est volontairement réduit. turbo.json déclare six tâches — build, lint, lint:fix, typecheck, test, dev — qui pour l'essentiel ne font que s'ordonner derrière les dépendances de leur workspace (dependsOn: ["^build"] et compagnie). dev est marquée cache: false, persistent: true. Ce que chaque tâche hache est la partie où il y a de la réflexion, et la part qui revient à l'application web vit dans apps/web/turbo.json : les variables d'environnement qui changent sa sortie, et le "cache": false d'astro check. Turbo ne sait toujours rien d'Astro ni de Strapi ; il ordonnance et met en cache, et chaque application possède ses propres scripts.

Statique par construction

apps/web/astro.config.mjs ne configure aucun adaptateur. Cette seule omission est la décision qui façonne tout le reste : la sortie est du HTML prérendu, synchronisé vers S3 et servi par CloudFront. Il n'y a aucun serveur au moment de la requête, donc toute lecture de données a lieu au moment du build — désormais via une phase de synchronisation qui ne s'exécute qu'une fois, avant le rendu de toute page : les loaders des collections de contenu appellent le client généré, celui-ci appelle Strapi pour les quatre langues, et les résultats atterrissent dans le content store d'Astro. Les pages ne récupèrent plus rien elles-mêmes ; elles lisent ce store, et la réponse finit toujours figée dans le HTML.

Les conséquences sont la partie intéressante. Les modifications de contenu ne sont pas immédiates — il leur faut un rebuild, et c'est pourquoi le CMS s'est vu greffer un bouton Deploy. Les secrets n'atteignent jamais un navigateur, parce que le seul processus qui les détient est le build. Et la configuration est attentive à cette frontière : CDN_URL et IMAGE_DOMAINS sont lues via le loadEnv de Vite et toutes deux sont facultatives, tandis que STRAPI_BASE_URL, STRAPI_API_TOKEN et SITE_URL sont déclarées dans le env.schema d'Astro sous context: 'server', le token en plus avec access: 'secret'. Une échappatoire a été fermée et une autre laissée ouverte à dessein : la configuration ne lève plus d'erreur sans CDN_URL, si bien que la suite de tests n'a besoin d'aucun identifiant, mais astro check comme un vrai build ont toujours besoin d'un CMS joignable, et la CI fournit de vrais identifiants depuis un secret plutôt que de se rabattre sur un content store vide.

Deux choses parlent bien au CMS depuis un navigateur, et elles sont l'exception qui précise la règle. POST /api/contact porte le formulaire de contact de la page d'accueil et — depuis l'arrivée de la connexion — les points d'entrée de session sous /api/auth/* ainsi que /api/users/me répondent aux pages /auth/* et à l'en-tête. Ni l'un ni l'autre ne peut détenir de secret, parce qu'une page construite statiquement n'a nulle part où en garder un : la route de contact est publique et défendue par un pot de miel et un limiteur de débit plutôt que par un token, et l'identifiant de la session est un cookie httpOnly sur l'origine du CMS qu'aucun script ne peut lire, échangé contre un jeton d'accès de dix minutes qui vit dans une variable de portée module et n'est écrit dans aucun stockage. Le site porte donc désormais une session qu'aucun build n'a jamais vue. L'îlot de l'en-tête demande qui vous êtes après le chargement de la page, et ne rend absolument rien quand la réponse est personne — ce qui est la réponse pour la quasi-totalité des visiteurs.

Le CMS fait aussi une chose dont ni un build ni un navigateur n'est témoin : il envoie du courrier. Une soumission du formulaire de contact laisse désormais deux messages derrière elle — la notification de l'exploitant, et un accusé de réception au visiteur dans celle des quatre langues où il a écrit. Ceux-là, une lettre d'information et les deux e-mails d'authentification font dix modèles en tout, et chacun est écrit à la main. Un générateur, à apps/admin/scripts/email-templates/, les composait tous les dix depuis une seule coquille, si bien qu'une réinitialisation de mot de passe et une réponse automatique avaient l'air du même site ; il a disparu, avec ses artefacts versionnés, parce qu'une copie générée de ce que le CMS détient déjà est une deuxième source de vérité et en dérive. Ils atteignent Strapi par deux chemins différents parce qu'ils sont deux choses différentes : les huit localisés sont des lignes du plugin Email Designer, écrites dans son onglet Custom, tandis que les deux e-mails d'authentification ne sont pas des lignes du tout, et sont collés à la main dans le store du plugin depuis l'écran Réglages de Strapi lui-même — l'éditeur du plugin n'a aucun moyen de conserver du markup écrit à la main, puisqu'y enregistrer réexporte toujours le document d'Unlayer par-dessus. apps/admin/src/lib/email-templates.ts est ce qui a survécu, et c'est désormais le seul fichier qu'il faut tenir à jour avec le CMS à la main : il porte un identifiant de référence par locale, et un modèle créé dans le plugin sans son templateReferenceId correspondant échoue en silence : un identifiant de référence inconnu écrit une ligne de log et rend la main, et le visiteur n'entend tout simplement jamais parler de rien. Veiller à cela est aussi ce qui a enfin doté apps/admin d'un lanceur de tests : une suite Vitest sous apps/admin/tests/, là où il n'y en avait aucune.

Du push au CDN

flowchart TB
  renovate["Renovate, le lundi"] --> pr["Pull request"]
  pr --> checks["Format, lint, typecheck, test, build web"]
  checks --> push["Push vers master"]
  push --> detect["Détection des chemins modifiés"]
  detect --> cms["Build Strapi, synchronisation, redémarrage"]
  cms --> web["Build Astro, synchronisation vers S3"]
  web --> cdn["Invalidation du CDN"]
  widget["Widget de publication Strapi"] --> web

Les vérifications et les déploiements sont deux workflows distincts, et c'est la seule décision sur laquelle repose la simplicité de tout le reste. .github/workflows/ci.yml s'exécute sur les pull requests et ne contient qu'un job, Checks : une vérification de fraîcheur de l'arborescence, format:check, turbo run lint typecheck test --continue, puis le build web. .github/workflows/deploy.yml s'exécute sur les push vers master, résout les cibles de déploiement et appelle cd-cms.yml puis cd-web.yml. Aucun ne fait le travail de l'autre, chacun n'a donc qu'un déclencheur, et la porte d'agrégation, les workflows réutilisables par périmètre et la plupart des conditions de propagation de saut qui vivaient ici ont disparu.

ci.yml n'a délibérément pas de déclencheur push. pull_request vérifie refs/pull/N/merge — la tête fusionnée dans la base — donc si la base n'a pas bougé, l'arbre qui atterrit sur master est celui déjà vérifié. Le revérifier là ne prouve rien, et la garantie que la base ne peut pas bouger sous une pull request périmée est un réglage de protection de branche (exiger des branches à jour, ou une file de fusion), non quelque chose que le YAML puisse exprimer. deploy.yml n'exécute donc aucune vérification.

Seul le déploiement du CMS est filtré par chemins, via dorny/paths-filter, parce que c'est un envoi d'environ 1 Go et un redémarrage de Strapi. Le déploiement web s'exécute à chaque push, et ce n'est pas de la paresse : /about/this-website publie un arbre construit à partir de git ls-files à la racine du dépôt, si bien qu'un commit ne touchant que docs/ modifie une page publiée. Le filtrer sur apps/web/** livrait silencieusement un arbre périmé. Le filtre a un trou qu'il vaut la peine de connaître — workflow_dispatch n'a pas de base de comparaison, il comparerait donc la branche à elle-même et sauterait tout, transformant un redéploiement manuel en no-op silencieux. L'étape de filtre est donc sautée sur dispatch et une étape de résolution force la cible CMS, ce qui fait de « dispatcher Deploy sur master » le bouton tout redéployer.

Le CMS passe avant le web parce que les pages prérendues incorporent les réponses de Strapi dans la sortie statique : un changement de contenu sans reconstruction du site laisse le site en ligne périmé. Le job web attend le job CMS en succès ou en saut, et c'est la seule condition de propagation de saut qui reste dans le pipeline — !cancelled() est ce qui permet à un job d'observer un prédécesseur sauté au lieu d'être sauté avec lui, au prix de la réénonciation des gardes qu'il écarte. Le déploiement web envoie ensuite en trois passes — assets immuables hachés, puis pages avec --delete, puis élagage des assets périmés — invalide CloudFront, et demande deux routes à travers la distribution, car déposer des octets dans un bucket n'est pas la même chose que servir ce bucket.

La dernière flèche vers le déploiement du site vient de Strapi lui-même. Un plugin local dans apps/admin/src/plugins/web-deploy/ ajoute un widget sur la page d'accueil et une page dans la barre latérale qui dispatchent cd-web.yml via l'API REST de GitHub avec un dispatch-id aléatoire. cd-web.yml répète cet identifiant dans son run-name, et le plugin retrouve le run par ce jeton — une corrélation déterministe plutôt qu'une devinette dans une fenêtre temporelle.

Un cinquième workflow n'appartient à aucune des deux moitiés. renovate.yml tourne auto-hébergé sur un cron du lundi et est le seul à écrire dans le dépôt au lieu d'en déployer, raison pour laquelle son jeton réside sur un environnement renovate restreint à master plutôt que parmi les secrets du dépôt : il dispose du droit d'écriture sur les workflows, donc il pourrait réécrire les fichiers mêmes qui portent la clé SSH de déploiement. Il ouvre des pull requests, si bien que sa sortie repasse par ci.yml comme n'importe quel autre changement. Une convention qu'il impose touche tous les workflows d'ici : une action tierce épinglée à un SHA nu lui est invisible, chaque épinglage porte donc sa version en commentaire final # vX.Y.Z, et en supprimer un arrête la surveillance de cette action sans le dire.

Ce qu'un build fait réellement

flowchart TD
  loader["Chargeurs de contenu"] --> client["Client Strapi généré"]
  client --> strapi["Strapi"]
  loader --> store["Dépôt de contenu"]
  store --> data["Couche de données du feature"]
  page["Page Astro"] --> routes["Résolution des locales et des routes"]
  page --> i18n["Traductions"]
  page --> data
  page --> html["HTML pré-rendu"]
  html --> islands["Îlots hydratés"]

Rendre une page relève surtout de la résolution. La route détermine la langue, la langue sélectionne un paquet de traductions, et les modules de fonctionnalités lisent dans le content store d'Astro le contenu dont la page a besoin — la récupération a déjà eu lieu, une fois par build, pendant la phase de synchronisation décrite plus haut. Là où une fonctionnalité sous src/features/ a du contenu à lire, elle garde cette séparation explicite, et les noms de fichiers sont les rôles : repository.ts est la couche de récupération qu'appelle un loader de contenu, le prédicat de validité voyage avec la collection dans content.config.ts, et service.ts relit le résultat via getCollection ou getEntry, si bien qu'une page ne parle jamais elle-même à Strapi. contact saute la couche entièrement ; search garde un service.ts mais n'a besoin d'aucun repository propre, parce qu'il lit à travers quatre autres fonctionnalités.

Cette page relève des deux moitiés. La forme de l'arborescence vient d'un artefact JSON généré, tandis que ses notes de chemin et cette vue d'ensemble proviennent du type de contenu localisé File Annotation dans Strapi. Le graphe de contributions en dessous interroge lui aussi Strapi, mais cela ne se produit plus au moment du rendu : about/contributions/repository.ts — le seul repository du code qui utilise un fetch brut plutôt que le client généré, parce que son point d'accès est une route Strapi sur mesure et non un type de contenu — s'exécute à l'intérieur d'un loader pendant la phase de synchronisation, et le service.ts du graphe relit le résultat depuis le content store. Ce loader est tolérant : un point d'accès injoignable ou une charge utile qui échoue à son schéma n'écrit aucune entrée du tout, si bien que le graphe disparaît plutôt que de faire échouer le build. Ce qui sort à l'autre bout est du HTML dans les deux cas.

L'interactivité est explicite. La règle que suit le code est que les composants Astro possèdent la mise en page et tout ce qui est décidable au moment du build, et que les fichiers React .tsx n'apparaissent que là où l'interface est vraiment interactive — la palette de recherche, les menus, les carrousels. Tout le reste est livré en HTML sans aucun JavaScript attaché. Le comportement qui a besoin du DOM mais d'aucun framework — les carrousels, les deux filtres de page, le champ de physique sous les compétences — est un Controller : une classe qui possède un seul élément racine, enregistre ses écouteurs via un helper qui se souvient comment les défaire, et que le gestionnaire de cycle de vie monte, si bien qu'une view transition le démonte et le reconstruit au lieu de le laisser fuir. La palette de recherche s'hydrate en client:idle et ne va même pas chercher son index avant que vous ne l'ouvriez.

Le timing était autrefois la seule partie qui n'était pas de la pure résolution : getSearchIndex mémoïsait une promesse par langue pour éviter que quatre rendus — un par langue — ne re-téléchargent chacun toutes les collections depuis Strapi. Maintenant que la recherche lit le content store au lieu de récupérer les données, il n'y a plus rien à re-télécharger — la phase de synchronisation a déjà tourné une fois pour les quatre langues avant que la moindre page ne soit rendue — si bien que la mémoïsation par langue a disparu purement et simplement.

Quatre couches sous src/

apps/web/src/ tient en quatre couches, et la direction entre elles est l'essentiel. core/ est la machinerie que rien ne rend : le gestionnaire de cycle de vie, la classe de base Controller, le client Strapi avec son repository paginant, et les loaders et lecteurs des collections de contenu. design-system/ est de la présentation sans aucune connaissance du domaine — primitives/ pour les atomes et leurs recettes de style, patterns/ pour les pièces composées qui ignorent encore ce qu'est un projet. shell/ est l'habillage que porte chaque page : en-tête, pied de page, mise en page de section, indicateur de chargement, SEO et analytics. features/ est tout ce qui connaît le domaine.

Une fonctionnalité peut atteindre n'importe laquelle des trois autres couches ; aucune d'elles ne peut revenir vers une fonctionnalité, et une fonctionnalité n'en atteint une autre qu'à travers son service.ts. À l'intérieur d'une fonctionnalité, les noms de fichiers sont les rôles, et c'est ce qui fait que le placement cesse d'être une question : un module qui récupère des données depuis Strapi n'a qu'un seul endroit où vivre, et un fichier difficile à nommer fait généralement deux choses. L'arborescence portait autrefois des répertoires components/, utils/, constants/ et common/ qui ne pouvaient répondre ni à l'une ni à l'autre. Elle n'en a plus aucun.

Quatre langues, un seul répertoire de pages

Le routage localisé est volontairement partagé. Le manifeste src/i18n/route-manifest.ts produit les motifs d'URL traduits de Paraglide et alimente l'intégration Astro localized-static-routes du dépôt. Pendant astro:config:setup, elle parcourt src/pages/, valide le manifeste puis appelle injectRoute pour chaque langue non par défaut. L'anglais reste sans préfixe ; les autres langues reçoivent /{locale} et, lorsqu'il est déclaré, un slug traduit. C'est pourquoi cette page existe à /about/this-website et à /de/ueber-mich/diese-website.

Paraglide assure l'inversion des URL et la compilation des messages. Les aides typées de src/i18n/routes.ts lui délèguent localeFromUrl, localizedHref et alternateHrefs. Les fichiers .astro, et les modules .ts qu'eux seuls atteignent, lisent les messages via useMessages(locale) — un proxy par langue au-dessus du barrel généré dans src/i18n/messages.ts, indexé par les clés du catalogue lui-même. Tout ce qu'Astro peut envoyer au navigateur — îlots React, modules atteints uniquement par un bloc <script> Astro, et tout ce qu'ils importent — continue d'importer exactement les fonctions de message générées dont il a besoin et de passer la langue explicitement, pour que le catalogue reste tree-shaké ; une suite de tests dédiée fait échouer le build si un module accessible au client importe le barrel à la place. Aucun middleware, provider, état global de build ou catalogue client n'est requis.

Il y a ici un piège qu'il faut énoncer clairement, parce qu'il échoue en silence. localizedHref préfixe toujours les langues non par défaut, entrée dans le manifeste ou pas — une route non mappée retombe directement sur /{locale}{route}. Ce n'est pas l'entrée de la table qui vous donne le préfixe ; c'est elle qui vous donne un slug traduit. Donc localizedHref('/about', { locale: 'ar' }) sans entrée renvoie /ar/about : correctement préfixé, un lien parfaitement fonctionnel, slug anglais. Oublier une entrée ne produit pas un lien cassé, ça produit un lien subtilement faux, et c'est bien plus facile à manquer en relecture.

Les 532 ms de lumière

Le mode sombre était autrefois basé sur une classe — un @custom-variant dark correspondant à une classe .dark que JavaScript appliquait sur DOMContentLoaded. Comme le CSS ne portait aucun repli prefers-color-scheme, un visiteur dont le système était réglé en sombre recevait la palette claire et la voyait, pendant 532 ms mesurées, avant que la classe n'arrive.

Le correctif a consisté à supprimer du code. le thème partagé dans packages/tailwind-config/theme.css définit maintenant la palette claire sur :root et la surcharge à l'intérieur d'une media query prefers-color-scheme: dark — ce qui est exactement ce en quoi se résout la variante dark: intégrée à Tailwind v4, si bien qu'il n'y a plus aucune surcharge @custom-variant et que chaque utilitaire dark: continue de fonctionner sans changement. Une media query se résout avant le premier affichage, donc le flash ne peut pas se produire. :root déclare aussi color-scheme: light dark, ce qui aligne également les barres de défilement natives, les contrôles de formulaire et la couleur du canevas avant peinture sur le réglage système ; la version basée sur une classe laissait tout cela bloqué en clair.

Une décision voisine se trouve quelques lignes plus haut, sous forme de commentaire là où un token se trouvait. Une animation d'entrée --animate-fade-in utilisait le mode de remplissage both, si bien que chaque conteneur qu'elle touchait restait à opacity: 0 jusqu'au démarrage de son animation — y compris le titre du hero et les deux colonnes du profil. Cela rendait l'élément LCP instable : il tombait sur le logo de la navigation, sur un paragraphe ou sur l'image de profil selon le run, et coûtait environ 100 ms de LCP. Le token a disparu et le commentaire explique pourquoi, pour que personne ne le remette. Le contenu doit simplement être là.

Un seul import pour les icônes

Chaque composant importe Icon depuis $/design-system/primitives/Icon.astro, jamais depuis astro-icon/components directement, et ce wrapper fait exactement une chose : il force is:inline.

Sans lui, astro-icon déduplique les sprites en donnant au premier rendu d'un nom d'icône la définition <symbol> et à tous les rendus suivants un simple <use href="#...">. L'ordre du document décide qui est premier — et dans cette application, cela peut être du balisage à l'intérieur du slot d'un astro-island, qu'Astro livre dans un <template data-astro-template> inerte jusqu'à l'hydratation. Le symbole n'entre alors jamais dans le DOM vivant, et chaque <use> de ce nom ne rend rien. Quand c'est arrivé, cela a emporté les 28 icônes flèche-droite plus les icônes GitHub et lien du pied de page sur un viewport bureau. Un usage mixte ne peut pas sauver la situation non plus, parce que les rendus inline font quand même avancer le compteur par nom d'astro-icon : tout inliner est donc la seule politique cohérente. Un test compagnon, tests/repo-guards.test.ts, vérifie que chaque nom d'icône utilisé existe réellement dans le jeu d'icônes installé.

Le endpoint qui se cache du routeur

La recherche est un fichier JSON statique par langue. src/features/search/ assemble un SearchDoc[] au moment du build depuis les projets, les articles, les entrées de la frise, les tags et des documents de page écrits à la main, et le endpoint à src/pages/search-index/[locale].json.ts émet /search-index/{locale}.json.

Cette extension de fichier est porteuse. Le loader des routes statiques ne parcourt que les fichiers .astro, donc un endpoint .ts lui est invisible : l'intégration n'injecte jamais de variantes préfixées par la langue, et les chemins de langue que produit le endpoint restent les chaînes littérales écrites dans le fichier. En faire une route .astro aurait donné à l'intégration un endpoint à localiser, ce qui n'est pas ce que veut un index JSON par langue.

L'arborescence de cette page

L'arborescence à gauche n'est pas lue sur le disque quand vous chargez la page — il n'y a rien au moment de la requête. apps/web/scripts/generate-repo-tree.mjs appelle git ls-files et replie le résultat en JSON imbriqué dans src/.generated/repo-tree.json. Ce fichier est versionné, et non parce que son contenu l'exige : c'est une fonction pure de la liste des fichiers suivis, donc l'arbre de travail le détermine déjà entièrement. Il est versionné pour que Turborepo le hache — l'arborescence vient de git ls-files à la racine du dépôt alors que turbo hache ses entrées paquet par paquet, si bien qu'un fichier ajouté sous apps/admin/ changeait cette page sans changer le hachage du build web, et un succès de cache servait alors l'arborescence périmée. L'étape gen:tree tourne avant dev, build, test et typecheck, si bien qu'il est toujours à jour au moment où quoi que ce soit le lit, et la CI fait échouer la pull request dès que la copie versionnée a dérivé.

Déléguer à git plutôt que de parcourir le système de fichiers signifie que .gitignore seul décide de ce qui est public, sans seconde implémentation de correspondance d'exclusions à maintenir en phase. Les brouillons non suivis sous draft/ et temp/ n'apparaissent tout simplement jamais. Le tri passe par un Intl.Collator('en') fixe pour que la sortie ne puisse pas varier d'une machine à l'autre, et l'artefact ne contient rien de plus qu'un nombre de fichiers, un nombre de répertoires et les enfants imbriqués. Une version antérieure y estampillait aussi le SHA de HEAD, précisément le champ qu'un artefact généré puis versionné ne peut pas porter : écrire le fichier fait avancer HEAD, donc l'estampille est périmée à l'instant où elle atterrit. Supprimer l'estampille et sortir l'artefact du suivi étaient le même correctif ; l'artefact est depuis revenu sous suivi, mais pas l'estampille, parce qu'une liste de fichiers converge là où un SHA de HEAD ne le peut pas.

Les notes attachées à certains chemins sont des entrées localisées File Annotation dans Strapi. Chaque chemin ordinaire est vérifié contre l'arborescence générée après la lecture des entrées ; un chemin périmé est ignoré avec un avertissement de build afin qu'une faute dans le CMS ne bloque pas les autres déploiements. L'entrée réservée $$ROOT_ANNOTATION$$ fournit cette vue d'ensemble au lieu de viser un nœud. Elle est obligatoire en anglais, les autres langues retombent sur l'anglais, et les deux marqueurs de diagramme ci-dessus ne sont remplacés qu'après résolution du markdown venu du CMS.

Ce site, et ceux d'avant

Cinq versions en deux ans et demi. Chacune a réglé le problème de la précédente et amené le sien — le plus souvent en sortant un framework avant que le problème ne le justifie.

  1. v1févr. 2024 – août 2025

    4 commits

    Des pages écrites à la main

    Rendu
    Fichiers statiques
    Contenu
    En dur dans le balisage
    Serveur
    Aucun
    Dépôts
    Hadi-Hijazi · HadiHz88.github.io

    Deux dépôts, quatre commits, aucune étape de build : du HTML et une feuille de style poussés sur GitHub Pages. Rien à compiler, rien à déployer, rien à payer — et c'est encore la version la plus rapide qu'ait connue ce site. La fin était prévisible : le contenu et la mise en page vivaient dans le même fichier, donc ajouter un projet revenait à copier un bloc de balisage et à se souvenir de tous les autres endroits à modifier.

  2. v2mars–avr. 2025

    50 commits

    Laravel, et un front que je n'ai pas écrit

    Rendu
    Rendu côté client
    Contenu
    Base de données derrière Laravel
    Serveur
    PHP et Node
    Dépôts
    My-Platform-BackEnd · My-Platform-FrontEnd

    Je voulais un vrai backend — une base de données, une interface d'administration, de l'authentification — et Laravel m'a donné les trois en un après-midi. L'apprendre était l'essentiel de l'objectif, et cette partie a marché. Le front avait été généré plutôt qu'écrit, donc il ne m'a jamais vraiment appartenu, et au bout de trois semaines le problème était clair : deux applications, deux déploiements, et un serveur qui tourne en permanence pour servir une page qui change deux fois par mois.

  3. v3juil.–sept. 2025

    144 commits

    La même architecture, dans un seul langage

    Rendu
    Rendu côté client
    Contenu
    Base de données derrière NestJS
    Serveur
    Node
    Dépôts
    my-website-backend · My-Website-Client

    Réécrire l'API en NestJS m'a rendu le code : un seul langage des deux côtés, des types partagés de bout en bout, et un quotidien bien plus agréable. Ce que cela n'a pas changé, c'est l'architecture. C'était toujours une API CRUD faite main pour quelques dizaines d'enregistrements, toujours une application monopage qui affichait un écran blanc avant d'afficher du contenu, et toujours deux choses à maintenir en production pour un site où personne ne se connecte.

  4. v4nov. 2025 – févr. 2026

    92 commits

    Statique, avec le contenu dans le dépôt

    Rendu
    Pré-rendu au build
    Contenu
    Fichiers JSON dans le dépôt
    Serveur
    Aucun
    Dépôts
    HadiHz-Portfolio

    Supprimer le backend a réglé le problème de performance d'un coup. Next.js pré-rendait chaque page, le contenu tenait dans des fichiers JSON à côté du code, et il ne restait plus aucun runtime capable d'être lent ou de tomber. Le coût s'est déplacé plutôt qu'il n'a disparu : chaque coquille devenait un commit, JSON est une surface d'écriture pénible, et une deuxième langue aurait voulu dire maintenir des fichiers parallèles à la main.

  5. v5avr. 2026 – aujourd'hui

    857 commits

    Astro et Strapi

    Rendu
    Pré-rendu, avec des îlots
    Contenu
    Strapi
    Serveur
    Aucun à la lecture
    Dépôts
    hadihz.me

    Cette version garde le résultat de la précédente et rend l'écriture possible. Strapi héberge le contenu, Astro pré-rend chaque page, et aucun JavaScript n'est envoyé tant qu'un composant ne le demande pas — l'arborescence ci-dessus et le graphique ci-dessous sont des îlots, tout le reste est du HTML. Le compromis restant est visible plutôt que caché : publier exige une reconstruction, d'où le pipeline de déploiement.

Commits par mois

Les mêmes cinq versions, mesurées. Une ligne par version, en pointillés une fois qu'elle a cessé de servir le site.

1 147 commits sur ces dépôts

Données au 30/09/2026