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 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.
Configurer les locales
Activez l’i18n en ajoutant un bloc i18n à votre configuration Astro. EmDash lit cette même configuration pour sa liste de locales, locale par défaut et 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 une 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
Ce design signifie :
- Slugs par locale —
/blog/my-postet/fr/blog/mon-articlefonctionnent naturellement - Publication par locale — publiez la version anglaise en gardant la française 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 pour une seule locale
Slugs, IDs d’entrée et IDs de base de données
Une entrée a deux identifiants avec des objectifs 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 API et les helpers qui font référence à une ligne de contenu stockée, y comprisgetTranslations()etgetEntryTerms().
Les traductions ont des IDs de base de données différents car chaque locale est une ligne séparée. Leur translation_group partagé enregistre que les lignes sont des traductions du même contenu. EmDash gère ce groupe quand 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 désambiguïser les slugs qui peuvent exister dans plus d’une locale. 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.redirect("/404");
---
<article>
<h1>{post.data.title}</h1>
</article>
Chaîne de fallback
Quand une entrée publiée correspondante n’existe pas 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 retourner un brouillon. Étant donné 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 retournent les entrées uniquement pour la locale demandée.
Chaque recherche de fallback utilise le même argument id. Par exemple, une requête pour le slug about peut retomber du français vers une entrée anglaise dont le slug est aussi 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 lier des variantes de locale avec des slugs différents.
Menus
Les menus sont par locale — le même name (p. 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é dans 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="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 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 aussi par locale, donc label / labelSingular peuvent aussi être traduits. Le pivot content_taxonomies.taxonomy_id stocke le translation_group du terme, donc une seule attribution couvre chaque locale 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,
});
---
Traduire un contenu hérite automatiquement des attributions de termes de la source — vous n’avez besoin de traduire les termes eux-mêmes qu’une seule fois, et chaque article qui les utilise se résout à la bonne locale au moment de la lecture.
Réparer les discordances de locale des taxonomies
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 déduire 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 la mise à 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 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 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);
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 retourne toutes les variantes de locale dans le 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
Quand l’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 :
- “Translate” apparaît pour les locales sans traduction — cliquez pour en créer une
- “Edit” 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 les 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.
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 optionnel. Une route d’entrée unique l’accepte aussi quand le chemin utilise un slug ; les IDs de base de données sont globalement uniques et n’ont pas besoin de désambiguïsation de locale.
GET /_emdash/api/content/posts?locale=fr
GET /_emdash/api/content/posts/my-post?locale=fr
Quand 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 endpoint 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, comme entry.data.id. 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
Retourne 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 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 en brouillon
emdash content create posts \
--locale fr \
--translation-of 01ABC... \
--slug mon-article \
--data '{"title":"Mon article"}' \
--draft
content create nécessite une entrée de --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 en utilisant 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.
Choisir quels champs sont traduisibles
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 maintenus synchronisés dans toutes les traductions du groupe
Sur une collection avec des révisions, publier une entrée copie les valeurs non traduisibles qu’elle a modifiées vers les autres traductions, et enregistrer 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 garde sa propre valeur, et publier 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 jamais synchronisés.
Construire des URLs de locale
EmDash stocke la locale ; Astro gère le routage public. La configuration EmDash supportée laisse la locale par défaut sans préfixe :
# prefix-other-locales (défaut Astro)
/blog/my-post → en (locale par défaut, pas de préfixe)
/fr/blog/mon-article → fr
Utilisez getRelativeLocaleUrl de astro:i18n pour ajouter le préfixe correct et tout mapping de chemin de locale personnalisé. N’activez pas un préfixe de locale par défaut ; comme décrit dans Configurer les locales, cette stratégie de routage empêche le chargement des pages admin injectées.
Sitemaps
Le sitemap par collection à /sitemap-{collection}.xml est conscient des locales. Il inclut les entrées publiées des collections routables activées pour le SEO. 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 mapping de path de locale personnalisé.
Les frères de traduction sont liés 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 frères sont groupés par translation_group, donc une variante de locale publiée apparaît comme alternate sur chaque autre variante publiée et indexable. Les locales manquantes dans i18n.locales sont omises car Astro n’a pas de route pour elles. Les sites avec une seule locale produisent un sitemap simple sans namespace xhtml.
Ajouter des liens hreflang à l’en-tête de la page
Les mêmes alternates appartiennent au <head> de chaque page de contenu. Si votre layout utilise <EmDashHead>, c’est automatique : quand l’i18n est activé et que le contexte de page inclut content, il émet un <link rel="alternate"> par frère de traduction publié — incluant un lien autoréférentiel, comme le recommande 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 en-têtes faits à 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.redirect("/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 correspond 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 retombe sur la première variante routable, donc l’ensemble ne manque jamais dex-default.- Les frères non publiés sont exclus — les traductions en brouillon ne fuient jamais dans les alternates.
- Les frères
noindexsont exclus. Si l’entrée actuelle estnoindex, aucun alternate n’est retourné. - 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 autoréférentiel et
x-defaultquand l’i18n est activé, reflétant le sitemap. - Avec l’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 la configuration i18n d’Astro. getHreflangAlternates() a besoin d’une URL de site absolue. Il utilise siteUrl de l’appel ou l’URL des paramètres du site ; sans l’un ou l’autre, il retourne un tableau vide car les liens hreflang doivent être absolus.
Importer du contenu multilingue
Importez du contenu WordPress via l’outil de migration admin — voir Importation de Contenu et Migrer depuis WordPress. Une exportation 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 de contenu importé, créez l’entrée traduite en brouillon et liez-la à l’ID de base de données original :
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 utilisée par les fichiers seed, appliquée après la fin de l’importation.
Prochaines étapes
- Interroger le Contenu — Référence complète de l’API de requête
- Travailler avec le Contenu — Gestion de contenu admin
- Routage i18n Astro — Configuration du routage Astro