copy_tuner_client 2.0.0 → 2.1.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.
@@ -3,8 +3,9 @@ name: copy-tuner-to-t-migrate
3
3
  description: >-
4
4
  copy_tuner_client v2.0.0 への移行で、削除された独自ヘルパー tt の呼び出しを Rails 標準の t(translate)へ
5
5
  置換するスキル。tt は PR #122 で存在理由を失い gem から削除されたため、残った tt(...) は NoMethodError に
6
- なる。大半は機械的に tt( → t( で済むが、訳文を文字列加工している箇所だけは t ではなく I18n.t へ移す必要があり
7
- (開発環境のマーカートークン混入バグ対策)、そこは 1 件ずつ確認する。対象は app 配下の .rb / .haml / .erb。
6
+ なる。大半は機械的に tt( → t( で済むが、訳文を文字列加工している箇所(→ I18n.t へ)と label 系ヘルパーの
7
+ 第一引数に渡している箇所(→ 引数構造を分離)だけは別扱いで、開発環境のマーカートークン残留バグを避けるため
8
+ 1 件ずつ確認する。対象は app 配下の .rb / .haml / .erb。
8
9
  disable-model-invocation: true
9
10
  license: MIT
10
11
  ---
@@ -36,13 +37,46 @@ middleware 自体が無いので再現しない=開発環境だけ壊れる)
36
37
  `t` にすると、`tt` がもともと潰していたバグをそのまま復活させてしまう。そこは middleware のラッパーを通らない
37
38
  **`I18n.t`(絶対キー)** へ移すのが正解で、機械的には決められないため 1 件ずつ確認する。
38
39
 
40
+ ## もう一つの罠: label の第一引数に渡す
41
+
42
+ 文字列加工していなくても危険な経路がもう一つある。`t(...)` の戻り値を **`label` 系ヘルパーの第一引数**
43
+ (form builder の `f.label` / `label_tag` / 素の `label`)に渡しているケース:
44
+
45
+ ```haml
46
+ = f.label t('activerecord.attributes.field.keywords'), class: 'form-label'
47
+ ```
48
+
49
+ `f.label` の**第一引数は「ラベルテキスト」ではなく method 名**(`for` 属性・`id`・ラベル文字列の元)として
50
+ 扱われる。そのため development では次の連鎖でマーカーが消せない位置に残る:
51
+
52
+ 1. `t(...)` が `⟦CT:activerecord.attributes.field.keywords⟧分類` を返す。
53
+ 2. `f.label` がこれを method 名と解釈し、**ラベルテキスト**と **`for` 属性**の 2 か所に展開する。
54
+ 3. ラベルテキスト側は Rails の `humanize` で**小文字化**され `⟦ct:…⟧` になる。
55
+ 4. Rewriter のマーカー検出プレフィックスは **大文字 `⟦CT:` 固定**(`lib/copy_tuner_client/copyray/marker.rb`
56
+ の `PREFIX`)。小文字化された `⟦ct:` は一致せず、除去されないまま画面に残る。
57
+
58
+ これも v1.x では `tt`(マーカー無し版)だったため表面化せず、**`tt → t` 一括置換で初めて顕在化する回帰**。
59
+
60
+ **重要: このケースは `I18n.t` 化では直らない。** マーカーを消しても、`t`/`I18n.t` の戻り値(訳文文字列)を
61
+ `label` の第一引数に渡す構造自体が `humanize` / `for` 属性の挙動として不適切だからだ。正しい修正は
62
+ **method 名を第一引数・表示テキストを第二引数に分離**すること:
63
+
64
+ ```haml
65
+ = f.label :keywords_cont, t('activerecord.attributes.field.keywords'), class: 'form-label'
66
+ ```
67
+
68
+ `for` 属性が変わるので、ラベルクリックでフィールドにフォーカスが当たることを実機で確認する。
69
+ スクリプトはこのパターンを suspicious のサブ種別 `label_arg` として抽出する(`label:` オプションに渡す
70
+ simple_form の `f.input :x, label: tt('k')` も同様に拾う)。
71
+
39
72
  ## 変換の 3 分類
40
73
 
41
- | 分類 | 例 | 扱い |
42
- |---|---|---|
43
- | **safe** | `tt('views.foo')` / `= tt '.title'` / `tt(key, default: x)` | `tt(` → `t(` に決定論的一括変換 |
44
- | **suspicious** | `truncate(tt('k'))` / `tt('k').length` / `tt('k')[0..n]` / `tt('k') =~ /re/` / `tt('k').gsub(...)` 等 | `I18n.t('絶対キー')` へ。**1 件ずつ確認**。スクリプトは触らない |
45
- | **other** | `def tt` / `alias tt`(定義側)/ app 外(`lib/` 等)/ `tt` を含む別識別子の誤検出 | 最後にまとめて提示。自動変換しない |
74
+ | 分類 | サブ種別 | 例 | 扱い |
75
+ |---|---|---|---|
76
+ | **safe** | — | `tt('views.foo')` / `= tt '.title'` / `tt(key, default: x)` / `f.label :method, tt('k')`(第二引数) | `tt(` → `t(` に決定論的一括変換 |
77
+ | **suspicious** | `string_manipulation` | `truncate(tt('k'))` / `tt('k').length` / `tt('k')[0..n]` / `tt('k') =~ /re/` / `tt('k').gsub(...)` 等 | `I18n.t('絶対キー')` へ。**1 件ずつ確認**。スクリプトは触らない |
78
+ | **suspicious** | `label_arg` | `= f.label tt('k')` / `label_tag tt('k')` / `f.input :x, label: tt('k')`(label 系の**第一引数 / label: オプション**) | **`f.label :method, t('k')` へ構造を直す**(method 名を第一引数・表示テキストを第二引数に)。**`I18n.t` 化では直らない**。1 件ずつ確認 |
79
+ | **other** | — | `def tt` / `alias tt`(定義側)/ app 外(`lib/` 等)/ `tt` を含む別識別子の誤検出 | 最後にまとめて提示。自動変換しない |
46
80
 
47
81
  **重要(定義側の罠):** アプリが v2.0.0 を待つ間の後方互換として `ApplicationHelper` に `def tt(key, **) = t(key, **)`
48
82
  のような **`tt` の定義**を生やしていることがある。これは「呼び出し」ではないので `t(` へ機械置換すると Rails の `t`
@@ -80,11 +114,21 @@ suspicious / other の行は**一切触らない**(行番号で限定してい
80
114
 
81
115
  ### 3. suspicious な箇所を 1 件ずつ確認
82
116
 
83
- ここが人間の判断が要る核心。手順 1 の suspicious 各件について、次を 1 件ずつ提示して確認を取る:
117
+ ここが人間の判断が要る核心。手順 1 の suspicious 各件について、次を 1 件ずつ提示して確認を取る。
118
+ **suspicious はサブ種別(`[label_arg]` / `[string_manipulation]`)で修正方法が異なる**ので、種別で分岐する:
84
119
 
120
+ **`[string_manipulation]`(戻り値を文字列加工している):**
85
121
  - 該当コード(`ファイル:行`)と、なぜ怪しいか(戻り値を文字列加工している=マーカー混入の危険)
86
122
  - 提案する置換: `t(...)` ではなく **`I18n.t('絶対キー')`**(`I18n` モジュール直呼びはラッパーを通らずマーカーが付かない)
87
123
 
124
+ **`[label_arg]`(label 系の第一引数 / label: オプションに渡している):**
125
+ - なぜ怪しいか(`humanize` で小文字化された `⟦ct:…⟧` が大文字 `⟦CT:` 固定の Rewriter をすり抜け残る。「もう一つの罠」参照)
126
+ - 提案する置換: **`I18n.t` 化ではなく引数構造を直す。** `= f.label t('views.foo.bar'), class: …` →
127
+ `= f.label :method_name, t('views.foo.bar'), class: …`。第一引数の method 名(`for` 属性に使うシンボル)は
128
+ ユーザに確認するか、対応する入力フィールド(`f.text_field :keywords_cont` 等)から推定する。
129
+ `label:` オプション(`f.input :x, label: tt('k')`)の場合は引数構造はそのままで `t('k')` でよいことが多いが、
130
+ そのフィールドのラベルが `humanize` を経るかは入力次第なので 1 件ずつ確認する。
131
+
88
132
  注意点:
89
133
  - **たとえユーザが「全部まとめて I18n.t にして」と言っても、suspicious は必ず 1 件ずつ提示する。** 相対キーを含む件は絶対キーをユーザと確認しないと置換できないためで、省略すると誤ったキーを書き込む危険がある。
90
134
  - **相対キー(先頭ドット `tt('.foo')`)は `I18n.t` では解決できない。** 絶対キー(`I18n.t('views.foo.bar')`)へ
@@ -94,6 +138,8 @@ suspicious / other の行は**一切触らない**(行番号で限定してい
94
138
  調べて基点キーを特定するか、ユーザに直接絶対キーを確認する。絶対キーが不明なときは
95
139
  `grep -r "キー末尾部分" config/locales/` でロケールファイルを検索して候補を絞り込む。
96
140
  - 本当に文字列加工していない(誤検出)なら、その場合は `t(...)` でよい。ユーザの判断に従う。
141
+ - **行をまたぐ変数経由(`txt = t('k')` → 別行で `f.label txt` や `truncate(txt)`)はスクリプトの行単位
142
+ スキャンでは拾えない**(構造的限界)。最終防波堤は手順 5 の「実画面に小文字マーカー `⟦ct:…⟧` が出ないか目視」。
97
143
 
98
144
  確認が取れた箇所だけ Edit で個別に置換する(スクリプトでは変換しない)。
99
145
 
@@ -118,6 +164,9 @@ git grep -nwI tt -- app # -I でバイナリ(画像等)の誤マッチを
118
164
  (safe は 0 件、`I18n.t` 化した suspicious も `tt` としては消える)。
119
165
  - 対象アプリで `bundle exec rspec`(または該当アプリのテスト)を回し、`NoMethodError` が出ないこと。
120
166
  - development で実画面を開き、文字列加工していた箇所にマーカートークン(`⟦CT:...⟧`)が混入していないこと。
167
+ - **label を含む画面で、小文字マーカー `⟦ct:...⟧`(小文字 ct)が残っていないこと。** Rewriter は大文字
168
+ `⟦CT:` しか除去しないため、`humanize` で小文字化された取りこぼしは**この目視が最終防波堤**になる
169
+ (label 第一引数の罠・行をまたぐ変数経由はここで初めて顕在化することがある)。
121
170
 
122
171
  ## スクリプトの責務(決定論的な範囲のみ)
123
172
 
@@ -81,6 +81,18 @@ STRING_METHOD_RE = /\btt\b\s*(?:\([^\n]*?\)|['":@][^\n,]*)\s*(?:\.\s*(?:#{STRING
81
81
  # 先頭ドットの相対キー tt('.foo') は I18n.t では解決できず絶対キー化が要る → 強調表示用。
82
82
  RELATIVE_KEY_RE = /\btt\b\s*(?:\(\s*)?['":]\s*\./
83
83
 
84
+ # label 系ヘルパーの第一引数 / label: オプションに tt(t) を渡すパターン。
85
+ # t 化すると戻り値(⟦CT:..⟧) が method 名扱いされ、humanize で小文字化された ⟦ct:..⟧ が
86
+ # Rewriter(大文字 ⟦CT: 固定)の除去網をすり抜け画面に残る。I18n.t 化では直らない。
87
+ # 正しい修正は method 名を第一引数・表示テキストを第二引数へ分離すること。
88
+ LABEL_FIRST_ARG_RE = /
89
+ (?: (?<![\w.])\w+\.label\b | \blabel_tag\b | (?<![\w.])label(?![\w]) )
90
+ \s*\(?\s*
91
+ tt\b (?=\s*[("':@])
92
+ /x
93
+ # simple_form 等の label: オプションに渡すケース(f.input :x, label: tt('k'))
94
+ LABEL_OPTION_RE = /\blabel:\s*tt\b/
95
+
84
96
  def relative(path)
85
97
  path.sub("#{ROOT}/", '')
86
98
  end
@@ -117,8 +129,19 @@ def classify_line(line, entry, in_app:)
117
129
  return [:other, entry]
118
130
  end
119
131
 
132
+ # label の第一引数 / label: オプションに渡すケースは文字列加工が無いので、safe 確定より前に拾う。
133
+ # 修正方法が文字列加工系(I18n.t 化)と異なるためサブ種別 :label_arg で区別する。
134
+ if line.match?(LABEL_FIRST_ARG_RE) || line.match?(LABEL_OPTION_RE)
135
+ entry[:kind] = :label_arg
136
+ entry[:hint] = 'label の引数に渡している。t 化すると humanize で小文字化された ⟦ct:..⟧ が ' \
137
+ 'Rewriter(大文字 ⟦CT: のみ除去)をすり抜け画面に残る。I18n.t 化では直らない。' \
138
+ 'method 名を第一引数・表示テキストを第二引数に分離: f.label :method, t(...)'
139
+ return [:suspicious, entry]
140
+ end
141
+
120
142
  return [:safe, entry] unless line.match?(STRING_HELPER_RE) || line.match?(STRING_METHOD_RE)
121
143
 
144
+ entry[:kind] = :string_manipulation
122
145
  entry[:relative_key] = line.match?(RELATIVE_KEY_RE)
123
146
  entry[:hint] = if entry[:relative_key]
124
147
  'I18n.t へ。相対キー(.foo)は絶対キーへ書き換えが必要'
@@ -147,10 +170,14 @@ end
147
170
  def print_section(title, entries)
148
171
  puts "\n== #{title} (#{entries.size}) =="
149
172
  entries.each do |e|
173
+ # hint があれば必ず kind も付く(classify_line で対で設定)ので素直に出す。
174
+ # suspicious 内のサブ種別(label_arg / string_manipulation)を可視化する。
150
175
  suffix = if e[:hint]
151
- " # #{e[:hint]}"
176
+ " # [#{e[:kind]}] #{e[:hint]}"
177
+ elsif e[:reason]
178
+ " # #{e[:reason]}"
152
179
  else
153
- (e[:reason] ? " # #{e[:reason]}" : '')
180
+ ''
154
181
  end
155
182
  puts "#{e[:file]}:#{e[:lineno]}: #{e[:code].strip}#{suffix}"
156
183
  end
@@ -19,9 +19,8 @@ describe CopyTunerClient::CopyrayMiddleware do
19
19
  configuration.project_id = 1
20
20
  configuration.client = FakeClient.new
21
21
  end
22
- # NOTE: CSS/JS 挿入は Rails の ActionController::Base.helpers に依存するため、
22
+ # NOTE: JS 挿入は Rails の ActionController::Base.helpers に依存するため、
23
23
  # Rewriter の効果だけを検証できるよう no-op にスタブする。
24
- allow(middleware).to receive(:append_css) { |html, _| html }
25
24
  allow(middleware).to receive(:append_js) { |html, *| html }
26
25
  end
27
26
 
@@ -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
+ }
@@ -0,0 +1,153 @@
1
+ import { BAR_STYLES } from './styles'
2
+ import { debounce } from './util'
3
+
4
+ type OpenCallback = (key: string) => void
5
+
6
+ type InitOptions = {
7
+ url: string
8
+ data: Record<string, string>
9
+ keysSkipped: boolean
10
+ onOpen: OpenCallback
11
+ }
12
+
13
+ // 画面下部のツールバー。CopyTuner / Sync ボタン、ページ内翻訳の検索・ログメニューを Shadow DOM 内に描画する。
14
+ export class CopytunerBar extends HTMLElement {
15
+ #onOpen: OpenCallback = () => {}
16
+ #searchBox!: HTMLInputElement
17
+ #logMenu!: HTMLDivElement
18
+
19
+ constructor() {
20
+ super()
21
+ this.attachShadow({ mode: 'open' })
22
+ }
23
+
24
+ // custom element の constructor 内では属性・プロパティを変更できない(createElement が弾く)ため、
25
+ // hidden の初期化は DOM 挿入後に呼ばれる connectedCallback で行う。
26
+ connectedCallback() {
27
+ this.hidden = true
28
+ }
29
+
30
+ // url/data/keysSkipped/onOpen はオブジェクトや関数を含むため属性ではなくメソッドで受け渡す。
31
+ init({ url, data, keysSkipped, onOpen }: InitOptions) {
32
+ this.#onOpen = onOpen
33
+ const shadow = this.shadowRoot as ShadowRoot
34
+
35
+ const style = document.createElement('style')
36
+ style.textContent = BAR_STYLES
37
+ shadow.append(style)
38
+
39
+ // 元々 Rails から出力されていたマークアップに合わせたボタン群。
40
+ // url は設定値だが innerHTML に直接埋めず setAttribute で渡す(XSS 面で安全側に倒す)。
41
+ const copyTunerButton = this.makeButton('CopyTuner', url, '_blank')
42
+ const syncButton = this.makeButton('Sync', '/copytuner', '_blank')
43
+ const openLogButton = this.makeButton('Translations in this page', 'javascript:void(0)')
44
+
45
+ this.#searchBox = document.createElement('input')
46
+ this.#searchBox.type = 'text'
47
+ this.#searchBox.classList.add('search')
48
+ this.#searchBox.placeholder = 'search'
49
+
50
+ shadow.append(copyTunerButton, syncButton, openLogButton, this.#searchBox)
51
+
52
+ this.#logMenu = this.makeLogMenu(data)
53
+ shadow.append(this.#logMenu)
54
+
55
+ // 巨大DOM/Nokogiri例外でキー付与がスキップされた場合は、オーバーレイが使えないので
56
+ // ツールバー(Translations in this page)から編集する旨を案内する。
57
+ if (keysSkipped) {
58
+ this.appendSkippedNotice()
59
+ }
60
+
61
+ openLogButton.addEventListener('click', (event) => {
62
+ event.preventDefault()
63
+ this.toggleLogMenu()
64
+ })
65
+ this.#searchBox.addEventListener('input', debounce(this.onSearch.bind(this), 250))
66
+ }
67
+
68
+ show() {
69
+ this.hidden = false
70
+ this.#searchBox.focus()
71
+ }
72
+
73
+ hide() {
74
+ this.hidden = true
75
+ }
76
+
77
+ private makeButton(label: string, href: string, target?: string): HTMLAnchorElement {
78
+ const button = document.createElement('a')
79
+ button.classList.add('button')
80
+ button.textContent = label
81
+ button.href = href
82
+ if (target) {
83
+ button.target = target
84
+ }
85
+ return button
86
+ }
87
+
88
+ private appendSkippedNotice() {
89
+ const notice = document.createElement('span')
90
+ notice.classList.add('notice')
91
+ notice.textContent = '⚠ This page is too large for the overlay. Use "Translations in this page" to edit.'
92
+ ;(this.shadowRoot as ShadowRoot).append(notice)
93
+ }
94
+
95
+ private showLogMenu() {
96
+ this.#logMenu.hidden = false
97
+ }
98
+
99
+ private toggleLogMenu() {
100
+ this.#logMenu.hidden = !this.#logMenu.hidden
101
+ }
102
+
103
+ private makeLogMenu(data: Record<string, string>): HTMLDivElement {
104
+ const div = document.createElement('div')
105
+ div.classList.add('log-menu')
106
+ div.hidden = true
107
+
108
+ const table = document.createElement('table')
109
+ const tbody = document.createElement('tbody')
110
+
111
+ for (const key of Object.keys(data).sort()) {
112
+ const value = data[key]
113
+ if (value === '') {
114
+ continue
115
+ }
116
+
117
+ const td1 = document.createElement('td')
118
+ td1.textContent = key
119
+ const td2 = document.createElement('td')
120
+ td2.textContent = value
121
+ const tr = document.createElement('tr')
122
+ tr.dataset.key = key
123
+
124
+ tr.addEventListener('click', ({ currentTarget }) => {
125
+ const row = currentTarget as HTMLTableRowElement
126
+ if (row.dataset.key) {
127
+ this.#onOpen(row.dataset.key)
128
+ }
129
+ })
130
+
131
+ tr.append(td1, td2)
132
+ tbody.append(tr)
133
+ }
134
+
135
+ table.append(tbody)
136
+ div.append(table)
137
+
138
+ return div
139
+ }
140
+
141
+ private onSearch() {
142
+ // debounce 経由で遅延実行されると Event.target は null 化されるため、検索ボックスを直接参照する
143
+ const keyword = this.#searchBox.value.trim()
144
+ this.showLogMenu()
145
+
146
+ const rows = [...this.#logMenu.querySelectorAll('tr')]
147
+ for (const row of rows) {
148
+ const isShow =
149
+ keyword === '' || [...row.querySelectorAll('td')].some((td) => (td.textContent ?? '').includes(keyword))
150
+ row.hidden = !isShow
151
+ }
152
+ }
153
+ }
data/src/main.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable no-console */
2
- import Copyray from './copyray'
2
+ import { CopyrayOverlay } from './copyray-overlay'
3
+ import { CopytunerBar } from './copytuner-bar'
3
4
  import { isMac } from './util'
4
5
 
5
6
  declare global {
@@ -7,8 +8,7 @@ declare global {
7
8
  CopyTuner: {
8
9
  url: string
9
10
  toggle?: () => void
10
- // TODO: type
11
- data: object
11
+ data: Record<string, string>
12
12
  // 巨大DOM/Nokogiri例外で data-copyray-key 付与をスキップしたか。
13
13
  // true のときオーバーレイは使えないのでツールバーから編集する旨を案内する。
14
14
  keysSkipped?: boolean
@@ -16,38 +16,42 @@ declare global {
16
16
  }
17
17
  }
18
18
 
19
- import './copyray.css'
20
-
21
- // NOTE: 元々railsから出力されいてたマークアップに合わせてひとまず、、
22
- const appendCopyTunerBar = (url: string) => {
23
- const bar = document.createElement('div')
24
- bar.id = 'copy-tuner-bar'
25
- bar.classList.add('copy-tuner-hidden')
26
- bar.innerHTML = `
27
- <a class="copy-tuner-bar-button" target="_blank" href="${url}">CopyTuner</a>
28
- <a href="/copytuner" target="_blank" class="copy-tuner-bar-button">Sync</a>
29
- <a href="javascript:void(0)" class="copy-tuner-bar-open-log copy-tuner-bar-button js-copy-tuner-bar-open-log">Translations in this page</a>
30
- <input type="text" class="copy-tuner-bar__search js-copy-tuner-bar-search" placeholder="search">
31
- `
32
- document.body.append(bar)
33
- }
19
+ customElements.define('copytuner-bar', CopytunerBar)
20
+ customElements.define('copyray-overlay', CopyrayOverlay)
34
21
 
35
22
  const start = () => {
36
23
  const { url, data, keysSkipped } = window.CopyTuner
24
+ const onOpen = (key: string) => window.open(`${url}/blurbs/${key}/edit`)
25
+
26
+ const bar = document.createElement('copytuner-bar') as CopytunerBar
27
+ document.body.append(bar)
28
+ bar.init({ url, data, keysSkipped: Boolean(keysSkipped), onOpen })
29
+
30
+ const overlay = document.createElement('copyray-overlay') as CopyrayOverlay
31
+ overlay.onOpen = onOpen
32
+ document.body.append(overlay)
33
+
34
+ const show = () => {
35
+ overlay.show()
36
+ bar.show()
37
+ }
38
+ const hide = () => {
39
+ overlay.hide()
40
+ bar.hide()
41
+ }
42
+ const toggle = () => (overlay.isShowing ? hide() : show())
37
43
 
38
- appendCopyTunerBar(url)
39
- const copyray = new Copyray(url, data, Boolean(keysSkipped))
40
- window.CopyTuner.toggle = () => copyray.toggle()
44
+ overlay.onToggle = toggle
45
+ window.CopyTuner.toggle = toggle
41
46
 
42
47
  document.addEventListener('keydown', (event) => {
43
- // @ts-expect-error TS2339
44
- if (copyray.isShowing && ['Escape', 'Esc'].includes(event.key)) {
45
- copyray.hide()
48
+ if (overlay.isShowing && ['Escape', 'Esc'].includes(event.key)) {
49
+ hide()
46
50
  return
47
51
  }
48
52
 
49
53
  if (((isMac && event.metaKey) || (!isMac && event.ctrlKey)) && event.shiftKey && event.key.toLowerCase() === 'k') {
50
- copyray.toggle()
54
+ toggle()
51
55
  }
52
56
  })
53
57