Skip to content

Instantly share code, notes, and snippets.

@pithyless
Forked from isaksky/i18n-impl.cljs
Last active June 24, 2021 09:49
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pithyless/f39c679b69104301bcbc2de3358653c8 to your computer and use it in GitHub Desktop.
Save pithyless/f39c679b69104301bcbc2de3358653c8 to your computer and use it in GitHub Desktop.
Handling translations in ClojureScript
(ns front.utilities.i18n-impl
(:require [front.i18n]))
(defn build-domain-index [text-vec]
(let [by-msgid (atom (transient {}))
by-msg (atom (transient {}))]
(doseq [{:keys [s_message s_message_id] :as text} text-vec]
(when-not (clojure.string/blank? s_message_id)
(swap! by-msgid assoc! s_message_id text))
(when-not (clojure.string/blank? s_message)
(swap! by-msg assoc! s_message text)))
{:msgid->text (persistent! @by-msgid)
:msg->text (persistent! @by-msg)}))
(defn build-text-index [text-ary]
(let [text-vec (js->clj text-ary :keywordize-keys true)
domain->texts (group-by :s_domain text-vec)]
(into
{}
(for [[k v] domain->texts]
[k (build-domain-index v)]))))
(defn interpolate-vars [text-str args]
(reduce-kv
(fn [acc k v]
(cond
(keyword? k)
(.replace acc (str "{" (name k) "}") (str v))
:else acc))
text-str
args))
(defn interpret-text [text msg args]
(let [count-arg (get args :count)
base-text-str
(cond
count-arg
(let [text-i18n (get text :texts_i18n)]
(case count-arg
;; FIXME - this assumes the locale uses Plural rule 1, like English.
;; See: https://developer.mozilla.org/en-US/docs/Mozilla/Localization/Localization_and_Plurals#List_of_Plural_Rules
1 (or (first text-i18n) (:s_message text) (first msg))
(or (nth text-i18n 1) (:s_message_plural text) (last msg))))
:else
(get-in
text
[:texts_i18n 0]
(get text :s_message (first msg))))]
(interpolate-vars (or base-text-str "") args)))
(defn get-text [text-index msg args]
(let [e (nth msg 0)
msg (if (qualified-keyword? e)
(subvec msg 1)
msg)
text-entry (cond
(qualified-keyword? e)
(get-in text-index [(namespace e) :msgid->text (name e)])
:else
(get-in text-index ["" :msg->text e]))]
(interpret-text text-entry msg args)))
(defn create-translator [text-ary]
(let [text-index (build-text-index text-ary)]
(reify front.i18n/ITranslate
(-get-text [_ msg args]
(get-text text-index msg args)))))
(ns front.i18n
(:require
[cljs.env :as env]
[cljs.analyzer :as ana]
[cljs.tagged-literals :as tlit]))
;; Adapted from:: https://github.com/thheller/cljs-i18n-api/blob/master/src/cljs/i18n.clj
(def vec-conj (fnil conj []))
(defmacro tr
"Macro that makes strings available for translation (added to icx_master as a compile step).
msg is a literal string or vector.
args are :key/value pairs (optional) to inject into the translated string
if the the argument count is uneven the last argument will be treated as a dynamic map
Examples:
Plain string. Use this form if no context/domain is needed to translate.
(tr \"Ok\")
A vector, where the first member is a qualified keyword. The namespace of the keyword
will be set as the domain, and the message_id will be the name, giving some context
to the translator. It will also be stable over time, even if the string (last arg)
changes.
(tr [:employee.pto-request/approve \"Approve\"])
;; Pluralization, interpolation: (the :count keyword is special)
(tr [:common/pony-brag-message \"I have {count} pony\" \"I have {count} ponies\"] :count 2)
"
[msg & args]
(let [msg (if (or (string? msg) (not (seqable? msg)))
[msg]
msg)
_ (when-not
(every?
#(or (string? %1) (qualified-keyword? %1))
msg)
(throw (ex-info "Only string literals or qualified keywords supported for the translation string."
{:translation-string msg})))
kv-args
(if (even? (count args))
args
(butlast args))
map-arg
(when (odd? (count args))
(last args))
static-args
(-> (apply hash-map kv-args)
(cond->
(map? map-arg)
(merge map-arg)))
current-ns
(-> &env :ns :name)
string-data
{:msg msg
:ns current-ns
:args (into [] (keys static-args))
:line line
:column column}]
;; thheller recommends storing it like this (and not globally),
;; or it will break with caching enabled for the build.
(when env/*compiler*
(swap! env/*compiler* update-in [::ana/namespaces current-ns ::strings] vec-conj string-data))
`(tr*
~msg
~(cond
(or (nil? map-arg)
(= map-arg static-args))
static-args
(and (empty? static-args)
(some? map-arg))
map-arg
:else
`(merge ~static-args ~map-arg)))))
(ns front.i18n
(:require-macros [front.i18n]))
(defprotocol ITranslate
(-get-text [this msg args]))
(defonce translate-ref (atom nil))
(defn set-translator! [tx]
{:pre [(satisfies? ITranslate tx)]}
(reset! translate-ref tx))
(defn tr*
"do not use directly, accesse via the `tr` macro"
[msg args]
(if-some [tx @translate-ref]
(-get-text tx msg args)
(some #(when (string? %1) %1) msg)))
(ns front.tasks.i18n-tool
(:require [cheshire.core :as c]))
(defn- classify-text-key [x]
(cond
(string? x) :string
(qualified-keyword? x) :qualified-keyword
:else :unknown))
(defn save-i18n-calls! [calls]
(let [data
(mapv
(fn [{:keys [msg args] :as call}]
(let [[domain msgid msg msg-plural]
(condp = (mapv classify-text-key msg)
[:string] [nil nil (first msg) nil]
[:string :string] (into [nil nil] msg)
[:qualified-keyword :string] (let [[kw s1] msg]
[(namespace kw) (name kw) s1])
[:qualified-keyword :string :string] (let [[kw s1 s2] msg]
[(namespace kw) (name kw) s1 s2])
(throw (ex-info "Unrecognized translation message form. Expected [qualified-kw? string1 string2?]" {:msg msg})))]
{:domain domain
:msgid msgid
:msg msg
:msg_plural msg-plural
}))
calls)]
(spit "i18n_data.json" (c/generate-string data))))
(defn hook
{:shadow.build/stage :flush}
[build-state & args]
(when (= :release (:shadow.build/mode build-state))
(let [i18n-calls
(for [[ns-key attrs] (get-in build-state [:compiler-env :cljs.analyzer/namespaces])
:let [ns-str (name ns-key)]
:when (clojure.string/starts-with? ns-str "front.")
str (get attrs :front.i18n/strings)]
str)]
(save-i18n-calls! i18n-calls)))
build-state)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment