FR EN

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.

21 min de lecture29 sectionsChapitre 5 / 22

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 bandeauCe 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 :

terminal bash
npm create inline@latest atelier-loriot -- --nom "Atelier Loriot" \
  --courriel bonjour@atelier-loriot.fr --langue fr

cd atelier-loriot
npm install
Vérifiez

La 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 :

terminal bash
npm run build
npm run serve

Ouvrez http://127.0.0.1:8788/fr/ dans un navigateur.

Vérifiez

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.

Gardez ce terminal ouvert

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.

src/styles/theme.css — remplacer tout le fichier css
/**
 * 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.

Vérifiez

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.

Ne supprimez aucune de ces variables

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.

src/styles/site.css — créer ce fichier css
/**
 * 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 :

src/layouts/Base.astro — modifier une ligne astro
---
import 'inline-core/styles/tokens.css';
import '../styles/theme.css';
import '../styles/site.css';          // ← ajoutez cette ligne
import { site } from '../content/site';
Vérifiez

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/ — y déposer trois fichiers text
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
Les noms comptent

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.

Pourquoi 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

terminal bash
rm src/components/Testimonial.astro

6.2 — Le contenu de la page

C'est ici que vit le texte, et c'est le seul fichier que le client modifiera.

src/content/pages/fr/home.json — remplacer tout le fichier json
{
  "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 :

TypeCe 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
sizexs, sm, base, lg, xl, 2xl, 3xl
weightthin, light, regular, medium, semibold, bold
italictrue, false
alignleft, center, right
colorprimary, secondary, muted, accent, inverse

6.3 — Le composant d'une étape

La liste « etapes » a besoin d'un composant qui sache afficher un item.

src/components/Etape.astro — créer ce fichier astro
---
/**
 * 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.

src/vues/Accueil.astro — créer ce fichier astro
---
/**
 * 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>
À quoi sert 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

src/layouts/Page.astro — créer ce fichier astro
---
/**
 * 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.

src/pages/[lang]/[...slug].astro — remplacer tout le fichier astro
---
/**
 * 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.

Vérifiez

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.

Si le build échoue

« 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

src/content/pages/fr/realisations.json — créer ce fichier json
{
  "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
        }
      }
    ]
  }
}
Les identifiants ne se renumérotent jamais

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.

src/components/Piece.astro — créer ce fichier astro
---
/**
 * 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

src/vues/Realisations.astro — créer ce fichier astro
---
/**
 * 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 :

src/pages/[lang]/[...slug].astro — modifier deux lignes astro
// 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 };
Vérifiez

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.

src/content/pages/fr/contact.json — créer ce fichier json
{
  "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."
      }
    }
  }
}
src/vues/Contact.astro — créer ce fichier astro
---
/**
 * 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>
src/pages/[lang]/[...slug].astro — modifier deux lignes astro
// 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 };
Vérifiez

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.

Pas de formulaire

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.

src/content/site.json — remplacer tout le fichier json
{
  "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" }
}
Vérifiez

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

astro.config.mjs — modifier deux lignes js
// 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

src/lib/locales.ts — modifier deux lignes ts
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 :

scripts/check-locales.mjs — modifier une ligne js
/** 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 :

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', '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

terminal bash
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.json

Puis traduisez les valeurs. Voici le plus court des trois, fait, pour montrer ce qui change et ce qui ne change pas :

src/content/pages/en/contact.json — remplacer tout le fichier json
{
  "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.

Vérifiez

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

terminal bash
npm run check
Vérifiez

Quatre 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 :

terminal bash
node scripts/check-locales.mjs
ce qui doit s'afficher text
Parité 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.

  1. Terminal 1 — le faux dépôt Git

    bash bash
    npm run mock:git

    Il imite les deux routes de l'API GitHub réellement utilisées et écrit directement dans vos fichiers. Aide au développement uniquement.

  2. Terminal 2 — le site

    bash bash
    npm run build
    npm run serve
  3. 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.

  4. Le navigateur — l'édition

    Revenez sur /fr/. Les textes s'encadrent au survol. Cliquez sur le grand titre, modifiez-le, puis cliquez sur Publier.

Vérifiez

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.

Si la clé est refusée

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é

atelier-loriot/src/ text
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.astro
  • npm run build annonce 8 pages.
  • npm run check annonce 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/auth répond 405.
  • La clé du site est dans un gestionnaire de mots de passe.
  • .env et .dev.vars ne 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 :

  1. écrire le contenu dans src/content/pages/fr/, puis sa traduction ;
  2. créer le corps de la page dans src/vues/ ;
  3. 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.

La suite

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.