FR EN

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.

6 min de lecture10 sectionsChapitre 8 / 22

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.

src/pages/[lang]/[...slug].astro (extrait) astro
---
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 },
      };
    }),
  );
}
---
Deux conséquences à connaître

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

  1. Écrire le contenu, dans chaque langue

    bash bash
    src/content/pages/fr/services.json
    src/content/pages/en/services.json

    Nom de fichier en minuscules, chiffres et traits d'union. Il devient le segment d'URL : /fr/services/.

  2. 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>
  3. 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" />
  4. Ajouter le lien dans la navigation

    src/content/site.json, section navigation. C'est de la structure : le client ne la modifie pas depuis l'overlay.

Puis :

bash bash
npm run build && npm run check

Les trois composants

Editable — texte et richtext

astro astro
<Editable data={data} path="blocks.hero.title" as="h1" />
<Editable data={data} path="blocks.pitch.detail" as="p" class="lead" />
PropriétéTypeRôle
dataPageLes données de la page. Inutile si field est fourni.
pathstringChemin dans le JSON, ex. blocks.hero.title. Obligatoire.
fieldTextField | RichtextFieldChamp déjà résolu — sert aux items de liste, dont le chemin contient un identifiant.
asstringBalise de rendu. Défaut : p.
classstringClasses ajoutées à celles des tokens de style.
untranslatedSet<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

astro astro
<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.

Les images vivent dans 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

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

src/components/Testimonial.astro astro
---
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>
Pourquoi 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

AttributPorté parRôle
data-cmsl'élément du champChemin dans le JSON — le point d'ancrage de l'overlay.
data-cms-typeidemtext, richtext ou media.
data-cms-kindun médiaimage ou video.
data-cms-untranslatedun champMarque un champ affiché dans la langue de référence.
data-cms-listle conteneur de listecollections.{nom}.
data-cms-itemun itemSon identifiant.
data-cms-templatele <template>Nom de la liste dont c'est le modèle.
data-cms-blankidemLe 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

ErreurMessageCause
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émarapport Zod, chemin par cheminUne valeur hors enum, un alt vide, un titre trop long.
Ne pas assouplir

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 build passe.
  • npm run check passe — 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 par Collection).
  • Le lien de navigation est ajouté dans site.json si la page doit être atteignable.
  • Les images sont dans src/media/, en minuscules sans accent.