Internationalisation (i18n)

Sur cette page

EmDash s’intègre au routage i18n natif d’Astro pour fournir une gestion de contenu multilingue. Astro gère le routage des URLs et la détection des locales ; EmDash gère le stockage et la récupération du contenu traduit.

Chaque traduction est une entrée de contenu complète et indépendante avec son propre slug, statut et historique de révisions. La version française d’un article peut être en brouillon tandis que la version anglaise est publiée.

Configuration

Activez i18n en ajoutant un bloc i18n à votre configuration Astro. EmDash lit cette même configuration pour sa liste de locales, la locale par défaut et la chaîne de fallback.

import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";

export default defineConfig({
	i18n: {
		defaultLocale: "en",
		locales: ["en", "fr", "es"],
		fallback: { fr: "en", es: "en" },
	},
	integrations: [
		emdash({
			database: sqlite({ url: "file:./data.db" }),
			storage: local({
				directory: "./uploads",
				baseUrl: "/_emdash/api/media/file",
			}),
		}),
	],
});

Quand i18n n’est pas présent dans la configuration Astro, toutes les fonctionnalités i18n sont désactivées et EmDash se comporte comme un CMS monolingue.

Comment fonctionnent les traductions

EmDash utilise un modèle de ligne par locale. Chaque traduction est sa propre ligne dans la base de données avec son propre ID, slug et statut, liée aux autres traductions via un identifiant translation_group partagé. Une table de posts avec trois traductions ressemble à ceci :

ec_posts:
id       | slug        | locale | translation_group | status
---------|-------------|--------|-------------------|----------
01ABC... | my-post     | en     | 01ABC...          | published
01DEF... | mon-article | fr     | 01ABC...          | draft
01GHI... | mi-entrada  | es     | 01ABC...          | published

Ce design signifie :

  • Slugs par locale/blog/my-post et /fr/blog/mon-article fonctionnent naturellement
  • Publication par locale — publiez la version anglaise tout en gardant le français en brouillon
  • Révisions par locale — chaque traduction a son propre historique de révisions
  • Requêtes mono-locale — les requêtes de liste retournent les entrées d’une seule locale

Interroger le contenu traduit

Entrée unique

Passez locale à getEmDashEntry pour récupérer une traduction spécifique. Quand omis, il utilise par défaut la locale actuelle de la requête (définie par le middleware i18n d’Astro).

---
import { getEmDashEntry } from "emdash";

const { slug } = Astro.params;
const { entry: post, error } = await getEmDashEntry("posts", slug, {
  locale: Astro.currentLocale,
});

if (!post) return Astro.redirect("/404");
---

<article>
  <h1>{post.data.title}</h1>
</article>

Chaîne de fallback

Quand aucun contenu n’existe pour la locale demandée, EmDash suit la chaîne de fallback définie dans votre configuration Astro. Étant donné fallback: { fr: "en" } :

  1. Essayer la locale demandée (fr)
  2. Essayer la locale de fallback (en)
  3. Essayer la locale par défaut

Le fallback ne s’applique qu’aux requêtes d’entrée unique. Les requêtes de liste retournent les entrées uniquement pour la locale demandée.

Les menus sont par locale — le même name (ex. "primary") peut exister en plusieurs locales, tous liés via un translation_group partagé. Les éléments de menu résolvent leurs références de contenu contre la version de la locale active du contenu référencé.

Le composant suivant récupère le menu principal pour la locale active :

---
import { getMenu } from "emdash";

const menu = await getMenu("primary", { locale: Astro.currentLocale });
---

<nav aria-label="Primary">
  <ul>
    {menu?.items.map((item) => (
      <li><a href={item.url}>{item.label}</a></li>
    ))}
  </ul>
</nav>

Créez des traductions d’un menu existant depuis la liste Menus de l’admin — les éléments sont clonés avec reference_id intact (il stocke le translation_group du contenu référencé), donc les liens du nouveau menu pointent automatiquement vers le bon contenu par locale.

Taxonomies (catégories, tags)

Les termes sont par locale. Les définitions (_emdash_taxonomy_defs) sont aussi par locale, donc label / labelSingular peuvent être traduits aussi. Le pivot content_taxonomies.taxonomy_id stocke le translation_group du terme, donc une seule assignation couvre toutes les locales du contenu.

L’exemple suivant récupère les catégories et les termes d’un article pour la locale active :

---
import { getTaxonomyTerms, getEntryTerms } from "emdash";

const categories = await getTaxonomyTerms("category", {
  locale: Astro.currentLocale,
});
const terms = await getEntryTerms("posts", post.id, undefined, {
  locale: Astro.currentLocale,
});
---

Traduire un contenu hérite automatiquement des assignations de termes de la source — vous n’avez besoin de traduire les termes eux-mêmes qu’une fois, et chaque article qui les utilise résout vers la bonne locale à la lecture.

Réparer les discordances de locale de taxonomie

Quand l’admin charge son manifeste de site, EmDash avertit dans les logs serveur quand les définitions de taxonomie ou les termes utilisent une locale qui n’est pas dans les i18n.locales configurés du site. Sans configuration i18n, en est la locale effective. Ces lignes sont laissées inchangées car EmDash ne peut pas inférer quelle locale configurée le contenu existant devait utiliser.

Sauvegardez la base de données, puis inspectez les lignes affectées nommées dans l’avertissement :

SELECT id, name, locale FROM _emdash_taxonomy_defs ORDER BY name, locale;
SELECT id, name, slug, locale FROM taxonomies ORDER BY name, slug, locale;

Après avoir confirmé la locale prévue pour chaque ligne, mettez-la à jour par id :

UPDATE _emdash_taxonomy_defs SET locale = 'ja' WHERE id = '<definition-id>';
UPDATE taxonomies SET locale = 'ja' WHERE id = '<term-id>';

Utilisez la casse exacte de i18n.locales. Avant de mettre à jour, vérifiez s’il existe une ligne avec le même nom de taxonomie et la locale cible, ou le même nom de terme, slug et locale cible. Ces combinaisons sont uniques ; si une ligne cible existe déjà, réconciliez les traductions au lieu d’appliquer une mise à jour de locale en masse. Redémarrez EmDash et confirmez que l’avertissement n’apparaît plus.

Liste de collections

Filtrez une collection par locale :

---
import { getEmDashCollection } from "emdash";

const { entries: posts } = await getEmDashCollection("posts", {
  locale: Astro.currentLocale,
  status: "published",
});
---

<ul>
  {posts.map((post) => (
    <li><a href={`/${post.data.slug}`}>{post.data.title}</a></li>
  ))}
</ul>

Sélecteur de langue

Utilisez getTranslations pour construire un sélecteur de langue qui renvoie vers les traductions existantes de l’entrée actuelle :

---
import { getTranslations } from "emdash";
import { getRelativeLocaleUrl } from "astro:i18n";

interface Props {
  collection: string;
  entryId: string;
}

const { collection, entryId } = Astro.props;
const { translations } = await getTranslations(collection, entryId);
---

<nav aria-label="Langue">
  <ul>
    {translations.map((t) => (
      <li>
        <a
          href={getRelativeLocaleUrl(t.locale, `/blog/${t.slug}`)}
          aria-current={t.locale === Astro.currentLocale ? "page" : undefined}
        >
          {t.locale.toUpperCase()}
        </a>
      </li>
    ))}
  </ul>
</nav>

La fonction getTranslations retourne toutes les variantes de locale dans le même groupe de traduction :

const { translationGroup, translations } = await getTranslations("posts", post.entry.id);
// translations: [
//   { locale: "en", id: "01ABC...", slug: "my-post", status: "published" },
//   { locale: "fr", id: "01DEF...", slug: "mon-article", status: "draft" },
// ]

Gérer les traductions dans l’admin

Liste de contenu

Quand i18n est activé, la liste de contenu affiche :

  • Une colonne de locale montrant la locale de chaque entrée
  • Un filtre de locale dans la barre d’outils pour basculer entre les locales

Créer des traductions

Ouvrez n’importe quelle entrée de contenu dans l’éditeur. La barre latérale affiche un panneau Traductions listant toutes les locales configurées. Pour chaque locale :

  • « Traduire » apparaît pour les locales sans traduction — cliquez pour en créer une
  • « Modifier » apparaît pour les locales avec une traduction existante — cliquez pour y naviguer
  • La locale actuelle est marquée d’une coche

Lors de la création d’une traduction, la nouvelle entrée est pré-remplie avec les données de la locale source et se voit attribuer un slug par défaut de {source-slug}-{locale}. Ajustez le slug et le contenu selon vos besoins, puis enregistrez.

Publication par locale

Chaque traduction a son propre statut. Publiez, dépubliez ou planifiez les traductions indépendamment. La version française peut être en brouillon tandis que la version anglaise est en ligne.

API de contenu

Paramètre de locale

Toutes les routes de l’API de contenu acceptent un paramètre de requête locale optionnel :

GET /_emdash/api/content/posts?locale=fr
GET /_emdash/api/content/posts/my-post?locale=fr

Quand omis, utilise la locale par défaut configurée.

Créer des traductions via l’API

Créez une traduction en passant locale et translationOf à l’endpoint de création de contenu :

POST /_emdash/api/content/posts
Content-Type: application/json

{
  "locale": "fr",
  "translationOf": "01ABC...",
  "data": {
    "title": "Mon Article",
    "slug": "mon-article"
  }
}

La nouvelle entrée partage le translation_group de l’entrée source et commence comme brouillon.

Lister les traductions

Récupérez toutes les traductions d’une entrée donnée :

GET /_emdash/api/content/posts/01ABC.../translations

Retourne l’ID du groupe de traduction et un tableau de variantes de locale avec leurs IDs, slugs et statuts.

CLI

La CLI supporte les flags --locale sur les commandes de contenu :

# Lister les articles en français
emdash content list posts --locale fr

# Obtenir une entrée spécifique en français
emdash content get posts my-post --locale fr

# Créer une traduction française d'une entrée existante
emdash content create posts --locale fr --translation-of 01ABC...

Alimenter du contenu multilingue

Les fichiers seed expriment les traductions avec locale et translationOf :

{
  "content": {
    "posts": [
      {
        "id": "welcome",
        "slug": "welcome",
        "locale": "en",
        "status": "published",
        "data": { "title": "Welcome" }
      },
      {
        "id": "welcome-fr",
        "slug": "bienvenue",
        "locale": "fr",
        "translationOf": "welcome",
        "status": "draft",
        "data": { "title": "Bienvenue" }
      }
    ]
  }
}

L’entrée de la locale source doit apparaître avant ses traductions dans le fichier seed pour que les références translationOf se résolvent correctement.

Traduisibilité des champs

Chaque champ a un paramètre translatable (par défaut : true). Lors de la création d’une traduction :

  • Les champs traduisibles sont pré-remplis depuis la locale source pour édition
  • Les champs non traduisibles sont copiés et gardés synchronisés à travers toutes les traductions du groupe

Les champs système comme status, published_at et author_id sont toujours par locale et jamais synchronisés.

Stratégie d’URLs

EmDash ne gère pas les URLs de locale — Astro gère le routage. Modèles courants :

# prefix-other-locales (défaut Astro)
/blog/my-post          → en (locale par défaut, pas de préfixe)
/fr/blog/mon-article   → fr

# prefix-always
/en/blog/my-post       → en
/fr/blog/mon-article   → fr

Utilisez getRelativeLocaleUrl d’astro:i18n pour construire des URLs correctes quel que soit le mode de routage.

Sitemaps

Le sitemap par collection à /sitemap-{collection}.xml est conscient des locales. Quand i18n est activé, chaque traduction est émise comme sa propre entrée <url>, avec le préfixe de locale résolu via getRelativeLocaleUrl d’Astro. Votre paramètre prefixDefaultLocale et tout mapping personnalisé de path de locale sont respectés automatiquement.

Les frères de traduction sont liés croisément avec des alternatives xhtml:link pour que les moteurs de recherche puissent servir la bonne langue à chaque utilisateur :

<url>
  <loc>https://example.com/blog/hello</loc>
  <lastmod>2026-05-28T16:33:15.461Z</lastmod>
  <xhtml:link rel="alternate" hreflang="en" href="https://example.com/blog/hello" />
  <xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/blog/bonjour" />
  <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/blog/hello" />
</url>

Les frères sont groupés par translation_group, donc une ligne ajoutée plus tard (une nouvelle variante de locale d’un article existant) apparaît automatiquement comme alternative sur chaque autre variante. Les sites avec une seule locale produisent un sitemap simple sans namespace xhtml.

Alternatives hreflang dans le head de la page

Les mêmes alternatives appartiennent au <head> de chaque page de contenu. Si votre layout utilise <EmDashHead>, c’est automatique : quand i18n est activé et que le contexte de la page inclut content, il émet un <link rel="alternate"> par frère de traduction publié — incluant un lien auto-référencé, comme Google le recommande — plus x-default :

<link rel="alternate" hreflang="en" href="https://example.com/blog/hello" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/blog/bonjour" />
<link rel="alternate" hreflang="x-default" href="https://example.com/blog/hello" />

Pour les heads écrits à la main, résolvez les alternatives avec getHreflangAlternates :

---
import { getEmDashEntry, getHreflangAlternates } from "emdash";

const { entry } = await getEmDashEntry("posts", Astro.params.slug);
const alternates = await getHreflangAlternates("posts", entry.data.id, {
	siteUrl: Astro.url.origin,
});
---

<head>
	{alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
</head>

Le comportement correspond exactement au sitemap :

  • x-default pointe vers la variante de la locale par défaut. Quand la locale par défaut n’a pas de traduction publiée, elle se rabat sur la première variante routable, donc l’ensemble ne manque jamais de x-default.
  • Les frères non publiés sont exclus — les traductions en brouillon ne fuient jamais dans les alternatives.
  • Les locales non routables sont supprimées. Une ligne dont la locale n’est pas dans vos i18n.locales configurés ne peut pas être servie, et lier les moteurs de recherche à un 404 est pire que pas de lien.
  • Les entrées non traduites obtiennent quand même une alternative auto-référencée et x-default quand i18n est activé, reflétant le sitemap.
  • Avec i18n désactivé, le résultat est vide et aucune requête n’est exécutée.

Les URLs sont construites depuis le urlPattern de la collection et localisées via votre configuration de routage i18n Astro (prefixDefaultLocale, mappings personnalisés de path de locale), donc head et sitemap concordent toujours.

Importer du contenu multilingue

Importez du contenu WordPress via l’outil de migration de l’admin — voir Import de contenu et Migrer depuis WordPress. Un export WXR ne porte pas la structure de locale et de groupe de traduction que WPML ou Polylang ajoutent, donc le contenu importé atterrit dans votre locale par défaut.

Pour construire des traductions à partir du contenu importé, créez l’entrée traduite et liez-la à l’originale :

emdash content create posts --locale fr --translation-of 01ABC...

C’est le même workflow --locale / --translation-of montré dans Alimenter du contenu multilingue ci-dessus, appliqué après la fin de l’import.

Prochaines étapes