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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +214 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +325 -0
  5. data/Rakefile +16 -0
  6. data/app/controllers/i18n_proofreading/application_controller.rb +32 -0
  7. data/app/controllers/i18n_proofreading/suggestions_controller.rb +92 -0
  8. data/app/helpers/i18n_proofreading/tag_helper.rb +19 -0
  9. data/app/models/i18n_proofreading/application_record.rb +7 -0
  10. data/app/models/i18n_proofreading/suggestion.rb +27 -0
  11. data/app/views/i18n_proofreading/suggestions/index.html.erb +77 -0
  12. data/app/views/layouts/i18n_proofreading/application.html.erb +96 -0
  13. data/config/locales/i18n_proofreading.ar.yml +16 -0
  14. data/config/locales/i18n_proofreading.bg.yml +16 -0
  15. data/config/locales/i18n_proofreading.bn.yml +16 -0
  16. data/config/locales/i18n_proofreading.de.yml +16 -0
  17. data/config/locales/i18n_proofreading.el.yml +16 -0
  18. data/config/locales/i18n_proofreading.en.yml +35 -0
  19. data/config/locales/i18n_proofreading.es.yml +16 -0
  20. data/config/locales/i18n_proofreading.fr.yml +16 -0
  21. data/config/locales/i18n_proofreading.hi.yml +16 -0
  22. data/config/locales/i18n_proofreading.hr.yml +16 -0
  23. data/config/locales/i18n_proofreading.id.yml +16 -0
  24. data/config/locales/i18n_proofreading.it.yml +16 -0
  25. data/config/locales/i18n_proofreading.ja.yml +16 -0
  26. data/config/locales/i18n_proofreading.ko.yml +16 -0
  27. data/config/locales/i18n_proofreading.lb.yml +16 -0
  28. data/config/locales/i18n_proofreading.nl.yml +16 -0
  29. data/config/locales/i18n_proofreading.pl.yml +16 -0
  30. data/config/locales/i18n_proofreading.pt.yml +16 -0
  31. data/config/locales/i18n_proofreading.ro.yml +16 -0
  32. data/config/locales/i18n_proofreading.ru.yml +16 -0
  33. data/config/locales/i18n_proofreading.th.yml +16 -0
  34. data/config/locales/i18n_proofreading.tr.yml +16 -0
  35. data/config/locales/i18n_proofreading.uk.yml +16 -0
  36. data/config/locales/i18n_proofreading.ur.yml +16 -0
  37. data/config/locales/i18n_proofreading.vi.yml +16 -0
  38. data/config/locales/i18n_proofreading.zh-CN.yml +16 -0
  39. data/config/routes.rb +13 -0
  40. data/lib/generators/i18n_proofreading/install/install_generator.rb +40 -0
  41. data/lib/generators/i18n_proofreading/install/templates/create_i18n_proofreading_suggestions.rb.tt +22 -0
  42. data/lib/generators/i18n_proofreading/install/templates/initializer.rb +56 -0
  43. data/lib/i18n_proofreading/configuration.rb +91 -0
  44. data/lib/i18n_proofreading/engine.rb +26 -0
  45. data/lib/i18n_proofreading/marking.rb +46 -0
  46. data/lib/i18n_proofreading/middleware.rb +145 -0
  47. data/lib/i18n_proofreading/version.rb +5 -0
  48. data/lib/i18n_proofreading/widget.js +443 -0
  49. data/lib/i18n_proofreading/widget.rb +96 -0
  50. data/lib/i18n_proofreading.rb +37 -0
  51. 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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nProofreading
4
+ VERSION = '0.9.0'
5
+ end