From 713f033b565742418e0c32344eb186892c4e9f88 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Mon, 3 Aug 2026 19:04:51 +0200 Subject: [PATCH 1/4] Engine: Introduce `SlotVisitor` for slot markers --- lib/herb/engine.rb | 14 +- lib/herb/engine/slot_markers.rb | 28 ++ lib/herb/engine/slot_visitor.rb | 339 ++++++++++++++++++ sig/herb/engine.rbs | 2 + sig/herb/engine/slot_markers.rbs | 19 + sig/herb/engine/slot_visitor.rbs | 109 ++++++ test/engine/slot_markers_test.rb | 113 ++++++ test/engine/slot_visitor_test.rb | 111 ++++++ ...ments_31c47dbf5cdcbe2e4bd9b9120154db50.txt | 5 + ...slots_e81037f44a850f73c33bc94ac396e06f.txt | 5 + ...false_d7b463f38d4c40ba9375e8900e3442c9.txt | 5 + ..._slot_405c7a041d4c2c73cf05f509c1f181d0.txt | 5 + ...nders_89c13d94f8b0cfcfa203319d48f722f3.txt | 5 + ...e_tag_fd82c00de61d0053bbb2b96c2b23b75e.txt | 5 + ...ments_ef948d89bef9034a61b163fdd4b3cdef.txt | 5 + ...rsion_2ea3fa9c29ad2f5deca8b1ffeb690b47.txt | 5 + ...ource_b86bac2a5187c3f03d01d8b01068de02.txt | 6 + ...ments_3c46b72b77e401c56e545d7a52a99e21.txt | 5 + ...ments_54774804186eb053a7db7b52763b5e18.txt | 5 + ...ators_94e800d72c2784d9f34e395f51329a3a.txt | 5 + 20 files changed, 794 insertions(+), 2 deletions(-) create mode 100644 lib/herb/engine/slot_markers.rb create mode 100644 lib/herb/engine/slot_visitor.rb create mode 100644 sig/herb/engine/slot_markers.rbs create mode 100644 sig/herb/engine/slot_visitor.rbs create mode 100644 test/engine/slot_markers_test.rb create mode 100644 test/engine/slot_visitor_test.rb create mode 100644 test/snapshots/engine/slot_markers_test/test_0001_delimits_a_child_slot_with_paired_comments_31c47dbf5cdcbe2e4bd9b9120154db50.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0002_gives_sibling_interpolations_independent_slots_e81037f44a850f73c33bc94ac396e06f.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0003_leaves_an_empty_addressable_position_when_a_conditional_is_false_d7b463f38d4c40ba9375e8900e3442c9.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0004_delimits_slots_nested_inside_another_slot_405c7a041d4c2c73cf05f509c1f181d0.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0005_a_nested_slot_only_exists_once_its_parent_branch_renders_89c13d94f8b0cfcfa203319d48f722f3.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0006_anchors_attribute_slots_on_the_element_instead_of_inside_the_tag_fd82c00de61d0053bbb2b96c2b23b75e.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0007_does_not_inject_markers_into_raw_text_elements_ef948d89bef9034a61b163fdd4b3cdef.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0008_wraps_the_document_in_a_region_marker_carrying_file_and_version_2ea3fa9c29ad2f5deca8b1ffeb690b47.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0009_compiles_slot_markers_into_the_generated_source_b86bac2a5187c3f03d01d8b01068de02.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0010_keeps_head_content_free_of_injected_elements_3c46b72b77e401c56e545d7a52a99e21.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0011_keeps_SVG_subtrees_free_of_injected_elements_54774804186eb053a7db7b52763b5e18.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0012_keeps_adjacent_siblings_adjacent_for_CSS_combinators_94e800d72c2784d9f34e395f51329a3a.txt diff --git a/lib/herb/engine.rb b/lib/herb/engine.rb index db193a916..123327bd3 100644 --- a/lib/herb/engine.rb +++ b/lib/herb/engine.rb @@ -6,6 +6,7 @@ require "pathname" require_relative "engine/debug_visitor" +require_relative "engine/slot_visitor" require_relative "engine/compiler" require_relative "engine/error_formatter" require_relative "engine/validation_errors" @@ -18,8 +19,7 @@ module Herb class Engine - attr_reader :src, :filename, :project_path, :relative_file_path, :bufvar, :debug, :content_for_head, - :validation_error_template, :visitors, :enabled_validators + attr_reader :src, :filename, :project_path, :relative_file_path, :bufvar, :debug, :content_for_head, :validation_error_template, :visitors, :enabled_validators, :slot_visitor # @rbs! # def self.optimize_warning_issued: () -> bool @@ -76,6 +76,7 @@ def initialize(input, properties = {}) @chain_appends = properties[:chain_appends] @buffer_on_stack = false @debug = properties.fetch(:debug, Herb.configuration.engine_option("debug", false)) + @slots = properties.fetch(:slots, Herb.configuration.engine_option("slots", false)) @content_for_head = properties[:content_for_head] @validation_error_template = nil @validation_mode = properties.fetch(:validation_mode, :raise) @@ -91,6 +92,15 @@ def initialize(input, properties = {}) @visitors = properties.fetch(:visitors, default_visitors) + if @slots + @slot_visitor = SlotVisitor.new( + file_path: @filename, + project_path: @project_path + ) + + @visitors.unshift(@slot_visitor) + end + if @debug && @visitors.empty? debug_visitor = DebugVisitor.new( file_path: @filename, diff --git a/lib/herb/engine/slot_markers.rb b/lib/herb/engine/slot_markers.rb new file mode 100644 index 000000000..03ca12da9 --- /dev/null +++ b/lib/herb/engine/slot_markers.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# typed: true + +module Herb + class Engine + class SlotMarkers + #: (Integer, Symbol) -> String + def slot_open(index, _type) + "" + end + + #: (Integer) -> String + def slot_close(index) + "" + end + + #: (String, String) -> String + def region_open(file, version) + "" + end + + #: (String) -> String + def region_close(file) + "" + end + end + end +end diff --git a/lib/herb/engine/slot_visitor.rb b/lib/herb/engine/slot_visitor.rb new file mode 100644 index 000000000..01530df35 --- /dev/null +++ b/lib/herb/engine/slot_visitor.rb @@ -0,0 +1,339 @@ +# frozen_string_literal: true +# typed: false + +require "digest" + +require_relative "slot_markers" + +module Herb + class Engine + class SlotVisitor < Herb::Visitor + ELEMENT_ANCHORED_TYPES = [:attribute, :attribute_interpolation, :boolean_attribute, :element].freeze #: Array[Symbol] + + attr_reader :slots #: Array[Slot] + + Slot = Data.define( + :index, #: Integer + :type, #: Symbol + :node_path, #: Array[Integer] + :expression, #: String? + :location #: String? + ) + + #: (?file_path: untyped, ?project_path: untyped, ?markers: SlotMarkers) -> void + def initialize(file_path: nil, project_path: nil, markers: SlotMarkers.new) + super() + + @markers = markers + @relative_file_path = relative_path_for(file_path, project_path) + + @slots = [] #: Array[Slot] + @path = [] #: Array[Integer] + + pending = {} #: Hash[untyped, Integer] + element_anchored = {} #: Hash[untyped, Array[Integer]] + + @pending = pending.compare_by_identity + @element_anchored = element_anchored.compare_by_identity + + @in_attribute = false + @in_html_comment = false + @in_html_doctype = false + @raw_text_depth = 0 + @current_open_tag = nil + end + + #: () -> String + def version + @version ||= Digest::SHA256.hexdigest( + @slots.map { |slot| "#{slot.index}:#{slot.type}" }.join(",") + ).slice(0, 8).to_s + end + + #: () -> Hash[Symbol, untyped] + def schema + { + file: @relative_file_path, + version: version, + slots: @slots.map { |slot| { index: slot.index, type: slot.type, node_path: slot.node_path } }, + } + end + + def visit_document_node(node) + visit_children_with_paths(node.children) + + insert_markers(node) + wrap_region(node) + end + + def visit_html_element_node(node) + raw_text = raw_text_element?(node) + @raw_text_depth += 1 if raw_text + + previous_open_tag = @current_open_tag + @current_open_tag = node.open_tag + + visit(node.open_tag) if node.open_tag + visit_children_with_paths(node.body) + visit(node.close_tag) if node.close_tag + + @current_open_tag = previous_open_tag + @raw_text_depth -= 1 if raw_text + end + + def visit_html_attribute_node(node) + @in_attribute = true + super + @in_attribute = false + end + + def visit_html_comment_node(node) + @in_html_comment = true + super + @in_html_comment = false + end + + def visit_html_doctype_node(node) + @in_html_doctype = true + super + @in_html_doctype = false + end + + def visit_erb_content_node(node) + record_slot(node, erb_output?(node.tag_opening&.value.to_s) ? :child : nil) + + super + end + + def visit_erb_yield_node(node) + record_slot(node, :child) + + super + end + + def visit_erb_if_node(node) + record_slot(node, :conditional) + visit_branching_node(node) + end + + def visit_erb_unless_node(node) + record_slot(node, :conditional) + visit_branching_node(node) + end + + def visit_erb_case_node(node) + record_slot(node, :conditional) + visit_branching_node(node) + end + + # A plain block is not necessarily a repeating region: `form_with do |f|` + # and `@user.tap do |u|` wrap their body once, while `@users.each do |u|` + # repeats it. Distinguishing them requires the receiver and message, which + # only `ERBIterationBlockNode` carries (see marcoroth/herb#1912), so until + # that lands every block is typed `:block` rather than over-claiming + # `:collection`. Keyed reconciliation needs the stronger fact. + def visit_erb_block_node(node) + record_slot(node, :block) + + visit_branching_node(node) + end + + def visit_erb_iteration_block_node(node) + record_slot(node, :collection) + + visit_branching_node(node) + end + + private + + #: (untyped, untyped) -> String + def relative_path_for(file_path, project_path) + return "unknown" unless file_path + + filename = file_path.is_a?(::Pathname) ? file_path : ::Pathname.new(file_path.to_s) + return filename.to_s unless filename.absolute? + + root = project_path ? ::Pathname.new(project_path.to_s) : ::Pathname.new(Dir.pwd) + filename.relative_path_from(root).to_s + rescue ArgumentError + file_path.to_s + end + + def visit_children_with_paths(children) + return unless children.is_a?(Array) + + children.each_with_index do |child, index| + @path.push(index) + visit(child) + @path.pop + end + end + + def visit_branching_node(node) + [:statements, :body, :children, :conditions].each do |property| + next unless node.respond_to?(property) + + visit_children_with_paths(node.send(property)) + end + + [:subsequent, :else_clause, :rescue_clause, :ensure_clause].each do |property| + next unless node.respond_to?(property) + + child = node.send(property) + visit(child) if child + end + end + + #: (untyped, Symbol?) -> void + def record_slot(node, type) + return unless type + + type = attribute_slot_type(type) if @in_attribute + + return unless type + return if @in_html_comment || @in_html_doctype + return if @raw_text_depth.positive? && !@in_attribute + + slot = Slot.new( + index: @slots.size, + type: type, + node_path: @path.dup, + expression: expression_for(node), + location: location_for(node) + ) + + @slots << slot + + if !ELEMENT_ANCHORED_TYPES.include?(type) + @pending[node] = slot.index + elsif @current_open_tag + anchored = @element_anchored[@current_open_tag] || [] #: Array[Integer] + anchored << slot.index + + @element_anchored[@current_open_tag] = anchored + end + end + + #: (Symbol) -> Symbol? + def attribute_slot_type(_type) + :attribute + end + + def expression_for(node) + return nil unless node.respond_to?(:content) + + content = node.content + value = content.respond_to?(:value) ? content.value : content + + value&.to_s&.strip + end + + def location_for(node) + location = node.location + return nil unless location + + "#{location.start.line}:#{location.start.column}" + end + + def raw_text_element?(node) + ["script", "style"].include?(node.tag_name&.value&.downcase.to_s) + end + + def erb_output?(opening) + opening.include?("=") + end + + def insert_markers(node) + each_child_array(node) do |array| + index = 0 + + while index < array.size + child = array[index] + slot_index = @pending[child] + + insert_markers(child) + + if slot_index + array.insert(index, comment_node(@markers.slot_open(slot_index, @slots[slot_index].type))) + array.insert(index + 2, comment_node(@markers.slot_close(slot_index))) + + index += 3 + else + index += 1 + end + end + end + + anchor_attributes(node) + end + + def anchor_attributes(node) + return unless node.is_a?(Herb::AST::HTMLElementNode) + + open_tag = node.open_tag + return unless open_tag.is_a?(Herb::AST::HTMLOpenTagNode) + + indices = @element_anchored[open_tag] + return if indices.nil? || indices.empty? + + open_tag.children << attribute_node("data-herb-slot", indices.join(",")) + end + + #: (untyped) { (Array[untyped]) -> void } -> void + def each_child_array(node) + [:children, :body, :statements].each do |property| + next unless node.respond_to?(property) + + array = node.send(property) + yield array if array.is_a?(Array) + end + + [:subsequent, :else_clause, :end_node, :rescue_clause, :ensure_clause, :open_tag].each do |property| + next unless node.respond_to?(property) + + child = node.send(property) + insert_markers(child) if child + end + end + + def wrap_region(document_node) + document_node.children.unshift(comment_node(@markers.region_open(@relative_file_path, version))) + + document_node.children.push(comment_node(@markers.region_close(@relative_file_path))) + end + + def comment_node(text) + Herb::AST::HTMLCommentNode.new( + "HTMLCommentNode", + Herb::Location.zero, + [], + token(:html_comment_start, text), + [], + token(:html_comment_end, "") + ) + end + + def attribute_node(name, value) + name_node = Herb::AST::HTMLAttributeNameNode.new( + "HTMLAttributeNameNode", Herb::Location.zero, [], [literal(name)] + ) + + value_node = Herb::AST::HTMLAttributeValueNode.new( + "HTMLAttributeValueNode", Herb::Location.zero, [], token(:quote, '"'), [literal(value)], token(:quote, '"'), true + ) + + Herb::AST::HTMLAttributeNode.new( + "HTMLAttributeNode", Herb::Location.zero, [], name_node, token(:equals, "="), value_node + ) + end + + def literal(content) + Herb::AST::LiteralNode.new("LiteralNode", Herb::Location.zero, [], content.dup) + end + + def token(type, value) + Herb::Token.new(value.dup, Herb::Range.zero, Herb::Location.zero, type.to_s) + end + end + end +end diff --git a/sig/herb/engine.rbs b/sig/herb/engine.rbs index ca83aaa89..c7e2b5f90 100644 --- a/sig/herb/engine.rbs +++ b/sig/herb/engine.rbs @@ -22,6 +22,8 @@ module Herb attr_reader enabled_validators: untyped + attr_reader slot_visitor: untyped + def self.optimize_warning_issued: () -> bool def self.optimize_warning_issued=: (bool) -> bool diff --git a/sig/herb/engine/slot_markers.rbs b/sig/herb/engine/slot_markers.rbs new file mode 100644 index 000000000..cb922834a --- /dev/null +++ b/sig/herb/engine/slot_markers.rbs @@ -0,0 +1,19 @@ +# Generated from lib/herb/engine/slot_markers.rb with RBS::Inline + +module Herb + class Engine + class SlotMarkers + # : (Integer, Symbol) -> String + def slot_open: (Integer, Symbol) -> String + + # : (Integer) -> String + def slot_close: (Integer) -> String + + # : (String, String) -> String + def region_open: (String, String) -> String + + # : (String) -> String + def region_close: (String) -> String + end + end +end diff --git a/sig/herb/engine/slot_visitor.rbs b/sig/herb/engine/slot_visitor.rbs new file mode 100644 index 000000000..5a0fdbef5 --- /dev/null +++ b/sig/herb/engine/slot_visitor.rbs @@ -0,0 +1,109 @@ +# Generated from lib/herb/engine/slot_visitor.rb with RBS::Inline + +module Herb + class Engine + class SlotVisitor < Herb::Visitor + ELEMENT_ANCHORED_TYPES: Array[Symbol] + + attr_reader slots: Array[Slot] + + class Slot < Data + attr_reader index(): Integer + + attr_reader type(): Symbol + + attr_reader node_path(): Array[Integer] + + attr_reader expression(): String? + + attr_reader location(): String? + + def self.new: (Integer index, Symbol type, Array[Integer] node_path, String? expression, String? location) -> instance + | (index: Integer, type: Symbol, node_path: Array[Integer], expression: String?, location: String?) -> instance + + def self.members: () -> [ :index, :type, :node_path, :expression, :location ] + + def members: () -> [ :index, :type, :node_path, :expression, :location ] + end + + # : (?file_path: untyped, ?project_path: untyped, ?markers: SlotMarkers) -> void + def initialize: (?file_path: untyped, ?project_path: untyped, ?markers: SlotMarkers) -> void + + # : () -> String + def version: () -> String + + # : () -> Hash[Symbol, untyped] + def schema: () -> Hash[Symbol, untyped] + + def visit_document_node: (untyped node) -> untyped + + def visit_html_element_node: (untyped node) -> untyped + + def visit_html_attribute_node: (untyped node) -> untyped + + def visit_html_comment_node: (untyped node) -> untyped + + def visit_html_doctype_node: (untyped node) -> untyped + + def visit_erb_content_node: (untyped node) -> untyped + + def visit_erb_yield_node: (untyped node) -> untyped + + def visit_erb_if_node: (untyped node) -> untyped + + def visit_erb_unless_node: (untyped node) -> untyped + + def visit_erb_case_node: (untyped node) -> untyped + + # A plain block is not necessarily a repeating region: `form_with do |f|` + # and `@user.tap do |u|` wrap their body once, while `@users.each do |u|` + # repeats it. Distinguishing them requires the receiver and message, which + # only `ERBIterationBlockNode` carries (see marcoroth/herb#1912), so until + # that lands every block is typed `:block` rather than over-claiming + # `:collection`. Keyed reconciliation needs the stronger fact. + def visit_erb_block_node: (untyped node) -> untyped + + def visit_erb_iteration_block_node: (untyped node) -> untyped + + private + + # : (untyped, untyped) -> String + def relative_path_for: (untyped, untyped) -> String + + def visit_children_with_paths: (untyped children) -> untyped + + def visit_branching_node: (untyped node) -> untyped + + # : (untyped, Symbol?) -> void + def record_slot: (untyped, Symbol?) -> void + + # : (Symbol) -> Symbol? + def attribute_slot_type: (Symbol) -> Symbol? + + def expression_for: (untyped node) -> untyped + + def location_for: (untyped node) -> untyped + + def raw_text_element?: (untyped node) -> untyped + + def erb_output?: (untyped opening) -> untyped + + def insert_markers: (untyped node) -> untyped + + def anchor_attributes: (untyped node) -> untyped + + # : (untyped) { (Array[untyped]) -> void } -> void + def each_child_array: (untyped) { (Array[untyped]) -> void } -> void + + def wrap_region: (untyped document_node) -> untyped + + def comment_node: (untyped text) -> untyped + + def attribute_node: (untyped name, untyped value) -> untyped + + def literal: (untyped content) -> untyped + + def token: (untyped type, untyped value) -> untyped + end + end +end diff --git a/test/engine/slot_markers_test.rb b/test/engine/slot_markers_test.rb new file mode 100644 index 000000000..b48bd3907 --- /dev/null +++ b/test/engine/slot_markers_test.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require_relative "../test_helper" +require_relative "../snapshot_utils" +require_relative "../../lib/herb/engine" + +module Engine + class SlotMarkersTest < Minitest::Spec + include SnapshotUtils + + OPTIONS = { slots: true, filename: "app/views/test.html.erb", validation_mode: :none }.freeze + + test "delimits a child slot with paired comments" do + assert_evaluated_snapshot("

<%= @name %>

", { "@name" => "Marco" }, OPTIONS) + end + + test "gives sibling interpolations independent slots" do + assert_evaluated_snapshot( + "

Hi <%= @name %>, you have <%= @count %>

", + { "@name" => "Marco", "@count" => 3 }, + OPTIONS + ) + end + + test "leaves an empty addressable position when a conditional is false" do + assert_evaluated_snapshot( + "
<% if @admin %>secret<% end %>
", + { "@admin" => false }, + OPTIONS + ) + end + + test "delimits slots nested inside another slot" do + assert_evaluated_snapshot( + "
<% if @admin %><%= @secret %><% end %>
", + { "@admin" => true, "@secret" => "s" }, + OPTIONS + ) + end + + test "a nested slot only exists once its parent branch renders" do + assert_evaluated_snapshot( + "
<% if @admin %><%= @secret %><% end %>
", + { "@admin" => false, "@secret" => "s" }, + OPTIONS + ) + end + + test "anchors attribute slots on the element instead of inside the tag" do + assert_evaluated_snapshot(%(
), { "@klass" => "card" }, OPTIONS) + end + + test "does not inject markers into raw text elements" do + assert_evaluated_snapshot(%(), { "@a" => "1" }, OPTIONS) + end + + test "wraps the document in a region marker carrying file and version" do + assert_evaluated_snapshot("

<%= @name %>

", { "@name" => "x" }, OPTIONS) + end + + test "compiles slot markers into the generated source" do + assert_compiled_snapshot("

<%= @name %>

", OPTIONS) + end + + test "keeps head content free of injected elements" do + assert_evaluated_snapshot( + %(<%= @t %>), + { "@t" => "T" }, + OPTIONS + ) + end + + test "keeps SVG subtrees free of injected elements" do + assert_evaluated_snapshot( + %(), + { "@r" => "4" }, + OPTIONS + ) + end + + test "keeps adjacent siblings adjacent for CSS combinators" do + assert_evaluated_snapshot( + %(
<%= @x %>

y

), + { "@x" => "" }, + OPTIONS + ) + end + + test "never emits a wrapper element" do + [ + ["

<%= @x %>

", { "@x" => "a" }], + ["
<% if @x %>y<% end %>
", { "@x" => true }], + ["", { "@x" => ["a"] }] + ].each do |template, locals| + engine = Herb::Engine.new(template, **OPTIONS) + output = evaluate_herb_source(engine.src, locals) + + refute_includes output, "

<%= @title %>

<% if @admin %>x<% end %>) + locals = { "@title" => "T", "@admin" => true } + + slotted = evaluate_herb_source(Herb::Engine.new(template, **OPTIONS).src, locals) + plain = evaluate_herb_source(Herb::Engine.new(template, validation_mode: :none).src, locals) + + assert_equal plain, slotted.gsub(%r{}, "") + end + end +end diff --git a/test/engine/slot_visitor_test.rb b/test/engine/slot_visitor_test.rb new file mode 100644 index 000000000..72299358e --- /dev/null +++ b/test/engine/slot_visitor_test.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require_relative "../test_helper" +require_relative "../../lib/herb/engine" + +module Engine + class SlotVisitorTest < Minitest::Spec + def slots_for(template, file_path: "app/views/test.html.erb") + Herb::Engine.new(template, slots: true, filename: file_path, validation_mode: :none).slot_visitor + end + + test "assigns an index to ERB output in child position" do + visitor = slots_for("

<%= @name %>

") + + assert_equal 1, visitor.slots.size + assert_equal 0, visitor.slots[0].index + assert_equal :child, visitor.slots[0].type + assert_equal "@name", visitor.slots[0].expression + end + + test "assigns indices in document order" do + visitor = slots_for("

<%= @a %>

<%= @b %>

<%= @c %>

") + + assert_equal [0, 1, 2], visitor.slots.map(&:index) + assert_equal ["@a", "@b", "@c"], visitor.slots.map(&:expression) + end + + test "records the node_path that TemplateDependencies reports" do + visitor = slots_for("

<%= @title %>

") + + assert_equal [0, 0, 0], visitor.slots[0].node_path + end + + test "records source location" do + visitor = slots_for("

\n <%= @name %>\n

") + + assert_equal "2:2", visitor.slots[0].location + end + + test "classifies conditionals" do + visitor = slots_for("
<% if @admin %>x<% end %>
") + + assert_equal :conditional, visitor.slots[0].type + end + + test "classifies ERB inside an attribute value" do + visitor = slots_for(%(
)) + + assert_equal :attribute, visitor.slots[0].type + end + + test "classifies an each block without claiming it repeats" do + visitor = slots_for("") + + assert_equal :block, visitor.slots[0].type + end + + test "classifies a builder block as a plain block" do + visitor = slots_for("
<%= form_with model: @user do |f| %><% end %>
") + + assert_equal :block, visitor.slots[0].type + end + + test "classifies a non-iteration method block as a plain block" do + visitor = slots_for("
<% @user.tap do |u| %>x<% end %>
") + + assert_equal :block, visitor.slots[0].type + end + + test "does not assign a slot to non-output ERB" do + visitor = slots_for("<% x = 1 %>

static

") + + assert_empty visitor.slots + end + + test "assigns nested slots inside a conditional" do + visitor = slots_for("
<% if @admin %><%= @secret %><% end %>
") + + assert_equal [:conditional, :child], visitor.slots.map(&:type) + assert_equal [0, 0], visitor.slots[0].node_path + assert_equal [0, 0, 0, 0], visitor.slots[1].node_path + end + + test "schema exposes slot count, types and paths" do + visitor = slots_for("

<%= @a %>

<% if @b %>x<% end %>
") + schema = visitor.schema + + assert_equal "app/views/test.html.erb", schema[:file] + assert_equal 2, schema[:slots].size + assert_equal([:child, :conditional], schema[:slots].map { |slot| slot[:type] }) + end + + test "schema version is stable for the same slot layout" do + assert_equal slots_for("

<%= @a %>

").version, slots_for("

<%= @b %>

").version + end + + test "schema version changes when a slot is added" do + refute_equal slots_for("

<%= @a %>

").version, + slots_for("

<%= @a %>

<%= @b %>

").version + end + + test "schema version changes when a slot type changes" do + refute_equal slots_for("
<%= @a %>
").version, + slots_for("
<% if @a %>x<% end %>
").version + end + + test "engine exposes no slot visitor unless slots are enabled" do + assert_nil Herb::Engine.new("

<%= @a %>

", validation_mode: :none).slot_visitor + end + end +end diff --git a/test/snapshots/engine/slot_markers_test/test_0001_delimits_a_child_slot_with_paired_comments_31c47dbf5cdcbe2e4bd9b9120154db50.txt b/test/snapshots/engine/slot_markers_test/test_0001_delimits_a_child_slot_with_paired_comments_31c47dbf5cdcbe2e4bd9b9120154db50.txt new file mode 100644 index 000000000..ce33b8f10 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0001_delimits_a_child_slot_with_paired_comments_31c47dbf5cdcbe2e4bd9b9120154db50.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0001_delimits a child slot with paired comments" +input: "{source: \"

<%= @name %>

\", locals: {\"@name\" => \"Marco\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +

Marco

\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0002_gives_sibling_interpolations_independent_slots_e81037f44a850f73c33bc94ac396e06f.txt b/test/snapshots/engine/slot_markers_test/test_0002_gives_sibling_interpolations_independent_slots_e81037f44a850f73c33bc94ac396e06f.txt new file mode 100644 index 000000000..af7854994 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0002_gives_sibling_interpolations_independent_slots_e81037f44a850f73c33bc94ac396e06f.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0002_gives sibling interpolations independent slots" +input: "{source: \"

Hi <%= @name %>, you have <%= @count %>

\", locals: {\"@name\" => \"Marco\", \"@count\" => 3}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +

Hi Marco, you have 3

\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0003_leaves_an_empty_addressable_position_when_a_conditional_is_false_d7b463f38d4c40ba9375e8900e3442c9.txt b/test/snapshots/engine/slot_markers_test/test_0003_leaves_an_empty_addressable_position_when_a_conditional_is_false_d7b463f38d4c40ba9375e8900e3442c9.txt new file mode 100644 index 000000000..de9642ba6 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0003_leaves_an_empty_addressable_position_when_a_conditional_is_false_d7b463f38d4c40ba9375e8900e3442c9.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0003_leaves an empty addressable position when a conditional is false" +input: "{source: \"
<% if @admin %>secret<% end %>
\", locals: {\"@admin\" => false}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0004_delimits_slots_nested_inside_another_slot_405c7a041d4c2c73cf05f509c1f181d0.txt b/test/snapshots/engine/slot_markers_test/test_0004_delimits_slots_nested_inside_another_slot_405c7a041d4c2c73cf05f509c1f181d0.txt new file mode 100644 index 000000000..3b631e63e --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0004_delimits_slots_nested_inside_another_slot_405c7a041d4c2c73cf05f509c1f181d0.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0004_delimits slots nested inside another slot" +input: "{source: \"
<% if @admin %><%= @secret %><% end %>
\", locals: {\"@admin\" => true, \"@secret\" => \"s\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
s
\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0005_a_nested_slot_only_exists_once_its_parent_branch_renders_89c13d94f8b0cfcfa203319d48f722f3.txt b/test/snapshots/engine/slot_markers_test/test_0005_a_nested_slot_only_exists_once_its_parent_branch_renders_89c13d94f8b0cfcfa203319d48f722f3.txt new file mode 100644 index 000000000..06eaa50ca --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0005_a_nested_slot_only_exists_once_its_parent_branch_renders_89c13d94f8b0cfcfa203319d48f722f3.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0005_a nested slot only exists once its parent branch renders" +input: "{source: \"
<% if @admin %><%= @secret %><% end %>
\", locals: {\"@admin\" => false, \"@secret\" => \"s\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0006_anchors_attribute_slots_on_the_element_instead_of_inside_the_tag_fd82c00de61d0053bbb2b96c2b23b75e.txt b/test/snapshots/engine/slot_markers_test/test_0006_anchors_attribute_slots_on_the_element_instead_of_inside_the_tag_fd82c00de61d0053bbb2b96c2b23b75e.txt new file mode 100644 index 000000000..620861c07 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0006_anchors_attribute_slots_on_the_element_instead_of_inside_the_tag_fd82c00de61d0053bbb2b96c2b23b75e.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0006_anchors attribute slots on the element instead of inside the tag" +input: "{source: \"
\\\">
\", locals: {\"@klass\" => \"card\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0007_does_not_inject_markers_into_raw_text_elements_ef948d89bef9034a61b163fdd4b3cdef.txt b/test/snapshots/engine/slot_markers_test/test_0007_does_not_inject_markers_into_raw_text_elements_ef948d89bef9034a61b163fdd4b3cdef.txt new file mode 100644 index 000000000..07680e440 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0007_does_not_inject_markers_into_raw_text_elements_ef948d89bef9034a61b163fdd4b3cdef.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0007_does not inject markers into raw text elements" +input: "{source: \"\", locals: {\"@a\" => \"1\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- + \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0008_wraps_the_document_in_a_region_marker_carrying_file_and_version_2ea3fa9c29ad2f5deca8b1ffeb690b47.txt b/test/snapshots/engine/slot_markers_test/test_0008_wraps_the_document_in_a_region_marker_carrying_file_and_version_2ea3fa9c29ad2f5deca8b1ffeb690b47.txt new file mode 100644 index 000000000..42390c63e --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0008_wraps_the_document_in_a_region_marker_carrying_file_and_version_2ea3fa9c29ad2f5deca8b1ffeb690b47.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0008_wraps the document in a region marker carrying file and version" +input: "{source: \"

<%= @name %>

\", locals: {\"@name\" => \"x\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +

x

\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0009_compiles_slot_markers_into_the_generated_source_b86bac2a5187c3f03d01d8b01068de02.txt b/test/snapshots/engine/slot_markers_test/test_0009_compiles_slot_markers_into_the_generated_source_b86bac2a5187c3f03d01d8b01068de02.txt new file mode 100644 index 000000000..ec4b9fa87 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0009_compiles_slot_markers_into_the_generated_source_b86bac2a5187c3f03d01d8b01068de02.txt @@ -0,0 +1,6 @@ +--- +source: "Engine::SlotMarkersTest#test_0009_compiles slot markers into the generated source" +input: "{source: \"

<%= @name %>

\", options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +_buf = ::String.new; _buf << '

'.freeze; _buf << (@name).to_s; _buf << '

'.freeze; +_buf.to_s diff --git a/test/snapshots/engine/slot_markers_test/test_0010_keeps_head_content_free_of_injected_elements_3c46b72b77e401c56e545d7a52a99e21.txt b/test/snapshots/engine/slot_markers_test/test_0010_keeps_head_content_free_of_injected_elements_3c46b72b77e401c56e545d7a52a99e21.txt new file mode 100644 index 000000000..841f084fd --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0010_keeps_head_content_free_of_injected_elements_3c46b72b77e401c56e545d7a52a99e21.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0010_keeps head content free of injected elements" +input: "{source: \"<%= @t %>\", locals: {\"@t\" => \"T\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +<!--herb-slot:0-->T<!--/herb-slot:0--> \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0011_keeps_SVG_subtrees_free_of_injected_elements_54774804186eb053a7db7b52763b5e18.txt b/test/snapshots/engine/slot_markers_test/test_0011_keeps_SVG_subtrees_free_of_injected_elements_54774804186eb053a7db7b52763b5e18.txt new file mode 100644 index 000000000..f947f80b3 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0011_keeps_SVG_subtrees_free_of_injected_elements_54774804186eb053a7db7b52763b5e18.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0011_keeps SVG subtrees free of injected elements" +input: "{source: \"\\\">\", locals: {\"@r\" => \"4\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- + \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0012_keeps_adjacent_siblings_adjacent_for_CSS_combinators_94e800d72c2784d9f34e395f51329a3a.txt b/test/snapshots/engine/slot_markers_test/test_0012_keeps_adjacent_siblings_adjacent_for_CSS_combinators_94e800d72c2784d9f34e395f51329a3a.txt new file mode 100644 index 000000000..4c7e938a1 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0012_keeps_adjacent_siblings_adjacent_for_CSS_combinators_94e800d72c2784d9f34e395f51329a3a.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0012_keeps adjacent siblings adjacent for CSS combinators" +input: "{source: \"
<%= @x %>

y

\", locals: {\"@x\" => \"\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +

y

\ No newline at end of file From 771cd92d781166d0456cb2356e97a516139d6214 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Mon, 3 Aug 2026 22:35:28 +0200 Subject: [PATCH 2/4] Update --- lib/herb/engine.rb | 2 + lib/herb/engine/slot_visitor.rb | 39 +++--- sig/herb/engine/slot_visitor.rbs | 13 +- test/engine/slot_markers_test.rb | 120 ++++++++++++++++++ test/engine/slot_visitor_test.rb | 31 ++++- ...ource_2912a83cbf6d3340c6c202391c0ec23d.txt | 6 + ...ource_c132ec0b62116a7ec0488232b6560561.txt | 6 + ...ource_57adf1ee1da7d3c231aaeb064b37f72d.txt | 6 + ..._slot_08d2f5a74b6e91516a355c5f6d9bf30c.txt | 5 + ..._else_ca496223807300bdbe0f64e4db8ba219.txt | 5 + ...ranch_4e6668673cdf9f9cbb8afe5ce1fa4612.txt | 5 + ...ranch_e835d1221d16a00a50c5951cc31f07bb.txt | 5 + ..._slot_0ec982ce34ce84e243e9e1b150d20a6a.txt | 5 + ..._slot_b63463f8f06e79ddef3e9a75eaab30c7.txt | 5 + ..._else_fabeff4879dfa370e1afe79d37ed9db3.txt | 5 + ...tions_2876bfbfb23ec2b36577e62369a18ebc.txt | 5 + ...slots_0a3b40d74e00a12ff8eab07036a815d6.txt | 5 + ...ement_246544cf2118f94c24e70453ba1bb30c.txt | 5 + ...value_4d09422b310d70d5601aa0de953b7988.txt | 5 + ...utput_eb64be26a50dc767fa7b04015ca90d5a.txt | 5 + ...mment_aafdd42677df13171b042c73442839e1.txt | 5 + ...space_19e8de61834a9d30e35931130497b852.txt | 7 + ...ement_dd152c19d679db0061449d461a443838.txt | 5 + ...slots_aabb292584ebec4d14d69193f52e95a1.txt | 5 + ...yield_3427374d2e110b865e54d3d937742891.txt | 6 + ...ents_3c46b72b77e401c56e545d7a52a99e21.txt} | 2 +- ...ents_54774804186eb053a7db7b52763b5e18.txt} | 2 +- ...tors_94e800d72c2784d9f34e395f51329a3a.txt} | 2 +- 28 files changed, 291 insertions(+), 26 deletions(-) create mode 100644 test/snapshots/engine/slot_markers_test/test_0010_compiles_a_conditional_into_the_generated_source_2912a83cbf6d3340c6c202391c0ec23d.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0011_compiles_a_collection_into_the_generated_source_c132ec0b62116a7ec0488232b6560561.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0012_compiles_attribute_slots_into_the_generated_source_57adf1ee1da7d3c231aaeb064b37f72d.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0013_treats_an_if_elsif_else_chain_as_one_conditional_slot_08d2f5a74b6e91516a355c5f6d9bf30c.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0014_delimits_a_slot_inside_an_unless_else_ca496223807300bdbe0f64e4db8ba219.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0015_delimits_a_slot_inside_a_when_branch_4e6668673cdf9f9cbb8afe5ce1fa4612.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0016_delimits_a_slot_inside_a_case_else_branch_e835d1221d16a00a50c5951cc31f07bb.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0017_gives_a_conditional_nested_inside_an_else_its_own_slot_0ec982ce34ce84e243e9e1b150d20a6a.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0018_gives_a_conditional_nested_inside_an_elsif_its_own_slot_b63463f8f06e79ddef3e9a75eaab30c7.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0019_delimits_a_collection_nested_inside_an_else_fabeff4879dfa370e1afe79d37ed9db3.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0020_delimits_nested_collections_2876bfbfb23ec2b36577e62369a18ebc.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0021_delimits_a_collection_body_with_several_slots_0a3b40d74e00a12ff8eab07036a815d6.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0022_anchors_several_attribute_slots_on_the_same_element_246544cf2118f94c24e70453ba1bb30c.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0023_anchors_an_interpolated_attribute_value_4d09422b310d70d5601aa0de953b7988.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0024_delimits_raw_output_eb64be26a50dc767fa7b04015ca90d5a.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0025_does_not_assign_a_slot_to_an_ERB_comment_aafdd42677df13171b042c73442839e1.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0026_preserves_surrounding_whitespace_19e8de61834a9d30e35931130497b852.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0027_delimits_a_slot_with_no_surrounding_element_dd152c19d679db0061449d461a443838.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0028_emits_only_a_region_marker_when_there_are_no_slots_aabb292584ebec4d14d69193f52e95a1.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0029_delimits_a_yield_3427374d2e110b865e54d3d937742891.txt rename test/snapshots/engine/slot_markers_test/{test_0010_keeps_head_content_free_of_injected_elements_3c46b72b77e401c56e545d7a52a99e21.txt => test_0030_keeps_head_content_free_of_injected_elements_3c46b72b77e401c56e545d7a52a99e21.txt} (87%) rename test/snapshots/engine/slot_markers_test/{test_0011_keeps_SVG_subtrees_free_of_injected_elements_54774804186eb053a7db7b52763b5e18.txt => test_0031_keeps_SVG_subtrees_free_of_injected_elements_54774804186eb053a7db7b52763b5e18.txt} (86%) rename test/snapshots/engine/slot_markers_test/{test_0012_keeps_adjacent_siblings_adjacent_for_CSS_combinators_94e800d72c2784d9f34e395f51329a3a.txt => test_0032_keeps_adjacent_siblings_adjacent_for_CSS_combinators_94e800d72c2784d9f34e395f51329a3a.txt} (87%) diff --git a/lib/herb/engine.rb b/lib/herb/engine.rb index 123327bd3..634bff392 100644 --- a/lib/herb/engine.rb +++ b/lib/herb/engine.rb @@ -93,6 +93,8 @@ def initialize(input, properties = {}) @visitors = properties.fetch(:visitors, default_visitors) if @slots + @parser_options[:iteration_nodes] = true unless @parser_options.key?(:iteration_nodes) + @slot_visitor = SlotVisitor.new( file_path: @filename, project_path: @project_path diff --git a/lib/herb/engine/slot_visitor.rb b/lib/herb/engine/slot_visitor.rb index 01530df35..1a9a99db0 100644 --- a/lib/herb/engine/slot_visitor.rb +++ b/lib/herb/engine/slot_visitor.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -# typed: false +# typed: true require "digest" @@ -9,6 +9,8 @@ module Herb class Engine class SlotVisitor < Herb::Visitor ELEMENT_ANCHORED_TYPES = [:attribute, :attribute_interpolation, :boolean_attribute, :element].freeze #: Array[Symbol] + BRANCH_BODY_PROPERTIES = [:statements, :body, :children, :conditions].freeze #: Array[Symbol] + BRANCH_CONTINUATION_PROPERTIES = [:subsequent, :else_clause, :rescue_clause, :ensure_clause].freeze #: Array[Symbol] attr_reader :slots #: Array[Slot] @@ -32,9 +34,11 @@ def initialize(file_path: nil, project_path: nil, markers: SlotMarkers.new) pending = {} #: Hash[untyped, Integer] element_anchored = {} #: Hash[untyped, Array[Integer]] + continuations = {} #: Hash[untyped, bool] @pending = pending.compare_by_identity @element_anchored = element_anchored.compare_by_identity + @continuations = continuations.compare_by_identity @in_attribute = false @in_html_comment = false @@ -112,26 +116,23 @@ def visit_erb_yield_node(node) end def visit_erb_if_node(node) - record_slot(node, :conditional) + record_slot(node, :conditional) unless continuation?(node) + visit_branching_node(node) end def visit_erb_unless_node(node) - record_slot(node, :conditional) + record_slot(node, :conditional) unless continuation?(node) + visit_branching_node(node) end def visit_erb_case_node(node) - record_slot(node, :conditional) + record_slot(node, :conditional) unless continuation?(node) + visit_branching_node(node) end - # A plain block is not necessarily a repeating region: `form_with do |f|` - # and `@user.tap do |u|` wrap their body once, while `@users.each do |u|` - # repeats it. Distinguishing them requires the receiver and message, which - # only `ERBIterationBlockNode` carries (see marcoroth/herb#1912), so until - # that lands every block is typed `:block` rather than over-claiming - # `:collection`. Keyed reconciliation needs the stronger fact. def visit_erb_block_node(node) record_slot(node, :block) @@ -170,20 +171,28 @@ def visit_children_with_paths(children) end def visit_branching_node(node) - [:statements, :body, :children, :conditions].each do |property| + BRANCH_BODY_PROPERTIES.each do |property| next unless node.respond_to?(property) visit_children_with_paths(node.send(property)) end - [:subsequent, :else_clause, :rescue_clause, :ensure_clause].each do |property| + BRANCH_CONTINUATION_PROPERTIES.each do |property| next unless node.respond_to?(property) child = node.send(property) - visit(child) if child + next unless child + + @continuations[child] = true + visit(child) end end + #: (untyped) -> bool + def continuation?(node) + @continuations.key?(node) + end + #: (untyped, Symbol?) -> void def record_slot(node, type) return unless type @@ -281,14 +290,14 @@ def anchor_attributes(node) #: (untyped) { (Array[untyped]) -> void } -> void def each_child_array(node) - [:children, :body, :statements].each do |property| + BRANCH_BODY_PROPERTIES.each do |property| next unless node.respond_to?(property) array = node.send(property) yield array if array.is_a?(Array) end - [:subsequent, :else_clause, :end_node, :rescue_clause, :ensure_clause, :open_tag].each do |property| + (BRANCH_CONTINUATION_PROPERTIES + [:end_node, :open_tag]).each do |property| next unless node.respond_to?(property) child = node.send(property) diff --git a/sig/herb/engine/slot_visitor.rbs b/sig/herb/engine/slot_visitor.rbs index 5a0fdbef5..421a885d3 100644 --- a/sig/herb/engine/slot_visitor.rbs +++ b/sig/herb/engine/slot_visitor.rbs @@ -5,6 +5,10 @@ module Herb class SlotVisitor < Herb::Visitor ELEMENT_ANCHORED_TYPES: Array[Symbol] + BRANCH_BODY_PROPERTIES: Array[Symbol] + + BRANCH_CONTINUATION_PROPERTIES: Array[Symbol] + attr_reader slots: Array[Slot] class Slot < Data @@ -55,12 +59,6 @@ module Herb def visit_erb_case_node: (untyped node) -> untyped - # A plain block is not necessarily a repeating region: `form_with do |f|` - # and `@user.tap do |u|` wrap their body once, while `@users.each do |u|` - # repeats it. Distinguishing them requires the receiver and message, which - # only `ERBIterationBlockNode` carries (see marcoroth/herb#1912), so until - # that lands every block is typed `:block` rather than over-claiming - # `:collection`. Keyed reconciliation needs the stronger fact. def visit_erb_block_node: (untyped node) -> untyped def visit_erb_iteration_block_node: (untyped node) -> untyped @@ -74,6 +72,9 @@ module Herb def visit_branching_node: (untyped node) -> untyped + # : (untyped) -> bool + def continuation?: (untyped) -> bool + # : (untyped, Symbol?) -> void def record_slot: (untyped, Symbol?) -> void diff --git a/test/engine/slot_markers_test.rb b/test/engine/slot_markers_test.rb index b48bd3907..11dacb357 100644 --- a/test/engine/slot_markers_test.rb +++ b/test/engine/slot_markers_test.rb @@ -62,6 +62,126 @@ class SlotMarkersTest < Minitest::Spec assert_compiled_snapshot("

<%= @name %>

", OPTIONS) end + test "compiles a conditional into the generated source" do + assert_compiled_snapshot("
<% if @admin %><%= @secret %><% end %>
", OPTIONS) + end + + test "compiles a collection into the generated source" do + assert_compiled_snapshot("
    <% @items.each do |item| %>
  • <%= item %>
  • <% end %>
", OPTIONS) + end + + test "compiles attribute slots into the generated source" do + assert_compiled_snapshot(%(
), OPTIONS) + end + + test "treats an if/elsif/else chain as one conditional slot" do + assert_evaluated_snapshot( + "
<% if @a %>A<% elsif @b %><%= @n %><% else %>C<% end %>
", + { "@a" => false, "@b" => true, "@n" => "N" }, + OPTIONS + ) + end + + test "delimits a slot inside an unless/else" do + assert_evaluated_snapshot( + "
<% unless @a %><%= @n %><% else %>E<% end %>
", + { "@a" => false, "@n" => "N" }, + OPTIONS + ) + end + + test "delimits a slot inside a when branch" do + assert_evaluated_snapshot( + "
<% case @x %><% when 1 %><%= @a %><% end %>
", + { "@x" => 1, "@a" => "A" }, + OPTIONS + ) + end + + test "delimits a slot inside a case else branch" do + assert_evaluated_snapshot( + "
<% case @x %><% when 9 %>N<% else %><%= @a %><% end %>
", + { "@x" => 1, "@a" => "A" }, + OPTIONS + ) + end + + test "gives a conditional nested inside an else its own slot" do + assert_evaluated_snapshot( + "
<% if @a %>A<% else %><% if @b %><%= @n %><% end %><% end %>
", + { "@a" => false, "@b" => true, "@n" => "N" }, + OPTIONS + ) + end + + test "gives a conditional nested inside an elsif its own slot" do + assert_evaluated_snapshot( + "
<% if @a %>A<% elsif @b %><% if @c %><%= @n %><% end %><% end %>
", + { "@a" => false, "@b" => true, "@c" => true, "@n" => "N" }, + OPTIONS + ) + end + + test "delimits a collection nested inside an else" do + assert_evaluated_snapshot( + "
<% if @a %>A<% else %><% @l.each do |i| %><%= i %><% end %><% end %>
", + { "@a" => false, "@l" => [1] }, + OPTIONS + ) + end + + test "delimits nested collections" do + assert_evaluated_snapshot( + "<% @rows.each do |row| %><% row.each do |cell| %><%= cell %><% end %><% end %>", + { "@rows" => [[1, 2]] }, + OPTIONS + ) + end + + test "delimits a collection body with several slots" do + assert_evaluated_snapshot( + "
    <% @users.each do |u| %>
  • <%= u %> (<%= u %>)
  • <% end %>
", + { "@users" => ["a", "b"] }, + OPTIONS + ) + end + + test "anchors several attribute slots on the same element" do + assert_evaluated_snapshot( + %(
), + { "@c" => "C", "@i" => "I" }, + OPTIONS + ) + end + + test "anchors an interpolated attribute value" do + assert_evaluated_snapshot(%(
), { "@c" => "C" }, OPTIONS) + end + + test "delimits raw output" do + assert_evaluated_snapshot("

<%== @h %>

", { "@h" => "x" }, OPTIONS) + end + + test "does not assign a slot to an ERB comment" do + assert_evaluated_snapshot("

<%# skip %><%= @a %>

", { "@a" => "A" }, OPTIONS) + end + + test "preserves surrounding whitespace" do + assert_evaluated_snapshot("
\n

<%= @t %>

\n
", { "@t" => "T" }, OPTIONS) + end + + test "delimits a slot with no surrounding element" do + assert_evaluated_snapshot("<%= @a %>", { "@a" => "A" }, OPTIONS) + end + + test "emits only a region marker when there are no slots" do + assert_evaluated_snapshot("
static
", {}, OPTIONS) + end + + test "delimits a yield" do + assert_compiled_snapshot("
<%= yield %>
", OPTIONS) + end + test "keeps head content free of injected elements" do assert_evaluated_snapshot( %(<%= @t %>), diff --git a/test/engine/slot_visitor_test.rb b/test/engine/slot_visitor_test.rb index 72299358e..0ae592829 100644 --- a/test/engine/slot_visitor_test.rb +++ b/test/engine/slot_visitor_test.rb @@ -49,10 +49,37 @@ def slots_for(template, file_path: "app/views/test.html.erb") assert_equal :attribute, visitor.slots[0].type end - test "classifies an each block without claiming it repeats" do + test "classifies an each block as a collection" do visitor = slots_for("
    <% @items.each do |item| %>
  • x
  • <% end %>
") - assert_equal :block, visitor.slots[0].type + assert_equal :collection, visitor.slots[0].type + end + + test "classifies other iteration methods as collections" do + [ + "
<% 3.times do |i| %>x<% end %>
", + "
<% @a.map do |i| %>x<% end %>
" + ].each do |template| + assert_equal :collection, slots_for(template).slots[0].type, "unexpected type for: #{template}" + end + end + + test "treats an if/elsif/else chain as a single conditional slot" do + visitor = slots_for("
<% if @a %>A<% elsif @b %>B<% else %>C<% end %>
") + + assert_equal [:conditional], visitor.slots.map(&:type) + end + + test "gives a conditional nested inside an else its own slot" do + visitor = slots_for("
<% if @a %>A<% else %><% if @b %>B<% end %><% end %>
") + + assert_equal [:conditional, :conditional], visitor.slots.map(&:type) + end + + test "assigns a slot inside a when branch" do + visitor = slots_for("
<% case @x %><% when 1 %><%= @a %><% end %>
") + + assert_equal [:conditional, :child], visitor.slots.map(&:type) end test "classifies a builder block as a plain block" do diff --git a/test/snapshots/engine/slot_markers_test/test_0010_compiles_a_conditional_into_the_generated_source_2912a83cbf6d3340c6c202391c0ec23d.txt b/test/snapshots/engine/slot_markers_test/test_0010_compiles_a_conditional_into_the_generated_source_2912a83cbf6d3340c6c202391c0ec23d.txt new file mode 100644 index 000000000..04d57299f --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0010_compiles_a_conditional_into_the_generated_source_2912a83cbf6d3340c6c202391c0ec23d.txt @@ -0,0 +1,6 @@ +--- +source: "Engine::SlotMarkersTest#test_0010_compiles a conditional into the generated source" +input: "{source: \"
<% if @admin %><%= @secret %><% end %>
\", options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +_buf = ::String.new; _buf << '
'.freeze; if @admin; _buf << ''.freeze; _buf << (@secret).to_s; _buf << ''.freeze; end; _buf << '
'.freeze; +_buf.to_s diff --git a/test/snapshots/engine/slot_markers_test/test_0011_compiles_a_collection_into_the_generated_source_c132ec0b62116a7ec0488232b6560561.txt b/test/snapshots/engine/slot_markers_test/test_0011_compiles_a_collection_into_the_generated_source_c132ec0b62116a7ec0488232b6560561.txt new file mode 100644 index 000000000..6cb9f51c5 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0011_compiles_a_collection_into_the_generated_source_c132ec0b62116a7ec0488232b6560561.txt @@ -0,0 +1,6 @@ +--- +source: "Engine::SlotMarkersTest#test_0011_compiles a collection into the generated source" +input: "{source: \"
    <% @items.each do |item| %>
  • <%= item %>
  • <% end %>
\", options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +_buf = ::String.new; _buf << '
    '.freeze; @items.each do |item|; _buf << '
  • '.freeze; _buf << (item).to_s; _buf << '
  • '.freeze; end; _buf << '
'.freeze; +_buf.to_s diff --git a/test/snapshots/engine/slot_markers_test/test_0012_compiles_attribute_slots_into_the_generated_source_57adf1ee1da7d3c231aaeb064b37f72d.txt b/test/snapshots/engine/slot_markers_test/test_0012_compiles_attribute_slots_into_the_generated_source_57adf1ee1da7d3c231aaeb064b37f72d.txt new file mode 100644 index 000000000..9c27d48de --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0012_compiles_attribute_slots_into_the_generated_source_57adf1ee1da7d3c231aaeb064b37f72d.txt @@ -0,0 +1,6 @@ +--- +source: "Engine::SlotMarkersTest#test_0012_compiles attribute slots into the generated source" +input: "{source: \"
\\\" id=\\\"<%= @i %>\\\">
\", options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +_buf = ::String.new; _buf << '
'.freeze; +_buf.to_s diff --git a/test/snapshots/engine/slot_markers_test/test_0013_treats_an_if_elsif_else_chain_as_one_conditional_slot_08d2f5a74b6e91516a355c5f6d9bf30c.txt b/test/snapshots/engine/slot_markers_test/test_0013_treats_an_if_elsif_else_chain_as_one_conditional_slot_08d2f5a74b6e91516a355c5f6d9bf30c.txt new file mode 100644 index 000000000..e1464779c --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0013_treats_an_if_elsif_else_chain_as_one_conditional_slot_08d2f5a74b6e91516a355c5f6d9bf30c.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0013_treats an if/elsif/else chain as one conditional slot" +input: "{source: \"
<% if @a %>A<% elsif @b %><%= @n %><% else %>C<% end %>
\", locals: {\"@a\" => false, \"@b\" => true, \"@n\" => \"N\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
N
\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0014_delimits_a_slot_inside_an_unless_else_ca496223807300bdbe0f64e4db8ba219.txt b/test/snapshots/engine/slot_markers_test/test_0014_delimits_a_slot_inside_an_unless_else_ca496223807300bdbe0f64e4db8ba219.txt new file mode 100644 index 000000000..72ff36b38 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0014_delimits_a_slot_inside_an_unless_else_ca496223807300bdbe0f64e4db8ba219.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0014_delimits a slot inside an unless/else" +input: "{source: \"
<% unless @a %><%= @n %><% else %>E<% end %>
\", locals: {\"@a\" => false, \"@n\" => \"N\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
N
\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0015_delimits_a_slot_inside_a_when_branch_4e6668673cdf9f9cbb8afe5ce1fa4612.txt b/test/snapshots/engine/slot_markers_test/test_0015_delimits_a_slot_inside_a_when_branch_4e6668673cdf9f9cbb8afe5ce1fa4612.txt new file mode 100644 index 000000000..9bdbd3ad8 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0015_delimits_a_slot_inside_a_when_branch_4e6668673cdf9f9cbb8afe5ce1fa4612.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0015_delimits a slot inside a when branch" +input: "{source: \"
<% case @x %><% when 1 %><%= @a %><% end %>
\", locals: {\"@x\" => 1, \"@a\" => \"A\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
A
\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0016_delimits_a_slot_inside_a_case_else_branch_e835d1221d16a00a50c5951cc31f07bb.txt b/test/snapshots/engine/slot_markers_test/test_0016_delimits_a_slot_inside_a_case_else_branch_e835d1221d16a00a50c5951cc31f07bb.txt new file mode 100644 index 000000000..fba1b12db --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0016_delimits_a_slot_inside_a_case_else_branch_e835d1221d16a00a50c5951cc31f07bb.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0016_delimits a slot inside a case else branch" +input: "{source: \"
<% case @x %><% when 9 %>N<% else %><%= @a %><% end %>
\", locals: {\"@x\" => 1, \"@a\" => \"A\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
A
\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0017_gives_a_conditional_nested_inside_an_else_its_own_slot_0ec982ce34ce84e243e9e1b150d20a6a.txt b/test/snapshots/engine/slot_markers_test/test_0017_gives_a_conditional_nested_inside_an_else_its_own_slot_0ec982ce34ce84e243e9e1b150d20a6a.txt new file mode 100644 index 000000000..7dd7ad4af --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0017_gives_a_conditional_nested_inside_an_else_its_own_slot_0ec982ce34ce84e243e9e1b150d20a6a.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0017_gives a conditional nested inside an else its own slot" +input: "{source: \"
<% if @a %>A<% else %><% if @b %><%= @n %><% end %><% end %>
\", locals: {\"@a\" => false, \"@b\" => true, \"@n\" => \"N\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
N
\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0018_gives_a_conditional_nested_inside_an_elsif_its_own_slot_b63463f8f06e79ddef3e9a75eaab30c7.txt b/test/snapshots/engine/slot_markers_test/test_0018_gives_a_conditional_nested_inside_an_elsif_its_own_slot_b63463f8f06e79ddef3e9a75eaab30c7.txt new file mode 100644 index 000000000..eac52b1f2 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0018_gives_a_conditional_nested_inside_an_elsif_its_own_slot_b63463f8f06e79ddef3e9a75eaab30c7.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0018_gives a conditional nested inside an elsif its own slot" +input: "{source: \"
<% if @a %>A<% elsif @b %><% if @c %><%= @n %><% end %><% end %>
\", locals: {\"@a\" => false, \"@b\" => true, \"@c\" => true, \"@n\" => \"N\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
N
\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0019_delimits_a_collection_nested_inside_an_else_fabeff4879dfa370e1afe79d37ed9db3.txt b/test/snapshots/engine/slot_markers_test/test_0019_delimits_a_collection_nested_inside_an_else_fabeff4879dfa370e1afe79d37ed9db3.txt new file mode 100644 index 000000000..a4efbd920 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0019_delimits_a_collection_nested_inside_an_else_fabeff4879dfa370e1afe79d37ed9db3.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0019_delimits a collection nested inside an else" +input: "{source: \"
<% if @a %>A<% else %><% @l.each do |i| %><%= i %><% end %><% end %>
\", locals: {\"@a\" => false, \"@l\" => [1]}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
1
\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0020_delimits_nested_collections_2876bfbfb23ec2b36577e62369a18ebc.txt b/test/snapshots/engine/slot_markers_test/test_0020_delimits_nested_collections_2876bfbfb23ec2b36577e62369a18ebc.txt new file mode 100644 index 000000000..27a8c97d2 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0020_delimits_nested_collections_2876bfbfb23ec2b36577e62369a18ebc.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0020_delimits nested collections" +input: "{source: \"<% @rows.each do |row| %><% row.each do |cell| %><%= cell %><% end %><% end %>\", locals: {\"@rows\" => [[1, 2]]}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +12 \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0021_delimits_a_collection_body_with_several_slots_0a3b40d74e00a12ff8eab07036a815d6.txt b/test/snapshots/engine/slot_markers_test/test_0021_delimits_a_collection_body_with_several_slots_0a3b40d74e00a12ff8eab07036a815d6.txt new file mode 100644 index 000000000..5e9ee32f9 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0021_delimits_a_collection_body_with_several_slots_0a3b40d74e00a12ff8eab07036a815d6.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0021_delimits a collection body with several slots" +input: "{source: \"
    <% @users.each do |u| %>
  • <%= u %> (<%= u %>)
  • <% end %>
\", locals: {\"@users\" => [\"a\", \"b\"]}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
  • a (a)
  • b (b)
\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0022_anchors_several_attribute_slots_on_the_same_element_246544cf2118f94c24e70453ba1bb30c.txt b/test/snapshots/engine/slot_markers_test/test_0022_anchors_several_attribute_slots_on_the_same_element_246544cf2118f94c24e70453ba1bb30c.txt new file mode 100644 index 000000000..2fb8437f8 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0022_anchors_several_attribute_slots_on_the_same_element_246544cf2118f94c24e70453ba1bb30c.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0022_anchors several attribute slots on the same element" +input: "{source: \"
\\\" id=\\\"<%= @i %>\\\">
\", locals: {\"@c\" => \"C\", \"@i\" => \"I\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0023_anchors_an_interpolated_attribute_value_4d09422b310d70d5601aa0de953b7988.txt b/test/snapshots/engine/slot_markers_test/test_0023_anchors_an_interpolated_attribute_value_4d09422b310d70d5601aa0de953b7988.txt new file mode 100644 index 000000000..27fab80f0 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0023_anchors_an_interpolated_attribute_value_4d09422b310d70d5601aa0de953b7988.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0023_anchors an interpolated attribute value" +input: "{source: \"
b\\\">
\", locals: {\"@c\" => \"C\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0024_delimits_raw_output_eb64be26a50dc767fa7b04015ca90d5a.txt b/test/snapshots/engine/slot_markers_test/test_0024_delimits_raw_output_eb64be26a50dc767fa7b04015ca90d5a.txt new file mode 100644 index 000000000..5352cfb6b --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0024_delimits_raw_output_eb64be26a50dc767fa7b04015ca90d5a.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0024_delimits raw output" +input: "{source: \"

<%== @h %>

\", locals: {\"@h\" => \"x\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +

<b>x</b>

\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0025_does_not_assign_a_slot_to_an_ERB_comment_aafdd42677df13171b042c73442839e1.txt b/test/snapshots/engine/slot_markers_test/test_0025_does_not_assign_a_slot_to_an_ERB_comment_aafdd42677df13171b042c73442839e1.txt new file mode 100644 index 000000000..e6157922d --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0025_does_not_assign_a_slot_to_an_ERB_comment_aafdd42677df13171b042c73442839e1.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0025_does not assign a slot to an ERB comment" +input: "{source: \"

<%# skip %><%= @a %>

\", locals: {\"@a\" => \"A\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +

A

\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0026_preserves_surrounding_whitespace_19e8de61834a9d30e35931130497b852.txt b/test/snapshots/engine/slot_markers_test/test_0026_preserves_surrounding_whitespace_19e8de61834a9d30e35931130497b852.txt new file mode 100644 index 000000000..ee201a528 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0026_preserves_surrounding_whitespace_19e8de61834a9d30e35931130497b852.txt @@ -0,0 +1,7 @@ +--- +source: "Engine::SlotMarkersTest#test_0026_preserves surrounding whitespace" +input: "{source: \"
\\n

<%= @t %>

\\n
\", locals: {\"@t\" => \"T\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
+

T

+
\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0027_delimits_a_slot_with_no_surrounding_element_dd152c19d679db0061449d461a443838.txt b/test/snapshots/engine/slot_markers_test/test_0027_delimits_a_slot_with_no_surrounding_element_dd152c19d679db0061449d461a443838.txt new file mode 100644 index 000000000..d05c767b8 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0027_delimits_a_slot_with_no_surrounding_element_dd152c19d679db0061449d461a443838.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0027_delimits a slot with no surrounding element" +input: "{source: \"<%= @a %>\", locals: {\"@a\" => \"A\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +A \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0028_emits_only_a_region_marker_when_there_are_no_slots_aabb292584ebec4d14d69193f52e95a1.txt b/test/snapshots/engine/slot_markers_test/test_0028_emits_only_a_region_marker_when_there_are_no_slots_aabb292584ebec4d14d69193f52e95a1.txt new file mode 100644 index 000000000..6c450b8b1 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0028_emits_only_a_region_marker_when_there_are_no_slots_aabb292584ebec4d14d69193f52e95a1.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0028_emits only a region marker when there are no slots" +input: "{source: \"
static
\", locals: {}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
static
\ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0029_delimits_a_yield_3427374d2e110b865e54d3d937742891.txt b/test/snapshots/engine/slot_markers_test/test_0029_delimits_a_yield_3427374d2e110b865e54d3d937742891.txt new file mode 100644 index 000000000..18101e743 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0029_delimits_a_yield_3427374d2e110b865e54d3d937742891.txt @@ -0,0 +1,6 @@ +--- +source: "Engine::SlotMarkersTest#test_0029_delimits a yield" +input: "{source: \"
<%= yield %>
\", options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +_buf = ::String.new; _buf << '
'.freeze; _buf << (yield).to_s; _buf << '
'.freeze; +_buf.to_s diff --git a/test/snapshots/engine/slot_markers_test/test_0010_keeps_head_content_free_of_injected_elements_3c46b72b77e401c56e545d7a52a99e21.txt b/test/snapshots/engine/slot_markers_test/test_0030_keeps_head_content_free_of_injected_elements_3c46b72b77e401c56e545d7a52a99e21.txt similarity index 87% rename from test/snapshots/engine/slot_markers_test/test_0010_keeps_head_content_free_of_injected_elements_3c46b72b77e401c56e545d7a52a99e21.txt rename to test/snapshots/engine/slot_markers_test/test_0030_keeps_head_content_free_of_injected_elements_3c46b72b77e401c56e545d7a52a99e21.txt index 841f084fd..1c03d1779 100644 --- a/test/snapshots/engine/slot_markers_test/test_0010_keeps_head_content_free_of_injected_elements_3c46b72b77e401c56e545d7a52a99e21.txt +++ b/test/snapshots/engine/slot_markers_test/test_0030_keeps_head_content_free_of_injected_elements_3c46b72b77e401c56e545d7a52a99e21.txt @@ -1,5 +1,5 @@ --- -source: "Engine::SlotMarkersTest#test_0010_keeps head content free of injected elements" +source: "Engine::SlotMarkersTest#test_0030_keeps head content free of injected elements" input: "{source: \"<%= @t %>\", locals: {\"@t\" => \"T\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" --- <!--herb-slot:0-->T<!--/herb-slot:0--> \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0011_keeps_SVG_subtrees_free_of_injected_elements_54774804186eb053a7db7b52763b5e18.txt b/test/snapshots/engine/slot_markers_test/test_0031_keeps_SVG_subtrees_free_of_injected_elements_54774804186eb053a7db7b52763b5e18.txt similarity index 86% rename from test/snapshots/engine/slot_markers_test/test_0011_keeps_SVG_subtrees_free_of_injected_elements_54774804186eb053a7db7b52763b5e18.txt rename to test/snapshots/engine/slot_markers_test/test_0031_keeps_SVG_subtrees_free_of_injected_elements_54774804186eb053a7db7b52763b5e18.txt index f947f80b3..5e875af88 100644 --- a/test/snapshots/engine/slot_markers_test/test_0011_keeps_SVG_subtrees_free_of_injected_elements_54774804186eb053a7db7b52763b5e18.txt +++ b/test/snapshots/engine/slot_markers_test/test_0031_keeps_SVG_subtrees_free_of_injected_elements_54774804186eb053a7db7b52763b5e18.txt @@ -1,5 +1,5 @@ --- -source: "Engine::SlotMarkersTest#test_0011_keeps SVG subtrees free of injected elements" +source: "Engine::SlotMarkersTest#test_0031_keeps SVG subtrees free of injected elements" input: "{source: \"\\\">\", locals: {\"@r\" => \"4\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" --- \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0012_keeps_adjacent_siblings_adjacent_for_CSS_combinators_94e800d72c2784d9f34e395f51329a3a.txt b/test/snapshots/engine/slot_markers_test/test_0032_keeps_adjacent_siblings_adjacent_for_CSS_combinators_94e800d72c2784d9f34e395f51329a3a.txt similarity index 87% rename from test/snapshots/engine/slot_markers_test/test_0012_keeps_adjacent_siblings_adjacent_for_CSS_combinators_94e800d72c2784d9f34e395f51329a3a.txt rename to test/snapshots/engine/slot_markers_test/test_0032_keeps_adjacent_siblings_adjacent_for_CSS_combinators_94e800d72c2784d9f34e395f51329a3a.txt index 4c7e938a1..74a960474 100644 --- a/test/snapshots/engine/slot_markers_test/test_0012_keeps_adjacent_siblings_adjacent_for_CSS_combinators_94e800d72c2784d9f34e395f51329a3a.txt +++ b/test/snapshots/engine/slot_markers_test/test_0032_keeps_adjacent_siblings_adjacent_for_CSS_combinators_94e800d72c2784d9f34e395f51329a3a.txt @@ -1,5 +1,5 @@ --- -source: "Engine::SlotMarkersTest#test_0012_keeps adjacent siblings adjacent for CSS combinators" +source: "Engine::SlotMarkersTest#test_0032_keeps adjacent siblings adjacent for CSS combinators" input: "{source: \"
<%= @x %>

y

\", locals: {\"@x\" => \"\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" ---

y

\ No newline at end of file From d113b5ae415005a2791068422d4d57d43e8fac43 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 4 Aug 2026 01:36:37 +0200 Subject: [PATCH 3/4] Support comments --- lib/herb/engine/slot_visitor.rb | 9 +++++++++ sig/herb/engine/slot_visitor.rbs | 4 ++++ test/engine/slot_markers_test.rb | 12 ++++++++++++ test/engine/slot_visitor_test.rb | 18 ++++++++++++++++++ ...utside_f993e8a2ad4c70965d6ecd6260cd462a.txt | 5 +++++ ...omment_7aee2cd8df90312626225c60985ce28c.txt | 5 +++++ ...omment_4a09acfaf0ec713bad4d85b78641db73.txt | 5 +++++ 7 files changed, 58 insertions(+) create mode 100644 test/snapshots/engine/slot_markers_test/test_0033_delimits_a_dynamic_HTML_comment_from_the_outside_f993e8a2ad4c70965d6ecd6260cd462a.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0034_delimits_a_conditional_inside_an_HTML_comment_7aee2cd8df90312626225c60985ce28c.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0035_does_not_delimit_a_static_HTML_comment_4a09acfaf0ec713bad4d85b78641db73.txt diff --git a/lib/herb/engine/slot_visitor.rb b/lib/herb/engine/slot_visitor.rb index 1a9a99db0..2106db221 100644 --- a/lib/herb/engine/slot_visitor.rb +++ b/lib/herb/engine/slot_visitor.rb @@ -92,6 +92,8 @@ def visit_html_attribute_node(node) end def visit_html_comment_node(node) + record_slot(node, :comment) if dynamic?(node) + @in_html_comment = true super @in_html_comment = false @@ -228,6 +230,13 @@ def attribute_slot_type(_type) :attribute end + #: (untyped) -> bool + def dynamic?(node) + return true if node.type.to_s.start_with?("AST_ERB_") + + node.compact_child_nodes.any? { |child| dynamic?(child) } + end + def expression_for(node) return nil unless node.respond_to?(:content) diff --git a/sig/herb/engine/slot_visitor.rbs b/sig/herb/engine/slot_visitor.rbs index 421a885d3..4e1ccd34a 100644 --- a/sig/herb/engine/slot_visitor.rbs +++ b/sig/herb/engine/slot_visitor.rbs @@ -81,6 +81,10 @@ module Herb # : (Symbol) -> Symbol? def attribute_slot_type: (Symbol) -> Symbol? + # Whether a subtree contains ERB, and so renders differently run to run. + # : (untyped) -> bool + def dynamic?: (untyped) -> bool + def expression_for: (untyped node) -> untyped def location_for: (untyped node) -> untyped diff --git a/test/engine/slot_markers_test.rb b/test/engine/slot_markers_test.rb index 11dacb357..a0b65b40e 100644 --- a/test/engine/slot_markers_test.rb +++ b/test/engine/slot_markers_test.rb @@ -206,6 +206,18 @@ class SlotMarkersTest < Minitest::Spec ) end + test "delimits a dynamic HTML comment from the outside" do + assert_evaluated_snapshot("", { "@c" => "X" }, OPTIONS) + end + + test "delimits a conditional inside an HTML comment" do + assert_evaluated_snapshot("", { "@a" => true }, OPTIONS) + end + + test "does not delimit a static HTML comment" do + assert_evaluated_snapshot("

<%= @a %>

", { "@a" => "A" }, OPTIONS) + end + test "never emits a wrapper element" do [ ["

<%= @x %>

", { "@x" => "a" }], diff --git a/test/engine/slot_visitor_test.rb b/test/engine/slot_visitor_test.rb index 0ae592829..2888f52b7 100644 --- a/test/engine/slot_visitor_test.rb +++ b/test/engine/slot_visitor_test.rb @@ -94,6 +94,24 @@ def slots_for(template, file_path: "app/views/test.html.erb") assert_equal :block, visitor.slots[0].type end + test "classifies a dynamic HTML comment" do + visitor = slots_for("") + + assert_equal [:comment], visitor.slots.map(&:type) + end + + test "does not assign a slot to a static HTML comment" do + visitor = slots_for("") + + assert_empty visitor.slots + end + + test "assigns one slot to a comment regardless of how much ERB it holds" do + visitor = slots_for("") + + assert_equal [:comment], visitor.slots.map(&:type) + end + test "does not assign a slot to non-output ERB" do visitor = slots_for("<% x = 1 %>

static

") diff --git a/test/snapshots/engine/slot_markers_test/test_0033_delimits_a_dynamic_HTML_comment_from_the_outside_f993e8a2ad4c70965d6ecd6260cd462a.txt b/test/snapshots/engine/slot_markers_test/test_0033_delimits_a_dynamic_HTML_comment_from_the_outside_f993e8a2ad4c70965d6ecd6260cd462a.txt new file mode 100644 index 000000000..ab89948e5 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0033_delimits_a_dynamic_HTML_comment_from_the_outside_f993e8a2ad4c70965d6ecd6260cd462a.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0033_delimits a dynamic HTML comment from the outside" +input: "{source: \"\", locals: {\"@c\" => \"X\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- + \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0034_delimits_a_conditional_inside_an_HTML_comment_7aee2cd8df90312626225c60985ce28c.txt b/test/snapshots/engine/slot_markers_test/test_0034_delimits_a_conditional_inside_an_HTML_comment_7aee2cd8df90312626225c60985ce28c.txt new file mode 100644 index 000000000..07929c70c --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0034_delimits_a_conditional_inside_an_HTML_comment_7aee2cd8df90312626225c60985ce28c.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0034_delimits a conditional inside an HTML comment" +input: "{source: \"\", locals: {\"@a\" => true}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- + \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0035_does_not_delimit_a_static_HTML_comment_4a09acfaf0ec713bad4d85b78641db73.txt b/test/snapshots/engine/slot_markers_test/test_0035_does_not_delimit_a_static_HTML_comment_4a09acfaf0ec713bad4d85b78641db73.txt new file mode 100644 index 000000000..8b5735f18 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0035_does_not_delimit_a_static_HTML_comment_4a09acfaf0ec713bad4d85b78641db73.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0035_does not delimit a static HTML comment" +input: "{source: \"

<%= @a %>

\", locals: {\"@a\" => \"A\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +

A

\ No newline at end of file From f56fb2695bc32a2f4467bebe9f899319a77062c6 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 4 Aug 2026 10:06:13 +0200 Subject: [PATCH 4/4] Improve slots for comments, attribtues, branches --- lib/herb/cli.rb | 24 +- lib/herb/engine.rb | 16 +- lib/herb/engine/slot_markers.rb | 20 ++ lib/herb/engine/slot_visitor.rb | 248 +++++++++++++++++- lib/herb/html/util.rb | 12 + lib/herb/warnings.rb | 27 ++ sig/herb/engine/slot_markers.rbs | 12 + sig/herb/engine/slot_visitor.rbs | 71 ++++- sig/herb/html/util.rbs | 10 + sig/herb/warnings.rbs | 15 +- test/engine/slot_markers_test.rb | 86 +++++- test/engine/slot_visitor_test.rb | 166 ++++++++++++ ..._slot_405c7a041d4c2c73cf05f509c1f181d0.txt | 2 +- ...ource_2912a83cbf6d3340c6c202391c0ec23d.txt | 2 +- ..._slot_08d2f5a74b6e91516a355c5f6d9bf30c.txt | 2 +- ..._else_ca496223807300bdbe0f64e4db8ba219.txt | 2 +- ...ranch_4e6668673cdf9f9cbb8afe5ce1fa4612.txt | 2 +- ...ranch_e835d1221d16a00a50c5951cc31f07bb.txt | 2 +- ..._slot_0ec982ce34ce84e243e9e1b150d20a6a.txt | 2 +- ..._slot_b63463f8f06e79ddef3e9a75eaab30c7.txt | 2 +- ..._else_fabeff4879dfa370e1afe79d37ed9db3.txt | 2 +- ...value_4d09422b310d70d5601aa0de953b7988.txt | 2 +- ...ments_3c46b72b77e401c56e545d7a52a99e21.txt | 2 +- ...ition_e4a20e17a14974b4d40acb64931e9171.txt | 5 + ...ntent_e8b430c7b27d0cb5018b4a9ba9576862.txt | 5 + ..._loop_8af8826f58c0325f26e303e7d4fe7648.txt | 5 + ...ction_a0f38287dd7e0e9b7e60eca8f2598709.txt | 5 + ...ction_ae29318fa98158d81939c985d126e325.txt | 5 + ...ctive_efa7eb8257db0e6d19a076d29d62946c.txt | 5 + ...t_all_47d5c5805564d4136fcfa6e053bc8b61.txt | 5 + ...dered_a0f0ee44e7033e51204f611f6996a7f2.txt | 5 + ...dered_bae31f016e324936cd0b8f0f20b8b1c6.txt | 5 + ...dered_8f1760da5edb1f85a2e0f3988fcdf1cf.txt | 5 + ...thing_3e36c3afaa8f0a019e06e701355664ac.txt | 5 + ...hable_277e7fc04fc4fecb4ea1c2d558624ada.txt | 5 + 35 files changed, 742 insertions(+), 47 deletions(-) create mode 100644 test/snapshots/engine/slot_markers_test/test_0037_keeps_markers_out_of_a_tag_for_ERB_in_element_position_e4a20e17a14974b4d40acb64931e9171.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0038_keeps_markers_out_of_RCDATA_content_e8b430c7b27d0cb5018b4a9ba9576862.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0039_delimits_a_for_loop_8af8826f58c0325f26e303e7d4fe7648.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0040_delimits_each_row_of_a_keyed_collection_a0f38287dd7e0e9b7e60eca8f2598709.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0041_does_not_delimit_rows_of_an_unkeyed_collection_ae29318fa98158d81939c985d126e325.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0042_delimits_rows_of_a_body_with_several_roots_via_the_herbkey_directive_efa7eb8257db0e6d19a076d29d62946c.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0043_delimits_rows_of_a_body_with_no_element_at_all_47d5c5805564d4136fcfa6e053bc8b61.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0044_names_the_branch_of_an_if_elsif_else_that_rendered_a0f0ee44e7033e51204f611f6996a7f2.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0045_names_the_else_branch_that_rendered_bae31f016e324936cd0b8f0f20b8b1c6.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0046_names_the_case_branch_that_rendered_8f1760da5edb1f85a2e0f3988fcdf1cf.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0047_names_no_branch_when_a_conditional_renders_nothing_3e36c3afaa8f0a019e06e701355664ac.txt create mode 100644 test/snapshots/engine/slot_markers_test/test_0048_keeps_nested_collection_rows_distinguishable_277e7fc04fc4fecb4ea1c2d558624ada.txt diff --git a/lib/herb/cli.rb b/lib/herb/cli.rb index e58e78d8e..02e60c81e 100644 --- a/lib/herb/cli.rb +++ b/lib/herb/cli.rb @@ -8,7 +8,7 @@ class Herb::CLI include Herb::Colors - attr_accessor :json, :silent, :log_file, :no_timing, :local, :escape, :no_escape, :freeze, :debug, :tool, :strict, :analyze, :track_whitespace, :verbose, :isolate, :arena_stats, :leak_check, :action_view_helpers, :trim, :optimize, :file_timeout + attr_accessor :json, :silent, :log_file, :no_timing, :local, :escape, :no_escape, :freeze, :debug, :tool, :strict, :analyze, :track_whitespace, :verbose, :isolate, :arena_stats, :leak_check, :action_view_helpers, :trim, :optimize, :slots, :file_timeout def initialize(args) @args = args @@ -334,6 +334,10 @@ def option_parser self.optimize = true end + parser.on("--slots", "Emit slot markers for reactive rendering (for compile/render commands) (default: false)") do + self.slots = true + end + parser.on("--tool TOOL", "Show config for specific tool: linter, formatter (for config command)") do |t| self.tool = t.to_sym end @@ -688,9 +692,12 @@ def compile_template options[:optimize] = true if optimize options[:trim] = true if trim + options[:slots] = true if slots options[:validate_ruby] = true engine = Herb::Engine.new(file_content, options) + print_warnings(engine.slot_visitor&.warnings || []) unless json + if json result = { success: true, @@ -760,6 +767,21 @@ def compile_template end end + def print_warnings(warnings) + return if warnings.empty? + + $stderr.puts + + warnings.each do |warning| + location = [@file, warning.location&.start&.line, warning.location&.start&.column].compact.join(":") + + warn "#{bold(yellow("warning"))}: #{warning.message}" + warn " #{dimmed(location)}" + warn " #{dimmed(warning.expression.to_s)}" if warning.respond_to?(:expression) && warning.expression + $stderr.puts + end + end + def render_template require_relative "engine" diff --git a/lib/herb/engine.rb b/lib/herb/engine.rb index 634bff392..edac8afd0 100644 --- a/lib/herb/engine.rb +++ b/lib/herb/engine.rb @@ -92,24 +92,24 @@ def initialize(input, properties = {}) @visitors = properties.fetch(:visitors, default_visitors) - if @slots - @parser_options[:iteration_nodes] = true unless @parser_options.key?(:iteration_nodes) - - @slot_visitor = SlotVisitor.new( + if @debug && @visitors.empty? + debug_visitor = DebugVisitor.new( file_path: @filename, project_path: @project_path ) - @visitors.unshift(@slot_visitor) + @visitors << debug_visitor end - if @debug && @visitors.empty? - debug_visitor = DebugVisitor.new( + if @slots + @parser_options[:iteration_nodes] = true unless @parser_options.key?(:iteration_nodes) + + @slot_visitor = SlotVisitor.new( file_path: @filename, project_path: @project_path ) - @visitors << debug_visitor + @visitors = [@slot_visitor, *@visitors] end unless [:raise, :overlay, :none].include?(@validation_mode) diff --git a/lib/herb/engine/slot_markers.rb b/lib/herb/engine/slot_markers.rb index 03ca12da9..187c192ea 100644 --- a/lib/herb/engine/slot_markers.rb +++ b/lib/herb/engine/slot_markers.rb @@ -14,6 +14,26 @@ def slot_close(index) "" end + #: (Integer, Integer) -> String + def branch(slot_index, branch_index) + "" + end + + #: (Integer) -> String + def row_open_prefix(slot_index) + "" + end + + #: (Integer) -> String + def row_close(slot_index) + "" + end + #: (String, String) -> String def region_open(file, version) "" diff --git a/lib/herb/engine/slot_visitor.rb b/lib/herb/engine/slot_visitor.rb index 2106db221..6fd5081e4 100644 --- a/lib/herb/engine/slot_visitor.rb +++ b/lib/herb/engine/slot_visitor.rb @@ -8,18 +8,23 @@ module Herb class Engine class SlotVisitor < Herb::Visitor - ELEMENT_ANCHORED_TYPES = [:attribute, :attribute_interpolation, :boolean_attribute, :element].freeze #: Array[Symbol] + ATTRIBUTE_TYPES = [:attribute, :attribute_interpolation].freeze #: Array[Symbol] + ELEMENT_ANCHORED_TYPES = [*ATTRIBUTE_TYPES, :boolean_attribute, :element, :raw_text].freeze #: Array[Symbol] BRANCH_BODY_PROPERTIES = [:statements, :body, :children, :conditions].freeze #: Array[Symbol] BRANCH_CONTINUATION_PROPERTIES = [:subsequent, :else_clause, :rescue_clause, :ensure_clause].freeze #: Array[Symbol] attr_reader :slots #: Array[Slot] + attr_reader :warnings #: Array[Herb::Warnings::Warning] Slot = Data.define( :index, #: Integer :type, #: Symbol :node_path, #: Array[Integer] :expression, #: String? - :location #: String? + :location, #: String? + :attribute, #: String? + :key_source, #: Symbol? + :key_expression #: String? ) #: (?file_path: untyped, ?project_path: untyped, ?markers: SlotMarkers) -> void @@ -30,6 +35,7 @@ def initialize(file_path: nil, project_path: nil, markers: SlotMarkers.new) @relative_file_path = relative_path_for(file_path, project_path) @slots = [] #: Array[Slot] + @warnings = [] #: Array[Herb::Warnings::Warning] @path = [] #: Array[Integer] pending = {} #: Hash[untyped, Integer] @@ -41,9 +47,11 @@ def initialize(file_path: nil, project_path: nil, markers: SlotMarkers.new) @continuations = continuations.compare_by_identity @in_attribute = false + @in_open_tag = false @in_html_comment = false @in_html_doctype = false @raw_text_depth = 0 + @rcdata_depth = 0 @current_open_tag = nil end @@ -59,7 +67,12 @@ def schema { file: @relative_file_path, version: version, - slots: @slots.map { |slot| { index: slot.index, type: slot.type, node_path: slot.node_path } }, + slots: @slots.map { |slot| + entry = { index: slot.index, type: slot.type, node_path: slot.node_path } + entry = entry.merge(attribute: slot.attribute) if slot.attribute + entry = entry.merge(key_source: slot.key_source) if slot.key_source + entry + }, } end @@ -71,8 +84,12 @@ def visit_document_node(node) end def visit_html_element_node(node) - raw_text = raw_text_element?(node) + tag_name = node.tag_name&.value&.downcase.to_s + raw_text = Herb::HTML::Util.raw_text_element?(tag_name) + rcdata = Herb::HTML::Util.rcdata_element?(tag_name) + @raw_text_depth += 1 if raw_text + @rcdata_depth += 1 if rcdata previous_open_tag = @current_open_tag @current_open_tag = node.open_tag @@ -82,10 +99,20 @@ def visit_html_element_node(node) visit(node.close_tag) if node.close_tag @current_open_tag = previous_open_tag + + @rcdata_depth -= 1 if rcdata @raw_text_depth -= 1 if raw_text end + def visit_html_open_tag_node(node) + @in_open_tag = true + super + @in_open_tag = false + end + def visit_html_attribute_node(node) + record_slot(node, attribute_type_for(node)) if dynamic?(node) + @in_attribute = true super @in_attribute = false @@ -147,6 +174,24 @@ def visit_erb_iteration_block_node(node) visit_branching_node(node) end + def visit_erb_while_node(node) + record_slot(node, :collection) + + visit_branching_node(node) + end + + def visit_erb_until_node(node) + record_slot(node, :collection) + + visit_branching_node(node) + end + + def visit_erb_for_node(node) + record_slot(node, :collection) + + visit_branching_node(node) + end + private #: (untyped, untyped) -> String @@ -199,22 +244,34 @@ def continuation?(node) def record_slot(node, type) return unless type - type = attribute_slot_type(type) if @in_attribute + type = anchored_type_for(type) return unless type return if @in_html_comment || @in_html_doctype - return if @raw_text_depth.positive? && !@in_attribute + + key_source, key_expression = type == :collection ? key_for(node) : [nil, nil] slot = Slot.new( index: @slots.size, type: type, node_path: @path.dup, expression: expression_for(node), - location: location_for(node) + location: location_for(node), + attribute: attribute_name_for(node), + key_source: key_source, + key_expression: key_expression ) @slots << slot + if type == :collection && key_source == :index + @warnings << Herb::Warnings::UnkeyedCollectionWarning.new( + node.location, + expression_for(node), + tag_name: keyable_tag_name_for(node) + ) + end + if !ELEMENT_ANCHORED_TYPES.include?(type) @pending[node] = slot.index elsif @current_open_tag @@ -226,8 +283,101 @@ def record_slot(node, type) end #: (Symbol) -> Symbol? - def attribute_slot_type(_type) - :attribute + def anchored_type_for(type) + return type if ATTRIBUTE_TYPES.include?(type) + + return nil if @in_attribute + return :element if @in_open_tag + return nil if @raw_text_depth.positive? + return :raw_text if @rcdata_depth.positive? + + type + end + + #: (untyped) -> Symbol + def attribute_type_for(node) + children = node.value&.children || [] + + children.one? ? :attribute : :attribute_interpolation + end + + #: (untyped) -> [Symbol, String?] + def key_for(node) + body = collection_body(node) + + directive = key_directive_in(body) + return [:directive, directive] if directive + + elements = body.grep(Herb::AST::HTMLElementNode) + return [:index, nil] unless elements.one? + + attributes = attributes_for(elements.first) + + ["herb-key", "id"].each do |name| + attribute = attributes.find { |candidate| attribute_name_for(candidate)&.downcase == name } + next unless attribute + + expression = key_expression_for(attribute) + next unless expression + + return [name == "herb-key" ? :herb_key : :id, expression] + end + + [:index, nil] + end + + #: (untyped) -> String? + def keyable_tag_name_for(node) + elements = collection_body(node).grep(Herb::AST::HTMLElementNode) + return nil unless elements.one? + + elements.first.tag_name&.value&.downcase + end + + #: (Array[untyped]) -> String? + def key_directive_in(body) + body.each do |child| + next unless child.is_a?(Herb::AST::ERBContentNode) + next unless child.tag_opening&.value == "<%#" + + match = child.content&.value.to_s.strip.match(/\Aherb:key\s+(?.+)\z/) + return match[:expression].strip if match + end + + nil + end + + #: (untyped) -> String? + def key_expression_for(attribute) + children = attribute.value&.children || [] + return nil unless children.one? + + erb = children.first + return nil unless erb.type.to_s == "AST_ERB_CONTENT_NODE" + + erb.content&.value&.strip + end + + #: (untyped) -> Array[untyped] + def collection_body(node) + BRANCH_BODY_PROPERTIES.filter_map { |property| + node.send(property) if node.respond_to?(property) + }.flatten + end + + #: (untyped) -> Array[untyped] + def attributes_for(element) + open_tag = element.open_tag + return [] unless open_tag.is_a?(Herb::AST::HTMLOpenTagNode) + + open_tag.children.grep(Herb::AST::HTMLAttributeNode) + end + + #: (untyped) -> String? + def attribute_name_for(node) + return nil unless node.is_a?(Herb::AST::HTMLAttributeNode) + + node.name&.children&.filter_map { |child| child.content if child.respond_to?(:content) }&.join end #: (untyped) -> bool @@ -253,10 +403,6 @@ def location_for(node) "#{location.start.line}:#{location.start.column}" end - def raw_text_element?(node) - ["script", "style"].include?(node.tag_name&.value&.downcase.to_s) - end - def erb_output?(opening) opening.include?("=") end @@ -270,6 +416,8 @@ def insert_markers(node) slot_index = @pending[child] insert_markers(child) + wrap_rows(child, slot_index) + mark_branches(child, slot_index) if slot_index array.insert(index, comment_node(@markers.slot_open(slot_index, @slots[slot_index].type))) @@ -285,6 +433,67 @@ def insert_markers(node) anchor_attributes(node) end + #: (untyped, Integer?) -> void + def mark_branches(node, slot_index) + return unless slot_index + return unless @slots[slot_index].type == :conditional + + branch_bodies(node).each_with_index do |body, branch_index| + body.unshift(text_node(@markers.branch(slot_index, branch_index))) + end + end + + #: (untyped) -> Array[Array[untyped]] + def branch_bodies(node) + bodies = [] #: Array[Array[untyped]] + + if node.respond_to?(:conditions) + node.conditions.each { |arm| bodies << arm.statements if arm.respond_to?(:statements) } + elsif node.respond_to?(:statements) + bodies << node.statements + end + + current = continuation_of(node) #: untyped + + while (branch = current) + bodies << branch.statements if branch.respond_to?(:statements) + + current = continuation_of(branch) + end + + bodies + end + + #: (untyped) -> untyped + def continuation_of(node) + subsequent = node.respond_to?(:subsequent) ? node.subsequent : nil + + subsequent || (node.respond_to?(:else_clause) ? node.else_clause : nil) + end + + #: (untyped, Integer?) -> void + def wrap_rows(node, slot_index) + return unless slot_index + + slot = @slots[slot_index] + return unless slot.type == :collection && slot.key_expression + + BRANCH_BODY_PROPERTIES.each do |property| + next unless node.respond_to?(property) + + body = node.send(property) + next unless body.is_a?(Array) && !body.empty? + + body.unshift( + text_node(@markers.row_open_prefix(slot_index)), + erb_output_node(slot.key_expression), + text_node(@markers.row_open_suffix) + ) + + body.push(text_node(@markers.row_close(slot_index))) + end + end + def anchor_attributes(node) return unless node.is_a?(Herb::AST::HTMLElementNode) @@ -320,6 +529,19 @@ def wrap_region(document_node) document_node.children.push(comment_node(@markers.region_close(@relative_file_path))) end + def text_node(content) + Herb::AST::HTMLTextNode.new("HTMLTextNode", Herb::Location.zero, [], content.dup) + end + + def erb_output_node(code) + Herb::AST::ERBContentNode.new( + "ERBContentNode", Herb::Location.zero, [], + token(:erb_start, "<%="), token(:erb_content, " #{code} "), token(:erb_end, "%>"), + nil, false, true, + nil # steep:ignore + ) + end + def comment_node(text) Herb::AST::HTMLCommentNode.new( "HTMLCommentNode", diff --git a/lib/herb/html/util.rb b/lib/herb/html/util.rb index 2695ec340..ab4ae3ca9 100644 --- a/lib/herb/html/util.rb +++ b/lib/herb/html/util.rb @@ -6,11 +6,23 @@ module HTML module Util # TODO: extract to shared utility for all languages in .yml VOID_ELEMENTS = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"].freeze #: Array[String] + RCDATA_ELEMENTS = ["textarea", "title"].freeze #: Array[String] + RAW_TEXT_ELEMENTS = ["script", "style", "xmp", "iframe", "noembed", "noframes", "plaintext"].freeze #: Array[String] #: (String) -> bool def self.void_element?(tag_name) VOID_ELEMENTS.include?(tag_name.downcase) end + + #: (String) -> bool + def self.rcdata_element?(tag_name) + RCDATA_ELEMENTS.include?(tag_name.downcase) + end + + #: (String) -> bool + def self.raw_text_element?(tag_name) + RAW_TEXT_ELEMENTS.include?(tag_name.downcase) + end end end end diff --git a/lib/herb/warnings.rb b/lib/herb/warnings.rb index 565f4848e..6ba5611af 100644 --- a/lib/herb/warnings.rb +++ b/lib/herb/warnings.rb @@ -8,6 +8,7 @@ module Warnings #| location: serialized_location?, #| message: String #| } + class Warning attr_reader :type #: String attr_reader :location #: Location? @@ -44,5 +45,31 @@ def to_json(state = nil) to_hash.to_json(state) end end + + # TODO: move to `config.yml` so they are in line with the way errors are defined and generated. + class UnkeyedCollectionWarning < Warning + attr_reader :expression #: String? + + attr_reader :tag_name #: String? + + #: (Location, ?String?, ?tag_name: String?) -> void + def initialize(location, expression = nil, tag_name: nil) + @expression = expression + @tag_name = tag_name + + remediation = + if tag_name + "Add a `herb-key` or `id` attribute to `<#{tag_name}>`" + else + "Add a `<%# herb:key ... %>` directive to this collection, or wrap each row in a single element with a `herb-key` or `id` attribute," + end + + super( + "unkeyed_collection", + location, + "#{remediation} so rows can be matched across updates. Without a key, inserting or reordering the collection re-renders every following row and discards its focus, scroll, and input state." + ) + end + end end end diff --git a/sig/herb/engine/slot_markers.rbs b/sig/herb/engine/slot_markers.rbs index cb922834a..cf12f5a53 100644 --- a/sig/herb/engine/slot_markers.rbs +++ b/sig/herb/engine/slot_markers.rbs @@ -9,6 +9,18 @@ module Herb # : (Integer) -> String def slot_close: (Integer) -> String + # : (Integer, Integer) -> String + def branch: (Integer, Integer) -> String + + # : (Integer) -> String + def row_open_prefix: (Integer) -> String + + # : () -> String + def row_open_suffix: () -> String + + # : (Integer) -> String + def row_close: (Integer) -> String + # : (String, String) -> String def region_open: (String, String) -> String diff --git a/sig/herb/engine/slot_visitor.rbs b/sig/herb/engine/slot_visitor.rbs index 4e1ccd34a..9a5626bc5 100644 --- a/sig/herb/engine/slot_visitor.rbs +++ b/sig/herb/engine/slot_visitor.rbs @@ -3,6 +3,8 @@ module Herb class Engine class SlotVisitor < Herb::Visitor + ATTRIBUTE_TYPES: Array[Symbol] + ELEMENT_ANCHORED_TYPES: Array[Symbol] BRANCH_BODY_PROPERTIES: Array[Symbol] @@ -11,6 +13,8 @@ module Herb attr_reader slots: Array[Slot] + attr_reader warnings: Array[Herb::Warnings::Warning] + class Slot < Data attr_reader index(): Integer @@ -22,12 +26,18 @@ module Herb attr_reader location(): String? - def self.new: (Integer index, Symbol type, Array[Integer] node_path, String? expression, String? location) -> instance - | (index: Integer, type: Symbol, node_path: Array[Integer], expression: String?, location: String?) -> instance + attr_reader attribute(): String? + + attr_reader key_source(): Symbol? - def self.members: () -> [ :index, :type, :node_path, :expression, :location ] + attr_reader key_expression(): String? - def members: () -> [ :index, :type, :node_path, :expression, :location ] + def self.new: (Integer index, Symbol type, Array[Integer] node_path, String? expression, String? location, String? attribute, Symbol? key_source, String? key_expression) -> instance + | (index: Integer, type: Symbol, node_path: Array[Integer], expression: String?, location: String?, attribute: String?, key_source: Symbol?, key_expression: String?) -> instance + + def self.members: () -> [ :index, :type, :node_path, :expression, :location, :attribute, :key_source, :key_expression ] + + def members: () -> [ :index, :type, :node_path, :expression, :location, :attribute, :key_source, :key_expression ] end # : (?file_path: untyped, ?project_path: untyped, ?markers: SlotMarkers) -> void @@ -43,6 +53,8 @@ module Herb def visit_html_element_node: (untyped node) -> untyped + def visit_html_open_tag_node: (untyped node) -> untyped + def visit_html_attribute_node: (untyped node) -> untyped def visit_html_comment_node: (untyped node) -> untyped @@ -63,6 +75,12 @@ module Herb def visit_erb_iteration_block_node: (untyped node) -> untyped + def visit_erb_while_node: (untyped node) -> untyped + + def visit_erb_until_node: (untyped node) -> untyped + + def visit_erb_for_node: (untyped node) -> untyped + private # : (untyped, untyped) -> String @@ -79,9 +97,32 @@ module Herb def record_slot: (untyped, Symbol?) -> void # : (Symbol) -> Symbol? - def attribute_slot_type: (Symbol) -> Symbol? + def anchored_type_for: (Symbol) -> Symbol? + + # : (untyped) -> Symbol + def attribute_type_for: (untyped) -> Symbol + + # : (untyped) -> [Symbol, String?] + def key_for: (untyped) -> [ Symbol, String? ] + + # : (untyped) -> String? + def keyable_tag_name_for: (untyped) -> String? + + # : (Array[untyped]) -> String? + def key_directive_in: (Array[untyped]) -> String? + + # : (untyped) -> String? + def key_expression_for: (untyped) -> String? + + # : (untyped) -> Array[untyped] + def collection_body: (untyped) -> Array[untyped] + + # : (untyped) -> Array[untyped] + def attributes_for: (untyped) -> Array[untyped] + + # : (untyped) -> String? + def attribute_name_for: (untyped) -> String? - # Whether a subtree contains ERB, and so renders differently run to run. # : (untyped) -> bool def dynamic?: (untyped) -> bool @@ -89,12 +130,22 @@ module Herb def location_for: (untyped node) -> untyped - def raw_text_element?: (untyped node) -> untyped - def erb_output?: (untyped opening) -> untyped def insert_markers: (untyped node) -> untyped + # : (untyped, Integer?) -> void + def mark_branches: (untyped, Integer?) -> void + + # : (untyped) -> Array[Array[untyped]] + def branch_bodies: (untyped) -> Array[Array[untyped]] + + # : (untyped) -> untyped + def continuation_of: (untyped) -> untyped + + # : (untyped, Integer?) -> void + def wrap_rows: (untyped, Integer?) -> void + def anchor_attributes: (untyped node) -> untyped # : (untyped) { (Array[untyped]) -> void } -> void @@ -102,6 +153,10 @@ module Herb def wrap_region: (untyped document_node) -> untyped + def text_node: (untyped content) -> untyped + + def erb_output_node: (untyped code) -> untyped + def comment_node: (untyped text) -> untyped def attribute_node: (untyped name, untyped value) -> untyped diff --git a/sig/herb/html/util.rbs b/sig/herb/html/util.rbs index c38e440fc..4d99ab831 100644 --- a/sig/herb/html/util.rbs +++ b/sig/herb/html/util.rbs @@ -6,8 +6,18 @@ module Herb # TODO: extract to shared utility for all languages in .yml VOID_ELEMENTS: Array[String] + RCDATA_ELEMENTS: Array[String] + + RAW_TEXT_ELEMENTS: Array[String] + # : (String) -> bool def self.void_element?: (String) -> bool + + # : (String) -> bool + def self.rcdata_element?: (String) -> bool + + # : (String) -> bool + def self.raw_text_element?: (String) -> bool end end end diff --git a/sig/herb/warnings.rbs b/sig/herb/warnings.rbs index 0c85d3f76..46686bc9b 100644 --- a/sig/herb/warnings.rbs +++ b/sig/herb/warnings.rbs @@ -2,11 +2,6 @@ module Herb module Warnings - # : type serialized_warning = { - # | type: String, - # | location: serialized_location?, - # | message: String - # | } class Warning attr_reader type: String @@ -29,5 +24,15 @@ module Herb # : (?untyped) -> String def to_json: (?untyped) -> String end + + # TODO: move to `config.yml` so they are in line with the way errors are defined and generated. + class UnkeyedCollectionWarning < Warning + attr_reader expression: String? + + attr_reader tag_name: String? + + # : (Location, ?String?, ?tag_name: String?) -> void + def initialize: (Location, ?String?, ?tag_name: String?) -> void + end end end diff --git a/test/engine/slot_markers_test.rb b/test/engine/slot_markers_test.rb index a0b65b40e..f7d617151 100644 --- a/test/engine/slot_markers_test.rb +++ b/test/engine/slot_markers_test.rb @@ -232,6 +232,90 @@ class SlotMarkersTest < Minitest::Spec end end + test "keeps markers out of a tag for ERB in element position" do + assert_evaluated_snapshot(%(
>x
), { "@a" => %(id="y") }, OPTIONS) + end + + test "keeps markers out of RCDATA content" do + assert_evaluated_snapshot("", { "@a" => "A" }, OPTIONS) + end + + test "delimits a for loop" do + assert_evaluated_snapshot( + "<% for x in @l %><%= x %><% end %>", + { "@l" => [1] }, + OPTIONS + ) + end + + test "delimits each row of a keyed collection" do + assert_evaluated_snapshot( + %(<% @users.each do |user| %>
  • <%= user[:name] %>
  • <% end %>), + { "@users" => [{ id: 1, name: "Marco" }, { id: 2, name: "Alice" }] }, + OPTIONS + ) + end + + test "does not delimit rows of an unkeyed collection" do + assert_evaluated_snapshot( + "<% @users.each do |user| %>
  • <%= user[:name] %>
  • <% end %>", + { "@users" => [{ id: 1, name: "Marco" }] }, + OPTIONS + ) + end + + test "delimits rows of a body with several roots via the herb:key directive" do + assert_evaluated_snapshot( + %(<% @users.each do |user| %><%# herb:key user[:id] %>
    <%= user[:name] %>
    <%= user[:email] %>
    <% end %>), + { "@users" => [{ id: 1, name: "Marco", email: "marco@example.com" }] }, + OPTIONS + ) + end + + test "delimits rows of a body with no element at all" do + assert_evaluated_snapshot( + %(<% @users.each do |user| %><%# herb:key user[:id] %><%= user[:name] %><% end %>), + { "@users" => [{ id: 1, name: "Marco" }, { id: 2, name: "Alice" }] }, + OPTIONS + ) + end + + test "names the branch of an if/elsif/else that rendered" do + assert_evaluated_snapshot( + "<% if @a %>A<% elsif @b %>B<% else %>C<% end %>", + { "@a" => false, "@b" => true }, + OPTIONS + ) + end + + test "names the else branch that rendered" do + assert_evaluated_snapshot( + "<% if @a %>A<% elsif @b %>B<% else %>C<% end %>", + { "@a" => false, "@b" => false }, + OPTIONS + ) + end + + test "names the case branch that rendered" do + assert_evaluated_snapshot( + "<% case @x %><% when 1 %>ONE<% else %>OTHER<% end %>", + { "@x" => 9 }, + OPTIONS + ) + end + + test "names no branch when a conditional renders nothing" do + assert_evaluated_snapshot("<% if @a %>A<% end %>", { "@a" => false }, OPTIONS) + end + + test "keeps nested collection rows distinguishable" do + assert_evaluated_snapshot( + %(<% @rows.each do |row| %><% row[:cells].each do |cell| %><%= cell %><% end %><% end %>), + { "@rows" => [{ id: 1, cells: [11, 12] }] }, + OPTIONS + ) + end + test "renders the same content as an unslotted template" do template = %(

    <%= @title %>

    <% if @admin %>x<% end %>
    ) locals = { "@title" => "T", "@admin" => true } @@ -239,7 +323,7 @@ class SlotMarkersTest < Minitest::Spec slotted = evaluate_herb_source(Herb::Engine.new(template, **OPTIONS).src, locals) plain = evaluate_herb_source(Herb::Engine.new(template, validation_mode: :none).src, locals) - assert_equal plain, slotted.gsub(%r{}, "") + assert_equal plain, slotted.gsub(%r{}, "") end end end diff --git a/test/engine/slot_visitor_test.rb b/test/engine/slot_visitor_test.rb index 2888f52b7..b71f1014d 100644 --- a/test/engine/slot_visitor_test.rb +++ b/test/engine/slot_visitor_test.rb @@ -112,6 +112,172 @@ def slots_for(template, file_path: "app/views/test.html.erb") assert_equal [:comment], visitor.slots.map(&:type) end + test "detects an explicit herb-key on a collection" do + visitor = slots_for(%(<% @u.each do |u| %>
  • x
  • <% end %>)) + + assert_equal :herb_key, visitor.slots.find { |slot| slot.type == :collection }.key_source + end + + test "falls back to an id, which Rails templates already carry for Turbo" do + visitor = slots_for(%(<% @u.each do |u| %>
  • x
  • <% end %>)) + + assert_equal :id, visitor.slots.find { |slot| slot.type == :collection }.key_source + end + + test "prefers herb-key over an id" do + visitor = slots_for(%(<% @u.each do |u| %>
  • x
  • <% end %>)) + + assert_equal :herb_key, visitor.slots.find { |slot| slot.type == :collection }.key_source + end + + test "falls back to index when a collection row carries no key" do + visitor = slots_for("<% @u.each do |u| %>
  • x
  • <% end %>") + + assert_equal :index, visitor.slots.find { |slot| slot.type == :collection }.key_source + end + + test "warns about an unkeyed collection and names the row to key" do + visitor = slots_for("<% @u.each do |u| %>
  • x
  • <% end %>") + + assert_equal 1, visitor.warnings.size + assert_equal "unkeyed_collection", visitor.warnings[0].type + assert_equal "li", visitor.warnings[0].tag_name + assert_includes visitor.warnings[0].message, "Add a `herb-key` or `id` attribute to `
  • `" + end + + test "warns about an unkeyed collection with several roots by suggesting a wrapper" do + visitor = slots_for("<% @u.each do |u| %>
  • a
  • b
  • <% end %>") + + assert_equal 1, visitor.warnings.size + assert_nil visitor.warnings[0].tag_name + assert_includes visitor.warnings[0].message, "wrap each row in a single element" + end + + test "does not warn when a collection carries a key" do + assert_empty slots_for("<% @u.each do |u| %>
  • x
  • <% end %>").warnings + assert_empty slots_for("<% @u.each do |u| %><%# herb:key u.id %>
  • a
  • b
  • <% end %>").warnings + end + + test "detects a herb:key directive on a body with several roots" do + visitor = slots_for("<% @u.each do |u| %><%# herb:key u.id %>
  • a
  • b
  • <% end %>") + slot = visitor.slots.find { |candidate| candidate.type == :collection } + + assert_equal :directive, slot.key_source + assert_equal "u.id", slot.key_expression + end + + test "detects a herb:key directive on a body with no element at all" do + visitor = slots_for("<% @u.each do |u| %><%# herb:key u.id %><%= u.name %><% end %>") + + assert_equal :directive, visitor.slots.find { |slot| slot.type == :collection }.key_source + end + + test "prefers a herb:key directive over an attribute" do + visitor = slots_for(%(<% @u.each do |u| %><%# herb:key u.uuid %>
  • x
  • <% end %>)) + slot = visitor.slots.find { |candidate| candidate.type == :collection } + + assert_equal :directive, slot.key_source + assert_equal "u.uuid", slot.key_expression + end + + test "records the key expression from the attribute it was found on" do + visitor = slots_for(%(<% @u.each do |u| %>
  • x
  • <% end %>)) + + assert_equal "dom_id(u)", visitor.slots.find { |slot| slot.type == :collection }.key_expression + end + + test "falls back to index when a collection body has no single root" do + visitor = slots_for("<% @u.each do |u| %>
  • a
  • b
  • <% end %>") + + assert_equal :index, visitor.slots.find { |slot| slot.type == :collection }.key_source + end + + test "exposes the key source in the schema" do + schema = slots_for(%(<% @u.each do |u| %>
  • x
  • <% end %>)).schema + + assert_equal :herb_key, schema[:slots].find { |slot| slot[:type] == :collection }[:key_source] + end + + test "records no key source for slots that are not collections" do + visitor = slots_for("

    <%= @a %>

    ") + + assert_nil visitor.slots[0].key_source + end + + test "records the attribute a slot belongs to" do + visitor = slots_for(%(
    )) + + assert_equal ["class", "id"], visitor.slots.map(&:attribute) + end + + test "distinguishes a whole attribute value from an interpolated one" do + assert_equal :attribute, slots_for(%(
    )).slots[0].type + assert_equal :attribute_interpolation, slots_for(%(
    )).slots[0].type + end + + test "gives an attribute one slot however many bindings it holds" do + visitor = slots_for(%(
    )) + + assert_equal 1, visitor.slots.size + assert_equal "class", visitor.slots[0].attribute + end + + test "exposes the attribute name in the schema" do + schema = slots_for(%(
    )).schema + + assert_equal "class", schema[:slots][0][:attribute] + end + + test "does not assign a slot to a static attribute" do + visitor = slots_for(%(
    <%= @a %>
    )) + + assert_equal [:child], visitor.slots.map(&:type) + end + + test "anchors ERB in element position on the element" do + visitor = slots_for("
    >x
    ") + + assert_equal [:element], visitor.slots.map(&:type) + end + + test "anchors RCDATA content on the element" do + ["", "<%= @a %>"].each do |template| + visitor = slots_for(template) + + assert_equal [:raw_text], visitor.slots.map(&:type), "unexpected type for: #{template}" + end + end + + test "does not assign a slot inside raw text elements" do + ["", ""].each do |template| + visitor = slots_for(template) + + assert_empty visitor.slots, "expected no slot for: #{template}" + end + end + + test "classifies while, until and for as repeating regions" do + { + "<% while @i %>x<% end %>" => :collection, + "<% until @i %>x<% end %>" => :collection, + "<% for x in @l %>x<% end %>" => :collection, + }.each do |template, expected| + visitor = slots_for(template) + + assert_equal expected, visitor.slots.first&.type, "unexpected type for: #{template}" + end + end + + test "assigns no slots when the template has parser errors" do + ["

    <%= @a %>

    ", "

    <%= @a %>

    "].each do |template| + engine = Herb::Engine.new(template, slots: true, filename: "t.html.erb", validation_mode: :none) + + assert_empty engine.slot_visitor.slots, "expected no slots for: #{template}" + refute_includes engine.src, "herb-slot" + refute_includes engine.src, "herb-region" + end + end + test "does not assign a slot to non-output ERB" do visitor = slots_for("<% x = 1 %>

    static

    ") diff --git a/test/snapshots/engine/slot_markers_test/test_0004_delimits_slots_nested_inside_another_slot_405c7a041d4c2c73cf05f509c1f181d0.txt b/test/snapshots/engine/slot_markers_test/test_0004_delimits_slots_nested_inside_another_slot_405c7a041d4c2c73cf05f509c1f181d0.txt index 3b631e63e..377298906 100644 --- a/test/snapshots/engine/slot_markers_test/test_0004_delimits_slots_nested_inside_another_slot_405c7a041d4c2c73cf05f509c1f181d0.txt +++ b/test/snapshots/engine/slot_markers_test/test_0004_delimits_slots_nested_inside_another_slot_405c7a041d4c2c73cf05f509c1f181d0.txt @@ -2,4 +2,4 @@ source: "Engine::SlotMarkersTest#test_0004_delimits slots nested inside another slot" input: "{source: \"
    <% if @admin %><%= @secret %><% end %>
    \", locals: {\"@admin\" => true, \"@secret\" => \"s\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" --- -
    s
    \ No newline at end of file +
    s
    \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0010_compiles_a_conditional_into_the_generated_source_2912a83cbf6d3340c6c202391c0ec23d.txt b/test/snapshots/engine/slot_markers_test/test_0010_compiles_a_conditional_into_the_generated_source_2912a83cbf6d3340c6c202391c0ec23d.txt index 04d57299f..bb6c559f5 100644 --- a/test/snapshots/engine/slot_markers_test/test_0010_compiles_a_conditional_into_the_generated_source_2912a83cbf6d3340c6c202391c0ec23d.txt +++ b/test/snapshots/engine/slot_markers_test/test_0010_compiles_a_conditional_into_the_generated_source_2912a83cbf6d3340c6c202391c0ec23d.txt @@ -2,5 +2,5 @@ source: "Engine::SlotMarkersTest#test_0010_compiles a conditional into the generated source" input: "{source: \"
    <% if @admin %><%= @secret %><% end %>
    \", options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" --- -_buf = ::String.new; _buf << '
    '.freeze; if @admin; _buf << ''.freeze; _buf << (@secret).to_s; _buf << ''.freeze; end; _buf << '
    '.freeze; +_buf = ::String.new; _buf << '
    '.freeze; if @admin; _buf << ''.freeze; _buf << (@secret).to_s; _buf << ''.freeze; end; _buf << '
    '.freeze; _buf.to_s diff --git a/test/snapshots/engine/slot_markers_test/test_0013_treats_an_if_elsif_else_chain_as_one_conditional_slot_08d2f5a74b6e91516a355c5f6d9bf30c.txt b/test/snapshots/engine/slot_markers_test/test_0013_treats_an_if_elsif_else_chain_as_one_conditional_slot_08d2f5a74b6e91516a355c5f6d9bf30c.txt index e1464779c..452687716 100644 --- a/test/snapshots/engine/slot_markers_test/test_0013_treats_an_if_elsif_else_chain_as_one_conditional_slot_08d2f5a74b6e91516a355c5f6d9bf30c.txt +++ b/test/snapshots/engine/slot_markers_test/test_0013_treats_an_if_elsif_else_chain_as_one_conditional_slot_08d2f5a74b6e91516a355c5f6d9bf30c.txt @@ -2,4 +2,4 @@ source: "Engine::SlotMarkersTest#test_0013_treats an if/elsif/else chain as one conditional slot" input: "{source: \"
    <% if @a %>A<% elsif @b %><%= @n %><% else %>C<% end %>
    \", locals: {\"@a\" => false, \"@b\" => true, \"@n\" => \"N\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" --- -
    N
    \ No newline at end of file +
    N
    \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0014_delimits_a_slot_inside_an_unless_else_ca496223807300bdbe0f64e4db8ba219.txt b/test/snapshots/engine/slot_markers_test/test_0014_delimits_a_slot_inside_an_unless_else_ca496223807300bdbe0f64e4db8ba219.txt index 72ff36b38..7a7dfc1aa 100644 --- a/test/snapshots/engine/slot_markers_test/test_0014_delimits_a_slot_inside_an_unless_else_ca496223807300bdbe0f64e4db8ba219.txt +++ b/test/snapshots/engine/slot_markers_test/test_0014_delimits_a_slot_inside_an_unless_else_ca496223807300bdbe0f64e4db8ba219.txt @@ -2,4 +2,4 @@ source: "Engine::SlotMarkersTest#test_0014_delimits a slot inside an unless/else" input: "{source: \"
    <% unless @a %><%= @n %><% else %>E<% end %>
    \", locals: {\"@a\" => false, \"@n\" => \"N\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" --- -
    N
    \ No newline at end of file +
    N
    \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0015_delimits_a_slot_inside_a_when_branch_4e6668673cdf9f9cbb8afe5ce1fa4612.txt b/test/snapshots/engine/slot_markers_test/test_0015_delimits_a_slot_inside_a_when_branch_4e6668673cdf9f9cbb8afe5ce1fa4612.txt index 9bdbd3ad8..4be1fba4e 100644 --- a/test/snapshots/engine/slot_markers_test/test_0015_delimits_a_slot_inside_a_when_branch_4e6668673cdf9f9cbb8afe5ce1fa4612.txt +++ b/test/snapshots/engine/slot_markers_test/test_0015_delimits_a_slot_inside_a_when_branch_4e6668673cdf9f9cbb8afe5ce1fa4612.txt @@ -2,4 +2,4 @@ source: "Engine::SlotMarkersTest#test_0015_delimits a slot inside a when branch" input: "{source: \"
    <% case @x %><% when 1 %><%= @a %><% end %>
    \", locals: {\"@x\" => 1, \"@a\" => \"A\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" --- -
    A
    \ No newline at end of file +
    A
    \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0016_delimits_a_slot_inside_a_case_else_branch_e835d1221d16a00a50c5951cc31f07bb.txt b/test/snapshots/engine/slot_markers_test/test_0016_delimits_a_slot_inside_a_case_else_branch_e835d1221d16a00a50c5951cc31f07bb.txt index fba1b12db..3b5c0025b 100644 --- a/test/snapshots/engine/slot_markers_test/test_0016_delimits_a_slot_inside_a_case_else_branch_e835d1221d16a00a50c5951cc31f07bb.txt +++ b/test/snapshots/engine/slot_markers_test/test_0016_delimits_a_slot_inside_a_case_else_branch_e835d1221d16a00a50c5951cc31f07bb.txt @@ -2,4 +2,4 @@ source: "Engine::SlotMarkersTest#test_0016_delimits a slot inside a case else branch" input: "{source: \"
    <% case @x %><% when 9 %>N<% else %><%= @a %><% end %>
    \", locals: {\"@x\" => 1, \"@a\" => \"A\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" --- -
    A
    \ No newline at end of file +
    A
    \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0017_gives_a_conditional_nested_inside_an_else_its_own_slot_0ec982ce34ce84e243e9e1b150d20a6a.txt b/test/snapshots/engine/slot_markers_test/test_0017_gives_a_conditional_nested_inside_an_else_its_own_slot_0ec982ce34ce84e243e9e1b150d20a6a.txt index 7dd7ad4af..c41f7fa10 100644 --- a/test/snapshots/engine/slot_markers_test/test_0017_gives_a_conditional_nested_inside_an_else_its_own_slot_0ec982ce34ce84e243e9e1b150d20a6a.txt +++ b/test/snapshots/engine/slot_markers_test/test_0017_gives_a_conditional_nested_inside_an_else_its_own_slot_0ec982ce34ce84e243e9e1b150d20a6a.txt @@ -2,4 +2,4 @@ source: "Engine::SlotMarkersTest#test_0017_gives a conditional nested inside an else its own slot" input: "{source: \"
    <% if @a %>A<% else %><% if @b %><%= @n %><% end %><% end %>
    \", locals: {\"@a\" => false, \"@b\" => true, \"@n\" => \"N\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" --- -
    N
    \ No newline at end of file +
    N
    \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0018_gives_a_conditional_nested_inside_an_elsif_its_own_slot_b63463f8f06e79ddef3e9a75eaab30c7.txt b/test/snapshots/engine/slot_markers_test/test_0018_gives_a_conditional_nested_inside_an_elsif_its_own_slot_b63463f8f06e79ddef3e9a75eaab30c7.txt index eac52b1f2..462bfd44d 100644 --- a/test/snapshots/engine/slot_markers_test/test_0018_gives_a_conditional_nested_inside_an_elsif_its_own_slot_b63463f8f06e79ddef3e9a75eaab30c7.txt +++ b/test/snapshots/engine/slot_markers_test/test_0018_gives_a_conditional_nested_inside_an_elsif_its_own_slot_b63463f8f06e79ddef3e9a75eaab30c7.txt @@ -2,4 +2,4 @@ source: "Engine::SlotMarkersTest#test_0018_gives a conditional nested inside an elsif its own slot" input: "{source: \"
    <% if @a %>A<% elsif @b %><% if @c %><%= @n %><% end %><% end %>
    \", locals: {\"@a\" => false, \"@b\" => true, \"@c\" => true, \"@n\" => \"N\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" --- -
    N
    \ No newline at end of file +
    N
    \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0019_delimits_a_collection_nested_inside_an_else_fabeff4879dfa370e1afe79d37ed9db3.txt b/test/snapshots/engine/slot_markers_test/test_0019_delimits_a_collection_nested_inside_an_else_fabeff4879dfa370e1afe79d37ed9db3.txt index a4efbd920..22a6a0c3a 100644 --- a/test/snapshots/engine/slot_markers_test/test_0019_delimits_a_collection_nested_inside_an_else_fabeff4879dfa370e1afe79d37ed9db3.txt +++ b/test/snapshots/engine/slot_markers_test/test_0019_delimits_a_collection_nested_inside_an_else_fabeff4879dfa370e1afe79d37ed9db3.txt @@ -2,4 +2,4 @@ source: "Engine::SlotMarkersTest#test_0019_delimits a collection nested inside an else" input: "{source: \"
    <% if @a %>A<% else %><% @l.each do |i| %><%= i %><% end %><% end %>
    \", locals: {\"@a\" => false, \"@l\" => [1]}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" --- -
    1
    \ No newline at end of file +
    1
    \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0023_anchors_an_interpolated_attribute_value_4d09422b310d70d5601aa0de953b7988.txt b/test/snapshots/engine/slot_markers_test/test_0023_anchors_an_interpolated_attribute_value_4d09422b310d70d5601aa0de953b7988.txt index 27fab80f0..cc0d072e2 100644 --- a/test/snapshots/engine/slot_markers_test/test_0023_anchors_an_interpolated_attribute_value_4d09422b310d70d5601aa0de953b7988.txt +++ b/test/snapshots/engine/slot_markers_test/test_0023_anchors_an_interpolated_attribute_value_4d09422b310d70d5601aa0de953b7988.txt @@ -2,4 +2,4 @@ source: "Engine::SlotMarkersTest#test_0023_anchors an interpolated attribute value" input: "{source: \"
    b\\\">
    \", locals: {\"@c\" => \"C\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" --- -
    \ No newline at end of file +
    \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0030_keeps_head_content_free_of_injected_elements_3c46b72b77e401c56e545d7a52a99e21.txt b/test/snapshots/engine/slot_markers_test/test_0030_keeps_head_content_free_of_injected_elements_3c46b72b77e401c56e545d7a52a99e21.txt index 1c03d1779..5a6816831 100644 --- a/test/snapshots/engine/slot_markers_test/test_0030_keeps_head_content_free_of_injected_elements_3c46b72b77e401c56e545d7a52a99e21.txt +++ b/test/snapshots/engine/slot_markers_test/test_0030_keeps_head_content_free_of_injected_elements_3c46b72b77e401c56e545d7a52a99e21.txt @@ -2,4 +2,4 @@ source: "Engine::SlotMarkersTest#test_0030_keeps head content free of injected elements" input: "{source: \"<%= @t %>\", locals: {\"@t\" => \"T\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" --- -<!--herb-slot:0-->T<!--/herb-slot:0--> \ No newline at end of file +T \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0037_keeps_markers_out_of_a_tag_for_ERB_in_element_position_e4a20e17a14974b4d40acb64931e9171.txt b/test/snapshots/engine/slot_markers_test/test_0037_keeps_markers_out_of_a_tag_for_ERB_in_element_position_e4a20e17a14974b4d40acb64931e9171.txt new file mode 100644 index 000000000..1eb3a3b57 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0037_keeps_markers_out_of_a_tag_for_ERB_in_element_position_e4a20e17a14974b4d40acb64931e9171.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0037_keeps markers out of a tag for ERB in element position" +input: "{source: \"
    >x
    \", locals: {\"@a\" => \"id=\\\"y\\\"\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
    x
    \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0038_keeps_markers_out_of_RCDATA_content_e8b430c7b27d0cb5018b4a9ba9576862.txt b/test/snapshots/engine/slot_markers_test/test_0038_keeps_markers_out_of_RCDATA_content_e8b430c7b27d0cb5018b4a9ba9576862.txt new file mode 100644 index 000000000..193ad2a49 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0038_keeps_markers_out_of_RCDATA_content_e8b430c7b27d0cb5018b4a9ba9576862.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0038_keeps markers out of RCDATA content" +input: "{source: \"\", locals: {\"@a\" => \"A\"}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- + \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0039_delimits_a_for_loop_8af8826f58c0325f26e303e7d4fe7648.txt b/test/snapshots/engine/slot_markers_test/test_0039_delimits_a_for_loop_8af8826f58c0325f26e303e7d4fe7648.txt new file mode 100644 index 000000000..a9b9da105 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0039_delimits_a_for_loop_8af8826f58c0325f26e303e7d4fe7648.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0039_delimits a for loop" +input: "{source: \"<% for x in @l %><%= x %><% end %>\", locals: {\"@l\" => [1]}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +1 \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0040_delimits_each_row_of_a_keyed_collection_a0f38287dd7e0e9b7e60eca8f2598709.txt b/test/snapshots/engine/slot_markers_test/test_0040_delimits_each_row_of_a_keyed_collection_a0f38287dd7e0e9b7e60eca8f2598709.txt new file mode 100644 index 000000000..e3d58a2cb --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0040_delimits_each_row_of_a_keyed_collection_a0f38287dd7e0e9b7e60eca8f2598709.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0040_delimits each row of a keyed collection" +input: "{source: \"<% @users.each do |user| %>
  • \\\"><%= user[:name] %>
  • <% end %>\", locals: {\"@users\" => [{id: 1, name: \"Marco\"}, {id: 2, name: \"Alice\"}]}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
  • Marco
  • Alice
  • \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0041_does_not_delimit_rows_of_an_unkeyed_collection_ae29318fa98158d81939c985d126e325.txt b/test/snapshots/engine/slot_markers_test/test_0041_does_not_delimit_rows_of_an_unkeyed_collection_ae29318fa98158d81939c985d126e325.txt new file mode 100644 index 000000000..2d63a247a --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0041_does_not_delimit_rows_of_an_unkeyed_collection_ae29318fa98158d81939c985d126e325.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0041_does not delimit rows of an unkeyed collection" +input: "{source: \"<% @users.each do |user| %>
  • <%= user[:name] %>
  • <% end %>\", locals: {\"@users\" => [{id: 1, name: \"Marco\"}]}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
  • Marco
  • \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0042_delimits_rows_of_a_body_with_several_roots_via_the_herbkey_directive_efa7eb8257db0e6d19a076d29d62946c.txt b/test/snapshots/engine/slot_markers_test/test_0042_delimits_rows_of_a_body_with_several_roots_via_the_herbkey_directive_efa7eb8257db0e6d19a076d29d62946c.txt new file mode 100644 index 000000000..7f046cb3e --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0042_delimits_rows_of_a_body_with_several_roots_via_the_herbkey_directive_efa7eb8257db0e6d19a076d29d62946c.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0042_delimits rows of a body with several roots via the herb:key directive" +input: "{source: \"<% @users.each do |user| %><%# herb:key user[:id] %>
    <%= user[:name] %>
    <%= user[:email] %>
    <% end %>\", locals: {\"@users\" => [{id: 1, name: \"Marco\", email: \"marco@example.com\"}]}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +
    Marco
    marco@example.com
    \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0043_delimits_rows_of_a_body_with_no_element_at_all_47d5c5805564d4136fcfa6e053bc8b61.txt b/test/snapshots/engine/slot_markers_test/test_0043_delimits_rows_of_a_body_with_no_element_at_all_47d5c5805564d4136fcfa6e053bc8b61.txt new file mode 100644 index 000000000..024b60ad8 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0043_delimits_rows_of_a_body_with_no_element_at_all_47d5c5805564d4136fcfa6e053bc8b61.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0043_delimits rows of a body with no element at all" +input: "{source: \"<% @users.each do |user| %><%# herb:key user[:id] %><%= user[:name] %><% end %>\", locals: {\"@users\" => [{id: 1, name: \"Marco\"}, {id: 2, name: \"Alice\"}]}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +MarcoAlice \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0044_names_the_branch_of_an_if_elsif_else_that_rendered_a0f0ee44e7033e51204f611f6996a7f2.txt b/test/snapshots/engine/slot_markers_test/test_0044_names_the_branch_of_an_if_elsif_else_that_rendered_a0f0ee44e7033e51204f611f6996a7f2.txt new file mode 100644 index 000000000..c18c0a1e8 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0044_names_the_branch_of_an_if_elsif_else_that_rendered_a0f0ee44e7033e51204f611f6996a7f2.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0044_names the branch of an if/elsif/else that rendered" +input: "{source: \"<% if @a %>A<% elsif @b %>B<% else %>C<% end %>\", locals: {\"@a\" => false, \"@b\" => true}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +B \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0045_names_the_else_branch_that_rendered_bae31f016e324936cd0b8f0f20b8b1c6.txt b/test/snapshots/engine/slot_markers_test/test_0045_names_the_else_branch_that_rendered_bae31f016e324936cd0b8f0f20b8b1c6.txt new file mode 100644 index 000000000..14c1a78d2 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0045_names_the_else_branch_that_rendered_bae31f016e324936cd0b8f0f20b8b1c6.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0045_names the else branch that rendered" +input: "{source: \"<% if @a %>A<% elsif @b %>B<% else %>C<% end %>\", locals: {\"@a\" => false, \"@b\" => false}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +C \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0046_names_the_case_branch_that_rendered_8f1760da5edb1f85a2e0f3988fcdf1cf.txt b/test/snapshots/engine/slot_markers_test/test_0046_names_the_case_branch_that_rendered_8f1760da5edb1f85a2e0f3988fcdf1cf.txt new file mode 100644 index 000000000..db11d22fa --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0046_names_the_case_branch_that_rendered_8f1760da5edb1f85a2e0f3988fcdf1cf.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0046_names the case branch that rendered" +input: "{source: \"<% case @x %><% when 1 %>ONE<% else %>OTHER<% end %>\", locals: {\"@x\" => 9}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +OTHER \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0047_names_no_branch_when_a_conditional_renders_nothing_3e36c3afaa8f0a019e06e701355664ac.txt b/test/snapshots/engine/slot_markers_test/test_0047_names_no_branch_when_a_conditional_renders_nothing_3e36c3afaa8f0a019e06e701355664ac.txt new file mode 100644 index 000000000..656f878cf --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0047_names_no_branch_when_a_conditional_renders_nothing_3e36c3afaa8f0a019e06e701355664ac.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0047_names no branch when a conditional renders nothing" +input: "{source: \"<% if @a %>A<% end %>\", locals: {\"@a\" => false}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- + \ No newline at end of file diff --git a/test/snapshots/engine/slot_markers_test/test_0048_keeps_nested_collection_rows_distinguishable_277e7fc04fc4fecb4ea1c2d558624ada.txt b/test/snapshots/engine/slot_markers_test/test_0048_keeps_nested_collection_rows_distinguishable_277e7fc04fc4fecb4ea1c2d558624ada.txt new file mode 100644 index 000000000..5e241c518 --- /dev/null +++ b/test/snapshots/engine/slot_markers_test/test_0048_keeps_nested_collection_rows_distinguishable_277e7fc04fc4fecb4ea1c2d558624ada.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::SlotMarkersTest#test_0048_keeps nested collection rows distinguishable" +input: "{source: \"<% @rows.each do |row| %>\\\"><% row[:cells].each do |cell| %>\\\"><%= cell %><% end %><% end %>\", locals: {\"@rows\" => [{id: 1, cells: [11, 12]}]}, options: {slots: true, filename: \"app/views/test.html.erb\", validation_mode: :none}}" +--- +1112 \ No newline at end of file