mirror of
https://github.com/discourse/discourse.git
synced 2024-11-23 01:16:38 -06:00
30990006a9
This reduces chances of errors where consumers of strings mutate inputs and reduces memory usage of the app. Test suite passes now, but there may be some stuff left, so we will run a few sites on a branch prior to merging
287 lines
9.5 KiB
Ruby
287 lines
9.5 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module JsLocaleHelper
|
|
|
|
def self.plugin_client_files(locale_str)
|
|
Dir["#{Rails.root}/plugins/*/config/locales/client.#{locale_str}.yml"]
|
|
end
|
|
|
|
def self.reloadable_plugins(locale, ctx)
|
|
return unless Rails.env.development?
|
|
plugin_client_files(locale.to_s).each do |file|
|
|
ctx.depend_on(file)
|
|
end
|
|
end
|
|
|
|
def self.plugin_translations(locale_str)
|
|
@plugin_translations ||= HashWithIndifferentAccess.new
|
|
|
|
@plugin_translations[locale_str] ||= begin
|
|
translations = {}
|
|
|
|
plugin_client_files(locale_str).each do |file|
|
|
if plugin_translations = YAML.load_file(file)[locale_str]
|
|
translations.deep_merge!(plugin_translations)
|
|
end
|
|
end
|
|
|
|
translations
|
|
end
|
|
end
|
|
|
|
def self.load_translations(locale)
|
|
@loaded_translations ||= HashWithIndifferentAccess.new
|
|
@loaded_translations[locale] ||= begin
|
|
locale_str = locale.to_s
|
|
|
|
# load default translations
|
|
yml_file = "#{Rails.root}/config/locales/client.#{locale_str}.yml"
|
|
if File.exist?(yml_file)
|
|
translations = YAML.load_file(yml_file)
|
|
else
|
|
# If we can't find a base file in Discourse, it might only exist in a plugin
|
|
# so let's start with a basic object we can merge into
|
|
translations = {
|
|
locale_str => {
|
|
'js' => {},
|
|
'admin_js' => {},
|
|
'wizard_js' => {}
|
|
}
|
|
}
|
|
end
|
|
|
|
# merge translations (plugin translations overwrite default translations)
|
|
if translations[locale_str] && plugin_translations(locale_str)
|
|
translations[locale_str]['js'] ||= {}
|
|
translations[locale_str]['admin_js'] ||= {}
|
|
translations[locale_str]['wizard_js'] ||= {}
|
|
|
|
translations[locale_str]['js'].deep_merge!(plugin_translations(locale_str)['js']) if plugin_translations(locale_str)['js']
|
|
translations[locale_str]['admin_js'].deep_merge!(plugin_translations(locale_str)['admin_js']) if plugin_translations(locale_str)['admin_js']
|
|
translations[locale_str]['wizard_js'].deep_merge!(plugin_translations(locale_str)['wizard_js']) if plugin_translations(locale_str)['wizard_js']
|
|
end
|
|
|
|
translations
|
|
end
|
|
end
|
|
|
|
# deeply removes keys from "deleting_from" that are already present in "checking_hashes"
|
|
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)
|
|
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)
|
|
locales = locales.uniq.compact
|
|
@loaded_merges ||= {}
|
|
@loaded_merges[locales.join('-')] ||= begin
|
|
all_translations = {}
|
|
merged_translations = {}
|
|
loaded_locales = []
|
|
|
|
locales.map(&:to_s).each do |locale|
|
|
all_translations[locale] = 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.translations_for(locale_str)
|
|
current_locale = I18n.locale
|
|
locale_sym = locale_str.to_sym
|
|
site_locale = SiteSetting.default_locale.to_sym
|
|
fallback_locale = LocaleSiteSetting.fallback_locale(locale_str)
|
|
|
|
I18n.locale = locale_sym
|
|
|
|
if Rails.env.development?
|
|
@loaded_translations = nil
|
|
@plugin_translations = nil
|
|
@loaded_merges = nil
|
|
end
|
|
|
|
translations =
|
|
if locale_sym == :en
|
|
load_translations(locale_sym)
|
|
elsif locale_sym == site_locale || site_locale == :en
|
|
load_translations_merged(locale_sym, fallback_locale, :en)
|
|
else
|
|
load_translations_merged(locale_sym, fallback_locale, site_locale, :en)
|
|
end
|
|
|
|
I18n.locale = current_locale
|
|
|
|
translations
|
|
end
|
|
|
|
def self.output_locale(locale)
|
|
locale_str = locale.to_s
|
|
fallback_locale_str = LocaleSiteSetting.fallback_locale(locale_str)&.to_s
|
|
translations = Marshal.load(Marshal.dump(translations_for(locale_str)))
|
|
|
|
message_formats = strip_out_message_formats!(translations[locale_str]['js'])
|
|
message_formats.merge!(strip_out_message_formats!(translations[locale_str]['admin_js']))
|
|
mf_locale, mf_filename = find_message_format_locale([locale_str], fallback_to_english: true)
|
|
result = generate_message_format(message_formats, mf_locale, mf_filename)
|
|
|
|
translations.keys.each do |l|
|
|
translations[l].keys.each do |k|
|
|
translations[l].delete(k) unless k == "js"
|
|
end
|
|
end
|
|
|
|
# I18n
|
|
result << "I18n.translations = #{translations.to_json};\n"
|
|
result << "I18n.locale = '#{locale_str}';\n"
|
|
result << "I18n.fallbackLocale = '#{fallback_locale_str}';\n" if fallback_locale_str && fallback_locale_str != "en"
|
|
result << "I18n.pluralizationRules.#{locale_str} = MessageFormat.locale.#{mf_locale};\n" if mf_locale != "en"
|
|
|
|
# moment
|
|
result << File.read("#{Rails.root}/vendor/assets/javascripts/moment.js")
|
|
result << File.read("#{Rails.root}/vendor/assets/javascripts/moment-timezone-with-data.js")
|
|
result << moment_locale(locale_str)
|
|
result << moment_locale(locale_str, timezone_names: true)
|
|
result << moment_formats
|
|
|
|
result
|
|
end
|
|
|
|
MOMENT_LOCALE_MAPPING ||= {
|
|
"hy" => "hy-am"
|
|
}
|
|
|
|
def self.find_moment_locale(locale_chain, timezone_names: false)
|
|
if timezone_names
|
|
path = "#{Rails.root}/vendor/assets/javascripts/moment-timezone-names-locale"
|
|
type = :moment_js_timezones
|
|
else
|
|
path = "#{Rails.root}/vendor/assets/javascripts/moment-locale"
|
|
type = :moment_js
|
|
end
|
|
|
|
find_locale(locale_chain, path, type, fallback_to_english: false) do |locale|
|
|
locale = MOMENT_LOCALE_MAPPING[locale] if MOMENT_LOCALE_MAPPING.key?(locale)
|
|
# moment.js uses a different naming scheme for locale files
|
|
locale.tr('_', '-').downcase
|
|
end
|
|
|
|
end
|
|
|
|
def self.find_message_format_locale(locale_chain, fallback_to_english:)
|
|
path = "#{Rails.root}/lib/javascripts/locale"
|
|
find_locale(locale_chain, path, :message_format, fallback_to_english: fallback_to_english)
|
|
end
|
|
|
|
def self.find_locale(locale_chain, path, type, fallback_to_english:)
|
|
locale_chain.each do |locale|
|
|
plugin_locale = DiscoursePluginRegistry.locales[locale]
|
|
return plugin_locale[type] if plugin_locale&.has_key?(type)
|
|
|
|
locale = yield(locale) if block_given?
|
|
filename = File.join(path, "#{locale}.js")
|
|
return [locale, filename] if File.exist?(filename)
|
|
end
|
|
|
|
locale_chain.map! { |locale| yield(locale) } if block_given?
|
|
|
|
# try again, but this time only with the language itself
|
|
locale_chain = locale_chain.map { |l| l.split(/[-_]/)[0] }
|
|
.uniq.reject { |l| locale_chain.include?(l) }
|
|
|
|
if locale_chain.any?
|
|
locale_data = find_locale(locale_chain, path, type, fallback_to_english: false)
|
|
return locale_data if locale_data
|
|
end
|
|
|
|
# English should alyways work
|
|
["en", File.join(path, "en.js")] if fallback_to_english
|
|
end
|
|
|
|
def self.moment_formats
|
|
result = +""
|
|
result << moment_format_function('short_date_no_year')
|
|
result << moment_format_function('short_date')
|
|
result << moment_format_function('long_date')
|
|
result << "moment.fn.relativeAge = function(opts){ return Discourse.Formatter.relativeAge(this.toDate(), opts)};\n"
|
|
end
|
|
|
|
def self.moment_format_function(name)
|
|
format = I18n.t("dates.#{name}")
|
|
"moment.fn.#{name.camelize(:lower)} = function(){ return this.format('#{format}'); };\n"
|
|
end
|
|
|
|
def self.moment_locale(locale, timezone_names: false)
|
|
_, filename = find_moment_locale([locale], timezone_names: timezone_names)
|
|
filename && File.exist?(filename) ? File.read(filename) << "\n" : ""
|
|
end
|
|
|
|
def self.generate_message_format(message_formats, locale, filename)
|
|
formats = message_formats.map { |k, v| k.inspect << " : " << compile_message_format(filename, locale, v) }.join(", ")
|
|
|
|
result = +"MessageFormat = {locale: {}};\n"
|
|
result << "I18n._compiledMFs = {#{formats}};\n"
|
|
result << File.read(filename) << "\n"
|
|
result << File.read("#{Rails.root}/lib/javascripts/messageformat-lookup.js") << "\n"
|
|
end
|
|
|
|
def self.reset_context
|
|
@ctx&.dispose
|
|
@ctx = nil
|
|
end
|
|
|
|
@mutex = Mutex.new
|
|
def self.with_context
|
|
@mutex.synchronize do
|
|
yield @ctx ||= begin
|
|
ctx = MiniRacer::Context.new(timeout: 15000)
|
|
ctx.load("#{Rails.root}/lib/javascripts/messageformat.js")
|
|
ctx
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.compile_message_format(path, locale, format)
|
|
with_context do |ctx|
|
|
ctx.load(path) if File.exist?(path)
|
|
ctx.eval("mf = new MessageFormat('#{locale}');")
|
|
ctx.eval("mf.precompile(mf.parse(#{format.inspect}))")
|
|
end
|
|
rescue MiniRacer::EvalError => e
|
|
message = +"Invalid Format: " << e.message
|
|
"function(){ return #{message.inspect};}"
|
|
end
|
|
|
|
def self.strip_out_message_formats!(hash, prefix = "", rval = {})
|
|
if hash.is_a?(Hash)
|
|
hash.each do |key, value|
|
|
if value.is_a?(Hash)
|
|
rval.merge!(strip_out_message_formats!(value, prefix + (prefix.length > 0 ? "." : "") << key, rval))
|
|
elsif key.to_s.end_with?("_MF")
|
|
rval[prefix + (prefix.length > 0 ? "." : "") << key] = value
|
|
hash.delete(key)
|
|
end
|
|
end
|
|
end
|
|
rval
|
|
end
|
|
|
|
end
|