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 añadiendo 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 de i18n están deshabilitadas 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 propia 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-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 por locale — las consultas de lista devuelven entradas para un solo locale

Consultar contenido traducido

Entrada individual

Pasa locale a getEmDashEntry para recuperar una traducción específica. Cuando se omite, se establece por defecto al 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 se 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 contenido referenciado del locale activo.

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="Principal">
  <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), por lo que los enlaces del nuevo menú apuntan automáticamente al contenido correcto por locale.

Taxonomías (categorías, etiquetas)

Los términos son por locale. Las definiciones (_emdash_taxonomy_defs) también son por locale, por lo que label / labelSingular también pueden traducirse. 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 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 en sí una vez, y cada post que los usa se resuelve al locale correcto en tiempo de lectura.

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

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

<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 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 de Traducciones que lista todos los locales configurados. Para cada locale:

  • “Traducir” aparece para locales sin traducción — haz clic para crear una
  • “Editar” aparece para locales con una traducción existente — haz 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-llena con datos del locale fuente y se le asigna un slug predeterminado de {slug-fuente}-{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 independientemente. La versión en francés puede estar en borrador mientras la versión en inglés está en vivo.

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, se establece por defecto al 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

{
  "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 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.

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:

  • Campos traducibles se pre-llenan desde el locale fuente para edición
  • Campos no traducibles se copian y 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 URL

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 de getRelativeLocaleUrl de Astro. Tu configuración de prefixDefaultLocale y cualquier mapeo personalizado de path de locale se respetan automáticamente.

Los hermanos de traducción se cruzan 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 fila añadida después (una nueva variante de locale de un post existente) aparece automáticamente como alternate en cada otra variante. Los sitios con un solo locale producen un sitemap simple sin namespace xhtml.

Alternates hreflang en el head 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 hermano de traducción publicado — incluyendo un enlace auto-referencial, 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 hechos a mano, resuelve los alternates 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, por lo que el conjunto nunca carece de un x-default.
  • Los hermanos no publicados se excluyen — las traducciones en borrador nunca se filtran a los alternates.
  • Los locales no enrutables se descartan. 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 un alternate auto-referencial 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 a partir del 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), por lo 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 — consulta Importar contenido y Migrar desde WordPress. Una exportación WXR no lleva la estructura de locale y grupo de traducción que WPML o Polylang añaden, por lo que el contenido importado llega a 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 arriba en Sembrar contenido multilingüe, aplicado después de que se complete la importación.

Próximos pasos