Internacionalización (i18n)

En esta página

EmDash se integra con el enrutamiento i18n incorporado de Astro para proporcionar gestión de contenido multilingüe. Astro se encarga del enrutamiento de URLs y la detección de locales; EmDash se encarga del 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 inglesa está publicada.

Configurar locales

Habilite i18n agregando un bloque i18n a su 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 se deshabilitan 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 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 — publique la versión en inglés mientras mantiene la francesa 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 para un solo locale

Slugs, IDs de entrada e IDs de base de datos

Una entrada tiene dos identificadores con diferentes propósitos:

  • entry.id es el slug de la entrada. Úselo al construir la URL pública.
  • entry.data.id es el ID de la base de datos. Úselo para operaciones de API y helpers que se refieren a una fila de contenido almacenada, incluyendo getTranslations() y getEntryTerms().

Las traducciones tienen diferentes IDs de base de datos porque cada locale es una fila separada. Su translation_group compartido registra que las filas son traducciones del mismo contenido. EmDash gestiona ese grupo cuando crea una traducción; los templates normalmente solo necesitan el ID de base de datos de cualquier fila en el grupo.

Consultar contenido traducido

Entrada individual

Pase Astro.currentLocale a getEmDashEntry en una ruta multilingüe. Astro conoce el locale seleccionado por su router, mientras que EmDash necesita el valor explícito para desambiguar slugs que pueden existir en más de un locale. Haga lo mismo para consultas de colección.

---
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 una entrada publicada coincidente no existe en el locale solicitado, getEmDashEntry sigue la cadena de fallback de la configuración de Astro. En modo de vista previa o edición visual, la misma búsqueda puede devolver un borrador. Dado fallback: { fr: "en" }:

  1. Intente el locale solicitado (fr)
  2. Intente el locale de fallback (en)
  3. Intente el locale predeterminado si no está ya en la cadena

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

Cada búsqueda de fallback usa el mismo argumento id. Por ejemplo, una solicitud para el slug about puede caer del francés a una entrada en inglés cuyo slug también es about. Una solicitud para a-propos no puede descubrir una entrada en inglés cuyo slug sea about; las dos filas usan identificadores públicos diferentes. Use getTranslations() para encontrar y vincular variantes de locale con diferentes slugs.

Menús

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

Cree 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 al contenido correcto por locale automáticamente.

Taxonomías (categorías, etiquetas)

Los términos son por locale. Cada locale puede tener su propia definición, por lo que label y labelSingular también pueden traducirse, mientras que hierarchical y collections se comparten en todos los locales (consulte Traducir taxonomías y términos). 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.data.id, undefined, {
  locale: Astro.currentLocale,
});
---

Traducir un contenido hereda automáticamente las asignaciones de términos de la fuente — solo necesita traducir los términos en sí una vez, y cada publicación que los use se 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ía o los 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 debía usar el contenido existente.

Haga una copia de seguridad de la base de datos, luego inspeccione 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ícelo por id:

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

Use la capitalización exacta de i18n.locales. Antes de actualizar, verifique si existe una fila con el mismo nombre de taxonomía y locale objetivo, o el mismo nombre de término, slug y locale objetivo. Esas combinaciones son únicas; si ya existe una fila objetivo, concilie las traducciones en lugar de aplicar una actualización masiva de locale. Reinicie EmDash y confirme que la advertencia ya no aparece.

Listado de colección

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

Construir un selector de idioma

Use 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);
const publishedTranslations = translations.filter(
  (translation): translation is typeof translation & { slug: string } =>
    translation.status === "published" && translation.slug !== null
);
---

<nav aria-label="Language">
  <ul>
    {publishedTranslations.map((translation) => (
      <li>
        <a
          href={getRelativeLocaleUrl(translation.locale, `/blog/${translation.slug}`)}
          aria-current={translation.locale === Astro.currentLocale ? "page" : undefined}
        >
          {translation.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.data.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

Abra cualquier entrada de contenido en el editor. La barra lateral muestra un panel de Traducciones que lista todos los locales configurados. Para cada locale:

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

Publicación por locale

Cada traducción tiene su propio estado. Publique, despublique o programe traducciones de forma independiente. La versión francesa puede estar en borrador mientras la versión inglesa está activa.

Usar la API de contenido

Parámetro locale

Las rutas de la API de contenido requieren una sesión autenticada o un token bearer. Las rutas de lista aceptan un parámetro de consulta locale opcional. Una ruta de entrada individual también lo acepta cuando la ruta usa un slug; los IDs de base de datos son globalmente únicos y no necesitan desambiguación de locale.

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

Cuando una solicitud de lista omite locale, usa el locale predeterminado configurado.

Crear traducciones vía API

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

POST /_emdash/api/content/posts
Content-Type: application/json
X-EmDash-Request: 1

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

translationOf es el ID de base de datos de la fila fuente, como entry.data.id. La nueva entrada comparte el translation_group de la entrada fuente y comienza como borrador.

Listar traducciones

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

Usar la CLI

Después de autenticar la CLI, use sus flags --locale en los 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 como borrador
emdash content create posts \
  --locale fr \
  --translation-of 01ABC... \
  --slug mon-article \
  --data '{"title":"Mon article"}' \
  --draft

content create requiere entrada de --data, --file o --stdin. Publica después de la creación a menos que pase --draft.

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.

Elegir qué campos son traducibles

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

  • Campos traducibles se pre-rellenan del locale fuente para edición
  • Campos no traducibles se copian y se mantienen sincronizados en todas las traducciones del grupo

En una colección con revisiones, publicar una entrada copia los valores no traducibles que cambió a las otras traducciones, y guardar un borrador cambia solo esa entrada. Si otra traducción tiene un borrador pendiente que cambió uno de esos valores, el borrador mantiene su propio valor, y publicar esa traducción lo copia al resto del grupo.

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

Un campo reference se comparte en lugar de sincronizarse: EmDash vincula sus enlaces por el translation_group, por lo que cada traducción de una entrada tiene una sola selección, y editarla desde cualquier locale la cambia para todas.

Construir URLs de locale

EmDash almacena el locale; Astro se encarga del enrutamiento público. La configuración soportada de EmDash deja el locale predeterminado sin prefijo:

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

Use getRelativeLocaleUrl de astro:i18n para agregar el prefijo correcto y cualquier mapeo de ruta de locale personalizado. No habilite un prefijo de locale predeterminado; como se describe en Configurar locales, esa estrategia de enrutamiento evita que se carguen las páginas de admin inyectadas.

Sitemaps

El sitemap por colección en /sitemap-{collection}.xml es consciente del locale. Incluye entradas publicadas de colecciones enrutables habilitadas para SEO. Las entradas eliminadas, entradas sin slug y entradas marcadas como noindex se excluyen. Cada traducción incluida se convierte en su propia entrada <url>. EmDash construye su ruta desde el urlPattern de la colección, luego aplica el prefijo de locale de Astro y cualquier mapeo de path de locale personalizado.

Los hermanos de traducción están vinculados 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 variante de locale publicada aparece como alternate en cada otra variante publicada e indexable. Los locales que faltan en i18n.locales se omiten porque Astro no tiene ruta para ellos. Los sitios con un solo locale producen un sitemap simple sin namespace xhtml.

Agregar enlaces hreflang al encabezado de la página

Los mismos alternates pertenecen al <head> de cada página de contenido. Si su layout usa <EmDashHead>, esto es automático: cuando i18n está habilitado y el contexto de página incluye content, emite un <link rel="alternate"> por hermano de traducción publicado — incluyendo un enlace autorreferencial, 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 encabezados hechos a mano, resuelva los alternates con getHreflangAlternates:

---
import { getEmDashEntry, getHreflangAlternates } from "emdash";

const { entry, error } = await getEmDashEntry("posts", Astro.params.slug, {
	locale: Astro.currentLocale,
});
if (error) return new Response("Server error", { status: 500 });
if (!entry) return Astro.redirect("/404");

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 con el sitemap:

  • x-default apunta a la variante del locale predeterminado. Cuando el locale predeterminado no tiene una 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 hermanos noindex se excluyen. Si la entrada actual es noindex, no se devuelven alternates.
  • Los locales no enrutables se eliminan. Una fila cuyo locale no está en sus i18n.locales configurados no puede servirse, y vincular motores de búsqueda a un 404 es peor que no tener enlace.
  • Las entradas no traducidas aún obtienen un alternate autorreferencial 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 la configuración i18n de Astro. getHreflangAlternates() necesita una URL de sitio absoluta. Usa siteUrl de la llamada o la URL de configuración del sitio; sin ninguna, devuelve un array vacío porque los enlaces hreflang deben ser absolutos.

Importar contenido multilingüe

Importe contenido de WordPress a través de la herramienta de migración del admin — vea 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, por lo que el contenido importado llega a su locale predeterminado.

Para construir traducciones a partir de contenido importado, cree la entrada traducida como borrador y vincúlela al ID de base de datos original:

emdash content create posts \
  --locale fr \
  --translation-of 01ABC... \
  --slug mon-article \
  --data '{"title":"Mon article"}' \
  --draft

Esta es la misma relación --locale y --translation-of utilizada por los archivos seed, aplicada después de que se complete la importación.

Próximos pasos