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,16 @@
|
|
|
1
|
+
pt:
|
|
2
|
+
i18n_proofreading:
|
|
3
|
+
pill: "Sugerir edições"
|
|
4
|
+
start: "Melhorar tradução"
|
|
5
|
+
stop: "Parar de sugerir (Esc)"
|
|
6
|
+
pill_active: "Parar de sugerir (Esc)"
|
|
7
|
+
title: "Sugerir uma correção de tradução"
|
|
8
|
+
current_text: "Texto atual"
|
|
9
|
+
suggested_text: "Texto sugerido"
|
|
10
|
+
comment: "Comentário"
|
|
11
|
+
comment_placeholder: "Nota opcional para o desenvolvedor"
|
|
12
|
+
prior_title: "Já sugerido (pendente)"
|
|
13
|
+
cancel: "Cancelar"
|
|
14
|
+
save: "Enviar sugestão"
|
|
15
|
+
error_blank: "Insira uma sugestão."
|
|
16
|
+
error_save: "Não foi possível salvar a sugestão."
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
ro:
|
|
2
|
+
i18n_proofreading:
|
|
3
|
+
pill: "Sugerează modificări"
|
|
4
|
+
pill_active: "Oprește sugestiile (Esc)"
|
|
5
|
+
start: "Îmbunătățește traducerea"
|
|
6
|
+
stop: "Oprește sugestiile (Esc)"
|
|
7
|
+
title: "Sugerează o corectură de traducere"
|
|
8
|
+
current_text: "Text curent"
|
|
9
|
+
suggested_text: "Text sugerat"
|
|
10
|
+
comment: "Comentariu"
|
|
11
|
+
comment_placeholder: "Notă opțională pentru dezvoltator"
|
|
12
|
+
prior_title: "Deja sugerat (în așteptare)"
|
|
13
|
+
cancel: "Anulează"
|
|
14
|
+
save: "Trimite sugestia"
|
|
15
|
+
error_blank: "Introduceți o sugestie."
|
|
16
|
+
error_save: "Sugestia nu a putut fi salvată."
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
ru:
|
|
2
|
+
i18n_proofreading:
|
|
3
|
+
pill: "Предложить правки"
|
|
4
|
+
start: "Улучшить перевод"
|
|
5
|
+
stop: "Остановить предложения (Esc)"
|
|
6
|
+
pill_active: "Остановить предложения (Esc)"
|
|
7
|
+
title: "Предложить исправление перевода"
|
|
8
|
+
current_text: "Текущий текст"
|
|
9
|
+
suggested_text: "Предлагаемый текст"
|
|
10
|
+
comment: "Комментарий"
|
|
11
|
+
comment_placeholder: "Необязательная заметка для разработчика"
|
|
12
|
+
prior_title: "Уже предложено (в ожидании)"
|
|
13
|
+
cancel: "Отмена"
|
|
14
|
+
save: "Отправить предложение"
|
|
15
|
+
error_blank: "Введите предложение."
|
|
16
|
+
error_save: "Не удалось сохранить предложение."
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
th:
|
|
2
|
+
i18n_proofreading:
|
|
3
|
+
pill: "แนะนำการแก้ไข"
|
|
4
|
+
start: "ปรับปรุงคำแปล"
|
|
5
|
+
stop: "หยุดแนะนำ (Esc)"
|
|
6
|
+
pill_active: "หยุดแนะนำ (Esc)"
|
|
7
|
+
title: "แนะนำการแก้ไขคำแปล"
|
|
8
|
+
current_text: "ข้อความปัจจุบัน"
|
|
9
|
+
suggested_text: "ข้อความที่แนะนำ"
|
|
10
|
+
comment: "ความคิดเห็น"
|
|
11
|
+
comment_placeholder: "หมายเหตุเพิ่มเติมสำหรับนักพัฒนา"
|
|
12
|
+
prior_title: "แนะนำไปแล้ว (รอดำเนินการ)"
|
|
13
|
+
cancel: "ยกเลิก"
|
|
14
|
+
save: "ส่งคำแนะนำ"
|
|
15
|
+
error_blank: "โปรดป้อนคำแนะนำ"
|
|
16
|
+
error_save: "ไม่สามารถบันทึกคำแนะนำได้"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
tr:
|
|
2
|
+
i18n_proofreading:
|
|
3
|
+
pill: "Düzenleme öner"
|
|
4
|
+
start: "Çeviriyi iyileştir"
|
|
5
|
+
stop: "Önermeyi durdur (Esc)"
|
|
6
|
+
pill_active: "Önermeyi durdur (Esc)"
|
|
7
|
+
title: "Çeviri düzeltmesi öner"
|
|
8
|
+
current_text: "Mevcut metin"
|
|
9
|
+
suggested_text: "Önerilen metin"
|
|
10
|
+
comment: "Yorum"
|
|
11
|
+
comment_placeholder: "Geliştirici için isteğe bağlı not"
|
|
12
|
+
prior_title: "Zaten önerildi (beklemede)"
|
|
13
|
+
cancel: "İptal"
|
|
14
|
+
save: "Öneriyi gönder"
|
|
15
|
+
error_blank: "Lütfen bir öneri girin."
|
|
16
|
+
error_save: "Öneri kaydedilemedi."
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
uk:
|
|
2
|
+
i18n_proofreading:
|
|
3
|
+
pill: "Запропонувати зміни"
|
|
4
|
+
start: "Покращити переклад"
|
|
5
|
+
stop: "Зупинити пропозиції (Esc)"
|
|
6
|
+
pill_active: "Зупинити пропозиції (Esc)"
|
|
7
|
+
title: "Запропонувати виправлення перекладу"
|
|
8
|
+
current_text: "Поточний текст"
|
|
9
|
+
suggested_text: "Запропонований текст"
|
|
10
|
+
comment: "Коментар"
|
|
11
|
+
comment_placeholder: "Необов’язкова примітка для розробника"
|
|
12
|
+
prior_title: "Уже запропоновано (очікує)"
|
|
13
|
+
cancel: "Скасувати"
|
|
14
|
+
save: "Надіслати пропозицію"
|
|
15
|
+
error_blank: "Будь ласка, введіть пропозицію."
|
|
16
|
+
error_save: "Не вдалося зберегти пропозицію."
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
ur:
|
|
2
|
+
i18n_proofreading:
|
|
3
|
+
pill: "ترامیم تجویز کریں"
|
|
4
|
+
start: "ترجمہ بہتر بنائیں"
|
|
5
|
+
stop: "تجویز کرنا بند کریں (Esc)"
|
|
6
|
+
pill_active: "تجویز کرنا بند کریں (Esc)"
|
|
7
|
+
title: "ترجمے کی اصلاح تجویز کریں"
|
|
8
|
+
current_text: "موجودہ متن"
|
|
9
|
+
suggested_text: "تجویز کردہ متن"
|
|
10
|
+
comment: "تبصرہ"
|
|
11
|
+
comment_placeholder: "ڈویلپر کے لیے اختیاری نوٹ"
|
|
12
|
+
prior_title: "پہلے ہی تجویز کیا جا چکا (زیر التوا)"
|
|
13
|
+
cancel: "منسوخ کریں"
|
|
14
|
+
save: "تجویز بھیجیں"
|
|
15
|
+
error_blank: "براہ کرم ایک تجویز درج کریں۔"
|
|
16
|
+
error_save: "تجویز محفوظ نہیں ہو سکی۔"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
vi:
|
|
2
|
+
i18n_proofreading:
|
|
3
|
+
pill: "Đề xuất chỉnh sửa"
|
|
4
|
+
start: "Cải thiện bản dịch"
|
|
5
|
+
stop: "Dừng đề xuất (Esc)"
|
|
6
|
+
pill_active: "Dừng đề xuất (Esc)"
|
|
7
|
+
title: "Đề xuất sửa bản dịch"
|
|
8
|
+
current_text: "Văn bản hiện tại"
|
|
9
|
+
suggested_text: "Văn bản đề xuất"
|
|
10
|
+
comment: "Nhận xét"
|
|
11
|
+
comment_placeholder: "Ghi chú tùy chọn cho nhà phát triển"
|
|
12
|
+
prior_title: "Đã đề xuất (đang chờ)"
|
|
13
|
+
cancel: "Hủy"
|
|
14
|
+
save: "Gửi đề xuất"
|
|
15
|
+
error_blank: "Vui lòng nhập một đề xuất."
|
|
16
|
+
error_save: "Không thể lưu đề xuất."
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
zh-CN:
|
|
2
|
+
i18n_proofreading:
|
|
3
|
+
pill: "建议修改"
|
|
4
|
+
start: "改进翻译"
|
|
5
|
+
stop: "停止建议 (Esc)"
|
|
6
|
+
pill_active: "停止建议(Esc)"
|
|
7
|
+
title: "建议翻译修正"
|
|
8
|
+
current_text: "当前文本"
|
|
9
|
+
suggested_text: "建议文本"
|
|
10
|
+
comment: "备注"
|
|
11
|
+
comment_placeholder: "给开发者的可选备注"
|
|
12
|
+
prior_title: "已提交建议(待处理)"
|
|
13
|
+
cancel: "取消"
|
|
14
|
+
save: "发送建议"
|
|
15
|
+
error_blank: "请输入建议。"
|
|
16
|
+
error_save: "无法保存建议。"
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
I18nProofreading::Engine.routes.draw do
|
|
4
|
+
# index is the read-only admin dashboard; create + the `context` collection
|
|
5
|
+
# route are the widget's public API (see SuggestionsController). There is
|
|
6
|
+
# deliberately no update/destroy — the tool never mutates suggestions from the
|
|
7
|
+
# UI, since it can't write to the host's locale files.
|
|
8
|
+
resources :suggestions, only: %i[index create] do
|
|
9
|
+
get :context, on: :collection
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
root to: 'suggestions#index'
|
|
13
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/active_record'
|
|
5
|
+
|
|
6
|
+
module I18nProofreading
|
|
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_proofreading: config initializer, migration, and engine mount.'
|
|
14
|
+
|
|
15
|
+
def create_initializer
|
|
16
|
+
copy_file 'initializer.rb', 'config/initializers/i18n_proofreading.rb'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create_suggestions_migration
|
|
20
|
+
migration_template 'create_i18n_proofreading_suggestions.rb.tt',
|
|
21
|
+
'db/migrate/create_i18n_proofreading_suggestions.rb'
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def mount_engine
|
|
25
|
+
route %(mount I18nProofreading::Engine => "/i18n_proofreading")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def post_install
|
|
29
|
+
say "\ni18n_proofreading 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
|
data/lib/generators/i18n_proofreading/install/templates/create_i18n_proofreading_suggestions.rb.tt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateI18nProofreadingSuggestions < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :i18n_proofreading_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 :status, null: false, default: 'pending'
|
|
13
|
+
t.string :author_id
|
|
14
|
+
t.string :author_label
|
|
15
|
+
|
|
16
|
+
t.timestamps
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
add_index :i18n_proofreading_suggestions, %i[translation_key locale]
|
|
20
|
+
add_index :i18n_proofreading_suggestions, :status
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
I18nProofreading.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
|
+
# Who may open the triage dashboard (mounted at your `mount_path`) to review,
|
|
14
|
+
# apply, and delete suggestions. Independent of the checks above — the widget
|
|
15
|
+
# is dev/staging-only, but you may want to triage from production. Defaults to
|
|
16
|
+
# development only; wire it to your own admin check to open it elsewhere.
|
|
17
|
+
#
|
|
18
|
+
# config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
|
|
19
|
+
|
|
20
|
+
# Attribute a suggestion to a user (optional). Return an object responding to
|
|
21
|
+
# #id (ideally #email too), or nil. Receives the Rack::Request. You resolve the
|
|
22
|
+
# user however your app does — session, Warden, a token, etc.
|
|
23
|
+
#
|
|
24
|
+
# config.current_user = ->(request) { nil }
|
|
25
|
+
|
|
26
|
+
# How to label the author in the "already suggested" list.
|
|
27
|
+
#
|
|
28
|
+
# config.author_label = ->(user) { user.try(:email) }
|
|
29
|
+
|
|
30
|
+
# The widget is injected into HTML responses automatically. Set this to false to
|
|
31
|
+
# place it yourself with `<%= i18n_proofreading_tag %>` at the end of your layout.
|
|
32
|
+
#
|
|
33
|
+
# config.auto_inject = true
|
|
34
|
+
|
|
35
|
+
# Show the floating "Suggest edits" pill. Set false to hide it and toggle
|
|
36
|
+
# suggest mode from your own link instead, e.g.
|
|
37
|
+
# `<%= link_to "Proofread", "?i18n_proofreading=true" %>`. "?i18n_proofreading=false"
|
|
38
|
+
# exits; the choice is remembered in a cookie.
|
|
39
|
+
#
|
|
40
|
+
# config.show_pill = true
|
|
41
|
+
# config.toggle_param = "i18n_proofreading"
|
|
42
|
+
|
|
43
|
+
# Keep this in sync with the `mount` line in config/routes.rb.
|
|
44
|
+
#
|
|
45
|
+
# config.mount_path = "/i18n_proofreading"
|
|
46
|
+
|
|
47
|
+
# Called with each saved suggestion — notify Slack, send an email, open a
|
|
48
|
+
# ticket. Runs inline after save, so keep it fast or hand off to a job.
|
|
49
|
+
#
|
|
50
|
+
# config.on_submit = ->(suggestion) { SuggestionMailer.with(suggestion:).created.deliver_later }
|
|
51
|
+
|
|
52
|
+
# Per-IP throttle on the submission endpoint, passed to Rails' built-in rate
|
|
53
|
+
# limiter (Rails 7.2+; ignored on 7.1). Set nil to disable.
|
|
54
|
+
#
|
|
55
|
+
# config.rate_limit = { to: 30, within: 60 }
|
|
56
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nProofreading
|
|
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
|
+
# Per-request gate for the triage dashboard (browse suggestions, change their
|
|
18
|
+
# status, delete). Independent of `enabled` and `enabled_environments`: the
|
|
19
|
+
# widget is dev/staging-only, but a maintainer may want to triage from
|
|
20
|
+
# production. Defaults to development only, so a fresh install never exposes
|
|
21
|
+
# the dashboard until you wire it to your own admin check.
|
|
22
|
+
attr_accessor :authorize_admin
|
|
23
|
+
|
|
24
|
+
# Resolve the current user for attribution (optional). Return an object
|
|
25
|
+
# responding to #id, or nil. Receives the Rack::Request.
|
|
26
|
+
attr_accessor :current_user
|
|
27
|
+
|
|
28
|
+
# Turn a resolved user into a short label shown in the "already suggested"
|
|
29
|
+
# list. Receives whatever #current_user returned.
|
|
30
|
+
attr_accessor :author_label
|
|
31
|
+
|
|
32
|
+
# The locales a suggestion may target. Used to validate submissions.
|
|
33
|
+
attr_accessor :available_locales
|
|
34
|
+
|
|
35
|
+
# Inject the widget into HTML responses automatically. Set false to place it
|
|
36
|
+
# yourself with `<%= i18n_proofreading_tag %>` in your layout.
|
|
37
|
+
attr_accessor :auto_inject
|
|
38
|
+
|
|
39
|
+
# Where the engine is mounted. The widget posts suggestions to
|
|
40
|
+
# "#{mount_path}/suggestions", so keep this in sync with the `mount` line in
|
|
41
|
+
# your routes.
|
|
42
|
+
attr_accessor :mount_path
|
|
43
|
+
|
|
44
|
+
# Show the floating toggle pill. Set false to hide it and drive suggest mode
|
|
45
|
+
# from your own link instead (see #toggle_param).
|
|
46
|
+
attr_accessor :show_pill
|
|
47
|
+
|
|
48
|
+
# Text on the floating toggle pill. Leave nil to use the localized default
|
|
49
|
+
# ("Suggest edits", translated via the `i18n_proofreading.pill` I18n key).
|
|
50
|
+
attr_accessor :pill_label
|
|
51
|
+
|
|
52
|
+
# The query-string parameter that turns suggest mode on/off, so a host can
|
|
53
|
+
# link to it from anywhere: "?i18n_proofreading=true" enables it, "false" exits.
|
|
54
|
+
# The choice is remembered in a cookie, so the rest of the app stays in
|
|
55
|
+
# suggest mode without the parameter.
|
|
56
|
+
attr_accessor :toggle_param
|
|
57
|
+
|
|
58
|
+
# Called with each saved suggestion — notify Slack, send an email, open a
|
|
59
|
+
# ticket. Runs inline after save; keep it fast or hand off to a job.
|
|
60
|
+
attr_accessor :on_submit
|
|
61
|
+
|
|
62
|
+
# Per-IP throttle for the public submission endpoint, as keyword arguments
|
|
63
|
+
# for Rails' rate limiter (Rails 7.2+; ignored on 7.1). Read once when the
|
|
64
|
+
# controller loads — set it in an initializer. nil disables throttling.
|
|
65
|
+
attr_accessor :rate_limit
|
|
66
|
+
|
|
67
|
+
def initialize
|
|
68
|
+
@enabled_environments = %w[development staging]
|
|
69
|
+
@enabled = ->(_request) { true }
|
|
70
|
+
@authorize_admin = ->(_request) { Rails.env.development? }
|
|
71
|
+
@current_user = ->(_request) {}
|
|
72
|
+
@author_label = ->(user) { user.respond_to?(:email) ? user.email : user&.to_s }
|
|
73
|
+
@available_locales = -> { I18n.available_locales.map(&:to_s) }
|
|
74
|
+
@auto_inject = true
|
|
75
|
+
@mount_path = '/i18n_proofreading'
|
|
76
|
+
@show_pill = true
|
|
77
|
+
@pill_label = nil
|
|
78
|
+
@toggle_param = 'i18n_proofreading'
|
|
79
|
+
@on_submit = ->(_suggestion) {}
|
|
80
|
+
@rate_limit = { to: 30, within: 60 }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def environment_enabled?
|
|
84
|
+
enabled_environments.map(&:to_s).include?(Rails.env.to_s)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def suggestions_endpoint
|
|
88
|
+
"#{mount_path.chomp('/')}/suggestions"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nProofreading
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace I18nProofreading
|
|
6
|
+
|
|
7
|
+
initializer 'i18n_proofreading.middleware' do |app|
|
|
8
|
+
app.middleware.use I18nProofreading::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 I18nProofreading.config.environment_enabled? &&
|
|
15
|
+
!I18n.backend.class.include?(I18nProofreading::Marking::Backend)
|
|
16
|
+
I18n.backend.class.prepend(I18nProofreading::Marking::Backend)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
initializer 'i18n_proofreading.helper' do
|
|
21
|
+
ActiveSupport.on_load(:action_view) do
|
|
22
|
+
include I18nProofreading::TagHelper
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nProofreading
|
|
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_proofreading.*` 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_proofreading)(\.|\z)|formats?\z/
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def enabled?
|
|
22
|
+
Thread.current[:i18n_proofreading_marking] || false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def enabled=(value)
|
|
26
|
+
Thread.current[:i18n_proofreading_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
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack'
|
|
4
|
+
|
|
5
|
+
module I18nProofreading
|
|
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 I18nProofreading.available?, so nothing happens in a disabled environment.
|
|
10
|
+
class Middleware
|
|
11
|
+
COOKIE = 'i18n_proofreading'
|
|
12
|
+
|
|
13
|
+
def initialize(app)
|
|
14
|
+
@app = app
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(env)
|
|
18
|
+
request = Rack::Request.new(env)
|
|
19
|
+
available = I18nProofreading.available?(request)
|
|
20
|
+
|
|
21
|
+
# "?i18n_proofreading=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 && I18nProofreading.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(I18nProofreading.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 = I18nProofreading.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: I18nProofreading.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
|