poetry-core 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bbcb8f5566d4740ebb19e9fc4e4ffed10332cff25bde4e807cf844ddee70770a
4
- data.tar.gz: 82e4cbb3c6f3bb340ecb20fb3e0406e83b8b2008364467dc83c7deddda93331e
3
+ metadata.gz: 1831a4d45731d76d83cfdbb9a65b20433a32bf7c57a15297f7838e04d0a55c21
4
+ data.tar.gz: a65f3175560897a70c6f2f8e29d3373bf3c5ef02f7baae20c35fcc3ddb4b3b6f
5
5
  SHA512:
6
- metadata.gz: 53de8890c3deeb5f377f4c9d825f9599c499ef4bc66becdfd3266864166a6119024486b32c385fd02ef452af27ec9eebac63c8ab54570bad6a0a758707164637
7
- data.tar.gz: d761c54985f8e367c72b5b9c99d609303b787d8bf13c9857bffee008e2b633a3fd778f719e3a07e70c179c11b7ad9430ba75693c511a4c3f33eb2a7794c325a2
6
+ metadata.gz: 2f77187b648438d1e648956744425e82efbeb28f4b8efe0aa19b04ec647db0420bc6320405f01ccb938d6594856974c26702a2572c972a5a71134c16afa67e85
7
+ data.tar.gz: 59f36c4032dc99a6643486ae88f1cacfe71cf7f1ab94f046ee22654276feba9baa4a95244bfa8a011edd2731d269a8dee2408b458e00d00c52367d38076ee647
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.9] - 2026-09-30
4
+
5
+ ### Added
6
+
7
+ - `element_tag(name, ..., **attributes)`, on every component and in every view: the helper a template builds its elements through, and where a component's template spends what `root_attributes` and `element_attributes` build. It takes what `content_tag` takes and renders the same bytes, and it renders a void element the way `tag.input` does. A host that compiles its templates with Herb's slots has Action View's own tag helpers resolved into markup ahead of the render, and a splat or a tag name held in a variable does not survive that: the element comes out without its attributes, or the template stops compiling. A helper Herb does not know stays a call. Components written on the DSL in a host should build their elements the same way.
8
+ - `poetry check` holds a component's template (an ERB file under `app/components/`) to the element rule, `element-tag`, as a warning: a tag helper handed a splat or a variable for its attributes, `content_tag` given a tag name that is not a literal, and `tag.attributes` handed a splat each name the `element_tag` call to write instead. A view is left alone: handing a tag helper a splat is valid Rails, and whether the host compiles with slots is not something a lint of the source can know.
9
+ - `poetry check` compiles a component's HTML template under Herb, in the posture `bin/rails herb:check` holds an app's views to: the security validator, with the compiled Ruby checked for syntax. Rails renders HTML templates through Herb under its 8.2 defaults and its task walks the view paths, so a template under `app/components/` is outside its reach. Rails checks views, Poetry checks components. A template Herb refuses is a `herb-compile` finding that carries the engine's reason and its line: an error when the app renders through Herb, a warning until then. The runner compiles when it is handed the tier (`Check::Runner.new(catalog, compile:)`), and `Poetry::Core::TemplateCompile` takes `posture: :rails` for it beside the strict posture the gems' own gate runs.
10
+ - `poetry check` holds a component class to the renderable rule, `renderable-reader`: an `option`, a `style` or a slot named `format` defines the reader Rails asks a component for the format of its template, and it answers with the declared value, so a render through the renderer raises `Invalid formats`. From Rails 8.2 every render in a view goes through the renderer, so the host's check reports it as an error there and as a warning before. A class that defines `format` itself, with `def` or `define_method`, is left alone: it keeps the keyword for its callers and reads the value under another name, which is what NumberField does.
11
+ - `Poetry::Core::SlotMarkers.strip(html)` takes off what a slot compile writes around a template's own markup: the region, slot and branch comments, the `data-herb-slot` attribute and the branches parked in a `<template>`. What is left is the markup the template wrote, which is what a test in a host that compiles with slots means to compare.
12
+
13
+ ### Changed
14
+
15
+ - The helpers of an app's own components (`helper :name`) are defined before the first view renders, not while the application is still initializing. The engine used to load every `app/components` directory from `to_prepare`, and a component is a view, so Action View loaded before initialization completed: Rails logs that in a boot that does not eager load, and raises on it when `config.action_on_early_load_hook` is `:raise`. A host that eager loads now syncs once its boot is done, with the classes already loaded; every other host syncs on its first view context, at render time; a reload syncs as before. `Poetry::Core::HostHelpers.sync_now!` is the sync, `ready?` says whether the first one has happened, and `HostHelpers::FirstView` is what Action View's base class gets for it.
16
+ - The template compile gate (`rake herb:compile`, `Poetry::Core::TemplateCompile`) runs on Herb 0.11. It requires the engine itself, since `require "herb"` no longer loads it. It hands the engine every validator Herb ships, each one fatal, since from 0.11 the engine validates nothing it is not handed, and has the compiled Ruby checked for syntax. It lists parse and compile errors, which Herb now raises as syntax errors, where they used to pass the rescue and abort the run. A Herb older than 0.11.0 raises a setup error that names the version found.
17
+
18
+ ## [0.1.8] - 2026-09-22
19
+
20
+ ### Added
21
+
22
+ - `ActiveModel::Type::HtmlSafeString`, the type behind every `option :name, :string` declaration: an html-safe value (a hint built with `link_to`, a translated `_html` key) stays html-safe where ActiveModel's String type copied it into a plain String, so a Field's `hint:` or `error:` renders the markup it was given; a plain String still escapes at render. The type is substituted inside the option DSL only, never registered, and reports `:string`, so the registry reads every declaration unchanged.
23
+
24
+ ### Changed
25
+
26
+ - A content block that returns a scalar (a number, a symbol, a boolean) while writing nothing to the buffer captures as nothing, so `poetry_badge { count }` rendered an empty badge without a word. A component that declares `requires_content` now raises in development and test naming the value and the fix (call `to_s` on it, or write it with `<%= %>`) and logs in production; every other component logs. A block that wrote to the buffer, returned a String or nil, or returned a collection (an empty loop's `each`) is untouched.
27
+ - `poetry check`'s stable-identity rules skip a pathless helper whose registry `helpers` entry declares `identity: false` (a table part helper stamps no poetry-minted id, so `key:` had nothing to stabilize and the warning was noise), and read a double-splat of an id-bearing hash (`**field.control_attributes`, `**attrs`) as identity: the id arrives through the splat.
28
+
3
29
  ## [0.1.7] - 2026-09-20
4
30
 
5
31
  ### Changed
@@ -39,6 +39,7 @@ module Poetry
39
39
  include ActiveModel::AttributeAssignment
40
40
  include ActiveModel::Validations
41
41
  include Poetry::Core::Contrib::WrappedHelper
42
+ include Poetry::Core::TagHelper
42
43
  include Poetry::Core::Concerns::Styles
43
44
  include Poetry::Core::Concerns::Options
44
45
  include Poetry::Core::Concerns::Stimulus
@@ -296,12 +297,12 @@ module Poetry
296
297
  # super("role" => "status", "data-variant" => variant)
297
298
  # end
298
299
  # @example The template
299
- # <%= tag.div(**root_attributes) do %>
300
+ # <%= element_tag(:div, **root_attributes) do %>
300
301
  #
301
302
  # @param extra [Hash] the component's own root attributes; a
302
303
  # `"data-slot"` here replaces the default
303
304
  # @return [Hash] the root's attributes, flat (`data-x`, `aria-x`,
304
- # booleans as the attribute name), for `tag` and `content_tag`
305
+ # booleans as the attribute name), for {Poetry::Core::TagHelper#element_tag}
305
306
  def root_attributes(extra = {})
306
307
  own = { "data-slot" => root_slot }.merge(extra).merge(component_data_attributes)
307
308
  wired = self.class.stimulus_elements.key?(:root) ? :root : nil
@@ -335,7 +336,7 @@ module Poetry
335
336
  # attrs["hidden"] = true unless open
336
337
  # element_attributes(:content, attrs)
337
338
  # end
338
- # # <%= tag.div(**content_attributes) do %>
339
+ # # <%= element_tag(:div, **content_attributes) do %>
339
340
  #
340
341
  # @param part [Symbol, String, nil] the part; nil (or a Hash in its
341
342
  # place) for markup with no part of its own
@@ -343,7 +344,7 @@ module Poetry
343
344
  # @param stimulus [Symbol, String, false, nil] the `use_stimulus`
344
345
  # element whose wiring joins the markup: nil takes the part's own when
345
346
  # one is declared, false takes none
346
- # @return [Hash] the element's attributes, flat, for `tag` and `content_tag`
347
+ # @return [Hash] the element's attributes, flat, for {Poetry::Core::TagHelper#element_tag}
347
348
  def element_attributes(part = nil, attrs = {}, stimulus: nil)
348
349
  if part.is_a?(Hash)
349
350
  attrs = part
@@ -382,7 +383,63 @@ module Poetry
382
383
  "(#{self.class.required_content})"
383
384
  end
384
385
 
386
+ # ViewComponent's render_in, with the content block wrapped once so
387
+ # #content can read what the block RETURNED - capture keeps only what
388
+ # the block wrote to the buffer, and a scalar return vanishes there.
389
+ #
390
+ # @param view_context [ActionView::Base] the rendering view
391
+ # @return [ActiveSupport::SafeBuffer] the rendered component
392
+ def render_in(view_context, **)
393
+ @content_guarded = false
394
+ remove_instance_variable(:@content_return) if defined?(@content_return)
395
+ return super unless block_given?
396
+
397
+ recorded = proc { |*args| @content_return = yield(*args) }
398
+ super(view_context, **, &recorded)
399
+ end
400
+
401
+ # The captured content block, guarded once per render: a block that
402
+ # returned a scalar (a number, a symbol, a boolean) while writing
403
+ # nothing to the buffer captures as nothing at all - `poetry_badge {
404
+ # count }` renders an empty badge - so a requires_content component
405
+ # raises in development and test naming the value and the fix, and
406
+ # logs everywhere else; a component without the declaration logs. A
407
+ # block that wrote to the buffer, returned a String, returned nil,
408
+ # returned a collection (an empty loop's each) or composed slots
409
+ # (its last call a with_* setter, which returns the slot or the
410
+ # component) is fine.
411
+ #
412
+ # @return [ActiveSupport::SafeBuffer, nil] the captured content
413
+ def content
414
+ captured = super
415
+ guard_content_return(captured) unless @content_guarded
416
+ captured
417
+ end
418
+
419
+ # The content-return guard behind #content.
420
+ #
421
+ # @param captured [ActiveSupport::SafeBuffer, nil] what capture kept
422
+ # @return [void]
423
+ # @raise [ArgumentError] a scalar return on a requires_content
424
+ # component, in development and test
425
+ def guard_content_return(captured)
426
+ @content_guarded = true
427
+ return unless defined?(@content_return)
428
+
429
+ value = @content_return
430
+ return if value.nil? || value.is_a?(::String) || value.respond_to?(:each) || captured.present?
431
+ return if value.is_a?(ViewComponent::Base) || value.is_a?(ViewComponent::Slot)
432
+
433
+ message = "#{passthrough_owner} content block returned #{value.inspect.truncate(60)} (#{value.class}), " \
434
+ "which captures as nothing - return a String (call to_s on it) or write it with <%= %>"
435
+ raise ArgumentError, message if strict_passthrough? && self.class.required_content
436
+
437
+ Rails.logger&.warn("poetry: #{message}") if defined?(Rails) && Rails.respond_to?(:logger)
438
+ end
439
+ private :guard_content_return
440
+
385
441
  # Indicates whether this component instance is persisted.
442
+
386
443
  # Always returns false as components are not persisted entities.
387
444
  #
388
445
  # @return [Boolean] always returns false
@@ -90,8 +90,9 @@ module Poetry
90
90
  required, default_value = extract_declared_defaults(options)
91
91
  register_option_format(name, options.delete(:format))
92
92
 
93
- attribute(name, type, **options)
93
+ attribute(name, option_cast_type(type), **options)
94
94
  add_option_validations(name, type, required)
95
+
95
96
  setup_declared_tracking(:option, name, default_value)
96
97
  define_type_getter(name, type)
97
98
  end
@@ -200,6 +201,18 @@ module Poetry
200
201
  validates name, presence: true if required
201
202
  end
202
203
 
204
+ # The ActiveModel type an option declaration casts with. A :string
205
+ # option casts with ActiveModel::Type::HtmlSafeString, which keeps
206
+ # an html-safe value (a link, a translated `_html` key) html-safe
207
+ # where the String type copied it into a plain String; every other
208
+ # type is looked up by name as ActiveModel does.
209
+ #
210
+ # @param type [Symbol] the declared type
211
+ # @return [Symbol, ActiveModel::Type::Value] what `attribute` receives
212
+ def option_cast_type(type)
213
+ type == :string ? ActiveModel::Type::HtmlSafeString.new : type
214
+ end
215
+
203
216
  # Defines a singleton method to access the type for this attribute.
204
217
  #
205
218
  # @param name [Symbol] the attribute name
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model/type"
4
+
5
+ module ActiveModel
6
+ module Type
7
+ # The string type behind every `option :name, :string` declaration:
8
+ # ActiveModel's String type copies each value into a fresh String,
9
+ # which drops the html_safe mark a SafeBuffer carries, so a caller's
10
+ # `hint: link_to(...)` or `t(".hint_html")` arrived escaped. This
11
+ # type keeps an html-safe value as it is and casts everything else
12
+ # exactly as the String type does; a plain String still escapes at
13
+ # render, because nothing here marks anything safe.
14
+ #
15
+ # Substituted inside the option DSL only - never registered, so
16
+ # `attribute :x, :string` in a host model keeps Rails' own type - and
17
+ # it reports :string, so the registry and every introspection surface
18
+ # read the declaration unchanged.
19
+ class HtmlSafeString < ActiveModel::Type::String
20
+ private
21
+
22
+ # An html-safe String passes through untouched; everything else casts
23
+ # as a String does.
24
+ #
25
+ # @param value [Object] the raw option value
26
+ # @return [::String]
27
+ def cast_value(value)
28
+ return value if value.is_a?(::String) && value.html_safe?
29
+
30
+ super
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ module Check
6
+ # The compile tier for component templates: each one is compiled
7
+ # under Herb the way `bin/rails herb:check` compiles an app's views.
8
+ #
9
+ # Rails renders HTML templates through Herb under its 8.2 framework
10
+ # defaults, and its check task tells an app which views will not
11
+ # compile. That task walks the view paths. A template that sits
12
+ # beside its component lives outside them, so nothing reaches it
13
+ # before the page that renders the component does. Rails checks
14
+ # views, this checks components: the app's own, and the ones the gems
15
+ # in the boot ship.
16
+ #
17
+ # The severity follows the app. One that renders through Herb has the
18
+ # template fail where it is used, which is an error. One that renders
19
+ # through Erubi renders it as it always has, so the finding is a
20
+ # WARNING about the day the app adopts the defaults.
21
+ #
22
+ # @api private
23
+ class Compile
24
+ # The rule the findings carry.
25
+ RULE = "herb-compile"
26
+
27
+ # The HTML templates of the components under a root, variants
28
+ # included. Rails compiles no other format through Herb.
29
+ GLOB = "app/components/**/*.html{,+*}.erb"
30
+
31
+ # A component's HTML template by convention: a file under
32
+ # `app/components/` in the HTML format, with or without a variant.
33
+ HTML_TEMPLATE = %r{(?:\A|/)app/components/.+\.html(?:\+[\w-]+)?\.erb\z}
34
+
35
+ # The path, line and column the engine opens a message with.
36
+ LOCATION = /\A:\d+:\d+(?::| -) /
37
+
38
+ class << self
39
+ # Whether a path is a component's HTML template by convention.
40
+ #
41
+ # @param path [String]
42
+ # @return [Boolean]
43
+ def html_template?(path)
44
+ HTML_TEMPLATE.match?(path.to_s)
45
+ end
46
+
47
+ # Why the tier cannot run, or nil when it can: the herb gem is
48
+ # missing, or older than the engine the gate is written for.
49
+ #
50
+ # @return [String, nil]
51
+ def unavailable
52
+ TemplateCompile.compile("")
53
+ nil
54
+ rescue Poetry::Core::Error => e
55
+ e.message
56
+ end
57
+
58
+ # Whether the booted app compiles its HTML templates with Herb:
59
+ # the handler registered for ERB hands them to the engine, or
60
+ # Rails' own implementation switch names it.
61
+ #
62
+ # @return [Boolean]
63
+ def rendering?
64
+ return false unless defined?(::Herb::Engine) && defined?(::ActionView::Template)
65
+
66
+ handler = ::ActionView::Template.handler_for_extension(:erb)
67
+ owner = handler.is_a?(Module) ? handler : handler.class
68
+ return true if defined?(::ReActionView::Template::Handlers::ERB) &&
69
+ owner <= ::ReActionView::Template::Handlers::ERB
70
+
71
+ implementation = owner.erb_implementation if owner.respond_to?(:erb_implementation)
72
+ implementation.is_a?(Class) && implementation <= ::Herb::Engine
73
+ end
74
+ end
75
+
76
+ # A compile tier for an app.
77
+ #
78
+ # @param rendering [Boolean] whether the app renders its HTML
79
+ # templates through Herb, which makes a finding an error
80
+ def initialize(rendering: false)
81
+ @rendering = rendering
82
+ end
83
+
84
+ # The finding for one template, when the engine refuses it.
85
+ #
86
+ # @param source [String] the template
87
+ # @param filename [String] its path, which the engine's message opens with
88
+ # @param shipped [Boolean] whether the template ships in a gem
89
+ # @return [Array<Finding>]
90
+ def lint(source, filename:, shipped: false)
91
+ failure = TemplateCompile.failure(source, filename: filename.to_s, posture: :rails)
92
+ return [] unless failure
93
+
94
+ [Finding.new(rule: RULE, severity: @rendering ? :error : :warning, line: failure.line,
95
+ message: message(failure, filename.to_s, shipped))]
96
+ end
97
+
98
+ # The findings for the component templates the roots ship, each
99
+ # stamped with its file. A template that cannot be read is skipped:
100
+ # the render that needs it reports that better than a lint can.
101
+ #
102
+ # @param roots [Array<String, Pathname>] gem roots
103
+ # @return [Array<Finding>]
104
+ def scan(roots)
105
+ roots.flat_map do |root|
106
+ Dir.glob(GLOB, base: root.to_s).sort.flat_map do |relative|
107
+ path = File.join(root.to_s, relative)
108
+ lint(File.read(path, encoding: "UTF-8"), filename: path, shipped: true)
109
+ .each { |finding| finding.file = path }
110
+ rescue SystemCallError, IOError, ArgumentError, Encoding::CompatibilityError
111
+ []
112
+ end
113
+ end
114
+ end
115
+
116
+ private
117
+
118
+ # The finding's sentence: the compiler, the engine's reason
119
+ # without the path it opens with, and what fixes a shipped template.
120
+ def message(failure, filename, shipped)
121
+ compiler = @rendering ? "this app renders with" : "Rails 8.2 renders with by default"
122
+ text = "does not compile under Herb, the compiler #{compiler}: #{reason(failure, filename)}"
123
+ return text unless shipped
124
+
125
+ "#{text} - the template ships in a gem: update the gem, or hold herb at the version it was " \
126
+ "checked against (the bundle resolves herb #{Herb::VERSION})"
127
+ end
128
+
129
+ # The engine's message on one line, without its location prefix.
130
+ def reason(failure, filename)
131
+ first = failure.message.to_s.each_line.map(&:strip).reject(&:empty?).first.to_s
132
+ first.delete_prefix(filename).sub(LOCATION, "")
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ module Check
6
+ # The renderable rule for a component class: no declaration defines
7
+ # the reader Rails asks a component for.
8
+ #
9
+ # Rails reads `format` off what it renders and takes the answer for
10
+ # the format of the template. An `option :format`, a `style :format`
11
+ # or a slot named `format` defines that reader, and it answers with
12
+ # the declared value. A render through the renderer then raises
13
+ # `Invalid formats`. A render from a controller has always gone
14
+ # through it; from Rails 8.2 a render in a view goes through it too,
15
+ # which is every helper call on the page.
16
+ #
17
+ # A class that answers Rails itself is left alone: one that defines
18
+ # `format` in its own body, with `def` or `define_method`, keeps the
19
+ # keyword for its callers and reads the value under another name.
20
+ #
21
+ # @api private
22
+ class RenderableReaders
23
+ # The rule the findings carry.
24
+ RULE = "renderable-reader"
25
+
26
+ # The name Rails reads off a renderable.
27
+ READER = "format"
28
+
29
+ # The declarations that define a reader named after the declaration.
30
+ DECLARATIONS = %w[option style renders_one renders_many].freeze
31
+
32
+ # A rule over a Ruby file; it reads the source alone and needs no
33
+ # catalog.
34
+ #
35
+ # @param severity [Symbol] :warning, or :error where the host's
36
+ # Rails renders a view through the renderer
37
+ def initialize(severity: :warning)
38
+ @severity = severity
39
+ end
40
+
41
+ # The findings for one Ruby source.
42
+ #
43
+ # @param source [String] the Ruby source
44
+ # @return [Array<Finding>]
45
+ def lint(source)
46
+ require "prism"
47
+
48
+ result = Prism.parse(source)
49
+ return [] unless result.success?
50
+
51
+ findings = []
52
+ walk(result.value, findings)
53
+ findings
54
+ end
55
+
56
+ private
57
+
58
+ # Judges every class body on the way down.
59
+ def walk(node, findings)
60
+ findings.concat(class_findings(node)) if node.is_a?(Prism::ClassNode)
61
+ node.compact_child_nodes.each { |child| walk(child, findings) }
62
+ end
63
+
64
+ # The declarations in one class body that define the reader, unless
65
+ # the body defines it itself.
66
+ def class_findings(klass)
67
+ statements = klass.body.is_a?(Prism::StatementsNode) ? klass.body.body : []
68
+ return [] if statements.any? { |statement| answers_rails?(statement) }
69
+
70
+ statements.filter_map do |statement|
71
+ next unless (name = declaration(statement))
72
+
73
+ Finding.new(rule: RULE, severity: @severity, line: statement.location.start_line,
74
+ message: message(name))
75
+ end
76
+ end
77
+
78
+ # A `def format` or `define_method(:format)` in the class body.
79
+ def answers_rails?(statement)
80
+ return statement.name.to_s == READER if statement.is_a?(Prism::DefNode)
81
+
82
+ call?(statement, "define_method") && literal_name(statement.arguments&.arguments&.first) == READER
83
+ end
84
+
85
+ # The declaration keyword when the statement declares the reader.
86
+ def declaration(statement)
87
+ return unless statement.is_a?(Prism::CallNode) && statement.receiver.nil?
88
+
89
+ name = statement.name.to_s
90
+ return unless DECLARATIONS.include?(name)
91
+ return unless literal_name(statement.arguments&.arguments&.first) == READER
92
+
93
+ name
94
+ end
95
+
96
+ # A receiverless call by that name.
97
+ def call?(node, name)
98
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name.to_s == name
99
+ end
100
+
101
+ # A symbol or string literal's text, or nil.
102
+ def literal_name(node)
103
+ node.unescaped if node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
104
+ end
105
+
106
+ # The finding's sentence.
107
+ def message(name)
108
+ "#{name} :#{READER} defines the reader Rails asks a component for the format of its template, and it " \
109
+ "answers with the declared value: a render through the renderer raises `Invalid formats`, and from " \
110
+ "Rails 8.2 every render in a view goes through it - keep the keyword and read the value under another " \
111
+ "name, with `define_method(:#{READER}) { nil }` beside the declaration, or rename it"
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -24,8 +24,12 @@ module Poetry
24
24
  #
25
25
  # A component whose registry entry declares "identity" => false
26
26
  # renders no poetry-minted id - key:/id: would have nothing to
27
- # stabilize - so both rules skip it. The registry derivation owns
28
- # that fact per gem; legacy entries without the key keep warning.
27
+ # stabilize - so both rules skip it (a pathless helper declares the
28
+ # same in the registry helpers section). The registry derivation
29
+ # owns that fact per gem; legacy entries without the key keep
30
+ # warning. A double-splat of an id-bearing hash (`**field.control_attributes`,
31
+ # `**attrs`) counts as identity too: the id arrives through the splat,
32
+ # and the scan reads the name, not the hash.
29
33
  #
30
34
  # @api private
31
35
  class StableIdentity
@@ -36,7 +40,8 @@ module Poetry
36
40
  CACHE_OPENER = /\A\s*cache[\s(]/
37
41
  LOOP_OPENER =
38
42
  /\.(?:each|each_with_index|each_with_object|each_slice|map|collect|flat_map)\b.*\bdo\b|\Afor\s.+\sin\s/
39
- IDENTITY_ARG = /(?:\bkey:|\bid:|["']key["']\s*=>|["']id["']\s*=>)/
43
+ IDENTITY_ARG =
44
+ /(?:\bkey:|\bid:|["']key["']\s*=>|["']id["']\s*=>|\*\*[\w.@]*(?:control_attributes|attributes|attrs)\b)/
40
45
 
41
46
  # A stable-identity linter over the catalog.
42
47
  def initialize(catalog)
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ module Check
6
+ # The element rule for component templates: an element is built
7
+ # through `element_tag`, and `tag.attributes` takes its hash as an
8
+ # argument.
9
+ #
10
+ # A host may compile its templates with Herb's slots. That compile
11
+ # resolves Action View's own tag helpers into markup ahead of the
12
+ # render, and three shapes do not survive it: attributes handed over
13
+ # in a splat or in a variable are dropped from the element, and a tag
14
+ # name that is not a literal leaves Ruby that does not parse. Nothing
15
+ # reports the first, and the second fails the page. `element_tag` is
16
+ # a helper Herb does not resolve, so the element is built at render
17
+ # time from what the component handed it.
18
+ #
19
+ # WARNING severity, component templates only: a view that hands a tag
20
+ # helper a splat is valid Rails, and whether its host compiles with
21
+ # slots is not something a lint of the source can know.
22
+ #
23
+ # `strict:` is the rule poetry's own templates are held to: no call to
24
+ # `content_tag` or `tag.<name>` at all, whatever its arguments.
25
+ #
26
+ # @api private
27
+ class TagHelpers
28
+ # An ERB tag with code in it: not a comment, not an escaped tag.
29
+ ERB_TAG = /<%(?![#%])(?:==|=|-)?(.*?)-?%>/m
30
+
31
+ # A tag-helper linter; it reads the source alone and needs no catalog.
32
+ #
33
+ # @param strict [Boolean] flag every call to an Action View tag
34
+ # helper, not only the shapes a slot compile gets wrong
35
+ def initialize(strict: false)
36
+ @strict = strict
37
+ end
38
+
39
+ # The findings for one ERB source.
40
+ #
41
+ # @param source [String] the template
42
+ # @return [Array<Finding>]
43
+ def lint(source)
44
+ require "prism"
45
+
46
+ source.to_enum(:scan, ERB_TAG).flat_map do
47
+ match = Regexp.last_match
48
+ line = source[0...match.begin(1)].count("\n") + 1
49
+ calls_in(Prism.parse(match[1]).value).filter_map { |call| finding_for(call, line) }
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ # Every call to `content_tag`, `tag.<name>` or `tag.attributes` under a node.
56
+ def calls_in(node, into = [])
57
+ return into unless node
58
+
59
+ into << node if node.is_a?(Prism::CallNode) && tag_helper?(node)
60
+ node.compact_child_nodes.each { |child| calls_in(child, into) } if node.respond_to?(:compact_child_nodes)
61
+ into
62
+ end
63
+
64
+ # Whether a call is `content_tag(...)` or a method on the bare `tag` builder.
65
+ def tag_helper?(call)
66
+ return true if call.name == :content_tag && call.receiver.nil?
67
+
68
+ builder = call.receiver
69
+ builder.is_a?(Prism::CallNode) && builder.name == :tag && builder.receiver.nil? && builder.arguments.nil?
70
+ end
71
+
72
+ # The finding for one call, or nil when the call is one a slot compile reads right.
73
+ def finding_for(call, base_line)
74
+ line = base_line + call.location.start_line - 1
75
+ written = written(call)
76
+ return attributes_finding(call, written, line) if call.name == :attributes && call.receiver
77
+
78
+ problem = problem_with(call)
79
+ return unless problem || @strict
80
+
81
+ Finding.new(rule: "element-tag", severity: :warning, line: line,
82
+ message: "#{written} #{problem || "is an Action View tag helper"} - " \
83
+ "build the element with #{replacement(call)}")
84
+ end
85
+
86
+ # The finding for `tag.attributes` handed a splat; its hash as an argument is the form to keep.
87
+ def attributes_finding(call, written, line)
88
+ return unless splat?(call)
89
+
90
+ Finding.new(rule: "element-tag", severity: :warning, line: line,
91
+ message: "#{written} hands its hash over as a splat, which a host compiling with " \
92
+ "Herb's slots drops from the element - pass it as an argument: " \
93
+ "tag.attributes(#{splat_source(call)})")
94
+ end
95
+
96
+ # What a slot compile gets wrong about a call, in words, or nil.
97
+ def problem_with(call)
98
+ if call.name == :content_tag && !literal_name?(call)
99
+ "takes its tag name from a variable, which stops the template compiling in a host " \
100
+ "that compiles with Herb's slots"
101
+ elsif splat?(call) || variable_options?(call)
102
+ "takes attributes a host compiling with Herb's slots cannot read, and drops them from the element"
103
+ end
104
+ end
105
+
106
+ # Whether `content_tag` names its tag with a symbol or a string literal.
107
+ def literal_name?(call)
108
+ first = positional(call).first
109
+
110
+ first.is_a?(Prism::SymbolNode) || first.is_a?(Prism::StringNode)
111
+ end
112
+
113
+ # Whether a call hands over a double splat.
114
+ def splat?(call)
115
+ keywords(call).any?(Prism::AssocSplatNode)
116
+ end
117
+
118
+ # Whether a block call takes its attributes as a variable: with a
119
+ # block, the argument after the tag name is the options.
120
+ def variable_options?(call)
121
+ return false unless call.block
122
+
123
+ options = call.name == :content_tag ? positional(call)[1] : positional(call)[0]
124
+ !options.nil? && !options.is_a?(Prism::HashNode)
125
+ end
126
+
127
+ # The arguments that are not keywords.
128
+ def positional(call)
129
+ (call.arguments&.arguments || []).grep_v(Prism::KeywordHashNode)
130
+ end
131
+
132
+ # The elements of the keyword hash, splats among them.
133
+ def keywords(call)
134
+ (call.arguments&.arguments || []).grep(Prism::KeywordHashNode).flat_map(&:elements)
135
+ end
136
+
137
+ # The call as it is written, up to its arguments.
138
+ def written(call)
139
+ call.receiver ? "tag.#{call.name}" : "content_tag"
140
+ end
141
+
142
+ # The `element_tag` call to write instead.
143
+ def replacement(call)
144
+ return "element_tag(:#{call.name}, ...)" if call.receiver
145
+
146
+ "element_tag(#{positional(call).first&.slice || ":name"}, ...)"
147
+ end
148
+
149
+ # The expression a splat spreads.
150
+ def splat_source(call)
151
+ keywords(call).grep(Prism::AssocSplatNode).first&.value&.slice || "attributes"
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -197,12 +197,16 @@ module Poetry
197
197
  # that yields its slot builder.
198
198
  def helper_yields?(helper) = !@helper_entries.dig(helper, "yields").nil?
199
199
 
200
- # A helper whose component entry declares "identity" => false:
201
- # its rendered output carries no poetry-minted id, so key:/id:
202
- # have nothing to stabilize and the stable-identity rules skip
203
- # it. Pathless helpers and legacy entries (no key) read as
204
- # minting - their warnings stand.
205
- def identity_free?(helper) = @components.dig(@path_by_helper[helper], "identity") == false
200
+ # A helper whose component entry declares "identity" => false, or
201
+ # a pathless helper whose registry helpers-section entry declares
202
+ # it (a table part helper stamps no poetry-minted id): its rendered
203
+ # output carries no random id, so key:/id: have nothing to
204
+ # stabilize and the stable-identity rules skip it. Legacy entries
205
+ # (no key) read as minting - their warnings stand.
206
+ def identity_free?(helper)
207
+ @components.dig(@path_by_helper[helper], "identity") == false ||
208
+ @helper_entries.dig(helper, "identity") == false
209
+ end
206
210
 
207
211
  # The element-level wiring projection: which elements of
208
212
  # a component carry which controllers, values, actions, and
@@ -419,10 +423,11 @@ module Poetry
419
423
  node.class.name.include?("ERB") && !erb_comment?(node)
420
424
  end
421
425
 
422
- # <%# ... %> parses as an ERBContentNode like any output tag - only
423
- # the tag opening tells prose from code. Comment text mentioning a
424
- # helper ("a plain poetry_input chromes it") must never reach Prism
425
- # as Ruby (the helper-arity false-positive class).
426
+ # <%# ... %> carries content like any output tag, whichever node
427
+ # class the parser gives it - only the tag opening tells prose from
428
+ # code. Comment text mentioning a helper ("a plain poetry_input
429
+ # chromes it") must never reach Prism as Ruby (the helper-arity
430
+ # false-positive class).
426
431
  def erb_comment?(node)
427
432
  node.respond_to?(:tag_opening) && node.tag_opening&.value == "<%#"
428
433
  end
@@ -1373,9 +1378,10 @@ module Poetry
1373
1378
  # A click target that is not a button: an inert element carrying
1374
1379
  # onclick or wearing role=button. A real <button> brings focus,
1375
1380
  # keyboard activation, and the role for free, which is the first
1376
- # rule of ARIA. Anchors stay out (a role=button link is an accepted
1377
- # pattern), and so does a Stimulus click on a bare div - a backdrop
1378
- # or a row is a legitimate click target without a role.
1381
+ # rule of ARIA. Anchors stay out (a link is a link - the Button's
1382
+ # anchor form renders no role, and an author who adds one has
1383
+ # chosen it), and so does a Stimulus click on a bare div - a
1384
+ # backdrop or a row is a legitimate click target without a role.
1379
1385
  def fake_button_findings(node)
1380
1386
  tag = open_tag_name(node)
1381
1387
  attributes = node.child_nodes.compact.grep(Herb::AST::HTMLAttributeNode)
@@ -1656,11 +1662,21 @@ module Poetry
1656
1662
  # @api private
1657
1663
  class Runner
1658
1664
  # A runner over the catalog's linters.
1659
- def initialize(catalog)
1665
+ #
1666
+ # @param catalog [Catalog] the component catalog to validate against
1667
+ # @param compile [Compile, nil] the compile tier, which a host's
1668
+ # check hands over and a gem's own leaves to its compile gate
1669
+ # @param renderable [RenderableReaders] the renderable rule for
1670
+ # component classes, an error where the host's Rails renders every
1671
+ # view through the renderer
1672
+ def initialize(catalog, compile: nil, renderable: RenderableReaders.new)
1660
1673
  @linter = Linter.new(catalog)
1661
1674
  @declarations = IconDeclarations.new(catalog)
1662
1675
  @internals = InternalConstants.new(catalog)
1663
1676
  @stable_identity = StableIdentity.new(catalog)
1677
+ @tag_helpers = TagHelpers.new
1678
+ @compile = compile
1679
+ @renderable = renderable
1664
1680
  end
1665
1681
 
1666
1682
  # Every finding across the paths, each stamped with its file.
@@ -1683,12 +1699,19 @@ module Poetry
1683
1699
  return [Finding.new(rule: "unreadable", severity: :warning,
1684
1700
  message: "not valid UTF-8 - skipped (re-encode the file to lint it)")]
1685
1701
  end
1686
- return @declarations.lint(source) + @internals.lint(source) if path.end_with?(".rb")
1702
+ if path.end_with?(".rb")
1703
+ return @declarations.lint(source) + @internals.lint(source) + @renderable.lint(source)
1704
+ end
1687
1705
 
1688
1706
  findings = @linter.lint(source, mail: Check.mail_template?(path, root: root))
1689
1707
  # The StableId heuristics ride every ERB pass (warnings only -
1690
- # they never flip the exit code).
1691
- findings + @stable_identity.lint(source)
1708
+ # they never flip the exit code); the element rule rides a
1709
+ # component's template, and so does the compile tier when the
1710
+ # runner was handed one.
1711
+ findings += @stable_identity.lint(source)
1712
+ findings += @tag_helpers.lint(source) if Check.component_template?(path)
1713
+ findings += @compile.lint(source, filename: path) if @compile && Compile.html_template?(path)
1714
+ findings
1692
1715
  rescue SystemCallError, IOError, ArgumentError, Encoding::CompatibilityError => e
1693
1716
  [Finding.new(rule: "unreadable", severity: :warning, message: "#{e.class}: #{e.message} - skipped")]
1694
1717
  end
@@ -1702,8 +1725,21 @@ module Poetry
1702
1725
  # leave these alone.
1703
1726
  MAIL_TEMPLATE = %r{(?:\A|/)(?:[a-z0-9_]+_mailer|layouts/mailer|devise/mailer)(?:/|\.)}
1704
1727
 
1728
+ # A component's template by convention: an ERB file under
1729
+ # `app/components/`, where a sidecar template lives.
1730
+ COMPONENT_TEMPLATE = %r{(?:\A|/)app/components/.+\.erb\z}
1731
+
1705
1732
  module_function
1706
1733
 
1734
+ # Whether a template path is a component's template by convention:
1735
+ # an ERB file under `app/components/`.
1736
+ #
1737
+ # @param path [String]
1738
+ # @return [Boolean]
1739
+ def component_template?(path)
1740
+ COMPONENT_TEMPLATE.match?(path.to_s)
1741
+ end
1742
+
1707
1743
  # Whether a template path is a mailer template by convention: a view
1708
1744
  # under a `*_mailer/` directory (Devise's `devise/mailer/` included)
1709
1745
  # or the mailer layout. With a root, only the path inside it counts,
@@ -44,14 +44,23 @@ module Poetry
44
44
  end
45
45
 
46
46
  # The app's and its engines' own components' helpers (`helper :name`):
47
- # defined at boot and on every reload, after the components directories load,
48
- # so a view can call one before anything else referenced the class.
47
+ # defined before the first view renders and again on every reload, so a
48
+ # view can call one before anything else referenced the class.
49
+ #
50
+ # The components are loaded to find the helpers, and a component is a
51
+ # view. Loading them while the framework is still initializing loads
52
+ # Action View early, which Rails reports and, when asked, raises on.
53
+ # Loading them from Action View's own load hook is no better: when a
54
+ # component class is what loads Action View, the classes are half
55
+ # defined. So a host that eager loads syncs once its boot is done, with
56
+ # the classes already loaded, and every other host syncs on its first
57
+ # view context, at render time. Each reload syncs from `to_prepare`.
49
58
  initializer "poetry_core.host_helpers" do |app|
50
- app.config.to_prepare do
51
- Poetry::Core::HostHelpers.sync!(Poetry::Core::HostComponents.discover)
52
- ActiveSupport.on_load(:action_view) do
53
- include Poetry::Core::HostHelpers unless include?(Poetry::Core::HostHelpers)
54
- end
59
+ app.config.after_initialize { Poetry::Core::HostHelpers.sync_now! if app.config.eager_load }
60
+ app.config.to_prepare { Poetry::Core::HostHelpers.sync_now! if Poetry::Core::HostHelpers.ready? }
61
+ ActiveSupport.on_load(:action_view) do
62
+ include Poetry::Core::HostHelpers unless include?(Poetry::Core::HostHelpers)
63
+ prepend Poetry::Core::HostHelpers::FirstView unless self < Poetry::Core::HostHelpers::FirstView
55
64
  end
56
65
  end
57
66
 
@@ -15,7 +15,45 @@ module Poetry
15
15
  #
16
16
  # @api private
17
17
  module HostHelpers
18
+ # Prepended to Action View's base class by the engine: the first view
19
+ # context of a host that did not eager load syncs the helpers, so the
20
+ # template about to render can call one. After that it is one
21
+ # comparison per view.
22
+ #
23
+ # @api private
24
+ module FirstView
25
+ # The view context, with the helpers defined first.
26
+ def initialize(...)
27
+ Poetry::Core::HostHelpers.sync_now! unless Poetry::Core::HostHelpers.ready?
28
+ super
29
+ end
30
+ end
31
+
32
+ SYNC = Mutex.new
33
+ private_constant :SYNC
34
+
18
35
  class << self
36
+ # Discovers the app's components and defines their helpers: the
37
+ # engine's sync, at the end of an eager loading boot, at the first
38
+ # view otherwise, and on every reload. One at a time, since two views
39
+ # can be the first on two threads.
40
+ #
41
+ # @return [Array<String>] the helper names now defined
42
+ def sync_now!
43
+ SYNC.synchronize do
44
+ names = sync!(HostComponents.discover)
45
+ @ready = true
46
+ names
47
+ end
48
+ end
49
+
50
+ # Whether the first sync has happened, so a reload may sync again.
51
+ #
52
+ # @return [Boolean]
53
+ def ready?
54
+ @ready == true
55
+ end
56
+
19
57
  # Defines the helpers of `components` and removes the helpers of
20
58
  # components that no longer declare one (a rename, a deletion).
21
59
  # Every name is checked before any is defined, so a clash leaves
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ # What a slot compile writes around a template's own markup, and how to
6
+ # take it off again.
7
+ #
8
+ # A host that compiles its templates with Herb's slots gets every
9
+ # dynamic part of the page marked, so the browser can find it again:
10
+ # comments around a rendering and around each slot, a `data-herb-slot`
11
+ # attribute on an element that carries one, and the branches that did
12
+ # not render, parked in a `<template>` behind the rendering. None of it
13
+ # is the component's. Taken off, what is left is the markup the template
14
+ # wrote, which is what a test or a contract means to compare.
15
+ #
16
+ # @example
17
+ # html = render_inline(Poetry::Ui::Button::Component.new) { "Save" }.to_html
18
+ # Poetry::Core::SlotMarkers.strip(html) # => the button, as a plain host renders it
19
+ module SlotMarkers
20
+ # A marker comment: a region, a slot, a branch or the seeds, opening
21
+ # or closing.
22
+ COMMENT = %r{<!--/?herb-[a-z]+\b.*?-->}m
23
+
24
+ # The attribute on an element that carries a slot.
25
+ ATTRIBUTE = / data-herb-slot="[^"]*"/
26
+
27
+ # Where a parked block opens: branches under `data-herb-region`, the
28
+ # state map under `data-herb-dependencies`.
29
+ PARKED = /<template data-herb-(?:region|dependencies)\b[^>]*>/
30
+
31
+ # Either end of a template element.
32
+ TEMPLATE_EDGE = %r{<template\b[^>]*>|</template>}
33
+
34
+ class << self
35
+ # The markup without anything a slot compile added. Markup that
36
+ # carries none of it comes back as it went in.
37
+ #
38
+ # @param html [String] rendered markup
39
+ # @return [String] the markup the templates wrote, html-safe when
40
+ # what it was given was
41
+ def strip(html)
42
+ source = html.to_s
43
+ return html unless source.include?("herb-")
44
+
45
+ stripped = without_parked(source).gsub(COMMENT, "").gsub(ATTRIBUTE, "")
46
+ html.respond_to?(:html_safe?) && html.html_safe? ? stripped.html_safe : stripped
47
+ end
48
+
49
+ private
50
+
51
+ # The markup without its parked blocks. A parked branch may hold a
52
+ # template element of its own, so the end of a block is the closing
53
+ # tag that balances its opening one.
54
+ def without_parked(html)
55
+ kept = +""
56
+ rest = html
57
+ while (opening = rest.match(PARKED))
58
+ kept << opening.pre_match
59
+ rest = after_block(opening.post_match)
60
+ end
61
+ kept << rest
62
+ end
63
+
64
+ # What follows the template element whose opening tag was just read.
65
+ def after_block(html)
66
+ depth = 1
67
+ rest = html
68
+ while depth.positive? && (edge = rest.match(TEMPLATE_EDGE))
69
+ depth += edge[0].start_with?("</") ? -1 : 1
70
+ rest = edge.post_match
71
+ end
72
+ depth.zero? ? rest : ""
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -3,8 +3,50 @@
3
3
  module Poetry
4
4
  module Core
5
5
  # The view-helper seam: included into ActionView by the engine
6
- # (initializer "poetry_core.tag_helper"). Carries no helpers in core.
6
+ # (initializer "poetry_core.tag_helper") and into every component, so a
7
+ # view and a component's template build an element the same way.
7
8
  module TagHelper
9
+ # The elements that take no content and no closing tag: {#element_tag}
10
+ # renders these through `tag` and every other name through
11
+ # `content_tag`.
12
+ VOID_ELEMENTS = %w[area base br col embed hr img input link meta source track wbr].freeze
13
+
14
+ # An element from a tag name and its attributes: where a component's
15
+ # template spends what `root_attributes` and `element_attributes`
16
+ # build. It takes what `content_tag` takes and renders what
17
+ # `content_tag` renders; a void element renders the way `tag.input`
18
+ # does.
19
+ #
20
+ # A component's template builds every element through this method,
21
+ # and never through `content_tag` or `tag.div` themselves. A host may
22
+ # compile its templates with Herb's slots, and that compile resolves
23
+ # Action View's own tag helpers into markup ahead of the render: a
24
+ # splat or a tag name held in a variable is more than it can read,
25
+ # and the element comes out without its attributes, or the template
26
+ # stops compiling. A method it does not know stays a call, so the
27
+ # element reaches the page as it was built. `tag.attributes` is the
28
+ # one helper a template still calls itself, for attributes written
29
+ # inside a literal tag, and it takes its hash as an argument, never as
30
+ # a splat. A view holds to the same rule wherever it hands a tag
31
+ # helper a splat.
32
+ #
33
+ # @param name [Symbol, String] the tag name
34
+ # @param arguments [Array] the content, when no block gives it, and
35
+ # the attributes when they arrive as a Hash
36
+ # @param attributes [Hash] what `root_attributes` or a part builder
37
+ # returned, splatted, or the attributes written out as keywords
38
+ # @return [ActiveSupport::SafeBuffer] the element
39
+ # @example A root, a part and a void element
40
+ # <%= element_tag(:button, **root_attributes) do %>
41
+ # <%= element_tag(:span, label, **element_attributes(:label)) %>
42
+ # <%= element_tag(:input, **input_attributes) %>
43
+ # <% end %>
44
+ def element_tag(name, *arguments, **attributes, &)
45
+ return content_tag(name, *arguments, **attributes, &) unless VOID_ELEMENTS.include?(name.to_s)
46
+
47
+ given = arguments.last.is_a?(Hash) ? arguments.last : {}
48
+ tag.public_send(name, **given, **attributes)
49
+ end
8
50
  end
9
51
  end
10
52
  end
@@ -3,15 +3,29 @@
3
3
  module Poetry
4
4
  module Core
5
5
  # The Herb COMPILE gate: every ERB template is fed through
6
- # `Herb::Engine` - the same compiler Rails routes templates through when
7
- # a host opts into the Herb ERB implementation - so a template that
8
- # parses but refuses to compile (ERB output in an attribute name, bare
9
- # output in attribute position, an element nested where the validators
10
- # forbid it) fails poetry's CI instead of the host's first request.
6
+ # `Herb::Engine` - the compiler Rails routes HTML templates through
7
+ # under its 8.2 framework defaults - so a template that parses but
8
+ # refuses to compile (ERB output in an attribute name, bare output in
9
+ # attribute position, an element nested where the validators forbid it)
10
+ # fails poetry's CI instead of the host's first request.
11
+ #
12
+ # The posture is the strictest one a host can run. The engine validates
13
+ # nothing unless it is handed validators, so the gate hands it every
14
+ # validator it ships, each one fatal, and has the compiled Ruby checked
15
+ # for syntax. Rails compiles without validators and its `herb:check`
16
+ # task adds the security one; a host that compiles gem templates with
17
+ # every validator raising is the far end. A template that passes here
18
+ # passes all of them. The validators are named in code, never read from
19
+ # `.herb.yml`, so no configuration can hollow the gate.
20
+ #
21
+ # A second posture, `:rails`, is the one `bin/rails herb:check` holds an
22
+ # app's views to: the security validator alone. `poetry:check` compiles
23
+ # component templates in it, since that task walks the view paths and
24
+ # never reaches them.
11
25
  #
12
26
  # Parsing clean (see CSS::TemplateClasses, the parse gate) and
13
- # compiling clean are different contracts: the engine runs validators
14
- # the parser does not.
27
+ # compiling clean are different contracts: the validators refuse shapes
28
+ # the parser accepts.
15
29
  #
16
30
  # Herb is loaded lazily: it is a build/CI-time tool, not a runtime
17
31
  # dependency of the gem.
@@ -26,6 +40,20 @@ module Poetry
26
40
  # copied verbatim into hosts. Rails renders both through the engine.
27
41
  DEFAULT_GLOBS = ["app/**/*.erb", "lib/generators/**/templates/**/*.erb"].freeze
28
42
 
43
+ # The oldest Herb the gate runs on: from this version the engine takes
44
+ # its validators as visitors and raises compile errors as syntax errors.
45
+ MINIMUM_HERB = "0.11.0"
46
+
47
+ # The validators of each posture, by their names in the engine.
48
+ POSTURES = {
49
+ strict: %i[security nesting accessibility render generator_template],
50
+ rails: %i[security]
51
+ }.freeze
52
+
53
+ # A template the engine refused: its message, and the line the engine
54
+ # points at when it names one.
55
+ Failure = Struct.new(:message, :line)
56
+
29
57
  # A template Herb's engine could not compile, with its message.
30
58
  CompileError = Struct.new(:path, :message) do
31
59
  # The path and the message.
@@ -39,25 +67,50 @@ module Poetry
39
67
 
40
68
  class << self
41
69
  # Compiles one ERB source string, returning the engine's error
42
- # message or nil when it compiles.
70
+ # message or nil when it compiles. A missing or outdated herb gem
71
+ # raises instead: that is a broken setup, not a finding about the
72
+ # template.
43
73
  #
74
+ # @param source [String] the template
75
+ # @param filename [String] the path the engine opens its message with
76
+ # @param posture [Symbol] :strict or :rails
44
77
  # @return [String, nil]
45
- def compile(source, filename: "template.html.erb")
78
+ def compile(source, filename: "template.html.erb", posture: :strict)
79
+ failure(source, filename: filename, posture: posture)&.message
80
+ end
81
+
82
+ # Compiles one ERB source string, returning what the engine refused
83
+ # it for, or nil when it compiles.
84
+ #
85
+ # @param source [String] the template
86
+ # @param filename [String] the path the engine opens its message with
87
+ # @param posture [Symbol] :strict or :rails
88
+ # @return [Failure, nil]
89
+ def failure(source, filename: "template.html.erb", posture: :strict)
46
90
  herb!
47
- Herb::Engine.new(source, filename: filename)
48
- nil
49
- rescue StandardError => e
50
- e.message
91
+ visitors = validators(posture)
92
+
93
+ begin
94
+ Herb::Engine.new(source, filename: filename, validate_ruby: true, visitors: visitors)
95
+ nil
96
+ rescue StandardError, SyntaxError => e
97
+ # The engine raises its compile and parse errors as syntax
98
+ # errors, which a bare rescue would let through.
99
+ Failure.new(e.message, line_of(e))
100
+ end
51
101
  end
52
102
 
53
103
  # Compiles every template under root matching the globs.
54
104
  #
105
+ # @param root [String, Pathname] the directory the globs are read under
106
+ # @param globs [Array<String>] the templates to compile
107
+ # @param posture [Symbol] :strict or :rails
55
108
  # @return [Result] compiled (Integer, templates that compiled) + errors (Array<CompileError>)
56
- def check(root:, globs: DEFAULT_GLOBS)
109
+ def check(root:, globs: DEFAULT_GLOBS, posture: :strict)
57
110
  errors = []
58
111
  paths = globs.flat_map { |glob| Dir.glob(glob, base: root.to_s) }.uniq.sort
59
112
  paths.each do |relative|
60
- message = compile(File.read(File.join(root, relative)), filename: relative)
113
+ message = compile(File.read(File.join(root, relative)), filename: relative, posture: posture)
61
114
  errors << CompileError.new(relative, message) if message
62
115
  end
63
116
  Result.new(paths.size - errors.size, errors)
@@ -65,12 +118,51 @@ module Poetry
65
118
 
66
119
  private
67
120
 
68
- # Requires the herb gem, raising a clear error when it is missing.
121
+ # The validators of a posture, each one fatal. Built per compile,
122
+ # since a validator keeps what it found. The strict posture is every
123
+ # validator the engine ships, so one a later release adds joins it.
124
+ def validators(posture = :strict)
125
+ names = POSTURES.fetch(posture) do
126
+ raise ArgumentError, "unknown posture #{posture.inspect} - one of #{POSTURES.keys.join(", ")}"
127
+ end
128
+ shipped = Herb::Engine::Validators::ALL
129
+ chosen = posture == :strict ? shipped.values : shipped.values_at(*names)
130
+ chosen.map { |validator| validator.new(fatal: true) }
131
+ end
132
+
133
+ # The line an engine error points at: a validator's error carries
134
+ # it, a parse error carries it on its first diagnostic.
135
+ def line_of(error)
136
+ return error.line if error.respond_to?(:line) && error.line
137
+ return unless error.respond_to?(:diagnostics)
138
+
139
+ location = error.diagnostics.first&.location
140
+ location&.start&.line
141
+ end
142
+
143
+ # Requires the herb gem with its engine and validators, raising a
144
+ # clear error when the gem is missing or too old for the gate.
69
145
  def herb!
70
- require "herb"
71
- rescue LoadError
72
- raise Poetry::Core::Error,
73
- "the herb gem is required for the template compile gate - add `gem \"herb\"` to your Gemfile"
146
+ begin
147
+ require "herb"
148
+ rescue LoadError
149
+ raise Poetry::Core::Error,
150
+ "the herb gem is required for the template compile gate - add `gem \"herb\"` to your Gemfile"
151
+ end
152
+
153
+ unless supported?(Herb::VERSION)
154
+ raise Poetry::Core::Error,
155
+ "the template compile gate needs herb #{MINIMUM_HERB} or newer, found #{Herb::VERSION} - " \
156
+ "run `bundle update herb`"
157
+ end
158
+
159
+ require "herb/engine"
160
+ require "herb/engine/validators"
161
+ end
162
+
163
+ # Whether a herb version carries the engine the gate is written for.
164
+ def supported?(version)
165
+ Gem::Version.new(version) >= Gem::Version.new(MINIMUM_HERB)
74
166
  end
75
167
  end
76
168
  end
@@ -4,6 +4,6 @@ module Poetry
4
4
  module Core
5
5
  # The gem version. Every gem in the family carries the same version
6
6
  # and pins poetry-core to it exactly.
7
- VERSION = "0.1.7"
7
+ VERSION = "0.1.9"
8
8
  end
9
9
  end
data/lib/poetry/core.rb CHANGED
@@ -5,6 +5,7 @@ require "zeitwerk"
5
5
  require "active_support/concern"
6
6
  require "active_model/type/symbol"
7
7
  require "active_model/type/list"
8
+ require "active_model/type/html_safe_string"
8
9
 
9
10
  ActiveModel::Type.register(:symbol, ActiveModel::Type::Symbol)
10
11
  ActiveModel::Type.register(:list, ActiveModel::Type::List)
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: poetry-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.1.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Solt
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-20 00:00:00.000000000 Z
10
+ date: 2026-09-30 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -180,13 +180,17 @@ files:
180
180
  - config/importmap.rb
181
181
  - config/state_vocabulary.json
182
182
  - eslint.config.mjs
183
+ - lib/active_model/type/html_safe_string.rb
183
184
  - lib/active_model/type/list.rb
184
185
  - lib/active_model/type/symbol.rb
185
186
  - lib/poetry-core.rb
186
187
  - lib/poetry/core.rb
187
188
  - lib/poetry/core/api_internals.rb
188
189
  - lib/poetry/core/check.rb
190
+ - lib/poetry/core/check/compile.rb
191
+ - lib/poetry/core/check/renderable_readers.rb
189
192
  - lib/poetry/core/check/stable_identity.rb
193
+ - lib/poetry/core/check/tag_helpers.rb
190
194
  - lib/poetry/core/config.rb
191
195
  - lib/poetry/core/contrib/wrapped_helper.rb
192
196
  - lib/poetry/core/css/bem_merger.rb
@@ -225,6 +229,7 @@ files:
225
229
  - lib/poetry/core/registry_items.rb
226
230
  - lib/poetry/core/requires_any.rb
227
231
  - lib/poetry/core/skill_text.rb
232
+ - lib/poetry/core/slot_markers.rb
228
233
  - lib/poetry/core/stable_id.rb
229
234
  - lib/poetry/core/stimulus/builder.rb
230
235
  - lib/poetry/core/stimulus/declarations.rb