FEATURE: Support for localized themes (#6848)

- Themes can supply translation files in a format like `/locales/{locale}.yml`. These files should be valid YAML, with a single top level key equal to the locale being defined. For now these can only be defined using the `discourse_theme` CLI, importing a `.tar.gz`, or from a GIT repository.

- Fallback is handled on a global level (if the locale is not defined in the theme), as well as on individual keys (if some keys are missing from the selected interface language).

- Administrators can override individual keys on a per-theme basis in the /admin/customize/themes user interface.

- Theme developers should access defined translations using the new theme prefix variables:
  JavaScript: `I18n.t(themePrefix("my_translation_key"))`
  Handlebars: `{{theme-i18n "my_translation_key"}}` or `{{i18n (theme-prefix "my_translation_key")}}`

- To design for backwards compatibility, theme developers can check for the presence of the `themePrefix` variable in JavaScript

- As part of this, the old `{{themeSetting.setting_name}}` syntax is deprecated in favour of `{{theme-setting "setting_name"}}`
This commit is contained in:
David Taylor 2019-01-17 11:46:11 +00:00 committed by GitHub
parent 740d047365
commit 880311dd4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1022 additions and 155 deletions

View File

@ -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")
);
}
});

View File

@ -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) {

View File

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

View File

@ -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 } });
}
});

View File

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

View File

@ -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);
}

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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];
}
});

View File

@ -339,7 +339,8 @@
}
.theme.settings {
.theme-setting {
.theme-setting,
.theme-translation {
padding-bottom: 0;
margin-top: 18px;
min-height: 35px;

View File

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

View File

@ -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 = {})

View File

@ -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"]

View File

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

View File

@ -1,4 +1,6 @@
require_dependency 'theme_settings_parser'
require_dependency 'theme_translation_parser'
require_dependency 'theme_javascript_compiler'
class ThemeField < ActiveRecord::Base
@ -11,9 +13,28 @@ class ThemeField < ActiveRecord::Base
where(theme_id: theme_ids)
.joins(
"JOIN (
SELECT #{theme_ids.map.with_index { |id, idx| "#{id.to_i} AS theme_id, #{idx} AS sort_column" }.join(" UNION ALL SELECT ")}
SELECT #{theme_ids.map.with_index { |id, idx| "#{id.to_i} AS theme_id, #{idx} AS theme_sort_column" }.join(" UNION ALL SELECT ")}
) as X ON X.theme_id = theme_fields.theme_id")
.order("sort_column")
.order("theme_sort_column")
}
scope :find_locale_fields, ->(theme_ids, locale_codes) {
return none unless theme_ids.present? && locale_codes.present?
find_by_theme_ids(theme_ids)
.where(target_id: Theme.targets[:translations], name: locale_codes)
.joins(self.sanitize_sql_array([
"JOIN (
SELECT * FROM (VALUES #{locale_codes.map { "(?)" }.join(",")}) as Y (locale_code, locale_sort_column)
) as Y ON Y.locale_code = theme_fields.name",
*locale_codes.map.with_index { |code, index| [code, index] }
]))
.reorder("X.theme_sort_column", "Y.locale_sort_column")
}
scope :find_first_locale_fields, ->(theme_ids, locale_codes) {
find_locale_fields(theme_ids, locale_codes)
.select("DISTINCT ON (X.theme_sort_column) *")
}
def self.types
@ -39,110 +60,125 @@ class ThemeField < ActiveRecord::Base
validates :name, format: { with: /\A[a-z_][a-z0-9_-]*\z/i },
if: Proc.new { |field| ThemeField.theme_var_type_ids.include?(field.type_id) }
COMPILER_VERSION = 6
COMPILER_VERSION = 7
belongs_to :theme
def settings(source)
settings = {}
theme.cached_settings.each do |k, v|
if source.include?("settings.#{k}")
settings[k] = v
end
end
if settings.length > 0
"let settings = #{settings.to_json};"
else
""
end
end
def transpile(es6_source, version)
template = Tilt::ES6ModuleTranspilerTemplate.new {}
wrapped = <<PLUGIN_API_JS
if ('Discourse' in window && typeof Discourse._registerPluginCode === 'function') {
Discourse._registerPluginCode('#{version}', api => {
#{settings(es6_source)}
#{es6_source}
});
}
PLUGIN_API_JS
template.babel_transpile(wrapped)
end
def process_html(html)
errors = nil
errors = []
javascript_cache || build_javascript_cache
javascript_cache.content = ''
js_compiler = ThemeJavascriptCompiler.new(theme_id)
doc = Nokogiri::HTML.fragment(html)
doc.css('script[type="text/x-handlebars"]').each do |node|
name = node["name"] || node["data-template-name"] || "broken"
is_raw = name =~ /\.raw$/
setting_helpers = ''
theme.cached_settings.each do |k, v|
val = v.is_a?(String) ? "\"#{v.gsub('"', "\\u0022")}\"" : v
setting_helpers += "{{theme-setting-injector #{is_raw ? "" : "context=this"} key=\"#{k}\" value=#{val}}}\n"
end
hbs_template = setting_helpers + node.inner_html
hbs_template = node.inner_html
if is_raw
template = "requirejs('discourse-common/lib/raw-handlebars').template(#{Barber::Precompiler.compile(hbs_template)})"
javascript_cache.content << <<COMPILED
(function() {
if ('Discourse' in window) {
Discourse.RAW_TEMPLATES[#{name.sub(/\.raw$/, '').inspect}] = #{template};
}
})();
COMPILED
else
template = "Ember.HTMLBars.template(#{Barber::Ember::Precompiler.compile(hbs_template)})"
javascript_cache.content << <<COMPILED
(function() {
if ('Em' in window) {
Ember.TEMPLATES[#{name.inspect}] = #{template};
}
})();
COMPILED
begin
if is_raw
js_compiler.append_raw_template(name, hbs_template)
else
js_compiler.append_ember_template(name, hbs_template)
end
rescue ThemeJavascriptCompiler::CompileError => ex
errors << ex.message
end
node.remove
end
doc.css('script[type="text/discourse-plugin"]').each do |node|
if node['version'].present?
begin
javascript_cache.content << transpile(node.inner_html, node['version'])
rescue MiniRacer::RuntimeError => ex
javascript_cache.content << "console.error('Theme Transpilation Error:', #{ex.message.inspect});"
errors ||= []
errors << ex.message
end
node.remove
next unless node['version'].present?
begin
js_compiler.append_plugin_script(node.inner_html, node['version'])
rescue ThemeJavascriptCompiler::CompileError => ex
errors << ex.message
end
node.remove
end
doc.css('script').each do |node|
next unless inline_javascript?(node)
javascript_cache.content << node.inner_html
javascript_cache.content << "\n"
js_compiler.append_raw_script(node.inner_html)
node.remove
end
errors.each do |error|
js_compiler.append_js_error(error)
end
js_compiler.prepend_settings(theme.cached_settings) if js_compiler.content.present? && theme.cached_settings.present?
javascript_cache.content = js_compiler.content
javascript_cache.save!
doc.add_child("<script src='#{javascript_cache.url}'></script>") if javascript_cache.content.present?
[doc.to_s, errors&.join("\n")]
end
def raw_translation_data
# Might raise ThemeTranslationParser::InvalidYaml
ThemeTranslationParser.new(self).load
end
def translation_data(with_overrides: true)
fallback_fields = theme.theme_fields.find_locale_fields([theme.id], I18n.fallbacks[name])
fallback_data = fallback_fields.each_with_index.map do |field, index|
begin
field.raw_translation_data
rescue ThemeTranslationParser::InvalidYaml
# If this is the locale with the error, raise it.
# If not, let the other theme_field raise the error when it processes itself
raise if field.id == id
{}
end
end
# TODO: Deduplicate the fallback data in the same way as JSLocaleHelper#load_translations_merged
# this would reduce the size of the payload, without affecting functionality
data = {}
fallback_data.each { |hash| data.merge!(hash) }
overrides = theme.translation_override_hash.deep_symbolize_keys
data.deep_merge!(overrides) if with_overrides
data
end
def process_translation
errors = []
javascript_cache || build_javascript_cache
js_compiler = ThemeJavascriptCompiler.new(theme_id)
begin
data = translation_data
js = <<~JS
/* Translation data for theme #{self.theme_id} (#{self.name})*/
const data = #{data.to_json};
for (let lang in data){
let cursor = I18n.translations;
for (let key of [lang, "js", "theme_translations"]){
cursor = cursor[key] = cursor[key] || {};
}
cursor[#{self.theme_id}] = data[lang];
}
JS
js_compiler.append_plugin_script(js, 0)
rescue ThemeTranslationParser::InvalidYaml => e
errors << e.message
end
javascript_cache.content = js_compiler.content
javascript_cache.save!
doc = ""
doc = "<script src='#{javascript_cache.url}'></script>" if javascript_cache.content.present?
[doc, errors&.join("\n")]
end
def validate_yaml!
return unless self.name == "yaml"
@ -181,12 +217,12 @@ COMPILED
self.error = errors.join("\n").presence
end
def self.guess_type(name)
def self.guess_type(name:, target:)
if html_fields.include?(name.to_s)
types[:html]
elsif scss_fields.include?(name.to_s)
types[:scss]
elsif name.to_s === "yaml"
elsif name.to_s == "yaml" || target.to_s == "translations"
types[:yaml]
end
end
@ -200,9 +236,10 @@ COMPILED
end
def ensure_baked!
if ThemeField.html_fields.include?(self.name)
if ThemeField.html_fields.include?(self.name) || translation = Theme.targets[:translations] == self.target_id
if !self.value_baked || compiler_version != COMPILER_VERSION
self.value_baked, self.error = process_html(self.value)
self.value_baked, self.error = translation ? process_translation : process_html(self.value)
self.error = nil unless self.error.present?
self.compiler_version = COMPILER_VERSION
if self.will_save_change_to_value_baked? ||

View File

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

View File

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

View File

@ -0,0 +1,3 @@
class ThemeTranslationSerializer < ApplicationSerializer
attributes :key, :value, :default
end

View File

@ -36,6 +36,7 @@
<%- end %>
<%- unless customization_disabled? %>
<%= raw theme_translations_lookup %>
<%= raw theme_lookup("head_tag") %>
<%- end %>

View File

@ -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!"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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