discourse/lib/js_locale_helper.rb
Loïc Guitaut 281570226b DEV: Output failing MF keys when compilation fails
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.
2024-10-15 12:45:23 +02:00

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