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)