i18n-keyless-rails 3.7.0 → 3.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41fc3ec59089f3a6e65ecc8b3d32483a32a69821580090b1c71c1ad7fbe2b078
4
- data.tar.gz: ceb6a6ee2b421d697a13c2a8b5333f3f787eccd9e8589bb596bbcd8765797cb5
3
+ metadata.gz: 0b5f7bbea883a39be3f717ee063ceb055b4876f7ce0a8027e15d0df728b9d1fe
4
+ data.tar.gz: afe3e6d4863e6a4867e9a412171bc8e4893def9b9fc0f0aa36ffda03cda13186
5
5
  SHA512:
6
- metadata.gz: e55140376f1b50deab0bf7f22df1f0a57fbda948113520cf6eb54adc4a0957d282dab930428e3c711a8798ef26662acd5fee19746c5a88fe603bd410b16e8219
7
- data.tar.gz: 48a0040816d435284bf1eb6dce88a2c4a77a3e51657d9d51155b74bba223ea1c657cbc482cfa20205d16d43808b855f279d7cc6f06903ece92781de09687b719
6
+ metadata.gz: 1ddf136839855b5e00bcbd685bebb6ce77bfe790454b20dd3f05255114506d10d4adef4e6adecf24dad17feab9fd4e24fc70bae3623d017822e51b226ab4987a
7
+ data.tar.gz: 7222d1ba284586cf64d14ff644e9507050d40c81cc497f00cc104222df6d5226567e92b07011ab89d3710174afc9f9070b6e6430b39a68a3cb142544395054fd
data/README.md CHANGED
@@ -104,6 +104,7 @@ end
104
104
  | `queue` | `I18N_KEYLESS_QUEUE` | none | An ActiveJob queue name: the misses of a request are enqueued as one `I18nKeyless::TranslateMissingKeysJob` instead of being sent after the response. |
105
105
  | `logger` | | `Rails.logger` | Where the `i18n-keyless:` warnings go. |
106
106
  | `rails_key_pattern` | | see above | The Rails-key rule. `nil`: every string is keyless. |
107
+ | `bundle_path` | `I18N_KEYLESS_BUNDLE_PATH` | none | The directory of a precompiled bundle (`manifest.json` plus `<namespace>/<lang>.json`). Every dictionary it covers is read at boot, never fetched. See "Precompiled bundle". |
107
108
 
108
109
  `I18N_KEYLESS_LANGUAGES` is the list the API translates a new string into, and the list it
109
110
  stores as your project's languages (it replaces the previous one, like the `supported` list
@@ -158,6 +159,18 @@ most `cache_ttl` seconds later. The miss guard and the usage lock live in the ca
158
159
  with a `:memory_store` they are per process, so use a shared store (Redis, Memcached, the
159
160
  database, the file store) in production, as Rails recommends anyway.
160
161
 
162
+ ## Precompiled bundle
163
+
164
+ Export your dictionaries once, at build time, with the MCP `export_bundle` tool or
165
+ `GET /translate/bundle`: a directory holding `manifest.json` and one
166
+ `<namespace>/<lang>.json` per dictionary. Point `bundle_path`
167
+ (`I18N_KEYLESS_BUNDLE_PATH`) at it, for example `Rails.root.join("i18n-keyless")`. Every
168
+ `(namespace, lang)` the manifest covers is then read at boot, into the process and into the
169
+ cache with the manifest's cursor, and never fetched from the API; the cache wins only when
170
+ it already holds a newer copy of the same language (a review that landed after the export).
171
+ Nothing else changes: a pair the manifest does not list is fetched as before, a miss still
172
+ POSTs, and its answer is merged next to the bundled lines.
173
+
161
174
  ## Limitations
162
175
 
163
176
  - **Plurals.** `t('Il y a %{count} pommes', count: n)` sends one source string and I18n
data/SKILL.md CHANGED
@@ -76,10 +76,17 @@ signatures; the `i18nk` helper is mixed into views, controllers, mailers and job
76
76
  `I18nKeyless.configure { |c| ... }` in an initializer, or the environment. Keys: `enabled`,
77
77
  `api_key`, `api_url`, `primary`, `languages`, `namespace`, `cache`, `cache_ttl`,
78
78
  `cache_prefix`, `timeout` (10 s), `retry` (`[500, 1500]` ms), `concurrency` (30), `usage`
79
- (true), `queue`, `logger`, `rails_key_pattern`.
79
+ (true), `queue`, `logger`, `rails_key_pattern`, `bundle_path`.
80
80
  Env: `I18N_KEYLESS_ENABLED`, `I18N_KEYLESS_API_KEY`, `I18N_KEYLESS_API_URL`,
81
81
  `I18N_KEYLESS_PRIMARY_LANG`, `I18N_KEYLESS_LANGUAGES`, `I18N_KEYLESS_NAMESPACE`,
82
- `I18N_KEYLESS_CACHE_TTL`, `I18N_KEYLESS_USAGE`, `I18N_KEYLESS_QUEUE`.
82
+ `I18N_KEYLESS_CACHE_TTL`, `I18N_KEYLESS_USAGE`, `I18N_KEYLESS_QUEUE`,
83
+ `I18N_KEYLESS_BUNDLE_PATH`.
84
+
85
+ Precompiled bundle: export the dictionaries at build time with the MCP `export_bundle`
86
+ tool or `GET /translate/bundle` (a directory: `manifest.json` plus one
87
+ `<namespace>/<lang>.json` per dictionary) and set `bundle_path` to that directory. Every
88
+ pair the manifest covers is read at boot, never fetched; the cache wins only when it is
89
+ newer. A miss still POSTs. Nothing else changes.
83
90
 
84
91
  ## Debug
85
92
 
@@ -36,7 +36,9 @@ module I18nKeyless
36
36
  Timeout::Error, SocketError, SystemCallError, EOFError, IOError, OpenSSL::SSL::SSLError
37
37
  ].freeze
38
38
 
39
- Dictionary = Struct.new(:ok, :not_modified, :translations, :etag, :error, keyword_init: true)
39
+ # `last_refresh`: the API's cursor for the dictionary (`data.lastRefresh`,
40
+ # epoch ms as a string), kept in the cache for the bundle precedence rule.
41
+ Dictionary = Struct.new(:ok, :not_modified, :translations, :etag, :error, :last_refresh, keyword_init: true)
40
42
  UsageResult = Struct.new(:ok, :sent, :error, keyword_init: true)
41
43
  Outcome = Struct.new(:action, :error, :response, :json, keyword_init: true)
42
44
 
@@ -310,6 +312,8 @@ module I18nKeyless
310
312
  result.translations = translations.is_a?(Hash) ? translations.select { |_, v| v.is_a?(String) } : {}
311
313
  etag = response["ETag"].to_s
312
314
  result.etag = etag.empty? ? nil : etag
315
+ cursor = json.dig("data", "lastRefresh")
316
+ result.last_refresh = cursor.nil? || cursor.to_s.empty? ? nil : cursor.to_s
313
317
  result
314
318
  end
315
319
 
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module I18nKeyless
6
+ # The precompiled bundle (docs/PROTOCOL.md, sections 4.5 and 7.4): a
7
+ # directory holding `manifest.json` and one `<namespace>/<lang>.json` per
8
+ # dictionary, as the MCP `export_bundle` tool or `GET /translate/bundle`
9
+ # yields it.
10
+ #
11
+ # A (namespace, lang) pair the manifest covers is read from the file instead
12
+ # of fetched, with the manifest's cursor. The two class methods `covers?`
13
+ # and `merge_with_storage` are pure and replayed by
14
+ # conformance/vectors/bundle-seed.json.
15
+ #
16
+ # Seed: { translations: Hash, last_refresh: String|nil }
17
+ # Stored: { translations: Hash, last_refresh: Object, lang: String }
18
+ class Bundle
19
+ attr_reader :path, :manifest
20
+
21
+ def initialize(path, manifest)
22
+ @path = path
23
+ @manifest = manifest
24
+ end
25
+
26
+ # Reads `manifest.json` from the directory. Nil when no path is configured;
27
+ # nil with one warning when the path holds no readable manifest.
28
+ def self.load(path, logger: nil)
29
+ path = path.to_s.strip.sub(%r{[/\\]+\z}, "")
30
+ return nil if path.empty?
31
+
32
+ manifest = read_json(File.join(path, "manifest.json"))
33
+ unless manifest.is_a?(Hash) && manifest["namespaces"].is_a?(Hash)
34
+ warn_with(logger, "bundle_path \"#{path}\" holds no readable manifest.json: the bundle is ignored")
35
+ return nil
36
+ end
37
+ new(path, manifest)
38
+ end
39
+
40
+ # `bundleCovers(manifest, namespace, lang)` of the SDKs: true when the
41
+ # manifest lists `lang` under `namespace`.
42
+ def self.covers?(manifest, namespace, lang)
43
+ return false unless manifest.is_a?(Hash)
44
+
45
+ languages = manifest.dig("namespaces", namespace.to_s, "languages")
46
+ languages.is_a?(Array) && languages.include?(lang.to_s)
47
+ end
48
+
49
+ # `mergeBundleWithStorage(bundle, stored, lang)` of the SDKs: the bundle is
50
+ # the base. What storage holds is merged on top (its keys win) and its
51
+ # cursor kept ONLY when its cursor is a strictly larger number than the
52
+ # bundle's AND it is in `lang`. A stored cursor that is empty, nil or not a
53
+ # number is never newer.
54
+ def self.merge_with_storage(bundle, stored, lang)
55
+ return bundle if stored.nil? || stored[:lang] != lang
56
+
57
+ stored_cursor = cursor_number(stored[:last_refresh])
58
+ return bundle if stored_cursor.nil?
59
+
60
+ # `Number(x)` of the SDKs: nil and "" are 0, anything else non-numeric is NaN,
61
+ # and nothing is larger than NaN.
62
+ bundle_raw = bundle[:last_refresh]
63
+ bundle_cursor = bundle_raw.nil? || bundle_raw.to_s.empty? ? 0.0 : cursor_number(bundle_raw)
64
+ return bundle if bundle_cursor.nil? || stored_cursor <= bundle_cursor
65
+
66
+ {
67
+ translations: bundle[:translations].merge(stored[:translations] || {}),
68
+ last_refresh: stored[:last_refresh]
69
+ }
70
+ end
71
+
72
+ def self.cursor_number(value)
73
+ return nil if value.nil?
74
+ return value.to_f if value.is_a?(Numeric)
75
+
76
+ text = value.to_s.strip
77
+ return nil if text.empty?
78
+
79
+ Float(text, exception: false)
80
+ end
81
+
82
+ def self.read_json(file)
83
+ return nil unless File.file?(file) && File.readable?(file)
84
+
85
+ JSON.parse(File.read(file))
86
+ rescue JSON::ParserError, SystemCallError, IOError, EncodingError
87
+ nil
88
+ end
89
+
90
+ def self.warn_with(logger, message)
91
+ logger&.warn("i18n-keyless: #{message}")
92
+ rescue StandardError
93
+ # Logging must never take a translation down.
94
+ end
95
+
96
+ def covers?(namespace, lang)
97
+ self.class.covers?(manifest, namespace, lang)
98
+ end
99
+
100
+ # The namespaces the manifest lists, in manifest order.
101
+ def namespaces
102
+ manifest["namespaces"].keys.map(&:to_s)
103
+ end
104
+
105
+ # The languages the manifest lists under one namespace.
106
+ def languages(namespace)
107
+ languages = manifest.dig("namespaces", namespace.to_s, "languages")
108
+ languages.is_a?(Array) ? languages.map(&:to_s) : []
109
+ end
110
+
111
+ # Every (namespace, lang) pair the manifest covers, in manifest order.
112
+ def pairs
113
+ namespaces.flat_map { |namespace| languages(namespace).map { |lang| [namespace, lang] } }
114
+ end
115
+
116
+ # The cursor of one namespace (epoch ms as a string), or nil.
117
+ def last_refresh(namespace)
118
+ cursor = manifest.dig("namespaces", namespace.to_s, "lastRefresh")
119
+ cursor.nil? ? nil : cursor.to_s
120
+ end
121
+
122
+ # One covered dictionary with its cursor, or nil when the pair is not
123
+ # covered or the file is unreadable (logged: the caller falls back to the
124
+ # fetch, like a pair the manifest does not list).
125
+ def seed(namespace, lang, logger: nil)
126
+ return nil unless covers?(namespace, lang)
127
+
128
+ file = File.join(path, namespace.to_s, "#{lang}.json")
129
+ translations = self.class.read_json(file)
130
+ unless translations.is_a?(Hash)
131
+ self.class.warn_with(logger, "bundle file \"#{file}\" is missing or unreadable: #{namespace}/#{lang} is fetched instead")
132
+ return nil
133
+ end
134
+ { translations: translations.select { |_, v| v.is_a?(String) }, last_refresh: last_refresh(namespace) }
135
+ end
136
+ end
137
+ end
@@ -43,6 +43,12 @@ module I18nKeyless
43
43
  attr_accessor :logger
44
44
  # The Rails-key rule (see DEFAULT_RAILS_KEY_PATTERN). `nil`: every string is keyless.
45
45
  attr_accessor :rails_key_pattern
46
+ # The precompiled bundle: a directory holding manifest.json and one
47
+ # <namespace>/<lang>.json per dictionary, as the MCP `export_bundle` tool or
48
+ # GET /translate/bundle yields it (for example Rails.root.join("i18n-keyless")).
49
+ # A dictionary the manifest covers is read at boot, never fetched, unless the
50
+ # cache already holds a newer copy. A miss still POSTs.
51
+ attr_accessor :bundle_path
46
52
 
47
53
  def initialize(env = ENV)
48
54
  @enabled = truthy?(env.fetch("I18N_KEYLESS_ENABLED", "true"))
@@ -61,6 +67,7 @@ module I18nKeyless
61
67
  @queue = env["I18N_KEYLESS_QUEUE"]
62
68
  @logger = nil
63
69
  @rails_key_pattern = DEFAULT_RAILS_KEY_PATTERN
70
+ @bundle_path = env["I18N_KEYLESS_BUNDLE_PATH"]
64
71
  end
65
72
 
66
73
  def enabled?
@@ -11,7 +11,12 @@ module I18nKeyless
11
11
  # it is served without asking the API. A stale entry is still served, and
12
12
  # revalidated with its ETag after the response (a 304 keeps it as is).
13
13
  #
14
- # Entry: { translations: Hash, etag: String|nil, fetched_at: Integer, failed: Boolean }
14
+ # Entry: { translations: Hash, etag: String|nil, fetched_at: Integer, failed: Boolean, last_refresh: String|nil }
15
+ #
16
+ # `last_refresh` is the API's cursor for the dictionary (epoch ms as a
17
+ # string, PROTOCOL.md section 4.2), or the bundle's when the entry was seeded
18
+ # from one. It is never sent on the wire (the ETag carries freshness here):
19
+ # it decides the precedence between a bundle and the cache (`seed`).
15
20
  class DictionaryStore
16
21
  # Seconds a failed fetch is remembered before the API is asked again.
17
22
  FAILURE_TTL = 60
@@ -40,8 +45,41 @@ module I18nKeyless
40
45
  entry[:translations].is_a?(Hash) ? entry : nil
41
46
  end
42
47
 
43
- def put(lang, namespace, translations, etag, failed: false)
44
- entry = { translations: translations, etag: etag, fetched_at: Time.now.to_i, failed: failed }
48
+ def put(lang, namespace, translations, etag, failed: false, last_refresh: nil)
49
+ entry = { translations: translations, etag: etag, fetched_at: Time.now.to_i, failed: failed, last_refresh: last_refresh }
50
+ cache.write(key(lang, namespace), entry)
51
+ entry
52
+ end
53
+
54
+ # Applies one bundle dictionary (PROTOCOL.md 7.4) to the stored entry with
55
+ # the SDKs' precedence (`Bundle.merge_with_storage`): the bundle is the
56
+ # base and the stored slice is merged on top, its cursor kept, only when
57
+ # the cache is strictly newer than the bundle. Otherwise the bundle's lines
58
+ # win, its cursor is stored, and the entry is fresh: no fetch and no
59
+ # revalidation until `ttl` seconds pass. Keys the cache has and the bundle
60
+ # lacks (a merged POST answer) are kept either way. Idempotent: a second
61
+ # call with the same bundle writes nothing.
62
+ #
63
+ # @param seed [Hash] { translations: Hash, last_refresh: String|nil }
64
+ def seed(lang, namespace, seed)
65
+ stored = get(lang, namespace)
66
+ merged = Bundle.merge_with_storage(
67
+ seed,
68
+ stored && { translations: stored[:translations], last_refresh: stored[:last_refresh], lang: lang },
69
+ lang
70
+ )
71
+ translations = (stored ? stored[:translations] : {}).merge(merged[:translations])
72
+ storage_wins = stored && merged[:last_refresh] != seed[:last_refresh]
73
+ if storage_wins
74
+ return stored if translations == stored[:translations]
75
+
76
+ entry = stored.merge(translations: translations)
77
+ else
78
+ return stored if stored && translations == stored[:translations] && stored[:last_refresh] == merged[:last_refresh]
79
+
80
+ # The ETag named what the API answered before the export: it no longer matches this content.
81
+ entry = { translations: translations, etag: nil, fetched_at: Time.now.to_i, failed: false, last_refresh: merged[:last_refresh] }
82
+ end
45
83
  cache.write(key(lang, namespace), entry)
46
84
  entry
47
85
  end
@@ -49,12 +49,15 @@ module I18nKeyless
49
49
  default_namespace: config.resolved_namespace,
50
50
  queue: config.queue,
51
51
  usage_enabled: config.usage?,
52
- logger: config.resolved_logger
52
+ logger: config.resolved_logger,
53
+ bundle: Bundle.load(config.bundle_path, logger: config.resolved_logger)
53
54
  )
54
55
  end
55
56
 
57
+ # @param bundle [Bundle, nil] the precompiled bundle (`bundle_path`): every
58
+ # (namespace, lang) it covers is seeded now, at boot, and never fetched
56
59
  def initialize(store:, api:, primary:, languages: [], default_namespace: DEFAULT_NAMESPACE, queue: nil,
57
- usage_enabled: true, logger: nil)
60
+ usage_enabled: true, logger: nil, bundle: nil)
58
61
  @store = store
59
62
  @api = api
60
63
  @primary = primary
@@ -63,14 +66,18 @@ module I18nKeyless
63
66
  @queue = queue.to_s.empty? ? nil : queue.to_s
64
67
  @usage_enabled = usage_enabled
65
68
  @logger = logger
69
+ @bundle = bundle
66
70
  @mutex = Mutex.new
67
71
  @loaded = {} # "lang|namespace" => lines loaded in this process
68
72
  @misses = {} # Miss#id => Miss
69
73
  @revalidate = {} # "lang|namespace" => [lang, namespace]
70
74
  @usage = {} # namespace => lookup key => YYYY-MM-DD
71
75
  @warned_no_languages = false
76
+ seed_bundle!
72
77
  end
73
78
 
79
+ attr_reader :bundle
80
+
74
81
  def usage_enabled?
75
82
  @usage_enabled
76
83
  end
@@ -121,18 +128,20 @@ module I18nKeyless
121
128
  key
122
129
  end
123
130
 
124
- # Loads the (lang, namespace) dictionary once per process.
131
+ # Loads the (lang, namespace) dictionary once per process. A pair the
132
+ # bundle covers was seeded at boot (`seed_bundle!`); after `I18n.reload!`
133
+ # it is seeded again here, from the file, never fetched.
125
134
  def ensure_loaded(lang, namespace)
126
135
  id = "#{lang}|#{namespace}"
127
136
  @mutex.synchronize do
128
137
  return @loaded[id] if @loaded.key?(id)
129
138
 
130
- entry = store.get(lang, namespace)
139
+ entry = seed_from_bundle(lang, namespace) || store.get(lang, namespace)
131
140
  if entry.nil?
132
141
  # First time ever for this language: the one blocking fetch.
133
142
  result = api.fetch_dictionary(lang, namespace, nil)
134
143
  entry = if result.ok
135
- store.put(lang, namespace, result.translations, result.etag)
144
+ store.put(lang, namespace, result.translations, result.etag, last_refresh: result.last_refresh)
136
145
  else
137
146
  store.put(lang, namespace, {}, nil, failed: true)
138
147
  end
@@ -271,17 +280,53 @@ module I18nKeyless
271
280
  result = api.fetch_dictionary(lang, namespace, entry && entry[:etag])
272
281
  unless result.ok
273
282
  # 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)
283
+ store.put(lang, namespace, entry ? entry[:translations] : {}, entry && entry[:etag], failed: true,
284
+ last_refresh: entry && entry[:last_refresh])
275
285
  return
276
286
  end
277
287
  if result.not_modified
278
288
  store.touch(lang, namespace)
279
289
  return
280
290
  end
281
- store.put(lang, namespace, result.translations, result.etag)
291
+ store.put(lang, namespace, result.translations, result.etag, last_refresh: result.last_refresh)
282
292
  refresh_loaded(lang, namespace, result.translations)
283
293
  end
284
294
 
295
+ # Boot (PROTOCOL.md 7.4, the node SDK model): every (namespace, lang) the
296
+ # manifest covers is read from its file into this process and into the
297
+ # cache, with the manifest's cursor, so the boot fetch of a covered pair
298
+ # never happens. The primary language is skipped: this gem never looks a
299
+ # primary dictionary up. An unreadable file is logged and left to the lazy
300
+ # path (fetched on first use), never fetched eagerly. Makes no request.
301
+ def seed_bundle!
302
+ return if bundle.nil?
303
+
304
+ @mutex.synchronize do
305
+ bundle.pairs.each do |namespace, lang|
306
+ next if lang == primary || !Locale.lang?(lang)
307
+
308
+ entry = seed_from_bundle(lang, namespace)
309
+ next if entry.nil?
310
+
311
+ id = "#{lang}|#{namespace}"
312
+ @revalidate[id] = [lang, namespace] if store.stale?(entry)
313
+ @loaded[id] = entry[:translations]
314
+ end
315
+ end
316
+ end
317
+
318
+ # The stored entry after the bundle's dictionary is applied to it (the
319
+ # cache wins only when it is newer), or nil when the bundle does not cover
320
+ # the pair or its file is unreadable. Called with the mutex held.
321
+ def seed_from_bundle(lang, namespace)
322
+ return nil if bundle.nil?
323
+
324
+ seed = bundle.seed(namespace, lang, logger: logger)
325
+ return nil if seed.nil?
326
+
327
+ store.seed(lang, namespace, seed)
328
+ end
329
+
285
330
  # Keeps this process's loaded lines current (a long-lived process keeps
286
331
  # the dictionaries between requests).
287
332
  def refresh_loaded(lang, namespace, lines)
@@ -4,5 +4,5 @@ module I18nKeyless
4
4
  # Sent as the `Version` header. The API reads its major to pick the wire
5
5
  # dialect: >= 3 means the v3 language codes ("zh-Hans", "cs"). One version
6
6
  # for every package and port of the monorepo (scripts/set-version.mjs).
7
- VERSION = "3.7.0"
7
+ VERSION = "3.8.0"
8
8
  end
data/lib/i18n_keyless.rb CHANGED
@@ -11,6 +11,7 @@ require_relative "i18n_keyless/config"
11
11
  require_relative "i18n_keyless/locale"
12
12
  require_relative "i18n_keyless/miss"
13
13
  require_relative "i18n_keyless/api_client"
14
+ require_relative "i18n_keyless/bundle"
14
15
  require_relative "i18n_keyless/dictionary_store"
15
16
  require_relative "i18n_keyless/translator"
16
17
  require_relative "i18n_keyless/backend"
data/llms.txt CHANGED
@@ -67,6 +67,7 @@ Rails-key rule (`I18nKeyless.keyless_key?`, `config.rails_key_pattern` = `/\A[a-
67
67
  | queue | I18N_KEYLESS_QUEUE | nil |
68
68
  | logger | | Rails.logger |
69
69
  | rails_key_pattern | | `/\A[a-z0-9_]+(\.[a-z0-9_]+)*\z/` (nil: every string is keyless) |
70
+ | bundle_path | I18N_KEYLESS_BUNDLE_PATH | nil: the directory of a precompiled bundle (manifest.json + <namespace>/<lang>.json, from the MCP `export_bundle` tool or GET /translate/bundle); every covered dictionary is read at boot, never fetched; a miss still POSTs |
70
71
 
71
72
  ## Locale mapping
72
73
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: i18n-keyless-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.7.0
4
+ version: 3.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Arnaud Ambroselli
@@ -54,6 +54,7 @@ files:
54
54
  - lib/i18n_keyless.rb
55
55
  - lib/i18n_keyless/api_client.rb
56
56
  - lib/i18n_keyless/backend.rb
57
+ - lib/i18n_keyless/bundle.rb
57
58
  - lib/i18n_keyless/config.rb
58
59
  - lib/i18n_keyless/dictionary_store.rb
59
60
  - lib/i18n_keyless/helper.rb