diff --git a/app/assets/javascripts/locales/i18n.js b/app/assets/javascripts/locales/i18n.js index 61ebac39410..c88b0551604 100644 --- a/app/assets/javascripts/locales/i18n.js +++ b/app/assets/javascripts/locales/i18n.js @@ -52,6 +52,8 @@ I18n.PLACEHOLDER = /(?:\{\{|%\{)(.*?)(?:\}\}?)/gm; I18n.fallbackRules = {}; +I18n.noFallbacks = false; + I18n.pluralizationRules = { en: function(n) { return n === 0 ? ["zero", "none", "other"] : n === 1 ? "one" : "other"; @@ -192,6 +194,15 @@ I18n.interpolate = function(message, options) { I18n.translate = function(scope, options) { options = this.prepareOptions(options); var translation = this.lookup(scope, options); + // Fallback to the default locale + if (!translation && this.currentLocale() !== this.defaultLocale && !this.noFallbacks) { + options.locale = this.defaultLocale; + translation = this.lookup(scope, options); + } + if (!translation && this.currentLocale() !== 'en' && !this.noFallbacks) { + options.locale = 'en'; + translation = this.lookup(scope, options); + } try { if (typeof translation === "object") { @@ -513,6 +524,7 @@ I18n.enable_verbose_localization = function(){ var keys = {}; var t = I18n.t; + I18n.noFallbacks = true; I18n.t = I18n.translate = function(scope, value){ var current = keys[scope]; diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2da6bcdfbad..b1a665be4b1 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -155,6 +155,8 @@ class ApplicationController < ActionController::Base else SiteSetting.default_locale end + + I18n.fallbacks.ensure_loaded! end def store_preloaded(key, json) diff --git a/app/views/common/_discourse_javascript.html.erb b/app/views/common/_discourse_javascript.html.erb index 98332fec1fb..012592f0df0 100644 --- a/app/views/common/_discourse_javascript.html.erb +++ b/app/views/common/_discourse_javascript.html.erb @@ -33,6 +33,7 @@ Discourse.Environment = '<%= Rails.env %>'; Discourse.SiteSettings = PreloadStore.get('siteSettings'); Discourse.LetterAvatarVersion = '<%= LetterAvatar.version %>'; + I18n.defaultLocale = '<%= SiteSetting.default_locale %>'; PreloadStore.get("customEmoji").forEach(function(emoji) { Discourse.Dialect.registerEmoji(emoji.name, emoji.url); }); diff --git a/config/cloud/cloud66/files/production.rb b/config/cloud/cloud66/files/production.rb index b7f03a09774..db662574bee 100644 --- a/config/cloud/cloud66/files/production.rb +++ b/config/cloud/cloud66/files/production.rb @@ -23,11 +23,6 @@ Discourse::Application.configure do # Specifies the header that your server uses for sending files config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx - # Enable locale fallbacks for I18n (makes lookups for any locale fall back to - # the I18n.default_locale when a translation can not be found) - config.i18n.fallbacks = true - - # you may use other configuration here for mail eg: sendgrid config.action_mailer.delivery_method = :smtp diff --git a/config/environments/production.rb b/config/environments/production.rb index 30263174ada..013e37f597f 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -24,10 +24,6 @@ Discourse::Application.configure do config.log_level = :info - # Enable locale fallbacks for I18n (makes lookups for any locale fall back to - # the I18n.default_locale when a translation can not be found) - config.i18n.fallbacks = true - if GlobalSetting.smtp_address settings = { address: GlobalSetting.smtp_address, diff --git a/config/environments/profile.rb b/config/environments/profile.rb index 43531b0d5c1..1784a23528b 100644 --- a/config/environments/profile.rb +++ b/config/environments/profile.rb @@ -27,10 +27,6 @@ Discourse::Application.configure do # Specifies the header that your server uses for sending files config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx - # Enable locale fallbacks for I18n (makes lookups for any locale fall back to - # the I18n.default_locale when a translation can not be found) - config.i18n.fallbacks = true - # we recommend you use mailcatcher https://github.com/sj26/mailcatcher config.action_mailer.smtp_settings = { address: "localhost", port: 1025 } diff --git a/config/initializers/i18n.rb b/config/initializers/i18n.rb new file mode 100644 index 00000000000..00dab6775c8 --- /dev/null +++ b/config/initializers/i18n.rb @@ -0,0 +1,35 @@ +# order: after 02-freedom_patches.rb + +# Include pluralization module +require 'i18n/backend/pluralization' +I18n::Backend::Simple.send(:include, I18n::Backend::Pluralization) + +# Include fallbacks module +require 'i18n/backend/fallbacks' +I18n.backend.class.send(:include, I18n::Backend::Fallbacks) + +# Configure custom fallback order +class FallbackLocaleList < Hash + def [](locale) + # user locale, site locale, english + # TODO - this can be extended to be per-language for a better user experience + # (e.g. fallback zh_TW to zh_CN / vice versa) + [locale, SiteSetting.default_locale.to_sym, :en].uniq.compact + end + + def ensure_loaded! + self[I18n.locale].each { |l| I18n.ensure_loaded! l } + end +end + +class NoFallbackLocaleList < FallbackLocaleList + def [](locale) + [locale] + end +end + +if Rails.env.production? + I18n.fallbacks = FallbackLocaleList.new +else + I18n.fallbacks = NoFallbackLocaleList.new +end diff --git a/config/initializers/pluralization.rb b/config/initializers/pluralization.rb deleted file mode 100644 index 0a567d44515..00000000000 --- a/config/initializers/pluralization.rb +++ /dev/null @@ -1,2 +0,0 @@ -require "i18n/backend/pluralization" -I18n::Backend::Simple.send(:include, I18n::Backend::Pluralization) diff --git a/lib/freedom_patches/translate_accelerator.rb b/lib/freedom_patches/translate_accelerator.rb index 95f9f9d0b12..7b3fc986a6c 100644 --- a/lib/freedom_patches/translate_accelerator.rb +++ b/lib/freedom_patches/translate_accelerator.rb @@ -59,6 +59,11 @@ module I18n end end + def ensure_loaded!(locale) + @loaded_locales ||= [] + load_locale locale unless @loaded_locales.include?(locale) + end + def translate(key, *args) load_locale(config.locale) unless @loaded_locales.include?(config.locale) return translate_no_cache(key, *args) if args.length > 0 diff --git a/lib/js_locale_helper.rb b/lib/js_locale_helper.rb index 2211015300e..22f7ebb835f 100644 --- a/lib/js_locale_helper.rb +++ b/lib/js_locale_helper.rb @@ -1,30 +1,94 @@ module JsLocaleHelper - def self.output_locale(locale, translations = nil) - current_locale = I18n.locale - I18n.locale = locale.to_sym + def self.load_translations(locale) + @loaded_translations ||= HashWithIndifferentAccess.new + @loaded_translations[locale] ||= begin + locale_str = locale.to_s + # load default translations + translations = YAML::load(File.open("#{Rails.root}/config/locales/client.#{locale_str}.yml")) + # load plugins translations + plugin_translations = {} + Dir["#{Rails.root}/plugins/*/config/locales/client.#{locale_str}.yml"].each do |file| + plugin_translations.deep_merge! YAML::load(File.open(file)) + end + + # merge translations (plugin translations overwrite default translations) + translations[locale_str]['js'].deep_merge!(plugin_translations[locale_str]['js']) if translations[locale_str] && plugin_translations[locale_str] && plugin_translations[locale_str]['js'] + + # We used to split the admin versus the client side, but it's much simpler to just + # include both for now due to the small size of the admin section. + # + # For now, let's leave it split out in the translation file in case we want to split + # it again later, so we'll merge the JSON ourselves. + admin_contents = translations[locale_str].delete('admin_js') + translations[locale_str]['js'].deep_merge!(admin_contents) if admin_contents.present? + translations[locale_str]['js'].deep_merge!(plugin_translations[locale_str]['admin_js']) if translations[locale_str] && plugin_translations[locale_str] && plugin_translations[locale_str]['admin_js'] + + translations + end + end + + # purpose-built recursive algorithm ahoy! + def self.deep_delete_matches(deleting_from, *checking_hashes) + checking_hashes.compact! + + new_hash = deleting_from.dup + deleting_from.each do |key, value| + if value.is_a? Hash + # Recurse + new_at_key = deep_delete_matches(deleting_from[key], *(checking_hashes.map {|h| h[key]})) + if new_at_key.empty? + new_hash.delete key + else + new_hash[key] = new_at_key + end + else + if checking_hashes.any? {|h| h.include? key} + new_hash.delete key + end + end + end + new_hash + end + + def self.load_translations_merged(*locales) + @loaded_merges ||= {} + @loaded_merges[locales.join('-')] ||= begin + all_translations = {} + merged_translations = {} + loaded_locales = [] + + locales.map(&:to_s).each do |locale| + all_translations[locale] = JsLocaleHelper.load_translations locale + merged_translations[locale] = deep_delete_matches(all_translations[locale][locale], *loaded_locales.map { |l| merged_translations[l] }) + loaded_locales << locale + end + merged_translations + end + end + + def self.output_locale(locale) + locale_sym = locale.to_sym locale_str = locale.to_s - # load default translations - translations ||= YAML::load(File.open("#{Rails.root}/config/locales/client.#{locale_str}.yml")) - # load plugins translations - plugin_translations = {} - Dir["#{Rails.root}/plugins/*/config/locales/client.#{locale_str}.yml"].each do |file| - plugin_translations.deep_merge! YAML::load(File.open(file)) + current_locale = I18n.locale + I18n.locale = locale_sym + + site_locale = SiteSetting.default_locale.to_sym + + if Rails.env.development? + translations = load_translations(locale_sym) + else + if locale_sym == :en + translations = load_translations(locale_sym) + elsif locale_sym == site_locale || site_locale == :en + translations = load_translations_merged(locale_sym, :en) + else + translations = load_translations_merged(locale_sym, site_locale, :en) + end end - # merge translations (plugin translations overwrite default translations) - translations[locale_str]['js'].deep_merge!(plugin_translations[locale_str]['js']) if translations[locale_str] && plugin_translations[locale_str] && plugin_translations[locale_str]['js'] - - # We used to split the admin versus the client side, but it's much simpler to just - # include both for now due to the small size of the admin section. - # - # For now, let's leave it split out in the translation file in case we want to split - # it again later, so we'll merge the JSON ourselves. - admin_contents = translations[locale_str].delete('admin_js') - translations[locale_str]['js'].deep_merge!(admin_contents) if admin_contents.present? - translations[locale_str]['js'].deep_merge!(plugin_translations[locale_str]['admin_js']) if translations[locale_str] && plugin_translations[locale_str] && plugin_translations[locale_str]['admin_js'] message_formats = strip_out_message_formats!(translations[locale_str]['js']) result = generate_message_format(message_formats, locale_str) diff --git a/spec/components/js_locale_helper_spec.rb b/spec/components/js_locale_helper_spec.rb index a6da4a3a631..18d73d2df2d 100644 --- a/spec/components/js_locale_helper_spec.rb +++ b/spec/components/js_locale_helper_spec.rb @@ -2,6 +2,24 @@ require 'spec_helper' require_dependency 'js_locale_helper' describe JsLocaleHelper do + + module StubLoadTranslations + def set_translations(locale, translations) + @loaded_translations ||= HashWithIndifferentAccess.new + @loaded_translations[locale] = translations + end + + def clear_cache! + @loaded_translations = nil + @loaded_merges = nil + end + end + JsLocaleHelper.extend StubLoadTranslations + + after do + JsLocaleHelper.clear_cache! + end + it 'should be able to generate translations' do expect(JsLocaleHelper.output_locale('en').length).to be > 0 end @@ -57,21 +75,23 @@ describe JsLocaleHelper do it 'handles message format special keys' do ctx = V8::Context.new ctx.eval("I18n = {};") - ctx.eval(JsLocaleHelper.output_locale('en', - { - "en" => - { - "js" => { - "hello" => "world", - "test_MF" => "{HELLO} {COUNT, plural, one {1 duck} other {# ducks}}", - "error_MF" => "{{BLA}", - "simple_MF" => "{COUNT, plural, one {1} other {#}}" - } - } - })) - expect(ctx.eval('I18n.translations')["en"]["js"]["hello"]).to eq("world") - expect(ctx.eval('I18n.translations')["en"]["js"]["test_MF"]).to eq(nil) + JsLocaleHelper.set_translations 'en', { + "en" => + { + "js" => { + "hello" => "world", + "test_MF" => "{HELLO} {COUNT, plural, one {1 duck} other {# ducks}}", + "error_MF" => "{{BLA}", + "simple_MF" => "{COUNT, plural, one {1} other {#}}" + } + } + } + + ctx.eval(JsLocaleHelper.output_locale('en')) + + expect(ctx.eval('I18n.translations["en"]["js"]["hello"]')).to eq("world") + expect(ctx.eval('I18n.translations["en"]["js"]["test_MF"]')).to eq(nil) expect(ctx.eval('I18n.messageFormat("test_MF", { HELLO: "hi", COUNT: 3 })')).to eq("hi 3 ducks") expect(ctx.eval('I18n.messageFormat("error_MF", { HELLO: "hi", COUNT: 3 })')).to match(/Invalid Format/) @@ -84,6 +104,67 @@ describe JsLocaleHelper do expect(message).not_to match 'Plural Function not found' end + it 'performs fallbacks to english if a translation is not available' do + JsLocaleHelper.set_translations 'en', { + "en" => { + "js" => { + "only_english" => "1-en", + "english_and_site" => "3-en", + "english_and_user" => "5-en", + "all_three" => "7-en", + } + } + } + JsLocaleHelper.set_translations 'ru', { + "ru" => { + "js" => { + "only_site" => "2-ru", + "english_and_site" => "3-ru", + "site_and_user" => "6-ru", + "all_three" => "7-ru", + } + } + } + JsLocaleHelper.set_translations 'uk', { + "uk" => { + "js" => { + "only_user" => "4-uk", + "english_and_user" => "5-uk", + "site_and_user" => "6-uk", + "all_three" => "7-uk", + } + } + } + + expected = { + "none" => "[uk.js.none]", + "only_english" => "1-en", + "only_site" => "2-ru", + "english_and_site" => "3-ru", + "only_user" => "4-uk", + "english_and_user" => "5-uk", + "site_and_user" => "6-uk", + "all_three" => "7-uk", + } + + SiteSetting.default_locale = 'ru' + I18n.locale = :uk + + ctx = V8::Context.new + ctx.eval('var window = this;') + ctx.load(Rails.root + 'app/assets/javascripts/locales/i18n.js') + ctx.eval(JsLocaleHelper.output_locale(I18n.locale)) + ctx.eval('I18n.defaultLocale = "ru";') + + # Test - unneeded translations are not emitted + expect(ctx.eval('I18n.translations.en.js').keys).to eq(["only_english"]) + expect(ctx.eval('I18n.translations.ru.js').keys).to eq(["only_site", "english_and_site"]) + + expected.each do |key, expect| + expect(ctx.eval("I18n.t(#{"js.#{key}".inspect})")).to eq(expect) + end + end + LocaleSiteSetting.values.each do |locale| it "generates valid date helpers for #{locale[:value]} locale" do js = JsLocaleHelper.output_locale(locale[:value])