copy_tuner_client 1.5.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +2 -2
  3. data/.github/workflows/rspec.yml +1 -1
  4. data/CHANGELOG.md +27 -0
  5. data/CLAUDE.md +6 -4
  6. data/README.md +6 -27
  7. data/UPGRADING.md +201 -0
  8. data/app/assets/javascripts/copytuner.js +162 -250
  9. data/biome.json +39 -0
  10. data/index.html +9 -11
  11. data/lib/copy_tuner_client/cache.rb +0 -2
  12. data/lib/copy_tuner_client/configuration.rb +23 -37
  13. data/lib/copy_tuner_client/copyray/marker.rb +24 -0
  14. data/lib/copy_tuner_client/copyray/rewriter.rb +113 -0
  15. data/lib/copy_tuner_client/copyray.rb +11 -4
  16. data/lib/copy_tuner_client/copyray_middleware.rb +10 -8
  17. data/lib/copy_tuner_client/engine.rb +1 -1
  18. data/lib/copy_tuner_client/helper_extension.rb +0 -3
  19. data/lib/copy_tuner_client/i18n_backend.rb +5 -12
  20. data/lib/copy_tuner_client/version.rb +1 -1
  21. data/mise.toml +3 -0
  22. data/package.json +9 -13
  23. data/pnpm-lock.yaml +600 -0
  24. data/skills/copy-tuner/SKILL.md +37 -6
  25. data/skills/copy-tuner-to-locales-cleanup/SKILL.md +4 -4
  26. data/skills/copy-tuner-to-locales-migrate-prefix/references/local-first-regexp.md +4 -9
  27. data/skills/copy-tuner-to-t-migrate/SKILL.md +131 -0
  28. data/skills/copy-tuner-to-t-migrate/scripts/migrate_tt.rb +189 -0
  29. data/spec/copy_tuner_client/cache_spec.rb +2 -10
  30. data/spec/copy_tuner_client/client_spec.rb +1 -0
  31. data/spec/copy_tuner_client/configuration_spec.rb +16 -16
  32. data/spec/copy_tuner_client/copyray/marker_spec.rb +41 -0
  33. data/spec/copy_tuner_client/copyray/rewriter_spec.rb +216 -0
  34. data/spec/copy_tuner_client/copyray_middleware_spec.rb +88 -0
  35. data/spec/copy_tuner_client/copyray_spec.rb +22 -39
  36. data/spec/copy_tuner_client/helper_extension_spec.rb +18 -5
  37. data/spec/copy_tuner_client/i18n_backend_spec.rb +8 -15
  38. data/spec/support/client_spec_helpers.rb +0 -1
  39. data/src/copyray-overlay.ts +125 -0
  40. data/src/copytuner-bar.ts +153 -0
  41. data/src/main.ts +33 -26
  42. data/src/{copyray.css → styles.ts} +112 -136
  43. data/src/util.ts +9 -1
  44. data/tsconfig.json +5 -10
  45. data/vite.config.ts +0 -1
  46. metadata +15 -8
  47. data/.eslintrc.js +0 -12
  48. data/app/assets/stylesheets/copytuner.css +0 -1
  49. data/src/copyray.ts +0 -130
  50. data/src/copytuner_bar.ts +0 -115
  51. data/src/specimen.ts +0 -84
  52. data/yarn.lock +0 -2540
@@ -0,0 +1,24 @@
1
+ module CopyTunerClient
2
+ class Copyray
3
+ # 訳文に埋め込むキーマーカーの可視テキストトークン。
4
+ # サーバ側(CopyrayMiddleware の Rewriter)だけが encode/scan/strip すればよく、
5
+ # フロントは data 属性化された後の DOM を見るので decode は不要。
6
+ #
7
+ # 例: encode('views.home.message') #=> "⟦CT:views.home.message⟧"
8
+ module Marker
9
+ # NOTE: 通常の本文・属性値に出現しない記号(U+27E6 / U+27E7)と固定プレフィックス CT: で
10
+ # 偶発衝突を二重に下げる。除去漏れても可読なので人間が原因に気づける(不可視文字は不採用)。
11
+ PREFIX = '⟦CT:'.freeze
12
+ SUFFIX = '⟧'.freeze
13
+
14
+ # NOTE: PREFIX/SUFFIX から動的構築し、区切り変更時に encode と SCAN_REGEXP がズレないようにする。
15
+ SCAN_REGEXP = /#{Regexp.escape(PREFIX)}(.*?)#{Regexp.escape(SUFFIX)}/
16
+
17
+ module_function
18
+
19
+ def encode(key)
20
+ "#{PREFIX}#{key}#{SUFFIX}"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,113 @@
1
+ require 'nokogiri'
2
+ require 'copy_tuner_client/copyray/marker'
3
+
4
+ module CopyTunerClient
5
+ class Copyray
6
+ # 完成 HTML を走査し、マーカートークンを含むテキストノードの親要素・属性値を持つ要素に
7
+ # data-copyray-key 属性を付与し、トークンを完全に除去する。
8
+ # CopyrayMiddleware の HTML 後処理(CSS/JS 挿入)の前段で呼ばれる。
9
+ module Rewriter
10
+ DATA_ATTR = 'data-copyray-key'.freeze
11
+
12
+ # NOTE: マーカーを含む巨大 HTML を Nokogiri で parse/traverse/再シリアライズすると
13
+ # サイズに比例して重くなる(実測 ~1MB で 167ms, ~3MB で 514ms)。development 限定機能で
14
+ # レスポンスを大きく悪化させないため、この閾値超では Nokogiri を通さず可視トークン除去のみ行う。
15
+ # 失うのは data-copyray-key(オーバーレイ編集導線)だけで、フォールバックの gsub は巨大ページでも数ms。
16
+ MAX_REWRITE_BYTESIZE = 1_000_000
17
+
18
+ class << self
19
+ # NOTE: 戻り値は [html, skipped]。skipped は data-copyray-key を付与できなかったことを表す
20
+ # (巨大DOMでのスキップ・Nokogiri 例外の双方で true)。ミドルウェアがこれを JS に伝え、
21
+ # オーバーレイ非対応である旨をツールバーで案内する。
22
+ def rewrite(html)
23
+ # NOTE: ボディが ASCII-8BIT に転落していると UTF-8 の Marker::PREFIX との include? 比較が
24
+ # Encoding::CompatibilityError を投げる(ミドルウェアのボディ連結で非ASCIIバイトを含む
25
+ # ASCII-8BIT チャンクが混じると発生)。実バイト列は本来 UTF-8 なので判定用に UTF-8 とみなす。
26
+ # String.new でエンコーディングだけ付け替える(元オブジェクトを破壊せずバッファもコピーしない)。
27
+ scannable = html.encoding == Encoding::UTF_8 ? html : String.new(html, encoding: Encoding::UTF_8)
28
+
29
+ # NOTE: マーカーが無ければ Copyray 無効時・マーカーの無い通常ページなので一切変形しない(高速パス)。
30
+ # これによりマーカー非注入の HTML は完全に無傷で、Nokogiri の正規化も通らない。
31
+ # (そもそも CopyrayMiddleware は development 限定で本番のスタックには登録されず、本番では呼ばれない。)
32
+ # 判定は正規表現より安い部分文字列検索で行う(プレフィックスがあれば必ずマーカー候補)。
33
+ return [html, false] unless scannable.include?(Marker::PREFIX)
34
+
35
+ # NOTE: 閾値超は Nokogiri を通さず可視トークン除去のみ。skipped=true で編集導線を諦めた旨を伝える。
36
+ return [strip_markers(scannable), true] if scannable.bytesize > MAX_REWRITE_BYTESIZE
37
+
38
+ [rewrite_with_nokogiri(scannable), false]
39
+ rescue StandardError => e
40
+ # NOTE: Copyray は開発支援機能なので、壊れた HTML 等で Nokogiri 処理が落ちても
41
+ # ページを 500 にしない。data-copyray-key 付与(編集導線)は諦め、最低限可視トークンだけ除去する。
42
+ warn_rewrite_failure(e)
43
+ [strip_markers(scannable), true]
44
+ end
45
+
46
+ private
47
+
48
+ # NOTE: gsub 対象が html ではなく scannable なのは、ASCII-8BIT のままだと UTF-8 の
49
+ # SCAN_REGEXP との比較で Encoding::CompatibilityError が再発しうるため。
50
+ def strip_markers(scannable)
51
+ scannable.gsub(Marker::SCAN_REGEXP, '')
52
+ end
53
+
54
+ def rewrite_with_nokogiri(scannable)
55
+ doc = Nokogiri::HTML(scannable)
56
+
57
+ doc.traverse do |node|
58
+ if node.text?
59
+ annotate_text_node(node)
60
+ elsif node.element?
61
+ annotate_attributes(node)
62
+ end
63
+ end
64
+
65
+ # NOTE: Nokogiri 走査はテキストノード親要素・属性値への属性付与とノード単位の除去を担うが、
66
+ # serialize 時の正規化(エンティティ復元等)でトークンが復活しうる縁を塞ぐため、
67
+ # 最終出力にもう一度 gsub をかけて残留トークンを保険で全除去する(可視トークンの除去漏れは画面に出るため)。
68
+ strip_markers(doc.to_html)
69
+ end
70
+
71
+ # NOTE: logger 未設定の構成でもフォールバック自体が落ちないよう nil ガードする。
72
+ def warn_rewrite_failure(error)
73
+ logger = CopyTunerClient.configuration.logger
74
+ logger&.warn("CopyTuner Copyray::Rewriter failed: #{error.class.name}: #{error.message}")
75
+ end
76
+
77
+ def annotate_text_node(node)
78
+ keys = scan_keys(node.content)
79
+ return if keys.empty?
80
+
81
+ set_keys(node.parent, keys)
82
+ node.content = node.content.gsub(Marker::SCAN_REGEXP, '')
83
+ end
84
+
85
+ def annotate_attributes(element)
86
+ element.attribute_nodes.each do |attr|
87
+ keys = scan_keys(attr.value)
88
+ next if keys.empty?
89
+
90
+ set_keys(element, keys)
91
+ attr.value = attr.value.gsub(Marker::SCAN_REGEXP, '')
92
+ end
93
+ end
94
+
95
+ # NOTE: 空キーは除く(JS 側も split 後に空要素を捨てるため、表現を両端で揃える)。
96
+ def scan_keys(text)
97
+ text.scan(Marker::SCAN_REGEXP).flatten.reject(&:empty?)
98
+ end
99
+
100
+ # NOTE: 同一テキストノード/属性値に複数マーカーが連結されると全キーをここで受け取り、
101
+ # 1 要素に複数キーをカンマ区切りで保持する(1 要素 1 キーだと 2 個目以降の編集導線が消える)。
102
+ # 既存値がある場合(同じ要素を複数経路で踏むケース)はマージして重複排除し、出現順を保つ。
103
+ # I18n キーに ',' は通常含まれないため区切り文字として安全。JS 側もカンマ区切りを前提に読む。
104
+ def set_keys(element, keys)
105
+ return if element.nil? || !element.element?
106
+
107
+ existing = element[DATA_ATTR]&.split(',') || []
108
+ element[DATA_ATTR] = (existing + keys).uniq.join(',')
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -1,9 +1,12 @@
1
+ require 'copy_tuner_client/copyray/marker'
2
+
1
3
  module CopyTunerClient
2
4
  class Copyray
3
5
  # This:
4
6
  # message
5
7
  # Becomes:
6
- # <!--COPYRAY views.home.index.message-->message
8
+ # ⟦CT:views.home.index.messagemessage
9
+ # マーカートークンは CopyrayMiddleware の Rewriter で data-copyray-key 属性に変換され、HTML から除去される。
7
10
  def self.augment_template(source, key)
8
11
  return source if source.blank? || !source.is_a?(String)
9
12
 
@@ -11,9 +14,13 @@ module CopyTunerClient
11
14
  # オーバーレイマーカーを出さない。編集できないキーを編集可能だと誤認させないため。
12
15
  return source if CopyTunerClient.configuration.local_first_key?(key)
13
16
 
14
- escape = CopyTunerClient.configuration.html_escape && !source.html_safe?
15
- augmented = "<!--COPYRAY #{key}-->#{escape ? ERB::Util.html_escape(source) : source}"
16
- augmented.html_safe
17
+ # NOTE: マーカーは平文・html_safe どちらの訳文にも埋め込む(画面に出る全テキストをオーバーレイ対象にする)。
18
+ # トークンの区切り記号 ⟦⟧ HTML 特殊文字ではないため、平文が ActionView でエスケープされても無傷で残り、
19
+ # Rewriter の走査は崩れない。
20
+ # html_safe フラグは source のものを引き継ぐ。html_safe を勝手に立てると平文訳文の本体(& < >)が
21
+ # エスケープされず XSS になり、逆に html_safe を落とすと _html 訳文がエスケープされて壊れるため。
22
+ augmented = Marker.encode(key) + source
23
+ source.html_safe? ? augmented.html_safe : augmented
17
24
  end
18
25
  end
19
26
  end
@@ -1,5 +1,7 @@
1
1
  # cf) xray-rails : xray/middleware.rb
2
2
 
3
+ require 'copy_tuner_client/copyray/rewriter'
4
+
3
5
  module CopyTunerClient
4
6
  class CopyrayMiddleware
5
7
  def initialize(app)
@@ -11,8 +13,12 @@ module CopyTunerClient
11
13
  status, headers, response = @app.call(env)
12
14
  if html_headers?(status, headers) && body = response_body(response)
13
15
  csp_nonce = env['action_dispatch.content_security_policy_nonce'] || env['secure_headers_content_security_policy_nonce']
14
- body = append_css(body, csp_nonce)
15
- body = append_js(body, csp_nonce)
16
+ # NOTE: CSS/JS 挿入の前に Rewriter を通す。serialize 後も </body> は必ず出力されるので
17
+ # append_to_html_body rindex は機能し、CSS/JS タグはトークン非含有なので二重処理も起きない。
18
+ # NOTE: skipped は data-copyray-key を付与できなかったこと(巨大DOM/Nokogiri例外)を表す。
19
+ # JS にこれを伝え、オーバーレイ非対応である旨をツールバーで案内させる。
20
+ body, skipped = CopyTunerClient::Copyray::Rewriter.rewrite(body)
21
+ body = append_js(body, csp_nonce, skipped: skipped)
16
22
  content_length = body.bytesize.to_s
17
23
  headers['Content-Length'] = content_length
18
24
  # maintains compatibility with other middlewares
@@ -32,12 +38,7 @@ module CopyTunerClient
32
38
  ActionController::Base.helpers
33
39
  end
34
40
 
35
- def append_css(html, csp_nonce)
36
- css_tag = helpers.stylesheet_link_tag 'copytuner', media: :all, nonce: csp_nonce
37
- append_to_html_body(html, css_tag)
38
- end
39
-
40
- def append_js(html, csp_nonce)
41
+ def append_js(html, csp_nonce, skipped: false)
41
42
  json =
42
43
  if CopyTunerClient::TranslationLog.initialized?
43
44
  CopyTunerClient::TranslationLog.translations.to_json
@@ -49,6 +50,7 @@ module CopyTunerClient
49
50
  window.CopyTuner = {
50
51
  url: '#{CopyTunerClient.configuration.project_url}',
51
52
  data: #{json},
53
+ keysSkipped: #{skipped},
52
54
  }
53
55
  SCRIPT
54
56
  append_to_html_body(html, helpers.javascript_include_tag('copytuner', type: 'module', crossorigin: 'anonymous', nonce: csp_nonce))
@@ -25,7 +25,7 @@ module CopyTunerClient
25
25
  end
26
26
 
27
27
  initializer "copy_tuner.assets.precompile", group: :all do |app|
28
- app.config.assets.precompile += ['copytuner.js', 'copytuner.css']
28
+ app.config.assets.precompile += ['copytuner.js']
29
29
  end
30
30
  end
31
31
  end
@@ -60,9 +60,6 @@ module CopyTunerClient
60
60
  alias_method :translate_without_copyray_comment, :translate
61
61
  alias_method :translate, :translate_with_copyray_comment
62
62
  alias :t :translate
63
- alias :tt :translate_without_copyray_comment
64
- else
65
- alias :tt :translate
66
63
  end
67
64
  end
68
65
  end
@@ -26,18 +26,11 @@ module CopyTunerClient
26
26
  #
27
27
  # @return [Object] the translated key (usually a String)
28
28
  def translate(locale, key, options = {})
29
- # I18nの標準処理に任せる(内部でlookupが呼ばれる)
30
- content = super
31
-
32
- return content if content.nil? || content.is_a?(Hash)
33
-
34
- # HTML escapeの処理(ツリー構造のHashは除く)
35
- if CopyTunerClient.configuration.html_escape
36
- content
37
- else
38
- # Backward compatible
39
- content.respond_to?(:html_safe) ? content.html_safe : content
40
- end
29
+ # I18nの標準処理に任せる(内部でlookupが呼ばれる)。
30
+ # NOTE: html_safe 化は backend では行わない。.html/_html キーの html_safe 化は
31
+ # ActionView の TranslationHelper(ActiveSupport::HtmlSafeTranslation)が担うため、
32
+ # backend I18n 標準どおり素の content を返すだけにする(旧 html_escape 分岐は廃止)。
33
+ super
41
34
  end
42
35
 
43
36
  # Returns locales available for this CopyTuner project.
@@ -1,6 +1,6 @@
1
1
  module CopyTunerClient
2
2
  # Client version
3
- VERSION = '1.5.0'.freeze
3
+ VERSION = '2.1.0'.freeze
4
4
 
5
5
  # API version being used to communicate with the server
6
6
  API_VERSION = '2.0'.freeze
data/mise.toml ADDED
@@ -0,0 +1,3 @@
1
+ [tools]
2
+ node = "24"
3
+ pnpm = "11.9.0"
data/package.json CHANGED
@@ -4,26 +4,22 @@
4
4
  "repository": "https://github.com/SonicGarden/copy-tuner-ruby-client",
5
5
  "author": "SonicGarden",
6
6
  "license": "MIT",
7
+ "packageManager": "pnpm@11.9.0",
7
8
  "engines": {
8
- "node": "16.x"
9
+ "node": ">=24"
9
10
  },
10
11
  "scripts": {
11
12
  "dev": "vite",
12
13
  "build": "tsc && vite build",
13
- "preview": "vite preview"
14
+ "preview": "vite preview",
15
+ "lint": "biome lint .",
16
+ "format": "biome format --write .",
17
+ "check": "biome check --write ."
14
18
  },
15
19
  "dependencies": {},
16
20
  "devDependencies": {
17
- "@sonicgarden/eslint-plugin": "^0.5.2",
18
- "@sonicgarden/prettier-config": "^0.0.1",
19
- "eslint": "^8.16.0",
20
- "lodash.debounce": "^4.0.8",
21
- "typescript": "^5.0.2",
22
- "vite": "^4.2.1"
23
- },
24
- "prettier": "@sonicgarden/prettier-config",
25
- "volta": {
26
- "node": "16.15.0",
27
- "yarn": "1.22.18"
21
+ "@biomejs/biome": "2.5.0",
22
+ "typescript": "^6.0.0",
23
+ "vite": "^8.0.0"
28
24
  }
29
25
  }