🚧 This library as under heavy development until release of version 1.x.x
🚧
Updated approach to i18n and java ResourceBundles. Uses icu4j for standardized message formatting and solves multiple pain points.
- Installation
- Basic usage
- Message formatting
- Message loading
- Message resolution
- Message references
- Type based formatters
- Missing messages
- Whitespace normalization
- DevMode
Add to your build.gradle
:
dependencies {
implementation "com.coditory.quark:quark-i18n:$version"
}
I18nMessagePack messagePack = I18nMessagePack.builder()
.scanClassPath("/i18n/messages-{locale}.yml")
.setDefaultLocale(Locales.EN_US)
.build();
// ...when request arrives
I18nMessages messages = messagePack.localize(req.getLocale());
print(messages.getMessage("greeting", userName));
Message formatting is fully handled by ICU4J. ICU is a mature, widely used set of libraries providing Unicode and Globalization support for software applications. It's a standard handled by multiple translation centric systems.
Some examples:
# Simple argument (indexed and named)
{0} sent you a message.
{name} sent you a message.
# Argument formatting
Message was sent on {0,date}.
# Select statement
{gender, select, female {She} male {He} other {They}} sent you a message.
# Pluralization
You have {0, plural, zero {no new messages}, one {one new message} other {# new messages}}
For more examples go to advanced message formatting examples
Messages can be created in 3 ways:
- Created manually using a builder
- Loaded from classpath
- Loaded from filesystem
I18nMessagePack messages = I18nMessagePack.builder()
.addMessage(Loacles.EN_US, "hello", "Hello {0}")
.addMessage(Loacles.PL_PL, "hello", "Cześć {0}")
.setDefaultLocale(PL_PL)
.build();
messages.
getMessage(Loacles.EN_US, "hello",userName);
If you want to quickly load messages from a nested map (for example fetched from a document storage)
you can use I18nParsers.parseEntries(map, locale)
to translate nested the map keys into localized message paths.
I18nMessagePack messages = I18nMessagePack.builder()
.addMessages(I18nParsers.parseEntries(Map.of("hello", "Hello {0}"), EN_US))
.build();
I18nMessagePack messages = I18nMessagePack.builder()
.scanClassPath("/i18n/messages-{locale}.yml")
.scanFileSystem("./overriddes/messages-{locale}.yml")
.setDefaultLocale(PL_PL)
.build();
Localization based path placeholders are used to assign all messages in the file to file's locale. Available placeholders:
{locale}
- matches language code or language code with country code{lang}
- matches language code (cannot be used with{locale}
){country}
- matches country code (requires{lang}
)
Prefixed based path placeholders are used to prefix all message paths (and references) in the file to file's prefix. Available placeholders:
{prefix}
- matches single directory name or part of a file{prefixes}
- matches multiple directory names
Supported formats:
YAML
- thanks to SnakeYAML libraryJSON
- thanks to Gson libraryproperties
- with UTF-8 encoding only
There is dev mode that auto-reloads files during development.
When path pattern does not contain one of localization placeholders ({locale}
, {lang}
)
then locale is parsed from the last segment of the message path:
Example in yml
homepage.title:
pl-PL: Strona główna
en-US: Homepage
To reload messages in file change use:
I18nMessagePack.builder()
.
scanFileSystem("i18n/*")
.
buildAndWatchForChanges();
ATM, it works for messages loaded from filesystem only, but for add your own implementation of WatchableI18nLoader
.
Messages are resolved with locale and message path. If there is no match for message path and locale then less strict locale is used. If there is still no match then the default locale (followed by a less strict default locale) is used.
I18nMessagePack messages = I18nMessagePack.builder()
.scanClassPath("/i18n/messages-{locale}.yml")
.setDefaultLocale(PL_PL)
.build();
String message = messages.getMessage(Locales.en_US, "hello");
Locations used to find the message:
1. (en_US, en) hello
2. (pl_PL, pl) hello
Sometimes it is useful to specify a common path prefix for all unmatched queries:
I18nMessagePack messages = I18nMessagePack.builder()
.scanClassPath("/i18n/messages-{locale}.yml")
.setDefaultLocale(PL_PL)
.prefixQueries("", "common")
.build();
String message = messages.getMessage(Locales.en_US, "hello");
Locations used to find the message:
1. (en_US, en) hello
2. (en_US, en) common.hello
3. (pl_PL, pl) hello
4. (pl_PL, pl) common.hello
Sometimes it's useful to prefix all queries with some path, like in the example:
I18nMessagePack messages = I18nMessagePack.builder()
.scanClassPath("/i18n/messages-{locale}.yml")
.setDefaultLocale(PL_PL)
.build();
I18nMessagePack homepageMessages = messages.prefixQueries("pages.homepage");
String homepageTitle = homepageMessages.getMessage(en_US, "title");
String homepageSubtitle = homepageMessages.getMessage(en_US, "title");
Locations used to find the message:
1. (en_US, en) pages.homepage.title
2. (en_US, en) title
3. (pl_PL, pl) pages.homepage.title
4. (pl_PL, pl) title
Sometimes it's useful to apply common locale to all queries:
I18nMessagePack messagePack = I18nMessagePack.builder()
.scanClassPath("/i18n/messages-{locale}.yml")
.setDefaultLocale(PL_PL)
.build();
// ...when request arrives
I18nMessages messages = messagePack.localize(req.getLocale());
String title = messages.getMessage("title");
String subtitle = messages.getMessage("subtitle");
Query localization mechanism can be used together with query prefixes:
I18nMessages messages = messagePack
.prefixQueries("pages.homepage")
.localize(req.getLocale());
Message references are the way to reuse text across multiple messages.
Example in yml:
# Common entries
company:
name: ACME
established: 1988
# Message
about-company: "${company.name} was established on ${company.established}"
messages.getMessage("about-company") =="ACME was established on 1988"
- It's not a part of ICU standard
- Reference resolution mechanism can be disabled with:
i18nMessagePackBuilder.disableReferences()
- You can add fallback path prefixes
with
i18nMessagePackBuilder.addReferenceFallbackKeyPrefix()
(example) - References in prefixed files are prefixed as
well
${foo} -> ${<file-prefix>.foo}
(example) - References can have a short notation
$common.reference
and long one${common.reference}
. The long one is useful when there reference is placed next to[a-zA-Z0-9-_]
, like inabc${common.reference}abc$
.
Let's configure messages:
I18nMessagePack messagePack = I18nMessagePack.builder()
.addMessage(EN_US, "msg", "${company.name} was established on 1988")
.scanClassPath("/i18n/messages-{locale}.yml")
.setDefaultLocale(PL_PL)
.prefixQueries("", "fallback")
.build();
Locations used to find the message:
1. (en_US, en) company.name
2. (en_US, en) fallback.company.name
3. (pl_PL, pl) company.name
4. (pl_PL, pl) fallback.company.name
If the reference is defined in a message stored in a prefixed file it will be automatically prefixed:
I18nMessagePack messagePack = I18nMessagePack.builder()
.scanClassPathLocation("i18n/{prefix}/message_{locale}.yml")
.setDefaultLocale(PL_PL)
.prefixQueries("", "fallback")
.build();
and file i18n/company/message_en-US.yml
contains
msg: ${name} was established on 1988
Locations used to find the message:
1. (en_US, en) company.name
2. (en_US, en) name
3. (en_US, en) fallback.company.name
4. (pl_PL, pl) company.name
5. (pl_PL, pl) name
6. (pl_PL, pl) fallback.company.name
You can map arguments by their type using argument transformers:
- transformation is located in the definition order
- only the arguments used in the message are transformed
- transformation is transitive - one value can be transformed multiple times
Example:
I18nMessages messages = I18nMessagePack.builder()
.addMessage(EN, "msg", "{0,number,00000.00000}")
.addArgumentTransformer(Foo, (foo) -> foo.getSomeNumber())
.buildLocalized(EN);
messages.
getMessage("msg",new Foo(123.456))=="00123.45600"
When message is missing, exception is thrown. This mechanism can be changed with:
// add custom missing message handler
i18nMessagePackBuilder.setMissingMessageHandler(customHandler);
// ...or simply return message path when message is missing
i18nMessagePackBuilder.
usePathOnMissingMessage();
It's important to find about missing messages as quickly as possible and avoid finding them on production.
That's why there is an option to detect them during build phase:
i18nMessagePackBuilder.validateNoMissingMessages() - throws exception on missing message
i18nMessagePackBuilder.logMissingMessages() - simply logs a report about missing messages
I18nMessagePack.builder()
.
addMessage(EN_US, "hello","Hello")
.
addMessage(PL_PL, "hello","Cześć")
.
addMessage(DE_DE, "bye","Tschüss")
.
logMissingMessages()
.
build();
Will generate following report:
Missing Messages
================
Path: bye
Missing: en_US, pl_PL
Sources: de_DE
Path: hello
Missing: de_DE
Sources: en_US, pl_PL
Total: 2
It's common to store glossary type of messages in the default language. Those kind of values are deliberately defined in a single place and should not be detected as missing in other locales. You can skip them using a custom missing message detector:
I18nMissingMessagesDetector detector = I18nMissingMessagesDetector.builder()
.skipPath(skipPath)
.logMissingMessages()
.build();
I18nMessagePack.
builder()
.
addMessage(EN_US, "a.b.c.d","MISSING")
.
addMessage(EN_US, "x","X")
.
addMessage(EN_GB, "x","X")
.
addMessage(PL_PL, "x","X")
.
detectMissingMessages(detector)
.
build();
// to skip a.b.c.d use one of sample path patterns as a skipPath:
// - "a.b.c.d",
// - "a.b.c.*",
// - "a.b.**",
// - "a.**",
// - "a.**.d",
// - "**.d",
// - "*.*.*.*"
You can normalize whitespaces by trimming texts them and compressing all consecutive whitespace characters to a
single ' '
.
It makes working with complicated messages easier.
White space normalization
' \n\tsome text with, \nspaces\t' -> 'some text with, spaces'
This mechanism is disabled by default and can be enabled with: i18nMessagePackBuilder.normalizeWhitespaces()
.
You can use file watching capabilities to speed up the development cycle:
I18nMessagePackBuidler messagesBuilder = I18nMessagePack.builder()
.setDefaultLocale(EN_US);
// ... other common settings
I18nMessagePack messages = devMode
? messagesBuilder.scanFileSystem("src/main/resources/i18n/*").buildAndWatchForChanges()
: messagesBuilder.scanClassPath("i18n/*").build();
Following setup will load messages directly from project structure and watch for changes.