mirror of
https://github.com/discourse/discourse.git
synced 2026-03-04 01:15:08 +08:00
This commit introduces a new build system for plugins, which shares a large amount of code with the recently-modernized theme compiler. Plugins are compiled to native ES Modules, and loaded using native `import()` in the browser, just like themes. To achieve inter-plugin imports, each bundled plugin entrypoint implements a custom 'module federation' interface. Each export from internal plugin modules is made available as a specially-named export on the entrypoint. When modules in another plugin's namespace are imported, they are automatically rewritten to use these federated entrypoints. This change should be almost 100% backwards-compatible. There are some edge cases which will behave differently, since modules are now eagerly evaluated according to the ESM spec, instead of being lazily required via asynchronous-module-definitions (AMD). The native ESM format should also provide a performance improvement. The old AMD system involved lots of nested function calls when booting the app. Now, all the source modules are bundled up into a single ESM bundle with minimal stack depth. The build system implements a filesystem-based cache. That means that if the plugin javascript files are unchanged, they will not need to be recompiled, even after restarting the Rails server. This mimics the behaviour of the theme system. Similarly, in production builds, existing files will be automatically reused if they exist. In future, we plan to include pre-built copies of common plugins in our prebuild-asset bundles. Initially, this new compiler is disabled by default. To test it, we can set the `ROLLUP_PLUGIN_COMPILER=1` environment variable. We'll continue to test, improve and document the system before enabling it by default. --------- Co-authored-by: Jarek Radosz <jradosz@gmail.com> Co-authored-by: Chris Manson <chris@manson.ie>
1153 lines
33 KiB
Ruby
1153 lines
33 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "csv"
|
|
require "json_schemer"
|
|
|
|
class Theme < ActiveRecord::Base
|
|
include GlobalPath
|
|
|
|
CORE_THEMES = { "foundation" => -1, "horizon" => -2 }
|
|
EDITABLE_SYSTEM_ATTRIBUTES = %w[
|
|
child_theme_ids
|
|
color_scheme_id
|
|
dark_color_scheme_id
|
|
default
|
|
locale
|
|
translations
|
|
user_selectable
|
|
updated_at
|
|
]
|
|
|
|
class SettingsMigrationError < StandardError
|
|
end
|
|
|
|
class InvalidFieldTargetError < StandardError
|
|
end
|
|
|
|
class InvalidFieldTypeError < StandardError
|
|
end
|
|
|
|
attr_accessor :child_components
|
|
attr_accessor :skip_child_components_update
|
|
|
|
def self.cache
|
|
@cache ||= DistributedCache.new("theme:compiler:#{AssetProcessor::BASE_COMPILER_VERSION}")
|
|
end
|
|
|
|
belongs_to :user
|
|
belongs_to :color_scheme
|
|
belongs_to :dark_color_scheme, class_name: "ColorScheme"
|
|
alias_method :color_palette, :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"
|
|
has_many :theme_site_settings, dependent: :destroy
|
|
|
|
validate :component_validations
|
|
validate :validate_theme_fields
|
|
|
|
after_create :update_child_components
|
|
before_update :check_editable_attributes, if: :system?
|
|
before_destroy :raise_invalid_parameters, if: :system?
|
|
|
|
scope :user_selectable, -> { where("user_selectable OR id = ?", SiteSetting.default_theme_id) }
|
|
|
|
scope :include_relations,
|
|
-> do
|
|
include_basic_relations.includes(
|
|
:theme_settings,
|
|
:theme_site_settings,
|
|
:settings_field,
|
|
theme_fields: %i[upload theme_settings_migration],
|
|
child_themes: [
|
|
{ color_scheme: :base_scheme },
|
|
:locale_fields,
|
|
:theme_translation_overrides,
|
|
],
|
|
)
|
|
end
|
|
|
|
scope :include_basic_relations,
|
|
-> do
|
|
includes(
|
|
:remote_theme,
|
|
:user,
|
|
:locale_fields,
|
|
:theme_translation_overrides,
|
|
color_scheme: %i[theme color_scheme_colors base_scheme],
|
|
parent_themes: %i[color_scheme locale_fields theme_translation_overrides],
|
|
)
|
|
end
|
|
|
|
scope :not_components, -> { where(component: false) }
|
|
scope :not_system, -> { where("id > 0") }
|
|
scope :system, -> { where("id < 0") }
|
|
|
|
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_dark_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 load_all_extra_js
|
|
theme_fields
|
|
.where(target_id: Theme.targets[:extra_js])
|
|
.order(:name, :id)
|
|
.pluck(:name, :value)
|
|
.to_h
|
|
end
|
|
|
|
def update_javascript_cache!
|
|
all_extra_js = load_all_extra_js
|
|
if all_extra_js.present?
|
|
js_compiler = ThemeJavascriptCompiler.new(id, name, build_settings_hash)
|
|
js_compiler.append_tree(all_extra_js)
|
|
|
|
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 = [
|
|
AssetProcessor::BASE_COMPILER_VERSION,
|
|
AssetProcessor.ember_version,
|
|
GlobalSetting.cdn_url,
|
|
GlobalSetting.s3_cdn_url,
|
|
GlobalSetting.s3_endpoint,
|
|
GlobalSetting.s3_bucket,
|
|
Discourse.current_hostname,
|
|
ENV["ROLLUP_PLUGIN_COMPILER"],
|
|
]
|
|
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.expire_site_setting_cache!
|
|
Theme
|
|
.not_components
|
|
.pluck(:id)
|
|
.each do |theme_id|
|
|
Discourse.cache.delete(SiteSettingExtension.theme_site_settings_cache_key(theme_id))
|
|
end
|
|
|
|
Discourse.cache.delete(SiteSettingExtension.theme_site_settings_cache_key(nil))
|
|
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
|
|
|
|
# NOTE: The cache is expired in the 014-track-setting-changes.rb
|
|
# initializer, so we don't need to do it here.
|
|
SiteSetting.default_theme_id = id
|
|
end
|
|
|
|
def default?
|
|
SiteSetting.default_theme_id == id
|
|
end
|
|
|
|
def system?
|
|
id < 0
|
|
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 screenshot_dark_url
|
|
theme_fields
|
|
.find do |field|
|
|
field.type_id == ThemeField.types[:theme_screenshot_upload_var] &&
|
|
field.name == "screenshot_dark"
|
|
end
|
|
&.upload_url
|
|
end
|
|
|
|
def screenshot_light_url
|
|
theme_fields
|
|
.find do |field|
|
|
field.type_id == ThemeField.types[:theme_screenshot_upload_var] &&
|
|
field.name == "screenshot_light"
|
|
end
|
|
&.upload_url
|
|
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,
|
|
about: 9,
|
|
)
|
|
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[common_theme mobile_theme desktop_theme]
|
|
|
|
if with_scheme
|
|
targets.prepend(:common, :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: targets[:extra_js])
|
|
.where.not(compiler_version: 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")
|
|
<link rel="modulepreload" href="#{c.url}" data-theme-id="#{c.theme_id}" nonce="#{ThemeField::CSP_NONCE_PLACEHOLDER}" />
|
|
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 = :common if target == :common_theme
|
|
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])
|
|
fields = fields.where(name: name.to_s) unless name.nil?
|
|
fields = fields.order(:target_id)
|
|
end
|
|
|
|
fields.each(&:ensure_baked!)
|
|
fields
|
|
end
|
|
|
|
# def foundation_theme
|
|
# def horizon_theme
|
|
CORE_THEMES.each { |name, id| define_singleton_method("#{name}_theme") { Theme.find(id) } }
|
|
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]
|
|
if target_id.blank?
|
|
raise InvalidFieldTargetError.new("Unknown target #{target} passed to set field")
|
|
end
|
|
|
|
type_id ||=
|
|
type ? ThemeField.types[type.to_sym] : ThemeField.guess_type(name: name, target: target)
|
|
if type_id.blank?
|
|
if type.present?
|
|
raise InvalidFieldTypeError.new("Unknown type #{type} passed to set field")
|
|
else
|
|
raise InvalidFieldTypeError.new(
|
|
"No type could be guessed for field #{name} for target #{target}",
|
|
)
|
|
end
|
|
end
|
|
|
|
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(preloaded_locale_fields: nil)
|
|
@internal_translations ||=
|
|
translations(internal: true, preloaded_locale_fields: preloaded_locale_fields)
|
|
end
|
|
|
|
def translations(internal: false, preloaded_locale_fields: nil)
|
|
fallbacks = I18n.fallbacks[I18n.locale]
|
|
begin
|
|
data =
|
|
(preloaded_locale_fields&.first || 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
|
|
ThemeStore::ZipExporter
|
|
.new(self)
|
|
.with_export_dir(scss_only: true) do |dir|
|
|
FileUtils.mkdir_p("#{dir}/_entry_loadpath/theme-entrypoint")
|
|
|
|
entrypoints = {
|
|
"common/common.scss" => "common.scss",
|
|
"common/embedded.scss" => "embedded.scss",
|
|
"common/color_definitions.scss" => "color_definitions.scss",
|
|
"desktop/desktop.scss" => "desktop.scss",
|
|
"mobile/mobile.scss" => "mobile.scss",
|
|
}
|
|
|
|
entrypoints.each do |source, destination|
|
|
source_path = "#{dir}/#{source}"
|
|
destination_path = "#{dir}/_entry_loadpath/theme-entrypoint/#{destination}"
|
|
FileUtils.mv(source_path, destination_path) if File.exist?(source_path)
|
|
end
|
|
|
|
yield ["#{dir}/_entry_loadpath", "#{dir}/stylesheets"]
|
|
end
|
|
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, cached_default_settings, minify: false)
|
|
compiler.append_tree(load_all_extra_js)
|
|
compiler.append_tree(migrations_tree)
|
|
compiler.append_tree(tests_tree)
|
|
|
|
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: [self.id]).count
|
|
end
|
|
|
|
def themeable_site_settings
|
|
return [] if self.component?
|
|
ThemeSiteSettingResolver.new(theme: self).resolved_theme_site_settings
|
|
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
|
|
|
|
def check_editable_attributes
|
|
return if (changes.keys - EDITABLE_SYSTEM_ATTRIBUTES).empty?
|
|
raise_invalid_parameters
|
|
end
|
|
|
|
def raise_invalid_parameters
|
|
raise Discourse::InvalidParameters
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: themes
|
|
#
|
|
# id :integer not null, primary key
|
|
# auto_update :boolean default(TRUE), not null
|
|
# compiler_version :integer default(0), not null
|
|
# component :boolean default(FALSE), not null
|
|
# enabled :boolean default(TRUE), not null
|
|
# hidden :boolean default(FALSE), not null
|
|
# name :string not null
|
|
# user_selectable :boolean default(FALSE), not null
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# color_scheme_id :integer
|
|
# dark_color_scheme_id :integer
|
|
# remote_theme_id :integer
|
|
# user_id :integer not null
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_themes_on_remote_theme_id (remote_theme_id) UNIQUE
|
|
#
|