EN FR

Chapter 05Tutorials

Workshop: build a site, step by step

Twelve steps, each with the file to create, the code to paste into it and what you must see on screen before moving on. At the end: three pages, two languages, editable by the client.

20 min read29 sectionsChapter 5 / 22

How to read this workshop

Every code block carries, in its banner, the exact path of the file and what to do with it. Three instructions, no more:

What the banner saysWhat you do
create this file The file does not exist yet. Create it, paste the whole block.
replace the whole file The file exists. Wipe its contents, paste the block instead.
change one line Touch only the lines flagged inside the block.

The “Check” boxes say what you must see before continuing. If that is not what you see, do not move on: the mistake will be far harder to find three steps later.

At the end you have a cabinetmaker's site — Atelier Loriot, three pages in French and English. Allow an hour. Everything below was built and verified in this exact order.

Step 1 — Create the project

In a terminal, wherever you keep your projects:

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

cd atelier-loriot
npm install
Check

The command ends by printing a 32-character site key. Copy it into a password manager right now: it will never be shown again. You will need it at step 12.

Step 2 — Run the site

Still inside the project folder:

terminal bash
npm run build
npm run serve

Open http://127.0.0.1:8788/fr/ in a browser.

Check

A page appears, with a large heading “Nous concevons des sites qui convertissent”, two testimonials and a video. That is the sample content shipped with the project. We are going to replace all of it.

Keep this terminal open

The server stays up for the whole workshop. After each change, run npm run build in a second terminal, then reload the page.

Step 3 — The theme

The site's colours and sizes live in a single file, and they are nothing but variables.

src/styles/theme.css — replace the whole file css
/**
 * The site theme: colours, size scale, weights.
 */

:root {
  /* One colour per value of the schema's « color » enum */
  --color-primary: #2b211a;
  --color-secondary: #4a3d33;
  --color-muted: #8a7a6c;
  --color-accent: #a4622a;
  --color-inverse: #fffdf9;

  /* Backgrounds and rules */
  --color-surface: #fffdf9;
  --color-surface-alt: #f6f0e7;
  --color-border: #e3d8c9;

  /* One size per value of the « size » enum */
  --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;

  /* One weight per value of the « weight » enum */
  --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;

  /* Reading width and side margin */
  --measure: 46rem;
  --gutter: 6vw;
}

Run npm run build again, reload the page.

Check

The background turned cream, the text dark brown, the typeface a serif. The page is still badly laid out: that is expected, layout comes in the next step.

Do not delete any of these variables

A missing variable raises no error: the browser drops the property and inherits something else. The site renders, in the wrong typeface or the wrong colour, with no message at all. Declare the eight colours, the seven sizes and the six weights, including the ones you do not use.

Step 4 — The layout

The theme says the colours; this second file says where things sit.

src/styles/site.css — create this file css
/**
 * The site layout. No hard-coded colour here: everything goes through the
 * theme's variables.
 */

/* --- The two top bars, placed by 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); }

/* --- The page body, placed by 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; }

/* --- Images and videos --- */

img, iframe { max-width: 100%; height: auto; }
figure { margin: 0; }
figcaption {
  margin-top: 0.5rem;
  font-size: var(--size-sm);
  color: var(--color-muted);
}

/* --- The list of steps, on the home page --- */

.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; }

/* --- The list of works --- */

.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; }

/* --- The footer, placed by 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; }

That file does nothing until something loads it. Open the document shell and add one line, the third one:

src/layouts/Base.astro — change one line astro
---
import 'inline-core/styles/tokens.css';
import '../styles/theme.css';
import '../styles/site.css';          // ← add this line
import { site } from '../content/site';
Check

After npm run build, the page is centred, with a rule under the navigation and a detached footer. The content is still the sample one.

Step 5 — The photos

Three images are needed. Take any photos and drop them into src/media/ under exactly these three names:

src/media/ — drop three files here text
src/media/
  atelier-etabli.webp
  bibliotheque-noyer.webp
  table-chene.webp
  library.ts          ← already there, created by the integration, leave it alone
The names matter

These exact names are what the content files will quote. Lowercase, no accents, no spaces. If your photos are JPEG, replace .webp with .jpg everywhere in the JSON files of the following steps.

Why src/media/ and not public/

From src/media/, Astro produces AVIF, WebP and several widths at build time, and writes the dimensions into the tag. From public/, the file is served as is — and the page jumps on load.

Step 6 — The home page

Six files, and they go together: the site will not rebuild until the end of this step. That is expected, do not stop halfway.

6.1 — Delete the sample component

terminal bash
rm src/components/Testimonial.astro

6.2 — The page content

This is where the text lives, and it is the only file the client will ever change.

src/content/pages/fr/home.json — replace the whole file 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."
        }
      }
    ]
  }
}

Three field types appear in it, and there are no others:

TypeWhat the client can doUse it for
text Change the text and its style: size, weight, italic, alignment, colour. Headings, taglines, labels.
richtext Bold, italic, links, lists. No style. Paragraphs.
media Replace the file and its description, or paste a YouTube link. Images and videos.

The values allowed inside style, and not one more:

KeyValues
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 — The component for one step

The « etapes » list needs a component that knows how to render one item.

src/components/Etape.astro — create this file astro
---
/**
 * One item of the « etapes » list.
 *
 * This component is rendered twice: once per existing step, and once empty in
 * the template the overlay clones to add one.
 */
import type { CollectionItem } from 'inline-core/schema';
import Editable from 'inline-core/components/Editable.astro';

interface Props {
  item: CollectionItem;
  /** Item path, e.g. « 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 — The page body

First create the src/vues/ folder. It will hold one file per page: what belongs to the home page, to the work page, to the contact page.

src/vues/Accueil.astro — create this file astro
---
/**
 * The body of the home page.
 *
 * The heading and the standfirst are not here: the route places them, in the
 * layout's header.
 */
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>
What blank is for

It is the empty step the overlay clones when the client clicks “add”. It is rendered by the same component as the others: there is only one rendering to keep up to date, not two.

6.5 — The page layout

src/layouts/Page.astro — create this file astro
---
/**
 * The layout of a page.
 *
 * It never reads the content: it receives already-resolved strings and two
 * slots to fill. The route is what knows what goes in them.
 */
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 — The route

A single file produces every address on the site.

src/pages/[lang]/[...slug].astro — replace the whole file astro
---
/**
 * The single route: one URL per page and per language, all built at build time.
 *
 * It does three things: resolve the content, pick the view, place the shared
 * header.
 */
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]));

  // The list of pages comes from the reference language: that one is authoritative.
  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}`);

      // A field missing from the translation is taken from the reference, and flagged.
      const { data, untranslated } = mergeWithDefault<Contenu>(
        reference.data as Contenu,
        translation?.data as Contenu | undefined,
      );

      return {
        // « home » is the root of its language: no extra segment.
        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) }));

/** One view per page. The choice is code, not content. */
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>

Run npm run build again.

Check

The build prints “generating optimized images” and emits four variants of your photo. On http://127.0.0.1:8788/fr/: the heading “Le bois massif, dessiné pour durer”, a paragraph with “relevé sur place” in bold, your photo, three step cards side by side, and a video.

If the build fails

“Le chemin blocks.… n'existe pas”: a key in the JSON does not match the one quoted in the view — compare the two. “Le fichier … est absent de src/media”: your photo's name does not match the one written in the JSON.

Step 7 — The Work page

Four files, three of them new. This is the page the client will keep alive alone.

7.1 — The content

src/content/pages/fr/realisations.json — create this file 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
        }
      }
    ]
  }
}
Identifiers are never renumbered

p-001 denotes that piece, forever. It is what links the page to the content: renumbering the list would lose the client's edits on every moved item. Required format: one letter, a dash, at least three digits.

7.2 — The component for one piece

This one carries a photo on top of the text.

src/components/Piece.astro — create this file astro
---
/**
 * One item of the « pieces » list: three texts and a 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;
  /** Item path, e.g. « collections.pieces.p-001 ». */
  path: string;
  untranslated?: Set<string>;
}

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

/**
 * « Media » expects the whole page, an item is only a piece of it: so we hand
 * it that piece in the shape it knows how to read. Two lines, and an item's
 * photo can be changed from the overlay like any other.
 */
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 — The page body

src/vues/Realisations.astro — create this file astro
---
/**
 * The body of the « Réalisations » page: a single list, which the client
 * extends on their own.
 */
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 — Declare the view in the route

Two lines to change in the file from step 6.6:

src/pages/[lang]/[...slug].astro — change two lines astro
// 1. with the other imports, at the top of the file
import Realisations from '../../vues/Realisations.astro';

// 2. the line that declares the views
const VUES: Record<string, any> = { home: Accueil, realisations: Realisations };
Check

After npm run build, the address http://127.0.0.1:8788/fr/realisations/ exists and shows two pieces with their photo. You declared no route: the JSON file's name was enough.

Step 8 — The Contact page

Three files, two of them new. Same mechanics, shorter.

src/content/pages/fr/contact.json — create this file 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 — create this file astro
---
/**
 * The body of the « Nous trouver » page. Two paragraphs, no form.
 */
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 — change two lines astro
// 1. with the other imports
import Contact from '../../vues/Contact.astro';

// 2. the views line, again
const VUES: Record<string, any> = { home: Accueil, realisations: Realisations, contact: Contact };
Check

http://127.0.0.1:8788/fr/contact/ shows the opening hours and the address, with “Du mardi au samedi” in bold and a line break in the middle.

No form

A form needs a server that receives, an anti-robot measure and a mail queue. None of that exists here. A mailto: link in the navigation, or a third-party service if the volume warrants it.

Step 9 — Navigation

The three pages exist but nothing links them. Links are structure: they live in a file the client never touches.

src/content/site.json — replace the whole file 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" }
}
Check

The three links appear at the top of every page and work. The address and phone number appear in the footer.

Step 10 — The second language

Four configuration files, then the content.

10.1 — Declare the language to Astro

astro.config.mjs — change two lines js
// 1. in the inline integration
inline({
  locales: ['fr', 'en'],          // ← add 'en'
  support: { email: 'bonjour@atelier-loriot.fr' },
}),

// 2. in the i18n block, further down the same file
i18n: {
  locales: ['fr', 'en'],          // ← add 'en'
  defaultLocale: 'fr',
  routing: { prefixDefaultLocale: true },
},

10.2 — Declare the language to the site

src/lib/locales.ts — change two lines ts
export const LOCALES = ['fr', 'en'] as const;   // ← add 'en'

export const LOCALE_LABELS: Record<Locale, string> = {
  fr: 'Français',
  en: 'English',                                // ← add this line
};

10.3 — The two checks

Two of the project's scripts ship wired to a single language and a single page. Left as they are, they report “all good” while verifying nothing. Each needs a fix, and they are not the same fix.

First file: scripts/check-locales.mjs. One line to change, around the twenty-fifth. The two lines around it are only there to help you find it:

scripts/check-locales.mjs — change one line js
/** Doit rester d'accord avec src/lib/locales.ts. */
const LOCALES = ['fr', 'en'];    // ← add 'en'
const DEFAULT_LOCALE = 'fr';     // unchanged

Second file: scripts/check-html.mjs. That one needs two edits, both within its first twenty-five lines. First the opening import line, which is missing readdirSync:

scripts/check-html.mjs — change one line js
// before
import { readFileSync, existsSync } from 'node:fs';

// after
import { readFileSync, readdirSync, existsSync } from 'node:fs';

Then the list of pages, written by hand and cut down to a single entry. Look for these two lines — it is the only place in the file where const PAGES appears:

scripts/check-html.mjs — the two lines to replace js
/** Une entrée par page construite, toutes langues confondues. */
const PAGES = [{ content: 'src/content/pages/fr/home.json', html: 'dist/fr/index.html' }];

And replace them — those two lines, not one more — with these:

scripts/check-html.mjs — the replacement js
/** Must stay in agreement with src/lib/locales.ts. */
const LOCALES = ['fr', 'en'];

/**
 * One entry per built page, all languages together.
 *
 * The list is derived from the content, not written by hand: a page forgotten
 * from a manual list would be a page nobody checks, and the check would still
 * report « all good ».
 */
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 — The English content

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

Then translate the values. Here is the shortest of the three, done, to show what changes and what does not:

src/content/pages/en/contact.json — replace the whole file 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 stay in French: they are key names, not displayed text. Translate the other two files the same way.

Check

After npm run build, the terminal announces 8 pages instead of 5. http://127.0.0.1:8788/en/realisations/ shows in English, and the language switcher at the top of the page moves between the two versions.

Step 11 — The checks

terminal bash
npm run check
Check

Four lines, including “6 page(s) vérifiée(s)” and “3 page(s) comparée(s) sur 2 langues”. If you read “1 page vérifiée” or “0 page comparée”, step 10.3 was not done.

Try the following, it is worth a long explanation. Open src/content/pages/en/contact.json, delete the whole "adresse" block, and run:

terminal bash
node scripts/check-locales.mjs
what must appear text
Parité des locales : échec

✗ en/contact.json : « blocks.infos.adresse » n'est pas traduit.

Put the block back. This is the quietest defect in a bilingual site: without that check, the English page would show the address in French for months without anyone noticing.

Step 12 — Editing as the client

No remote repository, no host, no account anywhere. Two terminals are needed.

  1. Terminal 1 — the fake Git repository

    bash bash
    npm run mock:git

    It mimics the two GitHub API routes actually used and writes straight into your files. Development aid only.

  2. Terminal 2 — the site

    bash bash
    npm run build
    npm run serve
  3. The browser — the key

    Open http://127.0.0.1:8788/admin and type the key from step 1. You should be redirected to the site.

  4. The browser — editing

    Go back to /fr/. Text is outlined on hover. Click the main heading, change it, then click Publier.

Check

Terminal 1 reports a write, and src/content/pages/fr/home.json holds your new text. That is the whole cycle: the client edits their page, a file changes in the project. Run npm run build to see it frozen into the page.

If the key is refused

Check that curl -i http://127.0.0.1:8788/api/auth answers 405 and not 404. 405 means the route exists and refuses the method — that is what we want here. 404 means the server is not serving the functions.

The finished site

atelier-loriot/src/ text
components/
  Etape.astro                  one item of the « etapes » list
  Piece.astro                  one item of the « pieces » list, photo included
content/
  site.json                    navigation, contact, legal
  pages/
    fr/  home.json  realisations.json  contact.json
    en/  home.json  realisations.json  contact.json
layouts/
  Base.astro                   the document shell (shipped)
  Page.astro                   the page layout (step 6.5)
lib/
  api.ts  locales.ts
media/
  atelier-etabli.webp  bibliotheque-noyer.webp  table-chene.webp  library.ts
pages/
  [lang]/[...slug].astro       the single route
styles/
  theme.css                    the theme
  site.css                     the layout
vues/
  Accueil.astro  Realisations.astro  Contact.astro
  • npm run build announces 8 pages.
  • npm run check announces 6 pages verified and 3 compared across 2 languages.
  • All six addresses answer, in French as in English.
  • curl -i http://127.0.0.1:8788/api/auth answers 405.
  • The site key is in a password manager.
  • .env and .dev.vars are not committed.

To add a fourth page

It takes three files, and you have already done it twice:

  1. write the content in src/content/pages/fr/, then its translation;
  2. create the page body in src/vues/;
  3. add the import and the entry in VUES, in the route.

Then the link in site.json, if the page has to be reachable from the navigation.

Next

Taking over a site that already exists rather than creating a new one is the next workshop: porting an existing HTML site.