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 URL y la detección de locale; EmDash maneja el almacenamiento y 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 francesa de una publicación 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 de i18n están deshabilitadas y EmDash se comporta como un CMS de un solo idioma.

Cómo funcionan las traducciones

EmDash utiliza un modelo 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 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 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, por defecto usa 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 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 mediante 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), 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 se pueden traducir. El 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,
});
---

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 publicación que los use se resolverá 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" },
// ]

Gestión de 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 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 de origen y se le asigna un slug predeterminado de {slug-origen}-{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 francesa 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, por defecto 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

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

La nueva entrada comparte el translation_group de la entrada de origen 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 publicaciones 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...

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

Traducibilidad de campos

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

  • Campos traducibles se rellenan previamente desde el locale de origen 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 siempre son 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.

Importar contenido multilingüe

Importa contenido de WordPress a través de la herramienta de migración del admin — ver Importar contenido y Migrar desde WordPress. Una exportación WXR no incluye la estructura de locale y grupo de traducción que WPML o Polylang agregan, 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 Sembrado de contenido multilingüe arriba, aplicado después de que se complete la importación.

Próximos pasos