tonsky / tongue

Do-it-yourself i18n library for Clojure/Script
Eclipse Public License 1.0
307 stars 19 forks source link

Functional interpolation #39

Open nathell opened 3 months ago

nathell commented 3 months ago

What

This PR allows to pass a function instead of a map to translate for interpolation. E.g. assuming translate is a result of calling build-translate, where previously you could do

(translate :en :hello {:name "world"}) ;=> "Hello, world!"

you can now also do:

(translate :en :hello #(if (= % :name) "world" "dunno")) ;=> "Hello, world!"

Why

To make Tongue more dynamic. For example, the third arg could be a generic resolver that looks up keys in a DB or some third-party API.

My specific use case is to be able to create a wrapper around translate that passes itself as an interpolate fn, recursively. This allows you to have specific substrings that can be used within larger, more generic translations. I have added a test that illustrates this.

I guess it might be potentially beneficial to enable this recursive behaviour in build-translate itself, without having to write a wrapper, but this might break backwards compatibility. As it stands, the PR is conservative, allowing recursive lookup as an opt-in.

tonsky commented 3 months ago

Sorry it took me so long to get back to it. I now have loaded into my head all the context needed to talk about this :)

I guess it might be potentially beneficial to enable this recursive behaviour in build-translate itself, without having to write a wrapper, but this might break backwards compatibility.

How would that look like? If I understand correctly, you want to allow:

(def translate
  (tongue/build-translate
    {:en-GB {:country "the UK"}
     :en-US {:country "the US"}
     :en {:welcome "Welcome to {country}!"}
     :ru {:welcome "Добро пожаловать в Россию!"}}))

(translate :en-GB :welcome {:country "Canada"}) ; => "Welcome to Canada"
(translate :en-GB :welcome) ; => "Welcome to the UK"

Basically adding a behaviour that if key is needed but not provided it is looked up in the dictionary again?

Would that completely solve your use-case? I think we can add it despite API breakage (it will only break for missing keys, hope nobody is relying on those). Or would you prefer to have fn interpolation too?

nathell commented 2 months ago

Sorry for the delay, I wanted to think about it some more :)

It turned out that it’d be sometimes handy to apply some transformations to the looked-up key, e.g. capitalize it. I was considering full-blown fn interpolation à la clojure.core.strint; but it’s macro-based, so I think it would be hard to get working with dynamically-constructed translation maps.

Instead, I ended up using a static mapping of transformations. E.g. I have a transformation named capitalize that lets me say:

(def strings
  {:en-GB {:country "the UK"}
   :en-US {:country "the US"}
   :en {:welcome "{country->capitalize} welcomes you!"}}) ; -> marks transformation; I’d have slightly preferred | but tongue’s interpolation regex doesn’t include it

;; later:   
(message :en-US :welcome) ;=> "The US welcomes you!"

Right now, this is the code I have (works with current Tongue):

;; ugly code to make tongue think a fn is a map
#?(:clj
   (potemkin/def-map-type FunctionWrappingMap [f]
     (get [_ k _] (f k))
     (assoc [this k v] this)
     (dissoc [this k] this)
     (keys [_] [])
     (meta [_] nil)
     (with-meta [this _] this)))

(defn function-wrapping-map
  "Returns a map-like object that responds true to map? and
  delegates all lookups to a function f. Don't try to do
  anything other than lookup with it."
  [f]
  #?(:clj (FunctionWrappingMap. f)
     :cljs
     (reify
       IMap
       (-dissoc [coll k] coll)
       ILookup
       (-lookup [coll k] (f k)))))

;; these are the transformations that a keyword might include
(def transformations
  {"capitalize" str/capitalize})

;; messages map
(def strings
  {:en-GB {:country "the UK"}
   :en-US {:country "the US"}
   :en {:welcome "{country->capitalize} welcomes you!"}})

;; in my code, this has a more elaborate logic
(defn current-locale []
  "en")

(def translate (tongue/build-translate strings))

(declare message)

(defn transform [k]
  (let [[s transformation-name] (str/split (name k) #"->")
        transformation (get transformations transformation-name)]
    (cond-> (message (keyword s))
      transformation transformation)))

(defn message
  ([k] (translate (current-locale) k (function-wrapping-map transform)))
  ([k m] (translate (current-locale) k (function-wrapping-map #(or (m %) (transform %)))))
  ([k arg & args] (apply translate (current-locale) k arg args)))

This is arguably hacky, but seems to satisfy my needs right now. WDYT?

tonsky commented 2 months ago

I am thinking if there are other use-cases besides UK/US English. For most other languages, you’d have to change the entire string, so this is not as useful?

What do you plan to use this feature for? Some concrete examples? I just don’t want to bring in complexity to such a simple tool. I want it to stay simple, as long as it lets you do what you need.