O EmDash se integra com o roteamento i18n integrado do Astro para fornecer gerenciamento de conteúdo multilíngue. O Astro lida com roteamento de URLs e detecção de locale; o EmDash lida com 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 em inglês 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 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 via um identificador compartilhado translation_group. Uma tabela de posts com três traduções se parece com isso:
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 em inglês mantendo o francês em rascunho
- Revisões por locale — cada tradução tem seu próprio histórico de revisões
- Consultas por locale — consultas de lista retornam entradas para apenas um locale
Consultar conteúdo traduzido
Entrada individual
Passe locale para getEmDashEntry para recuperar uma tradução específica. Quando omitido, o 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 individual. 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. Itens de menu resolvem suas referências de conteúdo contra a versão do conteúdo referenciado do locale ativo.
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="Principal">
<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 automaticamente para o conteúdo correto por locale.
Taxonomias (categorias, tags)
Termos são por locale. Definições (_emdash_taxonomy_defs) também são por locale, então label / labelSingular podem ser traduzidos também. O pivô 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 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.id, undefined, {
locale: Astro.currentLocale,
});
---
Traduzir um conteúdo herda automaticamente as atribuições de termos da fonte — você só precisa traduzir os próprios termos uma vez, e cada post que os usa se resolve para o locale correto em tempo de leitura.
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.data.slug}`}>{post.data.title}</a></li>
))}
</ul>
Seletor de idioma
Use getTranslations para construir um seletor de idioma que linka para as 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" },
// ]
Gerenciar 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
Criar traduções
Abra qualquer entrada de conteúdo no editor. A barra lateral exibe um painel 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 um check
Ao criar uma tradução, a nova entrada é pré-preenchida com dados do locale fonte e recebe um slug padrão de {slug-fonte}-{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 em inglês está ao vivo.
API de conteúdo
Parâmetro de locale
Todas as rotas da API de conteúdo aceitam um parâmetro de consulta locale opcional:
GET /_emdash/api/content/posts?locale=fr
GET /_emdash/api/content/posts/my-post?locale=fr
Quando omitido, o padrão é 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
{
"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.
Listar traduções
Recupere todas as traduções para uma entrada dada:
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
O 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...
Semear conteúdo multilíngue
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 se 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 qualquer mapeamento personalizado de path de locale são respeitados automaticamente.
Irmãos de tradução são cruzados com alternates xhtml:link para que mecanismos de busca possam servir o idioma correto para 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, então uma linha adicionada depois (uma nova variante de locale de um post existente) aparece automaticamente como alternate em cada outra variante. Sites com um único locale produzem um sitemap simples sem namespace xhtml.
Alternates hreflang no head da página
Os mesmos alternates 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, ele 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 feitos manualmente, resolva os alternates 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 tradução publicada, ele recai sobre a primeira variante roteável, então o conjunto nunca fica sem umx-default.- Irmãos não publicados são excluídos — traduções em rascunho nunca vazam para os alternates.
- 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 mecanismos de busca a um 404 é pior do que nenhum link. - Entradas não traduzidas ainda recebem um alternate 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.
Importar conteúdo multilíngue
Importe conteúdo WordPress através da ferramenta de migração do admin — veja Importar 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 chega no seu locale padrão.
Para construir traduções do conteúdo importado, crie a entrada traduzida e vincule-a ao original:
emdash content create posts --locale fr --translation-of 01ABC...
Este é o mesmo workflow --locale / --translation-of mostrado acima em Semear conteúdo multilíngue, aplicado após a importação ser concluída.
Próximos passos
- Consultar conteúdo — Referência completa da API de consultas
- Trabalhar com conteúdo — Gerenciamento de conteúdo do admin
- Roteamento i18n do Astro — Configuração de roteamento do Astro