Lokalized facilitates natural-sounding software translations on the JVM.
It is both a file format...
{
"I read {{bookCount}} books." : {
"translation" : "I read {{bookCount}} {{books}}.",
"placeholders" : {
"books" : {
"value" : "bookCount",
"translations" : {
"CARDINALITY_ONE" : "book",
"CARDINALITY_OTHER" : "books"
}
}
},
"alternatives" : [
{
"bookCount == 0" : "I didn't read any books."
}
]
}
}
...and a library that operates on it.
String translation = strings.get("I read {{bookCount}} books.",
new HashMap<String, Object>() {{
put("bookCount", 0);
}});
assertEquals("I didn't read any books.", translation);
- Complex translation rules can be expressed in a configuration file, not code
- First-class support for gender and plural (cardinal, ordinal, range) language forms per latest CLDR specifications
- Provide a simple expression language to handle traditionally difficult edge cases
- Support multiple platforms natively
- Immutability/thread-safety
- No dependencies
- Support for date/time, number, percentage, and currency formatting/parsing
- Support for collation
- Support for Java 7 and below
- Static analysis tool to autogenerate/sync localized strings files
- Additional Ports (JavaScript, Python, Android, Go, ...)
- Webapp for translators
<dependency>
<groupId>com.lokalized</groupId>
<artifactId>lokalized-java</artifactId>
<version>1.0.3</version>
</dependency>
If you don't use Maven, you can drop lokalized-java-1.0.3.jar directly into your project. No other dependencies are required.
- As a developer, it is unrealistic to embed per-locale translation rules in code for every text string
- As a translator, sufficient context and the power of an expression language are required to provide the best translations possible
- As a manager, it is preferable to have a single translation specification that works on the backend, web frontend, and native mobile apps
Perhaps most importantly, the Lokalized placeholder system and expression language allow you to support edge cases that are critical to natural-sounding translations - this can be difficult to achieve using traditional solutions.
We'll start with hands-on examples to illustrate key features.
Filenames must conform to the IETF BCP 47 language tag format.
Here is a generic English (en
) localized strings file which handles two localizations:
{
"I am going on vacation" : "I am going on vacation.",
"I read {{bookCount}} books." : {
"translation" : "I read {{bookCount}} {{books}}.",
"placeholders" : {
"books" : {
"value" : "bookCount",
"translations" : {
"CARDINALITY_ONE" : "book",
"CARDINALITY_OTHER" : "books"
}
}
},
"alternatives" : [
{
"bookCount == 0" : "I didn't read any books."
}
]
}
}
Here is a British English (en-GB
) localized strings file:
{
"I am going on vacation." : "I am going on holiday."
}
Lokalized performs locale matching and falls back to less-specific locales as appropriate, so there is no need to duplicate all the en
translations in en-GB
- it is sufficient to specify only the dialect-specific differences.
// Your "native" fallback strings file, used in case no specific locale match is found.
// ISO 639 alpha-2 or alpha-3 language code
final String FALLBACK_LANGUAGE_CODE = "en";
// Creates a Strings instance which loads localized strings files from the given directory.
// Normally you'll only need a single shared instance to support your entire application,
// even for multitenant/concurrent usage, e.g. a Servlet container
Strings strings = new DefaultStrings.Builder(FALLBACK_LANGUAGE_CODE,
() -> LocalizedStringLoader.loadFromFilesystem(Paths.get("my/strings/directory")))
.build();
You may also provide the builder with a locale-supplying lambda, which is useful for environments like webapps where each request can have a different locale.
// "Smart" locale selection which queries the current web request for locale data.
// MyWebContext is a class you might write yourself, perhaps using a ThreadLocal internally
Strings webappStrings = new DefaultStrings.Builder(FALLBACK_LANGUAGE_CODE,
() -> LocalizedStringLoader.loadFromFilesystem(Paths.get("my/strings/directory")))
.localeSupplier(() -> MyWebContext.getHttpServletRequest().getLocale())
.build();
// Lokalized knows how to map numbers to plural cardinalities per locale.
// That is, it understands that 3 means CARDINALITY_OTHER ("books") in English
String translation = strings.get("I read {{bookCount}} books.",
new HashMap<String, Object>() {{
put("bookCount", 3);
}});
assertEquals("I read 3 books.", translation);
// 1 means CARDINALITY_ONE ("book") in English
translation = strings.get("I read {{bookCount}} books.",
new HashMap<String, Object>() {{
put("bookCount", 1);
}});
assertEquals("I read 1 book.", translation);
// A special alternative rule is applied when bookCount == 0
translation = strings.get("I read {{bookCount}} books.",
new HashMap<String, Object>() {{
put("bookCount", 0);
}});
assertEquals("I didn't read any books.", translation);
// Here we force British English.
// Note that providing an explicit locale is an uncommon use case -
// standard practice is to specify a localeSupplier when constructing your
// Strings instance and Lokalized will use it to pick the appropriate locale, e.g.
// the locale specified by the current web request's Accept-Language header
translation = strings.get("I am going on vacation.", Locale.forLanguageTag("en-GB"));
// We have an exact match for this key in the en-GB file, so that translation is applied.
// If none were found, we would fall back to "en" and try there instead
assertEquals("I am going on holiday.", translation);
Lokalized's strength is handling phrases that must be rewritten in different ways according to language rules. Suppose we introduce gender alongside plural forms. In English, a noun's gender usually does not alter other components of a phrase. But in Spanish it does.
This English statement has 4 variants:
He was one of the X best baseball players.
She was one of the X best baseball players.
He was the best baseball player.
She was the best baseball player.
In Spanish, we have the same number of variants (in a language like Russian or Arabic there would be more!)
But notice how the statements must change to match gender - uno
becomes una
, jugadores
becomes jugadoras
, etc.
Fue uno de los X mejores jugadores de béisbol.
Fue una de las X mejores jugadoras de béisbol.
Él era el mejor jugador de béisbol.
Ella era la mejor jugadora de béisbol.
English is a little simpler than Spanish because gender only affects the He
or She
component of the sentence.
{
"{{heOrShe}} was one of the {{groupSize}} best baseball players." : {
"translation" : "{{heOrShe}} was one of the {{groupSize}} best baseball players.",
"placeholders" : {
"heOrShe" : {
"value" : "heOrShe",
"translations" : {
"MASCULINE" : "He",
"FEMININE" : "She"
}
}
},
"alternatives" : [
{
"heOrShe == MASCULINE && groupSize <= 1" : "He was the best baseball player."
},
{
"heOrShe == FEMININE && groupSize <= 1" : "She was the best baseball player."
}
]
}
}
Note that we define our own placeholders in translation
and drive them off of the heOrShe
value to support gender-based word changes.
{
"{{heOrShe}} was one of the {{groupSize}} best baseball players." : {
"translation" : "Fue {{uno}} de {{los}} {{groupSize}} mejores {{jugadores}} de béisbol.",
"placeholders" : {
"uno" : {
"value" : "heOrShe",
"translations" : {
"MASCULINE" : "uno",
"FEMININE" : "una"
}
},
"los" : {
"value" : "heOrShe",
"translations" : {
"MASCULINE" : "los",
"FEMININE" : "las"
}
},
"jugadores" : {
"value" : "heOrShe",
"translations" : {
"MASCULINE" : "jugadores",
"FEMININE" : "jugadoras"
}
}
},
"alternatives" : [
{
"heOrShe == MASCULINE && groupSize <= 1" : "Él era el mejor jugador de béisbol."
},
{
"heOrShe == FEMININE && groupSize <= 1" : "Ella era la mejor jugadora de béisbol."
}
]
}
}
Notice that we keep the gender and plural logic out of our code entirely and leave rule processing to the translation configuration.
// "Normal" translation
translation = strings.get("{{heOrShe}} was one of the {{groupSize}} best baseball players.",
new HashMap<String, Object>() {{
put("heOrShe", Gender.MASCULINE);
put("groupSize", 10);
}});
assertEquals("He was one of the 10 best baseball players.", translation);
// Alternative expression triggered
translation = strings.get("{{heOrShe}} was one of the {{groupSize}} best baseball players.",
new HashMap<String, Object>() {{
put("heOrShe", Gender.MASCULINE);
put("groupSize", 1);
}});
assertEquals("He was the best baseball player.", translation);
// Let's try Spanish
translation = strings.get("{{heOrShe}} was one of the {{groupSize}} best baseball players.",
new HashMap<String, Object>() {{
put("heOrShe", Gender.FEMININE);
put("groupSize", 3);
}}, Locale.forLanguageTag("es"));
// Note that the correct feminine forms were applied
assertEquals("Fue una de las 3 mejores jugadoras de béisbol.", translation);
You can exploit the recursive nature of alternative expressions to reduce logic duplication. Here, we define a toplevel alternative for groupSize <= 1
which itself has alternatives for MASCULINE
and FEMININE
cases. This is equivalent to the alternative rules defined above but might be a more "comfortable" way to express behavior for some.
Note that this is just a snippet to illustrate functionality - the other portion of this localized string has been elided for brevity.
{
"alternatives" : [
{
"groupSize <= 1" : {
"alternatives" : [
{
"heOrShe == MASCULINE" : "Él era el mejor jugador de béisbol."
},
{
"heOrShe == FEMININE" : "Ella era la mejor jugadora de béisbol."
}
]
}
}
]
}
When expressing a range of values (1-3 meters
, 2.5-3.5 hours
), the cardinality of the range is determined by applying per-language rules to its start and end cardinalities.
In English we don't think about this - all ranges are of the form CARDINALITY_OTHER
- but many other languages have range-specific forms.
French ranges can be either CARDINALITY_ONE
or CARDINALITY_OTHER
.
{
"The meeting will be {{minHours}}-{{maxHours}} hours long." : {
"translation" : "La réunion aura une durée de {{minHours}} à {{maxHours}} {{heures}}.",
"placeholders" : {
"heures" : {
"range" : {
"start" : "minHours",
"end" : "maxHours"
},
"translations" : {
"CARDINALITY_ONE" : "heure",
"CARDINALITY_OTHER" : "heures"
}
}
}
}
}
All English range forms evaluate to CARDINALITY_OTHER
so the file can be kept simple.
{
"The meeting will be {{minHours}}-{{maxHours}} hours long." : "The meeting will be {{minHours}}-{{maxHours}} hours long."
}
// French CARDINALITY_OTHER case
String translation = strings.get("The meeting will be {{minHours}}-{{maxHours}} hours long.",
new HashMap<String, Object>() {{
put("minHours", 1);
put("maxHours", 3);
}}, Locale.forLanguageTag("fr"));
assertEquals("La réunion aura une durée de 1 à 3 heures.", translation);
// French CARDINALITY_ONE case
translation = strings.get("The meeting will be {{minHours}}-{{maxHours}} hours long.",
new HashMap<String, Object>() {{
put("minHours", 0);
put("maxHours", 1);
}}, Locale.forLanguageTag("fr"));
assertEquals("La réunion aura une durée de 0 à 1 heure.", translation);
Many languages have special forms called ordinals to express a "ranking" in a sequence of numbers. For example, in English we might say
Take the 1st left after the intersection
She is my 2nd cousin
I finished the race in 3rd place
Let's look at an example related to birthdays.
English has 4 ordinals.
{
"{{hisOrHer}} {{year}}th birthday party is next week." : {
"translation" : "{{hisOrHer}} {{year}}{{ordinal}} birthday party is next week.",
"placeholders" : {
"hisOrHer" : {
"value" : "hisOrHer",
"translations" : {
"MASCULINE" : "His",
"FEMININE" : "Her"
}
},
"ordinal" : {
"value" : "year",
"translations" : {
"ORDINALITY_ONE" : "st",
"ORDINALITY_TWO" : "nd",
"ORDINALITY_FEW" : "rd",
"ORDINALITY_OTHER" : "th"
}
}
}
}
}
Spanish doesn't have ordinals, so we can disregard them. But we do have a few special cases - a first birthday and a quinceañera for girls.
{
"{{hisOrHer}} {{year}}th birthday party is next week." : {
"translation" : "Su fiesta de cumpleaños número {{year}} es la próxima semana.",
"alternatives" : [
{
"year == 1" : "Su primera fiesta de cumpleaños es la próxima semana."
},
{
"hisOrHer == FEMININE && year == 15" : "Su quinceañera es la próxima semana."
}
]
}
}
translation = strings.get("{{hisOrHer}} {{year}}th birthday party is next week.",
new HashMap<String, Object>() {{
put("hisOrHer", Gender.MASCULINE);
put("year", 18);
}});
// The ORDINALITY_OTHER rule is applied for 18 in English
assertEquals("His 18th birthday party is next week.", translation);
translation = strings.get("{{hisOrHer}} {{year}}th birthday party is next week.",
new HashMap<String, Object>() {{
put("hisOrHer", Gender.FEMININE);
put("year", 21);
}});
// The ORDINALITY_ONE rule is applied to any of the "one" numbers (1, 11, 21, ...) in English
assertEquals("Her 21st birthday party is next week.", translation);
translation = strings.get("{{hisOrHer}} {{year}}th birthday party is next week.",
new HashMap<String, Object>() {{
put("hisOrHer", Gender.MASCULINE);
put("year", 18);
}}, Locale.forLanguageTag("es"));
// Normal case
assertEquals("Su fiesta de cumpleaños número 18 es la próxima semana.", translation);
translation = strings.get("{{hisOrHer}} {{year}}th birthday party is next week.",
new HashMap<String, Object>() {{
put("year", 1);
}}, Locale.forLanguageTag("es"));
// Special case for first birthday
assertEquals("Su primera fiesta de cumpleaños es la próxima semana.", translation);
translation = strings.get("{{hisOrHer}} {{year}}th birthday party is next week.",
new HashMap<String, Object>() {{
put("hisOrHer", Gender.FEMININE);
put("year", 15);
}}, Locale.forLanguageTag("es"));
// Special case for a girl's 15th birthday
assertEquals("Su quinceañera es la próxima semana.", translation);
Gender rules vary across languages, but the general meaning is the same.
Lokalized supports these values:
Lokalized provides a Gender
type which enumerates supported genders.
For example: 1 book, 2 books, ...
Plural rules vary widely across languages.
Lokalized supports these values according to CLDR rules:
Values do not necessarily map exactly to the named number, e.g. in some languages CARDINALITY_ONE
might mean any number ending in 1
, not just 1
. Most languages only support a few plural forms, some have none at all (represented by CARDINALITY_OTHER
in those cases).
CARDINALITY_OTHER
: Matches everything (this language has no plural form)
CARDINALITY_ONE
: Matches 1 (e.g.1 dollar
)CARDINALITY_OTHER
: Everything else (e.g.256 dollars
)
CARDINALITY_ONE
: Matches 1, 21, 31, ... (e.g.1 рубль
or51 рубль
)CARDINALITY_FEW
: Matches 2-4, 22-24, 32-34, ... (e.g.2 рубля
or53 рубля
)CARDINALITY_MANY
: Matches 0, 5-20, 25-30, 45-50, ... (e.g.5 рублей
or17 рублей
)CARDINALITY_OTHER
: Everything else (e.g.0,3 руб
,1,5 руб
)
Lokalized provides a Cardinality
type which encapsulates cardinal functionality.
You may programmatically determine cardinality using Cardinality#forNumber(Number number, Locale locale)
and Cardinality#forNumber(Number number, Integer visibleDecimalPlaces, Locale locale)
as shown below.
It is important to note that the number of visible decimal places can be important for some languages when performing cardinality evaluation. For example, in English, 1
matches CARDINALITY_ONE
but 1.0
matches CARDINALITY_OTHER
. Even though the numbers' true values are identical, you would say 1 inch
and 1.0 inches
and therefore must take visible decimals into account.
// Basic case - a primitive number, no decimals
Cardinality cardinality = Cardinality.forNumber(1, Locale.forLanguageTag("en"));
assertEquals(Cardinality.ONE, cardinality);
// In the absence of an explicit number of visible decimals,
// 1.0 evaluates to Cardinality.ONE since primitive 1 == primitive 1.0
cardinality = Cardinality.forNumber(1.0, Locale.forLanguageTag("en"));
assertEquals(Cardinality.ONE, cardinality);
// With 1 visible decimal specified ("1.0"), we evaluate to Cardinality.OTHER
cardinality = Cardinality.forNumber(1, 1, Locale.forLanguageTag("en"));
assertEquals(Cardinality.OTHER, cardinality);
// Let's try BigDecimal instead of a primitive...
cardinality = Cardinality.forNumber(new BigDecimal("1"), Locale.forLanguageTag("en"));
assertEquals(Cardinality.ONE, cardinality);
// Using BigDecimal obviates the need to specify visible decimals
// since they can be encoded directly in the number.
// We evaluate to Cardinality.OTHER, as expected
cardinality = Cardinality.forNumber(new BigDecimal("1.0"), Locale.forLanguageTag("en"));
assertEquals(Cardinality.OTHER, cardinality);
For example: 0-1 hours, 1-2 hours, ...
The plural form of the range is determined by examining the cardinality of its start and end components.
CARDINALITY_ONE
-CARDINALITY_OTHER
⇒CARDINALITY_OTHER
(e.g.1–2 days
)CARDINALITY_OTHER
-CARDINALITY_ONE
⇒CARDINALITY_OTHER
(e.g.0–1 days
)CARDINALITY_OTHER
-CARDINALITY_OTHER
⇒CARDINALITY_OTHER
(e.g.0–2 days
)
CARDINALITY_ONE
-CARDINALITY_ONE
⇒CARDINALITY_ONE
(e.g.0–1 jour
)CARDINALITY_ONE
-CARDINALITY_OTHER
⇒CARDINALITY_OTHER
(e.g.0–2 jours
)CARDINALITY_OTHER
-CARDINALITY_OTHER
⇒CARDINALITY_OTHER
(e.g.2–100 jours
)
CARDINALITY_ZERO
-CARDINALITY_ZERO
⇒CARDINALITY_OTHER
(e.g.0–10 diennaktis
)CARDINALITY_ZERO
-CARDINALITY_ONE
⇒CARDINALITY_ONE
(e.g.0–1 diennakts
)CARDINALITY_ZERO
-CARDINALITY_OTHER
⇒CARDINALITY_OTHER
(e.g.0–2 diennaktis
)CARDINALITY_ONE
-CARDINALITY_ZERO
⇒CARDINALITY_OTHER
(e.g.0,1–10 diennaktis
)CARDINALITY_ONE
-CARDINALITY_ONE
⇒CARDINALITY_ONE
(e.g.0,1–1 diennakts
)CARDINALITY_ONE
-CARDINALITY_OTHER
⇒CARDINALITY_OTHER
(e.g.0,1–2 diennaktis
)CARDINALITY_OTHER
-CARDINALITY_ZERO
⇒CARDINALITY_OTHER
(e.g.0,2–10 diennaktis
)CARDINALITY_OTHER
-CARDINALITY_ONE
⇒CARDINALITY_ONE
(e.g.0,2–1 diennakts
)CARDINALITY_OTHER
-CARDINALITY_OTHER
⇒CARDINALITY_OTHER
(e.g.0,2–2 diennaktis
)
You may programmatically determine a range's cardinality using Cardinality#forRange(Cardinality start, Cardinality end, Locale locale)
as shown below.
// Latvian has a number of interesting range rules.
// ZERO-ZERO -> OTHER
Cardinality cardinality = Cardinality.forRange(Cardinality.ZERO, Cardinality.ZERO, Locale.forLanguageTag("lv"));
assertEquals(Cardinality.OTHER, cardinality);
// ZERO-ONE -> ONE
cardinality = Cardinality.forRange(Cardinality.ZERO, Cardinality.ONE, Locale.forLanguageTag("lv"));
assertEquals(Cardinality.ONE, cardinality);
For example: 1st, 2nd, 3rd, 4th, ...
Similar to plural cardinality, ordinal rules very widely across languages.
Lokalized supports these values according to CLDR rules:
Again, like cardinal values, ordinals do not necessarily map to the named number. For example, ORDINALITY_ONE
might apply to any number that ends in 1
.
ORDINALITY_OTHER
: Matches everything (this language has no ordinal form)
ORDINALITY_ONE
: Matches 1, 21, 31, ... (e.g.1st prize
)ORDINALITY_TWO
: Matches 2, 22, 32, ... (e.g.22nd prize
)ORDINALITY_FEW
: Matches 3, 23, 33, ... (e.g.33rd prize
)ORDINALITY_OTHER
: Everything else (e.g.12th prize
)
ORDINALITY_MANY
: Matches 8, 11, 80, 800 (e.g.Prendi l'8° a destra
)ORDINALITY_OTHER
: Everything else (e.g.Prendi la 7° a destra
)
Lokalized provides an Ordinality
type which encapsulates ordinal functionality.
You may programmatically determine ordinality using Ordinality#forNumber(Number number, Locale locale)
as shown below.
// e.g. "1st"
Ordinality ordinality = Ordinality.forNumber(1, Locale.forLanguageTag("en"));
assertEquals(Ordinality.ONE, ordinality);
// e.g. "2nd"
ordinality = Ordinality.forNumber(2, Locale.forLanguageTag("en"));
assertEquals(Ordinality.TWO, ordinality);
// e.g. "3rd"
ordinality = Ordinality.forNumber(3, Locale.forLanguageTag("en"));
assertEquals(Ordinality.FEW, ordinality);
// e.g. "21st"
ordinality = Ordinality.forNumber(21, Locale.forLanguageTag("en"));
assertEquals(Ordinality.ONE, ordinality);
// e.g. "27th"
ordinality = Ordinality.forNumber(27, Locale.forLanguageTag("en"));
assertEquals(Ordinality.OTHER, ordinality);
- Each strings file must be UTF-8 encoded and named according to the appropriate IETF BCP 47 language tag, such as
en
orzh-TW
- The file must contain a single toplevel JSON object
- The object's keys are the translation keys, e.g.
"I read {{bookCount}} books."
- The value for a translation key can be a string (simple cases) or an object (complex cases)
With formalities out of the way, let's return to our example en-GB
strings file, which contains a single translation. We can use the string form shorthand to concisely express our intent:
{
"I am going on vacation." : "I am going on holiday."
}
This is equivalent to the more verbose object form, which we don't need in this situation.
{
"I am going on vacation." : {
"translation" : "I am going on holiday."
}
}
In addition to translation
, each object form supports 3 additional keys: commentary
, placeholders
, and alternatives
.
All 4 are optional, with the stipulation that you must provide either a translation
or at least one alternatives
value.
This free-form field is used to supply context for the translator, such as how and where the phrase is used in the application. It might also include documentation about the application-supplied placeholder values (names and types) so it's clear what data is available to perform the translation.
{
"I am going on vacation." : {
"commentary" : "This is one of the options in the user's status update dropdown.",
"translation" : "I am going on holiday."
}
}
A placeholder is any translation value enclosed in a pair of "mustaches" - {{PLACEHOLDER_NAME_HERE}}
.
You are free to add as many as you like to support your translation.
Placeholder values are initially specified by application code - they are the context that is passed in at string evaluation time.
Your translation file may override passed-in placeholders if desired, but that is an uncommon use case.
In the below example of an en
strings file, the application code provides the bookCount
value and the translation file introduces a books
value to aid final translation.
{
"I read {{bookCount}} books." : {
"translation" : "I read {{bookCount}} {{books}}.",
"placeholders" : {
"books" : {
"value" : "bookCount",
"translations" : {
"CARDINALITY_ONE" : "book",
"CARDINALITY_OTHER" : "books"
}
}
}
}
}
Each placeholders
object key is the name of the placeholder - books
, in this example - and the value is an object with value
and translations
.
value
is the placeholder value to examine. It may be aNumber
,Cardinality
,Ordinality
, orGender
type. Lokalized will convertNumber
instances to the appropriateCardinality
orOrdinality
according the language's rulestranslations
is a set of language rules against which to evaluatevalue
and provide a translation
Here, the value of bookCount
is evaluated against the specified cardinality rules and the result is placed into books
. For example, if application code passes in 1
for bookCount
, this matches CARDINALITY_ONE
and book
is the value of the books
placeholder. If application code passes in a different value, CARDINALITY_OTHER
is matched and books
is used.
Supported values for translations
are Cardinality
, Ordinality
, and Gender
types.
You may not mix language forms in the same translations
object. For example, it is illegal to specify both CARDINALITY_ONE
and GENDER_MASCULINE
.
The placeholder structure is slightly different for cardinality ranges. A range
property is introduced and requires both a start
and end
value.
{
"The meeting will be {{minHours}}-{{maxHours}} hours long." : {
"translation" : "La réunion aura une durée de {{minHours}} à {{maxHours}} {{heures}}.",
"placeholders" : {
"heures" : {
"range" : {
"start" : "minHours",
"end" : "maxHours"
},
"translations" : {
"CARDINALITY_ONE" : "heure",
"CARDINALITY_OTHER" : "heures"
}
}
}
}
}
Here, the cardinalities of minHours
and maxHours
are evaluated to determine the overall cardinality of the range, which is used to select the appropriate value in translations
.
You are prohibited from supplying both range
and value
fields - use range
only for cardinality ranges and value
otherwise.
You may specify parenthesized expressions of arbitrary complexity in alternatives
to fine-tune your translations. It's perfectly legal to have an alternative like this:
gender == MASCULINE && (bookCount > 10 || magazineCount > 20)
Lokalized will automatically evaluate cardinality and ordinality for numbers if required by the expression. For example, in English, if I were to supply bookCount
of 50
, this expression would evalute to true
:
bookCount == CARDINALITY_OTHER
...and so would this:
bookCount == 50
Note that the supported comparison operators for cardinality, ordinality, and gender forms are ==
and !=
. You cannot say bookCount < CARDINALITY_FEW
, for example.
Alternative expression recursion is supported. That is, each value for alternatives
can itself have translation
, placeholders
, commentary
, and alternatives
. You can also use the simpler string-only form if no special translation functionality is needed.
Alternative evaluation follows these rules:
- Deepest level of recursion is evaluated first
- Expressions are evaluated according to their order in the list, halting at first matched expression
A somewhat contrived example of multiple levels of recursion follows. The first level of recursion uses a full object, the second uses the string shorthand.
{
"I read {{bookCount}} books." : {
"translation" : "I read {{bookCount}} books.",
"alternatives" : [
{
"bookCount < 3" : {
"translation" : "I only read a few books. {{bookCount}}, in fact!",
"alternatives": [
{
"bookCount == 0" : "I'm ashamed to admit I didn't read anything."
}
]
}
}
]
}
}
Evaluation works as you might expect.
// Deepest recursion
String translation = strings.get("I read {{bookCount}} books.",
new HashMap<String, Object>() {{
put("bookCount", 0);
}});
assertEquals("I'm ashamed to admit I didn't read anything.", translation);
// 1 level deep recursion
translation = strings.get("I read {{bookCount}} books.",
new HashMap<String, Object>() {{
put("bookCount", 1);
}});
assertEquals("I only read a few books. 1, in fact!", translation);
// Normal case
translation = strings.get("I read {{bookCount}} books.",
new HashMap<String, Object>() {{
put("bookCount", 3);
}});
assertEquals("I read 3 books.", translation);
A grammar for alternative expressions follows.
EXPRESSION = OPERAND COMPARISON_OPERATOR OPERAND | "(" EXPRESSION ")" | EXPRESSION BOOLEAN_OPERATOR EXPRESSION ;
OPERAND = VARIABLE | LANGUAGE_FORM | NUMBER ;
LANGUAGE_FORM = CARDINALITY | ORDINALITY | GENDER ;
CARDINALITY = "CARDINALITY_ZERO" | "CARDINALITY_ONE" | "CARDINALITY_TWO" | "CARDINALITY_FEW" | "CARDINALITY_MANY" | "CARDINALITY_OTHER" ;
ORDINALITY = "ORDINALITY_ZERO" | "ORDINALITY_ONE" | "ORDINALITY_TWO" | "ORDINALITY_FEW" | "ORDINALITY_MANY" | "ORDINALITY_OTHER" ;
GENDER = "MASCULINE" | "FEMININE" | "NEUTER" ;
VARIABLE = { alphabetic character | digit } ;
BOOLEAN_OPERATOR = "&&" | "||" ;
COMPARISON_OPERATOR = "<" | ">" | "<=" | ">=" | "==" | "!=" ;
- Evaluation of "normal" infix expressions of arbitrary complexity (can be nested/parenthesized)
- Comparison of gender, plural, and literal numeric values against each other or user-supplied variables
- The unary
!
operator - Explicit
null
operands (can be implicit, i.e. aVARIABLE
value) - A cardinality range construct (to be added in a future release)
Ultimately, it is up to you and your team how best to name your localization keys. Lokalized does not impose key naming constraints.
There are two common approaches - natural language and contextual. Some benefits and drawbacks of each are listed below to help you make the best decision for your situation.
For example: "I read {{bookCount}} books."
- Any developer can create a key by writing a phrase in her native language - no need to coordinate with others or choose arbitrary names
- Placeholders are encoded directly in the key and serve as "automatic" documentation for translators
- There is always a sensible default fallback in the event that a translation is missing
- Context is lost; the same text on one screen might have a completely different meaning on another
- Not suited for large amounts of text, like a software licensing agreement
- Small changes to text require updating every strings file since keys are not "constant"
For example: "SCREEN-PROFILE-BOOKS_READ"
- It is possible to specifically target app components, which enforces translation context
- Perfect for big chunks of text like legal disclaimers
- "Constant" keys means translations can change without affecting code
- You must come up with names for every key and cross-reference in your localized strings files
- Placeholders are not encoded in the key and must be communicated to translators through some other mechanism
- Requires diligent recordkeeping and inter-team communication ("are our iOS and Android apps using the same keys or are we duplicating effort?")
- There is no default language fallback if no translation is present; users will see your contextual key onscreen
It's possible to cherrypick and create a hybrid solution. For example, you might use natural language keys in most cases but switch to contextual for legalese and other special cases.
Lokalized uses java.util.logging
internally. The usual way to hook into this is with SLF4J, which can funnel all the different logging mechanisms in your app through a single one, normally Logback. Your Maven configuration might look like this:
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.1.9</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jul-to-slf4j</artifactId>
<version>1.7.22</version>
</dependency>
You might have code like this which runs at startup:
// Bridge all java.util.logging to SLF4J
java.util.logging.Logger rootLogger = java.util.logging.LogManager.getLogManager().getLogger("");
for (Handler handler : rootLogger.getHandlers())
rootLogger.removeHandler(handler);
SLF4JBridgeHandler.install();
Don't forget to uninstall the bridge at shutdown time:
// Sometime later
SLF4JBridgeHandler.uninstall();
Note: SLF4JBridgeHandler
can impact performance. You can mitigate that with Logback's LevelChangePropagator
configuration option as described here.
Lokalized was created by Mark Allen and sponsored by Transmogrify LLC and Revetware LLC.