O EmDash integra-se com o roteamento i18n nativo do Astro para fornecer gerenciamento de conteúdo multilíngue. O Astro lida com o roteamento de URLs e detecção de locale; o EmDash lida com o armazenamento e 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.
Configuração
Habilite i18n adicionando um bloco i18n à sua configuração Astro. O EmDash lê a 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 Astro, todas as funcionalidades i18n são desabilitadas e o EmDash se comporta como um CMS monolíngue.
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 via um identificador translation_group compartilhado. 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 significa:
- Slugs por locale —
/blog/my-poste/fr/blog/mon-articlefuncionam naturalmente - Publicação por locale — publique a versão inglesa mantendo a francesa em rascunho
- Revisões por locale — cada tradução tem seu próprio histórico de revisões
- Consultas mono-locale — consultas de lista retornam entradas para um único locale
Consultando conteúdo traduzido
Entrada única
Passe locale para getEmDashEntry para recuperar uma tradução específica. Quando omitido, usa como padrão o locale atual da requisição (definido pelo middleware i18n do 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>
Cadeia de fallback
Quando não existe conteúdo para o locale solicitado, o EmDash segue a cadeia de fallback definida na sua configuração Astro. Dado fallback: { fr: "en" }:
- Tenta o locale solicitado (
fr) - Tenta o locale de fallback (
en) - Tenta o locale padrão
O fallback se aplica apenas a consultas de entrada única. Consultas de lista retornam entradas apenas para o locale solicitado.
Menus
Menus são por locale — o mesmo name (ex: "primary") pode existir em vários locales, todos vinculados via um translation_group compartilhado. Os itens do menu resolvem suas referências de conteúdo contra a versão do locale ativo do conteúdo referenciado.
O seguinte componente 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 de Menus do admin — os itens são clonados com reference_id intacto (ele armazena o translation_group do conteúdo referenciado), então os links do novo menu apontam para o conteúdo correto por locale automaticamente.
Taxonomias (categorias, tags)
Os termos são por locale. As definições (_emdash_taxonomy_defs) também são por locale, então label / labelSingular podem ser traduzidos. O pivot content_taxonomies.taxonomy_id armazena o translation_group do termo, então uma única atribuição abrange todos os locales do conteúdo.
O seguinte exemplo busca categorias e 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.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.
Reparando incompatibilidades 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á nos i18n.locales configurados do site. Sem uma configuração i18n, en é o locale efetivo. Essas linhas são deixadas inalteradas porque o EmDash não pode inferir qual locale configurado o conteúdo existente deveria usar.
Faça backup do banco de dados, depois 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;
Após confirmar o locale pretendido para cada linha, atualize por id:
UPDATE _emdash_taxonomy_defs SET locale = 'ja' WHERE id = '<definition-id>';
UPDATE taxonomies SET locale = 'ja' WHERE id = '<term-id>';
Use a grafia exata de i18n.locales. Antes de atualizar, verifique se existe uma linha com o mesmo nome de taxonomia e locale alvo, ou o mesmo nome de termo, slug e locale alvo. Essas combinações são únicas; se uma linha alvo já existe, reconcilie as traduções em vez de aplicar uma atualização de locale em massa. Reinicie o EmDash e confirme que o aviso não aparece mais.
Listagem de coleções
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.data.slug}`}>{post.data.title}</a></li>
))}
</ul>
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);
---
<nav aria-label="Idioma">
<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>
A função getTranslations retorna todas as variantes de locale no mesmo grupo de tradução:
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" },
// ]
Gerenciando traduções no admin
Lista de conteúdo
Quando 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
Criando traduções
Abra qualquer entrada de conteúdo no editor. A barra lateral exibe um painel de Traduções listando todos os locales configurados. Para cada locale:
- “Traduzir” aparece para locales sem tradução — clique para criar uma
- “Editar” aparece para locales com tradução existente — clique para navegar até ela
- O locale atual é marcado com uma marca de verificação
Ao criar uma tradução, a nova entrada é pré-preenchida com dados do locale fonte e recebe um slug padrão de {source-slug}-{locale}. Ajuste o slug e o conteúdo conforme necessário, depois salve.
Publicação por locale
Cada tradução tem seu próprio status. Publique, despublique ou agende traduções independentemente. A versão francesa pode estar em rascunho enquanto a versão inglesa está ativa.
Content API
Parâmetro locale
Todas as rotas da API de conteúdo aceitam um parâmetro de query locale opcional:
GET /_emdash/api/content/posts?locale=fr
GET /_emdash/api/content/posts/my-post?locale=fr
Quando omitido, usa o locale padrão configurado.
Criando 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
{
"locale": "fr",
"translationOf": "01ABC...",
"data": {
"title": "Mon Article",
"slug": "mon-article"
}
}
A nova entrada compartilha o translation_group da entrada fonte e começa como rascunho.
Listando traduções
Recupere todas as traduções para uma determinada 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.
CLI
A CLI suporta flags --locale em 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 de uma entrada existente
emdash content create posts --locale fr --translation-of 01ABC...
Alimentando conteúdo multilíngue
Os arquivos seed expressam traduções usando 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 fonte deve aparecer antes de suas traduções no arquivo seed para que as referências translationOf resolvam corretamente.
Traduzibilidade de campos
Cada campo tem uma configuração translatable (padrão: true). Ao criar uma tradução:
- Campos traduzíveis são pré-preenchidos do locale fonte para edição
- Campos não traduzíveis são copiados e mantidos em sincronia em todas as traduções do grupo
Campos do sistema como status, published_at e author_id são sempre por locale e nunca sincronizados.
Estratégia de URL
O EmDash não gerencia URLs de locale — o Astro lida com o roteamento. Padrões comuns:
# prefix-other-locales (padrão Astro)
/blog/my-post → en (locale padrão, sem prefixo)
/fr/blog/mon-article → fr
# prefix-always
/en/blog/my-post → en
/fr/blog/mon-article → fr
Use getRelativeLocaleUrl de astro:i18n para construir URLs corretas independentemente do modo de roteamento.
Sitemaps
O sitemap por coleção em /sitemap-{collection}.xml é consciente de locale. Quando i18n está habilitado, cada tradução é emitida como sua própria entrada <url>, com o prefixo de locale resolvido através do getRelativeLocaleUrl do Astro. Sua configuração prefixDefaultLocale e quaisquer mapeamentos personalizados de path de locale são respeitados automaticamente.
Irmãos de tradução são interligados com alternativas xhtml:link para que motores 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>
Os irmãos são agrupados por translation_group, então uma linha adicionada posteriormente (uma nova variante de locale de um post existente) aparece automaticamente como alternativa em todas as outras variantes. Sites com um único locale produzem um sitemap simples sem namespace xhtml.
Alternativas hreflang no head da página
As mesmas alternativas pertencem ao <head> de cada página de conteúdo. Se seu layout usa <EmDashHead>, isso é automático: quando i18n está habilitado e o contexto da página inclui content, emite um <link rel="alternate"> por irmão de tradução publicado — incluindo um link auto-referencial, como o Google recomenda — mais 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 heads escritos à mão, resolva as alternativas com getHreflangAlternates:
---
import { getEmDashEntry, getHreflangAlternates } from "emdash";
const { entry } = await getEmDashEntry("posts", Astro.params.slug);
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 exatamente ao sitemap:
x-defaultaponta para a variante do locale padrão. Quando o locale padrão não tem uma tradução publicada, recai para a primeira variante roteável, então o conjunto nunca falta umx-default.- Irmãos não publicados são excluídos — traduções em rascunho nunca vazam para as alternativas.
- Locales não roteáveis são descartados. Uma linha cujo locale não está nos seus
i18n.localesconfigurados não pode ser servida, e vincular motores de busca a um 404 é pior que nenhum link. - Entradas não traduzidas ainda recebem uma alternativa auto-referencial e
x-defaultquando 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 através da sua configuração de roteamento i18n do Astro (prefixDefaultLocale, mapeamentos personalizados de path de locale), então head e sitemap sempre concordam.
Importando conteúdo multilíngue
Importe conteúdo WordPress através da ferramenta de migração do admin — veja Importação de conteúdo e Migrar do WordPress. Uma exportação WXR não carrega a estrutura de locale e grupo de tradução que WPML ou Polylang adicionam, então o conteúdo importado fica no seu locale padrão.
Para construir traduções a partir de conteúdo importado, crie a entrada traduzida e vincule-a à original:
emdash content create posts --locale fr --translation-of 01ABC...
Este é o mesmo fluxo de trabalho --locale / --translation-of mostrado em Alimentando conteúdo multilíngue acima, aplicado após a conclusão da importação.
Próximos passos
- Consultando conteúdo — Referência completa da API de consulta
- Trabalhando com conteúdo — Gerenciamento de conteúdo no admin
- Roteamento i18n do Astro — Configuração de roteamento do Astro