このサイトは非公式の日本語訳です。Cloudflare・EmDashプロジェクトが運営するサイトではありません。

このページで分かること

  • Astroのi18n設定でロケールを有効にする方法と、既定のロケールに接頭辞を付けると管理画面が開けなくなる注意点
  • 翻訳をロケールごとに別の行として持つ仕組み(スラッグ・公開状態・リビジョンがロケールごとに独立)、翻訳したコンテンツ・メニュー・タクソノミーの取得、言語切り替えの作り方
  • 管理画面・API・CLI・シードファイルでの翻訳の作成、翻訳対象にするフィールドの指定、サイトマップと hreflang の出力
難易度
実践
読む時間
10分
このページの目次

EmDashはAstroの組み込みのi18nルーティングと連携して、多言語のコンテンツ管理を実現します。URLのルーティングとロケールの判定はAstroが担当し、翻訳したコンテンツの保存と取得はEmDashが担当します。

それぞれの翻訳は、独自のスラッグ、公開状態、リビジョンの履歴を持つ、完全に独立したコンテンツのエントリーです。英語版の投稿を公開したまま、フランス語版を下書きにしておけます。

ロケールの設定

Astroの設定に i18n ブロックを追加して、i18nを有効にします。EmDashは、ロケールの一覧、既定のロケール、フォールバックの順序を、この同じ設定から読み取ります。

astro.config.mjs
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件のエントリー

多言語のルートでは、getEmDashEntryAstro.currentLocale を渡します。Astroはルーターが選んだロケールを知っていますが、EmDashは、複数のロケールに存在しうるスラッグを区別するために、明示的な値を必要とします。コレクションのクエリでも同じようにします。

src/pages/[...slug].astro
---
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. 既定のロケールが順序に含まれていなければ、既定のロケールを試します

フォールバックは、1件のエントリーのクエリにだけ適用されます。一覧のクエリは、リクエストされたロケールのエントリーだけを返します。

フォールバックの検索では、どれも同じ id の引数を使います。たとえば、スラッグ about のリクエストは、フランス語から、スラッグが同じく about の英語のエントリーにフォールバックできます。a-propos のリクエストでは、スラッグが about の英語のエントリーは見つかりません。2つの行は、公開用の識別子が異なるためです。スラッグの異なるロケールの版を探してリンクするには、getTranslations() を使います。

メニューはロケールごとに持ちます。同じ name(例:"primary")のメニューを複数のロケールに作ることができ、それらは共通の translation_group で結び付けられます。メニューの項目は、参照しているコンテンツのうち、現在のロケールの版に解決されます。

次のコンポーネントは、現在のロケールのプライマリーメニューを取得します。

src/components/PrimaryNav.astro
---
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)もロケールごとなので、labellabelSingular も翻訳できます。中間テーブルの 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を再起動し、警告が出なくなったことを確認します。

コレクションの一覧

コレクションをロケールで絞り込みます。

src/pages/posts.astro
---
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 を使って、現在のエントリーの既存の翻訳にリンクする言語切り替えを作ります。

src/components/LanguageSwitcher.astro
---
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での翻訳の作成

コンテンツの作成エンドポイントに 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"
  }
}

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

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

シードファイルでは、localetranslationOf で翻訳を表します。

.emdash/seed.json
{
  "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が保存し、公開側のルーティングはAstroが担当します。EmDashがサポートする設定では、既定のロケールには接頭辞を付けません。

# prefix-other-locales (Astro default)
/blog/my-post          → en (default locale, no prefix)
/fr/blog/mon-article   → fr

正しい接頭辞と、独自のロケールのパスの対応づけを付けるには、astro:i18ngetRelativeLocaleUrl を使います。既定のロケールの接頭辞は有効にしないでください。ロケールの設定で説明したとおり、そのルーティング方式では、挿入された管理画面のページが読み込めなくなります。

サイトマップ

コレクションごとのサイトマップ(/sitemap-{collection}.xml)は、ロケールに対応しています。ルーティングでき、SEOが有効なコレクションの、公開済みのエントリーが含まれます。削除されたエントリー、スラッグのないエントリー、noindex が付いたエントリーは除外されます。含まれるそれぞれの翻訳が、個別の <url> の項目になります。EmDashはコレクションの urlPattern からパスを組み立て、Astroのロケールの接頭辞と、独自のロケールの path の対応づけを適用します。

翻訳どうしは xhtml:link の代替リンクで相互にリンクされるため、検索エンジンは各ユーザーに正しい言語を案内できます。

/sitemap-post.xml
<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> にも必要です。レイアウトで <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 で代替リンクを解決します。

src/pages/blog/[slug].astro
---
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 の関係を、インポートが完了したあとに適用するものです。

次のステップ