i18n_feedback 0.8.2 → 0.8.3
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/README.md +6 -319
- data/lib/i18n_feedback.rb +12 -35
- metadata +14 -62
- data/CHANGELOG.md +0 -191
- data/MIT-LICENSE +0 -21
- data/Rakefile +0 -14
- data/app/controllers/i18n_feedback/application_controller.rb +0 -32
- data/app/controllers/i18n_feedback/suggestions_controller.rb +0 -92
- data/app/helpers/i18n_feedback/tag_helper.rb +0 -19
- data/app/models/i18n_feedback/application_record.rb +0 -7
- data/app/models/i18n_feedback/suggestion.rb +0 -27
- data/app/views/i18n_feedback/suggestions/index.html.erb +0 -77
- data/app/views/layouts/i18n_feedback/application.html.erb +0 -96
- data/config/locales/i18n_feedback.ar.yml +0 -16
- data/config/locales/i18n_feedback.bg.yml +0 -16
- data/config/locales/i18n_feedback.bn.yml +0 -16
- data/config/locales/i18n_feedback.de.yml +0 -16
- data/config/locales/i18n_feedback.el.yml +0 -16
- data/config/locales/i18n_feedback.en.yml +0 -35
- data/config/locales/i18n_feedback.es.yml +0 -16
- data/config/locales/i18n_feedback.fr.yml +0 -16
- data/config/locales/i18n_feedback.hi.yml +0 -16
- data/config/locales/i18n_feedback.hr.yml +0 -16
- data/config/locales/i18n_feedback.id.yml +0 -16
- data/config/locales/i18n_feedback.it.yml +0 -16
- data/config/locales/i18n_feedback.ja.yml +0 -16
- data/config/locales/i18n_feedback.ko.yml +0 -16
- data/config/locales/i18n_feedback.lb.yml +0 -16
- data/config/locales/i18n_feedback.nl.yml +0 -16
- data/config/locales/i18n_feedback.pl.yml +0 -16
- data/config/locales/i18n_feedback.pt.yml +0 -16
- data/config/locales/i18n_feedback.ro.yml +0 -16
- data/config/locales/i18n_feedback.ru.yml +0 -16
- data/config/locales/i18n_feedback.th.yml +0 -16
- data/config/locales/i18n_feedback.tr.yml +0 -16
- data/config/locales/i18n_feedback.uk.yml +0 -16
- data/config/locales/i18n_feedback.ur.yml +0 -16
- data/config/locales/i18n_feedback.vi.yml +0 -16
- data/config/locales/i18n_feedback.zh-CN.yml +0 -16
- data/config/routes.rb +0 -13
- data/lib/generators/i18n_feedback/install/install_generator.rb +0 -40
- data/lib/generators/i18n_feedback/install/templates/create_i18n_feedback_suggestions.rb.tt +0 -22
- data/lib/generators/i18n_feedback/install/templates/initializer.rb +0 -56
- data/lib/i18n_feedback/configuration.rb +0 -91
- data/lib/i18n_feedback/engine.rb +0 -26
- data/lib/i18n_feedback/marking.rb +0 -46
- data/lib/i18n_feedback/middleware.rb +0 -145
- data/lib/i18n_feedback/version.rb +0 -5
- data/lib/i18n_feedback/widget.js +0 -443
- data/lib/i18n_feedback/widget.rb +0 -96
data/lib/i18n_feedback/engine.rb
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
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
|
|
@@ -1,46 +0,0 @@
|
|
|
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. The tool's own
|
|
15
|
-
# `i18n_feedback.*` strings are skipped too: they aren't part of the host
|
|
16
|
-
# app's translatable copy, so the widget must never mark (or offer to edit)
|
|
17
|
-
# its own UI.
|
|
18
|
-
SKIP = /(\A|\.)(number|date|time|datetime|support|i18n_feedback)(\.|\z)|formats?\z/
|
|
19
|
-
|
|
20
|
-
class << self
|
|
21
|
-
def enabled?
|
|
22
|
-
Thread.current[:i18n_feedback_marking] || false
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def enabled=(value)
|
|
26
|
-
Thread.current[:i18n_feedback_marking] = value
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# Prepended onto the active I18n backend class.
|
|
31
|
-
module Backend
|
|
32
|
-
def translate(locale, key, options = {})
|
|
33
|
-
result = super
|
|
34
|
-
return result unless Marking.enabled? && result.is_a?(String)
|
|
35
|
-
|
|
36
|
-
full = [*options[:scope], key].join('.')
|
|
37
|
-
return result if full.match?(SKIP)
|
|
38
|
-
|
|
39
|
-
# `super` returns a plain String even for _html keys (ActionView marks the
|
|
40
|
-
# helper's output safe after the backend returns), so the marker lands
|
|
41
|
-
# inside that buffer without changing any html_safe status.
|
|
42
|
-
"#{result} #{LEFT}#{full}#{RIGHT}"
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
@@ -1,145 +0,0 @@
|
|
|
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
|
-
# "?i18n_feedback=true|false" is a one-shot command, not a sticky URL state:
|
|
22
|
-
# set the cookie and redirect to the same URL without the parameter. That
|
|
23
|
-
# keeps the cookie the single source of truth (so the pill's reload can turn
|
|
24
|
-
# suggest mode off) and stops the parameter from lingering in the address bar.
|
|
25
|
-
if available && request.get? && !(desired = toggle_override(request)).nil?
|
|
26
|
-
return toggle_redirect(request, desired)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
marking = available && cookie_on?(request)
|
|
30
|
-
Marking.enabled = marking
|
|
31
|
-
|
|
32
|
-
status, headers, body = @app.call(env)
|
|
33
|
-
|
|
34
|
-
if available && I18nFeedback.config.auto_inject && html?(headers) && !request.xhr?
|
|
35
|
-
status, headers, body = inject(status, headers, body, marking, csp_nonce(env))
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
[status, headers, body]
|
|
39
|
-
ensure
|
|
40
|
-
Marking.enabled = false
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
private
|
|
44
|
-
|
|
45
|
-
def cookie_on?(request)
|
|
46
|
-
!request.cookies[COOKIE].to_s.empty?
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def toggle_redirect(request, desired)
|
|
50
|
-
headers = persist_choice({ 'Content-Type' => 'text/html', 'Location' => url_without_toggle(request) }, desired)
|
|
51
|
-
[303, headers, []]
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def url_without_toggle(request)
|
|
55
|
-
query = Rack::Utils.parse_query(request.query_string)
|
|
56
|
-
query.delete(I18nFeedback.config.toggle_param)
|
|
57
|
-
built = Rack::Utils.build_query(query)
|
|
58
|
-
built.empty? ? request.path : "#{request.path}?#{built}"
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# The request's CSP nonce, so the injected scripts run under a nonce-based
|
|
62
|
-
# Content-Security-Policy. Reads the value Rails memoizes on the env, which is
|
|
63
|
-
# the same nonce the CSP header uses. nil when the app sets no nonce.
|
|
64
|
-
def csp_nonce(env)
|
|
65
|
-
return nil unless defined?(ActionDispatch::Request)
|
|
66
|
-
|
|
67
|
-
ActionDispatch::Request.new(env).content_security_policy_nonce
|
|
68
|
-
rescue StandardError
|
|
69
|
-
nil
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def toggle_override(request)
|
|
73
|
-
param = I18nFeedback.config.toggle_param
|
|
74
|
-
return nil unless request.params.key?(param)
|
|
75
|
-
|
|
76
|
-
%w[1 true on yes].include?(request.params[param].to_s.strip.downcase)
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def persist_choice(headers, desired)
|
|
80
|
-
headers = headers.dup
|
|
81
|
-
cookie = if desired
|
|
82
|
-
"#{COOKIE}=1; path=/; SameSite=Lax"
|
|
83
|
-
else
|
|
84
|
-
"#{COOKIE}=; path=/; max-age=0; SameSite=Lax"
|
|
85
|
-
end
|
|
86
|
-
key = header_key(headers, 'set-cookie') || 'set-cookie'
|
|
87
|
-
existing = headers[key]
|
|
88
|
-
headers[key] = case existing
|
|
89
|
-
when nil then cookie
|
|
90
|
-
when Array then existing + [cookie]
|
|
91
|
-
else "#{existing}\n#{cookie}"
|
|
92
|
-
end
|
|
93
|
-
headers
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def header_key(headers, name)
|
|
97
|
-
headers.key?(name) ? name : headers.keys.find { |k| k.to_s.casecmp?(name) }
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def html?(headers)
|
|
101
|
-
content_type(headers).to_s.include?('text/html')
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# The locale the page was actually rendered in, read from its `<html lang>`
|
|
105
|
-
# attribute. Auto-injection runs after the controller action, so I18n.locale
|
|
106
|
-
# may already have been reset from whatever the request used — the rendered
|
|
107
|
-
# `lang` is the reliable record of the page's language. Falls back to the
|
|
108
|
-
# ambient locale when the page declares none.
|
|
109
|
-
def page_locale(html)
|
|
110
|
-
lang = html[/<html[^>]*\blang=["']([^"']+)["']/i, 1]
|
|
111
|
-
(lang && normalize_locale(lang)) || I18n.locale
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# Map an HTML lang value ("es", "pt-BR") to a locale the app actually offers,
|
|
115
|
-
# trying the value as-is, its underscore form, then its language subtag. nil
|
|
116
|
-
# if none match, so the caller can fall back rather than emit an unknown one.
|
|
117
|
-
def normalize_locale(lang)
|
|
118
|
-
available = I18n.available_locales.map(&:to_s)
|
|
119
|
-
[lang, lang.tr('-', '_'), lang.split(/[-_]/).first].uniq.find { |c| available.include?(c) }&.to_sym
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def content_type(headers)
|
|
123
|
-
headers['Content-Type'] || headers['content-type']
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def inject(status, headers, body, marking, nonce)
|
|
127
|
-
html = +''
|
|
128
|
-
body.each { |part| html << part.to_s }
|
|
129
|
-
body.close if body.respond_to?(:close)
|
|
130
|
-
|
|
131
|
-
snippet = Widget.snippet(
|
|
132
|
-
endpoint: I18nFeedback.config.suggestions_endpoint,
|
|
133
|
-
locale: page_locale(html),
|
|
134
|
-
active: marking,
|
|
135
|
-
nonce: nonce
|
|
136
|
-
)
|
|
137
|
-
html = html.include?('</body>') ? html.sub('</body>', "#{snippet}</body>") : html + snippet
|
|
138
|
-
|
|
139
|
-
headers = headers.dup
|
|
140
|
-
headers['Content-Length'] = html.bytesize.to_s if headers.key?('Content-Length') || headers.key?('content-length')
|
|
141
|
-
|
|
142
|
-
[status, headers, [html]]
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
end
|
data/lib/i18n_feedback/widget.js
DELETED
|
@@ -1,443 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* i18n_feedback widget — self-contained, no framework, no build step.
|
|
3
|
-
*
|
|
4
|
-
* Reads its config from the <script type="application/json"
|
|
5
|
-
* data-i18n-feedback-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_feedback 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.__i18nFeedbackLoaded) return;
|
|
21
|
-
window.__i18nFeedbackLoaded = 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_feedback";
|
|
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-feedback-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("i18nf-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(".i18nf-pill")) return;
|
|
133
|
-
// Let a host's own suggest-mode toggle link through (e.g. "?i18n_feedback=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(".i18nf-pill");
|
|
156
|
-
if (existing) existing.remove();
|
|
157
|
-
|
|
158
|
-
var pill = document.createElement("button");
|
|
159
|
-
pill.type = "button";
|
|
160
|
-
pill.className = "i18nf-pill" + (config.active ? " i18nf-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", "i18nf-overlay");
|
|
182
|
-
overlay.addEventListener("click", function (event) {
|
|
183
|
-
if (event.target === overlay) close();
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
var panel = el("div", "i18nf-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", "i18nf-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", "i18nf-prior");
|
|
240
|
-
var title = el("p", "i18nf-prior-title");
|
|
241
|
-
title.textContent = t("priorTitle", "Already suggested (pending)");
|
|
242
|
-
box.appendChild(title);
|
|
243
|
-
|
|
244
|
-
var list = el("ul", "i18nf-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", "i18nf-actions");
|
|
259
|
-
|
|
260
|
-
var cancel = el("button", "i18nf-btn i18nf-btn-ghost");
|
|
261
|
-
cancel.type = "button";
|
|
262
|
-
cancel.textContent = t("cancel", "Cancel");
|
|
263
|
-
cancel.addEventListener("click", close);
|
|
264
|
-
|
|
265
|
-
saveButton = el("button", "i18nf-btn i18nf-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", "i18nf-heading");
|
|
327
|
-
var title = el("p", "i18nf-title");
|
|
328
|
-
title.textContent = t("title", "Suggest a translation fix");
|
|
329
|
-
var code = el("code", "i18nf-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", "i18nf-field");
|
|
339
|
-
var text = el("span", "i18nf-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", "i18nf-readonly");
|
|
348
|
-
node.textContent = value;
|
|
349
|
-
return node;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function textarea(value) {
|
|
353
|
-
var node = el("textarea", "i18nf-input");
|
|
354
|
-
node.rows = 3;
|
|
355
|
-
node.value = value;
|
|
356
|
-
return node;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function input(placeholder) {
|
|
360
|
-
var node = el("input", "i18nf-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("i18nf-styles")) return;
|
|
381
|
-
var style = document.createElement("style");
|
|
382
|
-
style.id = "i18nf-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
|
-
".i18nf-active [data-i18n-key] { cursor: copy; outline: 1px dashed rgba(37, 99, 235, 0.5); outline-offset: 2px; }",
|
|
387
|
-
".i18nf-active [data-i18n-key]:hover { outline: 2px dashed #2563eb; outline-offset: 2px; background: rgba(37, 99, 235, 0.08); }",
|
|
388
|
-
".i18nf-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
|
-
".i18nf-pill-on { background: #2563eb; color: #fff; border-color: #2563eb; }",
|
|
393
|
-
".i18nf-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
|
-
".i18nf-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
|
-
".i18nf-panel > * + * { margin-top: 14px; }",
|
|
400
|
-
".i18nf-title { font-weight: 700; font-size: 15px; margin: 0; }",
|
|
401
|
-
".i18nf-key { display: block; margin-top: 2px; font-size: 12px; color: #666;",
|
|
402
|
-
" word-break: break-all; font-family: ui-monospace, monospace; }",
|
|
403
|
-
".i18nf-field { display: block; }",
|
|
404
|
-
".i18nf-label { display: block; margin-bottom: 4px; font-size: 12px; color: #666; }",
|
|
405
|
-
".i18nf-readonly { margin: 0; padding: 8px 10px; background: #f4f4f5; border-radius: 8px; }",
|
|
406
|
-
".i18nf-input { display: block; width: 100%; box-sizing: border-box; padding: 8px 10px;",
|
|
407
|
-
" border: 1px solid #d4d4d8; border-radius: 8px; font: inherit; }",
|
|
408
|
-
".i18nf-prior { padding: 10px 12px; background: #f4f4f5; border-radius: 8px; font-size: 13px; }",
|
|
409
|
-
".i18nf-prior-title { margin: 0 0 4px; font-weight: 600; color: #555; }",
|
|
410
|
-
".i18nf-prior-list { margin: 0; padding-left: 18px; }",
|
|
411
|
-
".i18nf-error { margin: 0; color: #dc2626; font-size: 13px; }",
|
|
412
|
-
".i18nf-actions { display: flex; justify-content: flex-end; gap: 8px; }",
|
|
413
|
-
".i18nf-btn { padding: 8px 14px; border-radius: 8px; border: 1px solid transparent;",
|
|
414
|
-
" font: inherit; cursor: pointer; }",
|
|
415
|
-
".i18nf-btn-ghost { background: transparent; color: #111; }",
|
|
416
|
-
".i18nf-btn-primary { background: #2563eb; color: #fff; }",
|
|
417
|
-
".i18nf-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
|
-
" .i18nf-pill { background: #1f1f23; color: #f4f4f5; border-color: rgba(255,255,255,.18);",
|
|
425
|
-
" box-shadow: 0 4px 14px rgba(0,0,0,.5); }",
|
|
426
|
-
" .i18nf-pill-on { background: #2563eb; color: #fff; border-color: #2563eb; }",
|
|
427
|
-
" .i18nf-overlay { background: rgba(0,0,0,.65); }",
|
|
428
|
-
" .i18nf-panel { background: #1f1f23; color: #f4f4f5;",
|
|
429
|
-
" box-shadow: 0 20px 60px rgba(0,0,0,.7); }",
|
|
430
|
-
" .i18nf-key { color: #a1a1aa; }",
|
|
431
|
-
" .i18nf-label { color: #a1a1aa; }",
|
|
432
|
-
" .i18nf-readonly { background: #2a2a30; }",
|
|
433
|
-
" .i18nf-input { background: #2a2a30; color: #f4f4f5; border-color: #3f3f46; }",
|
|
434
|
-
" .i18nf-input::placeholder { color: #71717a; }",
|
|
435
|
-
" .i18nf-prior { background: #2a2a30; }",
|
|
436
|
-
" .i18nf-prior-title { color: #d4d4d8; }",
|
|
437
|
-
" .i18nf-error { color: #f87171; }",
|
|
438
|
-
" .i18nf-btn-ghost { color: #f4f4f5; }",
|
|
439
|
-
"}",
|
|
440
|
-
].join("\n");
|
|
441
|
-
document.head.appendChild(style);
|
|
442
|
-
}
|
|
443
|
-
})();
|