Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Empowering Developers with HTML-Aware ERB Tooli...

Empowering Developers with HTML-Aware ERB Tooling @ RubyKaigi 2025, Matsuyama, Ehime

ERB tooling has lagged behind modern web development needs, especially with the rise of Hotwire and HTML-over-the-wire. Discover a new HTML-aware ERB parser that unlocks advanced developer tools like formatters, linters, and LSP integrations, enhancing how we build and ship HTML in our Ruby applications.

Marco Roth

April 16, 2025
Tweet

More Decks by Marco Roth

Other Decks in Programming

Transcript

  1. Marco Roth 👋 t @marcoroth_ M @[email protected] g marcoroth.dev g

    @marcoroth Full-Stack Developer & Open Source Contributor b @marcoroth.dev
  2. a

  3. <div> <span><%= @user.firstname %></span> <b><%= @user.lastname %></b> </div> <div> <span><%=

    @user.firstname %></span> <b><%= @user.lastname %></b> </div>
  4. Ruby HTML <div> <span><%= @user.firstname %></span> <b><%= @user.lastname %></b> </div>

    <div> <span><%= @user.firstname %></span> <b><%= @user.lastname %></b> </div>
  5. Ruby HTML <div> <span><%= @user.firstname %></span> <b><%= @user.lastname %></b> </div>

    <div> <span><%= @user.firstname %></span> <b><%= @user.lastname %></b> </div>
  6. Ruby HTML @user.firstname @user.lastname <div> <span> </span> <b> </b> </div>

    t require "prism" Prism.parse("...") require "nokogiri" Nokogiri::HTML5.fragment("...")
  7. Text @ DocumentNode (location: (1:0)-(1:4)) ├── errors: [] └── children:

    (1 item) └── @ HTMLTextNode (location: (1:0)-(1:4)) ├── errors: [] └── content: "Text"
  8. <h1 > Title @ HTMLElementNode ├── errors: (1 item) │

    └── @ MissingClosingTagError │ ├── message: " │ │ Opening tag `<h1>` at (1:1) │ │ doesn't have a matching │ │ closing tag `</h1>`. │ │ " │ └── opening_tag: "h1" │ ├── open_tag: │ └── @ HTMLOpenTagNode │ ├── tag_opening: "<" │ ├── tag_name: "h1" │ ├── tag_closing: ">" │ ├── children: [] │ └── is_void: false │ ├── tag_name: "h1" ├── body: (1 item) │ └── @ HTMLTextNode │ └── content: "Title" │ ├── close_tag: ∅ └── is_void: false ^^^^^
  9. <h1 > Title @ HTMLElementNode │── errors: (1 item) │

    └── @ MissingClosingTagError │ ├── message: " │ │ Opening tag `<h1>` at (1:1) │ │ doesn't have a matching │ │ closing tag `</h1>`. │ │ " │ └── opening_tag: "h1" │ ├── open_tag: │ └── @ HTMLOpenTagNode │ ├── tag_opening: "<" │ ├── tag_name: "h1" │ ├── tag_closing: ">" │ ├── children: [] │ └── is_void: false │ │── tag_name: "h1" │── body: (1 item) │ └── @ HTMLTextNode │ └── content: "Title" │ └── close_tag: ∅ └── is_void: false ^^^^^
  10. <h1 > Title</h1> @ HTMLElementNode ├── errors: [] ├── open_tag:

    │ └── @ HTMLOpenTagNode │ ├── errors: [] │ ├── tag_opening: "<" │ ├── tag_name: "h1" │ ├── tag_closing: ">" │ ├── children: [] │ └── is_void: false │ ├── tag_name: "h1" ├── body: (1 item) │ └── @ HTMLTextNode │ ├── errors: [] │ └── content: "Title" │ ├── close_tag: │ └── @ HTMLCloseTagNode │ ├── errors: [] │ ├── tag_opening: "</" │ ├── tag_name: "h1" │ └── tag_closing: ">" │ └── is_void: false
  11. <h1 > Title</h1> @ HTMLElementNode │── errors: [] ├── open_tag:

    │ └── @ HTMLOpenTagNode │ ├── errors: [] │ ├── tag_opening: "<" │ ├── tag_name: "h1" │ ├── tag_closing: ">" │ ├── children: [] │ └── is_void: false │ ├── tag_name: "h1" ├── body: (1 item) │ └── @ HTMLTextNode │ │── errors: [] │ └── content: "Title" │ └── close_tag: │ └── @ HTMLCloseTagNode │ ├── errors: [] │ ├── tag_opening: "</" │ ├── tag_name: "h1" │ └── tag_closing: ">" │ └── is_void: false
  12. <h1><i>Title</i></h1> @ HTMLElementNode ├── open_tag: │ └── @ HTMLOpenTagNode │

    └── tag_name: "h1" │ ├── tag_name: "h1" ├── body: │ └── @ HTMLElementNode │ ├── open_tag: │ │ └── @ HTMLOpenTagNode │ │ └── tag_name: "i" │ │ │ ├── tag_name: "i" │ ├── body: │ │ └── @ HTMLTextNode │ │ └── content: "Title" │ │ │ └── close_tag: │ └── @ HTMLCloseTagNode │ └── tag_name: "i" │ └── close_tag: └── @ HTMLCloseTagNode └── tag_name: "h1"
  13. <h1><i>Title</i></h1> @ HTMLElementNode │── open_tag: │ └── @ HTMLOpenTagNode │

    └── tag_name: "h1" │ ├── tag_name: "h1" └── body: │ └── @ HTMLElementNode │ │── open_tag: │ │ └── @ HTMLOpenTagNode │ │ └── tag_name: "i" │ │ │ ├── tag_name: "i" │ └── body: │ │ └── @ HTMLTextNode │ │ └── content: "Title" │ │ │ └── close_tag: │ └── @ HTMLCloseTagNode │ └── tag_name: "i" │ └── close_tag: └── @ HTMLCloseTagNode └── tag_name: "h1"
  14. <%= @variable %> @ DocumentNode ├── errors: [] └── children:

    (1 item) └── @ ERBContentNode ├── errors: [] ├── tag_opening: "<%=" ├── content: " @variable " ├── tag_closing: "%>" ├── parsed: true └── valid: true
  15. <% if valid? %> <% end %> children: (3 items)

    ├── @ ERBContentNode │ ├── errors: [] │ ├── tag_opening: "<%" │ ├── content: " if valid? " │ ├── tag_closing: "%>" │ ├── parsed: false │ └── valid: false │ │ └── @ ERBContentNode ├── errors: [] ├── tag_opening: "<%" ├── content: " end " ├── tag_closing: "%>" ├── parsed: false └── valid: false ├── @ HTMLTextNode │ ├── errors: [] │ └── content: "\n\n"
  16. <% if valid? %> <% end %> ├── @ ERBContentNode

    │ ├── errors: [] │ ├── tag_opening: "<%" │ ├── content: " if valid? " │ ├── tag_closing: "%>" │ ├── parsed: false │ └── valid: false │ │ └── @ ERBContentNode ├── errors: [] ├── tag_opening: "<%" ├── content: " end " ├── tag_closing: "%>" ├── parsed: false └── valid: false ├── @ HTMLTextNode │ ├── errors: [] │ └── content: "\n\n" children: (3 items)
  17. <% if valid? %> <% end %> children: (1 item)

    └── @ ERBIfNode ├── errors: [] ├── tag_opening: "<%" ├── content: " if valid? " ├── tag_closing: "%>" ├── statements: (1 item) │ └── @ HTMLTextNode │ ├── errors: [] │ └── content: "\n\n" │ ├── subsequent: ∅ └── end_node: └── @ ERBEndNode ├── errors: [] ├── tag_opening: "<%" ├── content: " end " └── tag_closing: "%>"
  18. <% if valid? %> <h1 > Title</h1> <% end %>

    @ ERBIfNode ├── tag_opening: "<%" ├── content: " if valid? " ├── tag_closing: "%>" ├── statements: │ └── @ HTMLElementNode │ ├── open_tag: │ │ └── @ HTMLOpenTagNode │ │ ├── tag_opening: "<" │ │ ├── tag_name: "h1" │ │ ├── tag_closing: ">" │ │ ├── children: [] │ │ └── is_void: false │ │ │ ├── tag_name: "h1" │ ├── body: │ │ └── @ HTMLTextNode │ │ └── content: "Title" │ │ │ └── close_tag: │ └── @ HTMLCloseTagNode │ ├── tag_opening: "</" │ ├── tag_name: "h1" │ └── tag_closing: ">" │ ├── subsequent: ∅ └── end_node: └── @ ERBEndNode ├── tag_opening: "<%" ├── content: " end " └── tag_closing: "%>"
  19. <% if valid? %> <h1 > Title</h1> <% end %>

    @ ERBIfNode ├── tag_opening: "<%" ├── content: " if valid? " ├── tag_closing: "%>" ├── statements: │ └── @ HTMLElementNode │ ├── open_tag: │ │ └── @ HTMLOpenTagNode │ │ ├── tag_opening: "<" │ │ ├── tag_name: "h1" │ │ ├── tag_closing: ">" │ │ ├── children: [] │ │ └── is_void: false │ │ │ ├── tag_name: "h1" │ ├── body: │ │ └── @ HTMLTextNode │ │ └── content: "Title" │ │ │ └── close_tag: │ └── @ HTMLCloseTagNode │ ├── tag_opening: "</" │ ├── tag_name: "h1" │ └── tag_closing: ">" │ ├── subsequent: ∅ └── end_node: └── @ ERBEndNode ├── tag_opening: "<%" ├── content: " end " └── tag_closing: "%>"
  20. <% if valid? %> <h1 > Title</h1> <% end %>

    @ ERBIfNode ├── tag_opening: "<%" ├── content: " if valid? " ├── tag_closing: "%>" ├── statements: │ └── @ HTMLElementNode │ ├── open_tag: │ │ └── @ HTMLOpenTagNode │ │ ├── tag_opening: "<" │ │ ├── tag_name: "h1" │ │ ├── tag_closing: ">" │ │ ├── children: [] │ │ └── is_void: false │ │ │ ├── tag_name: "h1" │ ├── body: │ │ └── @ HTMLTextNode │ │ └── content: "Title" │ │ │ └── close_tag: │ └── @ HTMLCloseTagNode │ ├── tag_opening: "</" │ ├── tag_name: "h1" │ └── tag_closing: ">" │ ├── subsequent: ∅ └── end_node: └── @ ERBEndNode ├── tag_opening: "<%" ├── content: " end " └── tag_closing: "%>"
  21. <% if valid? %> <h1 > Title</h1> <% end %>

    @ ERBIfNode ├── tag_opening: "<%" ├── content: " if valid? " ├── tag_closing: "%>" ├── statements: │ └── @ HTMLElementNode │ ├── open_tag: │ │ └── @ HTMLOpenTagNode │ │ ├── tag_opening: "<" │ │ ├── tag_name: "h1" │ │ ├── tag_closing: ">" │ │ ├── children: [] │ │ └── is_void: false │ │ │ ├── tag_name: "h1" │ ├── body: │ │ └── @ HTMLTextNode │ │ └── content: "Title" │ │ │ └── close_tag: │ └── @ HTMLCloseTagNode │ ├── tag_opening: "</" │ ├── tag_name: "h1" │ └── tag_closing: ">" │ ├── subsequent: ∅ └── end_node: └── @ ERBEndNode ├── tag_opening: "<%" ├── content: " end " └── tag_closing: "%>"
  22. <% if valid? %> <h1 > Title</h1> <% end %>

    @ ERBIfNode ├── tag_opening: "<%" ├── content: " if valid? " ├── tag_closing: "%>" ├── statements: │ └── @ HTMLElementNode │ ├── open_tag: │ │ └── @ HTMLOpenTagNode │ │ ├── tag_opening: "<" │ │ ├── tag_name: "h1" │ │ ├── tag_closing: ">" │ │ ├── children: [] │ │ └── is_void: false │ │ │ ├── tag_name: "h1" │ ├── body: │ │ └── @ HTMLTextNode │ │ └── content: "Title" │ │ │ └── close_tag: │ └── @ HTMLCloseTagNode │ ├── tag_opening: "</" │ ├── tag_name: "h1" │ └── tag_closing: ">" │ ├── subsequent: ∅ └── end_node: └── @ ERBEndNode ├── tag_opening: "<%" ├── content: " end " └── tag_closing: "%>"
  23. <% if valid? %> <h1 > Title</h1> <% end %>

    @ ERBIfNode ├── tag_opening: "<%" ├── content: " if valid? " ├── tag_closing: "%>" ├── statements: │ └── @ HTMLElementNode │ ├── open_tag: │ │ └── @ HTMLOpenTagNode │ │ ├── tag_opening: "<" │ │ ├── tag_name: "h1" │ │ ├── tag_closing: ">" │ │ ├── children: [] │ │ └── is_void: false │ │ │ ├── tag_name: "h1" │ ├── body: │ │ └── @ HTMLTextNode │ │ └── content: "Title" │ │ │ └── close_tag: │ └── @ HTMLCloseTagNode │ ├── tag_opening: "</" │ ├── tag_name: "h1" │ └── tag_closing: ">" │ ├── subsequent: ∅ └── end_node: └── @ ERBEndNode ├── tag_opening: "<%" ├── content: " end " └── tag_closing: "%>"
  24. I invite you to give it a shot to see

    what you can build with it.
  25. <div> <h1><%= @title %></h1> <% if user_signed_in? %> <p>Welcome, <%=

    current_user.name %>!</p> <% else %> <p>Please <%= link_to "sign in", login_path %></p> <% end %> </div>
  26. Check for valid HTML5 Missing alt attributes on <img> No

    unsafe interpolation Only one element with the same ID invalid syntax ...
  27. <ul> <% @items do |item| %> <li><%= item %></li> <%

    end %> </ul> @items = [1, 2, 3] <ul> <li>1</li> <li>2</li> <li>3</li> </ul> State View Template Rendered View Log Initial view rendered
  28. <ul> <% @items do |item| %> <li><%= item %></li> <%

    end %> </ul> @items = [1, 2, 3, 4] <ul> <li>1</li> <li>2</li> <li>3</li> </ul> State View Template Rendered View Log Initial view rendered @items state changed
  29. <ul> <% @items do |item| %> <li><%= item %></li> <%

    end %> </ul> @items = [1, 2, 3, 4] <ul> <li>1</li> <li>2</li> <li>3</li> </ul> State View Template Rendered View Log Initial view rendered @items state changed re-rendering view
  30. <ul> <% @items do |item| %> <li><%= item %></li> <%

    end %> </ul> @items = [1, 2, 3, 4] <ul> <li>1</li> <li>2</li> <li>3</li> </ul> State View Template Rendered View Log Initial view rendered @items state changed re-rendering view tracing dependencies
  31. <ul> <% @items do |item| %> <li><%= item %></li> <%

    end %> </ul> @items = [1, 2, 3, 4] <ul> <li>1</li> <li>2</li> <li>3</li> </ul> State View Template Rendered View Log Initial view rendered @items state changed re-rendering view tracing dependencies render delta for item `4`
  32. <ul> <% @items do |item| %> <li><%= item %></li> <%

    end %> </ul> @items = [1, 2, 3, 4] <ul> <li>1</li> <li>2</li> <li>3</li> </ul> State View Template Rendered View Log Initial view rendered @items state changed re-rendering view tracing dependencies render delta for item `4` <li>4</li>
  33. <ul> <% @items do |item| %> <li><%= item %></li> <%

    end %> </ul> @items = [1, 2, 3, 4] <ul> <li>1</li> <li>2</li> <li>3</li> </ul> State View Template Rendered View Log Initial view rendered @items state changed re-rendering view tracing dependencies render delta for item `4` apply delta to view <li>4</li>
  34. <ul> <% @items do |item| %> <li><%= item %></li> <%

    end %> </ul> @items = [1, 2, 3, 4] <ul> <li>1</li> <li>2</li> <li>3</li> <li>4</li> </ul> State View Template Rendered View Log Initial view rendered @items state changed re-rendering view tracing dependencies render delta for item `4` apply delta to view
  35. tHTML+ERB <p id="name"> Hello <%= name %> </p> @ HTMLElementNode

    ├── tag_name: "p" ├── attributes: (1 item) │ └── @ HTMLAttributeNode │ ├── name: │ │ └── @ HTMLAttributeNameNode │ │ └── name: "id" │ │ │ ├── equals: "=" │ └── value: │ └── @ HTMLAttributeValueNode │ └── children: │ └── @ LiteralNode │ └── content: "name" ├── body: (3 items) │ └── @ DynamicContentNode │ ├── tag_opening: "<%=" │ ├── content: " name " │ └── tag_closing: "%>" │ └── close_tag: └── @ HTMLCloseTagNode ├── tag_opening: "</" ├── tag_name: "p" └── tag_closing: ">" HTML+ERB
  36. <%= tag.p(id: "name") do %> Hello <%= name %> <%

    end %> @ HTMLElementNode ├── tag_name: "p" ├── attributes: (1 item) │ └── @ HTMLAttributeNode │ ├── name: │ │ └── @ HTMLAttributeNameNode │ │ └── name: "id" │ │ │ ├── equals: "=" │ └── value: │ └── @ HTMLAttributeValueNode │ └── children: │ └── @ LiteralNode │ └── content: "name" ├── body: (3 items) │ └── @ DynamicContentNode │ ├── tag_opening: "<%=" │ ├── content: " name " │ └── tag_closing: "%>" │ └── close_tag: └── @ HTMLCloseTagNode ├── tag_opening: "</" ├── tag_name: "p" └── tag_closing: ">" HTML+ERB + ActionView
  37. %p#element Hello = name @ HTMLElementNode ├── tag_name: "p" ├──

    attributes: (1 item) │ └── @ HTMLAttributeNode │ ├── name: │ │ └── @ HTMLAttributeNameNode │ │ └── name: "id" │ │ │ ├── equals: "=" │ └── value: │ └── @ HTMLAttributeValueNode │ └── children: │ └── @ LiteralNode │ └── content: "name" ├── body: (3 items) │ └── @ DynamicContentNode │ ├── tag_opening: "=" │ ├── content: " name " │ └── tag_closing: ∅ │ └── close_tag: └── @ HTMLCloseTagNode ├── tag_opening: "</" ├── tag_name: "p" └── tag_closing: ">" Haml
  38. p id="element" | Hello #{name} @ HTMLElementNode ├── tag_name: "p"

    ├── attributes: (1 item) │ └── @ HTMLAttributeNode │ ├── name: │ │ └── @ HTMLAttributeNameNode │ │ └── name: "id" │ │ │ ├── equals: "=" │ └── value: │ └── @ HTMLAttributeValueNode │ └── children: │ └── @ LiteralNode │ └── content: "name" ├── body: (3 items) │ └── @ DynamicContentNode │ ├── tag_opening: "| " │ ├── content: " name " │ └── tag_closing: ∅ │ └── close_tag: └── @ HTMLCloseTagNode ├── tag_opening: "</" ├── tag_name: "p" └── tag_closing: ">" Slim
  39. <article id="<%= dom_id(article) %>"></article> <input <% if true %> type="text"

    <% end %> /> <% @posts.each do |post| %> <h1><%= post.title %></h1> <% end %> <%= content_tag(:p, "Hello world!") %> <%= tag.div tag.p("Hello world!") %> <%= tag.p do %> Hello world! <% end %> <%= tag.div( data: { controller: "hello", action: "click->hello#greet" } ) %> <%= link_to "Home", root_path %>
  40. <!-- index.html.erb --> <div data-controller="hello"> <%= render partial: "input" %>

    </div> <!-- _partial.html.erb --> <input data-hello-target="name" type="text">
  41. Come find my after the talk, I'd love to hear

    your ideas, questions and opinions.
  42. Thank you 🙏 t @marcoroth_ M @[email protected] g marcoroth.dev g

    @marcoroth b @marcoroth.dev l /in/marco-roth