{"id":26581520,"url":"https://github.com/metabase/mr-worldwide","last_synced_at":"2026-02-11T07:01:18.193Z","repository":{"id":278254919,"uuid":"935023953","full_name":"metabase/mr-worldwide","owner":"metabase","description":"i18n for Clojure","archived":false,"fork":false,"pushed_at":"2025-02-22T02:59:35.000Z","size":117,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":2,"default_branch":"master","last_synced_at":"2025-10-07T23:33:20.818Z","etag":null,"topics":["clojure","clojurescript","gettext","i18n","internationalization","l10n","localization","po-files","pot-files","translation"],"latest_commit_sha":null,"homepage":"","language":"Clojure","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"epl-1.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/metabase.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":".github/CODEOWNERS","security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2025-02-18T19:29:29.000Z","updated_at":"2025-02-22T02:59:39.000Z","dependencies_parsed_at":"2025-02-18T20:36:26.078Z","dependency_job_id":"dd365c79-87b4-4bea-9118-d4b4750642a5","html_url":"https://github.com/metabase/mr-worldwide","commit_stats":null,"previous_names":["metabase/mr-worldwide"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/metabase/mr-worldwide","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/metabase%2Fmr-worldwide","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/metabase%2Fmr-worldwide/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/metabase%2Fmr-worldwide/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/metabase%2Fmr-worldwide/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/metabase","download_url":"https://codeload.github.com/metabase/mr-worldwide/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/metabase%2Fmr-worldwide/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":29329492,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-02-11T06:13:03.264Z","status":"ssl_error","status_checked_at":"2026-02-11T06:12:55.843Z","response_time":97,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["clojure","clojurescript","gettext","i18n","internationalization","l10n","localization","po-files","pot-files","translation"],"created_at":"2025-03-23T07:20:14.313Z","updated_at":"2026-02-11T07:01:18.161Z","avatar_url":"https://github.com/metabase.png","language":"Clojure","funding_links":[],"categories":[],"sub_categories":[],"readme":"[![License](https://img.shields.io/badge/license-Eclipse%20Public%20License-blue.svg?style=for-the-badge)](https://raw.githubusercontent.com/camsaul/toucan2/master/LICENSE)\n[![GitHub last commit](https://img.shields.io/github/last-commit/camsaul/toucan2?style=for-the-badge)](https://github.com/camsaul/toucan2/commits/)\n\n[![Clojars Project](https://clojars.org/io.github.metabase/mr-worldwide/latest-version.svg)](https://clojars.org/io.github.metabase/mr-worldwide)\n[![Clojars Project](https://clojars.org/io.github.metabase/mr-worldwide.build/latest-version.svg)](https://clojars.org/io.github.metabase/mr-worldwide.build)\n\n# Mr. Worldwide\n\nMr. Worldwide is set of Clojure(Script) libraries for internationalization, spun out from the i18n tooling inside\n[Metabase](https://github.com/metabase/metabase) we've been iterating on for the past 10 years or so.\n\nIt is broken out into two libraries:\n\n* `io.github.metabase/mr-worldwide` -- code for marking strings for i18n and for translating them at runtime. Typically\n  this will be included in your project dependencies (i.e., in the uberjar, if you were to build one)\n\n* `io.github.metabase/mr-worldwide.build` -- code for building a `.pot` translation template from your Clojure source\n  files, and for building EDN and JSON bundles from translated `.po` files for use in Clojure and\n  JavaScript/ClojureScript respectively. Typically these steps will be called as part of your build process, so this\n  library is only needed as a build dependency.\n\n# Translating Strings in your Application with `mr-worldwide`\n\nYou can mark strings for translation with the `tru` and `trs` family of macros in `mr-worldwide.core`. `trs` stands\n*TRanslate System*, while `tru` stands for *TRanslate User*, and translate to the system locale and user locale\nrespectively.\n\nThe system locale should be used for strings that don't have one specific user associated with them, for example a bot\nthat posts notifications in a Slack channel or your app log messages (if you are a kook and want to translate them).\n\nThe user locale should be used for strings that have on specific user associated with them -- for example you can use it\nto translate your UI or user-facing error messages into their locale.\n\nIf a specific user locale isn't specified, the site locale serves as a fallback/default user locale. For example you\nmight want to have your site default to Spanish but let users override this with a different locale if *sólo hablan un\npoco de Español*.\n\nBasic usage looks something like this:\n\n```clj\n(require '[mr-worldwide.core :as i18n])\n\n(defn startup-message []\n  (i18n/trs \"The system is now starting...\"))\n```\n\nUnder the hood, `trs` macroexpands to something like\n\n```clj\n(str (SystemLocalizedString. \"The system is now starting...\"))\n```\n\n`SystemLocalizedString` and `UserLocalizedString` are two custom record types that hold on to the original string and\nthemselves appropriately when you call their `toString()` method (e.g., when you pass them to `str`). This finds the\nappropriate matching format string from the resources built by `mr-worldwide.build` and then uses\n[`java.util.MessageFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/MessageFormat.html) (in the JVM) or\n[`ttag`](https://ttag.js.org/) (in ClojureScript) to handle argument substitution, e.g.\n\n```clj\n(.format (MessageFormat. looked-up-string) (to-array args))\n```\n\n## Arguments\n\n`trs`, `trn`, and friends support zero-indexed argument placeholders like `{0}` or `{1}`. These are passed directly to\n[`java.util.MessageFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/MessageFormat.html), so refer to its\nJavaDoc for more details on the syntax.\n\nExamples:\n\n```clojure\n(trs \"{0} accepted their {1} invite\" user-name group-name)\n(tru \"{0}th percentile of {1}\" percentile field)\n(tru \"{0} does not support foreign keys.\" database-name)\n```\n\n## Translating Plurals\n\nYou can use `trsn` (*TRanslate System N*) and `trun` (*TRanslate User N*) for translating strings that may or may not\nneed to be pluralized depending on their arguments.\n\n```clj\n(trun \"{0} can\" \"{0} cans\" number-of-cans)\n\n;; e.g.\n(trun \"{0} can\" \"{0} cans\" 1) ; =\u003e \"1 can\"\n(trun \"{0} can\" \"{0} cans\" 2) ; =\u003e \"2 cans\"\n```\n\nYou can also use `trsn` and and `trun` even if the format string doesn't have any placeholders, e.g.\n\n```clj\n(i18n/trun \"Minute\" \"Minutes\" n)\n\n;; e.g.\n(i18n/trun \"Minute\" \"Minutes\" 1) =\u003e \"1 Minute\"\n(i18n/trun \"Minute\" \"Minutes\" 2) =\u003e \"2 Minutes\"\n```\n\n## Deferred Translation\n\nAs noted above, `trs`, `tru`, and the `-n` variations all translate their format string to the appropriate locale when\nthey are evaluated. If you want to defer translation until later, you can use the `deferred-` variations of these\nfunctions instead:\n\n```clj\n(def error-message (deferred-tru \"You broke it.\"))\n\n(defn handle-request [request]\n    {:status 500, :body (str error-message)})\n```\n\nThese basically macroexpand into something like\n\n```clj\n(UserLocalizedString. \"You broke it.\")\n```\n\nWhich means you can call `str` on it whenever you need them to be translated; they are translated appropriately each\ntime.\n\n### Automatically Translating Deferred Translations\n\nIt can be a good idea to add mappings to JSON encoders or other similar tooling to automatically handle\n`mr_worldwide.core.SiteLocalizedString` and `UserLocalizedString`, so you don't need to remember to manually call `(str\n...)` on it. For your convenience, Mr. Worldwide adds these for [Cheshire](https://github.com/dakrone/cheshire):\n\n```clj\n(defn- localized-to-json [localized-string json-generator]\n  (json/generate-string json-generator (str localized-string)))\n\n(cheshire.generate/add-encoder UserLocalizedString localized-to-json)\n(cheshire.generate/add-encoder SiteLocalizedString localized-to-json)\n```\n\nIf you're using a different JSON library, you might want to do something similar.\n\n## Single Quotes\n\nThe single quote (`'`) serves as the escape character in\n[`java.util.MessageFormat`](https://docs.oracle.com/javase/8/docs/api/java/text/MessageFormat.html), so to get a single\nquote or apostrophe in your output you need to escape it with another single quote, i.e. you need to use two single\nquotes.\n\n```clj\n;;; good\n(deferred-tru \"SAML attribute for the user''s email address\")\n\n;;; WRONG!!!\n(deferred-tru \"SAML attribute for the user's email address\")\n```\n\n`trs`, `tru` and friends will attempt to find incorrectly escaped single quotes and error at macroexpansion time, but\nthis is a best effort and we can't currently catch everything (once `clojure.reader.mind` drops this may change).\n\nBoth the original format strings and translated strings need to follow this rule.\n\nSince the apostrophe is such a common part of speech (especially in French), we often can end up with escape characters\nused as a regular part of a string rather than the escape character. In our experience we've ended up with lots of\nincorrectly translated strings that use a single apostrophe incorrectly. (e.g. `l'URL` instead of `l''URL`).\n`mr-worldwide.build.artifacts` will try to identify these and fix them automatically.\n\n## Setting User Locale\n\nYou can bind the current user locale with the dynamic variable `mr-worldwide.core/*user-locale*`. A typical place to do\nthis might be in Ring middleware, e.g.\n\n```clj\n(defn current-user-locale [request]\n  ...)\n\n(defn middleware [handler]\n  ;; you likely only need either the sync 1-arity or async 3-arity instead of both\n  (fn\n    ([request]\n     (binding [mr-worldwide.core/*user-locale* (current-user-locale request)]\n       (handler request)))\n    ([request respond raise]\n     (binding [mr-worldwide.core/*user-locale* (current-user-locale request)]\n       (handler request respond raise)))))\n```\n\nHow you determine user locale for a request is up to you. One option is to look at the [`Accept-Language`\nheader](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language). Another is to store the user's\npreferred language in your application database -- this is the approach Metabase takes.\n\n`*user-locale*` can be bound to a two-letter ISO language code string like `en` (language-only) or `en_US` (language\nplus country), a keyword version of these like `:en`, `:en-US`, or `:en/US`, a `java.util.Locale`, or a thunk (a\nfunction that takes no arguments) that when called returns one of the above.\n\n## Setting Site Locale\n\nYou can set the site locale with `*site-locale*` or by calling `set-default-site-locale!`. These accept the same\ndifferent types of arguments as `*user-locale*` above.\n\nIf these are unset, Mr. Worldwide falls back to the JVM default Locale, `(java.util.Locale/getDefault)`. You can specify\nthis with Java properties `user.language` and `user.country`, e.g.\n\n```\n-Duser.language=en -Duser.country=US\n```\n\n## Locale Fallback\n\nWhen translating format strings Mr. Worldwide will look for translation resource bundles that match both the relevant\nlanguage and country, and fall back to looking in other bundles of the same language.\n\nFor example if the user locale is set to `en_MX` (Mexican Spanish) but we don't have a translation for a specific format\nstring in `en_MX`, Mr. Worldwide will try looking for one in `en` (Spanish with no country specified); if it fails to\nfind one there it will try looking in any other `en_*` bundles available (e.g. `en_ES` -- Spanish Spanish).\n\n## Configuration\n\n\u003ca name=\"configuration\"\u003e\u003c/a\u003e\n\nBy default Mr. Worldwide will read available locales by looking on your classpath for `mr-worldwide/config.edn`, and for\nEDN resources by looking for files like `mr-worldwide/clj/pt-BR.edn`. `mr-worldwide.build` normally generates these\nfiles in your `resources` directory, so as long as `resources` is on your classpath (or copied into your uberjar) things\nwill work without further tweaks. If you configure `mr-worldwide.build` to generate the files somewhere else, you will\nneed to tell Mr. Worldwide where to find these files:\n\n- You can tell it where to find the config file by setting the JVM system property `mr-worldwide.config-filename` or by\n  calling `set-config-filename!`\n\n- You can tell it which directory to look for EDN resources in by setting the JVM system property\n  `mr-worldwide.clj-bundle-directory` or by calling `set-clj-bundle-directory!`.\n\n## `ttag` Integration (Cljs)\n\nFor ClojureScript usage, `trs` and `tru` compile to [`ttag`](https://ttag.js.org/) function calls, and\n`mr-worldwide.build` generates JSON resources for `ttag`'s consumption. Besides including the library as an additional\ndependency, you'll need a little bit of additional glue to make things work.\n\nThe gist is that you need to load the relevant JSON bundle from `resources/mr-worldwide/cljs` and call `ttag`'s\n`addLocale()` and `setLocale()` functions.\n\nHere's an example of how to do this adapted from how we use it at Metabase.\n\nFirst, add some code to load up the JSON bundle for the current locale:\n\n```clj\n;; it's a good idea to memoize this\n(defn json-resource [locale]\n  (let [locale-str (str/replace (str locale) \\- \\_)]\n    (some-\u003e (io/resource (str \"mr-worldwide/cljs/\" locale-str)) slurp)))\n```\n\nNext, include inject this JSON into your `index.html`:\n\n```html\n-- example template\n\u003cscript type=\"application/json\" id=\"_userLocalization\"\u003e\n    {{json}}\n\u003c/script\u003e\n```\n\nFinally, use `ttag` `addLocale` to load the translations and `useLocale` to use them:\n\n```js\nimport { addLocale, useLocale } from \"ttag\";\n\nfunction setLanguage() {\n  const translationsObject = JSON.parse(document.getElementById(\"_userLocalization\").textContent);\n  const locale = translationsObject.headers.language;\n  const msgs = translationsObject.translations[\"\"];\n\n  // we delete msgid property since it's redundant, but have to add it back in to\n  // make ttag happy\n  for (const msgid in msgs) {\n    if (msgs[msgid].msgid === undefined) {\n      msgs[msgid].msgid = msgid;\n    }\n  }\n\n  // add and set locale with ttag\n  addLocale(locale, translationsObject);\n  useLocale(locale);\n}\n```\n\nRefer to these files for a real-world working example:\n\n- [`index.clj`](https://github.com/metabase/metabase/blob/8ea5431774c03bd4450a05e21e766c1ef0c1c244/src/metabase/server/routes/index.clj)\n- [`index_bootstrap.js`](https://github.com/metabase/metabase/blob/8ea5431774c03bd4450a05e21e766c1ef0c1c244/resources/frontend_client/inline_js/index_bootstrap.js)\n- [`i18n.js`](https://github.com/metabase/metabase/blob/8ea5431774c03bd4450a05e21e766c1ef0c1c244/frontend/src/metabase/lib/i18n.js)\n\n### Note\n\nThese steps are currently more complicated than I'd like -- PRs to simplify the process of using Mr. Worldwide with\nClojureScript would be greatly appreciated!\n\n# Building Translation Resources with `mr-worldwide.build`\n\nYou can use `io.github.metabase/mr-worldwide.build` to build the translation resources that power\n`io.github.metabase/mr-worldwide`. When using Mr. Worldwide, there are three steps to getting your stuff translated:\n\n1. Generate a `.pot` translation template file from your source files\n2. Send your `.pot` template to your translators and get translated `.po` files in return\n3. Convert your `.po` files to EDN files (for consumption by Mr. Worldwide in the JVM) and JSON files (for consumption\n   by `ttag` in ClojureScript)\n\n`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\nwriting, Metabase uses [POEditor](https://poeditor.com/) for translation; feel free to copy, adapt, or derive\ninspiration from our scripts for [uploading `.pot`\nfiles](https://github.com/metabase/metabase/blob/00ec1bf63308c2f44a3a8e1b510ca1787451c877/bin/i18n/export-pot-to-poeditor)\nand [fetching translated `.po`\nfiles](https://github.com/metabase/metabase/blob/00ec1bf63308c2f44a3a8e1b510ca1787451c877/bin/i18n/import-po-from-poeditor).\n\n## Generating a `.pot` Translation Template File\n\nMr. Worldwide uses [grasp](https://github.com/borkdude/grasp) to walk your Clojure source files and find usages or\n`trs`, `tru`, and friends and [`JGetText`](https://mvnrepository.com/artifact/org.fedorahosted.tennera/jgettext) to\ngenerate a `.pot` file.\n\nCall\n\n```clj\n(mr-worldwide.build.pot/build-pot! config) ; config should be a map or nil\n```\n\nfrom your `build.clj` script, or with `clojure -X` e.g.\n\n```\nclojure -X:build:mr-worldwide.build.pot/build-pot! '{...}'\n```\n\nto generate the file. You aren't required to specify anything in `config`; but if you want to override things it default\nto:\n\n```clj\n{;; where to output the generate `.pot` file\n :pot-filename \"target/mr-worldwide/strings.pot\"\n\n ;; directories to look for Clojure source files in to scrape for tru/trs\n :source-paths [\"src\"]\n\n ;; optional additional messages to translate\n :overrides nil}\n```\n\n`:overrides` if specified should be a sequence of maps with `:file` and `:message` keys, e.g.\n\n```clj\n[{:file    \"/src/metabase/analyze/fingerprint/fingerprinters.clj\"\n  :message \"Error generating fingerprint for {0}\"}]\n```\n\n## Generating EDN and JSON Artifacts\n\nGenerate artifacts by calling\n\n```clj\n(mr-worldwide.build.artifacts/create-artifacts! config)\n```\n\nfrom your `build.clj` or with `clojure -X` e.g.\n\n```\nclojure -X:build mr-worldwide.build.artifacts/create-artifacts! {}\n```\n\nAs above, you should be ok with the `config` defaults, but you can override them if needed; the defaults are:\n\n```clj\n{;; directory to look for translated `.po` files in\n :po-files-directory \"target/mr-worldwide\"\n\n ;; base directory to output generated i18n resource bundle artifacts to\n :target-directory \"resources/mr-worldwide\"\n\n ;; directory to output EDN resources for consumption in the JVM\n :clj-target-directoy \"\u003ctarget-directory\u003e/clj\"\n\n ;; directory to output JSON resources for consumption in ClojureScript\n :cljs-target-directory \"\u003ctarget-directory\u003e/cljs\"\n\n ;; path to write the generated config file to\n :config-filename \"\u003ctarget-directory\u003e/config.edn\"}\n```\n\nNote that if you change these defaults you'll need to tell `mr-worldwide` where to look for things; see the section\nabout [Configuration](#configuration) above.\n\n# Test Utils (Mocking)\n\nMr. Worldwide ships with a few convenient helpers for testings things. Besides being able to bind `*site-locale*` and\n`*user-locale*`, you can use `with-mock-i18n-bundles` to mock the resource bundles used by `tru`, `trs`, and friends to\ntest i18n behavior:\n\n```clj\n(require '[mr-worldwide.core :as i18n]\n         '[mr-worldwide.test-util :as i18n.tu])\n\n(i18n.tu/with-mock-i18n-bundles {\"es\" {:messages {\"must be {0} characters or less\"\n                                                  \"deben tener {0} caracteres o menos\"}}}\n  (binding [i18n/*user-locale* \"es\"]\n    (i18n/tru \"must be {0} characters or less\" 140)))\n;; =\u003e \"deben tener 140 caracteres o menos\"\n```\n\nYou can also bind `mr-worldwide.impl/*locales*` to mock the set of available locales.\n\n# Reader Tags\n\n`mr-worldwide.core/locale` is a *pretty good* function for coercing all sorts of things to a `java.util.Locale`; you\nmight want to consider using the using it for reader literal tag `#locale`, so you can do things like\n\n```clj\n#locale \"en_US\"\n```\n\nTo do this: add it to a `data_readers.clj` file on your classpath:\n\n```\n{locale mr-worldwide.core/locale}\n```\n\nit's also nice to have instances of `Locale` print as\n\n```\n#locale \"en_US\"\n```\n\ninstead of\n\n```\n#object[java.util.Locale 0x699cba07 \"en_US\"]\n```\n\nYou can do this by defining these print methods for it:\n\n```clj\n(defmethod print-method java.util.Locale\n  [d writer]\n  ((get-method print-dup java.util.Locale) d writer))\n\n(defmethod print-dup java.util.Locale\n  [locale ^java.io.Writer writer]\n  (.write writer \"#locale \")\n  (.write writer (pr-str (str locale))))\n```\n\n# License\n\nCode, documentation, and artwork copyright © 2025 [Metabase, Inc.](https://metabase.com).\n\nDistributed under the [Eclipse Public\nLicense](https://raw.githubusercontent.com/metabase/mr-worldwide/master/LICENSE), same\nas Clojure.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmetabase%2Fmr-worldwide","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmetabase%2Fmr-worldwide","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmetabase%2Fmr-worldwide/lists"}