# frozen_string_literal: true require "distributed_cache" require "stylesheet/compiler" module Stylesheet end class Stylesheet::Manager BASE_COMPILER_VERSION = 1 CACHE_PATH ||= "tmp/stylesheet-cache" MANIFEST_DIR ||= "#{Rails.root}/tmp/cache/assets/#{Rails.env}" THEME_REGEX ||= /_theme\z/ COLOR_SCHEME_STYLESHEET ||= "color_definitions" @@lock = Mutex.new def self.cache @cache ||= DistributedCache.new("discourse_stylesheet") end def self.clear_theme_cache! cache.hash.keys.select { |k| k =~ /theme/ }.each { |k| cache.delete(k) } end def self.clear_color_scheme_cache! cache.hash.keys.select { |k| k =~ /color_definitions/ }.each { |k| cache.delete(k) } end def self.clear_core_cache!(targets) cache.hash.keys.select { |k| k =~ /#{targets.join("|")}/ }.each { |k| cache.delete(k) } end def self.clear_plugin_cache!(plugin) cache.hash.keys.select { |k| k =~ /#{plugin}/ }.each { |k| cache.delete(k) } end def self.color_scheme_cache_key(color_scheme, theme_id = nil) color_scheme_name = Slug.for(color_scheme.name) + color_scheme&.id.to_s theme_string = theme_id ? "_theme#{theme_id}" : "" "#{COLOR_SCHEME_STYLESHEET}_#{color_scheme_name}_#{theme_string}_#{Discourse.current_hostname}" end def self.precompile_css targets = %i[desktop mobile admin wizard desktop_rtl mobile_rtl admin_rtl wizard_rtl] targets += Discourse.find_plugin_css_assets( include_disabled: true, mobile_view: true, desktop_view: true, ) targets += Discourse.find_plugin_css_assets( include_disabled: true, mobile_view: true, desktop_view: true, rtl: true, ) targets.each do |target| $stderr.puts "precompile target: #{target}" Stylesheet::Manager::Builder.new(target: target, manager: nil).compile(force: true) end end def self.precompile_theme_css themes = Theme.where("user_selectable OR id = ?", SiteSetting.default_theme_id).pluck( :id, :color_scheme_id, ) color_schemes = ColorScheme.where(user_selectable: true).to_a color_schemes << ColorScheme.find_by(id: SiteSetting.default_dark_mode_color_scheme_id) color_schemes << ColorScheme.base color_schemes = color_schemes.compact.uniq targets = %i[desktop_theme mobile_theme] compiled = Set.new themes.each do |theme_id, color_scheme_id| manager = self.new(theme_id: theme_id) targets.each do |target| next if theme_id == -1 scss_checker = ScssChecker.new(target, manager.theme_ids) manager .load_themes(manager.theme_ids) .each do |theme| next if compiled.include?("#{target}_#{theme.id}") builder = Stylesheet::Manager::Builder.new(target: target, theme: theme, manager: manager) next if theme.component && !scss_checker.has_scss(theme.id) $stderr.puts "precompile target: #{target} #{theme.name}" builder.compile(force: true) compiled << "#{target}_#{theme.id}" end end theme_color_scheme = ColorScheme.find_by_id(color_scheme_id) theme = manager.get_theme(theme_id) [theme_color_scheme, *color_schemes].compact.uniq.each do |scheme| $stderr.puts "precompile target: #{COLOR_SCHEME_STYLESHEET} #{theme.name} (#{scheme.name})" Stylesheet::Manager::Builder.new( target: COLOR_SCHEME_STYLESHEET, theme: theme, color_scheme: scheme, manager: manager, ).compile(force: true) end clear_color_scheme_cache! end nil end def self.fs_asset_cachebuster if use_file_hash_for_cachebuster? @cachebuster ||= if File.exist?(manifest_full_path) File.readlines(manifest_full_path, "r")[0] else cachebuster = "#{BASE_COMPILER_VERSION}:#{fs_assets_hash}" FileUtils.mkdir_p(MANIFEST_DIR) File.open(manifest_full_path, "w") { |f| f.print(cachebuster) } cachebuster end else "#{BASE_COMPILER_VERSION}:#{max_file_mtime}" end end def self.recalculate_fs_asset_cachebuster! File.delete(manifest_full_path) if File.exist?(manifest_full_path) @cachebuster = nil fs_asset_cachebuster end def self.manifest_full_path path = "#{MANIFEST_DIR}/stylesheet-manifest" return path if !Rails.env.test? "#{path}-test_#{ENV["TEST_ENV_NUMBER"].presence || "0"}" end private_class_method :manifest_full_path def self.use_file_hash_for_cachebuster? Rails.env.production? end private_class_method :use_file_hash_for_cachebuster? def self.list_files globs = [ "#{Rails.root}/app/assets/stylesheets/**/*.*css", "#{Rails.root}/app/assets/images/**/*.*", ] Discourse.plugins.each do |plugin| path = File.dirname(plugin.path) globs << "#{path}/plugin.rb" globs << "#{path}/assets/stylesheets/**/*.*css" end globs.flat_map { |g| Dir.glob(g) }.compact end private_class_method :list_files def self.max_file_mtime list_files.map { |x| File.mtime(x) }.compact.max.to_i end private_class_method :max_file_mtime def self.fs_assets_hash hashes = list_files.sort.map { |x| Digest::SHA1.hexdigest("#{x}: #{File.read(x)}") } Digest::SHA1.hexdigest(hashes.join("|")) end private_class_method :fs_assets_hash def self.cache_fullpath path = "#{Rails.root}/#{CACHE_PATH}" return path if !Rails.env.test? File.join(path, "test_#{ENV["TEST_ENV_NUMBER"].presence || "0"}") end attr_reader :theme_ids def initialize(theme_id: nil) @theme_id = theme_id @theme_ids = Theme.transform_ids(@theme_id) @themes_cache = {} end def cache self.class.cache end def get_theme(theme_id) if theme = @themes_cache[theme_id] theme else load_themes([theme_id]).first end end def load_themes(theme_ids) themes = [] to_load_theme_ids = [] theme_ids.each do |theme_id| if @themes_cache[theme_id] themes << @themes_cache[theme_id] else to_load_theme_ids << theme_id end end Theme .where(id: to_load_theme_ids) .includes(:yaml_theme_fields, :theme_settings, :upload_fields, :builder_theme_fields) .each do |theme| @themes_cache[theme.id] = theme themes << theme end themes end def stylesheet_data(target = :desktop) stylesheet_details(target, "all") end def stylesheet_preload_tag(target = :desktop, media = "all") stylesheets = stylesheet_details(target, media) stylesheets .map do |stylesheet| href = stylesheet[:new_href] %[] end .join("\n") .html_safe end def stylesheet_link_tag(target = :desktop, media = "all", preload_callback = nil) stylesheets = stylesheet_details(target, media) stylesheets .map do |stylesheet| href = stylesheet[:new_href] preload_callback.call(href, "style") if preload_callback theme_id = stylesheet[:theme_id] data_theme_id = theme_id ? "data-theme-id=\"#{theme_id}\"" : "" theme_name = stylesheet[:theme_name] data_theme_name = theme_name ? "data-theme-name=\"#{CGI.escapeHTML(theme_name)}\"" : "" %[] end .join("\n") .html_safe end def stylesheet_details(target = :desktop, media = "all") target = target.to_sym current_hostname = Discourse.current_hostname is_theme_target = !!(target.to_s =~ THEME_REGEX) array_cache_key = ( if is_theme_target "array_themes_#{@theme_ids.join(",")}_#{target}_#{current_hostname}" else "array_#{target}_#{current_hostname}" end ) stylesheets = cache[array_cache_key] return stylesheets if stylesheets.present? @@lock.synchronize do stylesheets = [] if is_theme_target scss_checker = ScssChecker.new(target, @theme_ids) themes = load_themes(@theme_ids) themes.each do |theme| theme_id = theme&.id data = { target: target, theme_id: theme_id, theme_name: theme&.name.downcase, remote: theme.remote_theme_id?, } builder = Builder.new(target: target, theme: theme, manager: self) next if builder.theme&.component && !scss_checker.has_scss(theme_id) builder.compile unless File.exist?(builder.stylesheet_fullpath) href = builder.stylesheet_absolute_url data[:new_href] = href stylesheets << data end if stylesheets.size > 1 stylesheets = stylesheets.sort_by do |s| [s[:remote] ? 0 : 1, s[:theme_id] == @theme_id ? 1 : 0, s[:theme_name]] end end else builder = Builder.new(target: target, manager: self) builder.compile unless File.exist?(builder.stylesheet_fullpath) href = builder.stylesheet_absolute_url data = { target: target, new_href: href } stylesheets << data end cache.defer_set(array_cache_key, stylesheets.freeze) stylesheets end end def color_scheme_stylesheet_details(color_scheme_id = nil, media) theme_id = @theme_id || SiteSetting.default_theme_id color_scheme = begin ColorScheme.find(color_scheme_id) rescue StandardError # don't load fallback when requesting dark color scheme return false if media != "all" get_theme(theme_id)&.color_scheme || ColorScheme.base end return false if !color_scheme target = COLOR_SCHEME_STYLESHEET.to_sym current_hostname = Discourse.current_hostname cache_key = self.class.color_scheme_cache_key(color_scheme, theme_id) stylesheets = cache[cache_key] return stylesheets if stylesheets.present? stylesheet = { color_scheme_id: color_scheme.id } theme = get_theme(theme_id) builder = Builder.new( target: target, theme: get_theme(theme_id), color_scheme: color_scheme, manager: self, ) builder.compile unless File.exist?(builder.stylesheet_fullpath) href = builder.stylesheet_absolute_url stylesheet[:new_href] = href cache.defer_set(cache_key, stylesheet.freeze) stylesheet end def color_scheme_stylesheet_preload_tag(color_scheme_id = nil, media = "all") stylesheet = color_scheme_stylesheet_details(color_scheme_id, media) return "" if !stylesheet href = stylesheet[:new_href] %[].html_safe end def color_scheme_stylesheet_link_tag(color_scheme_id = nil, media = "all", preload_callback = nil) stylesheet = color_scheme_stylesheet_details(color_scheme_id, media) return "" if !stylesheet href = stylesheet[:new_href] preload_callback.call(href, "style") if preload_callback css_class = media == "all" ? "light-scheme" : "dark-scheme" %[].html_safe end end