diff --git a/Gemfile b/Gemfile index ebe6e5d..06e3661 100644 --- a/Gemfile +++ b/Gemfile @@ -5,3 +5,5 @@ gemspec gem "jekyll", ENV["JEKYLL_VERSION"] ? "~> #{ENV["JEKYLL_VERSION"]}" : ">= 4.0" gem "minima" + +gem "sass-embedded", "~> 0.9.1" if RUBY_VERSION >= "2.6.0" diff --git a/README.md b/README.md index 7bdefbf..d596900 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,35 @@ Jekyll Sass Converter comes bundled with Jekyll 2.0.0 and greater. For more information about usage, visit the [Jekyll Assets Documentation page](https://jekyllrb.com/docs/assets/). +### Sass Implementations + +#### SassC + +By default, Jekyll Sass Converter uses [sassc](https://rubygems.org/gems/sassc) +for Sass implmentation. `sassc` is based on LibSass, and +[LibSass is deprecated](https://sass-lang.com/blog/libsass-is-deprecated). + +#### Sass Embedded + +[sass-embedded](https://rubygems.org/gems/sass-embedded) is a host for the +[Sass embedded protocol](https://github.com/sass/embedded-protocol). + +The host runs [Dart Sass compiler](https://github.com/sass/dart-sass-embedded) as a subprocess +and communicates with the dart-sass compiler by sending / receiving +[protobuf](https://github.com/protocolbuffers/protobuf) messages via the standard +input-output channel. + +*`sass-embedded` is currently experimental, unstable and requires Ruby 2.6 or higher.* + +To use the `sass-embedded` implementation, you need to first install the `sass-embedded` gem +either via your `Gemfile` and Bundler, or directly. Then, you have to specify `sass-embedded` +as the desired implementation in your `_config.yml`: + +```yaml +sass: + implementation: sass-embedded +``` + ### Source Maps Starting with `v2.0`, the Converter will by default generate a _source map_ file along with @@ -53,6 +82,13 @@ Configuration options are specified in the `_config.yml` file in the following w Available options are: + * **`implementation`** + + Sets the Sass implementation to use. + Can be `sassc` or `sass-embedded`. + + Defaults to `sassc`. + * **`style`** Sets the style of the CSS-output. @@ -60,7 +96,8 @@ Available options are: See the [SASS_REFERENCE](https://sass-lang.com/documentation/cli/dart-sass#style) for details. - Defaults to `compact`. + Defaults to `compact` for `sassc`. + Defaults to `expanded` for `sass-embedded`. * **`sass_dir`** diff --git a/lib/jekyll/converters/scss.rb b/lib/jekyll/converters/scss.rb index 17913c4..462057c 100644 --- a/lib/jekyll/converters/scss.rb +++ b/lib/jekyll/converters/scss.rb @@ -35,6 +35,7 @@ class Scss < Converter end end + ALLOWED_IMPLEMENTATIONS = %w(sassc sass-embedded).freeze ALLOWED_STYLES = %w(nested expanded compact compressed).freeze # Associate this Converter with the "page" object that manages input and output files for @@ -113,9 +114,17 @@ def sass_dir jekyll_sass_configuration["sass_dir"] end + def sass_implementation + implementation = jekyll_sass_configuration["implementation"] + ALLOWED_IMPLEMENTATIONS.include?(implementation) ? implementation : "sassc" + end + def sass_style - style = jekyll_sass_configuration.fetch("style", :compact) - ALLOWED_STYLES.include?(style.to_s) ? style.to_sym : :compact + # `:expanded` is the default output style for newer sass implementations. + # For backward compatibility, `:compact` is kept as the default output style for sassc. + default = sass_implementation == "sassc" ? :compact : :expanded + style = jekyll_sass_configuration.fetch("style", default) + ALLOWED_STYLES.include?(style.to_s) ? style.to_sym : default end def user_sass_load_paths @@ -179,18 +188,53 @@ def sass_configs ) end + def sass_embedded_config(data) + { + :data => data, + :file => file_path, + :indented_syntax => syntax == :sass, + :include_paths => sass_load_paths, + :output_style => sass_style, + :source_map => sourcemap_required?, + :out_file => output_path, + :omit_source_map_url => !sourcemap_required?, + :source_map_contents => true, + } + end + def convert(content) + case sass_implementation + when "sass-embedded" + Jekyll::External.require_with_graceful_fail("sass-embedded") + sass_embedded_convert(content) + when "sassc" + sass_convert(content) + end + end + + private + + def sass_convert(content) config = sass_configs engine = SassC::Engine.new(content.dup, config) output = engine.render - generate_source_map(engine) if sourcemap_required? + sass_generate_source_map(engine) if sourcemap_required? replacement = add_charset? ? '@charset "UTF-8";' : "" output.sub(BYTE_ORDER_MARK, replacement) rescue SassC::SyntaxError => e raise SyntaxError, e.to_s end - private + def sass_embedded_convert(content) + output = ::Sass.render(**sass_embedded_config(content)) + sass_embedded_generate_source_map(output.map) if sourcemap_required? + replacement = add_charset? ? '@charset "UTF-8";' : "" + eof = sourcemap_required? ? "" : "\n" + output.css.sub(BYTE_ORDER_MARK, replacement) + eof + rescue ::Sass::Embedded::RenderError => e + Jekyll.logger.error e.formatted + raise SyntaxError, e.to_s + end # The Page instance for which this object acts as a converter. attr_reader :sass_page @@ -209,6 +253,16 @@ def filename File.join(site_source_relative_from_pwd, sass_page.name) end + # The path of the input scss (or sass) file. This information will be used for error + # reporting and will written into the source map file as main source. + # + # Returns the path of the input file or nil if #associate_page failed + def file_path + return nil if associate_page_failed? + + File.join(site_source_relative_from_pwd, sass_page.path) + end + # The value of the `line_comments` option. # When set to `true` causes the line number and filename of the source be emitted into the # compiled CSS-file. Useful for debugging when the source-map is not available. @@ -266,7 +320,7 @@ def source_map_page # Reads the source-map from the engine and adds it to the source-map-page. # # @param [::SassC::Engine] engine The sass Compiler engine. - def generate_source_map(engine) + def sass_generate_source_map(engine) return if associate_page_failed? source_map_page.source_map(engine.source_map) @@ -275,6 +329,13 @@ def generate_source_map(engine) Jekyll.logger.warn "Could not generate source map #{e.message} => #{e.cause}" end + def sass_embedded_generate_source_map(source_map) + return if associate_page_failed? + + source_map_page.source_map(source_map) + site.pages << source_map_page + end + def site if associate_page_failed? Jekyll.sites.last diff --git a/script/test b/script/test index 4d70246..1504f1e 100755 --- a/script/test +++ b/script/test @@ -1,2 +1,11 @@ #!/bin/bash -bundle exec rspec $@ + +set -e + +echo "Running rspec with sassc" +SASS_IMPLEMENTATION=sassc bundle exec rspec $@ + +if bundle info sass-embedded; then + echo "Running rspec with sass-embedded" + SASS_IMPLEMENTATION=sass-embedded bundle exec rspec $@ +fi diff --git a/spec/sass_converter_spec.rb b/spec/sass_converter_spec.rb index 7817b60..72efe9a 100644 --- a/spec/sass_converter_spec.rb +++ b/spec/sass_converter_spec.rb @@ -21,7 +21,16 @@ SASS end - let(:css_output) do + let(:expanded_css_output) do + <<~CSS + body { + font-family: Helvetica, sans-serif; + font-color: fuschia; + } + CSS + end + + let(:compact_css_output) do <<~CSS body { font-family: Helvetica, sans-serif; font-color: fuschia; } CSS @@ -51,15 +60,21 @@ def converter(overrides = {}) context "converting sass" do it "produces CSS" do - expect(converter.convert(content)).to eql(css_output) + expected = sass_embedded? ? expanded_css_output : compact_css_output + expect(converter.convert(content)).to eql(expected) end it "includes the syntax error line in the syntax error message" do - error_message = 'Error: Invalid CSS after "f": expected 1 selector or at-rule.' - error_message = %r!\A#{error_message} was "font-family: \$font-"\s+on line 1:1 of stdin! + expected = if sass_embedded? + %r!Expected newline!i + else + error_message = 'Error: Invalid CSS after "f": expected 1 selector or at-rule.' + %r!\A#{error_message} was "font-family: \$font-"\s+on line 1:1 of stdin! + end + expect do converter.convert(invalid_content) - end.to raise_error(Jekyll::Converters::Scss::SyntaxError, error_message) + end.to raise_error(Jekyll::Converters::Scss::SyntaxError, expected) end it "removes byte order mark from compressed Sass" do @@ -81,7 +96,7 @@ def converter(overrides = {}) make_site( "source" => File.expand_path("pages-collection", __dir__), "sass" => { - "style" => :compact, + "style" => :expanded, }, "collections" => { "pages" => { @@ -93,7 +108,7 @@ def converter(overrides = {}) it "produces CSS without raising errors" do expect { site.process }.not_to raise_error - expect(sass_converter.convert(content)).to eql(css_output) + expect(sass_converter.convert(content)).to eql(expanded_css_output) end end @@ -102,14 +117,14 @@ def converter(overrides = {}) make_site( "source" => File.expand_path("[alpha]beta", __dir__), "sass" => { - "style" => :compact, + "style" => :expanded, } ) end it "produces CSS without raising errors" do expect { site.process }.not_to raise_error - expect(sass_converter.convert(content)).to eql(css_output) + expect(sass_converter.convert(content)).to eql(expanded_css_output) end end end diff --git a/spec/scss_converter_spec.rb b/spec/scss_converter_spec.rb index e7c0237..d9966d8 100644 --- a/spec/scss_converter_spec.rb +++ b/spec/scss_converter_spec.rb @@ -22,7 +22,16 @@ SCSS end - let(:css_output) do + let(:expanded_css_output) do + <<~CSS + body { + font-family: Helvetica, sans-serif; + font-color: fuschia; + } + CSS + end + + let(:compact_css_output) do <<~CSS body { font-family: Helvetica, sans-serif; font-color: fuschia; } CSS @@ -112,8 +121,9 @@ def converter(overrides = {}) expect(verter.sass_configs[:style]).to eql(:compressed) end - it "defaults style to :compact" do - expect(verter.sass_configs[:style]).to eql(:compact) + it "defaults style to :expanded for sass-embedded or :compact for sassc" do + expected = sass_embedded? ? :expanded : :compact + expect(verter.sass_configs[:style]).to eql(expected) end it "at least contains :syntax and :load_paths keys" do @@ -124,14 +134,19 @@ def converter(overrides = {}) context "converting SCSS" do it "produces CSS" do - expect(converter.convert(content)).to eql(css_output) + expected = sass_embedded? ? expanded_css_output : compact_css_output + expect(converter.convert(content)).to eql(expected) end it "includes the syntax error line in the syntax error message" do - error_message = 'Error: Invalid CSS after "body": expected 1 selector or at-rule, was "{"' - error_message = %r!\A#{error_message}\s+on line 2! + expected = if sass_embedded? + %r!expected ";"!i + else + error_message = 'Error: Invalid CSS after "body": expected 1 selector or at-rule' + %r!\A#{error_message}, was "{"\s+on line 2! + end expect { scss_converter.convert(invalid_content) }.to( - raise_error(Jekyll::Converters::Scss::SyntaxError, error_message) + raise_error(Jekyll::Converters::Scss::SyntaxError, expected) ) end @@ -197,9 +212,13 @@ def converter(overrides = {}) it "brings in the grid partial" do site.process - expect(File.read(test_css_file)).to eql( - "a { color: #999999; }\n\n/*# sourceMappingURL=main.css.map */" - ) + + expected = if sass_embedded? + "a {\n color: #999999;\n}\n\n/*# sourceMappingURL=main.css.map */" + else + "a { color: #999999; }\n\n/*# sourceMappingURL=main.css.map */" + end + expect(File.read(test_css_file)).to eql(expected) end context "with the sass_dir specified twice" do @@ -326,7 +345,7 @@ def converter(overrides = {}) make_site( "source" => File.expand_path("pages-collection", __dir__), "sass" => { - "style" => :compact, + "style" => :expanded, }, "collections" => { "pages" => { @@ -338,7 +357,7 @@ def converter(overrides = {}) it "produces CSS without raising errors" do expect { site.process }.not_to raise_error - expect(scss_converter.convert(content)).to eql(css_output) + expect(scss_converter.convert(content)).to eql(expanded_css_output) end end @@ -347,14 +366,14 @@ def converter(overrides = {}) make_site( "source" => File.expand_path("[alpha]beta", __dir__), "sass" => { - "style" => :compact, + "style" => :expanded, } ) end it "produces CSS without raising errors" do expect { site.process }.not_to raise_error - expect(scss_converter.convert(content)).to eql(css_output) + expect(scss_converter.convert(content)).to eql(expanded_css_output) end end @@ -388,7 +407,10 @@ def converter(overrides = {}) it "contains relevant sass sources" do sources = sourcemap_data["sources"] - expect(sources).to include("main.scss") + # sass-embedded (dart-sass) does not inlcude main.scss in sources + # because main.scss only contains @import statements + # thus there is no actual scss code to be mapped + expect(sources).to include("main.scss") unless sass_embedded? expect(sources).to include("_sass/_grid.scss") expect(sources).to_not include("_sass/_color.scss") # not imported into "main.scss" end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d12abab..f5098c7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -9,11 +9,27 @@ require "jekyll-sass-converter" Jekyll.logger.log_level = :error + +module GlobalSharedContext + extend RSpec::SharedContext + + let(:sass_implementation) { ENV["SASS_IMPLEMENTATION"] } + let(:sass_embedded?) { sass_implementation == "sass-embedded" } +end + RSpec.configure do |config| config.run_all_when_everything_filtered = true config.filter_run :focus config.order = "random" + config.include GlobalSharedContext + config.before(:example) do + if sass_implementation + allow_any_instance_of(Jekyll::Converters::Scss) + .to(receive(:sass_implementation).and_return(sass_implementation)) + end + end + SOURCE_DIR = File.expand_path("source", __dir__) DEST_DIR = File.expand_path("dest", __dir__) SASS_LIB_DIR = File.expand_path("other_sass_library", __dir__)