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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb6db4ee347586bcb037eaeedb37564d969e9d1395db2a8b6ca450710d943796
4
- data.tar.gz: 4d9aeaa1a8ece352719e3019ae08049b7ad9103dc1ce61447e317149269451d3
3
+ metadata.gz: a89bcc5c9f18d206d9aeeaf42aab087c528e0746828637af04839cc3adcb96b0
4
+ data.tar.gz: ddcb6df38749afa84e7aa2c5b0cb0a249b5dc1ec7ffca359d134d6e1dc529610
5
5
  SHA512:
6
- metadata.gz: 48c4b5ac039436c560d5e667d7ed36f1474a784b1330e8efaea55376e306eecd0865d6ddb81fa8001cc20d1c790bfdb0668b8435322f3a8f13aca95b02a5a99e
7
- data.tar.gz: cde3e82b0c34baf896c2d54c887bf581f48c8d00e65a74fe34d11e531b5264ed85c2267d69ab94c1715bd2abf5ba9ad102c22b746ec6f6d36721a61bf4b202d4
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
+ [![Gem Version](https://img.shields.io/gem/v/i18n_feedback)](https://rubygems.org/gems/i18n_feedback)
4
+ [![Downloads](https://img.shields.io/gem/dt/i18n_feedback)](https://rubygems.org/gems/i18n_feedback)
5
+ [![CI](https://github.com/yshmarov/i18n-feedback/actions/workflows/ci.yml/badge.svg)](https://github.com/yshmarov/i18n-feedback/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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
+ ![Click any string in your running app and suggest a better wording](i18n-feedback-demo.gif)
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? Hide the pill and link to the toggle
106
- parameter from anywhere in your UI:
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
- `?i18n_feedback=true` turns suggest mode on and `?i18n_feedback=false` turns it
117
- off. The choice is remembered in a cookie, so the rest of the app stays in
118
- suggest mode without the parameter; `Esc` also exits.
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 window.__i18nFeedback = { endpoint, locale, active }.
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 = window.__i18nFeedback;
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
- if (config.active) document.addEventListener("click", handleClick, true);
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
- if (config.active) {
62
- document.documentElement.classList.add("i18nf-active");
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
- # A "?i18n_feedback=true|false" in the URL is a command: it overrides the
22
- # cookie for this request and is remembered so the rest of the app stays in
23
- # (or out of) suggest mode without the parameter.
24
- override = available ? toggle_override(request) : nil
25
- marking = available && (override.nil? ? cookie_on?(request) : override)
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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module I18nFeedback
4
- VERSION = '0.2.1'
4
+ VERSION = '0.2.2'
5
5
  end
@@ -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>: one carrying the runtime
20
- # config, one carrying the widget itself. `nonce:` stamps both tags so the
21
- # widget runs under a nonce-based Content-Security-Policy (e.g. one using
22
- # 'strict-dynamic'); pass nil when the app has no CSP nonce.
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#{nonce_attr}>window.__i18nFeedback=#{config.to_json};</script>) +
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: i18n_feedback
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov