Plugins übersetzen: Text Domain, Sprachpakete und JavaScript

Management Summary
Auf einer übersetzten WordPress-Seite sollen auch Schaltflächen und Beschriftungen der Erweiterungen zur Sprache passen. Der Artikel erklärt die Übersetzung solcher Texte am Beispiel einer Lesezeitanzeige. Texte vom Server und nachträglich im Browser veränderte Texte entstehen auf getrennten Wegen. Wird nur einer berücksichtigt, bleiben gemischte Sprachen auf der Seite. Passende Übersetzungen sowie richtige Einzahl- und Mehrzahlformen sorgen für eine verständliche, einheitliche Bedienung auf Deutsch, Englisch und Polnisch.
Inhalt
- Text Domain: die Kennung für Plugin-Texte
- PHP: Texte übersetzen und sicher ausgeben
- Pluralformen: 1 Minute, 2 Minuten, 5 Minuten
- Übersetzungen erst bei Bedarf laden
- JavaScript: Build-Datei und Übersetzung verbinden
- Die richtige JSON-Datei finden
- Sprachpakete von WordPress.org
- Lokale Übersetzungsdateien erstellen
- Prüfumfang und typische Fehler
- Weitere Artikel und Quellen
Eine übersetzte WordPress-Oberfläche übersetzt Plugin-Texte nicht automatisch mit. Ein Plugin benötigt gekennzeichnete Originaltexte und passende Übersetzungsdateien. Für PHP und JavaScript sind unterschiedliche Dateiformate nötig. Erst wenn beide Wege funktionieren, erscheinen beispielsweise die Lesezeit und die Beschriftung einer Schaltfläche in derselben Sprache.
Das Beispiel „LW Reading Time“ zeigt diese beiden Wege. Die Sprache richtet sich nach der von WordPress beziehungsweise einer Mehrsprachen-Erweiterung gewählten Sprache. WordPress erkennt damit nicht automatisch die bevorzugte Sprache jedes Besuchs. Das Beispiel ist eine Entwicklungsvorlage, kein hier nachgewiesenes veröffentlichtes Plugin.
Text Domain: die Kennung für Plugin-Texte
Die Text Domain ordnet einen Text dem richtigen Übersetzungskatalog zu. Für ein Plugin mit dem Verzeichnisnamen lw-reading-time lautet die Kennung ebenfalls lw-reading-time. Die Kennung steht in den Übersetzungsaufrufen als fester Text. Eine Variable erschwert die automatische Suche nach übersetzbaren Texten, weil die Suchwerkzeuge den PHP-Code dabei nicht ausführen.
Im PHP-Header nennt Text Domain diese Kennung. Domain Path: /languages bezeichnet den Übersetzungsordner innerhalb des Plugins. Der Ordner muss tatsächlich existieren. Die Text Domain verwendet Kleinbuchstaben und Bindestriche statt Leerzeichen und Unterstrichen. Auch alltägliche Texte wie „Settings“ erhalten die eigene Plugin-Kennung; eine zufällig vorhandene WordPress-Übersetzung ist keine verlässliche Grundlage.
PHP: Texte übersetzen und sicher ausgeben
Die folgende Hauptdatei ergänzt einzelne Beiträge um eine geschätzte Lesezeit und eine Schaltfläche. Die Lesezeit zählt durch Leerraum getrennte Textteile, entfernt HTML und Shortcodes und rechnet mit 200 Wörtern pro Minute. Das bleibt eine Näherung. Bei ungültigem UTF-8 oder fehlendem Beitrag bleibt die Ausgabe unverändert.
<?php
/**
* Plugin Name: LW Reading Time
* Description: Shows the estimated reading time above single posts.
* Version: 1.0.0
* Requires at least: 6.8
* Requires PHP: 7.4
* Author: Lukas Wojcik
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: lw-reading-time
* Domain Path: /languages
*
* @package LW_Reading_Time
*/
defined( 'ABSPATH' ) || exit;
const LW_READING_TIME_WORDS_PER_MINUTE = 200;
/**
* Returns the estimated reading time of a post in whole minutes.
*
* @param WP_Post $post Post object.
* @return int|null Minutes, at least 1; null for invalid text.
*/
function lw_reading_time_minutes( WP_Post $post ) {
$text = wp_strip_all_tags( strip_shortcodes( $post->post_content ) );
$words = preg_split( '/\s+/u', trim( $text ), -1, PREG_SPLIT_NO_EMPTY );
if ( false === $words ) {
return null;
}
return max( 1, (int) ceil( count( $words ) / LW_READING_TIME_WORDS_PER_MINUTE ) );
}
/**
* Prepends the reading time and a "mark as read" button to single posts.
*
* @param string $content Post content.
* @return string
*/
function lw_reading_time_prepend( $content ) {
if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
return $content;
}
$post = get_post();
if ( ! $post instanceof WP_Post ) {
return $content;
}
$minutes = lw_reading_time_minutes( $post );
if ( null === $minutes ) {
return $content;
}
$label = sprintf(
/* translators: %s: estimated reading time in minutes. */
_n( 'Reading time: %s minute', 'Reading time: %s minutes', $minutes, 'lw-reading-time' ),
number_format_i18n( $minutes )
);
$button = sprintf(
'<button type="button" class="lw-reading-time__mark" data-post="%1$d">%2$s</button>',
(int) $post->ID,
esc_html__( 'Mark as read', 'lw-reading-time' )
);
return '<p class="lw-reading-time">' . esc_html( $label ) . ' ' . $button . '</p>' . $content;
}
add_filter( 'the_content', 'lw_reading_time_prepend' );
/**
* Enqueues the front-end script and connects it to its translation files.
*/
function lw_reading_time_enqueue_script() {
if ( ! is_singular( 'post' ) ) {
return;
}
$asset_file = plugin_dir_path( __FILE__ ) . 'build/index.asset.php';
if ( ! file_exists( $asset_file ) ) {
return;
}
$asset = require $asset_file;
wp_enqueue_script(
'lw-reading-time',
plugins_url( 'build/index.js', __FILE__ ),
$asset['dependencies'],
$asset['version'],
array( 'in_footer' => true )
);
// Prefer this explicit directory for local sample JSON files; core can then fall back to language packs.
wp_set_script_translations( 'lw-reading-time', 'lw-reading-time', plugin_dir_path( __FILE__ ) . 'languages' );
}
add_action( 'wp_enqueue_scripts', 'lw_reading_time_enqueue_script' );
__() gibt einen übersetzten Text zurück, _e() schreibt den Text direkt in die Ausgabe. Für sichtbaren HTML-Text schützt esc_html__() zusätzlich die Ausgabe: Zeichen wie das Kleineralszeichen werden als Text behandelt, nicht als HTML-Befehl. Für HTML-Attribute gibt es entsprechende Funktionen mit esc_attr. Entscheidend ist der Ort der Ausgabe.
sprintf() setzt Werte in Platzhalter ein. Das Muster bleibt immer gleich, beispielsweise Reading time: %s minute; erst danach wird die Zahl eingesetzt. Bei mehreren Werten ermöglichen %1$s und %2$s eine andere Reihenfolge in der Übersetzung. Ein unmittelbar zugehöriger Kommentar mit translators: erklärt die Platzhalter. Das Beispiel schützt den fertig zusammengesetzten Text mit esc_html().
Pluralformen: 1 Minute, 2 Minuten, 5 Minuten
_n() erhält zwei englische Ausgangstexte, die Anzahl und die Text Domain. Die Übersetzungsdatei wählt daraus die passende Sprachform. Für die hier verwendeten ganzen Zahlen haben Englisch und Deutsch zwei gettext-Formen, Polnisch drei. Der polnische Ausdruck unterscheidet unter anderem 1, Zahlen wie 2 und 22 sowie Zahlen wie 5, 12 und 21.
| Minuten | Englisch | Deutsch | Polnisch (Form) |
|---|---|---|---|
| 1 | Reading time: 1 minute | Lesezeit: 1 Minute | Czas czytania: 1 minuta (0) |
| 2 | Reading time: 2 minutes | Lesezeit: 2 Minuten | Czas czytania: 2 minuty (1) |
| 5 | Reading time: 5 minutes | Lesezeit: 5 Minuten | Czas czytania: 5 minut (2) |
| 12 | Reading time: 12 minutes | Lesezeit: 12 Minuten | Czas czytania: 12 minut (2) |
| 21 | Reading time: 21 minutes | Lesezeit: 21 Minuten | Czas czytania: 21 minut (2) |
| 22 | Reading time: 22 minutes | Lesezeit: 22 Minuten | Czas czytania: 22 minuty (1) |
Auch der Singular enthält deshalb einen Platzhalter. Ein fest ausgeschriebenes „One minute“ wäre für Sprachen ungeeignet, deren erste Form auch bei anderen Zahlen verwendet wird. Die Regel gilt hier für ganze Minuten. Bruchzahlen wie 1,5 Minuten benötigen eine gesonderte sprachliche Behandlung.
Übersetzungen erst bei Bedarf laden
Für Plugins aus WordPress.org lädt WordPress seit Version 4.6 die Übersetzungen beim ersten Bedarf. Seit 6.8 gilt das auch für anderswo verteilte Plugins mit passenden Angaben zu Text Domain und Domain Path. Bei diesen Mindestversionen ist ein zusätzlicher Aufruf von load_plugin_textdomain() nicht nötig.
Das bedeutet jedoch nicht, dass Übersetzungen schon beim Einlesen der Hauptdatei angefordert werden sollten. WordPress 6.7 ergänzte einen Entwicklerhinweis für zu frühes Laden. Der geprüfte Code prüft dafür, ob after_setup_theme bereits läuft oder gelaufen ist; die Meldung empfiehlt init oder später. Für Plugin-Code ist ein Aufruf beim späteren tatsächlichen Bedarf meist klarer als eine übersetzte globale Konstante.
<?php
// Too early: runs while WordPress loads the plugin file, before after_setup_theme and init.
// A known domain path can trigger this diagnostic even without a translation file.
// _load_textdomain_just_in_time() via _doing_it_wrong().
define( 'LW_READING_TIME_TITLE', __( 'Reading time', 'lw-reading-time' ) );
// Better: translate at the moment the text is needed (init or later).
function lw_reading_time_title() {
return __( 'Reading time', 'lw-reading-time' );
}
// Plugin metadata without translating it: third parameter $translate = false.
function lw_reading_time_version() {
if ( ! function_exists( 'get_plugin_data' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$data = get_plugin_data( __FILE__, false, false );
return $data['Version'];
}
Ein bekannter Übersetzungspfad kann den Hinweis bereits auslösen, bevor überhaupt eine Sprachdatei geöffnet wird. Ein englischer Testlauf ohne Übersetzungsdateien schließt diesen Fehler daher nicht aus. get_plugin_data(..., false, false) liest reine Metadaten ohne Übersetzung; das ist etwa für eine Versionsnummer sinnvoll.
Für ältere unterstützte WordPress-Versionen oder abweichende Pfade kann ein ausdrücklicher Ladeaufruf weiterhin nötig sein. Seit WordPress 6.7 registriert dieser Aufruf den Pfad. WordPress lädt die Datei erst beim ersten benötigten Text. Eine spätere Registrierung behebt keinen Übersetzungsaufruf, der schon vorher zu früh stattgefunden hat.
JavaScript: Build-Datei und Übersetzung verbinden
Das Beispiel nutzt ein klassisches WordPress-Skript. Das Paket @wordpress/scripts baut aus src/index.js die ausgelieferte Datei build/index.js. Die zusätzlich erzeugte index.asset.php beschreibt Abhängigkeiten wie wp-i18n. Der PHP-Code übergibt diese Liste beim Einbinden des Skripts.
{
"name": "lw-reading-time",
"version": "1.0.0",
"private": true,
"license": "GPL-2.0-or-later",
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start"
},
"devDependencies": {
"@wordpress/scripts": "36.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
}
Die Paketversion ist hier auf 36.0.0 festgelegt. Deren Node-Bedingung lautet ^22.22.2 || ^24.15.0 || >=26.0.0: unterstützt sind entsprechende 22er- und 24er-Versionen sowie Versionen ab 26, nicht jede beliebige höhere Hauptversion. Die nach der Installation erzeugte Lockdatei hält auch die aufgelösten Abhängigkeiten fest. Spätere Builds können dieselbe Datei mit npm ci verwenden.
// src/index.js
import { _n, _x, sprintf } from '@wordpress/i18n';
const STORAGE_KEY = 'lwReadingTimeRead';
function getReadPosts() {
try {
const stored = JSON.parse( window.localStorage.getItem( STORAGE_KEY ) );
return new Set( Array.isArray( stored ) ? stored.filter( ( id ) => Number.isSafeInteger( id ) && id > 0 ) : [] );
} catch ( error ) {
return new Set();
}
}
const readPosts = getReadPosts();
function saveReadPosts() {
try {
window.localStorage.setItem( STORAGE_KEY, JSON.stringify( [ ...readPosts ] ) );
} catch ( error ) {
// The state remains available on this page only when storage fails.
}
}
function showRead( button ) {
button.disabled = true;
button.textContent = _x(
'Read',
'status of a post after it was marked as read',
'lw-reading-time'
);
button.title = sprintf(
/* translators: %d: number of locally known posts marked as read. */
_n(
'%d post marked as read locally',
'%d posts marked as read locally',
readPosts.size,
'lw-reading-time'
),
readPosts.size
);
}
document.querySelectorAll( '.lw-reading-time__mark' ).forEach( ( button ) => {
const postId = Number( button.dataset.post );
if ( ! Number.isSafeInteger( postId ) || postId <= 0 ) {
button.disabled = true;
return;
}
if ( readPosts.has( postId ) ) {
showRead( button );
}
button.addEventListener( 'click', () => {
for ( const id of getReadPosts() ) {
readPosts.add( id );
}
readPosts.add( postId );
saveReadPosts();
showRead( button );
} );
} );
_x() ergänzt einen Kontext: „Read“ bedeutet hier den Zustand „Gelesen“, nicht die Aufforderung „Lesen“. Die Schaltfläche schreibt nur einen lokalen Lesestatus. Beim nächsten Laden erhalten Beiträge mit gültiger gespeicherter Nummer wieder die Markierung „Gelesen“. Doppelte oder ungültige Werte werden verworfen.
Wenn der Browserspeicher gesperrt oder voll ist, bleibt der Zustand nur auf der aktuellen Seite erhalten. Das Beispiel synchronisiert keine Benutzerkonten oder Geräte und löst gleichzeitige Schreibzugriffe mehrerer Tabs nicht vollständig. Die Anzahl beschreibt lokal bekannte Markierungen, keine bestätigten Lesevorgänge. Die Übernahme des neuesten gespeicherten Stands beim Klick verringert Konflikte, garantiert aber keine konfliktfreie Speicherung.
Die richtige JSON-Datei finden
wp_set_script_translations() verbindet das registrierte Skript-Handle mit den Übersetzungen. Ein Handle ist der Name, unter dem WordPress das Skript verwaltet. Der Aufruf muss nach der Registrierung erfolgen. Das Beispiel nennt languages/ ausdrücklich als ersten Suchort für die lokalen JSON-Dateien. Dadurch ist die Reihenfolge für diesen Test klar.
Für die normale Plugin-Datei build/index.js entsteht der Dateiname aus Text Domain, Sprachkennung und dem MD5-Wert des relativen Pfads. Der Wert lautet dfbff627e6c248bcb3b61d7d06da9ca9. Eine Dateiumbenennung kann deshalb eine anders benannte Übersetzungsdatei erfordern. Angepasste Pfade oder Filter können die Suche verändern.
Der geprüfte WordPress-Code sucht im ausdrücklich angegebenen Ordner zuerst eine Datei mit dem Handle im Namen, danach die Datei mit dem Pfad-Hash. Anschließend folgt der passende allgemeine Sprachordner. Ohne ausdrücklichen Pfad verwendet WordPress zunächst den für die Text Domain ermittelten Ordner. Deshalb sind PHP-Sprachdateien und JavaScript-JSON-Dateien getrennt zu prüfen.
Sprachpakete von WordPress.org
translate.wordpress.org sammelt die Texte aus dem veröffentlichten Plugin-Code. Für das erste Sprachpaket einer Sprache müssen mindestens 90 Prozent der maßgeblichen Code-Texte übersetzt und freigegeben sein. Die Readme zählt nicht zu diesem Anteil. Sobald ein Sprachpaket existiert, können weitere Änderungen neue Pakete auslösen, auch wenn der Anteil wieder unter 90 Prozent fällt.
Ein Vorschlag ist noch keine freigegebene Übersetzung. Zuständige Sprach- oder Projektredaktionen prüfen die Einträge. Das Sprachpaket liefert Dateien für PHP und die benötigten JSON-Dateien für Skripte. WordPress legt Plugin-Sprachpakete unter wp-content/languages/plugins/ ab. Eine vorhandene Übersetzungsfunktion allein garantiert weder ein fertiges Sprachpaket noch dessen Installation.

Lokale Übersetzungsdateien erstellen
Eine POT-Datei ist die Liste der übersetzbaren Originaltexte. Eine PO-Datei ergänzt die Übersetzungen für eine Sprache. Aus dieser bearbeitbaren Datei entstehen die Laufzeitdateien MO, gegebenenfalls .l10n.php und JSON. Seit WordPress 6.5 kann das System statt MO die PHP-Übersetzungsdatei verwenden.
# Run in a separate plugin development directory, not on the live site.
# The JSON command below follows i18n-command 3.0.1 (requires WP-CLI 3.x).
# Older command versions may modify PO input; keep a separate source backup.
# 1. Build the script: creates build/index.js and build/index.asset.php (dependency "wp-i18n").
npm install --engine-strict
npm run build
# Keep package-lock.json after installation; use npm ci for later builds.
# 2. Extract all strings from PHP and from the built JavaScript into a template.
wp i18n make-pot . languages/lw-reading-time.pot --exclude=src
# 3. Copy the template to languages/lw-reading-time-pl_PL.po (and -de_DE.po),
# translate it in Poedit or a text editor, then compile the runtime files.
wp i18n make-mo languages
wp i18n make-php languages
wp i18n make-json languages
# Result for Polish:
# languages/lw-reading-time-pl_PL.po
# languages/lw-reading-time-pl_PL.mo
# languages/lw-reading-time-pl_PL.l10n.php
# languages/lw-reading-time-pl_PL-dfbff627e6c248bcb3b61d7d06da9ca9.json (md5 of "build/index.js")
Die Befehle beziehen sich beim JSON-Schritt auf i18n-command 3.0.1, das WP-CLI 3.x benötigt. Diese Version bietet keine --no-purge-Option und schreibt beim JSON-Export die PO-Datei nicht um. Ältere Anleitungen beschreiben anderes Verhalten. Die installierte Version und deren Hilfe müssen daher zu den Befehlen passen; bearbeitbare PO-Dateien gehören unabhängig davon in eine Sicherung oder Versionsverwaltung.
--exclude=src verhindert in diesem Beispiel, dass Quell- und Build-Datei dieselben Texte doppelt mit verschiedenen Pfaden beisteuern. Die Quellenangabe im PO muss zur ausgelieferten JavaScript-Datei passen. Das folgende polnische Beispiel zeigt die drei Pluralformen; Zeilennummern sind veranschaulichend und ändern sich beim Bearbeiten.
# languages/lw-reading-time-pl_PL.po (excerpt)
msgid ""
msgstr ""
"Language: pl_PL\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : 2);\n"
"X-Domain: lw-reading-time\n"
#. translators: %s: estimated reading time in minutes.
#: lw-reading-time.php:50
msgid "Reading time: %s minute"
msgid_plural "Reading time: %s minutes"
msgstr[0] "Czas czytania: %s minuta"
msgstr[1] "Czas czytania: %s minuty"
msgstr[2] "Czas czytania: %s minut"
#: lw-reading-time.php:57
msgid "Mark as read"
msgstr "Oznacz jako przeczytany"
#: build/index.js:1
msgctxt "status of a post after it was marked as read"
msgid "Read"
msgstr "Przeczytany"
#. translators: %d: number of locally known posts marked as read.
#: build/index.js:2
msgid "%d post marked as read locally"
msgid_plural "%d posts marked as read locally"
msgstr[0] "%d wpis oznaczony jako przeczytany lokalnie"
msgstr[1] "%d wpisy oznaczone jako przeczytane lokalnie"
msgstr[2] "%d wpisów oznaczonych jako przeczytane lokalnie"
Prüfumfang und typische Fehler
Neun isolierte PHP-Prüfungen decken unter anderem deutsche und polnische Zeichen, geschützte Leerzeichen, ungültiges UTF-8 und einen fehlenden Beitrag ab. 18 JavaScript-Prüfungen prüfen gespeicherte Markierungen, ungültige Werte, doppelte Klicks und gesperrten Speicher. Dabei wurden WordPress-Funktionen, Übersetzungen und Browserobjekte durch Testobjekte ersetzt. Ein vollständiger WordPress-Lauf, npm-Build oder Sprachpaketimport wurde damit nicht nachgewiesen.
PHP ist übersetzt, JavaScript bleibt englisch. Was ist zu prüfen?
Skript-Handle, Registrierung, JSON-Dateiname, Sprachkennung und Pfad müssen zusammenpassen. Eine PO-Referenz auf src statt build kann einen falschen Hash erzeugen. Außerdem müssen die übersetzten Einträge und die ausgelieferte Build-Datei tatsächlich vorhanden sein.
Müssen Verzeichnisplugins eigene Sprachdateien mitliefern?
Nach verfügbarer Übersetzung und erzeugtem Sprachpaket kann WordPress die Dateien selbst beziehen. Vor dem ersten Paket oder für ein außerhalb des Verzeichnisses verteiltes Plugin sind lokale Dateien weiterhin sinnvoll. Eine fehlende Übersetzung bleibt sonst beim Originaltext.
Reicht ein englischer Test ohne Fehlermeldung?
Nein. Zusätzlich sind echte deutsche und polnische Dateien, Singular und Plural sowie PHP- und JavaScript-Ausgabe zu prüfen. Ein fehlerfreier Ablauf belegt nicht, dass die richtige Übersetzung geladen wurde.
Weitere Artikel und Quellen
- Plugin Check
- Readme und Plugin-Header
- Skript-Handles
- Plugin Handbook: plugin internationalization
- Plugin Handbook: Localization
- Block Editor Handbook: Internationalization
- Code Reference: __()
- _e()
- _x()
- _n()
- esc_html__()
- Code Reference: load_plugin_textdomain()
- wp_set_script_translations()
- Make WordPress Core: I18N Improvements in 4.6
- Make WordPress Core: JavaScript i18n support in WordPress 5.0
- Make WordPress Core: I18N Improvements in 6.5 (Performant Translations)
- Make WordPress Core: Internationalization improvements in 6.7
- Make WordPress Core: Internationalization improvements in 6.8
- Meta Handbook: Translations
- Polyglots Handbook: Plugin/Theme Authors Guide
- WP-CLI: wp i18n make-pot
- make-json
- make-php
- make-mo
- wordpress-develop, Branch 7.1: wp-includes/l10n.php
- script-modules.php
- GlotPress: locales.php (Pluralregeln)
- translate.wordpress.org: Projekt Akismet, polnischer Export
- Plugin Check 2.1.0
- @wordpress/scripts
- @wordpress/i18n
- WordPress Coding Standards: EscapeOutputSniff
- WordPress Releases
- i18n-command 3.0.1: MakeJsonCommand
- @wordpress/scripts 36.0.0: package.json