Skip to content

Herb::Engine v0.7.0+

Herb::Engine is a drop-in replacement for Erubi::Engine that compiles HTML+ERB templates into Ruby code. It extends Erubi's functionality with HTML-aware parsing, validation, and security checks.

Usage

Basic usage (same as Erubi::Engine):

ruby
engine = Herb::Engine.new(source)
puts engine.src

With options:

ruby
engine = Herb::Engine.new(source,
  filename: "app/views/users/show.html.erb",
  escape: true,
)

Erubi Compatibility

Herb::Engine accepts all the same options as Erubi::Engine:

  • bufvar / outvar — Buffer variable name
  • bufval — Initial buffer value
  • escape / escape_html — Whether <%= %> escapes by default
  • escapefunc — Escape function name
  • filename — Template filename
  • freeze — Add frozen string literal comment
  • freeze_template_literals — Freeze template string literals
  • preamble / postamble — Custom preamble/postamble
  • chain_appends — Chain << calls for performance
  • ensure — Wrap in begin/ensure block
  • src — Initial source string

Herb-Specific Options

In addition to Erubi options, Herb::Engine supports:

OptionDefaultDescription
parser_options{}Parser options forwarded to the parser (e.g., { strict: false })
visitors[]AST visitors to run before compilation
context{}Extra keys to pass through to the visitors (see Visitor context)
project_pathDir.pwdProject root for relative path resolution
validate_rubyfalseRaise if the compiled output isn't valid Ruby

The engine compiles whatever passes it is given and holds no opinion beyond that. Validation, debug annotations, and Action View optimizations are all visitors you pass in visitors, so there is no option to turn any of them on.

Strict parsing is a parser option rather than an engine option, so it is set through parser_options, together with any other parser option:

ruby
Herb::Engine.new(source, parser_options: { strict: false })

Validators

Validators check a parsed template and report what they find. They are ordinary visitors, so nothing runs unless you pass it.

ValidatorDescription
SecurityValidatorDetects ERB output in unsafe positions (attribute names, attribute positions)
NestingValidatorValidates HTML nesting rules (e.g., no <div> inside <p>)
AccessibilityValidatorValidates accessibility-related attributes
RenderValidatorValidates render calls

Validators.all builds the set a project has switched on in .herb.yml, which is the usual way to ask for them:

ruby
require "herb/engine/validators"

Herb::Engine.new(source, visitors: Herb::Engine::Validators.all)

It takes the same per-validator overrides, so a template can opt out of one:

ruby
Herb::Engine.new(source, visitors: Herb::Engine::Validators.all(security: false))

A caller that already knows what it wants can skip the configuration lookup and name them directly.

ruby
require "herb/engine/validators/security_validator"

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

Whether a finding refuses to compile

Each validator decides that for itself, through fatal:. A fatal validator aborts compilation when it reports an error. One that is not fatal reports the same thing and lets the template compile, so the page still renders and the finding reaches the browser instead.

ruby
Herb::Engine::Validators.all(fatal: false)

Validators are fatal by default. Which exception gets raised is the validator's own choice rather than something the engine infers from its class name, so SecurityValidator aborts with Herb::Engine::SecurityError while a validator that names no exception aborts with Herb::Engine::CompilationError.

Because this is decided per validator rather than per engine, one compile can mix the two. Security problems can refuse to compile while accessibility findings only get reported:

ruby
Herb::Engine.new(
  source,
  visitors: [
    Herb::Engine::Validators::SecurityValidator.new(fatal: true),
    Herb::Engine::Validators::AccessibilityValidator.new(fatal: false)
  ]
)

Ordering

Validators.all returns a Herb::Engine::VisitorStack, an ordered list that also accepts anything else you want to run:

ruby
stack = Herb::Engine::Validators.all
stack.use(MyVisitor.new)
stack.insert_after(Herb::Engine::Validators::SecurityValidator, MyOtherVisitor.new)

use appends, insert and insert_after place a visitor relative to another one by class, and include_visitor? asks whether one is already there. Naming a class that is not in the stack raises Herb::Engine::VisitorStack::UnknownVisitorError rather than putting it somewhere arbitrary.

Transform Visitors

The visitors option accepts visitors that run over the AST before compilation. Transform visitors rewrite the AST, which changes what the compiler emits.

Herb ships the following transform visitors:

VisitorDescription
AutoCloseOmittedTagsVisitorReplaces omitted closing tags with explicit ones
ContentForVisitorAppends HTML to the end of every matching element
HTMLSafeAssertionsVisitorChecks every .html_safe call at runtime
ComponentVisitorRewrites capitalized tags into render calls (experimental)
DebugVisitorAnnotates output with the template and position it came from
OptimizeVisitorCompile-time optimizations for Action View helpers (experimental)
InstrumentationVisitorFrames every ERB tag so a render can be attributed to it (experimental)

Transform visitors are not loaded when you require "herb". Require the ones you want and pass them to the engine:

ruby
require "herb/engine/auto_close_omitted_tags_visitor"

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

Your own visitors are passed the same way. See Visitors for how to write one.

A visitor that needs the AST to carry more than the defaults can say so with required_parser_option, and one that only works better that way with recommended_parser_option:

ruby
class PrismProgramVisitor < Herb::Visitor
  required_parser_option prism_program: true
  recommended_parser_option strict: false
end

The engine turns both on before it parses, so passing the visitor is all it takes. What differs is how a conflict with the parser options passed to the engine is settled:

DeclarationOption not passed to the enginePassed with the same valuePassed with a different value
required_parser_optionThe engine turns it onNothing to settleRaises ArgumentError
recommended_parser_optionThe engine turns it onNothing to settleWarns, and the value passed to the engine wins

A requirement raises because a visitor that doesn't get it can't do its work, and silently overriding what you asked for would be worse than saying so. Two visitors requiring the same option differently raises for the same reason.

Every declaration adds to the ones a parent class made, and a subclass can override an inherited value by declaring it again. required_parser_options and recommended_parser_options return what a visitor ends up asking for.

Both declarations come from Herb::Visitor::ParserOptionRequirements, which Herb::Visitor includes. A class that is passed to the engine as a visitor without inheriting from Herb::Visitor can include it as well.

The engine settles this through Herb::Visitor.parser_options_for, which takes the visitors and the options to start from and returns what to parse with. Anything else that runs a set of visitors over a document it parses itself can use it the same way:

ruby
parser_options = Herb::Visitor.parser_options_for(visitors, strict: false)
result = Herb.parse(source, **parser_options)

visitors.each { |visitor| result.visit(visitor) }

Run order

Visitors run in the order they are given, and for most of them that order does not matter. It does when one visitor reads the ERB a template was written with and another rewrites it, because the reader would then be handed Herb's generated code where the author's tag should be.

A visitor says which of the two it is by answering on its class:

ruby
class MyReadingVisitor < Herb::Visitor
  def self.reads_erb_source? = true
end

reads_erb_source? means it copies the template's own ERB somewhere, the way DebugVisitor puts it in data-herb-debug-erb. rewrites_erb_source? means it leaves ERB behind that the author did not write, the way InstrumentationVisitor wraps every tag. A visitor that answers neither is unconstrained and can run anywhere.

The engine checks this before it compiles anything, so a stack in the wrong order raises rather than producing a template that is quietly wrong:

ruby
Herb::Engine.new(source, visitors: [
  Herb::Engine::InstrumentationVisitor.new,
  Herb::Engine::DebugVisitor.new
])
# => Herb::Engine::VisitorStack::OrderError

Visitor context

A visitor that includes Herb::Engine::ContextAware is handed a Herb::Engine::VisitorContext before the engine walks the AST, so it doesn't have to be told things the engine already knows:

ruby
class MyVisitor < Herb::Visitor
  include Herb::Engine::ContextAware

  def visit_html_element_node(node)
    context.relative_file_path #=> "app/views/users/show.html.erb"
    context.file_path          #=> #<Pathname:app/views/users/show.html.erb>
    context.project_path       #=> #<Pathname:/my/project>
    context.options[:escape]   #=> true

    super
  end
end

Herb::Engine.new(source, filename: "app/views/users/show.html.erb", visitors: [MyVisitor.new])

relative_file_path is the file path resolved against project_path, and is "unknown" when there is no file path. file_path stays exactly as it was given, so a visitor can still match on how the path was written. It is named file_path rather than filename because it holds a path, not a base name. The engine option keeps the name filename for Erubi compatibility. options holds the options the engine was built with, without visitors and src.

Pass context to the engine to add your own keys, reachable with #[] and #fetch:

ruby
Herb::Engine.new(source, context: { theme: "dark" }, visitors: [MyVisitor.new])

# inside the visitor
context[:theme]              #=> "dark"
context.fetch(:missing, 1)   #=> 1

A context is immutable, and #merge returns a new one. Setting context= yourself always wins over the engine, which is what lets a visitor run standalone against any AST:

ruby
visitor = MyVisitor.new
visitor.context = Herb::Engine::VisitorContext.new(file_path: "app/views/users/show.html.erb")

Herb.parse(source).value.accept(visitor)

AutoCloseOmittedTagsVisitor

Makes sure the compiled output always contains a closing tag, even when the template omits it.

Given this template:

html
<ul>
  <li>List Item 1
Element `<li>` at (2:3) has its closing tag omitted. While valid HTML, consider adding an explicit `</li>` closing tag at (3:2) for clarity, or set `strict: false` to allow this. (`OMITTED_CLOSING_TAG_ERROR`) (parser-no-errors)
<li>List Item 2
Missing explicit closing tag for `<li>`. Use `</li>` instead of relying on implicit tag closing. (html-require-closing-tags)
Element `<li>` at (3:3) has its closing tag omitted. While valid HTML, consider adding an explicit `</li>` closing tag at (4:0) for clarity, or set `strict: false` to allow this. (`OMITTED_CLOSING_TAG_ERROR`) (parser-no-errors)
</ul>
Missing explicit closing tag for `<li>`. Use `</li>` instead of relying on implicit tag closing. (html-require-closing-tags)

The engine renders:

html
<ul>
  <li>List Item 1
  </li><li>List Item 2
</li></ul>

The closing tag is inserted where the parser determined the element ends, which keeps the surrounding whitespace (and therefore the rendering of inline-block elements) identical to the template without the visitor.

ContentForVisitor

Appends HTML to the end of every element matching a tag name, so that it ends up right before that element's closing tag.

ruby
require "herb/engine/content_for_visitor"

Herb::Engine.new(source, visitors: [
  Herb::Engine::ContentForVisitor.new("<p>Footer</p>", tag_name: "main")
])

Tag names are matched case-insensitively, and every matching element in the template gets the content, including nested ones.

Pass attributes to narrow which elements match. Every condition in the hash has to hold:

ruby
Herb::Engine::ContentForVisitor.new(
  "<p>Footer</p>",
  tag_name: "main",
  attributes: { "id" => "content", "data-role" => /page/, "hidden" => false }
)
ConditionMatches when
trueThe attribute is present, whatever its value
falseThe attribute is absent
A RegexpThe attribute value matches it
Anything elseThe attribute value is equal to it

Attribute names are matched case-insensitively, and may be given as strings or symbols. An attribute whose value is built from ERB has no value known at compile time, so it matches true but never a string or Regexp condition.

Multiple visitors compose, and each appends after the last, in the order you pass them.

Given this template and a visitor for the head tag:

html
<head>
  <title>Hello</title>
</head>

The engine renders:

html
<head>
  <title>Hello</title>
<meta name="herb" content="1"></head>

The content is emitted as a Ruby string literal marked html_safe, so it is never escaped, and quotes, backslashes and #{} in it are not interpreted.

HTMLSafeAssertionsVisitor

Wraps the receiver of every .html_safe call in a template with a runtime assertion, so that marking a value as HTML-safe raises when the value contains HTML that the browser executes.

ruby
require "herb/engine/html_safe_assertions_visitor"

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

This template:

html
<div><%= @user.bio.html_safe %></div>
Avoid `.html_safe` in ERB output. It bypasses HTML escaping and can cause cross-site scripting (XSS) vulnerabilities. (erb-no-unsafe-raw)

Compiles as if it had been written as:

html
<div><%= ::Herb::Engine::HTMLSafeAssertions.check(@user.bio, file: __FILE__, line: 1, column: 6, source: "<%= @user.bio.html_safe %>", mode: :raise).html_safe %></div>
ERB tag `<%=` at (1:5) was terminated by nested `<%` tag at (1:106). Nesting `<%` tags is not supported. (`NESTED_ERB_TAG_ERROR`) (parser-no-errors)
argument_term_paren: unexpected end-of-input; expected a `)` to close the arguments (`RUBY_PARSE_ERROR`) (parser-no-errors)
string_literal_eof: unterminated string meets end of file (`RUBY_PARSE_ERROR`) (parser-no-errors)
Stray `%>` found at (1:159). This closing delimiter is not part of an ERB tag and will be treated as plain text. If you want a literal `%>`, use the HTML entities `&percnt;&gt;` instead. (`STRAY_ERB_CLOSING_TAG_ERROR`) (parser-no-errors)

The value keeps flowing through .html_safe unchanged, and the assertion runs on every render. A value that is already HTML-safe is never checked, since .html_safe is a no-op on it.

The calls are found in the Prism program that the prism_program parser option attaches to the document, so a call is wrapped wherever it appears, including in control flow such as <% elsif b.html_safe %>. Calls inside an ERB comment are not wrapped, since they are not part of the program. The visitor declares the option through required_parser_option, which the engine turns on for it. Parsing an AST for this visitor by hand needs the same option:

ruby
Herb.parse(source, prism_program: true)

The error surfaces while the template renders, not while it compiles, unlike the ones the validators raise. Rendering the template with a bio of <script>alert(1)</script> raises Herb::Engine::HTMLSafeAssertions::UnsafeHTMLError:

Unsafe `.html_safe` call in app/views/users/show.html.erb:1:6

    <%= @user.bio.html_safe %>

The value contains a `<script>` element, which the browser executes.

    "<script>alert(1)</script>"

Escape the value or run it through `sanitize` instead of marking it as HTML-safe.

The value is checked against these heuristics:

CheckReports
script_elementA <script> element
event_handlerAn inline event handler attribute, such as onerror or onclick
javascript_urlA javascript: or vbscript: URL
data_urlA data:text/html URL
risky_elementAn <iframe>, <object>, <embed>, <base> or <portal>
meta_refreshA <meta http-equiv="refresh"> element

The visitor takes the following options:

OptionDefaultDescription
mode:raise:raise raises on a violation, :warn warns and keeps rendering
ignore[]Checks to skip, given by name
file_pathnilPath baked into the assertion. Defaults to __FILE__, which Rails sets to the template
ruby
Herb::Engine::HTMLSafeAssertionsVisitor.new(mode: :warn, ignore: [:risky_element])

Set on_violation to report violations somewhere else instead of raising or warning. It receives the same error object, and is consulted before mode:

ruby
Herb::Engine::HTMLSafeAssertions.on_violation = ->(error) do
  ErrorTracking.capture_exception(error)
end

.html_safe passed as a block argument has no receiver to wrap, so the symbol becomes a block that checks every element it is called with:

html
<%= items.map(&:html_safe).join %>
html
<%= items.map(&proc { |value| ::Herb::Engine::HTMLSafeAssertions.check(value, ...).html_safe }).join %>
argument_no_forwarding_ellipses: unexpected ... when the parent method is not forwarding (`RUBY_PARSE_ERROR`) (parser-no-errors)

Since the assertions run on every render, this visitor is meant for development and test environments. In production, either leave it out or run it with mode: :warn.

ComponentVisitor

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 prints a warning the first time it is instantiated in a process.

Rewrites capitalized tags into render calls, so a component can be written as a tag instead of an ERB expression.

ruby
require "herb/engine/component_visitor"

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

A tag is transformed when its name is CamelCase in every segment. <DIV>, <BR> and <My-Component /> are left alone, since uppercase HTML tags are valid HTML.

How the tag is resolved is decided entirely from the tag name, with no lookup at compile time or at render time:

TagSeparatorResolves to
<Card />nonerender Card.new
<Users::Card />::, a constantrender Users::Card.new
<Users.Card />., a pathrender "users/card"
<Admin.Users.ProfileCard />., a pathrender "admin/users/profile_card"

Dot notation needs the dot_notation_tags parser option for the tag name to parse at all:

ruby
Herb::Engine.new(source,
  parser_options: { dot_notation_tags: true },
  visitors: [Herb::Engine::ComponentVisitor.new],
)

Attribute names are converted from kebab-case to snake_case and become keyword arguments:

AttributeBecomesNotes
name="hello"name: "hello"Quotes, backslashes and #{} are escaped
:count="@count"count: @countA : prefix is used as Ruby code
name="<%= @user.name %>"name: "#{@user.name}"ERB is interpolated into the string
disableddisabled: trueAn 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.

html
<MyComponent name="hello" :count="@count" item-id="7" />
Opening tag name `<MyComponent>` should be lowercase. Use `<mycomponent>` instead. (html-tag-name-lowercase)
Use `<MyComponent></MyComponent>` instead of self-closing `<MyComponent />` for HTML compatibility. (html-no-self-closing)

Compiles to the equivalent of:

erb
<%= render MyComponent.new(name: "hello", count: @count, item_id: "7") %>

For a partial, the same attributes become locals instead of keyword arguments:

html
<Users.Card name="hello" :count="@count" />
Unexpected Token. Expected: an identifier, `@`, `<%`, whitespace, or a newline, found: a character. (`UNEXPECTED_ERROR`) (parser-no-errors)
erb
<%= render "users/card", name: "hello", count: @count %>

A tag with a body becomes a block, and the body is compiled as normal, so it can contain HTML, ERB, and further components:

html
<Card title="Hello">
Opening tag name `<Card>` should be lowercase. Use `<card>` instead. (html-tag-name-lowercase)
<div>Regular HTML</div> <%= @thing %> <Button>Nested component</Button>
Closing tag name `</Button>` should be lowercase. Use `</button>` instead. (html-tag-name-lowercase)
Opening tag name `<Button>` should be lowercase. Use `<button>` instead. (html-tag-name-lowercase)
</Card>
Closing tag name `</Card>` should be lowercase. Use `</card>` instead. (html-tag-name-lowercase)
erb
<%= 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:

html
<Users.Card title="Hello">Body</Users.Card>
Closing tag `</Users>` at (1:32) is missing closing `>`. (`UNCLOSED_CLOSE_TAG_ERROR`) (parser-no-errors)
Unexpected Token. Expected: an identifier, `@`, `<%`, whitespace, or a newline, found: a character. (`UNEXPECTED_ERROR`) (parser-no-errors)
erb
<%= render layout: "users/card", locals: { title: "Hello" } do %>Body<% end %>

DebugVisitor

Annotates the rendered output with where it came from, so a rendered element can be traced back to the tag that produced it.

ruby
require "herb/engine/debug_visitor"

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

The first top-level element of a template carries which template it is, and each ERB output tag is wrapped in a <span style="display: contents"> carrying where in that template it was written:

AttributeOnSays
data-herb-debug-file-relative-pathelementwhich template this is
data-herb-debug-file-nameelementits basename
data-herb-debug-file-full-pathelementits full path
data-herb-debug-outline-typebothwhether it is a view, a partial, or an ERB output
data-herb-debug-attach-to-parentelementthat the template has more than one root
data-herb-debug-insertedspanthat this span is Herb's and not the author's
data-herb-debug-erbspanthe tag as it was written
data-herb-debug-line, -columnspanwhere that tag is
data-herb-debug-nodebothwhich render this was, with node: true

Tracing rendered output back to a tag

<%= link_to "Abc", "" %> produces an <a> that says nothing about where it came from. Wrapping it says so:

html
<span
  data-herb-debug-inserted="true"
  data-herb-debug-line="2"
  data-herb-debug-column="7"
  data-herb-debug-erb="&lt;%= link_to &quot;Abc&quot;, &quot;&quot; %&gt;"
  style="display: contents;"
>
  <a href="">Abc</a>
Attribute `href` must not be empty. Either provide a meaningful value or remove the attribute entirely. (html-no-empty-attributes)
</span>

Anything looking at the rendered page, such as a linter running over the response, walks up from the element it has a finding about and takes the first marker it meets:

Nearest markerWhat it can say
[data-herb-debug-inserted]the tag, and its line and column
[data-herb-debug-file-relative-path]only the template
neithernothing

Not every element ends up under a marker. An element written as plain HTML has no tag to name, an ERB tag inside an attribute value cannot be wrapped in a span, a template with more than one root only marks the first, and helpers that take a block are skipped. Treat a missing marker as unattributed rather than assuming coverage.

node: true adds the render as well, which needs InstrumentationVisitor in the same stack to have anything to report:

ruby
Herb::Engine.new(source, visitors: [
  Herb::Engine::DebugVisitor.new(node: true),
  Herb::Engine::InstrumentationVisitor.new
])

Without it the markers say only where in a file something was written, so a partial rendered three times puts three identical ones in the page. With it each carries the render it belongs to, which is what tells them apart.

The wrapper is a real cost. A <span> is not valid everywhere an ERB tag can appear, <ul> being the obvious case, so a strict linter reading the rendered page will have findings about Herb's own instrumentation.

OptimizeVisitor experimental

Asks the parser to resolve Action View helpers into the markup they produce, so the compiler emits that markup instead of a call the renderer has to make.

ruby
require "herb/engine/optimize_visitor"

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

<%= tag.div do %>Content<% end %> compiles to <div>Content</div> with no helper call left at all. Only the helpers the registry marks supported are resolved.

Replacing a helper call with its markup is the same thing as calling it only while the helper is the one it was resolved against. An application that defines its own content_tag gets the stock markup everywhere instead of its own, with nothing at the call site to say so. verify compiles a check into the template that reports a helper that has since been overwritten:

ruby
Herb::Engine::OptimizeVisitor.new(verify: true)

It reports rather than raises, because the markup is already rendered by the time the check runs:

app/views/posts/index.html.erb:1:1: [overwritten-helper] `tag` was compiled away as
ActionView::Helpers::TagHelper, but here it is defined by ApplicationHelper.

The check costs a call per render and only reports, so it belongs in development rather than production, and compiling it in is opt-in for the same reason the optimization is.

Diagnostics

Anything the engine or a visitor finds is a Herb::Diagnostic, whoever found it and whenever they found it. One value object means a parse error, a security violation, and a measurement taken while the page rendered all reach the browser through the same channel, so a new checker gets delivery without inventing one.

ruby
Herb::Diagnostic.new(
  template: "app/views/posts/index.html.erb",
  message: "This element is suspicious.",
  code: "suspicious-element",
  origin: "Herb Compiler",
  severity: :warning,
  location: node.location
)

origin says who found it and is what a consumer groups by. severity is one of :error, :warning, :info, or :hint. kind is :diagnostic by default, or :metric for a measurement, which carries a value badge instead of a severity.

Positions are Herb-native, counting lines from one and columns from zero, the same as everywhere else in Herb. The payload counts columns from one, and that shift happens in exactly one place, so a diagnostic built straight from a node needs no adjusting.

Reporting from a visitor

Any visitor can report by including Herb::Engine::Diagnostics. It is a mixin rather than a base class, so a visitor that rewrites the tree can report as well:

ruby
class SuspiciousElementVisitor < Herb::Visitor
  include Herb::Engine::ContextAware
  include Herb::Engine::Diagnostics

  def visit_html_element_node(node)
    warning("This element is suspicious.", node.location, code: "suspicious-element")

    super
  end
end

error, warning, info, and hint each record and keep walking. The engine collects from every visitor that responds to diagnostics once the visitors have run, so reporting needs no wiring beyond including the mixin. ContextAware is what fills in the template name, because the engine hands every context-aware visitor its VisitorContext.

Findings recorded this way are compiled into the template, so they reach the browser when it renders rather than being spliced into the HTML at compile time.

Delivering them to the browser

A Herb::Engine::Report::Session is where everything found while one page renders collects, so findings from separate producers end up in one payload rather than one channel each. Herb::Engine::Report::Middleware scopes one to each request and injects the result:

ruby
require "herb/engine/report/middleware"

config.middleware.use Herb::Engine::Report::Middleware

It writes a single data-herb-diagnostics script before </body>. A response it cannot safely touch is returned untouched, and any error while injecting is swallowed in favour of the original response, so nothing here can be the reason a page fails.

The session it used is left in the Rack env, which is how a test reads what a request collected:

ruby
get "/posts"

request.env[Herb::Engine::Report::Middleware::ENV_KEY].diagnostics

Wrapping a request works too. A session that is already open is one somebody means to read, so the middleware collects into that one rather than opening its own:

ruby
session = Herb::Engine::Report::Session.capture { get "/posts" }

Instrumentation experimental

InstrumentationVisitor frames every ERB tag with a call saying which tag is rendering, so whatever happens while it renders can be attributed to it rather than to the template as a whole.

It supplies where, and something else has to supply what. A template compiled with it and rendered with nothing watching records nothing at all, and only costs a call per tag. What makes it worth having is anything that calls Herb::Engine::Report::Session.observe while a tag is rendering:

ruby
require "herb/engine/instrumentation_visitor"

engine = Herb::Engine.new(source, visitors: [Herb::Engine::InstrumentationVisitor.new])

ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
  Herb::Engine::Report::Session.observe(:queries, payload[:sql]) unless payload[:cached]
end

Nothing about that subscription belongs to Herb, which is the point. Because what gets watched is decided at render time rather than compiled in, watching something new never means recompiling a template.

Session#measure turns what was observed into one diagnostic per tag that saw any:

ruby
session.measure(:queries, origin: "Herb Engine", code: "sql-queries") do |queries|
  "#{queries.size} SQL queries"
end
#=> app/views/posts/_card.html.erb:7:9: [sql-queries] 3 SQL queries

A count is a measurement rather than a fault, so what comes out carries a badge and no severity. Three queries at one tag is worth showing every time and worth worrying about only sometimes, and which of those it is depends on what the tag is for.

The render stack

A tag that renders a partial stays open while that partial renders, so Session.stack is a render stack across every instrumented template and not only within one. It reads innermost first, the way caller does:

ruby
Herb::Engine::Report::Session.stack
#=> [["app/views/posts/_card.html.erb", 2, 2],
#    ["app/views/posts/index.html.erb", 4, 4],
#    ["app/views/layouts/application.html.erb", 2, 2]]

Each frame is [template, line, column], with the column counted from zero as everywhere else in the AST. A template compiled without the visitor contributes no frames, so an uninstrumented partial part-way down a chain is skipped rather than showing as a gap.

An observation is only filed under the innermost frame, so anything wanting the rest has to take it while it still exists, which is one line in the subscriber:

ruby
Herb::Engine::Report::Session.observe(:queries, { sql: sql, stack: Herb::Engine::Report::Session.stack })

The render tree

Every template the visitor compiled reports one render when it starts, and those become the payload's renderTree. A node is one occurrence, so a partial rendered twice is two nodes rather than one entry counted twice:

json
[
  { "id": "1", "template": "app/views/layouts/application.html.erb" },
  { "id": "2", "template": "app/views/posts/index.html.erb", "parent": "1", "line": 2, "column": 3, "via": "partial" },
  { "id": "3", "template": "app/views/posts/_card.html.erb", "parent": "2", "line": 2, "column": 3, "via": "collection" },
  { "id": "4", "template": "app/views/posts/_card.html.erb", "parent": "2", "line": 2, "column": 3, "via": "collection" }
]

parent is the render this one happened inside, and line and column are where in that parent it was called from, counted the way the rest of the payload counts. Walking parent from any node gives the whole chain that reached it, which is the same information Session.stack reports live, except that the tree keeps the occurrences apart. Two renders of one partial produce identical stacks and different nodes.

via says what kind of render reached the template:

viaWritten as
partial<%= render "posts/card" %>
collection<%= render partial: "card", collection: @posts %>
layout<%= render layout: "box" do %>
template<%= render template: "posts/show" %>

It comes from the tag that did the rendering, because the template being rendered has no idea how it was reached. <%= render @post %> is left without a via on purpose, since Rails decides whether that is one partial or a collection by asking the object at render time, and there is no honest answer for it at compile time.

Reading via needs the render_nodes parser option, which the visitor recommends and the engine therefore turns on. Passing render_nodes: false explicitly still works and only costs the via field.

Annotating a render

Some things are facts about a render rather than faults in it. A render time exists for every template rather than the rare broken one, and belongs beside what it describes rather than in a list of things to fix. Sending those through record would spend the diagnostics budget on the ordinary case, so they go to annotate instead:

ruby
Herb::Engine::Report::Session.annotate(:render_time, 1.5, origin: "reactionview")

They collect into the payload's nodes, keyed by the render they were made during:

json
{
  "3": { "reactionview": { "render_time": 1.5 } },
  "4": { "reactionview": { "render_time": 1.5 } }
}

Each producer gets its own namespace under origin, so two of them can annotate one render without knowing about each other or agreeing on key names. Herb never reads the keys, so what they are called is up to whoever writes them, with snake_case being the convention the rest of the payload follows.

An annotation made outside any render is dropped rather than given a node of its own. Session.current_node reports which render is open, and nil when none is.

Instrumentation is experimental as it instruments every ERB tag.

ReActionView Integration

ReActionView registers Herb::Engine as the template handler for .html.erb and .html.herb files in Rails. It runs the validators with fatal: false in development, so problems reach the browser instead of raising and the page still renders.

Validator settings from .herb.yml are respected automatically, with no ReActionView-specific configuration needed.

ReActionView also lets you run transform visitors on every template it compiles, through config.transform_visitors:

config/initializers/reactionview.rb
ruby
require "herb/engine/auto_close_omitted_tags_visitor"

ReActionView.configure do |config|
  config.transform_visitors = [
    Herb::Engine::AutoCloseOmittedTagsVisitor.new
  ]
end

Released under the MIT License.