[ANN] Project Fluent for Haskell

We are happy to announce that Project Fluent has finally come to Haskell! :tada:

Fluent is a localisation system for natural-sounding translations. It keeps simple messages simple and lets translators express plurals, gender and other grammar when a language needs it.

Core:

  • fluent-syntax
    The FTL syntax tree and parser, as well as a quasi-quoter to construct resources at compile time. Tested against the upstream reference fixtures from the Fluent project.
  • fluent
    Translation API comprised of Bundle, Locale, and Translate. This library does not provide any concrete instances of Locale, and is thus not intended to be used directly for translating messages.
  • fluent-effectful
    A Fluent effect for the effectful effect system. Can be either single or multi language. Just like fluent, it does not contain any concrete instances of Locale.

Backends:

  • fluent-icu
    A Locale backed by text-icu. This library re-exports everything needed to translate messages.
  • miso-fluent
    In-browser localisation for miso apps, backed by the browser’s Intl API.

All five packages are version 1.0.0 and licensed under EUPL-1.2.

For usage examples, please have a look at the module documentation on Hackage.

These libraries power our production systems, so we are committed to long-term, responsible maintenance. We are very much looking forward to your feedback, feature requests, and bug reports. :slightly_smiling_face:


Why Fluent?

Most localisation tools map each translation one-to-one onto the English string, so English grammar limits every other language.

Let’s take an example from Firefox, adapted from Mozilla’s Fluent 1.0 announcement[1].

We add one line to en.ftl:

tabs-closing = You are about to close { $count } tabs.

In Czech, β€œtab” is panely for counts of 2-4 and panelΕ― otherwise. The Czech translator adds the following to cz.ftl:

tabs-closing = { $count ->
    [few] ChystΓ‘te se zavΕ™Γ­t { $count } panely.
   *[other] ChystΓ‘te se zavΕ™Γ­t { $count } panelΕ―.
}

The Haskell code is the same for every language:

translate "tabs-closing" ("count", 3 :: Int)
-- Right "ChystΓ‘te se zavΕ™Γ­t 3 panely."

The developer only supplies an identifier and variables, while the translator is free to decide the complexity of the translation for the given language. Complexity (or lack thereof) in one language does not affect the code or other languages. It’s nice not having to glue together strings and conditionals in code.

Mozilla calls this asymmetric localisation. It also covers grammatical case, gender, and more.

Haskell can have nice things, too!

Mozilla’s official Rust implementation, fluent-bundle[2], resolves numbers without locale formatting[3].

On the Haskell side of things, fluent-icu and miso-fluent format numbers, currencies, units and dates for the locale:

translate "price" ("amount", 1234.5 :: Double) enGB
-- Right "1,234.50 euros"
translate "price" ("amount", 1234.5 :: Double) deCH
-- Right "1'234.50 Euro"

This makes our Haskell implementation more feature-complete than the official Rust one. :slightly_smiling_face:

Is this LLM slop?

No! Where would be the fun in that? We take great pride in our work[4]. We do use LLMs sometimes[5], but every line of code in our codebase is thoroughly reviewed, rewritten, argued about, rewritten again … by real humans.

Speaking of real humans, I’d like to take this opportunity to thank the following ones for all the reviewing and arguing and rewriting over the months, and without whom these libraries would never have seen the light of day:


  1. Fluent 1.0: a localization system for natural-sounding translations - Mozilla Hacks - the Web developer blog β†©οΈŽ

  2. crates.io: Rust Package Registry β†©οΈŽ

  3. In types/number.rs, FluentNumber::as_string is self.value.to_string() plus zero-padding for minimumFractionDigits. style, currency, and currencyDisplay are parsed and stored in FluentNumberOptions, but nothing reads them when formatting. There’s no locale-aware digit grouping and no DATETIME builtin. The same file has a // XXX: Add support for other options. comment on the plural side. β†©οΈŽ

  4. or maybe we just like pain and have too much time to spare β†©οΈŽ

  5. after all, how else can you complain about them β†©οΈŽ

23 Likes

How is the Value class supposed to work? It is giving me some really large strings if I plug in NaN.

ghci> let Right x = translate "temperature" ("degrees", value @Double (0/0)) german :: Either String Text
ghci> putStrLn $ unpack x
-200’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000’000 Grad Celsius

The current implementation uses Scientific.fromFloatDigits to convert the given Double value to Scientific:

ghci> import Data.Scientific qualified as Scientific
ghci> Scientific.fromFloatDigits (0 / 0 :: Double)
-2.0e308

But what is the class supposed to do? It has no documentation, and the instances seem arbitrary. For example, fromFloatDigits supports any RealFloat, but Value doesn’t have an instance for every RealFloat. It reminds me of Convertible.

The class represents every type we know how to format, by going through SomeValue as a concrete representation of values and relevant formatting options.

It’s true that the instances are incomplete and arbitrarily chosen (e.g. why is it missing Float). They’re just the ones we thought of while implementing this. PRs welcome. :slight_smile:

I think that especially in the case of Double and Float, the Value class could earn its keep by implementing a custom format that checks for NaN.

1 Like

I used Fluent many years ago for a (js) project, and really liked it. So much better than gettext or anything else I’ve tried for translations.