i18n_proofreading 0.9.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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +214 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +325 -0
  5. data/Rakefile +16 -0
  6. data/app/controllers/i18n_proofreading/application_controller.rb +32 -0
  7. data/app/controllers/i18n_proofreading/suggestions_controller.rb +92 -0
  8. data/app/helpers/i18n_proofreading/tag_helper.rb +19 -0
  9. data/app/models/i18n_proofreading/application_record.rb +7 -0
  10. data/app/models/i18n_proofreading/suggestion.rb +27 -0
  11. data/app/views/i18n_proofreading/suggestions/index.html.erb +77 -0
  12. data/app/views/layouts/i18n_proofreading/application.html.erb +96 -0
  13. data/config/locales/i18n_proofreading.ar.yml +16 -0
  14. data/config/locales/i18n_proofreading.bg.yml +16 -0
  15. data/config/locales/i18n_proofreading.bn.yml +16 -0
  16. data/config/locales/i18n_proofreading.de.yml +16 -0
  17. data/config/locales/i18n_proofreading.el.yml +16 -0
  18. data/config/locales/i18n_proofreading.en.yml +35 -0
  19. data/config/locales/i18n_proofreading.es.yml +16 -0
  20. data/config/locales/i18n_proofreading.fr.yml +16 -0
  21. data/config/locales/i18n_proofreading.hi.yml +16 -0
  22. data/config/locales/i18n_proofreading.hr.yml +16 -0
  23. data/config/locales/i18n_proofreading.id.yml +16 -0
  24. data/config/locales/i18n_proofreading.it.yml +16 -0
  25. data/config/locales/i18n_proofreading.ja.yml +16 -0
  26. data/config/locales/i18n_proofreading.ko.yml +16 -0
  27. data/config/locales/i18n_proofreading.lb.yml +16 -0
  28. data/config/locales/i18n_proofreading.nl.yml +16 -0
  29. data/config/locales/i18n_proofreading.pl.yml +16 -0
  30. data/config/locales/i18n_proofreading.pt.yml +16 -0
  31. data/config/locales/i18n_proofreading.ro.yml +16 -0
  32. data/config/locales/i18n_proofreading.ru.yml +16 -0
  33. data/config/locales/i18n_proofreading.th.yml +16 -0
  34. data/config/locales/i18n_proofreading.tr.yml +16 -0
  35. data/config/locales/i18n_proofreading.uk.yml +16 -0
  36. data/config/locales/i18n_proofreading.ur.yml +16 -0
  37. data/config/locales/i18n_proofreading.vi.yml +16 -0
  38. data/config/locales/i18n_proofreading.zh-CN.yml +16 -0
  39. data/config/routes.rb +13 -0
  40. data/lib/generators/i18n_proofreading/install/install_generator.rb +40 -0
  41. data/lib/generators/i18n_proofreading/install/templates/create_i18n_proofreading_suggestions.rb.tt +22 -0
  42. data/lib/generators/i18n_proofreading/install/templates/initializer.rb +56 -0
  43. data/lib/i18n_proofreading/configuration.rb +91 -0
  44. data/lib/i18n_proofreading/engine.rb +26 -0
  45. data/lib/i18n_proofreading/marking.rb +46 -0
  46. data/lib/i18n_proofreading/middleware.rb +145 -0
  47. data/lib/i18n_proofreading/version.rb +5 -0
  48. data/lib/i18n_proofreading/widget.js +443 -0
  49. data/lib/i18n_proofreading/widget.rb +96 -0
  50. data/lib/i18n_proofreading.rb +37 -0
  51. metadata +111 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 578d960a613d57612657b905fb64b4625dd665e19ef02d90c61493d032058372
4
+ data.tar.gz: b8653d8cee11804f5765391a55e8b055d415ccb0681d7def0b144c1987f6410f
5
+ SHA512:
6
+ metadata.gz: c85beac359797b0eb32321adcce88f0859e4b30943754e9ff4fa106168ccc4891ccf678e31b0f908d80f1b42183a5696ad7371dddd529378df49c11d9d2016f4
7
+ data.tar.gz: 04e0f42e8a0c1991094fc13b84530cb39876073f1f9e615d6d1c8df2f11e398c867ef78f66f46570bd9e5c51b311f00c2d0df1a180206de533ac25e94a3b0acf
data/CHANGELOG.md ADDED
@@ -0,0 +1,214 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.9.0]
6
+
7
+ - **Renamed the gem `i18n_feedback` → `i18n_proofreading`.** The name now says
8
+ what it does. This renames everything: the gem, the `I18nProofreading` module,
9
+ the `i18n_proofreading.*` locale scope, the suggest-mode cookie / toggle
10
+ parameter (`i18n_proofreading`), the default mount path (`/i18n_proofreading`),
11
+ and the DB table (`i18n_proofreading_suggestions`).
12
+
13
+ **Migrating from `i18n_feedback`:** point your `Gemfile` at `i18n_proofreading`,
14
+ rename the initializer, and rename the table + any references:
15
+
16
+ ```ruby
17
+ # a migration
18
+ rename_table :i18n_feedback_suggestions, :i18n_proofreading_suggestions
19
+ ```
20
+
21
+ Then update `I18nFeedback` → `I18nProofreading`, the `config/initializers`
22
+ file, the `mount` line, any `t("i18n_feedback.*")` you added, and the
23
+ `?i18n_feedback=` toggle links to their `i18n_proofreading` equivalents.
24
+
25
+ - **Switched the test suite from RSpec to Minitest** (`test/`, `bin/rails test`
26
+ / `rake test`). No behavior change; internal only.
27
+
28
+ ## [0.8.2]
29
+
30
+ - Fix the dashboard's locale filter doing nothing under a strict (nonce-based)
31
+ Content-Security-Policy: it relied on an inline `onchange` handler, which such
32
+ a CSP blocks. The filter now has an always-visible **Filter** submit button, so
33
+ it works with JS off or inline handlers blocked (the `onchange` stays as a
34
+ nicety where allowed).
35
+ - Give the locale filter a visible **Locale** label.
36
+
37
+ ## [0.8.1]
38
+
39
+ - Make the dashboard **read-only**. Apply / Reject / Reopen / Delete only ever
40
+ flipped the stored `status` — they never touched the host's locale files, so
41
+ offering them implied a capability the gem doesn't have. The buttons and their
42
+ `update` / `destroy` routes and actions are removed; the dashboard now just
43
+ lists suggestions for review, and you make the edits in your own locale files.
44
+ A suggestion's `status` still exists on the model for out-of-band tracking and
45
+ as groundwork for a future "apply to locale file" feature.
46
+
47
+ ## [0.8.0]
48
+
49
+ - Add a built-in **triage dashboard**, mounted at the engine root (your
50
+ `mount_path`, default `/i18n_proofreading`): pending / applied / rejected tabs
51
+ with counts, a per-locale filter, each suggestion shown current-vs-proposed,
52
+ and one-click Apply / Reject / Reopen / Delete. Plain server-rendered HTML
53
+ with its own self-contained styling (light + dark via `prefers-color-scheme`)
54
+ — no host assets or JS framework required.
55
+ - Every dashboard string renders through Rails I18n under `i18n_proofreading.dashboard.*`
56
+ and `i18n_proofreading.statuses.*`, with English fallbacks, so a host can translate
57
+ or reword any of it from its own locale files.
58
+ - Add `config.authorize_admin` — the dashboard's gate, **defaulting to
59
+ development only** so a fresh install never exposes it in production. It is
60
+ independent of `enabled` / `enabled_environments`, so a maintainer can triage
61
+ from production even where the widget is off.
62
+ - The widget's "already suggested" context now loads from
63
+ `GET {mount_path}/suggestions/context` (was the collection index, now the
64
+ dashboard). Internal to the gem; the bundled widget was updated in lockstep.
65
+
66
+ ## [0.7.1]
67
+
68
+ - The `i18n_proofreading.stop` toggle label now reads "Stop suggesting (Esc)" in
69
+ every language — Esc already exits suggest mode, so the control says so.
70
+ - The widget JavaScript moved from `app/assets/` to `lib/` (it was always
71
+ inlined server-side, never served as an asset). Rails auto-registers every
72
+ engine's `app/assets/*` directory with the host's asset pipeline, so
73
+ Propshaft hosts were ingesting the file into their asset namespace under
74
+ the bare logical name `widget.js` — colliding with any other gem or host
75
+ file of the same name — and needlessly digesting a public copy at
76
+ precompile. The gem is now invisible to Sprockets/Propshaft entirely.
77
+ No behavior change.
78
+
79
+ ## [0.7.0]
80
+
81
+ - Reword the `i18n_proofreading.start` toggle label from "Suggest edits" to
82
+ "Improve translation" (localized in every language) — clearer for a menu/link
83
+ entry. The floating pill's own label (`pill`) is unchanged.
84
+ - Ship 5 more languages: Bulgarian, Greek, Croatian, Luxembourgish, and
85
+ Romanian — 25 languages besides English now bundled.
86
+
87
+ ## [0.6.1]
88
+
89
+ - Shorten the active-pill label from "Suggesting — tap to exit (Esc)" to
90
+ "Stop suggesting (Esc)" across all bundled languages.
91
+ - Release workflow: skip the flaky post-publish propagation probe
92
+ (`await-release: false`) so a successful push no longer fails the job.
93
+
94
+ ## [0.6.0]
95
+
96
+ - Ship `i18n_proofreading.start` / `i18n_proofreading.stop` labels in every bundled
97
+ language, for hosts that drive suggest mode from their own menu/link instead
98
+ of the floating pill. Because the `i18n_proofreading.*` scope is exempt from
99
+ key-marking, these are safe to render with `t(...)` — no plain-literal
100
+ workaround needed. See the "Toggling suggest mode from your own link" README
101
+ section for a full HTML example.
102
+
103
+ ## [0.5.0]
104
+
105
+ - Add a `status` to each suggestion — a string-backed Active Record enum with
106
+ values `pending`, `applied`, and `rejected` (`I18nProofreading::Suggestion::STATUSES`),
107
+ `status_`-prefixed (`status_applied?`, `status_applied!`, `Suggestion.status_pending`)
108
+ plus a `newest_first` scope. New suggestions start `pending`; the popover's
109
+ "already suggested" context now shows only pending ones, so applied/rejected
110
+ wordings stop resurfacing.
111
+
112
+ **Upgrading an existing install:** add the column with a migration —
113
+
114
+ ```ruby
115
+ add_column :i18n_proofreading_suggestions, :status, :string, null: false, default: "pending"
116
+ add_index :i18n_proofreading_suggestions, :status
117
+ ```
118
+
119
+ - Harden the public submission endpoint against abuse:
120
+ - Per-IP rate limiting via Rails' built-in limiter (Rails 7.2+; a no-op on
121
+ 7.1). Default 30 requests / 60s, tunable or disable-able via
122
+ `config.rate_limit`; returns `429 Too Many Requests`.
123
+ - Length caps on the stored free-text fields (`proposed_value` / `old_value`
124
+ 5000, `comment` / `page_url` 2000, `translation_key` 500) so a client can't
125
+ bloat the table with unbounded input.
126
+
127
+ - Add a `.github/workflows/release.yml` that publishes to RubyGems via trusted
128
+ publishing (OIDC) when a `v*` tag is pushed — no stored credentials or MFA
129
+ prompt.
130
+
131
+ ## [0.4.0]
132
+
133
+ - Add a `config.on_submit` hook, called with each saved suggestion right after
134
+ it's stored — notify Slack, send an email, open a ticket. Runs inline after
135
+ save, so keep it fast or hand off to a job.
136
+
137
+ ## [0.3.1]
138
+
139
+ - Fix the widget showing raw key markers (e.g. `⟦i18n_proofreading.title⟧`) in its own
140
+ popover while suggest mode was on. The key-marking backend was tagging the
141
+ tool's own `i18n_proofreading.*` strings; those are not part of the host app's
142
+ translatable copy, so they're now always skipped — the widget never marks or
143
+ offers to edit its own UI.
144
+ - Fix the popover not following the page's language under auto-injection. The
145
+ widget is injected in middleware *after* the controller action, so an
146
+ `around_action { I18n.with_locale(...) }` had already reset `I18n.locale` back
147
+ to the default — the popover (and the saved suggestion's `locale`) came out in
148
+ the wrong language. The locale is now read from the page's rendered
149
+ `<html lang>` attribute and labels resolve under it explicitly.
150
+
151
+ ## [0.3.0]
152
+
153
+ - Localize the widget's own UI. Every string in the pill and the suggestion
154
+ popover now resolves through Rails I18n under the `i18n_proofreading.*` scope and
155
+ follows the app's current `I18n.locale`, so the tool speaks the same language as
156
+ the app being proofread. Any key a host hasn't translated falls back to English
157
+ — so nothing goes blank in a locale you haven't fully covered. Override any
158
+ string by defining the matching key in your own locale files.
159
+ - Ship translations out of the box for 20 languages in addition to English:
160
+ Arabic, Bengali, Chinese (Simplified), Dutch, French, German, Hindi, Indonesian,
161
+ Italian, Japanese, Korean, Polish, Portuguese, Russian, Spanish, Thai, Turkish,
162
+ Ukrainian, Urdu and Vietnamese.
163
+ - Render the popover right-to-left for RTL locales (Arabic, Urdu, and other RTL
164
+ scripts), detected from the active locale's language subtag. The i18n key stays
165
+ left-to-right, since it's a code identifier rather than prose.
166
+ - `config.pill_label` now defaults to `nil`, meaning "use the localized default".
167
+ Setting it to a string still overrides the pill text as before.
168
+ - The widget now follows the operating system's light/dark/system appearance via
169
+ `prefers-color-scheme`. The pill and popover render with a dark surface when the
170
+ reviewer's system is in dark mode, with no configuration required.
171
+
172
+ ## [0.2.2]
173
+
174
+ - Fix suggest mode desyncing under a nonce-based CSP on Turbo visits. The runtime
175
+ config now rides in a `<script type="application/json">` block (data, not code)
176
+ that the widget re-reads on every `turbo:load`, instead of an executable
177
+ `<script>` the browser refuses to re-run when Turbo re-evaluates it with a stale
178
+ nonce. Only the widget code carries the CSP nonce now.
179
+ - Treat `?i18n_proofreading=true|false` as a one-shot command: the middleware sets the
180
+ cookie and redirects (303) to the same URL without the parameter. The cookie is
181
+ now the single source of truth, so the parameter no longer sticks in the address
182
+ bar and the pill's reload can turn suggest mode off.
183
+ - Let a host's own toggle link work while suggest mode is active. Suggest mode
184
+ freezes navigation so a stray click can't leave the page mid-proofread, but that
185
+ also froze a `?i18n_proofreading=false` link in your own nav — so the only way out
186
+ was the pill. Links carrying the toggle parameter are now exempt from the freeze.
187
+
188
+ ## [0.2.1]
189
+
190
+ - Keep the suggest pill and active-mode highlighting working across Turbo Drive
191
+ navigations. Turbo replaces `<body>` on each visit, which removed the pill; the
192
+ widget now re-runs its per-page setup on `turbo:load` instead of only on the
193
+ initial load, so it no longer requires a hard reload.
194
+
195
+ ## [0.2.0]
196
+
197
+ - Stamp the injected widget scripts with the request's Content-Security-Policy
198
+ nonce when one is present, so the tool works under a nonce-based CSP (including
199
+ `strict-dynamic`). No-op for apps without a CSP nonce.
200
+
201
+ ## [0.1.0]
202
+
203
+ - Initial release.
204
+ - In-context i18n key markers, gated to configured environments and toggled per
205
+ request by the widget.
206
+ - Self-contained browser widget (no CSS or JS framework required) with a suggest
207
+ pill and a per-string suggestion popover. The copy cursor and hover outline
208
+ appear only on the strings that resolve to a key.
209
+ - Optional floating pill (`config.show_pill`) and a URL toggle
210
+ (`?i18n_proofreading=true` / `false`, remembered in a cookie) so suggest mode can
211
+ be triggered from a host-provided link instead.
212
+ - `I18nProofreading::Suggestion` model and mountable engine endpoints for listing and
213
+ creating suggestions.
214
+ - `i18n_proofreading:install` generator (initializer, migration, engine mount).
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yaroslav Shmarov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,325 @@
1
+ # i18n_proofreading
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/i18n_proofreading)](https://rubygems.org/gems/i18n_proofreading)
4
+ [![Downloads](https://img.shields.io/gem/dt/i18n_proofreading)](https://rubygems.org/gems/i18n_proofreading)
5
+ [![CI](https://github.com/yshmarov/i18n-proofreading/actions/workflows/ci.yml/badge.svg)](https://github.com/yshmarov/i18n-proofreading/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](MIT-LICENSE)
7
+
8
+ In-context translation proofreading for Rails.
9
+
10
+ `i18n_proofreading` renders every translated string alongside its i18n key in the
11
+ environments you choose, lets a reviewer click any string in the running app and
12
+ suggest a better wording, and stores those suggestions for a developer to apply.
13
+ It is meant for development and staging, never production.
14
+
15
+ ![Click any string in your running app and suggest a better wording](i18n-proofreading-demo.gif)
16
+
17
+ <video src="https://github.com/yshmarov/i18n-proofreading/raw/main/i18n-proofreading-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-proofreading/raw/main/i18n-proofreading-demo-640-high.mp4">download it here</a>.
20
+ </video>
21
+
22
+ - **Zero UI dependencies.** The widget is plain JavaScript and styles itself. No
23
+ Tailwind, no daisyUI, no Stimulus, no importmap, no build step.
24
+ - **Zero layout changes.** The widget is injected into HTML responses
25
+ automatically (opt out and place it yourself if you prefer).
26
+ - **Trigger it your way.** Use the built-in floating pill, or hide it and switch
27
+ suggest mode on from your own link (a nav item, a menu, anywhere).
28
+ - **Pluggable gating and attribution.** You decide which environments and which
29
+ users see the tool, and how a suggestion is attributed.
30
+
31
+ ## How it works
32
+
33
+ 1. In an enabled environment, the I18n backend appends a hidden `⟦some.key⟧`
34
+ marker to each translated string. Markers are only emitted while a reviewer
35
+ has the tool switched on (a cookie), so pages are clean by default.
36
+ 2. The browser widget strips every marker out of the DOM on load and remembers
37
+ which key produced each piece of text.
38
+ 3. Clicking a string opens a popover showing the current text, any pending
39
+ suggestions, and a field to propose a new wording.
40
+ 4. Suggestions are `POST`ed to the mounted engine and stored in the
41
+ `i18n_proofreading_suggestions` table for you to review and apply.
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
+
50
+ ## Requirements
51
+
52
+ - Ruby >= 3.2
53
+ - Rails >= 7.1
54
+
55
+ ## Installation
56
+
57
+ Add the gem:
58
+
59
+ ```ruby
60
+ # Gemfile
61
+ gem "i18n_proofreading"
62
+ ```
63
+
64
+ ```bash
65
+ bundle install
66
+ bin/rails generate i18n_proofreading:install
67
+ bin/rails db:migrate
68
+ ```
69
+
70
+ The generator:
71
+
72
+ - writes `config/initializers/i18n_proofreading.rb`,
73
+ - creates the `i18n_proofreading_suggestions` migration,
74
+ - mounts the engine in `config/routes.rb`:
75
+
76
+ ```ruby
77
+ mount I18nProofreading::Engine => "/i18n_proofreading"
78
+ ```
79
+
80
+ Boot the app in development and look for the **“Suggest edits”** pill in the
81
+ bottom-left corner. Click it to turn on suggest mode, then click any text to
82
+ propose a fix. Press `Esc` (or the pill) to exit.
83
+
84
+ > The widget reads the CSRF token from `<meta name="csrf-token">`, which
85
+ > `csrf_meta_tags` in your layout already provides in a standard Rails app.
86
+
87
+ ## Configuration
88
+
89
+ Everything is optional; the defaults work out of the box in development.
90
+
91
+ ```ruby
92
+ # config/initializers/i18n_proofreading.rb
93
+ I18nProofreading.configure do |config|
94
+ # Environments the tool is active in.
95
+ config.enabled_environments = %w[development staging]
96
+
97
+ # Extra per-request gate. Return false to hide the tool. Receives the request.
98
+ config.enabled = ->(request) { true }
99
+
100
+ # Who may open the triage dashboard. Independent of the gates above; defaults
101
+ # to development only. Wire it to your own admin check to open it elsewhere.
102
+ config.authorize_admin = ->(request) { Rails.env.development? }
103
+
104
+ # Attribute a suggestion to a user (optional). Return an object responding to
105
+ # #id, or nil. Receives the request.
106
+ config.current_user = ->(request) { nil }
107
+
108
+ # Label shown for the author in the "already suggested" list.
109
+ config.author_label = ->(user) { user.try(:email) }
110
+
111
+ # Inject the widget automatically. Set false to place it yourself.
112
+ config.auto_inject = true
113
+
114
+ # Show the floating "Suggest edits" pill. Set false to drive suggest mode from
115
+ # your own link instead (see below).
116
+ config.show_pill = true
117
+
118
+ # Query parameter that toggles suggest mode.
119
+ config.toggle_param = "i18n_proofreading"
120
+
121
+ # Keep in sync with the `mount` in config/routes.rb.
122
+ config.mount_path = "/i18n_proofreading"
123
+
124
+ # Per-IP throttle on the submission endpoint, passed to Rails' built-in rate
125
+ # limiter (Rails 7.2+; ignored on 7.1). Set nil to disable.
126
+ config.rate_limit = { to: 30, within: 60 }
127
+ end
128
+ ```
129
+
130
+ ### Toggling suggest mode from your own link
131
+
132
+ Prefer a menu item over the floating pill? You can drive suggest mode from any
133
+ link in your own UI — a nav item, a sidebar entry, a footer — and optionally hide
134
+ the pill (the two can also coexist):
135
+
136
+ ```ruby
137
+ config.show_pill = false # optional
138
+ ```
139
+
140
+ A one-way "turn it on" link is just the toggle parameter:
141
+
142
+ ```erb
143
+ <%= link_to t("i18n_proofreading.start"), "?i18n_proofreading=true" %>
144
+ ```
145
+
146
+ For a single control that flips both ways, read the current state from the
147
+ `i18n_proofreading` cookie and point at the opposite state. The gem ships `start`
148
+ and `stop` labels under the `i18n_proofreading.*` scope in every bundled language,
149
+ so a localized toggle needs no keys of your own:
150
+
151
+ ```erb
152
+ <% if I18nProofreading.available?(request) %>
153
+ <% suggesting = cookies[:i18n_proofreading].present? %>
154
+ <%= link_to (suggesting ? t("i18n_proofreading.stop") : t("i18n_proofreading.start")),
155
+ "?#{I18nProofreading.config.toggle_param}=#{!suggesting}" %>
156
+ <% end %>
157
+ ```
158
+
159
+ Good to know:
160
+
161
+ - `?i18n_proofreading=true` turns suggest mode on, `false` turns it off. The state is
162
+ stored in the `i18n_proofreading` cookie, and the middleware then redirects to the
163
+ same URL without the parameter — so it never sticks in the address bar and the
164
+ cookie stays the single source of truth. `Esc` (or the pill) also exits.
165
+ - These links keep working **while suggest mode is active**. The widget freezes
166
+ ordinary navigation during proofreading (so a stray click can't leave the page
167
+ mid-edit), but any link carrying the toggle parameter is exempt — so a "Disable"
168
+ item in your nav always gets you out.
169
+ - The `i18n_proofreading.*` keys are **safe to run through `I18n.t`** — that scope is
170
+ exempt from key-marking, so the tool never flags its own controls as editable.
171
+ Use the bundled `i18n_proofreading.start` / `i18n_proofreading.stop` labels (or your own
172
+ keys); either way, no plain-literal workaround is needed.
173
+
174
+ ### Gating examples
175
+
176
+ ```ruby
177
+ # Only signed-in staff (however your app resolves that):
178
+ config.enabled = ->(request) { request.env["warden"]&.user&.staff? }
179
+
180
+ # Behind a feature flag:
181
+ config.enabled = ->(request) { Flipper.enabled?(:i18n_proofreading) }
182
+ ```
183
+
184
+ ### Placing the widget yourself
185
+
186
+ Set `config.auto_inject = false` and drop the helper at the end of your layout:
187
+
188
+ ```erb
189
+ <%= i18n_proofreading_tag %>
190
+ ```
191
+
192
+ It renders nothing unless the tool is available for the request.
193
+
194
+ ### Localizing the widget UI
195
+
196
+ The pill and the suggestion popover speak the app's language: every string
197
+ resolves through Rails I18n under the `i18n_proofreading.*` scope and follows the
198
+ language the page was rendered in (its `<html lang>`, falling back to
199
+ `I18n.locale`). Translations ship out of the box for English plus 25 more
200
+ languages — Arabic, Bengali, Bulgarian, Chinese (Simplified), Croatian, Dutch,
201
+ French, German, Greek, Hindi, Indonesian, Italian, Japanese, Korean,
202
+ Luxembourgish, Polish, Portuguese, Romanian, Russian, Spanish, Thai, Turkish,
203
+ Ukrainian, Urdu and Vietnamese — so the tool is already localized for most apps.
204
+ RTL locales (Arabic, Urdu, …) render the popover right-to-left automatically.
205
+
206
+ Any key you haven't translated falls back to English, so a partially translated
207
+ locale never leaves a control blank. To add a language, or reword the bundled
208
+ copy, define the keys in your own locale files (yours win over the gem's):
209
+
210
+ ```yaml
211
+ # config/locales/fr.yml
212
+ fr:
213
+ i18n_proofreading:
214
+ pill: "Proposer des corrections"
215
+ pill_active: "En cours — appuyez pour quitter (Échap)"
216
+ title: "Proposer une correction de traduction"
217
+ current_text: "Texte actuel"
218
+ suggested_text: "Texte proposé"
219
+ comment: "Commentaire"
220
+ comment_placeholder: "Note facultative pour le développeur"
221
+ prior_title: "Déjà proposé (en attente)"
222
+ cancel: "Annuler"
223
+ save: "Envoyer la suggestion"
224
+ error_blank: "Veuillez saisir une suggestion."
225
+ error_save: "Impossible d'enregistrer la suggestion."
226
+ ```
227
+
228
+ `config.pill_label` still overrides the pill text with a fixed string if you set
229
+ it; leave it `nil` (the default) to use the localized `i18n_proofreading.pill` key.
230
+
231
+ ### Light / dark / system appearance
232
+
233
+ The widget follows the reviewer's operating-system appearance via
234
+ `prefers-color-scheme` — no configuration needed. The pill and popover render on a
235
+ dark surface when the system is in dark mode and a light surface otherwise; the
236
+ blue accent stays the same in both.
237
+
238
+ ## Reviewing suggestions
239
+
240
+ ### Review dashboard
241
+
242
+ Mounted at your `mount_path` (default `/i18n_proofreading`), the engine root is a
243
+ built-in **read-only** review board: pending / applied / rejected tabs with
244
+ counts, a per-locale filter, and each suggestion shown as current-vs-proposed
245
+ with its comment and author. It's plain server-rendered HTML with its own
246
+ styling — no host assets or JS framework needed.
247
+
248
+ It is deliberately read-only. The gem never writes to your locale files, so it
249
+ doesn't pretend to: you review the suggestions here, then make the edits in your
250
+ own `config/locales/*.yml`. (A suggestion's `status` still exists on the model
251
+ for your own tracking — set it from the console — and is the groundwork for a
252
+ future "apply to locale file" feature.)
253
+
254
+ It has its own gate, `config.authorize_admin`, **defaulting to development
255
+ only** — so a fresh install never exposes it in production. Point it at your own
256
+ admin check to open it elsewhere:
257
+
258
+ ```ruby
259
+ config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
260
+ ```
261
+
262
+ The gate is independent of `enabled` / `enabled_environments`: the widget can be
263
+ dev/staging-only while a maintainer still triages from production.
264
+
265
+ ### From the console
266
+
267
+ Suggestions are also ordinary records:
268
+
269
+ ```ruby
270
+ I18nProofreading::Suggestion.where(status: "pending").newest_first.each do |s|
271
+ puts "#{s.locale} #{s.translation_key}: #{s.old_value.inspect} -> #{s.proposed_value.inspect}"
272
+ end
273
+ ```
274
+
275
+ Each row stores `translation_key`, `locale`, `old_value`, `proposed_value`,
276
+ `comment`, `page_url`, `status`, and optional `author_id` / `author_label`.
277
+
278
+ Every suggestion has a `status` — one of `pending`, `applied`, or `rejected`
279
+ (`I18nProofreading::Suggestion::STATUSES`), backed by an Active Record enum. New
280
+ suggestions start `pending`; once you apply a wording to your locale files or
281
+ decide against it, set the status accordingly so the popover stops offering it
282
+ as pending context:
283
+
284
+ ```ruby
285
+ suggestion.status_applied! # bang setter
286
+ suggestion.status_applied? # => true
287
+ I18nProofreading::Suggestion.status_pending.newest_first # scope per status
288
+ ```
289
+
290
+ ### Getting notified
291
+
292
+ To be pinged when a suggestion comes in, set `on_submit`. It's called with the
293
+ saved `Suggestion` right after it's stored — notify Slack, send an email, open a
294
+ ticket. It runs inline in the request, so keep it fast or hand off to a job:
295
+
296
+ ```ruby
297
+ config.on_submit = ->(suggestion) { SuggestionMailer.with(suggestion:).created.deliver_later }
298
+ ```
299
+
300
+ ## Security
301
+
302
+ - The tool is gated **on the server** for every marker, endpoint, and injection.
303
+ Setting the cookie by hand does nothing outside an enabled environment where
304
+ `config.enabled` returns true.
305
+ - Format and lookup namespaces (`number.*`, `date.*`, `*_html` formats, etc.) are
306
+ never marked, so currency and date formatting are unaffected.
307
+ - The injected widget code carries the request's Content-Security-Policy nonce
308
+ (the same one `ActionDispatch` emits), so it runs under a nonce-based
309
+ `script-src` policy — including `strict-dynamic` — with no configuration. It is
310
+ a no-op when the app sets no CSP nonce. The runtime config is shipped as a
311
+ `<script type="application/json">` block (data, not code), so it needs no nonce
312
+ and stays correct across Turbo visits.
313
+
314
+ ## Development
315
+
316
+ ```bash
317
+ bin/setup # or: bundle install
318
+ bundle exec rspec
319
+ ```
320
+
321
+ Tests run against a dummy Rails app under `spec/dummy`.
322
+
323
+ ## License
324
+
325
+ Released under the [MIT License](MIT-LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ # Defines build/install/release — `rake release` is what the trusted-publishing
5
+ # workflow (.github/workflows/release.yml) runs to push the gem to RubyGems.
6
+ require 'bundler/gem_tasks'
7
+
8
+ require 'rake/testtask'
9
+ Rake::TestTask.new(:test) do |t|
10
+ t.libs << 'test'
11
+ t.pattern = 'test/**/*_test.rb'
12
+ t.warning = false
13
+ t.verbose = false
14
+ end
15
+
16
+ task default: :test
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nProofreading
4
+ class ApplicationController < ActionController::Base
5
+ protect_from_forgery with: :exception
6
+
7
+ private
8
+
9
+ # Gate for the widget's public API (submit a suggestion, read prior context).
10
+ # The client can set the cookie, but it can never reach the endpoints unless
11
+ # the app itself says the tool is available for this request.
12
+ def require_available
13
+ head :forbidden unless I18nProofreading.available?(request)
14
+ end
15
+
16
+ # Gate for the triage dashboard. Independent of #require_available (see
17
+ # I18nProofreading.admin?). Renders a plain 403 with a hint rather than a bare
18
+ # head, since a human is usually looking at it.
19
+ def require_admin
20
+ return if I18nProofreading.admin?(request)
21
+
22
+ render plain: 'Forbidden. Set I18nProofreading.config.authorize_admin to grant access.',
23
+ status: :forbidden
24
+ end
25
+
26
+ def current_author
27
+ return @current_author if defined?(@current_author)
28
+
29
+ @current_author = I18nProofreading.config.current_user.call(request)
30
+ end
31
+ end
32
+ end