i18n_feedback 0.2.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb6db4ee347586bcb037eaeedb37564d969e9d1395db2a8b6ca450710d943796
4
- data.tar.gz: 4d9aeaa1a8ece352719e3019ae08049b7ad9103dc1ce61447e317149269451d3
3
+ metadata.gz: 8c389e87f5f47b445f94f6ea242e803159bb34b2e0847d2ccc87883d05b3a666
4
+ data.tar.gz: 62ef3573c3d1a2070b151bbd77848e6f3c2db2c3db2abe532b6c32f0e8afb78f
5
5
  SHA512:
6
- metadata.gz: 48c4b5ac039436c560d5e667d7ed36f1474a784b1330e8efaea55376e306eecd0865d6ddb81fa8001cc20d1c790bfdb0668b8435322f3a8f13aca95b02a5a99e
7
- data.tar.gz: cde3e82b0c34baf896c2d54c887bf581f48c8d00e65a74fe34d11e531b5264ed85c2267d69ab94c1715bd2abf5ba9ad102c22b746ec6f6d36721a61bf4b202d4
6
+ metadata.gz: 67b4232a078ccb30b36de48123023151659301f6036bbac5069ea46db65dabc094cdfff3f3bd6267d63efd43f98b2aaed6c7912d384fdff1da8979f5788c738c
7
+ data.tar.gz: 8f575349159537f6ee689d6abea8397ad425bfca692822c1ae93286934fa36bc8edba846b6a65572c5caf74e0fa6ce5ee752d29ad5e0c7df800329992355cae3
data/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.0]
6
+
7
+ - Localize the widget's own UI. Every string in the pill and the suggestion
8
+ popover now resolves through Rails I18n under the `i18n_feedback.*` scope and
9
+ follows the app's current `I18n.locale`, so the tool speaks the same language as
10
+ the app being proofread. Any key a host hasn't translated falls back to English
11
+ — so nothing goes blank in a locale you haven't fully covered. Override any
12
+ string by defining the matching key in your own locale files.
13
+ - Ship translations out of the box for 20 languages in addition to English:
14
+ Arabic, Bengali, Chinese (Simplified), Dutch, French, German, Hindi, Indonesian,
15
+ Italian, Japanese, Korean, Polish, Portuguese, Russian, Spanish, Thai, Turkish,
16
+ Ukrainian, Urdu and Vietnamese.
17
+ - Render the popover right-to-left for RTL locales (Arabic, Urdu, and other RTL
18
+ scripts), detected from the active locale's language subtag. The i18n key stays
19
+ left-to-right, since it's a code identifier rather than prose.
20
+ - `config.pill_label` now defaults to `nil`, meaning "use the localized default".
21
+ Setting it to a string still overrides the pill text as before.
22
+ - The widget now follows the operating system's light/dark/system appearance via
23
+ `prefers-color-scheme`. The pill and popover render with a dark surface when the
24
+ reviewer's system is in dark mode, with no configuration required.
25
+
26
+ ## [0.2.2]
27
+
28
+ - Fix suggest mode desyncing under a nonce-based CSP on Turbo visits. The runtime
29
+ config now rides in a `<script type="application/json">` block (data, not code)
30
+ that the widget re-reads on every `turbo:load`, instead of an executable
31
+ `<script>` the browser refuses to re-run when Turbo re-evaluates it with a stale
32
+ nonce. Only the widget code carries the CSP nonce now.
33
+ - Treat `?i18n_feedback=true|false` as a one-shot command: the middleware sets the
34
+ cookie and redirects (303) to the same URL without the parameter. The cookie is
35
+ now the single source of truth, so the parameter no longer sticks in the address
36
+ bar and the pill's reload can turn suggest mode off.
37
+ - Let a host's own toggle link work while suggest mode is active. Suggest mode
38
+ freezes navigation so a stray click can't leave the page mid-proofread, but that
39
+ also froze a `?i18n_feedback=false` link in your own nav — so the only way out
40
+ was the pill. Links carrying the toggle parameter are now exempt from the freeze.
41
+
5
42
  ## [0.2.1]
6
43
 
7
44
  - 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
 
@@ -137,6 +177,49 @@ Set `config.auto_inject = false` and drop the helper at the end of your layout:
137
177
 
138
178
  It renders nothing unless the tool is available for the request.
139
179
 
180
+ ### Localizing the widget UI
181
+
182
+ The pill and the suggestion popover speak the app's language: every string
183
+ resolves through Rails I18n under the `i18n_feedback.*` scope and follows the
184
+ current `I18n.locale`. Translations ship out of the box for English plus 20 more
185
+ languages — Arabic, Bengali, Chinese (Simplified), Dutch, French, German, Hindi,
186
+ Indonesian, Italian, Japanese, Korean, Polish, Portuguese, Russian, Spanish,
187
+ Thai, Turkish, Ukrainian, Urdu and Vietnamese — so the tool is already localized
188
+ for most apps. RTL locales (Arabic, Urdu, …) render the popover right-to-left
189
+ automatically.
190
+
191
+ Any key you haven't translated falls back to English, so a partially translated
192
+ locale never leaves a control blank. To add a language, or reword the bundled
193
+ copy, define the keys in your own locale files (yours win over the gem's):
194
+
195
+ ```yaml
196
+ # config/locales/fr.yml
197
+ fr:
198
+ i18n_feedback:
199
+ pill: "Proposer des corrections"
200
+ pill_active: "En cours — appuyez pour quitter (Échap)"
201
+ title: "Proposer une correction de traduction"
202
+ current_text: "Texte actuel"
203
+ suggested_text: "Texte proposé"
204
+ comment: "Commentaire"
205
+ comment_placeholder: "Note facultative pour le développeur"
206
+ prior_title: "Déjà proposé (en attente)"
207
+ cancel: "Annuler"
208
+ save: "Envoyer la suggestion"
209
+ error_blank: "Veuillez saisir une suggestion."
210
+ error_save: "Impossible d'enregistrer la suggestion."
211
+ ```
212
+
213
+ `config.pill_label` still overrides the pill text with a fixed string if you set
214
+ it; leave it `nil` (the default) to use the localized `i18n_feedback.pill` key.
215
+
216
+ ### Light / dark / system appearance
217
+
218
+ The widget follows the reviewer's operating-system appearance via
219
+ `prefers-color-scheme` — no configuration needed. The pill and popover render on a
220
+ dark surface when the system is in dark mode and a light surface otherwise; the
221
+ blue accent stays the same in both.
222
+
140
223
  ## Reviewing suggestions
141
224
 
142
225
  Suggestions are ordinary records:
@@ -157,6 +240,12 @@ Each row stores `translation_key`, `locale`, `old_value`, `proposed_value`,
157
240
  `config.enabled` returns true.
158
241
  - Format and lookup namespaces (`number.*`, `date.*`, `*_html` formats, etc.) are
159
242
  never marked, so currency and date formatting are unaffected.
243
+ - The injected widget code carries the request's Content-Security-Policy nonce
244
+ (the same one `ActionDispatch` emits), so it runs under a nonce-based
245
+ `script-src` policy — including `strict-dynamic` — with no configuration. It is
246
+ a no-op when the app sets no CSP nonce. The runtime config is shipped as a
247
+ `<script type="application/json">` block (data, not code), so it needs no nonce
248
+ and stays correct across Turbo visits.
160
249
 
161
250
  ## Development
162
251
 
@@ -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();
@@ -138,13 +158,19 @@
138
158
  var pill = document.createElement("button");
139
159
  pill.type = "button";
140
160
  pill.className = "i18nf-pill" + (config.active ? " i18nf-pill-on" : "");
141
- pill.textContent = config.active ? "✓ Suggesting — tap to exit (Esc)" : "✎ " + labelText();
161
+ pill.textContent = config.active ? "✓ " + t("pillActive", "Suggesting — tap to exit (Esc)") : "✎ " + labelText();
142
162
  pill.addEventListener("click", toggle);
143
163
  document.body.appendChild(pill);
144
164
  }
145
165
 
146
166
  function labelText() {
147
- return (config.pillLabel || "Suggest edits");
167
+ return config.pillLabel || t("pill", "Suggest edits");
168
+ }
169
+
170
+ // Resolve a server-translated label, falling back to English if the config
171
+ // predates the labels payload (e.g. a cached page from an older gem version).
172
+ function t(name, fallback) {
173
+ return (config.labels && config.labels[name]) || fallback;
148
174
  }
149
175
 
150
176
  // --- popover --------------------------------------------------------------
@@ -158,18 +184,21 @@
158
184
  });
159
185
 
160
186
  var panel = el("div", "i18nf-panel");
187
+ // Mirror the popover for right-to-left locales (Arabic, Urdu, …). The i18n
188
+ // key stays LTR — it's a code identifier, not prose (see heading()).
189
+ panel.dir = config.rtl ? "rtl" : "ltr";
161
190
  panel.appendChild(heading(key));
162
191
 
163
192
  priorNode = el("div");
164
193
  panel.appendChild(priorNode);
165
194
 
166
- panel.appendChild(field("Current text", readonly(currentValue)));
195
+ panel.appendChild(field(t("currentText", "Current text"), readonly(currentValue)));
167
196
 
168
197
  proposedInput = textarea(currentValue);
169
- panel.appendChild(field("Suggested text", proposedInput));
198
+ panel.appendChild(field(t("suggestedText", "Suggested text"), proposedInput));
170
199
 
171
- commentInput = input("Optional note for the developer");
172
- panel.appendChild(field("Comment", commentInput));
200
+ commentInput = input(t("commentPlaceholder", "Optional note for the developer"));
201
+ panel.appendChild(field(t("comment", "Comment"), commentInput));
173
202
 
174
203
  errorNode = el("p", "i18nf-error");
175
204
  errorNode.style.display = "none";
@@ -209,7 +238,7 @@
209
238
 
210
239
  var box = el("div", "i18nf-prior");
211
240
  var title = el("p", "i18nf-prior-title");
212
- title.textContent = "Already suggested (pending)";
241
+ title.textContent = t("priorTitle", "Already suggested (pending)");
213
242
  box.appendChild(title);
214
243
 
215
244
  var list = el("ul", "i18nf-prior-list");
@@ -230,12 +259,12 @@
230
259
 
231
260
  var cancel = el("button", "i18nf-btn i18nf-btn-ghost");
232
261
  cancel.type = "button";
233
- cancel.textContent = "Cancel";
262
+ cancel.textContent = t("cancel", "Cancel");
234
263
  cancel.addEventListener("click", close);
235
264
 
236
265
  saveButton = el("button", "i18nf-btn i18nf-btn-primary");
237
266
  saveButton.type = "button";
238
- saveButton.textContent = "Send suggestion";
267
+ saveButton.textContent = t("save", "Send suggestion");
239
268
  saveButton.addEventListener("click", function () {
240
269
  submit(key, currentValue);
241
270
  });
@@ -248,7 +277,7 @@
248
277
  function submit(key, currentValue) {
249
278
  var proposed = proposedInput.value.trim();
250
279
  if (!proposed) {
251
- showError("Please enter a suggestion.");
280
+ showError(t("errorBlank", "Please enter a suggestion."));
252
281
  return;
253
282
  }
254
283
 
@@ -277,7 +306,7 @@
277
306
  close();
278
307
  } else {
279
308
  saveButton.disabled = false;
280
- showError("Could not save the suggestion.");
309
+ showError(t("errorSave", "Could not save the suggestion."));
281
310
  }
282
311
  })
283
312
  .catch(function () {
@@ -296,8 +325,9 @@
296
325
  function heading(key) {
297
326
  var wrapper = el("div", "i18nf-heading");
298
327
  var title = el("p", "i18nf-title");
299
- title.textContent = "Suggest a translation fix";
328
+ title.textContent = t("title", "Suggest a translation fix");
300
329
  var code = el("code", "i18nf-key");
330
+ code.dir = "ltr"; // the key is a code path, never RTL prose
301
331
  code.textContent = key;
302
332
  wrapper.appendChild(title);
303
333
  wrapper.appendChild(code);
@@ -385,6 +415,28 @@
385
415
  ".i18nf-btn-ghost { background: transparent; color: #111; }",
386
416
  ".i18nf-btn-primary { background: #2563eb; color: #fff; }",
387
417
  ".i18nf-btn[disabled] { opacity: .6; cursor: default; }",
418
+
419
+ // Follow the OS light/dark/system setting via prefers-color-scheme, so the
420
+ // widget matches whatever the reviewer's system is set to without any extra
421
+ // configuration. Only the surfaces that carry their own background/color
422
+ // above are overridden here — the blue accents stay the same in both themes.
423
+ "@media (prefers-color-scheme: dark) {",
424
+ " .i18nf-pill { background: #1f1f23; color: #f4f4f5; border-color: rgba(255,255,255,.18);",
425
+ " box-shadow: 0 4px 14px rgba(0,0,0,.5); }",
426
+ " .i18nf-pill-on { background: #2563eb; color: #fff; border-color: #2563eb; }",
427
+ " .i18nf-overlay { background: rgba(0,0,0,.65); }",
428
+ " .i18nf-panel { background: #1f1f23; color: #f4f4f5;",
429
+ " box-shadow: 0 20px 60px rgba(0,0,0,.7); }",
430
+ " .i18nf-key { color: #a1a1aa; }",
431
+ " .i18nf-label { color: #a1a1aa; }",
432
+ " .i18nf-readonly { background: #2a2a30; }",
433
+ " .i18nf-input { background: #2a2a30; color: #f4f4f5; border-color: #3f3f46; }",
434
+ " .i18nf-input::placeholder { color: #71717a; }",
435
+ " .i18nf-prior { background: #2a2a30; }",
436
+ " .i18nf-prior-title { color: #d4d4d8; }",
437
+ " .i18nf-error { color: #f87171; }",
438
+ " .i18nf-btn-ghost { color: #f4f4f5; }",
439
+ "}",
388
440
  ].join("\n");
389
441
  document.head.appendChild(style);
390
442
  }
@@ -0,0 +1,14 @@
1
+ ar:
2
+ i18n_feedback:
3
+ pill: "اقترح تعديلات"
4
+ pill_active: "وضع الاقتراح — اضغط للخروج (Esc)"
5
+ title: "اقترح تصحيحًا للترجمة"
6
+ current_text: "النص الحالي"
7
+ suggested_text: "النص المقترح"
8
+ comment: "تعليق"
9
+ comment_placeholder: "ملاحظة اختيارية للمطور"
10
+ prior_title: "تم اقتراحه مسبقًا (قيد الانتظار)"
11
+ cancel: "إلغاء"
12
+ save: "إرسال الاقتراح"
13
+ error_blank: "يرجى إدخال اقتراح."
14
+ error_save: "تعذّر حفظ الاقتراح."
@@ -0,0 +1,14 @@
1
+ bn:
2
+ i18n_feedback:
3
+ pill: "সম্পাদনা প্রস্তাব করুন"
4
+ pill_active: "প্রস্তাব চলছে — বের হতে ট্যাপ করুন (Esc)"
5
+ title: "অনুবাদ সংশোধন প্রস্তাব করুন"
6
+ current_text: "বর্তমান লেখা"
7
+ suggested_text: "প্রস্তাবিত লেখা"
8
+ comment: "মন্তব্য"
9
+ comment_placeholder: "ডেভেলপারের জন্য ঐচ্ছিক নোট"
10
+ prior_title: "ইতিমধ্যে প্রস্তাবিত (অপেক্ষমাণ)"
11
+ cancel: "বাতিল"
12
+ save: "প্রস্তাব পাঠান"
13
+ error_blank: "অনুগ্রহ করে একটি প্রস্তাব লিখুন।"
14
+ error_save: "প্রস্তাব সংরক্ষণ করা যায়নি।"
@@ -0,0 +1,14 @@
1
+ de:
2
+ i18n_feedback:
3
+ pill: "Änderungen vorschlagen"
4
+ pill_active: "Vorschlagsmodus — zum Beenden tippen (Esc)"
5
+ title: "Übersetzungskorrektur vorschlagen"
6
+ current_text: "Aktueller Text"
7
+ suggested_text: "Vorgeschlagener Text"
8
+ comment: "Kommentar"
9
+ comment_placeholder: "Optionale Notiz für die Entwicklung"
10
+ prior_title: "Bereits vorgeschlagen (ausstehend)"
11
+ cancel: "Abbrechen"
12
+ save: "Vorschlag senden"
13
+ error_blank: "Bitte gib einen Vorschlag ein."
14
+ error_save: "Der Vorschlag konnte nicht gespeichert werden."
@@ -0,0 +1,14 @@
1
+ en:
2
+ i18n_feedback:
3
+ pill: "Suggest edits"
4
+ pill_active: "Suggesting — tap to exit (Esc)"
5
+ title: "Suggest a translation fix"
6
+ current_text: "Current text"
7
+ suggested_text: "Suggested text"
8
+ comment: "Comment"
9
+ comment_placeholder: "Optional note for the developer"
10
+ prior_title: "Already suggested (pending)"
11
+ cancel: "Cancel"
12
+ save: "Send suggestion"
13
+ error_blank: "Please enter a suggestion."
14
+ error_save: "Could not save the suggestion."
@@ -0,0 +1,14 @@
1
+ es:
2
+ i18n_feedback:
3
+ pill: "Sugerir cambios"
4
+ pill_active: "Sugiriendo — toca para salir (Esc)"
5
+ title: "Sugerir una corrección de traducción"
6
+ current_text: "Texto actual"
7
+ suggested_text: "Texto sugerido"
8
+ comment: "Comentario"
9
+ comment_placeholder: "Nota opcional para el desarrollador"
10
+ prior_title: "Ya sugerido (pendiente)"
11
+ cancel: "Cancelar"
12
+ save: "Enviar sugerencia"
13
+ error_blank: "Introduce una sugerencia."
14
+ error_save: "No se pudo guardar la sugerencia."
@@ -0,0 +1,14 @@
1
+ fr:
2
+ i18n_feedback:
3
+ pill: "Proposer des corrections"
4
+ pill_active: "En cours — appuyez pour quitter (Échap)"
5
+ title: "Proposer une correction de traduction"
6
+ current_text: "Texte actuel"
7
+ suggested_text: "Texte proposé"
8
+ comment: "Commentaire"
9
+ comment_placeholder: "Note facultative pour le développeur"
10
+ prior_title: "Déjà proposé (en attente)"
11
+ cancel: "Annuler"
12
+ save: "Envoyer la suggestion"
13
+ error_blank: "Veuillez saisir une suggestion."
14
+ error_save: "Impossible d'enregistrer la suggestion."
@@ -0,0 +1,14 @@
1
+ hi:
2
+ i18n_feedback:
3
+ pill: "बदलाव सुझाएँ"
4
+ pill_active: "सुझाव मोड — बाहर निकलने के लिए टैप करें (Esc)"
5
+ title: "अनुवाद सुधार सुझाएँ"
6
+ current_text: "वर्तमान टेक्स्ट"
7
+ suggested_text: "सुझाया गया टेक्स्ट"
8
+ comment: "टिप्पणी"
9
+ comment_placeholder: "डेवलपर के लिए वैकल्पिक टिप्पणी"
10
+ prior_title: "पहले से सुझाया गया (लंबित)"
11
+ cancel: "रद्द करें"
12
+ save: "सुझाव भेजें"
13
+ error_blank: "कृपया एक सुझाव दर्ज करें।"
14
+ error_save: "सुझाव सहेजा नहीं जा सका।"
@@ -0,0 +1,14 @@
1
+ id:
2
+ i18n_feedback:
3
+ pill: "Sarankan perubahan"
4
+ pill_active: "Menyarankan — ketuk untuk keluar (Esc)"
5
+ title: "Sarankan perbaikan terjemahan"
6
+ current_text: "Teks saat ini"
7
+ suggested_text: "Teks yang disarankan"
8
+ comment: "Komentar"
9
+ comment_placeholder: "Catatan opsional untuk pengembang"
10
+ prior_title: "Sudah disarankan (menunggu)"
11
+ cancel: "Batal"
12
+ save: "Kirim saran"
13
+ error_blank: "Silakan masukkan saran."
14
+ error_save: "Tidak dapat menyimpan saran."
@@ -0,0 +1,14 @@
1
+ it:
2
+ i18n_feedback:
3
+ pill: "Suggerisci modifiche"
4
+ pill_active: "Suggerimento — tocca per uscire (Esc)"
5
+ title: "Suggerisci una correzione di traduzione"
6
+ current_text: "Testo attuale"
7
+ suggested_text: "Testo suggerito"
8
+ comment: "Commento"
9
+ comment_placeholder: "Nota facoltativa per lo sviluppatore"
10
+ prior_title: "Già suggerito (in sospeso)"
11
+ cancel: "Annulla"
12
+ save: "Invia suggerimento"
13
+ error_blank: "Inserisci un suggerimento."
14
+ error_save: "Impossibile salvare il suggerimento."
@@ -0,0 +1,14 @@
1
+ ja:
2
+ i18n_feedback:
3
+ pill: "修正を提案"
4
+ pill_active: "提案中 — タップで終了(Esc)"
5
+ title: "翻訳の修正を提案"
6
+ current_text: "現在のテキスト"
7
+ suggested_text: "提案するテキスト"
8
+ comment: "コメント"
9
+ comment_placeholder: "開発者への任意のメモ"
10
+ prior_title: "提案済み(保留中)"
11
+ cancel: "キャンセル"
12
+ save: "提案を送信"
13
+ error_blank: "提案を入力してください。"
14
+ error_save: "提案を保存できませんでした。"
@@ -0,0 +1,14 @@
1
+ ko:
2
+ i18n_feedback:
3
+ pill: "수정 제안"
4
+ pill_active: "제안 중 — 종료하려면 탭하세요 (Esc)"
5
+ title: "번역 수정 제안"
6
+ current_text: "현재 텍스트"
7
+ suggested_text: "제안 텍스트"
8
+ comment: "의견"
9
+ comment_placeholder: "개발자를 위한 선택 메모"
10
+ prior_title: "이미 제안됨 (대기 중)"
11
+ cancel: "취소"
12
+ save: "제안 보내기"
13
+ error_blank: "제안을 입력하세요."
14
+ error_save: "제안을 저장할 수 없습니다."
@@ -0,0 +1,14 @@
1
+ nl:
2
+ i18n_feedback:
3
+ pill: "Wijzigingen voorstellen"
4
+ pill_active: "Aan het voorstellen — tik om te sluiten (Esc)"
5
+ title: "Een vertaalcorrectie voorstellen"
6
+ current_text: "Huidige tekst"
7
+ suggested_text: "Voorgestelde tekst"
8
+ comment: "Opmerking"
9
+ comment_placeholder: "Optionele notitie voor de ontwikkelaar"
10
+ prior_title: "Al voorgesteld (in behandeling)"
11
+ cancel: "Annuleren"
12
+ save: "Suggestie versturen"
13
+ error_blank: "Voer een suggestie in."
14
+ error_save: "Kan de suggestie niet opslaan."
@@ -0,0 +1,14 @@
1
+ pl:
2
+ i18n_feedback:
3
+ pill: "Zaproponuj zmiany"
4
+ pill_active: "Tryb sugestii — dotknij, aby wyjść (Esc)"
5
+ title: "Zaproponuj poprawkę tłumaczenia"
6
+ current_text: "Bieżący tekst"
7
+ suggested_text: "Proponowany tekst"
8
+ comment: "Komentarz"
9
+ comment_placeholder: "Opcjonalna notatka dla programisty"
10
+ prior_title: "Już zaproponowano (oczekujące)"
11
+ cancel: "Anuluj"
12
+ save: "Wyślij sugestię"
13
+ error_blank: "Wprowadź sugestię."
14
+ error_save: "Nie udało się zapisać sugestii."
@@ -0,0 +1,14 @@
1
+ pt:
2
+ i18n_feedback:
3
+ pill: "Sugerir edições"
4
+ pill_active: "Sugerindo — toque para sair (Esc)"
5
+ title: "Sugerir uma correção de tradução"
6
+ current_text: "Texto atual"
7
+ suggested_text: "Texto sugerido"
8
+ comment: "Comentário"
9
+ comment_placeholder: "Nota opcional para o desenvolvedor"
10
+ prior_title: "Já sugerido (pendente)"
11
+ cancel: "Cancelar"
12
+ save: "Enviar sugestão"
13
+ error_blank: "Insira uma sugestão."
14
+ error_save: "Não foi possível salvar a sugestão."
@@ -0,0 +1,14 @@
1
+ ru:
2
+ i18n_feedback:
3
+ pill: "Предложить правки"
4
+ pill_active: "Режим предложений — нажмите, чтобы выйти (Esc)"
5
+ title: "Предложить исправление перевода"
6
+ current_text: "Текущий текст"
7
+ suggested_text: "Предлагаемый текст"
8
+ comment: "Комментарий"
9
+ comment_placeholder: "Необязательная заметка для разработчика"
10
+ prior_title: "Уже предложено (в ожидании)"
11
+ cancel: "Отмена"
12
+ save: "Отправить предложение"
13
+ error_blank: "Введите предложение."
14
+ error_save: "Не удалось сохранить предложение."
@@ -0,0 +1,14 @@
1
+ th:
2
+ i18n_feedback:
3
+ pill: "แนะนำการแก้ไข"
4
+ pill_active: "กำลังแนะนำ — แตะเพื่อออก (Esc)"
5
+ title: "แนะนำการแก้ไขคำแปล"
6
+ current_text: "ข้อความปัจจุบัน"
7
+ suggested_text: "ข้อความที่แนะนำ"
8
+ comment: "ความคิดเห็น"
9
+ comment_placeholder: "หมายเหตุเพิ่มเติมสำหรับนักพัฒนา"
10
+ prior_title: "แนะนำไปแล้ว (รอดำเนินการ)"
11
+ cancel: "ยกเลิก"
12
+ save: "ส่งคำแนะนำ"
13
+ error_blank: "โปรดป้อนคำแนะนำ"
14
+ error_save: "ไม่สามารถบันทึกคำแนะนำได้"
@@ -0,0 +1,14 @@
1
+ tr:
2
+ i18n_feedback:
3
+ pill: "Düzenleme öner"
4
+ pill_active: "Öneri modu — çıkmak için dokun (Esc)"
5
+ title: "Çeviri düzeltmesi öner"
6
+ current_text: "Mevcut metin"
7
+ suggested_text: "Önerilen metin"
8
+ comment: "Yorum"
9
+ comment_placeholder: "Geliştirici için isteğe bağlı not"
10
+ prior_title: "Zaten önerildi (beklemede)"
11
+ cancel: "İptal"
12
+ save: "Öneriyi gönder"
13
+ error_blank: "Lütfen bir öneri girin."
14
+ error_save: "Öneri kaydedilemedi."
@@ -0,0 +1,14 @@
1
+ uk:
2
+ i18n_feedback:
3
+ pill: "Запропонувати зміни"
4
+ pill_active: "Режим пропозицій — торкніться, щоб вийти (Esc)"
5
+ title: "Запропонувати виправлення перекладу"
6
+ current_text: "Поточний текст"
7
+ suggested_text: "Запропонований текст"
8
+ comment: "Коментар"
9
+ comment_placeholder: "Необов’язкова примітка для розробника"
10
+ prior_title: "Уже запропоновано (очікує)"
11
+ cancel: "Скасувати"
12
+ save: "Надіслати пропозицію"
13
+ error_blank: "Будь ласка, введіть пропозицію."
14
+ error_save: "Не вдалося зберегти пропозицію."
@@ -0,0 +1,14 @@
1
+ ur:
2
+ i18n_feedback:
3
+ pill: "ترامیم تجویز کریں"
4
+ pill_active: "تجویز موڈ — باہر نکلنے کے لیے تھپتھپائیں (Esc)"
5
+ title: "ترجمے کی اصلاح تجویز کریں"
6
+ current_text: "موجودہ متن"
7
+ suggested_text: "تجویز کردہ متن"
8
+ comment: "تبصرہ"
9
+ comment_placeholder: "ڈویلپر کے لیے اختیاری نوٹ"
10
+ prior_title: "پہلے ہی تجویز کیا جا چکا (زیر التوا)"
11
+ cancel: "منسوخ کریں"
12
+ save: "تجویز بھیجیں"
13
+ error_blank: "براہ کرم ایک تجویز درج کریں۔"
14
+ error_save: "تجویز محفوظ نہیں ہو سکی۔"
@@ -0,0 +1,14 @@
1
+ vi:
2
+ i18n_feedback:
3
+ pill: "Đề xuất chỉnh sửa"
4
+ pill_active: "Đang đề xuất — chạm để thoát (Esc)"
5
+ title: "Đề xuất sửa bản dịch"
6
+ current_text: "Văn bản hiện tại"
7
+ suggested_text: "Văn bản đề xuất"
8
+ comment: "Nhận xét"
9
+ comment_placeholder: "Ghi chú tùy chọn cho nhà phát triển"
10
+ prior_title: "Đã đề xuất (đang chờ)"
11
+ cancel: "Hủy"
12
+ save: "Gửi đề xuất"
13
+ error_blank: "Vui lòng nhập một đề xuất."
14
+ error_save: "Không thể lưu đề xuất."
@@ -0,0 +1,14 @@
1
+ zh-CN:
2
+ i18n_feedback:
3
+ pill: "建议修改"
4
+ pill_active: "建议中 — 点击退出(Esc)"
5
+ title: "建议翻译修正"
6
+ current_text: "当前文本"
7
+ suggested_text: "建议文本"
8
+ comment: "备注"
9
+ comment_placeholder: "给开发者的可选备注"
10
+ prior_title: "已提交建议(待处理)"
11
+ cancel: "取消"
12
+ save: "发送建议"
13
+ error_blank: "请输入建议。"
14
+ error_save: "无法保存建议。"
@@ -38,7 +38,8 @@ module I18nFeedback
38
38
  # from your own link instead (see #toggle_param).
39
39
  attr_accessor :show_pill
40
40
 
41
- # Text on the floating toggle pill.
41
+ # Text on the floating toggle pill. Leave nil to use the localized default
42
+ # ("Suggest edits", translated via the `i18n_feedback.pill` I18n key).
42
43
  attr_accessor :pill_label
43
44
 
44
45
  # The query-string parameter that turns suggest mode on/off, so a host can
@@ -56,7 +57,7 @@ module I18nFeedback
56
57
  @auto_inject = true
57
58
  @mount_path = '/i18n_feedback'
58
59
  @show_pill = true
59
- @pill_label = 'Suggest edits'
60
+ @pill_label = nil
60
61
  @toggle_param = 'i18n_feedback'
61
62
  end
62
63
 
@@ -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.3.0'
5
5
  end
@@ -11,28 +11,74 @@ module I18nFeedback
11
11
  module Widget
12
12
  SOURCE = File.expand_path('../../app/assets/i18n_feedback/widget.js', __dir__)
13
13
 
14
+ # Right-to-left scripts, so the popover renders mirrored for those locales.
15
+ # Matched on the language subtag, so region variants ("ar-EG") count too.
16
+ RTL_LANGUAGES = %w[ar arc ckb dv fa ha he ks ku ps sd ug ur yi].freeze
17
+
14
18
  class << self
15
19
  def javascript
16
20
  @javascript ||= File.read(SOURCE)
17
21
  end
18
22
 
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.
23
+ # The two <script> tags to place before </body>.
24
+ #
25
+ # The config rides in a `type="application/json"` block: it is *data*, not
26
+ # code, so the browser never executes it and Turbo never tries to re-run it
27
+ # on a soft visit — which means it needs no CSP nonce and, crucially, the
28
+ # widget can re-read the *current* page's config on every `turbo:load`
29
+ # instead of being stuck with whatever the last full load evaluated.
30
+ #
31
+ # `nonce:` stamps only the widget script (the code), so it runs under a
32
+ # nonce-based Content-Security-Policy; pass nil when the app has no nonce.
23
33
  def snippet(endpoint:, locale:, active:, nonce: nil)
24
34
  config = {
25
35
  endpoint: endpoint,
26
36
  locale: locale.to_s,
27
37
  active: active ? true : false,
28
38
  showPill: I18nFeedback.config.show_pill ? true : false,
29
- pillLabel: I18nFeedback.config.pill_label
39
+ pillLabel: I18nFeedback.config.pill_label,
40
+ toggleParam: I18nFeedback.config.toggle_param,
41
+ labels: labels,
42
+ rtl: rtl?(locale)
30
43
  }
44
+ # Escape "</" so a value can't close the <script> block early.
45
+ json = config.to_json.gsub('</', '<\/')
31
46
  nonce_attr = nonce ? %( nonce="#{nonce}") : ''
32
47
 
33
- %(<script data-i18n-feedback#{nonce_attr}>window.__i18nFeedback=#{config.to_json};</script>) +
48
+ %(<script type="application/json" data-i18n-feedback-config>#{json}</script>) +
34
49
  %(<script data-i18n-feedback-widget#{nonce_attr}>#{javascript}</script>)
35
50
  end
51
+
52
+ private
53
+
54
+ # Every user-facing string in the widget, resolved through Rails I18n so the
55
+ # popover follows the app's current locale. Each lookup carries an English
56
+ # default, so the widget stays fully worded even when the host app hasn't
57
+ # loaded the gem's locale file or is missing a key for the active locale.
58
+ def labels
59
+ {
60
+ pill: t(:pill, 'Suggest edits'),
61
+ pillActive: t(:pill_active, 'Suggesting — tap to exit (Esc)'),
62
+ title: t(:title, 'Suggest a translation fix'),
63
+ currentText: t(:current_text, 'Current text'),
64
+ suggestedText: t(:suggested_text, 'Suggested text'),
65
+ comment: t(:comment, 'Comment'),
66
+ commentPlaceholder: t(:comment_placeholder, 'Optional note for the developer'),
67
+ priorTitle: t(:prior_title, 'Already suggested (pending)'),
68
+ cancel: t(:cancel, 'Cancel'),
69
+ save: t(:save, 'Send suggestion'),
70
+ errorBlank: t(:error_blank, 'Please enter a suggestion.'),
71
+ errorSave: t(:error_save, 'Could not save the suggestion.')
72
+ }
73
+ end
74
+
75
+ def t(key, default)
76
+ I18n.t(key, scope: :i18n_feedback, default: default)
77
+ end
78
+
79
+ def rtl?(locale)
80
+ RTL_LANGUAGES.include?(locale.to_s.downcase.split(/[-_]/).first)
81
+ end
36
82
  end
37
83
  end
38
84
  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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov
@@ -44,6 +44,27 @@ files:
44
44
  - app/helpers/i18n_feedback/tag_helper.rb
45
45
  - app/models/i18n_feedback/application_record.rb
46
46
  - app/models/i18n_feedback/suggestion.rb
47
+ - config/locales/i18n_feedback.ar.yml
48
+ - config/locales/i18n_feedback.bn.yml
49
+ - config/locales/i18n_feedback.de.yml
50
+ - config/locales/i18n_feedback.en.yml
51
+ - config/locales/i18n_feedback.es.yml
52
+ - config/locales/i18n_feedback.fr.yml
53
+ - config/locales/i18n_feedback.hi.yml
54
+ - config/locales/i18n_feedback.id.yml
55
+ - config/locales/i18n_feedback.it.yml
56
+ - config/locales/i18n_feedback.ja.yml
57
+ - config/locales/i18n_feedback.ko.yml
58
+ - config/locales/i18n_feedback.nl.yml
59
+ - config/locales/i18n_feedback.pl.yml
60
+ - config/locales/i18n_feedback.pt.yml
61
+ - config/locales/i18n_feedback.ru.yml
62
+ - config/locales/i18n_feedback.th.yml
63
+ - config/locales/i18n_feedback.tr.yml
64
+ - config/locales/i18n_feedback.uk.yml
65
+ - config/locales/i18n_feedback.ur.yml
66
+ - config/locales/i18n_feedback.vi.yml
67
+ - config/locales/i18n_feedback.zh-CN.yml
47
68
  - config/routes.rb
48
69
  - lib/generators/i18n_feedback/install/install_generator.rb
49
70
  - lib/generators/i18n_feedback/install/templates/create_i18n_feedback_suggestions.rb.tt