Catalogs & Codegen

YAML in, typed Kotlin out — and a checker that fails the build before your users find the gap.

Where catalogs live

One YAML file per locale, under the source set that owns them:

src/commonMain/i18n/messages.en.yaml
src/commonMain/i18n/messages.de.yaml
src/commonMain/i18n/messages.de-CH.yaml

Keys nest freely; nesting becomes a dotted key. Reserved Kotlin words and hyphenated keys are handled by the emitter, so you can name things naturally.

checkout:
  greeting: "Hello {{name}}!"
  is: "reserved words are fine"
  greet-user: "Hi {{first-name}}"
  items_one: "{{count}} item"
  items_other: "{{count}} items"
  nested:
    deep: "Deep {{value}}"

Wiring the plugin

apply<I18nPlugin>()

configure<I18nExtension> {
    packageName.set("com.example.app.i18n")
    moduleName.set("App")          // prefix for generated names; defaults to the project name
    fallbackLang.set("en")         // defines the generated API surface
    requiredLangs("de")            // must be at full key parity with the fallback
    // sourceSet.set("main")       // for a plain kotlin("jvm") module; default is commonMain
}

Generated code lands in build/generated/i18n/<sourceSet>/kotlin and is registered on the source set through the task provider, so every compilation depends on generation automatically. There is no manual wiring and no committed generated code.

What gets generated

Two things: a baked catalog object holding every locale's strings, and typed accessors hanging off the open translation surface.

// Generated — accessors named after your namespaces
val I18nTranslate.checkout: CheckoutI18n get() = CheckoutI18n(i18n)

// Usage — placeholders are named parameters
i18n.translate.checkout.greeting(name = "Alice")
i18n.translate.checkout.items(count = 3)
i18n.translate.checkout.nested.deep(value = "x")

The fallback catalog alone defines the API. Other languages may omit keys — they fall through the chain — but they can never introduce one the fallback does not have. That is what keeps the generated surface stable and makes a removed key a compile error at every call site.

The checker

checkI18n is wired into check, so it runs with your tests. It compares every non-fallback catalog against the fallback:

Finding Severity
A requiredLangs language is missing a key, or absent entirely Error — fails the build
A locale introduces a placeholder the fallback lacks Error — fails the build
A locale defines a key the fallback does not have Warning — error under -Pi18n.strict
An optional language is incomplete Warning — error under -Pi18n.strict
A locale omits a placeholder the fallback has Info — logged only, never fails

A report is written to build/reports/i18n/check.txt. Note that requiredLangs takes base languages only — a region inherits from its base, so requiring de-CH is rejected outright rather than silently doing nothing.

Note the asymmetry, which is easy to get backwards: introducing an unknown placeholder is an error, because it can never be filled. Omitting one is only INFO — a translator dropping {{count}} still produces a grammatical sentence, just one missing a number, and there are languages where leaving it out is the correct translation.

One catalog per module

Each module generates and ships its own catalog. An application installs them in order, and later installs win on shared keys:

val i18n = I18n(locale, fallback) {
    install(KraftFormsI18nCatalog)   // framework first
    install(AppI18nCatalog)          // app last — overrides on conflict
}

So an app can restate a framework string without forking the framework, and a library can ship sensible defaults without dictating them.