Internacionalização (i18n)

Nesta página

EmDash integra-se com o roteamento i18n integrado do Astro para fornecer gerenciamento de conteúdo multilíngue. O Astro lida com o roteamento de URLs e a detecção de locales; o EmDash lida com o armazenamento e a recuperação de conteúdo traduzido.

Cada tradução é uma entrada de conteúdo completa e independente, com seu próprio slug, status e histórico de revisões. A versão francesa de um post pode estar em rascunho enquanto a versão inglesa está publicada.

Configurar locales

Habilite o i18n adicionando um bloco i18n à sua configuração do Astro. O EmDash lê esta mesma configuração para sua lista de locales, locale padrão e cadeia 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",
			}),
		}),
	],
});

Quando i18n não está presente na configuração do Astro, todos os recursos de i18n ficam desabilitados e o EmDash se comporta como um CMS de idioma único.

Como as traduções funcionam

O EmDash usa um modelo de linha por locale. Cada tradução é sua própria linha no banco de dados, com seu próprio ID, slug e status, vinculada a outras traduções por meio de um identificador compartilhado translation_group. Uma tabela de posts com três traduções fica assim:

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 design implica:

  • Slugs por locale — /blog/my-post e /fr/blog/mon-article funcionam naturalmente
  • Publicação por locale — publique a versão em inglês enquanto mantém a francesa em rascunho
  • Revisões por locale — cada tradução tem seu próprio histórico de revisões
  • Consultas de locale único — consultas de lista retornam entradas de apenas um locale

Slugs, IDs de entrada e IDs de banco de dados

Uma entrada tem dois identificadores com propósitos diferentes:

  • entry.id é o slug da entrada. Use-o ao construir a URL pública.
  • entry.data.id é o ID do banco de dados. Use-o para operações de API e helpers que se referem a uma linha de conteúdo armazenada, incluindo getTranslations() e getEntryTerms().

Traduções têm IDs de banco de dados diferentes porque cada locale é uma linha separada. O translation_group compartilhado registra que as linhas são traduções do mesmo conteúdo. O EmDash gerencia esse grupo quando você cria uma tradução; templates normalmente só precisam do ID de banco de dados de qualquer linha do grupo.

Consultar conteúdo traduzido

Entrada única

Passe Astro.currentLocale para getEmDashEntry em uma rota multilíngue. O Astro conhece o locale selecionado pelo roteador; o EmDash precisa do valor explícito para desambiguar slugs que podem existir em mais de um locale. Faça o mesmo para consultas de coleção.

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

Cadeia de fallback

Quando não existe uma entrada publicada correspondente no locale solicitado, getEmDashEntry segue a cadeia de fallback da configuração do Astro. No modo de preview ou edição visual, a mesma busca pode retornar um rascunho. Com fallback: { fr: "en" }:

  1. Tente o locale solicitado (fr)
  2. Tente o locale de fallback (en)
  3. Tente o locale padrão se ele ainda não estiver na cadeia

O fallback só se aplica a consultas de entrada única. Consultas de lista retornam entradas apenas para o locale solicitado.

Cada busca de fallback usa o mesmo argumento id. Por exemplo, uma requisição para o slug about pode fazer fallback do francês para uma entrada em inglês cujo slug também é about. Uma requisição para a-propos não pode descobrir uma entrada em inglês cujo slug é about; as duas linhas usam identificadores públicos diferentes. Use getTranslations() para encontrar e vincular variantes de locale com slugs diferentes.

Menus são por locale — o mesmo name (p. ex. "primary") pode existir em vários locales, todos vinculados por um translation_group compartilhado. Itens de menu resolvem suas referências de conteúdo contra a versão, no locale ativo, do conteúdo referenciado.

O componente a seguir busca o menu principal para o locale ativo:

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

Crie traduções de um menu existente a partir da lista Menus do admin — os itens são clonados com reference_id intacto (ele armazena o translation_group do conteúdo referenciado), de modo que os links do novo menu apontam automaticamente para o conteúdo correto em cada locale.

Taxonomias (categorias, tags)

Termos são por locale. Cada locale pode ter sua própria definição, de modo que label e labelSingular também podem ser traduzidos, enquanto hierarchical e collections são compartilhados por todos os locales (veja Traduzir taxonomias e termos). O pivot content_taxonomies.taxonomy_id armazena o translation_group do termo, de modo que uma única atribuição abrange todos os locales do conteúdo.

O exemplo a seguir busca categorias e os termos de um post para o locale ativo:

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

Traduzir um conteúdo herda automaticamente as atribuições de termos da fonte — você só precisa traduzir os termos em si uma vez, e cada post que os usa resolve para o locale correto no momento da leitura.

Reparar discrepâncias de locale em taxonomias

Quando o admin carrega o manifesto do site, o EmDash avisa nos logs do servidor quando definições de taxonomia ou termos usam um locale que não está em i18n.locales configurados do site. Sem uma configuração i18n, en é o locale efetivo. Essas linhas permanecem inalteradas porque o EmDash não pode inferir qual locale configurado o conteúdo existente deveria usar.

Faça backup do banco de dados e inspecione as linhas afetadas mencionadas no aviso:

SELECT id, name, locale FROM _emdash_taxonomy_defs ORDER BY name, locale;
SELECT id, name, slug, locale FROM taxonomies ORDER BY name, slug, locale;

Depois de confirmar o locale pretendido para cada linha, atualize-o por id:

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

Use a capitalização exata de i18n.locales. Antes de atualizar, verifique se já existe uma linha com o mesmo nome de taxonomia e locale de destino, ou o mesmo nome de termo, slug e locale de destino. Essas combinações são únicas; se uma linha de destino já existir, reconcilie as traduções em vez de aplicar uma atualização em massa de locale. Reinicie o EmDash e confirme que o aviso não aparece mais.

Listagem de coleção

Filtre uma coleção 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 um seletor de idioma

Use getTranslations para construir um seletor de idioma que vincula às traduções existentes da entrada atual:

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

A função getTranslations retorna todas as variantes de locale no mesmo grupo de tradução:

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

Gerenciar traduções no admin

Lista de conteúdo

Quando o i18n está habilitado, a lista de conteúdo mostra:

  • Uma coluna de locale exibindo o locale de cada entrada
  • Um filtro de locale na barra de ferramentas para alternar entre locales

Criar traduções

Abra qualquer entrada de conteúdo no editor. A barra lateral exibe um painel Translations listando todos os locales configurados. Para cada locale:

  • “Translate” aparece para locales sem tradução — clique para criar uma
  • “Edit” aparece para locales com tradução existente — clique para abri-la
  • O locale atual é marcado com um check

Ao criar uma tradução, a nova entrada é pré-preenchida com dados do locale de origem e recebe um slug padrão {source-slug}-{locale}. Ajuste o slug e o conteúdo conforme necessário e salve.

Publicação por locale

Cada tradução tem seu próprio status. Publique, despublique ou agende traduções de forma independente. A versão francesa pode estar em rascunho enquanto a versão inglesa está no ar.

Usar a API de conteúdo

Parâmetro locale

Rotas da API de conteúdo exigem sessão autenticada ou token bearer. Rotas de lista aceitam um parâmetro de consulta opcional locale. Uma rota de entrada única também o aceita quando o caminho usa um slug; IDs de banco de dados são globalmente únicos e não precisam de desambiguação por locale.

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

Quando uma requisição de lista omite locale, ela usa o locale padrão configurado.

Criar traduções via API

Crie uma tradução passando locale e translationOf para o endpoint de criação de conteúdo:

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 é o ID de banco de dados da linha de origem, como entry.data.id. A nova entrada compartilha o translation_group da entrada de origem e começa como rascunho.

Listar traduções

Recupere todas as traduções de uma entrada:

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

Retorna o ID do grupo de tradução e um array de variantes de locale com seus IDs, slugs e status.

Usar a CLI

Depois de autenticar a CLI, use as flags --locale nos comandos de conteúdo:

# Listar posts em francês
emdash content list posts --locale fr

# Obter uma entrada específica em francês
emdash content get posts my-post --locale fr

# Criar uma tradução francesa como rascunho
emdash content create posts \
  --locale fr \
  --translation-of 01ABC... \
  --slug mon-article \
  --data '{"title":"Mon article"}' \
  --draft

content create exige entrada de --data, --file ou --stdin. Publica após a criação, a menos que você passe --draft.

Semear conteúdo multilíngue

Arquivos seed expressam traduções com locale e 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" }
      }
    ]
  }
}

A entrada do locale de origem deve aparecer antes das traduções no arquivo seed para que as referências translationOf se resolvam corretamente.

Escolher quais campos são traduzíveis

Cada campo tem a configuração translatable (padrão: true). Ao criar uma tradução:

  • Campos traduzíveis são pré-preenchidos a partir do locale de origem para edição
  • Campos não traduzíveis são copiados e mantidos sincronizados em todas as traduções do grupo

Em uma coleção com revisões, publicar uma entrada copia para as outras traduções os valores não traduzíveis que ela alterou; salvar um rascunho altera apenas essa entrada. Se outra tradução tiver um rascunho pendente que alterou um desses valores, o rascunho mantém seu próprio valor, e publicar essa tradução o copia para o restante do grupo.

Campos de sistema como status, published_at e author_id são sempre por locale e nunca sincronizados.

Um campo de referência é compartilhado em vez de sincronizado: o EmDash indexa suas ligações pelo grupo de tradução, de modo que cada tradução de uma entrada tem uma única seleção, e editá-lo a partir de qualquer locale altera-a para todas.

Construir URLs de locale

O EmDash armazena o locale; o Astro lida com o roteamento público. A configuração suportada do EmDash deixa o locale padrão sem prefixo:

# prefix-other-locales (padrão do Astro)
/blog/my-post          → en (locale padrão, sem prefixo)
/fr/blog/mon-article   → fr

Use getRelativeLocaleUrl de astro:i18n para adicionar o prefixo correto e qualquer mapeamento personalizado de caminho de locale. Não habilite prefixo no locale padrão; como descrito em Configurar locales, essa estratégia de roteamento impede o carregamento das páginas do admin injetadas.

Sitemaps

O sitemap por coleção em /sitemap-{collection}.xml é consciente de locale. Inclui entradas publicadas de coleções roteáveis com SEO habilitado. Entradas eliminadas, entradas sem slug e entradas marcadas como noindex ficam de fora. Cada tradução incluída vira sua própria entrada <url>. O EmDash constrói o caminho a partir do urlPattern da coleção e aplica o prefixo de locale do Astro e qualquer mapeamento personalizado de path de locale.

Irmãos de tradução são interligados com alternates xhtml:link para que os mecanismos de busca possam servir o idioma correto a cada usuário:

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

Irmãos são agrupados por translation_group, de modo que uma variante de locale publicada aparece como alternate em toda outra variante publicada e indexável. Locales ausentes de i18n.locales são omitidos porque o Astro não tem rota para eles. Sites com um único locale produzem um sitemap simples, sem namespace xhtml.

Os mesmos alternates devem estar no <head> de cada página de conteúdo. Se o layout usa <EmDashHead>, isso é automático: quando o i18n está habilitado e o contexto da página inclui content, ele emite um <link rel="alternate"> por irmão de tradução publicado — incluindo um link autorreferencial, como o Google recomenda — além de 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 <head> feito manualmente, resolva os alternates com 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>

O comportamento corresponde ao sitemap:

  • x-default aponta para a variante do locale padrão. Quando o locale padrão não tem tradução publicada, faz fallback para a primeira variante roteável, de modo que o conjunto nunca fica sem x-default.
  • Irmãos não publicados são excluídos — traduções em rascunho nunca aparecem nos alternates.
  • Irmãos noindex são excluídos. Se a entrada atual é noindex, nenhum alternate é retornado.
  • Locales não roteáveis são descartados. Uma linha cujo locale não está em i18n.locales configurados não pode ser servida, e vincular mecanismos de busca a um 404 é pior do que não vincular.
  • Entradas não traduzidas ainda recebem um alternate autorreferencial e x-default quando o i18n está habilitado, espelhando o sitemap.
  • Com i18n desabilitado, o resultado é vazio e nenhuma consulta é executada.

URLs são construídas a partir do urlPattern da coleção e localizadas pela configuração i18n do Astro. getHreflangAlternates() precisa de uma URL absoluta do site. Usa siteUrl da chamada ou a URL das configurações do site; sem nenhuma das duas, retorna um array vazio porque links hreflang devem ser absolutos.

Importar conteúdo multilíngue

Importe conteúdo do WordPress pela ferramenta de migração do admin — veja Importação de Conteúdo e Migrar do WordPress. Uma exportação WXR não traz a estrutura de locale e grupo de tradução que WPML ou Polylang adicionam, então o conteúdo importado cai no locale padrão.

Para montar traduções a partir do conteúdo importado, crie a entrada traduzida como rascunho e vincule-a ao ID de banco de dados original:

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

É a mesma relação --locale e --translation-of usada nos arquivos seed, aplicada após a conclusão da importação.

Próximos passos