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 @ Ruby on Rails Switzerland Meetup, June 2025, Zurich

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, transforming how we build and ship HTML in our Ruby applications.

Avatar for Marco Roth

Marco Roth

June 18, 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` <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> </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>
  35. <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
  36. 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
  37. <%= 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
  38. %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: ∅ Haml
  39. 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: ∅ Slim
  40. <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 %>
  41. <!-- index.html.erb --> <div data-controller="hello"> <%= render partial: "input" %>

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

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

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