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