國際化 (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 時使用它。
  • 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”——點擊即可跳轉到它
  • 目前區域設定會用勾號標記

建立翻譯時,新條目會預先填入來源區域設定的資料,並被指定預設 slug {source-slug}-{locale}。請視需要調整 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 關係相同,只不過是在匯入完成之後套用。

下一步