Liking cljdoc? Tell your friends :D

License GitHub last commit

Clojars Project Clojars Project

Mr. Worldwide

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 .pot translation template from your Clojure source files, and for building EDN and JSON bundles from translated .po files 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.

Translating Strings in your Application with mr-worldwide

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))

Arguments

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)

Translating Plurals

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"

Deferred Translation

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.

Automatically Translating Deferred Translations

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.

Single Quotes

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.

Setting User Locale

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.

Setting Site Locale

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

Locale Fallback

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).

Configuration

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-filename or by calling set-config-filename!

  • You can tell it which directory to look for EDN resources in by setting the JVM system property mr-worldwide.clj-bundle-directory or by calling set-clj-bundle-directory!.

ttag Integration (Cljs)

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:

Note

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!

Building Translation Resources with mr-worldwide.build

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:

  1. Generate a .pot translation template file from your source files
  2. Send your .pot template to your translators and get translated .po files in return
  3. Convert your .po files to EDN files (for consumption by Mr. Worldwide in the JVM) and JSON files (for consumption by ttag in 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.

Generating a .pot Translation Template File

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 nil

from 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}"}]

Generating EDN and JSON Artifacts

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.

Test Utils (Mocking)

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.

Reader Tags

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))))

License

Code, documentation, and artwork copyright © 2025 Metabase, Inc..

Distributed under the Eclipse Public License, same as Clojure.

Can you improve this documentation?Edit on GitHub

cljdoc is a website building & hosting documentation for Clojure/Script libraries

× close