discourse/app/models/theme_modifier_set.rb
Kris dbcbd0d896
FEATURE: add modifier to restrict theme color schemes (#38796)
This allows themes to set a modifier in their `about.json` that
restricts a theme's color palettes to only what's defined within the
theme.

```json
"modifiers": {
   "only_theme_color_schemes": true
}
```

Once set, a theme's color palette settings will only list the palettes
included in about.json. A banner is shown indicating that the theme
restricts palettes.

<img width="700" alt="image"
src="https://github.com/user-attachments/assets/cfb4433c-c7c9-4923-a121-fddd572c29ea"
/>

<img width="300" alt="image"
src="https://github.com/user-attachments/assets/67ec1f72-d408-4e3b-911f-a2d0e4db9a37"
/>


If there's only 1 palette defined by the theme, it will be set to both
light and dark settings.

If there are more than 1 palettes defined, we check to see if one is
dark and will use the first dark palette for the dark setting.

If there are no color palettes defined by the theme, the color selection
will be unrestricted.

---------

Co-authored-by: Gabriel Grubba <70247653+Grubba27@users.noreply.github.com>
2026-03-26 09:03:11 -04:00

163 lines
4.8 KiB
Ruby

# frozen_string_literal: true
class ThemeModifierSet < ActiveRecord::Base
class ThemeModifierSetError < StandardError
end
belongs_to :theme
def self.modifiers
@modifiers ||= self.load_modifiers
end
validate :type_validator
def type_validator
ThemeModifierSet.modifiers.each do |k, config|
value = self[k]
next if value.nil?
case config[:type]
when :boolean
next if [true, false].include?(value)
when :string_array
next if value.is_a?(Array) && value.all? { |v| v.is_a?(String) }
end
errors.add(k, :invalid)
end
end
after_save do
SvgSprite.expire_cache if saved_change_to_svg_icons?
CSP::Extension.clear_theme_extensions_cache! if saved_change_to_csp_extensions?
if saved_change_to_only_theme_color_schemes?
ApplicationSerializer.expire_cache_fragment!("user_color_schemes")
ApplicationSerializer.expire_cache_fragment!("user_themes")
end
end
# Given the ids of multiple active themes / theme components, this function
# will combine them into a 'resolved' behavior
def self.resolve_modifier_for_themes(theme_ids, modifier_name)
return nil if !(config = self.modifiers[modifier_name])
all_values =
self
.where(theme_id: theme_ids)
.where.not(modifier_name => nil)
.map { |s| s.public_send(modifier_name) }
case config[:type]
when :boolean
all_values.any?
when :string_array
all_values.flatten(1)
else
raise ThemeModifierSetError, "Invalid theme modifier combine_mode"
end
end
def topic_thumbnail_sizes
array = read_attribute(:topic_thumbnail_sizes)
return if array.nil?
array
.map do |dimension|
parts = dimension.split("x")
next if parts.length != 2
[parts[0].to_i, parts[1].to_i]
end
.filter(&:present?)
end
def topic_thumbnail_sizes=(val)
return write_attribute(:topic_thumbnail_sizes, val) if val.nil?
return write_attribute(:topic_thumbnail_sizes, val) if !val.is_a?(Array)
if !val.all? { |v| v.is_a?(Array) && v.length == 2 }
return write_attribute(:topic_thumbnail_sizes, val)
end
super(val.map { |dim| "#{dim[0]}x#{dim[1]}" })
end
def add_theme_setting_modifier(modifier_name, setting_name)
self.theme_setting_modifiers ||= {}
self.theme_setting_modifiers[modifier_name] = setting_name
end
def refresh_theme_setting_modifiers(target_setting_name: nil, target_setting_value: nil)
changed = false
if self.theme_setting_modifiers.present?
self.theme_setting_modifiers.each do |modifier_name, setting_name|
modifier_name = modifier_name.to_sym
setting_name = setting_name.to_sym
next if target_setting_name.present? && target_setting_name.to_sym != setting_name
value =
target_setting_name.present? ? target_setting_value : theme.settings[setting_name]&.value
value = coerce_setting_value(modifier_name, value)
if self[modifier_name] != value
self[modifier_name] = value
changed = true
end
end
end
changed
end
private
def coerce_setting_value(modifier_name, value)
type = ThemeModifierSet.modifiers.dig(modifier_name, :type)
if type == :boolean
value.to_s != "false"
elsif type == :string_array
value.is_a?(Array) ? value : value.to_s.split("|")
end
end
# Build the list of modifiers from the DB schema.
# This allows plugins to introduce new modifiers by adding columns to the table
def self.load_modifiers
hash = {}
columns_hash.each do |column_name, info|
next if %w[id theme_id theme_setting_modifiers].include?(column_name)
type = nil
if info.type == :string && info.array?
type = :string_array
elsif info.type == :boolean && !info.array?
type = :boolean
else
if !%i[boolean string].include?(info.type)
raise ThemeModifierSetError, "Invalid theme modifier column type"
end
end
hash[column_name.to_sym] = { type: type }
end
hash
end
end
# == Schema Information
#
# Table name: theme_modifier_sets
#
# id :bigint not null, primary key
# csp_extensions :string is an Array
# custom_homepage :boolean
# only_theme_color_schemes :boolean
# serialize_post_user_badges :string is an Array
# serialize_topic_excerpts :boolean
# serialize_topic_is_hot :boolean
# serialize_topic_op_likes_data :boolean
# svg_icons :string is an Array
# theme_setting_modifiers :jsonb
# topic_thumbnail_sizes :string is an Array
# theme_id :bigint not null
#
# Indexes
#
# index_theme_modifier_sets_on_theme_id (theme_id) UNIQUE
#