国際化 (i18n)

このページ

EmDash は Astro の組み込み i18n ルーティングと統合して、多言語コンテンツ管理を提供します。Astro が URL ルーティングとロケール検出を処理し、EmDash が翻訳されたコンテンツの保存と取得を処理します。

各翻訳は独自のスラグ、ステータス、リビジョン履歴を持つ完全で独立したコンテンツエントリです。投稿のフランス語版は下書きで、英語版は公開されているということが可能です。

ロケールの設定

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 はロケールごとに1行モデルを使用します。各翻訳はデータベース内の独自の行で、独自の ID、スラグ、ステータスを持ち、共有の translation_group 識別子を通じて他の翻訳とリンクされています。3つの翻訳を持つ 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

この設計は次のことを意味します:

  • ロケールごとのスラグ/blog/my-post/fr/blog/mon-article が自然に機能
  • ロケールごとの公開 — フランス語を下書きのまま英語版を公開
  • ロケールごとのリビジョン — 各翻訳が独自のリビジョン履歴を持つ
  • 単一ロケールクエリ — リストクエリは1つのロケールのエントリのみを返す

スラグ、エントリ ID、データベース ID

エントリには異なる目的を持つ2つの識別子があります:

  • entry.id はエントリのスラグです。公開 URL を構築する際に使用します。
  • entry.data.id はデータベース ID です。API 操作や getTranslations()getEntryTerms() を含む保存されたコンテンツ行を参照するヘルパーに使用します。

翻訳は異なるデータベース ID を持ちます。各ロケールが別の行だからです。共有の translation_group は行が同じコンテンツの翻訳であることを記録します。EmDash は翻訳を作成する時にそのグループを管理します。テンプレートは通常、グループ内の任意の行のデータベース ID のみが必要です。

翻訳されたコンテンツのクエリ

単一エントリ

多言語ルートで getEmDashEntryAstro.currentLocale を渡します。Astro はルーターが選択したロケールを知っていますが、EmDash は複数のロケールに存在する可能性のあるスラグを明確にするために明示的な値が必要です。コレクションクエリでも同様です。

---
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" } の場合:

  1. リクエストされたロケールを試す(fr
  2. フォールバックロケールを試す(en
  3. チェーンにまだない場合、デフォルトロケールを試す

フォールバックは単一エントリクエリにのみ適用されます。リストクエリはリクエストされたロケールのエントリのみを返します。

各フォールバックルックアップは同じ id 引数を使用します。例えば、スラグ about へのリクエストはフランス語からスラグが about でもある英語エントリにフォールバックできます。a-propos へのリクエストはスラグが about の英語エントリを発見できません。2つの行は異なる公開識別子を使用します。getTranslations() を使用して、異なるスラグを持つロケールバリアントを見つけてリンクしてください。

メニュー

メニューはロケールごとです — 同じ 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 の正確な大文字小文字を使用してください。更新前に、同じタクソノミー名とターゲットロケール、または同じ用語名、スラグ、ターゲットロケールの行が存在するか確認してください。これらの組み合わせは一意です。ターゲット行がすでに存在する場合、一括ロケール更新を適用する代わりに翻訳を調整してください。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” は既存の翻訳があるロケールに表示されます — クリックしてそこに移動
  • 現在のロケールはチェックマークで示されます

翻訳を作成する際、新しいエントリはソースロケールのデータで事前入力され、{ソーススラグ}-{ロケール} のデフォルトスラグが割り当てられます。必要に応じてスラグとコンテンツを調整し、保存します。

ロケールごとの公開

各翻訳は独自のステータスを持ちます。翻訳を独立して公開、非公開、またはスケジュールします。フランス語版は下書きで英語版はライブにできます。

コンテンツ API を使用する

locale パラメーター

コンテンツ API ルートは認証済みセッションまたはベアラートークンが必要です。リストルートはオプションの locale クエリパラメーターを受け付けます。単一エントリルートもパスがスラグを使用する場合に受け付けます。データベース ID はグローバルに一意であり、ロケールの曖昧さ解消を必要としません。

GET /_emdash/api/content/posts?locale=fr
GET /_emdash/api/content/posts/my-post?locale=fr

リストリクエストが locale を省略した場合、設定されたデフォルトロケールを使用します。

API 経由で翻訳を作成する

コンテンツ作成エンドポイントに localetranslationOf を渡して翻訳を作成します:

POST /_emdash/api/content/posts
Content-Type: application/json
X-EmDash-Request: 1

{
  "locale": "fr",
  "translationOf": "01ABC...",
  "slug": "mon-article",
  "data": {
    "title": "Mon Article"
  }
}

translationOfentry.data.id のようなソース行のデータベース ID です。新しいエントリはソースエントリの translation_group を共有し、下書きとして開始されます。

翻訳のリスト

特定のエントリのすべての翻訳を取得します:

GET /_emdash/api/content/posts/01ABC.../translations

翻訳グループ ID と、それぞれの ID、スラグ、ステータスを持つロケールバリアントの配列を返します。

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 を渡さない限り、作成後に公開します。

多言語コンテンツをシードする

シードファイルは localetranslationOf を使用して翻訳を表現します:

{
  "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)。翻訳を作成する際:

  • 翻訳可能なフィールドはソースロケールから編集用に事前入力されます
  • 翻訳不可能なフィールドはコピーされ、グループ内のすべての翻訳間で同期が維持されます

リビジョンのあるコレクションでは、エントリを公開すると変更された翻訳不可能な値が他の翻訳にコピーされ、下書きを保存するとそのエントリのみが変更されます。別の翻訳がそれらの値の1つを変更した保留中の下書きを持っている場合、下書きは独自の値を保持し、その翻訳を公開するとグループの残りにコピーされます。

statuspublished_atauthor_id などのシステムフィールドは常にロケールごとであり、同期されません。

ロケール URL を構築する

EmDash はロケールを保存し、Astro は公開ルーティングを処理します。サポートされている EmDash 設定はデフォルトロケールをプレフィックスなしにします:

# prefix-other-locales(Astro デフォルト)
/blog/my-post          → en(デフォルトロケール、プレフィックスなし)
/fr/blog/mon-article   → fr

astro:i18ngetRelativeLocaleUrl を使用して、正しいプレフィックスとカスタムロケールパスマッピングを追加します。デフォルトロケールプレフィックスを有効にしないでください。ロケールの設定で説明されているように、そのルーティング戦略は注入された管理ページの読み込みを妨げます。

サイトマップ

/sitemap-{collection}.xml のコレクションごとのサイトマップはロケール対応です。ルーティング可能な SEO 有効コレクションの公開済みエントリを含みます。削除されたエントリ、スラグのないエントリ、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 が含まれる場合、公開された翻訳兄弟ごとに1つの <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 関係で、インポート完了後に適用されます。

次のステップ