EmDash 与 Astro 的内置 i18n 路由集成,提供多语言内容管理。Astro 处理 URL 路由和区域设置检测;EmDash 处理翻译内容的存储和检索。
每个翻译都是一个完整的、独立的内容条目,拥有自己的 slug、状态和修订历史。一篇文章的法语版本可以处于草稿状态,而英语版本已发布。
配置区域设置
通过向 Astro 配置添加 i18n 块来启用 i18n。EmDash 读取相同的配置来获取其区域设置列表、默认区域设置和回退链。
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",
}),
}),
],
});
当 Astro 配置中没有 i18n 时,所有 i18n 功能都被禁用,EmDash 将作为单语言 CMS 运行。
翻译的工作原理
EmDash 使用每个区域设置一行模型。每个翻译都是数据库中拥有自己 ID、slug 和状态的行,通过共享的 translation_group 标识符与其他翻译关联。一个拥有三个翻译的 posts 表看起来像这样:
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
这种设计意味着:
- 每区域 slug —
/blog/my-post和/fr/blog/mon-article自然工作 - 每区域发布 — 发布英语版本同时保持法语为草稿
- 每区域修订 — 每个翻译都有自己的修订历史
- 单区域查询 — 列表查询仅返回一个区域设置的条目
Slug、条目 ID 和数据库 ID
一个条目有两个不同用途的标识符:
entry.id是条目的 slug。在构建公共 URL 时使用。entry.data.id是数据库 ID。用于 API 操作和引用存储内容行的辅助函数,包括getTranslations()和getEntryTerms()。
翻译具有不同的数据库 ID,因为每个区域设置是单独的行。它们共享的 translation_group 记录了这些行是同一内容的翻译。EmDash 在你创建翻译时管理该组;模板通常只需要组中任何行的数据库 ID。
查询翻译内容
单个条目
在多语言路由上将 Astro.currentLocale 传递给 getEmDashEntry。Astro 知道其路由器选择的区域设置,而 EmDash 需要显式值来消除可能存在于多个区域设置中的 slug 的歧义。对集合查询也要这样做。
---
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>
回退链
当请求的区域设置中不存在匹配的已发布条目时,getEmDashEntry 遵循 Astro 配置中的回退链。在预览或可视化编辑模式下,同一查找可以返回草稿。给定 fallback: { fr: "en" }:
- 尝试请求的区域设置(
fr) - 尝试回退区域设置(
en) - 如果尚未在链中,尝试默认区域设置
回退仅适用于单条目查询。列表查询仅返回请求区域设置的条目。
每次回退查找使用相同的 id 参数。例如,对 slug about 的请求可以从法语回退到 slug 也是 about 的英语条目。对 a-propos 的请求无法发现 slug 为 about 的英语条目;这两行使用不同的公共标识符。使用 getTranslations() 来查找和链接具有不同 slug 的区域变体。
菜单
菜单是每区域的 — 相同的 name(如 "primary")可以存在于多个区域设置中,都通过共享的 translation_group 链接。菜单项根据活动区域设置版本的引用内容解析其内容引用。
以下组件获取活动区域设置的主菜单:
---
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>
从管理后台的菜单列表创建现有菜单的翻译 — 项目会保持 reference_id 不变(它存储引用内容的 translation_group)地克隆,因此新菜单的链接会自动指向正确的每区域内容。
分类法(分类、标签)
术语是每区域的。定义(_emdash_taxonomy_defs)也是每区域的,因此 label / labelSingular 也可以翻译。枢纽 content_taxonomies.taxonomy_id 存储术语的 translation_group,因此单个分配跨越内容的每个区域设置。
以下示例获取活动区域设置的分类和文章术语:
---
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,
});
---
翻译内容会自动继承源的术语分配 — 你只需翻译术语本身一次,使用它们的每篇文章都会在读取时解析到正确的区域设置。
修复分类法区域设置不匹配
当管理后台加载其站点清单时,如果分类法定义或术语使用了不在站点配置的 i18n.locales 中的区域设置,EmDash 会在服务器日志中发出警告。没有 i18n 配置时,en 是有效区域设置。这些行不会被更改,因为 EmDash 无法推断现有内容打算使用哪个配置的区域设置。
备份数据库,然后检查警告中提到的受影响行:
SELECT id, name, locale FROM _emdash_taxonomy_defs ORDER BY name, locale;
SELECT id, name, slug, locale FROM taxonomies ORDER BY name, slug, locale;
确认每行的预期区域设置后,按 id 更新:
UPDATE _emdash_taxonomy_defs SET locale = 'ja' WHERE id = '<definition-id>';
UPDATE taxonomies SET locale = 'ja' WHERE id = '<term-id>';
使用 i18n.locales 中的确切大小写。更新前,检查是否存在具有相同分类法名称和目标区域设置,或相同术语名称、slug 和目标区域设置的行。这些组合是唯一的;如果目标行已存在,请调解翻译而不是应用批量区域设置更新。重启 EmDash 并确认警告不再出现。
集合列表
按区域设置过滤集合:
---
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>
构建语言切换器
使用 getTranslations 构建链接到当前条目现有翻译的语言切换器:
---
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>
getTranslations 函数返回同一翻译组中的所有区域变体:
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" },
// ]
在管理后台管理翻译
内容列表
当 i18n 启用时,内容列表显示:
- 显示每个条目区域设置的区域设置列
- 工具栏中用于在区域设置之间切换的区域设置过滤器
创建翻译
在编辑器中打开任何内容条目。侧边栏显示一个翻译面板,列出所有配置的区域设置。对于每个区域设置:
- “Translate” 出现在没有翻译的区域设置 — 点击创建一个
- “Edit” 出现在有现有翻译的区域设置 — 点击导航到它
- 当前区域设置用勾号标记
创建翻译时,新条目用源区域设置的数据预填充,并分配 {源slug}-{区域设置} 的默认 slug。根据需要调整 slug 和内容,然后保存。
每区域发布
每个翻译都有自己的状态。独立地发布、取消发布或计划翻译。法语版本可以是草稿,而英语版本是在线的。
使用内容 API
locale 参数
内容 API 路由需要认证会话或 bearer 令牌。列表路由接受可选的 locale 查询参数。当路径使用 slug 时,单条目路由也接受它;数据库 ID 是全局唯一的,不需要区域设置消歧。
GET /_emdash/api/content/posts?locale=fr
GET /_emdash/api/content/posts/my-post?locale=fr
当列表请求省略 locale 时,使用配置的默认区域设置。
通过 API 创建翻译
通过向内容创建端点传递 locale 和 translationOf 来创建翻译:
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 是源行的数据库 ID,如 entry.data.id。新条目共享源条目的 translation_group 并作为草稿开始。
列出翻译
检索给定条目的所有翻译:
GET /_emdash/api/content/posts/01ABC.../translations
返回翻译组 ID 和包含各自 ID、slug 和状态的区域变体数组。
使用 CLI
认证 CLI 后,在内容命令上使用 --locale 标志:
# 列出法语文章
emdash content list posts --locale fr
# 获取特定的法语条目
emdash content get posts my-post --locale fr
# 创建法语翻译作为草稿
emdash content create posts \
--locale fr \
--translation-of 01ABC... \
--slug mon-article \
--data '{"title":"Mon article"}' \
--draft
content create 需要来自 --data、--file 或 --stdin 的输入。除非传递 --draft,否则创建后发布。
播种多语言内容
种子文件使用 locale 和 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" }
}
]
}
}
源区域设置条目必须在种子文件中出现在其翻译之前,以便 translationOf 引用正确解析。
选择哪些字段可翻译
每个字段都有 translatable 设置(默认:true)。创建翻译时:
- 可翻译字段从源区域设置预填充以供编辑
- 不可翻译字段被复制并在组中所有翻译之间保持同步
在具有修订的集合上,发布条目会将更改的不可翻译值复制到其他翻译,保存草稿仅更改该条目。如果另一个翻译有一个更改了其中一个值的待处理草稿,该草稿保留自己的值,发布该翻译会将其复制到组的其余部分。
status、published_at 和 author_id 等系统字段始终是每区域的,永远不会同步。
构建区域设置 URL
EmDash 存储区域设置;Astro 处理公共路由。支持的 EmDash 配置让默认区域设置不带前缀:
# prefix-other-locales(Astro 默认)
/blog/my-post → en(默认区域设置,无前缀)
/fr/blog/mon-article → fr
使用 astro:i18n 中的 getRelativeLocaleUrl 添加正确的前缀和任何自定义区域设置路径映射。不要启用默认区域设置前缀;如配置区域设置中所述,该路由策略会阻止注入的管理页面加载。
站点地图
/sitemap-{collection}.xml 的每集合站点地图是区域设置感知的。它包括来自可路由、启用 SEO 的集合的已发布条目。已删除的条目、没有 slug 的条目和标记为 noindex 的条目被排除。每个包含的翻译成为自己的 <url> 条目。EmDash 从集合的 urlPattern 构建路径,然后应用 Astro 的区域设置前缀和任何自定义区域设置 path 映射。
翻译兄弟通过 xhtml:link 替代进行交叉链接,以便搜索引擎可以向每个用户提供正确的语言:
<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>
兄弟按 translation_group 分组,因此已发布的区域变体作为每个其他已发布、可索引变体的替代出现。i18n.locales 中缺少的区域设置被省略,因为 Astro 没有为它们提供路由。单区域设置站点生成没有 xhtml 命名空间的普通站点地图。
向页面头部添加 hreflang 链接
相同的替代属于每个内容页面的 <head>。如果你的布局使用 <EmDashHead>,这是自动的:当 i18n 启用且页面上下文包含 content 时,它为每个已发布的翻译兄弟输出一个 <link rel="alternate"> — 包括 Google 推荐的自引用链接 — 加上 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" />
对于手工编写的头部,使用 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>
行为与站点地图一致:
x-default指向默认区域设置变体。当默认区域设置没有已发布的翻译时,它回退到第一个可路由变体,因此集合永远不缺少x-default。- 未发布的兄弟被排除 — 草稿翻译永远不会泄漏到替代中。
noindex兄弟被排除。 如果当前条目是noindex,不返回替代。- 不可路由的区域设置被删除。 区域设置不在配置的
i18n.locales中的行无法提供服务,将搜索引擎链接到 404 比没有链接更糟。 - 未翻译的条目在 i18n 启用时仍然获得自引用替代和
x-default,反映站点地图。 - 当 i18n 禁用时,结果为空且不执行查询。
URL 从集合的 urlPattern 构建,并通过 Astro i18n 配置进行本地化。getHreflangAlternates() 需要绝对站点 URL。它使用调用中的 siteUrl 或站点设置 URL;没有任何一个时返回空数组,因为 hreflang 链接必须是绝对的。
导入多语言内容
通过管理迁移工具导入 WordPress 内容 — 参见内容导入和从 WordPress 迁移。WXR 导出不包含 WPML 或 Polylang 添加的区域设置和翻译组结构,因此导入的内容会落在你的默认区域设置中。
要从导入的内容构建翻译,将翻译后的条目创建为草稿并链接到原始数据库 ID:
emdash content create posts \
--locale fr \
--translation-of 01ABC... \
--slug mon-article \
--data '{"title":"Mon article"}' \
--draft
这与种子文件使用的 --locale 和 --translation-of 关系相同,在导入完成后应用。
下一步
- 查询内容 — 完整查询 API 参考
- 使用内容 — 管理内容管理
- Astro i18n 路由 — Astro 的路由配置