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 gestiona el enrutamiento de URLs y la detección de locale; EmDash gestiona el 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.

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 i18n están deshabilitadas y EmDash se comporta como un CMS monolingüe.

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 mediante 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 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 listado 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, 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 se aplica a consultas de entrada individual. Las consultas de listado devuelven entradas solo para el locale solicitado.

Menús

Los menús son por locale — el mismo name (p. ej. "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 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 Menus 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. La columna 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.id, undefined, {
  locale: Astro.currentLocale,
});
---

Al traducir un contenido, se heredan automáticamente las asignaciones de términos de la fuente — solo necesitas traducir los términos mismos una vez, y cada publicación que los use los 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="Language">
  <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 Translations con todos los locales configurados. Para cada locale:

  • “Translate” aparece para locales sin traducción — haz clic para crear una
  • “Edit” aparece para locales con 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 rellena previamente con datos del locale fuente y se le asigna un slug predeterminado de {source-slug}-{locale}. Ajusta el slug y el contenido según sea necesario y 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 vivo.

API de contenido

Parámetro 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 por defecto 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

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

CLI

La CLI admite flags --locale en los 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 en 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 desde el locale fuente para editar
  • 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 URL

EmDash no gestiona las URLs de locale — Astro gestiona 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 mediante 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 sirvan 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 posteriormente (una nueva variante de locale de una publicación existente) aparece automáticamente como alternativa en cada otra variante. Los sitios con un solo locale producen un sitemap simple sin namespace xhtml.

Importar contenido multilingüe

Importa contenido de WordPress a través de la herramienta de migración del admin — consulta Content Import y Migrate from WordPress. Una exportación WXR no incluye la estructura de locale y grupo de traducción que añaden WPML o Polylang, por lo que el contenido importado llega a tu locale predeterminado.

Para construir traducciones a partir 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 completar la importación.

Próximos pasos