translation_diff 1.0.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.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +84 -0
  3. data/.github/workflows/release.yml +28 -0
  4. data/.gitignore +11 -0
  5. data/.rubocop.yml +39 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +723 -0
  8. data/Gemfile +61 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +158 -0
  11. data/Rakefile +41 -0
  12. data/data/languages/azure.json +285 -0
  13. data/data/languages/deepl.json +220 -0
  14. data/data/languages/google.json +399 -0
  15. data/data/languages/modernmt.json +413 -0
  16. data/docs/caching.md +185 -0
  17. data/docs/configuration.md +205 -0
  18. data/docs/contracts.md +187 -0
  19. data/docs/development.md +34 -0
  20. data/docs/errors.md +92 -0
  21. data/docs/how-it-works.md +145 -0
  22. data/docs/instrumentation.md +96 -0
  23. data/docs/languages.md +94 -0
  24. data/docs/providers.md +379 -0
  25. data/docs/sql-cache.md +366 -0
  26. data/lib/generators/translation_diff/install_generator.rb +15 -0
  27. data/lib/generators/translation_diff/templates/create_translation_diff_tables.rb.erb +24 -0
  28. data/lib/translation_diff/active_record/support.rb +34 -0
  29. data/lib/translation_diff/active_record.rb +3 -0
  30. data/lib/translation_diff/batch.rb +98 -0
  31. data/lib/translation_diff/call_preparation.rb +47 -0
  32. data/lib/translation_diff/capabilities.rb +9 -0
  33. data/lib/translation_diff/configuration/cache_guard_options.rb +39 -0
  34. data/lib/translation_diff/configuration/cache_ttl_option.rb +30 -0
  35. data/lib/translation_diff/configuration/option_table.rb +38 -0
  36. data/lib/translation_diff/configuration/provider_option_owners.rb +40 -0
  37. data/lib/translation_diff/configuration.rb +135 -0
  38. data/lib/translation_diff/context.rb +23 -0
  39. data/lib/translation_diff/dispatcher.rb +57 -0
  40. data/lib/translation_diff/document.rb +23 -0
  41. data/lib/translation_diff/errors.rb +44 -0
  42. data/lib/translation_diff/fragment.rb +33 -0
  43. data/lib/translation_diff/http_provider.rb +128 -0
  44. data/lib/translation_diff/instrumentation.rb +25 -0
  45. data/lib/translation_diff/languages/refresh.rb +70 -0
  46. data/lib/translation_diff/languages/set.rb +53 -0
  47. data/lib/translation_diff/languages.rb +30 -0
  48. data/lib/translation_diff/leaves.rb +22 -0
  49. data/lib/translation_diff/markup.rb +85 -0
  50. data/lib/translation_diff/passage.rb +149 -0
  51. data/lib/translation_diff/preview.rb +3 -0
  52. data/lib/translation_diff/previewer.rb +78 -0
  53. data/lib/translation_diff/provider.rb +91 -0
  54. data/lib/translation_diff/providers/amazon.rb +126 -0
  55. data/lib/translation_diff/providers/azure.rb +78 -0
  56. data/lib/translation_diff/providers/deepl.rb +88 -0
  57. data/lib/translation_diff/providers/google.rb +64 -0
  58. data/lib/translation_diff/providers/libretranslate.rb +65 -0
  59. data/lib/translation_diff/providers/modernmt.rb +74 -0
  60. data/lib/translation_diff/providers/null.rb +20 -0
  61. data/lib/translation_diff/providers.rb +69 -0
  62. data/lib/translation_diff/railtie.rb +12 -0
  63. data/lib/translation_diff/rate_limiters/active_record.rb +92 -0
  64. data/lib/translation_diff/rate_limiters/redis.rb +59 -0
  65. data/lib/translation_diff/rate_limiters.rb +9 -0
  66. data/lib/translation_diff/redaction.rb +45 -0
  67. data/lib/translation_diff/registry.rb +31 -0
  68. data/lib/translation_diff/segment.rb +32 -0
  69. data/lib/translation_diff/segmenters/pragmatic.rb +102 -0
  70. data/lib/translation_diff/segmenters/simple.rb +122 -0
  71. data/lib/translation_diff/segmenters.rb +4 -0
  72. data/lib/translation_diff/sentence_cache.rb +76 -0
  73. data/lib/translation_diff/stores/active_record.rb +106 -0
  74. data/lib/translation_diff/stores/memory.rb +34 -0
  75. data/lib/translation_diff/stores/redis.rb +49 -0
  76. data/lib/translation_diff/stores.rb +9 -0
  77. data/lib/translation_diff/tasks/translation_diff.rake +21 -0
  78. data/lib/translation_diff/translation/request.rb +8 -0
  79. data/lib/translation_diff/translation/response.rb +35 -0
  80. data/lib/translation_diff/translation/usage.rb +8 -0
  81. data/lib/translation_diff/translator.rb +103 -0
  82. data/lib/translation_diff/version.rb +3 -0
  83. data/lib/translation_diff.rb +93 -0
  84. data/translation_diff.gemspec +56 -0
  85. metadata +243 -0
data/CHANGELOG.md ADDED
@@ -0,0 +1,723 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [1.0.0] - 2026-09-24
8
+
9
+ First release under the name **translation_diff**. This gem was published as
10
+ `deepl_diff` through 2.2.0; everything here is relative to `deepl_diff` 2.2.0.
11
+
12
+ ### Breaking
13
+
14
+ - **One `TranslationDiff::RateLimitExceeded`, whichever limiter noticed it.**
15
+ The two limiters used to raise two same-named classes under their own
16
+ namespaces, so an application that rescued one and then switched
17
+ `rate_limiter` from `:redis` to `:active_record` quietly stopped catching
18
+ it. See [Errors](docs/errors.md).
19
+
20
+ - **Language validation is on by default.** `TranslationDiff.translate` now
21
+ refuses, before making a request, any source/target pair the shipped data
22
+ doesn't list for that provider -- raising
23
+ `TranslationDiff::UnsupportedLanguageError`. Data ships for DeepL, Google,
24
+ Azure and ModernMT only; Amazon and LibreTranslate ship none, and a
25
+ provider with no shipped data refuses nothing. Two escapes: pass
26
+ `assume_supported: true` for one call, or set
27
+ `config.validate_languages = false` globally. See
28
+ [Languages](docs/languages.md).
29
+
30
+ - **Cache keys change for any document containing a `pre` or `code`
31
+ element.** `pre` and `code` are now opaque (see Added, below), so what
32
+ gets sent to the provider changed, and what gets keyed changed with it;
33
+ an entry cached under the old behaviour keeps serving what the old
34
+ behaviour produced. Give the configuration a new `cache_namespace`, or
35
+ let `cache_ttl` lapse, to get every such document retranslated. See
36
+ [Caching](docs/caching.md#what-a-cache-key-is-made-of).
37
+
38
+ - **A runtime `cache_namespace` change now moves the rate limiter too.**
39
+ `cache_namespace` names the limiter's own bookkeeping namespace as well
40
+ as the cache store's; it used to move only the store, leaving the
41
+ limiter counting silently under the old namespace. `active_record_base`
42
+ behaves the same way and for the same reason -- the SQL-backed limiter
43
+ builds its model from that class just as the store does. See
44
+ [Configuration](docs/configuration.md#changing-configuration-at-runtime).
45
+
46
+ - **A provider with a blank `cache_key` now raises
47
+ `TranslationDiff::InvalidProviderError`, not
48
+ `TranslationDiff::Translator::Error`.** The two are siblings under
49
+ `TranslationDiff::Error`, not parent and child, so an application
50
+ rescuing the old class specifically stops catching this failure.
51
+ Rescue `TranslationDiff::Error` to catch both. See
52
+ [Errors](docs/errors.md).
53
+
54
+ - **The cache stores, rate limiters and ActiveRecord plumbing moved under
55
+ their registry's own namespace; nothing was left behind at the old
56
+ name.** `TranslationDiff::MemoryCacheStore`, `RedisCacheStore` and
57
+ `ActiveRecordCacheStore` are now `TranslationDiff::Stores::Memory`,
58
+ `Stores::Redis` and `Stores::ActiveRecord`; `RedisRateLimiter` and
59
+ `ActiveRecordRateLimiter` are now `TranslationDiff::RateLimiters::Redis`
60
+ and `RateLimiters::ActiveRecord`; `TranslationDiff::ActiveRecordSupport`
61
+ is now `TranslationDiff::ActiveRecord::Support`. `config.cache = :redis`
62
+ and the rest of the symbol-keyed configuration are unaffected -- only
63
+ the constant a name resolves to changed. See
64
+ [Caching](docs/caching.md) and [SQL cache](docs/sql-cache.md).
65
+
66
+ - Renamed the gem to `translation_diff` and the module to `TranslationDiff`.
67
+ - The provider (`config.provider`; see Removed below for what replaced the
68
+ old `TranslationDiff.api` accessor) must satisfy the five-method contract
69
+ (`translate`, optional `detect`, `max_request_size`, `max_batch_size`,
70
+ `cache_key`) instead of being a raw client such as `DeepL`.
71
+ - `translate` takes keyword arguments -- `translate(values, from:, to:, **options)`
72
+ -- and no longer accepts a positional options hash.
73
+ - Request-size and batch-size limits moved out of `Chunker` and into the
74
+ provider (`#max_request_size`, `#max_batch_size`); they are no longer
75
+ hard-coded to DeepL's numbers.
76
+ - **Every cache key changes.** The key now includes the provider's
77
+ `cache_key`, a digest of the provider options, and lowercased language
78
+ codes. Nothing cached by `deepl_diff` -- or by an earlier `translation_diff`
79
+ prerelease -- is reused. The next translation of every sentence is a cache
80
+ miss, once, everywhere.
81
+ - Dropped `punkt-segmenter` and, with it, its `unicode_utils` dependency.
82
+ Sentence boundaries are now produced by `config.segmenter`,
83
+ defaulting to `TranslationDiff::Segmenters::Pragmatic`, backed by the
84
+ [`pragmatic_segmenter`](https://github.com/diasks2/pragmatic_segmenter) gem
85
+ (MIT, zero dependencies of its own) -- so `ox` and `pragmatic_segmenter` are
86
+ now the gem's only two runtime dependencies. Measured against the Golden
87
+ Rules corpus -- the `context "Golden Rules" do` block of each of the 10
88
+ per-language spec files on `diasks2/pragmatic_segmenter`, 80 exemplars in
89
+ total; a sample of the same corpus is in
90
+ `test/translation_diff/golden_rules_test.rb` -- the default now scores
91
+ 76/80 against punkt's 38/80 and the old in-house segmenter's 47/80; the
92
+ gap is largest on languages with no letter case at all -- Arabic, Hindi,
93
+ Armenian, Greek -- which the in-house segmenter cannot reason about by
94
+ design.
95
+ - `config.segmenter_instance.split_offsets` now takes a second, optional
96
+ `language:` keyword argument. `pragmatic_segmenter` picks its rule set by
97
+ language and falls back to English rules without one, which can
98
+ mis-segment other languages (Russian abbreviations, for one); `from:` is
99
+ the only way a caller supplies it, and only when segmentation happens
100
+ before language detection would need to run. `Segmenters::Pragmatic`
101
+ normalises the code first -- downcased, region subtag dropped -- and falls
102
+ back to English for anything `pragmatic_segmenter` does not recognise
103
+ afterward. Without this, DeepL's own codes (`"RU"`, `"EN-GB"`) missed their
104
+ rule set entirely: `pragmatic_segmenter`'s lookup is case-sensitive and
105
+ region-blind, so this gem's own flagship provider was hitting the broken
106
+ path on every call.
107
+ - **`config.rate_limit` now actually enforces the threshold you configure.**
108
+ `TranslationDiff::RedisRateLimiter` called `Ratelimit#add(size)`, but that
109
+ gem's signature is `add(subject, count = 1)` -- so it recorded the hit
110
+ under a subject *named after the character count*, while `exceeded?`
111
+ checked a subject nothing ever incremented. The limiter never limited
112
+ anything, in every release back to `v1.0.2` (tagged 2023-02-16, roughly
113
+ three years ago). If you have `rate_limit` configured, your traffic has
114
+ never actually been throttled; on upgrading to 3.1.0 it will be, for the
115
+ first time, against a threshold you set once and have never seen fire. You
116
+ changed no configuration, but your throttling behaviour changes on
117
+ upgrade. Re-validate `rate_limit` and `rate_interval` before upgrading --
118
+ see "Rate limiting" in the README.
119
+ - `rate_interval` is silently clamped by the `ratelimit` gem's fixed bucket
120
+ span to roughly **5-600 seconds** (measured: `3600` becomes `600`, `1`
121
+ becomes `5`). Combined with a limiter that now actually fires, an interval
122
+ configured above 600 seconds is enforced over 600 seconds instead -- up to
123
+ six times more eager than the configuration reads. Keep `rate_interval`
124
+ within that range, or expect a tighter effective window than configured.
125
+ - Providers must inherit `TranslationDiff::Provider`. A duck-typed object is
126
+ no longer accepted: the base class supplies the transport, the
127
+ configuration check and the capability defaults, and a provider without
128
+ them is a provider that fails in the ways this library has already been
129
+ bitten by twice.
130
+ - `provider.translate(texts, from:, to:, **options)` is now
131
+ `provider.translate(request)`, taking a `Translation::Request` and
132
+ returning a `Translation::Response`. The response carries the detected
133
+ source language and, where the provider reports it, the characters billed.
134
+ - `max_request_size` and `max_batch_size` move from provider methods to
135
+ `Capabilities`.
136
+ - `TranslationDiff::Providers::Naming` is gone; the registry stamps
137
+ `cache_key` and `Provider` implements it.
138
+ - Every provider normalises language codes to the casing its own vendor
139
+ documents and accepts either casing from the caller: DeepL upper-cases, the
140
+ other five lower-case. A code carrying a script or region subtag
141
+ (`"zh-Hans"`, `"pt-BR"`) is passed through untouched. `Provider#language`
142
+ is the shared rule and `self.language_case` selects the casing, so a
143
+ provider of your own gets it by inheriting. Previously only Google and
144
+ DeepL normalised at all -- Amazon Translate rejected `"EN"`/`"RU"` and
145
+ LibreTranslate answered 400, on every call, for anyone who followed the
146
+ README's "switch provider by changing `config.provider`" with DeepL-style
147
+ codes -- and DeepL upper-cased subtags too, corrupting `"zh-Hans"`.
148
+ - `deepl-rb` and `google-cloud-translate-v2` are no longer used at all.
149
+ `faraday` and `faraday-retry` become runtime dependencies; `aws-sigv4` is
150
+ required lazily by the Amazon provider only.
151
+ - `config.deepl_host` is renamed `config.deepl_api_base`, matching the
152
+ `<provider>_api_base` name every other provider uses. There is no alias: a
153
+ configuration still setting `deepl_host` raises `NoMethodError` on
154
+ `TranslationDiff.configure`. Rename it.
155
+ - A provider returning the wrong number of translations now raises
156
+ `TranslationDiff::ResponseError`, not the error that used to live on
157
+ `Request`; a `rescue` written to catch a short response that way no longer
158
+ catches one. Both are `TranslationDiff::Error`, so a rescue of the base
159
+ class is unaffected.
160
+ - A provider returning a well-formed response that carries no translation for
161
+ one input -- Azure answers 200 for a batch where a single string failed --
162
+ also raises `TranslationDiff::ResponseError`, naming the position. It
163
+ previously reached the spacing step and died there as `NoMethodError`.
164
+ - **The translation pipeline is new code.** `TranslationDiff::Linearizer`,
165
+ `Spacing`, `Chunker`, `Tokenizer`, `Cache` and `Request` are gone as public
166
+ constants. What replaces them: `Document` and `Leaves` (walking the
167
+ caller's structure), `Passage`, `Fragment` and `Segment` (markup and prose,
168
+ cut into sentences), `Markup` (entity references and a `<` that opens no
169
+ tag), `Batch` (packing sentences into provider requests), `SentenceCache`
170
+ (the cache key, read and write) and `Translator` (the coordinator
171
+ `TranslationDiff.translate` and `Context#translate` now build). See
172
+ [How it works](docs/how-it-works.md). If you referenced any of the six by
173
+ name, that reference is now a `NameError`.
174
+ - `TranslationDiff::Request::Error` is now `TranslationDiff::Translator::Error`
175
+ and `TranslationDiff::Cache::Error` is now
176
+ `TranslationDiff::SentenceCache::Error`. There is no alias for either: this
177
+ gem has never been published under the name `translation_diff` with those
178
+ constants in it. `TranslationDiff::Chunker::Error` is gone with no
179
+ replacement -- a single sentence too large to send now raises
180
+ `TranslationDiff::Batch::Error`. All three remain
181
+ `TranslationDiff::Error`, so a rescue of the base class is unaffected.
182
+ - **`TranslationDiff.translate` and `Context#translate` raise `ArgumentError`
183
+ when `to:` is missing or `nil`.** The keyword still defaults to `nil` in the
184
+ signature, and the message names it. Previously a `nil` target compared
185
+ equal to a `nil` source, the call short-circuited as "same language" and
186
+ your values came back untranslated, silently. If you have a caller reading
187
+ `to:` out of a configuration that can be blank, it has been a no-op and will
188
+ now raise.
189
+ - **The `cache` instrumentation event fires once per `translate` call, not
190
+ once per chunk.** The cache is now consulted for every sentence in one
191
+ `read_multi` before anything is batched. `hits` and `misses` still sum to
192
+ the same totals over a call, so a counter that adds them up is unaffected;
193
+ a counter of *events*, or a histogram of per-chunk hit ratios, will see the
194
+ cardinality drop. `request` and `rate_limit` still fire once per batch sent.
195
+ - **Two more cache keys move, beyond the entity and `<` fixes below.** A
196
+ sentence padded with Unicode whitespace -- a non-breaking space, say -- now
197
+ keys as the bare sentence: the pipeline uses one Unicode-aware definition
198
+ of padding everywhere, where the key used to be built with ASCII `strip`,
199
+ which leaves a `U+00A0` in place. And the whole document key format is
200
+ otherwise unmoved: it is pinned by test against recorded values, and every
201
+ other input in the corpus this rewrite was judged against produces the same
202
+ key it did before.
203
+
204
+ ### Removed
205
+
206
+ - The four module-level accessors (`TranslationDiff.api`, `.cache_store`,
207
+ `.segmenter`, `.rate_limiter`) and `TranslationDiff::CACHE_NAMESPACE`.
208
+ Every setting now lives on `TranslationDiff::Configuration`, reached
209
+ through `TranslationDiff.config` or `TranslationDiff.configure`.
210
+
211
+ ### Added
212
+
213
+ - **Every event from one `translate` call now shares a `call_id`.** Generated
214
+ once per call, opaque, and never derived from the text, it lands in
215
+ `translate`, `cache`, `request`, `rate_limit`, `usage` and `cache_error`
216
+ alike. Before it, a subscriber receiving `cache` or `request` events had no
217
+ way to tell which `translate` call they belonged to, short of tagging
218
+ `Thread.current` itself -- a workaround that breaks the moment two
219
+ translations share a thread. See
220
+ [Instrumentation](docs/instrumentation.md).
221
+
222
+ - **`translate` now carries `characters`: the total this call considered,
223
+ hit or miss.** A call served entirely from cache never fires a `request`
224
+ event and used to report nothing about its size; it now reports a number
225
+ there instead. `request`'s own `characters` keeps its narrower meaning --
226
+ what one batch actually sent -- so the two fields share a name but not an
227
+ event: summing the wrong one produces a wrong bill. See
228
+ [Instrumentation](docs/instrumentation.md).
229
+
230
+ - **`TranslationDiff.preview` predicts a `translate` call without making
231
+ it.** It answers how many sentences a call would send, how many the cache
232
+ already has, and how many characters that is -- without calling a
233
+ provider and without writing anything. A preview never pays for language
234
+ detection, so `from:` is required wherever there is anything to preview;
235
+ leaving it unset raises `TranslationDiff::Previewer::Error`. Built for an
236
+ editor that wants to show "this edit will send 1 sentence" before the
237
+ author saves. See
238
+ [Caching](docs/caching.md#asking-what-a-call-would-do-without-doing-it).
239
+
240
+ - **`pre` and `code` are no longer sent for translation, and changing a
241
+ configuration option at runtime now rebuilds only what it actually
242
+ feeds.** `pre` and `code` join `script` and `style` in
243
+ `config.opaque_elements`, the set `TranslationDiff::Passage` never treats
244
+ as prose -- default `%i[script style pre code]`, widen or narrow it as
245
+ needed -- after a live `<pre><code>` block came back from Google with
246
+ `jq '.meters'` mangled into `jq '.metros'`, because nothing told the
247
+ pipeline that code holds language, not prose. Separately, `provider` and
248
+ the cache, pool, rate and segmenter options each rebuild only their own
249
+ collaborator now, instead of nothing: before this, switching `provider`
250
+ at runtime meant `TranslationDiff.reset!` and reconfiguring from scratch,
251
+ discarding a Redis pool that had no reason to go. See
252
+ [Configuration](docs/configuration.md#changing-configuration-at-runtime).
253
+ Two upgrade consequences of this are filed under Breaking, above:
254
+ cache keys changing for a document containing `pre` or `code`, and a
255
+ runtime `cache_namespace` change now moving the rate limiter too.
256
+
257
+ - A `usage` instrumentation event, firing once per provider request, beside
258
+ `translate`, `cache`, `request` and `rate_limit`. Its payload carries
259
+ `provider`, `characters` (what this library sent, counted locally),
260
+ `billed_characters` (what the provider said it charged, or `nil`),
261
+ `reported` (whether the provider reports billing **at all** -- not that
262
+ this response was billed) and `model`. Summing `billed_characters` across
263
+ providers without checking `reported` first produces a total that is
264
+ quietly too low: only three of the six built-in providers report billing
265
+ at all. See [Instrumentation](docs/instrumentation.md).
266
+
267
+ - **A SQL-backed cache store and rate limiter, for an application that runs
268
+ Postgres or MySQL and does not want Redis for this alone.**
269
+ `TranslationDiff::ActiveRecordCacheStore` (`config.cache =
270
+ :active_record`) and `TranslationDiff::ActiveRecordRateLimiter`
271
+ (`config.rate_limiter = :active_record`) cache translations and throttle
272
+ requests in the application's own database, exercised in CI against
273
+ Postgres, MySQL and SQLite. Nothing on this branch is breaking: both
274
+ are opt-in, the default resolution of `cache` and `rate_limiter` is
275
+ untouched, and an application with `redis_url` set keeps getting Redis
276
+ exactly as before. `rails generate translation_diff:install` writes the
277
+ migration for both tables, idempotently -- every `create_table` and
278
+ `add_index` in it carries `if_not_exists: true`; for anyone not on Rails,
279
+ its body is in [SQL cache](docs/sql-cache.md) verbatim -- **the gem
280
+ itself never runs DDL.** `rake translation_diff:prune` ships inside the
281
+ gem: a `Railtie` wires it into a Rails application's own rake tasks
282
+ automatically, enhanced with `:environment` so it prunes that
283
+ application's own configuration; a non-Rails application loads the task
284
+ file itself and
285
+ must configure `TranslationDiff` before running it, since the task gets
286
+ no `:environment`-equivalent there. ActiveRecord 7.1 or newer is
287
+ required when either is used, refused by name at build time rather than
288
+ failing inside a query, and `activerecord` is never a dependency of this
289
+ gem -- it is required lazily on first use, the same way `redis` already
290
+ is. Four new configuration options: `cache_table_name`,
291
+ `rate_limit_table_name`, `active_record_base` and
292
+ `cache_prune_probability`; the last, along with `cache_namespace`, is
293
+ now validated at `configure` time -- a `cache_prune_probability` that
294
+ will not coerce to a number, or a `cache_namespace` over 64 characters,
295
+ is refused before either reaches a query. See
296
+ [SQL cache](docs/sql-cache.md).
297
+ - **The SQL cache store's write joins the caller's transaction, and its
298
+ errors are redacted, not silent.** A rollback in the caller's transaction
299
+ discards translations `ActiveRecordCacheStore` already wrote -- the
300
+ largest behavioural difference from `RedisCacheStore`, which is never
301
+ inside anyone's transaction. A failed write itself runs in its own
302
+ savepoint, so it no longer aborts a transaction it does not own, and the
303
+ `TranslationDiff::Error` it raises carries the adapter's error class, not
304
+ the row. `upsert_all` inlines values rather than binding them, though, so
305
+ a written translation still appears verbatim in the host application's
306
+ own ActiveRecord log at `debug` -- unrelated to this gem's own `logger`
307
+ option, which never prints content. The redaction now covers every
308
+ `ActiveRecord::ActiveRecordError` the write path can raise, not only a
309
+ statement failure -- an application whose GET requests are routed to a
310
+ read replica by `ActiveRecord::Middleware::DatabaseSelector` gets
311
+ `ActiveRecord::ReadOnlyError` there instead, and it is redacted the same
312
+ way. Pointing `active_record_base` at a different database or a
313
+ writer-role class does not exempt this store from that routing decision;
314
+ see [Rails replica routing](docs/sql-cache.md#rails-replica-routing) for
315
+ what does. See
316
+ [Transactions](docs/sql-cache.md#transactions) and
317
+ [What ends up in your log](docs/sql-cache.md#what-ends-up-in-your-log).
318
+ - **A failing cache write no longer loses the translation it was caching --
319
+ for every store, not only the SQL one.** `Translator#fill` now rescues a
320
+ store failure, logs it, fires a new `cache_error` instrumentation event
321
+ (`provider` and the error's class, never the text), and returns the
322
+ translation regardless: the cache is an optimisation on top of a
323
+ translation already paid for at the provider, and losing the write should
324
+ never mean losing that. See
325
+ [The three write paths fail differently](docs/caching.md#the-three-write-paths-fail-differently)
326
+ and [Instrumentation](docs/instrumentation.md). The rate limiter refuses
327
+ rather than degrades under the same routing -- it runs before the provider
328
+ is called, so nothing has been paid for yet -- but it too now raises a
329
+ redacted `TranslationDiff::Error` rather than a raw `ActiveRecord` one.
330
+ See [Rails replica routing](docs/sql-cache.md#rails-replica-routing).
331
+ - **MySQL: the migration's `translation` column now carries
332
+ `limit: 16_777_215`, giving it `MEDIUMTEXT` instead of `TEXT`.** `TEXT`
333
+ caps at 65,535 bytes on MySQL; a single sentence over that size failed
334
+ the whole batch it rode in with. This is a no-op on Postgres and
335
+ SQLite -- neither has a length ceiling on `text` to begin with, and
336
+ nothing else about the schema changes for either. **An installation that
337
+ already ran this migration on MySQL needs one statement, once:**
338
+ `ALTER TABLE translation_diff_translations MODIFY translation MEDIUMTEXT NOT NULL;`
339
+ -- this gem never runs DDL, so nothing does this for you. See
340
+ [The migration](docs/sql-cache.md#the-migration).
341
+ - **`config.rate_limiter` no longer requires `config.rate_limit`.** Setting
342
+ the limiter alone used to pass `nil` as the threshold, overriding the
343
+ keyword default and crashing every check with
344
+ `ArgumentError: comparison of Integer with nil failed`. An unset
345
+ `rate_limit` now falls back to the limiter's own default -- 8,000
346
+ characters per `rate_interval`, the same for both shipped limiters. See
347
+ [Configuration options](docs/configuration.md#configuration-options).
348
+ - **A refused request now says what it hit.** `RateLimitExceeded`
349
+ carries a message naming the namespace, the threshold and the
350
+ interval (`"rate limit reached for translation-diff: 8000 characters per
351
+ 60 seconds"`) -- never the content that tripped it. See
352
+ [Errors](docs/errors.md).
353
+ - **`cache_ttl` of `0` or less now means never expires, and `nil` is
354
+ reachable through `TranslationDiff.configure`.** Previously `nil` was
355
+ documented as meaningful but unreachable through the public
356
+ configuration path, and `0` wrote a row whose `expires_at` was already in
357
+ the past -- a cache entry that could never hit. Both now fold to the same
358
+ `nil` `expires_at`. See
359
+ [`cache_ttl` becomes `expires_at`](docs/sql-cache.md#cache_ttl-becomes-expires_at).
360
+ - **`ActiveRecordRateLimiter`'s sliding window is conservative, not
361
+ exact.** It sums the oldest bucket touching the trailing `rate_interval`
362
+ seconds in full, even though that bucket is only ever partially inside
363
+ the window, so the window actually enforced is `rate_interval` to
364
+ `rate_interval + rate_interval / 12` seconds -- slightly stricter than
365
+ configured, never looser. See
366
+ [The rate limiter](docs/sql-cache.md#the-rate-limiter).
367
+ - `write_multi(pairs)` joins the cache store contract, as an optional
368
+ method: a store that implements it gets one call carrying a whole batch
369
+ of sentences instead of one call per sentence; a store that does not is
370
+ still called once per sentence, exactly as before this method existed --
371
+ a custom cache store written against the older contract is unaffected.
372
+ All three shipped stores implement it now: `MemoryCacheStore` and
373
+ `RedisCacheStore` gain it here too, alongside `ActiveRecordCacheStore`.
374
+ The two batching paths fail differently from the per-key one and from
375
+ each other -- see
376
+ [The three write paths fail differently](docs/caching.md#the-three-write-paths-fail-differently).
377
+
378
+ - Five more providers, all on the same base class and the same transport:
379
+ `:google` (Cloud Translation v2), `:azure` (Azure AI Translator v3.0),
380
+ `:modernmt`, `:libretranslate` and `:amazon` (Amazon Translate). Each
381
+ declares its own options, its own limits, and what it can actually do --
382
+ see the provider table in the README, which is written from the same
383
+ capabilities the library reads at runtime.
384
+ - A Google provider: `config.provider = :google` translates through Cloud
385
+ Translation v2 (Basic) over HTTP directly, with no Google gem installed. It
386
+ declares `google_api_key`, `google_project_id` and `google_api_base`; an API
387
+ key alone is enough. With no key configured it reads `TRANSLATE_KEY` and
388
+ then `GOOGLE_CLOUD_KEY`, and `google_project_id` falls back to
389
+ `TRANSLATE_PROJECT` -- the variables `google-cloud-translate-v2` used to
390
+ read on your behalf. Application default credentials are **not** supported:
391
+ that path lived in the gem that is gone, and an application relying on ADC
392
+ must now configure an API key. The provider asks for `format: :html`, which
393
+ the tokenizer's output requires -- a `notranslate` span is handed over with
394
+ its tags -- and downcases bare language codes so a configuration written for
395
+ DeepL (`"EN"`) keeps working, leaving subtagged codes such as `"zh-Hans"`
396
+ alone.
397
+ - Environment-variable credential fallbacks, read by this library now that the
398
+ vendor SDKs that read them are gone: `DEEPL_AUTH_KEY` for `deepl_api_key`,
399
+ `TRANSLATE_KEY` then `GOOGLE_CLOUD_KEY` for `google_api_key`, and
400
+ `TRANSLATE_PROJECT` for `google_project_id`. Each is read on use rather than
401
+ at load, so setting one after requiring the gem still works, and an
402
+ explicitly configured value always wins. The Amazon provider deliberately
403
+ has no environment fallback: `aws-sigv4` is handed explicit credentials and
404
+ this library does not implement the AWS credential chain.
405
+ - A provider declares a default for one of its options by writing
406
+ `key => default` in `configuration_options` instead of a bare symbol; a
407
+ callable default is evaluated on every read.
408
+ - `TranslationDiff::Configuration`, a declarative settings object built
409
+ through the `option(key, default)` macro. Options fall back to their
410
+ default until assigned, treat a blank string as unset, and support a
411
+ callable default (evaluated on every read, not at load time). `#copy`
412
+ carries option values into an isolated configuration without carrying
413
+ already-built collaborators along with them.
414
+ - `TranslationDiff::Registry`, a small `name -> class` map backing every
415
+ pluggable extension point: a registered class need only answer
416
+ `build(config)`. Three registries exist: `TranslationDiff::Providers`,
417
+ `TranslationDiff::Stores`, and `TranslationDiff::Segmenters.registry`.
418
+ - `TranslationDiff::Context`, returned by `TranslationDiff.context`: an
419
+ isolated configuration scope with the same `#translate` entry point as the
420
+ `TranslationDiff` module, for multi-tenant and per-request configuration
421
+ that never touches the global configuration.
422
+ - Instrumentation and logging: `config.instrumenter` (anything satisfying
423
+ `ActiveSupport::Notifications`' interface) receives `translate`, `cache`,
424
+ `request` and `rate_limit` events, each named `<name>.translation_diff`
425
+ and carrying counts, language codes and provider names only -- never the
426
+ text being translated, its translation, or a credential. `config.logger`
427
+ receives a `debug` line per provider resolution, naming the provider
428
+ class, and is held to the same guarantee: it is never handed to
429
+ `deepl-rb`, which logs the whole request -- auth header and payload -- at
430
+ DEBUG when given a logger of its own. See "Instrumentation and logging" in
431
+ the README for how to opt into that logging deliberately. Both are no-ops,
432
+ at no extra cost, when left unset.
433
+ - `TranslationDiff::Providers.register` raises when a provider declares a
434
+ `configuration_options` name another provider already declared. The two
435
+ would otherwise share one accessor on `TranslationDiff::Configuration`, so
436
+ a credential set for one service would be sent to the other. The same
437
+ provider redeclaring its own options stays silent, so a double `require`
438
+ and Rails development reloading keep working.
439
+ - `TranslationDiff::MemoryCacheStore`, a bounded, in-process LRU, and the
440
+ new default cache store when `redis_url` is not configured -- so the
441
+ library works before any infrastructure does.
442
+ - `TranslationDiff::Segmenters::Pragmatic`, the default sentence segmenter,
443
+ wrapping `pragmatic_segmenter`'s per-language rule sets. Before
444
+ segmenting, it shadows every single newline (one with no adjoining
445
+ newline) to a space in a copy of the text -- `pragmatic_segmenter`
446
+ otherwise treats almost any single newline as a sentence boundary
447
+ candidate even with no punctuation at all, a false split that HTML text
448
+ nodes routinely trigger via incidental source-formatting newlines -- then
449
+ segments the shadow and recovers offsets against it, so the *original*
450
+ text, newline included, reaches the output untouched. Blank-line runs
451
+ (real paragraph breaks) are left alone. This costs one Golden Rules point
452
+ (77 -> 76: a bare list of items separated by single newlines, with no
453
+ punctuation, now segments as one unit instead of three) -- a deliberate
454
+ trade, since that shape does not arise in this gem's actual input. It
455
+ recovers offsets from the strings `pragmatic_segmenter` returns by locating
456
+ each one in the shadow, in order, and keeps every offset it locates; the
457
+ first sentence it cannot locate (`pragmatic_segmenter`'s cleaner also
458
+ collapses runs of three or more spaces and respaces abbreviations such as
459
+ `"Ph.D."`, among other things it rewrites) ends the search -- but the
460
+ boundary at the end of the last sentence it did locate is not discarded
461
+ with the rest, since it was matched character for character too; only the
462
+ genuinely unrecoverable remainder becomes one final unit. This is a
463
+ coarsening, not a failure -- every offset it ever emits has been verified,
464
+ so the cache unit is simply larger, never wrong. It never guesses an
465
+ offset it did not verify. `TranslationDiff::Segmenters::Pragmatic::Error`
466
+ exists for the one case that would still be silent corruption -- offsets
467
+ it computed itself violating their own postcondition (start at 0, strictly
468
+ increase, stay within the text) -- not for ordinary `pragmatic_segmenter`
469
+ rewriting.
470
+ - `TranslationDiff::Segmenters::Simple` (formerly `TranslationDiff::Segmenter`,
471
+ renamed and moved to its own namespace alongside `Pragmatic`), the
472
+ zero-dependency, in-house sentence segmenter this gem shipped with before
473
+ `pragmatic_segmenter` became the default. It is deliberately conservative:
474
+ it splits only on a handful of strong signals (a terminator followed by
475
+ whitespace and an uppercase or CJK next character, none of the guard
476
+ conditions -- a known abbreviation, an initial, digits on both sides, or a
477
+ URL/email -- matching) so that a missed sentence boundary, which only
478
+ costs a cache hit, is always preferred over a false one, which sends half
479
+ a sentence to the translation provider. Its central rule has no meaning in
480
+ scripts without letter case, which is why it is no longer the default; it
481
+ stays available for callers who want no extra dependency and translate
482
+ only from cased scripts. `config.segmenter` is swappable the same
483
+ way `config.provider` and `.cache` are.
484
+ - `TranslationDiff::Providers::DeepL` and `TranslationDiff::Providers::Null`.
485
+ - `test/support/provider_contract.rb`, the executable form of the provider
486
+ contract; any third-party provider can include it to verify it behaves as
487
+ documented.
488
+ - `detect` is now its own provider method. Previously
489
+ `api.translate(sample, nil, to)` returned an object of a different shape
490
+ than the same call with `from:` given -- one method, two return types,
491
+ told apart by an argument's value. A provider with no `detect` makes
492
+ `from:` required and raises a clear error when it is missing, instead of
493
+ `NoMethodError`.
494
+ - Four new providers: `TranslationDiff::Providers::Azure` (`:azure`),
495
+ `TranslationDiff::Providers::ModernMT` (`:modernmt`),
496
+ `TranslationDiff::Providers::LibreTranslate` (`:libretranslate`), and
497
+ `TranslationDiff::Providers::Amazon` (`:amazon`), Amazon Translate, signed
498
+ with `aws-sigv4` rather than headed. Every provider's limits, HTML
499
+ support, `notranslate` handling, detection and billing reporting are
500
+ declared through `Capabilities` and measured against the vendor rather
501
+ than assumed -- see the provider table in the README.
502
+ - An error hierarchy for everything a provider's transport can do wrong:
503
+ `TranslationDiff::ConfigurationError`, `TranslationDiff::ProviderError`
504
+ (and its `AuthenticationError`, `QuotaExceededError`,
505
+ `InvalidRequestError`, `ServiceError` and `RateLimitError` subclasses),
506
+ `TranslationDiff::TransportError`, `TranslationDiff::ResponseError` and
507
+ `TranslationDiff::InvalidProviderError`, all under `TranslationDiff::Error`.
508
+ - `config.open_timeout`, `config.timeout` and `config.max_retries`, read by
509
+ every HTTP provider's connection and retry policy.
510
+
511
+ ### Changed
512
+
513
+ - `Adapters` renamed to `Providers`, and providers are now registered by
514
+ name through `TranslationDiff::Providers.register` rather than assigned
515
+ directly to a module accessor. A provider built through the registry is
516
+ stamped with its registered name and needs no `cache_key` of its own; an
517
+ object assigned straight to `config.provider`, bypassing the registry,
518
+ must define `cache_key` itself or every call through it raises -- it has
519
+ no registered name to fall back on.
520
+ - The cache key's options digest uses a canonical, version-stable encoding
521
+ instead of `Hash#inspect`, which renders symbol-keyed hashes differently
522
+ across Ruby 3.2-4.0.
523
+
524
+ ### Fixed
525
+
526
+ - **A `notranslate` span nested inside an opaque element (`pre`, `code`,
527
+ `script` or `style`) no longer silences every sentence after it.**
528
+ Closing the protected span used to leave the scanner's own opacity depth
529
+ one too high, so nothing past it was ever handed to the segmenter again.
530
+ Found while adding the `pre`/`code` opaque elements above, and fixed the
531
+ same way for all four. See [How it works](docs/how-it-works.md#html).
532
+ - **Google and DeepL translations in HTML mode no longer come back
533
+ double-escaped.** Both vendors return entity-escaped text -- an
534
+ apostrophe as `&#39;`, a quote as `&quot;`, an ampersand as `&amp;` -- and
535
+ the pipeline decoded entities on the way in but never on the way out, so
536
+ the renderer escaped the vendor's own `&` a second time and a reader saw
537
+ `didn&#39;t` on the page. English is full of apostrophes, so in practice
538
+ every Google or DeepL translation into English was affected somewhere.
539
+ `TranslationDiff::Translation::Response.build` now decodes a provider's
540
+ reply the same way it already decoded the source, symmetrically, for
541
+ every provider -- named entities, and both the decimal (`&#39;`) and hex
542
+ (`&#x27;`) numeric forms, are decoded; an entity neither decoder
543
+ recognizes is left exactly as it arrived. See [How it
544
+ works](docs/how-it-works.md).
545
+ - **Behaviour change: a literal `<` in a source sentence now renders as
546
+ `&lt;`.** Decoding the fix above exposed a second bug: a provider's own
547
+ `&lt;` now decoded to a bare `<`, and a bare `<` in front of a letter
548
+ reads as an opening tag -- a provider could inject markup into the
549
+ rendered document. A translated `<` that is not shaped like a tag is now
550
+ escaped on render instead. `if a < b then stop.` used to come back with
551
+ the bare `<` exactly as written; it now comes back
552
+ `if a &lt; b then stop.`, the correct HTML encoding of that character and
553
+ identical once a browser renders it -- but visible to anything comparing
554
+ output byte-for-byte against an earlier release. `>` is untouched: a
555
+ stray `>` never opens anything a parser would honour. See [How it
556
+ works](docs/how-it-works.md#html).
557
+ - **A warm cache keeps serving the corrupted text after you upgrade.** A
558
+ cache entry's key is derived from the source sentence, not from the value
559
+ stored under it, so an entry written before this fix is served exactly as
560
+ it was written until it expires -- upgrading alone does not clear it.
561
+ Give the configuration a new `cache_namespace`, or let `cache_ttl` lapse,
562
+ to force every sentence to be retranslated under the fix. See
563
+ [Caching](docs/caching.md).
564
+
565
+ - `notranslate` works with DeepL. The provider sent no `tag_handling`, and
566
+ per DeepL's documentation "tags are treated as regular text" without it,
567
+ so a span the tokenizer had marked as protected was translated anyway.
568
+ The provider now sends `tag_handling: :html` and
569
+ `tag_handling_version: "v2"`, both overridable per call. This has been
570
+ broken since the gem moved from Google to DeepL: `tag_handling` appears
571
+ nowhere in the repository's history before this change. It failed
572
+ quietly, because DeepL leaves the tags themselves alone either way and
573
+ only the protected content came back changed.
574
+ - Comments, doctypes and CDATA sections survive a translation. `Tokenizer`
575
+ declared no handler for those three Ox SAX events, so the bytes each one
576
+ covered belonged to no token: a leading `<!-- ... -->` or `<!DOCTYPE html>`
577
+ disappeared from the result outright, and a comment in the middle of a
578
+ sentence leaked its `<!` into the surrounding text and handed the comment's
579
+ own contents to the translation provider as prose -- both paying for the
580
+ characters and risking an internal note coming back translated in place of
581
+ the comment.
582
+ - `TranslationDiff::RedisRateLimiter` requires `ratelimit` lazily, on the
583
+ first check, and raises `TranslationDiff::Error` naming the gem to add
584
+ when it is missing. Previously the bare constant surfaced a raw
585
+ `NameError` instead of the message the Redis path already raises.
586
+ - DeepL's batch limit was declared as 300 sentences per request; DeepL
587
+ documents 50. The request-size limit (1,700 escaped characters) was
588
+ already correct and is unchanged.
589
+ - **An entity reference no longer reaches the provider raw.** `Salt &amp;
590
+ pepper.` was sent to the provider as the six characters `&amp;`, so the
591
+ provider translated the entity's spelling as if it were words -- and was
592
+ billed for it. It is now sent as `Salt & pepper.`, the text the document
593
+ actually says, and re-encoded on the way out. Named entities, `&#38;` and
594
+ `&#x26;` alike are decoded; anything neither decoder knows is left as it
595
+ arrived.
596
+ - **A bare `<` no longer swallows the rest of the sentence.** `if a < b then
597
+ stop. Fine.` was parsed by `ox` as prose followed by an unclosed tag, so
598
+ only `if a` was ever sent for translation and everything after the `<` came
599
+ back untranslated. A `<` that no element name, closing name, declaration or
600
+ instruction follows is now escaped before parsing and restored after, so the
601
+ whole sentence is translated. `5 < 6 and 7 > 6. True.` was sent as three
602
+ fragments and is now sent as two sentences.
603
+ - **These two fixes move the cache key for the documents they affect.** A
604
+ document containing an entity reference, or a `<` that opens no tag, will
605
+ miss the cache once and be re-translated. That is the point: what was cached
606
+ for it was translated from the wrong text.
607
+ - **A known remaining limit: `&lt;` still reaches a provider undecoded**, and
608
+ `a <b then stop. Fine.` still loses everything after the `<`. Both fall out
609
+ of escaping a bare `<` as `&lt;` to work around `ox` rather than replacing
610
+ it with a lexer of this gem's own, which is out of scope here. `<b` cannot
611
+ be told apart from a tag without one. Every other entity is decoded.
612
+
613
+ ### Security
614
+
615
+ - `Configuration#inspect` and `Provider#inspect` print `[FILTERED]` in place
616
+ of every credential option's value, instead of the credential itself. The
617
+ filtered set is derived, not hand-maintained: option names matching a
618
+ sensitive pattern, plus whatever each registered provider declares in
619
+ `sensitive_options`. A non-credential option -- a base URL, a region,
620
+ `cache_namespace` -- stays visible in full. A URL-valued option that
621
+ carries a credential in its userinfo, `redis_url` included, has just that
622
+ part redacted (`rediss://default:[FILTERED]@cache.example.upstash.io:6379`);
623
+ the scheme, host, port and path stay visible. See
624
+ [Providers](docs/providers.md).
625
+
626
+ ## deepl_diff
627
+
628
+ The releases below were published under the gem's former name, `deepl_diff`.
629
+
630
+ ## [2.2.0] - 2026-09-07
631
+
632
+ Message-only release under the `deepl_diff` name. No code changes.
633
+
634
+ ### Added
635
+
636
+ - `spec.post_install_message`, pointing installers at `translation_diff`,
637
+ the gem's new name. `deepl_diff` 2.2.0 is the last release under that
638
+ name and keeps working for anyone pinned to it.
639
+
640
+ ## [2.1.0] - 2026-09-07
641
+
642
+ Six bugs found while auditing the library, each reproduced against the real
643
+ behaviour and covered by a regression test.
644
+
645
+ ### Added
646
+
647
+ - `DeepLDiff::Request::Error`, raised when the adapter returns fewer
648
+ translations than requested, instead of letting `nil` reach `Spacing` and
649
+ fail two layers away as `NoMethodError`.
650
+
651
+ ### Fixed
652
+
653
+ - The options hash passed to `Request` was consumed with `Hash#delete`, so a
654
+ second call with the same hash -- or any frozen hash -- broke.
655
+ - The chunker compared raw `String#size` against a limit meant for the
656
+ escaped request, so Cyrillic and other non-Latin text could run several
657
+ times over the actual request-size limit.
658
+ - A detected source language (a `String`) was compared against `:to`
659
+ (usually a `Symbol`) with `==`, so the same-language short circuit never
660
+ fired and text was translated into its own language.
661
+ - Non-string scalars (`nil`, `Integer`, `Symbol`) raised instead of passing
662
+ through untouched.
663
+ - The chunk count limit was off by one.
664
+
665
+ ### Upgrade note
666
+
667
+ The chunker fix changes how non-Latin text is split into requests: chunks
668
+ are smaller, so there are more requests for the same number of characters
669
+ billed. A `MAX_CHUNK_SIZE` tuned empirically against Cyrillic now produces a
670
+ very different payload than it did.
671
+
672
+ ## [2.0.0] - 2026-09-07
673
+
674
+ ### Breaking
675
+
676
+ - Six unused or duck-typed runtime dependencies were dropped:
677
+ `dry-initializer`, `connection_pool`, `redis`, `deepl-rb`,
678
+ `redis-namespace`, `ratelimit`. Applications that relied on this gem to
679
+ install them must now depend on them directly.
680
+ - `RedisRateLimiter` takes `threshold:` and `interval:` as keyword
681
+ arguments.
682
+
683
+ ### Fixed
684
+
685
+ - `RedisRateLimiter` declared `threshold` and `interval` as positional
686
+ parameters, so the keyword call the README already documented silently
687
+ discarded them and fell back to the defaults (8000 / 60).
688
+
689
+ ## [1.1.1] - 2026-09-07
690
+
691
+ No changes to `lib/`.
692
+
693
+ ### Changed
694
+
695
+ - The test suite moved from RSpec to Minitest.
696
+ - The gem is published from CI through RubyGems trusted publishing instead
697
+ of a personal API key.
698
+
699
+ ## [1.1.0] - 2026-09-07
700
+
701
+ ### Changed
702
+
703
+ - Every runtime dependency is now explicitly versioned; the gem requires
704
+ Ruby >= 3.2.
705
+ - CI moved from Travis to GitHub Actions, running against Ruby 3.2, 3.3,
706
+ 3.4 and 4.0, plus a RuboCop job.
707
+
708
+ ### Fixed
709
+
710
+ - `cgi/escape`, `digest/md5`, `forwardable` and `stringio` are now required
711
+ explicitly instead of relying on them being pulled in transitively.
712
+ - DeepL options were passed via `**options` into a method that takes a
713
+ positional hash, so an empty options hash passed nothing.
714
+ - `Chunker::Chunk` members `values`/`size` were renamed (eventually to
715
+ `texts`/`escaped_size`) because they shadowed `Struct#values` and
716
+ `Struct#size`.
717
+
718
+ [1.0.0]: https://github.com/Halvanhelv/translation_diff/compare/v2.2.0...v1.0.0
719
+ [2.2.0]: https://github.com/Halvanhelv/deepl_diff/compare/v2.1.0...v2.2.0
720
+ [2.1.0]: https://github.com/Halvanhelv/deepl_diff/compare/v2.0.0...v2.1.0
721
+ [2.0.0]: https://github.com/Halvanhelv/deepl_diff/compare/v1.1.1...v2.0.0
722
+ [1.1.1]: https://github.com/Halvanhelv/deepl_diff/compare/v1.1.0...v1.1.1
723
+ [1.1.0]: https://github.com/Halvanhelv/deepl_diff/compare/v1.0.1...v1.1.0