From 51f2f032d7848f0b9a310062d098b424d2fd8d17 Mon Sep 17 00:00:00 2001 From: benjamin wil Date: Sun, 27 Jun 2021 09:26:45 -0700 Subject: [PATCH] Add I18n interface for use in Mustache templates (#1679) * Add `i18n` dependency We will use `i18n` to provide localization for Gollum's frontend. I chose this because it's a well-supported, pretty normal Ruby library. * Configure I18n - Locale files will be kept in `lib/gollum/locales/[lang].yml` - The available locales, to start, will be English (`en`). * Add I18n interface for mustache templates This commit adds an interface that allows mustache templates to get I18n translation strings, transform any arguments that may be present in them, and then render them on the frontend. This is our first real step to getting internationalizing the Gollum frontend. --- gollum.gemspec | 1 + lib/gollum.rb | 7 +- lib/gollum/app.rb | 1 + lib/gollum/views/helpers/locale_helpers.rb | 82 +++++++++++++ lib/gollum/views/layout.rb | 3 +- test/gollum/views/test_locale_helper.rb | 134 +++++++++++++++++++++ test/helper.rb | 3 +- test/support/locales/de.yml | 8 ++ test/support/locales/en.yml | 8 ++ 9 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 lib/gollum/views/helpers/locale_helpers.rb create mode 100644 test/gollum/views/test_locale_helper.rb create mode 100644 test/support/locales/de.yml create mode 100644 test/support/locales/en.yml diff --git a/gollum.gemspec b/gollum.gemspec index d6e96522..341b54a1 100644 --- a/gollum.gemspec +++ b/gollum.gemspec @@ -39,6 +39,7 @@ Gem::Specification.new do |s| s.add_dependency 'rss', '~> 0.2.9' s.add_dependency 'therubyrhino', '~> 2.1.0' s.add_dependency 'webrick', '~> 1.7' + s.add_dependency 'i18n', '~> 1.8' s.add_development_dependency 'rack-test', '~> 0.6.3' s.add_development_dependency 'shoulda', '~> 3.6.0' diff --git a/lib/gollum.rb b/lib/gollum.rb index 5888b000..6ac88631 100644 --- a/lib/gollum.rb +++ b/lib/gollum.rb @@ -5,6 +5,7 @@ require 'digest/sha1' require 'ostruct' # external +require 'i18n' require 'github/markup' require 'rhino' if RUBY_PLATFORM == 'java' @@ -14,10 +15,13 @@ require File.expand_path('../gollum/uri_encode_component', __FILE__) module Gollum VERSION = '5.2.3' + ::I18n.available_locales = [:en] + ::I18n.load_path = Dir[File.expand_path("lib/gollum/locales") + "/*.yml"] + def self.assets_path ::File.expand_path('gollum/public', ::File.dirname(__FILE__)) end - + class TemplateFilter @@filters = {} @@ -32,5 +36,4 @@ module Gollum data end end - end diff --git a/lib/gollum/app.rb b/lib/gollum/app.rb index 09ec93e0..cfe66cf4 100644 --- a/lib/gollum/app.rb +++ b/lib/gollum/app.rb @@ -15,6 +15,7 @@ require 'pathname' require 'gollum' require 'gollum/assets' require 'gollum/views/helpers' +require 'gollum/views/helpers/locale_helpers' require 'gollum/views/layout' require 'gollum/views/editable' require 'gollum/views/has_page' diff --git a/lib/gollum/views/helpers/locale_helpers.rb b/lib/gollum/views/helpers/locale_helpers.rb new file mode 100644 index 00000000..2f9fb2e7 --- /dev/null +++ b/lib/gollum/views/helpers/locale_helpers.rb @@ -0,0 +1,82 @@ +module Precious + module Views + module LocaleHelpers + NO_METHOD_MESSAGE = 'Argument must be a view method' + YAML_VARIABLE_REGEXP = /\%\{[\w]+\}/ + + # Returns all I18n translation strings for the current view class. + # This method support YAML arguments. For example: + # + # last_edited: This content was last edited at %{date}. + # + # Where the `date` argument must be a method available on the current + # class. + # + # Use this interface within Mustache templates to render any user + # interface strings in the current locale. For example: + # + # {{ t.last_edited }} + # + def t + autofill I18n.t(locale_klass_name) + end + + private + + # Recursively looks up I18n translation values and autofills any YAML + # arguments with the return value of the current class's matching method. + # + # When a translation value with an argument has no matching method, we + # then return that value transformed to include the `no_method_message` + # + def autofill(yaml) + yaml.map { |i18n_key, i18n_value| + if i18n_value.is_a? Hash + [i18n_key, autofill(i18n_value)] + elsif has_arguments?(i18n_value) + fill_argument_content(i18n_key, i18n_value) + else + [i18n_key, i18n_value] + end + }.to_h + end + + def fill_argument_content(i18n_key, i18n_value) + i18n_value.gsub!(YAML_VARIABLE_REGEXP) do |argument| + method_name = argument.gsub(/[^\w]/, '') + + next if method_name.nil? + + begin + self.public_send(method_name) + rescue NoMethodError => error + no_method_message(method_name) + end + end + + [i18n_key, i18n_value] + end + + def has_arguments?(i18n_value) + i18n_value.match?(YAML_VARIABLE_REGEXP) + end + + # Returns the current class name in a format that is acceptable in YAML. + # To summarize its function: + # + # NameOfConstant => name_of_constant + # + def locale_klass_name + @locale_klass_name ||= self.class.name.gsub(/::/, '/'). + gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2'). + gsub(/([a-z\d])([A-Z])/,'\1_\2'). + tr('-', '_'). + downcase + end + + def no_method_message(method_name, message = NO_METHOD_MESSAGE) + "[#{message}: #{method_name}]" + end + end + end +end diff --git a/lib/gollum/views/layout.rb b/lib/gollum/views/layout.rb index 4881de36..e653a1d6 100644 --- a/lib/gollum/views/layout.rb +++ b/lib/gollum/views/layout.rb @@ -6,10 +6,11 @@ module Precious include Rack::Utils include Sprockets::Helpers include Precious::Views::AppHelpers + include Precious::Views::LocaleHelpers include Precious::Views::SprocketsHelpers include Precious::Views::RouteHelpers include Precious::Views::OcticonHelpers - + alias_method :h, :escape_html attr_reader :name, :path diff --git a/test/gollum/views/test_locale_helper.rb b/test/gollum/views/test_locale_helper.rb new file mode 100644 index 00000000..b82f8607 --- /dev/null +++ b/test/gollum/views/test_locale_helper.rb @@ -0,0 +1,134 @@ +require_relative "../../helper" +require_relative "../../../lib/gollum/views/helpers" + +describe Precious::Views::LocaleHelpers do + class TestClass < Mustache + include Precious::Views::LocaleHelpers + + def author + "J.R.R." + end + + def location + "Bloemfontein" + end + end + + def setup + ::I18n.available_locales = [:en, :de] + ::I18n.load_path = Dir[File.expand_path("test/support/locales" + "/*.yml")] + end + + def teardown + I18n.locale = :en + end + + let(:dummy_instance) { TestClass.new } + + describe "#t" do + describe "mustache usage" do + let(:subject) { dummy_instance.render(mustache_template) } + + let(:mustache_template) { "{{ t.hello_world }}" } + + describe "in the default locale" do + it "returns the translation string" do + _(subject).must_equal "Hello world" + end + end + + describe "in the configured locale" do + it "returns the translation string" do + I18n.locale = :de + + _(subject).must_equal "Hallo Welt" + end + end + + describe "translations with YAML arguments" do + let(:mustache_template) { "{{ t.author_info.full }}" } + + describe "in the default locale" do + it "autofills YAML arguments" do + _(subject).must_equal "Author J.R.R. is from Bloemfontein" + end + end + + describe "in the configured locale" do + it "autofills YAML arguments" do + I18n.locale = :de + + _(subject).must_equal "Autor J.R.R. ist vom Bloemfontein" + end + end + end + + describe "translations with invalid arguments" do + let(:mustache_template) { "{{ t.has_invalid_argument }}" } + + it "fails gracefully with embedded error message" do + expected_string = "Welcome to " \ + "[#{TestClass::NO_METHOD_MESSAGE}: no_matching_method]" + + _(subject).must_equal expected_string + end + end + + describe "out of scope translations" do + let(:mustache_template) { "{{ t.never_called }}" } + + it "does not include translation keys from other classes" do + _(subject).must_be_empty + end + end + + describe "missing translations" do + let(:mustache_template) { "{{ t.nested.nonexistent_key }}" } + + it "outputs an empty string" do + _(subject).must_be_empty + end + end + end + + describe "usage" do + let(:subject) { dummy_instance.t } + + it "returns a hash" do + _(subject).must_be_kind_of Hash + end + + it "returns translation keys under 'test_class'" do + i18n_keys = I18n.t("test_class").keys + + _(subject.keys).must_equal i18n_keys + end + + it "does not return translation keys under other classes" do + other_i18n_keys = I18n.t("nonexistant_test_class").keys + + _(subject.keys).wont_include other_i18n_keys + end + + it "returns nested keys" do + nested_keys = subject[:author_info].keys + + _(nested_keys).must_equal [:full] + end + + describe "auto-filled YAML arguments" do + let(:subject) { dummy_instance.t[:author_info][:full] } + + it "auto-fills in the default locale" do + _(subject).must_equal "Author J.R.R. is from Bloemfontein" + end + + it "auto-fills in a configured locale" do + I18n.locale = :de + + _(subject).must_equal "Autor J.R.R. ist vom Bloemfontein" + end + end + end + end +end diff --git a/test/helper.rb b/test/helper.rb index dda0a37e..17c0ec92 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -5,6 +5,7 @@ require 'shoulda' require 'mocha/setup' require 'fileutils' require 'minitest/reporters' +require 'minitest/spec' require 'twitter_cldr' require 'tmpdir' @@ -93,4 +94,4 @@ def context(*args, &block) klass.class_eval &block end -$contexts = [] \ No newline at end of file +$contexts = [] diff --git a/test/support/locales/de.yml b/test/support/locales/de.yml new file mode 100644 index 00000000..8848e530 --- /dev/null +++ b/test/support/locales/de.yml @@ -0,0 +1,8 @@ +de: + test_class: + author_info: + full: Autor %{author} ist vom %{location} + has_invalid_argument: Willkommen in %{no_matching_method} + hello_world: Hallo Welt + nonexistant_test_class: + never_called: Nie angerufen diff --git a/test/support/locales/en.yml b/test/support/locales/en.yml new file mode 100644 index 00000000..7dc9de0f --- /dev/null +++ b/test/support/locales/en.yml @@ -0,0 +1,8 @@ +en: + test_class: + author_info: + full: Author %{author} is from %{location} + has_invalid_argument: Welcome to %{no_matching_method} + hello_world: Hello world + nonexistant_test_class: + never_called: Never called