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.
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 says | What 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:
npm create inline@latest atelier-loriot -- --nom "Atelier Loriot" \
--courriel bonjour@atelier-loriot.fr --langue fr
cd atelier-loriot
npm installThe 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:
npm run build
npm run serveOpen http://127.0.0.1:8788/fr/ in a browser.
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.
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.
/**
* 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.
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.
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.
/**
* 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:
---
import 'inline-core/styles/tokens.css';
import '../styles/theme.css';
import '../styles/site.css'; // ← add this line
import { site } from '../content/site';
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/
atelier-etabli.webp
bibliotheque-noyer.webp
table-chene.webp
library.ts ← already there, created by the integration, leave it alone
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.
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
rm src/components/Testimonial.astro6.2 — The page content
This is where the text lives, and it is the only file the client will ever change.
{
"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:
| Type | What the client can do | Use 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:
| Key | Values |
|---|---|
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 — The component for one step
The « etapes » list needs a component that knows how to render one item.
---
/**
* 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.
---
/**
* 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>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
---
/**
* 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.
---
/**
* 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.
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.
“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
{
"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 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.
---
/**
* 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
---
/**
* 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:
// 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 };
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.
{
"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."
}
}
}
}---
/**
* 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>// 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 };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.
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.
{
"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" }
}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
// 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
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:
/** 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:
// 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:
/** 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:
/** 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
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.jsonThen translate the values. Here is the shortest of the three, done, to show what changes and what does not:
{
"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.
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
npm run checkFour 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:
node scripts/check-locales.mjsParité 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.
-
Terminal 1 — the fake Git repository
bash bash npm run mock:gitIt mimics the two GitHub API routes actually used and writes straight into your files. Development aid only.
-
Terminal 2 — the site
bash bash npm run build npm run serve -
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.
-
The browser — editing
Go back to /fr/. Text is outlined on hover. Click the main heading, change it, then click Publier.
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.
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
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.astronpm run buildannounces 8 pages.npm run checkannounces 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/authanswers 405.- The site key is in a password manager.
.envand.dev.varsare not committed.
To add a fourth page
It takes three files, and you have already done it twice:
- write the content in
src/content/pages/fr/, then its translation; - create the page body in
src/vues/; - 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.
Taking over a site that already exists rather than creating a new one is the next workshop: porting an existing HTML site.