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:
David Taylor
2019-01-17 11:46:11 +00:00
committed by GitHub
parent 740d047365
commit 880311dd4d
31 changed files with 1022 additions and 155 deletions

View File

@@ -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? ||