require 'html_tokenizer' require 'action_view' class BetterHtml::BetterErb module RuntimeChecks def initialize(erb, config: BetterHtml.config, **options) @parser = HtmlTokenizer::Parser.new @config = config super(erb, **options) end def validate! check_parser_errors unless @parser.context == :none raise BetterHtml::HtmlError, 'Detected an open tag at the end of this document.' end end private def class_name "BetterHtml::BetterErb::ValidatedOutputBuffer" end def wrap_method "#{class_name}.wrap" end def add_expr_auto_escaped(src, code, auto_escape) flush_newline_if_pending(src) src << "#{wrap_method}(@output_buffer, (#{parser_context.inspect}), '#{escape_text(code)}'.freeze, #{auto_escape})" method_name = "safe_#{@parser.context}_append" if code =~ self.class::BLOCK_EXPR block_check(src, "<%=#{code}%>") src << ".#{method_name}= " << code else src << ".#{method_name}=(" << code << ");" end @parser.append_placeholder("<%=#{code}%>") end def parser_context if [:quoted_value, :unquoted_value, :space_after_attribute].include?(@parser.context) { tag_name: @parser.tag_name, attribute_name: @parser.attribute_name, attribute_value: @parser.attribute_value, attribute_quoted: @parser.attribute_quoted?, quote_character: @parser.quote_character, } elsif [:attribute_name, :after_attribute_name, :after_equal].include?(@parser.context) { tag_name: @parser.tag_name, attribute_name: @parser.attribute_name, } elsif [:tag, :tag_name, :tag_end].include?(@parser.context) { tag_name: @parser.tag_name, } elsif @parser.context == :rawtext { tag_name: @parser.tag_name, rawtext_text: @parser.rawtext_text, } elsif @parser.context == :comment { comment_text: @parser.comment_text, } elsif [:none, :solidus_or_tag_name].include?(@parser.context) {} else raise RuntimeError, "Tried to interpolate into unknown location #{@parser.context}." end end def block_check(src, code) unless @parser.context == :none || @parser.context == :rawtext s = "Ruby statement not allowed.\n" s << "In '#{@parser.context}' on line #{@parser.line_number} column #{@parser.column_number}:\n" prefix = extract_line(@parser.line_number) code = code.lines.first s << "#{prefix}#{code}\n" s << "#{' ' * prefix.size}#{'^' * code.size}" raise BetterHtml::DontInterpolateHere, s end end def check_parser_errors errors = @parser.errors return if errors.empty? s = "#{errors.size} error(s) found in HTML document.\n" errors.each do |error| s = "#{error.message}\n" s << "On line #{error.line} column #{error.column}:\n" line = extract_line(error.line) s << "#{line}\n" s << "#{' ' * (error.column)}#{'^' * (line.size - error.column)}" end raise BetterHtml::HtmlError, s end def check_token(type, *args) check_tag_name(type, *args) if type == :tag_name check_attribute_name(type, *args) if type == :attribute_name check_quoted_value(type, *args) if type == :attribute_quoted_value_start check_unquoted_value(type, *args) if type == :attribute_unquoted_value end def check_tag_name(type, start, stop, line, column) text = @parser.document[start...stop] return if text.upcase == "!DOCTYPE" return if @config.partial_tag_name_pattern === text s = "Invalid tag name #{text.inspect} does not match "\ "regular expression #{@config.partial_tag_name_pattern.inspect}\n" s << build_location(line, column, text.size) raise BetterHtml::HtmlError, s end def check_attribute_name(type, start, stop, line, column) text = @parser.document[start...stop] return if @config.partial_attribute_name_pattern === text s = "Invalid attribute name #{text.inspect} does not match "\ "regular expression #{@config.partial_attribute_name_pattern.inspect}\n" s << build_location(line, column, text.size) raise BetterHtml::HtmlError, s end def check_quoted_value(type, start, stop, line, column) return if @config.allow_single_quoted_attributes text = @parser.document[start...stop] return if text == '"' s = "Single-quoted attributes are not allowed\n" s << build_location(line, column, text.size) raise BetterHtml::HtmlError, s end def check_unquoted_value(type, start, stop, line, column) return if @config.allow_unquoted_attributes s = "Unquoted attribute values are not allowed\n" s << build_location(line, column, stop-start) raise BetterHtml::HtmlError, s end def build_location(line, column, length) s = "On line #{line} column #{column}:\n" s << "#{extract_line(line)}\n" s << "#{' ' * column}#{'^' * length}" end def extract_line(line) line = @parser.document.lines[line-1] line.nil? ? "" : line.gsub(/\n$/, '') end end end