From 880311dd4d2b367e54cc8244fba60fce69e121c3 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 17 Jan 2019 11:46:11 +0000 Subject: [PATCH] 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"}}` --- ...ing.js.es6 => theme-setting-editor.js.es6} | 0 .../admin/components/theme-translation.js.es6 | 16 ++ .../admin-customize-themes-edit.js.es6 | 3 +- .../admin-customize-themes-show.js.es6 | 10 +- .../javascripts/admin/models/theme.js.es6 | 4 + .../admin/templates/customize-themes-show.hbs | 13 +- .../discourse-common/lib/helpers.js.es6 | 21 +- .../discourse/helpers/theme-helpers.js.es6 | 23 ++ .../helpers/theme-setting-injector.es6 | 36 --- .../discourse/services/theme-settings.js.es6 | 23 ++ .../stylesheets/common/admin/customize.scss | 3 +- app/controllers/admin/themes_controller.rb | 11 + app/helpers/application_helper.rb | 9 +- app/models/remote_theme.rb | 8 +- app/models/theme.rb | 52 +++- app/models/theme_field.rb | 195 ++++++++------- app/models/theme_translation_override.rb | 27 +++ app/serializers/theme_serializer.rb | 1 + .../theme_translation_serializer.rb | 3 + app/views/layouts/application.html.erb | 1 + config/locales/client.en.yml | 1 + config/locales/server.en.yml | 3 + ...21805_create_theme_translation_override.rb | 14 ++ lib/theme_javascript_compiler.rb | 223 ++++++++++++++++++ lib/theme_translation_manager.rb | 57 +++++ lib/theme_translation_parser.rb | 27 +++ spec/lib/theme_javascript_compiler_spec.rb | 102 ++++++++ spec/models/remote_theme_spec.rb | 11 +- spec/models/theme_field_spec.rb | 122 +++++++++- spec/models/theme_spec.rb | 117 ++++++++- spec/requests/admin/themes_controller_spec.rb | 41 ++++ 31 files changed, 1022 insertions(+), 155 deletions(-) rename app/assets/javascripts/admin/components/{theme-setting.js.es6 => theme-setting-editor.js.es6} (100%) create mode 100644 app/assets/javascripts/admin/components/theme-translation.js.es6 create mode 100644 app/assets/javascripts/discourse/helpers/theme-helpers.js.es6 delete mode 100644 app/assets/javascripts/discourse/helpers/theme-setting-injector.es6 create mode 100644 app/assets/javascripts/discourse/services/theme-settings.js.es6 create mode 100644 app/models/theme_translation_override.rb create mode 100644 app/serializers/theme_translation_serializer.rb create mode 100644 db/migrate/20181221121805_create_theme_translation_override.rb create mode 100644 lib/theme_javascript_compiler.rb create mode 100644 lib/theme_translation_manager.rb create mode 100644 lib/theme_translation_parser.rb create mode 100644 spec/lib/theme_javascript_compiler_spec.rb diff --git a/app/assets/javascripts/admin/components/theme-setting.js.es6 b/app/assets/javascripts/admin/components/theme-setting-editor.js.es6 similarity index 100% rename from app/assets/javascripts/admin/components/theme-setting.js.es6 rename to app/assets/javascripts/admin/components/theme-setting-editor.js.es6 diff --git a/app/assets/javascripts/admin/components/theme-translation.js.es6 b/app/assets/javascripts/admin/components/theme-translation.js.es6 new file mode 100644 index 00000000000..ad0cb8a7493 --- /dev/null +++ b/app/assets/javascripts/admin/components/theme-translation.js.es6 @@ -0,0 +1,16 @@ +import BufferedContent from "discourse/mixins/buffered-content"; +import SettingComponent from "admin/mixins/setting-component"; + +export default Ember.Component.extend(BufferedContent, SettingComponent, { + layoutName: "admin/templates/components/site-setting", + setting: Ember.computed.alias("translation"), + type: "string", + settingName: Ember.computed.alias("translation.key"), + + _save() { + return this.get("model").saveTranslation( + this.get("translation.key"), + this.get("buffered.value") + ); + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 index 0b31a299bbe..807c21e768e 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 @@ -16,7 +16,8 @@ export default Ember.Controller.extend({ { id: 0, name: "common" }, { id: 1, name: "desktop" }, { id: 2, name: "mobile" }, - { id: 3, name: "settings" } + { id: 3, name: "settings" }, + { id: 4, name: "translations" } ], fieldsForTarget: function(target) { diff --git a/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 index a5a703ceb95..5b67d6d40d9 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 @@ -90,11 +90,15 @@ export default Ember.Controller.extend({ return settings.map(setting => ThemeSettings.create(setting)); }, - @computed("settings") - hasSettings(settings) { - return settings.length > 0; + hasSettings: Ember.computed.notEmpty("settings"), + + @computed("model.translations") + translations(translations) { + return translations.map(setting => ThemeSettings.create(setting)); }, + hasTranslations: Ember.computed.notEmpty("translations"), + @computed("model.remoteError", "updatingRemote") showRemoteError(errorMessage, updating) { return errorMessage && !updating; diff --git a/app/assets/javascripts/admin/models/theme.js.es6 b/app/assets/javascripts/admin/models/theme.js.es6 index 83f42d962e7..a3ae2308202 100644 --- a/app/assets/javascripts/admin/models/theme.js.es6 +++ b/app/assets/javascripts/admin/models/theme.js.es6 @@ -188,6 +188,10 @@ const Theme = RestModel.extend({ const settings = {}; settings[name] = value; return this.save({ settings }); + }, + + saveTranslation(name, value) { + return this.save({ translations: { [name]: value } }); } }); diff --git a/app/assets/javascripts/admin/templates/customize-themes-show.hbs b/app/assets/javascripts/admin/templates/customize-themes-show.hbs index de44bd68460..fd47daf70d3 100644 --- a/app/assets/javascripts/admin/templates/customize-themes-show.hbs +++ b/app/assets/javascripts/admin/templates/customize-themes-show.hbs @@ -138,7 +138,18 @@
{{i18n "admin.customize.theme.theme_settings"}}
{{#d-section class="form-horizontal theme settings"}} {{#each settings as |setting|}} - {{theme-setting setting=setting model=model class="theme-setting"}} + {{theme-setting-editor setting=setting model=model class="theme-setting"}} + {{/each}} + {{/d-section}} + + {{/if}} + + {{#if hasTranslations}} +
+
{{i18n "admin.customize.theme.theme_translations"}}
+ {{#d-section class="form-horizontal theme settings translations"}} + {{#each translations as |translation|}} + {{theme-translation translation=translation model=model class="theme-translation"}} {{/each}} {{/d-section}}
diff --git a/app/assets/javascripts/discourse-common/lib/helpers.js.es6 b/app/assets/javascripts/discourse-common/lib/helpers.js.es6 index 4e83da60a8c..bbaaf6200a1 100644 --- a/app/assets/javascripts/discourse-common/lib/helpers.js.es6 +++ b/app/assets/javascripts/discourse-common/lib/helpers.js.es6 @@ -46,19 +46,24 @@ function resolveParams(ctx, options) { } export function registerUnbound(name, fn) { - const func = function(property, options) { - if ( - options.types && - (options.types[0] === "ID" || options.types[0] === "PathExpression") - ) { - property = get(this, property, options); + const func = function(...args) { + const options = args.pop(); + const properties = args; + + for (let i = 0; i < properties.length; i++) { + if ( + options.types && + (options.types[i] === "ID" || options.types[i] === "PathExpression") + ) { + properties[i] = get(this, properties[i], options); + } } - return fn.call(this, property, resolveParams(this, options)); + return fn.call(this, ...properties, resolveParams(this, options)); }; _helpers[name] = Ember.Helper.extend({ - compute: (params, args) => fn(params[0], args) + compute: (params, args) => fn(...params, args) }); Handlebars.registerHelper(name, func); } diff --git a/app/assets/javascripts/discourse/helpers/theme-helpers.js.es6 b/app/assets/javascripts/discourse/helpers/theme-helpers.js.es6 new file mode 100644 index 00000000000..eae8dfa66d6 --- /dev/null +++ b/app/assets/javascripts/discourse/helpers/theme-helpers.js.es6 @@ -0,0 +1,23 @@ +import { registerUnbound } from "discourse-common/lib/helpers"; +import deprecated from "discourse-common/lib/deprecated"; + +registerUnbound("theme-i18n", (themeId, key, params) => { + return I18n.t(`theme_translations.${themeId}.${key}`, params); +}); + +registerUnbound( + "theme-prefix", + (themeId, key) => `theme_translations.${themeId}.${key}` +); + +registerUnbound("theme-setting", (themeId, key, hash) => { + if (hash.deprecated) { + deprecated( + "The `{{themeSetting.setting_name}}` syntax is deprecated. Use `{{theme-setting 'setting_name'}}` instead", + { since: "v2.2.0.beta8", dropFrom: "v2.3.0" } + ); + } + return Discourse.__container__ + .lookup("service:theme-settings") + .getSetting(themeId, key); +}); diff --git a/app/assets/javascripts/discourse/helpers/theme-setting-injector.es6 b/app/assets/javascripts/discourse/helpers/theme-setting-injector.es6 deleted file mode 100644 index 716d2973ee5..00000000000 --- a/app/assets/javascripts/discourse/helpers/theme-setting-injector.es6 +++ /dev/null @@ -1,36 +0,0 @@ -// A small helper to inject theme settings into -// context objects of handlebars templates used -// in themes - -import { registerHelper } from "discourse-common/lib/helpers"; - -function inject(context, key, value) { - if (typeof value === "string") { - value = value.replace(/\\u0022/g, '"'); - } - - if (!(context instanceof Ember.Object)) { - injectPlainObject(context, key, value); - return; - } - - if (!context.get("themeSettings")) { - context.set("themeSettings", {}); - } - context.set(`themeSettings.${key}`, value); -} - -function injectPlainObject(context, key, value) { - if (!context.themeSettings) { - _.assign(context, { themeSettings: {} }); - } - _.assign(context.themeSettings, { [key]: value }); -} - -registerHelper("theme-setting-injector", function(arr, hash) { - inject(hash.context, hash.key, hash.value); -}); - -Handlebars.registerHelper("theme-setting-injector", function(hash) { - inject(this, hash.hash.key, hash.hash.value); -}); diff --git a/app/assets/javascripts/discourse/services/theme-settings.js.es6 b/app/assets/javascripts/discourse/services/theme-settings.js.es6 new file mode 100644 index 00000000000..5e1e8a81551 --- /dev/null +++ b/app/assets/javascripts/discourse/services/theme-settings.js.es6 @@ -0,0 +1,23 @@ +export default Ember.Service.extend({ + settings: null, + + init() { + this._super(...arguments); + this._settings = {}; + }, + + registerSettings(themeId, settingsObject) { + this._settings[themeId] = settingsObject; + }, + + getSetting(themeId, settingsKey) { + if (this._settings[themeId]) { + return this._settings[themeId][settingsKey]; + } + return null; + }, + + getObjectForTheme(themeId) { + return this._settings[themeId]; + } +}); diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss index bc832987e33..0c233ff7810 100644 --- a/app/assets/stylesheets/common/admin/customize.scss +++ b/app/assets/stylesheets/common/admin/customize.scss @@ -339,7 +339,8 @@ } .theme.settings { - .theme-setting { + .theme-setting, + .theme-translation { padding-bottom: 0; margin-top: 18px; min-height: 35px; diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb index ab0347e9280..68016925060 100644 --- a/app/controllers/admin/themes_controller.rb +++ b/app/controllers/admin/themes_controller.rb @@ -168,6 +168,7 @@ class Admin::ThemesController < Admin::AdminController set_fields update_settings + update_translations handle_switch save_remote = false @@ -188,6 +189,7 @@ class Admin::ThemesController < Admin::AdminController update_default_theme + @theme.reload log_theme_change(original_json, @theme) format.json { render json: @theme, status: :ok } else @@ -258,6 +260,7 @@ class Admin::ThemesController < Admin::AdminController :user_selectable, :component, settings: {}, + translations: {}, theme_fields: [:name, :target, :value, :upload_id, :type_id], child_theme_ids: [] ) @@ -286,6 +289,14 @@ class Admin::ThemesController < Admin::AdminController end end + def update_translations + return unless target_translations = theme_params[:translations] + + target_translations.each_pair do |translation_key, new_value| + @theme.update_translation(translation_key, new_value) + end + end + def log_theme_change(old_record, new_record) StaffActionLogger.new(current_user).log_theme_change(old_record, new_record) end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index d4a60fe613e..4cefbe39957 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -398,8 +398,13 @@ module ApplicationHelper end def theme_lookup(name) - lookup = Theme.lookup_field(theme_ids, mobile_view? ? :mobile : :desktop, name) - lookup.html_safe if lookup + Theme.lookup_field(theme_ids, mobile_view? ? :mobile : :desktop, name) + &.html_safe + end + + def theme_translations_lookup + Theme.lookup_field(theme_ids, :translations, I18n.locale) + &.html_safe end def discourse_stylesheet_link_tag(name, opts = {}) diff --git a/app/models/remote_theme.rb b/app/models/remote_theme.rb index c191477a4cf..cc47c35a324 100644 --- a/app/models/remote_theme.rb +++ b/app/models/remote_theme.rb @@ -132,8 +132,7 @@ class RemoteTheme < ActiveRecord::Base end Theme.targets.keys.each do |target| - next if target == :settings - + next if target == :settings || target == :translations ALLOWED_FIELDS.each do |field| lookup = if field == "scss" @@ -152,6 +151,11 @@ class RemoteTheme < ActiveRecord::Base settings_yaml = importer["settings.yaml"] || importer["settings.yml"] theme.set_field(target: :settings, name: "yaml", value: settings_yaml) + I18n.available_locales.each do |locale| + value = importer["locales/#{locale}.yml"] + theme.set_field(target: :translations, name: locale, value: value) + end + self.license_url ||= theme_info["license_url"] self.about_url ||= theme_info["about_url"] diff --git a/app/models/theme.rb b/app/models/theme.rb index 29073467bfe..cbb37e108af 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -3,6 +3,8 @@ require_dependency 'stylesheet/compiler' require_dependency 'stylesheet/manager' require_dependency 'theme_settings_parser' require_dependency 'theme_settings_manager' +require_dependency 'theme_translation_parser' +require_dependency 'theme_translation_manager' class Theme < ActiveRecord::Base @@ -15,6 +17,7 @@ class Theme < ActiveRecord::Base belongs_to :color_scheme has_many :theme_fields, dependent: :destroy has_many :theme_settings, dependent: :destroy + has_many :theme_translation_overrides, dependent: :destroy has_many :child_theme_relation, class_name: 'ChildTheme', foreign_key: 'parent_theme_id', dependent: :destroy has_many :child_themes, -> { order(:name) }, through: :child_theme_relation, source: :child_theme has_many :color_schemes @@ -203,7 +206,7 @@ class Theme < ActiveRecord::Base end def self.targets - @targets ||= Enum.new(common: 0, desktop: 1, mobile: 2, settings: 3) + @targets ||= Enum.new(common: 0, desktop: 1, mobile: 2, settings: 3, translations: 4) end def self.lookup_target(target_id) @@ -269,10 +272,15 @@ class Theme < ActiveRecord::Base def self.list_baked_fields(theme_ids, target, name) target = target.to_sym + name = name.to_sym - fields = ThemeField.find_by_theme_ids(theme_ids) - .where(target_id: [Theme.targets[target], Theme.targets[:common]]) - .where(name: name.to_s) + if target == :translations + fields = ThemeField.find_first_locale_fields(theme_ids, I18n.fallbacks[name]) + else + fields = ThemeField.find_by_theme_ids(theme_ids) + .where(target_id: [Theme.targets[target], Theme.targets[:common]]) + .where(name: name.to_s) + end fields.each(&:ensure_baked!) fields @@ -305,7 +313,7 @@ class Theme < ActiveRecord::Base target_id = Theme.targets[target.to_sym] raise "Unknown target #{target} passed to set field" unless target_id - type_id ||= type ? ThemeField.types[type.to_sym] : ThemeField.guess_type(name) + type_id ||= type ? ThemeField.types[type.to_sym] : ThemeField.guess_type(name: name, target: target) raise "Unknown type #{type} passed to set field" unless type_id value ||= "" @@ -347,6 +355,21 @@ class Theme < ActiveRecord::Base end end + def translations + fallbacks = I18n.fallbacks[I18n.locale] + begin + data = theme_fields.find_first_locale_fields([id], fallbacks).first&.translation_data(with_overrides: false) + return {} if data.nil? + best_translations = {} + fallbacks.reverse.each do |locale| + best_translations.deep_merge! data[locale] if data[locale] + end + ThemeTranslationManager.list_from_hash(theme: self, hash: best_translations, locale: I18n.locale) + rescue ThemeTranslationParser::InvalidYaml + {} + end + end + def settings field = settings_field return [] unless field && field.error.nil? @@ -389,6 +412,25 @@ class Theme < ActiveRecord::Base target_setting.value = new_value end + + def update_translation(translation_key, new_value) + target_translation = translations.find { |translation| translation.key == translation_key } + raise Discourse::NotFound unless target_translation + target_translation.value = new_value + end + + def translation_override_hash + hash = {} + theme_translation_overrides.each do |override| + cursor = hash + path = [override.locale] + override.translation_key.split(".") + path[0..-2].each do |key| + cursor = (cursor[key] ||= {}) + end + cursor[path[-1]] = override.value + end + hash + end end # == Schema Information diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb index a2d4af431e6..b4f7595bf96 100644 --- a/app/models/theme_field.rb +++ b/app/models/theme_field.rb @@ -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 = < { - #{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 << < 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("") 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 = "" 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? || diff --git a/app/models/theme_translation_override.rb b/app/models/theme_translation_override.rb new file mode 100644 index 00000000000..70aaa9110ce --- /dev/null +++ b/app/models/theme_translation_override.rb @@ -0,0 +1,27 @@ +class ThemeTranslationOverride < ActiveRecord::Base + belongs_to :theme + + after_commit do + theme.clear_cached_settings! + theme.remove_from_cache! + theme.theme_fields.where(target_id: Theme.targets[:translations]).update_all(value_baked: nil) + end +end + +# == Schema Information +# +# Table name: theme_translation_overrides +# +# id :bigint(8) not null, primary key +# theme_id :integer not null +# locale :string not null +# translation_key :string not null +# value :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_theme_translation_overrides_on_theme_id (theme_id) +# theme_translation_overrides_unique (theme_id,locale,translation_key) UNIQUE +# diff --git a/app/serializers/theme_serializer.rb b/app/serializers/theme_serializer.rb index eb744802dd2..bc2e1de0f9b 100644 --- a/app/serializers/theme_serializer.rb +++ b/app/serializers/theme_serializer.rb @@ -68,6 +68,7 @@ class ThemeSerializer < ChildThemeSerializer has_many :theme_fields, serializer: ThemeFieldSerializer, embed: :objects has_many :child_themes, serializer: ChildThemeSerializer, embed: :objects has_one :remote_theme, serializer: RemoteThemeSerializer, embed: :objects + has_many :translations, serializer: ThemeTranslationSerializer, embed: :objects def initialize(theme, options = {}) super diff --git a/app/serializers/theme_translation_serializer.rb b/app/serializers/theme_translation_serializer.rb new file mode 100644 index 00000000000..b1ad8967b5d --- /dev/null +++ b/app/serializers/theme_translation_serializer.rb @@ -0,0 +1,3 @@ +class ThemeTranslationSerializer < ApplicationSerializer + attributes :key, :value, :default +end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index fc109a741aa..f6a9b5613d7 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -36,6 +36,7 @@ <%- end %> <%- unless customization_disabled? %> + <%= raw theme_translations_lookup %> <%= raw theme_lookup("head_tag") %> <%- end %> diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index ff341e0d7f0..f06d08add59 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3394,6 +3394,7 @@ en: add: "Add" theme_settings: "Theme Settings" no_settings: "This theme has no settings." + theme_translations: "Theme Translations" empty: "No items" commits_behind: one: "Theme is 1 commit behind!" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 858c68dc85e..0206c05485d 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -82,6 +82,9 @@ en: string_value_not_valid_min_max: "It must be between %{min} and %{max} character long." string_value_not_valid_min: "It must be at least %{min} characters long." string_value_not_valid_max: "It must be at most %{max} characters long." + locale_errors: + top_level_locale: "The top level key in a locale file must match the locale name" + invalid_yaml: "Translation YAML invalid" emails: incoming: default_subject: "This topic needs a title" diff --git a/db/migrate/20181221121805_create_theme_translation_override.rb b/db/migrate/20181221121805_create_theme_translation_override.rb new file mode 100644 index 00000000000..3c918cbaedc --- /dev/null +++ b/db/migrate/20181221121805_create_theme_translation_override.rb @@ -0,0 +1,14 @@ +class CreateThemeTranslationOverride < ActiveRecord::Migration[5.2] + def change + create_table :theme_translation_overrides do |t| + t.integer :theme_id, null: false + t.string :locale, length: 30, null: false + t.string :translation_key, null: false + t.string :value, null: false + t.timestamps null: false + + t.index :theme_id + t.index [:theme_id, :locale, :translation_key], unique: true, name: 'theme_translation_overrides_unique' + end + end +end diff --git a/lib/theme_javascript_compiler.rb b/lib/theme_javascript_compiler.rb new file mode 100644 index 00000000000..f6b842ff8de --- /dev/null +++ b/lib/theme_javascript_compiler.rb @@ -0,0 +1,223 @@ +class ThemeJavascriptCompiler + + module PrecompilerExtension + def initialize(theme_id) + super() + @theme_id = theme_id + end + + def discourse_node_manipulator + <<~JS + + // Helper to replace old themeSetting syntax + function generateHelper(settingParts) { + const settingName = settingParts.join('.'); + return { + "path": { + "type": "PathExpression", + "original": "theme-setting", + "this": false, + "data": false, + "parts": [ + "theme-setting" + ], + "depth":0 + }, + "params": [ + { + type: "NumberLiteral", + value: #{@theme_id}, + original: #{@theme_id} + }, + { + "type": "StringLiteral", + "value": settingName, + "original": settingName + } + ], + "hash": { + "type": "Hash", + "pairs": [ + { + "type": "HashPair", + "key": "deprecated", + "value": { + "type": "BooleanLiteral", + "value": true, + "original": true + } + } + ] + } + } + } + + function manipulatePath(path) { + // Override old themeSetting syntax when it's a param inside another node + if(path.parts[0] == "themeSetting"){ + const settingParts = path.parts.slice(1); + path.type = "SubExpression"; + Object.assign(path, generateHelper(settingParts)) + } + } + + function manipulateNode(node) { + // Magically add theme id as the first param for each of these helpers + if (["theme-i18n", "theme-prefix", "theme-setting"].includes(node.path.parts[0])) { + node.params.unshift({ + type: "NumberLiteral", + value: #{@theme_id}, + original: #{@theme_id} + }) + } + + // Override old themeSetting syntax when it's in its own node + if (node.path.parts[0] == "themeSetting") { + Object.assign(node, generateHelper(node.path.parts.slice(1))) + } + } + JS + end + + def source + [super, discourse_node_manipulator, discourse_extension].join("\n") + end + end + + class RawTemplatePrecompiler < Barber::Precompiler + include PrecompilerExtension + + def discourse_extension + <<~JS + let _superCompile = Handlebars.Compiler.prototype.compile; + Handlebars.Compiler.prototype.compile = function(program, options) { + + // `replaceGet()` in raw-handlebars.js.es6 adds a `get` in front of things + // so undo this specific case for the old themeSetting.blah syntax + let visitor = new Handlebars.Visitor(); + visitor.mutating = true; + visitor.MustacheStatement = (node) => { + if(node.path.original == 'get' + && node.params + && node.params[0] + && node.params[0].parts[0] == 'themeSetting'){ + node.path.parts = node.params[0].parts + node.params = [] + } + }; + visitor.accept(program); + + [ + ["SubExpression", manipulateNode], + ["MustacheStatement", manipulateNode], + ["PathExpression", manipulatePath] + ].forEach((pass) => { + let visitor = new Handlebars.Visitor(); + visitor.mutating = true; + visitor[pass[0]] = pass[1]; + visitor.accept(program); + }) + + return _superCompile.apply(this, arguments); + }; + JS + end + end + + class EmberTemplatePrecompiler < Barber::Ember::Precompiler + include PrecompilerExtension + + def discourse_extension + <<~JS + Ember.HTMLBars.registerPlugin('ast', function(){ + return { name: 'theme-template-manipulator', + visitor: { SubExpression: manipulateNode, MustacheStatement: manipulateNode, PathExpression: manipulatePath} + }}); + JS + end + end + + class CompileError < StandardError + end + + attr_accessor :content + + def initialize(theme_id) + @theme_id = theme_id + @content = "" + end + + def prepend_settings(settings_hash) + @content.prepend <<~JS + (function() { + if ('Discourse' in window && Discourse.__container__) { + Discourse.__container__ + .lookup("service:theme-settings") + .registerSettings(#{@theme_id}, #{settings_hash.to_json}); + } + })(); + JS + end + + # TODO Error handling for handlebars templates + def append_ember_template(name, hbs_template) + name = name.inspect + compiled = EmberTemplatePrecompiler.new(@theme_id).compile(hbs_template) + content << <<~JS + (function() { + if ('Ember' in window) { + Ember.TEMPLATES[#{name}] = Ember.HTMLBars.template(#{compiled}); + } + })(); + JS + rescue Barber::PrecompilerError => e + raise CompileError.new e.instance_variable_get(:@error) # e.message contains the entire template, which could be very long + end + + def append_raw_template(name, hbs_template) + name = name.sub(/\.raw$/, '').inspect + compiled = RawTemplatePrecompiler.new(@theme_id).compile(hbs_template) + @content << <<~JS + (function() { + if ('Discourse' in window) { + Discourse.RAW_TEMPLATES[#{name}] = requirejs('discourse-common/lib/raw-handlebars').template(#{compiled}); + } + })(); + JS + rescue Barber::PrecompilerError => e + raise CompileError.new e.instance_variable_get(:@error) # e.message contains the entire template, which could be very long + end + + def append_plugin_script(script, api_version) + @content << transpile(script, api_version) + end + + def append_raw_script(script) + @content << script + "\n" + end + + def append_js_error(message) + @content << "console.error('Theme Transpilation Error:', #{message.inspect});" + end + + private + + def transpile(es6_source, version) + template = Tilt::ES6ModuleTranspilerTemplate.new {} + wrapped = <<~PLUGIN_API_JS + if ('Discourse' in window && typeof Discourse._registerPluginCode === 'function') { + const themeSetting = Discourse.__container__ + .lookup("service:theme-settings") + .getObjectForTheme(#{@theme_id}); + const themePrefix = (key) => `theme_translations.#{@theme_id}.${key}`; + Discourse._registerPluginCode('#{version}', api => { + #{es6_source} + }); + } + PLUGIN_API_JS + + template.babel_transpile(wrapped) + rescue MiniRacer::RuntimeError => ex + raise CompileError.new ex.message + end +end diff --git a/lib/theme_translation_manager.rb b/lib/theme_translation_manager.rb new file mode 100644 index 00000000000..df686d0193d --- /dev/null +++ b/lib/theme_translation_manager.rb @@ -0,0 +1,57 @@ +class ThemeTranslationManager + include ActiveModel::Serialization + attr_reader :key, :default, :theme + + def self.list_from_hash(locale:, hash:, theme:, parent_keys: []) + list = [] + hash.map do |key, value| + this_key_array = parent_keys + [key] + if value.is_a?(Hash) + self.list_from_hash(locale: locale, hash: value, theme: theme, parent_keys: this_key_array) + else + self.new(locale: locale, theme: theme, key: this_key_array.join("."), default: value) + end + end.flatten + end + + def initialize(locale:, key:, default:, theme:) + @locale = locale + @key = key + @default = default + @theme = theme + end + + def value + has_record? ? db_record.value : default + end + + def value=(new_value) + if new_value == @default + db_record.destroy! if db_record + new_value + else + if has_record? + record = db_record + record.value = new_value.to_s + record.save! + else + record = create_record!(new_value.to_s) + end + record.value + end + end + + def db_record + theme.theme_translation_overrides.to_a.find do |i| + i.locale.to_s == @locale.to_s && i.translation_key.to_s == key.to_s + end + end + + def has_record? + db_record.present? + end + + def create_record!(value) + record = ThemeTranslationOverride.create!(locale: @locale, translation_key: @key, theme: @theme, value: value) + end +end diff --git a/lib/theme_translation_parser.rb b/lib/theme_translation_parser.rb new file mode 100644 index 00000000000..3ce75803331 --- /dev/null +++ b/lib/theme_translation_parser.rb @@ -0,0 +1,27 @@ +class ThemeTranslationParser + class InvalidYaml < StandardError; end + + def initialize(setting_field) + @setting_field = setting_field + end + + def self.check_contains_hashes(hash) + hash.all? { |key, value| value.is_a?(String) || (value.is_a?(Hash) && self.check_contains_hashes(value)) } + end + + def load + return {} if @setting_field.value.blank? + + begin + parsed = YAML.safe_load(@setting_field.value) + rescue Psych::SyntaxError, Psych::DisallowedClass => e + raise InvalidYaml.new(e.message) + end + raise InvalidYaml.new(I18n.t("themes.locale_errors.invalid_yaml")) unless parsed.is_a?(Hash) && ThemeTranslationParser.check_contains_hashes(parsed) + raise InvalidYaml.new(I18n.t("themes.locale_errors.top_level_locale")) unless parsed.keys.length == 1 && parsed.keys[0] == @setting_field.name + + parsed.deep_symbolize_keys! + + parsed + end +end diff --git a/spec/lib/theme_javascript_compiler_spec.rb b/spec/lib/theme_javascript_compiler_spec.rb new file mode 100644 index 00000000000..44119471ad7 --- /dev/null +++ b/spec/lib/theme_javascript_compiler_spec.rb @@ -0,0 +1,102 @@ +require 'rails_helper' + +require_dependency 'theme_javascript_compiler' + +describe ThemeJavascriptCompiler do + + let(:theme_id) { 22 } + + describe ThemeJavascriptCompiler::RawTemplatePrecompiler do + # For the raw templates, we can easily render them serverside, so let's do that + + let(:compiler) { described_class.new(theme_id) } + + let(:helpers) { + <<~JS + Handlebars.registerHelper('theme-prefix', function(themeId, string) { + return `theme_translations.${themeId}.${string}` + }) + Handlebars.registerHelper('theme-i18n', function(themeId, string) { + return `translated(theme_translations.${themeId}.${string})` + }) + Handlebars.registerHelper('theme-setting', function(themeId, string) { + return `setting(${themeId}:${string})` + }) + Handlebars.registerHelper('dummy-helper', function(string) { + return `dummy(${string})` + }) + JS + } + + let(:mini_racer) { + ctx = MiniRacer::Context.new + ctx.eval(File.open("#{Rails.root}/vendor/assets/javascripts/handlebars.js").read) + ctx.eval(helpers) + ctx + } + + def render(template) + compiled = compiler.compile(template) + mini_racer.eval "Handlebars.template(#{compiled.squish})({})" + end + + it 'adds the theme id to the helpers' do + # Works normally + expect(render("{{theme-prefix 'translation_key'}}")). + to eq('theme_translations.22.translation_key') + expect(render("{{theme-i18n 'translation_key'}}")). + to eq('translated(theme_translations.22.translation_key)') + expect(render("{{theme-setting 'setting_key'}}")). + to eq('setting(22:setting_key)') + + # Works when used inside other statements + expect(render("{{dummy-helper (theme-prefix 'translation_key')}}")). + to eq('dummy(theme_translations.22.translation_key)') + end + + it 'works with the old settings syntax' do + expect(render("{{themeSetting.setting_key}}")). + to eq('setting(22:setting_key)') + + # Works when used inside other statements + expect(render("{{dummy-helper themeSetting.setting_key}}")). + to eq('dummy(setting(22:setting_key))') + end + end + + describe ThemeJavascriptCompiler::EmberTemplatePrecompiler do + # For the Ember (Glimmer) templates, serverside rendering is not trivial, + # so check the compiled JSON against known working output + let(:compiler) { described_class.new(theme_id) } + + def statement(template) + compiled = compiler.compile(template) + data = JSON.parse(compiled) + block = JSON.parse(data["block"]) + block["statements"] + end + + it 'adds the theme id to the helpers' do + expect(statement("{{theme-prefix 'translation_key'}}")). + to eq([[1, [27, "theme-prefix", [22, "translation_key"], nil], false]]) + expect(statement("{{theme-i18n 'translation_key'}}")). + to eq([[1, [27, "theme-i18n", [22, "translation_key"], nil], false]]) + expect(statement("{{theme-setting 'setting_key'}}")). + to eq([[1, [27, "theme-setting", [22, "setting_key"], nil], false]]) + + # Works when used inside other statements + expect(statement("{{dummy-helper (theme-prefix 'translation_key')}}")). + to eq([[1, [27, "dummy-helper", [[27, "theme-prefix", [22, "translation_key"], nil]], nil], false]]) + end + + it 'works with the old settings syntax' do + expect(statement("{{themeSetting.setting_key}}")). + to eq([[1, [27, "theme-setting", [22, "setting_key"], [["deprecated"], [true]]], false]]) + + # Works when used inside other statements + expect(statement("{{dummy-helper themeSetting.setting_key}}")). + to eq([[1, [27, "dummy-helper", [[27, "theme-setting", [22, "setting_key"], [["deprecated"], [true]]]], nil], false]]) + end + end + +end diff --git a/spec/models/remote_theme_spec.rb b/spec/models/remote_theme_spec.rb index 95950f19c59..a7eefb74d65 100644 --- a/spec/models/remote_theme_spec.rb +++ b/spec/models/remote_theme_spec.rb @@ -9,7 +9,7 @@ describe RemoteTheme do `cd #{repo_dir} && git init . ` `cd #{repo_dir} && git config user.email 'someone@cool.com'` `cd #{repo_dir} && git config user.name 'The Cool One'` - `cd #{repo_dir} && mkdir desktop mobile common assets` + `cd #{repo_dir} && mkdir desktop mobile common assets locales` files.each do |name, data| File.write("#{repo_dir}/#{name}", data) `cd #{repo_dir} && git add #{name}` @@ -56,7 +56,8 @@ describe RemoteTheme do "common/random.html" => "I AM SILLY", "common/embedded.scss" => "EMBED", "assets/awesome.woff2" => "FAKE FONT", - "settings.yaml" => "boolean_setting: true" + "settings.yaml" => "boolean_setting: true", + "locales/en.yml" => "sometranslations" ) end @@ -80,7 +81,7 @@ describe RemoteTheme do expect(remote.about_url).to eq("https://www.site.com/about") expect(remote.license_url).to eq("https://www.site.com/license") - expect(@theme.theme_fields.length).to eq(7) + expect(@theme.theme_fields.length).to eq(8) mapped = Hash[*@theme.theme_fields.map { |f| ["#{f.target_id}-#{f.name}", f.value] }.flatten] @@ -94,7 +95,9 @@ describe RemoteTheme do expect(mapped["3-yaml"]).to eq("boolean_setting: true") - expect(mapped.length).to eq(7) + expect(mapped["4-en"]).to eq("sometranslations") + + expect(mapped.length).to eq(8) expect(@theme.settings.length).to eq(1) expect(@theme.settings.first.value).to eq(true) diff --git a/spec/models/theme_field_spec.rb b/spec/models/theme_field_spec.rb index 5052c883b60..17718e764f7 100644 --- a/spec/models/theme_field_spec.rb +++ b/spec/models/theme_field_spec.rb @@ -76,7 +76,7 @@ describe ThemeField do theme_field = ThemeField.create!(theme_id: 1, target_id: 0, name: "header", value: html) - expect(theme_field.javascript_cache.content).to eq(extracted) + expect(theme_field.javascript_cache.content).to include(extracted) end it "correctly extracts and generates errors for transpiled js" do @@ -108,9 +108,8 @@ HTML expect(theme_field.value_baked).to include("") expect(javascript_cache.content).to include("testing-div") - expect(javascript_cache.content).to include("theme-setting-injector") expect(javascript_cache.content).to include("string_setting") - expect(javascript_cache.content).to include("test text \\\\\\\\u0022 123!") + expect(javascript_cache.content).to include("test text \\\" 123!") end it "correctly generates errors for transpiled css" do @@ -188,4 +187,121 @@ HTML field = create_yaml_field(get_fixture("valid")) expect(field.error).to be_nil end + + describe "locale fields" do + + let!(:theme) { Fabricate(:theme) } + let!(:theme2) { Fabricate(:theme) } + let!(:theme3) { Fabricate(:theme) } + + let!(:en1) { + ThemeField.create!(theme: theme, target_id: Theme.targets[:translations], name: "en", + value: { en: { somestring1: "helloworld", group: { key1: "enval1" } } } + .deep_stringify_keys.to_yaml + ) + } + let!(:fr1) { + ThemeField.create!(theme: theme, target_id: Theme.targets[:translations], name: "fr", + value: { fr: { somestring1: "bonjourworld", group: { key2: "frval2" } } } + .deep_stringify_keys.to_yaml + ) + } + let!(:fr2) { ThemeField.create!(theme: theme2, target_id: Theme.targets[:translations], name: "fr", value: "") } + let!(:en2) { ThemeField.create!(theme: theme2, target_id: Theme.targets[:translations], name: "en", value: "") } + let!(:ca3) { ThemeField.create!(theme: theme3, target_id: Theme.targets[:translations], name: "ca", value: "") } + let!(:en3) { ThemeField.create!(theme: theme3, target_id: Theme.targets[:translations], name: "en", value: "") } + + describe "scopes" do + it "find_locale_fields returns results in the correct order" do + expect(ThemeField.find_locale_fields( + [theme3.id, theme.id, theme2.id], ["en", "fr"] + )).to eq([en3, en1, fr1, en2, fr2]) + end + + it "find_first_locale_fields returns only the first locale for each theme" do + expect(ThemeField.find_first_locale_fields( + [theme3.id, theme.id, theme2.id], ["ca", "en", "fr"] + )).to eq([ca3, en1, en2]) + end + end + + describe "#raw_translation_data" do + it "errors if the top level key is incorrect" do + fr1.update(value: { wrongkey: { somestring1: "bonjourworld" } }.deep_stringify_keys.to_yaml) + expect { fr1.raw_translation_data }.to raise_error(ThemeTranslationParser::InvalidYaml) + end + + it "errors if there are multiple top level keys" do + fr1.update(value: { fr: { somestring1: "bonjourworld" }, otherkey: "hello" }.deep_stringify_keys.to_yaml) + expect { fr1.raw_translation_data }.to raise_error(ThemeTranslationParser::InvalidYaml) + end + + it "errors if YAML includes arrays" do + fr1.update(value: { fr: ["val1", "val2"] }.deep_stringify_keys.to_yaml) + expect { fr1.raw_translation_data }.to raise_error(ThemeTranslationParser::InvalidYaml) + end + + it "errors if YAML has invalid syntax" do + fr1.update(value: "fr: 'valuewithoutclosequote") + expect { fr1.raw_translation_data }.to raise_error(ThemeTranslationParser::InvalidYaml) + end + end + + describe "#translation_data" do + it "loads correctly" do + expect(fr1.translation_data).to eq( + fr: { somestring1: "bonjourworld", group: { key2: "frval2" } }, + en: { somestring1: "helloworld", group: { key1: "enval1" } } + ) + end + + it "raises errors for the current locale" do + fr1.update(value: { wrongkey: "hello" }.deep_stringify_keys.to_yaml) + expect { fr1.translation_data }.to raise_error(ThemeTranslationParser::InvalidYaml) + end + + it "doesn't raise errors for the fallback locale" do + en1.update(value: { wrongkey: "hello" }.deep_stringify_keys.to_yaml) + expect(fr1.translation_data).to eq( + fr: { somestring1: "bonjourworld", group: { key2: "frval2" } } + ) + end + + it "merges any overrides" do + # Overrides in the current locale (so in tests that will be english) + theme.update_translation("group.key1", "overriddentest1") + theme.reload + expect(fr1.translation_data).to eq( + fr: { somestring1: "bonjourworld", group: { key2: "frval2" } }, + en: { somestring1: "helloworld", group: { key1: "overriddentest1" } } + ) + end + end + + describe "javascript cache" do + it "is generated correctly" do + fr1.ensure_baked! + expect(fr1.value_baked).to include("") + expect(fr1.javascript_cache.content).to include("bonjourworld") + expect(fr1.javascript_cache.content).to include("helloworld") + expect(fr1.javascript_cache.content).to include("enval1") + end + end + + describe "prefix injection" do + it "injects into JS" do + html = <<~HTML + + HTML + + theme_field = ThemeField.create!(theme_id: theme.id, target_id: 0, name: "head_tag", value: html) + javascript_cache = theme_field.javascript_cache + expect(javascript_cache.content).to include("inline discourse plugin") + expect(javascript_cache.content).to include("theme_translations.#{theme.id}.") + end + end + end + end diff --git a/spec/models/theme_spec.rb b/spec/models/theme_spec.rb index ed8bca19e6a..ba0b91e9ddc 100644 --- a/spec/models/theme_spec.rb +++ b/spec/models/theme_spec.rb @@ -13,14 +13,6 @@ describe Theme do Guardian.new(user) end - let :customization_params do - { name: 'my name', user_id: user.id, header: "my awesome header" } - end - - let :customization do - Fabricate(:theme, customization_params) - end - let(:theme) { Fabricate(:theme, user: user) } let(:child) { Fabricate(:theme, user: user, component: true) } it 'can properly clean up color schemes' do @@ -326,9 +318,19 @@ HTML theme.save! transpiled = <<~HTML + (function() { + if ('Discourse' in window && Discourse.__container__) { + Discourse.__container__ + .lookup("service:theme-settings") + .registerSettings(#{theme.id}, {"name":"bob"}); + } + })(); if ('Discourse' in window && typeof Discourse._registerPluginCode === 'function') { + var themeSetting = Discourse.__container__.lookup("service:theme-settings").getObjectForTheme(#{theme.id}); + var themePrefix = function themePrefix(key) { + return 'theme_translations.#{theme.id}.' + key; + }; Discourse._registerPluginCode('1.0', function (api) { - var settings = { "name": "bob" }; alert(settings.name);var a = function a() {}; }); } @@ -342,9 +344,19 @@ HTML setting.value = 'bill' transpiled = <<~HTML + (function() { + if ('Discourse' in window && Discourse.__container__) { + Discourse.__container__ + .lookup("service:theme-settings") + .registerSettings(#{theme.id}, {"name":"bill"}); + } + })(); if ('Discourse' in window && typeof Discourse._registerPluginCode === 'function') { + var themeSetting = Discourse.__container__.lookup("service:theme-settings").getObjectForTheme(#{theme.id}); + var themePrefix = function themePrefix(key) { + return 'theme_translations.#{theme.id}.' + key; + }; Discourse._registerPluginCode('1.0', function (api) { - var settings = { "name": "bill" }; alert(settings.name);var a = function a() {}; }); } @@ -475,4 +487,89 @@ HTML expect(json).not_to match(/\"integer_setting\":54/) expect(json).to match(/\"boolean_setting\":false/) end + + describe "theme translations" do + it "can list working theme_translation_manager objects" do + en_translation = ThemeField.create!(theme_id: theme.id, name: "en", type_id: ThemeField.types[:yaml], target_id: Theme.targets[:translations], value: <<~YAML) + en: + group_of_translations: + translation1: en test1 + translation2: en test2 + base_translation1: en test3 + base_translation2: en test4 + YAML + fr_translation = ThemeField.create!(theme_id: theme.id, name: "fr", type_id: ThemeField.types[:yaml], target_id: Theme.targets[:translations], value: <<~YAML) + fr: + group_of_translations: + translation2: fr test2 + base_translation2: fr test4 + base_translation3: fr test5 + YAML + + I18n.locale = :fr + theme.update_translation("group_of_translations.translation1", "overriddentest1") + translations = theme.translations + theme.reload + + expect(translations.map(&:key)).to eq([ + "group_of_translations.translation1", + "group_of_translations.translation2", + "base_translation1", + "base_translation2", + "base_translation3" + ]) + + expect(translations.map(&:default)).to eq([ + "en test1", + "fr test2", + "en test3", + "fr test4", + "fr test5" + ]) + + expect(translations.map(&:value)).to eq([ + "overriddentest1", + "fr test2", + "en test3", + "fr test4", + "fr test5" + ]) + end + + it "can create a hash of overridden values" do + en_translation = ThemeField.create!(theme_id: theme.id, name: "en", type_id: ThemeField.types[:yaml], target_id: Theme.targets[:translations], value: <<~YAML) + en: + group_of_translations: + translation1: en test1 + YAML + + theme.update_translation("group_of_translations.translation1", "overriddentest1") + I18n.locale = :fr + theme.update_translation("group_of_translations.translation1", "overriddentest2") + theme.reload + expect(theme.translation_override_hash).to eq( + "en" => { + "group_of_translations" => { + "translation1" => "overriddentest1" + } + }, + "fr" => { + "group_of_translations" => { + "translation1" => "overriddentest2" + } + } + ) + end + + it "fall back when listing baked field" do + theme2 = Fabricate(:theme) + + en_translation = ThemeField.create!(theme_id: theme.id, name: "en", type_id: ThemeField.types[:yaml], target_id: Theme.targets[:translations], value: '') + fr_translation = ThemeField.create!(theme_id: theme.id, name: "fr", type_id: ThemeField.types[:yaml], target_id: Theme.targets[:translations], value: '') + + en_translation2 = ThemeField.create!(theme_id: theme2.id, name: "en", type_id: ThemeField.types[:yaml], target_id: Theme.targets[:translations], value: '') + + expect(Theme.list_baked_fields([theme.id, theme2.id], :translations, 'fr').map(&:id)).to contain_exactly(fr_translation.id, en_translation2.id) + end + end end diff --git a/spec/requests/admin/themes_controller_spec.rb b/spec/requests/admin/themes_controller_spec.rb index 6788b98fcc1..cdfbe248c3f 100644 --- a/spec/requests/admin/themes_controller_spec.rb +++ b/spec/requests/admin/themes_controller_spec.rb @@ -207,6 +207,47 @@ describe Admin::ThemesController do expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) end + it 'can update translations' do + theme.set_field(target: :translations, name: :en, value: { en: { somegroup: { somestring: "defaultstring" } } }.deep_stringify_keys.to_yaml) + theme.save! + + put "/admin/themes/#{theme.id}.json", params: { + theme: { + translations: { + "somegroup.somestring" => "overridenstring" + } + } + } + + # Response correct + expect(response.status).to eq(200) + json = ::JSON.parse(response.body) + expect(json["theme"]["translations"][0]["value"]).to eq("overridenstring") + + # Database correct + theme.reload + expect(theme.theme_translation_overrides.count).to eq(1) + expect(theme.theme_translation_overrides.first.translation_key).to eq("somegroup.somestring") + + # Set back to default + put "/admin/themes/#{theme.id}.json", params: { + theme: { + translations: { + "somegroup.somestring" => "defaultstring" + } + } + } + # Response correct + expect(response.status).to eq(200) + json = ::JSON.parse(response.body) + expect(json["theme"]["translations"][0]["value"]).to eq("defaultstring") + + # Database correct + theme.reload + expect(theme.theme_translation_overrides.count).to eq(0) + + end + it 'returns the right error message' do theme.update!(component: true)