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 | ;; ported-from: src/i18n/runtime.ts (the vendored suite i18n runtime —
;; byte-identical across the CLJS apps, like net/ and id/boot; game1 is canon)
;;
;; Design constraints it encodes:
;; - catalogs are bundled modules, never fetched — offline PWAs must not
;; depend on a runtime request, and boot stays synchronous
;; - the language preference mirrors per app (`<ns>:lang`) and rides the
;; suite identity record like hue/glyph; the mirror always wins at boot,
;; the bridge reconciles later (same contract as the identity itself)
;; - plurals go through Intl.PluralRules: Romanian's few/other split (with
;; "de" from 20 up) and Hungarian's uninflected numerals fall out of the
;; per-language variant text, not out of code
;;
;; Message shape: a string, or a map of plural variants with string keys
;; ("zero" "one" "two" "few" "many" "other" — "other" required). Catalogs are
;; CLJS maps keyed by message-key strings.
(ns sueta.i18n.runtime)
(def LANGS ["en" "ro" "hu"])
(def ^:private FALLBACK "en")
(defonce ^:private state
(atom {:ns "" :lang FALLBACK :catalogs {} :hydrated false}))
(defonce ^:private listeners (atom #{}))
(defonce ^:private plural-rules (atom {}))
(defn- lang? [x]
(and (string? x) (some #(= % x) LANGS)))
(defn- store-key []
(str (:ns @state) ":lang"))
(defn- from-navigator []
(let [cands (or (.-languages js/navigator) #js [(.-language js/navigator)])]
(or (some (fn [c]
(let [primary (.toLowerCase (.slice (or c "") 0 2))]
(when (lang? primary) primary)))
(array-seq cands))
FALLBACK)))
(defn init-i18n
"Resolve and apply the active language: per-app mirror -> browser -> en.
Synchronous; call before anything renders. opts: {:ns .. :catalogs {\"en\" ..}}."
[{:keys [ns catalogs]}]
(swap! state assoc :ns ns :catalogs catalogs)
(let [stored (try (js/localStorage.getItem (store-key))
(catch :default _ nil)) ; Safari private mode
lang (if (lang? stored) stored (from-navigator))]
(swap! state assoc :lang lang)
(set! (.-lang js/document.documentElement) lang)
lang))
(defn get-lang []
(:lang @state))
(defn set-lang
"Switch language: persist the mirror, retag the document, notify.
Pushing the choice suite-wide (the id bridge) is the caller's move."
[l]
(when (and (lang? l) (not= l (:lang @state)))
(swap! state assoc :lang l)
(try (js/localStorage.setItem (store-key) l)
(catch :default _ nil))
(set! (.-lang js/document.documentElement) l)
(doseq [f @listeners] (f l))))
(defn absorb-suite-lang
"Adopt a language that arrived from the suite record (bridge boot or an
unsolicited push). No-ops on junk; never writes back to the bridge."
[l]
(when (lang? l) (set-lang l)))
(defn on-lang-change [f]
(swap! listeners conj f)
(fn [] (swap! listeners disj f)))
(defn- var-of
"Interpolation slot lookup — vars may be a CLJS map (app call sites) or a
plain JS object (the kits calling back through the injected t)."
[vars k]
(cond
(map? vars) (get vars k)
(some? vars) (unchecked-get vars k)
:else nil))
(defn- fill [template vars]
(if (some? vars)
(.replace ^string template (js/RegExp. "\\{(\\w+)\\}" "g")
(fn [m k]
(let [v (var-of vars k)]
(if (some? v) (str v) m))))
template))
(defn- lookup [k]
(let [{:keys [lang catalogs]} @state]
(if-some [own (get-in catalogs [lang k])]
{:msg own :in lang}
(when-some [fb (get-in catalogs [FALLBACK k])]
{:msg fb :in FALLBACK}))))
(defn- rules-for [l]
(or (get @plural-rules l)
(let [r (js/Intl.PluralRules. l)]
(swap! plural-rules assoc l r)
r)))
(defn t
"Plain message. Unknown keys render as the key itself (visible, greppable).
vars: a map of slot name (string) -> value."
([k] (t k nil))
([k vars]
(if-some [{:keys [msg]} (lookup k)]
(fill (if (string? msg) msg (get msg "other")) vars)
k)))
(defn tn
"Plural message: the variant is picked by the rules of the language the
message actually came from (an EN fallback must not be indexed with
Romanian categories). {n} is available in the template."
([k n] (tn k n nil))
([k n vars]
(if-some [{:keys [msg in]} (lookup k)]
(let [all (assoc (or vars {}) "n" n)]
(if (string? msg)
(fill msg all)
(let [cat (.select (rules-for in) n)]
(fill (or (get msg cat) (get msg "other")) all))))
k)))
(defn fmt-num [n]
(.toLocaleString ^js n (get-lang)))
(defn fmt-date
([d] (fmt-date d js/undefined))
([^js d opts] (.toLocaleDateString d (get-lang) opts)))
(defn fmt-time
([d] (fmt-time d js/undefined))
([^js d opts] (.toLocaleTimeString d (get-lang) opts)))
(defn hydrate-html
"Hydrate static markup. English stays baked into the HTML (zero flash for
the default); for other languages every tagged element gets swapped once:
<span data-i18n=\"join.play\">Enter the grid</span>
<button data-i18n-attr=\"title:hud.invite.title\">…</button>
Text swaps are textContent-only — translated strings never enter innerHTML.
Prose with inline markup is authored as sibling per-language blocks
(`data-lang-block lang=\"ro\" hidden`) and this toggles the right one.
Idempotent; call again on every language switch."
([] (hydrate-html js/document))
([^js root]
(let [{:keys [lang hydrated]} @state]
(doseq [^js el (array-seq (.querySelectorAll root "[data-lang-block]"))]
(set! (.-hidden el) (not= (.getAttribute el "lang") lang)))
;; EN is the baked-in markup — skip unless a non-EN pass already ran
(when-not (and (= lang FALLBACK) (not hydrated))
(swap! state assoc :hydrated true)
(doseq [^js el (array-seq (.querySelectorAll root "[data-i18n]"))]
(set! (.-textContent el) (t (.. el -dataset -i18n))))
(doseq [^js el (array-seq (.querySelectorAll root "[data-i18n-attr]"))]
(doseq [pair (.split ^string (.. el -dataset -i18nAttr) ",")]
(let [i (.indexOf ^string pair ":")]
(when (pos? i)
(.setAttribute el
(.trim (.slice ^string pair 0 i))
(t (.trim (.slice ^string pair (inc i)))))))))))))
(def ^:private PICKER-CSS "
.i18n-pick { display: inline-flex; gap: 2px; align-items: baseline; font: inherit; }
.i18n-pick button {
font: inherit; font-size: 12px; letter-spacing: .06em; line-height: 1;
background: none; border: 0; padding: 4px 5px; cursor: pointer;
color: inherit; opacity: .45;
}
.i18n-pick button.on { opacity: 1; text-decoration: underline; text-underline-offset: 3px; }
.i18n-pick i { font-style: normal; opacity: .3; }
")
(defonce ^:private picker-css-done (atom false))
(defn lang-picker-el
"The uniform \"en · ro · hu\" row. Persists + notifies via set-lang; the
caller adds any suite-wide push (bridge putProfile) in :on-pick."
([] (lang-picker-el {}))
([{:keys [on-pick]}]
(when-not @picker-css-done
(reset! picker-css-done true)
(let [style (js/document.createElement "style")]
(set! (.-textContent style) PICKER-CSS)
(.appendChild js/document.head style)))
(let [box (js/document.createElement "div")
btns (atom {})]
(set! (.-className box) "i18n-pick")
(doseq [[i l] (map-indexed vector LANGS)]
(when (pos? i)
(let [sep (js/document.createElement "i")]
(set! (.-textContent sep) "·")
(.appendChild box sep)))
(let [b (js/document.createElement "button")]
(set! (.-type b) "button")
(set! (.-textContent b) l)
(.toggle (.-classList b) "on" (= l (get-lang)))
(.addEventListener b "click"
(fn []
(set-lang l)
(when on-pick (on-pick l))))
(swap! btns assoc l b)
(.appendChild box b)))
(on-lang-change (fn [l]
(doseq [[code ^js b] @btns]
(.toggle (.-classList b) "on" (= code l)))))
box)))
|