poetry-core 0.1.7 → 0.1.8

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: be0bfa6a89f4e90c5ff4227fb55faae46a68c2d8b6a85fbe7b954fe1a859314b
4
+ data.tar.gz: 466897acf54b0ada006299af237b50c8d32fe5ee846c8cac5d4b29723e93d33a
5
5
  SHA512:
6
- metadata.gz: 53de8890c3deeb5f377f4c9d825f9599c499ef4bc66becdfd3266864166a6119024486b32c385fd02ef452af27ec9eebac63c8ab54570bad6a0a758707164637
7
- data.tar.gz: d761c54985f8e367c72b5b9c99d609303b787d8bf13c9857bffee008e2b633a3fd778f719e3a07e70c179c11b7ad9430ba75693c511a4c3f33eb2a7794c325a2
6
+ metadata.gz: d8d10b2fce56d3217292ff638aec3f3680202278f28144ed5a2a91c6f92fd51d2ea288b3b67987a9edc27fc3cb9e1c20e9fab94f44a22596553c98abb8ca69a6
7
+ data.tar.gz: 75db75e6f640a176bf84d99d64f8c4e354fcc563c1f01892b83af1d217ebd7879989d841cb62a4adef7e0a091c870e6bf5689f1410e0322b84fee7c3dc07fb2f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.8] - 2026-09-22
4
+
5
+ ### Added
6
+
7
+ - `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.
8
+
9
+ ### Changed
10
+
11
+ - 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.
12
+ - `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.
13
+
3
14
  ## [0.1.7] - 2026-09-20
4
15
 
5
16
  ### Changed
@@ -382,7 +382,63 @@ module Poetry
382
382
  "(#{self.class.required_content})"
383
383
  end
384
384
 
385
+ # ViewComponent's render_in, with the content block wrapped once so
386
+ # #content can read what the block RETURNED - capture keeps only what
387
+ # the block wrote to the buffer, and a scalar return vanishes there.
388
+ #
389
+ # @param view_context [ActionView::Base] the rendering view
390
+ # @return [ActiveSupport::SafeBuffer] the rendered component
391
+ def render_in(view_context, **)
392
+ @content_guarded = false
393
+ remove_instance_variable(:@content_return) if defined?(@content_return)
394
+ return super unless block_given?
395
+
396
+ recorded = proc { |*args| @content_return = yield(*args) }
397
+ super(view_context, **, &recorded)
398
+ end
399
+
400
+ # The captured content block, guarded once per render: a block that
401
+ # returned a scalar (a number, a symbol, a boolean) while writing
402
+ # nothing to the buffer captures as nothing at all - `poetry_badge {
403
+ # count }` renders an empty badge - so a requires_content component
404
+ # raises in development and test naming the value and the fix, and
405
+ # logs everywhere else; a component without the declaration logs. A
406
+ # block that wrote to the buffer, returned a String, returned nil,
407
+ # returned a collection (an empty loop's each) or composed slots
408
+ # (its last call a with_* setter, which returns the slot or the
409
+ # component) is fine.
410
+ #
411
+ # @return [ActiveSupport::SafeBuffer, nil] the captured content
412
+ def content
413
+ captured = super
414
+ guard_content_return(captured) unless @content_guarded
415
+ captured
416
+ end
417
+
418
+ # The content-return guard behind #content.
419
+ #
420
+ # @param captured [ActiveSupport::SafeBuffer, nil] what capture kept
421
+ # @return [void]
422
+ # @raise [ArgumentError] a scalar return on a requires_content
423
+ # component, in development and test
424
+ def guard_content_return(captured)
425
+ @content_guarded = true
426
+ return unless defined?(@content_return)
427
+
428
+ value = @content_return
429
+ return if value.nil? || value.is_a?(::String) || value.respond_to?(:each) || captured.present?
430
+ return if value.is_a?(ViewComponent::Base) || value.is_a?(ViewComponent::Slot)
431
+
432
+ message = "#{passthrough_owner} content block returned #{value.inspect.truncate(60)} (#{value.class}), " \
433
+ "which captures as nothing - return a String (call to_s on it) or write it with <%= %>"
434
+ raise ArgumentError, message if strict_passthrough? && self.class.required_content
435
+
436
+ Rails.logger&.warn("poetry: #{message}") if defined?(Rails) && Rails.respond_to?(:logger)
437
+ end
438
+ private :guard_content_return
439
+
385
440
  # Indicates whether this component instance is persisted.
441
+
386
442
  # Always returns false as components are not persisted entities.
387
443
  #
388
444
  # @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
@@ -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)
@@ -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
@@ -1373,9 +1377,10 @@ module Poetry
1373
1377
  # A click target that is not a button: an inert element carrying
1374
1378
  # onclick or wearing role=button. A real <button> brings focus,
1375
1379
  # 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.
1380
+ # rule of ARIA. Anchors stay out (a link is a link - the Button's
1381
+ # anchor form renders no role, and an author who adds one has
1382
+ # chosen it), and so does a Stimulus click on a bare div - a
1383
+ # backdrop or a row is a legitimate click target without a role.
1379
1384
  def fake_button_findings(node)
1380
1385
  tag = open_tag_name(node)
1381
1386
  attributes = node.child_nodes.compact.grep(Herb::AST::HTMLAttributeNode)
@@ -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.8"
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.8
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-22 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -180,6 +180,7 @@ 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