Internacionalización (i18n)

En esta página

EmDash se integra con el enrutado i18n integrado de Astro para gestionar contenido multilingüe. Astro gestiona las URLs y la detección de idioma; EmDash almacena y recupera las traducciones.

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 entrada puede estar en borrador mientras la versión en inglés está publicada.

Configuración

Activa i18n añadiendo un bloque i18n en la configuración de Astro. EmDash lee esta configuración automáticamente; no hay configuración de idiomas separada en EmDash.

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

Si no hay i18n en la configuración de Astro, todas las funciones i18n están desactivadas y EmDash se comporta como un CMS monolingüe.

Cómo funcionan las traducciones

EmDash usa un modelo de una fila por idioma (locale). Cada traducción es su propia fila en la base de datos con su propio ID, slug y estado, enlazada a otras traducciones mediante un translation_group compartido.

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

Esto implica:

  • Slugs por idioma/blog/my-post y /fr/blog/mon-article encajan de forma natural
  • Publicación por idioma — publicar en inglés y dejar el francés en borrador
  • Revisiones por idioma — cada traducción tiene su propio historial
  • Sin mezclar listas entre idiomas — las consultas de lista devuelven entradas solo para un locale

Consultar contenido traducido

Entrada única

Pasa locale a getEmDashEntry para obtener una traducción concreta. Si se omite, se usa el locale actual de la petición (definido 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 reserva (fallback)

Si no hay contenido para el locale solicitado, EmDash sigue la cadena definida en la configuración de Astro. Con fallback: { fr: "en" }:

  1. Intenta el locale solicitado (fr)
  2. Intenta el locale de reserva (en)
  3. Intenta el locale por defecto

El fallback solo aplica a consultas de entrada única. Las listas devuelven entradas solo para el locale solicitado, sin mezclar.

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

getTranslations devuelve todas las variantes de idioma del 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

Con i18n activado, la lista muestra:

  • Una columna de idioma con el locale de cada entrada
  • Un filtro de idioma en la barra de herramientas

Crear traducciones

Abre cualquier entrada en el editor. En la barra lateral aparece el panel Translations. Para cada locale configurado:

  • «Translate» si aún no hay traducción: al hacer clic se crea
  • «Edit» si ya existe: al hacer clic navegas a esa traducción
  • El locale actual aparece marcado con una marca de verificación

Al crear una traducción, la nueva entrada se rellena con datos del locale de origen y recibe un slug por defecto {slug-origen}-{locale}. Ajusta slug y contenido y guarda.

Publicación por idioma

Cada traducción tiene su propio estado. Publica, retira o programa de forma independiente. El francés puede estar en borrador mientras el inglés está en producción.

API de contenido

Parámetro locale

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

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

Si se omite, se usa el locale por defecto configurado.

Crear traducciones por API

Crea una traducción pasando locale y translationOf al endpoint de creación:

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 fuente y comienza como borrador.

Listar traducciones

Recupera todas las traducciones de una entrada:

GET /_emdash/api/content/posts/01ABC.../translations

Devuelve el ID del grupo de traducción y un array de variantes con sus IDs, slugs y estados.

CLI

La CLI admite la opción --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 of an existing entry
emdash content create posts --locale fr --translation-of 01ABC...

Sembrar contenido multilingüe

Los archivos de seed expresan 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 seed para que translationOf se resuelva correctamente.

Campos traducibles

Cada campo tiene la opción translatable (por defecto true). Al crear una traducción:

  • Los campos traducibles se rellenan desde el locale de origen para editar
  • Los 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 idioma y no se sincronizan.

Estrategia de URL

EmDash no gestiona las URLs por idioma; Astro gestiona el enrutado. Patrones habituales:

# prefix-other-locales (Astro default)
/blog/my-post          → en (default locale, no prefix)
/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 en cualquier modo de enrutado.

Importar contenido multilingüe

WordPress con WPML o Polylang

El origen de importación de WordPress detecta WPML y Polylang automáticamente. Si se detectan, el contenido importado incluye metadatos de locale y grupo de traducción.

Archivos WXR

Las exportaciones WXR no incluyen metadatos de WPML/Polylang. Importa como un solo locale y crea traducciones a mano, o usa --locale para asignar un locale a todos los elementos importados:

# Import a French WXR export
emdash import wordpress export-fr.xml --execute --locale fr

# Match against existing English content by slug
emdash import wordpress export-fr.xml --execute --locale fr --translation-of-locale en

Próximos pasos