Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
name: "action_cable_meta_tag"
source: "ActionCable::Helpers::ActionCableHelper#action_cable_meta_tag"
gem: "actioncable"
output: "html"
visibility: "public"
head_content: true

@marcoroth marcoroth Aug 7, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kcdragon I think this is the best thing to do long-term!

Maybe we could reverse it to something like:

context: "head"

So other helpers could define other contexts, like:

context: "body"
context: "html"
context: "table"

There is also this other pull request which adds more schema-like metadata that could be also useful to have: #1030

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@marcoroth Cool. I'll change this PR to use context: "head". The other PR is interesting. Is the idea that if we knew which element these helpers outputted, we could automatically determine if it could only appear in <head> without needing something like context: "head"?

@marcoroth marcoroth Aug 7, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, at least as long as the helper actually maps to an HTML tag.

supported: false
supports_block: false
signature: "action_cable_meta_tag"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
name: "auto_discovery_link_tag"
source: "ActionView::Helpers::AssetTagHelper#auto_discovery_link_tag"
gem: "actionview"
output: "html"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "auto_discovery_link_tag(type = :rss, url_options = {}, tag_options = {})"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
name: "favicon_link_tag"
source: "ActionView::Helpers::AssetTagHelper#favicon_link_tag"
gem: "actionview"
output: "html"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "favicon_link_tag(source = \"favicon.ico\", options = {})"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
name: "javascript_include_tag"
source: "ActionView::Helpers::AssetTagHelper#javascript_include_tag"
gem: "actionview"
output: "html"
visibility: "public"
head_content: true
supported: true
supports_block: false
signature: "javascript_include_tag(*sources)"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
name: "preload_link_tag"
source: "ActionView::Helpers::AssetTagHelper#preload_link_tag"
gem: "actionview"
output: "html"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "preload_link_tag(source, options = {})"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
name: "stylesheet_link_tag"
source: "ActionView::Helpers::AssetTagHelper#stylesheet_link_tag"
gem: "actionview"
output: "html"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "stylesheet_link_tag(*sources)"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
name: "csp_meta_tag"
source: "ActionView::Helpers::CspHelper#csp_meta_tag"
gem: "actionview"
output: "html"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "csp_meta_tag(**options)"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
name: "csrf_meta_tags"
source: "ActionView::Helpers::CsrfHelper#csrf_meta_tags"
gem: "actionview"
output: "html"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "csrf_meta_tags"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
name: "javascript_import_module_tag"
source: "Importmap::ImportmapTagsHelper#javascript_import_module_tag"
gem: "importmap-rails"
output: "html"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "javascript_import_module_tag(*module_names)"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
name: "javascript_importmap_module_preload_tags"
source: "Importmap::ImportmapTagsHelper#javascript_importmap_module_preload_tags"
gem: "importmap-rails"
output: "html"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "javascript_importmap_module_preload_tags(importmap = Rails.application.importmap, entry_point: \"application\")"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ source: "Importmap::ImportmapTagsHelper#javascript_importmap_tags"
gem: "importmap-rails"
output: "html"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "javascript_importmap_tags(entry_point = \"application\", importmap: Rails.application.importmap)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ source: "Importmap::ImportmapTagsHelper#javascript_inline_importmap_tag"
gem: "importmap-rails"
output: "html"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "javascript_inline_importmap_tag(importmap_json = Rails.application.importmap.to_json(resolver: self))"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ source: "Importmap::ImportmapTagsHelper#javascript_module_preload_tag"
gem: "importmap-rails"
output: "html"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "javascript_module_preload_tag(*paths)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ source: "Turbo::DriveHelper#turbo_exempts_page_from_cache_tag"
gem: "turbo-rails"
output: "html"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "turbo_exempts_page_from_cache_tag"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ source: "Turbo::DriveHelper#turbo_exempts_page_from_preview_tag"
gem: "turbo-rails"
output: "html"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "turbo_exempts_page_from_preview_tag"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ source: "Turbo::DriveHelper#turbo_page_requires_reload_tag"
gem: "turbo-rails"
output: "html"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "turbo_page_requires_reload_tag"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ source: "Turbo::DriveHelper#turbo_refresh_method_tag"
gem: "turbo-rails"
output: "html"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "turbo_refresh_method_tag(method = :replace)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ source: "Turbo::DriveHelper#turbo_refresh_scroll_tag"
gem: "turbo-rails"
output: "html"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "turbo_refresh_scroll_tag(scroll = :reset)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ source: "Turbo::DriveHelper#turbo_refreshes_with"
gem: "turbo-rails"
output: "void"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "turbo_refreshes_with(method: :replace, scroll: :reset)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ source: "Turbo::IncludesHelper#turbo_include_tags"
gem: "turbo-rails"
output: "html"
visibility: "public"
head_content: true
supported: false
supports_block: false
signature: "turbo_include_tags"
Expand Down
22 changes: 19 additions & 3 deletions lib/herb/engine/debug_visitor.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
# frozen_string_literal: true
# typed: false

require_relative "../action_view/helper_registry"

module Herb
class Engine
class DebugVisitor < Herb::Visitor
HEAD_CONTENT_HELPER_PATTERN = /\b(?:#{Herb::ActionView::HelperRegistry.head_content_helpers.map { |helper| Regexp.escape(helper.name) }.join("|")})\b/ #: Regexp
private_constant :HEAD_CONTENT_HELPER_PATTERN

def initialize(file_path: nil, project_path: nil)
super()

Expand Down Expand Up @@ -76,7 +81,7 @@ def visit_erb_content_node(node)
if !@in_attribute && !@in_html_comment && !@in_html_doctype && !in_excluded_context? && erb_output?(node.tag_opening.value)
code = node.content.value.strip

@erb_nodes_to_wrap << node unless complex_rails_helper?(code)
@erb_nodes_to_wrap << node unless complex_rails_helper?(code) || head_content_helper?(code)
end

super
Expand Down Expand Up @@ -210,7 +215,7 @@ def create_debug_span_for_erb(erb_node)
code = erb_node.content.value.strip
erb_code = "#{opening} #{code} %>"

return erb_node if complex_rails_helper?(code)
return erb_node if complex_rails_helper?(code) || head_content_helper?(code)

line = erb_node.location&.start&.line
column = erb_node.location&.start&.column
Expand Down Expand Up @@ -329,7 +334,7 @@ def in_script_or_style_context?
end

def in_excluded_context?
excluded_tags = ["script", "style", "head", "title", "textarea", "pre", "svg", "math"]
excluded_tags = ["script", "style", "head", "title", "meta", "link", "base", "textarea", "pre", "svg", "math"]
return true if excluded_tags.any? { |tag| @element_stack.include?(tag) }

if @erb_block_stack.any? { |node| javascript_tag?(node.content.value.strip) || include_debug_disable_comment?(node.content.value.strip) }
Expand Down Expand Up @@ -386,6 +391,17 @@ def javascript_tag?(code)
false
end

# TODO: Rewrite using Prism Nodes once available

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this TODO because related methods have this same TODO

def head_content_helper?(code)
cleaned_code = code.strip.gsub(/\s+/, " ")

return true if cleaned_code.match?(HEAD_CONTENT_HELPER_PATTERN)

return true if cleaned_code.match?(/\btag\.(?:meta|link|title|base)\b/)

false
end

def include_debug_disable_comment?(code)
cleaned_code = code.strip.gsub(/\s+/, " ")

Expand Down
15 changes: 13 additions & 2 deletions sig/herb/action_view/helper_registry.rbs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions sig/herb/engine/debug_visitor.rbs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 20 additions & 2 deletions templates/lib/herb/action_view/helper_registry.rb.erb
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,10 @@ module Herb
attr_reader :special_behaviors #: Array[Symbol]
attr_reader :aliases #: Array[String]

#: (name: String, type: Symbol, source: String, gem: String, output: Symbol, visibility: String, tag_name: String?, is_void: bool, supports_block: bool, preferred_for_tag: bool, supported: bool, detect_style: Symbol, description: String, signature: String, documentation_url: String, implicit_attribute: HelperImplicitAttribute?, arguments: Array[HelperArgument], options: Array[HelperOption], block_arguments: Array[HelperBlockArgument], special_behaviors: Array[Symbol], aliases: Array[String]) -> void
#: (name: String, type: Symbol, source: String, gem: String, output: Symbol, visibility: String, tag_name: String?, is_void: bool, supports_block: bool, preferred_for_tag: bool, supported: bool, head_content: bool, detect_style: Symbol, description: String, signature: String, documentation_url: String, implicit_attribute: HelperImplicitAttribute?, arguments: Array[HelperArgument], options: Array[HelperOption], block_arguments: Array[HelperBlockArgument], special_behaviors: Array[Symbol], aliases: Array[String]) -> void
def initialize( # rubocop:disable Metrics/ParameterLists
name:, type:, source:, gem:, output:, visibility:, tag_name:, is_void:, supports_block:,
preferred_for_tag:, supported:, detect_style:, description:, signature:, documentation_url:,
preferred_for_tag:, supported:, head_content:, detect_style:, description:, signature:, documentation_url:,
implicit_attribute:, arguments:, options:, block_arguments:, special_behaviors:, aliases:
)
@name = name
Expand All @@ -130,6 +130,7 @@ module Herb
@supports_block = supports_block
@preferred_for_tag = preferred_for_tag
@supported = supported
@head_content = head_content
@detect_style = detect_style
@description = description
@signature = signature
Expand All @@ -154,6 +155,9 @@ module Herb
#: () -> bool
def supported? = @supported

#: () -> bool
def head_content? = @head_content

#: () -> bool
def static_tag_name? = !@tag_name.nil?

Expand Down Expand Up @@ -181,6 +185,7 @@ module Herb
supports_block: <%= helper.supports_block %>,
preferred_for_tag: <%= helper.preferred_for_tag %>,
supported: <%= helper.supported %>,
head_content: <%= helper.head_content? %>,
detect_style: :<%= helper.detect_style || "call_name" %>,
description: "<%= helper.escaped_description %>",
signature: "<%= helper.escaped_signature %>",
Expand Down Expand Up @@ -239,6 +244,8 @@ module Herb
<%- end -%>
}.freeze #: Hash[String, Array[HelperEntry]]

HEAD_CONTENT = [<%= helpers.select(&:head_content?).map(&:constant_name).join(", ") %>].freeze #: Array[HelperEntry]

class << self
#: (String) -> HelperEntry?
def get(name)
Expand Down Expand Up @@ -294,6 +301,17 @@ module Herb
entries.select(&:supported?)
end

#: () -> Array[HelperEntry]
def head_content_helpers
HEAD_CONTENT
end

#: (String) -> bool
def head_content?(name)
entry = get(name)
entry&.head_content? || false
end

#: (String) -> Array[HelperEntry]
def by_gem(gem_name)
entries.select { |entry| entry.gem == gem_name }
Expand Down
5 changes: 5 additions & 0 deletions templates/template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,7 @@ def initialize(config)
@visibility = config.fetch("visibility", "public")
@supports_block = config.fetch("supports_block", false)
@supported = config.fetch("supported", false)
@head_content = config.fetch("head_content", false)
@description = config.fetch("description", "").strip
@signature = config.fetch("signature")
@documentation_url = config.fetch("documentation_url")
Expand Down Expand Up @@ -788,6 +789,10 @@ def supported?
@supported
end

def head_content?
@head_content
end

def static_tag_name?
!tag_name.nil?
end
Expand Down
Loading
Loading