# frozen_string_literal: true # stdlib require "json" # 3rd party require "addressable/uri" require "sass-embedded" # internal require_relative "../source_map_page" module Jekyll module Converters class Scss < Converter EXTENSION_PATTERN = %r!^\.scss$!i SyntaxError = Class.new(ArgumentError) safe true priority :low # This hook is triggered just before the method {#convert(content)} is executed, it # associates the Scss (and Sass) converters with their respective sass_page objects. Jekyll::Hooks.register :pages, :pre_render do |page| next unless page.is_a?(Jekyll::Page) page.converters.each do |converter| converter.associate_page(page) if converter.is_a?(Jekyll::Converters::Scss) end end # This hook is triggered just after the method {#convert(content)} has been executed, it # dissociates the Scss (and Sass) converters with their respective sass_page objects. Jekyll::Hooks.register :pages, :post_render do |page| next unless page.is_a?(Jekyll::Page) page.converters.each do |converter| converter.dissociate_page(page) if converter.is_a?(Jekyll::Converters::Scss) end end ALLOWED_STYLES = %w(expanded compressed).freeze # Associate this Converter with the "page" object that manages input and output files for # this converter. # # Note: changing the associated sass_page during the live time of this Converter instance # may result in inconsistent results. # # @param [Jekyll:Page] page The sass_page for which this object acts as converter. def associate_page(page) if @sass_page Jekyll.logger.debug "Sass Converter:", "sass_page re-assigned: #{@sass_page.name} to #{page.name}" dissociate_page(page) return end @sass_page = page end # Dissociate this Converter with the "page" object. # # @param [Jekyll:Page] page The sass_page for which this object has acted as a converter. def dissociate_page(page) unless page.equal?(@sass_page) Jekyll.logger.debug "Sass Converter:", "dissociating a page that was never associated #{page.name}" end @source_map_page = nil @sass_page = nil @site = nil end def matches(ext) ext =~ self.class::EXTENSION_PATTERN end def output_ext(_ext) ".css" end def safe? !!@config["safe"] end def jekyll_sass_configuration @jekyll_sass_configuration ||= begin options = @config["sass"] || {} unless options["style"].nil? options["style"] = options["style"].to_s.delete_prefix(":").to_sym end options end end def syntax :scss end def sass_dir return "_sass" if jekyll_sass_configuration["sass_dir"].to_s.empty? jekyll_sass_configuration["sass_dir"] end def sass_style style = jekyll_sass_configuration["style"] ALLOWED_STYLES.include?(style.to_s) ? style.to_sym : :expanded end def user_sass_load_paths Array(jekyll_sass_configuration["load_paths"]) end def sass_dir_relative_to_site_source @sass_dir_relative_to_site_source ||= Jekyll.sanitized_path(site_source, sass_dir).delete_prefix("#{site.source}/") end # rubocop:disable Metrics/AbcSize def sass_load_paths paths = user_sass_load_paths + [sass_dir_relative_to_site_source] # Sanitize paths to prevent any attack vectors (.e.g. `/**/*`) paths.map! { |path| Jekyll.sanitized_path(site_source, path) } if safe? # Expand file globs (e.g. `node_modules/*/node_modules` ) Dir.chdir(site_source) do paths = paths.flat_map { |path| Dir.glob(path) } paths.map! do |path| # Sanitize again in case globbing was able to do something crazy. safe? ? Jekyll.sanitized_path(site_source, path) : File.expand_path(path) end end paths.uniq! paths << site.theme.sass_path if site.theme&.sass_path paths.select { |path| File.directory?(path) } end # rubocop:enable Metrics/AbcSize def sass_configs { :load_paths => sass_load_paths, :charset => !associate_page_failed?, :source_map => sourcemap_required?, :source_map_include_sources => true, :style => sass_style, :syntax => syntax, :url => sass_file_url, :quiet_deps => quiet_deps_option, :verbose => verbose_option, :fatal_deprecations => fatal_deprecations, :future_deprecations => future_deprecations, :silence_deprecations => silence_deprecations, } end def convert(content) output = ::Sass.compile_string(content, **sass_configs) result = output.css if sourcemap_required? source_map = process_source_map(output.source_map) generate_source_map_page(source_map) if (sm_url = source_mapping_url) result += "#{sass_style == :compressed ? "" : "\n\n"}/*# sourceMappingURL=#{sm_url} */" end end result rescue ::Sass::CompileError => e Jekyll.logger.error e.full_message if livereload? e.to_css # Render error message in browser window else raise SyntaxError, e.message end end private # The Page instance for which this object acts as a converter. attr_reader :sass_page def associate_page_failed? !sass_page end # Returns `true` if jekyll is serving with livereload. def livereload? !!(@config["serving"] && @config["livereload"]) end # The URL of the input scss (or sass) file. This information will be used for error reporting. def sass_file_url return if associate_page_failed? file_url_from_path(Jekyll.sanitized_path(site_source, sass_page.relative_path)) end # The value of the `sourcemap` option chosen by the user. # # This option controls when sourcemaps shall be generated or not. # # Returns the value of the `sourcemap`-option chosen by the user or ':always' by default. def sourcemap_option jekyll_sass_configuration.fetch("sourcemap", :always).to_sym end # Determines whether a sourcemap shall be generated or not. # # Returns `true` if a sourcemap shall be generated, `false` otherwise. def sourcemap_required? return false if associate_page_failed? || sourcemap_option == :never return true if sourcemap_option == :always !(sourcemap_option == :development && Jekyll.env != "development") end def source_map_page return if associate_page_failed? @source_map_page ||= SourceMapPage.new(sass_page) end # Returns the directory that source map sources are relative to. def sass_source_root if associate_page_failed? site_source else Jekyll.sanitized_path(site_source, File.dirname(sass_page.relative_path)) end end # Converts file urls in source map to relative paths. # # Returns processed source map string. def process_source_map(source_map) map_data = JSON.parse(source_map) unless associate_page_failed? map_data["file"] = Addressable::URI.encode("#{sass_page.basename}.css") end source_root_url = Addressable::URI.parse(file_url_from_path("#{sass_source_root}/")) map_data["sources"].map! do |s| s.start_with?("file:") ? Addressable::URI.parse(s).route_from(source_root_url).to_s : s end JSON.generate(map_data) end # Adds the source-map to the source-map-page and adds it to `site.pages`. def generate_source_map_page(source_map) return if associate_page_failed? source_map_page.source_map(source_map) site.pages << source_map_page end # Returns a source mapping url for given source-map. def source_mapping_url return if associate_page_failed? Addressable::URI.encode("#{sass_page.basename}.css.map") end def site associate_page_failed? ? Jekyll.sites.last : sass_page.site end def site_source site.source end def file_url_from_path(path) Addressable::URI.encode("file://#{path.start_with?("/") ? "" : "/"}#{path}") end # Returns the value of the `quiet_deps`-option chosen by the user or 'false' by default. def quiet_deps_option !!jekyll_sass_configuration.fetch("quiet_deps", false) end # Returns the value of the `verbose`-option chosen by the user or 'false' by default. def verbose_option !!jekyll_sass_configuration.fetch("verbose", false) end # Returns the value of the `fatal_deprecations`-option or '[]' by default. def fatal_deprecations Array(jekyll_sass_configuration["fatal_deprecations"]) end # Returns the value of the `future_deprecations`-option or '[]' by default. def future_deprecations Array(jekyll_sass_configuration["future_deprecations"]) end # Returns the value of the `silence_deprecations`-option or '[]' by default. def silence_deprecations Array(jekyll_sass_configuration["silence_deprecations"]) end end end end