2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-03-04 01:15:08 +08:00
discourse/app/models/theme.rb
David Taylor af3385baba
DEV: Introduce new rollup-based plugin build system (#35477)
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>
2026-03-03 13:50:55 +00:00

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
#