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 @ Latvian Ruby Community Meetup May 2025, Riga Latvia

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

May 15, 2025
Tweet

More Decks by Marco Roth

Other Decks in Programming

Transcript

  1. Marco Roth !  @marcoroth_  @[email protected] 🌐 marcoroth.dev 

    @marcoroth Full-Stack Developer & Open Source Contributor  @marcoroth.dev
  2. <div> <span><%= @user.firstname %></span> <b><%= @user.lastname %></b> </div> <div> <span><%=

    @user.firstname %></span> <b><%= @user.lastname %></b> </div>
  3. Ruby HTML <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 @user.firstname @user.lastname <div> <span> </span> <b> </b> </div>

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

    (1 item) └── @ HTMLTextNode (location: (1:0)-(1:4)) ├── errors: [] └── content: "Text"
  7. <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 ^^^^^
  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</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
  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><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"
  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. <%= @variable %> @ DocumentNode ├── errors: [] └── children:

    (1 item) └── @ ERBContentNode ├── errors: [] ├── tag_opening: "<%=" ├── content: " @variable " ├── tag_closing: "%>" ├── parsed: true └── valid: true
  14. <% 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"
  15. <% 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)
  16. <% 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: "%>"
  17. <% 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: "%>"
  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. I invite you to give it a shot to see

    what you can build with it.
  24. <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>
  25. Check for valid HTML5 Missing alt attributes on <img> No

    unsafe interpolation Only one element with the same ID invalid syntax ...
  26. <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
  27. <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
  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 re-rendering view
  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 tracing dependencies
  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 render delta for item `4`
  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` <li>4</li>
  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: ∅ 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: ∅ 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 /  @marcoroth_  @[email protected] 🌐 marcoroth.dev 

    @marcoroth  @marcoroth.dev  /in/marco-roth