Chapitre 08Construire
Ajouter une page, un bloc, une liste
Du fichier JSON au composant posé dans la page. Les trois composants d'édition, leurs propriétés, et la règle d'hydratation qu'il ne faut jamais enfreindre.
Le gabarit de page
Un seul fichier de route construit toutes les pages, dans toutes les langues :
src/pages/[lang]/[...slug].astro. C'est getStaticPaths qui décide
des URL à produire.
---
import { getCollection } from 'astro:content';
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}`);
const { data, untranslated } = mergeWithDefault(reference.data, translation?.data);
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 },
};
}),
);
}
---Une page qui n'existe que dans une traduction n'est pas construite : la langue de référence fait la liste. Et un champ absent de la traduction est repris de la référence, puis signalé — voir Multilingue.
Ajouter une page en quatre gestes
-
Écrire le contenu, dans chaque langue
bash bash src/content/pages/fr/services.json src/content/pages/en/services.jsonNom de fichier en minuscules, chiffres et traits d'union. Il devient le segment d'URL :
/fr/services/. -
Décider du rendu
Si la page ressemble aux autres, le gabarit existant suffit. Sinon, associez un composant de mise en page à un nom de page — le routage reste dans un seul fichier :
src/pages/[lang]/[...slug].astro astro --- import Home from '../../components/pages/Home.astro'; import Services from '../../components/pages/Services.astro'; const TEMPLATES = { home: Home, services: Services }; const { locale, page, data, untranslated } = Astro.props; const Template = TEMPLATES[page] ?? Home; const missing = new Set(untranslated); --- <Base title={data.meta.title} description={data.meta.description} …> <Template data={data} untranslated={missing} /> </Base> -
Poser les zones éditables
Un composant par type de champ. Le chemin est le même que dans le JSON.
src/components/pages/Services.astro astro --- import Editable from 'inline-core/components/Editable.astro'; import Media from 'inline-core/components/Media.astro'; const { data, untranslated } = Astro.props; --- <section class="hero"> <Editable data={data} path="blocks.hero.title" as="h1" untranslated={untranslated} /> <Editable data={data} path="blocks.hero.intro" as="p" untranslated={untranslated} /> </section> <Media data={data} path="blocks.hero.visual" widths={[480, 800, 1200]} sizes="(max-width: 48rem) 100vw, 48rem" /> -
Ajouter le lien dans la navigation
src/content/site.json, sectionnavigation. C'est de la structure : le client ne la modifie pas depuis l'overlay.
Puis :
npm run build && npm run checkLes trois composants
Editable — texte et richtext
<Editable data={data} path="blocks.hero.title" as="h1" />
<Editable data={data} path="blocks.pitch.detail" as="p" class="lead" />| Propriété | Type | Rôle |
|---|---|---|
data | Page | Les données de la page. Inutile si field est fourni. |
path | string | Chemin dans le JSON, ex. blocks.hero.title. Obligatoire. |
field | TextField | RichtextField | Champ déjà résolu — sert aux items de liste, dont le chemin contient un identifiant. |
as | string | Balise de rendu. Défaut : p. |
class | string | Classes ajoutées à celles des tokens de style. |
untranslated | Set<string> | Chemins repris de la langue de référence, à signaler. |
Le composant applique les classes de style pour un champ text, et pose le
balisage tel quel pour un richtext. Dans les deux cas, le rendu est purement
statique : la valeur est dans le HTML brut.
Media — image et vidéo
<Media
data={data}
path="blocks.showcase.visual"
widths={[480, 800, 1200, 1600]}
sizes="(max-width: 48rem) 100vw, 48rem"
/>
<Media data={data} path="blocks.showcase.film" />
Pour une image, le composant utilise <Image /> d'astro:assets :
AVIF et WebP, jeu de largeurs, dimensions écrites dans le HTML. Pour une vidéo, il rend une
<figure> avec l'iframe du fournisseur et le titre — le titre, lui, est bien
dans le HTML servi.
src/media
Pas dans public/media. C'est la seule façon pour <Image />
de les traiter au build. Un fichier de public/ serait servi tel quel : sans
AVIF, sans jeu de largeurs, sans dimensions. Voir Images et vidéos.
Collection — les listes
---
import Collection from 'inline-core/components/Collection.astro';
import Testimonial from '../components/Testimonial.astro';
---
<Collection
data={data}
name="testimonials"
item={Testimonial}
untranslated={missing}
blank={{
quote: {
type: 'text',
value: 'Leur retour, en une phrase.',
style: { size: 'lg', weight: 'regular', italic: true, align: 'left', color: 'primary' },
},
author: {
type: 'text',
value: 'Prénom N., fonction',
style: { size: 'sm', weight: 'medium', italic: false, align: 'left', color: 'muted' },
},
}}
/>Le composant d'item reçoit l'item et son chemin :
---
import type { CollectionItem } from 'inline-core/schema';
import Editable from 'inline-core/components/Editable.astro';
const { item, path, untranslated } = Astro.props;
---
<blockquote>
<Editable path={`${path}.quote`} field={item.quote} as="p" class="testimonial-quote" untranslated={untranslated} />
<Editable path={`${path}.author`} field={item.author} as="footer" class="testimonial-author" untranslated={untranslated} />
</blockquote>blank est obligatoire
Collection rend les items existants et un
<template> contenant un item vierge, avec le même composant. Le
bouton « Ajouter » de l'overlay clone ce modèle : il n'y a donc pas de second moteur de
rendu côté navigateur à tenir d'accord. Sans blank, l'overlay ne saurait pas
quel JSON créer.
npm run check échoue si une collection est rendue sans son
<template> : le bouton « Ajouter » ne pourrait rien faire.
Les attributs posés dans le HTML
| Attribut | Porté par | Rôle |
|---|---|---|
data-cms | l'élément du champ | Chemin dans le JSON — le point d'ancrage de l'overlay. |
data-cms-type | idem | text, richtext ou media. |
data-cms-kind | un média | image ou video. |
data-cms-untranslated | un champ | Marque un champ affiché dans la langue de référence. |
data-cms-list | le conteneur de liste | collections.{nom}. |
data-cms-item | un item | Son identifiant. |
data-cms-template | le <template> | Nom de la liste dont c'est le modèle. |
data-cms-blank | idem | Le JSON d'un item vierge. |
data-cms-file | <body> | Fichier de contenu de la page. |
data-cms-locale, data-cms-page | <body> | Langue et nom de page en cours. |
data-cms-locales | <body> | Les langues du site : code, adresse, libellé. |
Ces attributs sont inertes en production : coût nul, aucun effet sur le référencement. Ils ne servent qu'à l'overlay, qui n'est chargé qu'après authentification.
La règle d'hydratation
C'est le piège numéro un du projet, et il ne se voit pas dans la source rendue. Astro accepte
React, Vue et Svelte ; inline aussi, à une condition qui n'est pas celle qu'on
croit.
Légitime
Un composant de framework rendu au build, sans directive client:*, même pour du contenu éditable : il produit du HTML statique, le data-cms se retrouve dans la page, l'overlay travaille dessus.
Une île hydratée sans zone éditable : un carousel, une carte, un filtre. Le carousel doit contenir tous ses slides en dur ; le JavaScript ne fait que les faire défiler.
Interdit
client:only sur du contenu : rien n'est rendu au build, le texte n'est dans aucun HTML servi, donc dans aucun index.
client:load autour d'une zone éditable : le contenu est bien dans le HTML, mais le framework le réaffiche à l'arrivée du JavaScript — et efface les modifications en cours du client. C'est la plus contraignante des deux, et elle est invisible à la lecture de la page.
check-html.mjs applique exactement cette règle : il vérifie que chaque valeur du
JSON est dans le HTML brut, et qu'aucune zone éditable ne se trouve à l'intérieur
d'une île hydratée.
Ce que le build refuse
| Erreur | Message | Cause |
|---|---|---|
| Chemin inexistant | [Editable] Le chemin « … » n'existe pas dans le contenu. | Faute de frappe, ou champ absent du JSON d'une langue. |
| Mauvais type | [Editable] Le chemin « … » est de type « media » | Un média rendu avec Editable au lieu de Media. |
| Image absente | [Media] Le fichier « … » est absent de src/media. | Le JSON référence un fichier qui n'a pas été copié. |
| Identifiant en double | [Collection] Identifiant en double dans « … » | Deux items partagent un id : la réconciliation DOM/JSON casserait. |
| Schéma | rapport Zod, chemin par chemin | Une valeur hors enum, un alt vide, un titre trop long. |
Ces échecs sont volontaires. Un composant qui afficherait « (champ manquant) » plutôt que d'échouer laisserait passer en production une page incomplète que personne ne verrait avant le client.
Après avoir ajouté une page
- Le fichier existe dans toutes les langues déclarées.
npm run buildpasse.npm run checkpasse — contenu dans le HTML brut, parité des langues.- Un seul
<h1>sur la page, hiérarchie de titres sans saut de niveau. - Chaque liste a son
<template>(garanti si vous passez parCollection). - Le lien de navigation est ajouté dans
site.jsonsi la page doit être atteignable. - Les images sont dans
src/media/, en minuscules sans accent.