EmDash s’intègre au routage i18n intégré d’Astro pour fournir une gestion de contenu multilingue. Astro gère le routage des URLs et la détection de locale ; 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 pendant 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 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 partagé translation_group. 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
Cette conception signifie :
- Slugs par locale —
/blog/my-postet/fr/blog/mon-articlefonctionnent naturellement - Publication par locale — publiez la version anglaise en gardant le français en brouillon
- Révisions par locale — chaque traduction a son propre historique de révisions
- Requêtes par locale — les requêtes de liste renvoient les entrées pour une seule locale
Interroger le contenu traduit
Entrée individuelle
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. Avec fallback: { fr: "en" } :
- Essaie la locale demandée (
fr) - Essaie la locale de fallback (
en) - Essaie la locale par défaut
Le fallback ne s’applique qu’aux requêtes d’entrée individuelle. Les requêtes de liste renvoient les entrées uniquement pour la locale demandée.
Menus
Les menus sont par locale — le même name (ex. "primary") peut exister dans 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 du contenu référencé de la locale active.
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="Principal">
<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 des 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 également par locale, donc label / labelSingular peuvent aussi être traduits. 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 devez seulement traduire les termes eux-mêmes une fois, et chaque article qui les utilise se résout à la bonne locale en temps de lecture.
Liste de collection
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 lie aux 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 renvoie 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 affichant 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 reçoit un slug par défaut de {slug-source}-{locale}. Ajustez le slug et le contenu selon vos besoins, puis sauvegardez.
Publication par locale
Chaque traduction a son propre statut. Publiez, dépubliez ou planifiez des traductions indépendamment. La version française peut être en brouillon pendant 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 par défaut la locale par défaut configurée.
Créer des traductions via API
Créez une traduction en passant locale et translationOf au 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 pour une entrée donnée :
GET /_emdash/api/content/posts/01ABC.../translations
Renvoie 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 (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 maintenus synchronisés dans toutes les traductions du groupe
Les champs système comme status, published_at et author_id sont toujours par locale et ne sont jamais synchronisés.
Stratégie d’URL
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 depuis astro:i18n pour construire des URLs correctes quel que soit le mode de routage.
Sitemaps
Le sitemap par collection à /sitemap-{collection}.xml est conscient de la locale. 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 traductions sœurs sont liées avec des alternates 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 sœurs sont groupées par translation_group, donc une ligne ajoutée plus tard (une nouvelle variante de locale d’un article existant) apparaît automatiquement comme alternate sur chaque autre variante. Les sites avec une seule locale produisent un sitemap simple sans namespace xhtml.
Alternates hreflang dans le head de la page
Les mêmes alternates appartiennent au <head> de chaque page de contenu. Si votre layout utilise <EmDashHead>, c’est automatique : quand i18n est activé et le contexte de la page inclut content, il émet un <link rel="alternate"> par traduction sœur publiée — incluant un lien auto-référentiel, comme recommandé par Google — 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 faits à la main, résolvez les alternates 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-defaultpointe vers la variante de la locale par défaut. Quand la locale par défaut n’a pas de traduction publiée, il se rabat sur la première variante routable, donc l’ensemble ne manque jamais dex-default.- Les sœurs non publiées sont exclues — les traductions en brouillon ne fuient jamais dans les alternates.
- Les locales non routables sont supprimées. Une ligne dont la locale n’est pas dans vos
i18n.localesconfiguré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 un alternate auto-référentiel et
x-defaultquand i18n est activé, en miroir du sitemap. - Avec i18n désactivé, le résultat est vide et aucune requête n’est exécutée.
Les URLs sont construites à partir du 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 sont toujours cohérents.
Importer du contenu multilingue
Importez du contenu WordPress via l’outil de migration 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é arrive dans votre locale par défaut.
Pour construire des traductions à partir du contenu importé, créez l’entrée traduite et liez-la à l’original :
emdash content create posts --locale fr --translation-of 01ABC...
C’est le même workflow --locale / --translation-of montré ci-dessus dans Alimenter du contenu multilingue, appliqué après la fin de l’importation.
Prochaines étapes
- Interroger le contenu — Référence complète de l’API de requêtes
- Travailler avec le contenu — Gestion de contenu admin
- Routage i18n Astro — Configuration de routage d’Astro