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.
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.
index.html
carte.html
contact.html
assets/
css/style.css
js/main.js
img/
devanture.jpg
salle.jpg
torrefaction.jpgSautez 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 :
<!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.
<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><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>// 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'origine | Devient | Pourquoi |
|---|---|---|
| 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. |
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.
npm create inline@latest bergamote -- --nom "Café Bergamote" \
--courriel bonjour@cafe-bergamote.fr --langue fr
cd bergamote
npm install
npm run build
npm run servehttp://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 :
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.
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 :
/**
* 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.
/**
* 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;
}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.
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
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.
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 script | Devient | Pourquoi |
|---|---|---|
| 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. |
// 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));
});
}
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.
---
/**
* 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>
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 :
---
/**
* 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>
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.
{
"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" }
}{
"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" }
}
}
]
}
}
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.
{
"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
}
}
]
}
}{
"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.
---
/**
* 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} />---
/**
* 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>---
/**
* 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>---
/**
* 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>---
/**
* 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.
---
/**
* 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>npm run build
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.
/* 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 :
// 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 :
/** 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 :
/** 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.
npm run build && npm run check« 3 page(s) vérifiée(s) », et non 1. Puis, le serveur relancé :
# 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 :
# les anciennes adresses doivent renvoyer un 301, pas un 404
curl -sI https://cafe-bergamote.fr/carte.html | head -1
/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.
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
| Cas | Pourquoi | Que 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.csssont 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 buildetnpm run checkpassent, 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.
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.