Internacionalización (i18n)

En esta página

EmDash se integra con el enrutamiento i18n integrado de Astro para proporcionar gestión de contenido multilingüe. Astro maneja el enrutamiento de URLs y la detección de locale; EmDash maneja el almacenamiento y la 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 en francés de un post puede estar en borrador mientras la versión en inglés está publicada.

Configuración

Habilita i18n agregando un bloque i18n a tu 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 i18n se desactivan 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 translation_group compartido. 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-post y /fr/blog/mon-article funcionan naturalmente
  • 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 solo para un locale

Consultar contenido traducido

Entrada individual

Pasa locale a getEmDashEntry para recuperar una traducción específica. Cuando se omite, se usa por defecto el locale actual de la solicitud (establecido por el middleware i18n de 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>

Cadena de fallback

Cuando no existe contenido para el locale solicitado, EmDash sigue la cadena de fallback definida en tu configuración de Astro. Dado fallback: { fr: "en" }:

  1. Intenta el locale solicitado (fr)
  2. Intenta el locale de fallback (en)
  3. Intenta el locale predeterminado

El fallback solo aplica a consultas de entrada individual. Las consultas de lista devuelven entradas solo para el locale solicitado.

Menús

Los menús son por locale — el mismo name (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 del 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>

Crea 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), así 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. Las definiciones (_emdash_taxonomy_defs) también son por locale, así que label / labelSingular pueden traducirse también. El pivote content_taxonomies.taxonomy_id almacena el translation_group del término, así que una sola asignación abarca todos los locales del contenido.

El siguiente ejemplo obtiene categorías y los términos de un post para el locale activo:

---
import { getTaxonomyTerms, getEntryTerms } from "emdash";

const categories = await getTaxonomyTerms("category", {
  locale: Astro.currentLocale,
});
const terms = await getEntryTerms("posts", post.id, undefined, {
  locale: Astro.currentLocale,
});
---

Traducir un contenido hereda automáticamente las asignaciones de términos de la fuente — solo necesitas traducir los términos mismos una vez, y cada post que los usa 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ías o 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 debería usar el contenido existente.

Haz una copia de seguridad de la base de datos, luego inspecciona 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ízala por id:

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

Usa la misma escritura de i18n.locales. Antes de actualizar, verifica si existe una fila con el mismo nombre de taxonomía y locale destino, o el mismo nombre de término, slug y locale destino. Esas combinaciones son únicas; si ya existe una fila destino, reconcilia las traducciones en lugar de aplicar una actualización masiva de locale. Reinicia EmDash y confirma que la advertencia ya no aparece.

Listado de colecciones

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.data.slug}`}>{post.data.title}</a></li>
  ))}
</ul>

Selector de idioma

Usa 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);
---

<nav aria-label="Idioma">
  <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 función getTranslations devuelve todas las variantes de locale en el mismo grupo de traducción:

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" },
// ]

Gestionar traducciones en el admin

Lista de contenido

Cuando i18n está habilitado, la lista de contenido muestra:

  • Una columna de locale mostrando 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 de Traducciones listando todos los locales configurados. Para cada locale:

  • “Traducir” aparece para locales sin traducción — haz clic para crear una
  • “Editar” aparece para locales con traducción existente — haz clic para navegar a ella
  • El locale actual está marcado con un check

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

Publicación por locale

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

API de contenido

Parámetro de locale

Todas las rutas de la API de contenido aceptan un parámetro de consulta locale opcional:

GET /_emdash/api/content/posts?locale=fr
GET /_emdash/api/content/posts/my-post?locale=fr

Cuando se omite, usa el locale predeterminado configurado.

Crear traducciones via 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

{
  "locale": "fr",
  "translationOf": "01ABC...",
  "data": {
    "title": "Mon Article",
    "slug": "mon-article"
  }
}

La nueva entrada comparte el translation_group de la entrada fuente y comienza como borrador.

Listar traducciones

Recupera todas las traducciones de 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.

CLI

La CLI soporta flags --locale en comandos de contenido:

# Listar posts 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 de una entrada existente
emdash content create posts --locale fr --translation-of 01ABC...

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.

Traducibilidad de campos

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

  • Los campos traducibles se rellenan previamente del locale fuente para edición
  • Los campos no traducibles se copian y se mantienen sincronizados en todas las traducciones del grupo

Los campos del sistema como status, published_at y author_id son siempre por locale y nunca se sincronizan.

Estrategia de URLs

EmDash no gestiona URLs de locale — Astro maneja el enrutamiento. Patrones comunes:

# prefix-other-locales (predeterminado de Astro)
/blog/my-post          → en (locale predeterminado, sin prefijo)
/fr/blog/mon-article   → fr

# prefix-always
/en/blog/my-post       → en
/fr/blog/mon-article   → fr

Usa getRelativeLocaleUrl de astro:i18n para construir URLs correctas independientemente del modo de enrutamiento.

Sitemaps

El sitemap por colección en /sitemap-{collection}.xml es consciente del locale. Cuando i18n está habilitado, cada traducción se emite como su propia entrada <url>, con el prefijo de locale resuelto a través del getRelativeLocaleUrl de Astro. Tu configuración prefixDefaultLocale y cualquier mapeo personalizado de path de locale se respetan automáticamente.

Los hermanos de traducción se enlazan cruzadamente con alternativas 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, así que una fila agregada después (una nueva variante de locale de un post existente) aparece automáticamente como alternativa en todas las demás variantes. Los sitios con un solo locale producen un sitemap simple sin namespace xhtml.

Alternativas hreflang en el head de la página

Las mismas alternativas 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 hermano de traducción publicado — incluyendo un enlace auto-referenciado, 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 heads escritos a mano, resuelve las alternativas con 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>

El comportamiento coincide exactamente con el sitemap:

  • x-default apunta a la variante del locale predeterminado. Cuando el locale predeterminado no tiene traducción publicada, recurre a la primera variante enrutable, así que el conjunto nunca carece de un x-default.
  • Los hermanos no publicados se excluyen — las traducciones en borrador nunca se filtran en las alternativas.
  • Los locales no enrutables se eliminan. Una fila cuyo locale no está en tus i18n.locales configurados no puede servirse, y enlazar motores de búsqueda a un 404 es peor que no tener enlace.
  • Las entradas no traducidas aún obtienen una alternativa auto-referenciada y x-default cuando i18n está habilitado, reflejando el sitemap.
  • Con i18n deshabilitado, el resultado está vacío y no se ejecutan consultas.

Las URLs se construyen desde el urlPattern de la colección y se localizan a través de tu configuración de enrutamiento i18n de Astro (prefixDefaultLocale, mapeos personalizados de path de locale), así que head y sitemap siempre coinciden.

Importar contenido multilingüe

Importa contenido de WordPress a través de la herramienta de migración del admin — ve 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, así que el contenido importado aterriza en tu locale predeterminado.

Para construir traducciones del contenido importado, crea la entrada traducida y vincúlala al original:

emdash content create posts --locale fr --translation-of 01ABC...

Este es el mismo flujo de trabajo --locale / --translation-of mostrado en Sembrar contenido multilingüe arriba, aplicado después de que el import se complete.

Próximos pasos