EmDash s’intègre au routage i18n intégré d’Astro pour offrir une gestion de contenu multilingue. Astro gère le routage des URL et la détection de la 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, son propre statut et son propre historique de révisions. La version française d’un article peut être en brouillon alors que la version anglaise est publiée.
Configurer les locales
Activez l’i18n en ajoutant un bloc i18n à votre configuration Astro. EmDash lit cette même configuration pour obtenir 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",
}),
}),
],
});
Lorsque i18n est absent de 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 une ligne par locale. Chaque traduction est sa propre ligne dans la base de données, avec son propre ID, son propre slug et son propre statut, et elle est liée aux autres traductions par un identifiant translation_group partagé. Une table d’articles 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 implique que :
- Slugs par locale —
/blog/my-postet/fr/blog/mon-articlefonctionnent naturellement - Publication par locale — publiez la version anglaise tout en laissant le français en brouillon
- Révisions par locale — chaque traduction a son propre historique de révisions
- Requêtes sur une seule locale — les requêtes de liste ne renvoient que les entrées d’une seule locale
Slugs, IDs d’entrée et IDs de base de données
Une entrée possède deux identifiants aux usages différents :
entry.idest le slug de l’entrée. Utilisez-le pour construire l’URL publique.entry.data.idest l’ID de base de données. Utilisez-le pour les opérations d’API et pour les helpers qui font référence à une ligne de contenu stockée, notammentgetTranslations()etgetEntryTerms().
Les traductions ont des IDs de base de données différents, car chaque locale est une ligne distincte. Leur translation_group partagé indique que les lignes sont des traductions d’un même contenu. EmDash gère ce groupe lorsque vous créez une traduction ; les templates n’ont normalement besoin que de l’ID de base de données de n’importe quelle ligne du groupe.
Interroger le contenu traduit
Entrée unique
Passez Astro.currentLocale à getEmDashEntry sur une route multilingue. Astro connaît la locale sélectionnée par son routeur, tandis qu’EmDash a besoin de la valeur explicite pour lever l’ambiguïté des slugs qui peuvent exister dans plusieurs locales. Faites de même pour les requêtes de collection.
---
import { getEmDashEntry } from "emdash";
const { slug } = Astro.params;
const { entry: post, error } = await getEmDashEntry("posts", slug, {
locale: Astro.currentLocale,
});
if (!post) return Astro.rewrite("/404");
---
<article>
<h1>{post.data.title}</h1>
</article>
Chaîne de fallback
Lorsqu’aucune entrée publiée correspondante n’existe dans la locale demandée, getEmDashEntry suit la chaîne de fallback de la configuration Astro. En mode aperçu ou édition visuelle, la même recherche peut renvoyer un brouillon. Avec fallback: { fr: "en" } :
- Essayer la locale demandée (
fr) - Essayer la locale de fallback (
en) - Essayer la locale par défaut si elle n’est pas déjà dans la chaîne
Le fallback ne s’applique qu’aux requêtes d’entrée unique. Les requêtes de liste ne renvoient que les entrées de la locale demandée.
Chaque recherche de fallback utilise le même argument id. Par exemple, une requête pour le slug about peut se rabattre du français vers une entrée anglaise dont le slug est également about. Une requête pour a-propos ne peut pas découvrir une entrée anglaise dont le slug est about ; les deux lignes utilisent des identifiants publics différents. Utilisez getTranslations() pour trouver et relier les variantes de locale dont les slugs diffèrent.
Menus
Les menus sont par locale : le même name (par exemple "primary") peut exister dans plusieurs locales, toutes liées par un translation_group partagé. Les éléments de menu résolvent leurs références de contenu par rapport à la version du contenu référencé dans la locale active.
Le composant suivant récupère le menu principal de 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é), de sorte que les liens du nouveau menu pointent automatiquement vers le bon contenu de chaque locale.
Taxonomies (catégories, tags)
Les termes sont par locale. Chaque locale peut avoir sa propre définition, si bien que label et labelSingular peuvent aussi être traduits, tandis que hierarchical et collections sont partagés par toutes les locales (voir Traduire les taxonomies et les termes). Le pivot content_taxonomies.taxonomy_id stocke le translation_group du terme, de sorte qu’une seule affectation 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.data.id, undefined, {
locale: Astro.currentLocale,
});
---
La traduction d’un contenu hérite automatiquement des affectations de termes de la source : il suffit de traduire les termes eux-mêmes une seule fois, et chaque article qui les utilise se résout vers la bonne locale au moment de la lecture.
Réparer les discordances de locale des taxonomies
Lorsque l’admin charge le manifeste du site, EmDash émet un avertissement dans les journaux du serveur si des définitions de taxonomie ou des termes utilisent une locale absente des i18n.locales configurées du site. Sans configuration i18n, en est la locale effective. Ces lignes sont laissées inchangées, car EmDash ne peut pas déduire quelle locale configurée le contenu existant était censé utiliser.
Sauvegardez la base de données, puis inspectez les lignes concernées cité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 exactement la casse de i18n.locales. Avant la mise à jour, vérifiez s’il existe une ligne ayant le même nom de taxonomie et la même locale cible, ou le même nom de terme, le même slug et la même locale cible. Ces combinaisons sont uniques ; si une ligne cible existe déjà, rapprochez les traductions au lieu d’appliquer une mise à jour de locale en masse. Redémarrez EmDash et vérifiez que l’avertissement n’apparaît plus.
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.id}`}>{post.data.title}</a></li>
))}
</ul>
Construire un sélecteur de langue
Utilisez getTranslations pour construire un sélecteur de langue qui renvoie vers les traductions existantes de l’entrée courante :
---
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);
const publishedTranslations = translations.filter(
(translation): translation is typeof translation & { slug: string } =>
translation.status === "published" && translation.slug !== null
);
---
<nav aria-label="Language">
<ul>
{publishedTranslations.map((translation) => (
<li>
<a
href={getRelativeLocaleUrl(translation.locale, `/blog/${translation.slug}`)}
aria-current={translation.locale === Astro.currentLocale ? "page" : undefined}
>
{translation.locale.toUpperCase()}
</a>
</li>
))}
</ul>
</nav>
La fonction getTranslations renvoie toutes les variantes de locale du même groupe de traduction :
const { translationGroup, translations } = await getTranslations("posts", post.data.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
Lorsque l’i18n est activée, 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 passer d’une locale à l’autre
Créer des traductions
Ouvrez n’importe quelle entrée de contenu dans l’éditeur. La barre latérale affiche un panneau Translations listant toutes les locales configurées. Pour chaque locale :
- « Translate » apparaît pour les locales sans traduction : cliquez pour en créer une
- « Edit » apparaît pour les locales ayant une traduction existante : cliquez pour y accéder
- La locale courante 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 {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 alors que la version anglaise est en ligne.
Utiliser l’API de contenu
Paramètre locale
Les routes de l’API de contenu nécessitent une session authentifiée ou un jeton Bearer. Les routes de liste acceptent un paramètre de requête locale facultatif. Une route d’entrée unique l’accepte aussi lorsque le chemin utilise un slug ; les IDs de base de données sont uniques globalement et n’ont pas besoin de désambiguïsation par locale.
GET /_emdash/api/content/posts?locale=fr
GET /_emdash/api/content/posts/my-post?locale=fr
Lorsqu’une requête de liste omet locale, elle utilise la locale par défaut configurée.
Créer des traductions via l’API
Créez une traduction en passant locale et translationOf au point de terminaison de création de contenu :
POST /_emdash/api/content/posts
Content-Type: application/json
X-EmDash-Request: 1
{
"locale": "fr",
"translationOf": "01ABC...",
"slug": "mon-article",
"data": {
"title": "Mon Article"
}
}
translationOf est l’ID de base de données de la ligne source, par exemple entry.data.id. La nouvelle entrée partage le translation_group de l’entrée source et démarre en brouillon.
Lister les traductions
Récupérez toutes les traductions d’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.
Utiliser la CLI
Après avoir authentifié la CLI, utilisez ses indicateurs --locale sur les commandes de contenu :
# List French posts
emdash content list posts --locale fr
# Get a specific entry in French
emdash content get posts my-post --locale fr
# Create a French translation as a draft
emdash content create posts \
--locale fr \
--translation-of 01ABC... \
--slug mon-article \
--data '{"title":"Mon article"}' \
--draft
content create nécessite une entrée via --data, --file ou --stdin. Il publie après la création, sauf si vous passez --draft.
Amorcer 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 afin que les références translationOf soient résolues correctement.
Choisir quels champs sont traduisibles
Chaque champ possède 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 être modifiés
- Les champs non traduisibles sont copiés et gardés synchronisés entre toutes les traductions du groupe
Sur une collection avec révisions, la publication d’une entrée copie vers les autres traductions les valeurs non traduisibles qu’elle a modifiées, tandis que l’enregistrement d’un brouillon ne modifie que cette entrée. Si une autre traduction a un brouillon en attente qui a modifié l’une de ces valeurs, le brouillon conserve sa propre valeur, et la publication de cette traduction la copie vers le reste du groupe.
Les champs système comme status, published_at et author_id sont toujours par locale et ne sont jamais synchronisés.
Un champ de référence est partagé plutôt que synchronisé : EmDash indexe ses liens par le groupe de traduction, de sorte que chaque traduction d’une entrée a une seule sélection, et la modifier depuis n’importe quelle locale la change pour toutes.
Construire des URLs de locale
EmDash stocke la locale ; Astro gère le routage public. La configuration prise en charge par EmDash laisse la locale par défaut sans préfixe :
# prefix-other-locales (Astro default)
/blog/my-post → en (default locale, no prefix)
/fr/blog/mon-article → fr
Utilisez getRelativeLocaleUrl depuis astro:i18n pour ajouter le bon préfixe et tout mappage personnalisé des chemins de locale. N’activez pas de préfixe pour la locale par défaut ; comme décrit dans Configurer les locales, cette stratégie de routage empêche le chargement des pages d’admin injectées.
Sitemaps
Le sitemap par collection à l’adresse /sitemap-{collection}.xml tient compte de la locale. Il inclut les entrées publiées des collections routables pour lesquelles le SEO est activé. Les entrées supprimées, les entrées sans slug et les entrées marquées noindex sont exclues. Chaque traduction incluse devient sa propre entrée <url>. EmDash construit son chemin à partir du urlPattern de la collection, puis applique le préfixe de locale d’Astro et tout mappage path de locale personnalisé.
Les traductions sœurs sont reliées entre elles par des alternates xhtml:link, afin 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 entrées sœurs sont regroupées par translation_group, de sorte qu’une variante de locale publiée apparaît comme alternate sur toutes les autres variantes publiées et indexables. Les locales absentes de i18n.locales sont omises, car Astro n’a pas de route pour elles. Les sites à locale unique produisent un sitemap simple, sans espace de noms xhtml.
Ajouter des liens hreflang à l’en-tête de la page
Les mêmes alternates ont leur place dans le <head> de chaque page de contenu. Si votre layout utilise <EmDashHead>, c’est automatique : lorsque l’i18n est activée et que le contexte de la page inclut content, il émet un <link rel="alternate"> par traduction sœur publiée — y compris un lien auto-référencé, comme le recommande Google — ainsi que 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 en-têtes écrits à la main, résolvez les alternates avec getHreflangAlternates :
---
import { getEmDashEntry, getHreflangAlternates } from "emdash";
const { entry, error } = await getEmDashEntry("posts", Astro.params.slug, {
locale: Astro.currentLocale,
});
if (error) return new Response("Server error", { status: 500 });
if (!entry) return Astro.rewrite("/404");
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 est identique à celui du sitemap :
x-defaultpointe vers la variante de la locale par défaut. Lorsque la locale par défaut n’a pas de traduction publiée, il se rabat sur la première variante routable, de sorte que l’ensemble n’est jamais dépourvu dex-default.- Les entrées sœurs non publiées sont exclues — les traductions en brouillon ne fuient jamais dans les alternates.
- Les entrées sœurs
noindexsont exclues. Si l’entrée courante estnoindex, aucun alternate n’est renvoyé. - Les locales non routables sont écartées. Une ligne dont la locale ne figure pas dans vos
i18n.localesconfigurées ne peut pas être servie, et renvoyer les moteurs de recherche vers une 404 est pire que de ne pas mettre de lien. - Les entrées non traduites reçoivent tout de même un alternate auto-référencé et
x-defaultlorsque l’i18n est activée, comme dans le sitemap. - Avec l’i18n désactivée, le résultat est vide et aucune requête n’est exécutée.
Les URL sont construites à partir du urlPattern de la collection et localisées via la configuration i18n d’Astro. getHreflangAlternates() nécessite une URL de site absolue. Elle utilise siteUrl passé à l’appel ou l’URL des paramètres du site ; sans l’une ni l’autre, elle renvoie un tableau vide, car les liens hreflang doivent être absolus.
Importer du contenu multilingue
Importez le contenu WordPress via l’outil de migration de l’admin — voir Importation de contenu et Migrer depuis WordPress. Un export WXR ne contient pas la structure de locales et de groupes de traduction qu’ajoutent WPML ou Polylang ; le contenu importé atterrit donc dans votre locale par défaut.
Pour construire des traductions à partir du contenu importé, créez l’entrée traduite en brouillon et reliez-la à l’ID de base de données d’origine :
emdash content create posts \
--locale fr \
--translation-of 01ABC... \
--slug mon-article \
--data '{"title":"Mon article"}' \
--draft
C’est la même relation --locale et --translation-of que celle utilisée par les fichiers seed, appliquée une fois l’importation terminée.
Prochaines étapes
- Interroger le contenu — Référence complète de l’API de requête
- Travailler avec le contenu — Gestion du contenu dans l’admin
- Routage i18n d’Astro — Configuration du routage d’Astro