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