fluent-kt

A Kotlin Multiplatform port of Mozilla's Fluent, the localization system that powers Firefox, Thunderbird, and Mozilla VPN. fluent-kt parses .ftl files, resolves message references and plural/ordinal variants, and renders messages with locale-aware number, date, and list formatting.

Docs: API reference (Dokka)

Status

Target Supported
JVM
Linux x64 (Kotlin/Native)
macOS, iOS, JS, wasm ❌ — not yet (see todo/04-project-hygiene.md)

API reference

Generated KDoc is published on GitHub Pages:

https://ggallovalle.github.io/fluent-kt/

Build locally with ./gradlew dokkaGenerate (output under build/dokka/html).

Quick start

import dev.kbroom.fluent.bundle.fluentBundle
import dev.kbroom.fluent.bundle.fluentArgsOf
import dev.kbroom.fluent.intl.LanguageIdentifier

// Construct a bundle from inline FTL via the DSL.
val bundle = fluentBundle(
locales = listOf(LanguageIdentifier.parse("en-US")),
) {
resource("""
greet = Hello, { $name }!
unread-emails = { $count ->
[one] You have one unread email.
*[other] You have { $count } unread emails.
}
""".trimIndent())
builtins() // NUMBER, PLURAL, DATETIME, DATE, TIME, LIST, ...
}

bundle.format("greet", fluentArgsOf("name" to "World"))
// → "Hello, World!"

bundle.format("unread-emails", fluentArgsOf("count" to 5))
// → "You have 5 unread emails."

bundle.format("unread-emails", fluentArgsOf("count" to 1))
// → "You have one unread email."

Bundles are immutable

A FluentBundle is built once via the DSL or FluentBundleBuilder and never mutated afterwards. Concurrent reads from multiple threads are safe by construction — no locks, no seal(), no per-call setup. See the ../fluent-bundle/src/commonMain/kotlin/dev/kbroom/fluent/bundle/FluentBundle.kt class for details.

Module overview

Module Purpose
fluent-syntax Parser and serializer for FTL → AST. Independent of bundle/runtime.
intl-memoizer Locale-aware formatters (numbers, dates, lists) with thread-safe cache.
fluent-bundle FluentBundle runtime: resolver, message/term lookup, custom functions, builder DSL. The main API surface.
fluent-pseudo Pseudolocalization (Accented, Bidi, Long, Widened, Hidden modes) for catching hard-coded strings.
fluent-fallback Localization facade: locale chain with per-locale fallback (e.g. en-USen → default).
fluent-resmgr File-system resource loading for bundles, with locale-tag → language → base path resolution.
fluent-testing Test helpers: loader for upstream fluent-rs fixtures.
fluent Umbrella module re-exporting the common entry points.
fluent-codegen AST walk + Kotlin emitter + locale scaffold (used by the Gradle plugin).
fluent-gradle-plugin Gradle plugin io.github.ggallovalle.fluent — validate / generate / scaffold.
fluent-compose Android Jetpack Compose: assets provider, multi-bundle registry, codegen remember*Messages().
benchmarks JMH microbenchmarks via kotlinx-benchmark (not published).

Gradle plugin (experimental)

Typed accessors from a multi-bundle FTL tree (resources/i18n/{locale}/{bundle}/**/*.ftl):

plugins {
id("io.github.ggallovalle.fluent") version "0.2.2"
}

fluent {
sourceDirs.from("src/main/resources/i18n")
defaultLocale.set("en-US")
packageName.set("com.example.i18n")
}
./gradlew fluentValidate
./gradlew fluentGenerate
./gradlew fluentScaffoldLocale --locale=es-MX

Generated types (from the default locale):

  • AppMessages(bundle) / AppL10n(localization) — typed methods + KDoc from FTL docs

  • FtlIds.App.GREETING — message id constants

  • AppResources.MessagesResourceId constants for fluent-fallback

With Compose:

fluent {
// …
generateComposeAccessors.set(true)
}

dependencies {
implementation("io.github.ggallovalle:fluent-compose:0.2.2")
}

emits rememberAppMessages() / rememberErrorsMessages() that read LocalFluentBundlesnot generated *Text composables. Your UI owns Text / Button / semantics.

Android Compose (experimental)

setContent {
ProvideFluentFromAssets(
resourceIdsByBundle = mapOf(
"app" to AppResources.All,
"errors" to ErrorsResources.All,
),
fallbackLocale = LanguageIdentifier.parse("en-US"),
basePath = "i18n",
) {
HomeScreen()
}
}

@Composable
fun HomeScreen() {
val app = rememberAppMessages()
Text(app.greeting(name = "Ada"))
}

Assets layout: src/main/assets/i18n/{locale}/{bundle}/**/*.ftl. See examples/android-compose/ and examples/README.md. Docs: https://ggallovalle.github.io/fluent-kt/

See todo/09-ecosystem.md for the full design.

Build

./gradlew jvmTest          # JVM unit tests
./gradlew linuxX64Test # Native tests
./gradlew detektAll # Style + lint
./gradlew build # All artifacts
./gradlew :fluent-compose:testDebugUnitTest # Android Compose library
./gradlew :examples:android-compose:assembleDebug # sample app
./gradlew :benchmarks:jvmSmokeBenchmark # quick JMH smoke
./gradlew :benchmarks:jvmBenchmark # full JVM benchmarks

Requires JDK 21 (Gradle target version).

Developing

Agent / contributor conventions live in AGENTS.md.

Tests use testBalloon — not JUnit @Test classes. Pattern:

import de.infix.testBalloon.framework.core.testSuite
import kotlin.test.assertEquals

val FluentThingTest by testSuite {
test("does the thing") {
assertEquals(expected, actual)
}
}

Apply id("de.infix.testBalloon") and depend on de.infix.testBalloon:testBalloon-framework-core in the module’s test source set (including JVM-only modules such as fluent-codegen).

License

LICENSE — same as fluent-rs's MIT option.

Acknowledgements

  • Mozilla Fluent — the original Rust implementation this project is modeled after.

  • fluent-rs — reference for test fixtures and parser semantics.

All modules:

Link copied to clipboard
Link copied to clipboard
Link copied to clipboard
Link copied to clipboard
Link copied to clipboard
Link copied to clipboard
Link copied to clipboard