国际化 (i18n)

本页内容

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 带有前缀,它就会加上区域设置前缀(非默认区域设置为 en/my-post,默认区域设置为 my-post,除非设置了 prefixDefaultLocale)。当条目在站点根路径下路由时(例如 /${entry.id}),请原样使用它。
  • entry.data.slug 是不含区域设置的 slug。当路由已经添加了区域设置段时,以及向 getEmDashEntry() 传入 slug 时,请使用它。
  • 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.rewrite("/404");
---

<article>
  <h1>{post.data.title}</h1>
</article>

回退链

当请求的区域设置中不存在匹配的已发布条目时,getEmDashEntry 会沿着 Astro 配置中的回退链查找。在预览或可视化编辑模式下,同样的查找可以返回草稿。假设 fallback: { fr: "en" }:

  1. 尝试请求的区域设置(fr)
  2. 尝试回退区域设置(en)
  3. 如果默认区域设置尚未在链中,则尝试默认区域设置

回退只适用于单个条目的查询。列表查询只返回所请求区域设置的条目。

每次回退查找都使用相同的 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>

请在管理后台的 Menus 列表中为现有菜单创建翻译——菜单项会被克隆,并保留原有的 reference_id(它保存着被引用内容的 translation_group),因此新菜单的链接会自动指向正确的、对应区域设置的内容。

分类法(分类、标签)

术语按区域设置划分。每个区域设置都可以有自己的定义,因此 label 和 labelSingular 也可以翻译,而 hierarchical 和 collections 由所有区域设置共享(请参阅翻译分类法与术语)。关联表 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 后,内容列表会显示:

  • 一个区域设置列,显示每个条目的区域设置
  • 工具栏中的区域设置过滤器,用于在各区域设置之间切换

创建翻译

在编辑器中打开任意内容条目。侧边栏会显示一个 Translations 面板,列出所有已配置的区域设置。对每个区域设置:

  • 没有翻译的区域设置会显示 “Translate”——点击即可创建一个
  • 已有翻译的区域设置会显示 “Edit”——点击即可跳转到它
  • 当前区域设置会用对勾标记

选择 Translate 会将翻译创建为草稿并打开它。草稿以源条目的数据和相同的 slug 开始,这是允许的,因为 slug 在每个区域设置内是唯一的。如果翻译需要本地化的 slug,例如将 my-post 对应为 mon-article,请编辑该 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 标志:

# 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 需要通过 --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 之类的系统字段始终是按区域设置独立的,不会同步。

引用字段是共享的,而不是同步的:EmDash 以翻译分组作为其链接的键,因此一个条目的每个翻译都只有一个选择,从任何区域设置编辑它都会改变所有翻译的值。

构建区域设置 URL

EmDash 存储区域设置;Astro 处理公开路由。受支持的 EmDash 配置让默认区域设置不带前缀:

# prefix-other-locales (Astro default)
/blog/my-post          → en (default locale, no prefix)
/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" />

对于手写的 head,请使用 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>

行为与站点地图一致:

  • 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 关系相同,只不过是在导入完成之后应用。

下一步