mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
FEATURE: Support for localized themes (#6848)
- Themes can supply translation files in a format like `/locales/{locale}.yml`. These files should be valid YAML, with a single top level key equal to the locale being defined. For now these can only be defined using the `discourse_theme` CLI, importing a `.tar.gz`, or from a GIT repository.
- Fallback is handled on a global level (if the locale is not defined in the theme), as well as on individual keys (if some keys are missing from the selected interface language).
- Administrators can override individual keys on a per-theme basis in the /admin/customize/themes user interface.
- Theme developers should access defined translations using the new theme prefix variables:
JavaScript: `I18n.t(themePrefix("my_translation_key"))`
Handlebars: `{{theme-i18n "my_translation_key"}}` or `{{i18n (theme-prefix "my_translation_key")}}`
- To design for backwards compatibility, theme developers can check for the presence of the `themePrefix` variable in JavaScript
- As part of this, the old `{{themeSetting.setting_name}}` syntax is deprecated in favour of `{{theme-setting "setting_name"}}`
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
require_dependency 'theme_settings_parser'
|
||||
require_dependency 'theme_translation_parser'
|
||||
require_dependency 'theme_javascript_compiler'
|
||||
|
||||
class ThemeField < ActiveRecord::Base
|
||||
|
||||
@@ -11,9 +13,28 @@ class ThemeField < ActiveRecord::Base
|
||||
where(theme_id: theme_ids)
|
||||
.joins(
|
||||
"JOIN (
|
||||
SELECT #{theme_ids.map.with_index { |id, idx| "#{id.to_i} AS theme_id, #{idx} AS sort_column" }.join(" UNION ALL SELECT ")}
|
||||
SELECT #{theme_ids.map.with_index { |id, idx| "#{id.to_i} AS theme_id, #{idx} AS theme_sort_column" }.join(" UNION ALL SELECT ")}
|
||||
) as X ON X.theme_id = theme_fields.theme_id")
|
||||
.order("sort_column")
|
||||
.order("theme_sort_column")
|
||||
}
|
||||
|
||||
scope :find_locale_fields, ->(theme_ids, locale_codes) {
|
||||
return none unless theme_ids.present? && locale_codes.present?
|
||||
|
||||
find_by_theme_ids(theme_ids)
|
||||
.where(target_id: Theme.targets[:translations], name: locale_codes)
|
||||
.joins(self.sanitize_sql_array([
|
||||
"JOIN (
|
||||
SELECT * FROM (VALUES #{locale_codes.map { "(?)" }.join(",")}) as Y (locale_code, locale_sort_column)
|
||||
) as Y ON Y.locale_code = theme_fields.name",
|
||||
*locale_codes.map.with_index { |code, index| [code, index] }
|
||||
]))
|
||||
.reorder("X.theme_sort_column", "Y.locale_sort_column")
|
||||
}
|
||||
|
||||
scope :find_first_locale_fields, ->(theme_ids, locale_codes) {
|
||||
find_locale_fields(theme_ids, locale_codes)
|
||||
.select("DISTINCT ON (X.theme_sort_column) *")
|
||||
}
|
||||
|
||||
def self.types
|
||||
@@ -39,110 +60,125 @@ class ThemeField < ActiveRecord::Base
|
||||
validates :name, format: { with: /\A[a-z_][a-z0-9_-]*\z/i },
|
||||
if: Proc.new { |field| ThemeField.theme_var_type_ids.include?(field.type_id) }
|
||||
|
||||
COMPILER_VERSION = 6
|
||||
COMPILER_VERSION = 7
|
||||
|
||||
belongs_to :theme
|
||||
|
||||
def settings(source)
|
||||
|
||||
settings = {}
|
||||
|
||||
theme.cached_settings.each do |k, v|
|
||||
if source.include?("settings.#{k}")
|
||||
settings[k] = v
|
||||
end
|
||||
end
|
||||
|
||||
if settings.length > 0
|
||||
"let settings = #{settings.to_json};"
|
||||
else
|
||||
""
|
||||
end
|
||||
end
|
||||
|
||||
def transpile(es6_source, version)
|
||||
template = Tilt::ES6ModuleTranspilerTemplate.new {}
|
||||
wrapped = <<PLUGIN_API_JS
|
||||
if ('Discourse' in window && typeof Discourse._registerPluginCode === 'function') {
|
||||
Discourse._registerPluginCode('#{version}', api => {
|
||||
#{settings(es6_source)}
|
||||
#{es6_source}
|
||||
});
|
||||
}
|
||||
PLUGIN_API_JS
|
||||
|
||||
template.babel_transpile(wrapped)
|
||||
end
|
||||
|
||||
def process_html(html)
|
||||
errors = nil
|
||||
errors = []
|
||||
javascript_cache || build_javascript_cache
|
||||
javascript_cache.content = ''
|
||||
|
||||
js_compiler = ThemeJavascriptCompiler.new(theme_id)
|
||||
|
||||
doc = Nokogiri::HTML.fragment(html)
|
||||
|
||||
doc.css('script[type="text/x-handlebars"]').each do |node|
|
||||
name = node["name"] || node["data-template-name"] || "broken"
|
||||
|
||||
is_raw = name =~ /\.raw$/
|
||||
setting_helpers = ''
|
||||
theme.cached_settings.each do |k, v|
|
||||
val = v.is_a?(String) ? "\"#{v.gsub('"', "\\u0022")}\"" : v
|
||||
setting_helpers += "{{theme-setting-injector #{is_raw ? "" : "context=this"} key=\"#{k}\" value=#{val}}}\n"
|
||||
end
|
||||
hbs_template = setting_helpers + node.inner_html
|
||||
hbs_template = node.inner_html
|
||||
|
||||
if is_raw
|
||||
template = "requirejs('discourse-common/lib/raw-handlebars').template(#{Barber::Precompiler.compile(hbs_template)})"
|
||||
javascript_cache.content << <<COMPILED
|
||||
(function() {
|
||||
if ('Discourse' in window) {
|
||||
Discourse.RAW_TEMPLATES[#{name.sub(/\.raw$/, '').inspect}] = #{template};
|
||||
}
|
||||
})();
|
||||
COMPILED
|
||||
else
|
||||
template = "Ember.HTMLBars.template(#{Barber::Ember::Precompiler.compile(hbs_template)})"
|
||||
javascript_cache.content << <<COMPILED
|
||||
(function() {
|
||||
if ('Em' in window) {
|
||||
Ember.TEMPLATES[#{name.inspect}] = #{template};
|
||||
}
|
||||
})();
|
||||
COMPILED
|
||||
begin
|
||||
if is_raw
|
||||
js_compiler.append_raw_template(name, hbs_template)
|
||||
else
|
||||
js_compiler.append_ember_template(name, hbs_template)
|
||||
end
|
||||
rescue ThemeJavascriptCompiler::CompileError => ex
|
||||
errors << ex.message
|
||||
end
|
||||
|
||||
node.remove
|
||||
end
|
||||
|
||||
doc.css('script[type="text/discourse-plugin"]').each do |node|
|
||||
if node['version'].present?
|
||||
begin
|
||||
javascript_cache.content << transpile(node.inner_html, node['version'])
|
||||
rescue MiniRacer::RuntimeError => ex
|
||||
javascript_cache.content << "console.error('Theme Transpilation Error:', #{ex.message.inspect});"
|
||||
|
||||
errors ||= []
|
||||
errors << ex.message
|
||||
end
|
||||
|
||||
node.remove
|
||||
next unless node['version'].present?
|
||||
begin
|
||||
js_compiler.append_plugin_script(node.inner_html, node['version'])
|
||||
rescue ThemeJavascriptCompiler::CompileError => ex
|
||||
errors << ex.message
|
||||
end
|
||||
|
||||
node.remove
|
||||
end
|
||||
|
||||
doc.css('script').each do |node|
|
||||
next unless inline_javascript?(node)
|
||||
|
||||
javascript_cache.content << node.inner_html
|
||||
javascript_cache.content << "\n"
|
||||
js_compiler.append_raw_script(node.inner_html)
|
||||
node.remove
|
||||
end
|
||||
|
||||
errors.each do |error|
|
||||
js_compiler.append_js_error(error)
|
||||
end
|
||||
|
||||
js_compiler.prepend_settings(theme.cached_settings) if js_compiler.content.present? && theme.cached_settings.present?
|
||||
javascript_cache.content = js_compiler.content
|
||||
javascript_cache.save!
|
||||
|
||||
doc.add_child("<script src='#{javascript_cache.url}'></script>") if javascript_cache.content.present?
|
||||
[doc.to_s, errors&.join("\n")]
|
||||
end
|
||||
|
||||
def raw_translation_data
|
||||
# Might raise ThemeTranslationParser::InvalidYaml
|
||||
ThemeTranslationParser.new(self).load
|
||||
end
|
||||
|
||||
def translation_data(with_overrides: true)
|
||||
fallback_fields = theme.theme_fields.find_locale_fields([theme.id], I18n.fallbacks[name])
|
||||
|
||||
fallback_data = fallback_fields.each_with_index.map do |field, index|
|
||||
begin
|
||||
field.raw_translation_data
|
||||
rescue ThemeTranslationParser::InvalidYaml
|
||||
# If this is the locale with the error, raise it.
|
||||
# If not, let the other theme_field raise the error when it processes itself
|
||||
raise if field.id == id
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
# TODO: Deduplicate the fallback data in the same way as JSLocaleHelper#load_translations_merged
|
||||
# this would reduce the size of the payload, without affecting functionality
|
||||
data = {}
|
||||
fallback_data.each { |hash| data.merge!(hash) }
|
||||
overrides = theme.translation_override_hash.deep_symbolize_keys
|
||||
data.deep_merge!(overrides) if with_overrides
|
||||
data
|
||||
end
|
||||
|
||||
def process_translation
|
||||
errors = []
|
||||
javascript_cache || build_javascript_cache
|
||||
js_compiler = ThemeJavascriptCompiler.new(theme_id)
|
||||
begin
|
||||
data = translation_data
|
||||
|
||||
js = <<~JS
|
||||
/* Translation data for theme #{self.theme_id} (#{self.name})*/
|
||||
const data = #{data.to_json};
|
||||
|
||||
for (let lang in data){
|
||||
let cursor = I18n.translations;
|
||||
for (let key of [lang, "js", "theme_translations"]){
|
||||
cursor = cursor[key] = cursor[key] || {};
|
||||
}
|
||||
cursor[#{self.theme_id}] = data[lang];
|
||||
}
|
||||
JS
|
||||
|
||||
js_compiler.append_plugin_script(js, 0)
|
||||
rescue ThemeTranslationParser::InvalidYaml => e
|
||||
errors << e.message
|
||||
end
|
||||
|
||||
javascript_cache.content = js_compiler.content
|
||||
javascript_cache.save!
|
||||
doc = ""
|
||||
doc = "<script src='#{javascript_cache.url}'></script>" if javascript_cache.content.present?
|
||||
[doc, errors&.join("\n")]
|
||||
end
|
||||
|
||||
def validate_yaml!
|
||||
return unless self.name == "yaml"
|
||||
|
||||
@@ -181,12 +217,12 @@ COMPILED
|
||||
self.error = errors.join("\n").presence
|
||||
end
|
||||
|
||||
def self.guess_type(name)
|
||||
def self.guess_type(name:, target:)
|
||||
if html_fields.include?(name.to_s)
|
||||
types[:html]
|
||||
elsif scss_fields.include?(name.to_s)
|
||||
types[:scss]
|
||||
elsif name.to_s === "yaml"
|
||||
elsif name.to_s == "yaml" || target.to_s == "translations"
|
||||
types[:yaml]
|
||||
end
|
||||
end
|
||||
@@ -200,9 +236,10 @@ COMPILED
|
||||
end
|
||||
|
||||
def ensure_baked!
|
||||
if ThemeField.html_fields.include?(self.name)
|
||||
if ThemeField.html_fields.include?(self.name) || translation = Theme.targets[:translations] == self.target_id
|
||||
if !self.value_baked || compiler_version != COMPILER_VERSION
|
||||
self.value_baked, self.error = process_html(self.value)
|
||||
self.value_baked, self.error = translation ? process_translation : process_html(self.value)
|
||||
self.error = nil unless self.error.present?
|
||||
self.compiler_version = COMPILER_VERSION
|
||||
|
||||
if self.will_save_change_to_value_baked? ||
|
||||
|
||||
Reference in New Issue
Block a user