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,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
|
-
#
|
|
8
|
+
# ⟦CT:views.home.index.message⟧message
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
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))
|
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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.
|
data/mise.toml
ADDED
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": "
|
|
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
|
-
"@
|
|
18
|
-
"
|
|
19
|
-
"
|
|
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
|
}
|