FR EN

Chapitre 06Tutoriels

Atelier : reprendre un site HTML existant

Un vrai site de départ — trois pages, une feuille de style, un script, des images — repris fichier par fichier. Le même dessin à l'arrivée, mais éditable par le client.

20 min de lecture17 sectionsChapitre 6 / 22

Comment lire cet atelier

Même convention que l'atelier précédent : chaque bloc de code porte, dans son bandeau, le chemin exact du fichier et ce qu'il faut en faire — le créer, le remplacer entièrement, ou n'y changer qu'une ligne. Les encadrés « Vérifiez » disent ce que vous devez voir avant de continuer.

Si vous avez déjà fait l'atelier précédent, vous reconnaîtrez la moitié des gestes. Sinon, celui-ci se suit quand même : rien n'y est supposé acquis.

Le site de départ

Café Bergamote, un site statique écrit à la main il y a trois ans. Il fonctionne, il est bien référencé, et son propriétaire veut simplement pouvoir corriger ses horaires sans appeler personne.

cafe-bergamote/ — l'existant text
index.html
carte.html
contact.html
assets/
  css/style.css
  js/main.js
  img/
    devanture.jpg
    salle.jpg
    torrefaction.jpg
Vous avez votre propre site ?

Sautez cette section et transposez : ce qui suit ne dépend que de la forme du site de départ — un en-tête et un pied de page répétés, un bloc central qui change, une feuille de style, un script.

Voici l'accueil, dans son état d'origine :

index.html — le site de départ html
<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Café Bergamote — torréfaction artisanale à Lyon</title>
  <meta name="description" content="Café de spécialité torréfié sur place, rue Sainte-Catherine à Lyon.">
  <link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
  <header class="entete">
    <a class="marque" href="index.html">Café Bergamote</a>
    <button class="burger" aria-expanded="false" aria-controls="menu">Menu</button>
    <nav class="menu" id="menu">
      <a href="index.html">Accueil</a>
      <a href="carte.html">La carte</a>
      <a href="contact.html">Nous trouver</a>
    </nav>
  </header>

  <main>
    <section class="hero">
      <h1>Torréfié sur place, tous les mardis</h1>
      <p class="accroche">Café de spécialité, rue Sainte-Catherine depuis 2019.</p>
    </section>

    <section>
      <img src="assets/img/devanture.jpg" alt="La devanture du café, un matin d'hiver"
           width="1600" height="900">
    </section>

    <section class="propos">
      <h2>Notre torréfaction</h2>
      <p>
        Nous achetons en direct producteur et torréfions <strong>en petites séries</strong>,
        le mardi matin. Le café part en boutique le jour même.
        <a href="carte.html">Voir la carte</a>.
      </p>
    </section>

    <section>
      <h2>Ce qu'on en dit</h2>
      <ul class="liste-avis">
        <li>
          <blockquote>Le meilleur espresso du quartier, sans discussion.</blockquote>
          <cite>Claire D.</cite>
        </li>
        <li>
          <blockquote>On y vient pour le café, on y reste pour l'accueil.</blockquote>
          <cite>Malik T.</cite>
        </li>
      </ul>
    </section>
  </main>

  <footer class="pied">
    <p>12 rue Sainte-Catherine, 69001 Lyon — 04 78 00 00 00</p>
    <p>© <span id="annee"></span> Café Bergamote</p>
  </footer>

  <script src="assets/js/main.js"></script>
</body>
</html>

Les deux autres pages sont bâties pareil : même en-tête, même pied de page, même script — et un <main> différent. C'est cette répétition qui va devenir un gabarit.

carte.html — seulement la partie qui change html
<main>
  <section class="hero hero-court">
    <h1>La carte</h1>
    <p class="accroche">Torréfaction du mardi, mise à jour chaque semaine.</p>
  </section>

  <section class="cafes">
    <div class="cafe">
      <img src="assets/img/torrefaction.jpg" alt="Grains en cours de refroidissement"
           width="1600" height="900">
      <h2>Éthiopie Sidamo</h2>
      <p class="origine">Lavé — notes de bergamote et d'abricot</p>
      <p>Notre café signature, celui qui a donné son nom à la maison.</p>
    </div>
    <div class="cafe">
      <img src="assets/img/salle.jpg" alt="La salle du café en fin de journée"
           width="1600" height="900">
      <h2>Colombie Huila</h2>
      <p class="origine">Lavé — chocolat noir et noisette</p>
      <p>Le plus rond de la carte, parfait en filtre comme en espresso.</p>
    </div>
  </section>
</main>
contact.html — seulement la partie qui change html
<main>
  <section class="hero hero-court">
    <h1>Nous trouver</h1>
    <p class="accroche">Ouvert du mardi au dimanche.</p>
  </section>

  <section>
    <h2>Horaires</h2>
    <p><strong>Du mardi au samedi</strong>, de 8 h à 19 h.<br>Le dimanche, de 9 h à 13 h.</p>
  </section>

  <section>
    <h2>Adresse</h2>
    <p>12 rue Sainte-Catherine, 69001 Lyon.<br>Métro Hôtel de Ville.</p>
  </section>
</main>
assets/js/main.js — le site de départ js
// Menu repliable sur petit écran.
var burger = document.querySelector('.burger');
var menu = document.querySelector('.menu');

burger.addEventListener('click', function () {
  var ouvert = menu.classList.toggle('ouvert');
  burger.setAttribute('aria-expanded', String(ouvert));
});

// Année du pied de page.
document.getElementById('annee').textContent = new Date().getFullYear();

Ce qui devient quoi

C'est le seul vrai travail de réflexion de l'atelier, et il ne s'automatise pas : aucun outil ne sait décider ce que le client a le droit de changer.

Dans le site d'origineDevientPourquoi
Le titre et l'accroche de chaque page Champs text Leur taille et leur couleur sont des décisions d'édition.
Le paragraphe « Notre torréfaction » Champ richtext De la prose : gras et liens oui, taille non.
Les photos Champs media Elles changeront à la première saison.
La liste des avis, la grille des cafés Collections Le client en ajoutera, en retirera.
Le menu, l'adresse, le téléphone site.json Structure : un lien cassé casse le site entier.
Les intitulés « Horaires », « Adresse » Rien, ils restent dans le gabarit Ce sont des étiquettes, pas du contenu.
Les grilles CSS Rien, elles ne bougent pas Le dessin du site n'appartient pas au client.
En cas de doute, laissez en structure

Une zone qu'on rend éditable plus tard coûte cinq minutes. Une zone éditable par erreur, que le client casse au troisième mois, coûte un appel, un diagnostic et un correctif.

Étape 1 — Créer le projet à côté

On ne convertit pas sur place. On crée un projet neuf et on y fait entrer l'ancien site morceau par morceau — l'ancien reste en ligne pendant tout ce temps.

terminal bash
npm create inline@latest bergamote -- --nom "Café Bergamote" \
  --courriel bonjour@cafe-bergamote.fr --langue fr

cd bergamote
npm install
npm run build
npm run serve
Vérifiez

http://127.0.0.1:8788/fr/ affiche le site d'exemple livré avec le projet. Notez au passage la clé de site affichée à la création : elle ne sera plus jamais montrée.

Faites ensuite le ménage — ces trois fichiers d'exemple ne serviront pas :

terminal bash
rm src/components/Testimonial.astro
rm src/content/pages/fr/home.json
mkdir -p src/vues public/js

Étape 2 — La feuille de style

Elle se copie sans une modification. C'est le point qui rassure le plus au moment de décider d'une reprise : le site ne change pas d'allure.

terminal bash
cp ../cafe-bergamote/assets/css/style.css src/styles/site.css

Si vous suivez avec le site d'exemple, voici son contenu exact — c'est le style.css d'origine, tel quel :

src/styles/site.css — créer ce fichier css
/**
 * La feuille de style du site d'origine, copiée sans modification.
 */

:root {
  --encre: #241c16;
  --papier: #fffdf9;
  --brique: #a4622a;
}

body {
  margin: 0;
  font-family: 'Iowan Old Style', Georgia, serif;
  color: var(--encre);
  background: var(--papier);
  line-height: 1.6;
}

.entete {
  display: flex;
  align-items: center;
  gap: 2rem;
  padding: 1rem 5vw;
  border-bottom: 1px solid #e3d8c9;
}
.marque { font-size: 1.4rem; font-weight: 600; text-decoration: none; color: inherit; }
.menu { display: flex; gap: 1.25rem; margin-left: auto; }
.menu a { color: var(--encre); text-decoration: none; font-size: 0.9rem; }
.burger {
  display: none;
  margin-left: auto;
  border: 1px solid #e3d8c9;
  background: none;
  font: inherit;
  padding: 0.3rem 0.7rem;
  border-radius: 4px;
}

main { max-width: 46rem; margin: 0 auto; padding: 0 5vw 4rem; }

.hero { padding: 4rem 0 2rem; text-align: center; }
.hero h1 { font-size: 3rem; font-weight: 700; margin: 0 0 0.5rem; }
.hero-court { padding: 2.5rem 0 1.5rem; }
.accroche { font-size: 1.3rem; color: #8a7a6c; margin: 0; }

section { margin: 3rem 0; }

img, iframe { max-width: 100%; height: auto; }

.liste-avis {
  list-style: none;
  display: grid;
  gap: 2rem;
  padding: 0;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
}
.liste-avis li { border-left: 3px solid var(--brique); padding-left: 1rem; }
.liste-avis blockquote { margin: 0; font-style: italic; }
.liste-avis cite { display: block; margin-top: 0.4rem; font-size: 0.85rem; color: #8a7a6c; }

.cafes {
  display: grid;
  gap: 2.5rem;
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}
.cafe img { width: 100%; height: auto; border-radius: 4px; }
.cafe h2 { margin: 0.75rem 0 0; }
.origine { color: var(--brique); font-weight: 500; margin: 0.2rem 0 0.6rem; }

.pied {
  border-top: 1px solid #e3d8c9;
  padding: 2rem 5vw 3rem;
  color: #8a7a6c;
  font-size: 0.875rem;
}
.pied p { margin: 0.2rem 0; }

@media (max-width: 40rem) {
  .burger { display: block; }
  .menu { display: none; }
  .menu.ouvert { display: flex; flex-direction: column; }
}

Il reste une chose à faire, une seule : tokens.css — la feuille du paquet qui traduit les crans de style en propriétés CSS — attend des variables sous des noms précis, que l'ancienne feuille ne déclare pas. On écrit donc un pont.

src/styles/theme.css — remplacer tout le fichier css
/**
 * Le pont entre l'ancienne feuille de style et le schéma d'inline.
 *
 * tokens.css attend des variables sous des noms précis. L'ancien site avait les
 * siens : on les branche les uns sur les autres, sans rien changer à site.css.
 */

:root {
  /* Les couleurs du site existant, sous les noms qu'attend le schéma */
  --color-primary: var(--encre, #241c16);
  --color-secondary: #4a3d33;
  --color-muted: #8a7a6c;
  --color-accent: var(--brique, #a4622a);
  --color-inverse: var(--papier, #fffdf9);

  --color-surface: var(--papier, #fffdf9);
  --color-surface-alt: #f6f0e7;
  --color-border: #e3d8c9;

  /* L'échelle relevée dans site.css, cran par cran */
  --size-xs: 0.75rem;
  --size-sm: 0.875rem;   /* .liste-avis cite */
  --size-base: 1rem;
  --size-lg: 1.3rem;     /* .accroche */
  --size-xl: 1.6rem;
  --size-2xl: 2.1rem;
  --size-3xl: 3rem;      /* .hero h1 */

  --weight-thin: 100;
  --weight-light: 300;
  --weight-regular: 400;
  --weight-medium: 500;   /* .origine */
  --weight-semibold: 600; /* .marque */
  --weight-bold: 700;     /* .hero h1 */

  --font-body: 'Iowan Old Style', Georgia, serif;
  --line-height: 1.6;
}
Relevez les valeurs, ne les inventez pas

Ouvrez l'ancienne feuille et notez les tailles et graisses réellement utilisées, comme ci-dessus en commentaire. Une variable oubliée ne provoque aucune erreur : le navigateur ignore la propriété et hérite d'autre chose. La page s'affiche, et personne ne voit que la moitié du site a changé de police.

Le piège de la spécificité

tokens.css pose des classes à un seul niveau — .cms-color-primary. Si votre feuille contient .hero h1 { color: … }, à deux niveaux, c'est elle qui gagne et le cran de couleur choisi par le client n'aura aucun effet. Deux issues : retirer la propriété de l'ancienne règle, ou ne pas rendre ce champ éditable en couleur.

Étape 3 — Les images

terminal bash
cp ../cafe-bergamote/assets/img/*.jpg src/media/

Renommez-les ensuite en minuscules, sans accent ni espace. Dans notre exemple, les trois fichiers s'appellent devanture, salle et torrefaction — gardez l'extension que vous avez, et écrivez la même dans les fichiers de contenu de l'étape 6.

Ce que la reprise gagne au passage

Dans src/media/, Astro produit au build de l'AVIF, du WebP et plusieurs largeurs, avec une empreinte dans le nom servi. L'ancien site servait un seul JPEG. C'est un gain qui arrive sans rien demander.

Étape 4 — Le JavaScript

Le script d'origine fait deux choses. L'une se garde telle quelle, l'autre disparaît — et sa disparition est un gain.

Ce que faisait le scriptDevientPourquoi
Replier le menu sur petit écran Inchangé, dans public/js/menu.js C'est de l'amélioration progressive : la page marche sans lui.
Écrire l'année dans le pied de page Calculée au build, à l'étape 5 Elle entre dans le HTML servi, donc dans l'index des moteurs.
public/js/menu.js — créer ce fichier js
// Repris tel quel de l'ancien site, moins la ligne de l'année.
var burger = document.querySelector('.burger');
var menu = document.querySelector('.menu');

if (burger && menu) {
  burger.addEventListener('click', function () {
    var ouvert = menu.classList.toggle('ouvert');
    burger.setAttribute('aria-expanded', String(ouvert));
  });
}
Un script qui réécrit une zone éditable la casse

L'ancien getElementById('annee').textContent = … était inoffensif parce que le pied de page n'est pas éditable. Le même geste sur une zone data-cms effacerait, au chargement, ce que le client vient de saisir. La règle : aucun script ne touche au contenu d'une zone éditable. Animations, compteurs et carrousels restent permis tant qu'ils déplacent le balisage sans le réécrire.

Étape 5 — La coquille et le gabarit

C'est l'étape qui transforme trois fichiers qui se ressemblent en un fichier et trois pages. Tout ce qui était identique d'une page à l'autre remonte ici.

src/layouts/Base.astro — remplacer tout le fichier astro
---
/**
 * La coquille du document : ce qui était identique dans les trois pages HTML
 * d'origine — le head, l'en-tête, le pied de page, le script.
 */
import 'inline-core/styles/tokens.css';
import '../styles/theme.css';
import '../styles/site.css';
import { site } from '../content/site';
import { LOCALE_LABELS } from '../lib/locales';

interface Props {
  title: string;
  description: string;
  /** Fichier de contenu source — point d'ancrage de l'overlay, inerte sinon. */
  contentFile: string;
  locale: string;
  pageName: string;
  alternates?: Array<{ locale: string; href: string }>;
  untranslated?: number;
}

const {
  title,
  description,
  contentFile,
  locale,
  pageName,
  alternates = [],
  untranslated = 0,
} = Astro.props;

/**
 * L'année était calculée en JavaScript dans l'ancien site. Au build, elle est
 * dans le HTML servi : un visiteur sans JavaScript la voit aussi.
 */
const annee = new Date().getFullYear();
---

<!doctype html>
<html lang={locale}>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{title}</title>
    <meta name="description" content={description} />
    <link rel="canonical" href={new URL(Astro.url.pathname, Astro.site).href} />
    {
      alternates.map((entry) => (
        <link
          rel="alternate"
          hreflang={entry.locale}
          href={new URL(entry.href, Astro.site).href}
        />
      ))
    }
  </head>

  <body
    data-cms-file={contentFile}
    data-cms-locale={locale}
    data-cms-page={pageName}
    data-cms-untranslated={untranslated > 0 ? String(untranslated) : undefined}
  >
    {/* Le même balisage qu'avant, aux classes près : rien n'a bougé. */}
    <header class="entete">
      <a class="marque" href={`/${locale}/`}>{site.name}</a>
      <button class="burger" aria-expanded="false" aria-controls="menu">Menu</button>
      <nav class="menu" id="menu">
        {site.navigation.map((item) => <a href={item.href}>{item.label}</a>)}
        {
          alternates
            .filter((entry) => entry.locale !== locale)
            .map((entry) => (
              <a href={entry.href} hreflang={entry.locale} lang={entry.locale}>
                {LOCALE_LABELS[entry.locale as keyof typeof LOCALE_LABELS] ?? entry.locale}
              </a>
            ))
        }
      </nav>
    </header>

    <slot />

    <footer class="pied">
      <p>{site.contact.address} — {site.contact.phone}</p>
      <p>© {annee} {site.name}</p>
    </footer>

    <script src="/js/menu.js" is:inline></script>
  </body>
</html>
Les classes n'ont pas bougé

entete, marque, burger, menu, pied : pas un caractère de différence. C'est la condition pour que site.css continue de s'appliquer sans retouche. is:inline sur la balise <script> dit à Astro de ne pas y toucher.

Le <main> et la section « hero » se répétaient aussi. Ils vont dans un gabarit intermédiaire :

src/layouts/Page.astro — créer ce fichier astro
---
/**
 * Le gabarit d'une page : le <main> et la section « hero », que les trois
 * pages d'origine répétaient à l'identique.
 */
import Base from './Base.astro';

interface Props {
  title: string;
  description: string;
  contentFile: string;
  locale: string;
  pageName: string;
  alternates?: Array<{ locale: string; href: string }>;
  untranslated?: number;
  /** « hero-court » sur les pages intérieures, comme sur l'ancien site. */
  court?: boolean;
}

const { court = false, ...coquille } = Astro.props;
---

<Base {...coquille}>
  <main>
    <section class={court ? 'hero hero-court' : 'hero'}>
      <slot name="entete" />
    </section>

    <slot />
  </main>
</Base>
Un gabarit ne lit jamais le contenu

Page.astro ne connaît pas blocks.page.titre : il expose un emplacement nommé, et c'est la route qui y pose le composant. Un gabarit qui irait chercher un chemin dans le JSON ne servirait plus qu'aux pages ayant ce bloc-là.

Étape 6 — Le contenu

Quatre fichiers. Le premier est de la structure, les trois autres sont au client.

src/content/site.json — remplacer tout le fichier json
{
  "name": "Café Bergamote",
  "locale": "fr",
  "navigation": [
    { "label": "Accueil", "href": "/fr/" },
    { "label": "La carte", "href": "/fr/carte/" },
    { "label": "Nous trouver", "href": "/fr/contact/" }
  ],
  "contact": {
    "email": "bonjour@cafe-bergamote.fr",
    "phone": "04 78 00 00 00",
    "address": "12 rue Sainte-Catherine, 69001 Lyon"
  },
  "footer": { "legal": "Café Bergamote" }
}
src/content/pages/fr/home.json — créer ce fichier json
{
  "meta": {
    "title": "Café Bergamote — torréfaction artisanale à Lyon",
    "description": "Café de spécialité torréfié sur place, rue Sainte-Catherine à Lyon."
  },
  "blocks": {
    "page": {
      "titre": {
        "type": "text",
        "value": "Torréfié sur place, tous les mardis",
        "style": { "size": "3xl", "weight": "bold", "italic": false, "align": "center", "color": "primary" }
      },
      "chapo": {
        "type": "text",
        "value": "Café de spécialité, rue Sainte-Catherine depuis 2019.",
        "style": { "size": "lg", "weight": "regular", "italic": false, "align": "center", "color": "muted" }
      }
    },
    "hero": {
      "photo": {
        "type": "media",
        "kind": "image",
        "src": "devanture.webp",
        "alt": "La devanture du café, un matin d'hiver",
        "width": 1600,
        "height": 900
      }
    },
    "propos": {
      "titre": {
        "type": "text",
        "value": "Notre torréfaction",
        "style": { "size": "2xl", "weight": "semibold", "italic": false, "align": "left", "color": "primary" }
      },
      "corps": {
        "type": "richtext",
        "value": "Nous achetons en direct producteur et torréfions <strong>en petites séries</strong>, le mardi matin. Le café part en boutique le jour même. <a href=\"/fr/carte/\">Voir la carte</a>."
      }
    },
    "avis": {
      "titre": {
        "type": "text",
        "value": "Ce qu'on en dit",
        "style": { "size": "2xl", "weight": "semibold", "italic": false, "align": "left", "color": "primary" }
      }
    }
  },
  "collections": {
    "avis": [
      {
        "id": "a-001",
        "citation": {
          "type": "text",
          "value": "Le meilleur espresso du quartier, sans discussion.",
          "style": { "size": "base", "weight": "regular", "italic": true, "align": "left", "color": "primary" }
        },
        "auteur": {
          "type": "text",
          "value": "Claire D.",
          "style": { "size": "sm", "weight": "regular", "italic": false, "align": "left", "color": "muted" }
        }
      },
      {
        "id": "a-002",
        "citation": {
          "type": "text",
          "value": "On y vient pour le café, on y reste pour l'accueil.",
          "style": { "size": "base", "weight": "regular", "italic": true, "align": "left", "color": "primary" }
        },
        "auteur": {
          "type": "text",
          "value": "Malik T.",
          "style": { "size": "sm", "weight": "regular", "italic": false, "align": "left", "color": "muted" }
        }
      }
    ]
  }
}
Les liens internes changent de forme

L'ancien site écrivait des fichiers voisins — carte.html. Astro sert un dossier par page : le même lien devient /fr/carte/, absolu, sinon il se résoudrait sous la page courante. C'est l'oubli le plus fréquent d'une reprise, et il ne se voit qu'au clic.

src/content/pages/fr/carte.json — créer ce fichier json
{
  "meta": {
    "title": "La carte — Café Bergamote",
    "description": "Torréfaction du mardi, mise à jour chaque semaine."
  },
  "blocks": {
    "page": {
      "titre": {
        "type": "text",
        "value": "La carte",
        "style": { "size": "3xl", "weight": "bold", "italic": false, "align": "center", "color": "primary" }
      },
      "chapo": {
        "type": "text",
        "value": "Torréfaction du mardi, mise à jour chaque semaine.",
        "style": { "size": "lg", "weight": "regular", "italic": false, "align": "center", "color": "muted" }
      }
    }
  },
  "collections": {
    "cafes": [
      {
        "id": "c-001",
        "nom": {
          "type": "text",
          "value": "Éthiopie Sidamo",
          "style": { "size": "xl", "weight": "semibold", "italic": false, "align": "left", "color": "primary" }
        },
        "origine": {
          "type": "text",
          "value": "Lavé — notes de bergamote et d'abricot",
          "style": { "size": "base", "weight": "medium", "italic": false, "align": "left", "color": "accent" }
        },
        "texte": {
          "type": "richtext",
          "value": "Notre café signature, celui qui a donné son nom à la maison."
        },
        "photo": {
          "type": "media",
          "kind": "image",
          "src": "torrefaction.webp",
          "alt": "Grains en cours de refroidissement après torréfaction",
          "width": 1600,
          "height": 900
        }
      },
      {
        "id": "c-002",
        "nom": {
          "type": "text",
          "value": "Colombie Huila",
          "style": { "size": "xl", "weight": "semibold", "italic": false, "align": "left", "color": "primary" }
        },
        "origine": {
          "type": "text",
          "value": "Lavé — chocolat noir et noisette",
          "style": { "size": "base", "weight": "medium", "italic": false, "align": "left", "color": "accent" }
        },
        "texte": {
          "type": "richtext",
          "value": "Le plus rond de la carte, parfait en filtre comme en espresso."
        },
        "photo": {
          "type": "media",
          "kind": "image",
          "src": "salle.webp",
          "alt": "La salle du café en fin de journée",
          "width": 1600,
          "height": 900
        }
      }
    ]
  }
}
src/content/pages/fr/contact.json — créer ce fichier json
{
  "meta": {
    "title": "Nous trouver — Café Bergamote",
    "description": "12 rue Sainte-Catherine, 69001 Lyon. Ouvert du mardi au dimanche."
  },
  "blocks": {
    "page": {
      "titre": {
        "type": "text",
        "value": "Nous trouver",
        "style": { "size": "3xl", "weight": "bold", "italic": false, "align": "center", "color": "primary" }
      },
      "chapo": {
        "type": "text",
        "value": "Ouvert du mardi au dimanche.",
        "style": { "size": "lg", "weight": "regular", "italic": false, "align": "center", "color": "muted" }
      }
    },
    "infos": {
      "horaires": {
        "type": "richtext",
        "value": "<strong>Du mardi au samedi</strong>, de 8 h à 19 h.<br>Le dimanche, de 9 h à 13 h."
      },
      "adresse": {
        "type": "richtext",
        "value": "12 rue Sainte-Catherine, 69001 Lyon.<br>Métro Hôtel de Ville."
      }
    }
  }
}

Étape 7 — Les composants et les vues

Cinq fichiers : deux composants pour les items de liste, trois vues pour les corps de page.

src/components/Avis.astro — créer ce fichier astro
---
/**
 * Un avis. Le même balisage que dans l'ancien <li> : blockquote puis cite.
 */
import type { CollectionItem } from 'inline-core/schema';
import Editable from 'inline-core/components/Editable.astro';

interface Props {
  item: CollectionItem;
  path: string;
  untranslated?: Set<string>;
}

const { item, path, untranslated } = Astro.props;
---

<Editable
  path={`${path}.citation`}
  field={item.citation as any}
  as="blockquote"
  untranslated={untranslated}
/>
<Editable path={`${path}.auteur`} field={item.auteur as any} as="cite" untranslated={untranslated} />
src/components/Cafe.astro — créer ce fichier astro
---
/**
 * Un café de la carte : une photo et trois textes. Le même balisage que
 * l'ancien <div class="cafe">.
 */
import type { CollectionItem } from 'inline-core/schema';
import Editable from 'inline-core/components/Editable.astro';
import Media from 'inline-core/components/Media.astro';

interface Props {
  item: CollectionItem;
  path: string;
  untranslated?: Set<string>;
}

const { item, path, untranslated } = Astro.props;

/**
 * « Media » attend la page entière, un item n'en est qu'un morceau : on lui
 * présente donc ce morceau sous la forme qu'il sait lire.
 */
const [, liste, id] = path.split('.');
const morceau = { collections: { [liste]: { [id]: item } } } as any;
---

<div class="cafe">
  <Media
    data={morceau}
    path={`${path}.photo`}
    widths={[400, 800, 1200]}
    sizes="(max-width: 40rem) 100vw, 20rem"
  />
  <Editable path={`${path}.nom`} field={item.nom as any} as="h2" untranslated={untranslated} />
  <Editable
    path={`${path}.origine`}
    field={item.origine as any}
    as="p"
    class="origine"
    untranslated={untranslated}
  />
  <Editable path={`${path}.texte`} field={item.texte as any} as="p" untranslated={untranslated} />
</div>
src/vues/Accueil.astro — créer ce fichier astro
---
/**
 * Le corps de l'ancienne index.html, moins l'en-tête et le pied de page.
 */
import type { Page } from 'inline-core/schema';
import Editable from 'inline-core/components/Editable.astro';
import Media from 'inline-core/components/Media.astro';
import Collection from 'inline-core/components/Collection.astro';
import Avis from '../components/Avis.astro';

interface Props {
  data: Page;
  missing: Set<string>;
}

const { data, missing } = Astro.props;
---

<section>
  <Media
    data={data}
    path="blocks.hero.photo"
    widths={[480, 800, 1200, 1600]}
    sizes="(max-width: 48rem) 100vw, 46rem"
  />
</section>

<section class="propos">
  <Editable data={data} path="blocks.propos.titre" as="h2" untranslated={missing} />
  <Editable data={data} path="blocks.propos.corps" as="p" untranslated={missing} />
</section>

<section>
  <Editable data={data} path="blocks.avis.titre" as="h2" untranslated={missing} />
  <Collection
    data={data}
    name="avis"
    item={Avis}
    class="liste-avis"
    untranslated={missing}
    blank={{
      citation: {
        type: 'text',
        value: 'Leur retour, en une phrase.',
        style: { size: 'base', weight: 'regular', italic: true, align: 'left', color: 'primary' },
      },
      auteur: {
        type: 'text',
        value: 'Prénom N.',
        style: { size: 'sm', weight: 'regular', italic: false, align: 'left', color: 'muted' },
      },
    }}
  />
</section>
src/vues/Carte.astro — créer ce fichier astro
---
/**
 * Le corps de l'ancienne carte.html : la grille des cafés, devenue une liste
 * que le client alimente lui-même.
 */
import type { Page } from 'inline-core/schema';
import Collection from 'inline-core/components/Collection.astro';
import Cafe from '../components/Cafe.astro';

interface Props {
  data: Page;
  missing: Set<string>;
}

const { data, missing } = Astro.props;
---

<section>
  <Collection
    data={data}
    name="cafes"
    item={Cafe}
    class="cafes"
    untranslated={missing}
    blank={{
      nom: {
        type: 'text',
        value: 'Nouveau café',
        style: { size: 'xl', weight: 'semibold', italic: false, align: 'left', color: 'primary' },
      },
      origine: {
        type: 'text',
        value: 'Origine — notes de dégustation',
        style: { size: 'base', weight: 'medium', italic: false, align: 'left', color: 'accent' },
      },
      texte: { type: 'richtext', value: 'Décrivez-le en une phrase.' },
      photo: {
        type: 'media',
        kind: 'image',
        src: 'torrefaction.webp',
        alt: 'Photo à remplacer',
        width: 1600,
        height: 900,
      },
    }}
  />
</section>
src/vues/Contact.astro — créer ce fichier astro
---
/**
 * Le corps de l'ancienne contact.html. Le formulaire n'est pas repris.
 */
import type { Page } from 'inline-core/schema';
import Editable from 'inline-core/components/Editable.astro';

interface Props {
  data: Page;
  missing: Set<string>;
}

const { data, missing } = Astro.props;
---

<section>
  <h2>Horaires</h2>
  <Editable data={data} path="blocks.infos.horaires" as="p" untranslated={missing} />
</section>

<section>
  <h2>Adresse</h2>
  <Editable data={data} path="blocks.infos.adresse" as="p" untranslated={missing} />
</section>

Étape 8 — La route

Un fichier remplace les trois fichiers HTML. C'est le dernier de la reprise.

src/pages/[lang]/[...slug].astro — remplacer tout le fichier astro
---
/**
 * La route unique : une URL par page et par langue.
 */
import { getCollection } from 'astro:content';
import type { Page as Contenu } from 'inline-core/schema';
import Editable from 'inline-core/components/Editable.astro';
import Page from '../../layouts/Page.astro';
import Accueil from '../../vues/Accueil.astro';
import Carte from '../../vues/Carte.astro';
import Contact from '../../vues/Contact.astro';
import { DEFAULT_LOCALE, LOCALES, localePath, mergeWithDefault } from '../../lib/locales';

export async function getStaticPaths() {
  const entries = await getCollection('pages');
  const byId = new Map(entries.map((entry) => [entry.id, entry]));

  const pages = entries
    .filter((entry) => entry.id.startsWith(`${DEFAULT_LOCALE}/`))
    .map((entry) => entry.id.slice(DEFAULT_LOCALE.length + 1));

  return LOCALES.flatMap((locale) =>
    pages.map((page) => {
      const reference = byId.get(`${DEFAULT_LOCALE}/${page}`)!;
      const translation = byId.get(`${locale}/${page}`);

      const { data, untranslated } = mergeWithDefault<Contenu>(
        reference.data as Contenu,
        translation?.data as Contenu | undefined,
      );

      return {
        params: { lang: locale, slug: page === 'home' ? undefined : page },
        props: { locale, page, data, untranslated },
      };
    }),
  );
}

const { locale, page, data, untranslated } = Astro.props;

const missing = new Set(untranslated);
const alternates = LOCALES.map((code) => ({ locale: code, href: localePath(code, page) }));

/** Une vue par page, comme il y avait un fichier HTML par page. */
const VUES: Record<string, any> = { home: Accueil, carte: Carte, contact: Contact };
const Vue = VUES[page];
---

<Page
  title={data.meta.title}
  description={data.meta.description}
  contentFile={`src/content/pages/${locale}/${page}.json`}
  locale={locale}
  pageName={page}
  court={page !== 'home'}
  alternates={alternates}
  untranslated={untranslated.length}
>
  <Fragment slot="entete">
    <Editable data={data} path="blocks.page.titre" as="h1" untranslated={missing} />
    <Editable data={data} path="blocks.page.chapo" as="p" class="accroche" untranslated={missing} />
  </Fragment>

  <Vue data={data} missing={missing} />
</Page>
terminal bash
npm run build
Vérifiez

Le build annonce 5 pages — vos trois pages, plus /admin et /aide. Ouvrez /fr/, /fr/carte/ et /fr/contact/ : c'est le site d'avant, à l'identique.

Étape 9 — Les deux ajustements

Deux choses ont bougé malgré tout. Elles sont petites, mais il faut les faire.

9.1 — Un sélecteur CSS

Le composant Collection rend un <div> avec un <article> par item, là où l'ancien HTML avait un <ul> avec des <li>. La classe, elle, est conservée : une seule règle est à reprendre.

src/styles/site.css — modifier une ligne css
/* avant */
.liste-avis li { border-left: 3px solid var(--brique); padding-left: 1rem; }

/* après */
.liste-avis article { border-left: 3px solid var(--brique); padding-left: 1rem; }

9.2 — Le contrôle du HTML

Le script scripts/check-html.mjs est réglé d'usine sur une seule page. Laissé tel quel, il annonce « tout va bien » en n'ayant vérifié que l'accueil. Deux retouches, toutes deux dans ses vingt-cinq premières lignes. D'abord la première ligne d'import, à laquelle il manque readdirSync :

scripts/check-html.mjs — modifier une ligne js
// avant
import { readFileSync, existsSync } from 'node:fs';

// après
import { readFileSync, readdirSync, existsSync } from 'node:fs';

Ensuite la liste des pages, écrite à la main et réduite à une seule entrée. Cherchez ces deux lignes — c'est le seul endroit du fichier où figure const PAGES :

scripts/check-html.mjs — les deux lignes à remplacer js
/** Une entrée par page construite, toutes langues confondues. */
const PAGES = [{ content: 'src/content/pages/fr/home.json', html: 'dist/fr/index.html' }];

Et remplacez-les — ces deux lignes-là, pas une de plus — par celles-ci :

scripts/check-html.mjs — le remplacement js
/** Doit rester d'accord avec src/lib/locales.ts. */
const LOCALES = ['fr'];

/**
 * Une entrée par page construite, toutes langues confondues.
 *
 * La liste est déduite du contenu, pas écrite à la main : une page oubliée
 * dans une liste manuelle serait une page non vérifiée, et le contrôle
 * annoncerait quand même « tout va bien ».
 */
const PAGES = LOCALES.flatMap((locale) =>
  readdirSync(join(root, 'src/content/pages', locale))
    .filter((name) => name.endsWith('.json'))
    .map((name) => {
      const page = name.slice(0, -'.json'.length);
      return {
        content: `src/content/pages/${locale}/${name}`,
        html: page === 'home' ? `dist/${locale}/index.html` : `dist/${locale}/${page}/index.html`,
      };
    }),
);

Étape 10 — Vérifier l'équivalence

L'objectif d'une reprise n'est pas « ça marche », c'est « c'est le même site ». Quatre contrôles le disent.

terminal bash
npm run build && npm run check
Vérifiez

« 3 page(s) vérifiée(s) », et non 1. Puis, le serveur relancé :

terminal bash
# 1. le contenu est dans la source, sans JavaScript — doit renvoyer 1
curl -s http://127.0.0.1:8788/fr/ | grep -c "Torréfié sur place"

# 2. les routes d'édition répondent — doit renvoyer 405, pas 404
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8788/api/auth

# 3. les classes d'origine sont bien là — doit renvoyer 1 pour chacune
curl -s http://127.0.0.1:8788/fr/ | grep -c 'class="entete"'
curl -s http://127.0.0.1:8788/fr/ | grep -c 'class="liste-avis"'

Et une fois en ligne, celui qu'on oublie toujours :

terminal bash
# les anciennes adresses doivent renvoyer un 301, pas un 404
curl -sI https://cafe-bergamote.fr/carte.html | head -1
Les redirections, le jour de la bascule

/carte.html devient /fr/carte/. Un site déjà référencé ne peut pas se permettre de perdre ses adresses : posez une redirection permanente de chaque ancienne vers la nouvelle, chez l'hébergeur ou dans astro.config.mjs. Le jour de la bascule, pas la semaine suivante.

Et la comparaison à l'œil

Ouvrez l'ancien et le nouveau côte à côte, à la même largeur, puis en mobile. Les écarts qui restent viennent presque toujours d'une variable manquante dans theme.css ou d'un sélecteur à deux niveaux que tokens.css ne peut pas battre — les deux pièges de l'étape 2.

Ce qui ne se reprend pas

CasPourquoiQue faire
Les formulaires Hors périmètre : ils demandent un serveur qui reçoit. Ils restent ce qu'ils étaient — service tiers ou mailto:.
Le contenu généré côté navigateur S'il n'est pas dans le HTML, il n'y a rien à reprendre. Le figer dans la page d'abord. C'est un gain de référencement au passage.
Un carrousel qui charge ses images en script Le balisage doit contenir tous ses éléments en dur. Les écrire dans la page ; le script ne fait plus que les faire défiler.
Les inclusions PHP ou SSI Elles n'ont plus lieu d'être. C'est exactement ce que le gabarit de l'étape 5 remplace.

Et ensuite

  • La feuille de style d'origine est importée telle quelle, et les classes n'ont pas bougé.
  • Les variables attendues par tokens.css sont toutes déclarées.
  • L'en-tête et le pied de page ne sont écrits qu'une fois.
  • Aucun script ne réécrit le contenu d'une zone éditable.
  • Les liens internes sont absolus et préfixés par la langue.
  • Les images sont dans src/media/, avec description et dimensions.
  • npm run build et npm run check passent, sur les trois pages.
  • Les anciennes adresses renverront un 301 le jour de la bascule.

Pour l'édition par le client — clé, overlay, publication — la marche à suivre est la même que dans l'atelier précédent : étape 12. Pour ajouter une deuxième langue à ce site repris, c'est l'étape 10 du même atelier, mot pour mot.

Un ordre de reprise qui marche

L'accueil seul d'abord, puis validation par le client — c'est là qu'on découvre qu'il voulait aussi modifier les horaires du pied de page. Ensuite les pages à contenu changeant. Les pages figées en dernier, ou jamais : mentions légales et page 404 n'ont rien à gagner à devenir éditables.