copy_tuner_client 1.1.5 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CLAUDE.md +32 -0
- data/README.md +42 -0
- data/lib/copy_tuner_client/cache.rb +4 -0
- data/lib/copy_tuner_client/configuration.rb +34 -2
- data/lib/copy_tuner_client/copyray.rb +4 -0
- data/lib/copy_tuner_client/i18n_backend.rb +12 -0
- data/lib/copy_tuner_client/translation_log.rb +5 -0
- data/lib/copy_tuner_client/version.rb +1 -1
- data/skills/copy-tuner/SKILL.md +39 -15
- data/skills/copy-tuner-to-locales-cleanup/SKILL.md +165 -0
- data/skills/copy-tuner-to-locales-cleanup/references/example-touchpoints.md +111 -0
- data/skills/copy-tuner-to-locales-cleanup/references/verification-final.md +73 -0
- data/skills/copy-tuner-to-locales-migrate-prefix/SKILL.md +265 -0
- data/skills/copy-tuner-to-locales-migrate-prefix/references/example-touchpoints.md +126 -0
- data/skills/copy-tuner-to-locales-migrate-prefix/references/export-and-split.md +126 -0
- data/skills/copy-tuner-to-locales-migrate-prefix/references/local-first-regexp.md +72 -0
- data/skills/copy-tuner-to-locales-migrate-prefix/references/verification-per-prefix.md +63 -0
- data/skills/copy-tuner-to-locales-migrate-prefix/scripts/migrate_prefix.rb +279 -0
- data/spec/copy_tuner_client/cache_spec.rb +21 -0
- data/spec/copy_tuner_client/configuration_spec.rb +53 -0
- data/spec/copy_tuner_client/copyray_spec.rb +11 -0
- data/spec/copy_tuner_client/helper_extension_spec.rb +6 -0
- data/spec/copy_tuner_client/i18n_backend_spec.rb +81 -0
- data/spec/copy_tuner_client/translation_log_spec.rb +51 -0
- metadata +12 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# local_first_key_regexp の使い方
|
|
2
|
+
|
|
3
|
+
SKILL.md 手順 7 の詳細。prefix 単位移行の核心となる gem オプション。
|
|
4
|
+
|
|
5
|
+
## 何をするオプションか
|
|
6
|
+
|
|
7
|
+
[copy-tuner-ruby-client #110](https://github.com/SonicGarden/copy-tuner-ruby-client/pull/110) で
|
|
8
|
+
`Configuration#local_first_key_regexp` が追加された。`I18nBackend#lookup` は次のように動く:
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
# locale を除いたキー(例: "views.foo.bar")が regexp にマッチしたら…
|
|
12
|
+
if local_first_key?(key_without_locale)
|
|
13
|
+
return super # ← I18n::Backend::Simple に委譲。CopyTuner キャッシュもアップロードキューも触らない
|
|
14
|
+
end
|
|
15
|
+
# マッチしなければ従来どおり CopyTuner キャッシュ優先 → 無ければ super
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
判定は `key_without_locale.to_s.match?(local_first_key_regexp)` の単純マッチ。
|
|
19
|
+
|
|
20
|
+
## 完全分離の意味
|
|
21
|
+
|
|
22
|
+
マッチキーは **CopyTuner を一切参照しない**。つまり:
|
|
23
|
+
|
|
24
|
+
- ローカル YAML に値があればそれを返す。
|
|
25
|
+
- ローカル YAML に**無ければ即 `nil`(未訳)**。CopyTuner へフォールバックしない。
|
|
26
|
+
- 空キーのアップロードキュー投入もしない(`Cache#[]=` の単一関門で抑止)。
|
|
27
|
+
- CopyRay のオーバーレイマーカー(`<!--COPYRAY key-->`)も注入されない(編集できないキーを編集可能と誤認
|
|
28
|
+
させないため)。
|
|
29
|
+
|
|
30
|
+
この「無ければ未訳」の挙動こそが、**移行漏れ(ローカルへ書き忘れたキー)を未訳として顕在化させる**仕組み。
|
|
31
|
+
だから移行のたびに `translation missing` チェック(SKILL.md 手順 8)が効く。
|
|
32
|
+
|
|
33
|
+
## regexp は単一・配列非対応
|
|
34
|
+
|
|
35
|
+
`local_first_key_regexp` は単一の `Regexp` を取る(`attr_accessor`、デフォルト `nil`)。**配列は渡せない**。
|
|
36
|
+
複数 prefix を移行済みにするには **1 本の正規表現に積み上げる**。`Regexp.union` を使うのが安全:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
config.local_first_key_regexp = Regexp.union(
|
|
40
|
+
/\Adevise\./,
|
|
41
|
+
/\Aice_cube\./,
|
|
42
|
+
/\Arestrict_dependent_destroy\./,
|
|
43
|
+
/\Aviews\./,
|
|
44
|
+
)
|
|
45
|
+
# => /(?-mix:\Adevise\.)|(?-mix:\Aice_cube\.)|.../ 相当
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`Regexp.union` はメタ文字のエスケープを自動でやり、要素を OR でつなぐので、prefix を 1 行ずつ足すだけで済む。
|
|
49
|
+
手書きの `/\A(devise|ice_cube|...)\./` でも等価だが、union のほうが追記時のミスが少ない。
|
|
50
|
+
|
|
51
|
+
## `\A` アンカー必須
|
|
52
|
+
|
|
53
|
+
各 prefix は **`\A` で先頭アンカー**する。キーは locale 除去後なので `\A` 起点でよい。
|
|
54
|
+
|
|
55
|
+
- `\A` 無しの `/views\./` は `reviews.foo` の `views.` 部分にもマッチしてしまう(部分マッチ事故)。
|
|
56
|
+
- `\Aviews\.` なら `views.` で始まるキーだけにマッチする。
|
|
57
|
+
- `views` 配下を更に刻むなら `\Aviews\.users\.` のように 2 階層目までアンカーできる。
|
|
58
|
+
|
|
59
|
+
ドット `.` は正規表現のメタ文字なので、prefix 区切りのドットは `\.` とエスケープする
|
|
60
|
+
(`/\Aviews\./`。`Regexp.union` に文字列を渡す場合は自動エスケープされるが、Regexp リテラルを渡すときは自分で
|
|
61
|
+
書く)。
|
|
62
|
+
|
|
63
|
+
## deprecated な exclude_key_regexp との違い
|
|
64
|
+
|
|
65
|
+
| | `local_first_key_regexp` | `exclude_key_regexp`(非推奨) |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| 対象キー | locale を**除いた**キー(`views.foo`) | locale を**含む**キー(`ja.views.foo`) |
|
|
68
|
+
| 作用タイミング | lookup(読み込み)時 | upload(送信)時 |
|
|
69
|
+
| 効果 | ローカル YAML を優先(完全分離) | サーバへのアップロードを抑止するだけ |
|
|
70
|
+
|
|
71
|
+
`exclude_key_regexp` は PR #110 で非推奨化された(設定すると `ActiveSupport::Deprecation` 警告が出る)。
|
|
72
|
+
移行では使わない。もし既存設定に `exclude_key_regexp` があれば、cleanup スキルで gem ごと撤去される。
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# prefix 移行後の検証
|
|
2
|
+
|
|
3
|
+
SKILL.md 手順 8 の詳細。対象 prefix を `local_first_key_regexp` に足した後、その範囲がローカルだけで解決する
|
|
4
|
+
ことを確かめる。完全分離(マッチキーは CopyTuner を見ない)なので、ローカルへの書き忘れは未訳として現れる。
|
|
5
|
+
|
|
6
|
+
## 1. regexp が意図どおりマッチするか
|
|
7
|
+
|
|
8
|
+
gem の判定そのものを使って、今回の prefix のキーがマッチし、隣接キーが誤マッチしないことを確認する。
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
bin/rails runner 'p [
|
|
12
|
+
CopyTunerClient.configuration.local_first_key?("views.foo.bar"), # => true を期待
|
|
13
|
+
CopyTunerClient.configuration.local_first_key?("reviews.foo") # => false を期待(部分マッチ事故の確認)
|
|
14
|
+
]'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 2. テスト
|
|
18
|
+
|
|
19
|
+
プロジェクトの品質コマンドに従う。多くの Rails プロジェクトでは:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
docker compose up -d db # 未起動なら
|
|
23
|
+
bundle exec rspec
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`translation missing` が出ないこと。完全分離した prefix のキーがローカル YAML に揃っていないと、ここで
|
|
27
|
+
未訳が顕在化する(それが狙い)。落ちたら、抜き出したサブツリーに漏れがないか・配置ファイルがロードされて
|
|
28
|
+
いるかを確認する。
|
|
29
|
+
|
|
30
|
+
## 3. 未翻訳キーの検出
|
|
31
|
+
|
|
32
|
+
対象 prefix が関わる主要画面・フローを動かし、`translation missing` が出ないか確認する。
|
|
33
|
+
ログから機械的に拾うなら:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
grep -rni 'translation missing' log/ tmp/ 2>/dev/null || echo 'none'
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
より厳密にやるなら、開発・テスト環境で `config.i18n.raise_on_missing_translations = true` を一時的に
|
|
40
|
+
有効化してテストを流し、未定義キーで例外が出るかを確認する手もある(任意・恒久設定にするかはプロジェクト判断)。
|
|
41
|
+
|
|
42
|
+
## 4. i18n の主要参照が解決するか
|
|
43
|
+
|
|
44
|
+
移行した prefix に応じて、影響しやすい参照パターンを主要画面または rails runner で確認する:
|
|
45
|
+
|
|
46
|
+
- `views.*` を移したら … ビューの `t('...')` / `t('.lazy_key')`
|
|
47
|
+
- `activerecord.attributes` を移したら … `Model.human_attribute_name(:attr)`(CSV ヘッダ・フォームラベル)
|
|
48
|
+
- `activerecord.models` を移したら … `Model.model_name.human`
|
|
49
|
+
- `activerecord.enums` を移したら … enum の表示名(`human_enum_name` 等のヘルパ経由)
|
|
50
|
+
|
|
51
|
+
例:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bin/rails runner 'puts [User.model_name.human, User.human_attribute_name(:name)].inspect'
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`translation missing` を含まず、期待する日本語が返ること。
|
|
58
|
+
|
|
59
|
+
## 5. 移行していない prefix が壊れていないこと
|
|
60
|
+
|
|
61
|
+
未移行 prefix は `local_first_key_regexp` にマッチしないので、引き続き CopyTuner から引かれる。今回の移行で
|
|
62
|
+
未移行 prefix の表示が変わっていないことも、主要画面で軽く確認しておく(regexp のアンカー漏れで意図せず
|
|
63
|
+
広くマッチしていないかの確認も兼ねる)。
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# copy_tuner → config/locales prefix-unit migration script (SKILL.md step 6).
|
|
4
|
+
#
|
|
5
|
+
# Run via `bin/rails runner` so that the Rails i18n load path and CopyTuner
|
|
6
|
+
# configuration are available:
|
|
7
|
+
#
|
|
8
|
+
# bin/rails runner .claude/skills/copy-tuner-to-locales-migrate-prefix/scripts/migrate_prefix.rb \
|
|
9
|
+
# -- --prefix date --export tmp/copy_tuner_all.yml --out config/locales/0010_date.yml
|
|
10
|
+
#
|
|
11
|
+
# What it does, in one pass:
|
|
12
|
+
# (1) Place : extract the target prefix subtree from the 0000_original_*.yml
|
|
13
|
+
# originals (load-order deep_merge), deep_merge the export subtree on top
|
|
14
|
+
# (export wins for string blurbs), then re-apply the originals'
|
|
15
|
+
# non-representable values (arrays/symbols/numbers/booleans such as the
|
|
16
|
+
# `date.order` symbol array) last so a corrupted export value cannot
|
|
17
|
+
# overwrite them, and write it to --out.
|
|
18
|
+
# (2) Static guards : YAML round-trip, --out is on the i18n load path, every
|
|
19
|
+
# leaf key matches the prefix regexp.
|
|
20
|
+
# (3) Leak check : simulate the original-deletion in memory and resolve every
|
|
21
|
+
# target-prefix key through a fresh I18n::Backend::Simple. A key that goes
|
|
22
|
+
# missing after deletion is a migration leak.
|
|
23
|
+
# (4) Gate : only when there are zero leaks, prune the prefix subtree from the
|
|
24
|
+
# originals on disk. Any leak aborts with the originals untouched.
|
|
25
|
+
#
|
|
26
|
+
# 対象 locale はプロジェクトの I18n.available_locales を実行時に参照して決める(default_locale を先頭に並べる)。
|
|
27
|
+
# `--locales ja,en` で明示上書きできる(fixture を使った検証用。指定時は指定順を尊重する)。
|
|
28
|
+
|
|
29
|
+
require 'optparse'
|
|
30
|
+
require 'yaml'
|
|
31
|
+
|
|
32
|
+
# NOTE: `bin/rails runner` は Kernel#abort が投げる SystemExit を握りつぶし終了コードが 0 になる
|
|
33
|
+
# (実機確認済み)。中断を呼び出し側へ確実に伝えるため、abort ではなく warn + exit(1) を使う。
|
|
34
|
+
def die(message)
|
|
35
|
+
warn message
|
|
36
|
+
exit(1)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# ---- 引数 ----
|
|
40
|
+
options = {}
|
|
41
|
+
parser =
|
|
42
|
+
OptionParser.new do |opts|
|
|
43
|
+
opts.banner = 'Usage: bin/rails runner migrate_prefix.rb -- --prefix PREFIX --export PATH --out PATH [--regexp PATTERN] [--originals-glob GLOB]'
|
|
44
|
+
opts.on('--prefix PREFIX', 'ドット区切りの対象 prefix(例: date / activerecord.attributes)') { |v| options[:prefix] = v }
|
|
45
|
+
opts.on('--export PATH', '全件 export YAML(手順3で出力)') { |v| options[:export] = v }
|
|
46
|
+
opts.on('--out PATH', '移行分の出力先(例: config/locales/0010_date.yml)') { |v| options[:out] = v }
|
|
47
|
+
opts.on('--regexp PATTERN', 'leaf キー検証用の正規表現(省略時は prefix から \A<prefix>\. を生成)') { |v| options[:regexp] = v }
|
|
48
|
+
opts.on('--originals-glob GLOB', 'オリジナルファイルの glob(既定: config/locales/0000_original_*.yml)') { |v| options[:originals_glob] = v }
|
|
49
|
+
opts.on('--locales LIST', 'カンマ区切りの対象 locale(既定: I18n.available_locales。検証用の上書き)') { |v| options[:locales] = v }
|
|
50
|
+
end
|
|
51
|
+
# `bin/rails runner script -- ...` の `--` 以降だけを渡したいが、runner が剥がさない環境もあるため両対応。
|
|
52
|
+
argv = ARGV.include?('--') ? ARGV[(ARGV.index('--') + 1)..] : ARGV
|
|
53
|
+
parser.parse!(argv)
|
|
54
|
+
|
|
55
|
+
die(parser.banner) unless options[:prefix] && options[:export] && options[:out]
|
|
56
|
+
|
|
57
|
+
PREFIX = options[:prefix]
|
|
58
|
+
prefix_keys = PREFIX.split('.')
|
|
59
|
+
|
|
60
|
+
# 対象 locale 群。--locales 指定時はその順を尊重、未指定なら default_locale を先頭にした available_locales。
|
|
61
|
+
LOCALES =
|
|
62
|
+
if options[:locales]
|
|
63
|
+
options[:locales].split(',').map(&:strip).reject(&:empty?)
|
|
64
|
+
else
|
|
65
|
+
([I18n.default_locale.to_s] + I18n.available_locales.map(&:to_s)).uniq
|
|
66
|
+
end
|
|
67
|
+
# 黙って 'ja' に倒すと将来 locale 追加時にサイレント脱落を招くため、空なら明示的に中断する。
|
|
68
|
+
die('対象 locale が空。I18n.available_locales が取れているか(Rails 環境のロード)、--locales の指定を確認すること。') if LOCALES.empty?
|
|
69
|
+
# 既定の regexp は prefix に \A アンカーを付けたもの(手順7の local_first_key_regexp と整合させる)。
|
|
70
|
+
regexp = options[:regexp] ? Regexp.new(options[:regexp]) : /\A#{Regexp.escape(PREFIX)}\./o
|
|
71
|
+
originals_glob = options.fetch(:originals_glob, 'config/locales/0000_original_*.yml')
|
|
72
|
+
|
|
73
|
+
# ---- ヘルパ ----
|
|
74
|
+
|
|
75
|
+
# ネスト Hash を非破壊 deep merge(右辺優先)。配列・スカラはそのまま右辺で置換。
|
|
76
|
+
def deep_merge(base, override)
|
|
77
|
+
base.merge(override) do |_key, b, o|
|
|
78
|
+
b.is_a?(Hash) && o.is_a?(Hash) ? deep_merge(b, o) : o
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def deep_merge!(base, override)
|
|
83
|
+
base.replace(deep_merge(base, override))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# YAML をロードして指定 locale ルート(例 `ja:`)配下の Hash を返す。シンボル(date.order の :year 等)を許可。
|
|
87
|
+
def load_locale_tree(path, locale)
|
|
88
|
+
raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true) || {}
|
|
89
|
+
raw[locale] || {}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# prefix(["date"] や ["activerecord","attributes"])のサブツリーを掘り出す。無ければ nil。
|
|
93
|
+
def dig_prefix(tree, keys)
|
|
94
|
+
keys.reduce(tree) do |node, key|
|
|
95
|
+
return nil unless node.is_a?(Hash) && node.key?(key)
|
|
96
|
+
|
|
97
|
+
node[key]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# prefix サブツリーを {"date" => {...}} のように prefix で包んだ Hash にして返す(無ければ {})。
|
|
102
|
+
def extract_prefix(tree, keys)
|
|
103
|
+
sub = dig_prefix(tree, keys)
|
|
104
|
+
return {} if sub.nil?
|
|
105
|
+
|
|
106
|
+
keys.reverse.reduce(sub) { |acc, key| { key => acc } }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# prefix サブツリーを取り除いた新しい Hash を返す。空になった親も刈る(非破壊)。
|
|
110
|
+
def prune_prefix(tree, keys)
|
|
111
|
+
head, *rest = keys
|
|
112
|
+
return tree unless tree.is_a?(Hash) && tree.key?(head)
|
|
113
|
+
|
|
114
|
+
dup = tree.dup
|
|
115
|
+
if rest.empty?
|
|
116
|
+
dup.delete(head)
|
|
117
|
+
else
|
|
118
|
+
pruned_child = prune_prefix(dup[head], rest)
|
|
119
|
+
if pruned_child.is_a?(Hash) && pruned_child.empty?
|
|
120
|
+
dup.delete(head) # 空親を刈る
|
|
121
|
+
else
|
|
122
|
+
dup[head] = pruned_child
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
dup
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ネスト Hash の全 leaf を「ドット区切りキー => 値」で返す。配列・シンボル・nil 値は leaf として扱い、
|
|
129
|
+
# その中までは展開しない(date.order の [:year,...] は date.order 1 個)。
|
|
130
|
+
def leaf_entries(tree, prefix = [])
|
|
131
|
+
tree.each_with_object({}) do |(key, value), acc|
|
|
132
|
+
path = prefix + [key]
|
|
133
|
+
if value.is_a?(Hash) && !value.empty?
|
|
134
|
+
acc.merge!(leaf_entries(value, path))
|
|
135
|
+
else
|
|
136
|
+
acc[path.join('.')] = value
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# ネスト Hash から「非表現値(非 String・非 Hash の leaf)だけ」を残したネスト Hash を返す。
|
|
142
|
+
# 配列・シンボル・数値・真偽値・nil が対象。String leaf は捨てる(export 勝ちに委ねる)。
|
|
143
|
+
# copy_tuner は flat な文字列 blurb しか持てず、これらは export に出てこない/壊れて出る可能性がある。
|
|
144
|
+
# orig 由来のこの結果を最後に deep_merge することで、壊れた export 値が非表現値を上書きするのを防ぐ。
|
|
145
|
+
def select_non_blurb(tree)
|
|
146
|
+
tree.each_with_object({}) do |(key, value), acc|
|
|
147
|
+
if value.is_a?(Hash) && !value.empty?
|
|
148
|
+
child = select_non_blurb(value)
|
|
149
|
+
acc[key] = child unless child.empty? # 非表現値が残った枝だけ保持
|
|
150
|
+
elsif !value.is_a?(String) && !value.is_a?(Hash)
|
|
151
|
+
acc[key] = value # Array / Symbol / Integer / Float / true / false / nil
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# locale ルートを持つ raw Hash(`{ "ja" => {...}, "en" => {...} }`)から、対象 prefix を全 locale で刈った
|
|
157
|
+
# 新しい Hash を返す(非破壊)。移行漏れ検証のシミュレーションと実削除の両方で使う。
|
|
158
|
+
def prune_prefix_all_locales(raw, locales, keys)
|
|
159
|
+
locales.reduce(raw) { |acc, locale| acc.merge(locale => prune_prefix(acc[locale] || {}, keys)) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# ---- (1) 配置 ----
|
|
163
|
+
out_path = options[:out]
|
|
164
|
+
# Dir.glob は Ruby 3.0+ で昇順ソート済み(= Rails の i18n ロード順と同じ)を返す。
|
|
165
|
+
original_files = Dir.glob(originals_glob)
|
|
166
|
+
die("オリジナルが見つからない: #{originals_glob}") if original_files.empty?
|
|
167
|
+
|
|
168
|
+
# locale ごとに merged サブツリーを構築する。{ "ja" => {...}, "en" => {...} } の形。
|
|
169
|
+
# あるロケールに当該 prefix のキーが一つも無い(orig も export も空)場合は warn してスキップし、
|
|
170
|
+
# 書き出し・検証から自然に除外する(複数ロケールでは「en は別 prefix しか持たない」等が正常に起こりうる)。
|
|
171
|
+
merged_by_locale = {}
|
|
172
|
+
LOCALES.each do |locale|
|
|
173
|
+
orig_sub = {}
|
|
174
|
+
original_files.each { |f| deep_merge!(orig_sub, extract_prefix(load_locale_tree(f, locale), prefix_keys)) }
|
|
175
|
+
|
|
176
|
+
exp_sub = extract_prefix(load_locale_tree(options[:export], locale), prefix_keys)
|
|
177
|
+
|
|
178
|
+
if orig_sub.empty? && exp_sub.empty?
|
|
179
|
+
warn("locale #{locale}: prefix '#{PREFIX}' のキーが無いためスキップ")
|
|
180
|
+
next
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# orig がベース・export で上書き(String leaf は export 勝ち)し、最後に非表現値(非 String leaf)を
|
|
184
|
+
# orig 値で再適用して必ず勝たせる。export 側に壊れた非表現値(文字列化等)が出ても置換されない。
|
|
185
|
+
merged_by_locale[locale] = deep_merge(deep_merge(orig_sub, exp_sub), select_non_blurb(orig_sub))
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# 全 locale でスキップ=当該 prefix がどこにも存在しない(prefix の typo)。
|
|
189
|
+
if merged_by_locale.empty?
|
|
190
|
+
die("prefix '#{PREFIX}' のキーが export にもオリジナルにも見つからない。prefix を確認すること。")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# ---- (2) 静的ガード(書き出し前にできる検証を先に。中途半端な --out を残さない)----
|
|
194
|
+
# --out が次回起動時に Rails の i18n glob でロードされる場所か(採番ミス・配置ミスの検出)。
|
|
195
|
+
# NOTE: I18n.load_path は runner 起動時に確定済みで、いま書き出す --out は含まれない。よって
|
|
196
|
+
# 「現在の load_path に在るか」ではなく「Rails 標準 glob(config/locales/**/*.yml)に合致するか」を確認する。
|
|
197
|
+
abs_out = File.expand_path(out_path)
|
|
198
|
+
locales_root = File.expand_path('config/locales')
|
|
199
|
+
unless abs_out.start_with?("#{locales_root}/") && abs_out.end_with?('.yml', '.yaml')
|
|
200
|
+
die("#{out_path} が config/locales 配下の .yml ではない。Rails の i18n ロード対象になる場所へ出力すること。")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# 全 leaf キーが regexp にマッチするか(regexp と prefix の不一致の早期検出)。leaf キーは locale を
|
|
204
|
+
# 含まない(merged は locale ルート配下)ので、全 locale を同じ regexp で検証できる。
|
|
205
|
+
merged_by_locale.each do |locale, merged|
|
|
206
|
+
non_matching = leaf_entries(merged).keys.grep_v(regexp)
|
|
207
|
+
next if non_matching.empty?
|
|
208
|
+
|
|
209
|
+
die("locale #{locale}: regexp #{regexp.inspect} にマッチしない leaf キーがある(regexp/prefix 不一致):\n #{non_matching.join("\n ")}")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# 書き出し(ここまでの検証を通過してから)。merged_by_locale は { "ja" => {...}, "en" => {...} } 形なので
|
|
213
|
+
# そのまま全 locale ルートを持つ YAML になる(スキップした locale は含まれない)。
|
|
214
|
+
File.write(out_path, merged_by_locale.to_yaml)
|
|
215
|
+
total_leaves = merged_by_locale.sum { |_locale, merged| leaf_entries(merged).size }
|
|
216
|
+
puts "配置: #{out_path} (locale #{merged_by_locale.keys.join(',')} / leaf 合計 #{total_leaves} キー)"
|
|
217
|
+
|
|
218
|
+
# YAML ラウンドトリップ(to_yaml → 再読込で各 locale の merged と一致するか)。
|
|
219
|
+
merged_by_locale.each do |locale, merged|
|
|
220
|
+
die("YAML ラウンドトリップ不一致。書き出し結果が壊れている: #{out_path} (locale #{locale})") unless load_locale_tree(out_path, locale) == merged
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# ---- (3) 移行漏れ検証(削除をメモリ上でシミュレート)----
|
|
224
|
+
# 削除後に Rails がロードするであろう全 locales ツリーを実ロード順で再現する。
|
|
225
|
+
#
|
|
226
|
+
# NOTE: いま書き出した --out は起動時確定の I18n.load_path に含まれない(実機確認済み)。そのため
|
|
227
|
+
# load_path 由来の既存ファイル群(gem の locale 含む)を実順で読みつつ、--out を**末尾に明示追加**して
|
|
228
|
+
# 後勝ちさせ、「削除後+移行分ロード済み」の状態を再現する。
|
|
229
|
+
abs_out = File.expand_path(out_path)
|
|
230
|
+
sim_paths = I18n.load_path.dup
|
|
231
|
+
sim_paths << out_path unless sim_paths.map { |p| File.expand_path(p) }.include?(abs_out)
|
|
232
|
+
original_abs = original_files.map { |f| File.expand_path(f) }
|
|
233
|
+
|
|
234
|
+
sim_tree = {}
|
|
235
|
+
sim_paths.each do |path|
|
|
236
|
+
next unless File.exist?(path)
|
|
237
|
+
next unless path.end_with?('.yml', '.yaml') # .rb ロードパスはこのスキルの範囲外(必要なら別途)
|
|
238
|
+
|
|
239
|
+
content = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true) || {}
|
|
240
|
+
next unless content.is_a?(Hash)
|
|
241
|
+
|
|
242
|
+
# オリジナルからは対象 prefix を全 locale で刈った状態にする(実削除後と等価)。
|
|
243
|
+
content = prune_prefix_all_locales(content, LOCALES, prefix_keys) if original_abs.include?(File.expand_path(path))
|
|
244
|
+
deep_merge!(sim_tree, content)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
sim = I18n::Backend::Simple.new
|
|
248
|
+
sim_tree.each { |loc, data| sim.store_translations(loc, data) }
|
|
249
|
+
|
|
250
|
+
# NOTE: missing 判定は `default:` センチネルで行う。`throw: true` は missing 時に Ruby の throw
|
|
251
|
+
# (catch/throw フロー制御)で MissingTranslation を投げるため rescue では捕まらず UncaughtThrowError に
|
|
252
|
+
# なる(実機確認済み)。default にユニークなオブジェクトを渡せば、missing のときだけそれが返る。
|
|
253
|
+
# 空文字 "" は「存在」扱い(センチネルと equal? でないため missing 扱いしない)。
|
|
254
|
+
missing = Object.new
|
|
255
|
+
|
|
256
|
+
# 母集合は locale ごとに独立に取る(= その locale の merged の全 leaf キー)。locale 横断の和集合に
|
|
257
|
+
# すると、en にしか無いキーを ja で lookup して誤検知する。各 locale はその locale に実在するキーだけ検証する。
|
|
258
|
+
leaks = []
|
|
259
|
+
merged_by_locale.each do |locale, merged|
|
|
260
|
+
leaf_entries(merged).each_key do |key|
|
|
261
|
+
value = sim.translate(locale.to_sym, key, default: missing)
|
|
262
|
+
leaks << [locale, key] if value.equal?(missing)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# ---- (4) ゲート ----
|
|
267
|
+
unless leaks.empty?
|
|
268
|
+
warn "移行漏れ検出: 削除すると次の #{leaks.size} キーが未訳になる。オリジナルは変更していない。"
|
|
269
|
+
leaks.sort.each { |locale, key| warn " - #{locale}: #{key}" }
|
|
270
|
+
die('中断。--out の内容・採番・regexp を確認すること。')
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
original_files.each do |f|
|
|
274
|
+
raw = YAML.safe_load_file(f, permitted_classes: [Symbol], aliases: true) || {}
|
|
275
|
+
File.write(f, prune_prefix_all_locales(raw, LOCALES, prefix_keys).to_yaml)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
puts "削除: prefix '#{PREFIX}' をオリジナル #{original_files.size} ファイルから刈り取った。"
|
|
279
|
+
puts '完了。手順7(local_first_key_regexp 追加)が未済なら次に実施すること。'
|
|
@@ -34,6 +34,27 @@ describe 'CopyTunerClient::Cache' do
|
|
|
34
34
|
expect(cache.queued.keys).to match_array(%w[en.test.key])
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
it 'local_first_key_regexpにマッチするキー(locale除く)はアップロードキューに入れないこと' do
|
|
38
|
+
cache = build_cache(local_first_key_regexp: /\Aviews\./)
|
|
39
|
+
cache['ja.views.foo'] = 'local value'
|
|
40
|
+
cache['ja.messages.bar'] = 'copy tuner value'
|
|
41
|
+
|
|
42
|
+
cache.download
|
|
43
|
+
|
|
44
|
+
# 完全分離: views.* は copy_tuner へアップロードしない
|
|
45
|
+
expect(cache.queued.keys).to match_array(%w[ja.messages.bar])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'local_first_key_regexpがnil(デフォルト)の場合は全キーをアップロードすること' do
|
|
49
|
+
cache = build_cache(local_first_key_regexp: nil)
|
|
50
|
+
cache['ja.views.foo'] = 'local value'
|
|
51
|
+
cache['ja.messages.bar'] = 'copy tuner value'
|
|
52
|
+
|
|
53
|
+
cache.download
|
|
54
|
+
|
|
55
|
+
expect(cache.queued.keys).to match_array(%w[ja.views.foo ja.messages.bar])
|
|
56
|
+
end
|
|
57
|
+
|
|
37
58
|
it '変更がない場合はアップロードしないこと' do
|
|
38
59
|
cache = build_cache
|
|
39
60
|
cache.flush
|
|
@@ -45,6 +45,7 @@ describe CopyTunerClient::Configuration do
|
|
|
45
45
|
it { is_expected.to have_config_option(:middleware).overridable }
|
|
46
46
|
it { is_expected.to have_config_option(:client).overridable }
|
|
47
47
|
it { is_expected.to have_config_option(:cache).overridable }
|
|
48
|
+
it { is_expected.to have_config_option(:local_first_key_regexp).overridable.default(nil) }
|
|
48
49
|
|
|
49
50
|
it 'should provide default values for secure connections' do
|
|
50
51
|
config = CopyTunerClient::Configuration.new
|
|
@@ -189,6 +190,58 @@ describe CopyTunerClient::Configuration do
|
|
|
189
190
|
expect(prefixed_logger).to be_a(CopyTunerClient::PrefixedLogger)
|
|
190
191
|
expect(prefixed_logger.original_logger).to eq(logger)
|
|
191
192
|
end
|
|
193
|
+
|
|
194
|
+
describe '#local_first_key?' do
|
|
195
|
+
let(:config) { CopyTunerClient::Configuration.new }
|
|
196
|
+
|
|
197
|
+
it 'returns false when local_first_key_regexp is nil (default)' do
|
|
198
|
+
expect(config.local_first_key?('views.foo.bar')).to eq false
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
context 'when local_first_key_regexp is set' do
|
|
202
|
+
before { config.local_first_key_regexp = /\Aviews\./ }
|
|
203
|
+
|
|
204
|
+
it 'returns true for a matching key' do
|
|
205
|
+
expect(config.local_first_key?('views.foo.bar')).to eq true
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it 'returns false for a non-matching key' do
|
|
209
|
+
expect(config.local_first_key?('models.foo.bar')).to eq false
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
it 'returns false for a nil key' do
|
|
213
|
+
expect(config.local_first_key?(nil)).to eq false
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it 'coerces a Symbol key before matching' do
|
|
217
|
+
expect(config.local_first_key?(:'views.foo')).to eq true
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
describe '#exclude_key_regexp= (deprecated)' do
|
|
223
|
+
let(:config) { CopyTunerClient::Configuration.new }
|
|
224
|
+
let(:deprecator) { instance_double(ActiveSupport::Deprecation, warn: nil) }
|
|
225
|
+
|
|
226
|
+
before { allow(ActiveSupport::Deprecation).to receive(:new).and_return(deprecator) }
|
|
227
|
+
|
|
228
|
+
it 'warns when a value is set' do
|
|
229
|
+
expect(deprecator).to receive(:warn).with(/exclude_key_regexp is deprecated/)
|
|
230
|
+
|
|
231
|
+
config.exclude_key_regexp = /\Aja\.views\./
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it 'stores the assigned value' do
|
|
235
|
+
config.exclude_key_regexp = /\Aja\.views\./
|
|
236
|
+
expect(config.exclude_key_regexp).to eq(/\Aja\.views\./)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
it 'does not warn when set to nil' do
|
|
240
|
+
expect(deprecator).not_to receive(:warn)
|
|
241
|
+
|
|
242
|
+
config.exclude_key_regexp = nil
|
|
243
|
+
end
|
|
244
|
+
end
|
|
192
245
|
end
|
|
193
246
|
|
|
194
247
|
shared_context 'stubbed configuration' do
|
|
@@ -54,5 +54,16 @@ describe CopyTunerClient::Copyray do
|
|
|
54
54
|
it_behaves_like 'Not escaped'
|
|
55
55
|
end
|
|
56
56
|
end
|
|
57
|
+
|
|
58
|
+
context 'when the key matches local_first_key_regexp' do
|
|
59
|
+
let(:source) { 'Hello' }
|
|
60
|
+
let(:key) { 'views.foo' }
|
|
61
|
+
|
|
62
|
+
before { CopyTunerClient.configuration.local_first_key_regexp = /\Aviews\./ }
|
|
63
|
+
|
|
64
|
+
it 'does not inject the overlay marker' do
|
|
65
|
+
is_expected.to eq 'Hello'
|
|
66
|
+
end
|
|
67
|
+
end
|
|
57
68
|
end
|
|
58
69
|
end
|
|
@@ -23,4 +23,10 @@ describe CopyTunerClient::HelperExtension do
|
|
|
23
23
|
view = KeywordArgumentsView.new
|
|
24
24
|
expect(view.translate('some.key', name: 'World')).to eq '<!--COPYRAY some.key-->Hello, World'
|
|
25
25
|
end
|
|
26
|
+
|
|
27
|
+
it 'does not inject the overlay marker for a local_first key' do
|
|
28
|
+
CopyTunerClient.configuration.local_first_key_regexp = /\Aviews\./
|
|
29
|
+
view = KeywordArgumentsView.new
|
|
30
|
+
expect(view.translate('views.foo', name: 'World')).to eq 'Hello, World'
|
|
31
|
+
end
|
|
26
32
|
end
|
|
@@ -427,4 +427,85 @@ describe 'CopyTunerClient::I18nBackend' do
|
|
|
427
427
|
end
|
|
428
428
|
end
|
|
429
429
|
end
|
|
430
|
+
|
|
431
|
+
describe 'local_first_key_regexp(ローカル優先キー)' do
|
|
432
|
+
after { CopyTunerClient.configuration.local_first_key_regexp = nil }
|
|
433
|
+
|
|
434
|
+
# ローカル config/locales 相当のデータを I18n::Backend::Simple 側だけに格納する。
|
|
435
|
+
# I18nBackend#store_translations は cache にも書き込んでしまうため、
|
|
436
|
+
# Simple の store_translations を直接呼んでローカル YAML のみを再現する。
|
|
437
|
+
def store_local(backend, locale, data)
|
|
438
|
+
I18n::Backend::Simple.instance_method(:store_translations).bind(backend).call(locale, data)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# アップロード抑止(Cache#[]=)の検証用に、現在の configuration を反映した実 Cache を作る。
|
|
442
|
+
def build_real_cache
|
|
443
|
+
CopyTunerClient::Cache.new(FakeClient.new, CopyTunerClient.configuration.to_hash)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
context 'local_first_key_regexp が nil(デフォルト)のとき' do
|
|
447
|
+
it 'views.* でも従来どおり copy_tuner(cache)を優先すること' do
|
|
448
|
+
CopyTunerClient.configuration.local_first_key_regexp = nil
|
|
449
|
+
cache['ja.views.foo'] = 'copy tuner value'
|
|
450
|
+
store_local(subject, :ja, views: { foo: 'local value' })
|
|
451
|
+
|
|
452
|
+
expect(subject.translate('ja', 'views.foo')).to eq('copy tuner value')
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
context 'local_first_key_regexp = /\Aviews\./ のとき' do
|
|
457
|
+
before { CopyTunerClient.configuration.local_first_key_regexp = /\Aviews\./ }
|
|
458
|
+
|
|
459
|
+
it 'views.* は cache に値があってもローカル YAML を優先すること' do
|
|
460
|
+
cache['ja.views.foo'] = 'copy tuner value'
|
|
461
|
+
store_local(subject, :ja, views: { foo: 'local value' })
|
|
462
|
+
|
|
463
|
+
expect(subject.translate('ja', 'views.foo')).to eq('local value')
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
it 'views.* 以外のキーは従来どおり copy_tuner を優先すること' do
|
|
467
|
+
cache['ja.messages.foo'] = 'copy tuner value'
|
|
468
|
+
store_local(subject, :ja, messages: { foo: 'local value' })
|
|
469
|
+
|
|
470
|
+
expect(subject.translate('ja', 'messages.foo')).to eq('copy tuner value')
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
it 'views.* がローカルにも cache にも無いとき nil を返し、空キー登録(アップロード)をしないこと' do
|
|
474
|
+
spy_cache = TestCache.new
|
|
475
|
+
allow(spy_cache).to receive(:[]=).and_call_original
|
|
476
|
+
backend = CopyTunerClient::I18nBackend.new(spy_cache)
|
|
477
|
+
I18n.backend = backend
|
|
478
|
+
|
|
479
|
+
result = backend.translate('ja', 'views.missing', default: nil)
|
|
480
|
+
|
|
481
|
+
expect(result).to be_nil
|
|
482
|
+
# 案1(完全分離): local_first キーは空キー登録(アップロードキュー投入)を行わない
|
|
483
|
+
expect(spy_cache).not_to have_received(:[]=).with('ja.views.missing', nil)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
it 'views.* を default: 付きで翻訳しても copy_tuner へアップロードしないこと' do
|
|
487
|
+
real_cache = build_real_cache
|
|
488
|
+
backend = CopyTunerClient::I18nBackend.new(real_cache)
|
|
489
|
+
I18n.backend = backend
|
|
490
|
+
|
|
491
|
+
result = backend.translate('ja', 'views.bar', default: 'literal default')
|
|
492
|
+
|
|
493
|
+
expect(result).to eq('literal default')
|
|
494
|
+
# 完全分離: local_first キーは default: 経由でもアップロードキューに入らない(Cache#[]= が弾く)
|
|
495
|
+
expect(real_cache.queued.keys).not_to include('ja.views.bar')
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
it 'store_translations 経由でも local_first キーはアップロードキューに入らないこと' do
|
|
499
|
+
real_cache = build_real_cache
|
|
500
|
+
backend = CopyTunerClient::I18nBackend.new(real_cache)
|
|
501
|
+
I18n.backend = backend
|
|
502
|
+
|
|
503
|
+
backend.store_translations(:ja, views: { foo: 'local value' }, messages: { bar: 'msg value' })
|
|
504
|
+
|
|
505
|
+
# 完全分離: views.* はキューに入らず、それ以外(messages.*)は従来どおり入る
|
|
506
|
+
expect(real_cache.queued.keys).not_to include('ja.views.foo')
|
|
507
|
+
expect(real_cache.queued.keys).to include('ja.messages.bar')
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
end
|
|
430
511
|
end
|