Chapitre 05Tutoriels
Atelier : construire un site, pas à pas
Douze étapes, chacune avec le fichier à créer, le code à y coller et ce que vous devez voir à l'écran avant de passer à la suivante. À la fin : trois pages, deux langues, éditables par le client.
Comment lire cet atelier
Chaque bloc de code porte, dans son bandeau, le chemin exact du fichier et ce qu'il faut en faire. Trois consignes seulement :
| Ce que dit le bandeau | Ce que vous faites |
|---|---|
| créer ce fichier | Le fichier n'existe pas encore. Créez-le, collez le bloc entier. |
| remplacer tout le fichier | Le fichier existe. Effacez tout son contenu, collez le bloc à la place. |
| modifier une ligne | Ne touchez qu'aux lignes signalées dans le bloc. |
Les encadrés « Vérifiez » disent ce que vous devez voir avant de continuer. Si ce n'est pas ce que vous voyez, ne passez pas à la suite : l'erreur sera bien plus dure à trouver trois étapes plus loin.
À la fin, vous avez le site d'un ébéniste — Atelier Loriot, trois pages en français et en anglais. Comptez une heure. Tout ce qui suit a été construit et vérifié dans cet ordre exact.
Étape 1 — Créer le projet
Dans un terminal, à l'endroit où vous rangez vos projets :
npm create inline@latest atelier-loriot -- --nom "Atelier Loriot" \
--courriel bonjour@atelier-loriot.fr --langue fr
cd atelier-loriot
npm installLa commande se termine en affichant une clé de site de 32 caractères. Copiez-la tout de suite dans un gestionnaire de mots de passe : elle ne sera plus jamais affichée. Vous en aurez besoin à l'étape 12.
Étape 2 — Lancer le site
Toujours dans le dossier du projet :
npm run build
npm run serveOuvrez http://127.0.0.1:8788/fr/ dans un navigateur.
Une page s'affiche, avec un grand titre « Nous concevons des sites qui convertissent », deux témoignages et une vidéo. C'est le contenu d'exemple livré avec le projet. On va tout remplacer.
Le serveur reste en marche pendant tout l'atelier. À chaque modification, relancez
npm run build dans un second terminal, puis rechargez la page.
Étape 3 — La charte
Les couleurs et les tailles du site vivent dans un seul fichier, et ce ne sont que des variables.
/**
* La charte du site : couleurs, échelle de tailles, graisses.
*/
:root {
/* Une couleur par valeur de l'enum « color » du schéma */
--color-primary: #2b211a;
--color-secondary: #4a3d33;
--color-muted: #8a7a6c;
--color-accent: #a4622a;
--color-inverse: #fffdf9;
/* Les fonds et les filets */
--color-surface: #fffdf9;
--color-surface-alt: #f6f0e7;
--color-border: #e3d8c9;
/* Une taille par valeur de l'enum « size » */
--size-xs: 0.75rem;
--size-sm: 0.875rem;
--size-base: 1.0625rem;
--size-lg: 1.3rem;
--size-xl: 1.6rem;
--size-2xl: 2.1rem;
--size-3xl: 3rem;
/* Une graisse par valeur de l'enum « weight » */
--weight-thin: 100;
--weight-light: 300;
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
--font-body: 'Iowan Old Style', Georgia, serif;
--line-height: 1.65;
/* Largeur de lecture et marge latérale */
--measure: 46rem;
--gutter: 6vw;
}Relancez npm run build, rechargez la page.
Le fond est passé au crème, le texte au brun foncé, la police à une serif. La page reste mal disposée : c'est normal, la mise en page arrive à l'étape suivante.
Une variable manquante ne provoque pas d'erreur : le navigateur ignore la propriété et hérite d'autre chose. Le site s'affiche, en mauvaise police ou en mauvaise couleur, sans qu'aucun message ne le signale. Déclarez les huit couleurs, les sept tailles et les six graisses, même celles que vous n'utilisez pas.
Étape 4 — La mise en page
La charte dit les couleurs ; ce second fichier dit où les choses se placent.
/**
* La mise en page du site. Aucune couleur en dur ici : tout passe par les
* variables de la charte.
*/
/* --- Les deux barres du haut, posées par Base.astro --- */
.site-langs {
display: flex;
gap: 0.75rem;
padding: 0.75rem var(--gutter) 0;
font-size: var(--size-xs);
}
.site-langs a { color: var(--color-muted); text-decoration: none; }
.site-langs a[aria-current='true'] {
color: var(--color-primary);
font-weight: var(--weight-semibold);
}
.site-nav {
display: flex;
gap: 1.5rem;
padding: 0.75rem var(--gutter) 1rem;
border-bottom: 1px solid var(--color-border);
}
.site-nav a {
color: var(--color-secondary);
text-decoration: none;
font-size: var(--size-sm);
}
.site-nav a:hover { color: var(--color-accent); }
/* --- Le corps de page, posé par Page.astro --- */
.page {
max-width: var(--measure);
margin: 0 auto;
padding: 0 var(--gutter) 5rem;
}
.page-head { padding: 4rem 0 2.5rem; }
.page-head h1 { margin: 0; }
.chapo { margin: 0.5rem 0 0; }
.page section { margin: 3.5rem 0; }
/* --- Les images et les vidéos --- */
img, iframe { max-width: 100%; height: auto; }
figure { margin: 0; }
figcaption {
margin-top: 0.5rem;
font-size: var(--size-sm);
color: var(--color-muted);
}
/* --- La liste des étapes, sur l'accueil --- */
.etapes {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
}
.etapes > article {
padding: 1.25rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-alt);
}
.etapes h3 { margin: 0 0 0.4rem; }
.etapes p { margin: 0; }
/* --- La liste des réalisations --- */
.pieces {
display: grid;
gap: 2.5rem;
grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
}
.pieces > article { margin: 0; }
.pieces img { width: 100%; border-radius: 6px; }
.pieces h2 { margin: 0.75rem 0 0; }
.piece-bois { margin: 0.2rem 0 0.6rem; }
/* --- Le pied de page, posé par Base.astro --- */
.site-footer {
border-top: 1px solid var(--color-border);
padding: 2rem var(--gutter) 3rem;
color: var(--color-muted);
font-size: var(--size-sm);
}
.site-footer p { margin: 0.2rem 0; }Ce fichier ne sert à rien tant que personne ne le charge. Ouvrez la coquille du document et ajoutez une ligne, la troisième :
---
import 'inline-core/styles/tokens.css';
import '../styles/theme.css';
import '../styles/site.css'; // ← ajoutez cette ligne
import { site } from '../content/site';
Après npm run build, la page est centrée, avec un filet sous la navigation et
un pied de page détaché. Le contenu, lui, est toujours celui de l'exemple.
Étape 5 — Les photos
Il faut trois images. Prenez n'importe quelles photos et déposez-les dans
src/media/ sous exactement ces trois noms :
src/media/
atelier-etabli.webp
bibliotheque-noyer.webp
table-chene.webp
library.ts ← déjà là, créé par l'intégration, ne pas y toucher
Ce sont ces noms exacts que les fichiers de contenu vont citer. Minuscules, sans accent ni
espace. Si vos photos sont en JPEG, remplacez .webp par .jpg
partout dans les fichiers JSON des étapes suivantes.
src/media/ et pas public/
Dans src/media/, Astro produit au build de l'AVIF, du WebP et plusieurs
largeurs, et écrit les dimensions dans la balise. Dans public/, le fichier est
servi tel quel — et la page saute au chargement.
Étape 6 — L'accueil
Six fichiers, et ils vont ensemble : le site ne se reconstruira qu'à la fin de l'étape. C'est normal, ne vous arrêtez pas en route.
6.1 — Supprimer le composant d'exemple
rm src/components/Testimonial.astro6.2 — Le contenu de la page
C'est ici que vit le texte, et c'est le seul fichier que le client modifiera.
{
"meta": {
"title": "Atelier Loriot — ébéniste à Nantes",
"description": "Meubles sur mesure en bois massif, dessinés et fabriqués à Nantes depuis 1998."
},
"blocks": {
"page": {
"titre": {
"type": "text",
"value": "Le bois massif, dessiné pour durer",
"style": { "size": "3xl", "weight": "bold", "italic": false, "align": "left", "color": "primary" }
},
"chapo": {
"type": "text",
"value": "Ébénisterie sur mesure à Nantes depuis 1998.",
"style": { "size": "lg", "weight": "regular", "italic": false, "align": "left", "color": "muted" }
}
},
"metier": {
"titre": {
"type": "text",
"value": "Notre façon de travailler",
"style": { "size": "2xl", "weight": "semibold", "italic": false, "align": "left", "color": "primary" }
},
"corps": {
"type": "richtext",
"value": "Chaque pièce part d'un <strong>relevé sur place</strong> et d'un dessin coté. Nous travaillons le chêne, le noyer et le frêne, tous issus de forêts françaises."
},
"photo": {
"type": "media",
"kind": "image",
"src": "atelier-etabli.webp",
"alt": "L'établi principal de l'atelier, outils à main alignés au mur",
"width": 1600,
"height": 1067
}
},
"film": {
"video": {
"type": "media",
"kind": "video",
"provider": "youtube",
"videoId": "aqz-KE-bpKQ",
"title": "Trois jours de fabrication, en deux minutes"
}
}
},
"collections": {
"etapes": [
{
"id": "e-001",
"titre": {
"type": "text",
"value": "Le relevé",
"style": { "size": "lg", "weight": "semibold", "italic": false, "align": "left", "color": "primary" }
},
"texte": {
"type": "richtext",
"value": "Nous venons mesurer, photographier, comprendre l'usage."
}
},
{
"id": "e-002",
"titre": {
"type": "text",
"value": "Le dessin",
"style": { "size": "lg", "weight": "semibold", "italic": false, "align": "left", "color": "primary" }
},
"texte": {
"type": "richtext",
"value": "Un plan coté, une essence, un devis. Rien ne commence avant votre accord."
}
},
{
"id": "e-003",
"titre": {
"type": "text",
"value": "L'atelier",
"style": { "size": "lg", "weight": "semibold", "italic": false, "align": "left", "color": "primary" }
},
"texte": {
"type": "richtext",
"value": "Débit, assemblage, finition à l'huile dure. Puis la pose chez vous."
}
}
]
}
}Trois types de champs y apparaissent, et il n'y en a pas d'autres :
| Type | Ce que le client peut faire | À utiliser pour |
|---|---|---|
text |
Changer le texte et son style : taille, graisse, italique, alignement, couleur. | Titres, accroches, libellés. |
richtext |
Mettre en gras, en italique, poser un lien, faire une liste. Pas de style. | Paragraphes. |
media |
Remplacer le fichier et sa description, ou coller un lien YouTube. | Images et vidéos. |
Les valeurs admises dans style, et pas une de plus :
| Clé | Valeurs |
|---|---|
size | xs, sm, base, lg, xl, 2xl, 3xl |
weight | thin, light, regular, medium, semibold, bold |
italic | true, false |
align | left, center, right |
color | primary, secondary, muted, accent, inverse |
6.3 — Le composant d'une étape
La liste « etapes » a besoin d'un composant qui sache afficher un item.
---
/**
* Un item de la liste « etapes ».
*
* Ce composant est rendu deux fois : une fois par étape existante, une fois à
* vide dans le modèle que l'overlay clone pour en ajouter une.
*/
import type { CollectionItem } from 'inline-core/schema';
import Editable from 'inline-core/components/Editable.astro';
interface Props {
item: CollectionItem;
/** Chemin de l'item, ex. « collections.etapes.e-001 ». */
path: string;
untranslated?: Set<string>;
}
const { item, path, untranslated } = Astro.props;
---
<Editable path={`${path}.titre`} field={item.titre as any} as="h3" untranslated={untranslated} />
<Editable path={`${path}.texte`} field={item.texte as any} as="p" untranslated={untranslated} />6.4 — Le corps de la page
Créez d'abord le dossier src/vues/. Il contiendra un fichier par page : ce qui
est propre à l'accueil, propre aux réalisations, propre au contact.
---
/**
* Le corps de la page d'accueil.
*
* Le titre et le chapô ne sont pas ici : ils sont posés par la route, dans
* l'en-tête du gabarit.
*/
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 Etape from '../components/Etape.astro';
interface Props {
data: Page;
missing: Set<string>;
}
const { data, missing } = Astro.props;
---
<section>
<Editable data={data} path="blocks.metier.titre" as="h2" untranslated={missing} />
<Editable data={data} path="blocks.metier.corps" as="p" untranslated={missing} />
<Media
data={data}
path="blocks.metier.photo"
widths={[480, 800, 1200, 1600]}
sizes="(max-width: 48rem) 100vw, 46rem"
/>
</section>
<section>
<Collection
data={data}
name="etapes"
item={Etape}
class="etapes"
untranslated={missing}
blank={{
titre: {
type: 'text',
value: 'Une étape de plus',
style: { size: 'lg', weight: 'semibold', italic: false, align: 'left', color: 'primary' },
},
texte: { type: 'richtext', value: 'Décrivez-la en une phrase.' },
}}
/>
</section>
<section>
<Media data={data} path="blocks.film.video" />
</section>blank
C'est l'étape vierge que l'overlay clone quand le client clique sur « ajouter ». Elle est rendue par le même composant que les autres : il n'y a donc qu'un seul rendu à tenir à jour, pas deux.
6.5 — Le gabarit de page
---
/**
* Le gabarit d'une page.
*
* Il ne lit jamais le contenu : il reçoit des chaînes déjà résolues et deux
* emplacements à remplir. C'est la route qui sait ce qu'elle y met.
*/
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;
}
const props = Astro.props;
---
<Base {...props}>
<main class="page">
<header class="page-head">
<slot name="entete" />
</header>
<slot />
</main>
</Base>6.6 — La route
Un seul fichier produit toutes les adresses du site.
---
/**
* La route unique : une URL par page et par langue, toutes construites au build.
*
* Elle fait trois choses : résoudre le contenu, choisir la vue, poser l'en-tête
* commun.
*/
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 { 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]));
// La liste des pages vient de la langue de référence : c'est elle qui fait foi.
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}`);
// Un champ absent de la traduction est repris de la référence, et signalé.
const { data, untranslated } = mergeWithDefault<Contenu>(
reference.data as Contenu,
translation?.data as Contenu | undefined,
);
return {
// « home » est la racine de sa langue : pas de segment de plus.
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. Le choix est du code, pas du contenu. */
const VUES: Record<string, any> = { home: Accueil };
const Vue = VUES[page];
---
<Page
title={data.meta.title}
description={data.meta.description}
contentFile={`src/content/pages/${locale}/${page}.json`}
locale={locale}
pageName={page}
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="chapo" untranslated={missing} />
</Fragment>
<Vue data={data} missing={missing} />
</Page>Relancez npm run build.
Le build affiche « generating optimized images » et sort quatre variantes de votre photo. Sur http://127.0.0.1:8788/fr/ : le titre « Le bois massif, dessiné pour durer », un paragraphe avec « relevé sur place » en gras, votre photo, trois cartes d'étapes côte à côte, et une vidéo.
« Le chemin blocks.… n'existe pas » : une clé du JSON ne correspond pas à celle citée dans la vue — comparez les deux. « Le fichier … est absent de src/media » : le nom de votre photo ne correspond pas à celui écrit dans le JSON.
Étape 7 — La page Réalisations
Quatre fichiers, dont trois nouveaux. C'est la page que le client alimentera seul.
7.1 — Le contenu
{
"meta": {
"title": "Réalisations — Atelier Loriot",
"description": "Bibliothèques, tables, escaliers : quelques pièces sorties de l'atelier ces dernières années."
},
"blocks": {
"page": {
"titre": {
"type": "text",
"value": "Réalisations",
"style": { "size": "3xl", "weight": "semibold", "italic": false, "align": "left", "color": "primary" }
},
"chapo": {
"type": "text",
"value": "Quelques pièces sorties de l'atelier ces dernières années.",
"style": { "size": "lg", "weight": "regular", "italic": false, "align": "left", "color": "muted" }
}
}
},
"collections": {
"pieces": [
{
"id": "p-001",
"nom": {
"type": "text",
"value": "Bibliothèque murale, Nantes",
"style": { "size": "xl", "weight": "semibold", "italic": false, "align": "left", "color": "primary" }
},
"bois": {
"type": "text",
"value": "Noyer massif — 2024",
"style": { "size": "sm", "weight": "medium", "italic": false, "align": "left", "color": "accent" }
},
"texte": {
"type": "richtext",
"value": "Quatre mètres de long, montée sur place en deux jours."
},
"photo": {
"type": "media",
"kind": "image",
"src": "bibliotheque-noyer.webp",
"alt": "Bibliothèque murale en noyer occupant tout un mur de séjour",
"width": 1600,
"height": 1067
}
},
{
"id": "p-002",
"nom": {
"type": "text",
"value": "Table de ferme, Vertou",
"style": { "size": "xl", "weight": "semibold", "italic": false, "align": "left", "color": "primary" }
},
"bois": {
"type": "text",
"value": "Chêne massif — 2023",
"style": { "size": "sm", "weight": "medium", "italic": false, "align": "left", "color": "accent" }
},
"texte": {
"type": "richtext",
"value": "Plateau d'un seul tenant, piètement chevillé, finition à l'huile."
},
"photo": {
"type": "media",
"kind": "image",
"src": "table-chene.webp",
"alt": "Table de ferme en chêne massif, plateau d'un seul tenant",
"width": 1600,
"height": 1067
}
}
]
}
}
p-001 désigne cette pièce-là, pour toujours. C'est ce qui relie la page au
contenu : renuméroter la liste ferait perdre les modifications du client sur chaque item
déplacé. Format imposé : une lettre, un tiret, au moins trois chiffres.
7.2 — Le composant d'une pièce
Celui-ci porte une photo en plus des textes.
---
/**
* Un item de la liste « pieces » : trois textes et une photo.
*/
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;
/** Chemin de l'item, ex. « collections.pieces.p-001 ». */
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. Deux lignes, et
* la photo d'un item se change depuis l'overlay comme n'importe quelle autre.
*/
const [, liste, id] = path.split('.');
const morceau = { collections: { [liste]: { [id]: item } } } as any;
---
<figure>
<Media
data={morceau}
path={`${path}.photo`}
widths={[400, 800, 1200]}
sizes="(max-width: 40rem) 100vw, 21rem"
/>
<figcaption>
<Editable path={`${path}.nom`} field={item.nom as any} as="h2" untranslated={untranslated} />
<Editable
path={`${path}.bois`}
field={item.bois as any}
as="p"
class="piece-bois"
untranslated={untranslated}
/>
<Editable path={`${path}.texte`} field={item.texte as any} as="p" untranslated={untranslated} />
</figcaption>
</figure>7.3 — Le corps de la page
---
/**
* Le corps de la page « Réalisations » : une seule liste, que le client
* allonge lui-même.
*/
import type { Page } from 'inline-core/schema';
import Collection from 'inline-core/components/Collection.astro';
import Piece from '../components/Piece.astro';
interface Props {
data: Page;
missing: Set<string>;
}
const { data, missing } = Astro.props;
---
<section>
<Collection
data={data}
name="pieces"
item={Piece}
class="pieces"
untranslated={missing}
blank={{
nom: {
type: 'text',
value: 'Nouvelle pièce',
style: { size: 'xl', weight: 'semibold', italic: false, align: 'left', color: 'primary' },
},
bois: {
type: 'text',
value: 'Essence — année',
style: { size: 'sm', weight: 'medium', italic: false, align: 'left', color: 'accent' },
},
texte: { type: 'richtext', value: 'Décrivez la pièce en une phrase.' },
photo: {
type: 'media',
kind: 'image',
src: 'table-chene.webp',
alt: 'Photo à remplacer',
width: 1600,
height: 1067,
},
}}
/>
</section>7.4 — Déclarer la vue dans la route
Deux lignes à changer dans le fichier de l'étape 6.6 :
// 1. avec les autres imports, en haut du fichier
import Realisations from '../../vues/Realisations.astro';
// 2. la ligne qui déclare les vues
const VUES: Record<string, any> = { home: Accueil, realisations: Realisations };
Après npm run build, l'adresse
http://127.0.0.1:8788/fr/realisations/ existe et affiche deux pièces avec
leur photo. Vous n'avez déclaré aucune route : le nom du fichier JSON a suffi.
Étape 8 — La page Contact
Trois fichiers, dont deux nouveaux. Même mécanique, en plus court.
{
"meta": {
"title": "Nous trouver — Atelier Loriot",
"description": "Atelier ouvert du mardi au samedi, 12 rue des Ébénistes à Nantes."
},
"blocks": {
"page": {
"titre": {
"type": "text",
"value": "Nous trouver",
"style": { "size": "3xl", "weight": "semibold", "italic": false, "align": "left", "color": "primary" }
},
"chapo": {
"type": "text",
"value": "L'atelier se visite sur rendez-vous.",
"style": { "size": "lg", "weight": "regular", "italic": false, "align": "left", "color": "muted" }
}
},
"infos": {
"horaires": {
"type": "richtext",
"value": "<strong>Du mardi au samedi</strong>, de 9 h à 18 h.<br>Fermé les jours fériés."
},
"adresse": {
"type": "richtext",
"value": "12 rue des Ébénistes, 44000 Nantes.<br>Tramway ligne 1, arrêt Bouffay."
}
}
}
}---
/**
* Le corps de la page « Nous trouver ». Deux paragraphes, pas de formulaire.
*/
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>// 1. avec les autres imports
import Contact from '../../vues/Contact.astro';
// 2. la ligne des vues, à nouveau
const VUES: Record<string, any> = { home: Accueil, realisations: Realisations, contact: Contact };http://127.0.0.1:8788/fr/contact/ affiche les horaires et l'adresse, avec « Du mardi au samedi » en gras et un retour à la ligne au milieu.
Un formulaire demande un serveur qui reçoit, un anti-robot et une file de courriels. Rien de
tout ça n'existe ici. Un lien mailto: dans la navigation, ou un service tiers
si le volume le justifie.
Étape 9 — La navigation
Les trois pages existent mais aucun lien ne les relie. Les liens sont de la structure : ils vivent dans un fichier que le client ne touche pas.
{
"name": "Atelier Loriot",
"locale": "fr",
"navigation": [
{ "label": "Accueil", "href": "/fr/" },
{ "label": "Réalisations", "href": "/fr/realisations/" },
{ "label": "Nous trouver", "href": "/fr/contact/" }
],
"contact": {
"email": "bonjour@atelier-loriot.fr",
"phone": "02 40 00 00 00",
"address": "12 rue des Ébénistes, 44000 Nantes"
},
"footer": { "legal": "© Atelier Loriot — SIRET 000 000 000 00000" }
}Les trois liens apparaissent en haut de chaque page et fonctionnent. L'adresse et le téléphone apparaissent en pied de page.
Étape 10 — La deuxième langue
Quatre fichiers de configuration, puis le contenu.
10.1 — Déclarer la langue à Astro
// 1. dans l'intégration inline
inline({
locales: ['fr', 'en'], // ← ajoutez 'en'
support: { email: 'bonjour@atelier-loriot.fr' },
}),
// 2. dans le bloc i18n, plus bas dans le même fichier
i18n: {
locales: ['fr', 'en'], // ← ajoutez 'en'
defaultLocale: 'fr',
routing: { prefixDefaultLocale: true },
},10.2 — Déclarer la langue au site
export const LOCALES = ['fr', 'en'] as const; // ← ajoutez 'en'
export const LOCALE_LABELS: Record<Locale, string> = {
fr: 'Français',
en: 'English', // ← ajoutez cette ligne
};10.3 — Les deux contrôles
Deux scripts du projet sont réglés d'usine sur une seule langue et une seule page. Laissés tels quels, ils annoncent « tout va bien » sans rien vérifier. Chacun demande une correction, et ce ne sont pas les mêmes.
Premier fichier : scripts/check-locales.mjs. Une seule ligne à
changer, vers la vingt-cinquième. Les deux lignes qui l'entourent ne servent qu'à la
retrouver :
/** Doit rester d'accord avec src/lib/locales.ts. */
const LOCALES = ['fr', 'en']; // ← ajoutez 'en'
const DEFAULT_LOCALE = 'fr'; // ne bouge pas
Second fichier : scripts/check-html.mjs. Celui-là demande 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', 'en'];
/**
* 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`,
};
}),
);10.4 — Le contenu anglais
mkdir -p src/content/pages/en
cp src/content/pages/fr/home.json src/content/pages/en/home.json
cp src/content/pages/fr/realisations.json src/content/pages/en/realisations.json
cp src/content/pages/fr/contact.json src/content/pages/en/contact.jsonPuis traduisez les valeurs. Voici le plus court des trois, fait, pour montrer ce qui change et ce qui ne change pas :
{
"meta": {
"title": "Find us — Atelier Loriot",
"description": "Workshop open Tuesday to Saturday, 12 rue des Ébénistes in Nantes."
},
"blocks": {
"page": {
"titre": {
"type": "text",
"value": "Find us",
"style": { "size": "3xl", "weight": "semibold", "italic": false, "align": "left", "color": "primary" }
},
"chapo": {
"type": "text",
"value": "The workshop can be visited by appointment.",
"style": { "size": "lg", "weight": "regular", "italic": false, "align": "left", "color": "muted" }
}
},
"infos": {
"horaires": {
"type": "richtext",
"value": "<strong>Tuesday to Saturday</strong>, 9 am to 6 pm.<br>Closed on public holidays."
},
"adresse": {
"type": "richtext",
"value": "12 rue des Ébénistes, 44000 Nantes.<br>Tram line 1, Bouffay stop."
}
}
}
}
titre, chapo, horaires, adresse restent en
français : ce sont des noms de clés, pas du texte affiché. Traduisez les deux autres fichiers
sur le même modèle.
Après npm run build, le terminal annonce 8 pages au lieu de 5.
http://127.0.0.1:8788/en/realisations/ s'affiche en anglais, et le
sélecteur de langue en haut de page bascule d'une version à l'autre.
Étape 11 — Les contrôles
npm run checkQuatre lignes, dont « 6 page(s) vérifiée(s) » et « 3 page(s) comparée(s) sur 2 langues ». Si vous lisez « 1 page vérifiée » ou « 0 page comparée », l'étape 10.3 n'a pas été faite.
Faites l'essai suivant, il vaut une longue explication. Ouvrez
src/content/pages/en/contact.json, supprimez tout le bloc
"adresse", et lancez :
node scripts/check-locales.mjsParité des locales : échec
✗ en/contact.json : « blocks.infos.adresse » n'est pas traduit.Remettez le bloc. C'est le défaut le plus discret d'un site bilingue : sans ce contrôle, la page anglaise afficherait l'adresse en français pendant des mois sans que personne ne le remarque.
Étape 12 — Éditer comme le client
Sans dépôt distant, sans hébergeur, sans compte nulle part. Il faut deux terminaux.
-
Terminal 1 — le faux dépôt Git
bash bash npm run mock:gitIl imite les deux routes de l'API GitHub réellement utilisées et écrit directement dans vos fichiers. Aide au développement uniquement.
-
Terminal 2 — le site
bash bash npm run build npm run serve -
Le navigateur — la clé
Ouvrez http://127.0.0.1:8788/admin et saisissez la clé de l'étape 1. Vous devez être redirigé vers le site.
-
Le navigateur — l'édition
Revenez sur /fr/. Les textes s'encadrent au survol. Cliquez sur le grand titre, modifiez-le, puis cliquez sur Publier.
Le terminal 1 affiche une écriture, et src/content/pages/fr/home.json contient
votre nouveau texte. C'est tout le cycle : le client édite sa page, un fichier change dans
le projet. Relancez npm run build pour le voir figé dans la page.
Vérifiez que curl -i http://127.0.0.1:8788/api/auth répond
405 et non 404. 405 signifie que la route existe et refuse la méthode —
c'est ce qu'on veut ici. 404 signifie que le serveur ne sert pas les fonctions.
Le site terminé
components/
Etape.astro un item de la liste « etapes »
Piece.astro un item de la liste « pieces », photo comprise
content/
site.json navigation, contact, mentions
pages/
fr/ home.json realisations.json contact.json
en/ home.json realisations.json contact.json
layouts/
Base.astro la coquille du document (livrée)
Page.astro le gabarit de page (étape 6.5)
lib/
api.ts locales.ts
media/
atelier-etabli.webp bibliotheque-noyer.webp table-chene.webp library.ts
pages/
[lang]/[...slug].astro la route unique
styles/
theme.css la charte
site.css la mise en page
vues/
Accueil.astro Realisations.astro Contact.astronpm run buildannonce 8 pages.npm run checkannonce 6 pages vérifiées et 3 comparées sur 2 langues.- Les six adresses répondent, en français comme en anglais.
curl -i http://127.0.0.1:8788/api/authrépond 405.- La clé du site est dans un gestionnaire de mots de passe.
.envet.dev.varsne sont pas versionnés.
Pour ajouter une quatrième page
Le geste tient en trois fichiers, et vous l'avez déjà fait deux fois :
- écrire le contenu dans
src/content/pages/fr/, puis sa traduction ; - créer le corps de la page dans
src/vues/; - ajouter l'import et l'entrée dans
VUES, dans la route.
Puis le lien dans site.json, si la page doit être atteignable depuis la
navigation.
Reprendre un site qui existe déjà plutôt que d'en créer un neuf, c'est l'atelier suivant : reprendre un site HTML existant.