copy_tuner_client 2.1.2 → 2.2.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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +5 -0
  3. data/.rubocop.yml +46 -1
  4. data/CHANGELOG.md +2 -235
  5. data/CLAUDE.md +1 -0
  6. data/Gemfile +1 -0
  7. data/README.md +42 -5
  8. data/copy_tuner_client.gemspec +3 -3
  9. data/gemfiles/8.0.gemfile +3 -3
  10. data/gemfiles/8.1.gemfile +3 -3
  11. data/gemfiles/main.gemfile +3 -3
  12. data/lib/copy_tuner_client/cache.rb +34 -27
  13. data/lib/copy_tuner_client/client.rb +35 -23
  14. data/lib/copy_tuner_client/configuration.rb +87 -40
  15. data/lib/copy_tuner_client/copyray/rewriter.rb +1 -1
  16. data/lib/copy_tuner_client/copyray.rb +1 -0
  17. data/lib/copy_tuner_client/copyray_middleware.rb +31 -24
  18. data/lib/copy_tuner_client/dotted_hash.rb +7 -7
  19. data/lib/copy_tuner_client/engine.rb +4 -6
  20. data/lib/copy_tuner_client/helper_extension.rb +58 -54
  21. data/lib/copy_tuner_client/i18n_backend.rb +12 -14
  22. data/lib/copy_tuner_client/i18n_compat.rb +2 -1
  23. data/lib/copy_tuner_client/poller.rb +3 -4
  24. data/lib/copy_tuner_client/prefixed_logger.rb +13 -12
  25. data/lib/copy_tuner_client/process_guard.rb +29 -26
  26. data/lib/copy_tuner_client/queue_with_timeout.rb +29 -19
  27. data/lib/copy_tuner_client/rails.rb +9 -8
  28. data/lib/copy_tuner_client/request_sync.rb +26 -24
  29. data/lib/copy_tuner_client/simple_form_extention.rb +10 -6
  30. data/lib/copy_tuner_client/translation_log.rb +10 -5
  31. data/lib/copy_tuner_client/version.rb +1 -1
  32. data/lib/copy_tuner_client.rb +4 -8
  33. data/lib/tasks/copy_tuner_client_tasks.rake +14 -16
  34. data/skills/copy-tuner-to-locales-cleanup/SKILL.md +5 -5
  35. data/skills/copy-tuner-to-locales-migrate-prefix/SKILL.md +69 -37
  36. data/skills/copy-tuner-to-locales-migrate-prefix/references/example-touchpoints.md +31 -10
  37. data/skills/copy-tuner-to-locales-migrate-prefix/scripts/migrate_prefix.rb +158 -13
  38. data/skills/copy-tuner-to-t-migrate/scripts/migrate_tt.rb +19 -17
  39. data/spec/copy_tuner_client/cache_spec.rb +63 -57
  40. data/spec/copy_tuner_client/client_spec.rb +82 -81
  41. data/spec/copy_tuner_client/configuration_spec.rb +380 -206
  42. data/spec/copy_tuner_client/copyray/marker_spec.rb +2 -1
  43. data/spec/copy_tuner_client/copyray/rewriter_spec.rb +15 -10
  44. data/spec/copy_tuner_client/copyray_middleware_spec.rb +9 -6
  45. data/spec/copy_tuner_client/copyray_spec.rb +8 -8
  46. data/spec/copy_tuner_client/dotted_hash_spec.rb +33 -33
  47. data/spec/copy_tuner_client/helper_extension_spec.rb +47 -44
  48. data/spec/copy_tuner_client/i18n_backend_spec.rb +144 -143
  49. data/spec/copy_tuner_client/poller_spec.rb +25 -24
  50. data/spec/copy_tuner_client/prefixed_logger_spec.rb +15 -12
  51. data/spec/copy_tuner_client/process_guard_spec.rb +27 -27
  52. data/spec/copy_tuner_client/request_sync_spec.rb +51 -58
  53. data/spec/copy_tuner_client/translation_log_spec.rb +38 -24
  54. data/spec/copy_tuner_client/warden_integration_spec.rb +95 -0
  55. data/spec/copy_tuner_client_spec.rb +5 -6
  56. data/spec/support/client_spec_helpers.rb +1 -1
  57. data/spec/support/defines_constants.rb +3 -34
  58. data/spec/support/fake_client.rb +1 -3
  59. data/spec/support/fake_copy_tuner_app.rb +54 -62
  60. data/spec/support/fake_html_safe_string.rb +2 -3
  61. data/spec/support/fake_logger.rb +22 -21
  62. data/spec/support/fake_passenger.rb +4 -6
  63. data/spec/support/fake_unicorn.rb +2 -4
  64. data/spec/support/middleware_stack.rb +38 -4
  65. data/spec/support/writing_cache.rb +4 -4
  66. metadata +2 -1
@@ -9,14 +9,16 @@ module CopyTunerClient
9
9
  #
10
10
  # A client is usually instantiated when {Configuration#apply} is called, and
11
11
  # the application will not need to interact with it directly.
12
- class Client
12
+ class Client # rubocop:disable Metrics/ClassLength
13
13
  # These errors will be rescued when connecting CopyTuner.
14
- HTTP_ERRORS = [Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, EOFError,
15
- Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
16
- Net::ProtocolError, SocketError, OpenSSL::SSL::SSLError,
17
- Errno::ECONNREFUSED]
14
+ HTTP_ERRORS = [
15
+ Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, EOFError,
16
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
17
+ Net::ProtocolError, SocketError, OpenSSL::SSL::SSLError,
18
+ Errno::ECONNREFUSED
19
+ ].freeze
18
20
 
19
- USER_AGENT = "copy_tuner_client #{CopyTunerClient::VERSION}"
21
+ USER_AGENT = "copy_tuner_client #{CopyTunerClient::VERSION}".freeze
20
22
 
21
23
  # ETags を外部から取得可能にする
22
24
  # @return [String, nil] 現在のETag値
@@ -36,9 +38,11 @@ module CopyTunerClient
36
38
  @etag = nil
37
39
  @downloaded_blurbs = {}
38
40
 
39
- [:api_key, :host, :port, :public, :http_read_timeout,
40
- :http_open_timeout, :secure, :logger, :ca_file, :s3_host, :download_cache_dir].each do |option|
41
- instance_variable_set "@#{option}", options[option]
41
+ %i[
42
+ api_key host port public http_read_timeout
43
+ http_open_timeout secure logger ca_file s3_host download_cache_dir
44
+ ].each do |option|
45
+ instance_variable_set("@#{option}", options[option])
42
46
  end
43
47
 
44
48
  @download_cache_dir.mkpath
@@ -59,19 +63,19 @@ module CopyTunerClient
59
63
  connect(s3_host) do |http|
60
64
  request = Net::HTTP::Get.new(uri(download_resource))
61
65
  request['If-None-Match'] = @etag
62
- log 'Start downloading translations'
66
+ log('Start downloading translations')
63
67
  t = Time.now
64
68
  response = http.request(request)
65
69
  t_ms = ((Time.now - t) * 1000).to_i
66
70
  downloaded = check(response)
67
71
  if downloaded
68
72
  # NOTE: Net::HTTPではgzipが透過的に扱われるため正確なファイルサイズや速度をログに出すのは難しい
69
- log "Downloaded translations (#{t_ms}ms)"
73
+ log("Downloaded translations (#{t_ms}ms)")
70
74
  @downloaded_blurbs = JSON.parse(response.body)
71
75
  @etag = response['ETag']
72
76
  last_download_path.write(JSON.pretty_generate(etag: @etag, downloaded_blurbs: @downloaded_blurbs))
73
77
  else
74
- log "No new translations (#{t_ms}ms)"
78
+ log("No new translations (#{t_ms}ms)")
75
79
  end
76
80
 
77
81
  yield(@downloaded_blurbs) if downloaded || cache_fallback
@@ -83,9 +87,10 @@ module CopyTunerClient
83
87
  # @raise [ConnectionError] if the connection fails
84
88
  def upload(data)
85
89
  connect(host) do |http|
86
- response = http.post(uri('draft_blurbs'), data.to_json, 'Content-Type' => 'application/json', 'User-Agent' => USER_AGENT)
87
- check response
88
- log 'Uploaded missing translations'
90
+ headers = { 'Content-Type' => 'application/json', 'User-Agent' => USER_AGENT }
91
+ response = http.post(uri('draft_blurbs'), data.to_json, headers)
92
+ check(response)
93
+ log('Uploaded missing translations')
89
94
  end
90
95
  end
91
96
 
@@ -94,15 +99,22 @@ module CopyTunerClient
94
99
  def deploy
95
100
  connect(host) do |http|
96
101
  response = http.post(uri('deploys'), '', 'User-Agent' => USER_AGENT)
97
- check response
98
- log 'Deployed'
102
+ check(response)
103
+ log('Deployed')
99
104
  end
100
105
  end
101
106
 
102
107
  private
103
108
 
104
- attr_reader :host, :port, :api_key, :http_read_timeout,
105
- :http_open_timeout, :secure, :logger, :ca_file, :s3_host
109
+ attr_reader :host,
110
+ :port,
111
+ :api_key,
112
+ :http_read_timeout,
113
+ :http_open_timeout,
114
+ :secure,
115
+ :logger,
116
+ :ca_file,
117
+ :s3_host
106
118
 
107
119
  def public?
108
120
  @public
@@ -130,7 +142,7 @@ module CopyTunerClient
130
142
  cache = JSON.parse(pathname.read.to_s).transform_keys(&:to_sym)
131
143
  @etag = cache[:etag]
132
144
  @downloaded_blurbs = cache[:downloaded_blurbs]
133
- log "Loaded cache data from #{pathname}"
145
+ log("Loaded cache data from #{pathname}")
134
146
  rescue JSON::JSONError, Errno::ENOENT, Errno::ENOTDIR
135
147
  nil
136
148
  end
@@ -144,9 +156,9 @@ module CopyTunerClient
144
156
  http.ca_file = ca_file
145
157
 
146
158
  begin
147
- yield http
148
- rescue *HTTP_ERRORS => exception
149
- raise ConnectionError, "#{exception.class.name}: #{exception.message}"
159
+ yield(http)
160
+ rescue *HTTP_ERRORS => e
161
+ raise ConnectionError, "#{e.class.name}: #{e.message}"
150
162
  end
151
163
  end
152
164
 
@@ -12,14 +12,16 @@ module CopyTunerClient
12
12
  # Used to set up and modify settings for the client.
13
13
  class Configuration # rubocop:disable Metrics/ClassLength
14
14
  # These options will be present in the Hash returned by {#to_hash}.
15
- OPTIONS = %i[api_key development_environments environment_name host
16
- http_open_timeout http_read_timeout client_name client_url
17
- client_version port protocol proxy_host proxy_pass
18
- proxy_port proxy_user secure polling_delay sync_interval
19
- sync_interval_staging sync_ignore_path_regex logger
20
- framework middleware disable_middleware disable_test_translation
21
- ca_file local_first_key_regexp s3_host locales ignored_keys ignored_key_handler
22
- download_cache_dir].freeze
15
+ OPTIONS = %i[
16
+ api_key development_environments environment_name host
17
+ http_open_timeout http_read_timeout client_name client_url
18
+ client_version port protocol proxy_host proxy_pass
19
+ proxy_port proxy_user secure polling_delay sync_interval
20
+ sync_interval_staging sync_ignore_path_regex logger
21
+ framework middleware disable_middleware disable_test_translation
22
+ ca_file local_first_key_regexp s3_host locales ignored_keys ignored_key_handler
23
+ download_cache_dir
24
+ ].freeze
23
25
 
24
26
  # NOTE: Rails 標準ロケールで非文字列値(precision: Integer, significant: Boolean,
25
27
  # strip_insignificant_zeros: Boolean)を含むのは number.*.format 配下のみ。store_item が文字列しか
@@ -182,6 +184,7 @@ module CopyTunerClient
182
184
  self.local_first_key_regexp = nil
183
185
  self.project_id = nil
184
186
  self.download_cache_dir = Pathname.new(Dir.pwd).join('tmp', 'cache', 'copy_tuner_client')
187
+ self.middleware_position = default_middleware_position
185
188
 
186
189
  @applied = false
187
190
  end
@@ -200,7 +203,7 @@ module CopyTunerClient
200
203
  base_options = { public: public?, upload_disabled: upload_disabled? }
201
204
 
202
205
  OPTIONS.inject(base_options) do |hash, option|
203
- hash.merge option.to_sym => public_send(option)
206
+ hash.merge(option.to_sym => public_send(option))
204
207
  end
205
208
  end
206
209
 
@@ -209,7 +212,7 @@ module CopyTunerClient
209
212
  # @param [Hash] hash A set of configuration options that will take precedence over the defaults
210
213
  # @return [Hash] the merged configuration hash
211
214
  def merge(hash)
212
- to_hash.merge hash
215
+ to_hash.merge(hash)
213
216
  end
214
217
 
215
218
  # Determines if the published or draft content will be used
@@ -222,7 +225,7 @@ module CopyTunerClient
222
225
  # Determines if the content will be editable
223
226
  # @return [Boolean] Returns +true+ if in a development environment, +false+ otherwise.
224
227
  def development?
225
- development_environments.include? environment_name
228
+ development_environments.include?(environment_name)
226
229
  end
227
230
 
228
231
  def enable_middleware?
@@ -252,15 +255,11 @@ module CopyTunerClient
252
255
  # This creates the {I18nBackend} and puts them together.
253
256
  #
254
257
  # When {#test?} returns +false+, the poller will be started.
255
- def apply # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
258
+ def apply # rubocop:disable Metrics/AbcSize
256
259
  # NOTE: project_id は必須。未設定なら apply 時点で明示的に失敗させる
257
260
  validate_project_id!
258
261
 
259
- self.locales ||= self.locales = if defined?(::Rails)
260
- ::Rails.application.config.i18n.available_locales.presence || Array(::Rails.application.config.i18n.default_locale)
261
- else
262
- [:en]
263
- end
262
+ self.locales ||= default_locales
264
263
 
265
264
  self.client ||= Client.new(to_hash)
266
265
  self.cache ||= Cache.new(client, to_hash)
@@ -268,31 +267,12 @@ module CopyTunerClient
268
267
  process_guard = ProcessGuard.new(cache, @poller, to_hash)
269
268
  I18n.backend = I18nBackend.new(cache)
270
269
 
271
- if enable_middleware?
272
- logger.info 'Using copytuner sync middleware'
273
- request_sync_options = { poller: @poller, cache:, interval: sync_interval, ignore_regex: sync_ignore_path_regex }
274
- if middleware_position.is_a?(Hash) && middleware_position[:before]
275
- middleware.insert_before middleware_position[:before], RequestSync, request_sync_options
276
- middleware.insert_before middleware_position[:before], CopyTunerClient::CopyrayMiddleware
277
- elsif middleware_position.is_a?(Hash) && middleware_position[:after]
278
- middleware.insert_after middleware_position[:after], RequestSync, request_sync_options
279
- middleware.insert_after middleware_position[:after], CopyTunerClient::CopyrayMiddleware
280
- else
281
- middleware.use RequestSync, request_sync_options
282
- middleware.use CopyTunerClient::CopyrayMiddleware
283
- end
284
- else
285
- logger.info '[[[Warn]]] Not using copytuner sync middleware' unless middleware
286
- end
270
+ setup_middleware
287
271
 
288
272
  @applied = true
289
- logger.info "Client #{VERSION} ready (s3_download)"
290
- logger.info "Environment Info: #{environment_info}"
291
- logger.info "Available locales: #{self.locales.join(' ')}"
273
+ log_applied
292
274
 
293
- unless test?
294
- process_guard.start
295
- end
275
+ process_guard.start unless test?
296
276
 
297
277
  unless test? && disable_test_translation
298
278
  logger.info 'Download translation now'
@@ -348,7 +328,7 @@ module CopyTunerClient
348
328
  validate_project_id!
349
329
 
350
330
  path = "/projects/#{project_id}"
351
- URI::Generic.build(scheme: self.protocol, host: self.host, port: self.port.to_i, path:).to_s
331
+ URI::Generic.build(scheme: protocol, host:, port: port.to_i, path:).to_s
352
332
  end
353
333
 
354
334
  # locale を除いたキーが local_first_key_regexp にマッチするかを返す。
@@ -369,6 +349,73 @@ module CopyTunerClient
369
349
 
370
350
  private
371
351
 
352
+ def default_locales
353
+ if defined?(::Rails)
354
+ rails_i18n = ::Rails.application.config.i18n
355
+ rails_i18n.available_locales.presence || Array(rails_i18n.default_locale)
356
+ else
357
+ [:en]
358
+ end
359
+ end
360
+
361
+ # throw :warden は Warden::Manager の catch(:warden) までスタックを巻き戻すため、
362
+ # Warden より内側の middleware は @app.call の戻り値を受け取れず、Devise::FailureApp の
363
+ # 応答がマーカー除去を経ずにブラウザへ届いてしまう。これを避けるため Warden の直前を既定位置にする。
364
+ #
365
+ # 判定に Devise の有無も見るのは、warden を require するだけで Warden::Manager を
366
+ # スタックに積まない gem(authtrail 等)が存在するため。定数の有無だけで決めると
367
+ # そうしたアプリで insert_before が対象を見つけられず起動時例外になる。
368
+ # スタックへ積むのは Devise の railtie(config.app_middleware.use)である。
369
+ def default_middleware_position
370
+ return nil unless defined?(::Warden::Manager) && defined?(::Devise)
371
+
372
+ { before: ::Warden::Manager }
373
+ end
374
+
375
+ def setup_middleware
376
+ if enable_middleware?
377
+ logger.info 'Using copytuner sync middleware'
378
+ insert_middleware
379
+ else
380
+ logger.info '[[[Warn]]] Not using copytuner sync middleware' unless middleware
381
+ end
382
+ end
383
+
384
+ def log_applied
385
+ logger.info "Client #{VERSION} ready (s3_download)"
386
+ logger.info "Environment Info: #{environment_info}"
387
+ logger.info "Available locales: #{locales.join(' ')}"
388
+ end
389
+
390
+ def insert_middleware
391
+ # NOTE: 値の nil を除外するのは、{ before: SomeClass if cond } のように条件次第で nil が入る
392
+ # 書き方で従来は末尾 use にフォールバックしていた挙動を保つため(キーの有無だけで分岐すると
393
+ # insert_before(nil) が対象を見つけられず例外になる)。
394
+ case middleware_position
395
+ in { before: target } if target
396
+ middleware.insert_before(target, RequestSync, request_sync_options)
397
+ middleware.insert_before(target, CopyTunerClient::CopyrayMiddleware)
398
+ in { after: target } if target
399
+ # NOTE: insert_after(index, *) は insert(index + 1, *) を呼ぶため、同じ対象へ 2 回挿入すると
400
+ # 後から挿入した方が対象に近い位置に来て順序が反転する。逆順で呼ぶことで
401
+ # 外側→内側が RequestSync → CopyrayMiddleware に揃う。
402
+ middleware.insert_after(target, CopyTunerClient::CopyrayMiddleware)
403
+ middleware.insert_after(target, RequestSync, request_sync_options)
404
+ else
405
+ middleware.use(RequestSync, request_sync_options)
406
+ middleware.use(CopyTunerClient::CopyrayMiddleware)
407
+ end
408
+ end
409
+
410
+ def request_sync_options
411
+ {
412
+ poller: @poller,
413
+ cache:,
414
+ interval: sync_interval,
415
+ ignore_regex: sync_ignore_path_regex,
416
+ }
417
+ end
418
+
372
419
  # project_id は必須。未設定なら明示的に失敗させる。
373
420
  # apply(起動時の全体検証)と project_url(apply を経ない経路へのセーフネット)の両方から呼ぶ。
374
421
  def validate_project_id!
@@ -38,7 +38,7 @@ module CopyTunerClient
38
38
  # NOTE: 閾値超は Nokogiri を通さず可視トークン除去のみ。skipped=true で編集導線を諦めた旨を伝える。
39
39
  return [strip_markers(scannable), true] if scannable.bytesize > MAX_REWRITE_BYTESIZE
40
40
 
41
- [rewrite_with_nokogiri(scannable, fragment: fragment), false]
41
+ [rewrite_with_nokogiri(scannable, fragment:), false]
42
42
  rescue StandardError => e
43
43
  # NOTE: Copyray は開発支援機能なので、壊れた HTML 等で Nokogiri 処理が落ちても
44
44
  # ページを 500 にしない。data-copyray-key 付与(編集導線)は諦め、最低限可視トークンだけ除去する。
@@ -1,6 +1,7 @@
1
1
  require 'copy_tuner_client/copyray/marker'
2
2
 
3
3
  module CopyTunerClient
4
+ # 翻訳テキストにオーバーレイ編集用のマーカートークンを埋め込む
4
5
  class Copyray
5
6
  # This:
6
7
  # message
@@ -3,6 +3,7 @@
3
3
  require 'copy_tuner_client/copyray/rewriter'
4
4
 
5
5
  module CopyTunerClient
6
+ # レスポンス HTML を Rewriter に通し、マーカートークンを data-copyray-key 属性へ変換する Rack middleware
6
7
  class CopyrayMiddleware
7
8
  def initialize(app)
8
9
  @app = app
@@ -11,26 +12,8 @@ module CopyTunerClient
11
12
  def call(env)
12
13
  CopyTunerClient::TranslationLog.clear
13
14
  status, headers, response = @app.call(env)
14
- if rewritable?(status, headers) && body = response_body(response)
15
- csp_nonce = env['action_dispatch.content_security_policy_nonce'] || env['secure_headers_content_security_policy_nonce']
16
- # NOTE: CSS/JS 挿入の前に Rewriter を通す。serialize 後も </body> は必ず出力されるので
17
- # append_to_html_body の rindex は機能し、CSS/JS タグはトークン非含有なので二重処理も起きない。
18
- # NOTE: skipped は data-copyray-key を付与できなかったこと(巨大DOM/Nokogiri例外)を表す。
19
- # JS にこれを伝え、オーバーレイ非対応である旨をツールバーで案内させる。
20
- # NOTE: turbo stream はページ断片なので fragment パーサで走査する(html/body ラッパを付けない)。
21
- turbo_stream = turbo_stream?(headers)
22
- body, skipped = CopyTunerClient::Copyray::Rewriter.rewrite(body, fragment: turbo_stream)
23
- # NOTE: ブートストラップ JS はフルページ読み込み時に一度だけ挿入すればよい。turbo stream 断片には
24
- # 挿入先の </body> も無く、既に初期化済みのページへマージされるだけなので挿入しない。
25
- body = append_js(body, csp_nonce, skipped: skipped) unless turbo_stream
26
- content_length = body.bytesize.to_s
27
- headers['Content-Length'] = content_length
28
- # maintains compatibility with other middlewares
29
- if defined?(ActionDispatch::Response::RackBody) && response.is_a?(ActionDispatch::Response::RackBody)
30
- ActionDispatch::Response.new(status, headers, [body]).to_a
31
- else
32
- [status, headers, [body]]
33
- end
15
+ if rewritable?(status, headers) && (body = response_body(response))
16
+ rewrite_response(env, status, headers, body, response)
34
17
  else
35
18
  [status, headers, response]
36
19
  end
@@ -38,6 +21,30 @@ module CopyTunerClient
38
21
 
39
22
  private
40
23
 
24
+ def rewrite_response(env, status, headers, body, response)
25
+ csp_nonce =
26
+ env.fetch('action_dispatch.content_security_policy_nonce') do
27
+ env['secure_headers_content_security_policy_nonce']
28
+ end
29
+ # NOTE: CSS/JS 挿入の前に Rewriter を通す。serialize 後も </body> は必ず出力されるので
30
+ # append_to_html_body の rindex は機能し、CSS/JS タグはトークン非含有なので二重処理も起きない。
31
+ # NOTE: skipped は data-copyray-key を付与できなかったこと(巨大DOM/Nokogiri例外)を表す。
32
+ # JS にこれを伝え、オーバーレイ非対応である旨をツールバーで案内させる。
33
+ # NOTE: turbo stream はページ断片なので fragment パーサで走査する(html/body ラッパを付けない)。
34
+ turbo_stream = turbo_stream?(headers)
35
+ body, skipped = CopyTunerClient::Copyray::Rewriter.rewrite(body, fragment: turbo_stream)
36
+ # NOTE: ブートストラップ JS はフルページ読み込み時に一度だけ挿入すればよい。turbo stream 断片には
37
+ # 挿入先の </body> も無く、既に初期化済みのページへマージされるだけなので挿入しない。
38
+ body = append_js(body, csp_nonce, skipped:) unless turbo_stream
39
+ headers['Content-Length'] = body.bytesize.to_s
40
+ # maintains compatibility with other middlewares
41
+ if defined?(ActionDispatch::Response::RackBody) && response.is_a?(ActionDispatch::Response::RackBody)
42
+ ActionDispatch::Response.new(status, headers, [body]).to_a
43
+ else
44
+ [status, headers, [body]]
45
+ end
46
+ end
47
+
41
48
  def helpers
42
49
  ActionController::Base.helpers
43
50
  end
@@ -57,7 +64,8 @@ module CopyTunerClient
57
64
  keysSkipped: #{skipped},
58
65
  }
59
66
  SCRIPT
60
- append_to_html_body(html, helpers.javascript_include_tag('copytuner', type: 'module', crossorigin: 'anonymous', nonce: csp_nonce))
67
+ tag = helpers.javascript_include_tag('copytuner', type: 'module', crossorigin: 'anonymous', nonce: csp_nonce)
68
+ append_to_html_body(html, tag)
61
69
  end
62
70
 
63
71
  def append_to_html_body(html, content)
@@ -65,7 +73,7 @@ module CopyTunerClient
65
73
  return html unless html.include?('</body>')
66
74
 
67
75
  position = html.rindex('</body>')
68
- html.insert(position, content + "\n")
76
+ html.insert(position, "#{content}\n")
69
77
  end
70
78
 
71
79
  def file?(headers)
@@ -80,8 +88,7 @@ module CopyTunerClient
80
88
  end
81
89
 
82
90
  def turbo_stream?(headers)
83
- headers['Content-Type'] &&
84
- headers['Content-Type'].include?('text/vnd.turbo-stream.html')
91
+ headers['Content-Type']&.include?('text/vnd.turbo-stream.html')
85
92
  end
86
93
 
87
94
  def response_body(response)
@@ -1,4 +1,5 @@
1
1
  module CopyTunerClient
2
+ # `a.b.c` 形式のドット区切りキーを持つ Hash とネストした Hash を相互変換する
2
3
  module DottedHash
3
4
  def to_h(dotted_hash)
4
5
  hash = {}
@@ -15,18 +16,17 @@ module CopyTunerClient
15
16
 
16
17
  all_keys.each_with_index do |key, index|
17
18
  prefix = "#{key}."
18
- conflict_keys = ((index + 1)..Float::INFINITY)
19
- .take_while { |i| all_keys[i]&.start_with?(prefix) }
20
- .map { |i| all_keys[i] }
19
+ conflict_keys =
20
+ ((index + 1)..Float::INFINITY)
21
+ .take_while { |i| all_keys[i]&.start_with?(prefix) }
22
+ .map { |i| all_keys[i] }
21
23
 
22
- if conflict_keys.present?
23
- results[key] = conflict_keys
24
- end
24
+ results[key] = conflict_keys if conflict_keys.present?
25
25
  end
26
26
 
27
27
  results
28
28
  end
29
29
 
30
- module_function :to_h, :conflict_keys # rubocop:disable Style/AccessModifierDeclarations
30
+ module_function :to_h, :conflict_keys
31
31
  end
32
32
  end
@@ -5,11 +5,11 @@ require 'copy_tuner_client/helper_extension'
5
5
  module CopyTunerClient
6
6
  # Connects to integration points for Rails 3 applications
7
7
  class Engine < ::Rails::Engine
8
- initializer :initialize_copy_tuner_rails, :before => :load_config_initializers do |app|
8
+ initializer :initialize_copy_tuner_rails, before: :load_config_initializers do |_app|
9
9
  CopyTunerClient::Rails.initialize
10
10
  end
11
11
 
12
- initializer :initialize_copy_tuner_hook_methods, :after => :load_config_initializers do |app|
12
+ initializer :initialize_copy_tuner_hook_methods, after: :load_config_initializers do |_app|
13
13
  ActiveSupport.on_load(:action_view) do
14
14
  CopyTunerClient::HelperExtension.hook_translation_helper(
15
15
  ActionView::Helpers::TranslationHelper,
@@ -17,14 +17,12 @@ module CopyTunerClient
17
17
  )
18
18
  end
19
19
 
20
- if CopyTunerClient.configuration.enable_middleware?
21
- CopyTunerClient::TranslationLog.install_hook
22
- end
20
+ CopyTunerClient::TranslationLog.install_hook if CopyTunerClient.configuration.enable_middleware?
23
21
 
24
22
  require 'copy_tuner_client/simple_form_extention'
25
23
  end
26
24
 
27
- initializer "copy_tuner.assets.precompile", group: :all do |app|
25
+ initializer 'copy_tuner.assets.precompile', group: :all do |app|
28
26
  app.config.assets.precompile += ['copytuner.js']
29
27
  end
30
28
  end
@@ -1,67 +1,71 @@
1
1
  module CopyTunerClient
2
+ # ActionView の translate/t を alias_method で差し替え、Copyray のオーバーレイマーカーを注入する
2
3
  module HelperExtension
3
- class << self
4
- def hook_translation_helper(mod, middleware_enabled:)
5
- mod.class_eval do
6
- def translate_with_copyray_comment(key, **options)
7
- source = translate_without_copyray_comment(key, **options)
4
+ # NOTE: class_eval ブロック内で def すると、ブロック内メソッドの複雑さが
5
+ # hook_translation_helper 自体の Metrics/AbcSize としてカウントされてしまうため、
6
+ # メソッド本体は独立したモジュールに切り出して include する。
7
+ module CopyrayCommentInjection
8
+ def translate_with_copyray_comment(key, **options)
9
+ source = translate_without_copyray_comment(key, **options)
8
10
 
9
- if controller && CopyTunerClient::Rails.controller_of_rails_engine?(controller)
10
- return source
11
- end
11
+ return source if controller && CopyTunerClient::Rails.controller_of_rails_engine?(controller)
12
12
 
13
- # TODO: test
14
- # NOTE: default引数が設定されている場合は、copytunerキャッシュの値をI18n.t呼び出しにより上書きしている
15
- # SEE: https://github.com/rails/rails/blob/6c43ebc220428ce9fc9569c2e5df90a38a4fc4e4/actionview/lib/action_view/helpers/translation_helper.rb#L82
16
- if options.key?(:default)
17
- I18n.t(key.to_s.first == '.' ? scope_key_by_partial(key) : key, **options)
18
- end
13
+ # TODO: test
14
+ # NOTE: default引数が設定されている場合は、copytunerキャッシュの値をI18n.t呼び出しにより上書きしている
15
+ # SEE: https://github.com/rails/rails/blob/6c43ebc220428ce9fc9569c2e5df90a38a4fc4e4/actionview/lib/action_view/helpers/translation_helper.rb#L82
16
+ if options.key?(:default)
17
+ I18n.t(key.to_s.first == '.' ? scope_key_by_partial(key) : key, **options)
18
+ end
19
19
 
20
- # NOTE: マーカーは HTML コメントとしてブラウザに無視されつつ Copyray オーバーレイのキー特定に
21
- # 使われる。HTML 以外の経路(メール本文・render :json・CSV/PDF など)ではコメントが文字列として
22
- # 出力に混入してしまうため、それらの経路には注入しない。default 引数による初期値登録は維持する
23
- # 必要があるため、このガードは初期値登録(上の I18n.t 呼び出し)より後に置く。
24
- return source unless copyray_injectable?
20
+ # NOTE: マーカーは HTML コメントとしてブラウザに無視されつつ Copyray オーバーレイのキー特定に
21
+ # 使われる。HTML 以外の経路(メール本文・render :json・CSV/PDF など)ではコメントが文字列として
22
+ # 出力に混入してしまうため、それらの経路には注入しない。default 引数による初期値登録は維持する
23
+ # 必要があるため、このガードは初期値登録(上の I18n.t 呼び出し)より後に置く。
24
+ return source unless copyray_injectable?
25
25
 
26
- if CopyTunerClient.configuration.disable_copyray_comment_injection
27
- source
28
- else
29
- separator = options[:separator] || I18n.default_separator
30
- scope = options[:scope]
31
- scope_key =
32
- if key.to_s.first == '.'
33
- scope_key_by_partial(key)
34
- else
35
- # NOTE: locale prefix無しのkeyが必要のためこうしている
36
- I18n.normalize_keys(nil, key, scope, separator).compact.join(separator)
37
- end
38
- CopyTunerClient::Copyray.augment_template(source, scope_key)
39
- end
40
- end
26
+ if CopyTunerClient.configuration.disable_copyray_comment_injection
27
+ source
28
+ else
29
+ CopyTunerClient::Copyray.augment_template(source, copyray_scope_key(key, options))
30
+ end
31
+ end
41
32
 
42
- # NOTE: HTML 以外の経路(メール本文・render :json・CSV/PDF など)ではマーカーが文字列として
43
- # 出力に混入するため注入しない。判定には controller.request.format を使い、@current_template.format /
44
- # lookup_context.formats のような ActionView の内部実装には依存させない(Rails バージョン間で壊れうるため)。
45
- def copyray_injectable?
46
- current_controller = controller
47
- return false if current_controller.nil?
33
+ def copyray_scope_key(key, options)
34
+ return scope_key_by_partial(key) if key.to_s.first == '.'
48
35
 
49
- # NOTE: mailer は request を持たず request.format で判定できない。かつメール本文への
50
- # マーカー混入は実害が大きいため、controller の型で明示除外する。
51
- return false if defined?(ActionMailer::Base) && current_controller.is_a?(ActionMailer::Base)
36
+ separator = options.fetch(:separator, I18n.default_separator)
37
+ # NOTE: locale prefix無しのkeyが必要のためこうしている
38
+ I18n.normalize_keys(nil, key, options[:scope], separator).compact.join(separator)
39
+ end
40
+ private :copyray_scope_key
52
41
 
53
- # NOTE: request が無い/format html でない経路には注入しない。&. と || false で
54
- # request 不在時も安全に false を返す。
55
- current_controller.request&.format&.html? || false
56
- end
57
- private :copyray_injectable?
42
+ # NOTE: HTML 以外の経路(メール本文・render :json・CSV/PDF など)ではマーカーが文字列として
43
+ # 出力に混入するため注入しない。判定には controller.request.format を使い、@current_template.format /
44
+ # lookup_context.formats のような ActionView の内部実装には依存させない(Rails バージョン間で壊れうるため)。
45
+ def copyray_injectable?
46
+ current_controller = controller
47
+ return false if current_controller.nil?
58
48
 
59
- if middleware_enabled
60
- alias_method :translate_without_copyray_comment, :translate
61
- alias_method :translate, :translate_with_copyray_comment
62
- alias :t :translate
63
- end
64
- end
49
+ # NOTE: mailer は request を持たず request.format で判定できない。かつメール本文への
50
+ # マーカー混入は実害が大きいため、controller の型で明示除外する。
51
+ return false if defined?(ActionMailer::Base) && current_controller.is_a?(ActionMailer::Base)
52
+
53
+ # NOTE: request が無い/format が html でない経路には注入しない。&. と || false で
54
+ # request 不在時も安全に false を返す。
55
+ current_controller.request&.format&.html? || false
56
+ end
57
+ private :copyray_injectable?
58
+ end
59
+ private_constant :CopyrayCommentInjection
60
+
61
+ def self.hook_translation_helper(mod, middleware_enabled:)
62
+ return unless middleware_enabled
63
+
64
+ mod.include(CopyrayCommentInjection)
65
+ mod.class_eval do
66
+ alias_method :translate_without_copyray_comment, :translate
67
+ alias_method :translate, :translate_with_copyray_comment
68
+ alias_method :t, :translate
65
69
  end
66
70
  end
67
71
  end