O EmDash integra-se com o roteamento i18n integrado do Astro para fornecer gestão de conteúdo multilingue. O Astro gere o roteamento de URLs e a deteção de locale; o EmDash gere o armazenamento e a recuperação de conteúdo traduzido.
Cada tradução é uma entrada de conteúdo completa e independente com o seu próprio slug, estado 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
Ative i18n adicionando um bloco i18n à sua configuração Astro. O EmDash lê esta mesma configuração para a sua lista de locales, locale predefinido 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 estão desativadas e o EmDash comporta-se como um CMS monolingue.
Como funcionam as traduções
O EmDash usa um modelo linha por locale. Cada tradução é a sua própria linha na base de dados com o seu próprio ID, slug e estado, ligada a outras traduções via um identificador translation_group partilhado. 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 o francês em rascunho
- Revisões por locale — cada tradução tem o seu próprio histórico de revisões
- Consultas de locale único — consultas de listagem devolvem entradas apenas para um locale
Consultar conteúdo traduzido
Entrada única
Passe locale a getEmDashEntry para recuperar uma tradução específica. Quando omitido, usa por predefinição o locale atual do pedido (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 pedido, o EmDash segue a cadeia de fallback definida na sua configuração Astro. Dado fallback: { fr: "en" }:
- Tente o locale pedido (
fr) - Tente o locale de fallback (
en) - Tente o locale predefinido
O fallback aplica-se apenas a consultas de entrada única. Consultas de listagem devolvem entradas apenas para o locale pedido.
Menus
Os menus são por locale — o mesmo name (ex.: "primary") pode existir em vários locales, todos ligados via um translation_group partilhado. Os itens do menu resolvem as suas referências de conteúdo contra a versão do conteúdo referenciado no locale ativo.
O seguinte componente obtém 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 (armazena o translation_group do conteúdo referenciado), pelo que os links do novo menu apontam automaticamente para o conteúdo correto por locale.
Taxonomias (categorias, tags)
Os termos são por locale. As definições (_emdash_taxonomy_defs) também são por locale, pelo que label / labelSingular também podem ser traduzidos. A coluna pivot content_taxonomies.taxonomy_id armazena o translation_group do termo, pelo que uma única atribuição abrange cada locale do conteúdo.
O seguinte exemplo obtém 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,
});
---
Ao traduzir um conteúdo, herda automaticamente as atribuições de termos da fonte — só precisa de traduzir os próprios termos uma vez, e cada post que os usa resolve-os 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 liga à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="Language">
<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 devolve 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" },
// ]
Gerir traduções no Admin
Lista de conteúdo
Quando i18n está ativado, 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 navegar
- O locale atual está marcado com um visto
Ao criar uma tradução, a nova entrada é pré-preenchida com dados do locale fonte e recebe um slug predefinido de {source-slug}-{locale}. Ajuste o slug e o conteúdo conforme necessário e guarde.
Publicação por locale
Cada tradução tem o seu próprio estado. Publique, despublique ou agende traduções independentemente. A versão francesa pode estar em rascunho enquanto a versão inglesa está online.
API de conteúdo
Parâmetro 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, usa por predefinição o locale predefinido 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
{
"locale": "fr",
"translationOf": "01ABC...",
"data": {
"title": "Mon Article",
"slug": "mon-article"
}
}
A nova entrada partilha o translation_group da entrada fonte e começa como rascunho.
Listar traduções
Recupere todas as traduções para uma entrada determinada:
GET /_emdash/api/content/posts/01ABC.../translations
Devolve o ID do grupo de tradução e um array de variantes de locale com os seus IDs, slugs e estados.
CLI
A CLI suporta 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 de uma entrada existente
emdash content create posts --locale fr --translation-of 01ABC...
Semear conteúdo multilingue
Os ficheiros 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 das suas traduções no ficheiro seed para que as referências translationOf se resolvam corretamente.
Traduzibilidade de campos
Cada campo tem uma definição translatable (predefiniçã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
Campos de sistema como status, published_at e author_id são sempre por locale e nunca sincronizados.
Estratégia de URL
O EmDash não gere URLs de locale — o Astro gere o roteamento. Padrões comuns:
# prefix-other-locales (predefinição Astro)
/blog/my-post → en (locale predefinido, 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 do locale. Quando i18n está ativado, cada tradução é emitida como a sua própria entrada <url>, com o prefixo de locale resolvido através de getRelativeLocaleUrl do Astro. A sua definiçã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 os motores de busca sirvam o idioma correto a cada utilizador:
<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, pelo que uma linha adicionada posteriormente (uma nova variante de locale de um post existente) aparece automaticamente como alternativa em cada outra variante. Sites com um único locale produzem um sitemap simples sem namespace xhtml.
Importar conteúdo multilingue
Importe conteúdo WordPress através da ferramenta de migração do admin — consulte Content Import e Migrate from WordPress. Uma exportação WXR não inclui a estrutura de locale e grupo de tradução que WPML ou Polylang adicionam, pelo que o conteúdo importado chega ao seu locale predefinido.
Para construir traduções a partir do conteúdo importado, crie a entrada traduzida e ligue-a ao original:
emdash content create posts --locale fr --translation-of 01ABC...
Este é o mesmo fluxo de trabalho --locale / --translation-of mostrado em Semear conteúdo multilingue acima, aplicado após a conclusão da importação.
Próximos passos
- Querying Content — Referência completa da API de consulta
- Working with Content — Gestão de conteúdo no admin
- Astro i18n routing — Configuração de roteamento Astro