i18n-keyless-rails 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nKeyless
4
+ # The `i18nk` helper, mixed into views, controllers, mailers and jobs by the
5
+ # Railtie. `t()` with an i18n-keyless `context`, for ambiguous strings:
6
+ #
7
+ # i18nk("8 heures", context: "duration") # "8 hours"
8
+ # i18nk("8 heures", context: "clock time") # "8 AM"
9
+ # i18nk("Bienvenue %{name}", name: user.name, context: "greeting")
10
+ # i18nk("Payer", namespace: "checkout")
11
+ #
12
+ # The string is stored as "key__context", exactly like the SDKs. `%{name}`
13
+ # placeholders are I18n's own replacement. Unlike `t()`, `i18nk` never treats
14
+ # its argument as a Rails key: `i18nk("close")` is the source string "close".
15
+ module Helper
16
+ def i18nk(text, values = nil, context: nil, locale: nil, namespace: nil, **more_values)
17
+ values = (values || {}).merge(more_values)
18
+ I18nKeyless.translate(text, values, context: context, locale: locale, namespace: namespace)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nKeyless
4
+ # Maps a Rails locale ("fr", :"pt-BR", "zh_CN", "en-GB") onto one of the 48
5
+ # i18n-keyless language codes. A port of `resolveLang` from i18n-keyless-core.
6
+ module Locale
7
+ # The 48 languages i18n-keyless translates into, as the API spells them (v3).
8
+ AVAILABLE_LANGS = %w[
9
+ ar bn ca zh-Hans zh-Hant hr cs da nl en en-GB fi
10
+ fr fr-CA de el gu he hi hu id it ja kn ko ms
11
+ ml mr no or pl pt pt-BR pa ro ru sk sl es es-MX
12
+ sv ta te th tr uk ur vi
13
+ ].freeze
14
+
15
+ # Chinese is selected by script, not by region, and the regions do not map
16
+ # to a script by name, so the common region tags are spelled out.
17
+ CHINESE_REGION_SCRIPTS = {
18
+ "cn" => "zh-Hans", "sg" => "zh-Hans", "hans" => "zh-Hans",
19
+ "tw" => "zh-Hant", "hk" => "zh-Hant", "mo" => "zh-Hant", "hant" => "zh-Hant"
20
+ }.freeze
21
+
22
+ # The App Store Connect listing slot of each code (a convenience the SDKs
23
+ # ship; not a wire concern).
24
+ APP_STORE_LOCALES = {
25
+ "ar" => "ar-SA", "bn" => "bn", "ca" => "ca", "zh-Hans" => "zh-Hans", "zh-Hant" => "zh-Hant",
26
+ "hr" => "hr", "cs" => "cs", "da" => "da", "nl" => "nl-NL", "en" => "en-US", "en-GB" => "en-GB",
27
+ "fi" => "fi", "fr" => "fr-FR", "fr-CA" => "fr-CA", "de" => "de-DE", "el" => "el", "gu" => "gu",
28
+ "he" => "he", "hi" => "hi", "hu" => "hu", "id" => "id", "it" => "it", "ja" => "ja", "kn" => "kn",
29
+ "ko" => "ko", "ms" => "ms", "ml" => "ml", "mr" => "mr", "no" => "no", "or" => "or", "pl" => "pl",
30
+ "pt" => "pt-PT", "pt-BR" => "pt-BR", "pa" => "pa", "ro" => "ro", "ru" => "ru", "sk" => "sk",
31
+ "sl" => "sl", "es" => "es-ES", "es-MX" => "es-MX", "sv" => "sv", "ta" => "ta", "te" => "te",
32
+ "th" => "th", "tr" => "tr", "uk" => "uk", "ur" => "ur", "vi" => "vi"
33
+ }.freeze
34
+
35
+ BY_LOWERCASE = AVAILABLE_LANGS.to_h { |lang| [lang.downcase, lang] }.freeze
36
+ private_constant :BY_LOWERCASE
37
+
38
+ module_function
39
+
40
+ # The i18n-keyless code for a locale tag, most specific match first, or
41
+ # nil when no supported language matches.
42
+ #
43
+ # to_lang("pt_BR") # => "pt-BR"
44
+ # to_lang("pt-AO") # => "pt"
45
+ # to_lang("zh_CN") # => "zh-Hans"
46
+ # to_lang("zh_TW") # => "zh-Hant"
47
+ # to_lang("es-419") # => "es-MX"
48
+ # to_lang("xx") # => nil
49
+ def to_lang(tag)
50
+ resolve(tag)
51
+ end
52
+
53
+ # `resolveLang(tag, { supported, fallback })` of the SDKs: the first
54
+ # candidate present in `supported` (when given), else `fallback`.
55
+ #
56
+ # resolve("pt-BR", supported: ["pt", "en"], fallback: "en") # => "pt"
57
+ # resolve("ja", supported: ["pt", "en"], fallback: "en") # => "en"
58
+ def resolve(tag, supported: nil, fallback: nil)
59
+ candidates(tag).each do |candidate|
60
+ return candidate if supported.nil? || supported.include?(candidate)
61
+ end
62
+ fallback
63
+ end
64
+
65
+ # `to_app_store_locale("fr")` # => "fr-FR"
66
+ def to_app_store_locale(lang)
67
+ APP_STORE_LOCALES[lang.to_s]
68
+ end
69
+
70
+ def lang?(code)
71
+ AVAILABLE_LANGS.include?(code.to_s)
72
+ end
73
+
74
+ def candidates(tag)
75
+ return [] if tag.nil?
76
+
77
+ normalized = tag.to_s.tr("_", "-").strip.downcase
78
+ return [] if normalized.empty?
79
+
80
+ parts = normalized.split("-")
81
+ language = parts.first
82
+ region = parts.last
83
+ list = []
84
+ push = ->(lang) { list << lang if lang && !list.include?(lang) }
85
+
86
+ # 1. the tag as written ("pt-BR", "zh-Hans")
87
+ push.call(BY_LOWERCASE[normalized])
88
+
89
+ # 2. Chinese resolves by script and never falls back to a bare language
90
+ if language == "zh"
91
+ push.call(CHINESE_REGION_SCRIPTS.fetch(region, "zh-Hans"))
92
+ return list
93
+ end
94
+
95
+ # 3. UN M49 code for Latin America, which is what the es-MX slot really covers
96
+ push.call("es-MX") if normalized == "es-419"
97
+
98
+ # 4. the bare language ("pt-AO" => "pt")
99
+ push.call(BY_LOWERCASE[language])
100
+ list
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "rack/body_proxy"
5
+ rescue LoadError
6
+ # Rack is not a dependency of the gem: without it the flush runs before the response leaves.
7
+ end
8
+
9
+ module I18nKeyless
10
+ # Flushes the misses, the revalidations and the usage after each response
11
+ # is sent (the body is closed), so a translation never delays a page.
12
+ class Middleware
13
+ def initialize(app)
14
+ @app = app
15
+ end
16
+
17
+ def call(env)
18
+ status, headers, body = @app.call(env)
19
+ if defined?(::Rack::BodyProxy)
20
+ [status, headers, ::Rack::BodyProxy.new(body) { I18nKeyless.flush }]
21
+ else
22
+ I18nKeyless.flush
23
+ [status, headers, body]
24
+ end
25
+ rescue Exception # rubocop:disable Lint/RescueException -- the flush still runs after a failed request
26
+ I18nKeyless.flush
27
+ raise
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nKeyless
4
+ # One source string that had no translation, with the languages it was
5
+ # requested in. Sent to POST /translate once per (namespace, key, context).
6
+ class Miss
7
+ attr_reader :key, :context, :namespace, :langs
8
+
9
+ def initialize(key, context = nil, namespace = Translator::DEFAULT_NAMESPACE, langs = [])
10
+ @key = key
11
+ @context = context.to_s.empty? ? nil : context
12
+ @namespace = namespace.to_s.empty? ? Translator::DEFAULT_NAMESPACE : namespace
13
+ @langs = Array(langs).dup
14
+ end
15
+
16
+ # The lookup key, stored exactly like the SDKs: "key__context".
17
+ def lookup_key
18
+ self.class.lookup_key_for(key, context)
19
+ end
20
+
21
+ def self.lookup_key_for(key, context)
22
+ context.nil? || context.to_s.empty? ? key.to_s : "#{key}__#{context}"
23
+ end
24
+
25
+ # Dedupe id: one POST per (namespace, key, context), whatever the languages.
26
+ def id
27
+ "#{namespace}:#{lookup_key}"
28
+ end
29
+
30
+ def add_lang(lang)
31
+ @langs << lang unless @langs.include?(lang)
32
+ end
33
+
34
+ # A plain Hash (string keys), for an ActiveJob argument.
35
+ def to_h
36
+ { "key" => key, "context" => context, "namespace" => namespace, "langs" => langs.dup }
37
+ end
38
+
39
+ def self.from_h(data)
40
+ data = data.transform_keys(&:to_s)
41
+ new(data["key"], data["context"], data["namespace"] || Translator::DEFAULT_NAMESPACE, data["langs"] || [])
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nKeyless
4
+ # Auto-loaded by Rails. Chains the backend after the application's I18n
5
+ # backend, mixes `i18nk` into views, controllers, mailers and jobs, and
6
+ # flushes the misses after each response (Rack middleware), after each job,
7
+ # and at exit (rake tasks, `rails runner`).
8
+ class Railtie < ::Rails::Railtie
9
+ initializer "i18n_keyless.middleware" do |app|
10
+ app.middleware.use I18nKeyless::Middleware
11
+ end
12
+
13
+ initializer "i18n_keyless.helpers" do
14
+ ActiveSupport.on_load(:action_view) { include I18nKeyless::Helper }
15
+ ActiveSupport.on_load(:action_controller) { include I18nKeyless::Helper }
16
+ ActiveSupport.on_load(:action_mailer) { include I18nKeyless::Helper }
17
+ ActiveSupport.on_load(:active_job) do
18
+ include I18nKeyless::Helper
19
+ after_perform { I18nKeyless.flush }
20
+ end
21
+ end
22
+
23
+ # After the application's own `config.i18n.backend`, which Rails applies in
24
+ # its own after_initialize (registered before this one).
25
+ config.after_initialize do
26
+ I18nKeyless.reset!
27
+ I18nKeyless.install! if I18nKeyless.enabled?
28
+ at_exit { I18nKeyless.flush }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nKeyless
4
+ # Sends the misses of one request to POST /translate from a queue worker.
5
+ # Enqueued when `config.queue` (I18N_KEYLESS_QUEUE) is set.
6
+ class TranslateMissingKeysJob < ::ActiveJob::Base
7
+ def perform(misses)
8
+ I18nKeyless.translator.translate_now(misses.map { |miss| Miss.from_h(miss) })
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nKeyless
4
+ # The bridge between Rails' I18n and the i18n-keyless API.
5
+ #
6
+ # Nothing happens until the first lookup that misses in a non-primary
7
+ # locale. That miss loads the locale's dictionary (from the cache, or from the
8
+ # API once) and keeps it in this process. A miss that is not in the dictionary
9
+ # either is recorded and the source text is returned. The misses are sent to
10
+ # POST /translate after the response (the Rack middleware), or as an
11
+ # ActiveJob when `queue` is set.
12
+ #
13
+ # With usage reporting on (the default, like the node SDK), the date each
14
+ # key was last served is recorded and POSTed to
15
+ # /translate/last-used-translations after the response, at most once every
16
+ # 10 s across processes.
17
+ #
18
+ # One instance per process, shared by every thread: the per-request state
19
+ # (misses, usage, dictionaries to revalidate) sits behind a mutex, and a
20
+ # flush takes whatever is there, whichever request recorded it.
21
+ class Translator
22
+ DEFAULT_NAMESPACE = "default"
23
+
24
+ NO_LANGUAGES_WARNING = "I18N_KEYLESS_LANGUAGES is required for translation: set it to every language " \
25
+ "your app serves (for example \"en,fr,es\"). Missing strings are served as their " \
26
+ "source text until then."
27
+
28
+ attr_reader :store, :api, :primary, :languages, :default_namespace, :queue, :logger
29
+
30
+ def self.build(config)
31
+ api_key = config.api_key.to_s
32
+ new(
33
+ store: DictionaryStore.new(
34
+ cache: config.resolved_cache,
35
+ prefix: config.cache_prefix.to_s.empty? ? "i18n-keyless" : config.cache_prefix.to_s,
36
+ ttl: config.cache_ttl,
37
+ api_key_hash: DictionaryStore.hash_key(api_key)
38
+ ),
39
+ api: ApiClient.new(
40
+ api_key: api_key,
41
+ api_url: config.resolved_api_url,
42
+ timeout: [config.timeout.to_i, 1].max,
43
+ retry_delays: config.resolved_retry,
44
+ concurrency: [config.concurrency.to_i, 1].max,
45
+ logger: config.resolved_logger
46
+ ),
47
+ primary: config.resolved_primary,
48
+ languages: config.resolved_languages,
49
+ default_namespace: config.resolved_namespace,
50
+ queue: config.queue,
51
+ usage_enabled: config.usage?,
52
+ logger: config.resolved_logger
53
+ )
54
+ end
55
+
56
+ def initialize(store:, api:, primary:, languages: [], default_namespace: DEFAULT_NAMESPACE, queue: nil,
57
+ usage_enabled: true, logger: nil)
58
+ @store = store
59
+ @api = api
60
+ @primary = primary
61
+ @languages = languages
62
+ @default_namespace = default_namespace
63
+ @queue = queue.to_s.empty? ? nil : queue.to_s
64
+ @usage_enabled = usage_enabled
65
+ @logger = logger
66
+ @mutex = Mutex.new
67
+ @loaded = {} # "lang|namespace" => lines loaded in this process
68
+ @misses = {} # Miss#id => Miss
69
+ @revalidate = {} # "lang|namespace" => [lang, namespace]
70
+ @usage = {} # namespace => lookup key => YYYY-MM-DD
71
+ @warned_no_languages = false
72
+ end
73
+
74
+ def usage_enabled?
75
+ @usage_enabled
76
+ end
77
+
78
+ # `resolveNamespace` of the SDKs: the per-call namespace, else the
79
+ # configured default, else the literal `default`. Empty strings fall through.
80
+ def self.resolve_namespace(per_call, config_default)
81
+ return per_call.to_s unless per_call.nil? || per_call.to_s.empty?
82
+ return config_default.to_s unless config_default.nil? || config_default.to_s.empty?
83
+
84
+ DEFAULT_NAMESPACE
85
+ end
86
+
87
+ # The `i18nk` helper: a translation with an optional `context`, `%{name}`
88
+ # placeholders replaced by I18n after the lookup.
89
+ def get(text, values = {}, context: nil, locale: nil, namespace: nil)
90
+ text = text.to_s
91
+ return text if text.empty?
92
+
93
+ locale = (locale || I18n.locale).to_s
94
+ translated = lookup(locale, text, context: context, namespace: namespace) || text
95
+ I18nKeyless.interpolate(translated, values)
96
+ end
97
+
98
+ # The backend path. Returns the translation when the dictionary has it,
99
+ # the source text otherwise (never nil for a context lookup, so
100
+ # "key__context" never leaks to the page).
101
+ def lookup(locale, key, context: nil, namespace: nil)
102
+ key = key.to_s
103
+ return key if key.empty?
104
+
105
+ namespace = self.class.resolve_namespace(namespace, default_namespace)
106
+ lookup_key = Miss.lookup_key_for(key, context)
107
+ lang = Locale.to_lang(locale.to_s)
108
+ return key if lang.nil?
109
+
110
+ # Usage is recorded in the primary locale too, so the API does not prune
111
+ # keys that only ever render in their source language (node SDK rule).
112
+ record_usage(namespace, lookup_key)
113
+ return key if lang == primary
114
+
115
+ lines = ensure_loaded(lang, namespace)
116
+ value = lines[lookup_key]
117
+ # An empty stored translation counts as missing, like in the SDKs.
118
+ return value if value.is_a?(String) && !value.empty?
119
+
120
+ record_miss(key, context, namespace, lang)
121
+ key
122
+ end
123
+
124
+ # Loads the (lang, namespace) dictionary once per process.
125
+ def ensure_loaded(lang, namespace)
126
+ id = "#{lang}|#{namespace}"
127
+ @mutex.synchronize do
128
+ return @loaded[id] if @loaded.key?(id)
129
+
130
+ entry = store.get(lang, namespace)
131
+ if entry.nil?
132
+ # First time ever for this language: the one blocking fetch.
133
+ result = api.fetch_dictionary(lang, namespace, nil)
134
+ entry = if result.ok
135
+ store.put(lang, namespace, result.translations, result.etag)
136
+ else
137
+ store.put(lang, namespace, {}, nil, failed: true)
138
+ end
139
+ elsif store.stale?(entry)
140
+ # Serve what we have now, ask the API after the response.
141
+ @revalidate[id] = [lang, namespace]
142
+ end
143
+ @loaded[id] = entry[:translations]
144
+ end
145
+ end
146
+
147
+ # Forgets the dictionaries loaded in this process (I18n.reload!).
148
+ def reset_loaded!
149
+ @mutex.synchronize { @loaded.clear }
150
+ end
151
+
152
+ def pending_usage
153
+ @mutex.synchronize { @usage.transform_values(&:dup) }
154
+ end
155
+
156
+ def pending_misses
157
+ @mutex.synchronize { @misses.values }
158
+ end
159
+
160
+ # Runs after the response is sent: POSTs the misses, then revalidates the
161
+ # stale dictionaries served during the request, then sends the usage.
162
+ # Never raises.
163
+ def flush
164
+ misses, revalidate = @mutex.synchronize do
165
+ taken = [@misses.values, @revalidate.values]
166
+ @misses = {}
167
+ @revalidate = {}
168
+ taken
169
+ end
170
+
171
+ misses = [] if !misses.empty? && !can_translate?
172
+ claimed = misses.select { |miss| store.claim_miss(miss) }
173
+ unless claimed.empty?
174
+ if queue && defined?(I18nKeyless::TranslateMissingKeysJob)
175
+ TranslateMissingKeysJob.set(queue: queue).perform_later(claimed.map(&:to_h))
176
+ else
177
+ translate_now(claimed)
178
+ end
179
+ end
180
+
181
+ revalidate.each { |(lang, namespace)| revalidate_now(lang, namespace) }
182
+
183
+ flush_usage
184
+ rescue StandardError => e
185
+ warn("flush error: #{e.message}")
186
+ end
187
+
188
+ # POST /translate for each miss and merge the answers into the cache, so
189
+ # the very next request has them. The dictionaries are then marked stale:
190
+ # the next request revalidates them with the API after its response.
191
+ def translate_now(misses)
192
+ return if misses.empty? || !can_translate?
193
+
194
+ results = api.translate(misses, primary, languages)
195
+ touched = {}
196
+ misses.each do |miss|
197
+ translation = results[miss.id]
198
+ if translation.nil?
199
+ store.release_miss(miss)
200
+ next
201
+ end
202
+ lookup_key = miss.lookup_key
203
+ translation.each do |lang, text|
204
+ next if lang == primary || !Locale.lang?(lang) || text.to_s.empty?
205
+
206
+ (touched["#{miss.namespace}|#{lang}"] ||= {})[lookup_key] = text
207
+ end
208
+ end
209
+ touched.each do |id, lines|
210
+ namespace, lang = id.split("|", 2)
211
+ store.merge(lang, namespace, lines)
212
+ refresh_loaded(lang, namespace, lines)
213
+ end
214
+ end
215
+
216
+ private
217
+
218
+ def record_usage(namespace, lookup_key)
219
+ return if !usage_enabled? || lookup_key.empty?
220
+
221
+ date = Time.now.utc.strftime("%Y-%m-%d")
222
+ @mutex.synchronize { (@usage[namespace] ||= {})[lookup_key] = date }
223
+ end
224
+
225
+ def record_miss(key, context, namespace, lang)
226
+ return if key.empty?
227
+
228
+ miss = Miss.new(key, context, namespace)
229
+ @mutex.synchronize do
230
+ miss = (@misses[miss.id] ||= miss)
231
+ miss.add_lang(lang)
232
+ end
233
+ end
234
+
235
+ # Merges this request's usage dates into the stored map, then POSTs the
236
+ # whole map when it holds unsent changes and no POST left in the last 10 s.
237
+ # Fire and forget: a failure keeps the changes for a later request.
238
+ def flush_usage
239
+ recorded = @mutex.synchronize do
240
+ taken = @usage
241
+ @usage = {}
242
+ taken
243
+ end
244
+ return unless usage_enabled?
245
+
246
+ store.merge_usage(recorded) unless recorded.empty?
247
+ return if !store.usage_dirty? || !store.claim_usage_slot
248
+
249
+ result = api.send_usage(primary, store.usage)
250
+ store.clear_usage_dirty if result.ok
251
+ rescue StandardError
252
+ # Analytics must never affect the response.
253
+ end
254
+
255
+ # POST /translate overwrites the project's language list with the one it
256
+ # receives, so a miss is never sent without a configured list: it would
257
+ # shrink the project to the primary language and damage every other client
258
+ # on the same API key. The source text is served instead.
259
+ def can_translate?
260
+ return true unless languages.empty?
261
+
262
+ unless @warned_no_languages
263
+ @warned_no_languages = true
264
+ warn(NO_LANGUAGES_WARNING)
265
+ end
266
+ false
267
+ end
268
+
269
+ def revalidate_now(lang, namespace)
270
+ entry = store.get(lang, namespace)
271
+ result = api.fetch_dictionary(lang, namespace, entry && entry[:etag])
272
+ unless result.ok
273
+ # Remember the failure briefly, so the next requests do not all retry.
274
+ store.put(lang, namespace, entry ? entry[:translations] : {}, entry && entry[:etag], failed: true)
275
+ return
276
+ end
277
+ if result.not_modified
278
+ store.touch(lang, namespace)
279
+ return
280
+ end
281
+ store.put(lang, namespace, result.translations, result.etag)
282
+ refresh_loaded(lang, namespace, result.translations)
283
+ end
284
+
285
+ # Keeps this process's loaded lines current (a long-lived process keeps
286
+ # the dictionaries between requests).
287
+ def refresh_loaded(lang, namespace, lines)
288
+ id = "#{lang}|#{namespace}"
289
+ @mutex.synchronize do
290
+ @loaded[id] = @loaded[id].merge(lines) if @loaded.key?(id)
291
+ end
292
+ end
293
+
294
+ def warn(message)
295
+ logger&.warn("i18n-keyless: #{message}")
296
+ rescue StandardError
297
+ # Logging must never take a translation down.
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nKeyless
4
+ # Sent as the `Version` header. The API reads its major to pick the wire
5
+ # dialect: >= 3 means the v3 language codes ("zh-Hans", "cs"). One version
6
+ # for every package and port of the monorepo (scripts/set-version.mjs).
7
+ VERSION = "3.5.0"
8
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "i18n"
4
+ require "logger"
5
+ require "active_support"
6
+ require "active_support/cache"
7
+ require "active_support/core_ext/object/blank"
8
+
9
+ require_relative "i18n_keyless/version"
10
+ require_relative "i18n_keyless/config"
11
+ require_relative "i18n_keyless/locale"
12
+ require_relative "i18n_keyless/miss"
13
+ require_relative "i18n_keyless/api_client"
14
+ require_relative "i18n_keyless/dictionary_store"
15
+ require_relative "i18n_keyless/translator"
16
+ require_relative "i18n_keyless/backend"
17
+ require_relative "i18n_keyless/helper"
18
+ require_relative "i18n_keyless/middleware"
19
+
20
+ # Keyless translations for Ruby on Rails.
21
+ #
22
+ # `t('Welcome to our app')` resolves through the i18n-keyless API: a missing
23
+ # string is translated by AI once, for every language, cached in `Rails.cache`
24
+ # and served from there. The gem is an `I18n` backend chained AFTER the
25
+ # application's own backend, so `config/locales/*.yml` keeps working and wins.
26
+ #
27
+ # I18nKeyless.configure do |c|
28
+ # c.api_key = "..."
29
+ # c.languages = %w[en fr es]
30
+ # end
31
+ #
32
+ # Every value also has an `I18N_KEYLESS_*` environment counterpart (see Config).
33
+ module I18nKeyless
34
+ class << self
35
+ def config
36
+ @config ||= Config.new
37
+ end
38
+
39
+ # Yields the config, then rebuilds the translator so the new values apply.
40
+ def configure
41
+ yield config if block_given?
42
+ reset!
43
+ config
44
+ end
45
+
46
+ # Forgets the built translator (and its per-process dictionaries). The next
47
+ # call builds a new one from `config`.
48
+ def reset!
49
+ @translator = nil
50
+ end
51
+
52
+ def enabled?
53
+ config.enabled?
54
+ end
55
+
56
+ def translator
57
+ @translator ||= Translator.build(config)
58
+ end
59
+
60
+ # The `i18nk` helper: a translation with an optional `context`, `%{name}`
61
+ # placeholders replaced by I18n. Returns the source text when the gem is
62
+ # disabled, when the locale is the primary language, or on a miss.
63
+ #
64
+ # I18nKeyless.translate("8 heures", context: "duration")
65
+ # I18nKeyless.translate("Bienvenue %{name}", name: "Ada", context: "greeting")
66
+ def translate(text, values = nil, context: nil, locale: nil, namespace: nil, **more_values)
67
+ text = text.to_s
68
+ values = (values || {}).merge(more_values).transform_keys(&:to_sym)
69
+ return interpolate(text, values) unless enabled?
70
+
71
+ translator.get(text, values, context: context, locale: locale, namespace: namespace)
72
+ end
73
+ alias t translate
74
+
75
+ # POSTs the recorded misses, revalidates the stale dictionaries served
76
+ # since the last flush, and sends the usage analytics. Called by the Rack
77
+ # middleware after each response, after each ActiveJob, and at exit.
78
+ # Never raises.
79
+ def flush
80
+ return unless enabled? && @translator
81
+
82
+ @translator.flush
83
+ end
84
+
85
+ # Chains the keyless backend after `base` (the current `I18n.backend` by
86
+ # default). Idempotent: an already chained backend is left alone.
87
+ def install!(base = I18n.backend)
88
+ return base if base.is_a?(Backend)
89
+ return base if base.is_a?(I18n::Backend::Chain) && base.backends.any? { |b| b.is_a?(Backend) }
90
+
91
+ I18n.backend = I18n::Backend::Chain.new(base, Backend.new)
92
+ end
93
+
94
+ # Removes the keyless backend from the chain, restoring the application's own backend.
95
+ def uninstall!
96
+ backend = I18n.backend
97
+ return backend unless backend.is_a?(I18n::Backend::Chain) && backend.backends.any? { |b| b.is_a?(Backend) }
98
+
99
+ rest = backend.backends.reject { |b| b.is_a?(Backend) }
100
+ I18n.backend = rest.length == 1 ? rest.first : I18n::Backend::Chain.new(*rest)
101
+ end
102
+
103
+ # Is this `I18n.t` key a keyless source string, or a Rails key?
104
+ #
105
+ # A Rails key is a lowercase identifier path: `hello`, `users.index.title`,
106
+ # `activerecord.errors.models.user`. Those are left to the YAML files (and
107
+ # never sent to the API). Anything else, a space, an uppercase letter, a
108
+ # punctuation mark, is a source string. `config.rails_key_pattern` holds the
109
+ # rule; `nil` makes every string keyless.
110
+ def keyless_key?(key)
111
+ return false unless key.is_a?(String) && !key.empty?
112
+
113
+ pattern = config.rails_key_pattern
114
+ pattern.nil? || !pattern.match?(key)
115
+ end
116
+
117
+ # `%{name}` placeholders, I18n's own replacement, applied only when values
118
+ # are given (like I18n::Backend::Base#translate).
119
+ def interpolate(text, values)
120
+ return text if values.nil? || values.empty?
121
+
122
+ I18n.interpolate(text, values)
123
+ end
124
+ end
125
+ end
126
+
127
+ ActiveSupport.on_load(:active_job) { require_relative "i18n_keyless/translate_missing_keys_job" }
128
+
129
+ require_relative "i18n_keyless/railtie" if defined?(::Rails::Railtie)