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" } の場合:
- リクエストされたロケール(
fr)を試します - フォールバックロケール(
en)を試します - デフォルトロケールがまだチェーンに含まれていない場合は、デフォルトロケールを試します
フォールバックは単一エントリのクエリにのみ適用されます。一覧クエリは、リクエストされたロケールのエントリだけを返します。
各フォールバック検索では、同じ 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 の関係であり、インポートの完了後に適用します。
次のステップ
- コンテンツのクエリ — クエリ API の完全なリファレンス
- コンテンツの操作 — 管理画面でのコンテンツ管理
- Astro の i18n ルーティング — Astro のルーティング設定