EmDash se integra con el enrutamiento i18n incorporado de Astro para proporcionar gestión de contenido multilingüe. Astro se encarga del enrutamiento de URLs y la detección de locales; EmDash se encarga del almacenamiento y recuperación de contenido traducido.
Cada traducción es una entrada de contenido completa e independiente con su propio slug, estado e historial de revisiones. La versión francesa de una publicación puede estar en borrador mientras la versión inglesa está publicada.
Configurar locales
Habilite i18n agregando un bloque i18n a su configuración de Astro. EmDash lee esta misma configuración para su lista de locales, locale predeterminado y cadena 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",
}),
}),
],
});
Cuando i18n no está presente en la configuración de Astro, todas las funciones de i18n se deshabilitan y EmDash se comporta como un CMS de un solo idioma.
Cómo funcionan las traducciones
EmDash usa un modelo de fila por locale. Cada traducción es su propia fila en la base de datos con su propio ID, slug y estado, vinculada a otras traducciones a través de un identificador compartido translation_group. Una tabla de posts con tres traducciones se ve así:
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
Este diseño significa:
- Slugs por locale —
/blog/my-posty/fr/blog/mon-articlefuncionan naturalmente - Publicación por locale — publique la versión en inglés mientras mantiene la francesa en borrador
- Revisiones por locale — cada traducción tiene su propio historial de revisiones
- Consultas de un solo locale — las consultas de lista devuelven entradas para un solo locale
Slugs, IDs de entrada e IDs de base de datos
Una entrada tiene dos identificadores con diferentes propósitos:
entry.ides el slug de la entrada. Úselo al construir la URL pública.entry.data.ides el ID de la base de datos. Úselo para operaciones de API y helpers que se refieren a una fila de contenido almacenada, incluyendogetTranslations()ygetEntryTerms().
Las traducciones tienen diferentes IDs de base de datos porque cada locale es una fila separada. Su translation_group compartido registra que las filas son traducciones del mismo contenido. EmDash gestiona ese grupo cuando crea una traducción; los templates normalmente solo necesitan el ID de base de datos de cualquier fila en el grupo.
Consultar contenido traducido
Entrada individual
Pase Astro.currentLocale a getEmDashEntry en una ruta multilingüe. Astro conoce el locale seleccionado por su router, mientras que EmDash necesita el valor explícito para desambiguar slugs que pueden existir en más de un locale. Haga lo mismo para consultas de colección.
---
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>
Cadena de fallback
Cuando una entrada publicada coincidente no existe en el locale solicitado, getEmDashEntry sigue la cadena de fallback de la configuración de Astro. En modo de vista previa o edición visual, la misma búsqueda puede devolver un borrador. Dado fallback: { fr: "en" }:
- Intente el locale solicitado (
fr) - Intente el locale de fallback (
en) - Intente el locale predeterminado si no está ya en la cadena
El fallback solo se aplica a consultas de entrada individual. Las consultas de lista devuelven entradas solo para el locale solicitado.
Cada búsqueda de fallback usa el mismo argumento id. Por ejemplo, una solicitud para el slug about puede caer del francés a una entrada en inglés cuyo slug también es about. Una solicitud para a-propos no puede descubrir una entrada en inglés cuyo slug sea about; las dos filas usan identificadores públicos diferentes. Use getTranslations() para encontrar y vincular variantes de locale con diferentes slugs.
Menús
Los menús son por locale — el mismo name (p. ej. "primary") puede existir en varios
locales, todos vinculados a través de un translation_group compartido. Los elementos del menú resuelven sus
referencias de contenido contra la versión en el locale activo del contenido
referenciado.
El siguiente componente obtiene el menú principal para el locale activo:
---
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>
Cree traducciones de un menú existente desde la lista de Menús del admin — los
elementos se clonan con reference_id intacto (almacena el translation_group del contenido referenciado), por lo que los enlaces del nuevo menú apuntan al contenido correcto por locale
automáticamente.
Taxonomías (categorías, etiquetas)
Los términos son por locale. Cada locale puede tener su propia definición, por lo que label y
labelSingular también pueden traducirse, mientras que hierarchical y collections
se comparten en todos los locales (consulte
Traducir taxonomías y términos). El pivot
content_taxonomies.taxonomy_id almacena el translation_group del término, por lo que una
sola asignación abarca cada locale del contenido.
El siguiente ejemplo obtiene categorías y los términos de una publicación para el locale activo:
---
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,
});
---
Traducir un contenido hereda automáticamente las asignaciones de términos de la fuente — solo necesita traducir los términos en sí una vez, y cada publicación que los use se resuelve al locale correcto en tiempo de lectura.
Reparar discrepancias de locale en taxonomías
Cuando el admin carga su manifiesto del sitio, EmDash advierte en los logs del servidor cuando
las definiciones de taxonomía o los términos usan un locale que no está en los i18n.locales configurados del sitio. Sin una configuración i18n, en es el locale efectivo.
Estas filas se dejan sin cambios porque EmDash no puede inferir qué locale configurado debía usar el contenido existente.
Haga una copia de seguridad de la base de datos, luego inspeccione las filas afectadas nombradas en la advertencia:
SELECT id, name, locale FROM _emdash_taxonomy_defs ORDER BY name, locale;
SELECT id, name, slug, locale FROM taxonomies ORDER BY name, slug, locale;
Después de confirmar el locale previsto para cada fila, actualícelo por id:
UPDATE _emdash_taxonomy_defs SET locale = 'ja' WHERE id = '<definition-id>';
UPDATE taxonomies SET locale = 'ja' WHERE id = '<term-id>';
Use la capitalización exacta de i18n.locales. Antes de actualizar, verifique si existe una fila con el mismo nombre de taxonomía y locale objetivo, o el mismo nombre de término, slug y locale objetivo. Esas combinaciones son únicas; si ya existe una fila objetivo, concilie las traducciones en lugar de aplicar una actualización masiva de locale. Reinicie EmDash y
confirme que la advertencia ya no aparece.
Listado de colección
Filtre una colección por 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>
Construir un selector de idioma
Use getTranslations para construir un selector de idioma que enlace a traducciones existentes de la entrada actual:
---
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 función getTranslations devuelve todas las variantes de locale en el mismo grupo de traducción:
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" },
// ]
Gestionar traducciones en el admin
Lista de contenido
Cuando i18n está habilitado, la lista de contenido muestra:
- Una columna de locale que muestra el locale de cada entrada
- Un filtro de locale en la barra de herramientas para cambiar entre locales
Crear traducciones
Abra cualquier entrada de contenido en el editor. La barra lateral muestra un panel de Traducciones que lista todos los locales configurados. Para cada locale:
- “Translate” aparece para locales sin traducción — haga clic para crear una
- “Edit” aparece para locales con una traducción existente — haga clic para navegar a ella
- El locale actual está marcado con una marca de verificación
Al crear una traducción, la nueva entrada se pre-rellena con datos del locale fuente y se le asigna un slug predeterminado de {source-slug}-{locale}. Ajuste el slug y el contenido según sea necesario, luego guarde.
Publicación por locale
Cada traducción tiene su propio estado. Publique, despublique o programe traducciones de forma independiente. La versión francesa puede estar en borrador mientras la versión inglesa está activa.
Usar la API de contenido
Parámetro locale
Las rutas de la API de contenido requieren una sesión autenticada o un token bearer. Las rutas de lista aceptan un parámetro de consulta locale opcional. Una ruta de entrada individual también lo acepta cuando la ruta usa un slug; los IDs de base de datos son globalmente únicos y no necesitan desambiguación de locale.
GET /_emdash/api/content/posts?locale=fr
GET /_emdash/api/content/posts/my-post?locale=fr
Cuando una solicitud de lista omite locale, usa el locale predeterminado configurado.
Crear traducciones vía API
Cree una traducción pasando locale y translationOf al endpoint de creación de contenido:
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 es el ID de base de datos de la fila fuente, como entry.data.id. La nueva entrada comparte el translation_group de la entrada fuente y comienza como borrador.
Listar traducciones
Recupere todas las traducciones para una entrada dada:
GET /_emdash/api/content/posts/01ABC.../translations
Devuelve el ID del grupo de traducción y un array de variantes de locale con sus IDs, slugs y estados.
Usar la CLI
Después de autenticar la CLI, use sus flags --locale en los comandos de contenido:
# Listar publicaciones en francés
emdash content list posts --locale fr
# Obtener una entrada específica en francés
emdash content get posts my-post --locale fr
# Crear una traducción al francés como borrador
emdash content create posts \
--locale fr \
--translation-of 01ABC... \
--slug mon-article \
--data '{"title":"Mon article"}' \
--draft
content create requiere entrada de --data, --file o --stdin. Publica después de la creación a menos que pase --draft.
Sembrar contenido multilingüe
Los archivos seed expresan traducciones usando locale y 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" }
}
]
}
}
La entrada del locale fuente debe aparecer antes de sus traducciones en el archivo seed para que las referencias translationOf se resuelvan correctamente.
Elegir qué campos son traducibles
Cada campo tiene una configuración translatable (predeterminado: true). Al crear una traducción:
- Campos traducibles se pre-rellenan del locale fuente para edición
- Campos no traducibles se copian y se mantienen sincronizados en todas las traducciones del grupo
En una colección con revisiones, publicar una entrada copia los valores no traducibles que cambió a las otras traducciones, y guardar un borrador cambia solo esa entrada. Si otra traducción tiene un borrador pendiente que cambió uno de esos valores, el borrador mantiene su propio valor, y publicar esa traducción lo copia al resto del grupo.
Los campos del sistema como status, published_at y author_id son siempre por locale y nunca se sincronizan.
Un campo reference se comparte en lugar de sincronizarse: EmDash vincula sus enlaces por el translation_group, por lo que cada traducción de una entrada tiene una sola selección, y editarla desde cualquier locale la cambia para todas.
Construir URLs de locale
EmDash almacena el locale; Astro se encarga del enrutamiento público. La configuración soportada de EmDash deja el locale predeterminado sin prefijo:
# prefix-other-locales (predeterminado de Astro)
/blog/my-post → en (locale predeterminado, sin prefijo)
/fr/blog/mon-article → fr
Use getRelativeLocaleUrl de astro:i18n para agregar el prefijo correcto y cualquier mapeo de ruta de locale personalizado. No habilite un prefijo de locale predeterminado; como se describe en Configurar locales, esa estrategia de enrutamiento evita que se carguen las páginas de admin inyectadas.
Sitemaps
El sitemap por colección en /sitemap-{collection}.xml es consciente del locale. Incluye entradas publicadas de colecciones enrutables habilitadas para SEO. Las entradas eliminadas, entradas sin slug y entradas marcadas como noindex se excluyen. Cada traducción incluida se convierte en su propia entrada <url>. EmDash construye su ruta desde el urlPattern de la colección, luego aplica el prefijo de locale de Astro y cualquier mapeo de path de locale personalizado.
Los hermanos de traducción están vinculados con alternates xhtml:link para que los motores de búsqueda puedan servir el idioma correcto a cada usuario:
<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>
Los hermanos se agrupan por translation_group, por lo que una variante de locale publicada aparece como alternate en cada otra variante publicada e indexable. Los locales que faltan en i18n.locales se omiten porque Astro no tiene ruta para ellos. Los sitios con un solo locale producen un sitemap simple sin namespace xhtml.
Agregar enlaces hreflang al encabezado de la página
Los mismos alternates pertenecen al <head> de cada página de contenido. Si su layout usa <EmDashHead>, esto es automático: cuando i18n está habilitado y el contexto de página incluye content, emite un <link rel="alternate"> por hermano de traducción publicado — incluyendo un enlace autorreferencial, como recomienda Google — más 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" />
Para encabezados hechos a mano, resuelva los alternates con 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>
El comportamiento coincide con el sitemap:
x-defaultapunta a la variante del locale predeterminado. Cuando el locale predeterminado no tiene una traducción publicada, recurre a la primera variante enrutable, por lo que el conjunto nunca carece de unx-default.- Los hermanos no publicados se excluyen — las traducciones en borrador nunca se filtran a los alternates.
- Los hermanos
noindexse excluyen. Si la entrada actual esnoindex, no se devuelven alternates. - Los locales no enrutables se eliminan. Una fila cuyo locale no está en sus
i18n.localesconfigurados no puede servirse, y vincular motores de búsqueda a un 404 es peor que no tener enlace. - Las entradas no traducidas aún obtienen un alternate autorreferencial y
x-defaultcuando i18n está habilitado, reflejando el sitemap. - Con i18n deshabilitado, el resultado está vacío y no se ejecutan consultas.
Las URLs se construyen a partir del urlPattern de la colección y se localizan a través de la configuración i18n de Astro. getHreflangAlternates() necesita una URL de sitio absoluta. Usa siteUrl de la llamada o la URL de configuración del sitio; sin ninguna, devuelve un array vacío porque los enlaces hreflang deben ser absolutos.
Importar contenido multilingüe
Importe contenido de WordPress a través de la herramienta de migración del admin — vea Importación de contenido y Migrar desde WordPress. Una exportación WXR no lleva la estructura de locale y grupo de traducción que WPML o Polylang agregan, por lo que el contenido importado llega a su locale predeterminado.
Para construir traducciones a partir de contenido importado, cree la entrada traducida como borrador y vincúlela al ID de base de datos original:
emdash content create posts \
--locale fr \
--translation-of 01ABC... \
--slug mon-article \
--data '{"title":"Mon article"}' \
--draft
Esta es la misma relación --locale y --translation-of utilizada por los archivos seed, aplicada después de que se complete la importación.
Próximos pasos
- Consultar contenido — Referencia completa de la API de consultas
- Trabajar con contenido — Gestión de contenido en el admin
- Enrutamiento i18n de Astro — Configuración de enrutamiento de Astro