margot
i18n code generator using YAML files (or TOML, JSON, CSV) to generate type-safe Gleam code inspired by Dart slang.
Table of Contents
Usage
Add margot CLI to your project as a dev dependency
gleam add margot --dev
Create one <locale>.i18n.yaml file per language in a lang directory at the root of your project next to gleam.toml (YAML is used here, see File Types for the other types) :
├── gleam.toml
├── lang
│ ├── en.i18n.yaml
│ └── fr.i18n.yaml
└── src
└── app.gleam
# lang/en.i18n.yaml
home:
title: "Welcome back!"
greeting: "Hello {name}"
unread_messages:
one: "You have {n} unread message"
other: "You have {n} unread messages"
buttons:
save: "Save"
cancel: "Cancel"
# lang/fr.i18n.yaml
home:
title: "Bon retour !"
greeting: "Bonjour {name}"
unread_messages:
one: "Vous avez {n} message non lu"
other: "Vous avez {n} messages non lus"
buttons:
save: "Enregistrer"
cancel: "Annuler"
Then run the margot module with the following :
gleam run -m margot
And it creates the src/t.gleam file for you, defining :
- the
t.Localeenum with one variant per locale (t.Enandt.Frhere) t.auto_()which auto-detects the user locale for you- one function per translation taking the locale as its first argument and after your parameters (
t.buttons__save(locale))
├── gleam.toml
├── lang
│ ├── en.i18n.yaml
│ └── fr.i18n.yaml
└── src
├── app.gleam
└── t.gleam <- generated
import t
pub fn main() {
let locale = t.auto_() // on JS it auto-detects the user language, otherwise it falls back to the default one
// let locale = t.Fr // or pick one yourself
echo t.buttons__save(locale) // basic
echo t.home__greeting(locale, name: "Margot") // parameters
echo t.home__unread_messages(locale, n: 3) // pluralization
}
The name of a function is the path of the translation in the YAML file with the keys joined by __ (home.greeting becomes t.home__greeting).
A missing translation is only a warning (margot falls back to the default locale) but a missing parameter, or one of the wrong type, is an error.
CLI
| Command | Description |
|---|---|
gleam run -m margot | Generate the Gleam files from the translation files |
gleam run -m margot watch | Rebuild automatically when a translation file changes |
Configuration
By default margot generates the src/t.gleam file. You can change where it is generated by adding a [tools.margot] table to your gleam.toml.
[tools.margot]
output = "src/app/i18n.gleam"
The name of the file is the name of the module so here src/app/i18n.gleam is imported with import app/i18n and must be a valid Gleam module name (lowercase and _).
The path can go outside of the project root using .., missing directories are created.
The JavaScript file used to detect the user locale is generated next to the module and is named after it (src/app/i18n_ffi.mjs here).
├── gleam.toml
├── lang
│ ├── en.i18n.yaml
│ └── fr.i18n.yaml
└── src
└── app
├── i18n.gleam <- generated
└── i18n_ffi.mjs <- generated
Personally, and this is just my opinion, I find a t module faster to memorize and use, but do as you please.
Main Features
File Types
margot reads one file per locale in the lang directory. The extension of the file gives its type : <locale>.i18n.<extension>.
The following types are supported :
| File Type | File extension |
|---|---|
| YAML | .i18n.yaml |
| TOML | .i18n.toml |
| JSON | .i18n.json |
| CSV | .i18n.csv |
Every type generates exactly the same code, the rest of this README uses YAML. The order of the keys in the file is the order of the generated functions.
YAML Example
You have already seen what YAML looks like.
# lang/en.i18n.yaml
home:
title: "Welcome back!"
greeting: "Hello {name}" # some comment
unread_messages:
one: "You have {n} unread message"
other: "You have {n} unread messages"
TOML Example
Tables and dotted keys both work. The keys with a modifier contain parentheses, so they must be quoted.
# lang/en.i18n.toml
[home]
title = "Welcome back!"
greeting = "Hello {name}" # some comment
[home.unread_messages]
one = "You have {n} unread message"
other = "You have {n} unread messages"
[ranking."place(ordinal)"]
one = "{n}st place"
other = "{n}th place"
Dates and times (since = 2024-01-01) are not translations, quote them if you want them as text. Arrays of tables ([[table]]) are not supported.
JSON Example
{
"home": {
"title": "Welcome back!",
"greeting": "Hello {name}",
"unread_messages": {
"one": "You have {n} unread message",
"other": "You have {n} unread messages"
}
}
}
CSV Example
A row is the path of the translation joined by . and its value.
home.title,Welcome back!
home.greeting,Hello {name}
home.unread_messages.one,You have {n} unread message
home.unread_messages.other,You have {n} unread messages
A value containing a comma or a quote must be quoted, and "" is a quote :
introduce,"Hello, @:{fields.name}"
quote,"Say ""hello"""
A backslash is not special so \{name} is written as is. There are no types and no comments.
Mixing types
Every type can live in the same lang directory, for example en.i18n.yaml next to fr.i18n.toml.
A locale must come from one file though, having en.i18n.yaml and en.i18n.toml is an error.
├── gleam.toml
└── lang
├── en.i18n.yaml
├── fr.i18n.toml
├── de.i18n.json
└── it.i18n.csv
Parameter
Translations often have dynamic parameters. Wrap the name of a parameter in braces {name}.
You can always escape them by adding a backslash \\{notAnArgument}.
Each translation has its own parameters. They become the labelled arguments of the generated function after the locale.
hello: "Hello {name}. I am {height}m."
let s = t.hello(locale, name: "Margot", height: "1.80") // Hello Margot. I am 1.80m.
By default every parameter is a String except the parameter n which is an Int since it’s often used for pluralization (see Pluralization).
You can specify the type using the {name: Type} syntax.
Supported types are String, Int and Float.
greet: "Hello {name}, you are {age: Int} years old"
// fn(Locale, name: String, age: Int) -> String
let s = t.greet(locale, name: "Margot", age: 23)
When a translation uses the same parameter name several times, the same value is used for all of them. If two locales use the same parameter with different types generation fails. The same goes for parameters coming from a linked translation (see Linked Translations).
Linked Translations
You can link one translation to another. Add @:{path} with the absolute path of the desired translation.
fields:
name: "my name is {first_name}"
age: "I am {age} years old"
introduce: "Hello, @:{fields.name} and @:{fields.age}"
let s = t.introduce(locale, first_name: "Margot", age: "23") // Hello, my name is Margot and I am 23 years old.
The parameters of the linked translations are added to the ones of the translation so first_name & age become first_name, age.
Parameters coming from linked translations are merged as if they were a single translation. This means the rules about not mixing parameter types apply, and parameters with the same name share a single value.
first: "Hi {name}"
second: "Bye {name}"
both: "@:{first}, @:{second}"
let s = t.both(locale, name: "Margot") // Hi Margot, Bye Margot
To avoid repeating long paths you can use a relative path by using a single dot like that @:{.path}.
The link is then resolved against the siblings of the current translation.
remaining_time:
hours_unit: "{h}h"
minutes_unit: "{m}m"
hours: "@:{.hours_unit} @:{.minutes_unit}"
let s = t.remaining_time__hours(locale, h: "2", m: "30") // 2h 30m
Each additional dot goes up one level, like relative imports in Python : @:{..path} is resolved against the parent group, @:{...path} against the grandparent and so on.
Going above the root is an error.
first:
floor: "first floor"
other:
floor: "other floor"
second:
floor: "second floor"
parent: "@:{..floor}" # first floor
cousin: "@:{..other.floor}" # other floor
Optionally you can escape linked translations by using a backslash :
fields:
name: "my name is {first_name}"
introduce: "Hello, \\@:{fields.name}inator"
Pluralization
Handling plurals in translations makes your sentences richer and more natural for the end user.
This follows the CLDR concept defined here of placing different quantities inside categories.
Those categories are defined with the following keywords: zero, one, two, few, many, other.
Some languages are supported out of the box (ar, cs, de, en, es, fr, he, id, it, ja, ko, ms, pl, pt, ru, sv, th, uk, vi and zh).
Languages not yet supported will just use zero, one and other.
# lang/en.i18n.yaml
some_key:
apple:
one: "I have {n} apple."
other: "I have {n} apples."
let a = t.some_key__apple(locale, n: 1) // I have 1 apple.
let b = t.some_key__apple(locale, n: 2) // I have 2 apples.
Plurals are cardinals by default.
To specify ordinals you need to add the (ordinal) modifier at the end of the key.
When doing linked translations the modifier is not applied in the key path (@:{some_key.place}).
# lang/en.i18n.yaml
some_key:
apple(cardinal): # cardinal
one: "I have {n} apple."
other: "I have {n} apples."
place(ordinal): # ordinal (rarely used)
one: "{n}st place."
two: "{n}nd place."
few: "{n}rd place."
other: "{n}th place."
Inside your plurals you can add as many parameters as you want.
playlist_track_count:
one: "\"{name}\" has {n} track."
other: "\"{name}\" has {n} tracks."
let a = t.playlist_track_count(locale, n: 2, name: "Road trip") // "Road trip" has 2 tracks.
As said, the plural is driven by n by default. Add =name to the modifier to target another parameter instead :
apple(cardinal=count):
one: "I have {count} apple."
other: "I have {count} apples."
place(ordinal=rank):
one: "{rank}st place."
other: "{rank}th place."
let a = t.apple(locale, count: 2) // I have 2 apples.
let b = t.place(locale, rank: 1) // 1st place.
To use multiple different plurals in the same sentence you can use Linked Translations.
Parameters with the same name are merged, so by default the plurals share the same n :
apples:
one: "one apple"
other: "{n} apples"
bananas:
one: "one banana"
other: "{n} bananas"
sentence: "I have @:{apples} and @:{bananas}"
let s = t.sentence(locale, n: 2) // I have 2 apples and 2 bananas
Give each plural its own parameter to make them independent :
apples(cardinal=apple_count):
one: "one apple"
other: "{apple_count} apples"
bananas(cardinal=banana_count):
one: "one banana"
other: "{banana_count} bananas"
sentence: "I have @:{apples} and @:{bananas}"
let s = t.sentence(locale, apple_count: 1, banana_count: 2) // I have one apple and 2 bananas
A key must be a plural in every locale.
Other Features
Region Extensions
You can extend a language-only locale (e.g. en) with region-specific locales (e.g. en-US, en-GB).
It always falls back to the language-only locale so a region file only contains what differs.
A language-only locale falls back to the default locale with a warning for each missing key.
The default locale is en when it exists, otherwise the first locale in alphabetical order.
It synergizes well with Wildcard Locales.
en.i18n.yaml
en-US.i18n.yaml -> en.i18n.yaml
en-GB.i18n.yaml -> en.i18n.yaml
fr.i18n.yaml -> en.i18n.yaml (default locale, warns on missing keys)
fr-CA.i18n.yaml -> fr.i18n.yaml
Variants of t.Locale are named in PascalCase: en-US becomes t.EnUs.
Wildcard Locales
Reuse the same translations for multiple locales.
A locale can come from several files. When they define the same translation the first one wins in this order:
- the file named after the locale (
de.i18n.yaml) - the wildcards with codes (
[de,fr,ja].i18n.yaml) - the wildcards with
any([any]-FR.i18n.yaml)
It works well with Region Extensions.
| Syntax | Description |
|---|---|
[de,en,fr-FR] | Spreads to the specified codes |
[any] | Spreads to all existing codes in that position |
[any] at the start of a name is every existing language and [any] after a - is every existing region :
[any]-FRspreads to every language,en-[any]spreads to every region.
Scripts (Hant in zh-Hant-TW) are never spread and an [any] without any existing code is a warning.
lang/
├── de.i18n.yaml
├── en.i18n.yaml
├── fr.i18n.yaml
├── [de,fr,ja].i18n.yaml
└── [any]-FR.i18n.yaml
This will spread to the following locales:
| Locale | Source |
|---|---|
de | de.i18n.yaml, [de,fr,ja].i18n.yaml |
en | en.i18n.yaml |
fr | fr.i18n.yaml, [de,fr,ja].i18n.yaml |
ja | [de,fr,ja].i18n.yaml |
de-FR | [any]-FR.i18n.yaml |
en-FR | [any]-FR.i18n.yaml |
fr-FR | [any]-FR.i18n.yaml |
ja-FR | [any]-FR.i18n.yaml |
Sanitization
All keys and parameter names must be valid Gleam identifiers. Otherwise margot will automatically sanitize them.
The suffix _ is added if the generated function name, or a parameter name, is one of the Gleam keywords (import, type, case, fn, use, auto, …).
So the following key
import: "Import"
will be sanitized to
t.import_(locale) // "Import"
Note:
Sanitization happens after resolving Linked Translations.
Therefore, links use the key as written in your files (e.g. @:{general.import}).