copy_tuner_client 1.5.0 → 2.1.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 (52) 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 +6 -4
  6. data/README.md +6 -27
  7. data/UPGRADING.md +201 -0
  8. data/app/assets/javascripts/copytuner.js +162 -250
  9. data/biome.json +39 -0
  10. data/index.html +9 -11
  11. data/lib/copy_tuner_client/cache.rb +0 -2
  12. data/lib/copy_tuner_client/configuration.rb +23 -37
  13. data/lib/copy_tuner_client/copyray/marker.rb +24 -0
  14. data/lib/copy_tuner_client/copyray/rewriter.rb +113 -0
  15. data/lib/copy_tuner_client/copyray.rb +11 -4
  16. data/lib/copy_tuner_client/copyray_middleware.rb +10 -8
  17. data/lib/copy_tuner_client/engine.rb +1 -1
  18. data/lib/copy_tuner_client/helper_extension.rb +0 -3
  19. data/lib/copy_tuner_client/i18n_backend.rb +5 -12
  20. data/lib/copy_tuner_client/version.rb +1 -1
  21. data/mise.toml +3 -0
  22. data/package.json +9 -13
  23. data/pnpm-lock.yaml +600 -0
  24. data/skills/copy-tuner/SKILL.md +37 -6
  25. data/skills/copy-tuner-to-locales-cleanup/SKILL.md +4 -4
  26. data/skills/copy-tuner-to-locales-migrate-prefix/references/local-first-regexp.md +4 -9
  27. data/skills/copy-tuner-to-t-migrate/SKILL.md +131 -0
  28. data/skills/copy-tuner-to-t-migrate/scripts/migrate_tt.rb +189 -0
  29. data/spec/copy_tuner_client/cache_spec.rb +2 -10
  30. data/spec/copy_tuner_client/client_spec.rb +1 -0
  31. data/spec/copy_tuner_client/configuration_spec.rb +16 -16
  32. data/spec/copy_tuner_client/copyray/marker_spec.rb +41 -0
  33. data/spec/copy_tuner_client/copyray/rewriter_spec.rb +216 -0
  34. data/spec/copy_tuner_client/copyray_middleware_spec.rb +88 -0
  35. data/spec/copy_tuner_client/copyray_spec.rb +22 -39
  36. data/spec/copy_tuner_client/helper_extension_spec.rb +18 -5
  37. data/spec/copy_tuner_client/i18n_backend_spec.rb +8 -15
  38. data/spec/support/client_spec_helpers.rb +0 -1
  39. data/src/copyray-overlay.ts +125 -0
  40. data/src/copytuner-bar.ts +153 -0
  41. data/src/main.ts +33 -26
  42. data/src/{copyray.css → styles.ts} +112 -136
  43. data/src/util.ts +9 -1
  44. data/tsconfig.json +5 -10
  45. data/vite.config.ts +0 -1
  46. metadata +15 -8
  47. data/.eslintrc.js +0 -12
  48. data/app/assets/stylesheets/copytuner.css +0 -1
  49. data/src/copyray.ts +0 -130
  50. data/src/copytuner_bar.ts +0 -115
  51. data/src/specimen.ts +0 -84
  52. data/yarn.lock +0 -2540
@@ -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,88 @@
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: JS 挿入は Rails の ActionController::Base.helpers に依存するため、
23
+ # Rewriter の効果だけを検証できるよう no-op にスタブする。
24
+ allow(middleware).to receive(:append_js) { |html, *| html }
25
+ end
26
+
27
+ context 'マーカートークンを含む HTML レスポンスのとき' do
28
+ let(:body) { "<html><body><p>#{marker('a.b')}Hello</p></body></html>" }
29
+
30
+ it 'マーカーを data-copyray-key 属性に書き換え、トークンを除去する' do
31
+ _status, _headers, response = middleware.call({})
32
+ result = response.join
33
+
34
+ expect(result).to include('data-copyray-key="a.b"')
35
+ expect(result).not_to match CopyTunerClient::Copyray::Marker::SCAN_REGEXP
36
+ end
37
+
38
+ it '書き換え後のボディから Content-Length を再計算する' do
39
+ _status, out_headers, response = middleware.call({})
40
+ expect(out_headers['Content-Length']).to eq response.join.bytesize.to_s
41
+ end
42
+ end
43
+
44
+ context 'HTML 以外のレスポンスのとき' do
45
+ let(:headers) { { 'Content-Type' => 'application/json' } }
46
+ let(:body) { "{\"x\":\"#{marker('a.b')}\"}" }
47
+
48
+ it '書き換えずにそのまま通過させる' do
49
+ _status, _headers, response = middleware.call({})
50
+ expect(response.join).to eq body
51
+ end
52
+ end
53
+
54
+ describe '#append_js' do
55
+ # NOTE: append_js は private かつ Rails の view ヘルパー(javascript_tag 等)に依存する。
56
+ # トップレベルの no-op スタブを外して実体を呼び、ヘルパーは渡された script 本文をそのまま
57
+ # 返す最小フェイクに差し替えて、window.CopyTuner に keysSkipped が埋まることだけ検証する。
58
+ subject(:script) { middleware.__send__(:append_js, '<html><body></body></html>', nil, skipped: skipped) }
59
+
60
+ let(:fake_helpers) do
61
+ Class.new {
62
+ def javascript_tag(content, **_opts) = content
63
+ def javascript_include_tag(*, **) = ''
64
+ }.new
65
+ end
66
+
67
+ before do
68
+ allow(middleware).to receive(:append_js).and_call_original
69
+ allow(middleware).to receive(:helpers).and_return(fake_helpers)
70
+ end
71
+
72
+ context 'skipped が true のとき' do
73
+ let(:skipped) { true }
74
+
75
+ it 'window.CopyTuner に keysSkipped: true を出力する' do
76
+ expect(script).to include('keysSkipped: true')
77
+ end
78
+ end
79
+
80
+ context 'skipped が false のとき' do
81
+ let(:skipped) { false }
82
+
83
+ it 'window.CopyTuner に keysSkipped: false を出力する' do
84
+ expect(script).to include('keysSkipped: false')
85
+ end
86
+ end
87
+ end
88
+ 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
@@ -26,8 +26,11 @@ describe CopyTunerClient::HelperExtension do
26
26
  module KeywordArgumentsHelper
27
27
  attr_writer :controller
28
28
 
29
+ # NOTE: ActionView の TranslationHelper を模し、.html/_html キーのみ html_safe な訳文を返す。
30
+ # マーカーは平文・html_safe どちらにも注入されるが、html_safe フラグの引き継ぎを検証できるよう両方返し分ける。
29
31
  def translate(key, **options)
30
- "Hello, #{options[:name]}"
32
+ source = "Hello, #{options[:name]}"
33
+ key.to_s.end_with?('.html', '_html') ? source.html_safe : source
31
34
  end
32
35
 
33
36
  def controller
@@ -54,7 +57,17 @@ describe CopyTunerClient::HelperExtension do
54
57
  end
55
58
 
56
59
  it 'works with keyword argument method' do
57
- 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
58
71
  end
59
72
 
60
73
  it 'does not inject the overlay marker for a local_first key' do
@@ -62,9 +75,9 @@ describe CopyTunerClient::HelperExtension do
62
75
  expect(view.translate('views.foo', name: 'World')).to eq 'Hello, World'
63
76
  end
64
77
 
65
- context 'injection guard by request format' do
66
- it 'injects the marker when request.format is html' do
67
- expect(view.translate('some.key', name: 'World')).to eq '<!--COPYRAY some.key-->Hello, World'
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.keyHello, World'
68
81
  end
69
82
 
70
83
  %i[json text csv pdf].each do |format|
@@ -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
@@ -0,0 +1,125 @@
1
+ import { OVERLAY_STYLES } from './styles'
2
+ import { computeBoundingBox } from './util'
3
+
4
+ type OpenCallback = (key: string) => void
5
+
6
+ type Blurb = {
7
+ keys: string[]
8
+ element: Element
9
+ }
10
+
11
+ const findBlurbs = (): Blurb[] =>
12
+ Array.from(document.querySelectorAll('[data-copyray-key]')).map((element) => ({
13
+ // 1 要素に複数キーがカンマ区切りで入りうる(同一テキストノードに複数訳文が連結された場合)
14
+ keys: (element.getAttribute('data-copyray-key') ?? '').split(',').filter(Boolean),
15
+ element,
16
+ }))
17
+
18
+ // オーバーレイ背景・翻訳要素のハイライト枠(specimen)・トグルボタンをまとめて Shadow DOM 内に描画する。
19
+ export class CopyrayOverlay extends HTMLElement {
20
+ #onOpen: OpenCallback = () => {}
21
+ #onToggle: () => void = () => {}
22
+ #backdrop: HTMLDivElement
23
+ #specimens: HTMLDivElement
24
+ #toggleButton: HTMLAnchorElement
25
+
26
+ constructor() {
27
+ super()
28
+ const shadow = this.attachShadow({ mode: 'open' })
29
+
30
+ const style = document.createElement('style')
31
+ style.textContent = OVERLAY_STYLES
32
+ shadow.append(style)
33
+
34
+ this.#backdrop = document.createElement('div')
35
+ this.#backdrop.classList.add('backdrop')
36
+ this.#backdrop.addEventListener('click', () => this.hide())
37
+
38
+ // specimen をページ座標基準で absolute 配置するコンテナ
39
+ this.#specimens = document.createElement('div')
40
+ this.#specimens.classList.add('specimens')
41
+
42
+ this.#toggleButton = document.createElement('a')
43
+ this.#toggleButton.classList.add('toggle-button')
44
+ this.#toggleButton.textContent = 'Open CopyTuner'
45
+ // 旧実装ではトグルボタンが overlay と bar の両方を表示していた。show() ではなく onToggle 経由で表示する。
46
+ this.#toggleButton.addEventListener('click', () => this.#onToggle())
47
+
48
+ shadow.append(this.#backdrop, this.#specimens, this.#toggleButton)
49
+
50
+ // 初期は非表示(背景と specimen を隠す)。トグルボタンは常時表示のため :host([hidden]) は使わず個別制御する。
51
+ this.hide()
52
+ }
53
+
54
+ set onOpen(callback: OpenCallback) {
55
+ this.#onOpen = callback
56
+ }
57
+
58
+ set onToggle(callback: () => void) {
59
+ this.#onToggle = callback
60
+ }
61
+
62
+ get isShowing(): boolean {
63
+ return !this.#backdrop.hidden
64
+ }
65
+
66
+ show() {
67
+ this.reset()
68
+ this.#backdrop.hidden = false
69
+
70
+ for (const { element, keys } of findBlurbs()) {
71
+ const box = this.makeBox(element, keys)
72
+ if (box) {
73
+ this.#specimens.append(box)
74
+ }
75
+ }
76
+ }
77
+
78
+ hide() {
79
+ this.reset()
80
+ this.#backdrop.hidden = true
81
+ }
82
+
83
+ reset() {
84
+ this.#specimens.replaceChildren()
85
+ }
86
+
87
+ private makeBox(element: Element, keys: string[]): HTMLDivElement | null {
88
+ const bounds = computeBoundingBox(element)
89
+ if (bounds === null) return null
90
+
91
+ const box = document.createElement('div')
92
+ box.classList.add('specimen')
93
+ box.style.left = `${bounds.left}px`
94
+ box.style.top = `${bounds.top}px`
95
+ box.style.width = `${bounds.width}px`
96
+ box.style.height = `${bounds.height}px`
97
+
98
+ const { position, top, left } = getComputedStyle(element)
99
+ if (position === 'fixed') {
100
+ box.style.position = 'fixed'
101
+ box.style.top = top
102
+ box.style.left = left
103
+ }
104
+
105
+ // box 全体のクリックは先頭キーを開く(広いクリック領域を維持)。複数キー時は各ラベルから個別に開ける
106
+ box.addEventListener('click', () => this.#onOpen(keys[0]))
107
+
108
+ for (const key of keys) {
109
+ box.append(this.makeLabel(key))
110
+ }
111
+ return box
112
+ }
113
+
114
+ private makeLabel(key: string): HTMLDivElement {
115
+ const label = document.createElement('div')
116
+ label.classList.add('specimen-handle')
117
+ label.textContent = key
118
+ // ラベルのクリックはそのキーを開く。box への伝播を止めて先頭キーとの二重発火を防ぐ
119
+ label.addEventListener('click', (event) => {
120
+ event.stopPropagation()
121
+ this.#onOpen(key)
122
+ })
123
+ return label
124
+ }
125
+ }