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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +214 -0
- data/MIT-LICENSE +21 -0
- data/README.md +325 -0
- data/Rakefile +16 -0
- data/app/controllers/i18n_proofreading/application_controller.rb +32 -0
- data/app/controllers/i18n_proofreading/suggestions_controller.rb +92 -0
- data/app/helpers/i18n_proofreading/tag_helper.rb +19 -0
- data/app/models/i18n_proofreading/application_record.rb +7 -0
- data/app/models/i18n_proofreading/suggestion.rb +27 -0
- data/app/views/i18n_proofreading/suggestions/index.html.erb +77 -0
- data/app/views/layouts/i18n_proofreading/application.html.erb +96 -0
- data/config/locales/i18n_proofreading.ar.yml +16 -0
- data/config/locales/i18n_proofreading.bg.yml +16 -0
- data/config/locales/i18n_proofreading.bn.yml +16 -0
- data/config/locales/i18n_proofreading.de.yml +16 -0
- data/config/locales/i18n_proofreading.el.yml +16 -0
- data/config/locales/i18n_proofreading.en.yml +35 -0
- data/config/locales/i18n_proofreading.es.yml +16 -0
- data/config/locales/i18n_proofreading.fr.yml +16 -0
- data/config/locales/i18n_proofreading.hi.yml +16 -0
- data/config/locales/i18n_proofreading.hr.yml +16 -0
- data/config/locales/i18n_proofreading.id.yml +16 -0
- data/config/locales/i18n_proofreading.it.yml +16 -0
- data/config/locales/i18n_proofreading.ja.yml +16 -0
- data/config/locales/i18n_proofreading.ko.yml +16 -0
- data/config/locales/i18n_proofreading.lb.yml +16 -0
- data/config/locales/i18n_proofreading.nl.yml +16 -0
- data/config/locales/i18n_proofreading.pl.yml +16 -0
- data/config/locales/i18n_proofreading.pt.yml +16 -0
- data/config/locales/i18n_proofreading.ro.yml +16 -0
- data/config/locales/i18n_proofreading.ru.yml +16 -0
- data/config/locales/i18n_proofreading.th.yml +16 -0
- data/config/locales/i18n_proofreading.tr.yml +16 -0
- data/config/locales/i18n_proofreading.uk.yml +16 -0
- data/config/locales/i18n_proofreading.ur.yml +16 -0
- data/config/locales/i18n_proofreading.vi.yml +16 -0
- data/config/locales/i18n_proofreading.zh-CN.yml +16 -0
- data/config/routes.rb +13 -0
- data/lib/generators/i18n_proofreading/install/install_generator.rb +40 -0
- data/lib/generators/i18n_proofreading/install/templates/create_i18n_proofreading_suggestions.rb.tt +22 -0
- data/lib/generators/i18n_proofreading/install/templates/initializer.rb +56 -0
- data/lib/i18n_proofreading/configuration.rb +91 -0
- data/lib/i18n_proofreading/engine.rb +26 -0
- data/lib/i18n_proofreading/marking.rb +46 -0
- data/lib/i18n_proofreading/middleware.rb +145 -0
- data/lib/i18n_proofreading/version.rb +5 -0
- data/lib/i18n_proofreading/widget.js +443 -0
- data/lib/i18n_proofreading/widget.rb +96 -0
- data/lib/i18n_proofreading.rb +37 -0
- metadata +111 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* i18n_proofreading widget — self-contained, no framework, no build step.
|
|
3
|
+
*
|
|
4
|
+
* Reads its config from the <script type="application/json"
|
|
5
|
+
* data-i18n-proofreading-config> the server injects — re-read on every render so a
|
|
6
|
+
* Turbo visit always reflects the current page's suggest state.
|
|
7
|
+
*
|
|
8
|
+
* A floating pill toggles "suggest mode", which is server state: the pill sets or
|
|
9
|
+
* clears the i18n_proofreading cookie and reloads, so the backend only prints the
|
|
10
|
+
* "…text… ⟦some.key.path⟧" markers while proofreading. The markers are never shown
|
|
11
|
+
* to the user — on load the widget strips each ⟦key⟧ token out of the DOM and
|
|
12
|
+
* stashes the key on its element. A click on any such element opens a popover to
|
|
13
|
+
* suggest a wording; navigation is frozen while the popover is open. Esc, or the
|
|
14
|
+
* pill, exits.
|
|
15
|
+
*/
|
|
16
|
+
(function () {
|
|
17
|
+
"use strict";
|
|
18
|
+
|
|
19
|
+
var config = readConfig();
|
|
20
|
+
if (!config || window.__i18nProofreadingLoaded) return;
|
|
21
|
+
window.__i18nProofreadingLoaded = true;
|
|
22
|
+
|
|
23
|
+
var LEFT = "⟦"; // ⟦
|
|
24
|
+
var RIGHT = "⟧"; // ⟧
|
|
25
|
+
var TOKEN = new RegExp(LEFT + "([^" + RIGHT + "]+)" + RIGHT);
|
|
26
|
+
var TOKENS = new RegExp("\\s*" + LEFT + "[^" + RIGHT + "]+" + RIGHT, "g");
|
|
27
|
+
var COOKIE = "i18n_proofreading";
|
|
28
|
+
var MARKED_ATTRS = ["placeholder", "title", "aria-label", "value"];
|
|
29
|
+
var Z = 2147483000;
|
|
30
|
+
|
|
31
|
+
var overlay = null;
|
|
32
|
+
var proposedInput = null;
|
|
33
|
+
var commentInput = null;
|
|
34
|
+
var errorNode = null;
|
|
35
|
+
var saveButton = null;
|
|
36
|
+
var priorNode = null;
|
|
37
|
+
|
|
38
|
+
function ready(fn) {
|
|
39
|
+
if (document.readyState === "loading") {
|
|
40
|
+
document.addEventListener("DOMContentLoaded", fn);
|
|
41
|
+
} else {
|
|
42
|
+
fn();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
ready(function () {
|
|
47
|
+
// Document-level listeners survive Turbo navigations, so register them once.
|
|
48
|
+
// handleClick is always attached and gates on the *current* config.active, so
|
|
49
|
+
// toggling suggest mode across a Turbo visit needs no re-registration.
|
|
50
|
+
document.addEventListener("keydown", handleKeydown);
|
|
51
|
+
document.addEventListener("click", handleClick, true);
|
|
52
|
+
|
|
53
|
+
// Everything else lives in <body>, which Turbo replaces on every visit —
|
|
54
|
+
// taking the pill and the active-mode highlighting with it. Re-run the
|
|
55
|
+
// per-page setup on each visit so the widget keeps working without a hard
|
|
56
|
+
// reload. render() also runs now for the initial (or non-Turbo) load.
|
|
57
|
+
render();
|
|
58
|
+
document.addEventListener("turbo:load", render);
|
|
59
|
+
document.addEventListener("turbo:frame-load", strip);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function readConfig() {
|
|
63
|
+
var el = document.querySelector("script[data-i18n-proofreading-config]");
|
|
64
|
+
if (!el) return null;
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(el.textContent);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function render() {
|
|
73
|
+
// Re-read on each visit: the injected config is data (not an executed
|
|
74
|
+
// script), so it reflects the page Turbo just rendered.
|
|
75
|
+
config = readConfig() || config;
|
|
76
|
+
injectStyles();
|
|
77
|
+
if (config.showPill !== false) buildPill();
|
|
78
|
+
document.documentElement.classList.toggle("i18np-active", !!config.active);
|
|
79
|
+
if (config.active) strip();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- suggest-mode toggle --------------------------------------------------
|
|
83
|
+
|
|
84
|
+
function toggle() {
|
|
85
|
+
if (config.active) {
|
|
86
|
+
document.cookie = COOKIE + "=; path=/; max-age=0";
|
|
87
|
+
} else {
|
|
88
|
+
document.cookie = COOKIE + "=1; path=/";
|
|
89
|
+
}
|
|
90
|
+
window.location.reload();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- marker stripping -----------------------------------------------------
|
|
94
|
+
|
|
95
|
+
function strip() {
|
|
96
|
+
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
|
|
97
|
+
acceptNode: function (node) {
|
|
98
|
+
var tag = node.parentElement && node.parentElement.tagName;
|
|
99
|
+
if (tag === "SCRIPT" || tag === "STYLE") return NodeFilter.FILTER_REJECT;
|
|
100
|
+
return TOKEN.test(node.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
var nodes = [];
|
|
105
|
+
while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
106
|
+
|
|
107
|
+
nodes.forEach(function (node) {
|
|
108
|
+
var match = node.nodeValue.match(TOKEN);
|
|
109
|
+
if (!match) return;
|
|
110
|
+
var key = match[1];
|
|
111
|
+
node.nodeValue = node.nodeValue.replace(TOKENS, "");
|
|
112
|
+
var element = node.parentElement;
|
|
113
|
+
if (element) {
|
|
114
|
+
element.dataset.i18nKey = key;
|
|
115
|
+
element.dataset.i18nValue = node.nodeValue.trim();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
MARKED_ATTRS.forEach(function (attr) {
|
|
120
|
+
var selector = "[" + attr + '*="' + LEFT + '"]';
|
|
121
|
+
document.querySelectorAll(selector).forEach(function (element) {
|
|
122
|
+
element.setAttribute(attr, element.getAttribute(attr).replace(TOKENS, ""));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- interaction ----------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
function handleClick(event) {
|
|
130
|
+
if (!config.active) return;
|
|
131
|
+
if (overlay && overlay.contains(event.target)) return;
|
|
132
|
+
if (event.target.closest && event.target.closest(".i18np-pill")) return;
|
|
133
|
+
// Let a host's own suggest-mode toggle link through (e.g. "?i18n_proofreading=false"
|
|
134
|
+
// in a nav menu); otherwise navigation-freezing would trap the user in suggest
|
|
135
|
+
// mode with no way out but the pill.
|
|
136
|
+
if (event.target.closest && event.target.closest('a[href*="' + config.toggleParam + '="]')) return;
|
|
137
|
+
|
|
138
|
+
// Freeze navigation so a stray click can't leave the page mid-proofread.
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
event.stopPropagation();
|
|
141
|
+
|
|
142
|
+
var element = event.target.closest && event.target.closest("[data-i18n-key]");
|
|
143
|
+
if (element) open(element.dataset.i18nKey, element.dataset.i18nValue || "");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function handleKeydown(event) {
|
|
147
|
+
if (event.key !== "Escape") return;
|
|
148
|
+
if (overlay) close();
|
|
149
|
+
else if (config.active) toggle();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- pill -----------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
function buildPill() {
|
|
155
|
+
var existing = document.querySelector(".i18np-pill");
|
|
156
|
+
if (existing) existing.remove();
|
|
157
|
+
|
|
158
|
+
var pill = document.createElement("button");
|
|
159
|
+
pill.type = "button";
|
|
160
|
+
pill.className = "i18np-pill" + (config.active ? " i18np-pill-on" : "");
|
|
161
|
+
pill.textContent = config.active ? "✓ " + t("pillActive", "Suggesting — tap to exit (Esc)") : "✎ " + labelText();
|
|
162
|
+
pill.addEventListener("click", toggle);
|
|
163
|
+
document.body.appendChild(pill);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function labelText() {
|
|
167
|
+
return config.pillLabel || t("pill", "Suggest edits");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Resolve a server-translated label, falling back to English if the config
|
|
171
|
+
// predates the labels payload (e.g. a cached page from an older gem version).
|
|
172
|
+
function t(name, fallback) {
|
|
173
|
+
return (config.labels && config.labels[name]) || fallback;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- popover --------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
function open(key, currentValue) {
|
|
179
|
+
close();
|
|
180
|
+
|
|
181
|
+
overlay = el("div", "i18np-overlay");
|
|
182
|
+
overlay.addEventListener("click", function (event) {
|
|
183
|
+
if (event.target === overlay) close();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
var panel = el("div", "i18np-panel");
|
|
187
|
+
// Mirror the popover for right-to-left locales (Arabic, Urdu, …). The i18n
|
|
188
|
+
// key stays LTR — it's a code identifier, not prose (see heading()).
|
|
189
|
+
panel.dir = config.rtl ? "rtl" : "ltr";
|
|
190
|
+
panel.appendChild(heading(key));
|
|
191
|
+
|
|
192
|
+
priorNode = el("div");
|
|
193
|
+
panel.appendChild(priorNode);
|
|
194
|
+
|
|
195
|
+
panel.appendChild(field(t("currentText", "Current text"), readonly(currentValue)));
|
|
196
|
+
|
|
197
|
+
proposedInput = textarea(currentValue);
|
|
198
|
+
panel.appendChild(field(t("suggestedText", "Suggested text"), proposedInput));
|
|
199
|
+
|
|
200
|
+
commentInput = input(t("commentPlaceholder", "Optional note for the developer"));
|
|
201
|
+
panel.appendChild(field(t("comment", "Comment"), commentInput));
|
|
202
|
+
|
|
203
|
+
errorNode = el("p", "i18np-error");
|
|
204
|
+
errorNode.style.display = "none";
|
|
205
|
+
panel.appendChild(errorNode);
|
|
206
|
+
|
|
207
|
+
panel.appendChild(actions(key, currentValue));
|
|
208
|
+
|
|
209
|
+
overlay.appendChild(panel);
|
|
210
|
+
document.body.appendChild(overlay);
|
|
211
|
+
proposedInput.focus();
|
|
212
|
+
loadPrior(key);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function close() {
|
|
216
|
+
if (overlay) {
|
|
217
|
+
overlay.remove();
|
|
218
|
+
overlay = null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function loadPrior(key) {
|
|
223
|
+
var params = new URLSearchParams({ key: key, locale: config.locale });
|
|
224
|
+
fetch(config.endpoint + "/context?" + params.toString(), { headers: { Accept: "application/json" } })
|
|
225
|
+
.then(function (response) {
|
|
226
|
+
return response.ok ? response.json() : [];
|
|
227
|
+
})
|
|
228
|
+
.then(function (items) {
|
|
229
|
+
if (items && items.length) renderPrior(items);
|
|
230
|
+
})
|
|
231
|
+
.catch(function () {
|
|
232
|
+
// Best-effort context; ignore fetch/parse errors.
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function renderPrior(items) {
|
|
237
|
+
if (!priorNode) return;
|
|
238
|
+
|
|
239
|
+
var box = el("div", "i18np-prior");
|
|
240
|
+
var title = el("p", "i18np-prior-title");
|
|
241
|
+
title.textContent = t("priorTitle", "Already suggested (pending)");
|
|
242
|
+
box.appendChild(title);
|
|
243
|
+
|
|
244
|
+
var list = el("ul", "i18np-prior-list");
|
|
245
|
+
items.forEach(function (item) {
|
|
246
|
+
var row = document.createElement("li");
|
|
247
|
+
var who = item.author_label ? " — " + item.author_label : "";
|
|
248
|
+
row.textContent = "“" + item.proposed_value + "”" + who;
|
|
249
|
+
list.appendChild(row);
|
|
250
|
+
});
|
|
251
|
+
box.appendChild(list);
|
|
252
|
+
|
|
253
|
+
priorNode.replaceWith(box);
|
|
254
|
+
priorNode = box;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function actions(key, currentValue) {
|
|
258
|
+
var wrapper = el("div", "i18np-actions");
|
|
259
|
+
|
|
260
|
+
var cancel = el("button", "i18np-btn i18np-btn-ghost");
|
|
261
|
+
cancel.type = "button";
|
|
262
|
+
cancel.textContent = t("cancel", "Cancel");
|
|
263
|
+
cancel.addEventListener("click", close);
|
|
264
|
+
|
|
265
|
+
saveButton = el("button", "i18np-btn i18np-btn-primary");
|
|
266
|
+
saveButton.type = "button";
|
|
267
|
+
saveButton.textContent = t("save", "Send suggestion");
|
|
268
|
+
saveButton.addEventListener("click", function () {
|
|
269
|
+
submit(key, currentValue);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
wrapper.appendChild(cancel);
|
|
273
|
+
wrapper.appendChild(saveButton);
|
|
274
|
+
return wrapper;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function submit(key, currentValue) {
|
|
278
|
+
var proposed = proposedInput.value.trim();
|
|
279
|
+
if (!proposed) {
|
|
280
|
+
showError(t("errorBlank", "Please enter a suggestion."));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
saveButton.disabled = true;
|
|
285
|
+
|
|
286
|
+
fetch(config.endpoint, {
|
|
287
|
+
method: "POST",
|
|
288
|
+
headers: {
|
|
289
|
+
"Content-Type": "application/json",
|
|
290
|
+
Accept: "application/json",
|
|
291
|
+
"X-CSRF-Token": csrfToken(),
|
|
292
|
+
},
|
|
293
|
+
body: JSON.stringify({
|
|
294
|
+
suggestion: {
|
|
295
|
+
translation_key: key,
|
|
296
|
+
locale: config.locale,
|
|
297
|
+
old_value: currentValue,
|
|
298
|
+
proposed_value: proposed,
|
|
299
|
+
comment: commentInput.value.trim(),
|
|
300
|
+
page_url: window.location.href,
|
|
301
|
+
},
|
|
302
|
+
}),
|
|
303
|
+
})
|
|
304
|
+
.then(function (response) {
|
|
305
|
+
if (response.ok) {
|
|
306
|
+
close();
|
|
307
|
+
} else {
|
|
308
|
+
saveButton.disabled = false;
|
|
309
|
+
showError(t("errorSave", "Could not save the suggestion."));
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
.catch(function () {
|
|
313
|
+
saveButton.disabled = false;
|
|
314
|
+
showError("Could not save the suggestion.");
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function showError(message) {
|
|
319
|
+
errorNode.textContent = message;
|
|
320
|
+
errorNode.style.display = "";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// --- small DOM builders ---------------------------------------------------
|
|
324
|
+
|
|
325
|
+
function heading(key) {
|
|
326
|
+
var wrapper = el("div", "i18np-heading");
|
|
327
|
+
var title = el("p", "i18np-title");
|
|
328
|
+
title.textContent = t("title", "Suggest a translation fix");
|
|
329
|
+
var code = el("code", "i18np-key");
|
|
330
|
+
code.dir = "ltr"; // the key is a code path, never RTL prose
|
|
331
|
+
code.textContent = key;
|
|
332
|
+
wrapper.appendChild(title);
|
|
333
|
+
wrapper.appendChild(code);
|
|
334
|
+
return wrapper;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function field(label, control) {
|
|
338
|
+
var wrapper = el("label", "i18np-field");
|
|
339
|
+
var text = el("span", "i18np-label");
|
|
340
|
+
text.textContent = label;
|
|
341
|
+
wrapper.appendChild(text);
|
|
342
|
+
wrapper.appendChild(control);
|
|
343
|
+
return wrapper;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function readonly(value) {
|
|
347
|
+
var node = el("p", "i18np-readonly");
|
|
348
|
+
node.textContent = value;
|
|
349
|
+
return node;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function textarea(value) {
|
|
353
|
+
var node = el("textarea", "i18np-input");
|
|
354
|
+
node.rows = 3;
|
|
355
|
+
node.value = value;
|
|
356
|
+
return node;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function input(placeholder) {
|
|
360
|
+
var node = el("input", "i18np-input");
|
|
361
|
+
node.type = "text";
|
|
362
|
+
node.placeholder = placeholder;
|
|
363
|
+
return node;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function el(tag, className) {
|
|
367
|
+
var node = document.createElement(tag);
|
|
368
|
+
if (className) node.className = className;
|
|
369
|
+
return node;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function csrfToken() {
|
|
373
|
+
var meta = document.querySelector('meta[name="csrf-token"]');
|
|
374
|
+
return meta ? meta.content : "";
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// --- styles ---------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
function injectStyles() {
|
|
380
|
+
if (document.getElementById("i18np-styles")) return;
|
|
381
|
+
var style = document.createElement("style");
|
|
382
|
+
style.id = "i18np-styles";
|
|
383
|
+
style.textContent = [
|
|
384
|
+
// Only the strings that actually resolve to a key are editable, so only
|
|
385
|
+
// those are highlighted. `outline` (not `border`) avoids any layout shift.
|
|
386
|
+
".i18np-active [data-i18n-key] { cursor: copy; outline: 1px dashed rgba(37, 99, 235, 0.5); outline-offset: 2px; }",
|
|
387
|
+
".i18np-active [data-i18n-key]:hover { outline: 2px dashed #2563eb; outline-offset: 2px; background: rgba(37, 99, 235, 0.08); }",
|
|
388
|
+
".i18np-pill { position: fixed; bottom: 16px; left: 16px; z-index: " + (Z + 1) + ";",
|
|
389
|
+
" font: 13px/1.2 system-ui, sans-serif; padding: 9px 14px; border-radius: 999px;",
|
|
390
|
+
" border: 1px solid rgba(0,0,0,.15); background: #fff; color: #111; cursor: pointer;",
|
|
391
|
+
" box-shadow: 0 4px 14px rgba(0,0,0,.18); }",
|
|
392
|
+
".i18np-pill-on { background: #2563eb; color: #fff; border-color: #2563eb; }",
|
|
393
|
+
".i18np-overlay { position: fixed; inset: 0; z-index: " + (Z + 2) + "; display: flex;",
|
|
394
|
+
" align-items: center; justify-content: center; padding: 16px;",
|
|
395
|
+
" background: rgba(0,0,0,.45); cursor: default; }",
|
|
396
|
+
".i18np-panel { width: 28rem; max-width: 100%; max-height: 90vh; overflow: auto;",
|
|
397
|
+
" background: #fff; color: #111; border-radius: 12px; padding: 20px;",
|
|
398
|
+
" box-shadow: 0 20px 60px rgba(0,0,0,.35); font: 14px/1.5 system-ui, sans-serif; }",
|
|
399
|
+
".i18np-panel > * + * { margin-top: 14px; }",
|
|
400
|
+
".i18np-title { font-weight: 700; font-size: 15px; margin: 0; }",
|
|
401
|
+
".i18np-key { display: block; margin-top: 2px; font-size: 12px; color: #666;",
|
|
402
|
+
" word-break: break-all; font-family: ui-monospace, monospace; }",
|
|
403
|
+
".i18np-field { display: block; }",
|
|
404
|
+
".i18np-label { display: block; margin-bottom: 4px; font-size: 12px; color: #666; }",
|
|
405
|
+
".i18np-readonly { margin: 0; padding: 8px 10px; background: #f4f4f5; border-radius: 8px; }",
|
|
406
|
+
".i18np-input { display: block; width: 100%; box-sizing: border-box; padding: 8px 10px;",
|
|
407
|
+
" border: 1px solid #d4d4d8; border-radius: 8px; font: inherit; }",
|
|
408
|
+
".i18np-prior { padding: 10px 12px; background: #f4f4f5; border-radius: 8px; font-size: 13px; }",
|
|
409
|
+
".i18np-prior-title { margin: 0 0 4px; font-weight: 600; color: #555; }",
|
|
410
|
+
".i18np-prior-list { margin: 0; padding-left: 18px; }",
|
|
411
|
+
".i18np-error { margin: 0; color: #dc2626; font-size: 13px; }",
|
|
412
|
+
".i18np-actions { display: flex; justify-content: flex-end; gap: 8px; }",
|
|
413
|
+
".i18np-btn { padding: 8px 14px; border-radius: 8px; border: 1px solid transparent;",
|
|
414
|
+
" font: inherit; cursor: pointer; }",
|
|
415
|
+
".i18np-btn-ghost { background: transparent; color: #111; }",
|
|
416
|
+
".i18np-btn-primary { background: #2563eb; color: #fff; }",
|
|
417
|
+
".i18np-btn[disabled] { opacity: .6; cursor: default; }",
|
|
418
|
+
|
|
419
|
+
// Follow the OS light/dark/system setting via prefers-color-scheme, so the
|
|
420
|
+
// widget matches whatever the reviewer's system is set to without any extra
|
|
421
|
+
// configuration. Only the surfaces that carry their own background/color
|
|
422
|
+
// above are overridden here — the blue accents stay the same in both themes.
|
|
423
|
+
"@media (prefers-color-scheme: dark) {",
|
|
424
|
+
" .i18np-pill { background: #1f1f23; color: #f4f4f5; border-color: rgba(255,255,255,.18);",
|
|
425
|
+
" box-shadow: 0 4px 14px rgba(0,0,0,.5); }",
|
|
426
|
+
" .i18np-pill-on { background: #2563eb; color: #fff; border-color: #2563eb; }",
|
|
427
|
+
" .i18np-overlay { background: rgba(0,0,0,.65); }",
|
|
428
|
+
" .i18np-panel { background: #1f1f23; color: #f4f4f5;",
|
|
429
|
+
" box-shadow: 0 20px 60px rgba(0,0,0,.7); }",
|
|
430
|
+
" .i18np-key { color: #a1a1aa; }",
|
|
431
|
+
" .i18np-label { color: #a1a1aa; }",
|
|
432
|
+
" .i18np-readonly { background: #2a2a30; }",
|
|
433
|
+
" .i18np-input { background: #2a2a30; color: #f4f4f5; border-color: #3f3f46; }",
|
|
434
|
+
" .i18np-input::placeholder { color: #71717a; }",
|
|
435
|
+
" .i18np-prior { background: #2a2a30; }",
|
|
436
|
+
" .i18np-prior-title { color: #d4d4d8; }",
|
|
437
|
+
" .i18np-error { color: #f87171; }",
|
|
438
|
+
" .i18np-btn-ghost { color: #f4f4f5; }",
|
|
439
|
+
"}",
|
|
440
|
+
].join("\n");
|
|
441
|
+
document.head.appendChild(style);
|
|
442
|
+
}
|
|
443
|
+
})();
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module I18nProofreading
|
|
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
|
+
# Lives under lib/ (not app/assets/), so a host that runs an asset
|
|
13
|
+
# pipeline never ingests it: Rails auto-registers every engine's
|
|
14
|
+
# app/assets/* directory, which would put this file in the host's asset
|
|
15
|
+
# namespace as a bare "widget.js" and into its precompiled output.
|
|
16
|
+
SOURCE = File.expand_path('widget.js', __dir__)
|
|
17
|
+
|
|
18
|
+
# Right-to-left scripts, so the popover renders mirrored for those locales.
|
|
19
|
+
# Matched on the language subtag, so region variants ("ar-EG") count too.
|
|
20
|
+
RTL_LANGUAGES = %w[ar arc ckb dv fa ha he ks ku ps sd ug ur yi].freeze
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def javascript
|
|
24
|
+
@javascript ||= File.read(SOURCE)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The two <script> tags to place before </body>.
|
|
28
|
+
#
|
|
29
|
+
# The config rides in a `type="application/json"` block: it is *data*, not
|
|
30
|
+
# code, so the browser never executes it and Turbo never tries to re-run it
|
|
31
|
+
# on a soft visit — which means it needs no CSP nonce and, crucially, the
|
|
32
|
+
# widget can re-read the *current* page's config on every `turbo:load`
|
|
33
|
+
# instead of being stuck with whatever the last full load evaluated.
|
|
34
|
+
#
|
|
35
|
+
# `nonce:` stamps only the widget script (the code), so it runs under a
|
|
36
|
+
# nonce-based Content-Security-Policy; pass nil when the app has no nonce.
|
|
37
|
+
def snippet(endpoint:, locale:, active:, nonce: nil)
|
|
38
|
+
config = {
|
|
39
|
+
endpoint: endpoint,
|
|
40
|
+
locale: locale.to_s,
|
|
41
|
+
active: active ? true : false,
|
|
42
|
+
showPill: I18nProofreading.config.show_pill ? true : false,
|
|
43
|
+
pillLabel: I18nProofreading.config.pill_label,
|
|
44
|
+
toggleParam: I18nProofreading.config.toggle_param,
|
|
45
|
+
labels: labels(locale),
|
|
46
|
+
rtl: rtl?(locale)
|
|
47
|
+
}
|
|
48
|
+
# Escape "</" so a value can't close the <script> block early.
|
|
49
|
+
json = config.to_json.gsub('</', '<\/')
|
|
50
|
+
nonce_attr = nonce ? %( nonce="#{nonce}") : ''
|
|
51
|
+
|
|
52
|
+
%(<script type="application/json" data-i18n-proofreading-config>#{json}</script>) +
|
|
53
|
+
%(<script data-i18n-proofreading-widget#{nonce_attr}>#{javascript}</script>)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Every user-facing string in the widget, resolved through Rails I18n so the
|
|
59
|
+
# popover matches the locale the page was rendered in. The locale is passed
|
|
60
|
+
# explicitly (not read from I18n.locale) because auto-injection runs in
|
|
61
|
+
# middleware *after* the controller action, by which point an
|
|
62
|
+
# `around_action { I18n.with_locale(...) }` has reset the ambient locale
|
|
63
|
+
# back to the default. Each lookup carries an English default, so the widget
|
|
64
|
+
# stays fully worded even for a locale the gem ships no translation for.
|
|
65
|
+
def labels(locale)
|
|
66
|
+
{
|
|
67
|
+
pill: t(locale, :pill, 'Suggest edits'),
|
|
68
|
+
pillActive: t(locale, :pill_active, 'Stop suggesting (Esc)'),
|
|
69
|
+
title: t(locale, :title, 'Suggest a translation fix'),
|
|
70
|
+
currentText: t(locale, :current_text, 'Current text'),
|
|
71
|
+
suggestedText: t(locale, :suggested_text, 'Suggested text'),
|
|
72
|
+
comment: t(locale, :comment, 'Comment'),
|
|
73
|
+
commentPlaceholder: t(locale, :comment_placeholder, 'Optional note for the developer'),
|
|
74
|
+
priorTitle: t(locale, :prior_title, 'Already suggested (pending)'),
|
|
75
|
+
cancel: t(locale, :cancel, 'Cancel'),
|
|
76
|
+
save: t(locale, :save, 'Send suggestion'),
|
|
77
|
+
errorBlank: t(locale, :error_blank, 'Please enter a suggestion.'),
|
|
78
|
+
errorSave: t(locale, :error_save, 'Could not save the suggestion.')
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Resolve under the given locale, tolerating one the app doesn't list in
|
|
83
|
+
# available_locales (enforce_available_locales would otherwise raise) — the
|
|
84
|
+
# English default still applies.
|
|
85
|
+
def t(locale, key, default)
|
|
86
|
+
I18n.t(key, scope: :i18n_proofreading, locale: locale, default: default)
|
|
87
|
+
rescue I18n::InvalidLocale
|
|
88
|
+
default
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def rtl?(locale)
|
|
92
|
+
RTL_LANGUAGES.include?(locale.to_s.downcase.split(/[-_]/).first)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'i18n_proofreading/version'
|
|
4
|
+
require 'i18n_proofreading/configuration'
|
|
5
|
+
require 'i18n_proofreading/marking'
|
|
6
|
+
require 'i18n_proofreading/widget'
|
|
7
|
+
require 'i18n_proofreading/middleware'
|
|
8
|
+
require 'i18n_proofreading/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 I18nProofreading
|
|
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
|
+
|
|
30
|
+
# May this request browse and triage the dashboard? Independent of
|
|
31
|
+
# `available?` — the dashboard has its own gate so maintainers can review
|
|
32
|
+
# suggestions from production even where the widget itself is off.
|
|
33
|
+
def admin?(request)
|
|
34
|
+
!!config.authorize_admin.call(request)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: i18n_proofreading
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.9.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/controllers/i18n_proofreading/application_controller.rb
|
|
42
|
+
- app/controllers/i18n_proofreading/suggestions_controller.rb
|
|
43
|
+
- app/helpers/i18n_proofreading/tag_helper.rb
|
|
44
|
+
- app/models/i18n_proofreading/application_record.rb
|
|
45
|
+
- app/models/i18n_proofreading/suggestion.rb
|
|
46
|
+
- app/views/i18n_proofreading/suggestions/index.html.erb
|
|
47
|
+
- app/views/layouts/i18n_proofreading/application.html.erb
|
|
48
|
+
- config/locales/i18n_proofreading.ar.yml
|
|
49
|
+
- config/locales/i18n_proofreading.bg.yml
|
|
50
|
+
- config/locales/i18n_proofreading.bn.yml
|
|
51
|
+
- config/locales/i18n_proofreading.de.yml
|
|
52
|
+
- config/locales/i18n_proofreading.el.yml
|
|
53
|
+
- config/locales/i18n_proofreading.en.yml
|
|
54
|
+
- config/locales/i18n_proofreading.es.yml
|
|
55
|
+
- config/locales/i18n_proofreading.fr.yml
|
|
56
|
+
- config/locales/i18n_proofreading.hi.yml
|
|
57
|
+
- config/locales/i18n_proofreading.hr.yml
|
|
58
|
+
- config/locales/i18n_proofreading.id.yml
|
|
59
|
+
- config/locales/i18n_proofreading.it.yml
|
|
60
|
+
- config/locales/i18n_proofreading.ja.yml
|
|
61
|
+
- config/locales/i18n_proofreading.ko.yml
|
|
62
|
+
- config/locales/i18n_proofreading.lb.yml
|
|
63
|
+
- config/locales/i18n_proofreading.nl.yml
|
|
64
|
+
- config/locales/i18n_proofreading.pl.yml
|
|
65
|
+
- config/locales/i18n_proofreading.pt.yml
|
|
66
|
+
- config/locales/i18n_proofreading.ro.yml
|
|
67
|
+
- config/locales/i18n_proofreading.ru.yml
|
|
68
|
+
- config/locales/i18n_proofreading.th.yml
|
|
69
|
+
- config/locales/i18n_proofreading.tr.yml
|
|
70
|
+
- config/locales/i18n_proofreading.uk.yml
|
|
71
|
+
- config/locales/i18n_proofreading.ur.yml
|
|
72
|
+
- config/locales/i18n_proofreading.vi.yml
|
|
73
|
+
- config/locales/i18n_proofreading.zh-CN.yml
|
|
74
|
+
- config/routes.rb
|
|
75
|
+
- lib/generators/i18n_proofreading/install/install_generator.rb
|
|
76
|
+
- lib/generators/i18n_proofreading/install/templates/create_i18n_proofreading_suggestions.rb.tt
|
|
77
|
+
- lib/generators/i18n_proofreading/install/templates/initializer.rb
|
|
78
|
+
- lib/i18n_proofreading.rb
|
|
79
|
+
- lib/i18n_proofreading/configuration.rb
|
|
80
|
+
- lib/i18n_proofreading/engine.rb
|
|
81
|
+
- lib/i18n_proofreading/marking.rb
|
|
82
|
+
- lib/i18n_proofreading/middleware.rb
|
|
83
|
+
- lib/i18n_proofreading/version.rb
|
|
84
|
+
- lib/i18n_proofreading/widget.js
|
|
85
|
+
- lib/i18n_proofreading/widget.rb
|
|
86
|
+
homepage: https://github.com/yshmarov/i18n-proofreading
|
|
87
|
+
licenses:
|
|
88
|
+
- MIT
|
|
89
|
+
metadata:
|
|
90
|
+
source_code_uri: https://github.com/yshmarov/i18n-proofreading
|
|
91
|
+
changelog_uri: https://github.com/yshmarov/i18n-proofreading/blob/main/CHANGELOG.md
|
|
92
|
+
rubygems_mfa_required: 'true'
|
|
93
|
+
rdoc_options: []
|
|
94
|
+
require_paths:
|
|
95
|
+
- lib
|
|
96
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '3.2'
|
|
101
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
102
|
+
requirements:
|
|
103
|
+
- - ">="
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '0'
|
|
106
|
+
requirements: []
|
|
107
|
+
rubygems_version: 4.0.6
|
|
108
|
+
specification_version: 4
|
|
109
|
+
summary: 'In-context i18n proofreading for Rails: click any translated string and
|
|
110
|
+
suggest a fix.'
|
|
111
|
+
test_files: []
|