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 detecção de locales; 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 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 são 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 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 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 para 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, incluindogetTranslations()egetEntryTerms().
Traduções têm IDs de banco de dados diferentes porque cada locale é uma linha separada. Seu 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 no grupo.
Consultar conteúdo traduzido
Entrada única
Passe Astro.currentLocale para getEmDashEntry em uma rota multilíngue. O Astro conhece o locale selecionado por seu roteador, enquanto 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 uma entrada publicada correspondente não existe 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. Dado fallback: { fr: "en" }:
- Tente o locale solicitado (
fr) - Tente o locale de fallback (
en) - Tente o locale padrão se ainda não está 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 cair 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
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 no 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="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 (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)
Termos são por locale. 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 cada locale do conteúdo.
O exemplo seguinte 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 de taxonomia
Quando o admin carrega seu 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, então 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-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 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á existir, 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çã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 linka para 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 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 de Traduções 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 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 {slug-fonte}-{locale}. Ajuste o slug e o conteúdo conforme necessário, então 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.
Usar a API de conteúdo
Parâmetro locale
Rotas da API de conteúdo requerem uma sessão autenticada ou token bearer. 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; IDs de banco de dados são globalmente únicos e não precisam de desambiguação de 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, 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 fonte, como entry.data.id. 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.
Usar a CLI
Após autenticar a CLI, use suas 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 requer 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 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.
Escolher quais campos são traduzíveis
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 sincronizados em todas as traduções do grupo
Em uma coleção com revisões, publicar uma entrada copia os valores não traduzíveis que ela mudou para as outras traduções, e salvar um rascunho muda apenas aquela entrada. Se outra tradução tem um rascunho pendente que mudou um desses valores, o rascunho mantém seu próprio valor, e publicar essa tradução o copia para o resto do grupo.
Campos de sistema como status, published_at e author_id são sempre por locale e nunca sincronizados.
Construir URLs de locale
O EmDash armazena o locale; o Astro lida com o roteamento público. A configuração EmDash suportada 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 de caminho de locale personalizado. Não habilite um prefixo de locale padrão; como descrito em Configurar locales, essa estratégia de roteamento impede o carregamento das páginas admin injetadas.
Sitemaps
O sitemap por coleção em /sitemap-{collection}.xml é consciente de locale. Inclui entradas publicadas de coleções roteáveis habilitadas para SEO. 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 seu caminho a partir do urlPattern da coleção, então aplica o prefixo de locale do Astro e qualquer mapeamento de path de locale personalizado.
Irmãos de tradução são interligados com alternates xhtml:link para que motores 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 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.
Adicionar links hreflang ao cabeçalho 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, emite um <link rel="alternate"> por irmão de tradução publicado — incluindo um link autorreferencial, 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 cabeçalhos feitos à 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.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-defaultaponta para a variante do locale padrão. Quando o locale padrão não tem uma tradução publicada, ele cai 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 os alternates.
- Irmãos
noindexsão excluídos. Se a entrada atual énoindex, nenhum alternate é retornado. - Locales não roteáveis são removidos. 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 do que nenhum link. - Entradas não traduzidas ainda obtêm um alternate autorreferencial 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 configuração i18n do Astro. getHreflangAlternates() precisa de uma URL de site absoluta. Usa siteUrl da chamada ou a URL das configurações do site; sem nenhuma, retorna um array vazio porque links hreflang devem ser absolutos.
Importar conteúdo multilíngue
Importe conteúdo WordPress através da ferramenta de migração 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 chega no seu locale padrão.
Para construir traduções a partir de 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
Esta é a mesma relação --locale e --translation-of usada pelos arquivos seed, aplicada após a conclusão da importação.
Próximos passos
- Consultar Conteúdo — Referência completa de API de consulta
- Trabalhar com Conteúdo — Gerenciamento de conteúdo admin
- Roteamento i18n do Astro — Configuração de roteamento do Astro