i18n_proofreading 0.9.2 → 0.9.4

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: 442bbc0db3a43634199ae4ad02e0b4c93bfe4b0278f5c4bf4236793acfd9006d
4
- data.tar.gz: b5adfadeac507e64888ea36412173daec3ec8b8b0067fe1c056de8b1c328c5b7
3
+ metadata.gz: f5a99dfd18ee5650a22a7511b6dc7cd061c4be32dae3d2ab7442174ce226b105
4
+ data.tar.gz: dbc832b5abd4f24d57927dec1e5cd5a593498ca4a818609488c0b61355ecba35
5
5
  SHA512:
6
- metadata.gz: ad016e5b43be45f388e0f0c11956dbcfaed05d2702f1765d598a2c29e4ee2af8a539bb853c8d8ef8d0dd0121273c64e64353611b8acb494348d10e24188aa5d4
7
- data.tar.gz: '08506d993c5b691a33ab9dc327fc2ca84814723cd289bf014ff164181f4fa85d882901286ccc0816aa26363aba371893090ee4024845cb7e0431c57bb2483dd4'
6
+ metadata.gz: 1c1aa74a2f8d4286a83b1b4b94b1e149999c1769f9512b3990e7e389e637e9c96db4645b041dfd506abb8948b9d9011d59995145ed8fab6c0fffe2901cf8ad50
7
+ data.tar.gz: d5e1166331606efb1b6a957c2b004360ad86c9fdae1d3846d2907f625ab52607e96bce3e73c5106910acdeeae4d2733f30e9ed6e43a335e6ecbdb6ada7001016
data/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.4]
6
+
7
+ - **Accessibility parity for the suggestion popover.** The panel is now a real
8
+ dialog (`role="dialog"`, `aria-modal="true"`, named by its title via
9
+ `aria-labelledby`); Tab/Shift+Tab are trapped inside it while it is open and
10
+ closing it returns focus to the element that opened it. Validation and save
11
+ errors are announced to screen readers (`role="alert"`), and the previously
12
+ hardcoded English network-failure message now goes through the localized
13
+ `errorSave` label like the rest. The page behind the open dialog is
14
+ scroll-locked (`overflow: hidden` on `<html>`, restored on close) — and if a
15
+ Turbo body swap removes the overlay without `close()`, the next render
16
+ releases the lock so the new page never arrives frozen. The floating pill is
17
+ untouched.
18
+
19
+ ## [0.9.3]
20
+
21
+ - Fix the widget going dead after a Turbo Drive visit under a nonce-based
22
+ Content-Security-Policy. The widget code was inlined as a nonce'd `<script>`;
23
+ Turbo body swaps re-run body scripts against the *original* page's CSP
24
+ header, which refuses the freshly minted inline nonce — so after any soft
25
+ visit the widget never booted until a hard reload. The code is now served as
26
+ a **same-origin, content-fingerprinted script**
27
+ (`GET {mount_path}/widget.js?v=<md5>`), which `script-src 'self'` (or the
28
+ nonce the tag still carries) covers on every visit. The fingerprinted URL is
29
+ immutable and cached for a year — new code means a new URL — while any other
30
+ URL only ETag-revalidates. The inline JSON config block is unchanged: it is
31
+ data, not code, and needs no nonce.
32
+
5
33
  ## [0.9.2]
6
34
 
7
35
  - Make the suggestion popover **full-screen on mobile** (≤ 480px wide): edge to
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nProofreading
4
+ # Serves the widget JavaScript as a plain same-origin script. Same-origin
5
+ # matters: under a `script-src 'self'` (or nonce-based) CSP, an external
6
+ # script from the app's own host is always allowed — including when Turbo
7
+ # Drive swaps the <body> and re-runs body scripts under the *original*
8
+ # page's CSP header, where a freshly minted inline nonce would be refused.
9
+ class WidgetsController < ApplicationController
10
+ # The script is static and carries no user data; without this, Rails'
11
+ # cross-origin JavaScript guard refuses to serve it to a plain
12
+ # <script src> request.
13
+ skip_forgery_protection
14
+
15
+ # The script URL carries a content fingerprint (?v=<md5>), so stale code
16
+ # is structurally impossible: new code means a new URL. The canonical
17
+ # fingerprinted URL is immutable and gets long-lived caching; anything
18
+ # else only ETag-revalidates.
19
+ def show
20
+ expires_in 1.year, public: true if params[:v] == Widget.fingerprint
21
+ return unless stale?(etag: [I18nProofreading::VERSION, Widget.fingerprint])
22
+
23
+ render plain: Widget.javascript, content_type: 'text/javascript'
24
+ end
25
+ end
26
+ end
data/config/routes.rb CHANGED
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  I18nProofreading::Engine.routes.draw do
4
+ # The widget code, served same-origin so it runs under a `'self'`/nonce-based
5
+ # CSP even across Turbo body swaps (see WidgetsController).
6
+ get 'widget.js', to: 'widgets#show', as: :widget
7
+
4
8
  # index is the read-only admin dashboard; create + the `context` collection
5
9
  # route are the widget's public API (see SuggestionsController). There is
6
10
  # deliberately no update/destroy — the tool never mutates suggestions from the
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module I18nProofreading
4
- VERSION = '0.9.2'
4
+ VERSION = '0.9.4'
5
5
  end
@@ -34,6 +34,8 @@
34
34
  var errorNode = null;
35
35
  var saveButton = null;
36
36
  var priorNode = null;
37
+ var openerNode = null;
38
+ var savedOverflow = null; // pre-lock inline overflow of <html>; null = not locked
37
39
 
38
40
  function ready(fn) {
39
41
  if (document.readyState === "loading") {
@@ -73,6 +75,12 @@
73
75
  // Re-read on each visit: the injected config is data (not an executed
74
76
  // script), so it reflects the page Turbo just rendered.
75
77
  config = readConfig() || config;
78
+ // A Turbo body swap can drop the overlay without close(); documentElement
79
+ // survives the swap, so release the scroll lock or the new page is frozen.
80
+ if (overlay && !document.body.contains(overlay)) {
81
+ overlay = null;
82
+ unlockScroll();
83
+ }
76
84
  injectStyles();
77
85
  if (config.showPill !== false) buildPill();
78
86
  document.documentElement.classList.toggle("i18np-active", !!config.active);
@@ -144,11 +152,30 @@
144
152
  }
145
153
 
146
154
  function handleKeydown(event) {
155
+ if (event.key === "Tab" && overlay) {
156
+ trapTab(event);
157
+ return;
158
+ }
147
159
  if (event.key !== "Escape") return;
148
160
  if (overlay) close();
149
161
  else if (config.active) toggle();
150
162
  }
151
163
 
164
+ // Keep Tab/Shift+Tab cycling inside the open dialog.
165
+ function trapTab(event) {
166
+ var focusable = overlay.querySelectorAll("button, [href], input, textarea, select");
167
+ if (!focusable.length) return;
168
+ var first = focusable[0];
169
+ var last = focusable[focusable.length - 1];
170
+ if (event.shiftKey && document.activeElement === first) {
171
+ event.preventDefault();
172
+ last.focus();
173
+ } else if (!event.shiftKey && document.activeElement === last) {
174
+ event.preventDefault();
175
+ first.focus();
176
+ }
177
+ }
178
+
152
179
  // --- pill -----------------------------------------------------------------
153
180
 
154
181
  function buildPill() {
@@ -178,12 +205,17 @@
178
205
  function open(key, currentValue) {
179
206
  close();
180
207
 
208
+ openerNode = document.activeElement;
209
+
181
210
  overlay = el("div", "i18np-overlay");
182
211
  overlay.addEventListener("click", function (event) {
183
212
  if (event.target === overlay) close();
184
213
  });
185
214
 
186
215
  var panel = el("div", "i18np-panel");
216
+ panel.setAttribute("role", "dialog");
217
+ panel.setAttribute("aria-modal", "true");
218
+ panel.setAttribute("aria-labelledby", "i18np-title");
187
219
  // Mirror the popover for right-to-left locales (Arabic, Urdu, …). The i18n
188
220
  // key stays LTR — it's a code identifier, not prose (see heading()).
189
221
  panel.dir = config.rtl ? "rtl" : "ltr";
@@ -201,6 +233,7 @@
201
233
  panel.appendChild(field(t("comment", "Comment"), commentInput));
202
234
 
203
235
  errorNode = el("p", "i18np-error");
236
+ errorNode.setAttribute("role", "alert");
204
237
  errorNode.style.display = "none";
205
238
  panel.appendChild(errorNode);
206
239
 
@@ -208,6 +241,7 @@
208
241
 
209
242
  overlay.appendChild(panel);
210
243
  document.body.appendChild(overlay);
244
+ lockScroll();
211
245
  proposedInput.focus();
212
246
  loadPrior(key);
213
247
  }
@@ -216,9 +250,25 @@
216
250
  if (overlay) {
217
251
  overlay.remove();
218
252
  overlay = null;
253
+ unlockScroll();
254
+ if (openerNode && document.body.contains(openerNode)) openerNode.focus();
255
+ openerNode = null;
219
256
  }
220
257
  }
221
258
 
259
+ // Scroll-lock the page behind the modal, remembering the inline value so
260
+ // close() can put it back exactly as it was.
261
+ function lockScroll() {
262
+ if (savedOverflow === null) savedOverflow = document.documentElement.style.overflow;
263
+ document.documentElement.style.overflow = "hidden";
264
+ }
265
+
266
+ function unlockScroll() {
267
+ if (savedOverflow === null) return;
268
+ document.documentElement.style.overflow = savedOverflow;
269
+ savedOverflow = null;
270
+ }
271
+
222
272
  function loadPrior(key) {
223
273
  var params = new URLSearchParams({ key: key, locale: config.locale });
224
274
  fetch(config.endpoint + "/context?" + params.toString(), { headers: { Accept: "application/json" } })
@@ -311,7 +361,7 @@
311
361
  })
312
362
  .catch(function () {
313
363
  saveButton.disabled = false;
314
- showError("Could not save the suggestion.");
364
+ showError(t("errorSave", "Could not save the suggestion."));
315
365
  });
316
366
  }
317
367
 
@@ -325,6 +375,7 @@
325
375
  function heading(key) {
326
376
  var wrapper = el("div", "i18np-heading");
327
377
  var title = el("p", "i18np-title");
378
+ title.id = "i18np-title"; // aria-labelledby target; only one dialog at a time
328
379
  title.textContent = t("title", "Suggest a translation fix");
329
380
  var code = el("code", "i18np-key");
330
381
  code.dir = "ltr"; // the key is a code path, never RTL prose
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'digest'
4
5
 
5
6
  module I18nProofreading
6
7
  # Serves the self-contained browser widget. The JavaScript is plain ES (no
7
8
  # framework, no build step) and styles itself inline, so it drops into any Rails
8
- # app regardless of its CSS or JS setup. It is inlined into the page rather than
9
- # served as a separate asset to avoid any dependency on the host's asset
10
- # pipeline.
9
+ # app regardless of its CSS or JS setup. It is served by the engine's own
10
+ # controller (see WidgetsController) rather than through an asset pipeline, to
11
+ # avoid any dependency on the host's asset setup.
11
12
  module Widget
12
13
  # Lives under lib/ (not app/assets/), so a host that runs an asset
13
14
  # pipeline never ingests it: Rails auto-registers every engine's
@@ -24,6 +25,13 @@ module I18nProofreading
24
25
  @javascript ||= File.read(SOURCE)
25
26
  end
26
27
 
28
+ # Content fingerprint for the cache-busting script URL: a changed file is
29
+ # a changed URL, so no browser can ever run stale widget code — Safari
30
+ # has been caught ignoring must-revalidate on same-URL scripts.
31
+ def fingerprint
32
+ @fingerprint ||= Digest::MD5.hexdigest(javascript)
33
+ end
34
+
27
35
  # The two <script> tags to place before </body>.
28
36
  #
29
37
  # The config rides in a `type="application/json"` block: it is *data*, not
@@ -32,8 +40,12 @@ module I18nProofreading
32
40
  # widget can re-read the *current* page's config on every `turbo:load`
33
41
  # instead of being stuck with whatever the last full load evaluated.
34
42
  #
35
- # `nonce:` stamps only the widget script (the code), so it runs under a
36
- # nonce-based Content-Security-Policy; pass nil when the app has no nonce.
43
+ # The code is a same-origin `src` script served by the engine NOT
44
+ # inlined. Under a nonce-based CSP, Turbo Drive body swaps re-run body
45
+ # scripts against the *original* page's CSP header, so a fresh inline
46
+ # nonce gets refused; a same-origin src is covered by `'self'` on every
47
+ # visit. `nonce:` is still stamped for hosts whose script-src has no
48
+ # 'self'; pass nil when the app has no nonce.
37
49
  def snippet(endpoint:, locale:, active:, nonce: nil)
38
50
  config = {
39
51
  endpoint: endpoint,
@@ -48,9 +60,10 @@ module I18nProofreading
48
60
  # Escape "</" so a value can't close the <script> block early.
49
61
  json = config.to_json.gsub('</', '<\/')
50
62
  nonce_attr = nonce ? %( nonce="#{nonce}") : ''
63
+ src = "#{I18nProofreading.config.mount_path.chomp('/')}/widget.js?v=#{fingerprint}"
51
64
 
52
65
  %(<script type="application/json" data-i18n-proofreading-config>#{json}</script>) +
53
- %(<script data-i18n-proofreading-widget#{nonce_attr}>#{javascript}</script>)
66
+ %(<script src="#{src}" defer#{nonce_attr} data-i18n-proofreading-widget></script>)
54
67
  end
55
68
 
56
69
  private
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: i18n_proofreading
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.2
4
+ version: 0.9.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov
@@ -40,6 +40,7 @@ files:
40
40
  - Rakefile
41
41
  - app/controllers/i18n_proofreading/application_controller.rb
42
42
  - app/controllers/i18n_proofreading/suggestions_controller.rb
43
+ - app/controllers/i18n_proofreading/widgets_controller.rb
43
44
  - app/helpers/i18n_proofreading/tag_helper.rb
44
45
  - app/models/i18n_proofreading/application_record.rb
45
46
  - app/models/i18n_proofreading/suggestion.rb