国際化(i18n)
このページで分かること
- Astroのi18n設定でロケールを有効にする方法と、既定のロケールに接頭辞を付けると管理画面が開けなくなる注意点
- 翻訳をロケールごとに別の行として持つ仕組み(スラッグ・公開状態・リビジョンがロケールごとに独立)、翻訳したコンテンツ・メニュー・タクソノミーの取得、言語切り替えの作り方
- 管理画面・API・CLI・シードファイルでの翻訳の作成、翻訳対象にするフィールドの指定、サイトマップと
hreflangの出力
このページの目次
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として動作します。
やさしい解説
WordPressではWPMLやPolylangなどのプラグインで多言語化しますが、EmDashではAstroの設定ファイル(astro.config.mjs)の i18n ブロックで言語を指定します。注意点は、既定の言語のURLに /en/ のような接頭辞を付ける設定にすると、管理画面(/_emdash/admin)が404になって開けなくなることです。既定の言語は接頭辞なし、それ以外の言語だけ接頭辞あり、というAstroの既定の方式を使います。
翻訳の仕組み
EmDashは ロケールごとに1行(row-per-locale) のモデルを使います。それぞれの翻訳は、独自のID、スラッグ、公開状態を持つデータベースの1行で、共通の 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だけがあれば足ります。
やさしい解説
EmDashでは、1つの記事の英語版とフランス語版は、別々のエントリーとして保存されます。そのため、言語ごとにスラッグ(URLの一部)や公開状態を変えられます。どのエントリーが同じ記事の翻訳かは、共通の translation_group で結び付けられます。テンプレートでURLを作るときは entry.id(スラッグ)、翻訳の一覧などを取得するときは entry.data.id(データベースのID)と、使い分けます。
翻訳したコンテンツの取得
1件のエントリー
多言語のルートでは、getEmDashEntry に Astro.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" } の場合は次のとおりです。
- リクエストされたロケール(
fr)を試します - フォールバックのロケール(
en)を試します - 既定のロケールが順序に含まれていなければ、既定のロケールを試します
フォールバックは、1件のエントリーのクエリにだけ適用されます。一覧のクエリは、リクエストされたロケールのエントリーだけを返します。
フォールバックの検索では、どれも同じ 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 を保ったまま複製されます(reference_id には、参照するコンテンツの translation_group が保存されています)。そのため、新しいメニューのリンクは、自動的にロケールごとの正しいコンテンツを指します。
タクソノミー(カテゴリー、タグ)
タームはロケールごとに持ちます。定義(_emdash_taxonomy_defs)もロケールごとなので、label/labelSingular も翻訳できます。中間テーブルの 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,
});
---
コンテンツを翻訳すると、元のコンテンツのタームの割り当てが自動的に引き継がれます。翻訳が必要なのは ターム自体 だけで、それも1回で済みます。そのタームを使うすべての投稿は、読み込むときに正しいロケールに解決されます。
タクソノミーのロケールの不一致の修復
管理画面がサイトのマニフェストを読み込むとき、サイトで設定した 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が有効な場合、コンテンツの一覧には次のものが表示されます。
- 各エントリーのロケールを表示する ロケールの列
- ロケールを切り替えるための、ツールバーの ロケールの絞り込み
翻訳の作成
エディターで任意のコンテンツのエントリーを開きます。サイドバーに、設定したすべてのロケールを一覧にした「翻訳」パネルが表示されます。それぞれのロケールについて、次のように表示されます。
- 翻訳がないロケールには「翻訳」が表示されます。クリックすると翻訳を作成します
- 翻訳があるロケールには「編集」が表示されます。クリックするとその翻訳に移動します
- 現在のロケールにはチェックマークが付きます
翻訳を作成すると、新しいエントリーには元のロケールのデータがあらかじめ入り、既定のスラッグとして {source-slug}-{locale} が割り当てられます。必要に応じてスラッグとコンテンツを調整してから保存します。
ロケールごとの公開
それぞれの翻訳は、独自の公開状態を持ちます。翻訳ごとに、公開、公開の取り消し、予約公開ができます。英語版を公開したまま、フランス語版を下書きにしておけます。
コンテンツAPIの使用
ロケールのパラメーター
コンテンツAPIのルートには、認証済みのセッションかベアラートークンが必要です。一覧のルートは、任意の locale クエリパラメーターを受け付けます。1件のエントリーのルートでも、パスにスラッグを使う場合は 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)。翻訳を作成するときの動作は次のとおりです。
- 翻訳対象のフィールド は、元のロケールの値があらかじめ入り、編集できます
- 翻訳対象でないフィールド はコピーされ、グループ内のすべての翻訳で同じ値に保たれます
リビジョンを持つコレクションでは、エントリーを公開すると、そのエントリーで変更した翻訳対象でない値が他の翻訳にコピーされます。下書きの保存では、そのエントリーだけが変わります。別の翻訳に、同じ値を変更した未公開の下書きがある場合、その下書きは自分の値を保ち、その翻訳を公開すると、その値がグループの残りにコピーされます。
status、published_at、author_id などのシステムのフィールドは、常にロケールごとに持ち、同期されません。
ロケールの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 の代替リンクで相互にリンクされるため、検索エンジンは各ユーザーに正しい言語を案内できます。
<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にそのルートがないため除外されます。ロケールが1つだけのサイトでは、xhtml の名前空間のない、通常のサイトマップが作られます。
ページのheadへの 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" />
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.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の場合は、代替リンクを1つも返しません。- ルーティングできないロケールは除かれます。 ロケールが設定した
i18n.localesにない行は配信できず、検索エンジンを404に誘導するのはリンクがないより悪いためです。 - 翻訳のないエントリー でも、i18nが有効な場合は、サイトマップと同じく、自分自身を指す代替リンクと
x-defaultが出力されます。 - i18nが無効 な場合、結果は空で、クエリは実行されません。
URLは、コレクションの urlPattern から組み立てられ、Astroのi18nの設定によってロケールに合わせられます。getHreflangAlternates() には、サイトの絶対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のルーティングの設定