Internacionalización (i18n)

En esta página

EmDash se integra con el enrutamiento i18n integrado de Astro para ofrecer gestión de contenido multilingüe. Astro se encarga del enrutamiento de URL y de la detección del locale; EmDash se encarga del almacenamiento y la recuperación del 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 en francés de una publicación puede estar en borrador mientras la versión en inglés está publicada.

Configurar locales

Habilita i18n añadiendo un bloque i18n a tu configuración de Astro. EmDash lee esta misma configuración para obtener su lista de locales, el locale predeterminado y la 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 desactivan y EmDash se comporta como un CMS de un solo idioma.

Cómo funcionan las traducciones

EmDash usa un modelo de una fila por locale. Cada traducción es su propia fila en la base de datos, con su propio ID, slug y estado, y se vincula con las demás traducciones mediante un identificador translation_group compartido. Una tabla de publicaciones con tres traducciones tiene este aspecto:

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 implica que:

  • Slugs por locale — /blog/my-post y /fr/blog/mon-article funcionan de forma natural
  • Publicación por locale — publica la versión en inglés mientras mantienes el francés 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 de un único locale

Slugs, IDs de entrada e IDs de base de datos

Una entrada tiene dos identificadores con fines distintos:

  • entry.id es el slug, con el prefijo del locale siempre que las URL de ese locale lleven prefijo (en/my-post para un locale que no es el predeterminado, my-post para el predeterminado, salvo que se establezca prefixDefaultLocale). Úsalo tal cual cuando las entradas se enrutan en la raíz del sitio, como /${entry.id}.
  • entry.data.slug es el slug sin locale. Úsalo cuando la ruta ya añade el segmento del locale, y cuando pasas un slug a getEmDashEntry().
  • entry.data.id es el ID de base de datos. Úsalo para operaciones de API y para los helpers que se refieren a una fila de contenido almacenada, incluidos getTranslations() y getEntryTerms().

Las traducciones tienen IDs de base de datos diferentes porque cada locale es una fila distinta. Su translation_group compartido registra que las filas son traducciones del mismo contenido. EmDash gestiona ese grupo cuando creas una traducción; las plantillas normalmente solo necesitan el ID de base de datos de cualquier fila del grupo.

Consultar contenido traducido

Entrada individual

Pasa Astro.currentLocale a getEmDashEntry en una ruta multilingüe. Astro conoce el locale seleccionado por su enrutador, mientras que EmDash necesita el valor explícito para desambiguar los slugs que pueden existir en más de un locale. Haz lo mismo en las 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.rewrite("/404");
---

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

Cadena de fallback

Cuando no existe una entrada publicada coincidente en el locale solicitado, getEmDashEntry sigue la cadena de fallback de la configuración de Astro. En el modo de vista previa o de edición visual, la misma búsqueda puede devolver un borrador. Dado fallback: { fr: "en" }:

  1. Probar el locale solicitado (fr)
  2. Probar el locale de fallback (en)
  3. Probar el locale predeterminado si aún no está en la cadena

El fallback solo se aplica a las consultas de una sola entrada. Las consultas de lista devuelven únicamente entradas del locale solicitado.

Cada búsqueda de fallback usa el mismo argumento id. Por ejemplo, una solicitud del slug about puede recurrir del francés a una entrada en inglés cuyo slug también sea about. Una solicitud de a-propos no puede descubrir una entrada en inglés cuyo slug sea about; las dos filas usan identificadores públicos diferentes. Usa getTranslations() para encontrar y enlazar variantes de locale con slugs distintos.

Menús

Los menús son por locale: el mismo name (por ejemplo, "primary") puede existir en varios locales, todos vinculados mediante un translation_group compartido. Los elementos del menú resuelven sus referencias de contenido contra la versión del contenido referenciado en el locale activo.

El siguiente componente obtiene el menú principal del 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>

Crea traducciones de un menú existente desde la lista Menus del admin: los elementos se clonan con reference_id intacto (almacena el translation_group del contenido referenciado), de modo que los enlaces del nuevo menú apuntan automáticamente al contenido correcto de cada locale.

Taxonomías (categorías, etiquetas)

Los términos son por locale. Cada locale puede tener su propia definición, así que label y labelSingular también se pueden traducir, mientras que hierarchical y collections son compartidos por todos los locales (consulta Traducir taxonomías y términos). El pivote content_taxonomies.taxonomy_id almacena el translation_group del término, por lo que una sola asignación abarca todos los locales del contenido.

El siguiente ejemplo obtiene las 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,
});
---

Al traducir un contenido se heredan automáticamente las asignaciones de términos del original: solo necesitas traducir los propios términos una vez, y cada publicación que los use se resuelve al locale correcto en el momento de la lectura.

Reparar discrepancias de locale en taxonomías

Cuando el admin carga el manifiesto del sitio, EmDash avisa en los registros del servidor si las definiciones de taxonomía o los términos usan un locale que no está en los i18n.locales configurados del sitio. Sin configuración de 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.

Haz una copia de seguridad de la base de datos y luego inspecciona las filas afectadas que menciona el aviso:

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

Tras confirmar el locale previsto para cada fila, actualízala por id:

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

Usa exactamente las mayúsculas y minúsculas de i18n.locales. Antes de actualizar, comprueba si existe una fila con el mismo nombre de taxonomía y el mismo locale de destino, o con el mismo nombre de término, slug y locale de destino. Esas combinaciones son únicas; si ya existe una fila de destino, concilia las traducciones en lugar de aplicar una actualización masiva de locales. Reinicia EmDash y confirma que el aviso ya no aparece.

Listado de colección

Filtra 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

Usa getTranslations para construir un selector de idioma que enlace a las 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 del 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

Abre cualquier entrada de contenido en el editor. La barra lateral muestra un panel Translations con todos los locales configurados. Para cada locale:

  • “Translate” aparece en los locales sin traducción: haz clic para crear una
  • “Edit” aparece en los locales con una traducción existente: haz clic para ir a ella
  • El locale actual se marca con una marca de verificación

Al crear una traducción, la nueva entrada se rellena previamente con los datos del locale de origen y se le asigna un slug predeterminado {source-slug}-{locale}. Ajusta el slug y el contenido según sea necesario y luego guarda.

Publicación por locale

Cada traducción tiene su propio estado. Publica, despublica o programa las traducciones de forma independiente. La versión en francés puede estar en borrador mientras la versión en inglés está en vivo.

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 una sola entrada también lo acepta cuando la ruta usa un slug; los IDs de base de datos son únicos globalmente y no necesitan desambiguación por 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

Crea 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 de origen, como entry.data.id. La nueva entrada comparte el translation_group de la entrada de origen y empieza como borrador.

Listar traducciones

Recupera todas las traducciones de una entrada determinada:

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

Tras autenticar la CLI, usa sus indicadores --locale en los comandos de contenido:

# 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 requiere entrada mediante --data, --file o --stdin. Publica tras la creación a menos que pases --draft.

Sembrar contenido multilingüe

Los archivos seed expresan las traducciones con 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 de origen debe aparecer antes que sus traducciones en el archivo seed para que las referencias translationOf se resuelvan correctamente.

Elegir qué campos son traducibles

Cada campo tiene un ajuste translatable (valor predeterminado: true). Al crear una traducción:

  • Los campos traducibles se rellenan previamente desde el locale de origen para su edición
  • Los 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 a las demás traducciones los valores no traducibles que cambió, y guardar un borrador solo modifica esa entrada. Si otra traducción tiene un borrador pendiente que cambió uno de esos valores, el borrador conserva 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 de referencia se comparte en lugar de sincronizarse: EmDash indexa sus vínculos por el grupo de traducción, de modo que cada traducción de una entrada tiene una única selección, y editarla desde cualquier locale la cambia para todas.

Construir URLs de locale

EmDash almacena el locale; Astro gestiona el enrutamiento público. La configuración compatible de EmDash deja el locale predeterminado sin prefijo:

# prefix-other-locales (Astro default)
/blog/my-post          → en (default locale, no prefix)
/fr/blog/mon-article   → fr

Usa getRelativeLocaleUrl de astro:i18n para añadir el prefijo correcto y cualquier asignación personalizada de rutas de locale. No actives un prefijo para el locale predeterminado; como se describe en Configurar locales, esa estrategia de enrutamiento impide que se carguen las páginas del admin inyectadas.

Sitemaps

El sitemap por colección en /sitemap-{collection}.xml tiene en cuenta el locale. Incluye las entradas publicadas de colecciones enrutables con SEO habilitado. Las entradas eliminadas, las entradas sin slug y las entradas marcadas como noindex se excluyen. Cada traducción incluida se convierte en su propia entrada <url>. EmDash construye su ruta a partir del urlPattern de la colección y luego aplica el prefijo de locale de Astro y cualquier asignación personalizada de path de locale.

Las traducciones hermanas se enlazan entre sí 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 todas las demás variantes publicadas e indexables. Los locales que faltan en i18n.locales se omiten porque Astro no tiene una ruta para ellos. Los sitios con un solo locale generan un sitemap simple sin el espacio de nombres xhtml.

Agregar enlaces hreflang al encabezado de la página

Los mismos alternates pertenecen al <head> de cada página de contenido. Si tu layout usa <EmDashHead>, esto es automático: cuando i18n está habilitado y el contexto de la página incluye content, emite un <link rel="alternate"> por cada traducción hermana publicada —incluido un enlace autorreferencial, como recomienda Google— además de 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 escritos a mano, resuelve 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.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>

El comportamiento coincide con el del sitemap:

  • x-default apunta a la variante del locale predeterminado. Cuando el locale predeterminado no tiene una traducción publicada, recurre a la primera variante enrutable, de modo que el conjunto nunca carece de x-default.
  • Los hermanos sin publicar se excluyen — las traducciones en borrador nunca se filtran a los alternates.
  • Los hermanos noindex se excluyen. Si la entrada actual es noindex, no se devuelve ningún alternate.
  • Los locales no enrutables se descartan. Una fila cuyo locale no está en tus i18n.locales configurados no se puede servir, y dirigir a los motores de búsqueda a un 404 es peor que no incluir ningún enlace.
  • Las entradas sin traducir siguen recibiendo un alternate autorreferencial y x-default cuando i18n está habilitado, igual que en el sitemap.
  • Con i18n deshabilitado, el resultado está vacío y no se ejecuta ninguna consulta.

Las URL se construyen a partir del urlPattern de la colección y se localizan mediante la configuración i18n de Astro. getHreflangAlternates() necesita una URL de sitio absoluta. Usa siteUrl de la llamada o la URL de los ajustes del sitio; sin ninguna de las dos, devuelve un array vacío porque los enlaces hreflang deben ser absolutos.

Importar contenido multilingüe

Importa contenido de WordPress mediante la herramienta de migración del admin; consulta Importación de contenido y Migrar desde WordPress. Una exportación WXR no incluye la estructura de locales y grupos de traducción que añaden WPML o Polylang, por lo que el contenido importado queda en tu locale predeterminado.

Para construir traducciones a partir del contenido importado, crea la entrada traducida como borrador y vincúlala al ID de base de datos original:

emdash content create posts \
  --locale fr \
  --translation-of 01ABC... \
  --slug mon-article \
  --data '{"title":"Mon article"}' \
  --draft

Es la misma relación --locale y --translation-of que usan los archivos seed, aplicada una vez completada la importación.

Próximos pasos