i18n_proofreading 0.9.6 → 0.10.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 +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +191 -238
- data/lib/i18n_proofreading/version.rb +1 -1
- data/lib/i18n_proofreading/widget.js +63 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0d4eb6649e4d744fc7a9e2374bf649a7968110d39d0a65b9c8fcb179776f4699
|
|
4
|
+
data.tar.gz: ea706e9e99fd29a0b5acc78d5c43bd5f41858d48311d1c6eb33b8821e4ba9522
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 47a2414c8c08b22c761034de75a39600c3f86bf3af81afe3782f68de5e27e3cbb8dd5cc933399b6232c796b4fe7bd8bcf119417df3536990150a5fd8ce713b2e
|
|
7
|
+
data.tar.gz: 9c9d0a69aad3f89f87df5a88ea38d7d0141af267acaaab6f94c87027cb444a82b3a458247e278319127baad1a05ddfc5673dcd1ce87ad679d9036142202762f8
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.10.0]
|
|
6
|
+
|
|
7
|
+
- The full-screen mobile suggestion dialog now survives the on-screen keyboard.
|
|
8
|
+
On phones the panel is a `100dvh` fixed element, and iOS Safari opens the
|
|
9
|
+
keyboard *over* it without resizing — hiding the Save/Cancel row behind the
|
|
10
|
+
keyboard. While the dialog is open (and only when the `max-width:480px`
|
|
11
|
+
full-screen media query matches), the widget uses the `visualViewport` API to
|
|
12
|
+
pin the panel's `height`/`top` to the actually-visible area, so its action row
|
|
13
|
+
stays just above the keyboard as it slides in. Listeners are removed and the
|
|
14
|
+
inline styles cleared on close; desktop is untouched, and browsers without
|
|
15
|
+
`visualViewport` keep the plain CSS behaviour.
|
|
16
|
+
|
|
17
|
+
## [0.9.7]
|
|
18
|
+
|
|
19
|
+
- The widget's injected stylesheet now refreshes when its content changes
|
|
20
|
+
instead of once-and-never-again — so a shipped widget update takes effect on
|
|
21
|
+
the next Turbo visit instead of needing a full page reload (Turbo keeps
|
|
22
|
+
`<head>` across visits, which could otherwise pin old CSS while fresh
|
|
23
|
+
widget.js runs). Backported from livechat 0.4.5.
|
|
24
|
+
|
|
5
25
|
## [0.9.6]
|
|
6
26
|
|
|
7
27
|
- No more flash of raw `⟦key⟧` markers when entering suggest mode: the widget
|
data/README.md
CHANGED
|
@@ -5,56 +5,15 @@
|
|
|
5
5
|
[](https://github.com/yshmarov/i18n_proofreading/actions/workflows/ci.yml)
|
|
6
6
|
[](MIT-LICENSE)
|
|
7
7
|
|
|
8
|
-
In-context translation proofreading for Rails
|
|
8
|
+
**In-context translation proofreading for Rails.** Your reviewer clicks any
|
|
9
|
+
string in the running app and suggests a better wording. You get the i18n key,
|
|
10
|
+
the old text, and the proposal — without anyone opening a YAML file.
|
|
9
11
|
|
|
10
|
-
|
|
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.
|
|
12
|
+

|
|
14
13
|
|
|
15
|
-
|
|
14
|
+
<sub>[Watch as MP4](https://github.com/yshmarov/i18n_proofreading/raw/main/docs/demo-640.mp4) (sharper, 3 MB)</sub>
|
|
16
15
|
|
|
17
|
-
|
|
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:
|
|
16
|
+
## Install
|
|
58
17
|
|
|
59
18
|
```ruby
|
|
60
19
|
# Gemfile
|
|
@@ -67,75 +26,109 @@ bin/rails generate i18n_proofreading:install
|
|
|
67
26
|
bin/rails db:migrate
|
|
68
27
|
```
|
|
69
28
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
29
|
+
Boot the app in development and look for the **"Suggest edits"** pill in the
|
|
30
|
+
bottom-left. Click it, then click any text. `Esc` exits.
|
|
31
|
+
|
|
32
|
+
No layout change needed — the widget injects itself into HTML responses.
|
|
33
|
+
|
|
34
|
+
> [!IMPORTANT]
|
|
35
|
+
> Development and staging only by design. `enabled_environments` defaults to
|
|
36
|
+
> `%w[development staging]`, and the review dashboard defaults to development
|
|
37
|
+
> only. This is not a production tool.
|
|
38
|
+
|
|
39
|
+
Ruby >= 3.2 · Rails >= 7.1 · CSRF token comes from `csrf_meta_tags`, already in
|
|
40
|
+
a standard Rails layout.
|
|
41
|
+
|
|
42
|
+
## What you get
|
|
43
|
+
|
|
44
|
+
| | |
|
|
45
|
+
| -------------- | ------------------------------------------------------------------- |
|
|
46
|
+
| **Highlight** | Every translated string outlined in the live app, mapped to its key |
|
|
47
|
+
| **Suggest** | Click a string → current text, your proposal, an optional comment |
|
|
48
|
+
| **Review** | Built-in board: pending / applied / rejected, filtered by locale |
|
|
49
|
+
| **Storage** | `i18n_proofreading_suggestions` — ordinary Active Record rows |
|
|
50
|
+
| **Deps** | None. Plain JS — no Tailwind, no Stimulus, no importmap, no build step |
|
|
51
|
+
| **Layout** | Auto-injected. Opt out and place the tag yourself if you prefer |
|
|
52
|
+
| **Auth** | Lambdas over the raw request — Devise, Rails 8 auth, feature flags |
|
|
53
|
+
| **i18n** | The tool's own UI ships in 26 languages, RTL mirrored |
|
|
54
|
+
| **Theme** | Follows system light/dark |
|
|
55
|
+
| **Turbo/CSP** | Turbo Drive and strict nonce-based CSP (incl. `strict-dynamic`) |
|
|
56
|
+
|
|
57
|
+
## The flow
|
|
58
|
+
|
|
59
|
+
| 1. Turn it on — every translated string is outlined |
|
|
60
|
+
| --- |
|
|
61
|
+
|  |
|
|
62
|
+
| Markers are only emitted while a reviewer has the tool switched on, so pages are clean by default. |
|
|
63
|
+
| **2. Click any string and propose a wording** |
|
|
64
|
+
|  |
|
|
65
|
+
| The popover shows the i18n key, the current text, any pending suggestions, and a comment field. |
|
|
66
|
+
| **3. Triage what came in** |
|
|
67
|
+
|  |
|
|
68
|
+
| Read-only by design — the gem never writes to your locale files. You make the edit. |
|
|
79
69
|
|
|
80
|
-
|
|
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
|
|
70
|
+
## How it works
|
|
88
71
|
|
|
89
|
-
|
|
72
|
+
1. In an enabled environment, the I18n backend appends a hidden `⟦some.key⟧`
|
|
73
|
+
marker to each translated string — only while a reviewer has the tool on
|
|
74
|
+
(a cookie), so pages are clean by default.
|
|
75
|
+
2. The widget strips every marker out of the DOM on load and remembers which
|
|
76
|
+
key produced each piece of text.
|
|
77
|
+
3. Clicking a string opens a popover: current text, pending suggestions, and a
|
|
78
|
+
field to propose new wording.
|
|
79
|
+
4. Suggestions `POST` to the mounted engine and land in
|
|
80
|
+
`i18n_proofreading_suggestions`.
|
|
81
|
+
|
|
82
|
+
## Configure
|
|
83
|
+
|
|
84
|
+
Everything is optional — the defaults work out of the box in development. In
|
|
85
|
+
`config/initializers/i18n_proofreading.rb`:
|
|
86
|
+
|
|
87
|
+
| Option | Default | What it does |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| `enabled_environments` | `%w[development staging]` | Environments the tool is active in |
|
|
90
|
+
| `enabled` | everyone | Extra per-request gate. `false` hides the tool |
|
|
91
|
+
| `authorize_admin` | development only | **Who can open the review board.** Independent of the gates above |
|
|
92
|
+
| `current_user` | `nil` | Attribute a suggestion to a user. Receives the request |
|
|
93
|
+
| `author_label` | the user's `email` | Label shown for the author |
|
|
94
|
+
| `available_locales` | `I18n.available_locales` | Which locales a suggestion may target |
|
|
95
|
+
| `auto_inject` | `true` | Inject the widget into HTML responses |
|
|
96
|
+
| `show_pill` | `true` | The floating "Suggest edits" pill |
|
|
97
|
+
| `pill_label` | `nil` | Fixed pill text. `nil` uses the localized default |
|
|
98
|
+
| `toggle_param` | `"i18n_proofreading"` | Query param that toggles suggest mode |
|
|
99
|
+
| `mount_path` | `"/i18n_proofreading"` | Keep in sync with `mount` in `routes.rb` |
|
|
100
|
+
| `on_submit` | no-op | Runs inline after each save — Slack, email, tickets |
|
|
101
|
+
| `rate_limit` | `{ to: 30, within: 1.minute }` | Per-IP throttle (Rails 7.2+). `nil` disables |
|
|
102
|
+
|
|
103
|
+
Gates receive the **raw request**, so they work with any auth:
|
|
90
104
|
|
|
91
105
|
```ruby
|
|
92
|
-
#
|
|
93
|
-
|
|
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
|
|
106
|
+
# Only signed-in staff
|
|
107
|
+
config.enabled = ->(request) { request.env["warden"]&.user&.staff? }
|
|
117
108
|
|
|
118
|
-
|
|
119
|
-
|
|
109
|
+
# Behind a feature flag
|
|
110
|
+
config.enabled = ->(request) { Flipper.enabled?(:i18n_proofreading) }
|
|
120
111
|
|
|
121
|
-
|
|
122
|
-
|
|
112
|
+
# Devise / Warden
|
|
113
|
+
config.current_user = ->(request) { request.env["warden"]&.user }
|
|
114
|
+
config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
|
|
123
115
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
116
|
+
# Rails 8 built-in auth (bin/rails generate authentication)
|
|
117
|
+
config.current_user = lambda do |request|
|
|
118
|
+
token = request.cookies["session_token"]
|
|
119
|
+
Session.find_signed(token)&.user if token
|
|
127
120
|
end
|
|
128
|
-
```
|
|
129
121
|
|
|
130
|
-
|
|
122
|
+
# Ping me when a suggestion lands
|
|
123
|
+
config.on_submit = ->(s) { SuggestionMailer.with(suggestion: s).created.deliver_later }
|
|
124
|
+
```
|
|
131
125
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
the pill (the two can also coexist):
|
|
126
|
+
<details>
|
|
127
|
+
<summary><b>Toggling suggest mode from your own link</b></summary>
|
|
135
128
|
|
|
136
|
-
|
|
137
|
-
config.show_pill = false
|
|
138
|
-
|
|
129
|
+
Prefer a menu item over the floating pill? Drive suggest mode from any link —
|
|
130
|
+
and optionally hide the pill with `config.show_pill = false` (the two can also
|
|
131
|
+
coexist).
|
|
139
132
|
|
|
140
133
|
A one-way "turn it on" link is just the toggle parameter:
|
|
141
134
|
|
|
@@ -143,10 +136,9 @@ A one-way "turn it on" link is just the toggle parameter:
|
|
|
143
136
|
<%= link_to t("i18n_proofreading.start"), "?i18n_proofreading=true" %>
|
|
144
137
|
```
|
|
145
138
|
|
|
146
|
-
For a single control that flips both ways, read the
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
so a localized toggle needs no keys of your own:
|
|
139
|
+
For a single control that flips both ways, read the cookie and point at the
|
|
140
|
+
opposite state. The gem ships `start` and `stop` labels in every bundled
|
|
141
|
+
language:
|
|
150
142
|
|
|
151
143
|
```erb
|
|
152
144
|
<% if I18nProofreading.available?(request) %>
|
|
@@ -156,72 +148,90 @@ so a localized toggle needs no keys of your own:
|
|
|
156
148
|
<% end %>
|
|
157
149
|
```
|
|
158
150
|
|
|
159
|
-
|
|
151
|
+
Three things worth knowing:
|
|
160
152
|
|
|
161
|
-
- `?i18n_proofreading=true` turns
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
cookie stays the single source of truth. `Esc` (or the pill) also exits.
|
|
153
|
+
- `?i18n_proofreading=true` turns it on, `false` off. State lives in the
|
|
154
|
+
`i18n_proofreading` cookie; the middleware then redirects to the same URL
|
|
155
|
+
without the parameter, so it never sticks in the address bar.
|
|
165
156
|
- These links keep working **while suggest mode is active**. The widget freezes
|
|
166
|
-
ordinary navigation during proofreading
|
|
167
|
-
mid-edit
|
|
168
|
-
|
|
169
|
-
|
|
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.
|
|
157
|
+
ordinary navigation during proofreading so a stray click can't leave the page
|
|
158
|
+
mid-edit — but any link carrying the toggle parameter is exempt.
|
|
159
|
+
- The `i18n_proofreading.*` scope is **exempt from key-marking**, so the tool
|
|
160
|
+
never flags its own controls as editable. No plain-literal workaround needed.
|
|
173
161
|
|
|
174
|
-
|
|
162
|
+
</details>
|
|
175
163
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
config.enabled = ->(request) { request.env["warden"]&.user&.staff? }
|
|
164
|
+
<details>
|
|
165
|
+
<summary><b>Placing the widget yourself</b></summary>
|
|
179
166
|
|
|
180
|
-
|
|
181
|
-
|
|
167
|
+
Set `config.auto_inject = false` and drop the helper at the end of your layout:
|
|
168
|
+
|
|
169
|
+
```erb
|
|
170
|
+
<%= i18n_proofreading_tag %>
|
|
182
171
|
```
|
|
183
172
|
|
|
184
|
-
|
|
173
|
+
It renders nothing unless the tool is available for the request.
|
|
185
174
|
|
|
186
|
-
|
|
187
|
-
receive the raw request, so they work with whatever auth you have:
|
|
175
|
+
</details>
|
|
188
176
|
|
|
189
|
-
|
|
190
|
-
# Devise / Warden:
|
|
191
|
-
config.current_user = ->(request) { request.env["warden"]&.user }
|
|
177
|
+
## Reviewing suggestions
|
|
192
178
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
179
|
+
The engine root (default `/i18n_proofreading`) is a **read-only** board:
|
|
180
|
+
pending / applied / rejected tabs with counts, a per-locale filter, and each
|
|
181
|
+
suggestion shown as current-vs-proposed with its comment and author.
|
|
182
|
+
|
|
183
|
+
Read-only is deliberate. The gem never writes to your locale files, so it
|
|
184
|
+
doesn't pretend to — you review here, then edit your own
|
|
185
|
+
`config/locales/*.yml`.
|
|
186
|
+
|
|
187
|
+
Its gate, `authorize_admin`, is independent of `enabled` /
|
|
188
|
+
`enabled_environments`: the widget can be dev/staging-only while a maintainer
|
|
189
|
+
triages from production.
|
|
190
|
+
|
|
191
|
+
Suggestions are also ordinary records:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
I18nProofreading::Suggestion.where(status: "pending").newest_first.each do |s|
|
|
195
|
+
puts "#{s.locale} #{s.translation_key}: #{s.old_value.inspect} -> #{s.proposed_value.inspect}"
|
|
197
196
|
end
|
|
198
197
|
```
|
|
199
198
|
|
|
200
|
-
|
|
199
|
+
Each row stores `translation_key`, `locale`, `old_value`, `proposed_value`,
|
|
200
|
+
`comment`, `page_url`, `status`, and optional `author_id` / `author_label`.
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
<details>
|
|
203
|
+
<summary><b>Statuses</b></summary>
|
|
203
204
|
|
|
204
|
-
|
|
205
|
-
|
|
205
|
+
Every suggestion is `pending`, `applied`, or `rejected`
|
|
206
|
+
(`I18nProofreading::Suggestion::STATUSES`), backed by an Active Record enum.
|
|
207
|
+
New suggestions start `pending`; once you apply a wording or decide against it,
|
|
208
|
+
set the status so the popover stops offering it as pending context:
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
suggestion.status_applied! # bang setter
|
|
212
|
+
suggestion.status_applied? # => true
|
|
213
|
+
I18nProofreading::Suggestion.status_pending.newest_first # scope per status
|
|
206
214
|
```
|
|
207
215
|
|
|
208
|
-
|
|
216
|
+
</details>
|
|
217
|
+
|
|
218
|
+
## Localization
|
|
209
219
|
|
|
210
|
-
|
|
220
|
+
The pill and popover speak the app's language — every string resolves through
|
|
221
|
+
Rails I18n under `i18n_proofreading.*` and follows the language the page was
|
|
222
|
+
rendered in (`<html lang>`, falling back to `I18n.locale`). 26 languages ship,
|
|
223
|
+
missing keys fall back to English, and RTL locales render the popover
|
|
224
|
+
right-to-left.
|
|
211
225
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
languages — Arabic, Bengali, Bulgarian, Chinese (Simplified), Croatian, Dutch,
|
|
226
|
+
<details>
|
|
227
|
+
<summary><b>Bundled languages, and overriding the copy</b></summary>
|
|
228
|
+
|
|
229
|
+
Arabic, Bengali, Bulgarian, Chinese (Simplified), Croatian, Dutch, English,
|
|
217
230
|
French, German, Greek, Hindi, Indonesian, Italian, Japanese, Korean,
|
|
218
231
|
Luxembourgish, Polish, Portuguese, Romanian, Russian, Spanish, Thai, Turkish,
|
|
219
|
-
Ukrainian, Urdu
|
|
220
|
-
RTL locales (Arabic, Urdu, …) render the popover right-to-left automatically.
|
|
232
|
+
Ukrainian, Urdu, Vietnamese.
|
|
221
233
|
|
|
222
|
-
|
|
223
|
-
locale never leaves a control blank. To add a language, or reword the bundled
|
|
224
|
-
copy, define the keys in your own locale files (yours win over the gem's):
|
|
234
|
+
Define the keys in your own locale files — yours win over the gem's:
|
|
225
235
|
|
|
226
236
|
```yaml
|
|
227
237
|
# config/locales/fr.yml
|
|
@@ -241,101 +251,44 @@ fr:
|
|
|
241
251
|
error_save: "Impossible d'enregistrer la suggestion."
|
|
242
252
|
```
|
|
243
253
|
|
|
244
|
-
`config.pill_label`
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
### Light / dark / system appearance
|
|
248
|
-
|
|
249
|
-
The widget follows the reviewer's operating-system appearance via
|
|
250
|
-
`prefers-color-scheme` — no configuration needed. The pill and popover render on a
|
|
251
|
-
dark surface when the system is in dark mode and a light surface otherwise; the
|
|
252
|
-
blue accent stays the same in both.
|
|
253
|
-
|
|
254
|
-
## Reviewing suggestions
|
|
255
|
-
|
|
256
|
-
### Review dashboard
|
|
257
|
-
|
|
258
|
-
Mounted at your `mount_path` (default `/i18n_proofreading`), the engine root is a
|
|
259
|
-
built-in **read-only** review board: pending / applied / rejected tabs with
|
|
260
|
-
counts, a per-locale filter, and each suggestion shown as current-vs-proposed
|
|
261
|
-
with its comment and author. It's plain server-rendered HTML with its own
|
|
262
|
-
styling — no host assets or JS framework needed.
|
|
263
|
-
|
|
264
|
-
It is deliberately read-only. The gem never writes to your locale files, so it
|
|
265
|
-
doesn't pretend to: you review the suggestions here, then make the edits in your
|
|
266
|
-
own `config/locales/*.yml`. (A suggestion's `status` still exists on the model
|
|
267
|
-
for your own tracking — set it from the console — and is the groundwork for a
|
|
268
|
-
future "apply to locale file" feature.)
|
|
269
|
-
|
|
270
|
-
It has its own gate, `config.authorize_admin`, **defaulting to development
|
|
271
|
-
only** — so a fresh install never exposes it in production. Point it at your own
|
|
272
|
-
admin check to open it elsewhere:
|
|
273
|
-
|
|
274
|
-
```ruby
|
|
275
|
-
config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
The gate is independent of `enabled` / `enabled_environments`: the widget can be
|
|
279
|
-
dev/staging-only while a maintainer still triages from production.
|
|
254
|
+
`config.pill_label` overrides the pill text with a fixed string; leave it `nil`
|
|
255
|
+
to use the localized `i18n_proofreading.pill` key.
|
|
280
256
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
Suggestions are also ordinary records:
|
|
284
|
-
|
|
285
|
-
```ruby
|
|
286
|
-
I18nProofreading::Suggestion.where(status: "pending").newest_first.each do |s|
|
|
287
|
-
puts "#{s.locale} #{s.translation_key}: #{s.old_value.inspect} -> #{s.proposed_value.inspect}"
|
|
288
|
-
end
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
Each row stores `translation_key`, `locale`, `old_value`, `proposed_value`,
|
|
292
|
-
`comment`, `page_url`, `status`, and optional `author_id` / `author_label`.
|
|
293
|
-
|
|
294
|
-
Every suggestion has a `status` — one of `pending`, `applied`, or `rejected`
|
|
295
|
-
(`I18nProofreading::Suggestion::STATUSES`), backed by an Active Record enum. New
|
|
296
|
-
suggestions start `pending`; once you apply a wording to your locale files or
|
|
297
|
-
decide against it, set the status accordingly so the popover stops offering it
|
|
298
|
-
as pending context:
|
|
299
|
-
|
|
300
|
-
```ruby
|
|
301
|
-
suggestion.status_applied! # bang setter
|
|
302
|
-
suggestion.status_applied? # => true
|
|
303
|
-
I18nProofreading::Suggestion.status_pending.newest_first # scope per status
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
### Getting notified
|
|
307
|
-
|
|
308
|
-
To be pinged when a suggestion comes in, set `on_submit`. It's called with the
|
|
309
|
-
saved `Suggestion` right after it's stored — notify Slack, send an email, open a
|
|
310
|
-
ticket. It runs inline in the request, so keep it fast or hand off to a job:
|
|
311
|
-
|
|
312
|
-
```ruby
|
|
313
|
-
config.on_submit = ->(suggestion) { SuggestionMailer.with(suggestion:).created.deliver_later }
|
|
314
|
-
```
|
|
257
|
+
</details>
|
|
315
258
|
|
|
316
259
|
## Security
|
|
317
260
|
|
|
318
|
-
-
|
|
319
|
-
|
|
320
|
-
`
|
|
321
|
-
- Format and lookup namespaces (`number.*`, `date.*`,
|
|
322
|
-
|
|
323
|
-
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
261
|
+
- **Gated on the server** for every marker, endpoint, and injection. Setting
|
|
262
|
+
the cookie by hand does nothing outside an enabled environment where
|
|
263
|
+
`enabled` returns true.
|
|
264
|
+
- **Format and lookup namespaces are never marked** (`number.*`, `date.*`,
|
|
265
|
+
`*_html` formats), so currency and date formatting are unaffected.
|
|
266
|
+
- **CSP nonce carried** from `ActionDispatch`, so it runs under a nonce-based
|
|
267
|
+
`script-src` including `strict-dynamic`. Runtime config ships as
|
|
268
|
+
`<script type="application/json">` (data, not code), so it needs no nonce and
|
|
269
|
+
survives Turbo visits.
|
|
270
|
+
- **Rate-limited per IP** on the submission endpoint (30/min by default,
|
|
271
|
+
Rails 7.2+).
|
|
329
272
|
|
|
330
273
|
## Development
|
|
331
274
|
|
|
332
275
|
```bash
|
|
333
|
-
bin/setup
|
|
334
|
-
bundle exec
|
|
276
|
+
bin/setup
|
|
277
|
+
bundle exec rake test # unit + integration
|
|
278
|
+
bundle exec rake test:system # browser tests (headless Chrome)
|
|
279
|
+
bundle exec rubocop
|
|
335
280
|
```
|
|
336
281
|
|
|
337
|
-
Tests run against a dummy Rails app
|
|
282
|
+
Tests run against a dummy Rails app in `test/dummy`.
|
|
283
|
+
|
|
284
|
+
## Also by the same author
|
|
285
|
+
|
|
286
|
+
- [testimonials](https://github.com/yshmarov/testimonials) — testimonials,
|
|
287
|
+
reviews and NPS for Rails.
|
|
288
|
+
- [ideasbugs](https://github.com/yshmarov/ideasbugs) — in-app bug reports and
|
|
289
|
+
feature requests.
|
|
290
|
+
- [SupeRails](https://superails.com) — Rails screencasts.
|
|
338
291
|
|
|
339
292
|
## License
|
|
340
293
|
|
|
341
|
-
|
|
294
|
+
MIT.
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
var priorNode = null;
|
|
37
37
|
var openerNode = null;
|
|
38
38
|
var savedOverflow = null; // pre-lock inline overflow of <html>; null = not locked
|
|
39
|
+
var panelNode = null; // the open dialog's .i18np-panel (for keyboard pinning)
|
|
40
|
+
var viewportHandler = null; // visualViewport listener while open; null = detached
|
|
39
41
|
|
|
40
42
|
function ready(fn) {
|
|
41
43
|
if (document.readyState === "loading") {
|
|
@@ -92,6 +94,7 @@
|
|
|
92
94
|
// survives the swap, so release the scroll lock or the new page is frozen.
|
|
93
95
|
if (overlay && !document.body.contains(overlay)) {
|
|
94
96
|
overlay = null;
|
|
97
|
+
unwatchViewport();
|
|
95
98
|
unlockScroll();
|
|
96
99
|
}
|
|
97
100
|
injectStyles();
|
|
@@ -231,6 +234,7 @@
|
|
|
231
234
|
});
|
|
232
235
|
|
|
233
236
|
var panel = el("div", "i18np-panel");
|
|
237
|
+
panelNode = panel;
|
|
234
238
|
panel.setAttribute("role", "dialog");
|
|
235
239
|
panel.setAttribute("aria-modal", "true");
|
|
236
240
|
panel.setAttribute("aria-labelledby", "i18np-title");
|
|
@@ -260,12 +264,14 @@
|
|
|
260
264
|
overlay.appendChild(panel);
|
|
261
265
|
document.body.appendChild(overlay);
|
|
262
266
|
lockScroll();
|
|
267
|
+
watchViewport();
|
|
263
268
|
proposedInput.focus();
|
|
264
269
|
loadPrior(key);
|
|
265
270
|
}
|
|
266
271
|
|
|
267
272
|
function close() {
|
|
268
273
|
if (overlay) {
|
|
274
|
+
unwatchViewport();
|
|
269
275
|
overlay.remove();
|
|
270
276
|
overlay = null;
|
|
271
277
|
unlockScroll();
|
|
@@ -274,6 +280,51 @@
|
|
|
274
280
|
}
|
|
275
281
|
}
|
|
276
282
|
|
|
283
|
+
// --- keep the mobile full-screen dialog above the on-screen keyboard ------
|
|
284
|
+
//
|
|
285
|
+
// On phones the panel is a 100dvh fixed box (see the max-width:480px CSS).
|
|
286
|
+
// iOS Safari opens the keyboard *over* that fixed box without resizing it, so
|
|
287
|
+
// the Save/Cancel row ends up hidden behind the keyboard. visualViewport
|
|
288
|
+
// reports the actually-visible area, so while the dialog is open we pin the
|
|
289
|
+
// panel to it — its height and top follow the viewport as the keyboard slides
|
|
290
|
+
// in, keeping the action row just above it. Desktop is untouched: the media
|
|
291
|
+
// query gates every write, and we clear the inline styles whenever it doesn't
|
|
292
|
+
// match (e.g. a phone rotated to a wide viewport). Browsers without
|
|
293
|
+
// visualViewport keep the plain CSS behaviour (no-op).
|
|
294
|
+
|
|
295
|
+
function pinPanelToViewport() {
|
|
296
|
+
if (!panelNode) return;
|
|
297
|
+
var vv = window.visualViewport;
|
|
298
|
+
if (window.matchMedia("(max-width: 480px)").matches) {
|
|
299
|
+
panelNode.style.height = vv.height + "px";
|
|
300
|
+
panelNode.style.top = vv.offsetTop + "px";
|
|
301
|
+
} else {
|
|
302
|
+
panelNode.style.height = "";
|
|
303
|
+
panelNode.style.top = "";
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function watchViewport() {
|
|
308
|
+
if (!window.visualViewport) return; // older browsers: leave the CSS as-is
|
|
309
|
+
pinPanelToViewport(); // pin once on open, before the keyboard even appears
|
|
310
|
+
viewportHandler = pinPanelToViewport;
|
|
311
|
+
window.visualViewport.addEventListener("resize", viewportHandler);
|
|
312
|
+
window.visualViewport.addEventListener("scroll", viewportHandler);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function unwatchViewport() {
|
|
316
|
+
if (viewportHandler && window.visualViewport) {
|
|
317
|
+
window.visualViewport.removeEventListener("resize", viewportHandler);
|
|
318
|
+
window.visualViewport.removeEventListener("scroll", viewportHandler);
|
|
319
|
+
}
|
|
320
|
+
viewportHandler = null;
|
|
321
|
+
if (panelNode) {
|
|
322
|
+
panelNode.style.height = "";
|
|
323
|
+
panelNode.style.top = "";
|
|
324
|
+
}
|
|
325
|
+
panelNode = null;
|
|
326
|
+
}
|
|
327
|
+
|
|
277
328
|
// Scroll-lock the page behind the modal, remembering the inline value so
|
|
278
329
|
// close() can put it back exactly as it was.
|
|
279
330
|
function lockScroll() {
|
|
@@ -446,10 +497,7 @@
|
|
|
446
497
|
// --- styles ---------------------------------------------------------------
|
|
447
498
|
|
|
448
499
|
function injectStyles() {
|
|
449
|
-
|
|
450
|
-
var style = document.createElement("style");
|
|
451
|
-
style.id = "i18np-styles";
|
|
452
|
-
style.textContent = [
|
|
500
|
+
var css = [
|
|
453
501
|
// Only the strings that actually resolve to a key are editable, so only
|
|
454
502
|
// those are highlighted. `outline` (not `border`) avoids any layout shift.
|
|
455
503
|
".i18np-active [data-i18n-key] { cursor: copy; outline: 1px dashed rgba(37, 99, 235, 0.5); outline-offset: 2px; }",
|
|
@@ -523,6 +571,17 @@
|
|
|
523
571
|
" .i18np-panel .i18np-actions { padding-bottom: calc(0px + env(safe-area-inset-bottom)); }",
|
|
524
572
|
"}",
|
|
525
573
|
].join("\n");
|
|
574
|
+
|
|
575
|
+
// Re-inject only when the CSS changed. Turbo keeps <head> across visits, so
|
|
576
|
+
// a stale <style> would otherwise pin old CSS after a shipped update, even
|
|
577
|
+
// while fresh widget.js runs — as self-freshening as the fingerprinted URL.
|
|
578
|
+
var existing = document.getElementById("i18np-styles");
|
|
579
|
+
if (existing && existing.textContent === css) return;
|
|
580
|
+
if (existing) existing.remove();
|
|
581
|
+
|
|
582
|
+
var style = document.createElement("style");
|
|
583
|
+
style.id = "i18np-styles";
|
|
584
|
+
style.textContent = css;
|
|
526
585
|
document.head.appendChild(style);
|
|
527
586
|
}
|
|
528
587
|
})();
|