discourse/lib/stylesheet/manager/builder.rb
Rafael dos Santos Silva 6e522e4aad
DEV: Move to Sass compilation to dart-sass (#19910)
This PR is a major change to Sass compilation in Discourse.

The new version of sass-ruby moves to dart-sass putting we back on the supported version of Sass. It does so while keeping compatibility with the existing method signatures, so minimal change is needed in Discourse for this change.

This moves us

From:
  - sassc 2.0.1 (Feb 2019)
  - libsass 3.5.2 (May 2018)

To:
  - dart-sass 1.58

This update applies the following breaking changes:

> 
> These breaking changes are coming soon or have recently been released:
> 
>  [Functions are stricter about which units they allow](https://sass-lang.com/documentation/breaking-changes/function-units) beginning in Dart Sass 1.32.0.
> 
>  [Selectors with invalid combinators are invalid](https://sass-lang.com/documentation/breaking-changes/bogus-combinators) beginning in Dart Sass 1.54.0.
> 
>  [/ is changing from a division operation to a list separator](https://sass-lang.com/documentation/breaking-changes/slash-div) beginning in Dart Sass 1.33.0.
> 
>  [Parsing the special syntax of @-moz-document will be invalid](https://sass-lang.com/documentation/breaking-changes/moz-document) beginning in Dart Sass 1.7.2.
> 
>  [Compound selectors could not be extended](https://sass-lang.com/documentation/breaking-changes/extend-compound) in Dart Sass 1.0.0 and Ruby Sass 4.0.0.


SCSS files have been migrated automatically using `sass-migrator division app/assets/stylesheets/**/*.scss`
2023-02-07 12:24:57 -03:00

300 lines
8.1 KiB
Ruby

# frozen_string_literal: true
class Stylesheet::Manager::Builder
attr_reader :theme
def initialize(target: :desktop, theme: nil, color_scheme: nil, manager:)
@target = target
@theme = theme
@color_scheme = color_scheme
@manager = manager
end
def compile(opts = {})
if !opts[:force]
if File.exist?(stylesheet_fullpath)
if !StylesheetCache.where(target: qualified_target, digest: digest).exists?
begin
source_map =
begin
File.read(source_map_fullpath)
rescue Errno::ENOENT
end
StylesheetCache.add(
qualified_target,
digest,
File.read(stylesheet_fullpath),
source_map,
)
rescue => e
Rails.logger.warn "Completely unexpected error adding contents of '#{stylesheet_fullpath}' to cache #{e}"
end
end
return true
end
end
rtl = @target.to_s =~ /_rtl\z/
css, source_map =
with_load_paths do |load_paths|
Stylesheet::Compiler.compile_asset(
@target,
rtl: rtl,
theme_id: theme&.id,
theme_variables: theme&.scss_variables.to_s,
source_map_file: source_map_url_relative_from_stylesheet,
color_scheme_id: @color_scheme&.id,
load_paths: load_paths,
)
rescue SassC::SyntaxError, SassC::NotRenderedError => e
if Stylesheet::Importer::THEME_TARGETS.include?(@target.to_s)
# no special errors for theme, handled in theme editor
["", nil]
elsif @target.to_s == Stylesheet::Manager::COLOR_SCHEME_STYLESHEET
# log error but do not crash for errors in color definitions SCSS
Rails.logger.error "SCSS compilation error: #{e.message}"
["", nil]
else
raise Discourse::ScssError, e.message
end
end
FileUtils.mkdir_p(cache_fullpath)
File.open(stylesheet_fullpath, "w") { |f| f.puts css }
File.open(source_map_fullpath, "w") { |f| f.puts source_map } if source_map.present?
begin
StylesheetCache.add(qualified_target, digest, css, source_map)
rescue => e
Rails.logger.warn "Completely unexpected error adding item to cache #{e}"
end
css
end
def current_hostname
Discourse.current_hostname
end
def cache_fullpath
Stylesheet::Manager.cache_fullpath
end
def stylesheet_fullpath
"#{cache_fullpath}/#{stylesheet_filename}"
end
def source_map_fullpath
"#{cache_fullpath}/#{source_map_filename}"
end
def source_map_filename
"#{stylesheet_filename}.map"
end
def source_map_url_relative_from_stylesheet
"#{source_map_filename}?__ws=#{current_hostname}"
end
def stylesheet_fullpath_no_digest
"#{cache_fullpath}/#{stylesheet_filename_no_digest}"
end
def stylesheet_absolute_url
"#{GlobalSetting.cdn_url}#{stylesheet_relpath}?__ws=#{current_hostname}"
end
def root_path
"#{GlobalSetting.relative_url_root}/"
end
def stylesheet_relpath
"#{root_path}stylesheets/#{stylesheet_filename}"
end
def stylesheet_relpath_no_digest
"#{root_path}stylesheets/#{stylesheet_filename_no_digest}"
end
def qualified_target
if is_theme?
"#{@target}_#{theme&.id}"
elsif @color_scheme
"#{@target}_#{scheme_slug}_#{@color_scheme&.id.to_s}_#{@theme&.id}"
else
scheme_string = theme&.color_scheme ? "_#{theme.color_scheme.id}" : ""
"#{@target}#{scheme_string}"
end
end
def stylesheet_filename(with_digest = true)
digest_string = "_#{self.digest}" if with_digest
"#{qualified_target}#{digest_string}.css"
end
def stylesheet_filename_no_digest
stylesheet_filename(_with_digest = false)
end
def is_theme?
!!(@target.to_s =~ Stylesheet::Manager::THEME_REGEX)
end
def is_color_scheme?
!!(@target.to_s == Stylesheet::Manager::COLOR_SCHEME_STYLESHEET)
end
def scheme_slug
Slug.for(ActiveSupport::Inflector.transliterate(@color_scheme.name), "scheme")
end
# digest encodes the things that trigger a recompile
def digest
@digest ||=
begin
if is_theme?
theme_digest
elsif is_color_scheme?
color_scheme_digest
else
default_digest
end
end
end
def with_load_paths
if theme
theme.with_scss_load_paths { |p| yield p }
else
yield nil
end
end
def scss_digest
if %i[mobile_theme desktop_theme].include?(@target)
resolve_baked_field(@target.to_s.sub("_theme", ""), :scss)
elsif @target == :embedded_theme
resolve_baked_field(:common, :embedded_scss)
else
raise "attempting to look up theme digest for invalid field"
end
end
def theme_digest
Digest::SHA1.hexdigest(
scss_digest.to_s + color_scheme_digest.to_s + settings_digest + uploads_digest +
current_hostname,
)
end
# this protects us from situations where new versions of a plugin removed a file
# old instances may still be serving CSS and not aware of the change
# so we could end up poisoning the cache with a bad file that can not be removed
def plugins_digest
assets = []
DiscoursePluginRegistry.stylesheets.each { |_, paths| assets += paths.to_a }
DiscoursePluginRegistry.mobile_stylesheets.each { |_, paths| assets += paths.to_a }
DiscoursePluginRegistry.desktop_stylesheets.each { |_, paths| assets += paths.to_a }
Digest::SHA1.hexdigest(assets.sort.join)
end
def settings_digest
themes =
if !theme
[]
elsif Theme.is_parent_theme?(theme.id)
@manager.load_themes(@manager.theme_ids)
else
[@manager.get_theme(theme.id)]
end
fields =
themes.each_with_object([]) do |theme, array|
array.concat(theme.yaml_theme_fields.map(&:updated_at))
end
settings =
themes.each_with_object([]) do |theme, array|
array.concat(theme.theme_settings.map(&:updated_at))
end
timestamps = fields.concat(settings).map!(&:to_f).sort!.join(",")
Digest::SHA1.hexdigest(timestamps)
end
def uploads_digest
sha1s = []
(theme&.upload_fields || []).map { |upload_field| sha1s << upload_field.upload&.sha1 }
Digest::SHA1.hexdigest(sha1s.compact.sort!.join("\n"))
end
def default_digest
Digest::SHA1.hexdigest "default-#{Stylesheet::Manager.fs_asset_cachebuster}-#{plugins_digest}-#{current_hostname}"
end
def color_scheme_digest
cs = @color_scheme || theme&.color_scheme
categories_updated =
Stylesheet::Manager
.cache
.defer_get_set("categories_updated") do
Category.where("uploaded_background_id IS NOT NULL").pluck(:updated_at).map(&:to_i).sum
end
fonts = "#{SiteSetting.base_font}-#{SiteSetting.heading_font}"
digest_string = "#{current_hostname}-"
if cs || categories_updated > 0
theme_color_defs = resolve_baked_field(:common, :color_definitions)
digest_string +=
"#{RailsMultisite::ConnectionManagement.current_db}-#{cs&.id}-#{cs&.version}-#{theme_color_defs}-#{Stylesheet::Manager.fs_asset_cachebuster}-#{categories_updated}-#{fonts}"
else
digest_string += "defaults-#{Stylesheet::Manager.fs_asset_cachebuster}-#{fonts}"
if cdn_url = GlobalSetting.cdn_url
digest_string += "-#{cdn_url}"
end
end
Digest::SHA1.hexdigest digest_string
end
def resolve_baked_field(target, name)
theme_ids =
if !theme
[]
elsif Theme.is_parent_theme?(theme.id)
@manager.theme_ids
else
[theme.id]
end
theme_ids = [theme_ids.first] if name != :color_definitions
baked_fields = []
targets = [Theme.targets[target.to_sym], Theme.targets[:common]]
@manager
.load_themes(theme_ids)
.each do |theme|
theme.builder_theme_fields.each do |theme_field|
if theme_field.name == name.to_s && targets.include?(theme_field.target_id)
baked_fields << theme_field
end
end
end
baked_fields
.map do |f|
f.ensure_baked!
f.value_baked || f.value
end
.join("\n")
end
end