/
gettext.clj
224 lines (197 loc) · 10.2 KB
/
gettext.clj
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
(ns fulcro.gettext
"A set of functions for working with GNU gettext translation files, including translation generation tools to go
from PO files to cljc."
(:require [clojure.string :as str]
[clojure.java.io :as io]
[clojure.pprint :as pp]
[clojure.java.shell :refer [sh]])
(:import (java.io File)))
(defn- cljc-output-dir
"Given a base source path (no trailing /) and a ns, returns the path to the directory that should contain it."
[src-base ns]
(let [path-from-ns (-> ns (str/replace #"\." "/") (str/replace #"-" "_"))]
(str src-base "/" path-from-ns)))
(defn strip-comments
"Given a sequence of strings that possibly contain comments of the form # ... <newline>: Return the sequence of
strings without those comments."
[string]
(str/replace string #"(?m)^#[^\n\r]*(\r\n|\n|\r|$)" ""))
(defn is-header? [entry]
(str/starts-with? entry "msgid \"\"\nmsgstr \"\"\n\"Project-Id-V"))
(defn get-blocks
[resource]
(filter (comp not is-header?) (map strip-comments (str/split (slurp resource) #"\n\n"))))
(defn stripquotes [s]
(-> s
(str/replace #"^\"" "")
(str/replace #"\"$" "")))
(defn block->translation
[gettext-block]
(let [lines (str/split-lines gettext-block)]
(-> (reduce (fn [{:keys [msgid msgctxt msgstr section] :as acc} line]
(let [[_ k v :as keyline] (re-matches #"^(msgid|msgctxt|msgstr)\s+\"(.*)\"\s*$" line)]
(cond
(and line (.matches line "^\".*\"$")) (update acc section #(str % (stripquotes line)))
(and k (#{"msgid" "msgctxt" "msgstr"} k)) (-> acc
(assoc :section (keyword k))
(update (keyword k) #(str % v)))
:else (do
(println "Unexpected input -->" line "<--")
acc)))) {} lines)
(dissoc :section))))
(defn- map-translations
"Map translated strings to lookup keys.
Parameters:
* `resource` - A resource to read the po from.
Returns a map of msgstr values to msgctxt|msgid string keys."
[file-or-resource]
(let [translations (map block->translation (get-blocks
(if (string? file-or-resource)
(io/as-file file-or-resource)
file-or-resource)))]
(reduce (fn [acc translation]
(let [{:keys [msgctxt msgid msgstr] :or {msgctxt "" msgid "" msgstr ""}} translation
msg (if (and (-> msgstr .trim .isEmpty) (-> msgid .trim .isEmpty not))
(do
(println (str "WARNING: Message '" msgid "' is missing a translation! Using the default locale's message instead of an empty string."))
msgid)
msgstr)]
(assoc acc (str msgctxt "|" msgid) msg)))
{} translations)))
(defn- wrap-translations-in-ns
"Wrap a translation map with supporting clojurescript code
Parameters:
* `locale` - the locale which this translation targets
* `translation` - a clojurescript map of translations
* `ns` - the ns in which to load the translations
* `dynamic?` - The ns for this locale will be dynamically loaded as a module.
Returns a string of clojurescript code."
[& {:keys [locale translation ns dynamic?]}]
(let [trans-ns (str ns "." locale)
locale-kw (if (keyword? locale) locale (keyword locale))
str-locale (name locale-kw)
ns-decl (str "(ns " trans-ns " (:require fulcro.i18n #?(:cljs cljs.loader)))")
comment ";; This file was generated by Fulcro."
trans-def (pp/write (list 'def 'translations translation) :stream nil)
swap-decl (pp/write (list 'swap! 'fulcro.i18n/*loaded-translations* 'assoc str-locale 'translations) :stream nil)
loaded-decl (str "#?(:cljs (cljs.loader/set-loaded! " locale-kw "))")]
(str/join "\n\n" (keep identity [ns-decl comment trans-def swap-decl (when dynamic? loaded-decl)]))))
(defn- po-path [{:keys [podir]} po-file] (.getAbsolutePath (new File podir po-file)))
(defn- find-po-files
"Finds any existing po-files, and adds them to settings. Returns the new settings."
[{:keys [podir] :as settings}]
(assoc settings :existing-po-files (filter #(.endsWith % ".po") (str/split-lines (:out (sh "ls" (.getAbsolutePath podir)))))))
(defn- js-missing?
"Checks for compiled js. Returns settings if all OK, nil otherwise."
[{:keys [js-path] :as settings}]
(if (.exists (io/as-file js-path))
settings
(do
(println js-path "is missing. Did you compile?"))))
(defn- gettext-missing?
"Checks for gettext. Returns settings if all OK, nil otherwise."
[settings]
(let [xgettext (:exit (sh "which" "xgettext"))
msgmerge (:exit (sh "which" "msgmerge"))]
(if (or (not= xgettext 0) (not= msgmerge 0))
(do
(println "Count not find xgettext or msgmerge on PATH")
nil)
settings)))
(defn- run
"Run a shell command and logging the command and result."
[& args]
(println "Running: " (str/join " " args))
(let [result (:exit (apply sh args))]
(when (not= 0 result)
(print "Command Failed: " (str/join " " args))
(println result))))
(defn- clojure-ize-locale [po-filename]
(-> po-filename
(str/replace #"^([a-z]+_*[A-Z]*).po$" "$1")
(str/replace #"_" "-")))
(defn- expand-settings
"Adds defaults and some additional helpful config items"
[{:keys [src ns po] :as settings}]
(let [srcdir ^File (some-> src (io/as-file))
output-path (some-> src (cljc-output-dir ns))
outdir ^File (some-> output-path (io/as-file))
podir ^File (some-> po (io/as-file))]
(merge settings
{:messages-pot (some-> podir (File. "messages.pot") (.getAbsolutePath))
:podir podir
:outdir outdir
:srcdir srcdir
:output-path output-path})))
(defn- verify-source-folders
"Verifies that the source folder (target of the translation cljc) and ..."
[{:keys [^File srcdir ^File outdir] :as settings}]
(cond
(not (.exists srcdir)) (do
(println "The given source-folder does not exist")
nil)
(not (.exists outdir)) (do (println "Making missing source folder " (.getAbsolutePath outdir))
(.mkdirs outdir)
settings)
:else settings))
(defn- verify-po-folders
"Verifies that po files can be generated. Returns settings if so, nil otherwise."
[{:keys [^File podir] :as settings}]
(cond
(not (.exists podir)) (do
(println "Creating missing PO directory: " (.getAbsolutePath podir))
(.mkdirs podir)
settings)
(not (.isDirectory podir)) (do
(println "po-folder must be a directory.")
nil)
:else settings))
(defn extract-strings
"Extract strings from a compiled js file (whitespace optimized) as a PO template. If existing translations exist
then this function will auto-update those (using `msgmerge`) as well.
Remember that you must first compile your application (*without* modules) and with `:whitespace` optimization to generate
a single javascript file. The gettext tools know how to extract from Javascript, but not Clojurescript.
Parameters:
`:js-path` - The path to your generated javascript (defaults to `i18n/i18n.js` from project root)
`:po` - The directory where your PO template and PO files should live (defaults to `i18n` from project root). "
[{:keys [js-path po] :or {js-path "i18n/i18n.js" po "i18n"}}]
(when-let [{:keys [existing-po-files messages-pot]
:as settings} (some-> {:js-path js-path :po po}
expand-settings
js-missing?
gettext-missing?
verify-po-folders
find-po-files)]
(println "Extracting strings")
(run "xgettext" "--from-code=UTF-8" "--debug" "-k" "-ktr:1" "-ktrc:1c,2" "-ktrf:1" "-o" messages-pot js-path)
(doseq [po (:existing-po-files settings)]
(when (.exists (io/as-file (po-path settings po)))
(println "Merging extracted PO template file to existing translations for " po)
(run "msgmerge" "--force-po" "--no-wrap" "-U" (po-path settings po) messages-pot)))))
(defn deploy-translations
"Scans for .po files and generates cljc for those translations in your app. At the moment, these must be output to
the package `translations`.
The settings map should contain:
:src - The source folder (base) of where to emit the files. Defaults to `src`
:po - The directory where you po files live. Defaults to `i18n`
:as-modules? - If true, generates the locales so they can be dynamically loaded. NOTE: You must configure your build to support this as well
by putting each locale in a module with the locale's cljs keyword (e.g. a module :de-AT { :entries #{translations/de-AT}})."
[{:keys [src po as-modules?] :or {src "src" po "i18n" as-modules? false} :as settings}]
(let [{:keys [existing-po-files output-path outdir]
:as settings} (some-> {:src src :ns "translations" :po po :as-modules? as-modules?}
expand-settings
verify-po-folders
verify-source-folders
find-po-files)
replace-hyphen #(str/replace % #"-" "_")
locales (map clojure-ize-locale existing-po-files)]
(println "po path is: " po)
(println "Output path is: " output-path)
(doseq [po existing-po-files]
(let [locale (clojure-ize-locale po)
translation-map (map-translations (po-path settings po))
cljc-translations (wrap-translations-in-ns :ns "translations" :locale locale :translation translation-map :dynamic? as-modules?)
cljc-trans-path (str output-path "/" (replace-hyphen locale) ".cljc")]
(println "Writing " cljc-trans-path)
(spit cljc-trans-path cljc-translations)))
(println "Deployed translations.")))