Ce site
Comment ce site est construit, et ce que contient le dépôt qui le produit.
Dépôt
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-configetpackages/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 danspackages/tailwind-config/theme.cssplutô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.
.github/workflows
Les workflows et les actions composites sont séparés de sorte que chacun n'ait
qu'un déclencheur et qu'une forme de job. 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. C'est la seule vérification exigée par la protection de
branche. Il y avait trois jobs derrière une porte d'agrégation CI complete,
et chacun lançait sa propre installation complète de l'espace de travail — le
job web installant tout Strapi, le job admin tout Astro — de sorte que
l'installation était payée trois fois pour exécuter une seule fois prettier,
tsc et Vitest. Turbo exécute toujours les tâches en parallèle à l'intérieur
du job unique, et --continue fait remonter tous les échecs en une seule
exécution.
Il n'y a délibérément pas de déclencheur push. pull_request ne vérifie pas
la branche mais 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é, et le revérifier ne prouve rien. Cet argument ne tient que si la base
ne peut pas bouger sous une pull request périmée, ce qui relève d'un réglage
de protection de branche et non du YAML : exiger des branches à jour, ou
utiliser une file de fusion.
deploy.yml s'exécute sur les push vers master et ne porte aucune
vérification. Il résout les cibles de déploiement puis appelle cd-cms.yml et
cd-web.yml dans cet ordre, parce que les pages prérendues incorporent les
réponses de Strapi dans la sortie statique. Seul le CMS est filtré par chemins
: c'est un envoi d'environ 1 Go et un redémarrage de Strapi, là où le
déploiement web est un build et une synchronisation S3. Filtrer le déploiement
web était en réalité une erreur — /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 — et cela livrait
silencieusement un arbre périmé. Une condition de propagation de saut
subsiste, sur le job web : cms saute légitimement, et !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.
WEB_ENV est un secret de dépôt, tandis que les quatre secrets de déploiement
résident sur l'environnement Production. Le job de vérification en a besoin,
car astro check lance une synchronisation de contenu à travers chaque loader
contre un Strapi vivant, et la seule façon de lire un secret d'environnement
est de déclarer cet environnement — ce qui, sur un job de pull request,
enregistrerait un déploiement sur Production à chaque pull request et
livrerait son contenu à n'importe quelle branche du dépôt. Les tests n'ont
plus besoin d'aucun secret : astro.config.mjs ne lève plus d'erreur sans
CDN_URL. Les pull requests issues d'un fork ne peuvent toujours pas passer
le typecheck, GitHub ne leur envoyant aucun secret.
WEB_ENV parvient au job de vérification et au déploiement web de la même
façon — écrit dans apps/web/.env et exporté — et cette symétrie porte un
poids réel plutôt que d'être un souci de propreté. Turborepo hache le fichier
via inputs et les valeurs exportées via env : un job qui ne fait que l'un
des deux calcule un autre hachage de build et reconstruit ce que l'autre a
déjà construit. Faire les deux est ce qui permet au déploiement de restaurer
l'artefact produit par la pull request. Les artefacts du cache distant sont
signés avec TURBO_REMOTE_CACHE_SIGNATURE_KEY ; non défini, turbo avertit une
fois et reconstruit tout.
Quatre décisions de durcissement qu'il vaut mieux ne pas défaire. Les actions
tierces sont épinglées à des SHA de commit tandis que celles de première partie
restent sur des tags majeurs : burnett01/rsync-deployments et
appleboy/ssh-action reçoivent tous deux SERVER_SSH_KEY, qui vaut root sur
le VPS, et un tag mutable est beaucoup à miser sur un mainteneur unique. Le
déploiement du CMS compare le majeur Node du serveur à celui du runner et sort
avant cp -al s'ils diffèrent, parce que le bundle embarque des modules
natifs compilés sur le runner. Un health check en échec revient à previous et
revérifie, de sorte que la production serve la dernière version connue comme
bonne même si le job passe au rouge. Et l'envoi web se fait en trois passes —
assets immuables, puis pages avec --delete, puis élagage — pour qu'aucune
fenêtre n'existe où du HTML en ligne pointe vers des assets absents ou déjà
supprimés.
renovate.yml est le cinquième, et le seul qui écrive dans le dépôt au lieu
d'en déployer. Il tourne auto-hébergé sur un cron du lundi — Renovate n'agit
que pendant l'exécution du job, ce cron est donc tout le calendrier — et son
jeton réside sur un environnement renovate plutôt que parmi les secrets du
dépôt, car un droit d'écriture sur les workflows lui permettrait de réécrire
les fichiers mêmes qui portent SERVER_SSH_KEY.
L'essentiel de la prose réside dans docs/CI-CD.md, mais pas la totalité :
renovate.yml garde son raisonnement sur place, en vingt lignes de
commentaire, parce que ce qui justifie son secret d'environnement et son essai
à blanc par défaut concerne ce fichier et non le pipeline. Un commentaire est
porteur plutôt qu'explicatif — chaque épinglage SHA d'une action tierce se
termine par un # vX.Y.Z, et Renovate désactive tout SHA nu qu'il ne peut
rattacher à un tag, si bien qu'en supprimer un arrête la surveillance de cette
action sans rien dire.
AGENTS.md
Ce fichier faisait 528 lignes. Il en fait 69, et le raisonnement derrière la coupe est plus utile que le diff.
L'ancienne version mettait en cache ce que le dépôt consignait déjà — versions des dépendances, inventaires de chaque commande — elle périmait donc en restant immobile, et les contraintes qui comptent vraiment étaient enfouies sous ce remplissage. Ce qui reste ne fait qu'aiguiller : ce qu'est le dépôt, les six commandes de contrôle, les règles valables partout, et un tableau « où lire ensuite ».
Le détail est descendu, pas disparu. apps/web/AGENTS.md (121 lignes) et
apps/admin/AGENTS.md (103) portent les contraintes qu'aucun fichier source
n'avoue — le serveur de dev qui se détache et répond un stub de 70 octets au
lieu d'une trace, le git stash qui supprime les tables de Strapi pendant
qu'il surveille — chacune compressée en une à trois lignes : la contrainte et
sa raison. CLAUDE.md est tombé à huit lignes et ne garde que le seul piège
qui mord avant la lecture de ce fichier.
Le reste est devenu cinq skills sous .claude/skills/ :
client-controllers, feature-modules, design-system,
output-neutral-refactor (qui embarque ses scripts de contrôle avec la prose)
et cms-annotations. Une skill se charge à la demande — la description de son
front-matter énonce les situations où elle s'applique — si bien que la part
toujours chargée du contexte d'un agent reste petite tandis que la profondeur
reste à une lecture.
Une règle énonce le principe que tout ce découpage encode : lire les versions
dans chaque package.json, jamais dans la prose. Ce qui est écrit ici est ce
qu'aucun fichier n'avoue ; ce qu'un fichier énonce déjà lui est laissé.
apps/admin/src/api
Quinze types de contenu dans la forme standard à quatre répertoires de Strapi,
tous localisés sauf les deux types de soumission, qui stockent ce qu'un
visiteur a tapé et n'ont pas de traduction. about, hero et profile sont
des single types ; le reste des collections, et cette scission se propage dans
le client web généré sous forme de deux classes différentes — un
SingleTypeAPI n'exposant que find et update, un CollectionAPI avec le
jeu complet — si bien qu'appeler une méthode de collection sur un single type
est une erreur de type plutôt qu'une surprise à l'exécution.
Trois des dix-huit répertoires ne contiennent aucun type de contenu, ce qui
est à quoi ressemble un endpoint purement custom dans Strapi : un routeur, un
contrôleur, parfois un service, pas de content-types/. contribution et
contribution-repository servent le graphe de contributions et la timeline du
dépôt de cette page depuis l'API GraphQL de GitHub. github-auth est la jambe
de connexion GitHub, et le seul endroit où ce CMS remplace un flux de plugin au
lieu de l'étendre — le /connect/* de Strapi finit par écrire l'access_token
du fournisseur dans la query string d'une redirection, où il atterrit dans
l'historique du navigateur, et aucun hook ne permet de changer cela. Seuls le
cookie d'état, l'échange code-contre-token et le cookie de session sont à nous.
Trente-neuf des cinquante-deux fichiers de contrôleur, de route et de service
sont des factories.createCoreX(...) de sept lignes : l'app web lit via l'API
REST avec un token et n'a besoin d'aucun endpoint sur mesure. Les treize
restants appartiennent aux cinq apis custom — les deux lecteurs de
contributions, les deux de la connexion, et les deux écritures publiques.
Ces écritures sont la raison pour laquelle testimonial-submission est un type
de contenu à part entière plutôt qu'une route sur testimonial. Les deux types
de soumission prennent un routeur custom au lieu de createCoreRouter, parce
que le jeu CRUD par défaut placerait create, update et delete à une case
à cocher du public. Détacher entièrement l'écriture des témoignages va plus
loin : la collection testimonial publiée que le site lit garde des
permissions publiques en lecture seule, et aucun create public ne se trouve à
côté en attendant d'être activé. POST /api/testimonials/submit est
auth: false — une page statique ne détient aucun secret à présenter — donc un
honeypot qui répond 200 sans rien stocker, plus deux paliers de limitation de
débit par IP, tiennent lieu d'authentification.
Les lifecycles contiennent le code intéressant. tag normalise son champ JSON
aliases à l'écriture, en adaptant l'AliasError d'un module sans framework
en ValidationError de Strapi pour que l'admin l'attache au bon champ ; les
alias sont ce qui rend les synonymes de tags cherchables. Six types exportent
createTagMirror(...), qui copie la liste de tags du document anglais vers
toutes les autres locales, parce qu'une ligne de relation stocke un id de
ligne et que chaque locale est une ligne distincte — lier un tag sur le
projet anglais laisse la ligne française sans aucun. Ni manyToMany ni
pluginOptions.i18n.localized: false n'y changent rien ; les deux ont été
essayés.
Rien ici n'écrit dans le CMS au démarrage : il n'y a pas de seeders. Ce qui lit
est scripts/cms-snapshot/pull.ts, qui écrit chaque type api:: dans le
.cms-snapshot/ git-ignoré, les quatre locales côte à côte pour qu'une
traduction ayant dérivé se voie à l'œil ; documents publiés seulement,
contact-submission ignoré. C'est dérivé et en lecture seule. Le texte des
formulaires de l'admin et les deux corps d'e-mails d'authentification vivent
dans core_store, pas dans un type de contenu, ce qui laisse
pnpm --filter admin cms:export comme seule copie de tout cela et seule
sauvegarde du contenu. Il faut vraiment le lancer : ce dépôt a déjà perdu des
lignes de File Annotation une fois.
apps/web/astro.config.mjs
La chose la plus lourde de conséquences dans ce fichier est absente : il n'y a
pas d'adapter. C'est ce qui rend le site entièrement statique — du HTML
prérendu, aucun serveur au moment de la requête, chaque lecture Strapi résolue
pendant le build.
La gestion de l'environnement est scindée en deux, délibérément. CDN_URL et
IMAGE_DOMAINS sont tirées via le loadEnv de Vite au moment de l'évaluation
de la configuration, parce qu'elles construisent la liste d'autorisation
image.domains avant que la validation d'environnement propre à Astro ne
s'exécute. Les deux sont facultatives et cumulatives : chacune ajoute des hôtes,
une entrée peut être un hôte nu ou une URL complète, et aucune n'est requise. La
configuration levait une erreur sans CDN_URL, ce qui obligeait chaque
exécution de Vitest à disposer des identifiants de production —
vitest.config.ts construit sa configuration via getViteConfig — et forçait
donc un job pull_request à déclarer l'environnement Production pour la seule
raison de les lire. Tout le reste passe par
env.schema : STRAPI_BASE_URL et SITE_URL comme valeurs serveur publiques
avec valeurs par défaut et validation d'URL, STRAPI_API_TOKEN en
access: 'secret' — contexte serveur uniquement, donc impossible de fuiter dans
un bundle client, même par accident.
Une seule ligne décide où vit le store de la couche de contenu, et il vaut la
peine de savoir pourquoi elle s'écarte du défaut. Astro résout ce store vers
.astro/ sous astro dev, et vers cacheDir pour toute autre commande. Vitest
passe par getViteConfig, c'est-à-dire la branche dev, et lit donc
.astro/data-store.json, tandis qu'astro sync et astro check écrivent la
copie cacheDir — deux fichiers distincts sous le node_modules/.astro par
défaut. Les tests adossés au contenu ne passaient alors que sur une machine où
astro dev avait laissé un store, jamais sur un checkout CI neuf. Poser
cacheDir: '.astro' fait converger les trois vers un seul fichier.
Quatre intégrations, dans l'ordre : sondaPost() (une seconde intégration du
dépôt qui génère un rapport de bundle sur astro:build:done),
localizedStaticRoutes(...), react(), icon(). L'intégration des routes
statiques consomme translatedRoutes depuis
src/i18n/route-manifest.ts; ce même manifeste génère les urlPatterns de
Paraglide pour le plugin Vite. Les pages injectées, les liens de langue et la
localisation inverse restent ainsi alignés. /about/this-website existe aussi
comme /حول/هذا-الموقع, /a-propos/ce-site et
/ueber-mich/diese-website.
Le reste est du réglage : prefetch activé pour chaque lien avec un défaut
hover, concurrence de build à 6, barre d'outils de développement désactivée,
sourcemaps Vite activées, et trois polices Google enregistrées via le
fournisseur de polices d'Astro — Quicksand pour le texte latin, Rubik pour
l'arabe, Pixelify Sans pour la typographie pixel d'affichage — chacune exposée
comme variable CSS que le thème partagé transforme en utilitaire font-*.
apps/web/integrations/localized-static-routes
Une intégration Astro sur mesure et entièrement statique. Sur
astro:config:setup elle parcourt src/pages/ à la recherche de fichiers
.astro, transforme chaque chemin en route et appelle injectRoute une fois
par langue non par défaut — trois routes supplémentaires par page, pour ar,
fr et de. L'anglais est servi sans préfixe. Le manifeste partagé
src/i18n/route-manifest.ts fournit les slugs traduits à cet injecteur et au
compilateur d'URL de Paraglide, si bien que /about/this-website est aussi
accessible à /fr/a-propos/ce-site.
L'intégration n'expose volontairement aucun module virtuel d'exécution. Avant
l'injection, elle valide l'unicité des langues, la langue par défaut, l'existence
des routes mappées et exclues, les barres obliques initiales, la parité des
paramètres dynamiques et les collisions — ces dernières face aux pages sources
autant qu'entre elles, car Astro ne voit qu'une seule table de routes. Une page
placée dans src/pages/ar/about.astro est donc rejetée : elle occupe déjà l'URL
où la variante arabe de /about est injectée. La localisation et l'inversion à
l'exécution vivent dans src/i18n/routes.ts et délèguent à Paraglide avec une
langue explicite. Une page non mappée garde son slug canonique sous le préfixe,
par exemple /ar/auth/sign-in ; une route traduite pour certaines langues
seulement déclenche un avertissement, puisqu'elle retombe silencieusement.
L'injection n'a lieu qu'à la configuration, et Astro ne rejoue ce hook que pour
un fichier qu'on lui a demandé de surveiller. Le manifeste des routes est donc
enregistré via addWatchFile, et astro:server:setup — qui surveille les
fichiers .astro ajoutés et supprimés sous src/pages/ — touche ce manifeste
après un debounce de 100 ms au lieu de redémarrer le serveur lui-même. Ce détour
est délibéré : le restart() de Vite reconstruit à partir de la configuration
qu'Astro avait déjà résolue, et revient donc avec l'ancienne table de routes, en
affichant une ligne de log rassurante pendant que la nouvelle page reste
uniquement en anglais.
Elle n'expose aucun module d'exécution, mais elle génère bien un type.
astro:config:done écrit une union ambiante LocalizedRoutes.SourceRoute — un
membre par page trouvée — à laquelle localizedHref est contrainte : lier une
faute de frappe, ou une page renommée entre-temps, ne compile plus au lieu de
renvoyer un 404 en production. C'est parce qu'elle est construite depuis le même
parcours que celui qui alimente injectRoute que les routes qu'on peut lier et
celles qui existent ne peuvent pas diverger.
apps/web/src/.generated
Deux entrées de build produites par l'outillage plutôt qu'écrites à la main. Les deux sont versionnées, et le dépôt ne les versionne pas pour la même raison.
strapi-client/ est un client typé de l'API Strapi — client.ts, types.ts,
index.ts — écrit par pnpm --filter admin generate, qui récupère le schéma
depuis un Strapi en cours d'exécution sur le port 3333. Démarrez le CMS
avant de régénérer. La sortie est marquée en en-tête @ts-nocheck et « ne pas
éditer manuellement », et porte le hash de schéma depuis lequel elle a été
générée, si bien qu'un changement de type de contenu que personne n'a régénéré
apparaît dans un diff plutôt qu'uniquement à l'exécution. Elle est versionnée,
et c'est ce qui permet à un clone propre de passer le typecheck et le build
sans aucun CMS qui tourne quelque part.
Treize fichiers y accèdent via l'alias $strapi, mais un seul instancie quoi
que ce soit : src/core/cms/client.ts construit l'unique StrapiClient avec
l'URL de base et le token venus d'astro:env/server. Un seul repository.ts
de fonctionnalité importe la garde isStrapiErrorOf, et les autres
n'importent que des types — BlogGetPayload, TagGetPayload,
HeroGetPayload et compagnie, que le types.ts de chaque fonctionnalité
resserre vers la forme que veulent ses composants. Le client est donc un
singleton tandis que sa surface de types est utilisée librement, ce qui est
exactement la séparation souhaitée.
repo-tree.json est l'arborescence de fichiers que rend cette page, produite
par scripts/generate-repo-tree.mjs depuis git ls-files. Elle porte un
nombre de fichiers, un nombre de répertoires et les enfants imbriqués — et
rien d'autre. Elle est versionnée, bien que rien dans son contenu ne le
réclame : c'est une fonction pure de la liste des fichiers suivis, et dev,
build, test et typecheck la régénèrent chacun avant de s'exécuter. Ce
qui le réclame, c'est Turborepo. L'arborescence est construite depuis git ls-files à la racine du dépôt alors que turbo hache ses entrées paquet par
paquet : un fichier ajouté sous apps/admin/ changeait donc cette page sans
changer le hachage du build web — et un succès de cache servait alors
l'ancienne arborescence. La versionner place l'arborescence dans ce hachage,
et pnpm --filter web gen:tree:check en CI fait échouer la pull request dès
que la copie versionnée a dérivé. Elle y estampillait aussi le SHA de HEAD,
qui est bien la seule chose qu'un fichier généré et versionné ne peut pas
porter — écrire le fichier fait avancer HEAD — et cette estampille est
restée supprimée quand le fichier est revenu sous suivi.
Le répertoire entier est listé dans .prettierignore, au motif qu'une
régénération annulerait tout formatage qui lui aurait été appliqué, et que la
vérification de formatage en CI signalerait alors des fichiers que personne
n'a écrits.
apps/web/src/core/controller.ts
Chaque comportement client du site est une sous-classe de Controller. Aucun
<script> isolé ne manipule le DOM : un bloc de script importe sa classe et
appelle Controller.mount(id, resolve), et cette seule ligne constitue toute la
surface d'enregistrement.
La raison en est les view transitions. Astro remplace le document au lieu de le
recharger ; un écouteur attaché sur astro:page-load et jamais retiré accumule
donc une copie vivante par navigation, rattachée à des nœuds que le document ne
contient plus. Le nettoyage est ici le comportement par défaut plutôt qu'une
arrière-pensée : listen() et own() enregistrent un disposer au moment même
de l'abonnement, et disconnect() les déroule en sens inverse. Le cycle de vie
était auparavant un singleton AstroLifecycle distinct, mais Controller en
était le seul appelant : les deux ont été repliés dans ce fichier unique.
Un module script s'exécute une fois par session, pas une fois par page ;
resolve est donc la porte qui décide si la page courante a quelque chose à
monter — byId, bySelector et whenPresent sont les trois usuels, et ne rien
renvoyer est ordinaire plutôt qu'une faute, de sorte que rien n'est journalisé.
La clé id fait qu'un réenregistrement remplace au lieu de dupliquer. Montages
et démontages sont sérialisés dans une unique file de promesses, ce qui empêche
un montage asynchrone de chevaucher le démontage de la page qu'il remplace.
Les sites d'appel ne passent aucune configuration en argument. Chaque point
d'accroche est un attribut data-* que le contrôleur balaie au connect, si bien
que le markup reste dans le fichier .astro où vit déjà le reste de la page.
Trois bases s'appuient sur cette convention : core/filter-controller.ts prend
un prefix et des keys, garde requête et facettes dans l'URL et laisse
present, resultMessages et onCriteriaChange à ses sous-classes ;
core/form-controller.ts lit ses libellés depuis data-strings et possède la
ligne de statut, le verrou de soumission et le mapping des erreurs de champ en
400 ; et design-system/patterns/carousel/EmblaController.ts balaie
data-embla-*, tire sa direction de dir — les pages arabes ne demandent donc
aucun câblage — et exige que le conteneur de points existe même vide, puisque
son gestionnaire de clic y est délégué.
apps/web/src/design-system
De la présentation sans connaissance du domaine. Ce répertoire n'a pas le droit
d'importer depuis features/, shell/ ou i18n/ ; la seule dépendance qu'il
prend est core/controller, parce que trois de ses patterns sont du
comportement plutôt que du markup.
Les tokens ne sont pas ici du tout. Tailwind v4 n'a pas de fichier de config
JavaScript : ils vivent donc un package plus haut, dans
packages/tailwind-config/theme.css. Un bloc @theme simple porte les valeurs
brutes — --leading-display: 0.92, --leading-heading: 0.95,
--tracking-eyebrow: 0.2em, et --text-eyebrow avec son propre
--text-eyebrow--line-height, pour que la plus petite typo ne reste pas sur le
ratio par défaut — tandis qu'un bloc @theme inline mappe les noms sémantiques
sur les variables de palette, ce qui transforme --color-background en
utilitaire bg-background.
Deux commentaires de ce fichier consignent des décisions plutôt que du code. Le
mode sombre est une simple media query prefers-color-scheme : la version à
base de classe appliquait la sienne en JavaScript sur DOMContentLoaded sans
repli CSS, si bien que les visiteurs en thème sombre recevaient la palette
claire et la voyaient pendant 532 ms mesurées. Une media query se résout avant
le premier paint, donc ce flash ne peut pas se produire. Et il n'y a pas de
token d'animation d'entrée : --animate-fade-in utilisait le fill mode both,
qui maintenait chaque wrapper concerné à opacity: 0 jusqu'au démarrage de son
animation, rendait l'élément LCP instable et coûtait environ 100 ms. Les deux
commentaires existent pour qu'aucune des deux décisions ne revienne en douce.
Typography est un composant composé au-dessus d'une seule recette tv() :
quatorze variantes et un axe de tonalité, rendus statiquement depuis .astro et
jamais hydratés. Là où un composant est impossible, la fonction typography()
brute est l'échappatoire : transition:name, set:html, class:list, le
passage de slot, un élément interactif et <time> réclament tous la chaîne de
classes plutôt que le wrapper.
Les primitives sont délibérément peu nombreuses : Chip avec chip.ts,
Surface sur tone/radius/padding, les formes d'Input, Button et Link
partageant une même recette interactive, et Icon. La barre pour en ajouter
une est de deux consommateurs, pas un. patterns/ réunit les pièces composées
qui ignorent encore ce qu'est un projet — EmptyState, FilterControls,
FilterBottomSheet, AnimatedTooltip, MarkdownBody, PixelSignal,
card-transitions et le carrousel Embla. Même la sortie générée prend sa
couleur de la même façon : le thème Shiki écrit à la main dans lib/shiki.ts
est bâti sur var(--color-foreground) et color-mix au-dessus d'un fond
transparent, si bien qu'un seul thème se lit correctement dans les deux
schémas au lieu d'en exiger une paire.
apps/web/src/design-system/primitives/Icon.astro
Treize lignes, dont cinq sont le commentaire expliquant pourquoi le
fichier existe — la plus petite des primitives du design system, et celle dont
la justification est la plus longue. Il ré-exporte l'Icon d'astro-icon avec
une prop forcée :
<AstroIconBase {...Astro.props} is:inline />
Chaque composant de l'application importe depuis ici et jamais depuis
astro-icon/components. La raison est un mode de défaillance qui ne produit
aucune erreur d'aucune sorte. Par défaut astro-icon déduplique les sprites : le
premier rendu d'un nom d'icône donné émet la définition <symbol>, et chaque
rendu ultérieur est un simple <use href="#...">. L'ordre du document décide
qui compte comme premier — et dans cette application, cela peut être du balisage
à l'intérieur du slot d'un astro-island (les menus de navigation et latéral),
qu'Astro livre dans un <template data-astro-template> inerte jusqu'à
l'hydratation. Le symbole n'entre jamais dans le DOM vivant, donc chaque <use>
qui le vise ne résout rien et ne rend rien. Mesuré, sur un viewport bureau : les
28 icônes flèche-droite plus les icônes GitHub et lien du pied de page,
disparues.
N'inliner que les icônes à l'intérieur des îlots ne corrige rien, parce que les
rendus inline font quand même avancer le compteur par nom d'astro-icon — le
chemin sprite et le chemin inline partagent la même comptabilité, donc une
politique mixte ne fait que déplacer les instances qui disparaissent. Forcer
is:inline partout est le seul réglage cohérent, au prix de la répétition du
balisage SVG à chaque instance.
tests/repo-guards.test.ts garde l'autre moitié du problème, en s'appuyant sur
scripts/icons/scan.ts pour vérifier que chaque nom pixelarticons:*
référencé dans les sources existe réellement dans le jeu d'icônes installé, et
en faisant échouer la vérification si quoi que ce soit importe lucide-react.
L'ancien script qu'il remplace n'a jamais été branché sur la CI, c'est donc la
première fois que ces deux vérifications s'exécutent automatiquement.
apps/web/src/features
Douze modules de fonctionnalité — about, auth, blogs, contact,
entries, hero, og, profile, projects, search, tags,
testimonials — un par domaine. Rien de partagé ne vit ici : core/ détient la
classe de base Controller, le client Strapi et les loaders de collections de
contenu, design-system/ la présentation sans connaissance du domaine, et
shell/ l'habillage que porte chaque page.
L'idée directrice est qu'un fichier est nommé pour son rôle plutôt que pour
son genre, et que les rôles forment une chaîne. repository.ts est le seul
endroit autorisé à toucher le client Strapi généré : il détient les formes
populate, les filtres et l'ordre de tri, et renvoie les types Strapi bruts.
Une page ne l'appelle jamais — content.config.ts le branche dans un loader qui
s'exécute une fois par sync, avec le prédicat de validité de la fonctionnalité
déclaré à côté, de sorte qu'un enregistrement fautif nomme sa collection et son
id d'entrée au lieu de surgir en assertion lancée au milieu du rendu.
service.ts se place au-dessus du content store et le relit via getCollection
ou getEntry. La page attend le service et passe le résultat plus bas ; ui/
rend ce qu'on lui donne et ne va rien chercher lui-même. controller.ts — ou
controllers/<nom>.ts quand une fonctionnalité en a plusieurs — est la moitié
navigateur. types.ts porte les formes du domaine, et plusieurs
fonctionnalités portent un translation-keys.ts qui mappe les valeurs d'énums
du domaine sur les clés i18n en un seul endroit, plutôt que d'éparpiller de la
concaténation de chaînes dans les composants.
Nommer par rôle est ce qui empêche le placement d'être une question : un module
qui interroge Strapi n'a qu'un seul endroit où vivre, et un fichier difficile à
nommer est en général un fichier qui fait deux choses. C'est aussi pourquoi
l'arbre n'a plus de répertoires components/, utils/ ou constants/, ni le
moindre fichier baril — rien ne ré-exporte un voisin, donc chaque import nomme
le module qu'il veut réellement. Les imports entre fonctionnalités passent par
l'alias $/ et n'atteignent que le service.ts de l'autre, ce qui évite qu'un
déplacement de répertoire produise un diff plein de correctifs ../../...
auth est l'exception délibérée, et mérite d'être lue avant de copier la forme
de quoi que ce soit d'autre ici : pas de repository et pas de service, parce
qu'elle détient une session, et qu'une session n'existe que dans un navigateur.
apps/web/src/features/search
La recherche du site est entièrement statique. service.ts assemble un
SearchDoc[] par langue au moment du build — projets, articles, entrées de
chronologie et tags via un mappeur chacun dans mappers.ts, plus des
documents de route et d'ancre d'accueil écrits à la main dans pages.ts, si
bien que la recherche fait aussi office de navigation. Le résultat est servi
comme un simple fichier JSON depuis src/pages/search-index/[locale].json.ts,
délibérément un point d'accès .ts : le loader localized-static-routes ne
parcourt que les fichiers .astro, donc il n'injecte jamais de variantes
préfixées par la langue et les chemins à l'intérieur restent littéraux.
Une subtilité vit encore dans service.ts : comme /projects/[slug] et
/blogs/[slug] génèrent leurs chemins statiques à partir des seules données
de la langue par défaut, la page de détail de chaque langue vit au slug de la
langue par défaut — les href de recherche résolvent donc les slugs depuis
l'entité anglaise canonique, indexée par documentId, plutôt que depuis celle
qu'ils décrivent. Une seconde subtilité a disparu : l'index par langue était
mémoïsé comme une promesse, pas comme une valeur, parce que le point d'accès
est rendu quatre fois par build et que chaque rendu aurait sinon
re-téléchargé toutes les collections depuis Strapi. Lire le jeu canonique est
désormais une lecture du store, donc cette mémoïsation a été supprimée.
La correspondance ne vit pas du tout dans cette fonctionnalité. $/lib/fuzzy
est le seul module de l'application qui importe Fuse, et le même index
alimente aussi les filtres des pages projets et tags, si bien que les trois
surfaces classent de façon identique. engine.ts y adapte les SearchDoc[]
et ajoute le départage par type, et grouping.ts possède les plafonds par
type et le plafond global — lequel écarte des groupes entiers mal classés
plutôt que de rogner des lignes dans un groupe qui signale déjà un
dépassement. Les deux tournent dans le navigateur et sont délibérément purs :
$/lib/text, leurs propres types, et rien d'autre.
La palette sous ui/ est l'un des rares îlots React authentiques du site, et
c'est désormais plusieurs petits fichiers plutôt qu'un gros.
SearchPalette.tsx est la coquille et s'hydrate en client:idle ;
use-palette-open.ts et use-palette-search.ts portent les deux morceaux
d'état qui étaient auparavant emmêlés à l'intérieur ; PaletteInput.tsx,
PaletteResults.tsx, PaletteRowItem.tsx et PaletteStates.tsx font le
rendu, avec leurs recettes de classes dans palette-styles.ts. Aucun d'eux ne
va chercher quoi que ce soit : worker-client.ts démarre search.worker.ts à
la première ouverture — ou sur un préchargement au survol — et garde un client
par URL d'index au niveau du module, si bien que l'index survit à une view
transition, et retombe sur le même moteur exécuté sur le thread principal là
où un worker de module ne peut pas être construit.
apps/web/src/i18n
Paraglide compile les traductions depuis les catalogues JSON existants,
organisés par espaces de noms. config.ts reste la source de vérité pour
Locale, locales et defaultLocale ; project.inlang/settings.json reflète
ces quatre langues et épingle le plugin de catalogue officiel. Le plugin Vite
écrit des message-modules typés et ignorés dans src/.generated/paraglide/.
messages.ts est désormais toute la surface d'accès aux messages de
l'application, et non plus le petit helper qu'il était. Les fichiers .astro,
et les modules .ts qu'eux seuls atteignent, lisent les messages via
useMessages(locale) — un proxy par langue au-dessus de ce barrel généré,
indexé par les clés du catalogue lui-même (t['header:language.label']())
plutôt qu'en important des fonctions de message individuelles. Les tables
d'étiquettes d'énumération stockent des données LabelRef — une clé de
catalogue, ou { key, context } — résolues via label(t, ref). Aucun chemin de
pré-rendu n'appelle setLocale(), donc le build concurrent d'Astro ne peut pas
mélanger l'état des langues.
Tout ce qu'Astro peut envoyer au navigateur est l'exception : îlots React,
modules atteints uniquement par un bloc <script> Astro, et tout ce qu'ils
importent — huit fichiers en tout — continuent d'importer exactement les
fonctions de message générées dont ils ont besoin et de passer { locale }
explicitement, pour que le catalogue reste tree-shaké dans leurs chunks plutôt
que tiré en entier via le barrel. tests/i18n/paraglide.test.ts calcule cet
ensemble accessible au client et fait échouer le build si l'un de ses modules
importe le barrel à la place.
Le validateur de catalogues rejette toujours les dérives de langue, d'espace de
noms et de clés, les valeurs invalides, les anciens placeholders, les écarts
d'interpolation et les catégories de pluriel manquantes.
tests/i18n/paraglide.test.ts compile aussi le projet et vérifie les
paramètres, les clés de contexte, le formatage des nombres et les six catégories
arabes.
apps/web/src/styles/view-transitions.css
Toutes les navigations animées du site sont configurées ici, dans une seule
feuille de style plutôt que dispersées entre les composants. Le
<ClientRouter /> d'Astro remplace le document au lieu de le recharger, et
l'API View Transition du navigateur transforme ce remplacement en animation —
ce fichier décide de laquelle.
Deux choses se produisent lors d'une navigation. La page elle-même effectue un
fondu enchaîné, et tout élément marqué comme partagé se déforme de sa position
de départ vers sa position d'arrivée : la vignette d'un projet qui grandit
jusqu'à devenir l'image principale de sa page de détail, par exemple. Ajouter
une nouvelle paire demande deux attributs de chaque côté et aucun JavaScript —
transition:name pour les apparier, puis data-vt-image ou data-vt-text
pour choisir un préréglage.
Les règles ne sont volontairement pas dans une couche. Astro émet son propre
CSS de transition dans @layer astro, et les règles hors couche l'emportent
sur les règles en couche : rien ici n'a donc besoin de !important.
Le fichier est surtout composé de commentaires, et ceux-ci consignent des
mesures plutôt que des préférences. Le fondu de page comme le morphing
d'élément partagé avaient d'abord reçu une courbe à départ rapide, et tous deux
ont dû être corrigés : en échantillonnant image par image l'animation du
navigateur, deux tiers du mouvement tombaient dans les trois premières images,
ce qui se lit comme un saut et non comme une transition. Les chiffres figurent
dans le fichier, et tests/projects/transitions.test.ts les redérive à partir
de ces variables, de sorte qu'une régression fait échouer les tests au lieu
d'être livrée.
Une règle s'exprime par une absence. L'en-tête et le pied de page ne reçoivent
délibérément pas de nom de transition : nommer un élément en fait une racine
d'arrière-plan, et la pilule givrée de l'en-tête dépend de backdrop-filter
qui échantillonne la page derrière elle — la nommer aplatissait donc
silencieusement l'effet de verre sur toutes les pages. Le fondu de page
conserve l'instantané sortant opaque en dessous, si bien que l'habillage n'a
jamais eu besoin d'être extrait : il ne semble tout simplement pas changer.
Un morceau de tout cela ne peut pas s'exprimer en CSS, et il vit dans
design-system/patterns/card-transitions.ts. Un transition:name ne porte
qu'une seule valeur, et une couverture de projet porte délibérément le même
nom sur la page d'accueil, sur l'index et sur le hero de la page de détail —
ce partage est ce qui fait fonctionner le morph depuis l'une ou l'autre liste
sans câblage par page. L'effet de bord est que les deux listes partagent alors
ce nom entre elles : naviguer de l'accueil vers l'index appariait les quatre
couvertures mises en avant et les faisait voler depuis 2752 px sous la ligne de
flottaison. CardTransitions.install() ajoute un unique écouteur
astro:before-preparation qui lit sourceElement, remonte jusqu'à son ancêtre
[data-vt-card] et pose viewTransitionName = 'none' en inline sur les cibles
de toutes les autres cartes — en inline, parce que les noms viennent de règles
de feuille de style et ne peuvent pas simplement être retirés, et rien ne les
restaure puisque le routeur jette ce document de toute façon. Une navigation
d'historique ne porte pas de sourceElement, donc toutes les cartes sont
dénommées, ce qui est précisément ce qui empêche le trajet retour de voler. Le
helper n'existe que pour tenir le morph à l'écart des mauvaises cartes ; il ne
sait rien des projets ni des blogs, d'où sa place parmi les patterns du design
system plutôt que dans l'une des deux fonctionnalités qui l'appellent.
turbo.json
Sept tâches, et aucune connaissance de ce que sont réellement les deux
applications. build, lint, lint:fix, typecheck, sync, test et dev
déclarent de l'ordonnancement et du cache, rien d'autre ; la manière dont un
build se déroule reste dans les scripts package.json de chaque application.
dev est en cache: false, persistent: true, pour que Turbo le garde attaché
au lieu d'essayer de mettre un serveur en cache.
L'entrée intéressante est sync, qui existe pour résoudre une course plutôt
que pour exécuter une étape de build que quiconque aurait demandée.
apps/web/astro.config.mjs pose cacheDir: '.astro', ce qui fait
délibérément converger le store de la couche de contenu vers un seul fichier —
apps/web/.astro/data-store.json — afin que Vitest, astro sync et
astro check lisent le même contenu au lieu des deux copies distinctes que
produit le node_modules/.astro par défaut. Le prix de cette convergence est
que typecheck et test écrivent désormais le même fichier, et Turbo exécute
les tâches indépendantes en parallèle : en CI elles sont entrées en collision,
échouant sur un ENOENT en renommant data-store.json.tmp. Déclarer sync et
donner aux deux tâches dependsOn: ["^…", "sync"] fait exister le store, une
fois, avant que l'une ou l'autre ne le lise. sync est la seule tâche dont la
sortie est .astro/**.
web#typecheck est en cache: false, et c'est délibéré plutôt qu'un oubli :
astro check synchronise le contenu CMS en direct à travers chaque loader, et
aucun hachage d'inputs ne peut voir une ligne modifiée dans Strapi ; un
passage mis en cache rendrait donc compte d'un contenu qui n'existe plus. Le
apps/web/turbo.json au niveau de l'application n'ajoute que les listes env
— les cinq variables Astro, déclarées par tâche parce que Turbo tourne en mode
env strict et retire silencieusement tout ce qui n'est pas déclaré.
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.
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.
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.
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.
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.
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