This has come up a few times here on Discourse so you can search a bit and find some prior conversations.
We’ve built a script that goes to our translation management system (we use locize.io, which is alright, but there are probably nicer alternatives) and fetches all the translations as JSON. Then we iterate through all the languages, for each language a simple script translates each files into module that looks like this:
module Translation exposing (..)
featureName : String
featureName =
"name_en"
acquisition : String
acquisition =
"Acquisition:"
after : String
after =
"After"
area : String
area =
"Area:"
before : String
before =
"Before"
-- ...
Then the script compiles the application as main.en-US.js (for example), then moves on to the next language. The backend will then decide which JS file to serve to each user.
The advantages of this are:
No performance overhead - these are just regular strings.
Compile time safety - the project won’t compile if something is wrong with the translations.
Code simplicity - there is no need to pass around any state, simply text T.after instead of text "After"
Autocomplete works for translations
The disadvantages are:
If a user wants to switch language, this needs a full reload (IMO this is not a big deal, since most users don’t switch languages very often)
Extra build time complexity and time - the app is essentially rebuilt for each language. However, incremental builds tend to be reasonably fast.
In development, adding a new translation string is a little tedious:
Go to the management system and add the string
Run a script that regenerates the Translation file
I have used ChristophP/elm-i18next a lot. It was a good experience. (Christoph, the author, was my coworker). Just from floating around Elm meet ups, I can recall two other teams that used basically the same approach as ChristophP/elm-i18next and were also happy with it.
But its not the only approach. Like @gampleman points out, there are different costs and benefits to different approaches. I have also seen an approach like this:
type Language
= English
| German
| Spanish
hello : Language -> String
hello lang =
case lang of
English ->
"hello"
German ->
"hallo"
-- ..
The benefits as I see it are:
Type safety
Ability to change languages without reloading the app, or fetching a new set of translations
Costs I see are:
Either the developers have to put in every language manually or you need a complex compile-to-elm system
Bigger program size, since you have all the languages all the time.
I think the best solution depends on the size of your company, who does the writing/translation, and how many languages you want to support. We will only ever need to support 2 languages, so I think we’ll go with compile-time translations like @gampleman, but just write them directly as Elm files.
I’m thinking of having two folders like:
Swedish
Common.elm
Salaryfiles.elm
…
English
Common.elm
Salaryfiles.elm
…
And then the build script would rename one of the folders to Text and build, and then rename the other one to Text and build. If the languages don’t match, you’ll get an error at build time.
For development you could have a symlink like Text -> Swedish
Btw, all phrases wouldn’t need to be simple strings either, you could have functions like:
pluralFruit : Int -> Fruit -> String
pluralFruit count frut =
case fruit of
Banana ->
String.fromInt count ++ " banan" ++ (if count == 1 then "" else "er")
We do something similar, we have JSON files that are named like en-us.json that contain the localization data.
"welcome-message": "Welcome %s, you have %d notifications",
"plain-message": "Hello"
At build time we have a webpack plugin that turns those into Elm code like
welcomeMessage: String -> Int -> String
welcomeMessage s1 d1 =
"Welcome " ++ s1 ++ ", you have " ++ String.fromInt d1 ++ " notifications"
plainMessage: String
plainMessage =
"Hello"
There are a few things I really like about that approach:
It works with dead code elimination, unused translations won’t show up in the optimized build
Plain string values get compiled away during the optimize step (meaning no function call overhead for strings like plainMessage.
Missing translations are a compile error
The downside is that you have to have separate builds for each language. In day to day use I like it a lot more than our previous solution of keeping a Dict String String on the Session object in our model, it becomes cumbersome to pass the session everywhere you need a translation in my opinion.
Here’s another interesting solution I’ve seen to the “bucket brigade” problem (having to pass the current language to all view functions in your app) https://package.elm-lang.org/packages/arowM/elm-html-with-context/latest/
It replaces the regular Html type with a type that’s actually a function. Then at the top level of your app you resolve it with the selected language. This way you do it all in Elm, without complicating your build step.
Tried that approach in my demo app, and it did simplified the API.
But I have to wrap many elm-ui functions, and naming is a bit challenging. I have to remove the lazy call, otherwise the text nodes rendered were not correct, I couldn’t figure out why
Massive Decks (a web game) contains a pure-elm localization system which might be of interest to someone looking to implement something similar.
You probably want to start looking at this folder.
The structure is roughly what you’d expect—mostly about mapping a custom string type to text strings, but it also does some additional things like enhancing strings with other HTML content if rendered into HTML, and allowing the use of references from one string to another.