国際化 (i18n)

このページ

EmDash は Astro の組み込み i18n ルーティングと統合して、多言語コンテンツ管理を提供します。URL ルーティングとロケール検出は Astro が担当し、翻訳されたコンテンツの保存と取得は 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 にプレフィックスが付く場合には、ロケールをプレフィックスとして付けたスラグです(デフォルト以外のロケールでは en/my-post、デフォルトロケールでは prefixDefaultLocale が設定されていない限り my-post)。エントリがサイトのルートでルーティングされる場合(/${entry.id} など)は、そのまま使用します。
  • entry.data.slug は、ロケールを含まないスラグです。ルートがすでにロケールのセグメントを追加している場合や、getEmDashEntry() にスラグを渡す場合に使用します。
  • entry.data.id はデータベース ID です。API 操作や、getTranslations() と getEntryTerms() を含む、保存済みのコンテンツ行を参照するヘルパーで使用します。

各ロケールは別々の行なので、翻訳ごとにデータベース ID は異なります。共通の translation_group が、それらの行が同じコンテンツの翻訳であることを記録しています。翻訳を作成すると EmDash がこのグループを管理します。テンプレートで通常必要になるのは、グループ内のいずれかの行のデータベース ID だけです。

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

単一エントリ

多言語ルートでは、Astro.currentLocale を getEmDashEntry に渡します。Astro のルーターが選択したロケールは 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.rewrite("/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>

既存のメニューの翻訳は、管理画面の Menus 一覧から作成します。項目は reference_id(参照先コンテンツの translation_group を保持しています)をそのままに複製されるため、新しいメニューのリンクは、ロケールごとの正しいコンテンツを自動的に指します。

タクソノミー(カテゴリー、タグ)

ターム(term)はロケールごとに管理されます。各ロケールが独自の定義を持てるため、label と labelSingular も翻訳できます。一方、hierarchical と collections はすべてのロケールで共有されます(タクソノミーとタームを翻訳するを参照)。ピボットの content_taxonomies.taxonomy_id にはタームの translation_group が保存されるため、1 回の割り当てがコンテンツのすべてのロケールにわたって有効になります。

次の例では、現在のロケールのカテゴリーと、投稿のタームを取得します。

---
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 が有効な場合、コンテンツリストには次のものが表示されます。

  • 各エントリのロケールを表示するロケール列
  • ロケールを切り替えるための、ツールバーのロケールフィルター

翻訳の作成

エディターで任意のコンテンツエントリを開きます。サイドバーに、設定済みのすべてのロケールを一覧表示する Translations パネルが表示されます。各ロケールについて:

  • 翻訳のないロケールには “Translate” が表示されます。クリックして作成します
  • 既存の翻訳があるロケールには “Edit” が表示されます。クリックしてそこへ移動します
  • 現在のロケールにはチェックマークが付きます

Translate を選択すると、翻訳が下書きとして作成され、開きます。下書きには元のエントリのデータと同じスラグが引き継がれます。スラグはロケールごとに一意であるため、これは許可されています。たとえば my-post に対する mon-article のように、翻訳にローカライズされたスラグが必要な場合は、スラグを編集します。コンテンツを調整してから保存します。

ロケールごとの公開

各翻訳は独自のステータスを持ちます。翻訳ごとに個別に、公開、非公開、スケジュール設定ができます。英語版が公開中でも、フランス語版は下書きのままにできます。

コンテンツ API を使用する

locale パラメーター

コンテンツ API ルートには、認証済みのセッションまたはベアラートークンが必要です。一覧ルートは、任意の locale クエリパラメーターを受け付けます。パスにスラグを使用する場合は、単一エントリのルートもこのパラメーターを受け付けます。データベース 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 は、entry.data.id など、元の行のデータベース ID です。新しいエントリは元のエントリの translation_group を共有し、下書きとして始まります。

翻訳のリスト

指定したエントリのすべての翻訳を取得します。

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

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

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)。翻訳を作成するときは次のようになります。

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

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

status、published_at、author_id などのシステムフィールドは常にロケールごとに管理され、同期されることはありません。

参照フィールドは、同期されるのではなく共有されます。EmDash はリンクを翻訳グループをキーにして保持するため、エントリのすべての翻訳が 1 つの選択を共有し、どのロケールから編集してもすべてに反映されます。

ロケール 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 が有効なコレクションの公開済みエントリが含まれます。削除済みのエントリ、スラグのないエントリ、noindex に設定されたエントリは除外されます。含まれる各翻訳は、それぞれ独自の <url> エントリになります。EmDash はコレクションの urlPattern からパスを組み立て、Astro のロケールプレフィックスとカスタムのロケール path マッピングを適用します。

翻訳の兄弟同士は xhtml:link の alternate で相互にリンクされるため、検索エンジンは各ユーザーに適切な言語を提供できます。

<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 でグループ化されるため、公開済みのロケールバリアントは、他のすべての公開済みでインデックス可能なバリアントに alternate として現れます。i18n.locales にないロケールは、Astro にルートがないため省略されます。ロケールが 1 つだけのサイトでは、xhtml 名前空間のない通常のサイトマップが生成されます。

ページヘッドに hreflang リンクを追加する

同じ alternate は、すべてのコンテンツページの <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" />

手書きの head では、getHreflangAlternates で alternate を解決します。

---
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 が欠けることはありません。
  • 未公開の兄弟は除外されます — 下書きの翻訳が alternate に漏れることはありません。
  • noindex の兄弟は除外されます。 現在のエントリが noindex の場合、alternate は返されません。
  • ルーティングできないロケールは除外されます。 ロケールが設定済みの i18n.locales にない行は提供できず、検索エンジンを 404 にリンクするのは、リンクしないより悪い結果になります。
  • 未翻訳のエントリでも、i18n が有効な場合は、サイトマップと同様に、自己参照の alternate と x-default が付与されます。
  • i18n が無効の場合、結果は空になり、クエリは実行されません。

URL はコレクションの urlPattern から組み立てられ、Astro の i18n 設定を通じてローカライズされます。getHreflangAlternates() には絶対 URL のサイト URL が必要です。呼び出しの siteUrl またはサイト設定の URL を使用します。どちらもない場合、hreflang リンクは絶対 URL でなければならないため、空の配列を返します。

多言語コンテンツのインポート

管理画面の移行ツールで 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 の関係であり、インポートの完了後に適用します。

次のステップ