YAML-driven internationalization for Bukkit / Spigot / Paper / Folia plugins. MiniMessage native, legacy & codes alongside, per-player locale resolution, hot reload, Bedrock-aware sending, and a plural-form selector — packed into a ~160 KB JAR with zero bundled runtime dependencies.
Originally built for the JExSuite plugin family, JExTranslate is consumable as a standalone library. It runs on Minecraft 1.8 through 1.21+ with feature detection at startup, so the same plugin JAR works across the whole Bukkit-API span without compile-time version flags.
plugins/MyPlugin/translations/
├── en_US.yml <- default
├── de_DE.yml
└── fr_FR.yml
# en_US.yml
prefix: "<dark_gray>[<gold>MyPlugin</gold>]</dark_gray> "
welcome:
join: "<green>Welcome back, <bold>{player}</bold>!</green>"
balance:
message: "Your balance: <gold>{balance} coins</gold>"r18n.msg("welcome.join")
.with("player", player.getName())
.prefix()
.send(player);
// → [MyPlugin] Welcome back, **SaltyFeaRz**!- Install
- Quick start
- Translation files
- Sending messages
- Locale resolution
- Plurals
- Bedrock
- Hot reload
- Configuration reference
- Admin command
- Migration from
I18n - Building from source
- License & credits
// build.gradle.kts
repositories {
maven("https://maven.pkg.github.com/JExcellence/JExSuite") {
credentials {
username = providers.gradleProperty("gpr.user").orNull
password = providers.gradleProperty("gpr.key").orNull
}
}
}
dependencies {
compileOnly("de.jexcellence.translate:jextranslate:3.0.0")
}compileOnly because JExTranslate is loaded at runtime by the server (typically via JExDependency or shaded into your plugin JAR — see Building from source).
Requires Java 21+ and Bukkit API 1.8 or newer.
public final class MyPlugin extends JavaPlugin {
private R18nManager r18n;
@Override
public void onEnable() {
r18n = R18nManager.builder(this)
.defaultLocale("en_US")
.supportedLocales("en_US", "de_DE", "fr_FR")
.enableKeyValidation(true)
.enableFileWatcher(true) // hot reload while server is running
.build();
r18n.initialize()
.thenRun(() -> getLogger().info("Translations loaded"))
.exceptionally(ex -> {
getLogger().log(Level.SEVERE, "Translation init failed", ex);
return null;
});
}
@Override
public void onDisable() {
if (r18n != null) r18n.shutdown();
}
}// Send to a player — locale resolved from Player.getLocale()
r18n.msg("welcome.join").with("player", player.getName()).send(player);
// Add the configured plugin prefix
r18n.msg("error.no_permission").prefix().send(player);
// Console (uses the default locale)
r18n.msg("startup.complete").console();
// Broadcast to every online player (each gets their own locale)
r18n.msg("server.restart").with("seconds", 30).broadcast();
// Force a specific locale
r18n.msg("admin.report").locale("en_US").send(adminPlayer);Component title = r18n.msg("gui.shop_title").component(player);
List<Component> lore = r18n.msg("item.lore").components(player);
String plain = r18n.msg("mail.subject").plain(player); // no formattingFiles live at plugins/<YourPlugin>/translations/<locale>.yml and are auto-extracted from the plugin JAR on first start. Players (or you) can edit them in place; with enableFileWatcher(true) the changes pick up without a restart.
Keys are dot-separated and map directly to YAML nesting. Use [a-z0-9_.] only.
welcome:
player: "Hello, {player}!"
server: "You joined {server}."
error:
no_permission: "<red>You don't have permission.</red>"
unknown_command: "<red>Unknown command. Try /help.</red>"→ welcome.player, welcome.server, error.no_permission, error.unknown_command.
A value is either a string or a list of strings. Lists become multi-line chat output or item lore.
join_message: "<green>Welcome back, {player}!</green>"
help_menu:
- "<gold>--- Help ---</gold>"
- "<yellow>/spawn</yellow> <gray>- teleport to spawn</gray>"
- "<yellow>/balance</yellow> <gray>- check your balance</gray>"r18n.msg("help_menu").send(player); // sends each line
List<Component> lore = r18n.msg("help_menu").components(player);Curly braces. Both {name} and %name% are recognised.
balance.line: "Your balance: <gold>{balance} coins</gold>"r18n.msg("balance.line").with("balance", account.getBalance()).send(player);Placeholder values are MiniMessage-escaped automatically — a player named <red>Griefer</red> cannot hijack your message into a red one.
MiniMessage is the native format:
fancy:
click: "<click:run_command:'/spawn'><aqua>[Click to teleport]</aqua></click>"
gradient: "<gradient:red:gold>Server is restarting!</gradient>"
rainbow: "<rainbow>Have a colourful day!</rainbow>"Legacy & codes also work and are converted to MiniMessage at parse time:
old: "&aGreen &bAqua &cRed &lBold"Disable with .legacyColorSupport(false) if you want strict MiniMessage only.
Define prefix once at the top of each locale file. Calling .prefix() on a MessageBuilder prepends it to whatever you're sending.
prefix: "<dark_gray>[<gold>MyPlugin</gold>]</dark_gray> "r18n.msg("error.no_permission").prefix().send(player);
// → [MyPlugin] You don't have permission.Every send / convert method lives on MessageBuilder, returned by r18n.msg(key).
| Method | Purpose |
|---|---|
.with(name, value) |
Add a placeholder |
.locale(code) |
Force a specific locale (overrides player's locale) |
.prefix() |
Prepend the configured prefix value |
.count(name, n) |
Pluralised placeholder — drives the .zero / .one / .other selector |
.send(target) |
Send to a Player, CommandSender, or Adventure Audience |
.broadcast() |
Send to every online player (each in their own locale) |
.console() / .console(locale) |
Send to the console |
.sendBedrock(player) |
Force Bedrock-safe send (strip click/hover/fonts, downgrade hex) |
.component(player) |
Returns the rendered Adventure Component |
.components(player) |
Returns List<Component> for multi-line values |
.text(player) |
Returns the rendered string |
.texts(player) |
Returns List<String> for multi-line values |
.plain(player) |
Plain text — no formatting (useful for Bedrock forms) |
.toBedrockString(player) |
Bedrock-compatible legacy §-string |
.toBedrockStrings(player) |
Multi-line Bedrock-compatible strings |
.exists(player) |
true if the key resolves for the player's locale |
Verbose aliases are also available — message(key) for msg(key), placeholder(k, v) for with(k, v), withPrefix() for prefix(), toComponent(p) for component(p), etc. Use whichever style fits your codebase.
Resolved per-message, in this order:
- Explicit
.locale("...")on the builder Player.getLocale(), normalised and matched against the configured supported locales- The default locale
If a key is missing in the resolved locale, the lookup falls back to the default locale automatically. If it's missing there too, onMissingKey runs — by default that surfaces a bright Missing: <key> line so gaps are visible during development.
Append .zero / .one / .two / .few / .many / .other to a key:
items:
count:
zero: "You have no items."
one: "You have <gold>{count}</gold> item."
other: "You have <gold>{count}</gold> items."r18n.msg("items.count").count("count", inventory.size()).send(player);The selector follows ICU CLDR rules per locale — Russian, Polish, Arabic, etc. all get correct forms. If a specific form isn't defined, it falls through to .other, then to the base key.
Geyser / Floodgate Bedrock players are auto-detected. When you send(player) a message that uses click events, hover events, or custom fonts, those features are automatically stripped for Bedrock players while colours and formatting are preserved. Java players still see the full message.
You can also bypass detection or get a Bedrock-formatted string explicitly:
r18n.msg("welcome").with("player", name).sendBedrock(player); // force Bedrock formatting
String bedrockText = r18n.msg("item.name").toBedrockString(player);
List<String> bedrockLore = r18n.msg("item.lore").toBedrockStrings(player);
if (r18n.msg("any.key").isBedrockPlayer(player)) {
// Bedrock-only branch
}Two knobs control the conversion:
new R18nConfiguration.Builder()
.hexColorFallback(HexColorFallback.NEAREST_LEGACY) // hex → nearest &-code
.bedrockFormatMode(BedrockFormatMode.CONSERVATIVE) // strip click/hover/fonts
.build();R18nManager.builder(plugin).enableFileWatcher(true).build();A daemon thread watches plugins/<YourPlugin>/translations/ for create / modify / delete events and reloads the parsed translations in-memory. Or trigger reloads from code:
r18n.reload().thenRun(() -> sender.sendMessage("Translations reloaded"));Invalid YAML / JSON during a reload is logged and the previous in-memory state is preserved — broken edits don't take down the plugin.
| Method | Default | What it does |
|---|---|---|
.defaultLocale(String) |
en_US |
Fallback locale when the player's locale isn't loaded |
.supportedLocales(String...) |
{ en_US } |
Locales to load. Empty set or .autoDetectLocales() loads every file in the dir |
.autoDetectLocales() |
— | Load whatever's in the translation directory, no whitelist |
.translationDirectory(String) |
translations |
Folder name inside the plugin's data folder |
.enableKeyValidation(boolean) |
true |
Log keys missing from non-default locales on startup |
.enablePlaceholderAPI(boolean) |
false |
Resolve %placeholder% via PlaceholderAPI before MiniMessage parsing |
.enableFileWatcher(boolean) |
false |
Hot reload on file change |
.configuration(R18nConfiguration) |
— | Pass a hand-built configuration for fine control |
For caching, missing-key handling, Bedrock formatting, metrics, etc. Pass to .configuration(...) on the manager builder.
| Method | Default | Description |
|---|---|---|
.legacyColorSupport(boolean) |
true |
Honour &a-style colour codes alongside MiniMessage |
.debugMode(boolean) |
false |
Verbose logging through the load + lookup pipeline |
.enableCache(boolean) |
true |
Cache parsed Component objects |
.cacheMaxSize(int) |
1000 |
Cache capacity |
.cacheExpireMinutes(int) |
30 |
Cache TTL |
.enableMetrics(boolean) |
false |
Track translation usage — surfaces via /r18n metrics |
.bedrockSupportEnabled(boolean) |
true |
Auto-detect Bedrock players via Geyser / Floodgate |
.hexColorFallback(HexColorFallback) |
NEAREST_LEGACY |
How <#aabbcc> hex colours are downgraded for Bedrock |
.bedrockFormatMode(BedrockFormatMode) |
CONSERVATIVE |
What to strip for Bedrock (click / hover / fonts / etc.) |
.onMissingKey((key, locale, placeholders) -> String) |
Renders Missing: <key> |
Customise behaviour for unresolvable keys |
var config = new R18nConfiguration.Builder()
.defaultLocale("en_US")
.supportedLocales("en_US", "de_DE")
.cacheMaxSize(2000)
.cacheExpireMinutes(60)
.onMissingKey((key, locale, placeholders) ->
"<red>[Missing: " + key + "]</red>")
.build();
var r18n = R18nManager.builder(plugin).configuration(config).build();A built-in /r18n command with reload, missing, export, and metrics subcommands ships with the library. To register it:
# plugin.yml
commands:
r18n:
description: R18n translation management
usage: /r18n [reload|missing|export|metrics]
permission: r18n.adminr18n.registerCommand(); // /r18n
r18n.registerCommand("translate"); // custom alias| Subcommand | Effect |
|---|---|
/r18n reload |
Reload all translation files |
/r18n missing <locale> |
List keys present in default locale but missing from <locale> |
/r18n export <csv|json|yaml> |
Export every loaded translation to a file in the plugin data folder |
/r18n metrics |
Cache hit rate + per-key usage counts (requires enableMetrics(true)) |
Programmatic export:
r18n.exportTranslations(
Path.of("plugins/MyPlugin/translations-export.json"),
TranslationExportService.ExportFormat.JSON
);The legacy I18n / I18n.Builder API is deprecated since 3.0.0 and slated for removal. The replacement is r18n.msg(key) / MessageBuilder.
| Old | New |
|---|---|
new I18n.Builder("k", player).build().sendMessage() |
r18n.msg("k").send(player) |
…withPlaceholder("p", v)… |
…with("p", v)… |
…includePrefix()… |
…prefix()… |
…build().component() |
r18n.msg("k").component(player) |
new I18n.Builder("k").build().sendMessage() (console) |
r18n.msg("k").console() |
Differences worth knowing:
- No
.build()step —MessageBuildermethods send / convert directly - The player goes to
send(player)/component(player), not the constructor - Locale override (
.locale("…")), plural support (.count(…)), and Bedrock methods only exist on the new API
JExTranslate is part of the JExSuite monorepo:
git clone https://github.com/JExcellence/JExSuite.git
cd JExSuite
./gradlew :JExTranslate:build
./gradlew :JExTranslate:publishToMavenLocalThe built artifact lands at JExTranslate/build/libs/jextranslate-3.0.0.jar.
To consume from your own plugin's build script while developing:
// build.gradle.kts
repositories { mavenLocal() }
dependencies { compileOnly("de.jexcellence.translate:jextranslate:3.0.0") }If you want to ship JExTranslate inside your plugin JAR rather than rely on JExDependency, swap compileOnly for implementation and add a Shadow plugin relocation so the de.jexcellence.jextranslate package doesn't collide with other plugins doing the same:
import com.gradleup.shadow.tasks.ShadowJar
tasks.named<ShadowJar>("shadowJar") {
relocate("de.jexcellence.jextranslate", "myplugin.shaded.jextranslate")
}Author: JExcellence — https://jexcellence.de Adventure / MiniMessage: https://docs.advntr.dev Bedrock detection: Geyser / Floodgate (optional soft-dep) Cache: Caffeine
Part of the JExSuite plugin ecosystem.