copy_tuner_client 1.5.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.
- checksums.yaml +4 -4
- data/.gitattributes +2 -2
- data/.github/workflows/rspec.yml +1 -1
- data/CHANGELOG.md +27 -0
- data/CLAUDE.md +2 -3
- data/README.md +0 -25
- data/UPGRADING.md +201 -0
- data/app/assets/javascripts/copytuner.js +78 -73
- data/app/assets/stylesheets/copytuner.css +1 -1
- 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 -2
- 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/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 +89 -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.css +11 -0
- data/src/copyray.ts +10 -29
- data/src/copytuner_bar.ts +15 -1
- data/src/main.ts +5 -2
- data/src/specimen.ts +17 -7
- metadata +9 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# copy_tuner_client v2.0.0 移行: tt → t 置換スクリプト(SKILL.md 手順 1〜2)。
|
|
4
|
+
#
|
|
5
|
+
# copy_tuner_client が生やしていた独自ヘルパー tt は PR #122 で削除された。gem を上げた
|
|
6
|
+
# アプリのビューに tt(...) が残ると NoMethodError になるため t へ移す。大半は機械的に
|
|
7
|
+
# tt( → t( で済むが、訳文を文字列加工している箇所だけは素朴に t へ変えると開発環境で
|
|
8
|
+
# マーカートークン混入バグが再発する(skills/copy-tuner/SKILL.md の罠を参照)。そこは
|
|
9
|
+
# I18n.t(絶対キー)へ変換すべきなので、このスクリプトは自動変換せず「怪しい」として抽出する。
|
|
10
|
+
#
|
|
11
|
+
# Rails context は不要なので素の ruby で動く:
|
|
12
|
+
#
|
|
13
|
+
# ruby skills/copy-tuner-to-t-migrate/scripts/migrate_tt.rb --report
|
|
14
|
+
# ruby skills/copy-tuner-to-t-migrate/scripts/migrate_tt.rb --apply-safe
|
|
15
|
+
#
|
|
16
|
+
# 出力する 3 分類:
|
|
17
|
+
# (1) safe : 文字列加工されていない tt 呼び出し。tt( → t( に決定論的変換できる。
|
|
18
|
+
# (2) suspicious : 戻り値が同一行で文字列加工されている tt 呼び出し。I18n.t 提案・要確認。
|
|
19
|
+
# (3) other : app 外 / tt を含む別識別子の誤検出など。自動変換せず目視で確認。
|
|
20
|
+
#
|
|
21
|
+
# 判定はヒューリスティック(完全な AST 解析ではない)。誤判定のコストは
|
|
22
|
+
# safe 誤分類(怪しいのに safe)= マーカー混入バグ再発 → 重大
|
|
23
|
+
# suspicious 誤分類(安全なのに suspicious)= 人間が 1 件確認するだけ → 軽微
|
|
24
|
+
# なので、疑わしきは suspicious に倒す。
|
|
25
|
+
|
|
26
|
+
require 'optparse'
|
|
27
|
+
|
|
28
|
+
# NOTE: 中断を呼び出し側(Bash 手順)へ確実に伝えるため warn + exit(1) を使う。
|
|
29
|
+
def die(message)
|
|
30
|
+
warn message
|
|
31
|
+
exit(1)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# ---- 引数 ----
|
|
35
|
+
options = { mode: :report, root: '.' }
|
|
36
|
+
parser =
|
|
37
|
+
OptionParser.new do |opts|
|
|
38
|
+
opts.banner = 'Usage: ruby migrate_tt.rb [--report | --apply-safe] [--root DIR]'
|
|
39
|
+
opts.on('--report', '3 分類を出力する(既定)。ファイルは変更しない') { options[:mode] = :report }
|
|
40
|
+
opts.on('--apply-safe', 'safe 分類のみ tt( → t( を実ファイルに適用する') { options[:mode] = :apply_safe }
|
|
41
|
+
opts.on('--root DIR', 'リポジトリルート(既定: カレントディレクトリ)') { |v| options[:root] = v }
|
|
42
|
+
opts.on('--json', 'report を JSON で出力する(機械処理用)') { options[:json] = true }
|
|
43
|
+
opts.on('-h', '--help', 'ヘルプ') do
|
|
44
|
+
puts opts
|
|
45
|
+
exit(0)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
parser.parse!(ARGV)
|
|
49
|
+
|
|
50
|
+
ROOT = File.expand_path(options[:root])
|
|
51
|
+
die("ルートが見つからない: #{ROOT}") unless File.directory?(ROOT)
|
|
52
|
+
|
|
53
|
+
# 対象: app 配下の .rb / .haml / .erb。それ以外のヒットは other 扱いで提示のみ。
|
|
54
|
+
APP_GLOB = File.join(ROOT, 'app', '**', '*.{rb,haml,erb}')
|
|
55
|
+
# app 外も走査して other に入れる(lib/ 等の取りこぼし提示用)。
|
|
56
|
+
ALL_GLOB = File.join(ROOT, '**', '*.{rb,haml,erb}')
|
|
57
|
+
|
|
58
|
+
# 単語境界の tt 呼び出し。tt( だけでなく haml の `= tt '...'`(括弧なし)も拾う。
|
|
59
|
+
# 直前が識別子文字(attr/http/setting 等の一部や `.tt` のメソッド呼び出し)でないことを (?<![\w.]) で担保。
|
|
60
|
+
TT_CALL = /(?<![\w.])tt(\s*\(|\s+['":@])/
|
|
61
|
+
|
|
62
|
+
# tt の「定義」側(呼び出しではない)。アプリが後方互換で `def tt` を生やしている場合がある。
|
|
63
|
+
# これを safe として t( へ機械置換すると Rails の t を再定義してしまい破滅的なので、定義は別扱いにする。
|
|
64
|
+
TT_DEFINITION = /\b(?:def\s+(?:self\.)?tt\b|alias(?:_method)?\s+:?tt\b|alias(?:_method)?\s+['"]tt['"])/
|
|
65
|
+
|
|
66
|
+
# tt(...) の戻り値が同一行で文字列加工されているかの判定に使う語彙。
|
|
67
|
+
STRING_HELPERS = %w[truncate simple_format strip_tags highlight excerpt word_wrap].freeze
|
|
68
|
+
STRING_METHODS = %w[
|
|
69
|
+
length size bytesize slice first last truncate truncate_words
|
|
70
|
+
gsub sub gsub! sub! strip lstrip rstrip chomp chop chars bytes lines
|
|
71
|
+
scan match match? =~ start_with? end_with? include? index rindex
|
|
72
|
+
upcase downcase capitalize ljust rjust center delete squeeze tr
|
|
73
|
+
to_i to_f to_sym html_safe
|
|
74
|
+
].freeze
|
|
75
|
+
|
|
76
|
+
# (a) truncate( ... tt( ... ) のように文字列加工ヘルパーの引数になっている
|
|
77
|
+
STRING_HELPER_RE = /\b(?:#{STRING_HELPERS.join('|')})\s*\(\s*[^)]*\btt\b/
|
|
78
|
+
# (b) tt(...) または tt '...' の直後に .method / =~ / [ が続く。引数の閉じ括弧をまたぐ単純近似。
|
|
79
|
+
STRING_METHOD_RE = /\btt\b\s*(?:\([^\n]*?\)|['":@][^\n,]*)\s*(?:\.\s*(?:#{STRING_METHODS.join('|')})\b|=~|\[)/
|
|
80
|
+
|
|
81
|
+
# 先頭ドットの相対キー tt('.foo') は I18n.t では解決できず絶対キー化が要る → 強調表示用。
|
|
82
|
+
RELATIVE_KEY_RE = /\btt\b\s*(?:\(\s*)?['":]\s*\./
|
|
83
|
+
|
|
84
|
+
def relative(path)
|
|
85
|
+
path.sub("#{ROOT}/", '')
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
safe = []
|
|
89
|
+
suspicious = []
|
|
90
|
+
other = []
|
|
91
|
+
|
|
92
|
+
app_set = Dir.glob(APP_GLOB).to_set
|
|
93
|
+
|
|
94
|
+
# 1 ファイル分を走査し、ヒット行を safe / suspicious / other へ振り分ける。
|
|
95
|
+
def scan_file(path, in_app:)
|
|
96
|
+
rel = relative(path)
|
|
97
|
+
File.readlines(path).each_with_index do |line, idx|
|
|
98
|
+
next unless line =~ TT_CALL
|
|
99
|
+
|
|
100
|
+
entry = { file: rel, lineno: idx + 1, code: line.rstrip }
|
|
101
|
+
yield(classify_line(line, entry, in_app: in_app))
|
|
102
|
+
end
|
|
103
|
+
rescue StandardError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# ヒット 1 行を分類し、[:safe | :suspicious | :other, entry] を返す。
|
|
108
|
+
def classify_line(line, entry, in_app:)
|
|
109
|
+
# tt の定義(def/alias)は呼び出しではない。t( へ置換すると Rails の t を壊すので絶対に自動変換しない。
|
|
110
|
+
if line.match?(TT_DEFINITION)
|
|
111
|
+
entry[:reason] = 'tt の定義(def/alias)。自動変換禁止。呼び出しを全て t へ移した後に手動で削除する'
|
|
112
|
+
return [:other, entry]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
unless in_app
|
|
116
|
+
entry[:reason] = 'app 外(要目視)'
|
|
117
|
+
return [:other, entry]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
return [:safe, entry] unless line.match?(STRING_HELPER_RE) || line.match?(STRING_METHOD_RE)
|
|
121
|
+
|
|
122
|
+
entry[:relative_key] = line.match?(RELATIVE_KEY_RE)
|
|
123
|
+
entry[:hint] = if entry[:relative_key]
|
|
124
|
+
'I18n.t へ。相対キー(.foo)は絶対キーへ書き換えが必要'
|
|
125
|
+
else
|
|
126
|
+
'I18n.t(絶対キー) へ置換を検討'
|
|
127
|
+
end
|
|
128
|
+
[:suspicious, entry]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
buckets = { safe: safe, suspicious: suspicious, other: other }
|
|
132
|
+
Dir.glob(ALL_GLOB).each do |path|
|
|
133
|
+
# vendor / node_modules / tmp 等のノイズを除外
|
|
134
|
+
next if relative(path).start_with?('vendor/', 'node_modules/', 'tmp/', '.git/')
|
|
135
|
+
|
|
136
|
+
scan_file(path, in_app: app_set.include?(path)) do |bucket, entry|
|
|
137
|
+
buckets[bucket] << entry
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if options[:json]
|
|
142
|
+
require 'json'
|
|
143
|
+
puts JSON.pretty_generate(safe: safe, suspicious: suspicious, other: other)
|
|
144
|
+
exit(0)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def print_section(title, entries)
|
|
148
|
+
puts "\n== #{title} (#{entries.size}) =="
|
|
149
|
+
entries.each do |e|
|
|
150
|
+
suffix = if e[:hint]
|
|
151
|
+
" # #{e[:hint]}"
|
|
152
|
+
else
|
|
153
|
+
(e[:reason] ? " # #{e[:reason]}" : '')
|
|
154
|
+
end
|
|
155
|
+
puts "#{e[:file]}:#{e[:lineno]}: #{e[:code].strip}#{suffix}"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
if options[:mode] == :report
|
|
160
|
+
print_section('safe(tt( → t( に決定論的変換できる)', safe)
|
|
161
|
+
print_section('suspicious(文字列加工あり・I18n.t 提案・要 1 件ずつ確認)', suspicious)
|
|
162
|
+
print_section('other(app 外・誤検出・要目視)', other)
|
|
163
|
+
puts "\n合計: safe=#{safe.size} suspicious=#{suspicious.size} other=#{other.size}"
|
|
164
|
+
exit(0)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# ---- --apply-safe: safe 分類のみ tt( → t( を適用 ----
|
|
168
|
+
# 同一ファイル内の safe 行だけを対象に、単語境界の tt を t へ置換する。
|
|
169
|
+
# suspicious / other 行は触らない(行番号で限定)。
|
|
170
|
+
by_file = safe.group_by { |e| e[:file] }
|
|
171
|
+
changed = 0
|
|
172
|
+
by_file.each do |rel, entries|
|
|
173
|
+
path = File.join(ROOT, rel)
|
|
174
|
+
lines = File.readlines(path)
|
|
175
|
+
target_linenos = entries.to_set { |e| e[:lineno] }
|
|
176
|
+
target_linenos.each do |lineno|
|
|
177
|
+
line = lines[lineno - 1]
|
|
178
|
+
# safe 行内の tt 呼び出しのみ t へ。tt('...') や tt(... の tt を t に。
|
|
179
|
+
new_line = line.gsub(TT_CALL) { "t#{Regexp.last_match(1)}" }
|
|
180
|
+
if new_line != line
|
|
181
|
+
lines[lineno - 1] = new_line
|
|
182
|
+
changed += 1
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
File.write(path, lines.join)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
puts "safe 置換を適用: #{changed} 箇所 / #{by_file.size} ファイル"
|
|
189
|
+
puts "残った suspicious=#{suspicious.size} other=#{other.size} は手作業で対応すること(--report で再確認)"
|
|
@@ -24,16 +24,6 @@ describe 'CopyTunerClient::Cache' do
|
|
|
24
24
|
expect(cache.keys).to match_array(%w[en.test.key en.test.other_key])
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
it 'exclude_key_regexpが設定されている場合、該当データを除外すること' do
|
|
28
|
-
cache = build_cache(exclude_key_regexp: /^en\.test\.other_key$/)
|
|
29
|
-
cache['en.test.key'] = 'expected'
|
|
30
|
-
cache['en.test.other_key'] = 'expected'
|
|
31
|
-
|
|
32
|
-
cache.download
|
|
33
|
-
|
|
34
|
-
expect(cache.queued.keys).to match_array(%w[en.test.key])
|
|
35
|
-
end
|
|
36
|
-
|
|
37
27
|
it 'local_first_key_regexpにマッチするキー(locale除く)はアップロードキューに入れないこと' do
|
|
38
28
|
cache = build_cache(local_first_key_regexp: /\Aviews\./)
|
|
39
29
|
cache['ja.views.foo'] = 'local value'
|
|
@@ -284,6 +274,7 @@ describe 'CopyTunerClient::Cache' do
|
|
|
284
274
|
it 'トップレベルからflushできること' do
|
|
285
275
|
cache = build_cache
|
|
286
276
|
CopyTunerClient.configure do |config|
|
|
277
|
+
config.project_id = 1
|
|
287
278
|
config.cache = cache
|
|
288
279
|
end
|
|
289
280
|
expect(cache).to receive(:flush).at_least(:once)
|
|
@@ -410,6 +401,7 @@ describe 'CopyTunerClient::Cache' do
|
|
|
410
401
|
|
|
411
402
|
it 'トップレベル定数から呼び出せること' do
|
|
412
403
|
CopyTunerClient.configure do |config|
|
|
404
|
+
config.project_id = 1
|
|
413
405
|
config.cache = cache
|
|
414
406
|
end
|
|
415
407
|
expect(cache).to receive(:export)
|
|
@@ -253,27 +253,24 @@ describe CopyTunerClient::Configuration do
|
|
|
253
253
|
end
|
|
254
254
|
end
|
|
255
255
|
|
|
256
|
-
describe '
|
|
257
|
-
let(:config)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
it 'warns when a value is set' do
|
|
263
|
-
expect(deprecator).to receive(:warn).with(/exclude_key_regexp is deprecated/)
|
|
264
|
-
|
|
265
|
-
config.exclude_key_regexp = /\Aja\.views\./
|
|
256
|
+
describe 'project_id の必須化' do
|
|
257
|
+
let(:config) do
|
|
258
|
+
config = CopyTunerClient::Configuration.new
|
|
259
|
+
config.api_key = 'abc123'
|
|
260
|
+
config
|
|
266
261
|
end
|
|
267
262
|
|
|
268
|
-
it '
|
|
269
|
-
config.
|
|
270
|
-
expect(config.exclude_key_regexp).to eq(/\Aja\.views\./)
|
|
263
|
+
it 'project_id が未設定のとき apply が ArgumentError を出すこと' do
|
|
264
|
+
expect { config.apply }.to raise_error(ArgumentError, 'project_id is required')
|
|
271
265
|
end
|
|
272
266
|
|
|
273
|
-
it '
|
|
274
|
-
expect
|
|
267
|
+
it 'project_id が未設定のとき project_url が ArgumentError を出すこと' do
|
|
268
|
+
expect { config.project_url }.to raise_error(ArgumentError, 'project_id is required')
|
|
269
|
+
end
|
|
275
270
|
|
|
276
|
-
|
|
271
|
+
it 'project_id を設定すると project_url がそれを使った URL を返すこと' do
|
|
272
|
+
config.project_id = 77
|
|
273
|
+
expect(config.project_url).to include('/projects/77')
|
|
277
274
|
end
|
|
278
275
|
end
|
|
279
276
|
end
|
|
@@ -294,6 +291,9 @@ shared_context 'stubbed configuration' do
|
|
|
294
291
|
allow(CopyTunerClient::Poller).to receive(:new).and_return(poller)
|
|
295
292
|
allow(CopyTunerClient::ProcessGuard).to receive(:new).and_return(process_guard)
|
|
296
293
|
subject.logger = logger
|
|
294
|
+
# NOTE: apply は project_id 必須になったため、未設定だと raise する。applied 系テストは
|
|
295
|
+
# project_id 自体を検証しないので適当な値を補っておく
|
|
296
|
+
subject.project_id ||= 1
|
|
297
297
|
apply
|
|
298
298
|
end
|
|
299
299
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'copy_tuner_client/copyray/marker'
|
|
3
|
+
|
|
4
|
+
describe CopyTunerClient::Copyray::Marker do
|
|
5
|
+
describe '.encode' do
|
|
6
|
+
it 'キーを可視トークンの区切り記号で囲む' do
|
|
7
|
+
expect(described_class.encode('views.home.index.message')).to eq '⟦CT:views.home.index.message⟧'
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it 'ドット・アンダースコア・スラッシュを含むキーをそのまま保持する' do
|
|
11
|
+
expect(described_class.encode('en.foo_bar.baz/qux')).to eq '⟦CT:en.foo_bar.baz/qux⟧'
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'マルチバイトキーをそのまま保持する' do
|
|
15
|
+
expect(described_class.encode('ja.見出し')).to eq '⟦CT:ja.見出し⟧'
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
describe '::SCAN_REGEXP' do
|
|
20
|
+
it 'エンコード済みトークンからキーを取り出す' do
|
|
21
|
+
key = 'views.home.index.message'
|
|
22
|
+
match = described_class::SCAN_REGEXP.match(described_class.encode(key))
|
|
23
|
+
expect(match[1]).to eq key
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'ドット・アンダースコア・スラッシュ・マルチバイトを含むキーを取り出す' do
|
|
27
|
+
key = 'ja.foo_bar.baz/qux.見出し'
|
|
28
|
+
match = described_class::SCAN_REGEXP.match(described_class.encode(key))
|
|
29
|
+
expect(match[1]).to eq key
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it '非貪欲マッチで隣接するトークンを分離する' do
|
|
33
|
+
text = "#{described_class.encode('a.b')}hello#{described_class.encode('c.d')}"
|
|
34
|
+
expect(text.scan(described_class::SCAN_REGEXP).flatten).to eq ['a.b', 'c.d']
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'プレーンテキストにはマッチしない' do
|
|
38
|
+
expect('just a normal sentence with CT: and brackets [x]').not_to match described_class::SCAN_REGEXP
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -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,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
|