diff --git a/app/assets/javascripts/admin/components/site-setting.js.es6 b/app/assets/javascripts/admin/components/site-setting.js.es6
index 73e8160a28ca0..98bdf950f6504 100644
--- a/app/assets/javascripts/admin/components/site-setting.js.es6
+++ b/app/assets/javascripts/admin/components/site-setting.js.es6
@@ -1,96 +1,10 @@
import BufferedContent from 'discourse/mixins/buffered-content';
import SiteSetting from 'admin/models/site-setting';
-import { propertyNotEqual } from 'discourse/lib/computed';
-import computed from 'ember-addons/ember-computed-decorators';
-import { categoryLinkHTML } from 'discourse/helpers/category-link';
-
-const CustomTypes = ['bool', 'enum', 'list', 'url_list', 'host_list', 'category_list', 'value_list'];
-
-export default Ember.Component.extend(BufferedContent, {
- classNameBindings: [':row', ':setting', 'setting.overridden', 'typeClass'],
- content: Ember.computed.alias('setting'),
- dirty: propertyNotEqual('buffered.value', 'setting.value'),
- validationMessage: null,
-
- @computed("setting", "buffered.value")
- preview(setting, value) {
- // A bit hacky, but allows us to use helpers
- if (setting.get('setting') === 'category_style') {
- let category = this.site.get('categories.firstObject');
- if (category) {
- return categoryLinkHTML(category, {
- categoryStyle: value
- });
- }
- }
-
- let preview = setting.get('preview');
- if (preview) {
- return new Handlebars.SafeString("
{{#if showCommon}}
- -
- {{#link-to 'adminCustomizeThemes.edit' model.id 'common' fieldName replace=true title=field.title}}
- {{i18n 'admin.customize.theme.common'}}
- {{/link-to}}
-
+ -
+ {{#link-to 'adminCustomizeThemes.edit' model.id 'common' fieldName replace=true}}
+ {{i18n 'admin.customize.theme.common'}}
+ {{/link-to}}
+
{{/if}}
{{#if showDesktop}}
- -
- {{#link-to 'adminCustomizeThemes.edit' model.id 'desktop' fieldName replace=true title=field.title}}
- {{i18n 'admin.customize.theme.desktop'}}
- {{d-icon 'desktop'}}
- {{/link-to}}
-
+ -
+ {{#link-to 'adminCustomizeThemes.edit' model.id 'desktop' fieldName replace=true}}
+ {{i18n 'admin.customize.theme.desktop'}}
+ {{d-icon 'desktop'}}
+ {{/link-to}}
+
{{/if}}
{{#if showMobile}}
- -
- {{#link-to 'adminCustomizeThemes.edit' model.id 'mobile' fieldName replace=true title=field.title}}
- {{i18n 'admin.customize.theme.mobile'}}
- {{d-icon 'mobile'}}
- {{/link-to}}
-
+ -
+ {{#link-to 'adminCustomizeThemes.edit' model.id 'mobile' fieldName replace=true}}
+ {{i18n 'admin.customize.theme.mobile'}}
+ {{d-icon 'mobile'}}
+ {{/link-to}}
+
+ {{/if}}
+ {{#if showSettings}}
+ -
+ {{#link-to 'adminCustomizeThemes.edit' model.id 'settings' fieldName replace=true}}
+ {{i18n 'admin.customize.theme.settings'}}
+ {{d-icon 'cog'}}
+ {{/link-to}}
+
{{/if}}
diff --git a/app/assets/javascripts/admin/templates/customize-themes-show.hbs b/app/assets/javascripts/admin/templates/customize-themes-show.hbs
index 7f339d63d7480..b51061359badb 100644
--- a/app/assets/javascripts/admin/templates/customize-themes-show.hbs
+++ b/app/assets/javascripts/admin/templates/customize-themes-show.hbs
@@ -50,16 +50,16 @@
{{i18n "admin.customize.theme.css_html"}}
{{#if hasEditedFields}}
-
{{i18n "admin.customize.theme.custom_sections"}}
-
- {{#each editedDescriptions as |desc|}}
- - {{desc}}
- {{/each}}
-
+
{{i18n "admin.customize.theme.custom_sections"}}
+
+ {{#each editedDescriptions as |desc|}}
+ - {{desc}}
+ {{/each}}
+
{{else}}
-
- {{i18n "admin.customize.theme.edit_css_html_help"}}
-
+
+ {{i18n "admin.customize.theme.edit_css_html_help"}}
+
{{/if}}
{{#if model.remote_theme}}
@@ -71,17 +71,17 @@
{{/if}}
{{#d-button action="editTheme" class="btn edit"}}{{i18n 'admin.customize.theme.edit_css_html'}}{{/d-button}}
{{#if model.remote_theme}}
-
- {{#if updatingRemote}}
- {{i18n 'admin.customize.theme.updating'}}
- {{else}}
- {{#if model.remote_theme.commits_behind}}
- {{i18n 'admin.customize.theme.commits_behind' count=model.remote_theme.commits_behind}}
+
+ {{#if updatingRemote}}
+ {{i18n 'admin.customize.theme.updating'}}
{{else}}
- {{i18n 'admin.customize.theme.up_to_date'}} {{format-date model.remote_theme.updated_at leaveAgo="true"}}
+ {{#if model.remote_theme.commits_behind}}
+ {{i18n 'admin.customize.theme.commits_behind' count=model.remote_theme.commits_behind}}
+ {{else}}
+ {{i18n 'admin.customize.theme.up_to_date'}} {{format-date model.remote_theme.updated_at leaveAgo="true"}}
+ {{/if}}
{{/if}}
- {{/if}}
-
+
{{/if}}
@@ -105,6 +105,17 @@
{{#d-button action="addUploadModal" icon="plus"}}{{i18n "admin.customize.theme.add"}}{{/d-button}}
+
{{i18n "admin.customize.theme.theme_settings"}}
+ {{#d-section class="form-horizontal theme settings"}}
+ {{#if hasSettings}}
+ {{#each settings as |setting|}}
+ {{theme-setting setting=setting model=model class="theme-setting"}}
+ {{/each}}
+ {{else}}
+ {{i18n "admin.customize.theme.no_settings"}}
+ {{/if}}
+ {{/d-section}}
+
{{#if availableChildThemes}}
{{i18n "admin.customize.theme.theme_components"}}
{{#unless model.childThemes.length}}
diff --git a/app/assets/javascripts/admin/templates/site-settings-category.hbs b/app/assets/javascripts/admin/templates/site-settings-category.hbs
index 2e44f80c38cea..1af19ed7b615b 100644
--- a/app/assets/javascripts/admin/templates/site-settings-category.hbs
+++ b/app/assets/javascripts/admin/templates/site-settings-category.hbs
@@ -1,7 +1,7 @@
{{#if filteredContent}}
{{#d-section class="form-horizontal settings"}}
{{#each filteredContent as |setting|}}
- {{site-setting setting=setting saveAction="saveSetting"}}
+ {{site-setting setting=setting}}
{{/each}}
{{/d-section}}
{{else}}
diff --git a/app/assets/javascripts/admin/templates/site-settings.hbs b/app/assets/javascripts/admin/templates/site-settings.hbs
index 4561129eb868d..e3d9f4d8756ba 100644
--- a/app/assets/javascripts/admin/templates/site-settings.hbs
+++ b/app/assets/javascripts/admin/templates/site-settings.hbs
@@ -2,7 +2,7 @@
diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss
index 841e4d4d991c0..8ba862a92aac2 100644
--- a/app/assets/stylesheets/common/admin/customize.scss
+++ b/app/assets/stylesheets/common/admin/customize.scss
@@ -55,6 +55,21 @@
}
}
+ .theme.settings {
+ .theme-setting {
+ padding-bottom: 0;
+ padding-top: 18px;
+ min-height: 35px;
+ }
+ .setting-label {
+ width: 25%;
+ h3 {
+ margin-top: 0;
+ margin-bottom: .5rem;
+ }
+ }
+ }
+
.current-style.maximized {
position: fixed;
top: 0;
diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb
index b0f8d21f39638..3013c1c2844e8 100644
--- a/app/controllers/admin/themes_controller.rb
+++ b/app/controllers/admin/themes_controller.rb
@@ -138,6 +138,7 @@ def update
end
set_fields
+ update_settings
save_remote = false
if params[:theme][:remote_check]
@@ -158,7 +159,7 @@ def update
update_default_theme
log_theme_change(original_json, @theme)
- format.json { render json: @theme, status: :created }
+ format.json { render json: @theme, status: :ok }
else
format.json {
@@ -193,7 +194,7 @@ def show
response.headers['Content-Disposition'] = "attachment; filename=#{@theme.name.parameterize}.dcstyle.json"
response.sending_file = true
- render json: ThemeWithEmbeddedUploadsSerializer.new(@theme, root: 'theme')
+ render json: ::ThemeWithEmbeddedUploadsSerializer.new(@theme, root: 'theme')
end
end
@@ -223,6 +224,7 @@ def theme_params
:color_scheme_id,
:default,
:user_selectable,
+ settings: {},
theme_fields: [:name, :target, :value, :upload_id, :type_id],
child_theme_ids: []
)
@@ -243,6 +245,14 @@ def set_fields
end
end
+ def update_settings
+ return unless target_settings = theme_params[:settings]
+
+ target_settings.each_pair do |setting_name, new_value|
+ @theme.update_setting(setting_name.to_sym, new_value)
+ end
+ end
+
def log_theme_change(old_record, new_record)
StaffActionLogger.new(current_user).log_theme_change(old_record, new_record)
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 653ba952fc1a1..08739a425b6af 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -460,6 +460,7 @@ def locale_from_header
def preload_anonymous_data
store_preloaded("site", Site.json_for(guardian))
store_preloaded("siteSettings", SiteSetting.client_settings_json)
+ store_preloaded("themeSettings", Theme.settings_for_client(@theme_key))
store_preloaded("customHTML", custom_html_json)
store_preloaded("banner", banner_json)
store_preloaded("customEmoji", custom_emoji)
diff --git a/app/models/remote_theme.rb b/app/models/remote_theme.rb
index 1b8bfba1ed3f7..9d11932744832 100644
--- a/app/models/remote_theme.rb
+++ b/app/models/remote_theme.rb
@@ -76,6 +76,8 @@ def update_from_remote(importer = nil)
end
Theme.targets.keys.each do |target|
+ next if target == :settings
+
ALLOWED_FIELDS.each do |field|
lookup =
if field == "scss"
@@ -91,6 +93,9 @@ def update_from_remote(importer = nil)
end
end
+ settings_yaml = importer["settings.yaml"] || importer["settings.yml"]
+ theme.set_field(target: :settings, name: "yaml", value: settings_yaml)
+
self.license_url ||= theme_info["license_url"]
self.about_url ||= theme_info["about_url"]
self.remote_updated_at = Time.zone.now
diff --git a/app/models/theme.rb b/app/models/theme.rb
index e8aeaf0553176..8e6c152163ff1 100644
--- a/app/models/theme.rb
+++ b/app/models/theme.rb
@@ -1,6 +1,8 @@
require_dependency 'distributed_cache'
require_dependency 'stylesheet/compiler'
require_dependency 'stylesheet/manager'
+require_dependency 'theme_settings_parser'
+require_dependency 'theme_settings_manager'
class Theme < ActiveRecord::Base
@@ -8,6 +10,7 @@ class Theme < ActiveRecord::Base
belongs_to :color_scheme
has_many :theme_fields, dependent: :destroy
+ has_many :theme_settings, dependent: :destroy
has_many :child_theme_relation, class_name: 'ChildTheme', foreign_key: 'parent_theme_id', dependent: :destroy
has_many :child_themes, through: :child_theme_relation, source: :child_theme
has_many :color_schemes
@@ -34,11 +37,13 @@ def notify_color_change(color)
@included_themes = nil
remove_from_cache!
+ clear_cached_settings!
notify_scheme_change if saved_change_to_color_scheme_id?
end
after_destroy do
remove_from_cache!
+ clear_cached_settings!
if SiteSetting.default_theme_key == self.key
Theme.clear_default!
end
@@ -122,7 +127,11 @@ def self.clear_cache!
end
def self.targets
- @targets ||= Enum.new(common: 0, desktop: 1, mobile: 2)
+ @targets ||= Enum.new(common: 0, desktop: 1, mobile: 2, settings: 3)
+ end
+
+ def self.lookup_target(target_id)
+ self.targets.invert[target_id]
end
def notify_scheme_change(clear_manager_cache = true)
@@ -288,6 +297,56 @@ def add_child_theme!(theme)
child_themes.reload
save!
end
+
+ def settings
+ field = theme_fields.where(target_id: Theme.targets[:settings], name: "yaml").first
+ return [] unless field && field.error.nil?
+
+ settings = []
+ ThemeSettingsParser.new(field).load do |name, default, type, opts|
+ settings << ThemeSettingsManager.create(name, default, type, self, opts)
+ end
+ settings
+ end
+
+ def cached_settings
+ Rails.cache.fetch("settings_for_theme_#{self.key}", expires_in: 30.minutes) do
+ hash = {}
+ self.settings.each do |setting|
+ hash[setting.name] = setting.value
+ end
+ hash
+ end
+ end
+
+ def clear_cached_settings!
+ Rails.cache.delete("settings_for_theme_#{self.key}")
+ end
+
+ def included_settings
+ hash = {}
+
+ self.included_themes.each do |theme|
+ hash.merge!(theme.cached_settings)
+ end
+
+ hash.merge!(self.cached_settings)
+ hash
+ end
+
+ def self.settings_for_client(key)
+ theme = Theme.find_by(key: key)
+ return {}.to_json unless theme
+
+ theme.included_settings.to_json
+ end
+
+ def update_setting(setting_name, new_value)
+ target_setting = settings.find { |setting| setting.name == setting_name }
+ raise Discourse::NotFound unless target_setting
+
+ target_setting.value = new_value
+ end
end
# == Schema Information
diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb
index eb1dcd2bc26a6..ac8ffdd53af5e 100644
--- a/app/models/theme_field.rb
+++ b/app/models/theme_field.rb
@@ -1,3 +1,5 @@
+require_dependency 'theme_settings_parser'
+
class ThemeField < ActiveRecord::Base
belongs_to :upload
@@ -7,7 +9,8 @@ def self.types
scss: 1,
theme_upload_var: 2,
theme_color_var: 3,
- theme_var: 4)
+ theme_var: 4,
+ yaml: 5)
end
def self.theme_var_type_ids
@@ -77,11 +80,54 @@ def process_html(html)
[doc.to_s, errors&.join("\n")]
end
+ def validate_yaml!
+ return unless self.name == "yaml"
+
+ errors = []
+ begin
+ ThemeSettingsParser.new(self).load do |name, default, type, opts|
+ setting = ThemeSetting.new(name: name, data_type: type, theme: theme)
+ translation_key = "themes.settings_errors"
+
+ if setting.invalid?
+ setting.errors.details.each_pair do |attribute, _errors|
+ _errors.each do |hash|
+ errors << I18n.t("#{translation_key}.#{attribute}_#{hash[:error]}", name: name)
+ end
+ end
+ end
+
+ if default.nil?
+ errors << I18n.t("#{translation_key}.default_value_missing", name: name)
+ end
+
+ if (min = opts[:min]) && (max = opts[:max])
+ unless ThemeSetting.value_in_range?(default, (min..max), type)
+ errors << I18n.t("#{translation_key}.default_out_range", name: name)
+ end
+ end
+
+ unless ThemeSetting.acceptable_value_for_type?(default, type)
+ errors << I18n.t("#{translation_key}.default_not_match_type", name: name)
+ end
+ end
+ rescue ThemeSettingsParser::InvalidYaml => e
+ errors << e.message
+ end
+
+ self.error = errors.join("\n").presence unless self.destroyed?
+ if will_save_change_to_error?
+ update_columns(error: self.error)
+ end
+ end
+
def self.guess_type(name)
if html_fields.include?(name.to_s)
types[:html]
elsif scss_fields.include?(name.to_s)
types[:scss]
+ elsif name.to_s === "yaml"
+ types[:yaml]
end
end
@@ -121,7 +167,7 @@ def ensure_scss_compiles!
)
self.error = nil unless error.nil?
rescue SassC::SyntaxError => e
- self.error = e.message
+ self.error = e.message unless self.destroyed?
end
if will_save_change_to_error?
@@ -143,6 +189,8 @@ def target_name
after_commit do
ensure_baked!
ensure_scss_compiles!
+ validate_yaml!
+ theme.clear_cached_settings!
Stylesheet::Manager.clear_theme_cache! if self.name.include?("scss")
diff --git a/app/models/theme_setting.rb b/app/models/theme_setting.rb
new file mode 100644
index 0000000000000..136c78ecbb695
--- /dev/null
+++ b/app/models/theme_setting.rb
@@ -0,0 +1,68 @@
+class ThemeSetting < ActiveRecord::Base
+ belongs_to :theme
+
+ validates_presence_of :name, :theme
+ validates :data_type, numericality: { only_integer: true }
+ validates :name, length: { maximum: 255 }
+
+ after_save do
+ theme.clear_cached_settings!
+ end
+
+ def self.types
+ @types ||= Enum.new(integer: 0, float: 1, string: 2, bool: 3, list: 4, enum: 5)
+ end
+
+ def self.acceptable_value_for_type?(value, type)
+ case type
+ when self.types[:integer]
+ value.is_a?(Integer)
+ when self.types[:float]
+ value.is_a?(Integer) || value.is_a?(Float)
+ when self.types[:bool]
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
+ when self.types[:list]
+ value.is_a?(String)
+ else
+ true
+ end
+ end
+
+ def self.value_in_range?(value, range, type)
+ if type == self.types[:integer] || type == self.types[:float]
+ range.include? value
+ elsif type == self.types[:string]
+ range.include? value.to_s.length
+ end
+ end
+
+ def self.guess_type(value)
+ case value
+ when Integer
+ types[:integer]
+ when Float
+ types[:float]
+ when String
+ types[:string]
+ when TrueClass, FalseClass
+ types[:bool]
+ end
+ end
+end
+
+# == Schema Information
+#
+# Table name: theme_settings
+#
+# id :integer not null, primary key
+# name :string(255) not null
+# data_type :integer not null
+# value :string
+# theme_id :integer not null
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+# Indexes
+#
+# index_theme_settings_on_theme_id (theme_id)
+#
diff --git a/app/serializers/theme_serializer.rb b/app/serializers/theme_serializer.rb
index 78532e399a9da..8cf183b4a8d5a 100644
--- a/app/serializers/theme_serializer.rb
+++ b/app/serializers/theme_serializer.rb
@@ -24,11 +24,7 @@ def filename
end
def target
- case object.target_id
- when 0 then "common"
- when 1 then "desktop"
- when 2 then "mobile"
- end
+ Theme.lookup_target(object.target_id)&.to_s
end
def include_error?
@@ -60,7 +56,7 @@ def about_url
end
class ThemeSerializer < ChildThemeSerializer
- attributes :color_scheme, :color_scheme_id, :user_selectable, :remote_theme_id
+ attributes :color_scheme, :color_scheme_id, :user_selectable, :remote_theme_id, :settings
has_many :theme_fields, serializer: ThemeFieldSerializer, embed: :objects
has_many :child_themes, serializer: ChildThemeSerializer, embed: :objects
@@ -69,6 +65,10 @@ class ThemeSerializer < ChildThemeSerializer
def child_themes
object.child_themes.order(:name)
end
+
+ def settings
+ object.settings.map { |setting| ThemeSettingsSerializer.new(setting, root: false) }
+ end
end
class ThemeFieldWithEmbeddedUploadsSerializer < ThemeFieldSerializer
@@ -94,4 +94,8 @@ def raw_upload
class ThemeWithEmbeddedUploadsSerializer < ThemeSerializer
has_many :theme_fields, serializer: ThemeFieldWithEmbeddedUploadsSerializer, embed: :objects
+
+ def include_settings?
+ false
+ end
end
diff --git a/app/serializers/theme_settings_serializer.rb b/app/serializers/theme_settings_serializer.rb
new file mode 100644
index 0000000000000..cb3aa20794bfd
--- /dev/null
+++ b/app/serializers/theme_settings_serializer.rb
@@ -0,0 +1,35 @@
+class ThemeSettingsSerializer < ApplicationSerializer
+ attributes :setting, :type, :default, :value, :description, :valid_values
+
+ def setting
+ object.name
+ end
+
+ def type
+ object.type_name
+ end
+
+ def default
+ object.default
+ end
+
+ def value
+ object.value
+ end
+
+ def description
+ object.description
+ end
+
+ def valid_values
+ object.choices
+ end
+
+ def include_valid_values?
+ object.type == ThemeSetting.types[:enum]
+ end
+
+ def include_description?
+ object.description.present?
+ end
+end
diff --git a/app/views/common/_discourse_javascript.html.erb b/app/views/common/_discourse_javascript.html.erb
index 3211308db2044..dac6b19fbe96e 100644
--- a/app/views/common/_discourse_javascript.html.erb
+++ b/app/views/common/_discourse_javascript.html.erb
@@ -42,6 +42,7 @@
Discourse.BaseUri = '<%= Discourse::base_uri %>';
Discourse.Environment = '<%= Rails.env %>';
Discourse.SiteSettings = ps.get('siteSettings');
+ Discourse.ThemeSettings = ps.get('themeSettings');
Discourse.LetterAvatarVersion = '<%= LetterAvatar.version %>';
Discourse.MarkdownItURL = '<%= asset_url('markdown-it-bundle.js') %>';
Discourse.ServiceWorkerURL = '<%= Rails.application.assets_manifest.assets['service-worker.js'] %>'
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 65ebd21d03c97..c2c3475806049 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -3045,6 +3045,7 @@ en:
common: "Common"
desktop: "Desktop"
mobile: "Mobile"
+ settings: "Settings"
preview: "Preview"
is_default: "Theme is enabled by default"
user_selectable: "Theme can be selected by users"
@@ -3073,6 +3074,8 @@ en:
updating: "Updating..."
up_to_date: "Theme is up-to-date, last checked:"
add: "Add"
+ theme_settings: "Theme Settings"
+ no_settings: "This theme has no settings."
commits_behind:
one: "Theme is 1 commit behind!"
other: "Theme is {{count}} commits behind!"
@@ -3097,6 +3100,9 @@ en:
body_tag:
text: "