Getting Started

A catalog, an I18n instance, and a resolved string.

Dependency

dependencies {
    implementation("io.peekandpoke.ultra:i18n:0.117.0")
}

The runtime is multiplatform and has no dependencies of its own.

The runtime, without codegen

You can use the resolver directly. This is what the generated accessors call underneath, and it is the quickest way to see the moving parts.

import io.peekandpoke.ultra.i18n.I18n
import io.peekandpoke.ultra.i18n.Locale
import io.peekandpoke.ultra.i18n.MapI18nCatalog

val catalog = MapI18nCatalog(
    "en" to mapOf(
        "checkout.greeting" to "Hello {{name}}!",
        "checkout.items_one" to "{{count}} item",
        "checkout.items_other" to "{{count}} items",
    ),
    "de" to mapOf(
        "checkout.greeting" to "Hallo {{name}}!",
    ),
)

val i18n = I18n(locale = Locale("de"), fallback = Locale("en")) {
    install(catalog)
}

i18n.resolve("checkout.greeting", mapOf("name" to "Alice"))
// -> "Hallo Alice!"

i18n.resolvePlural("checkout.items", count = 3)
// -> "3 items"   (no German plural in the catalog, so the chain falls through to en)

Two things to note

The fallback is required, and it is not derived from the locale. It has to be a concrete target language you know is complete. Deriving it from locale.base would leave de-CH falling back to de and then to nothing.

A missing key returns the key itself rather than throwing or rendering empty. A visible checkout.greeting in the UI is a bug report; a blank space is not.

Placeholders

Placeholders are {{name}}. Substitution is single-pass: a value is never re-scanned, so a value that itself contains {{...}} can never be re-interpreted as another placeholder.

i18n.resolve("greeting", mapOf("name" to "{{secret}}", "secret" to "hunter2"))
// -> "Hello {{secret}}!"   — NOT "Hello hunter2!"

That matters when any part of a message comes from user input. Unknown placeholders are left in place for the same reason as missing keys — visibly wrong beats silently blank.

Plurals

Plural forms are separate keys with a _one / _other suffix. resolvePlural picks the form and supplies {{count}} for you.

i18n.resolvePlural("checkout.items", count = 1)   // "1 item"
i18n.resolvePlural("checkout.items", count = 7)   // "7 items"

If the _one form is missing the _other form is used, and if both are missing the base key is returned. Selection currently uses the simple count == 1 rule rather than CLDR plural categories.

Next

Writing catalogs by hand gets old quickly, and it puts the key strings back in your code. The Gradle plugin generates typed accessors from YAML and checks your languages against each other.