m1p (short for "map" in the tradition of i18n) is a map interpolation library that can be used for i18n (or just externalizing textual content for a single language), theming, and similar use cases. Some assembly required. Batteries not included. Bring your own bling.
With tools.deps:
no.cjohansen/m1p {:mvn/version "2024.03.15"}
With Leiningen:
[no.cjohansen/m1p "2024.03.15"]
m1p makes it possible to loosely couple data processing code from textual content by placing it in a dictionary, and to loosely couple its content from app data that will eventually be interpolated into it.
- You produce data with placeholders for text, theming, and other "flavor content".
- m1p looks up content templates from dictionaries and inflates them with app data.
- m1p folds inflated content templates into your data.
- Et voila!
m1p was created with these goals:
- i18n, theming, and similar concerns should be handled orthogonal to data processing.
- Dictionaries should be serializable data.
- Dictionaries should support declarative interpolation and custom transformations.
The best way to understand m1p (pronounced "meep", or "meep, meep" if you're really feeling it) is through an example. In many ways, m1p is an i18n library that isn't really an i18n library. It's very good at retrieving and "just-in-time processing" values, but it knows nothing about locales, pluralization, number formatting, and other i18n concerns. It can, however, learn those things. We'll explore how m1p works by using it as a i18n library.
m1p works with dictionaries built from plain serializable maps:
(require '[m1p.core :as m1p])
(def dictionary
{:header/title "Hello, world!"})
(m1p/interpolate
[:div.main
[:h1 [:i18n :header/title]]] ;; 1
{:dictionaries {:i18n dictionary}}) ;; 2
;;=> [:div.main [:h1 "Hello, world!"]]
[:i18n :header/title]
is a reference tuple that refers to the key:header/title
in the:i18n
dictionary.- m1p can interpolate from multiple dictionaries at once. This example only has
one dictionary, the
:i18n
one.
Greeting the world is all well and good, but what if we desire a more personal greeting? Well, then we have to fold some data into the template before folding the result back into our data:
(m1p/interpolate
[:div.main
[:h1 [:i18n :header/title {:greetee "Internet"}]]]
{:dictionaries {:i18n dictionary}})
To achieve this we expanded the reference tuple to pass some data: [:i18n :header/title {:greetee "Internet"}]
.
We'll also update the dictionary to include :greetee
with [:fn/str ...]
(more about this shortly):
(def dictionary
(m1p/prepare-dictionary
{:header/title [:fn/str "Hello, {{:greetee}}!"]}))
All in all, it looks like this:
(def dictionary
(m1p/prepare-dictionary
{:header/title [:fn/str "Hello, {{:greetee}}!"]}))
(m1p/interpolate
[:div.main
[:h1 [:i18n :header/title {:greetee "Internet"}]]]
{:dictionaries {:i18n dictionary}})
;;=> [:div.main [:h1 "Hello, Internet!"]]
Yay!
m1p.core/prepare-dictionary
helps dictionaries do interesting things to their
values on retrieval: interpolate data, "pluralize" texts, format dates, apply
gradients to colors - your imagination is the limit. These transformations are
performed with dictionary functions.
[:fn/str "Hello, {{:greetee}}"]
references the built-in dictionary function
:fn/str
, which performs classic mustachioed string interpolation.
It is possible and encouraged to register your own custom dictionary
functions.
Another one of m1p's built-in dictionary functions, :fn/get
, is
even simpler. It just gets parameters:
(def dictionary
(m1p/prepare-dictionary
{:header/title [:span "Hello, "
[:strong [:fn/get :greetee]]]}))
(m1p/interpolate
[:div.main
[:h1 [:i18n :header/title {:greetee "Internet"}]]]
{:dictionaries {:i18n dictionary}})
;;=> [:div.main [:h1 [:span "Hello, " [:strong "Internet"]]]]
m1p.core/prepare-dictionary
enables the use of dictionary functions, and front
loads some processing for faster retrieval.
(require '[m1p.core :as m1p])
(def en-dictionary
(m1p/prepare-dictionary
[#:home
{:title "Home page" ;; 1
:text [:fn/str "Welcome {{:display-name}}"]} ;; 2
#:login
{:title "Log in"
:help-text [:span {} ;; 3
"Need help? "
[:a {:href [:fn/get :url]} ;; 4
"Go here"]]}]))
- A dictionary can be specified as a vector of maps, in which case m1p will
(apply merge ,,,)
them. This makes it easy to use Clojure's namespace maps for a pleasant dictionary editing experience, and is very useful when combining dictionaries for several namespaces. - The
:fn/str
is a built-in dictionary function. It can be overridden. - Dictionary keys can contain any value.
- Dictionary functions can be used anywhere in data structures.
Dictionary functions can bring any number of new features to m1p dictionaries. These functions are called with:
opt
the map of options passed tom1p.core/interpolate
.params
- the data passed to the reference tuple looking up the dictionary key.- Remaining values from the dictionary tuple referencing the dictionary function.
We will now consider two examples that are commonly used in i18n tooling.
Dates are typically formatted differently under different locales, and here's how to do it with dictionary functions:
(import '[java.time LocalDateTime]
'[java.time.format DateTimeFormatter]
'[java.util Locale])
(defn format-date [opt params pattern local-date] ;; 1
(when local-date
(-> (DateTimeFormatter/ofPattern pattern)
(.withLocale (Locale. (:locale opt))) ;; 2
(.format local-date))))
(def dictionary
(m1p/prepare-dictionary
{:updated-at [:fn/str "Last updated " ;; 3
[:fn/date "E MMM d" [:fn/get :date]]]} ;; 4
{:dictionary-fns {:fn/date format-date}})) ;; 5
(m1p/interpolate
{:text [:i18n :updated-at
{:date (LocalDateTime/of 2024 6 8 9 37 12)}]}
{:locale "en" ;; 6
:dictionaries {:i18n dictionary}})
;;=> {:text "Last updated Wed Jun 8"}
format-date
uses Java libraries to format a local date. For ClojureScript there isgoog.i18n.DateTimeFormat
, which works in a very similar way.opt
is passed from the call tom1p.core/interpolate
and is a convenient way to pass in things like the current locale.- Dictionary functions can be arbitrarily nested
- The
:updated-at
key uses:fn/get
to pass the date to the formatter. - "Install" the date formatter dictionary function.
- Options passed to
interpolate
are available in dictionary functions.
Because the date argument is extracted from params
with :fn/get
, it must be
named in the reference tuple. When dictionary keys only refer to a single value,
you can use the dictionary function :fn/param
to select the single argument
instead:
(def dictionary
(m1p/prepare-dictionary
{:updated-at [:fn/str "Last updated "
[:fn/date "E MMM d" [:fn/param]]]
:created-at [:fn/str "Created by {{:creator}} "
[:fn/date "E MMM d" [:fn/get :date]]]}
{:dictionary-fns {:fn/date format-date}}))
(m1p/interpolate
{:created [:i18n :created-at {:creator "Christian"
:date (LocalDateTime/of 2024 6 8 8 37 12)}]
:updated [:i18n :updated-at (LocalDateTime/of 2024 6 8 9 37 12)]}
{:locale "en"
:dictionaries {:i18n dictionary}})
;;=>
;; {:created "Created by Christian Wed Jun 8"
;; :updated "Last updated Wed Jun 8"}
Pluralization is a hard problem to solve properly across all languages, but usually a trivial matter to implement for the handful of languages a specific app supports.
Here's how to add a naive "0, 1, many"-style pluralization helper to a dictionary:
(require '[m1p.core :as m1p])
(defn pluralize [opt n & plurals]
(-> (nth plurals (min (if (number? n) n 0) (dec (count plurals))))
(m1p/interpolate-string {:n n} opt)))
(def dictionary
(m1p/prepare-dictionary
{:songs [:fn/plural "no songs" "one song" "a couple of songs" "{{:n}} songs"]}
{:dictionary-fns {:fn/plural pluralize}}))
(m1p/interpolate
[:ul
[:li [:i18n :songs 0]]
[:li [:i18n :songs 1]]
[:li [:i18n :songs 2]]
[:li [:i18n :songs 4]]]
{:dictionaries {:i18n dictionary}})
;;=>
;; [:ul
;; [:li "no songs"]
;; [:li "one song"]
;; [:li "a couple of songs"]
;; [:li "4 songs"]]
Seems a little weird for an "i18n library" to not touch on how to switch between
different languages and locales, no? Turns out switching between dictionaries
isn't the hard part: just check the current locale and select the right
dictionary to pass to interpolate
:
(def dictionary-opts
{:dictionary-fns {:fn/date format-date
:fn/plural pluralize}})
(def dictionaries
{:en (m1p/prepare-dictionary
{:title [:fn/str "Hello {{:display-name}}!"]}
dictionary-opts)
:nb (m1p/prepare-dictionary
{:title [:fn/str "Hei {{:display-name}}!"]}
dictionary-opts)})
(def locale :nb)
(m1p/interpolate
[:i18n :title {:display-name "Meep meep"}]
{:dictionaries {:i18n (get dictionaries locale)}})
;;=> "Hei Meep meep!"
Because dictionaries will be used interchangeably, it is a good idea to test
them for parity. The m1p.validation
namespace contains several functions that
can detect common problems. You can combine these however you want, and possibly
add some of your own and perform assertions on them in your test suite, during
builds, or similar.
(require '[m1p.core :as m1p]
'[m1p.validation :as v])
(def dicts
{:en (m1p/prepare-dictionary
[#:home
{:title "Home page"
:text [:fn/str "Welcome {{:display-name}}"]}
#:login
{:title "Log in"
:help-text [:span {}
"Need help? "
[:a {:href [:fn/get :url]}
"Go here"]]}])
:nb (m1p/prepare-dictionary
[#:home
{:title "Hjemmeside"
:text "Welcome {{:display-name}}"} ;; Missing [:fn/str ,,,]
#:login
{:title "Logg inn"}])})
(concat
(v/find-non-kw-keys dicts)
(v/find-unqualified-keys dicts)
(v/find-missing-keys dicts)
(v/find-misplaced-interpolations dicts)
(v/find-type-discrepancies dicts)
(v/find-interpolation-discrepancies dicts)
(v/find-fn-get-param-discrepancies dicts))
;;=>
;; [{:kind :missing-key
;; :dictionary :nb
;; :key :login/help-text}
;; {:kind :misplaced-interpolation-syntax
;; :dictionary :nb
;; :key :home/text}
;; {:kind :interpolation-discrepancy
;; :key :home/text
;; :dictionaries {:en #{:display-name}
;; :nb #{}}}]
All the validation functions return a list of potential problems in your
dictionaries. The data can be used to generate warnings and/or errors as you see
fit. For a more human consumable report, pass the data to
m1p.validation/print-report
(which formats the data with
m1p.validation/format-report
and prints it):
(->> (concat
(v/find-non-kw-keys dicts)
(v/find-unqualified-keys dicts)
(v/find-missing-keys dicts)
(v/find-misplaced-interpolations dicts)
(v/find-type-discrepancies dicts)
(v/find-interpolation-discrepancies dicts)
(v/find-fn-get-param-discrepancies dicts))
(v/print-report dicts))
;; Problems in :nb
;; String interpolation syntax outside :fn/str:
;; :home/text
;;
;; Missing keys:
;; :login/help-text
;;
;; Interpolation discrepancies
;; :home/text
;; :en #{:display-name}
;; :nb #{}
A dictionary is just a map. One of the core ideas in m1p is to create this map
in such a way that its source can be serializable Clojure data. For runtime use,
pass it through m1p.core/prepare-dictionary
which turns some values into
functions.
Because serializable dictionaries is an important goal for m1p, the dictionary can contain reference tuples that turn into function calls on retrieval.
m1p does not have anything akin to get-in
, so dictionaries is just one flat
key-value data structure. For this reason, it is highly suggested to use
namespaced keys in dictionaries.
A reference tuple is a vector where the first element is a keyword that has meaning to m1p:
[:some-keyword ,,,]
There are two main forms of reference tuples:
- References to dictionary keys in data passed to
m1p.core/interpolate
- References to dictionary functions in dictionary values.
Dictionary key lookups look like this:
[dictionary-k k & [params]]
That is:
dictionary-k
- A key referencing a dictionaryk
- A key in said dictionaryparams
- Optional argument to dictionary functions
Dictionary key lookups will be replaced with the result of:
(m1p.core/lookup opt dictionary k params)
Where does the dictionary-k
key come from? When calling
m1p.core/interpolate
, dictionaries are passed like so:
(m1p.core/interpolate params {:dictionaries {k dict}})
The k
is the value to use in lookup references in params
to lookup keys in
that dictionary.
References to dictionary functions look like this:
[fn-k & args]
Available fn-k
s are determined when calling m1p.core/prepare-dictionary
:
(m1p/prepare-dictionary
dictionary-map
{:dictionary-fns {k fn}})
Any k
in the :dictionary-fns
map can be used in reference tuples in the
dictionary to have the associated function called on retrieval, in addition to
built-in dictionary functions, see below.
A dictionary function is a function that can be invoked on retrieval based on
declarative data in the dictionary. Dictionary functions are passed to
m1p.core/prepare-dictionary
in the second argument under
:dictionary-functions
:
(m1p/prepare-dictionary
dictionary-map
{:dictionary-fns {k fn}})
See reference tuples for more information about referencing these in dictionaries.
Dictionary functions are called with opts
from the call to
m1p.core/interpolate
, params
from the dictionary key lookup and any
remaining items from the reference tuple that invoked the function.
This reference lookup:
(def dictionary
(m1p.core/prepare-dictionary
{:songs [:fn/plural "no songs" "one song" "{{:n}} songs"]}
{:dictionary-fns {:fn/plural pluralize}}))
And this interpolation:
(m1p.core/interpolate
[:i18n :songs 0]
{:dictionaries {:i18n dictionary}
:fn.str/on-missing-interpolation (fn [_ _ k] (str "No" k "!"))})
Will result in pluralize
being called with the options map passed to
interpolate
, 0
as params
, and then the strings "no songs"
, "one song"
,
and "{{:n}} songs"
. In fewer words:
(def opt {:fn.str/on-missing-interpolation (fn [_ _ k] (str "No" k "!"))})
(pluralize opt 0 "no songs" "one song" "{{:n}} songs")
A built-in dictionary function. Performs string
interpolation on the strings xs
and joins the strings with no separator. Any
occurrence of {{:k}}
will be replaced with :k
from params
.
If a string contains an interpolation placeholder that is not provided in
params
on retrieval, the behavior is defined by the function passed as
:fn.str/on-missing-interpolation
in the options map to
m1p.core/prepare-dictionary
. This function is called with an options map,
params
, and the missing placeholder/key.
By default missing interpolations will render the string:
"[Missing interpolation key :k]"
You might want to provide a custom function for this to throw exceptions during test and developent, and to log the problem and output an empty string in production.
Returns the key k
in params
in [:i18n :dict/key params]
. Return the string
"[Missing key k]"
if not found. Change this behavior by passing a function as
:fn.get/on-missing-key
in the options map to m1p.core/prepare-dictionary
.
The function will be called with an options map, params
and the missing key.
You might want to provide a custom function for this to throw exceptions during test and developent, and to log the problem in production.
Returns the entire params
as passed to interpolate
in place.
Enables the use of dictionary functions in dictionary
. opt
is a map of
options:
-
:exception-handler
a function to call when a dictionary function throws an exception. The handler receives an ex-info with ex-data in this shape:```clj {:fn, :lookup-key, :data} ```
The handler function should return the value to interpolate in place of the failed call. Popular option: Logging the error and returning
nil
.
Interpolate data
with keys from :dictionaries
in opt
.
Supported options:
:dictionaries
the dictionaries to interpolate from:on-missing-dictionary-key
a function to call when attempting to interpolate a dictionary key that can't be found. Will be called withopts
frominterpolate
, params, and the key.
Additional options in opt
are passed to dictionary functions.
Lookup a single key k
from the dictionary
.
Supported options in opt
:
:on-missing-dictionary-key
a function to call when attempting to interpolate a dictionary key that can't be found. Will be called withopts
frominterpolate
, params, and the key.
Additional options in opt
are passed to dictionary functions.
Functions that finds common problems in dictionaries that are to be used
interchangeably. In all these functions, dicts
is a map of dictionaries to be
compared, e.g. {:en dict, :nb dict}
. All functions return a list of problems
as maps with the keys :dictionary
(e.g. :en
), :key
, :kind
(the kind of
problem), and optionally :data
. Feel free to create your own validation
functions that work on the same data structures.
Return a list of all keys across all dictionaries that are not keywords.
Return a list of all keys across all dictionaries that don't have a namespace.
Finds all keys used across all dictionaries and returns a list of keys missing from individual dictionaries.
Returns a list of all keys whose type is not the same across all dictionaries. The list will include one entry per dictionary for each key with type discrepancies.
Returns a list of all keys whose values use a different set of string interpolations.
Returns a list of all keys whose values use a different set of parameters with
:fn/get
.
A multi-method that returns a string label for the printed report for a :kind
.
If you add your own validation functions, implement this to label your custom
problems, so format-report
and print-report
can treat your problems like
m1p's own:
(defmethod m1p.validation/get-label :my-custom-problem [_] "Custom problem")
Formats problems in a human-readable string.
Prints the report formatted by m1p.validation/format-report
.
Copyright © 2022-2024 Christian Johansen & Magnar Sveen Distributed under the Eclipse Public License either version 1.0 or (at your option) any later version.