copy_tuner_client 1.4.0 → 2.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +2 -2
  3. data/.github/workflows/rspec.yml +1 -1
  4. data/CHANGELOG.md +27 -0
  5. data/CLAUDE.md +2 -3
  6. data/README.md +0 -25
  7. data/UPGRADING.md +201 -0
  8. data/app/assets/javascripts/copytuner.js +78 -73
  9. data/app/assets/stylesheets/copytuner.css +1 -1
  10. data/lib/copy_tuner_client/cache.rb +0 -2
  11. data/lib/copy_tuner_client/configuration.rb +23 -37
  12. data/lib/copy_tuner_client/copyray/marker.rb +24 -0
  13. data/lib/copy_tuner_client/copyray/rewriter.rb +113 -0
  14. data/lib/copy_tuner_client/copyray.rb +11 -4
  15. data/lib/copy_tuner_client/copyray_middleware.rb +10 -2
  16. data/lib/copy_tuner_client/helper_extension.rb +24 -3
  17. data/lib/copy_tuner_client/i18n_backend.rb +5 -12
  18. data/lib/copy_tuner_client/version.rb +1 -1
  19. data/skills/copy-tuner/SKILL.md +37 -6
  20. data/skills/copy-tuner-to-locales-cleanup/SKILL.md +4 -4
  21. data/skills/copy-tuner-to-locales-migrate-prefix/references/local-first-regexp.md +4 -9
  22. data/skills/copy-tuner-to-t-migrate/SKILL.md +131 -0
  23. data/skills/copy-tuner-to-t-migrate/scripts/migrate_tt.rb +189 -0
  24. data/spec/copy_tuner_client/cache_spec.rb +2 -10
  25. data/spec/copy_tuner_client/client_spec.rb +1 -0
  26. data/spec/copy_tuner_client/configuration_spec.rb +16 -16
  27. data/spec/copy_tuner_client/copyray/marker_spec.rb +41 -0
  28. data/spec/copy_tuner_client/copyray/rewriter_spec.rb +216 -0
  29. data/spec/copy_tuner_client/copyray_middleware_spec.rb +89 -0
  30. data/spec/copy_tuner_client/copyray_spec.rb +22 -39
  31. data/spec/copy_tuner_client/helper_extension_spec.rb +97 -5
  32. data/spec/copy_tuner_client/i18n_backend_spec.rb +8 -15
  33. data/spec/support/client_spec_helpers.rb +0 -1
  34. data/src/copyray.css +11 -0
  35. data/src/copyray.ts +10 -29
  36. data/src/copytuner_bar.ts +15 -1
  37. data/src/main.ts +5 -2
  38. data/src/specimen.ts +17 -7
  39. metadata +9 -1
@@ -0,0 +1,216 @@
1
+ require 'spec_helper'
2
+ require 'copy_tuner_client/copyray/marker'
3
+ require 'copy_tuner_client/copyray/rewriter'
4
+
5
+ describe CopyTunerClient::Copyray::Rewriter do
6
+ def marker(key)
7
+ CopyTunerClient::Copyray::Marker.encode(key)
8
+ end
9
+
10
+ def ascii8(str)
11
+ str.dup.force_encoding(Encoding::ASCII_8BIT)
12
+ end
13
+
14
+ describe '.rewrite' do
15
+ # NOTE: rewrite は [html, skipped] を返すが、既存テストの大半は html だけを検証する。
16
+ # 1 回の呼び出しを let でメモ化して共有し、result は html、skipped は 2 要素目を指す。
17
+ let(:rewritten) { described_class.rewrite(html) }
18
+ subject(:result) { rewritten.first }
19
+ let(:skipped) { rewritten.last }
20
+
21
+ context '要素直下の単純なテキストノード' do
22
+ let(:html) { "<html><body><p>#{marker('a.b')}Hello</p></body></html>" }
23
+
24
+ it '親要素に data-copyray-key を付与する' do
25
+ expect(result).to include('data-copyray-key="a.b"')
26
+ end
27
+
28
+ it 'マーカートークンを除去する' do
29
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
30
+ expect(result).to include('>Hello<')
31
+ end
32
+
33
+ it 'skipped が false を返す(属性付与に成功した)' do
34
+ expect(skipped).to be false
35
+ end
36
+ end
37
+
38
+ context 'ActionView が body を html エスケープした平文訳文(トークンは無傷)' do
39
+ # NOTE: 平文訳文は ActionView がエスケープするため body の & < > はエンティティ化するが、
40
+ # トークンの区切り記号 ⟦⟧ は HTML 特殊文字ではないので無傷で残る。Rewriter はこれを拾えること。
41
+ let(:html) { "<html><body><p>#{marker('plain.key')}Hello &amp; &lt;World&gt;</p></body></html>" }
42
+
43
+ it '要素に属性を付与しトークンを除去するが、エスケープ済み body はそのまま残す' do
44
+ expect(Nokogiri::HTML(result).at_css('p')['data-copyray-key']).to eq 'plain.key'
45
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
46
+ expect(result).to include('Hello &amp; &lt;World&gt;')
47
+ end
48
+ end
49
+
50
+ context 'ネストしたインライン要素内のマーカー' do
51
+ let(:html) { "<html><body><p>foo <a>#{marker('link.key')}Hi</a> bar</p></body></html>" }
52
+
53
+ it '最も近い親要素(<a>)に属性を付与する' do
54
+ fragment = Nokogiri::HTML(result)
55
+ a = fragment.at_css('a')
56
+ expect(a['data-copyray-key']).to eq 'link.key'
57
+ expect(fragment.at_css('p')['data-copyray-key']).to be_nil
58
+ end
59
+ end
60
+
61
+ context 'テキスト中間に置かれたマーカー' do
62
+ let(:html) { "<html><body><p>foo #{marker('mid.key')}Hi</p></body></html>" }
63
+
64
+ it '内包する要素に属性を付与する' do
65
+ expect(Nokogiri::HTML(result).at_css('p')['data-copyray-key']).to eq 'mid.key'
66
+ end
67
+ end
68
+
69
+ context '属性値の中のマーカー' do
70
+ let(:html) { %(<html><body><input placeholder="#{marker('search.placeholder')}検索"></body></html>) }
71
+
72
+ it '要素自身に data-copyray-key を付与する' do
73
+ expect(Nokogiri::HTML(result).at_css('input')['data-copyray-key']).to eq 'search.placeholder'
74
+ end
75
+
76
+ it '属性値からトークンを除去する' do
77
+ placeholder = Nokogiri::HTML(result).at_css('input')['placeholder']
78
+ expect(placeholder).to eq '検索'
79
+ end
80
+ end
81
+
82
+ context 'head(title)の中のマーカー' do
83
+ let(:html) { "<html><head><title>#{marker('page.title')}タイトル</title></head><body></body></html>" }
84
+
85
+ it 'トークンを除去する' do
86
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
87
+ expect(Nokogiri::HTML(result).at_css('title').text).to eq 'タイトル'
88
+ end
89
+ end
90
+
91
+ context '同一テキストノード内の複数マーカー' do
92
+ let(:html) { "<html><body><p>#{marker('first.key')}A#{marker('second.key')}B</p></body></html>" }
93
+
94
+ it 'すべてのキーをカンマ区切りで data-copyray-key に保持する' do
95
+ expect(Nokogiri::HTML(result).at_css('p')['data-copyray-key']).to eq 'first.key,second.key'
96
+ end
97
+
98
+ it 'すべてのトークンを除去する' do
99
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
100
+ end
101
+ end
102
+
103
+ context '同一属性値内の複数マーカー' do
104
+ let(:html) { %(<html><body><input placeholder="#{marker('a.key')}x#{marker('b.key')}y"></body></html>) }
105
+
106
+ it 'すべてのキーをカンマ区切りで data-copyray-key に保持する' do
107
+ expect(Nokogiri::HTML(result).at_css('input')['data-copyray-key']).to eq 'a.key,b.key'
108
+ end
109
+
110
+ it '属性値からすべてのトークンを除去する' do
111
+ expect(Nokogiri::HTML(result).at_css('input')['placeholder']).to eq 'xy'
112
+ end
113
+ end
114
+
115
+ context 'マーカーが無い html' do
116
+ let(:html) { '<html><body><p>Nothing to see</p></body></html>' }
117
+
118
+ it 'html を無変形で返す(no-op 高速パス)' do
119
+ expect(result).to eq html
120
+ end
121
+
122
+ it 'skipped が false を返す(変形していない)' do
123
+ expect(skipped).to be false
124
+ end
125
+ end
126
+
127
+ context 'HTML が閾値(MAX_REWRITE_BYTESIZE)を超える場合' do
128
+ # NOTE: マーカーを含みつつ閾値超のサイズにするため、padding でかさ増しする。
129
+ let(:padding) { '<span>x</span>' * ((described_class::MAX_REWRITE_BYTESIZE / 14) + 1) }
130
+ let(:html) { "<html><body><p>#{marker('big.key')}Hello</p>#{padding}</body></html>" }
131
+
132
+ it '閾値を超えるサイズである(前提確認)' do
133
+ expect(html.bytesize).to be > described_class::MAX_REWRITE_BYTESIZE
134
+ end
135
+
136
+ it 'マーカートークンを除去する' do
137
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
138
+ end
139
+
140
+ it 'data-copyray-key を付与しない(Nokogiri を通さない)' do
141
+ expect(result).not_to include(described_class::DATA_ATTR)
142
+ end
143
+
144
+ it 'skipped が true を返す' do
145
+ expect(skipped).to be true
146
+ end
147
+ end
148
+
149
+ context 'ASCII-8BIT に転落したがマーカーを含む body' do
150
+ let(:html) { ascii8("<html><body><p>#{marker('a.b')}日本語</p></body></html>") }
151
+
152
+ it '例外を投げず親要素に属性を付与する' do
153
+ expect { result }.not_to raise_error
154
+ expect(Nokogiri::HTML(result).at_css('p')['data-copyray-key']).to eq 'a.b'
155
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
156
+ end
157
+ end
158
+
159
+ context 'マーカーが無い ASCII-8BIT の body' do
160
+ let(:html) { ascii8('<html><body><p>日本語</p></body></html>') }
161
+
162
+ it '例外を投げず無変形で返す' do
163
+ expect { result }.not_to raise_error
164
+ end
165
+
166
+ it '渡された文字列のエンコーディングを破壊しない' do
167
+ result
168
+ expect(html.encoding).to eq Encoding::ASCII_8BIT
169
+ end
170
+ end
171
+
172
+ context 'rewrite が内部で例外を投げる' do
173
+ # NOTE: 壊れた HTML 等で Nokogiri 処理が例外を投げる状況を模す。
174
+ # Copyray は開発支援機能なので、ここでページを 500 にしない(フォールバック動作)。
175
+ let(:html) { "<html><body><p>#{marker('a.b')}Hello</p></body></html>" }
176
+
177
+ before do
178
+ allow(described_class).to receive(:rewrite_with_nokogiri).and_raise(RuntimeError, 'boom')
179
+ end
180
+
181
+ it '例外を伝播させずトークンだけ除去して返す' do
182
+ expect { result }.not_to raise_error
183
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
184
+ expect(result).to include('<p>Hello</p>')
185
+ end
186
+
187
+ it 'skipped が true を返す(属性付与できなかった)' do
188
+ expect(skipped).to be true
189
+ end
190
+
191
+ it 'logger.warn で例外内容を記録する' do
192
+ logger = double('logger')
193
+ allow(CopyTunerClient.configuration).to receive(:logger).and_return(logger)
194
+ expect(logger).to receive(:warn).with(/Rewriter failed.*RuntimeError.*boom/)
195
+ result
196
+ end
197
+
198
+ it 'logger が nil でもフォールバックが落ちない' do
199
+ allow(CopyTunerClient.configuration).to receive(:logger).and_return(nil)
200
+ expect { result }.not_to raise_error
201
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
202
+ end
203
+ end
204
+
205
+ context '出力にマーカートークンが一切残らない' do
206
+ let(:html) do
207
+ "<html><head><title>#{marker('t')}T</title></head>" \
208
+ "<body><input placeholder=\"#{marker('p')}x\"><p>#{marker('a')}A<a>#{marker('b')}B</a></p></body></html>"
209
+ end
210
+
211
+ it 'シリアライズ後の出力にトークンが残っていない' do
212
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,89 @@
1
+ require 'spec_helper'
2
+ require 'copy_tuner_client/copyray_middleware'
3
+ require 'copy_tuner_client/copyray/marker'
4
+ require 'copy_tuner_client/translation_log'
5
+
6
+ describe CopyTunerClient::CopyrayMiddleware do
7
+ def marker(key)
8
+ CopyTunerClient::Copyray::Marker.encode(key)
9
+ end
10
+
11
+ let(:headers) { { 'Content-Type' => 'text/html' } }
12
+ let(:app) { ->(_env) { [status, headers, [body]] } }
13
+ let(:status) { 200 }
14
+
15
+ subject(:middleware) { described_class.new(app) }
16
+
17
+ before do
18
+ CopyTunerClient.configure do |configuration|
19
+ configuration.project_id = 1
20
+ configuration.client = FakeClient.new
21
+ end
22
+ # NOTE: CSS/JS 挿入は Rails の ActionController::Base.helpers に依存するため、
23
+ # Rewriter の効果だけを検証できるよう no-op にスタブする。
24
+ allow(middleware).to receive(:append_css) { |html, _| html }
25
+ allow(middleware).to receive(:append_js) { |html, *| html }
26
+ end
27
+
28
+ context 'マーカートークンを含む HTML レスポンスのとき' do
29
+ let(:body) { "<html><body><p>#{marker('a.b')}Hello</p></body></html>" }
30
+
31
+ it 'マーカーを data-copyray-key 属性に書き換え、トークンを除去する' do
32
+ _status, _headers, response = middleware.call({})
33
+ result = response.join
34
+
35
+ expect(result).to include('data-copyray-key="a.b"')
36
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
37
+ end
38
+
39
+ it '書き換え後のボディから Content-Length を再計算する' do
40
+ _status, out_headers, response = middleware.call({})
41
+ expect(out_headers['Content-Length']).to eq response.join.bytesize.to_s
42
+ end
43
+ end
44
+
45
+ context 'HTML 以外のレスポンスのとき' do
46
+ let(:headers) { { 'Content-Type' => 'application/json' } }
47
+ let(:body) { "{\"x\":\"#{marker('a.b')}\"}" }
48
+
49
+ it '書き換えずにそのまま通過させる' do
50
+ _status, _headers, response = middleware.call({})
51
+ expect(response.join).to eq body
52
+ end
53
+ end
54
+
55
+ describe '#append_js' do
56
+ # NOTE: append_js は private かつ Rails の view ヘルパー(javascript_tag 等)に依存する。
57
+ # トップレベルの no-op スタブを外して実体を呼び、ヘルパーは渡された script 本文をそのまま
58
+ # 返す最小フェイクに差し替えて、window.CopyTuner に keysSkipped が埋まることだけ検証する。
59
+ subject(:script) { middleware.__send__(:append_js, '<html><body></body></html>', nil, skipped: skipped) }
60
+
61
+ let(:fake_helpers) do
62
+ Class.new {
63
+ def javascript_tag(content, **_opts) = content
64
+ def javascript_include_tag(*, **) = ''
65
+ }.new
66
+ end
67
+
68
+ before do
69
+ allow(middleware).to receive(:append_js).and_call_original
70
+ allow(middleware).to receive(:helpers).and_return(fake_helpers)
71
+ end
72
+
73
+ context 'skipped が true のとき' do
74
+ let(:skipped) { true }
75
+
76
+ it 'window.CopyTuner に keysSkipped: true を出力する' do
77
+ expect(script).to include('keysSkipped: true')
78
+ end
79
+ end
80
+
81
+ context 'skipped が false のとき' do
82
+ let(:skipped) { false }
83
+
84
+ it 'window.CopyTuner に keysSkipped: false を出力する' do
85
+ expect(script).to include('keysSkipped: false')
86
+ end
87
+ end
88
+ end
89
+ end
@@ -7,62 +7,45 @@ describe CopyTunerClient::Copyray do
7
7
 
8
8
  let(:key) { 'en.test.key' }
9
9
 
10
- shared_examples 'Not escaped' do
11
- it { is_expected.to be_html_safe }
12
- it { is_expected.to eq "<!--COPYRAY #{key}--><b>Hello</b>" }
13
- end
14
-
15
- context 'html_escape option is false' do
16
- before do
17
- CopyTunerClient.configure do |configuration|
18
- configuration.html_escape = false
19
- configuration.client = FakeClient.new
20
- end
10
+ before do
11
+ CopyTunerClient.configure do |configuration|
12
+ configuration.project_id = 1
13
+ configuration.client = FakeClient.new
21
14
  end
15
+ end
22
16
 
23
- context 'string not marked as html safe' do
24
- let(:source) { FakeHtmlSafeString.new('<b>Hello</b>') }
17
+ context 'when the source is html_safe (e.g. _html key)' do
18
+ let(:source) { FakeHtmlSafeString.new('<b>Hello</b>').html_safe }
25
19
 
26
- it_behaves_like 'Not escaped'
20
+ it 'keeps the html_safe flag so the translation is not re-escaped' do
21
+ is_expected.to be_html_safe
27
22
  end
28
23
 
29
- context 'string marked as html safe' do
30
- let(:source) { FakeHtmlSafeString.new('<b>Hello</b>').html_safe }
31
-
32
- it_behaves_like 'Not escaped'
24
+ it 'prepends the visible marker token without escaping' do
25
+ is_expected.to eq '⟦CT:en.test.key⟧<b>Hello</b>'
33
26
  end
34
27
  end
35
28
 
36
- context 'html_escape option is true' do
37
- before do
38
- CopyTunerClient.configure do |configuration|
39
- configuration.html_escape = true
40
- configuration.client = FakeClient.new
41
- end
42
- end
43
-
44
- context 'string not marked as html safe' do
45
- let(:source) { FakeHtmlSafeString.new('<b>Hello</b>') }
29
+ context 'when the source is plain text (not html_safe)' do
30
+ let(:source) { FakeHtmlSafeString.new('Hello & <World>') }
46
31
 
47
- it { is_expected.to be_html_safe }
48
- it { is_expected.to eq "<!--COPYRAY #{key}-->&lt;b&gt;Hello&lt;/b&gt;" }
49
- end
50
-
51
- context 'string marked as html safe' do
52
- let(:source) { FakeHtmlSafeString.new('<b>Hello</b>').html_safe }
53
-
54
- it_behaves_like 'Not escaped'
32
+ it 'prepends the marker but keeps the source non html_safe so ActionView still escapes the body' do
33
+ is_expected.to eq '⟦CT:en.test.keyHello & <World>'
34
+ is_expected.not_to be_html_safe
55
35
  end
56
36
  end
57
37
 
58
38
  context 'when the key matches local_first_key_regexp' do
59
- let(:source) { 'Hello' }
60
39
  let(:key) { 'views.foo' }
61
40
 
62
41
  before { CopyTunerClient.configuration.local_first_key_regexp = /\Aviews\./ }
63
42
 
64
- it 'does not inject the overlay marker' do
65
- is_expected.to eq 'Hello'
43
+ it 'does not inject the marker into a plain source' do
44
+ expect(CopyTunerClient::Copyray.augment_template('Hello', key)).to eq 'Hello'
45
+ end
46
+
47
+ it 'does not inject the marker into an html_safe source' do
48
+ expect(CopyTunerClient::Copyray.augment_template('Hello'.html_safe, key)).to eq 'Hello'
66
49
  end
67
50
  end
68
51
  end
@@ -3,13 +3,42 @@ require 'copy_tuner_client/helper_extension'
3
3
  require 'copy_tuner_client/copyray'
4
4
 
5
5
  describe CopyTunerClient::HelperExtension do
6
+ # NOTE: helper_extension が参照する CopyTunerClient::Rails は engine への依存があり
7
+ # 単体 spec では require できないため、メソッドをスタブできる最小の入れ物だけ用意する。
8
+ module CopyTunerClient
9
+ module Rails
10
+ def self.controller_of_rails_engine?(_controller)
11
+ false
12
+ end
13
+ end
14
+ end
15
+
16
+ # NOTE: request.format で描画フォーマットを判定するため、format を差し替えられる
17
+ # 最小のフェイク request / controller を用意する。mailer 判定は controller の型で行う。
18
+ Format = Struct.new(:type) do
19
+ def html?
20
+ type == :html
21
+ end
22
+ end
23
+ Request = Struct.new(:format)
24
+ Controller = Struct.new(:request)
25
+
6
26
  module KeywordArgumentsHelper
27
+ attr_writer :controller
28
+
29
+ # NOTE: ActionView の TranslationHelper を模し、.html/_html キーのみ html_safe な訳文を返す。
30
+ # マーカーは平文・html_safe どちらにも注入されるが、html_safe フラグの引き継ぎを検証できるよう両方返し分ける。
7
31
  def translate(key, **options)
8
- "Hello, #{options[:name]}"
32
+ source = "Hello, #{options[:name]}"
33
+ key.to_s.end_with?('.html', '_html') ? source.html_safe : source
9
34
  end
10
35
 
11
36
  def controller
12
- nil
37
+ return @controller if defined?(@controller)
38
+
39
+ # NOTE: 実 HTML 描画では controller が存在し request.format が html になるため、
40
+ # デフォルトはそれを再現した controller。
41
+ @controller = Controller.new(Request.new(Format.new(:html)))
13
42
  end
14
43
  end
15
44
 
@@ -19,14 +48,77 @@ describe CopyTunerClient::HelperExtension do
19
48
 
20
49
  CopyTunerClient::HelperExtension.hook_translation_helper(KeywordArgumentsHelper, middleware_enabled: true)
21
50
 
51
+ let(:view) { KeywordArgumentsView.new }
52
+
53
+ before do
54
+ # NOTE: controller_of_rails_engine? は ::Rails::Engine への依存があり単体 spec では評価できないため、
55
+ # この spec の関心(注入ガード)に絞って常に false を返すようスタブする。
56
+ allow(CopyTunerClient::Rails).to receive(:controller_of_rails_engine?).and_return(false)
57
+ end
58
+
22
59
  it 'works with keyword argument method' do
23
- view = KeywordArgumentsView.new
24
- expect(view.translate('some.key', name: 'World')).to eq '<!--COPYRAY some.key-->Hello, World'
60
+ expect(view.translate('some.key_html', name: 'World')).to eq '⟦CT:some.key_html⟧Hello, World'
61
+ end
62
+
63
+ it 'injects the marker into a plain (non html_safe) translation, keeping it non html_safe' do
64
+ result = view.translate('some.key', name: 'World')
65
+ expect(result).to eq '⟦CT:some.key⟧Hello, World'
66
+ expect(result).not_to be_html_safe
67
+ end
68
+
69
+ it 'keeps the html_safe flag for an _html key so the body is not re-escaped' do
70
+ expect(view.translate('some.key_html', name: 'World')).to be_html_safe
25
71
  end
26
72
 
27
73
  it 'does not inject the overlay marker for a local_first key' do
28
74
  CopyTunerClient.configuration.local_first_key_regexp = /\Aviews\./
29
- view = KeywordArgumentsView.new
30
75
  expect(view.translate('views.foo', name: 'World')).to eq 'Hello, World'
31
76
  end
77
+
78
+ context 'injection guard by rendering context' do
79
+ it 'injects the marker when request.format is :html' do
80
+ expect(view.translate('some.key', name: 'World')).to eq '⟦CT:some.key⟧Hello, World'
81
+ end
82
+
83
+ %i[json text csv pdf].each do |format|
84
+ it "does not inject the marker when request.format is :#{format}" do
85
+ view.controller = Controller.new(Request.new(Format.new(format)))
86
+ expect(view.translate('some.key', name: 'World')).to eq 'Hello, World'
87
+ end
88
+ end
89
+ end
90
+
91
+ context 'injection guard by controller' do
92
+ it 'does not inject the marker when rendered by a mailer' do
93
+ stub_const('ActionMailer::Base', Class.new)
94
+ view.controller = ActionMailer::Base.new
95
+ expect(view.translate('some.key', name: 'World')).to eq 'Hello, World'
96
+ end
97
+
98
+ it 'does not inject the marker when controller is nil' do
99
+ view.controller = nil
100
+ expect(view.translate('some.key', name: 'World')).to eq 'Hello, World'
101
+ end
102
+
103
+ it 'does not inject the marker when the controller has no request' do
104
+ view.controller = Controller.new(nil)
105
+ expect(view.translate('some.key', name: 'World')).to eq 'Hello, World'
106
+ end
107
+
108
+ it 'does not raise when ActionMailer is not loaded' do
109
+ hide_const('ActionMailer::Base') if defined?(ActionMailer::Base)
110
+ view.controller = Controller.new(Request.new(Format.new(:html)))
111
+ expect { view.translate('some.key', name: 'World') }.not_to raise_error
112
+ end
113
+ end
114
+
115
+ # NOTE: マーカー注入を抑止する非 HTML 経路でも、default 引数による初期値登録(I18n.t 呼び出し)は
116
+ # 維持されなければならない。注入ガードが初期値登録まで巻き添えで止めていないことを保証する。
117
+ context 'default value registration' do
118
+ it 'registers the default value even when the marker is not injected' do
119
+ view.controller = Controller.new(Request.new(Format.new(:json)))
120
+ expect(I18n).to receive(:t).with('some.key', hash_including(default: 'Default'))
121
+ view.translate('some.key', name: 'World', default: 'Default')
122
+ end
123
+ end
32
124
  end
@@ -138,12 +138,20 @@ describe 'CopyTunerClient::I18nBackend' do
138
138
  expect(cache['en.test.key']).to eq 'default %{interpolate}'
139
139
  end
140
140
 
141
+ # NOTE: backend は html_safe 化をしない(.html/_html キーの html_safe 化は ActionView の
142
+ # TranslationHelper が担う)。html_escape 設定の有無に関わらず素の content を返す i18n 標準準拠の挙動。
141
143
  it 'html safeを付与しないこと' do
142
144
  cache['en.test.key'] = FakeHtmlSafeString.new("Hello")
143
145
  backend = build_backend
144
146
  expect(backend.translate('en', 'test.key')).to_not be_html_safe
145
147
  end
146
148
 
149
+ it 'html_safe な値を渡しても backend が独自に html_safe 化しないこと' do
150
+ cache['en.test.key'] = FakeHtmlSafeString.new("Hello").html_safe
151
+ backend = build_backend
152
+ expect(backend.translate('en', 'test.key')).to be_html_safe
153
+ end
154
+
147
155
  it 'defaultが配列の場合に順に検索できること' do
148
156
  cache['en.key.one'] = "Expected"
149
157
  backend = build_backend
@@ -151,21 +159,6 @@ describe 'CopyTunerClient::I18nBackend' do
151
159
  to eq('Expected')
152
160
  end
153
161
 
154
- context 'html_escapeオプションがtrueの場合' do
155
- before do
156
- CopyTunerClient.configure do |configuration|
157
- configuration.html_escape = true
158
- configuration.client = FakeClient.new
159
- end
160
- end
161
-
162
- it 'html safeを付与しないこと' do
163
- cache['en.test.key'] = FakeHtmlSafeString.new("Hello")
164
- backend = build_backend
165
- expect(backend.translate('en', 'test.key')).not_to be_html_safe
166
- end
167
- end
168
-
169
162
  context '非文字列キーの場合' do
170
163
  it 'キャッシュに登録されないこと' do
171
164
  expect { subject.translate('en', {}) }.to throw_symbol(:exception)
@@ -4,7 +4,6 @@ module ClientSpecHelpers
4
4
  CopyTunerClient.configure(false) do |config|
5
5
  config.api_key = 'abc123'
6
6
  config.s3_host = 'copy-tuner.com'
7
- config.html_escape = true
8
7
  end
9
8
  end
10
9
  end
data/src/copyray.css CHANGED
@@ -79,10 +79,12 @@
79
79
 
80
80
  .copyray-specimen-handle {
81
81
  float: left;
82
+ margin: 0 2px 2px 0;
82
83
  background: #fff;
83
84
  padding: 0 3px;
84
85
  color: #333;
85
86
  font-size: 10px;
87
+ cursor: pointer;
86
88
  }
87
89
 
88
90
  .copyray-specimen-handle.Specimen {
@@ -187,6 +189,15 @@ a.copyray-toggle-button:hover {
187
189
  background-color: #555;
188
190
  }
189
191
 
192
+ .copy-tuner-bar__notice {
193
+ display: inline-block;
194
+ margin: 8px;
195
+ font-size: 13px;
196
+ line-height: 24px;
197
+ vertical-align: middle;
198
+ color: #ffd24d;
199
+ }
200
+
190
201
  input[type='text'].copy-tuner-bar__search {
191
202
  -webkit-appearance: none;
192
203
  -moz-appearance: none;
data/src/copyray.ts CHANGED
@@ -1,35 +1,16 @@
1
1
  import CopyTunerBar from './copytuner_bar'
2
2
  import Specimen from './specimen'
3
3
 
4
- const findBlurbs = () => {
5
- const filterNone = () => NodeFilter.FILTER_ACCEPT
6
-
7
- // @ts-expect-error TS2554
8
- const iterator = document.createNodeIterator(document.body, NodeFilter.SHOW_COMMENT, filterNone, false)
9
-
10
- const comments = []
11
- let curNode
12
-
13
- while ((curNode = iterator.nextNode())) {
14
- comments.push(curNode)
15
- }
16
-
17
- return (
18
- comments
19
- // @ts-expect-error TS2531
20
- .filter((comment) => comment.nodeValue.startsWith('COPYRAY'))
21
- .map((comment) => {
22
- // @ts-expect-error TS2488
23
- const [, key] = comment.nodeValue.match(/^COPYRAY (\S*)$/)
24
- const element = comment.parentNode
25
- return { key, element }
26
- })
27
- )
28
- }
4
+ const findBlurbs = () =>
5
+ Array.from(document.querySelectorAll('[data-copyray-key]')).map((element) => ({
6
+ // 1 要素に複数キーがカンマ区切りで入りうる(同一テキストノードに複数訳文が連結された場合)
7
+ keys: (element.getAttribute('data-copyray-key') ?? '').split(',').filter(Boolean),
8
+ element,
9
+ }))
29
10
 
30
11
  export default class Copyray {
31
12
  // @ts-expect-error TS7006
32
- constructor(baseUrl, data) {
13
+ constructor(baseUrl, data, keysSkipped = false) {
33
14
  // @ts-expect-error TS2339
34
15
  this.baseUrl = baseUrl
35
16
  // @ts-expect-error TS2339
@@ -46,7 +27,7 @@ export default class Copyray {
46
27
  this.boundOpen = this.open.bind(this)
47
28
 
48
29
  // @ts-expect-error TS2339
49
- this.copyTunerBar = new CopyTunerBar(document.querySelector('#copy-tuner-bar'), this.data, this.boundOpen)
30
+ this.copyTunerBar = new CopyTunerBar(document.querySelector('#copy-tuner-bar'), this.data, this.boundOpen, keysSkipped)
50
31
  }
51
32
 
52
33
  show() {
@@ -93,9 +74,9 @@ export default class Copyray {
93
74
  }
94
75
 
95
76
  makeSpecimens() {
96
- for (const { element, key } of findBlurbs()) {
77
+ for (const { element, keys } of findBlurbs()) {
97
78
  // @ts-expect-error TS2339
98
- this.specimens.push(new Specimen(element, key, this.boundOpen))
79
+ this.specimens.push(new Specimen(element, keys, this.boundOpen))
99
80
  }
100
81
  }
101
82