Internationalisierung (i18n)

Auf dieser Seite

EmDash lässt sich in das integrierte i18n-Routing von Astro integrieren und bietet so mehrsprachige Inhaltsverwaltung. Astro übernimmt das URL-Routing und die Locale-Erkennung; EmDash übernimmt das Speichern und Abrufen übersetzter Inhalte.

Jede Übersetzung ist ein vollständiger, eigenständiger Inhaltseintrag mit eigenem Slug, eigenem Status und eigener Revisionshistorie. Die französische Version eines Beitrags kann ein Entwurf sein, während die englische Version veröffentlicht ist.

Locales konfigurieren

Aktivieren Sie i18n, indem Sie Ihrer Astro-Konfiguration einen i18n-Block hinzufügen. EmDash liest genau diese Konfiguration für die Liste der Locales, die Standard-Locale und die Fallback-Kette.

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",
			}),
		}),
	],
});

Ist in der Astro-Konfiguration kein i18n vorhanden, sind alle i18n-Funktionen deaktiviert und EmDash verhält sich wie ein einsprachiges CMS.

Wie Übersetzungen funktionieren

EmDash verwendet ein Zeile-pro-Locale-Modell. Jede Übersetzung ist eine eigene Zeile in der Datenbank mit eigener ID, eigenem Slug und eigenem Status und über einen gemeinsamen translation_group-Bezeichner mit den anderen Übersetzungen verknüpft. Eine Posts-Tabelle mit drei Übersetzungen sieht so aus:

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

Dieses Design bedeutet:

  • Slugs pro Locale — /blog/my-post und /fr/blog/mon-article funktionieren ganz natürlich
  • Veröffentlichung pro Locale — veröffentlichen Sie die englische Version, während Französisch ein Entwurf bleibt
  • Revisionen pro Locale — jede Übersetzung hat ihre eigene Revisionshistorie
  • Abfragen für eine einzelne Locale — Listenabfragen liefern nur Einträge für eine Locale

Slugs, Eintrags-IDs und Datenbank-IDs

Ein Eintrag hat zwei Bezeichner mit unterschiedlichem Zweck:

  • entry.id ist der Slug, dem die Locale vorangestellt wird, sobald die URLs der Locale ein Präfix haben (en/my-post für eine Nicht-Standard-Locale, my-post für die Standard-Locale, sofern nicht prefixDefaultLocale gesetzt ist). Verwenden Sie ihn unverändert, wenn Einträge im Stammverzeichnis der Site geroutet werden, etwa als /${entry.id}.
  • entry.data.slug ist der Slug ohne Locale. Verwenden Sie ihn, wenn die Route das Locale-Segment bereits hinzufügt, und wenn Sie einen Slug an getEmDashEntry() übergeben.
  • entry.data.id ist die Datenbank-ID. Verwenden Sie sie für API-Operationen und für Helfer, die sich auf eine gespeicherte Inhaltszeile beziehen, darunter getTranslations() und getEntryTerms().

Übersetzungen haben unterschiedliche Datenbank-IDs, weil jede Locale eine eigene Zeile ist. Ihre gemeinsame translation_group hält fest, dass die Zeilen Übersetzungen desselben Inhalts sind. EmDash verwaltet diese Gruppe, wenn Sie eine Übersetzung erstellen; Templates benötigen normalerweise nur die Datenbank-ID einer beliebigen Zeile der Gruppe.

Übersetzte Inhalte abfragen

Einzelner Eintrag

Übergeben Sie auf einer mehrsprachigen Route Astro.currentLocale an getEmDashEntry. Astro kennt die vom Router gewählte Locale, EmDash benötigt jedoch den expliziten Wert, um Slugs eindeutig zuzuordnen, die in mehr als einer Locale existieren können. Gehen Sie bei Collection-Abfragen genauso vor.

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

Fallback-Kette

Existiert in der angeforderten Locale kein passender veröffentlichter Eintrag, folgt getEmDashEntry der Fallback-Kette aus der Astro-Konfiguration. Im Vorschau- oder visuellen Bearbeitungsmodus kann dieselbe Suche einen Entwurf zurückgeben. Bei fallback: { fr: "en" }:

  1. Die angeforderte Locale (fr) versuchen
  2. Die Fallback-Locale (en) versuchen
  3. Die Standard-Locale versuchen, falls sie noch nicht in der Kette ist

Der Fallback gilt nur für Abfragen einzelner Einträge. Listenabfragen liefern nur Einträge der angeforderten Locale.

Jede Fallback-Suche verwendet dasselbe id-Argument. Eine Anfrage für den Slug about kann beispielsweise von Französisch auf einen englischen Eintrag zurückfallen, dessen Slug ebenfalls about lautet. Eine Anfrage für a-propos kann dagegen keinen englischen Eintrag mit dem Slug about finden; die beiden Zeilen verwenden unterschiedliche öffentliche Bezeichner. Verwenden Sie getTranslations(), um Locale-Varianten mit unterschiedlichen Slugs zu finden und zu verlinken.

Menüs

Menüs gelten pro Locale — derselbe name (z. B. "primary") kann in mehreren Locales existieren, alle über eine gemeinsame translation_group verknüpft. Menüeinträge lösen ihre Inhaltsverweise gegen die Version des referenzierten Inhalts in der aktiven Locale auf.

Die folgende Komponente ruft das primäre Menü für die aktive Locale ab:

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

Erstellen Sie Übersetzungen eines bestehenden Menüs über die Menus-Liste im Admin — die Einträge werden mit unverändertem reference_id geklont (es speichert die translation_group des referenzierten Inhalts), sodass die Links des neuen Menüs automatisch auf den passenden Inhalt der jeweiligen Locale zeigen.

Taxonomien (Kategorien, Tags)

Terms gelten pro Locale. Jede Locale kann eine eigene Definition haben, sodass sich label und labelSingular ebenfalls übersetzen lassen, während hierarchical und collections von allen Locales gemeinsam genutzt werden (siehe Taxonomien und Terms übersetzen). Der Pivot content_taxonomies.taxonomy_id speichert die translation_group des Terms, sodass eine einzelne Zuweisung alle Locales des Inhalts abdeckt.

Das folgende Beispiel ruft Kategorien und die Terms eines Beitrags für die aktive Locale ab:

---
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,
});
---

Beim Übersetzen eines Inhalts werden die Term-Zuweisungen der Quelle automatisch übernommen — Sie müssen nur die Terms selbst einmal übersetzen, und jeder Beitrag, der sie verwendet, wird beim Lesen zur passenden Locale aufgelöst.

Taxonomie-Locale-Abweichungen reparieren

Wenn der Admin sein Site-Manifest lädt, warnt EmDash in den Server-Logs, falls Taxonomiedefinitionen oder Terms eine Locale verwenden, die nicht in den konfigurierten i18n.locales der Site enthalten ist. Ohne i18n-Konfiguration ist en die wirksame Locale. Diese Zeilen bleiben unverändert, weil EmDash nicht ableiten kann, welche konfigurierte Locale für den bestehenden Inhalt gedacht war.

Sichern Sie die Datenbank und prüfen Sie dann die betroffenen Zeilen, die in der Warnung genannt werden:

SELECT id, name, locale FROM _emdash_taxonomy_defs ORDER BY name, locale;
SELECT id, name, slug, locale FROM taxonomies ORDER BY name, slug, locale;

Aktualisieren Sie nach der Bestätigung der beabsichtigten Locale jeder Zeile diese anhand der id:

UPDATE _emdash_taxonomy_defs SET locale = 'ja' WHERE id = '<definition-id>';
UPDATE taxonomies SET locale = 'ja' WHERE id = '<term-id>';

Verwenden Sie exakt die Schreibweise aus i18n.locales. Prüfen Sie vor dem Aktualisieren, ob bereits eine Zeile mit demselben Taxonomienamen und derselben Ziel-Locale oder mit demselben Term-Namen, Slug und derselben Ziel-Locale existiert. Diese Kombinationen sind eindeutig; existiert die Zielzeile bereits, gleichen Sie die Übersetzungen ab, statt ein Locale-Update in einem Schritt auf alle Zeilen anzuwenden. Starten Sie EmDash neu und vergewissern Sie sich, dass die Warnung nicht mehr erscheint.

Collection-Auflistung

Eine Collection nach Locale filtern:

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

Einen Sprachumschalter bauen

Verwenden Sie getTranslations, um einen Sprachumschalter zu bauen, der auf vorhandene Übersetzungen des aktuellen Eintrags verlinkt:

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

Die Funktion getTranslations liefert alle Locale-Varianten derselben Übersetzungsgruppe:

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" },
// ]

Übersetzungen im Admin verwalten

Inhaltsliste

Ist i18n aktiviert, zeigt die Inhaltsliste:

  • Eine Locale-Spalte, die die Locale jedes Eintrags anzeigt
  • Einen Locale-Filter in der Symbolleiste, um zwischen Locales zu wechseln

Übersetzungen erstellen

Öffnen Sie einen beliebigen Inhaltseintrag im Editor. Die Seitenleiste zeigt ein Translations-Panel mit allen konfigurierten Locales. Für jede Locale gilt:

  • „Translate“ erscheint bei Locales ohne Übersetzung — klicken Sie, um eine zu erstellen
  • „Edit“ erscheint bei Locales mit vorhandener Übersetzung — klicken Sie, um dorthin zu wechseln
  • Die aktuelle Locale ist mit einem Häkchen markiert

Wenn Sie Translate wählen, wird die Übersetzung als Entwurf erstellt und geöffnet. Der Entwurf beginnt mit den Daten des Quelleintrags und demselben Slug, was zulässig ist, weil Slugs innerhalb jeder Locale eindeutig sind. Benötigt die Übersetzung einen lokalisierten Slug, etwa mon-article für my-post, bearbeiten Sie den Slug. Passen Sie den Inhalt an und speichern Sie anschließend.

Veröffentlichung pro Locale

Jede Übersetzung hat ihren eigenen Status. Veröffentlichen, depublizieren oder planen Sie Übersetzungen unabhängig voneinander. Die französische Version kann ein Entwurf sein, während die englische Version live ist.

Die Content-API verwenden

Locale-Parameter

Content-API-Routen erfordern eine authentifizierte Sitzung oder ein Bearer-Token. Listenrouten akzeptieren einen optionalen Query-Parameter locale. Eine Route für einen einzelnen Eintrag akzeptiert ihn ebenfalls, wenn der Pfad einen Slug verwendet; Datenbank-IDs sind global eindeutig und benötigen keine Locale-Unterscheidung.

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

Lässt eine Listenanfrage locale weg, wird die konfigurierte Standard-Locale verwendet.

Übersetzungen per API erstellen

Erstellen Sie eine Übersetzung, indem Sie locale und translationOf an den Endpunkt zum Erstellen von Inhalten übergeben:

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 ist die Datenbank-ID der Quellzeile, etwa entry.data.id. Der neue Eintrag teilt sich die translation_group des Quelleintrags und beginnt als Entwurf.

Übersetzungen auflisten

Alle Übersetzungen eines Eintrags abrufen:

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

Liefert die ID der Übersetzungsgruppe und ein Array von Locale-Varianten mit ihren IDs, Slugs und Status.

Die CLI verwenden

Verwenden Sie nach der Authentifizierung der CLI deren --locale-Flags bei Inhaltsbefehlen:

# 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 benötigt eine Eingabe über --data, --file oder --stdin. Der Eintrag wird nach dem Erstellen veröffentlicht, sofern Sie nicht --draft übergeben.

Mehrsprachige Inhalte seeden

Seed-Dateien drücken Übersetzungen mit locale und translationOf aus:

{
  "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" }
      }
    ]
  }
}

Der Eintrag der Quell-Locale muss in der Seed-Datei vor seinen Übersetzungen stehen, damit die translationOf-Verweise korrekt aufgelöst werden.

Wählen, welche Felder übersetzbar sind

Jedes Feld hat die Einstellung translatable (Standard: true). Beim Erstellen einer Übersetzung gilt:

  • Übersetzbare Felder werden zur Bearbeitung aus der Quell-Locale vorausgefüllt
  • Nicht übersetzbare Felder werden kopiert und über alle Übersetzungen der Gruppe hinweg synchron gehalten

Bei einer Collection mit Revisionen kopiert das Veröffentlichen eines Eintrags die von ihm geänderten nicht übersetzbaren Werte in die anderen Übersetzungen, während das Speichern eines Entwurfs nur diesen Eintrag ändert. Hat eine andere Übersetzung einen ausstehenden Entwurf, der einen dieser Werte geändert hat, behält der Entwurf seinen eigenen Wert, und das Veröffentlichen dieser Übersetzung kopiert ihn in den Rest der Gruppe.

Systemfelder wie status, published_at und author_id gelten immer pro Locale und werden nie synchronisiert.

Ein Referenzfeld wird gemeinsam genutzt statt synchronisiert: EmDash verschlüsselt seine Verknüpfungen über die Übersetzungsgruppe, sodass jede Übersetzung eines Eintrags dieselbe Auswahl hat und eine Änderung aus einer beliebigen Locale für alle gilt.

Locale-URLs erstellen

EmDash speichert die Locale; Astro übernimmt das öffentliche Routing. Die unterstützte EmDash-Konfiguration lässt die Standard-Locale ohne Präfix:

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

Verwenden Sie getRelativeLocaleUrl aus astro:i18n, um das richtige Präfix und eine eventuelle benutzerdefinierte Zuordnung von Locale-Pfaden hinzuzufügen. Aktivieren Sie kein Präfix für die Standard-Locale; wie unter Locales konfigurieren beschrieben, verhindert diese Routing-Strategie, dass die eingefügten Admin-Seiten geladen werden.

Sitemaps

Die Sitemap pro Collection unter /sitemap-{collection}.xml berücksichtigt Locales. Sie enthält veröffentlichte Einträge aus routbaren Collections mit aktiviertem SEO. Gelöschte Einträge, Einträge ohne Slug und als noindex markierte Einträge werden ausgeschlossen. Jede enthaltene Übersetzung wird zu einem eigenen <url>-Eintrag. EmDash baut den Pfad aus dem urlPattern der Collection und wendet dann das Locale-Präfix von Astro sowie eine eventuelle benutzerdefinierte Locale-path-Zuordnung an.

Übersetzungs-Geschwister werden über xhtml:link-Alternates miteinander verknüpft, damit Suchmaschinen jedem Nutzer die passende Sprache ausliefern können:

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

Geschwister werden nach translation_group gruppiert, sodass eine veröffentlichte Locale-Variante als Alternate bei jeder anderen veröffentlichten, indexierbaren Variante erscheint. Locales, die in i18n.locales fehlen, werden ausgelassen, weil Astro dafür keine Route hat. Sites mit nur einer Locale erzeugen eine einfache Sitemap ohne xhtml-Namespace.

Dieselben Alternates gehören in den <head> jeder Inhaltsseite. Wenn Ihr Layout <EmDashHead> verwendet, geschieht das automatisch: Ist i18n aktiviert und enthält der Seitenkontext content, gibt es pro veröffentlichtem Übersetzungs-Geschwister ein <link rel="alternate"> aus — einschließlich eines selbstreferenzierenden Links, wie von Google empfohlen — sowie 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" />

Für selbst geschriebene Header lösen Sie die Alternates mit getHreflangAlternates auf:

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

Das Verhalten entspricht dem der Sitemap:

  • x-default verweist auf die Variante der Standard-Locale. Hat die Standard-Locale keine veröffentlichte Übersetzung, fällt es auf die erste routbare Variante zurück, sodass die Menge nie ohne x-default ist.
  • Unveröffentlichte Geschwister werden ausgeschlossen — Entwurfsübersetzungen gelangen nie in die Alternates.
  • noindex-Geschwister werden ausgeschlossen. Ist der aktuelle Eintrag noindex, werden keine Alternates zurückgegeben.
  • Nicht routbare Locales werden verworfen. Eine Zeile, deren Locale nicht in Ihren konfigurierten i18n.locales steht, kann nicht ausgeliefert werden, und Suchmaschinen auf eine 404-Seite zu verweisen ist schlimmer als kein Link.
  • Unübersetzte Einträge erhalten bei aktiviertem i18n weiterhin einen selbstreferenzierenden Alternate und x-default, entsprechend der Sitemap.
  • Bei deaktiviertem i18n ist das Ergebnis leer, und es werden keine Abfragen ausgeführt.

URLs werden aus dem urlPattern der Collection gebaut und über die Astro-i18n-Konfiguration lokalisiert. getHreflangAlternates() benötigt eine absolute Site-URL. Es verwendet siteUrl aus dem Aufruf oder die URL aus den Site-Einstellungen; ohne beides gibt es ein leeres Array zurück, weil hreflang-Links absolut sein müssen.

Mehrsprachige Inhalte importieren

Importieren Sie WordPress-Inhalte über das Migrationswerkzeug im Admin — siehe Inhaltsimport und Migration von WordPress. Ein WXR-Export enthält nicht die Locale- und Übersetzungsgruppen-Struktur, die WPML oder Polylang hinzufügen, daher landen importierte Inhalte in Ihrer Standard-Locale.

Um aus importierten Inhalten Übersetzungen aufzubauen, erstellen Sie den übersetzten Eintrag als Entwurf und verknüpfen ihn mit der ursprünglichen Datenbank-ID:

emdash content create posts \
  --locale fr \
  --translation-of 01ABC... \
  --slug mon-article \
  --data '{"title":"Mon article"}' \
  --draft

Das ist dieselbe --locale-/--translation-of-Beziehung wie bei Seed-Dateien, nur nach Abschluss des Imports angewendet.

Nächste Schritte