mirror of
https://github.com/discourse/discourse.git
synced 2025-05-09 23:33:07 +08:00

Previously all locale bundles would be built & compressed during assets:precompile. For most sites, only one of these languages was actually used, so this is fairly wasteful. This commit moves the main locale bundle into the ExtraLocalesController, which has recently undergone many improvements to make it more efficient. This allows locale files to be bundled "just in time" when they're first accessed. Now that brotli level=6 is enabled for these assets in our nginx config, this change should have no impact on the locale bundle size.
359 lines
11 KiB
Ruby
359 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 = +<<~JS
|
|
require("discourse/loader-shims");
|
|
require("discourse-i18n");
|
|
JS
|
|
|
|
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
|
|
|
|
result << <<~JS
|
|
require("discourse/lib/load-moment");
|
|
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}/node_modules/@discourse/moment-timezone-names-translations/locales"
|
|
type = :moment_js_timezones
|
|
else
|
|
path = "#{Rails.root}/app/assets/javascripts/discourse/node_modules/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
|