blob: 3aca7a7e52788a12cf4195b1409d0d1519be74ce [file] [log] [blame]
# 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