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.
- checksums.yaml +7 -0
- data/LICENSE.md +21 -0
- data/README.md +237 -0
- data/SKILL.md +106 -0
- data/lib/i18n-keyless-rails.rb +4 -0
- data/lib/i18n_keyless/api_client.rb +355 -0
- data/lib/i18n_keyless/backend.rb +52 -0
- data/lib/i18n_keyless/config.rb +125 -0
- data/lib/i18n_keyless/dictionary_store.rb +151 -0
- data/lib/i18n_keyless/helper.rb +21 -0
- data/lib/i18n_keyless/locale.rb +103 -0
- data/lib/i18n_keyless/middleware.rb +30 -0
- data/lib/i18n_keyless/miss.rb +44 -0
- data/lib/i18n_keyless/railtie.rb +31 -0
- data/lib/i18n_keyless/translate_missing_keys_job.rb +11 -0
- data/lib/i18n_keyless/translator.rb +300 -0
- data/lib/i18n_keyless/version.rb +8 -0
- data/lib/i18n_keyless.rb +129 -0
- data/llms.txt +101 -0
- metadata +95 -0
|
@@ -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
|
data/lib/i18n_keyless.rb
ADDED
|
@@ -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)
|