D’un CV LinkedIn à un site statique
Le chemin complet, pour un indépendant ou une association : transformer un profil et une idée de logo en un site qu’une seule personne peut tenir. Le code compte plus que l’hébergement.
Le point de départ tient en deux choses : un profil LinkedIn ou un CV, et une intuition graphique. Le point d’arrivée est un site statique bilingue, sans base de données, sans serveur, sans framework côté client. Ce qui suit est le trajet, avec le code qui compte.
La tentation est d’écrire ses expériences directement dans du HTML. C’est l’erreur qui coûte le plus cher ensuite : chaque correction de date se fait à deux endroits, et une seconde langue double tout. Extrayez plutôt le CV en données typées, une fois, et laissez les pages s’en servir.
// src/data/missions.ts
export interface Mission {
id: string;
client: string; // identique dans les deux langues
stack: string;
period: Bilingual<string>; // « mai — septembre 2025 » / « May — September 2025 »
title: Bilingual<string>;
}
export const MISSIONS: Mission[] = [
{
id: 'ocde',
client: 'OCDE',
stack: 'Microsoft Fabric · Power BI · Python',
period: { fr: 'mai — septembre 2025', en: 'May — September 2025' },
title: {
fr: 'Cas d’usage RH sur Microsoft Fabric',
en: 'HR use case on Microsoft Fabric',
},
},
];Le nom du client et la pile technique ne se traduisent pas ; la période et le titre, si. Séparer les deux dès le départ évite de retraduire « Python » quarante fois. Une page de détail par mission se génère ensuite toute seule.
--- // src/pages/missions/[id].astro
import { MISSIONS } from '../../data/missions';
export function getStaticPaths() {
return MISSIONS.map((m) => ({ params: { id: m.id }, props: { mission: m } }));
}
const { mission } = Astro.props;
---
<h1>{mission.title.fr}</h1>Une idée de logo n’est pas une identité. Ce qu’il faut produire, ce sont les décisions qui en découlent : combien d’encres, quelles graisses, quelles tailles, quels écarts. Écrites une fois en variables CSS, elles deviennent la seule source de vérité — et le reste du site n’a plus le droit d’inventer une valeur.
/* tokens/colors.css — trois encres et un papier, rien d'autre */
:root {
--ink: #17171B;
--paper: #F6F4EF;
--accent: #C33A28;
}
/* Le thème sombre est une commutation, pas un filtre :
l'accent s'éclaircit, il ne s'inverse pas. */
:root[data-surface='ink'] {
--ink: #F6F4EF;
--paper: #17171B;
--accent: #E8674C;
}Un piège attend ici. Si un composant écrit « fond noir, texte blanc » en dur, il disparaît en mode sombre, où le noir est justement la couleur de la page. L’inversion doit être relative à la surface, pas absolue.
:root { --surface-inverse: var(--ink); --on-inverse: var(--paper); }
:root[data-surface='ink'] { --surface-inverse: var(--paper); --on-inverse: var(--ink); }
.btn--primary {
background: var(--surface-inverse);
color: var(--on-inverse);
}Astro convient parce qu’il produit du HTML et n’envoie aucun JavaScript tant qu’on ne lui en demande pas. Pour un site vitrine, c’est exactement le bon défaut : les pages restent lisibles sans script, et le poids ne dépend pas du nombre de composants.
npm create astro@latest -- --template minimal
npm installCe qui sort du build est du HTML que l’on peut lire : les styles des composants sont isolés par un attribut, et aucune balise <script> n’apparaît tant qu’on n’en écrit pas une.
<!-- dist/services/index.html — aucun script, aucun runtime -->
<article class="card" data-astro-cid-7f3k2p>
<h2 class="card__title" data-astro-cid-7f3k2p>Construire</h2>
<p class="card__note" data-astro-cid-7f3k2p>Applications web, plateformes analytiques.</p>
</article>Les styles se chargent une fois dans la mise en page, et chaque composant garde les siens dans son propre fichier — Astro les isole automatiquement, sans convention de nommage à tenir.
--- // src/layouts/Base.astro
import '../styles/tokens.css';
const { title, lang } = Astro.props;
---
<html lang={lang}>
<head><title>{title}</title></head>
<body><slot /></body>
</html>La façon coûteuse est de dupliquer les pages. La façon tenable est d’écrire la page une fois et de la router deux fois : le corps vit dans une vue, et les fichiers de route ne font que passer la langue.
--- // src/pages/services.astro (français, à la racine)
import Services from '../views/Services.astro';
---
<Services lang="fr" />
--- // src/pages/en/services.astro (anglais, préfixé)
import Services from '../../views/Services.astro';
---
<Services lang="en" />Le sélecteur de langue doit pointer vers la même page dans l’autre langue, pas vers l’accueil. Et il ne doit pas changer d’ordre : si l’on écrit la langue courante en premier, les deux étiquettes échangent leur place à chaque clic et le bouton bouge sous le curseur.
export function alternatePath(path: string, lang: 'fr' | 'en') {
const stripped = path.replace(/^\/en(?=\/|$)/, '') || '/';
return lang === 'fr' ? `/en${stripped === '/' ? '' : stripped}` : stripped;
}Une charte que rien ne vérifie est une intention. Trente lignes de Node suffisent à refuser ce que l’identité interdit : une couleur écrite en dur, une fonte non déclarée, une ombre. Le contrôle tourne avant chaque publication, et il a déjà refusé du code écrit dix minutes plus tôt.
// scripts/check-adherence.mjs
const RULES = [
{ id: 'hex', re: /#[0-9a-fA-F]{3,8}\b/g, msg: 'couleur en dur — utiliser un jeton' },
{ id: 'shadow', re: /box-shadow:\s*(?!none)/g, msg: 'aucune ombre dans le système' },
];
for (const file of walk('src')) {
// Les commentaires citent les valeurs à dessein : les neutraliser d'abord.
const text = stripComments(readFileSync(file, 'utf8'));
text.split('\n').forEach((line, i) => {
for (const rule of RULES) {
if (rule.re.test(line)) findings.push(`${file}:${i + 1} ${rule.msg}`);
}
});
}
process.exit(findings.length ? 1 : 0);"scripts": {
"verify": "astro check && node scripts/check-adherence.mjs && astro build"
}Un symbole appelé par une balise <img> ne peut pas hériter de la couleur du texte : currentColor y désigne la couleur par défaut du fichier, pas celle de la page. En mode sombre, tous les pictogrammes sont restés noirs sur noir. La correction est de les insérer dans le document plutôt que de les lier.
--- // src/components/Schema.astro
const FILES = import.meta.glob('../assets/*.svg', {
query: '?raw', import: 'default', eager: true,
});
const { name, height } = Astro.props;
const raw = FILES[`../assets/${name}.svg`];
// Ne retirer width/height que sur la balise <svg> ouvrante :
// un remplacement global vide aussi les <rect> à l'intérieur.
const [, w, h] = raw.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
const width = Math.round((height * +w) / +h);
---
<span style="color: var(--ink)" set:html={resize(raw, width, height)} />C’est la partie la moins intéressante, et c’est tant mieux. Un site statique se dépose n’importe où. Un dépôt privé, un job de construction qui refuse de publier si le contrôle échoue, un hébergeur statique : trois éléments, aucune administration système.
- run: npm ci
- run: npm run verify # types + adhérence + build ; échoue = rien n'est publié
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: pages deploy dist --project-name=mon-site- Un CMS. Le contenu tient dans des fichiers versionnés ; une interface d’édition serait une base de données à sauvegarder pour trois pages.
- Un framework côté client. Rien sur ce site n’a besoin d’état ; le seul script tient en quelques lignes et bascule un attribut.
- Les adresses de courriel dans les pages : c’est ce qu’un robot moissonne en premier.
- Les mesures d’audience. Aucun cookie, donc aucune bannière de consentement à écrire.
Pour une association ou un indépendant, l’enjeu n’est pas la sortie du site : c’est de pouvoir le reprendre dans deux ans. Des données typées, des jetons, un contrôle qui refuse les écarts — le reste se relit.