i18n_feedback 0.1.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 +7 -0
- data/CHANGELOG.md +18 -0
- data/MIT-LICENSE +21 -0
- data/README.md +172 -0
- data/Rakefile +11 -0
- data/app/assets/i18n_feedback/widget.js +379 -0
- data/app/controllers/i18n_feedback/application_controller.rb +23 -0
- data/app/controllers/i18n_feedback/suggestions_controller.rb +51 -0
- data/app/helpers/i18n_feedback/tag_helper.rb +18 -0
- data/app/models/i18n_feedback/application_record.rb +7 -0
- data/app/models/i18n_feedback/suggestion.rb +14 -0
- data/config/routes.rb +5 -0
- data/lib/generators/i18n_feedback/install/install_generator.rb +40 -0
- data/lib/generators/i18n_feedback/install/templates/create_i18n_feedback_suggestions.rb.tt +20 -0
- data/lib/generators/i18n_feedback/install/templates/initializer.rb +39 -0
- data/lib/i18n_feedback/configuration.rb +71 -0
- data/lib/i18n_feedback/engine.rb +26 -0
- data/lib/i18n_feedback/marking.rb +43 -0
- data/lib/i18n_feedback/middleware.rb +101 -0
- data/lib/i18n_feedback/version.rb +5 -0
- data/lib/i18n_feedback/widget.rb +35 -0
- data/lib/i18n_feedback.rb +30 -0
- metadata +83 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: db55035ed25835495f5e4840e9b3e6663a7e745d52600803207e8e38b7f92dcf
|
|
4
|
+
data.tar.gz: 82cd102f8766cbab2de69c3c69fe56b6fefd8e27efb4f1e897b2556e9ef76510
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 886de0912affd3a3d0e9ed7a20bca10605b6d2da21cb8446028049e0568cefe74ea2c57ee28f083cb29010220eabb3790cd0eec0e0af478ded46f45761a66ef2
|
|
7
|
+
data.tar.gz: 94e65e65440bf08316bf2b55eacdc8af421dfad8072ac62b1172b93bb8b93ed4974f3b31d2051ba11bb9c4e708299a7a97a4a7bfa60d0fe4606312a888439844
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.1.0]
|
|
6
|
+
|
|
7
|
+
- Initial release.
|
|
8
|
+
- In-context i18n key markers, gated to configured environments and toggled per
|
|
9
|
+
request by the widget.
|
|
10
|
+
- Self-contained browser widget (no CSS or JS framework required) with a suggest
|
|
11
|
+
pill and a per-string suggestion popover. The copy cursor and hover outline
|
|
12
|
+
appear only on the strings that resolve to a key.
|
|
13
|
+
- Optional floating pill (`config.show_pill`) and a URL toggle
|
|
14
|
+
(`?i18n_feedback=true` / `false`, remembered in a cookie) so suggest mode can
|
|
15
|
+
be triggered from a host-provided link instead.
|
|
16
|
+
- `I18nFeedback::Suggestion` model and mountable engine endpoints for listing and
|
|
17
|
+
creating suggestions.
|
|
18
|
+
- `i18n_feedback: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,172 @@
|
|
|
1
|
+
# i18n_feedback
|
|
2
|
+
|
|
3
|
+
In-context translation proofreading for Rails.
|
|
4
|
+
|
|
5
|
+
`i18n_feedback` renders every translated string alongside its i18n key in the
|
|
6
|
+
environments you choose, lets a reviewer click any string in the running app and
|
|
7
|
+
suggest a better wording, and stores those suggestions for a developer to apply.
|
|
8
|
+
It is meant for development and staging, never production.
|
|
9
|
+
|
|
10
|
+
- **Zero UI dependencies.** The widget is plain JavaScript and styles itself. No
|
|
11
|
+
Tailwind, no daisyUI, no Stimulus, no importmap, no build step.
|
|
12
|
+
- **Zero layout changes.** The widget is injected into HTML responses
|
|
13
|
+
automatically (opt out and place it yourself if you prefer).
|
|
14
|
+
- **Trigger it your way.** Use the built-in floating pill, or hide it and switch
|
|
15
|
+
suggest mode on from your own link (a nav item, a menu, anywhere).
|
|
16
|
+
- **Pluggable gating and attribution.** You decide which environments and which
|
|
17
|
+
users see the tool, and how a suggestion is attributed.
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
1. In an enabled environment, the I18n backend appends a hidden `⟦some.key⟧`
|
|
22
|
+
marker to each translated string. Markers are only emitted while a reviewer
|
|
23
|
+
has the tool switched on (a cookie), so pages are clean by default.
|
|
24
|
+
2. The browser widget strips every marker out of the DOM on load and remembers
|
|
25
|
+
which key produced each piece of text.
|
|
26
|
+
3. Clicking a string opens a popover showing the current text, any pending
|
|
27
|
+
suggestions, and a field to propose a new wording.
|
|
28
|
+
4. Suggestions are `POST`ed to the mounted engine and stored in the
|
|
29
|
+
`i18n_feedback_suggestions` table for you to review and apply.
|
|
30
|
+
|
|
31
|
+
## Requirements
|
|
32
|
+
|
|
33
|
+
- Ruby >= 3.2
|
|
34
|
+
- Rails >= 7.1
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
Add the gem (from source until it is published on RubyGems):
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
# Gemfile
|
|
42
|
+
gem "i18n_feedback", github: "yshmarov/i18n-feedback"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
bundle install
|
|
47
|
+
bin/rails generate i18n_feedback:install
|
|
48
|
+
bin/rails db:migrate
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The generator:
|
|
52
|
+
|
|
53
|
+
- writes `config/initializers/i18n_feedback.rb`,
|
|
54
|
+
- creates the `i18n_feedback_suggestions` migration,
|
|
55
|
+
- mounts the engine in `config/routes.rb`:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
mount I18nFeedback::Engine => "/i18n_feedback"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Boot the app in development and look for the **“Suggest edits”** pill in the
|
|
62
|
+
bottom-left corner. Click it to turn on suggest mode, then click any text to
|
|
63
|
+
propose a fix. Press `Esc` (or the pill) to exit.
|
|
64
|
+
|
|
65
|
+
> The widget reads the CSRF token from `<meta name="csrf-token">`, which
|
|
66
|
+
> `csrf_meta_tags` in your layout already provides in a standard Rails app.
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
Everything is optional; the defaults work out of the box in development.
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# config/initializers/i18n_feedback.rb
|
|
74
|
+
I18nFeedback.configure do |config|
|
|
75
|
+
# Environments the tool is active in.
|
|
76
|
+
config.enabled_environments = %w[development staging]
|
|
77
|
+
|
|
78
|
+
# Extra per-request gate. Return false to hide the tool. Receives the request.
|
|
79
|
+
config.enabled = ->(request) { true }
|
|
80
|
+
|
|
81
|
+
# Attribute a suggestion to a user (optional). Return an object responding to
|
|
82
|
+
# #id, or nil. Receives the request.
|
|
83
|
+
config.current_user = ->(request) { nil }
|
|
84
|
+
|
|
85
|
+
# Label shown for the author in the "already suggested" list.
|
|
86
|
+
config.author_label = ->(user) { user.try(:email) }
|
|
87
|
+
|
|
88
|
+
# Inject the widget automatically. Set false to place it yourself.
|
|
89
|
+
config.auto_inject = true
|
|
90
|
+
|
|
91
|
+
# Show the floating "Suggest edits" pill. Set false to drive suggest mode from
|
|
92
|
+
# your own link instead (see below).
|
|
93
|
+
config.show_pill = true
|
|
94
|
+
|
|
95
|
+
# Query parameter that toggles suggest mode.
|
|
96
|
+
config.toggle_param = "i18n_feedback"
|
|
97
|
+
|
|
98
|
+
# Keep in sync with the `mount` in config/routes.rb.
|
|
99
|
+
config.mount_path = "/i18n_feedback"
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Toggling suggest mode from your own link
|
|
104
|
+
|
|
105
|
+
Prefer a menu item over the floating pill? Hide the pill and link to the toggle
|
|
106
|
+
parameter from anywhere in your UI:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
config.show_pill = false
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```erb
|
|
113
|
+
<%= link_to "Proofread translations", "?i18n_feedback=true" %>
|
|
114
|
+
```
|
|
115
|
+
|
|
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.
|
|
119
|
+
|
|
120
|
+
### Gating examples
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
# Only signed-in staff (however your app resolves that):
|
|
124
|
+
config.enabled = ->(request) { request.env["warden"]&.user&.staff? }
|
|
125
|
+
|
|
126
|
+
# Behind a feature flag:
|
|
127
|
+
config.enabled = ->(request) { Flipper.enabled?(:i18n_feedback) }
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Placing the widget yourself
|
|
131
|
+
|
|
132
|
+
Set `config.auto_inject = false` and drop the helper at the end of your layout:
|
|
133
|
+
|
|
134
|
+
```erb
|
|
135
|
+
<%= i18n_feedback_tag %>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
It renders nothing unless the tool is available for the request.
|
|
139
|
+
|
|
140
|
+
## Reviewing suggestions
|
|
141
|
+
|
|
142
|
+
Suggestions are ordinary records:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
I18nFeedback::Suggestion.order(created_at: :desc).each do |s|
|
|
146
|
+
puts "#{s.locale} #{s.translation_key}: #{s.old_value.inspect} -> #{s.proposed_value.inspect}"
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Each row stores `translation_key`, `locale`, `old_value`, `proposed_value`,
|
|
151
|
+
`comment`, `page_url`, and optional `author_id` / `author_label`.
|
|
152
|
+
|
|
153
|
+
## Security
|
|
154
|
+
|
|
155
|
+
- The tool is gated **on the server** for every marker, endpoint, and injection.
|
|
156
|
+
Setting the cookie by hand does nothing outside an enabled environment where
|
|
157
|
+
`config.enabled` returns true.
|
|
158
|
+
- Format and lookup namespaces (`number.*`, `date.*`, `*_html` formats, etc.) are
|
|
159
|
+
never marked, so currency and date formatting are unaffected.
|
|
160
|
+
|
|
161
|
+
## Development
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
bin/setup # or: bundle install
|
|
165
|
+
bundle exec rspec
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Tests run against a dummy Rails app under `spec/dummy`.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
Released under the [MIT License](MIT-LICENSE).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
|
|
5
|
+
APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
|
|
6
|
+
load 'rails/tasks/engine.rake' if File.exist?(APP_RAKEFILE)
|
|
7
|
+
|
|
8
|
+
require 'rspec/core/rake_task'
|
|
9
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
10
|
+
|
|
11
|
+
task default: :spec
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* i18n_feedback widget — self-contained, no framework, no build step.
|
|
3
|
+
*
|
|
4
|
+
* Reads window.__i18nFeedback = { endpoint, locale, active }.
|
|
5
|
+
*
|
|
6
|
+
* A floating pill toggles "suggest mode", which is server state: the pill sets or
|
|
7
|
+
* clears the i18n_feedback cookie and reloads, so the backend only prints the
|
|
8
|
+
* "…text… ⟦some.key.path⟧" markers while proofreading. The markers are never shown
|
|
9
|
+
* to the user — on load the widget strips each ⟦key⟧ token out of the DOM and
|
|
10
|
+
* stashes the key on its element. A click on any such element opens a popover to
|
|
11
|
+
* suggest a wording; navigation is frozen while the popover is open. Esc, or the
|
|
12
|
+
* pill, exits.
|
|
13
|
+
*/
|
|
14
|
+
(function () {
|
|
15
|
+
"use strict";
|
|
16
|
+
|
|
17
|
+
var config = window.__i18nFeedback;
|
|
18
|
+
if (!config || window.__i18nFeedbackLoaded) return;
|
|
19
|
+
window.__i18nFeedbackLoaded = true;
|
|
20
|
+
|
|
21
|
+
var LEFT = "⟦"; // ⟦
|
|
22
|
+
var RIGHT = "⟧"; // ⟧
|
|
23
|
+
var TOKEN = new RegExp(LEFT + "([^" + RIGHT + "]+)" + RIGHT);
|
|
24
|
+
var TOKENS = new RegExp("\\s*" + LEFT + "[^" + RIGHT + "]+" + RIGHT, "g");
|
|
25
|
+
var COOKIE = "i18n_feedback";
|
|
26
|
+
var MARKED_ATTRS = ["placeholder", "title", "aria-label", "value"];
|
|
27
|
+
var Z = 2147483000;
|
|
28
|
+
|
|
29
|
+
var overlay = null;
|
|
30
|
+
var proposedInput = null;
|
|
31
|
+
var commentInput = null;
|
|
32
|
+
var errorNode = null;
|
|
33
|
+
var saveButton = null;
|
|
34
|
+
var priorNode = null;
|
|
35
|
+
|
|
36
|
+
function ready(fn) {
|
|
37
|
+
if (document.readyState === "loading") {
|
|
38
|
+
document.addEventListener("DOMContentLoaded", fn);
|
|
39
|
+
} else {
|
|
40
|
+
fn();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
ready(function () {
|
|
45
|
+
injectStyles();
|
|
46
|
+
if (config.showPill !== false) buildPill();
|
|
47
|
+
document.addEventListener("keydown", handleKeydown);
|
|
48
|
+
|
|
49
|
+
if (config.active) {
|
|
50
|
+
document.documentElement.classList.add("i18nf-active");
|
|
51
|
+
document.addEventListener("click", handleClick, true);
|
|
52
|
+
document.addEventListener("turbo:load", strip);
|
|
53
|
+
document.addEventListener("turbo:frame-load", strip);
|
|
54
|
+
strip();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// --- suggest-mode toggle --------------------------------------------------
|
|
59
|
+
|
|
60
|
+
function toggle() {
|
|
61
|
+
if (config.active) {
|
|
62
|
+
document.cookie = COOKIE + "=; path=/; max-age=0";
|
|
63
|
+
} else {
|
|
64
|
+
document.cookie = COOKIE + "=1; path=/";
|
|
65
|
+
}
|
|
66
|
+
window.location.reload();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- marker stripping -----------------------------------------------------
|
|
70
|
+
|
|
71
|
+
function strip() {
|
|
72
|
+
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
|
|
73
|
+
acceptNode: function (node) {
|
|
74
|
+
var tag = node.parentElement && node.parentElement.tagName;
|
|
75
|
+
if (tag === "SCRIPT" || tag === "STYLE") return NodeFilter.FILTER_REJECT;
|
|
76
|
+
return TOKEN.test(node.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
var nodes = [];
|
|
81
|
+
while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
82
|
+
|
|
83
|
+
nodes.forEach(function (node) {
|
|
84
|
+
var match = node.nodeValue.match(TOKEN);
|
|
85
|
+
if (!match) return;
|
|
86
|
+
var key = match[1];
|
|
87
|
+
node.nodeValue = node.nodeValue.replace(TOKENS, "");
|
|
88
|
+
var element = node.parentElement;
|
|
89
|
+
if (element) {
|
|
90
|
+
element.dataset.i18nKey = key;
|
|
91
|
+
element.dataset.i18nValue = node.nodeValue.trim();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
MARKED_ATTRS.forEach(function (attr) {
|
|
96
|
+
var selector = "[" + attr + '*="' + LEFT + '"]';
|
|
97
|
+
document.querySelectorAll(selector).forEach(function (element) {
|
|
98
|
+
element.setAttribute(attr, element.getAttribute(attr).replace(TOKENS, ""));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- interaction ----------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
function handleClick(event) {
|
|
106
|
+
if (overlay && overlay.contains(event.target)) return;
|
|
107
|
+
if (event.target.closest && event.target.closest(".i18nf-pill")) return;
|
|
108
|
+
|
|
109
|
+
// Freeze navigation so a stray click can't leave the page mid-proofread.
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
event.stopPropagation();
|
|
112
|
+
|
|
113
|
+
var element = event.target.closest && event.target.closest("[data-i18n-key]");
|
|
114
|
+
if (element) open(element.dataset.i18nKey, element.dataset.i18nValue || "");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function handleKeydown(event) {
|
|
118
|
+
if (event.key !== "Escape") return;
|
|
119
|
+
if (overlay) close();
|
|
120
|
+
else if (config.active) toggle();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- pill -----------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
function buildPill() {
|
|
126
|
+
var pill = document.createElement("button");
|
|
127
|
+
pill.type = "button";
|
|
128
|
+
pill.className = "i18nf-pill" + (config.active ? " i18nf-pill-on" : "");
|
|
129
|
+
pill.textContent = config.active ? "✓ Suggesting — tap to exit (Esc)" : "✎ " + labelText();
|
|
130
|
+
pill.addEventListener("click", toggle);
|
|
131
|
+
document.body.appendChild(pill);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function labelText() {
|
|
135
|
+
return (config.pillLabel || "Suggest edits");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- popover --------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
function open(key, currentValue) {
|
|
141
|
+
close();
|
|
142
|
+
|
|
143
|
+
overlay = el("div", "i18nf-overlay");
|
|
144
|
+
overlay.addEventListener("click", function (event) {
|
|
145
|
+
if (event.target === overlay) close();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
var panel = el("div", "i18nf-panel");
|
|
149
|
+
panel.appendChild(heading(key));
|
|
150
|
+
|
|
151
|
+
priorNode = el("div");
|
|
152
|
+
panel.appendChild(priorNode);
|
|
153
|
+
|
|
154
|
+
panel.appendChild(field("Current text", readonly(currentValue)));
|
|
155
|
+
|
|
156
|
+
proposedInput = textarea(currentValue);
|
|
157
|
+
panel.appendChild(field("Suggested text", proposedInput));
|
|
158
|
+
|
|
159
|
+
commentInput = input("Optional note for the developer");
|
|
160
|
+
panel.appendChild(field("Comment", commentInput));
|
|
161
|
+
|
|
162
|
+
errorNode = el("p", "i18nf-error");
|
|
163
|
+
errorNode.style.display = "none";
|
|
164
|
+
panel.appendChild(errorNode);
|
|
165
|
+
|
|
166
|
+
panel.appendChild(actions(key, currentValue));
|
|
167
|
+
|
|
168
|
+
overlay.appendChild(panel);
|
|
169
|
+
document.body.appendChild(overlay);
|
|
170
|
+
proposedInput.focus();
|
|
171
|
+
loadPrior(key);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function close() {
|
|
175
|
+
if (overlay) {
|
|
176
|
+
overlay.remove();
|
|
177
|
+
overlay = null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function loadPrior(key) {
|
|
182
|
+
var params = new URLSearchParams({ key: key, locale: config.locale });
|
|
183
|
+
fetch(config.endpoint + "?" + params.toString(), { headers: { Accept: "application/json" } })
|
|
184
|
+
.then(function (response) {
|
|
185
|
+
return response.ok ? response.json() : [];
|
|
186
|
+
})
|
|
187
|
+
.then(function (items) {
|
|
188
|
+
if (items && items.length) renderPrior(items);
|
|
189
|
+
})
|
|
190
|
+
.catch(function () {
|
|
191
|
+
// Best-effort context; ignore fetch/parse errors.
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function renderPrior(items) {
|
|
196
|
+
if (!priorNode) return;
|
|
197
|
+
|
|
198
|
+
var box = el("div", "i18nf-prior");
|
|
199
|
+
var title = el("p", "i18nf-prior-title");
|
|
200
|
+
title.textContent = "Already suggested (pending)";
|
|
201
|
+
box.appendChild(title);
|
|
202
|
+
|
|
203
|
+
var list = el("ul", "i18nf-prior-list");
|
|
204
|
+
items.forEach(function (item) {
|
|
205
|
+
var row = document.createElement("li");
|
|
206
|
+
var who = item.author_label ? " — " + item.author_label : "";
|
|
207
|
+
row.textContent = "“" + item.proposed_value + "”" + who;
|
|
208
|
+
list.appendChild(row);
|
|
209
|
+
});
|
|
210
|
+
box.appendChild(list);
|
|
211
|
+
|
|
212
|
+
priorNode.replaceWith(box);
|
|
213
|
+
priorNode = box;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function actions(key, currentValue) {
|
|
217
|
+
var wrapper = el("div", "i18nf-actions");
|
|
218
|
+
|
|
219
|
+
var cancel = el("button", "i18nf-btn i18nf-btn-ghost");
|
|
220
|
+
cancel.type = "button";
|
|
221
|
+
cancel.textContent = "Cancel";
|
|
222
|
+
cancel.addEventListener("click", close);
|
|
223
|
+
|
|
224
|
+
saveButton = el("button", "i18nf-btn i18nf-btn-primary");
|
|
225
|
+
saveButton.type = "button";
|
|
226
|
+
saveButton.textContent = "Send suggestion";
|
|
227
|
+
saveButton.addEventListener("click", function () {
|
|
228
|
+
submit(key, currentValue);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
wrapper.appendChild(cancel);
|
|
232
|
+
wrapper.appendChild(saveButton);
|
|
233
|
+
return wrapper;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function submit(key, currentValue) {
|
|
237
|
+
var proposed = proposedInput.value.trim();
|
|
238
|
+
if (!proposed) {
|
|
239
|
+
showError("Please enter a suggestion.");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
saveButton.disabled = true;
|
|
244
|
+
|
|
245
|
+
fetch(config.endpoint, {
|
|
246
|
+
method: "POST",
|
|
247
|
+
headers: {
|
|
248
|
+
"Content-Type": "application/json",
|
|
249
|
+
Accept: "application/json",
|
|
250
|
+
"X-CSRF-Token": csrfToken(),
|
|
251
|
+
},
|
|
252
|
+
body: JSON.stringify({
|
|
253
|
+
suggestion: {
|
|
254
|
+
translation_key: key,
|
|
255
|
+
locale: config.locale,
|
|
256
|
+
old_value: currentValue,
|
|
257
|
+
proposed_value: proposed,
|
|
258
|
+
comment: commentInput.value.trim(),
|
|
259
|
+
page_url: window.location.href,
|
|
260
|
+
},
|
|
261
|
+
}),
|
|
262
|
+
})
|
|
263
|
+
.then(function (response) {
|
|
264
|
+
if (response.ok) {
|
|
265
|
+
close();
|
|
266
|
+
} else {
|
|
267
|
+
saveButton.disabled = false;
|
|
268
|
+
showError("Could not save the suggestion.");
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
.catch(function () {
|
|
272
|
+
saveButton.disabled = false;
|
|
273
|
+
showError("Could not save the suggestion.");
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function showError(message) {
|
|
278
|
+
errorNode.textContent = message;
|
|
279
|
+
errorNode.style.display = "";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// --- small DOM builders ---------------------------------------------------
|
|
283
|
+
|
|
284
|
+
function heading(key) {
|
|
285
|
+
var wrapper = el("div", "i18nf-heading");
|
|
286
|
+
var title = el("p", "i18nf-title");
|
|
287
|
+
title.textContent = "Suggest a translation fix";
|
|
288
|
+
var code = el("code", "i18nf-key");
|
|
289
|
+
code.textContent = key;
|
|
290
|
+
wrapper.appendChild(title);
|
|
291
|
+
wrapper.appendChild(code);
|
|
292
|
+
return wrapper;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function field(label, control) {
|
|
296
|
+
var wrapper = el("label", "i18nf-field");
|
|
297
|
+
var text = el("span", "i18nf-label");
|
|
298
|
+
text.textContent = label;
|
|
299
|
+
wrapper.appendChild(text);
|
|
300
|
+
wrapper.appendChild(control);
|
|
301
|
+
return wrapper;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function readonly(value) {
|
|
305
|
+
var node = el("p", "i18nf-readonly");
|
|
306
|
+
node.textContent = value;
|
|
307
|
+
return node;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function textarea(value) {
|
|
311
|
+
var node = el("textarea", "i18nf-input");
|
|
312
|
+
node.rows = 3;
|
|
313
|
+
node.value = value;
|
|
314
|
+
return node;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function input(placeholder) {
|
|
318
|
+
var node = el("input", "i18nf-input");
|
|
319
|
+
node.type = "text";
|
|
320
|
+
node.placeholder = placeholder;
|
|
321
|
+
return node;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function el(tag, className) {
|
|
325
|
+
var node = document.createElement(tag);
|
|
326
|
+
if (className) node.className = className;
|
|
327
|
+
return node;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function csrfToken() {
|
|
331
|
+
var meta = document.querySelector('meta[name="csrf-token"]');
|
|
332
|
+
return meta ? meta.content : "";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// --- styles ---------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
function injectStyles() {
|
|
338
|
+
if (document.getElementById("i18nf-styles")) return;
|
|
339
|
+
var style = document.createElement("style");
|
|
340
|
+
style.id = "i18nf-styles";
|
|
341
|
+
style.textContent = [
|
|
342
|
+
// Only the strings that actually resolve to a key are editable, so only
|
|
343
|
+
// those are highlighted. `outline` (not `border`) avoids any layout shift.
|
|
344
|
+
".i18nf-active [data-i18n-key] { cursor: copy; outline: 1px dashed rgba(37, 99, 235, 0.5); outline-offset: 2px; }",
|
|
345
|
+
".i18nf-active [data-i18n-key]:hover { outline: 2px dashed #2563eb; outline-offset: 2px; background: rgba(37, 99, 235, 0.08); }",
|
|
346
|
+
".i18nf-pill { position: fixed; bottom: 16px; left: 16px; z-index: " + (Z + 1) + ";",
|
|
347
|
+
" font: 13px/1.2 system-ui, sans-serif; padding: 9px 14px; border-radius: 999px;",
|
|
348
|
+
" border: 1px solid rgba(0,0,0,.15); background: #fff; color: #111; cursor: pointer;",
|
|
349
|
+
" box-shadow: 0 4px 14px rgba(0,0,0,.18); }",
|
|
350
|
+
".i18nf-pill-on { background: #2563eb; color: #fff; border-color: #2563eb; }",
|
|
351
|
+
".i18nf-overlay { position: fixed; inset: 0; z-index: " + (Z + 2) + "; display: flex;",
|
|
352
|
+
" align-items: center; justify-content: center; padding: 16px;",
|
|
353
|
+
" background: rgba(0,0,0,.45); cursor: default; }",
|
|
354
|
+
".i18nf-panel { width: 28rem; max-width: 100%; max-height: 90vh; overflow: auto;",
|
|
355
|
+
" background: #fff; color: #111; border-radius: 12px; padding: 20px;",
|
|
356
|
+
" box-shadow: 0 20px 60px rgba(0,0,0,.35); font: 14px/1.5 system-ui, sans-serif; }",
|
|
357
|
+
".i18nf-panel > * + * { margin-top: 14px; }",
|
|
358
|
+
".i18nf-title { font-weight: 700; font-size: 15px; margin: 0; }",
|
|
359
|
+
".i18nf-key { display: block; margin-top: 2px; font-size: 12px; color: #666;",
|
|
360
|
+
" word-break: break-all; font-family: ui-monospace, monospace; }",
|
|
361
|
+
".i18nf-field { display: block; }",
|
|
362
|
+
".i18nf-label { display: block; margin-bottom: 4px; font-size: 12px; color: #666; }",
|
|
363
|
+
".i18nf-readonly { margin: 0; padding: 8px 10px; background: #f4f4f5; border-radius: 8px; }",
|
|
364
|
+
".i18nf-input { display: block; width: 100%; box-sizing: border-box; padding: 8px 10px;",
|
|
365
|
+
" border: 1px solid #d4d4d8; border-radius: 8px; font: inherit; }",
|
|
366
|
+
".i18nf-prior { padding: 10px 12px; background: #f4f4f5; border-radius: 8px; font-size: 13px; }",
|
|
367
|
+
".i18nf-prior-title { margin: 0 0 4px; font-weight: 600; color: #555; }",
|
|
368
|
+
".i18nf-prior-list { margin: 0; padding-left: 18px; }",
|
|
369
|
+
".i18nf-error { margin: 0; color: #dc2626; font-size: 13px; }",
|
|
370
|
+
".i18nf-actions { display: flex; justify-content: flex-end; gap: 8px; }",
|
|
371
|
+
".i18nf-btn { padding: 8px 14px; border-radius: 8px; border: 1px solid transparent;",
|
|
372
|
+
" font: inherit; cursor: pointer; }",
|
|
373
|
+
".i18nf-btn-ghost { background: transparent; color: #111; }",
|
|
374
|
+
".i18nf-btn-primary { background: #2563eb; color: #fff; }",
|
|
375
|
+
".i18nf-btn[disabled] { opacity: .6; cursor: default; }",
|
|
376
|
+
].join("\n");
|
|
377
|
+
document.head.appendChild(style);
|
|
378
|
+
}
|
|
379
|
+
})();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nFeedback
|
|
4
|
+
class ApplicationController < ActionController::Base
|
|
5
|
+
protect_from_forgery with: :exception
|
|
6
|
+
|
|
7
|
+
before_action :require_available
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Server-side gate. The client can set the cookie, but it can never reach the
|
|
12
|
+
# endpoints unless the app itself says the tool is available for this request.
|
|
13
|
+
def require_available
|
|
14
|
+
head :forbidden unless I18nFeedback.available?(request)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def current_author
|
|
18
|
+
return @current_author if defined?(@current_author)
|
|
19
|
+
|
|
20
|
+
@current_author = I18nFeedback.config.current_user.call(request)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nFeedback
|
|
4
|
+
class SuggestionsController < ApplicationController
|
|
5
|
+
# Pending suggestions for one key/locale, shown as read-only context when the
|
|
6
|
+
# proofreader reopens the popover for a string someone already flagged.
|
|
7
|
+
def index
|
|
8
|
+
suggestions = Suggestion
|
|
9
|
+
.where(translation_key: params[:key], locale: params[:locale])
|
|
10
|
+
.order(id: :desc)
|
|
11
|
+
.limit(20)
|
|
12
|
+
|
|
13
|
+
render json: suggestions.map { |suggestion| suggestion_json(suggestion) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create
|
|
17
|
+
suggestion = Suggestion.new(suggestion_params)
|
|
18
|
+
attribute_author(suggestion)
|
|
19
|
+
|
|
20
|
+
if suggestion.save
|
|
21
|
+
head :created
|
|
22
|
+
else
|
|
23
|
+
render json: { errors: suggestion.errors.full_messages }, status: :unprocessable_entity
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def attribute_author(suggestion)
|
|
30
|
+
author = current_author
|
|
31
|
+
return unless author
|
|
32
|
+
|
|
33
|
+
suggestion.author_id = author.id.to_s if author.respond_to?(:id)
|
|
34
|
+
suggestion.author_label = I18nFeedback.config.author_label.call(author)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def suggestion_json(suggestion)
|
|
38
|
+
{
|
|
39
|
+
proposed_value: suggestion.proposed_value,
|
|
40
|
+
author_label: suggestion.author_label,
|
|
41
|
+
created_at: suggestion.created_at.iso8601
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def suggestion_params
|
|
46
|
+
params
|
|
47
|
+
.require(:suggestion)
|
|
48
|
+
.permit(:translation_key, :locale, :old_value, :proposed_value, :comment, :page_url)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nFeedback
|
|
4
|
+
# Lets a host place the widget explicitly (`<%= i18n_feedback_tag %>` at the end
|
|
5
|
+
# of a layout) instead of relying on auto-injection. Renders nothing unless the
|
|
6
|
+
# tool is available for the current request.
|
|
7
|
+
module TagHelper
|
|
8
|
+
def i18n_feedback_tag
|
|
9
|
+
return ''.html_safe unless I18nFeedback.available?(request)
|
|
10
|
+
|
|
11
|
+
Widget.snippet(
|
|
12
|
+
endpoint: I18nFeedback.config.suggestions_endpoint,
|
|
13
|
+
locale: I18n.locale,
|
|
14
|
+
active: I18nFeedback::Marking.enabled?
|
|
15
|
+
).html_safe
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nFeedback
|
|
4
|
+
# A proposed wording for one translation key in one locale. Author attribution
|
|
5
|
+
# is optional and stored as loose fields (no foreign key to the host's user
|
|
6
|
+
# table) so the model is portable across apps with different user models.
|
|
7
|
+
class Suggestion < ApplicationRecord
|
|
8
|
+
validates :translation_key, presence: true
|
|
9
|
+
validates :proposed_value, presence: true
|
|
10
|
+
validates :locale,
|
|
11
|
+
presence: true,
|
|
12
|
+
inclusion: { in: ->(_) { I18nFeedback.config.available_locales.call.map(&:to_s) } }
|
|
13
|
+
end
|
|
14
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/active_record'
|
|
5
|
+
|
|
6
|
+
module I18nFeedback
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path('templates', __dir__)
|
|
12
|
+
|
|
13
|
+
desc 'Installs i18n_feedback: config initializer, migration, and engine mount.'
|
|
14
|
+
|
|
15
|
+
def create_initializer
|
|
16
|
+
copy_file 'initializer.rb', 'config/initializers/i18n_feedback.rb'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create_suggestions_migration
|
|
20
|
+
migration_template 'create_i18n_feedback_suggestions.rb.tt',
|
|
21
|
+
'db/migrate/create_i18n_feedback_suggestions.rb'
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def mount_engine
|
|
25
|
+
route %(mount I18nFeedback::Engine => "/i18n_feedback")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def post_install
|
|
29
|
+
say "\ni18n_feedback installed. Run `rails db:migrate`, then boot in development", :green
|
|
30
|
+
say "and look for the “Suggest edits” pill in the bottom-left corner.\n"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def migration_version
|
|
36
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateI18nFeedbackSuggestions < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :i18n_feedback_suggestions do |t|
|
|
6
|
+
t.string :translation_key, null: false
|
|
7
|
+
t.string :locale, null: false
|
|
8
|
+
t.text :old_value
|
|
9
|
+
t.text :proposed_value, null: false
|
|
10
|
+
t.text :comment
|
|
11
|
+
t.string :page_url
|
|
12
|
+
t.string :author_id
|
|
13
|
+
t.string :author_label
|
|
14
|
+
|
|
15
|
+
t.timestamps
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
add_index :i18n_feedback_suggestions, %i[translation_key locale]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
I18nFeedback.configure do |config|
|
|
4
|
+
# Environments the tool is active in. It is never active anywhere else.
|
|
5
|
+
config.enabled_environments = %w[development staging]
|
|
6
|
+
|
|
7
|
+
# Per-request gate on top of the environment check. Return false to hide the
|
|
8
|
+
# tool. Receives the Rack::Request — plug in an admin check, a feature flag, an
|
|
9
|
+
# IP allowlist, etc.
|
|
10
|
+
#
|
|
11
|
+
# config.enabled = ->(request) { true }
|
|
12
|
+
|
|
13
|
+
# Attribute a suggestion to a user (optional). Return an object responding to
|
|
14
|
+
# #id (ideally #email too), or nil. Receives the Rack::Request. You resolve the
|
|
15
|
+
# user however your app does — session, Warden, a token, etc.
|
|
16
|
+
#
|
|
17
|
+
# config.current_user = ->(request) { nil }
|
|
18
|
+
|
|
19
|
+
# How to label the author in the "already suggested" list.
|
|
20
|
+
#
|
|
21
|
+
# config.author_label = ->(user) { user.try(:email) }
|
|
22
|
+
|
|
23
|
+
# The widget is injected into HTML responses automatically. Set this to false to
|
|
24
|
+
# place it yourself with `<%= i18n_feedback_tag %>` at the end of your layout.
|
|
25
|
+
#
|
|
26
|
+
# config.auto_inject = true
|
|
27
|
+
|
|
28
|
+
# Show the floating "Suggest edits" pill. Set false to hide it and toggle
|
|
29
|
+
# suggest mode from your own link instead, e.g.
|
|
30
|
+
# `<%= link_to "Proofread", "?i18n_feedback=true" %>`. "?i18n_feedback=false"
|
|
31
|
+
# exits; the choice is remembered in a cookie.
|
|
32
|
+
#
|
|
33
|
+
# config.show_pill = true
|
|
34
|
+
# config.toggle_param = "i18n_feedback"
|
|
35
|
+
|
|
36
|
+
# Keep this in sync with the `mount` line in config/routes.rb.
|
|
37
|
+
#
|
|
38
|
+
# config.mount_path = "/i18n_feedback"
|
|
39
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nFeedback
|
|
4
|
+
# Host-tunable settings. Everything has a safe default, so a fresh install works
|
|
5
|
+
# with zero configuration in development; the hooks below let an app decide who
|
|
6
|
+
# sees the tool and how suggestions are attributed.
|
|
7
|
+
class Configuration
|
|
8
|
+
# Environments the tool is active in. It is never active anywhere else, so a
|
|
9
|
+
# production deploy can't accidentally expose the key markers or the endpoint.
|
|
10
|
+
attr_accessor :enabled_environments
|
|
11
|
+
|
|
12
|
+
# Per-request gate, on top of the environment check. Return false to hide the
|
|
13
|
+
# tool for this request. Receives the Rack::Request. Plug in an admin check, a
|
|
14
|
+
# feature flag, an allowlist, etc.
|
|
15
|
+
attr_accessor :enabled
|
|
16
|
+
|
|
17
|
+
# Resolve the current user for attribution (optional). Return an object
|
|
18
|
+
# responding to #id, or nil. Receives the Rack::Request.
|
|
19
|
+
attr_accessor :current_user
|
|
20
|
+
|
|
21
|
+
# Turn a resolved user into a short label shown in the "already suggested"
|
|
22
|
+
# list. Receives whatever #current_user returned.
|
|
23
|
+
attr_accessor :author_label
|
|
24
|
+
|
|
25
|
+
# The locales a suggestion may target. Used to validate submissions.
|
|
26
|
+
attr_accessor :available_locales
|
|
27
|
+
|
|
28
|
+
# Inject the widget into HTML responses automatically. Set false to place it
|
|
29
|
+
# yourself with `<%= i18n_feedback_tag %>` in your layout.
|
|
30
|
+
attr_accessor :auto_inject
|
|
31
|
+
|
|
32
|
+
# Where the engine is mounted. The widget posts suggestions to
|
|
33
|
+
# "#{mount_path}/suggestions", so keep this in sync with the `mount` line in
|
|
34
|
+
# your routes.
|
|
35
|
+
attr_accessor :mount_path
|
|
36
|
+
|
|
37
|
+
# Show the floating toggle pill. Set false to hide it and drive suggest mode
|
|
38
|
+
# from your own link instead (see #toggle_param).
|
|
39
|
+
attr_accessor :show_pill
|
|
40
|
+
|
|
41
|
+
# Text on the floating toggle pill.
|
|
42
|
+
attr_accessor :pill_label
|
|
43
|
+
|
|
44
|
+
# The query-string parameter that turns suggest mode on/off, so a host can
|
|
45
|
+
# link to it from anywhere: "?i18n_feedback=true" enables it, "false" exits.
|
|
46
|
+
# The choice is remembered in a cookie, so the rest of the app stays in
|
|
47
|
+
# suggest mode without the parameter.
|
|
48
|
+
attr_accessor :toggle_param
|
|
49
|
+
|
|
50
|
+
def initialize
|
|
51
|
+
@enabled_environments = %w[development staging]
|
|
52
|
+
@enabled = ->(_request) { true }
|
|
53
|
+
@current_user = ->(_request) {}
|
|
54
|
+
@author_label = ->(user) { user.respond_to?(:email) ? user.email : user&.to_s }
|
|
55
|
+
@available_locales = -> { I18n.available_locales.map(&:to_s) }
|
|
56
|
+
@auto_inject = true
|
|
57
|
+
@mount_path = '/i18n_feedback'
|
|
58
|
+
@show_pill = true
|
|
59
|
+
@pill_label = 'Suggest edits'
|
|
60
|
+
@toggle_param = 'i18n_feedback'
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def environment_enabled?
|
|
64
|
+
enabled_environments.map(&:to_s).include?(Rails.env.to_s)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def suggestions_endpoint
|
|
68
|
+
"#{mount_path.chomp('/')}/suggestions"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nFeedback
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace I18nFeedback
|
|
6
|
+
|
|
7
|
+
initializer 'i18n_feedback.middleware' do |app|
|
|
8
|
+
app.middleware.use I18nFeedback::Middleware
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Prepend the key-marking backend once the app (and its I18n backend) is up,
|
|
12
|
+
# and only in an enabled environment, so production never carries the patch.
|
|
13
|
+
config.after_initialize do
|
|
14
|
+
if I18nFeedback.config.environment_enabled? &&
|
|
15
|
+
!I18n.backend.class.include?(I18nFeedback::Marking::Backend)
|
|
16
|
+
I18n.backend.class.prepend(I18nFeedback::Marking::Backend)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
initializer 'i18n_feedback.helper' do
|
|
21
|
+
ActiveSupport.on_load(:action_view) do
|
|
22
|
+
include I18nFeedback::TagHelper
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nFeedback
|
|
4
|
+
# Appends a "⟦namespace.key⟧" marker to each translated string so the widget can
|
|
5
|
+
# map rendered text back to its i18n key. The marker is never shown to the user:
|
|
6
|
+
# the widget strips every token out of the DOM on load and stashes the key on the
|
|
7
|
+
# element. Marking is a per-request, per-thread toggle (see Middleware), so pages
|
|
8
|
+
# stay clean unless a proofreader has switched the tool on.
|
|
9
|
+
module Marking
|
|
10
|
+
LEFT = '⟦'
|
|
11
|
+
RIGHT = '⟧'
|
|
12
|
+
|
|
13
|
+
# Skip format/lookup namespaces — marking these would corrupt number, date,
|
|
14
|
+
# and currency formatting and interpolation strings.
|
|
15
|
+
SKIP = /(\A|\.)(number|date|time|datetime|support)(\.|\z)|formats?\z/
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def enabled?
|
|
19
|
+
Thread.current[:i18n_feedback_marking] || false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def enabled=(value)
|
|
23
|
+
Thread.current[:i18n_feedback_marking] = value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Prepended onto the active I18n backend class.
|
|
28
|
+
module Backend
|
|
29
|
+
def translate(locale, key, options = {})
|
|
30
|
+
result = super
|
|
31
|
+
return result unless Marking.enabled? && result.is_a?(String)
|
|
32
|
+
|
|
33
|
+
full = [*options[:scope], key].join('.')
|
|
34
|
+
return result if full.match?(SKIP)
|
|
35
|
+
|
|
36
|
+
# `super` returns a plain String even for _html keys (ActionView marks the
|
|
37
|
+
# helper's output safe after the backend returns), so the marker lands
|
|
38
|
+
# inside that buffer without changing any html_safe status.
|
|
39
|
+
"#{result} #{LEFT}#{full}#{RIGHT}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack'
|
|
4
|
+
|
|
5
|
+
module I18nFeedback
|
|
6
|
+
# Runs on every request to (1) flip key-marking on for the duration of the
|
|
7
|
+
# request when a proofreader has the tool switched on, and (2) inject the widget
|
|
8
|
+
# into HTML responses so the host needs no layout changes. Both are strictly
|
|
9
|
+
# gated by I18nFeedback.available?, so nothing happens in a disabled environment.
|
|
10
|
+
class Middleware
|
|
11
|
+
COOKIE = 'i18n_feedback'
|
|
12
|
+
|
|
13
|
+
def initialize(app)
|
|
14
|
+
@app = app
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(env)
|
|
18
|
+
request = Rack::Request.new(env)
|
|
19
|
+
available = I18nFeedback.available?(request)
|
|
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)
|
|
26
|
+
Marking.enabled = marking
|
|
27
|
+
|
|
28
|
+
status, headers, body = @app.call(env)
|
|
29
|
+
|
|
30
|
+
headers = persist_choice(headers, override) if available && !override.nil?
|
|
31
|
+
|
|
32
|
+
if available && I18nFeedback.config.auto_inject && html?(headers) && !request.xhr?
|
|
33
|
+
status, headers, body = inject(status, headers, body, marking)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
[status, headers, body]
|
|
37
|
+
ensure
|
|
38
|
+
Marking.enabled = false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def cookie_on?(request)
|
|
44
|
+
!request.cookies[COOKIE].to_s.empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def toggle_override(request)
|
|
48
|
+
param = I18nFeedback.config.toggle_param
|
|
49
|
+
return nil unless request.params.key?(param)
|
|
50
|
+
|
|
51
|
+
%w[1 true on yes].include?(request.params[param].to_s.strip.downcase)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def persist_choice(headers, desired)
|
|
55
|
+
headers = headers.dup
|
|
56
|
+
cookie = if desired
|
|
57
|
+
"#{COOKIE}=1; path=/; SameSite=Lax"
|
|
58
|
+
else
|
|
59
|
+
"#{COOKIE}=; path=/; max-age=0; SameSite=Lax"
|
|
60
|
+
end
|
|
61
|
+
key = header_key(headers, 'set-cookie') || 'set-cookie'
|
|
62
|
+
existing = headers[key]
|
|
63
|
+
headers[key] = case existing
|
|
64
|
+
when nil then cookie
|
|
65
|
+
when Array then existing + [cookie]
|
|
66
|
+
else "#{existing}\n#{cookie}"
|
|
67
|
+
end
|
|
68
|
+
headers
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def header_key(headers, name)
|
|
72
|
+
headers.key?(name) ? name : headers.keys.find { |k| k.to_s.casecmp?(name) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def html?(headers)
|
|
76
|
+
content_type(headers).to_s.include?('text/html')
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def content_type(headers)
|
|
80
|
+
headers['Content-Type'] || headers['content-type']
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def inject(status, headers, body, marking)
|
|
84
|
+
html = +''
|
|
85
|
+
body.each { |part| html << part.to_s }
|
|
86
|
+
body.close if body.respond_to?(:close)
|
|
87
|
+
|
|
88
|
+
snippet = Widget.snippet(
|
|
89
|
+
endpoint: I18nFeedback.config.suggestions_endpoint,
|
|
90
|
+
locale: I18n.locale,
|
|
91
|
+
active: marking
|
|
92
|
+
)
|
|
93
|
+
html = html.include?('</body>') ? html.sub('</body>', "#{snippet}</body>") : html + snippet
|
|
94
|
+
|
|
95
|
+
headers = headers.dup
|
|
96
|
+
headers['Content-Length'] = html.bytesize.to_s if headers.key?('Content-Length') || headers.key?('content-length')
|
|
97
|
+
|
|
98
|
+
[status, headers, [html]]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module I18nFeedback
|
|
6
|
+
# Serves the self-contained browser widget. The JavaScript is plain ES (no
|
|
7
|
+
# 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.
|
|
11
|
+
module Widget
|
|
12
|
+
SOURCE = File.expand_path('../../app/assets/i18n_feedback/widget.js', __dir__)
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def javascript
|
|
16
|
+
@javascript ||= File.read(SOURCE)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The two <script> tags to place before </body>: one carrying the runtime
|
|
20
|
+
# config, one carrying the widget itself.
|
|
21
|
+
def snippet(endpoint:, locale:, active:)
|
|
22
|
+
config = {
|
|
23
|
+
endpoint: endpoint,
|
|
24
|
+
locale: locale.to_s,
|
|
25
|
+
active: active ? true : false,
|
|
26
|
+
showPill: I18nFeedback.config.show_pill ? true : false,
|
|
27
|
+
pillLabel: I18nFeedback.config.pill_label
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
%(<script data-i18n-feedback>window.__i18nFeedback=#{config.to_json};</script>) +
|
|
31
|
+
%(<script data-i18n-feedback-widget>#{javascript}</script>)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'i18n_feedback/version'
|
|
4
|
+
require 'i18n_feedback/configuration'
|
|
5
|
+
require 'i18n_feedback/marking'
|
|
6
|
+
require 'i18n_feedback/widget'
|
|
7
|
+
require 'i18n_feedback/middleware'
|
|
8
|
+
require 'i18n_feedback/engine'
|
|
9
|
+
|
|
10
|
+
# In-context translation proofreading for Rails. Renders each i18n key alongside
|
|
11
|
+
# its text in the chosen environments, lets a proofreader click any string and
|
|
12
|
+
# suggest a better wording, and stores the suggestions for a developer to apply.
|
|
13
|
+
module I18nFeedback
|
|
14
|
+
class << self
|
|
15
|
+
def config
|
|
16
|
+
@config ||= Configuration.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def configure
|
|
20
|
+
yield config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Is the tool available for this request? True only in an enabled environment
|
|
24
|
+
# and when the host's `enabled` predicate passes. Checked on the server for
|
|
25
|
+
# every marker, endpoint, and injection, so the client can never turn it on.
|
|
26
|
+
def available?(request)
|
|
27
|
+
config.environment_enabled? && !!config.enabled.call(request)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: i18n_feedback
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yaroslav Shmarov
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
description: |
|
|
27
|
+
A mountable Rails engine that renders each translation alongside its i18n key
|
|
28
|
+
in development and staging, lets reviewers click any string in the running app
|
|
29
|
+
and propose a better wording, and stores those suggestions for a developer to
|
|
30
|
+
apply. Framework-agnostic: no CSS or JS framework required.
|
|
31
|
+
email:
|
|
32
|
+
- yaroslav.shmarov@clickfunnels.com
|
|
33
|
+
executables: []
|
|
34
|
+
extensions: []
|
|
35
|
+
extra_rdoc_files: []
|
|
36
|
+
files:
|
|
37
|
+
- CHANGELOG.md
|
|
38
|
+
- MIT-LICENSE
|
|
39
|
+
- README.md
|
|
40
|
+
- Rakefile
|
|
41
|
+
- app/assets/i18n_feedback/widget.js
|
|
42
|
+
- app/controllers/i18n_feedback/application_controller.rb
|
|
43
|
+
- app/controllers/i18n_feedback/suggestions_controller.rb
|
|
44
|
+
- app/helpers/i18n_feedback/tag_helper.rb
|
|
45
|
+
- app/models/i18n_feedback/application_record.rb
|
|
46
|
+
- app/models/i18n_feedback/suggestion.rb
|
|
47
|
+
- config/routes.rb
|
|
48
|
+
- lib/generators/i18n_feedback/install/install_generator.rb
|
|
49
|
+
- lib/generators/i18n_feedback/install/templates/create_i18n_feedback_suggestions.rb.tt
|
|
50
|
+
- lib/generators/i18n_feedback/install/templates/initializer.rb
|
|
51
|
+
- lib/i18n_feedback.rb
|
|
52
|
+
- lib/i18n_feedback/configuration.rb
|
|
53
|
+
- lib/i18n_feedback/engine.rb
|
|
54
|
+
- lib/i18n_feedback/marking.rb
|
|
55
|
+
- lib/i18n_feedback/middleware.rb
|
|
56
|
+
- lib/i18n_feedback/version.rb
|
|
57
|
+
- lib/i18n_feedback/widget.rb
|
|
58
|
+
homepage: https://github.com/yshmarov/i18n-feedback
|
|
59
|
+
licenses:
|
|
60
|
+
- MIT
|
|
61
|
+
metadata:
|
|
62
|
+
source_code_uri: https://github.com/yshmarov/i18n-feedback
|
|
63
|
+
changelog_uri: https://github.com/yshmarov/i18n-feedback/blob/main/CHANGELOG.md
|
|
64
|
+
rubygems_mfa_required: 'true'
|
|
65
|
+
rdoc_options: []
|
|
66
|
+
require_paths:
|
|
67
|
+
- lib
|
|
68
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '3.2'
|
|
73
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
74
|
+
requirements:
|
|
75
|
+
- - ">="
|
|
76
|
+
- !ruby/object:Gem::Version
|
|
77
|
+
version: '0'
|
|
78
|
+
requirements: []
|
|
79
|
+
rubygems_version: 4.0.10
|
|
80
|
+
specification_version: 4
|
|
81
|
+
summary: 'In-context i18n proofreading for Rails: click any translated string and
|
|
82
|
+
suggest a fix.'
|
|
83
|
+
test_files: []
|