poetry-core 0.0.2

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.
Files changed (166) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/DESIGN.md +145 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +30 -0
  6. data/THIRD_PARTY_NOTICES.md +507 -0
  7. data/app/components/poetry/core/box/component.rb +82 -0
  8. data/app/components/poetry/core/component.rb +390 -0
  9. data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
  10. data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
  11. data/app/components/poetry/core/concerns/introspection.rb +533 -0
  12. data/app/components/poetry/core/concerns/options.rb +262 -0
  13. data/app/components/poetry/core/concerns/parts.rb +148 -0
  14. data/app/components/poetry/core/concerns/stimulus.rb +363 -0
  15. data/app/components/poetry/core/concerns/styles.rb +323 -0
  16. data/app/components/poetry/core/style.rb +150 -0
  17. data/app/components/poetry/core/wrapper/component.rb +90 -0
  18. data/app/javascript/poetry/core/accordion_controller.js +165 -0
  19. data/app/javascript/poetry/core/action_bar_controller.js +109 -0
  20. data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
  21. data/app/javascript/poetry/core/calendar_controller.js +406 -0
  22. data/app/javascript/poetry/core/carousel_controller.js +167 -0
  23. data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
  24. data/app/javascript/poetry/core/checked_controller.js +208 -0
  25. data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
  26. data/app/javascript/poetry/core/combobox_controller.js +1095 -0
  27. data/app/javascript/poetry/core/command_controller.js +510 -0
  28. data/app/javascript/poetry/core/context_menu_controller.js +204 -0
  29. data/app/javascript/poetry/core/date_field_controller.js +584 -0
  30. data/app/javascript/poetry/core/date_picker_controller.js +111 -0
  31. data/app/javascript/poetry/core/deferred_controller.js +111 -0
  32. data/app/javascript/poetry/core/dialog_controller.js +206 -0
  33. data/app/javascript/poetry/core/dismissable_controller.js +182 -0
  34. data/app/javascript/poetry/core/drawer_controller.js +365 -0
  35. data/app/javascript/poetry/core/file_input_controller.js +191 -0
  36. data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
  37. data/app/javascript/poetry/core/helpers/announce.js +228 -0
  38. data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
  39. data/app/javascript/poetry/core/helpers/collection.js +18 -0
  40. data/app/javascript/poetry/core/helpers/direction.js +15 -0
  41. data/app/javascript/poetry/core/helpers/escape.js +38 -0
  42. data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
  43. data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
  44. data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
  45. data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
  46. data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
  47. data/app/javascript/poetry/core/helpers/mask.js +283 -0
  48. data/app/javascript/poetry/core/helpers/portal.js +217 -0
  49. data/app/javascript/poetry/core/helpers/presence.js +210 -0
  50. data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
  51. data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
  52. data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
  53. data/app/javascript/poetry/core/helpers/state.js +80 -0
  54. data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
  55. data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
  56. data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
  57. data/app/javascript/poetry/core/hotkey_controller.js +55 -0
  58. data/app/javascript/poetry/core/hover_card_controller.js +431 -0
  59. data/app/javascript/poetry/core/index.js +218 -0
  60. data/app/javascript/poetry/core/mask_controller.js +500 -0
  61. data/app/javascript/poetry/core/menu_controller.js +1050 -0
  62. data/app/javascript/poetry/core/menubar_controller.js +329 -0
  63. data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
  64. data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
  65. data/app/javascript/poetry/core/number_field_controller.js +404 -0
  66. data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
  67. data/app/javascript/poetry/core/otp_controller.js +219 -0
  68. data/app/javascript/poetry/core/popover_controller.js +300 -0
  69. data/app/javascript/poetry/core/popper_controller.js +439 -0
  70. data/app/javascript/poetry/core/pressed_controller.js +74 -0
  71. data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
  72. data/app/javascript/poetry/core/radio_group_controller.js +197 -0
  73. data/app/javascript/poetry/core/resizable_controller.js +202 -0
  74. data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
  75. data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
  76. data/app/javascript/poetry/core/search_field_controller.js +95 -0
  77. data/app/javascript/poetry/core/select_controller.js +924 -0
  78. data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
  79. data/app/javascript/poetry/core/sheet_controller.js +24 -0
  80. data/app/javascript/poetry/core/sidebar_controller.js +272 -0
  81. data/app/javascript/poetry/core/slider_controller.js +419 -0
  82. data/app/javascript/poetry/core/state_controller.js +82 -0
  83. data/app/javascript/poetry/core/table_selection_controller.js +153 -0
  84. data/app/javascript/poetry/core/tabs_controller.js +151 -0
  85. data/app/javascript/poetry/core/tag_group_controller.js +134 -0
  86. data/app/javascript/poetry/core/toast_controller.js +228 -0
  87. data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
  88. data/app/javascript/poetry/core/toaster_controller.js +197 -0
  89. data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
  90. data/app/javascript/poetry/core/tooltip_controller.js +541 -0
  91. data/app/javascript/poetry/core/tree_controller.js +265 -0
  92. data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
  93. data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
  94. data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
  95. data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
  96. data/app/views/poetry/core/preview.html.erb +13 -0
  97. data/config/component_registry.yml +20 -0
  98. data/config/controllers_manifest.json +1528 -0
  99. data/config/importmap.rb +11 -0
  100. data/config/state_vocabulary.json +31 -0
  101. data/lib/active_model/type/list.rb +33 -0
  102. data/lib/active_model/type/symbol.rb +36 -0
  103. data/lib/poetry/core/check/stable_identity.rb +104 -0
  104. data/lib/poetry/core/check.rb +1445 -0
  105. data/lib/poetry/core/config.rb +286 -0
  106. data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
  107. data/lib/poetry/core/css/bem_merger.rb +45 -0
  108. data/lib/poetry/core/css/bem_reference.rb +87 -0
  109. data/lib/poetry/core/css/override_scan.rb +110 -0
  110. data/lib/poetry/core/css/resolver.rb +161 -0
  111. data/lib/poetry/core/css/safelist.rb +41 -0
  112. data/lib/poetry/core/css/tailwind_merger.rb +102 -0
  113. data/lib/poetry/core/css/template_classes.rb +102 -0
  114. data/lib/poetry/core/css/theme_coverage.rb +72 -0
  115. data/lib/poetry/core/css/var_coverage.rb +75 -0
  116. data/lib/poetry/core/css/verifier.rb +106 -0
  117. data/lib/poetry/core/design_lint.rb +720 -0
  118. data/lib/poetry/core/design_md/import.rb +250 -0
  119. data/lib/poetry/core/design_md.rb +461 -0
  120. data/lib/poetry/core/engine.rb +71 -0
  121. data/lib/poetry/core/errors.rb +53 -0
  122. data/lib/poetry/core/html/attributes.rb +667 -0
  123. data/lib/poetry/core/icons.rb +157 -0
  124. data/lib/poetry/core/llms_text.rb +354 -0
  125. data/lib/poetry/core/page_architectures.rb +304 -0
  126. data/lib/poetry/core/part_contract.rb +285 -0
  127. data/lib/poetry/core/preview/abstract.rb +121 -0
  128. data/lib/poetry/core/preview/base.rb +227 -0
  129. data/lib/poetry/core/preview/sidecarable.rb +136 -0
  130. data/lib/poetry/core/preview/template.rb +186 -0
  131. data/lib/poetry/core/recipe_items.rb +82 -0
  132. data/lib/poetry/core/registry.rb +363 -0
  133. data/lib/poetry/core/registry_address.rb +115 -0
  134. data/lib/poetry/core/registry_client.rb +217 -0
  135. data/lib/poetry/core/registry_installer.rb +234 -0
  136. data/lib/poetry/core/registry_items.rb +184 -0
  137. data/lib/poetry/core/skill_text.rb +288 -0
  138. data/lib/poetry/core/stable_id.rb +110 -0
  139. data/lib/poetry/core/stimulus/builder.rb +395 -0
  140. data/lib/poetry/core/stimulus/declarations.rb +444 -0
  141. data/lib/poetry/core/stimulus/manifest.rb +68 -0
  142. data/lib/poetry/core/stimulus/merger.rb +131 -0
  143. data/lib/poetry/core/stimulus_contract.rb +263 -0
  144. data/lib/poetry/core/tag_helper.rb +10 -0
  145. data/lib/poetry/core/template_compile.rb +74 -0
  146. data/lib/poetry/core/token_import.rb +317 -0
  147. data/lib/poetry/core/tokens/color.rb +215 -0
  148. data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
  149. data/lib/poetry/core/tokens/generator.rb +211 -0
  150. data/lib/poetry/core/tokens.rb +117 -0
  151. data/lib/poetry/core/version.rb +9 -0
  152. data/lib/poetry/core.rb +52 -0
  153. data/lib/poetry-core.rb +5 -0
  154. data/tokens/aliases.css +145 -0
  155. data/tokens/tailwind-theme.css +47 -0
  156. data/tokens/tokens.css +78 -0
  157. data/tokens/tokens.dtcg.json +779 -0
  158. data/vendor/floating-ui/LICENSE +20 -0
  159. data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
  160. data/vendor/shadcn-tailwind/LICENSE +21 -0
  161. data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
  162. data/vendor/shadcn-tailwind/tailwind.css +629 -0
  163. data/vendor/tw-animate-css/LICENSE +21 -0
  164. data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
  165. data/vendor/tw-animate-css/tw-animate.css +1 -0
  166. metadata +267 -0
@@ -0,0 +1,533 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ module Concerns
6
+ # The prop-introspection shim: a machine-readable
7
+ # description of a component's public surface - style attributes,
8
+ # options, and slots - derived from the metadata the Styles/Options
9
+ # DSLs and ViewComponent already carry. This is the single source the
10
+ # generated registry, the docs tables, and the MCP prop schema are
11
+ # built from; nothing here is hand-authored.
12
+ module Introspection
13
+ extend ActiveSupport::Concern
14
+
15
+ # The registry's marker for a proc default (its value depends on
16
+ # other attributes and is unknowable statically).
17
+ DYNAMIC_DEFAULT = :dynamic
18
+ # The parameter kinds counted toward positional arity.
19
+ POSITIONAL_KINDS = %i[req opt].freeze
20
+ # Parameter kinds that make a keyword surface open or unknowable
21
+ # (**rest accepts anything; a positional can swallow a braceless
22
+ # hash), and the kinds that ARE the keyword surface.
23
+ OPEN_PARAMETER_KINDS = %i[keyrest rest req opt].freeze
24
+ # The parameter kinds that ARE the keyword surface.
25
+ KEYWORD_PARAMETER_KINDS = %i[key keyreq].freeze
26
+
27
+ class_methods do
28
+ # The component's full prop surface.
29
+ #
30
+ # @return [Hash] { styles: [...], options: [...], slots: [...] }
31
+ # Documents a slot declared with renders_one/renders_many. The
32
+ # string travels the same road as option/style doc: params - the
33
+ # registry, the agent surface, and the generated API docs.
34
+ # Prefer the doc: keyword on the declaration itself (it lands
35
+ # here); call slot_doc directly only when the doc and the
36
+ # declaration live in different modules.
37
+ #
38
+ # @param name [Symbol] the slot name as declared (plural for
39
+ # renders_many)
40
+ # @param text [String] one reference-register sentence
41
+ # @return [void]
42
+ #
43
+ # @example
44
+ # slot_doc :trigger, "The button that opens the dialog."
45
+ def slot_doc(name, text)
46
+ declared_ivar_hash(:@_slot_docs)[name.to_sym] = text
47
+ end
48
+
49
+ # The slot_doc strings, hierarchy-wide (nearest wins).
50
+ #
51
+ # @return [Hash{Symbol => String}]
52
+ def slot_docs
53
+ collect_declared_map(:@_slot_docs)
54
+ end
55
+
56
+ # ViewComponent's renders_one, with the doc riding the
57
+ # declaration: doc: is lifted into {slot_doc}, renders: passes
58
+ # the callable as a keyword so the doc can come first, and the
59
+ # polymorphic types: form is re-formed into the positional hash
60
+ # ViewComponent expects - the ViewComponent surface underneath
61
+ # is unchanged. Unknown keywords raise at class load, and a
62
+ # positional callable cannot be combined with renders:/types:.
63
+ #
64
+ # @param slot_name [Symbol] the slot name
65
+ # @param callable [Object, nil] ViewComponent's positional
66
+ # callable (a component class, class-name string, or lambda)
67
+ # @param opts [Hash] doc:, renders:, and/or types:
68
+ # @return [void]
69
+ #
70
+ # @example A documented slot
71
+ # renders_one :leading, doc: "Optional leading visual."
72
+ #
73
+ # @example A documented lambda slot, doc first
74
+ # renders_one :trigger,
75
+ # doc: "The button that opens the dialog.",
76
+ # renders: lambda { |**options, &block| ... }
77
+ def renders_one(slot_name, callable = nil, **opts)
78
+ callable = declared_slot_callable(slot_name, callable, opts)
79
+ callable.nil? ? super(slot_name) : super(slot_name, callable)
80
+ end
81
+
82
+ # ViewComponent's renders_many with the same keyword surface as
83
+ # {renders_one}; doc: describes the collection contract and is
84
+ # keyed by the plural declared name, exactly like {slot_doc}.
85
+ #
86
+ # @param slot_name [Symbol] the plural slot name
87
+ # @param callable [Object, nil] ViewComponent's positional
88
+ # callable (a component class, class-name string, or lambda)
89
+ # @param opts [Hash] doc:, renders:, and/or types:
90
+ # @return [void]
91
+ #
92
+ # @example A documented collection slot
93
+ # renders_many :items,
94
+ # doc: "The rows, rendered in call order.",
95
+ # renders: lambda { |label:, **options| ... }
96
+ def renders_many(slot_name, callable = nil, **opts)
97
+ callable = declared_slot_callable(slot_name, callable, opts)
98
+ callable.nil? ? super(slot_name) : super(slot_name, callable)
99
+ end
100
+
101
+ # The component's full declared surface - styles, options, slots
102
+ # (with descriptions), required slots, and requires_any groups -
103
+ # as the registry generator serializes it.
104
+ #
105
+ # @return [Hash{Symbol => Object}]
106
+ def prop_definitions
107
+ slots = slot_definitions
108
+ {
109
+ styles: style_attributes.map { |name| style_definition(name) },
110
+ options: option_attributes.map { |name| option_definition(name) },
111
+ slots: slots,
112
+ slot_extras: slot_extras,
113
+ required_slots: Introspection.required_slots_surface(self, slots),
114
+ requires_any: Introspection.requires_any_surface(self, slots)
115
+ }
116
+ end
117
+
118
+ private
119
+
120
+ # Resolves a renders_one/renders_many declaration's keyword
121
+ # surface back to the positional callable ViewComponent expects.
122
+ # Everything but doc:/renders: must be the polymorphic types:
123
+ # hash - anything else is a typo caught at class load.
124
+ def declared_slot_callable(slot_name, callable, opts)
125
+ doc = opts.delete(:doc)
126
+ slot_doc(slot_name, doc) if doc
127
+ renders = opts.delete(:renders)
128
+ unknown = opts.keys - [:types]
129
+ if unknown.any?
130
+ raise Poetry::Core::Error,
131
+ "#{self}##{slot_name}: unknown slot option(s) #{unknown.join(", ")} - " \
132
+ "a slot declaration takes doc:, renders:, and types:"
133
+ end
134
+ if callable && (renders || opts.any?)
135
+ raise Poetry::Core::Error,
136
+ "#{self}##{slot_name}: pass the callable positionally OR as renders:/types:, not both"
137
+ end
138
+
139
+ callable || renders || (opts unless opts.empty?)
140
+ end
141
+
142
+ def style_definition(name)
143
+ definition = { name: name, type: attribute_types[name.to_s].type }
144
+ variants = respond_to?("#{name}_variants") ? public_send("#{name}_variants") : nil
145
+ definition[:variants] = variants if variants.is_a?(Array)
146
+ definition.merge!(default_definition(name, style_attributes_with_static_defaults,
147
+ style_attributes_with_proc_defaults))
148
+ definition[:required] = true if required_attribute?(name)
149
+ doc = style_docs[name.to_sym]
150
+ definition[:description] = doc if doc
151
+ definition
152
+ end
153
+
154
+ def option_definition(name)
155
+ definition = { name: name, type: attribute_types[name.to_s].type }
156
+ # An inclusion validator IS the option's enum contract:
157
+ # projecting it makes every enum option statically
158
+ # checkable - select's side:/align:, pagination's
159
+ # current_variant: - through the same value tier style variants
160
+ # already ride. Procs/ranges stay unprojected (unknowable).
161
+ enum = validators_on(name).find { |validator| validator.kind == :inclusion }
162
+ &.options&.dig(:in)
163
+ definition[:variants] = enum if enum.is_a?(Array)
164
+ definition.merge!(default_definition(name, option_attributes_with_static_defaults,
165
+ option_attributes_with_proc_defaults))
166
+ definition[:required] = true if required_attribute?(name)
167
+ format = option_format(name)
168
+ definition[:format] = format if format
169
+ doc = option_docs[name.to_sym]
170
+ definition[:description] = doc if doc
171
+ definition
172
+ end
173
+
174
+ # ViewComponent's registered slots: renders_one -> one, renders_many
175
+ # -> many (ViewComponent registers the plural name with collection).
176
+ # A typed slot (renders_one :icon, Icon::Component) also carries the
177
+ # slot component's registry path - the machine-readable form of "this
178
+ # slot takes that component's props, not a render block" (an
179
+ # agent can only honor a contract a surface
180
+ # states). Recursion, setter arities, and builder surfaces come from
181
+ # the module-level walker.
182
+ def slot_definitions
183
+ Introspection.slot_surface(self)
184
+ end
185
+
186
+ # Hand-rolled with_* conveniences (NavigationMenu#with_link) are
187
+ # part of the consumer call surface even though they are not
188
+ # registered slots.
189
+ def slot_extras
190
+ Introspection.hand_rolled_setters(self, slot_definitions)
191
+ end
192
+
193
+ # The default, keyed three ways: a static value (from ActiveModel's
194
+ # default attributes, may legitimately be false), :dynamic for proc
195
+ # defaults (value depends on other attributes), or no key at all.
196
+ def default_definition(name, static_names, proc_names)
197
+ if proc_names.include?(name)
198
+ { default: DYNAMIC_DEFAULT }
199
+ elsif static_names.include?(name)
200
+ { default: _default_attributes[name.to_s]&.value_before_type_cast }
201
+ else
202
+ {}
203
+ end
204
+ end
205
+
206
+ def required_attribute?(name)
207
+ validators_on(name).any? { |validator| validator.kind == :presence }
208
+ end
209
+ end
210
+
211
+ # The recursive slot walker (composition contracts). Works on
212
+ # ANY slot-owning class - poetry components and their internal
213
+ # builder classes alike (Menubar::Menu is a plain ViewComponent::Base)
214
+ # - so the registry can state the full nested call surface:
215
+ #
216
+ # - types: a polymorphic slot's with_<type> setters
217
+ # - setter_args: max POSITIONAL arity per setter, introspected from
218
+ # the slot lambda / renderable class (a kwargs-only lambda is 0 -
219
+ # forecloses `with_item(:item, ...)`, a
220
+ # type-as-argument convention no setter has)
221
+ # - builders: a class cannot be seen through a wrapping lambda
222
+ # (`->(**o) { Menu.new(bar: self, **o) }`), so a slot-owning class
223
+ # declares SLOT_BUILDERS = { setter => BuilderClass } and the
224
+ # walker recurses into the builder's own surface (cycle-guarded:
225
+ # sub-in-sub terminates by omission, not loop)
226
+ # - yieldless: a slot lambda that declares &block consumes the
227
+ # consumer's block itself (poetry convention: capture(&block) with
228
+ # no arguments), so a block param at the call site is nil at render
229
+ # (e.g. `menu.with_item do |item|`). A lambda that
230
+ # stores the block and calls it WITH arguments later (DataTable's
231
+ # per-row cell renderer) is indistinguishable by signature, so such
232
+ # a class declares SLOT_BLOCK_YIELDS = { setter => what the block
233
+ # receives } and the walker exempts those setters
234
+ # - setter_kwargs: the accepted keyword names of a closed-signature
235
+ # slot lambda (`|classes: nil, &block|`) - any other keyword is an
236
+ # ArgumentError at render (e.g.
237
+ # `with_item(class:)`). Open signatures (**rest), positional-hash
238
+ # signatures, and class renderables (kwargs ride the attributes
239
+ # hash) are unknowable-or-open and stay unemitted.
240
+ # - required_content: a lambda cannot be seen raising "requires a
241
+ # content block" (Carousel with_item), so a slot-owning class
242
+ # declares SLOT_REQUIRED_CONTENT = { setter => hint } (the
243
+ # SLOT_BUILDERS pattern) and the registry states the requirement
244
+ # - required_slots: a before_render raise ("Menubar menu requires
245
+ # with_trigger" - an omission every other
246
+ # check passes silently) cannot be introspected, so a slot-owning class
247
+ # declares REQUIRED_SLOTS = { setter => hint } and the registry
248
+ # states which setters a call cannot omit. Keys must resolve to a
249
+ # declared setter (name / singular / type) - an unresolvable key
250
+ # fails registry generation rather than shipping a rule no
251
+ # template could ever satisfy. Only declared where satisfaction
252
+ # is exactly derivable: a requirement satisfiable through a
253
+ # hand-rolled alias (NavigationMenu's with_item-or-with_link)
254
+ # stays undeclared - a false "missing slot" on a legitimate
255
+ # template is worse than a silent gap.
256
+ # - requires_any: the conditional any-of contracts no
257
+ # single-fact rule can state (Button's
258
+ # "content OR icon slot OR loading:", Command's "id OR
259
+ # aria-label"). A class declares REQUIRES_ANY = [{ hint:,
260
+ # content: true, slots: [...], options: [...] }, ...] mirroring
261
+ # its before_render predicate; a call satisfying NO listed
262
+ # alternative fails poetry check statically. Slot names must
263
+ # resolve; each group needs a hint and at least one alternative.
264
+ # - SLOT_RENDERS = { setter => ComponentClass }: a lambda-wrapped
265
+ # slot that purely forwards **options/&block to one component
266
+ # (Toast's with_action -> Button) hides that component from
267
+ # introspection exactly like SLOT_BUILDERS hides builder classes
268
+ # - the declaration restores the slot's "component" fact so the
269
+ # whole typed-slot rule family (option values, requires_content,
270
+ # requires_any) applies to its callers. Declare ONLY pure
271
+ # forwarders - a lambda that intercepts caller keys would make
272
+ # the projected contract a lie.
273
+ class << self
274
+ # The registry-shaped slot contracts of one slot-owning class -
275
+ # see the walker notes above for every emitted key.
276
+ #
277
+ # @param klass [Class] a component or builder class
278
+ # @param seen [Array<Class>] the recursion guard
279
+ # @return [Array<Hash>]
280
+ # @raise [Poetry::Core::Error] when a SLOT_RENDERS entry is not a
281
+ # poetry component class
282
+ def slot_surface(klass, seen: [])
283
+ return [] unless klass.respond_to?(:registered_slots)
284
+
285
+ builders = declared_builders(klass)
286
+ required_content = declared_required_content(klass)
287
+ block_yields = declared_constant(klass, :SLOT_BLOCK_YIELDS)
288
+ renders = declared_constant(klass, :SLOT_RENDERS)
289
+ slot_docs = klass.respond_to?(:slot_docs) ? klass.slot_docs : {}
290
+ klass.registered_slots.map do |slot_name, config|
291
+ definition = { name: slot_name, many: config[:collection] == true }
292
+ doc = slot_docs[slot_name.to_sym]
293
+ definition[:description] = doc if doc
294
+ renderable = config[:renderable]
295
+ definition[:component] = renderable.component_path if renderable.respond_to?(:component_path)
296
+ # A declared pure-forwarding lambda (SLOT_RENDERS) restores
297
+ # the component fact a wrapping lambda hides.
298
+ # Polymorphic slots stay out - their types are their contract.
299
+ if config[:renderable_hash].nil? &&
300
+ (declared = renders[slot_setters(slot_name, config).first&.to_sym])
301
+ unless declared.respond_to?(:component_path)
302
+ raise Poetry::Core::Error,
303
+ "#{klass}::SLOT_RENDERS[#{slot_name}] must be a poetry component class"
304
+ end
305
+
306
+ definition[:component] ||= declared.component_path
307
+ end
308
+ definition[:types] = config[:renderable_hash].keys if config[:renderable_hash]
309
+ setter_args = setter_positional_args(slot_name, config)
310
+ definition[:setter_args] = setter_args unless setter_args.empty?
311
+ setter_kwargs = setter_keyword_args(slot_name, config)
312
+ definition[:setter_kwargs] = setter_kwargs unless setter_kwargs.empty?
313
+ yieldless = yieldless_setters(slot_name, config) - block_yields.keys
314
+ definition[:yieldless] = yieldless unless yieldless.empty?
315
+ required = required_content.slice(*slot_setters(slot_name, config).map(&:to_sym))
316
+ definition[:required_content] = required unless required.empty?
317
+ surfaces = builder_surfaces(slot_name, config, builders, seen + [klass])
318
+ definition[:builders] = surfaces unless surfaces.empty?
319
+ definition
320
+ end
321
+ end
322
+
323
+ # The validated REQUIRED_SLOTS declaration of a slot-owning class:
324
+ # each key must name a setter the given slot definitions actually
325
+ # generate (the slot itself, a collection's singular, or a
326
+ # polymorphic type).
327
+ #
328
+ # @param klass [Class] the slot-owning class
329
+ # @param definitions [Array<Hash>] the class's {slot_surface}
330
+ # @return [Hash{String => String}] setter name => hint
331
+ # @raise [Poetry::Core::Error] for a key matching no slot setter
332
+ def required_slots_surface(klass, definitions)
333
+ declared_constant(klass, :REQUIRED_SLOTS).to_h do |key, hint|
334
+ name = key.to_s
335
+ unless definitions.any? { |slot| resolves_setter?(slot, name) }
336
+ raise Poetry::Core::Error,
337
+ "#{klass}::REQUIRED_SLOTS key #{key.inspect} matches no slot setter"
338
+ end
339
+
340
+ [name, hint]
341
+ end
342
+ end
343
+
344
+ # The validated REQUIRES_ANY declaration: each group needs
345
+ # a hint plus at least one alternative, and slot alternatives must
346
+ # name setters the definitions actually generate.
347
+ #
348
+ # @param klass [Class] the slot-owning class
349
+ # @param definitions [Array<Hash>] the class's {slot_surface}
350
+ # @return [Array<Hash>] one normalized group per declaration
351
+ # (hint, plus content/slots/options as declared)
352
+ # @raise [Poetry::Core::Error] for a group without a hint or an
353
+ # alternative, or a slot matching no slot setter
354
+ def requires_any_surface(klass, definitions)
355
+ declared_constant(klass, :REQUIRES_ANY).map do |group|
356
+ group = group.transform_keys(&:to_s)
357
+ slots = (group["slots"] || []).map(&:to_s)
358
+ options = (group["options"] || []).map(&:to_s)
359
+ unless group["hint"] && (group["content"] || slots.any? || options.any?)
360
+ raise Poetry::Core::Error,
361
+ "#{klass}::REQUIRES_ANY group needs a hint and at least one alternative"
362
+ end
363
+
364
+ slots.each do |name|
365
+ next if definitions.any? { |slot| resolves_setter?(slot, name) }
366
+
367
+ raise Poetry::Core::Error,
368
+ "#{klass}::REQUIRES_ANY slot #{name.inspect} matches no slot setter"
369
+ end
370
+ emitted = { "hint" => group["hint"] }
371
+ emitted["content"] = true if group["content"]
372
+ emitted["slots"] = slots if slots.any?
373
+ emitted["options"] = options if options.any?
374
+ emitted
375
+ end
376
+ end
377
+
378
+ # Every own with_* method that is neither a slot-generated setter
379
+ # (with_<name>/<singular>/<type> and their _content twins) nor
380
+ # inherited - NavigationMenu#with_link, PieChart's with_py.
381
+ #
382
+ # @param klass [Class] the slot-owning class
383
+ # @param definitions [Array<Hash>] the class's {slot_surface}
384
+ # @return [Array<String>] the setter names without their with_
385
+ # prefix, sorted
386
+ def hand_rolled_setters(klass, definitions)
387
+ generated = definitions.flat_map do |slot|
388
+ names = [slot[:name].to_s]
389
+ names << slot[:name].to_s.delete_suffix("s") if slot[:many]
390
+ names.concat((slot[:types] || []).map(&:to_s))
391
+ names
392
+ end
393
+ klass.public_instance_methods(false).map(&:to_s)
394
+ .select { |method| method.start_with?("with_") }
395
+ .reject { |method| method.end_with?("_content") }
396
+ .map { |method| method.delete_prefix("with_") }
397
+ .sort - generated
398
+ end
399
+
400
+ private
401
+
402
+ def declared_builders(klass)
403
+ declared_constant(klass, :SLOT_BUILDERS)
404
+ end
405
+
406
+ def declared_required_content(klass)
407
+ declared_constant(klass, :SLOT_REQUIRED_CONTENT)
408
+ end
409
+
410
+ def declared_constant(klass, name)
411
+ klass.const_defined?(name) ? klass.const_get(name) : {}
412
+ rescue NameError
413
+ {}
414
+ end
415
+
416
+ # Does a slot definition generate this setter name? Mirrors the
417
+ # checker's slot_entry resolution: exact name, a collection's
418
+ # singular, or a polymorphic type.
419
+ def resolves_setter?(slot, name)
420
+ slot[:name].to_s == name ||
421
+ (slot[:many] && slot[:name].to_s == "#{name}s") ||
422
+ (slot[:types] || []).map(&:to_s).include?(name)
423
+ end
424
+
425
+ # The per-item setter suffixes a slot generates (the plural batch
426
+ # setter of a collection has a different shape and is not tracked).
427
+ def slot_setters(slot_name, config)
428
+ return config[:renderable_hash].keys.map(&:to_s) if config[:renderable_hash]
429
+
430
+ name = slot_name.to_s
431
+ [config[:collection] ? name.delete_suffix("s") : name]
432
+ end
433
+
434
+ def setter_positional_args(slot_name, config)
435
+ if (types = config[:renderable_hash])
436
+ types.filter_map do |type, definition|
437
+ arity = positional_arity(definition[:renderable_function] || definition[:renderable])
438
+ [type, arity] if arity
439
+ end.to_h
440
+ else
441
+ setter = slot_setters(slot_name, config).first
442
+ arity = positional_arity(config[:renderable_function] || config[:renderable])
443
+ arity ? { setter.to_sym => arity } : {}
444
+ end
445
+ end
446
+
447
+ def setter_keyword_args(slot_name, config)
448
+ if (types = config[:renderable_hash])
449
+ types.filter_map do |type, definition|
450
+ names = keyword_names(definition[:renderable_function] || definition[:renderable])
451
+ [type, names] if names
452
+ end.to_h
453
+ else
454
+ setter = slot_setters(slot_name, config).first
455
+ names = keyword_names(config[:renderable_function] || config[:renderable])
456
+ names ? { setter.to_sym => names } : {}
457
+ end
458
+ end
459
+
460
+ def yieldless_setters(slot_name, config)
461
+ if (types = config[:renderable_hash])
462
+ types.filter_map do |type, definition|
463
+ type if consumes_block?(definition[:renderable_function] || definition[:renderable])
464
+ end
465
+ elsif consumes_block?(config[:renderable_function] || config[:renderable])
466
+ slot_setters(slot_name, config).map(&:to_sym)
467
+ else
468
+ []
469
+ end
470
+ end
471
+
472
+ # The closed keyword surface of a slot lambda, or nil when open or
473
+ # unknowable: class renderables take kwargs through the attributes
474
+ # hash, and a lambda with no keywords at all has nothing to
475
+ # enumerate.
476
+ def keyword_names(callable)
477
+ return nil if callable.is_a?(Class) || !callable.respond_to?(:parameters)
478
+
479
+ parameters = callable.parameters
480
+ return nil if parameters.any? { |kind, _name| OPEN_PARAMETER_KINDS.include?(kind) }
481
+
482
+ names = parameters.filter_map { |kind, name| name.to_s if KEYWORD_PARAMETER_KINDS.include?(kind) }
483
+ names.empty? ? nil : names
484
+ end
485
+
486
+ # A lambda declaring &block takes the consumer's block for itself -
487
+ # ViewComponent never renders it against a component, so a block
488
+ # param at the call site can only be nil. Class renderables and
489
+ # block-less lambdas returning components DO yield (render_in
490
+ # yields the component instance).
491
+ def consumes_block?(callable)
492
+ !callable.is_a?(Class) && callable.respond_to?(:parameters) &&
493
+ callable.parameters.any? { |kind, _name| kind == :block }
494
+ end
495
+
496
+ # Max positional argument count, or nil when unknowable (no
497
+ # callable, or a *rest signature).
498
+ def positional_arity(callable)
499
+ parameters =
500
+ if callable.is_a?(Class)
501
+ callable.instance_method(:initialize).parameters
502
+ elsif callable.respond_to?(:parameters)
503
+ callable.parameters
504
+ end
505
+ return nil unless parameters
506
+ return nil if parameters.any? { |kind, _name| kind == :rest }
507
+
508
+ parameters.count { |kind, _name| POSITIONAL_KINDS.include?(kind) }
509
+ rescue NameError
510
+ nil
511
+ end
512
+
513
+ def builder_surfaces(slot_name, config, builders, seen)
514
+ slot_setters(slot_name, config).filter_map do |setter|
515
+ builder = builders[setter.to_sym]
516
+ next if builder.nil? || seen.include?(builder)
517
+
518
+ slots = slot_surface(builder, seen: seen)
519
+ extras = hand_rolled_setters(builder, slots)
520
+ next if slots.empty? && extras.empty?
521
+
522
+ surface = { slots: slots }
523
+ surface[:slot_extras] = extras unless extras.empty?
524
+ required = required_slots_surface(builder, slots)
525
+ surface[:required_slots] = required unless required.empty?
526
+ [setter.to_sym, surface]
527
+ end.to_h
528
+ end
529
+ end
530
+ end
531
+ end
532
+ end
533
+ end