i18n_proofreading 1.0.1 → 1.1.1

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: 1018fa5f4f5be802e224bfc1a8bf5e93b1cff5c9122e854327792a9ffc89c0eb
4
- data.tar.gz: 48b4c23cef0d8aac0ba47f996be007b259d3354fffa6065a1fea5f820b4802eb
3
+ metadata.gz: 9a73bed80d485e35617f3ea214165c0ffcfc296721ac896f09d389cbf3a1b6e8
4
+ data.tar.gz: 4cc2e32bf3420c01ea3af58c33582ffaa37c9fd26cf6e7425db1b554c4652c84
5
5
  SHA512:
6
- metadata.gz: cb442c1774c324987f44e8a347f74f061cd7ee64388df006f77b4a7d94ff6b6e57844303190f9fce36d7f556d7391160a1ef611e2424fe9a2a090d78638f16df
7
- data.tar.gz: 3094f3c4160701c912116a499be621486b1f718168450b8030956bb43d3b1864576e7a278a95e948552444969c45835b150f7a05cd50106408bd9b3dfa381587
6
+ metadata.gz: 1c884b9b47f7e5712ab618fc663f5160ed7d7eabd571b295818efe445f11111f2bbb81e7110336594c7885d79c032479241623a33bb81d170309165ccb522f40
7
+ data.tar.gz: 7c939c1e63d708c6772dcd4e703816967942b1ca2400a05027cbd8fa200ec1c446de390daf2bca70cf5313eccaa4615dc132464ffbe395a3a8cfb19d84ba04f0
data/CHANGELOG.md CHANGED
@@ -2,6 +2,50 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.1.1] - 2026-10-30
6
+
7
+ ### Fixed
8
+
9
+ - **A Turbo visit to a page without the tool now switches the widget off.**
10
+ Before, the widget kept the previous page's config when the new page carried
11
+ none, so the pill survived a sign-out or `enabled` turning false until a hard
12
+ reload, and a visit made while in suggest mode (Back, `Turbo.visit`) kept
13
+ every click on the new page frozen. A host that worked around this with a
14
+ `data-turbo-track="reload"` element can drop it.
15
+ - **The pill comes back after a Turbo render that has no `turbo:load`**, such
16
+ as a form answered with a 422 or a morph refresh. The widget's per-page setup
17
+ now runs on `turbo:render` as well as `turbo:load`; it is idempotent, so a
18
+ normal visit runs it twice at the cost of rebuilding one button.
19
+ - Costs: a page with a malformed config block now gets no widget instead of the
20
+ previous page's; a host that injects the tag in only some layouts sees the
21
+ pill only on those pages. No configuration, migration, or server-side change.
22
+ - Close a suggestion dialog that a custom render preserves after suggest mode
23
+ becomes unavailable or inactive, releasing its scroll and focus locks. Any
24
+ unsent draft in that dialog is discarded when leaving suggest mode.
25
+
26
+ ### Changed
27
+
28
+ - Exercise real Turbo visits, cached history, 422 responses, and morph refreshes
29
+ in browser tests. Turbo is a development/test dependency only; the installed
30
+ gem still has no Turbo dependency or asset build step.
31
+
32
+ ## [1.1.0] - 2026-10-18
33
+
34
+ - Add Rails edge CI and experimental Ruby 4.0+ Ractor request support. The host
35
+ must opt into `Rails.application.ractorize!` and use the upstream Rack/I18n
36
+ dependencies documented in `docs/ractors.md`.
37
+ - Exercise widget injection, suggestions, the read-only dashboard and request
38
+ callbacks through the mounted engine in worker Ractors.
39
+ - Prepare immutable widget assets and a frozen configuration snapshot at
40
+ Ractorization. Callbacks still run for each request, with isolated captures;
41
+ callbacks that capture mutable state fail at boot with the setting name.
42
+ Normal Rails boots retain mutable configuration and lazy asset loading.
43
+ - Bridge framework operations that are not yet Ractor safe (mounted URL
44
+ generation, digests/key derivation and attribute factories) to the main
45
+ Ractor. This adds dispatch overhead in Ractor mode; it is compatibility
46
+ support, not a claim of improved throughput. Existing Rails 7.1–8.1 installs
47
+ need no migration or initializer changes.
48
+
5
49
  ## [1.0.1] - 2026-08-11
6
50
 
7
51
  - **A failing `on_submit` hook no longer turns a saved suggestion into a 500.**
data/README.md CHANGED
@@ -314,6 +314,9 @@ configure Action View during initialization can boot reliably.
314
314
  `script-src` including `strict-dynamic`. Runtime config ships as
315
315
  `<script type="application/json">` (data, not code), so it needs no nonce and
316
316
  survives Turbo visits.
317
+ - **The browser follows the server's gate across Turbo visits.** A page the
318
+ middleware injected nothing into (signed out, `enabled` false) turns the
319
+ widget off: no pill and no suggest mode, even when the previous page had them.
317
320
  - **Rate-limited per IP** on the submission endpoint (30/min by default,
318
321
  Rails 7.2+).
319
322
  - **Host-owned review data.** A row may contain translated text, proposed text,
@@ -357,3 +360,9 @@ touch your own CSS — and migrations that follow your app's `primary_key_type`.
357
360
  ## License
358
361
 
359
362
  MIT.
363
+
364
+ ## Rails edge and Ractors
365
+
366
+ Rails edge is tested alongside Rails 7.1–8.1. Experimental Ruby 4.0+ Ractor hosting
367
+ requires additional upstream dependencies and freezes configuration at startup.
368
+ See [the Ractor hosting guide](docs/ractors.md) for callbacks, requirements and checks.
data/Rakefile CHANGED
@@ -10,7 +10,7 @@ require 'rake/testtask'
10
10
  # Unit/integration tests — no browser, runs on every Ruby x Rails combo.
11
11
  Rake::TestTask.new(:test) do |t|
12
12
  t.libs << 'test'
13
- t.test_files = FileList['test/**/*_test.rb'].exclude('test/system/**/*')
13
+ t.test_files = FileList['test/**/*_test.rb'].exclude('test/system/**/*', 'test/ractor/**/*')
14
14
  t.warning = false
15
15
  t.verbose = false
16
16
  end
@@ -24,3 +24,11 @@ Rake::TestTask.new('test:system') do |t|
24
24
  end
25
25
 
26
26
  task default: :test
27
+
28
+ namespace :test do
29
+ desc 'Run isolated configuration and Rails edge Ractor request tests (Ruby 4.0+)'
30
+ task :ractor do
31
+ ruby '-Itest', 'test/ractor/configuration_test.rb'
32
+ ruby '-Itest', 'test/ractor/requests_test.rb'
33
+ end
34
+ end
@@ -15,7 +15,7 @@ module I18nProofreading
15
15
  # Rails.cache); a no-op on Rails 7.1. Tune or disable via config.rate_limit —
16
16
  # read once at boot, after the host's initializer.
17
17
  if respond_to?(:rate_limit) && I18nProofreading.config.rate_limit
18
- rate_limit(**I18nProofreading.config.rate_limit,
18
+ I18nProofreading::RactorSupport.rate_limit(self, **I18nProofreading.config.rate_limit,
19
19
  only: :create,
20
20
  with: lambda {
21
21
  render json: { errors: ['Too many suggestions. Please slow down and try again.'] },
@@ -13,6 +13,15 @@ module I18nProofreading
13
13
 
14
14
  enum :status, STATUSES.index_by(&:itself), prefix: :status, default: :pending
15
15
 
16
+ # Explicit methods work in non-main Ractors; Rails' enum-generated
17
+ # predicates and bang methods are currently unisolated closures.
18
+ def status_pending? = status_for_database == 'pending'
19
+ def status_pending! = update!(status: 'pending')
20
+ def status_applied? = status_for_database == 'applied'
21
+ def status_applied! = update!(status: 'applied')
22
+ def status_rejected? = status_for_database == 'rejected'
23
+ def status_rejected! = update!(status: 'rejected')
24
+
16
25
  validates :translation_key, presence: true, length: { maximum: 500 }
17
26
  validates :proposed_value, presence: true, length: { maximum: 5_000 }
18
27
  validates :old_value, length: { maximum: 5_000 }
data/docs/demo-640.mp4 ADDED
Binary file
data/docs/demo.gif ADDED
Binary file
data/docs/ractors.md ADDED
@@ -0,0 +1,123 @@
1
+ # Rails edge and experimental Ractor hosting
2
+
3
+ The regular CI matrix covers Rails 7.1, 7.2, 8.0 and 8.1. A required Rails
4
+ edge job runs the same suite against `rails/rails` main. A separate job boots
5
+ an eager-loaded host, calls `Rails.application.ractorize!`, and sends real
6
+ Rack requests through the mounted engine in non-main Ractors, including
7
+ simultaneous requests. The Ractor checks run in separate processes because
8
+ Ractorization freezes Rails; they never freeze the ordinary test application.
9
+
10
+ ## Host prerequisites
11
+
12
+ Ractor hosting currently requires Ruby 4.0+, Rails main, Rack main's
13
+ `rack/ractorize`, and the unreleased [I18n Ractor support](https://github.com/ruby-i18n/i18n/pull/741).
14
+ `gemfiles/rails_ractor.gemfile` records the exact I18n revision tested. These
15
+ are development/test dependencies, not new runtime dependencies for released
16
+ Rails. The gem's supported dependency range remains Rails >= 7.1 and < 9.
17
+
18
+ For a host experimenting with Ractor hosting, the tested upstream overrides are:
19
+
20
+ ```ruby
21
+ gem "rails", github: "rails/rails", branch: "main"
22
+ gem "rack", github: "rack/rack", branch: "main"
23
+ gem "i18n", github: "Shopify/i18n", ref: "58aa1dcd3e04c34831e1c2ec06c83391559c7c91"
24
+ ```
25
+
26
+ Follow Rails' own Ractor hosting requirements: disable reloading, eager-load
27
+ application code, use a shareable logger/cache and a Ractor-compatible job
28
+ adapter. Before application classes load, enable Rails' callback isolation:
29
+
30
+ ```ruby
31
+ require "active_support/ractors"
32
+ ActiveSupport::Ractors.unshareable_proc_action = :raise
33
+ ```
34
+
35
+ Our isolated fixture uses `ActiveSupport::Ractors::Logger`, a null
36
+ cache, and (where jobs are loaded) `:inline`. Its separate shared counter proves
37
+ that public submission limits remain effective across worker Ractors. The
38
+ ordinary dummy application's `:test` job adapter is unchanged.
39
+
40
+ The engine prepares itself automatically on `before_ractorize`; ordinary Rails
41
+ boots do not freeze its configuration or load the experimental I18n extension.
42
+ An unavailable I18n Ractor extension produces a startup error explaining the
43
+ prerequisite. Rails edge and Ractor hosting remain experimental: this is not a
44
+ promise that every third-party host middleware, database driver, storage
45
+ service, mail adapter or Action Cable adapter is Ractor safe.
46
+
47
+ ## Configuration and callbacks
48
+
49
+ Finish configuring I18nProofreading before calling `ractorize!`. The engine publishes
50
+ a frozen copy of the configuration and warms/freezes widget JS, CSS and content
51
+ fingerprints. Changes after Ractorization require a process restart. Arrays and
52
+ hashes supplied by the host are copied before freezing.
53
+
54
+ Callbacks are isolated with Ruby's `Ractor.shareable_lambda` or
55
+ `Ractor.shareable_proc`, preserving lambda/proc arity. They continue to receive
56
+ the same request, user, record or event arguments and run on each request.
57
+ Isolated callbacks use arguments and constants; their implicit initializer
58
+ `self` becomes nil, following Rails' isolated callback convention.
59
+
60
+ ```ruby
61
+ I18nProofreading.configure do |config|
62
+ config.enabled = ->(request) { request.get_header("HTTP_X_WIDGET") != "off" }
63
+ end
64
+ ```
65
+
66
+ Immutable captured values must already be shareable:
67
+
68
+ ```ruby
69
+ expected_host = "app.example.com".freeze
70
+ I18nProofreading.configure do |config|
71
+ config.enabled = ->(request) { request.host == expected_host }
72
+ end
73
+ ```
74
+
75
+ Mutable captures (an array collecting notifications, an unfrozen captured
76
+ string, or reassigned outer locals) cannot be shared. The engine fails at
77
+ startup with `I18nProofreading.config.<setting>` in the error rather than failing on
78
+ a visitor's request. Use request-local state and external persistence for
79
+ side effects. A pre-isolated callback made with an explicit shareable `self:`
80
+ keeps that receiver. Stateful callable objects must be copyable/shareable;
81
+ custom objects remain subject to Ruby's Ractor rules.
82
+
83
+ ## Scope and cost
84
+
85
+ Some Rails main paths are still being converted to Ractors. The engine warms
86
+ model attributes and controller names, prepares ETag callbacks, and supplies
87
+ narrow bridges for mounted URL generation, cache freshness, digest/key
88
+ operations, GlobalID URI decoding and boot-time attribute decorators. Those bridges preserve the
89
+ original operation on the main Ractor; requests, authorization callbacks,
90
+ rendering and persistence orchestration execute in their worker Ractors.
91
+ Adapter name quoting delegates to its original implementation on the main Ractor,
92
+ which preserves escaping and avoids process-global mutable caches in workers.
93
+ Disk-backed uploads use Ractor-local storage clients and plain temporary files
94
+ with explicit cleanup, because Tempfile's generated delegation methods cannot
95
+ be called from another Ractor. MIME registrations must finish before
96
+ Ractorization, when Marcel's MIME tables become immutable. Eager model schema
97
+ preparation adds startup database work.
98
+ The bridges are internal compatibility code, add coordination overhead, and
99
+ must be reviewed as upstream Rails evolves. No throughput improvement is claimed.
100
+
101
+ ## Run the checks
102
+
103
+ Use the Ruby configured in `mise.toml`, never macOS's system Ruby.
104
+
105
+ ```sh
106
+ BUNDLE_GEMFILE=gemfiles/rails_edge.gemfile bundle install
107
+ BUNDLE_GEMFILE=gemfiles/rails_edge.gemfile bundle exec rake test
108
+ BUNDLE_GEMFILE=gemfiles/rails_ractor.gemfile bundle install
109
+ BUNDLE_GEMFILE=gemfiles/rails_ractor.gemfile bundle exec rake test:ractor
110
+ ```
111
+
112
+ The request fixture creates and removes its own temporary database and host;
113
+ it does not reuse or freeze `test/dummy`. The shared schema lives in
114
+ `test/support/schema.rb` so the ordinary and Ractor suites exercise the same
115
+ record shape. Successful checks establish the tested SQLite host and explicit
116
+ request paths, not all combinations of host integrations.
117
+
118
+ The media job also runs with `RACTOR_MEDIA=1`, exercising private disk-backed
119
+ Active Storage downloads through the engine's authorization gate. Storage
120
+ services are initialized per Ractor, and streamed responses use a thread local
121
+ to that Ractor. Livechat additionally runs with `RACTOR_CABLE=1`, checking the
122
+ signed stream token and broadcasts delegated to the main Action Cable server.
123
+ Other storage services and production Cable adapters need host-specific QA.
Binary file
Binary file
@@ -2,8 +2,19 @@
2
2
 
3
3
  module I18nProofreading
4
4
  class Engine < ::Rails::Engine
5
+ initializer 'i18n_proofreading.ractor_middleware' do |app|
6
+ app.middleware.insert_before 0, RactorSupport::Middleware
7
+ end
8
+
5
9
  isolate_namespace I18nProofreading
6
10
 
11
+ initializer 'i18n_proofreading.ractorize', before: 'action_controller.ractorize' do
12
+ if Rails::Application.method_defined?(:ractorize!)
13
+ Engine.routes.singleton_class.prepend(RactorSupport::MountedRoutes)
14
+ end
15
+ ActiveSupport.on_load(:before_ractorize) { I18nProofreading::RactorSupport.prepare! }
16
+ end
17
+
7
18
  rake_tasks do
8
19
  load File.expand_path('../tasks/i18n_proofreading_tasks.rake', __dir__)
9
20
  end
@@ -0,0 +1,518 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nProofreading
4
+ # Used only by Rails' opt-in before_ractorize hook. Normal Rails boots keep
5
+ # mutable configuration and lazy assets, including on older Ruby versions.
6
+ module RactorSupport
7
+ # Small bridges for framework paths that Rails main does not prepare yet.
8
+ # They are installed only when the host explicitly calls ractorize!.
9
+ # Rails' built-in JSON renderer is still defined from an unisolated Proc.
10
+ # Keep its JSON/JSONP and escaping behavior in a regular Ruby method.
11
+ module JsonRendering
12
+ def _render_with_renderer_json(json, options)
13
+ json_options = options.except(:callback, :content_type, :status)
14
+ json_options[:escape] ||= false if !self.class.escape_json_responses? && options[:callback].blank?
15
+ json = json.to_json(json_options) unless json.is_a?(String)
16
+ if options[:callback].present?
17
+ self.content_type = :js if media_type.nil? || media_type == Mime[:json]
18
+ "/**/#{options[:callback]}(#{json})"
19
+ else
20
+ self.content_type = :json if media_type.nil?
21
+ json
22
+ end
23
+ end
24
+ end
25
+
26
+ module DependencyInterlock
27
+ def interlock
28
+ return super if Ractor.main?
29
+
30
+ Ractor[:engine_dependency_interlock] ||= ActiveSupport::Dependencies::Interlock.new
31
+ end
32
+ end
33
+
34
+ module LiveThreads
35
+ private
36
+
37
+ def new_controller_thread
38
+ return super if Ractor.main?
39
+
40
+ Thread.new do
41
+ Thread.current.abort_on_exception = true
42
+ yield
43
+ end
44
+ end
45
+ end
46
+
47
+ module UniquenessCoverage
48
+ private
49
+
50
+ def covered_by_unique_index?(klass, record, attribute, scope)
51
+ return super unless frozen?
52
+
53
+ copies = Ractor[:engine_uniqueness_validators] ||= {}
54
+ copy = copies[[self, klass]] ||= dup
55
+ copy.send(:covered_by_unique_index?, klass, record, attribute, scope)
56
+ end
57
+ end
58
+
59
+ # GlobalID calls CGI.unescape without an encoding, whose default reads a
60
+ # process-wide class variable. Preserve CGI's configured default on main.
61
+ module UriUnescaping
62
+ def unescape(string, *arguments)
63
+ return super if Ractor.main?
64
+
65
+ shared_arguments = Ractor.make_shareable([string, *arguments], copy: true)
66
+ ActiveSupport::Ractors.on_main(self) { unescape(*shared_arguments) }
67
+ end
68
+ end
69
+
70
+ module CacheFreshness
71
+ def strict_freshness
72
+ return super if Ractor.main?
73
+
74
+ ActiveSupport::Ractors.on_main(self) { strict_freshness }
75
+ end
76
+ end
77
+
78
+ # Attribute decorators (including Rails enums and serialized attributes)
79
+ # are boot-time factories. Keep their original receivers and captures on
80
+ # the main Ractor, rather than changing the semantics of Rails' closures.
81
+ class Decorator
82
+ def initialize(callback)
83
+ @key = RactorSupport.register_decorator(callback)
84
+ freeze
85
+ end
86
+
87
+ def call(*arguments)
88
+ return RactorSupport.call_decorator(@key, arguments) if Ractor.main?
89
+
90
+ shared_arguments = Ractor.make_shareable(arguments, copy: true)
91
+ ActiveSupport::Ractors.on_main(self) { RactorSupport.call_decorator(@key, shared_arguments) }
92
+ end
93
+ end
94
+
95
+ def self.register_decorator(callback)
96
+ @decorators ||= []
97
+ @decorators << callback
98
+ @decorators.length - 1
99
+ end
100
+
101
+ def self.call_decorator(key, arguments)
102
+ @decorators.fetch(key).call(*arguments)
103
+ end
104
+
105
+ def self.prepare_attributes(model)
106
+ prepare_attributes(model.superclass) if model.superclass.respond_to?(:pending_attribute_modifications, true)
107
+ model.send(:pending_attribute_modifications)&.each do |modification|
108
+ next unless modification.respond_to?(:decorator) && modification.decorator.is_a?(Proc)
109
+
110
+ modification.decorator = Decorator.new(modification.decorator)
111
+ end
112
+ end
113
+
114
+ module AdapterQuoting
115
+ def quote_column_name(name)
116
+ return super if Ractor.main?
117
+
118
+ shared_name = Ractor.make_shareable(name, copy: true)
119
+ ActiveSupport::Ractors.on_main(self) { quote_column_name(shared_name) }
120
+ end
121
+
122
+ def quote_table_name(name)
123
+ return super if Ractor.main?
124
+
125
+ shared_name = Ractor.make_shareable(name, copy: true)
126
+ ActiveSupport::Ractors.on_main(self) { quote_table_name(shared_name) }
127
+ end
128
+ end
129
+
130
+ # Route arguments can contain records with native Set caches. Only their
131
+ # to_param value is needed to generate a mount prefix; keep the caller's
132
+ # original record objects in the options passed to the actual route.
133
+ def self.routing_snapshot(value)
134
+ return value if Ractor.shareable?(value)
135
+
136
+ copy = case value
137
+ when ActiveRecord::Base then value.to_param
138
+ when Hash then value.to_h { |key, item| [routing_snapshot(key), routing_snapshot(item)] }
139
+ when Array then value.map { |item| routing_snapshot(item) }
140
+ when Set then Set.new(value.map { |item| routing_snapshot(item) })
141
+ else return Ractor.make_shareable(value, copy: true)
142
+ end
143
+ Ractor.make_shareable(copy)
144
+ end
145
+
146
+ module MountedRoutes
147
+ def find_script_name(options)
148
+ return super unless defined?(Ractor.shareable_proc) && !Ractor.main?
149
+
150
+ shared_options = RactorSupport.routing_snapshot(options)
151
+ prefix, updated = ActiveSupport::Ractors.on_main(self) do
152
+ local_options = shared_options.dup
153
+ [find_script_name(local_options), local_options]
154
+ end
155
+ (shared_options.keys - updated.keys).each { |key| options.delete(key) }
156
+ updated.each do |key, value|
157
+ options[key] = value unless shared_options.key?(key) && shared_options[key] == value
158
+ end
159
+ prefix
160
+ end
161
+ end
162
+
163
+ module Digesting
164
+ def hexdigest(value)
165
+ return super if Ractor.main?
166
+
167
+ shared_value = Ractor.make_shareable(value, copy: true)
168
+ ActiveSupport::Ractors.on_main(self) { hexdigest(shared_value) }
169
+ end
170
+ end
171
+
172
+ module KeyGeneration
173
+ def generate_key(salt, key_size = 64)
174
+ return super if Ractor.main?
175
+
176
+ shared_salt = Ractor.make_shareable(salt, copy: true)
177
+ ActiveSupport::Ractors.on_main(self) { generate_key(shared_salt, key_size) }
178
+ end
179
+ end
180
+
181
+ module UploadCleanup
182
+ def open
183
+ reopen(path, 'rb+')
184
+ self
185
+ end
186
+
187
+ # Tempfile#close takes a positional unlink flag.
188
+ def close(unlink_now = false) # rubocop:disable Style/OptionalBooleanParameter
189
+ super() unless closed?
190
+ unlink if unlink_now
191
+ end
192
+
193
+ def unlink
194
+ File.unlink(path)
195
+ rescue Errno::ENOENT
196
+ nil
197
+ end
198
+
199
+ def close!
200
+ close(true)
201
+ end
202
+ end
203
+
204
+ def self.upload_tempfile(filename, _content_type)
205
+ extension = File.extname(filename.gsub("\0", '%00'))[0, 129]
206
+ Tempfile.create(['RackMultipart', extension]).extend(UploadCleanup)
207
+ end
208
+
209
+ class Middleware
210
+ def initialize(app)
211
+ @app = app
212
+ end
213
+
214
+ def call(env)
215
+ if (fallbacks = RactorSupport.fallbacks)
216
+ Fiber[:i18n_fallbacks] ||= fallbacks
217
+ env['rack.multipart.tempfile_factory'] ||= RactorSupport.method(:upload_tempfile) unless Ractor.main?
218
+ end
219
+ @app.call(env)
220
+ end
221
+ end
222
+
223
+ def self.fallbacks
224
+ @fallbacks
225
+ end
226
+
227
+ module StorageMetadata
228
+ def analyzed = read_store_attribute(:metadata, :analyzed)
229
+ def identified = read_store_attribute(:metadata, :identified)
230
+ def composed = read_store_attribute(:metadata, :composed)
231
+
232
+ def analyzed=(value)
233
+ write_store_attribute(:metadata, :analyzed, value)
234
+ end
235
+
236
+ def identified=(value)
237
+ write_store_attribute(:metadata, :identified, value)
238
+ end
239
+
240
+ def composed=(value)
241
+ write_store_attribute(:metadata, :composed, value)
242
+ end
243
+ end
244
+
245
+ class StorageServices
246
+ def initialize(configurations)
247
+ @configurations = Ractor.make_shareable(configurations, copy: true)
248
+ freeze
249
+ end
250
+
251
+ def fetch(name, &)
252
+ registry = if Ractor.main?
253
+ RactorSupport.storage_registry
254
+ else
255
+ Ractor[:engine_storage_services] ||= ActiveStorage::Service::Registry.new(@configurations)
256
+ end
257
+ registry.fetch(name, &)
258
+ end
259
+ end
260
+
261
+ def self.storage_registry
262
+ @storage_registry
263
+ end
264
+
265
+ module StorageChecksums
266
+ def checksum_implementation(**options)
267
+ implementation = super
268
+ if Ractor.main? || !implementation.respond_to?(:name) || !implementation.name&.start_with?('OpenSSL::Digest::')
269
+ return implementation
270
+ end
271
+
272
+ DigestFactory.new(implementation.name.delete_prefix('OpenSSL::Digest::'))
273
+ end
274
+ end
275
+
276
+ class DigestFactory
277
+ def initialize(algorithm)
278
+ @algorithm = algorithm.freeze
279
+ freeze
280
+ end
281
+
282
+ def new
283
+ OpenSSL::Digest.new(@algorithm)
284
+ end
285
+
286
+ def file(io)
287
+ new.file(io)
288
+ end
289
+
290
+ def base64digest(value)
291
+ new.base64digest(value)
292
+ end
293
+ end
294
+
295
+ def self.prepare_redirects!
296
+ base = ActionController::Base
297
+ readers = Module.new
298
+ %i[raise_on_open_redirects action_on_open_redirect action_on_path_relative_redirect].each do |key|
299
+ source = base.method(key).source_location&.first
300
+ next unless source&.end_with?('/action_controller/metal/redirecting.rb')
301
+
302
+ value = Ractor.make_shareable(base.public_send(key), copy: true)
303
+ readers.define_method(key, Ractor.shareable_proc { Ractor.main? ? super() : value })
304
+ end
305
+ return if readers.instance_methods(false).empty?
306
+
307
+ base.prepend(readers)
308
+ base.singleton_class.prepend(readers)
309
+ end
310
+
311
+ def self.prepare_previewers!
312
+ ActiveStorage.previewers.each do |previewer|
313
+ readers = Module.new
314
+ previewer.methods.grep(/_exists\?\z/).each do |capability|
315
+ method = previewer.method(capability)
316
+ next unless method.arity.zero? && method.source_location&.first&.include?('/active_storage/previewer/')
317
+
318
+ readers.define_method(capability, Ractor.shareable_proc do
319
+ if Ractor.main?
320
+ super()
321
+ else
322
+ ActiveSupport::Ractors.on_main(self) { public_send(capability) }
323
+ end
324
+ end)
325
+ end
326
+ previewer.singleton_class.prepend(readers) unless readers.instance_methods(false).empty?
327
+ end
328
+ end
329
+
330
+ def self.prepare_storage!
331
+ return unless defined?(ActiveStorage::Blob)
332
+
333
+ prepare_previewers!
334
+ ActiveStorage::Blob.prepend(StorageMetadata) unless ActiveStorage::Blob < StorageMetadata
335
+ %i[EXTENSIONS TYPE_EXTS TYPE_ALIASES TYPE_PARENTS ROOT_XML MAGIC].each do |name|
336
+ Ractor.make_shareable(Marcel.const_get(name)) if Marcel.const_defined?(name)
337
+ end
338
+ ActiveStorage::Service.prepend(StorageChecksums) unless ActiveStorage::Service < StorageChecksums
339
+ unless Ractor.shareable?(ActiveStorage::Blob.services)
340
+ @storage_registry = ActiveStorage::Blob.services
341
+ configurations = @storage_registry.instance_variable_get(:@configurations)
342
+ default_name = ActiveStorage::Blob.service.name
343
+ default_reader = Module.new
344
+ default_reader.define_method(:service, Ractor.shareable_proc do
345
+ Ractor.main? ? super() : services.fetch(default_name)
346
+ end)
347
+ ActiveStorage::Blob.singleton_class.prepend(default_reader)
348
+ ActiveStorage::Blob.services = StorageServices.new(configurations)
349
+ end
350
+ settings = ActiveStorage.class_variables.map { |name| name.to_s.delete_prefix('@@').to_sym }
351
+ settings.select! { |key| ActiveStorage.respond_to?(key) }
352
+ snapshot = settings.to_h { |key| [key, ActiveStorage.public_send(key)] }
353
+ checksum = snapshot[:checksum_implementation]
354
+ if checksum.is_a?(Class) && checksum.name&.start_with?('OpenSSL::Digest::')
355
+ snapshot[:checksum_implementation] = DigestFactory.new(checksum.name.delete_prefix('OpenSSL::Digest::'))
356
+ end
357
+ Ractor.make_shareable(snapshot)
358
+ readers = Module.new
359
+ settings.each do |key|
360
+ readers.define_method(key, Ractor.shareable_proc { Ractor.main? ? super() : snapshot.fetch(key) })
361
+ end
362
+ ActiveStorage.singleton_class.prepend(readers)
363
+ end
364
+
365
+ def self.prepare_framework!
366
+ CGI.singleton_class.prepend(UriUnescaping) unless CGI.singleton_class < UriUnescaping
367
+ Ractor.make_shareable(GlobalID.app) if defined?(GlobalID) && GlobalID.respond_to?(:app)
368
+ I18n.available_locales.each { |locale| I18n.transliterate('ractor', locale: locale) }
369
+ require 'i18n/ractorize'
370
+ require 'tempfile'
371
+ prepare_redirects!
372
+ %i[BASE36_ALPHABET BASE58_ALPHABET].each do |name|
373
+ Ractor.make_shareable(SecureRandom.const_get(name)) if SecureRandom.const_defined?(name)
374
+ end
375
+ uniqueness = ActiveRecord::Validations::UniquenessValidator
376
+ uniqueness.prepend(UniquenessCoverage) unless uniqueness < UniquenessCoverage
377
+ dependencies = ActiveSupport::Dependencies.singleton_class
378
+ dependencies.prepend(DependencyInterlock) unless dependencies < DependencyInterlock
379
+ prepare_storage!
380
+ Ractor.make_shareable(ActiveRecord::Associations::AssociationScope::INSTANCE)
381
+ Ractor.make_shareable(ActionDispatch::Http::Cache::Response::SPECIAL_KEYS)
382
+ freshness = ActionDispatch::Http::Cache::Request.singleton_class
383
+ freshness.prepend(CacheFreshness) unless freshness < CacheFreshness
384
+ fallbacks = I18n.fallbacks
385
+ I18n.available_locales.each { |locale| fallbacks[locale] }
386
+ @fallbacks = Ractor.make_shareable(fallbacks)
387
+ ActiveSupport::Digest.singleton_class.prepend(Digesting) unless ActiveSupport::Digest.singleton_class < Digesting
388
+ ActiveSupport::KeyGenerator.prepend(KeyGeneration) unless ActiveSupport::KeyGenerator < KeyGeneration
389
+ ActiveRecord::Base.connection_handler.connection_pool_list.each do |pool|
390
+ adapter = pool.db_config.adapter_class.singleton_class
391
+ adapter.prepend(AdapterQuoting) unless adapter < AdapterQuoting
392
+ end
393
+ rescue LoadError => e
394
+ raise LoadError,
395
+ "Ractor requests need the upstream I18n Ractor support (see gemfiles/rails_ractor.gemfile): #{e.message}"
396
+ end
397
+
398
+ def self.prepare!
399
+ # A cache store can reference the whole middleware stack and freeze
400
+ # other mounted engines before their own hooks run. Warm every engine's
401
+ # request environment before making any of that graph shareable.
402
+ Rails.application.railties.each do |railtie|
403
+ railtie.env_config if railtie.is_a?(Rails::Engine)
404
+ end
405
+ Engine.instance.env_config
406
+ prepare_framework!
407
+ ActiveRecord::Base.descendants.each do |model|
408
+ next if model.abstract_class? || !model.table_exists?
409
+
410
+ model.reflect_on_all_associations.each { |reflection| reflection.send(:inverse_name) }
411
+ prepare_attributes(model)
412
+ Ractor.make_shareable(model.defined_enums)
413
+ model.defined_enums.each do |attribute, mapping|
414
+ method_name = attribute.pluralize
415
+ source = model.method(method_name).source_location&.first
416
+ next unless source&.end_with?('/active_record/enum.rb')
417
+
418
+ model.define_singleton_method(method_name, Ractor.shareable_proc { mapping })
419
+ end
420
+ model.define_attribute_methods
421
+ if model.respond_to?(:attachment_reflections)
422
+ model.attachment_reflections.each_value(&:named_variants)
423
+ Ractor.make_shareable(model.attachment_reflections)
424
+ end
425
+ end
426
+ ActionController::Base.descendants.each do |controller|
427
+ next unless controller.name&.start_with?('I18nProofreading::')
428
+
429
+ controller.prepend(LiveThreads) if defined?(ActionController::Live) && controller < ActionController::Live
430
+ renderer_source = controller.instance_method(:_render_with_renderer_json).source_location&.first
431
+ if renderer_source&.end_with?('/action_controller/metal/renderers.rb') && !(controller < JsonRendering)
432
+ controller.prepend(JsonRendering)
433
+ end
434
+ Ractor.make_shareable(controller.controller_name)
435
+ controller.etaggers = controller.etaggers.map { |callback| Ractor.shareable_proc(&callback) }.freeze
436
+ end
437
+ I18nProofreading.instance_variable_set(:@config, configuration(I18nProofreading.config))
438
+ %i[javascript dashboard_javascript dashboard_stylesheet fingerprint
439
+ dashboard_fingerprint dashboard_stylesheet_fingerprint].each do |method|
440
+ next unless Widget.respond_to?(method)
441
+
442
+ Widget.public_send(method)
443
+ end
444
+ ActionController::Base.descendants.each do |controller|
445
+ next unless controller.respond_to?(:i18n_proofreading_rate_limit_options)
446
+
447
+ options = controller.i18n_proofreading_rate_limit_options
448
+ controller.i18n_proofreading_rate_limit_options = share_options(options)
449
+ end
450
+ Widget.instance_variables.each do |variable|
451
+ Widget.instance_variable_set(variable, Ractor.make_shareable(Widget.instance_variable_get(variable)))
452
+ end
453
+ end
454
+
455
+ # Rails edge's rate_limit currently closes over reassigned keyword
456
+ # arguments. A named filter avoids that unshareable closure; the actual
457
+ # counter, cache key and rejection still use Rails' rate_limiting method.
458
+ def self.rate_limit(controller, to:, within:, only:, with:, **options)
459
+ unless Rails::Application.method_defined?(:ractorize!)
460
+ return controller.rate_limit(to: to, within: within, only: only, with: with, **options)
461
+ end
462
+
463
+ controller.class_attribute :i18n_proofreading_rate_limit_options, instance_accessor: false
464
+ controller.i18n_proofreading_rate_limit_options = {
465
+ to: to, within: within, by: -> { request.remote_ip }, with: with,
466
+ store: controller.cache_store, name: nil, scope: controller.controller_path
467
+ }.merge(options.slice(:by, :store, :name, :scope))
468
+ controller.i18n_proofreading_rate_limit_options[:scope] ||= controller.controller_path
469
+ filters = options.except(:by, :store, :name, :scope)
470
+ controller.before_action :i18n_proofreading_rate_limit, only: only, **filters
471
+ controller.class_eval <<~RUBY, __FILE__, __LINE__ + 1
472
+ private
473
+ def i18n_proofreading_rate_limit
474
+ rate_limiting(**self.class.i18n_proofreading_rate_limit_options)
475
+ end
476
+ RUBY
477
+ end
478
+
479
+ def self.share_options(options)
480
+ options.to_h do |key, value|
481
+ value = configuration_value(value) if value.is_a?(Proc)
482
+ [key, Ractor.make_shareable(value)]
483
+ end.freeze
484
+ end
485
+
486
+ def self.configuration_value(value)
487
+ case value
488
+ when Proc
489
+ return value if Ractor.shareable?(value)
490
+
491
+ # Like Rails callbacks, isolated callbacks use arguments and constants,
492
+ # not their initializer's implicit self. Keep lambda/proc arity intact.
493
+ value.lambda? ? Ractor.shareable_lambda(&value) : Ractor.shareable_proc(&value)
494
+ when Hash
495
+ value.to_h { |key, item| [configuration_value(key), configuration_value(item)] }.freeze
496
+ when Array
497
+ value.map { |item| configuration_value(item) }.freeze
498
+ else
499
+ Ractor.make_shareable(value, copy: true)
500
+ end
501
+ end
502
+
503
+ def self.configuration(original)
504
+ prepared = original.dup
505
+ original.instance_variables.each do |variable|
506
+ value = original.instance_variable_get(variable)
507
+ begin
508
+ shared = configuration_value(value)
509
+ prepared.instance_variable_set(variable, shared)
510
+ rescue Ractor::Error, TypeError => e
511
+ setting = variable.to_s.delete_prefix('@')
512
+ raise ArgumentError, "I18nProofreading.config.#{setting} cannot be shared with Ractors: #{e.message}"
513
+ end
514
+ end
515
+ Ractor.make_shareable(prepared)
516
+ end
517
+ end
518
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module I18nProofreading
4
- VERSION = '1.0.1'
4
+ VERSION = '1.1.1'
5
5
  end
@@ -20,6 +20,12 @@
20
20
  if (!config || window.__i18nProofreadingLoaded) return;
21
21
  window.__i18nProofreadingLoaded = true;
22
22
 
23
+ // The state of a page without a config block: the middleware injected
24
+ // nothing, so the tool is not available there (signed out, `enabled` false,
25
+ // a layout without the tag). Handlers read `config.active`, so this keeps
26
+ // them inert instead of acting on the last page's config.
27
+ var UNAVAILABLE = { active: false, showPill: false };
28
+
23
29
  var LEFT = "⟦"; // ⟦
24
30
  var RIGHT = "⟧"; // ⟧
25
31
  var TOKEN = new RegExp(LEFT + "([^" + RIGHT + "]+)" + RIGHT);
@@ -56,9 +62,13 @@
56
62
 
57
63
  // Everything else lives in <body>, which Turbo replaces on every visit —
58
64
  // taking the pill and the active-mode highlighting with it. Re-run the
59
- // per-page setup on each visit so the widget keeps working without a hard
60
- // reload. render() also runs now for the initial (or non-Turbo) load.
65
+ // per-page setup after every body swap so the widget keeps working without
66
+ // a hard reload. render() also runs now for the initial (or non-Turbo) load.
67
+ // turbo:render covers swaps with no turbo:load after them (a form answered
68
+ // with a 422 or 500, a morph refresh); turbo:load covers the rest. render()
69
+ // is idempotent, so a visit that fires both only rebuilds one button.
61
70
  render();
71
+ document.addEventListener("turbo:render", render);
62
72
  document.addEventListener("turbo:load", render);
63
73
  document.addEventListener("turbo:frame-load", function () { strip(); });
64
74
 
@@ -88,18 +98,23 @@
88
98
 
89
99
  function render() {
90
100
  // Re-read on each visit: the injected config is data (not an executed
91
- // script), so it reflects the page Turbo just rendered.
92
- config = readConfig() || config;
93
- // A Turbo body swap can drop the overlay without close(); documentElement
94
- // survives the swap, so release the scroll lock or the new page is frozen.
95
- if (overlay && !document.body.contains(overlay)) {
96
- overlay = null;
97
- unwatchViewport();
98
- unlockScroll();
99
- }
101
+ // script), so it reflects the page Turbo just rendered. No config means the
102
+ // tool is not available on this page, so the widget switches off rather
103
+ // than keep the previous page's pill or suggest mode across the visit.
104
+ config = readConfig() || UNAVAILABLE;
105
+ // A body swap can detach the dialog; a custom render can preserve it even
106
+ // after suggest mode is disabled. Both must release its scroll/focus lock.
107
+ if (overlay && (!config.active || !document.body.contains(overlay))) close();
108
+ // A cached snapshot or a morph can carry the previous page's pill; the
109
+ // current config alone decides whether one is shown.
110
+ removePill();
111
+ // <html> survives a Turbo visit, so the suggest-mode class must be cleared
112
+ // as well as set.
113
+ document.documentElement.classList.toggle("i18np-active", !!config.active);
114
+ if (config === UNAVAILABLE) return;
115
+
100
116
  injectStyles();
101
117
  if (config.showPill !== false) buildPill();
102
- document.documentElement.classList.toggle("i18np-active", !!config.active);
103
118
  if (config.active) strip();
104
119
  }
105
120
 
@@ -199,10 +214,13 @@
199
214
 
200
215
  // --- pill -----------------------------------------------------------------
201
216
 
202
- function buildPill() {
203
- var existing = document.querySelector(".i18np-pill");
204
- if (existing) existing.remove();
217
+ function removePill() {
218
+ document.querySelectorAll(".i18np-pill").forEach(function (pill) {
219
+ pill.remove();
220
+ });
221
+ }
205
222
 
223
+ function buildPill() {
206
224
  var pill = document.createElement("button");
207
225
  pill.type = "button";
208
226
  pill.className = "i18np-pill" + (config.active ? " i18np-pill-on" : "");
@@ -14,8 +14,8 @@ module I18nProofreading
14
14
  # pipeline never ingests it: Rails auto-registers every engine's
15
15
  # app/assets/* directory, which would put this file in the host's asset
16
16
  # namespace as a bare "widget.js" and into its precompiled output.
17
- SOURCE = File.expand_path('widget.js', __dir__)
18
- DASHBOARD_STYLESHEET_SOURCE = File.expand_path('dashboard.css', __dir__)
17
+ SOURCE = File.expand_path('widget.js', __dir__).freeze
18
+ DASHBOARD_STYLESHEET_SOURCE = File.expand_path('dashboard.css', __dir__).freeze
19
19
 
20
20
  # Right-to-left scripts, so the popover renders mirrored for those locales.
21
21
  # Matched on the language subtag, so region variants ("ar-EG") count too.
@@ -46,8 +46,10 @@ module I18nProofreading
46
46
  # The config rides in a `type="application/json"` block: it is *data*, not
47
47
  # code, so the browser never executes it and Turbo never tries to re-run it
48
48
  # on a soft visit — which means it needs no CSP nonce and, crucially, the
49
- # widget can re-read the *current* page's config on every `turbo:load`
50
- # instead of being stuck with whatever the last full load evaluated.
49
+ # widget can re-read the *current* page's config after every Turbo render
50
+ # instead of being stuck with whatever the last full load evaluated. A
51
+ # page without the block is a page the tool is off for, and the widget
52
+ # switches itself off there.
51
53
  #
52
54
  # The code is a same-origin `src` script served by the engine — NOT
53
55
  # inlined. Under a nonce-based CSP, Turbo Drive body swaps re-run body
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'i18n_proofreading/version'
4
4
  require 'i18n_proofreading/configuration'
5
+ require 'i18n_proofreading/ractor_support'
5
6
  require 'i18n_proofreading/marking'
6
7
  require 'i18n_proofreading/widget'
7
8
  require 'i18n_proofreading/tag_helper'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: i18n_proofreading
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov
@@ -86,6 +86,17 @@ files:
86
86
  - config/locales/i18n_proofreading.vi.yml
87
87
  - config/locales/i18n_proofreading.zh-CN.yml
88
88
  - config/routes.rb
89
+ - docs/demo-640.mp4
90
+ - docs/demo.gif
91
+ - docs/ractors.md
92
+ - docs/screenshots/01-highlight.png
93
+ - docs/screenshots/02-suggest.png
94
+ - docs/screenshots/03-review-board.png
95
+ - docs/screenshots/admin-ui/after-index-mobile.png
96
+ - docs/screenshots/admin-ui/after-index.png
97
+ - docs/screenshots/admin-ui/after-show-mobile.png
98
+ - docs/screenshots/admin-ui/after-show.png
99
+ - docs/screenshots/admin-ui/before-index.png
89
100
  - lib/generators/i18n_proofreading/install/install_generator.rb
90
101
  - lib/generators/i18n_proofreading/install/templates/create_i18n_proofreading_suggestions.rb.tt
91
102
  - lib/generators/i18n_proofreading/install/templates/initializer.rb
@@ -96,6 +107,7 @@ files:
96
107
  - lib/i18n_proofreading/engine.rb
97
108
  - lib/i18n_proofreading/marking.rb
98
109
  - lib/i18n_proofreading/middleware.rb
110
+ - lib/i18n_proofreading/ractor_support.rb
99
111
  - lib/i18n_proofreading/seeds.rb
100
112
  - lib/i18n_proofreading/tag_helper.rb
101
113
  - lib/i18n_proofreading/version.rb
@@ -124,7 +136,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
124
136
  - !ruby/object:Gem::Version
125
137
  version: '0'
126
138
  requirements: []
127
- rubygems_version: 4.0.16
139
+ rubygems_version: 4.0.20
128
140
  specification_version: 4
129
141
  summary: 'In-context i18n proofreading for Rails: click any translated string and
130
142
  suggest a fix.'