Skip to content

Engine: Implement ComponentVisitor - #2032

Merged
marcoroth merged 10 commits into
mainfrom
component-visitor
Aug 5, 2026
Merged

marcoroth merged 10 commits into
mainfrom
component-visitor

Conversation

@marcoroth

@marcoroth marcoroth commented Aug 5, 2026 •

Copy link
Copy Markdown
Owner

This pull request implements an experimental Herb::Engine::ComponentVisitor, an opt-in transform visitor that rewrites capitalized HTML tags into render calls before compilation, so that a component or a partial can be written as a tag instead of an ERB expression.

Warning

ComponentVisitor is experimental and a proof of concept. The generated render calls, the attribute mapping, and the class itself may change or be removed without a major version bump. It warns the first time it is instantiated in a process, and how a tag is resolved is expected to move to an explicit registry later.

require "herb/engine/component_visitor"

Herb::Engine.new(source, visitors: [Herb::Engine::ComponentVisitor.new])

Like the other transform visitors, it isn't loaded when you require "herb".

Resolution

How a tag resolves is decided entirely from the tag name. There is no constant lookup while compiling and no lookup at render time, so the same template always compiles to the same output:

Tag Separator Resolves to
<Card /> none render Card.new
<Users::Card /> ::, a constant render Users::Card.new
<Users.Card /> ., a path render "users/card"
<Admin.Users.ProfileCard /> ., a path render "admin/users/profile_card"

A tag is only transformed when every segment of its name is CamelCase, so <DIV>, <BR>, <A> and <My-Component /> are left as HTML. Uppercase tags are valid HTML, and web components like <my-stimulus-element /> have to keep working.

Dot notation requires the dot_notation_tags parser option for the tag name to parse in the first place.

Attributes

Attribute names are converted from kebab-case to snake_case and become keyword arguments for a component, or locals for a partial:

Attribute Becomes Notes
name="hello" name: "hello" Quotes, backslashes and #{} are escaped with String#dump
:count="@count" count: @count A : prefix is used as Ruby code
name="<%= @user.name %>" name: "#{@user.name}" ERB is interpolated into the string
disabled disabled: true An attribute without a value
item-id="7" item_id: "7"

An attribute whose name isn't a valid keyword argument, such as @click, is skipped, and the first of a repeated attribute wins, the same way HTML resolves duplicates.

Body

A tag with a body becomes a block, and the body is kept as AST nodes rather than being reconstructed as a string, so the compiler emits it the way it emits any other template content:

<Card title="Hello">
  <div>Regular HTML</div>
  <%= @thing %>
  <Button>Nested component</Button>
</Card>
<%= render Card.new(title: "Hello") do %>
  <div>Regular HTML</div>
  <%= @thing %>
  <%= render Button.new do %>Nested component<% end %>
<% end %>

A partial with a body is rendered as a layout, so the body reaches the partial through yield, since render "path" do ... end would drop the block:

<Users.Card title="Hello">Body</Users.Card>
<%= render layout: "users/card", locals: { title: "Hello" } do %>Body<% end %>

Resolvers

The visitor itself only walks the AST and looks up a resolver. What a tag renders to lives in Resolver subclasses, and the list is injectable:

Herb::Engine::ComponentVisitor.new(
  resolvers: [
    Herb::Engine::ComponentVisitor::ComponentResolver.new,
    Herb::Engine::ComponentVisitor::PartialResolver.new
  ]
)

A resolver claims a tag name through handles? and returns Ruby source from render_code, so it isn't limited to render:

class IconResolver < Herb::Engine::ComponentVisitor::Resolver
  def handles?(tag_name)
    tag_name.start_with?("Icon")
  end

  def render_code(tag_name, attributes, block: false)
    %(icon_tag("#{tag_name.delete_prefix("Icon").downcase}"))
  end
end

A tag that no resolver claims is left as HTML.

Adapts the proof-of-concept to the changes that landed on main since it was
written:

- `ERBContentNode` gained a `prism_node` field, so the synthesized node
  needs the additional argument
- the engine no longer emits a newline after the preamble, so the expected
  compiled sources and the snapshots were regenerated
- nilable node fields and `Steep` need the attribute lookups to be guarded
  instead of chained

`ComponentVisitor` also gets a stable `#inspect`, because the snapshot key
includes the engine options and the default `#inspect` embeds the object
address, which made the snapshot file name change on every run.
Warns once per process on the first instantiation, matching how the
experimental `optimize` engine option announces itself, and documents the
visitor with a warning callout.
@github-actions github-actions Bot added documentation Improvements or additions to documentation ruby Ruby source for the gem and its libraries rbs RBS type signatures in sig/ engine Herb engine and Rails template compilation rubygem The herb RubyGem and its packaging labels Aug 5, 2026
Comment thread lib/herb/engine/component_visitor/partial_resolver.rb Fixed
@marcoroth marcoroth changed the title Engine: Implement ComponentVisitor Engine: Implement ComponentVisitor Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026 •

Copy link
Copy Markdown

🌿 Interactive Playground and Documentation Preview

A preview deployment has been built for this pull request. Try out the changes live in the interactive playground:


🌱 Grown from commit 02f8278


✅ Preview deployment has been cleaned up.

@pkg-pr-new

pkg-pr-new Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
npx https://pkg.pr.new/@herb-tools/formatter@2032
npx https://pkg.pr.new/@herb-tools/language-server@2032
npx https://pkg.pr.new/@herb-tools/linter@2032

commit: 02f8278

@marcoroth
marcoroth marked this pull request as ready for review August 5, 2026 23:21
@marcoroth
marcoroth merged commit 66e4d1a into main Aug 5, 2026
34 checks passed
@marcoroth
marcoroth deleted the component-visitor branch August 5, 2026 23:37

This branch was successfully deployed

1 active deployment
herb-tools (Preview) — 02f82783 Deployed Aug 5, 2026 by github-actions[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation engine Herb engine and Rails template compilation rbs RBS type signatures in sig/ ruby Ruby source for the gem and its libraries rubygem The herb RubyGem and its packaging

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants