Mr. Worldwide is set of Clojure(Script) libraries for internationalization, spun out from the i18n tooling inside Metabase we've been iterating on for the past 10 years or so.
It is broken out into two libraries:
-
io.github.metabase/mr-worldwide-- code for marking strings for i18n and for translating them at runtime. Typically this will be included in your project dependencies (i.e., in the uberjar, if you were to build one) -
io.github.metabase/mr-worldwide.build-- code for building a.pottranslation template from your Clojure source files, and for building EDN and JSON bundles from translated.pofiles for use in Clojure and JavaScript/ClojureScript respectively. Typically these steps will be called as part of your build process, so this library is only needed as a build dependency.
You can mark strings for translation with the tru and trs family of macros in mr-worldwide.core. trs stands
TRanslate System, while tru stands for TRanslate User, and translate to the system locale and user locale
respectively.
The system locale should be used for strings that don't have one specific user associated with them, for example a bot that posts notifications in a Slack channel or your app log messages (if you are a kook and want to translate them).
The user locale should be used for strings that have on specific user associated with them -- for example you can use it to translate your UI or user-facing error messages into their locale.
If a specific user locale isn't specified, the site locale serves as a fallback/default user locale. For example you might want to have your site default to Spanish but let users override this with a different locale if sólo hablan un poco de Español.
Basic usage looks something like this:
(require '[mr-worldwide.core :as i18n])
(defn startup-message []
(i18n/trs "The system is now starting..."))Under the hood, trs macroexpands to something like
(str (SystemLocalizedString. "The system is now starting..."))SystemLocalizedString and UserLocalizedString are two custom record types that hold on to the original string and
themselves appropriately when you call their toString() method (e.g., when you pass them to str). This finds the
appropriate matching format string from the resources built by mr-worldwide.build and then uses
java.util.MessageFormat (in the JVM) or
ttag (in ClojureScript) to handle argument substitution, e.g.
(.format (MessageFormat. looked-up-string) (to-array args))trs, trn, and friends support zero-indexed argument placeholders like {0} or {1}. These are passed directly to
java.util.MessageFormat, so refer to its
JavaDoc for more details on the syntax.
Examples:
(trs "{0} accepted their {1} invite" user-name group-name)
(tru "{0}th percentile of {1}" percentile field)
(tru "{0} does not support foreign keys." database-name)You can use trsn (TRanslate System N) and trun (TRanslate User N) for translating strings that may or may not
need to be pluralized depending on their arguments.
(trun "{0} can" "{0} cans" number-of-cans)
;; e.g.
(trun "{0} can" "{0} cans" 1) ; => "1 can"
(trun "{0} can" "{0} cans" 2) ; => "2 cans"You can also use trsn and and trun even if the format string doesn't have any placeholders, e.g.
(i18n/trun "Minute" "Minutes" n)
;; e.g.
(i18n/trun "Minute" "Minutes" 1) => "1 Minute"
(i18n/trun "Minute" "Minutes" 2) => "2 Minutes"As noted above, trs, tru, and the -n variations all translate their format string to the appropriate locale when
they are evaluated. If you want to defer translation until later, you can use the deferred- variations of these
functions instead:
(def error-message (deferred-tru "You broke it."))
(defn handle-request [request]
{:status 500, :body (str error-message)})These basically macroexpand into something like
(UserLocalizedString. "You broke it.")Which means you can call str on it whenever you need them to be translated; they are translated appropriately each
time.
It can be a good idea to add mappings to JSON encoders or other similar tooling to automatically handle
mr_worldwide.core.SiteLocalizedString and UserLocalizedString, so you don't need to remember to manually call (str ...) on it. For your convenience, Mr. Worldwide adds these for Cheshire:
(defn- localized-to-json [localized-string json-generator]
(json/generate-string json-generator (str localized-string)))
(cheshire.generate/add-encoder UserLocalizedString localized-to-json)
(cheshire.generate/add-encoder SiteLocalizedString localized-to-json)If you're using a different JSON library, you might want to do something similar.
The single quote (') serves as the escape character in
java.util.MessageFormat, so to get a single
quote or apostrophe in your output you need to escape it with another single quote, i.e. you need to use two single
quotes.
;;; good
(deferred-tru "SAML attribute for the user''s email address")
;;; WRONG!!!
(deferred-tru "SAML attribute for the user's email address")trs, tru and friends will attempt to find incorrectly escaped single quotes and error at macroexpansion time, but
this is a best effort and we can't currently catch everything (once clojure.reader.mind drops this may change).
Both the original format strings and translated strings need to follow this rule.
Since the apostrophe is such a common part of speech (especially in French), we often can end up with escape characters
used as a regular part of a string rather than the escape character. In our experience we've ended up with lots of
incorrectly translated strings that use a single apostrophe incorrectly. (e.g. l'URL instead of l''URL).
mr-worldwide.build.artifacts will try to identify these and fix them automatically.
You can bind the current user locale with the dynamic variable mr-worldwide.core/*user-locale*. A typical place to do
this might be in Ring middleware, e.g.
(defn current-user-locale [request]
...)
(defn middleware [handler]
;; you likely only need either the sync 1-arity or async 3-arity instead of both
(fn
([request]
(binding [mr-worldwide.core/*user-locale* (current-user-locale request)]
(handler request)))
([request respond raise]
(binding [mr-worldwide.core/*user-locale* (current-user-locale request)]
(handler request respond raise)))))How you determine user locale for a request is up to you. One option is to look at the Accept-Language
header. Another is to store the user's
preferred language in your application database -- this is the approach Metabase takes.
*user-locale* can be bound to a two-letter ISO language code string like en (language-only) or en_US (language
plus country), a keyword version of these like :en, :en-US, or :en/US, a java.util.Locale, or a thunk (a
function that takes no arguments) that when called returns one of the above.
You can set the site locale with *site-locale* or by calling set-default-site-locale!. These accept the same
different types of arguments as *user-locale* above.
If these are unset, Mr. Worldwide falls back to the JVM default Locale, (java.util.Locale/getDefault). You can specify
this with Java properties user.language and user.country, e.g.
-Duser.language=en -Duser.country=US
When translating format strings Mr. Worldwide will look for translation resource bundles that match both the relevant language and country, and fall back to looking in other bundles of the same language.
For example if the user locale is set to en_MX (Mexican Spanish) but we don't have a translation for a specific format
string in en_MX, Mr. Worldwide will try looking for one in en (Spanish with no country specified); if it fails to
find one there it will try looking in any other en_* bundles available (e.g. en_ES -- Spanish Spanish).
By default Mr. Worldwide will read available locales by looking on your classpath for mr-worldwide/config.edn, and for
EDN resources by looking for files like mr-worldwide/clj/pt-BR.edn. mr-worldwide.build normally generates these
files in your resources directory, so as long as resources is on your classpath (or copied into your uberjar) things
will work without further tweaks. If you configure mr-worldwide.build to generate the files somewhere else, you will
need to tell Mr. Worldwide where to find these files:
-
You can tell it where to find the config file by setting the JVM system property
mr-worldwide.config-filenameor by callingset-config-filename! -
You can tell it which directory to look for EDN resources in by setting the JVM system property
mr-worldwide.clj-bundle-directoryor by callingset-clj-bundle-directory!.
For ClojureScript usage, trs and tru compile to ttag function calls, and
mr-worldwide.build generates JSON resources for ttag's consumption. Besides including the library as an additional
dependency, you'll need a little bit of additional glue to make things work.
The gist is that you need to load the relevant JSON bundle from resources/mr-worldwide/cljs and call ttag's
addLocale() and setLocale() functions.
Here's an example of how to do this adapted from how we use it at Metabase.
First, add some code to load up the JSON bundle for the current locale:
;; it's a good idea to memoize this
(defn json-resource [locale]
(let [locale-str (str/replace (str locale) \- \_)]
(some-> (io/resource (str "mr-worldwide/cljs/" locale-str)) slurp)))Next, include inject this JSON into your index.html:
-- example template
<script type="application/json" id="_userLocalization">
{{json}}
</script>Finally, use ttag addLocale to load the translations and useLocale to use them:
import { addLocale, useLocale } from "ttag";
function setLanguage() {
const translationsObject = JSON.parse(document.getElementById("_userLocalization").textContent);
const locale = translationsObject.headers.language;
const msgs = translationsObject.translations[""];
// we delete msgid property since it's redundant, but have to add it back in to
// make ttag happy
for (const msgid in msgs) {
if (msgs[msgid].msgid === undefined) {
msgs[msgid].msgid = msgid;
}
}
// add and set locale with ttag
addLocale(locale, translationsObject);
useLocale(locale);
}Refer to these files for a real-world working example:
These steps are currently more complicated than I'd like -- PRs to simplify the process of using Mr. Worldwide with ClojureScript would be greatly appreciated!
You can use io.github.metabase/mr-worldwide.build to build the translation resources that power
io.github.metabase/mr-worldwide. When using Mr. Worldwide, there are three steps to getting your stuff translated:
- Generate a
.pottranslation template file from your source files - Send your
.pottemplate to your translators and get translated.pofiles in return - Convert your
.pofiles to EDN files (for consumption by Mr. Worldwide in the JVM) and JSON files (for consumption byttagin ClojureScript)
mr-worldwide.build handles step 1 and 3 for you; step 2 is left as an exercise for the reader. At the time of this
writing, Metabase uses POEditor for translation; feel free to copy, adapt, or derive
inspiration from our scripts for uploading .pot
files
and fetching translated .po
files.
Mr. Worldwide uses grasp to walk your Clojure source files and find usages or
trs, tru, and friends and JGetText to
generate a .pot file.
Call
(mr-worldwide.build.pot/build-pot! config) ; config should be a map or nilfrom your build.clj script, or with clojure -X e.g.
clojure -X:build:mr-worldwide.build.pot/build-pot! '{...}'
to generate the file. You aren't required to specify anything in config; but if you want to override things it default
to:
{;; where to output the generate `.pot` file
:pot-filename "target/mr-worldwide/strings.pot"
;; directories to look for Clojure source files in to scrape for tru/trs
:source-paths ["src"]
;; optional additional messages to translate
:overrides nil}:overrides if specified should be a sequence of maps with :file and :message keys, e.g.
[{:file "/src/metabase/analyze/fingerprint/fingerprinters.clj"
:message "Error generating fingerprint for {0}"}]Generate artifacts by calling
(mr-worldwide.build.artifacts/create-artifacts! config)from your build.clj or with clojure -X e.g.
clojure -X:build mr-worldwide.build.artifacts/create-artifacts! {}
As above, you should be ok with the config defaults, but you can override them if needed; the defaults are:
{;; directory to look for translated `.po` files in
:po-files-directory "target/mr-worldwide"
;; base directory to output generated i18n resource bundle artifacts to
:target-directory "resources/mr-worldwide"
;; directory to output EDN resources for consumption in the JVM
:clj-target-directoy "<target-directory>/clj"
;; directory to output JSON resources for consumption in ClojureScript
:cljs-target-directory "<target-directory>/cljs"
;; path to write the generated config file to
:config-filename "<target-directory>/config.edn"}Note that if you change these defaults you'll need to tell mr-worldwide where to look for things; see the section
about Configuration above.
Mr. Worldwide ships with a few convenient helpers for testings things. Besides being able to bind *site-locale* and
*user-locale*, you can use with-mock-i18n-bundles to mock the resource bundles used by tru, trs, and friends to
test i18n behavior:
(require '[mr-worldwide.core :as i18n]
'[mr-worldwide.test-util :as i18n.tu])
(i18n.tu/with-mock-i18n-bundles {"es" {:messages {"must be {0} characters or less"
"deben tener {0} caracteres o menos"}}}
(binding [i18n/*user-locale* "es"]
(i18n/tru "must be {0} characters or less" 140)))
;; => "deben tener 140 caracteres o menos"You can also bind mr-worldwide.impl/*locales* to mock the set of available locales.
mr-worldwide.core/locale is a pretty good function for coercing all sorts of things to a java.util.Locale; you
might want to consider using the using it for reader literal tag #locale, so you can do things like
#locale "en_US"To do this: add it to a data_readers.clj file on your classpath:
{locale mr-worldwide.core/locale}
it's also nice to have instances of Locale print as
#locale "en_US"
instead of
#object[java.util.Locale 0x699cba07 "en_US"]
You can do this by defining these print methods for it:
(defmethod print-method java.util.Locale
[d writer]
((get-method print-dup java.util.Locale) d writer))
(defmethod print-dup java.util.Locale
[locale ^java.io.Writer writer]
(.write writer "#locale ")
(.write writer (pr-str (str locale))))Code, documentation, and artwork copyright © 2025 Metabase, Inc..
Distributed under the Eclipse Public License, same as Clojure.