mirror of
https://github.com/discourse/discourse.git
synced 2025-01-16 03:42:41 +08:00
3135f472e2
This commit contains various quality improvements to our site setup wizard, along with some rearrangement of steps to improve the admin setup experience and encourage admins to customize the site early to avoid "all sites look the same" sentiment. #### Step rearrangement * “Your site is ready” from 3 → 4 * “Logos” from 4 → 5 * “Look and feel” from 5 → 3 #### Font selector improvements Changes the wizard font selector dropdown to show a preview of all fonts with a CSS class so you don't have to choose the font to get a preview. Also makes the fonts appear in alphabetical order. #### Preview improvements Placeholder text changed from lorem ipsum to actual topic titles, category names, and post content. This makes it feel more "real". Fixes "undefined" categories. Added a date to the topic timeline. Fixes button rectangles and other UI elements not changing in size when the font changed, leading to cut off text which looked super messy. Also fixed some font color issues. Fixed table header alignment for Latest topic list. #### Homepage style selector improvements Limited the big list of homepage styles to Latest, Hot, Categories with latest topics, and Category boxes based on research into the most common options. #### Preview header Changed the preview header to move the hamburger to the left and add a chat icon #### And more! Changed the background of the wizard to use our branded blob style.
1042 lines
29 KiB
Ruby
1042 lines
29 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "csv"
|
|
require "json_schemer"
|
|
|
|
class Theme < ActiveRecord::Base
|
|
include GlobalPath
|
|
|
|
BASE_COMPILER_VERSION = 87
|
|
|
|
class SettingsMigrationError < StandardError
|
|
end
|
|
|
|
attr_accessor :child_components
|
|
attr_accessor :skip_child_components_update
|
|
|
|
def self.cache
|
|
@cache ||= DistributedCache.new("theme:compiler:#{BASE_COMPILER_VERSION}")
|
|
end
|
|
|
|
belongs_to :user
|
|
belongs_to :color_scheme
|
|
has_many :theme_fields, dependent: :destroy, validate: false
|
|
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 :parent_theme_relation,
|
|
class_name: "ChildTheme",
|
|
foreign_key: "child_theme_id",
|
|
dependent: :destroy
|
|
has_many :child_themes, -> { order(:name) }, through: :child_theme_relation, source: :child_theme
|
|
has_many :parent_themes,
|
|
-> { order(:name) },
|
|
through: :parent_theme_relation,
|
|
source: :parent_theme
|
|
has_many :color_schemes
|
|
has_many :theme_settings_migrations
|
|
belongs_to :remote_theme, dependent: :destroy
|
|
has_one :theme_modifier_set, dependent: :destroy
|
|
has_one :theme_svg_sprite, dependent: :destroy
|
|
|
|
has_one :settings_field,
|
|
-> { where(target_id: Theme.targets[:settings], name: "yaml") },
|
|
class_name: "ThemeField"
|
|
has_one :javascript_cache, dependent: :destroy
|
|
has_many :locale_fields,
|
|
-> { filter_locale_fields(I18n.fallbacks[I18n.locale]) },
|
|
class_name: "ThemeField"
|
|
has_many :upload_fields,
|
|
-> { where(type_id: ThemeField.types[:theme_upload_var]).preload(:upload) },
|
|
class_name: "ThemeField"
|
|
has_many :extra_scss_fields,
|
|
-> { where(target_id: Theme.targets[:extra_scss]) },
|
|
class_name: "ThemeField"
|
|
has_many :yaml_theme_fields,
|
|
-> { where("name = 'yaml' AND type_id = ?", ThemeField.types[:yaml]) },
|
|
class_name: "ThemeField"
|
|
has_many :var_theme_fields,
|
|
-> { where("type_id IN (?)", ThemeField.theme_var_type_ids) },
|
|
class_name: "ThemeField"
|
|
has_many :builder_theme_fields,
|
|
-> { where("name IN (?)", ThemeField.scss_fields) },
|
|
class_name: "ThemeField"
|
|
has_many :migration_fields,
|
|
-> { where(target_id: Theme.targets[:migrations]) },
|
|
class_name: "ThemeField"
|
|
|
|
validate :component_validations
|
|
validate :validate_theme_fields
|
|
|
|
after_create :update_child_components
|
|
|
|
scope :user_selectable, -> { where("user_selectable OR id = ?", SiteSetting.default_theme_id) }
|
|
|
|
scope :include_relations,
|
|
-> do
|
|
includes(
|
|
:child_themes,
|
|
:parent_themes,
|
|
:remote_theme,
|
|
:theme_settings,
|
|
:settings_field,
|
|
:locale_fields,
|
|
:user,
|
|
:color_scheme,
|
|
:theme_translation_overrides,
|
|
theme_fields: %i[upload theme_settings_migration],
|
|
)
|
|
end
|
|
|
|
delegate :remote_url, to: :remote_theme, private: true, allow_nil: true
|
|
|
|
def notify_color_change(color, scheme: nil)
|
|
scheme ||= color.color_scheme
|
|
changed_colors << color if color
|
|
changed_schemes << scheme if scheme
|
|
end
|
|
|
|
def theme_modifier_set
|
|
super || build_theme_modifier_set
|
|
end
|
|
|
|
after_save do
|
|
changed_colors.each(&:save!)
|
|
changed_schemes.each(&:save!)
|
|
|
|
changed_colors.clear
|
|
changed_schemes.clear
|
|
|
|
any_non_css_fields_changed =
|
|
changed_fields.any? { |f| !(f.basic_scss_field? || f.extra_scss_field?) }
|
|
|
|
changed_fields.each(&:save!)
|
|
changed_fields.clear
|
|
|
|
theme_modifier_set.save!
|
|
|
|
theme_fields.select(&:basic_html_field?).each(&:invalidate_baked!) if saved_change_to_name?
|
|
|
|
if saved_change_to_color_scheme_id? || saved_change_to_user_selectable? || saved_change_to_name?
|
|
Theme.expire_site_cache!
|
|
end
|
|
notify_with_scheme = saved_change_to_color_scheme_id?
|
|
|
|
reload
|
|
settings_field&.ensure_baked! # Other fields require setting to be **baked**
|
|
theme_fields.each(&:ensure_baked!)
|
|
|
|
update_javascript_cache!
|
|
|
|
remove_from_cache!
|
|
ColorScheme.hex_cache.clear
|
|
|
|
notify_theme_change(with_scheme: notify_with_scheme)
|
|
|
|
if theme_setting_requests_refresh
|
|
DB.after_commit do
|
|
Discourse.request_refresh!
|
|
self.theme_setting_requests_refresh = false
|
|
end
|
|
end
|
|
|
|
if any_non_css_fields_changed && should_refresh_development_clients?
|
|
MessageBus.publish "/file-change", ["development-mode-theme-changed"]
|
|
end
|
|
end
|
|
|
|
def should_refresh_development_clients?
|
|
Rails.env.development?
|
|
end
|
|
|
|
def update_child_components
|
|
if !component? && child_components.present? && !skip_child_components_update
|
|
child_components.each do |url|
|
|
url = ThemeStore::GitImporter.new(url.strip).url
|
|
theme = RemoteTheme.find_by(remote_url: url)&.theme
|
|
theme ||= RemoteTheme.import_theme(url, user)
|
|
child_themes << theme
|
|
end
|
|
end
|
|
end
|
|
|
|
def update_javascript_cache!
|
|
all_extra_js =
|
|
theme_fields
|
|
.where(target_id: Theme.targets[:extra_js])
|
|
.order(:name, :id)
|
|
.pluck(:name, :value)
|
|
.to_h
|
|
|
|
if all_extra_js.present?
|
|
js_compiler = ThemeJavascriptCompiler.new(id, name)
|
|
js_compiler.append_tree(all_extra_js)
|
|
settings_hash = build_settings_hash
|
|
|
|
js_compiler.prepend_settings(settings_hash) if settings_hash.present?
|
|
|
|
javascript_cache || build_javascript_cache
|
|
javascript_cache.update!(content: js_compiler.content, source_map: js_compiler.source_map)
|
|
else
|
|
javascript_cache&.destroy!
|
|
end
|
|
end
|
|
|
|
after_destroy do
|
|
remove_from_cache!
|
|
Theme.clear_default! if SiteSetting.default_theme_id == self.id
|
|
|
|
if self.id
|
|
ColorScheme
|
|
.where(theme_id: self.id)
|
|
.where("id NOT IN (SELECT color_scheme_id FROM themes where color_scheme_id IS NOT NULL)")
|
|
.destroy_all
|
|
|
|
ColorScheme.where(theme_id: self.id).update_all(theme_id: nil)
|
|
end
|
|
|
|
Theme.expire_site_cache!
|
|
end
|
|
|
|
def self.compiler_version
|
|
get_set_cache "compiler_version" do
|
|
dependencies = [
|
|
BASE_COMPILER_VERSION,
|
|
EmberCli.ember_version,
|
|
GlobalSetting.cdn_url,
|
|
GlobalSetting.s3_cdn_url,
|
|
GlobalSetting.s3_endpoint,
|
|
GlobalSetting.s3_bucket,
|
|
Discourse.current_hostname,
|
|
]
|
|
Digest::SHA1.hexdigest(dependencies.join)
|
|
end
|
|
end
|
|
|
|
def self.get_set_cache(key, &blk)
|
|
cache.defer_get_set(key, &blk)
|
|
end
|
|
|
|
def self.theme_ids
|
|
get_set_cache "theme_ids" do
|
|
Theme.pluck(:id)
|
|
end
|
|
end
|
|
|
|
def self.parent_theme_ids
|
|
get_set_cache "parent_theme_ids" do
|
|
Theme.where(component: false).pluck(:id)
|
|
end
|
|
end
|
|
|
|
def self.is_parent_theme?(id)
|
|
self.parent_theme_ids.include?(id)
|
|
end
|
|
|
|
def self.user_theme_ids
|
|
get_set_cache "user_theme_ids" do
|
|
Theme.user_selectable.pluck(:id)
|
|
end
|
|
end
|
|
|
|
def self.enabled_theme_and_component_ids
|
|
get_set_cache "enabled_theme_and_component_ids" do
|
|
theme_ids = Theme.user_selectable.where(enabled: true).pluck(:id)
|
|
component_ids =
|
|
ChildTheme
|
|
.where(parent_theme_id: theme_ids)
|
|
.joins(:child_theme)
|
|
.where(themes: { enabled: true })
|
|
.pluck(:child_theme_id)
|
|
(theme_ids | component_ids)
|
|
end
|
|
end
|
|
|
|
def self.allowed_remote_theme_ids
|
|
return nil if GlobalSetting.allowed_theme_repos.blank?
|
|
|
|
get_set_cache "allowed_remote_theme_ids" do
|
|
urls = GlobalSetting.allowed_theme_repos.split(",").map(&:strip)
|
|
Theme.joins(:remote_theme).where("remote_themes.remote_url in (?)", urls).pluck(:id)
|
|
end
|
|
end
|
|
|
|
def self.components_for(theme_id)
|
|
get_set_cache "theme_components_for_#{theme_id}" do
|
|
ChildTheme.where(parent_theme_id: theme_id).pluck(:child_theme_id)
|
|
end
|
|
end
|
|
|
|
def self.expire_site_cache!
|
|
Site.clear_anon_cache!
|
|
clear_cache!
|
|
ApplicationSerializer.expire_cache_fragment!("user_themes")
|
|
ColorScheme.hex_cache.clear
|
|
CSP::Extension.clear_theme_extensions_cache!
|
|
SvgSprite.expire_cache
|
|
end
|
|
|
|
def self.clear_default!
|
|
SiteSetting.default_theme_id = -1
|
|
expire_site_cache!
|
|
end
|
|
|
|
def self.transform_ids(id)
|
|
return [] if id.blank?
|
|
id = id.to_i
|
|
|
|
get_set_cache "transformed_ids_#{id}" do
|
|
all_ids =
|
|
if self.is_parent_theme?(id)
|
|
components = components_for(id).tap { |c| c.sort!.uniq! }
|
|
[id, *components]
|
|
else
|
|
[id]
|
|
end
|
|
|
|
disabled_ids =
|
|
Theme
|
|
.where(id: all_ids)
|
|
.includes(:remote_theme)
|
|
.select { |t| !t.supported? || !t.enabled? }
|
|
.map(&:id)
|
|
|
|
all_ids - disabled_ids
|
|
end
|
|
end
|
|
|
|
def set_default!
|
|
if component
|
|
raise Discourse::InvalidParameters.new(I18n.t("themes.errors.component_no_default"))
|
|
end
|
|
SiteSetting.default_theme_id = id
|
|
Theme.expire_site_cache!
|
|
end
|
|
|
|
def default?
|
|
SiteSetting.default_theme_id == id
|
|
end
|
|
|
|
def supported?
|
|
if minimum_version = remote_theme&.minimum_discourse_version
|
|
return false unless Discourse.has_needed_version?(Discourse::VERSION::STRING, minimum_version)
|
|
end
|
|
|
|
if maximum_version = remote_theme&.maximum_discourse_version
|
|
return false unless Discourse.has_needed_version?(maximum_version, Discourse::VERSION::STRING)
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
def component_validations
|
|
return unless component
|
|
|
|
errors.add(:base, I18n.t("themes.errors.component_no_color_scheme")) if color_scheme_id.present?
|
|
errors.add(:base, I18n.t("themes.errors.component_no_user_selectable")) if user_selectable
|
|
errors.add(:base, I18n.t("themes.errors.component_no_default")) if default?
|
|
end
|
|
|
|
def validate_theme_fields
|
|
theme_fields.each do |field|
|
|
field.errors.full_messages.each { |message| errors.add(:base, message) } unless field.valid?
|
|
end
|
|
end
|
|
|
|
def switch_to_component!
|
|
return if component
|
|
|
|
Theme.transaction do
|
|
self.component = true
|
|
|
|
self.color_scheme_id = nil
|
|
self.user_selectable = false
|
|
Theme.clear_default! if default?
|
|
|
|
ChildTheme.where("parent_theme_id = ?", id).destroy_all
|
|
self.save!
|
|
end
|
|
end
|
|
|
|
def switch_to_theme!
|
|
return unless component
|
|
|
|
Theme.transaction do
|
|
self.enabled = true
|
|
self.component = false
|
|
ChildTheme.where("child_theme_id = ?", id).destroy_all
|
|
self.save!
|
|
end
|
|
end
|
|
|
|
def self.find_default
|
|
find_by(id: SiteSetting.default_theme_id)
|
|
end
|
|
|
|
def self.lookup_field(theme_id, target, field, skip_transformation: false, csp_nonce: nil)
|
|
return "" if theme_id.blank?
|
|
|
|
theme_ids = !skip_transformation ? transform_ids(theme_id) : [theme_id]
|
|
resolved = (resolve_baked_field(theme_ids, target.to_sym, field) || "")
|
|
resolved = resolved.gsub(ThemeField::CSP_NONCE_PLACEHOLDER, csp_nonce) if csp_nonce
|
|
resolved.html_safe
|
|
end
|
|
|
|
def self.lookup_modifier(theme_ids, modifier_name)
|
|
theme_ids = [theme_ids] unless theme_ids.is_a?(Array)
|
|
|
|
get_set_cache("#{theme_ids.join(",")}:modifier:#{modifier_name}:#{Theme.compiler_version}") do
|
|
ThemeModifierSet.resolve_modifier_for_themes(theme_ids, modifier_name)
|
|
end
|
|
end
|
|
|
|
def self.remove_from_cache!
|
|
clear_cache!
|
|
end
|
|
|
|
def self.clear_cache!
|
|
cache.clear
|
|
end
|
|
|
|
def self.targets
|
|
@targets ||=
|
|
Enum.new(
|
|
common: 0,
|
|
desktop: 1,
|
|
mobile: 2,
|
|
settings: 3,
|
|
translations: 4,
|
|
extra_scss: 5,
|
|
extra_js: 6,
|
|
tests_js: 7,
|
|
migrations: 8,
|
|
)
|
|
end
|
|
|
|
def self.lookup_target(target_id)
|
|
self.targets.invert[target_id]
|
|
end
|
|
|
|
def self.notify_theme_change(
|
|
theme_ids,
|
|
with_scheme: false,
|
|
clear_manager_cache: true,
|
|
all_themes: false
|
|
)
|
|
Stylesheet::Manager.clear_theme_cache!
|
|
targets = %i[mobile_theme desktop_theme]
|
|
|
|
if with_scheme
|
|
targets.prepend(:desktop, :mobile, :admin)
|
|
targets.append(*Discourse.find_plugin_css_assets(mobile_view: true, desktop_view: true))
|
|
Stylesheet::Manager.cache.clear if clear_manager_cache
|
|
end
|
|
|
|
if all_themes
|
|
message = theme_ids.map { |id| refresh_message_for_targets(targets, id) }.flatten
|
|
else
|
|
message = refresh_message_for_targets(targets, theme_ids).flatten
|
|
end
|
|
|
|
MessageBus.publish("/file-change", message)
|
|
end
|
|
|
|
def notify_theme_change(with_scheme: false)
|
|
DB.after_commit do
|
|
theme_ids = Theme.transform_ids(id)
|
|
self.class.notify_theme_change(theme_ids, with_scheme: with_scheme)
|
|
end
|
|
end
|
|
|
|
def self.refresh_message_for_targets(targets, theme_ids)
|
|
theme_ids = [theme_ids] unless theme_ids.is_a?(Array)
|
|
|
|
targets.each_with_object([]) do |target, data|
|
|
theme_ids.each do |theme_id|
|
|
data << Stylesheet::Manager.new(theme_id: theme_id).stylesheet_data(target.to_sym)
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.resolve_baked_field(theme_ids, target, name)
|
|
target = target.to_sym
|
|
name = name&.to_sym
|
|
|
|
target = :mobile if target == :mobile_theme
|
|
target = :desktop if target == :desktop_theme
|
|
|
|
case target
|
|
when :extra_js
|
|
get_set_cache("#{theme_ids.join(",")}:extra_js:#{Theme.compiler_version}") do
|
|
require_rebake =
|
|
ThemeField.where(theme_id: theme_ids, target_id: Theme.targets[:extra_js]).where(
|
|
"compiler_version <> ?",
|
|
Theme.compiler_version,
|
|
)
|
|
|
|
ActiveRecord::Base.transaction do
|
|
require_rebake.each { |tf| tf.ensure_baked! }
|
|
|
|
Theme.where(id: require_rebake.map(&:theme_id)).each(&:update_javascript_cache!)
|
|
end
|
|
|
|
caches =
|
|
JavascriptCache
|
|
.where(theme_id: theme_ids)
|
|
.index_by(&:theme_id)
|
|
.values_at(*theme_ids)
|
|
.compact
|
|
|
|
caches.map { |c| <<~HTML.html_safe }.join("\n")
|
|
<script defer src="#{c.url}" data-theme-id="#{c.theme_id}" nonce="#{ThemeField::CSP_NONCE_PLACEHOLDER}"></script>
|
|
HTML
|
|
end
|
|
when :translations
|
|
theme_field_values(theme_ids, :translations, I18n.fallbacks[name])
|
|
.to_a
|
|
.select(&:second)
|
|
.uniq { |((theme_id, _, _), _)| theme_id }
|
|
.flat_map(&:second)
|
|
.join("\n")
|
|
else
|
|
theme_field_values(theme_ids, [:common, target], name).values.compact.flatten.join("\n")
|
|
end
|
|
end
|
|
|
|
def self.theme_field_values(theme_ids, targets, names)
|
|
cache.defer_get_set_bulk(
|
|
Array(theme_ids).product(Array(targets), Array(names)),
|
|
lambda do |(theme_id, target, name)|
|
|
"#{theme_id}:#{target}:#{name}:#{Theme.compiler_version}"
|
|
end,
|
|
) do |keys|
|
|
keys = keys.map { |theme_id, target, name| [theme_id, Theme.targets[target], name.to_s] }
|
|
|
|
keys
|
|
.map do |theme_id, target_id, name|
|
|
ThemeField.where(theme_id: theme_id, target_id: target_id, name: name)
|
|
end
|
|
.inject { |a, b| a.or(b) }
|
|
.each(&:ensure_baked!)
|
|
.map { |tf| [[tf.theme_id, tf.target_id, tf.name], tf.value_baked || tf.value] }
|
|
.group_by(&:first)
|
|
.transform_values { |x| x.map(&:second) }
|
|
.values_at(*keys)
|
|
end
|
|
end
|
|
|
|
def self.list_baked_fields(theme_ids, target, name)
|
|
target = target.to_sym
|
|
name = name&.to_sym
|
|
|
|
if target == :translations
|
|
fields = ThemeField.find_first_locale_fields(theme_ids, I18n.fallbacks[name])
|
|
else
|
|
target = :mobile if target == :mobile_theme
|
|
target = :desktop if target == :desktop_theme
|
|
fields =
|
|
ThemeField.find_by_theme_ids(theme_ids).where(
|
|
target_id: [Theme.targets[target], Theme.targets[:common]],
|
|
)
|
|
fields = fields.where(name: name.to_s) unless name.nil?
|
|
fields = fields.order(:target_id)
|
|
end
|
|
|
|
fields.each(&:ensure_baked!)
|
|
fields
|
|
end
|
|
|
|
def resolve_baked_field(target, name)
|
|
list_baked_fields(target, name).map { |f| f.value_baked || f.value }.join("\n")
|
|
end
|
|
|
|
def list_baked_fields(target, name)
|
|
theme_ids = Theme.transform_ids(id)
|
|
theme_ids = [theme_ids.first] if name != :color_definitions
|
|
self.class.list_baked_fields(theme_ids, target, name)
|
|
end
|
|
|
|
def remove_from_cache!
|
|
self.class.remove_from_cache!
|
|
end
|
|
|
|
def changed_fields
|
|
@changed_fields ||= []
|
|
end
|
|
|
|
def changed_colors
|
|
@changed_colors ||= []
|
|
end
|
|
|
|
def changed_schemes
|
|
@changed_schemes ||= Set.new
|
|
end
|
|
|
|
def set_field(target:, name:, value: nil, type: nil, type_id: nil, upload_id: nil)
|
|
name = name.to_s
|
|
|
|
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: name, target: target)
|
|
raise "Unknown type #{type} passed to set field" unless type_id
|
|
|
|
value ||= ""
|
|
|
|
field = theme_fields.find_by(name: name, target_id: target_id, type_id: type_id)
|
|
|
|
if field
|
|
if value.blank? && !upload_id
|
|
field.destroy
|
|
else
|
|
if field.value != value || field.upload_id != upload_id
|
|
field.value = value
|
|
field.upload_id = upload_id
|
|
changed_fields << field
|
|
end
|
|
end
|
|
else
|
|
if value.present? || upload_id.present?
|
|
field =
|
|
theme_fields.build(
|
|
target_id: target_id,
|
|
value: value,
|
|
name: name,
|
|
type_id: type_id,
|
|
upload_id: upload_id,
|
|
)
|
|
changed_fields << field
|
|
end
|
|
end
|
|
field
|
|
end
|
|
|
|
def child_theme_ids=(theme_ids)
|
|
super(theme_ids)
|
|
Theme.clear_cache!
|
|
end
|
|
|
|
def parent_theme_ids=(theme_ids)
|
|
super(theme_ids)
|
|
Theme.clear_cache!
|
|
end
|
|
|
|
def add_relative_theme!(kind, theme)
|
|
new_relation =
|
|
if kind == :child
|
|
child_theme_relation.new(child_theme_id: theme.id)
|
|
else
|
|
parent_theme_relation.new(parent_theme_id: theme.id)
|
|
end
|
|
if new_relation.save
|
|
child_themes.reload
|
|
parent_themes.reload
|
|
save!
|
|
Theme.clear_cache!
|
|
else
|
|
raise Discourse::InvalidParameters.new(new_relation.errors.full_messages.join(", "))
|
|
end
|
|
end
|
|
|
|
def internal_translations
|
|
@internal_translations ||= translations(internal: true)
|
|
end
|
|
|
|
def translations(internal: false)
|
|
fallbacks = I18n.fallbacks[I18n.locale]
|
|
begin
|
|
data =
|
|
locale_fields.first&.translation_data(
|
|
with_overrides: false,
|
|
internal: internal,
|
|
fallback_fields: locale_fields,
|
|
)
|
|
return {} if data.nil?
|
|
best_translations = {}
|
|
fallbacks.reverse.each { |locale| best_translations.deep_merge! data[locale] if data[locale] }
|
|
ThemeTranslationManager.list_from_hash(
|
|
theme: self,
|
|
hash: best_translations,
|
|
locale: I18n.locale,
|
|
)
|
|
rescue ThemeTranslationParser::InvalidYaml
|
|
{}
|
|
end
|
|
end
|
|
|
|
def settings
|
|
field = settings_field
|
|
settings = {}
|
|
|
|
if field && field.error.nil?
|
|
ThemeSettingsParser
|
|
.new(field)
|
|
.load do |name, default, type, opts|
|
|
settings[name] = ThemeSettingsManager.create(name, default, type, self, opts)
|
|
end
|
|
end
|
|
|
|
settings
|
|
end
|
|
|
|
def cached_settings
|
|
Theme.get_set_cache "settings_for_theme_#{self.id}" do
|
|
build_settings_hash
|
|
end
|
|
end
|
|
|
|
def cached_default_settings
|
|
Theme.get_set_cache "default_settings_for_theme_#{self.id}" do
|
|
settings_hash = {}
|
|
self.settings.each { |name, setting| settings_hash[name] = setting.default }
|
|
|
|
theme_uploads = build_theme_uploads_hash
|
|
settings_hash["theme_uploads"] = theme_uploads if theme_uploads.present?
|
|
|
|
theme_uploads_local = build_local_theme_uploads_hash
|
|
settings_hash["theme_uploads_local"] = theme_uploads_local if theme_uploads_local.present?
|
|
|
|
settings_hash
|
|
end
|
|
end
|
|
|
|
def build_settings_hash
|
|
hash = {}
|
|
self.settings.each { |name, setting| hash[name] = setting.value }
|
|
|
|
theme_uploads = build_theme_uploads_hash
|
|
hash["theme_uploads"] = theme_uploads if theme_uploads.present?
|
|
|
|
theme_uploads_local = build_local_theme_uploads_hash
|
|
hash["theme_uploads_local"] = theme_uploads_local if theme_uploads_local.present?
|
|
|
|
hash
|
|
end
|
|
|
|
def build_theme_uploads_hash
|
|
hash = {}
|
|
upload_fields
|
|
.includes(:javascript_cache, :upload)
|
|
.each do |field|
|
|
hash[field.name] = Discourse.store.cdn_url(field.upload.url) if field.upload&.url
|
|
end
|
|
hash
|
|
end
|
|
|
|
def build_local_theme_uploads_hash
|
|
hash = {}
|
|
upload_fields
|
|
.includes(:javascript_cache, :upload)
|
|
.each do |field|
|
|
hash[field.name] = field.javascript_cache.local_url if field.javascript_cache
|
|
end
|
|
hash
|
|
end
|
|
|
|
# Retrieves a theme setting
|
|
#
|
|
# @param setting_name [String, Symbol] The name of the setting to retrieve.
|
|
#
|
|
# @return [Object] The value of the setting that matches the provided name.
|
|
#
|
|
# @raise [Discourse::NotFound] If no setting is found with the provided name.
|
|
#
|
|
# @example
|
|
# theme.get_setting("some_boolean") => True
|
|
# theme.get_setting("some_string") => "hello"
|
|
# theme.get_setting(:some_boolean) => True
|
|
# theme.get_setting(:some_string) => "hello"
|
|
#
|
|
def get_setting(setting_name)
|
|
target_setting = settings[setting_name.to_sym]
|
|
raise Discourse::NotFound unless target_setting
|
|
target_setting.value
|
|
end
|
|
|
|
def update_setting(setting_name, new_value)
|
|
target_setting = settings[setting_name.to_sym]
|
|
raise Discourse::NotFound unless target_setting
|
|
target_setting.value = new_value
|
|
self.theme_setting_requests_refresh = true if target_setting.requests_refresh?
|
|
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 { |key| cursor = (cursor[key] ||= {}) }
|
|
cursor[path[-1]] = override.value
|
|
end
|
|
hash
|
|
end
|
|
|
|
def generate_metadata_hash
|
|
{}.tap do |meta|
|
|
meta[:name] = name
|
|
meta[:component] = component
|
|
|
|
RemoteTheme::METADATA_PROPERTIES.each do |property|
|
|
meta[property] = remote_theme&.public_send(property)
|
|
meta[property] = nil if meta[property] == "URL" # Clean up old discourse_theme CLI placeholders
|
|
end
|
|
|
|
meta[:assets] = {}.tap do |hash|
|
|
theme_fields
|
|
.where(type_id: ThemeField.types[:theme_upload_var])
|
|
.each { |field| hash[field.name] = field.file_path }
|
|
end
|
|
|
|
meta[:color_schemes] = {}.tap do |hash|
|
|
schemes = self.color_schemes
|
|
# The selected color scheme may not belong to the theme, so include it anyway
|
|
schemes = [self.color_scheme] + schemes if self.color_scheme
|
|
schemes.uniq.each do |scheme|
|
|
hash[scheme.name] = {}.tap do |colors|
|
|
scheme.colors.each { |color| colors[color.name] = color.hex }
|
|
end
|
|
end
|
|
end
|
|
|
|
meta[:modifiers] = {}.tap do |hash|
|
|
ThemeModifierSet.modifiers.keys.each do |modifier|
|
|
value = self.theme_modifier_set.public_send(modifier)
|
|
hash[modifier] = value if !value.nil?
|
|
end
|
|
end
|
|
|
|
meta[
|
|
:learn_more
|
|
] = "https://meta.discourse.org/t/beginners-guide-to-using-discourse-themes/91966"
|
|
end
|
|
end
|
|
|
|
def disabled_by
|
|
find_disable_action_log&.acting_user
|
|
end
|
|
|
|
def disabled_at
|
|
find_disable_action_log&.created_at
|
|
end
|
|
|
|
def with_scss_load_paths
|
|
return yield([]) if self.extra_scss_fields.empty?
|
|
|
|
ThemeStore::ZipExporter
|
|
.new(self)
|
|
.with_export_dir(extra_scss_only: true) { |dir| yield ["#{dir}/stylesheets"] }
|
|
end
|
|
|
|
def scss_variables
|
|
settings_hash = build_settings_hash
|
|
theme_variable_fields = var_theme_fields
|
|
|
|
return if theme_variable_fields.empty? && settings_hash.empty?
|
|
|
|
contents = +""
|
|
|
|
theme_variable_fields&.each do |field|
|
|
if field.type_id == ThemeField.types[:theme_upload_var]
|
|
if upload = field.upload
|
|
url = upload_cdn_path(upload.url)
|
|
contents << "$#{field.name}: unquote(\"#{url}\");"
|
|
else
|
|
contents << "$#{field.name}: unquote(\"\");"
|
|
end
|
|
else
|
|
contents << to_scss_variable(field.name, field.value)
|
|
end
|
|
end
|
|
|
|
settings_hash&.each do |name, value|
|
|
next if name == "theme_uploads" || name == "theme_uploads_local"
|
|
contents << to_scss_variable(name, value)
|
|
end
|
|
|
|
contents
|
|
end
|
|
|
|
def migrate_settings(start_transaction: true, fields: nil, allow_out_of_sequence_migration: false)
|
|
block = ->(*) do
|
|
runner = ThemeSettingsMigrationsRunner.new(self)
|
|
results =
|
|
runner.run(fields:, raise_error_on_out_of_sequence: !allow_out_of_sequence_migration)
|
|
|
|
next if results.blank?
|
|
|
|
old_settings = self.theme_settings.pluck(:name)
|
|
self.theme_settings.destroy_all
|
|
|
|
final_result = results.last
|
|
|
|
final_result[:settings_after].each do |key, val|
|
|
self.update_setting(key.to_sym, val)
|
|
rescue Discourse::NotFound
|
|
if old_settings.include?(key)
|
|
final_result[:settings_after].delete(key)
|
|
else
|
|
raise Theme::SettingsMigrationError.new(
|
|
I18n.t(
|
|
"themes.import_error.migrations.unknown_setting_returned_by_migration",
|
|
name: final_result[:original_name],
|
|
setting_name: key,
|
|
),
|
|
)
|
|
end
|
|
end
|
|
|
|
results.each do |res|
|
|
record =
|
|
ThemeSettingsMigration.new(
|
|
theme_id: self.id,
|
|
version: res[:version],
|
|
name: res[:name],
|
|
theme_field_id: res[:theme_field_id],
|
|
)
|
|
|
|
record.calculate_diff(res[:settings_before], res[:settings_after])
|
|
|
|
# If out of sequence migration is allowed we don't want to raise an error if the record is invalid due to version
|
|
# conflicts
|
|
allow_out_of_sequence_migration ? record.save : record.save!
|
|
end
|
|
|
|
self.reload
|
|
self.update_javascript_cache!
|
|
end
|
|
|
|
if start_transaction
|
|
self.transaction(&block)
|
|
else
|
|
block.call
|
|
end
|
|
end
|
|
|
|
def convert_list_to_json_schema(setting_row, setting)
|
|
schema = setting.json_schema
|
|
return if !schema
|
|
keys = schema["items"]["properties"].keys
|
|
return if !keys
|
|
|
|
current_values = CSV.parse(setting_row.value, **{ col_sep: "|" }).flatten
|
|
|
|
new_values =
|
|
current_values.map do |item|
|
|
parts = CSV.parse(item, **{ col_sep: "," }).flatten
|
|
raise "Schema validation failed" if keys.size < parts.size
|
|
parts.zip(keys).map(&:reverse).to_h
|
|
end
|
|
|
|
schemer = JSONSchemer.schema(schema)
|
|
raise "Schema validation failed" if !schemer.valid?(new_values)
|
|
|
|
setting_row.value = new_values.to_json
|
|
setting_row.data_type = setting.type
|
|
setting_row.save!
|
|
end
|
|
|
|
def baked_js_tests_with_digest
|
|
tests_tree =
|
|
theme_fields_to_tree(
|
|
theme_fields.where(target_id: Theme.targets[:tests_js]).order(name: :asc),
|
|
)
|
|
|
|
return nil, nil if tests_tree.blank?
|
|
|
|
migrations_tree =
|
|
theme_fields_to_tree(
|
|
theme_fields.where(target_id: Theme.targets[:migrations]).order(name: :asc),
|
|
)
|
|
|
|
compiler = ThemeJavascriptCompiler.new(id, name, minify: false)
|
|
compiler.append_tree(migrations_tree, include_variables: false)
|
|
compiler.append_tree(tests_tree)
|
|
|
|
compiler.append_raw_script "test_setup.js", <<~JS
|
|
(function() {
|
|
require("discourse/lib/theme-settings-store").registerSettings(#{self.id}, #{cached_default_settings.to_json}, { force: true });
|
|
})();
|
|
JS
|
|
|
|
content = compiler.content
|
|
|
|
if compiler.source_map
|
|
content +=
|
|
"\n//# sourceMappingURL=data:application/json;base64,#{Base64.strict_encode64(compiler.source_map)}\n"
|
|
end
|
|
|
|
[content, Digest::SHA1.hexdigest(content)]
|
|
end
|
|
|
|
def repository_url
|
|
return unless remote_url
|
|
remote_url.gsub(
|
|
%r{([^@]+@)?(http(s)?://)?(?<host>[^:/]+)[:/](?<path>((?!\.git).)*)(\.git)?(?<rest>.*)},
|
|
'\k<host>/\k<path>\k<rest>',
|
|
)
|
|
end
|
|
|
|
def user_selectable_count
|
|
UserOption.where(theme_ids: [id]).count
|
|
end
|
|
|
|
private
|
|
|
|
attr_accessor :theme_setting_requests_refresh
|
|
|
|
def theme_fields_to_tree(theme_fields_scope)
|
|
theme_fields_scope.reduce({}) do |tree, theme_field|
|
|
tree[theme_field.file_path] = theme_field.value
|
|
tree
|
|
end
|
|
end
|
|
|
|
def to_scss_variable(name, value)
|
|
escaped = SassC::Script::Value::String.quote(value.to_s, sass: true)
|
|
"$#{name}: unquote(#{escaped});"
|
|
end
|
|
|
|
def find_disable_action_log
|
|
if component? && !enabled?
|
|
@disable_log ||=
|
|
UserHistory
|
|
.where(context: id.to_s, action: UserHistory.actions[:disable_theme_component])
|
|
.order("created_at DESC")
|
|
.first
|
|
end
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: themes
|
|
#
|
|
# id :integer not null, primary key
|
|
# name :string not null
|
|
# user_id :integer not null
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# compiler_version :integer default(0), not null
|
|
# user_selectable :boolean default(FALSE), not null
|
|
# hidden :boolean default(FALSE), not null
|
|
# color_scheme_id :integer
|
|
# remote_theme_id :integer
|
|
# component :boolean default(FALSE), not null
|
|
# enabled :boolean default(TRUE), not null
|
|
# auto_update :boolean default(TRUE), not null
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_themes_on_remote_theme_id (remote_theme_id) UNIQUE
|
|
#
|