← viewpoints
28 July 2026

From a LinkedIn CV to a static site

The whole path, for an independent or an association: turning a profile and an idea for a logo into a site one person can maintain. The code matters more than the hosting.

The starting point is two things: a LinkedIn profile or a CV, and a graphic hunch. The finish line is a bilingual static site — no database, no server, no client-side framework. What follows is the route, with the code that matters.

01the CV becomes data

The temptation is to write your experience straight into HTML. It is the mistake that costs most later: every date fix happens in two places, and a second language doubles everything. Extract the CV into typed data once instead, and let the pages consume it.

typescript
// 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',
    },
  },
];

A client name and a technical stack do not translate; a period and a title do. Separating the two from the start saves you from translating "Python" forty times. A detail page per engagement then generates itself.

astro
--- // 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>
02the logo becomes a system

An idea for a logo is not an identity. What you need to produce are the decisions that follow from it: how many inks, which weights, which sizes, which gaps. Written once as CSS custom properties they become the single source of truth — and the rest of the site loses the right to invent a value.

css
/* 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;
}

A trap waits here. If a component hardcodes "black background, white text", it vanishes in dark mode, where black is precisely the page colour. Inversion has to be relative to the surface, not absolute.

css
: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);
}
03the skeleton

Astro fits because it emits HTML and ships no JavaScript until you ask for some. For a brochure site that is exactly the right default: pages stay readable without script, and weight does not scale with the number of components.

terminal
npm create astro@latest -- --template minimal
npm install

What comes out of the build is HTML you can read: component styles are scoped by an attribute, and no <script> tag appears until you write one.

html
<!-- 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>

Styles load once in the layout, and each component keeps its own in its own file — Astro scopes them automatically, with no naming convention to maintain.

astro
--- // 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>
04two languages, no duplication

The expensive way is to duplicate the pages. The sustainable way is to write the page once and route it twice: the body lives in a view, and the route files do nothing but pass the language.

astro
--- // 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" />

The locale switch must point at the same page in the other language, not at the home page. And it must not reorder itself: write the current language first and the two labels swap places on every click, so the control moves under the cursor.

typescript
export function alternatePath(path: string, lang: 'fr' | 'en') {
  const stripped = path.replace(/^\/en(?=\/|$)/, '') || '/';
  return lang === 'fr' ? `/en${stripped === '/' ? '' : stripped}` : stripped;
}
05making the rule executable

A brand guide nothing checks is an intention. Thirty lines of Node are enough to refuse what the identity forbids: a hardcoded colour, an undeclared font, a shadow. The check runs before every publication, and it has already refused code written ten minutes earlier.

javascript
// 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);
json
"scripts": {
  "verify": "astro check && node scripts/check-adherence.mjs && astro build"
}
06images are code

A symbol pulled in with an <img> tag cannot inherit the text colour: currentColor there means the file’s own default, not the page’s. In dark mode every pictogram stayed black on black. The fix is to inline them into the document rather than link them.

astro
--- // 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)} />
07publishing

This is the least interesting part, and that is as it should be. A static site drops anywhere. A private repository, a build job that refuses to publish when the check fails, a static host: three pieces, no system administration.

yaml
- 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
08what was ruled out
  • A CMS. The content fits in versioned files; an editing interface would be a database to back up, for three pages.
  • A client-side framework. Nothing here needs state; the only script is a few lines that toggle an attribute.
  • Email addresses in the pages: that is the first thing a scraper harvests.
  • Audience measurement. No cookies, therefore no consent banner to write.

For an association or an independent, the point is not launching the site: it is being able to pick it up again in two years. Typed data, tokens, a check that refuses drift — the rest can be read.