359 lines
11 KiB
Ruby
359 lines
11 KiB
Ruby
# 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$/
|
|
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 = [:desktop, :mobile, :desktop_rtl, :mobile_rtl, :admin, :wizard]
|
|
targets += Discourse.find_plugin_css_assets(include_disabled: true, mobile_view: true, desktop_view: 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 = [: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]
|
|
%[<link href="#{href}" rel="preload" as="style"/>]
|
|
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)}\"" : ""
|
|
%[<link href="#{href}" media="#{media}" rel="stylesheet" data-target="#{target}" #{data_theme_id} #{data_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 = is_theme_target ?
|
|
"array_themes_#{@theme_ids.join(",")}_#{target}_#{current_hostname}" :
|
|
"array_#{target}_#{current_hostname}"
|
|
|
|
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
|
|
# 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]
|
|
|
|
%[<link href="#{href}" rel="preload" as="style"/>].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"
|
|
|
|
%[<link href="#{href}" media="#{media}" rel="stylesheet" class="#{css_class}"/>].html_safe
|
|
end
|
|
end
|