mirror of
https://github.com/discourse/discourse.git
synced 2024-11-25 09:42:07 +08:00
281570226b
Currently, when the MessageFormat compiler fails on some translations, we just have the raw output from the compiler in the logs and that’s not always very helpful. Now, when there is an error, we iterate over the translation keys and try to compile them one by one. When we detect one that is failing, it’s added to a list that is now outputted in the logs. That way, it’s easier to know which keys are not properly translated, and the problems can be addressed quicker. --- The previous implementation of this patch had a bug: it wasn’t handling locales with country/region code properly. So instead of iterating over the problematic keys, it was raising an error.
356 lines
11 KiB
Ruby
356 lines
11 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module JsLocaleHelper
|
|
def self.plugin_client_files(locale_str)
|
|
files = Dir["#{Rails.root}/plugins/*/config/locales/client*.#{locale_str}.yml"]
|
|
I18n::Backend::DiscourseI18n.sort_locale_files(files)
|
|
end
|
|
|
|
def self.reloadable_plugins(locale_sym, ctx)
|
|
return unless Rails.env.development?
|
|
I18n.fallbacks[locale_sym].each do |locale|
|
|
plugin_client_files(locale.to_s).each { |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"] ||= {}
|
|
|
|
if plugin_translations(locale_str)["js"]
|
|
translations[locale_str]["js"].deep_merge!(plugin_translations(locale_str)["js"])
|
|
end
|
|
if plugin_translations(locale_str)["admin_js"]
|
|
translations[locale_str]["admin_js"].deep_merge!(
|
|
plugin_translations(locale_str)["admin_js"],
|
|
)
|
|
end
|
|
if plugin_translations(locale_str)["wizard_js"]
|
|
translations[locale_str]["wizard_js"].deep_merge!(
|
|
plugin_translations(locale_str)["wizard_js"],
|
|
)
|
|
end
|
|
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
|
|
new_hash.delete(key) if checking_hashes.any? { |h| h.include?(key) }
|
|
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.clear_cache!
|
|
@loaded_translations = nil
|
|
@plugin_translations = nil
|
|
@loaded_merges = nil
|
|
end
|
|
|
|
def self.translations_for(locale_str, no_fallback: false)
|
|
clear_cache! if Rails.env.development?
|
|
|
|
locale_sym = locale_str.to_sym
|
|
|
|
translations =
|
|
I18n.with_locale(locale_sym) do
|
|
if locale_sym == :en || no_fallback
|
|
load_translations(locale_sym)
|
|
else
|
|
load_translations_merged(*I18n.fallbacks[locale_sym])
|
|
end
|
|
end
|
|
|
|
Marshal.load(Marshal.dump(translations))
|
|
end
|
|
|
|
def self.output_MF(locale)
|
|
require "messageformat"
|
|
|
|
message_formats =
|
|
I18n.fallbacks[locale]
|
|
.each_with_object(HashWithIndifferentAccess.new) do |l, hash|
|
|
translations = translations_for(l, no_fallback: true)
|
|
hash[l] = remove_message_formats!(translations, l).merge(
|
|
TranslationOverride
|
|
.mf_locales(l)
|
|
.pluck(:translation_key, :value)
|
|
.to_h
|
|
.transform_keys { _1.sub(/^[a-z_]*js\./, "") },
|
|
)
|
|
end
|
|
.compact_blank
|
|
js_message_formats = message_formats.transform_keys(&:dasherize)
|
|
compiled = MessageFormat.compile(js_message_formats.keys, js_message_formats, strict: false)
|
|
transpiled = DiscourseJsProcessor.transpile(<<~JS, "", "discourse-mf")
|
|
import Messages from '@messageformat/runtime/messages';
|
|
#{compiled.sub("export default", "const msgData =")};
|
|
const messages = new Messages(msgData, "#{locale.to_s.dasherize}");
|
|
messages.defaultLocale = "en";
|
|
globalThis.I18n._mfMessages = messages;
|
|
JS
|
|
<<~JS
|
|
#{transpiled}
|
|
require("discourse-mf");
|
|
JS
|
|
rescue => e
|
|
js_locale = locale.to_s.dasherize
|
|
message_formats[locale]
|
|
.filter_map do |key, value|
|
|
next if MessageFormat.compile(js_locale, value, strict: false)
|
|
rescue StandardError
|
|
key
|
|
end
|
|
.then do |strings|
|
|
Rails.logger.error(
|
|
"Failed to compile message formats for #{locale}.\n\nBroken strings are: #{strings.join(", ")}\n\nError: #{e}",
|
|
)
|
|
end
|
|
<<~JS
|
|
console.error("Failed to compile message formats for #{locale}. Some translation strings will be missing.");
|
|
JS
|
|
end
|
|
|
|
def self.output_locale(locale)
|
|
locale_str = locale.to_s
|
|
fallback_locale_str = LocaleSiteSetting.fallback_locale(locale_str)&.to_s
|
|
translations = translations_for(locale_str)
|
|
|
|
remove_message_formats!(translations, locale)
|
|
result = +""
|
|
|
|
translations.keys.each do |l|
|
|
translations[l].keys.each { |k| translations[l].delete(k) unless k == "js" }
|
|
end
|
|
|
|
# I18n
|
|
result << "I18n.translations = #{translations.to_json};\n"
|
|
result << "I18n.locale = '#{locale_str}';\n"
|
|
if fallback_locale_str && fallback_locale_str != "en"
|
|
result << "I18n.fallbackLocale = '#{fallback_locale_str}';\n"
|
|
end
|
|
|
|
# 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
|
|
|
|
def self.output_client_overrides(main_locale)
|
|
locales = I18n.fallbacks[main_locale]
|
|
all_overrides =
|
|
locales
|
|
.each_with_object({}) do |locale, overrides|
|
|
overrides[locale] = TranslationOverride
|
|
.client_locales(locale)
|
|
.pluck(:translation_key, :value)
|
|
.to_h
|
|
end
|
|
.compact_blank
|
|
|
|
return "" if all_overrides.blank?
|
|
|
|
all_overrides.reduce do |(_, main_overrides), (_, fallback_overrides)|
|
|
fallback_overrides.slice!(*fallback_overrides.keys - main_overrides.keys)
|
|
end
|
|
|
|
"I18n._overrides = #{all_overrides.compact_blank.to_json};"
|
|
end
|
|
|
|
def self.output_extra_locales(bundle, locale)
|
|
translations = translations_for(locale)
|
|
locales = translations.keys
|
|
|
|
locales.each do |l|
|
|
translations[l].keys.each do |k|
|
|
bundle_translations = translations[l].delete(k)
|
|
translations[l].deep_merge!(bundle_translations) if k == bundle
|
|
end
|
|
end
|
|
|
|
return "" if translations.blank?
|
|
|
|
output = +"if (!I18n.extras) { I18n.extras = {}; }"
|
|
locales.each do |l|
|
|
translations_json = translations[l].to_json
|
|
output << <<~JS
|
|
if (!I18n.extras["#{l}"]) { I18n.extras["#{l}"] = {}; }
|
|
Object.assign(I18n.extras["#{l}"], #{translations_json});
|
|
JS
|
|
end
|
|
|
|
output
|
|
end
|
|
|
|
MOMENT_LOCALE_MAPPING ||= { "hy" => "hy-am", "ug" => "ug-cn" }
|
|
|
|
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_locale(locale_chain, path, type, fallback_to_english:)
|
|
locale_chain.map!(&:to_s)
|
|
|
|
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 always 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.remove_message_formats!(translations, locale)
|
|
message_formats = {}
|
|
I18n.fallbacks[locale]
|
|
.map(&:to_s)
|
|
.each do |l|
|
|
next unless translations.key?(l)
|
|
|
|
%w[js admin_js].each do |k|
|
|
message_formats.merge!(strip_out_message_formats!(translations[l][k]))
|
|
end
|
|
end
|
|
message_formats
|
|
end
|
|
|
|
def self.strip_out_message_formats!(hash, prefix = "", message_formats = {})
|
|
if hash.is_a?(Hash)
|
|
hash.each do |key, value|
|
|
if value.is_a?(Hash)
|
|
message_formats.merge!(
|
|
strip_out_message_formats!(value, join_key(prefix, key), message_formats),
|
|
)
|
|
elsif key.to_s.end_with?("_MF")
|
|
message_formats[join_key(prefix, key)] = value
|
|
hash.delete(key)
|
|
end
|
|
end
|
|
end
|
|
message_formats
|
|
end
|
|
|
|
def self.join_key(prefix, key)
|
|
prefix.blank? ? key : "#{prefix}.#{key}"
|
|
end
|
|
end
|