国際化 (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はロケールごとの行モデルを使用します。各翻訳はデータベース内の独自の行で、独自の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つのロケールのエントリのみを返す

翻訳コンテンツのクエリ

単一エントリ

getEmDashEntrylocaleを渡して特定の翻訳を取得します。省略した場合、リクエストの現在のロケール(Astroのi18nミドルウェアで設定)がデフォルトになります。

---
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>

フォールバックチェーン

リクエストされたロケールのコンテンツが存在しない場合、EmDashはAstro設定で定義されたフォールバックチェーンに従います。fallback: { fr: "en" }の場合:

  1. リクエストされたロケール(fr)を試行
  2. フォールバックロケール(en)を試行
  3. デフォルトロケールを試行

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

メニュー

メニューはロケールごとです — 同じname(例:"primary")が複数のロケールに存在でき、すべて共有のtranslation_groupを介してリンクされています。メニューアイテムはアクティブなロケールのバージョンに対してコンテンツ参照を解決します。

次のコンポーネントはアクティブなロケールのプライマリメニューを取得します:

---
import { getMenu } from "emdash";

const menu = await getMenu("primary", { locale: Astro.currentLocale });
---

<nav aria-label="プライマリ">
  <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.id, undefined, {
  locale: Astro.currentLocale,
});
---

コンテンツを翻訳すると、ソースのターム割り当てが自動的に継承されます — ターム自体を一度翻訳するだけで、それを使用するすべての記事が読み取り時に正しいロケールに解決されます。

コレクションリスト

ロケールでコレクションをフィルタリング:

---
import { getEmDashCollection } from "emdash";

const { entries: posts } = await getEmDashCollection("posts", {
  locale: Astro.currentLocale,
  status: "published",
});
---

<ul>
  {posts.map((post) => (
    <li><a href={`/${post.data.slug}`}>{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);
---

<nav aria-label="言語">
  <ul>
    {translations.map((t) => (
      <li>
        <a
          href={getRelativeLocaleUrl(t.locale, `/blog/${t.slug}`)}
          aria-current={t.locale === Astro.currentLocale ? "page" : undefined}
        >
          {t.locale.toUpperCase()}
        </a>
      </li>
    ))}
  </ul>
</nav>

getTranslations関数は同じ翻訳グループ内のすべてのロケールバリアントを返します:

const { translationGroup, translations } = await getTranslations("posts", post.entry.id);
// translations: [
//   { locale: "en", id: "01ABC...", slug: "my-post", status: "published" },
//   { locale: "fr", id: "01DEF...", slug: "mon-article", status: "draft" },
// ]

管理画面での翻訳管理

コンテンツリスト

i18nが有効な場合、コンテンツリストには以下が表示されます:

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

翻訳の作成

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

  • 「翻訳」 は翻訳のないロケールに表示 — クリックして作成
  • 「編集」 は既存の翻訳があるロケールに表示 — クリックしてナビゲート
  • 現在のロケールはチェックマークで示されます

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

ロケールごとの公開

各翻訳は独自のステータスを持ちます。翻訳の公開、非公開、スケジュールを独立して行います。フランス語版が下書きで英語版がライブということも可能です。

コンテンツAPI

ロケールパラメータ

すべてのコンテンツAPIルートはオプションのlocaleクエリパラメータを受け付けます:

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

省略した場合、設定済みのデフォルトロケールがデフォルトになります。

APIによる翻訳の作成

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

POST /_emdash/api/content/posts
Content-Type: application/json

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

新しいエントリはソースエントリの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...

多言語コンテンツのシード

シードファイルは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)があります。翻訳を作成する際:

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

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

URL戦略

EmDashはロケールURLを管理しません — Astroがルーティングを処理します。一般的なパターン:

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

# prefix-always
/en/blog/my-post       → en
/fr/blog/mon-article   → fr

ルーティングモードに関係なく正しいURLを構築するには、astro:i18ngetRelativeLocaleUrlを使用してください。

サイトマップ

/sitemap-{collection}.xmlのコレクションごとのサイトマップはロケールを認識します。i18nが有効な場合、各翻訳は独自の<url>エントリとして出力され、ロケールプレフィックスはAstroのgetRelativeLocaleUrlを通じて解決されます。prefixDefaultLocale設定とカスタムロケール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として表示されます。単一ロケールのサイトはxhtml名前空間なしのプレーンなサイトマップを生成します。

ページheadのhreflang alternate

同じalternateは各コンテンツページの<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でalternateを解決します:

---
import { getEmDashEntry, getHreflangAlternates } from "emdash";

const { entry } = await getEmDashEntry("posts", Astro.params.slug);
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に漏れることはありません。
  • ルーティング不可能なロケールは除外されます。 設定されたi18n.localesにないロケールの行はサーブできず、検索エンジンを404にリンクすることはリンクしないよりも悪いです。
  • 未翻訳のエントリ もi18nが有効な場合、サイトマップをミラーリングして自己参照alternateとx-defaultを取得します。
  • i18n無効の場合、結果は空でクエリは実行されません。

URLはコレクションのurlPatternから構築され、Astroのi18nルーティング設定(prefixDefaultLocale、カスタムロケールpathマッピング)を通じてローカライズされるため、headとサイトマップは常に一致します。

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

管理画面のマイグレーションツールを通じてWordPressコンテンツをインポートします — コンテンツインポートWordPressからの移行を参照。WXRエクスポートはWPMLやPolylangが追加するロケールと翻訳グループ構造を含まないため、インポートされたコンテンツはデフォルトロケールに配置されます。

インポートされたコンテンツから翻訳を構築するには、翻訳エントリを作成してオリジナルにリンクします:

emdash content create posts --locale fr --translation-of 01ABC...

これは上記の多言語コンテンツのシードに示されている同じ--locale / --translation-ofワークフローで、インポート完了後に適用されます。

次のステップ