i18n_feedback 0.2.1 → 0.2.2
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/CHANGELOG.md +16 -0
- data/README.md +52 -6
- data/app/assets/i18n_feedback/widget.js +27 -7
- data/lib/i18n_feedback/middleware.rb +21 -7
- data/lib/i18n_feedback/version.rb +1 -1
- data/lib/i18n_feedback/widget.rb +15 -6
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a89bcc5c9f18d206d9aeeaf42aab087c528e0746828637af04839cc3adcb96b0
|
|
4
|
+
data.tar.gz: ddcb6df38749afa84e7aa2c5b0cb0a249b5dc1ec7ffca359d134d6e1dc529610
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 323a02d3eb15547900b837d8778b21e5a57b1816e3fd021e74650ad6a4b0e487c56c8f0658f6920a76474f70309f4a68304f0545f0d4c1ce245d138566c29ba9
|
|
7
|
+
data.tar.gz: 1a28aa0bc4f392e28fc65a4ed8edeeae6a20acd37deb74a398b810a20e4d9a9e054911610458b1168d2499a7e4be4abd269ac695daada9fdfea525d9878f3617
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.2.2]
|
|
6
|
+
|
|
7
|
+
- Fix suggest mode desyncing under a nonce-based CSP on Turbo visits. The runtime
|
|
8
|
+
config now rides in a `<script type="application/json">` block (data, not code)
|
|
9
|
+
that the widget re-reads on every `turbo:load`, instead of an executable
|
|
10
|
+
`<script>` the browser refuses to re-run when Turbo re-evaluates it with a stale
|
|
11
|
+
nonce. Only the widget code carries the CSP nonce now.
|
|
12
|
+
- Treat `?i18n_feedback=true|false` as a one-shot command: the middleware sets the
|
|
13
|
+
cookie and redirects (303) to the same URL without the parameter. The cookie is
|
|
14
|
+
now the single source of truth, so the parameter no longer sticks in the address
|
|
15
|
+
bar and the pill's reload can turn suggest mode off.
|
|
16
|
+
- Let a host's own toggle link work while suggest mode is active. Suggest mode
|
|
17
|
+
freezes navigation so a stray click can't leave the page mid-proofread, but that
|
|
18
|
+
also froze a `?i18n_feedback=false` link in your own nav — so the only way out
|
|
19
|
+
was the pill. Links carrying the toggle parameter are now exempt from the freeze.
|
|
20
|
+
|
|
5
21
|
## [0.2.1]
|
|
6
22
|
|
|
7
23
|
- Keep the suggest pill and active-mode highlighting working across Turbo Drive
|
data/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# i18n_feedback
|
|
2
2
|
|
|
3
|
+
[](https://rubygems.org/gems/i18n_feedback)
|
|
4
|
+
[](https://rubygems.org/gems/i18n_feedback)
|
|
5
|
+
[](https://github.com/yshmarov/i18n-feedback/actions/workflows/ci.yml)
|
|
6
|
+
[](MIT-LICENSE)
|
|
7
|
+
|
|
3
8
|
In-context translation proofreading for Rails.
|
|
4
9
|
|
|
5
10
|
`i18n_feedback` renders every translated string alongside its i18n key in the
|
|
@@ -7,6 +12,13 @@ environments you choose, lets a reviewer click any string in the running app and
|
|
|
7
12
|
suggest a better wording, and stores those suggestions for a developer to apply.
|
|
8
13
|
It is meant for development and staging, never production.
|
|
9
14
|
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
<video src="https://github.com/yshmarov/i18n-feedback/raw/main/i18n-feedback-demo-640-high.mp4" controls muted playsinline width="640">
|
|
18
|
+
Your browser can't play this video —
|
|
19
|
+
<a href="https://github.com/yshmarov/i18n-feedback/raw/main/i18n-feedback-demo-640-high.mp4">download it here</a>.
|
|
20
|
+
</video>
|
|
21
|
+
|
|
10
22
|
- **Zero UI dependencies.** The widget is plain JavaScript and styles itself. No
|
|
11
23
|
Tailwind, no daisyUI, no Stimulus, no importmap, no build step.
|
|
12
24
|
- **Zero layout changes.** The widget is injected into HTML responses
|
|
@@ -28,6 +40,13 @@ It is meant for development and staging, never production.
|
|
|
28
40
|
4. Suggestions are `POST`ed to the mounted engine and stored in the
|
|
29
41
|
`i18n_feedback_suggestions` table for you to review and apply.
|
|
30
42
|
|
|
43
|
+
## Turbo
|
|
44
|
+
|
|
45
|
+
Works with Turbo Drive out of the box. Turbo replaces `<body>` on every visit,
|
|
46
|
+
which would take the pill and the active-mode highlighting with it, so the
|
|
47
|
+
widget registers its document-level listeners once and re-renders on
|
|
48
|
+
`turbo:load`. The pill survives navigation without a full reload.
|
|
49
|
+
|
|
31
50
|
## Requirements
|
|
32
51
|
|
|
33
52
|
- Ruby >= 3.2
|
|
@@ -102,20 +121,41 @@ end
|
|
|
102
121
|
|
|
103
122
|
### Toggling suggest mode from your own link
|
|
104
123
|
|
|
105
|
-
Prefer a menu item over the floating pill?
|
|
106
|
-
|
|
124
|
+
Prefer a menu item over the floating pill? You can drive suggest mode from any
|
|
125
|
+
link in your own UI — a nav item, a sidebar entry, a footer — and optionally hide
|
|
126
|
+
the pill (the two can also coexist):
|
|
107
127
|
|
|
108
128
|
```ruby
|
|
109
|
-
config.show_pill = false
|
|
129
|
+
config.show_pill = false # optional
|
|
110
130
|
```
|
|
111
131
|
|
|
132
|
+
A one-way "turn it on" link is just the toggle parameter:
|
|
133
|
+
|
|
112
134
|
```erb
|
|
113
135
|
<%= link_to "Proofread translations", "?i18n_feedback=true" %>
|
|
114
136
|
```
|
|
115
137
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
138
|
+
For a single control that flips both ways, read the current state from the
|
|
139
|
+
`i18n_feedback` cookie and point at the opposite state:
|
|
140
|
+
|
|
141
|
+
```erb
|
|
142
|
+
<% on = cookies[:i18n_feedback].present? %>
|
|
143
|
+
<%= link_to (on ? "Disable translations editing" : "Enable translations editing"),
|
|
144
|
+
"?i18n_feedback=#{!on}" %>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Good to know:
|
|
148
|
+
|
|
149
|
+
- `?i18n_feedback=true` turns suggest mode on, `false` turns it off. The state is
|
|
150
|
+
stored in the `i18n_feedback` cookie, and the middleware then redirects to the
|
|
151
|
+
same URL without the parameter — so it never sticks in the address bar and the
|
|
152
|
+
cookie stays the single source of truth. `Esc` (or the pill) also exits.
|
|
153
|
+
- These links keep working **while suggest mode is active**. The widget freezes
|
|
154
|
+
ordinary navigation during proofreading (so a stray click can't leave the page
|
|
155
|
+
mid-edit), but any link carrying the toggle parameter is exempt — so a "Disable"
|
|
156
|
+
item in your nav always gets you out.
|
|
157
|
+
- **Don't** run the label through `I18n.t`: the tool would then mark its own
|
|
158
|
+
control as an editable string. Keep the label a plain literal.
|
|
119
159
|
|
|
120
160
|
### Gating examples
|
|
121
161
|
|
|
@@ -157,6 +197,12 @@ Each row stores `translation_key`, `locale`, `old_value`, `proposed_value`,
|
|
|
157
197
|
`config.enabled` returns true.
|
|
158
198
|
- Format and lookup namespaces (`number.*`, `date.*`, `*_html` formats, etc.) are
|
|
159
199
|
never marked, so currency and date formatting are unaffected.
|
|
200
|
+
- The injected widget code carries the request's Content-Security-Policy nonce
|
|
201
|
+
(the same one `ActionDispatch` emits), so it runs under a nonce-based
|
|
202
|
+
`script-src` policy — including `strict-dynamic` — with no configuration. It is
|
|
203
|
+
a no-op when the app sets no CSP nonce. The runtime config is shipped as a
|
|
204
|
+
`<script type="application/json">` block (data, not code), so it needs no nonce
|
|
205
|
+
and stays correct across Turbo visits.
|
|
160
206
|
|
|
161
207
|
## Development
|
|
162
208
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* i18n_feedback widget — self-contained, no framework, no build step.
|
|
3
3
|
*
|
|
4
|
-
* Reads
|
|
4
|
+
* Reads its config from the <script type="application/json"
|
|
5
|
+
* data-i18n-feedback-config> the server injects — re-read on every render so a
|
|
6
|
+
* Turbo visit always reflects the current page's suggest state.
|
|
5
7
|
*
|
|
6
8
|
* A floating pill toggles "suggest mode", which is server state: the pill sets or
|
|
7
9
|
* clears the i18n_feedback cookie and reloads, so the backend only prints the
|
|
@@ -14,7 +16,7 @@
|
|
|
14
16
|
(function () {
|
|
15
17
|
"use strict";
|
|
16
18
|
|
|
17
|
-
var config =
|
|
19
|
+
var config = readConfig();
|
|
18
20
|
if (!config || window.__i18nFeedbackLoaded) return;
|
|
19
21
|
window.__i18nFeedbackLoaded = true;
|
|
20
22
|
|
|
@@ -43,8 +45,10 @@
|
|
|
43
45
|
|
|
44
46
|
ready(function () {
|
|
45
47
|
// Document-level listeners survive Turbo navigations, so register them once.
|
|
48
|
+
// handleClick is always attached and gates on the *current* config.active, so
|
|
49
|
+
// toggling suggest mode across a Turbo visit needs no re-registration.
|
|
46
50
|
document.addEventListener("keydown", handleKeydown);
|
|
47
|
-
|
|
51
|
+
document.addEventListener("click", handleClick, true);
|
|
48
52
|
|
|
49
53
|
// Everything else lives in <body>, which Turbo replaces on every visit —
|
|
50
54
|
// taking the pill and the active-mode highlighting with it. Re-run the
|
|
@@ -55,13 +59,24 @@
|
|
|
55
59
|
document.addEventListener("turbo:frame-load", strip);
|
|
56
60
|
});
|
|
57
61
|
|
|
62
|
+
function readConfig() {
|
|
63
|
+
var el = document.querySelector("script[data-i18n-feedback-config]");
|
|
64
|
+
if (!el) return null;
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(el.textContent);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
function render() {
|
|
73
|
+
// Re-read on each visit: the injected config is data (not an executed
|
|
74
|
+
// script), so it reflects the page Turbo just rendered.
|
|
75
|
+
config = readConfig() || config;
|
|
59
76
|
injectStyles();
|
|
60
77
|
if (config.showPill !== false) buildPill();
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
strip();
|
|
64
|
-
}
|
|
78
|
+
document.documentElement.classList.toggle("i18nf-active", !!config.active);
|
|
79
|
+
if (config.active) strip();
|
|
65
80
|
}
|
|
66
81
|
|
|
67
82
|
// --- suggest-mode toggle --------------------------------------------------
|
|
@@ -112,8 +127,13 @@
|
|
|
112
127
|
// --- interaction ----------------------------------------------------------
|
|
113
128
|
|
|
114
129
|
function handleClick(event) {
|
|
130
|
+
if (!config.active) return;
|
|
115
131
|
if (overlay && overlay.contains(event.target)) return;
|
|
116
132
|
if (event.target.closest && event.target.closest(".i18nf-pill")) return;
|
|
133
|
+
// Let a host's own suggest-mode toggle link through (e.g. "?i18n_feedback=false"
|
|
134
|
+
// in a nav menu); otherwise navigation-freezing would trap the user in suggest
|
|
135
|
+
// mode with no way out but the pill.
|
|
136
|
+
if (event.target.closest && event.target.closest('a[href*="' + config.toggleParam + '="]')) return;
|
|
117
137
|
|
|
118
138
|
// Freeze navigation so a stray click can't leave the page mid-proofread.
|
|
119
139
|
event.preventDefault();
|
|
@@ -18,17 +18,19 @@ module I18nFeedback
|
|
|
18
18
|
request = Rack::Request.new(env)
|
|
19
19
|
available = I18nFeedback.available?(request)
|
|
20
20
|
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
# "?i18n_feedback=true|false" is a one-shot command, not a sticky URL state:
|
|
22
|
+
# set the cookie and redirect to the same URL without the parameter. That
|
|
23
|
+
# keeps the cookie the single source of truth (so the pill's reload can turn
|
|
24
|
+
# suggest mode off) and stops the parameter from lingering in the address bar.
|
|
25
|
+
if available && request.get? && !(desired = toggle_override(request)).nil?
|
|
26
|
+
return toggle_redirect(request, desired)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
marking = available && cookie_on?(request)
|
|
26
30
|
Marking.enabled = marking
|
|
27
31
|
|
|
28
32
|
status, headers, body = @app.call(env)
|
|
29
33
|
|
|
30
|
-
headers = persist_choice(headers, override) if available && !override.nil?
|
|
31
|
-
|
|
32
34
|
if available && I18nFeedback.config.auto_inject && html?(headers) && !request.xhr?
|
|
33
35
|
status, headers, body = inject(status, headers, body, marking, csp_nonce(env))
|
|
34
36
|
end
|
|
@@ -44,6 +46,18 @@ module I18nFeedback
|
|
|
44
46
|
!request.cookies[COOKIE].to_s.empty?
|
|
45
47
|
end
|
|
46
48
|
|
|
49
|
+
def toggle_redirect(request, desired)
|
|
50
|
+
headers = persist_choice({ 'Content-Type' => 'text/html', 'Location' => url_without_toggle(request) }, desired)
|
|
51
|
+
[303, headers, []]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def url_without_toggle(request)
|
|
55
|
+
query = Rack::Utils.parse_query(request.query_string)
|
|
56
|
+
query.delete(I18nFeedback.config.toggle_param)
|
|
57
|
+
built = Rack::Utils.build_query(query)
|
|
58
|
+
built.empty? ? request.path : "#{request.path}?#{built}"
|
|
59
|
+
end
|
|
60
|
+
|
|
47
61
|
# The request's CSP nonce, so the injected scripts run under a nonce-based
|
|
48
62
|
# Content-Security-Policy. Reads the value Rails memoizes on the env, which is
|
|
49
63
|
# the same nonce the CSP header uses. nil when the app sets no nonce.
|
data/lib/i18n_feedback/widget.rb
CHANGED
|
@@ -16,21 +16,30 @@ module I18nFeedback
|
|
|
16
16
|
@javascript ||= File.read(SOURCE)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
# The two <script> tags to place before </body
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
19
|
+
# The two <script> tags to place before </body>.
|
|
20
|
+
#
|
|
21
|
+
# The config rides in a `type="application/json"` block: it is *data*, not
|
|
22
|
+
# code, so the browser never executes it and Turbo never tries to re-run it
|
|
23
|
+
# on a soft visit — which means it needs no CSP nonce and, crucially, the
|
|
24
|
+
# widget can re-read the *current* page's config on every `turbo:load`
|
|
25
|
+
# instead of being stuck with whatever the last full load evaluated.
|
|
26
|
+
#
|
|
27
|
+
# `nonce:` stamps only the widget script (the code), so it runs under a
|
|
28
|
+
# nonce-based Content-Security-Policy; pass nil when the app has no nonce.
|
|
23
29
|
def snippet(endpoint:, locale:, active:, nonce: nil)
|
|
24
30
|
config = {
|
|
25
31
|
endpoint: endpoint,
|
|
26
32
|
locale: locale.to_s,
|
|
27
33
|
active: active ? true : false,
|
|
28
34
|
showPill: I18nFeedback.config.show_pill ? true : false,
|
|
29
|
-
pillLabel: I18nFeedback.config.pill_label
|
|
35
|
+
pillLabel: I18nFeedback.config.pill_label,
|
|
36
|
+
toggleParam: I18nFeedback.config.toggle_param
|
|
30
37
|
}
|
|
38
|
+
# Escape "</" so a value can't close the <script> block early.
|
|
39
|
+
json = config.to_json.gsub('</', '<\/')
|
|
31
40
|
nonce_attr = nonce ? %( nonce="#{nonce}") : ''
|
|
32
41
|
|
|
33
|
-
%(<script data-i18n-feedback
|
|
42
|
+
%(<script type="application/json" data-i18n-feedback-config>#{json}</script>) +
|
|
34
43
|
%(<script data-i18n-feedback-widget#{nonce_attr}>#{javascript}</script>)
|
|
35
44
|
end
|
|
36
45
|
end
|