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.
- checksums.yaml +4 -4
- data/.gitattributes +2 -2
- data/.github/workflows/rspec.yml +1 -1
- data/CHANGELOG.md +27 -0
- data/CLAUDE.md +6 -4
- data/README.md +6 -27
- data/UPGRADING.md +201 -0
- data/app/assets/javascripts/copytuner.js +162 -250
- data/biome.json +39 -0
- data/index.html +9 -11
- data/lib/copy_tuner_client/cache.rb +0 -2
- data/lib/copy_tuner_client/configuration.rb +23 -37
- data/lib/copy_tuner_client/copyray/marker.rb +24 -0
- data/lib/copy_tuner_client/copyray/rewriter.rb +113 -0
- data/lib/copy_tuner_client/copyray.rb +11 -4
- data/lib/copy_tuner_client/copyray_middleware.rb +10 -8
- data/lib/copy_tuner_client/engine.rb +1 -1
- data/lib/copy_tuner_client/helper_extension.rb +0 -3
- data/lib/copy_tuner_client/i18n_backend.rb +5 -12
- data/lib/copy_tuner_client/version.rb +1 -1
- data/mise.toml +3 -0
- data/package.json +9 -13
- data/pnpm-lock.yaml +600 -0
- data/skills/copy-tuner/SKILL.md +37 -6
- data/skills/copy-tuner-to-locales-cleanup/SKILL.md +4 -4
- data/skills/copy-tuner-to-locales-migrate-prefix/references/local-first-regexp.md +4 -9
- data/skills/copy-tuner-to-t-migrate/SKILL.md +131 -0
- data/skills/copy-tuner-to-t-migrate/scripts/migrate_tt.rb +189 -0
- data/spec/copy_tuner_client/cache_spec.rb +2 -10
- data/spec/copy_tuner_client/client_spec.rb +1 -0
- data/spec/copy_tuner_client/configuration_spec.rb +16 -16
- data/spec/copy_tuner_client/copyray/marker_spec.rb +41 -0
- data/spec/copy_tuner_client/copyray/rewriter_spec.rb +216 -0
- data/spec/copy_tuner_client/copyray_middleware_spec.rb +88 -0
- data/spec/copy_tuner_client/copyray_spec.rb +22 -39
- data/spec/copy_tuner_client/helper_extension_spec.rb +18 -5
- data/spec/copy_tuner_client/i18n_backend_spec.rb +8 -15
- data/spec/support/client_spec_helpers.rb +0 -1
- data/src/copyray-overlay.ts +125 -0
- data/src/copytuner-bar.ts +153 -0
- data/src/main.ts +33 -26
- data/src/{copyray.css → styles.ts} +112 -136
- data/src/util.ts +9 -1
- data/tsconfig.json +5 -10
- data/vite.config.ts +0 -1
- metadata +15 -8
- data/.eslintrc.js +0 -12
- data/app/assets/stylesheets/copytuner.css +0 -1
- data/src/copyray.ts +0 -130
- data/src/copytuner_bar.ts +0 -115
- data/src/specimen.ts +0 -84
- 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 & <World></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 & <World>')
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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 '
|
|
37
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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.key⟧Hello & <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
|
|
65
|
-
|
|
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.
|
|
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
|
|
66
|
-
it 'injects the marker when request.format is html' do
|
|
67
|
-
expect(view.translate('some.key', name: 'World')).to eq '
|
|
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'
|
|
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)
|
|
@@ -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
|
+
}
|