O EmDash se integra ao roteamento i18n integrado do Astro para oferecer gerenciamento de conteúdo multilíngue. O Astro cuida do roteamento de URLs e da detecção de locale; o EmDash cuida do armazenamento e da recuperação do 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 em francês de um post pode estar em rascunho enquanto a versão em inglês está publicada.
Configurar locales
Habilite o i18n adicionando um bloco i18n à configuração do Astro. O EmDash lê essa mesma configuração para obter a lista de locales, o locale padrão e a 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 são desabilitados e o EmDash se comporta como um CMS de um único idioma.
Como as traduções funcionam
O EmDash usa um modelo de uma linha por locale. Cada tradução é sua própria linha no banco de dados, com seu próprio ID, slug e status, vinculada às outras traduções por meio de um identificador translation_group compartilhado. Uma tabela de posts com três traduções tem esta aparência:
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
Esse design significa que:
- 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 de um único locale — as 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 finalidades diferentes:
entry.idé o slug, com o prefixo do locale sempre que as URLs do locale tiverem prefixo (en/my-postpara um locale que não é o padrão,my-postpara o locale padrão, a menos queprefixDefaultLocaleesteja definido). Use-o como está quando as entradas são roteadas na raiz do site, como/${entry.id}.entry.data.slugé o slug sem o locale. Use-o quando a rota já adiciona o segmento do locale e quando você passa um slug paragetEmDashEntry().entry.data.idé o ID do banco de dados. Use-o para operações de API e para helpers que se referem a uma linha de conteúdo armazenada, incluindogetTranslations()egetEntryTerms().
As 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; os 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, enquanto o EmDash precisa do valor explícito para desambiguar slugs que podem existir em mais de um locale. Faça o mesmo nas 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.rewrite("/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 pré-visualização ou de edição visual, a mesma busca pode retornar um rascunho. Dado fallback: { fr: "en" }:
- Tentar o locale solicitado (
fr) - Tentar o locale de fallback (
en) - Tentar o locale padrão se ele ainda não estiver na cadeia
O fallback se aplica apenas às consultas de entrada única. As consultas de lista retornam apenas entradas do locale solicitado.
Cada busca de fallback usa o mesmo argumento id. Por exemplo, uma solicitação para o slug about pode recorrer do francês a uma entrada em inglês cujo slug também seja about. Uma solicitação para a-propos não consegue descobrir uma entrada em inglês cujo slug seja about; as duas linhas usam identificadores públicos diferentes. Use getTranslations() para encontrar e vincular variantes de locale com slugs diferentes.
Menus
Os menus são por locale — o mesmo name (por exemplo, "primary") pode existir em vários locales, todos vinculados por um translation_group compartilhado. Os itens de menu resolvem suas referências de conteúdo em relação à versão do conteúdo referenciado no locale ativo.
O componente a seguir busca o menu principal do 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 na 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 de cada locale.
Taxonomias (categorias, tags)
Os termos são por locale. Cada locale pode ter sua própria definição, então 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 pivô 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 as 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 origem — você só precisa traduzir os próprios termos uma vez, e todo post que os usa é resolvido 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 se definições de taxonomia ou termos usam um locale que não está nos i18n.locales configurados do site. Sem configuração de i18n, en é o locale efetivo. Essas linhas são deixadas inalteradas porque o EmDash não consegue inferir qual locale configurado o conteúdo existente deveria usar.
Faça um backup do banco de dados e depois inspecione as linhas afetadas citadas 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-a por id:
UPDATE _emdash_taxonomy_defs SET locale = 'ja' WHERE id = '<definition-id>';
UPDATE taxonomies SET locale = 'ja' WHERE id = '<term-id>';
Use exatamente a capitalização de i18n.locales. Antes de atualizar, verifique se existe uma linha com o mesmo nome de taxonomia e o mesmo locale de destino, ou com o mesmo nome de termo, slug e locale de destino. Essas combinações são únicas; se a linha de destino já existir, concilie 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çã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 leve à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 do 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 uma tradução existente — clique para ir até ela
- O locale atual é marcado com um visto
Ao criar uma tradução, a nova entrada é preenchida previamente com os 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 depois salve.
Publicação por locale
Cada tradução tem seu próprio status. Publique, cancele a publicação ou agende traduções de forma independente. A versão em francês pode estar em rascunho enquanto a versão em inglês está no ar.
Usar a API de conteúdo
Parâmetro locale
As rotas da API de conteúdo exigem uma sessão autenticada ou um token bearer. As rotas de lista aceitam um parâmetro de consulta locale opcional. Uma rota de entrada única também o aceita quando o caminho usa um slug; os 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 solicitaçã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 ao 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 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.
Usar a CLI
Depois de autenticar a CLI, use os flags --locale nos comandos de conteúdo:
# List French posts
emdash content list posts --locale fr
# Get a specific entry in French
emdash content get posts my-post --locale fr
# Create a French translation as a draft
emdash content create posts \
--locale fr \
--translation-of 01ABC... \
--slug mon-article \
--data '{"title":"Mon article"}' \
--draft
content create exige entrada via --data, --file ou --stdin. Ele publica após a criação, a menos que você passe --draft.
Semear conteúdo multilíngue
Os 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 de suas traduções no arquivo seed, para que as referências translationOf sejam resolvidas corretamente.
Escolher quais campos são traduzíveis
Cada campo tem uma configuração translatable (padrão: true). Ao criar uma tradução:
- Os campos traduzíveis são preenchidos previamente a partir do locale de origem para edição
- Os 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, e salvar um rascunho altera apenas aquela 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 do sistema como status, published_at e author_id são sempre por locale e nunca são sincronizados.
Um campo de referência é compartilhado em vez de sincronizado: o EmDash indexa seus vínculos pelo grupo de tradução, de modo que cada tradução de uma entrada tem uma única seleção, e editá-la a partir de qualquer locale a altera para todas.
Construir URLs de locale
O EmDash armazena o locale; o Astro cuida do roteamento público. A configuração compatível do EmDash deixa o locale padrão sem prefixo:
# prefix-other-locales (Astro default)
/blog/my-post → en (default locale, no prefix)
/fr/blog/mon-article → fr
Use getRelativeLocaleUrl de astro:i18n para adicionar o prefixo correto e qualquer mapeamento personalizado de caminhos de locale. Não habilite um prefixo para o locale padrão; como descrito em Configurar locales, essa estratégia de roteamento impede que as páginas de admin injetadas sejam carregadas.
Sitemaps
O sitemap por coleção em /sitemap-{collection}.xml considera o locale. Ele inclui entradas publicadas de coleções roteáveis com SEO habilitado. Entradas excluídas, entradas sem slug e entradas marcadas como noindex são excluídas. Cada tradução incluída se torna sua própria entrada <url>. O EmDash constrói o caminho a partir do urlPattern da coleção e depois aplica o prefixo de locale do Astro e qualquer mapeamento path de locale personalizado.
As traduções irmãs são vinculadas entre si 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>
As irmãs são agrupadas por translation_group, de modo que uma variante de locale publicada aparece como alternate em todas as outras variantes publicadas e indexáveis. 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 o namespace xhtml.
Adicionar links hreflang ao <head> da página
Os mesmos alternates pertencem ao <head> de toda página de conteúdo. Se o seu 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"> para cada tradução irmã publicada — incluindo um link autorreferenciado, 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 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.rewrite("/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 é igual ao do sitemap:
x-defaultaponta para a variante do locale padrão. Quando o locale padrão não tem uma tradução publicada, ele recorre à primeira variante roteável, de modo que o conjunto nunca fica semx-default.- As irmãs não publicadas são excluídas — traduções em rascunho nunca vazam para os alternates.
- As irmãs
noindexsão excluídas. Se a entrada atual fornoindex, nenhum alternate é retornado. - 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 apontar os mecanismos de busca para um 404 é pior do que não ter link. - Entradas não traduzidas ainda recebem um alternate autorreferenciado e
x-defaultquando o i18n está habilitado, espelhando o sitemap. - Com o i18n desabilitado, o resultado é vazio e nenhuma consulta é executada.
As 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 de site absoluta. Ele usa siteUrl da chamada ou a URL das configurações do site; sem nenhuma das duas, retorna um array vazio, porque os links hreflang precisam 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 carrega a estrutura de locales e grupos de tradução que o WPML ou o Polylang adicionam, portanto o conteúdo importado cai no seu locale padrão.
Para construir 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
Essa é a mesma relação --locale e --translation-of usada pelos arquivos seed, aplicada depois que a importação é concluída.
Próximos passos
- Consultar conteúdo — Referência completa da API de consulta
- Trabalhar com conteúdo — Gerenciamento de conteúdo no admin
- Roteamento i18n do Astro — Configuração de roteamento do Astro