| # A visitor for converting a Sass tree into CSS. |
| class Sass::Tree::Visitors::ToCss < Sass::Tree::Visitors::Base |
| # The source mapping for the generated CSS file. This is only set if |
| # `build_source_mapping` is passed to the constructor and \{Sass::Engine#render} has been |
| # run. |
| attr_reader :source_mapping |
| |
| # @param build_source_mapping [Boolean] Whether to build a |
| # \{Sass::Source::Map} while creating the CSS output. The mapping will |
| # be available from \{#source\_mapping} after the visitor has completed. |
| def initialize(build_source_mapping = false) |
| @tabs = 0 |
| @line = 1 |
| @offset = 1 |
| @result = String.new("") |
| @source_mapping = build_source_mapping ? Sass::Source::Map.new : nil |
| @lstrip = nil |
| @in_directive = false |
| end |
| |
| # Runs the visitor on `node`. |
| # |
| # @param node [Sass::Tree::Node] The root node of the tree to convert to CSS> |
| # @return [String] The CSS output. |
| def visit(node) |
| super |
| rescue Sass::SyntaxError => e |
| e.modify_backtrace(:filename => node.filename, :line => node.line) |
| raise e |
| end |
| |
| protected |
| |
| def with_tabs(tabs) |
| old_tabs, @tabs = @tabs, tabs |
| yield |
| ensure |
| @tabs = old_tabs |
| end |
| |
| # Associate all output produced in a block with a given node. Used for source |
| # mapping. |
| def for_node(node, attr_prefix = nil) |
| return yield unless @source_mapping |
| start_pos = Sass::Source::Position.new(@line, @offset) |
| yield |
| |
| range_attr = attr_prefix ? :"#{attr_prefix}_source_range" : :source_range |
| return if node.invisible? || !node.send(range_attr) |
| source_range = node.send(range_attr) |
| target_end_pos = Sass::Source::Position.new(@line, @offset) |
| target_range = Sass::Source::Range.new(start_pos, target_end_pos, nil) |
| @source_mapping.add(source_range, target_range) |
| end |
| |
| def trailing_semicolon? |
| @result.end_with?(";") && !@result.end_with?('\;') |
| end |
| |
| # Move the output cursor back `chars` characters. |
| def erase!(chars) |
| return if chars == 0 |
| str = @result.slice!(-chars..-1) |
| newlines = str.count("\n") |
| if newlines > 0 |
| @line -= newlines |
| @offset = @result[@result.rindex("\n") || 0..-1].size |
| else |
| @offset -= chars |
| end |
| end |
| |
| # Avoid allocating lots of new strings for `#output`. This is important |
| # because `#output` is called all the time. |
| NEWLINE = "\n" |
| |
| # Add `s` to the output string and update the line and offset information |
| # accordingly. |
| def output(s) |
| if @lstrip |
| s = s.gsub(/\A\s+/, "") |
| @lstrip = false |
| end |
| |
| newlines = s.count(NEWLINE) |
| if newlines > 0 |
| @line += newlines |
| @offset = s[s.rindex(NEWLINE)..-1].size |
| else |
| @offset += s.size |
| end |
| |
| @result << s |
| end |
| |
| # Strip all trailing whitespace from the output string. |
| def rstrip! |
| erase! @result.length - 1 - (@result.rindex(/[^\s]/) || -1) |
| end |
| |
| # lstrip the first output in the given block. |
| def lstrip |
| old_lstrip = @lstrip |
| @lstrip = true |
| yield |
| ensure |
| @lstrip &&= old_lstrip |
| end |
| |
| # Prepend `prefix` to the output string. |
| def prepend!(prefix) |
| @result.insert 0, prefix |
| return unless @source_mapping |
| |
| line_delta = prefix.count("\n") |
| offset_delta = prefix.gsub(/.*\n/, '').size |
| @source_mapping.shift_output_offsets(offset_delta) |
| @source_mapping.shift_output_lines(line_delta) |
| end |
| |
| def visit_root(node) |
| node.children.each do |child| |
| next if child.invisible? |
| visit(child) |
| next if node.style == :compressed |
| output "\n" |
| next unless child.is_a?(Sass::Tree::DirectiveNode) && child.has_children && !child.bubbles? |
| output "\n" |
| end |
| rstrip! |
| if node.style == :compressed && trailing_semicolon? |
| erase! 1 |
| end |
| return "" if @result.empty? |
| |
| output "\n" |
| |
| unless @result.ascii_only? |
| if node.style == :compressed |
| # A byte order mark is sufficient to tell browsers that this |
| # file is UTF-8 encoded, and will override any other detection |
| # methods as per http://encoding.spec.whatwg.org/#decode-and-encode. |
| prepend! "\uFEFF" |
| else |
| prepend! "@charset \"UTF-8\";\n" |
| end |
| end |
| |
| @result |
| rescue Sass::SyntaxError => e |
| e.sass_template ||= node.template |
| raise e |
| end |
| |
| def visit_charset(node) |
| for_node(node) {output("@charset \"#{node.name}\";")} |
| end |
| |
| def visit_comment(node) |
| return if node.invisible? |
| spaces = (' ' * [@tabs - node.resolved_value[/^ */].size, 0].max) |
| output(spaces) |
| |
| content = node.resolved_value.split("\n").join("\n" + spaces) |
| if node.type == :silent |
| content.gsub!(%r{^(\s*)//(.*)$}) {"#{$1}/*#{$2} */"} |
| end |
| if (node.style == :compact || node.style == :compressed) && node.type != :loud |
| content.gsub!(%r{\n +(\* *(?!/))?}, ' ') |
| end |
| for_node(node) {output(content)} |
| end |
| |
| # @comment |
| # rubocop:disable MethodLength |
| def visit_directive(node) |
| was_in_directive = @in_directive |
| tab_str = ' ' * @tabs |
| if !node.has_children || node.children.empty? |
| output(tab_str) |
| for_node(node) {output(node.resolved_value)} |
| if node.has_children |
| output("#{' ' unless node.style == :compressed}{}") |
| elsif node.children.empty? |
| output(";") |
| end |
| return |
| end |
| |
| @in_directive ||= !node.is_a?(Sass::Tree::MediaNode) |
| output(tab_str) if node.style != :compressed |
| for_node(node) {output(node.resolved_value)} |
| output(node.style == :compressed ? "{" : " {") |
| output(node.style == :compact ? ' ' : "\n") if node.style != :compressed |
| |
| had_children = true |
| first = true |
| node.children.each do |child| |
| next if child.invisible? |
| if node.style == :compact |
| if child.is_a?(Sass::Tree::PropNode) |
| with_tabs(first || !had_children ? 0 : @tabs + 1) do |
| visit(child) |
| output(' ') |
| end |
| else |
| unless had_children |
| erase! 1 |
| output "\n" |
| end |
| |
| if first |
| lstrip {with_tabs(@tabs + 1) {visit(child)}} |
| else |
| with_tabs(@tabs + 1) {visit(child)} |
| end |
| |
| rstrip! |
| output "\n" |
| end |
| had_children = child.has_children |
| first = false |
| elsif node.style == :compressed |
| unless had_children |
| output(";") unless trailing_semicolon? |
| end |
| with_tabs(0) {visit(child)} |
| had_children = child.has_children |
| else |
| with_tabs(@tabs + 1) {visit(child)} |
| output "\n" |
| end |
| end |
| rstrip! |
| if node.style == :compressed && trailing_semicolon? |
| erase! 1 |
| end |
| if node.style == :expanded |
| output("\n#{tab_str}") |
| elsif node.style != :compressed |
| output(" ") |
| end |
| output("}") |
| ensure |
| @in_directive = was_in_directive |
| end |
| # @comment |
| # rubocop:enable MethodLength |
| |
| def visit_media(node) |
| with_tabs(@tabs + node.tabs) {visit_directive(node)} |
| output("\n") if node.style != :compressed && node.group_end |
| end |
| |
| def visit_supports(node) |
| visit_media(node) |
| end |
| |
| def visit_cssimport(node) |
| visit_directive(node) |
| end |
| |
| def visit_prop(node) |
| return if node.resolved_value.empty? && !node.custom_property? |
| tab_str = ' ' * (@tabs + node.tabs) |
| output(tab_str) |
| for_node(node, :name) {output(node.resolved_name)} |
| output(":") |
| output(" ") unless node.style == :compressed || node.custom_property? |
| for_node(node, :value) do |
| output(if node.custom_property? |
| format_custom_property_value(node) |
| else |
| node.resolved_value |
| end) |
| end |
| output(";") unless node.style == :compressed |
| end |
| |
| # @comment |
| # rubocop:disable MethodLength |
| def visit_rule(node) |
| with_tabs(@tabs + node.tabs) do |
| rule_separator = node.style == :compressed ? ',' : ', ' |
| line_separator = |
| case node.style |
| when :nested, :expanded; "\n" |
| when :compressed; "" |
| else; " " |
| end |
| rule_indent = ' ' * @tabs |
| per_rule_indent, total_indent = if [:nested, :expanded].include?(node.style) |
| [rule_indent, ''] |
| else |
| ['', rule_indent] |
| end |
| |
| joined_rules = node.resolved_rules.members.map do |seq| |
| next if seq.invisible? |
| rule_part = seq.to_s(style: node.style, placeholder: false) |
| if node.style == :compressed |
| rule_part.gsub!(/([^,])\s*\n\s*/m, '\1 ') |
| rule_part.gsub!(/\s*([+>])\s*/m, '\1') |
| rule_part.gsub!(/nth([^( ]*)\(([^)]*)\)/m) do |match| |
| match.tr(" \t\n", "") |
| end |
| rule_part.strip! |
| end |
| rule_part |
| end.compact.join(rule_separator) |
| |
| joined_rules.lstrip! |
| joined_rules.gsub!(/\s*\n\s*/, "#{line_separator}#{per_rule_indent}") |
| |
| old_spaces = ' ' * @tabs |
| if node.style != :compressed |
| if node.options[:debug_info] && !@in_directive |
| visit(debug_info_rule(node.debug_info, node.options)) |
| output "\n" |
| elsif node.options[:trace_selectors] |
| output("#{old_spaces}/* ") |
| output(node.stack_trace.gsub("\n", "\n #{old_spaces}")) |
| output(" */\n") |
| elsif node.options[:line_comments] |
| output("#{old_spaces}/* line #{node.line}") |
| |
| if node.filename |
| relative_filename = |
| if node.options[:css_filename] |
| begin |
| Sass::Util.relative_path_from( |
| node.filename, File.dirname(node.options[:css_filename])).to_s |
| rescue ArgumentError |
| nil |
| end |
| end |
| relative_filename ||= node.filename |
| output(", #{relative_filename}") |
| end |
| |
| output(" */\n") |
| end |
| end |
| |
| end_props, trailer, tabs = '', '', 0 |
| if node.style == :compact |
| separator, end_props, bracket = ' ', ' ', ' { ' |
| trailer = "\n" if node.group_end |
| elsif node.style == :compressed |
| separator, bracket = ';', '{' |
| else |
| tabs = @tabs + 1 |
| separator, bracket = "\n", " {\n" |
| trailer = "\n" if node.group_end |
| end_props = (node.style == :expanded ? "\n" + old_spaces : ' ') |
| end |
| output(total_indent + per_rule_indent) |
| for_node(node, :selector) {output(joined_rules)} |
| output(bracket) |
| |
| with_tabs(tabs) do |
| node.children.each_with_index do |child, i| |
| if i > 0 |
| if separator.start_with?(";") && trailing_semicolon? |
| erase! 1 |
| end |
| output(separator) |
| end |
| visit(child) |
| end |
| end |
| if node.style == :compressed && trailing_semicolon? |
| erase! 1 |
| end |
| |
| output(end_props) |
| output("}" + trailer) |
| end |
| end |
| # @comment |
| # rubocop:enable MethodLength |
| |
| def visit_keyframerule(node) |
| visit_directive(node) |
| end |
| |
| private |
| |
| # Reformats the value of `node` so that it's nicely indented, preserving its |
| # existing relative indentation. |
| # |
| # @param node [Sass::Script::Tree::PropNode] A custom property node. |
| # @return [String] |
| def format_custom_property_value(node) |
| if node.style == :compact || node.style == :compressed || !node.resolved_value.include?("\n") |
| # Folding not involving newlines was done in the parser. We can safely |
| # fold newlines here because tokens like strings can't contain literal |
| # newlines, so we know any adjacent whitespace is tokenized as whitespace. |
| return node.resolved_value.gsub(/[ \t\r\f]*\n[ \t\r\f\n]*/, ' ') |
| end |
| |
| # Find the smallest amount of indentation in the custom property and use |
| # that as the base indentation level. |
| lines = node.resolved_value.split("\n") |
| indented_lines = lines[1..-1] |
| min_indentation = indented_lines. |
| map {|line| line[/^[ \t]*/]}. |
| reject {|line| line.empty?}. |
| min_by {|line| line.length} |
| |
| # Limit the base indentation to the same indentation level as the node name |
| # so that if *every* line is indented relative to the property name that's |
| # preserved. |
| if node.name_source_range |
| base_indentation = min_indentation[0...node.name_source_range.start_pos.offset - 1] |
| end |
| |
| lines.first + "\n" + indented_lines.join("\n").gsub(/^#{base_indentation}/, ' ' * @tabs) |
| end |
| |
| def debug_info_rule(debug_info, options) |
| node = Sass::Tree::DirectiveNode.resolved("@media -sass-debug-info") |
| debug_info.map {|k, v| [k.to_s, v.to_s]}.to_a.each do |k, v| |
| rule = Sass::Tree::RuleNode.new([""]) |
| rule.resolved_rules = Sass::Selector::CommaSequence.new( |
| [Sass::Selector::Sequence.new( |
| [Sass::Selector::SimpleSequence.new( |
| [Sass::Selector::Element.new(k.to_s.gsub(/[^\w-]/, "\\\\\\0"), nil)], |
| false) |
| ]) |
| ]) |
| prop = Sass::Tree::PropNode.new([""], [""], :new) |
| prop.resolved_name = "font-family" |
| prop.resolved_value = Sass::SCSS::RX.escape_ident(v.to_s) |
| rule << prop |
| node << rule |
| end |
| node.options = options.merge(:debug_info => false, |
| :line_comments => false, |
| :style => :compressed) |
| node |
| end |
| end |