# 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({}) do |l, hash| translations = translations_for(l, no_fallback: true) hash[l.to_s.dasherize] = 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 compiled = MessageFormat.compile(message_formats.keys, 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 Rails.logger.error("Failed to compile message formats for #{locale} '#{e}'") <<~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