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,444 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ module Stimulus
6
+ # The use_stimulus declaration model (the DSL behind
7
+ # Concerns::Stimulus). Declarations build at class-load time and are
8
+ # validated against the controllers manifest THERE - an unknown
9
+ # controller, value, action method, target, or event name raises at
10
+ # boot/test-collection instead of first render. The Builder still
11
+ # guards emission for wiring built outside declarations.
12
+ #
13
+ # Element-major by design: every controller wired to one element
14
+ # builds into ONE HTML::Attributes instance at render, so a plain
15
+ # Hash#merge of two builds can never clobber data-controller -
16
+ # correct by construction, not by convention.
17
+ #
18
+ # See {Poetry::Core::Concerns::Stimulus} for the component-facing
19
+ # contract and a full declaration example.
20
+ #
21
+ # @example A use_stimulus declaration (class body of a component)
22
+ # use_stimulus do
23
+ # on :root do
24
+ # controller :hover_card do
25
+ # register
26
+ # value :open
27
+ # end
28
+ # end
29
+ # on :trigger do
30
+ # controller :hover_card do
31
+ # action :pointer_enter, on: :pointerenter
32
+ # end
33
+ # controller :popper do
34
+ # target :anchor
35
+ # end
36
+ # end
37
+ # end
38
+ module Declarations
39
+ # The condition keywords every DSL call accepts.
40
+ CONDITION_KEYS = %i[if unless].freeze
41
+
42
+ # kind: :register | :value | :action | :target. Values carry
43
+ # source {type: :implicit|:literal|:method, value:}; actions carry
44
+ # on:/at:. conditions is nil or {if:/unless: Symbol|Proc}.
45
+ Entry = Struct.new(:kind, :name, :source, :on, :at, :conditions, keyword_init: true)
46
+ # :entries shadows Enumerable#entries, which Wiring never uses - the
47
+ # member is literally a list of Entry structs, so the natural name wins.
48
+ Wiring = Struct.new(:identifier, :conditions, :entries, keyword_init: true) # rubocop:disable Lint/StructNewOverride
49
+ # One declared element (:root or a named part) and the per-controller
50
+ # wirings attached to it.
51
+ Element = Struct.new(:name, :extend_inherited, :conditions, :wirings, keyword_init: true)
52
+
53
+ # Raised at class load for an invalid or manifest-unknown
54
+ # declaration.
55
+ class DeclarationError < Poetry::Core::Error; end
56
+
57
+ module_function
58
+
59
+ # Symbols resolve against the manifest by unique suffix
60
+ # (:hover_card -> "poetry--core--hover-card"), so declarations
61
+ # never hand-write gem namespaces; strings and arrays pass through
62
+ # the Builder's existing policy (strict for poetry--*, unvalidated
63
+ # for host-app controllers).
64
+ #
65
+ # @param identifier [Symbol, String, Array] a manifest suffix
66
+ # (:hover_card), a full identifier, or the Builder's array form
67
+ # @return [String] the full Stimulus identifier
68
+ # @raise [DeclarationError] when a Symbol matches no manifest
69
+ # controller, or more than one
70
+ def resolve_identifier(identifier)
71
+ return Builder.format_identifier(identifier) unless identifier.is_a?(Symbol)
72
+
73
+ dashed = identifier.to_s.tr("_", "-")
74
+ catalog = Manifest.catalog.keys
75
+ return dashed if catalog.include?(dashed)
76
+
77
+ matches = catalog.select { |key| key.end_with?("--#{dashed}") }
78
+ case matches.size
79
+ when 1 then matches.first
80
+ when 0
81
+ raise DeclarationError,
82
+ "unknown stimulus controller #{identifier.inspect} - known: " \
83
+ "#{catalog.sort.join(", ")}. Pass a String for a host-app controller."
84
+ else
85
+ raise DeclarationError,
86
+ "ambiguous stimulus controller #{identifier.inspect}: " \
87
+ "#{matches.sort.join(", ")} - use the full identifier"
88
+ end
89
+ end
90
+
91
+ # A validated cross-controller event name for action `on:` sources,
92
+ # resolved to the manifest's REAL emitted name ("poetry:calendar:change";
93
+ # layer controllers keep the identifier prefix) - ends the hand-written
94
+ # string seam between dispatching and listening controllers.
95
+ #
96
+ # @param controller [Symbol, String] the dispatching controller
97
+ # @param name [Symbol, String] the event's short name (the suffix
98
+ # after the final colon)
99
+ # @return [String] the full emitted event name
100
+ # @example
101
+ # event(:calendar, :change) # => "poetry:calendar:change"
102
+ def event_name(controller, name)
103
+ identifier = resolve_identifier(controller)
104
+ known = Manifest.definition(identifier)&.fetch("events", nil)
105
+ return "#{identifier}:#{name}" if known.nil?
106
+
107
+ matches = known.select { |event| event.end_with?(":#{name}") }
108
+ return matches.first if matches.length == 1
109
+
110
+ if matches.length > 1
111
+ raise DeclarationError,
112
+ "ambiguous event #{name.inspect} for #{identifier} " \
113
+ "(#{matches.sort.join(", ")}) - use the full event string"
114
+ end
115
+ raise DeclarationError,
116
+ "unknown event #{name.inspect} for #{identifier} - known: #{known.sort.join(", ")}"
117
+ end
118
+
119
+ # Validates and returns the if:/unless: conditions of a DSL call.
120
+ #
121
+ # @api private
122
+ def extract_conditions!(context, options)
123
+ return nil if options.empty?
124
+
125
+ unknown = options.keys - CONDITION_KEYS
126
+ if unknown.any?
127
+ raise DeclarationError,
128
+ "#{context}: unknown option(s) #{unknown.map(&:inspect).join(", ")} - " \
129
+ "allowed: if:, unless:"
130
+ end
131
+ options.each do |key, condition|
132
+ next if condition.is_a?(Symbol) || condition.is_a?(Proc)
133
+
134
+ raise DeclarationError, "#{context}: #{key}: must be a Symbol or Proc"
135
+ end
136
+ options
137
+ end
138
+
139
+ # Name validations mirror the Builder's render-time checks, moved
140
+ # to declaration time. A nil definition (host controller) skips.
141
+ #
142
+ # @api private
143
+ def validate_value!(identifier, definition, name)
144
+ validate_name!(identifier, definition&.fetch("values", {})&.keys, name, "value")
145
+ end
146
+
147
+ # @api private
148
+ def validate_target!(identifier, definition, name)
149
+ validate_name!(identifier, definition&.fetch("targets", []), name, "target")
150
+ end
151
+
152
+ # @api private
153
+ def validate_action!(identifier, definition, name)
154
+ validate_name!(identifier, definition&.fetch("methods", []), name, "action method")
155
+ end
156
+
157
+ # @api private
158
+ def validate_name!(identifier, known, name, kind)
159
+ return if known.nil?
160
+
161
+ js_name = camelize(name)
162
+ return if known.include?(js_name)
163
+
164
+ raise DeclarationError,
165
+ "unknown #{kind} #{js_name.inspect} for #{identifier} - " \
166
+ "known #{kind}s: #{known.sort.join(", ")}"
167
+ end
168
+
169
+ # The declared Ruby name in its JS (lowerCamel) spelling.
170
+ #
171
+ # @api private
172
+ def camelize(name)
173
+ name.to_s.camelize(:lower)
174
+ end
175
+
176
+ # Registry-shaped serialization of one resolved element (plain
177
+ # string keys, YAML round-trippable) - the element-level projection
178
+ # consumers read: the registry, skill text, and the docs wiring
179
+ # tables. Conditions serialize as possibility-space: the predicate
180
+ # name for symbols, "conditional" for procs.
181
+ #
182
+ # @api private
183
+ def serialize_element(element)
184
+ definition = { "element" => element.name.to_s }
185
+ if (label = condition_label(element.conditions))
186
+ definition["conditional"] = label
187
+ end
188
+ definition["controllers"] = element.wirings.map { |wiring| serialize_wiring(wiring) }
189
+ definition
190
+ end
191
+
192
+ # @api private
193
+ def serialize_wiring(wiring)
194
+ serialized = { "identifier" => wiring.identifier }
195
+ if (label = condition_label(wiring.conditions))
196
+ serialized["conditional"] = label
197
+ end
198
+ wiring.entries.each do |entry|
199
+ case entry.kind
200
+ when :register
201
+ serialized["registers"] = condition_label(entry.conditions) || true
202
+ when :value
203
+ (serialized["values"] ||= []) << serialize_entry(entry, "name" => entry.name.to_s)
204
+ when :action
205
+ action = { "method" => camelize(entry.name) }
206
+ action["on"] = serialize_on(entry.on) unless entry.on.nil?
207
+ action["at"] = entry.at.to_s if entry.at
208
+ (serialized["actions"] ||= []) << serialize_entry(entry, action)
209
+ when :target
210
+ (serialized["targets"] ||= []) << serialize_entry(entry, "name" => camelize(entry.name))
211
+ end
212
+ end
213
+ serialized
214
+ end
215
+
216
+ # @api private
217
+ def serialize_entry(entry, base)
218
+ if (label = condition_label(entry.conditions))
219
+ base["conditional"] = label
220
+ end
221
+ base
222
+ end
223
+
224
+ # @api private
225
+ def serialize_on(on)
226
+ on.is_a?(Array) ? on.map(&:to_s) : on.to_s
227
+ end
228
+
229
+ # The serialized label of a conditions hash, or nil when
230
+ # unconditional.
231
+ #
232
+ # @api private
233
+ def condition_label(conditions)
234
+ return nil if conditions.nil? || conditions.empty?
235
+
236
+ conditions.filter_map do |key, condition|
237
+ condition.is_a?(Symbol) ? "#{key} #{condition}" : key.to_s
238
+ end.join(", ").presence || "conditional"
239
+ end
240
+
241
+ # Evaluates one use_stimulus block; #elements is the harvest. The
242
+ # block's vocabulary is {#on} (declare an element) and {#event}
243
+ # (build a validated event name).
244
+ class RootDSL
245
+ attr_reader :elements
246
+
247
+ # Starts an empty harvest for one declaring class.
248
+ #
249
+ # @param declaring [String] the declaring class's name, for error
250
+ # messages
251
+ def initialize(declaring)
252
+ @declaring = declaring
253
+ @elements = []
254
+ end
255
+
256
+ # Declares the wiring of one element of the component's anatomy.
257
+ # `:root` is the component root; other names match the element
258
+ # keys templates read back through `stimulus_attributes_for`.
259
+ # Redeclaring an element in a subclass replaces it wholesale
260
+ # unless `extend: true` merges into the inherited wiring.
261
+ #
262
+ # @param name [Symbol, String] the element to wire (:root, :trigger, ...)
263
+ # @param extend [Boolean] merge into the inherited element instead
264
+ # of replacing it
265
+ # @param options [Hash] if:/unless: conditions (Symbol predicate or Proc)
266
+ # @yield evaluates the block as an {ElementDSL} for this element
267
+ # @return [void]
268
+ # @example
269
+ # on :trigger do
270
+ # controller :popper do
271
+ # target :anchor
272
+ # end
273
+ # end
274
+ def on(name, extend: false, **options, &block)
275
+ name = name.to_sym
276
+ conditions = Declarations.extract_conditions!("#{@declaring} element #{name.inspect}", options)
277
+ element = Element.new(name: name, extend_inherited: extend,
278
+ conditions: conditions, wirings: [])
279
+ ElementDSL.new(@declaring, element).instance_exec(&block) if block
280
+ @elements << element
281
+ end
282
+
283
+ # A validated cross-controller event name - see
284
+ # {Declarations.event_name}.
285
+ #
286
+ # @param controller [Symbol, String] the dispatching controller
287
+ # @param name [Symbol, String] the event's short name
288
+ # @return [String] the full emitted event name
289
+ def event(controller, name) = Declarations.event_name(controller, name)
290
+ end
291
+
292
+ # Inside `on :element do ... end`: {#controller} attaches one
293
+ # controller's wiring to the element.
294
+ class ElementDSL
295
+ # Binds the DSL to the element its block fills.
296
+ #
297
+ # @param declaring [String] the declaring class's name, for error
298
+ # messages
299
+ # @param element [Element] the element being wired
300
+ def initialize(declaring, element)
301
+ @declaring = declaring
302
+ @element = element
303
+ end
304
+
305
+ # Wires one Stimulus controller to this element.
306
+ #
307
+ # @param identifier [Symbol, String, Array] a Symbol resolves
308
+ # against the controllers manifest by unique suffix
309
+ # (:hover_card); pass a String for a host-app controller
310
+ # @param options [Hash] if:/unless: conditions (Symbol predicate or Proc)
311
+ # @yield evaluates the block as a {WiringDSL} for this controller
312
+ # @return [void]
313
+ def controller(identifier, **options, &block)
314
+ resolved = Declarations.resolve_identifier(identifier)
315
+ conditions = Declarations.extract_conditions!(
316
+ "#{@declaring} controller #{resolved}", options
317
+ )
318
+ wiring = Wiring.new(identifier: resolved, conditions: conditions, entries: [])
319
+ WiringDSL.new(@declaring, wiring).instance_exec(&block) if block
320
+ @element.wirings << wiring
321
+ end
322
+
323
+ # A validated cross-controller event name - see
324
+ # {Declarations.event_name}.
325
+ #
326
+ # @param controller [Symbol, String] the dispatching controller
327
+ # @param name [Symbol, String] the event's short name
328
+ # @return [String] the full emitted event name
329
+ def event(controller, name) = Declarations.event_name(controller, name)
330
+ end
331
+
332
+ # Inside `controller :name do ... end`: the wiring vocabulary.
333
+ # {#register} boots the controller on the element; {#value},
334
+ # {#action}, and {#target} declare the data attributes the render
335
+ # emits - each name validated against the controllers manifest at
336
+ # class load.
337
+ class WiringDSL
338
+ # Binds the DSL to one controller wiring and looks up the
339
+ # controller's manifest definition the entries validate against.
340
+ #
341
+ # @param declaring [String] the declaring class's name, for error
342
+ # messages
343
+ # @param wiring [Wiring] the controller wiring the entries collect into
344
+ def initialize(declaring, wiring)
345
+ @declaring = declaring
346
+ @wiring = wiring
347
+ @definition = Manifest.definition(wiring.identifier)
348
+ end
349
+
350
+ # Emits the controller's identifier into this element's
351
+ # data-controller - a controller instance boots here. Value,
352
+ # action, and target entries alone never register a controller.
353
+ #
354
+ # @param options [Hash] if:/unless: conditions (Symbol predicate or Proc)
355
+ # @return [void]
356
+ def register(**options)
357
+ push(kind: :register, options: options)
358
+ end
359
+
360
+ # Declares one Stimulus value the render emits as
361
+ # `data-<identifier>-<name>-value`. Three source shapes:
362
+ #
363
+ # value :open # reads the same-named method/option
364
+ # value :orientation, :horizontal # literal
365
+ # value :selected, from: :selected_iso # named method reference
366
+ #
367
+ # Literal presence is arity-detected, so `value :x, false` and
368
+ # `value :x, nil` stay literals.
369
+ #
370
+ # @param name [Symbol] the value name (validated against the
371
+ # controllers manifest)
372
+ # @param literal [Array] at most one literal value
373
+ # @param from [Symbol, nil] the method the value reads at render
374
+ # @param options [Hash] if:/unless: conditions (Symbol predicate or Proc)
375
+ # @return [void]
376
+ # @raise [DeclarationError] when both a literal and from: are
377
+ # given, or the manifest has no such value
378
+ def value(name, *literal, from: nil, **options)
379
+ if literal.size > 1 || (!literal.empty? && from)
380
+ raise DeclarationError,
381
+ "#{context}: value #{name.inspect} takes ONE of a literal or from:"
382
+ end
383
+
384
+ Declarations.validate_value!(@wiring.identifier, @definition, name)
385
+ # literal presence is arity-detected, so `value :x, false` and
386
+ # `value :x, nil` stay literals (never .any?, which is false
387
+ # for [false]).
388
+ source = if from then { type: :method, value: from.to_sym }
389
+ elsif !literal.empty? then { type: :literal, value: literal.first }
390
+ else { type: :implicit, value: name.to_sym }
391
+ end
392
+ push(kind: :value, name: name.to_sym, source: source, options: options)
393
+ end
394
+
395
+ # Declares one action the render emits into this element's
396
+ # data-action. on: nil declares a BARE descriptor (Stimulus
397
+ # element-default event - the forwarding shape:
398
+ # "poetry--core--x#method").
399
+ #
400
+ # @param method [Symbol] the controller method (validated against
401
+ # the controllers manifest; declared snake_case, emitted lowerCamel)
402
+ # @param on [Symbol, String, Array<Symbol>, nil] the event(s) to
403
+ # listen for
404
+ # @param at [Symbol, String, nil] the event target (:window, :document)
405
+ # @param options [Hash] if:/unless: conditions (Symbol predicate or Proc)
406
+ # @return [void]
407
+ def action(method, on: nil, at: nil, **options)
408
+ Declarations.validate_action!(@wiring.identifier, @definition, method)
409
+ push(kind: :action, name: method.to_sym, on: on, at: at, options: options)
410
+ end
411
+
412
+ # Marks this element as one of the controller's named targets.
413
+ #
414
+ # @param name [Symbol] the target name (validated against the
415
+ # controllers manifest; declared snake_case, emitted lowerCamel)
416
+ # @param options [Hash] if:/unless: conditions (Symbol predicate or Proc)
417
+ # @return [void]
418
+ def target(name, **options)
419
+ Declarations.validate_target!(@wiring.identifier, @definition, name)
420
+ push(kind: :target, name: name.to_sym, options: options)
421
+ end
422
+
423
+ # A validated cross-controller event name - see
424
+ # {Declarations.event_name}.
425
+ #
426
+ # @param controller [Symbol, String] the dispatching controller
427
+ # @param name [Symbol, String] the event's short name
428
+ # @return [String] the full emitted event name
429
+ def event(controller, name) = Declarations.event_name(controller, name)
430
+
431
+ private
432
+
433
+ def context = "#{@declaring} controller #{@wiring.identifier}"
434
+
435
+ def push(kind:, options:, name: nil, source: nil, on: nil, at: nil)
436
+ conditions = Declarations.extract_conditions!("#{context} #{kind}", options)
437
+ @wiring.entries << Entry.new(kind: kind, name: name, source: source,
438
+ on: on, at: at, conditions: conditions)
439
+ end
440
+ end
441
+ end
442
+ end
443
+ end
444
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Poetry
6
+ module Core
7
+ module Stimulus
8
+ # The controllers manifest: the JS-side API surface (targets / values /
9
+ # classes / methods) introspected from the live controller classes in
10
+ # CI (test/javascript/controllers_manifest.test.js, regenerated with
11
+ # `npm run manifest`) and committed at config/controllers_manifest.json.
12
+ #
13
+ # The Builder validates every name it emits against this, so a
14
+ # renamed controller method can never silently strand gem-rendered
15
+ # wiring - the Ruby<->JS seam is guarded at render time.
16
+ #
17
+ # Policy: poetry-namespaced identifiers ("poetry--*") are validated
18
+ # strictly (unknown one raises); host-app controllers are unknown to
19
+ # poetry and pass through unvalidated.
20
+ module Manifest
21
+ # Raised for a poetry-- identifier the manifest does not know.
22
+ class UnknownController < Poetry::Core::Error; end
23
+ # Raised for a target/value/action name the controller's manifest
24
+ # entry does not list.
25
+ class UnknownName < Poetry::Core::Error; end
26
+
27
+ # The identifier prefix marking a controller as poetry-owned (and
28
+ # therefore manifest-validated).
29
+ POETRY_PREFIX = "poetry--"
30
+
31
+ module_function
32
+
33
+ # The merged controller catalog, identifier => definition
34
+ # (`{"targets" =>, "values" =>, "classes" =>, "methods" =>}`),
35
+ # loaded from poetry-core's committed manifest on first read.
36
+ #
37
+ # @return [Hash{String => Hash}]
38
+ def catalog
39
+ @catalog ||= JSON.parse(Poetry::Core.root.join("config/controllers_manifest.json").read)
40
+ end
41
+
42
+ # Other poetry gems merge their committed manifests here.
43
+ #
44
+ # @param path [String, Pathname] a committed controllers_manifest.json
45
+ # @return [Hash{String => Hash}] the catalog after the merge
46
+ def register(path)
47
+ catalog.merge!(JSON.parse(File.read(path)))
48
+ end
49
+
50
+ # The catalog definition of one controller.
51
+ #
52
+ # @param identifier [String] the full Stimulus identifier
53
+ # ("poetry--core--popper")
54
+ # @return [Hash, nil] the controller's definition; nil for host-app
55
+ # (non-poetry) identifiers, which are not poetry's to validate.
56
+ # @raise [UnknownController] for a `poetry--` identifier the catalog
57
+ # does not know
58
+ def definition(identifier)
59
+ return catalog[identifier] if catalog.key?(identifier)
60
+ return nil unless identifier.start_with?(POETRY_PREFIX)
61
+
62
+ raise UnknownController,
63
+ "unknown poetry Stimulus controller #{identifier.inspect} - known: #{catalog.keys.sort.join(", ")}"
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ # The Stimulus layer: builders, declarations, and attribute merging.
6
+ module Stimulus
7
+ # Intelligently merges Stimulus controller data attributes from multiple sources.
8
+ #
9
+ # This class handles the complex task of combining Stimulus data attributes
10
+ # (controllers, actions, targets, values, classes, etc.) without duplicating
11
+ # controllers or actions. This is particularly useful when building components
12
+ # that may have Stimulus attributes from multiple concerns or sources.
13
+ #
14
+ # @example Merging attributes with duplicate controllers
15
+ # merger = Poetry::Core::Stimulus::Merger.new
16
+ # attrs1 = { data: { controller: "dropdown modal" } }
17
+ # attrs2 = { data: { controller: "dropdown tooltip" } }
18
+ # merged = merger.merge_attributes(attrs1, attrs2)
19
+ # # => { data: { controller: "dropdown modal tooltip" } }
20
+ #
21
+ # @example Merging with actions
22
+ # merger = Poetry::Core::Stimulus::Merger.new
23
+ # attrs1 = { data: { controller: "form", action: "submit->form#save" } }
24
+ # attrs2 = { data: { action: "keyup->form#validate" } }
25
+ # merged = merger.merge_attributes(attrs1, attrs2)
26
+ # # => { data: { controller: "form", action: "submit->form#save keyup->form#validate" } }
27
+ class Merger
28
+ # Merges stimulus attributes in place, modifying the original attributes hash.
29
+ #
30
+ # @param attributes [Hash] The base attributes hash to merge into (will be modified)
31
+ # @param other_attributes [Array<Hash>] One or more attribute hashes to merge
32
+ # @return [Hash] The modified attributes hash
33
+ def merge_attributes!(attributes, *other_attributes)
34
+ attributes[:data] ||= {}
35
+ other_attributes.each { |attr| merge_stimulus_attributes attributes, attr }
36
+ attributes
37
+ end
38
+
39
+ # Merges stimulus attributes non-destructively by deep duplicating the original.
40
+ #
41
+ # @param attributes [Hash] The base attributes hash (will not be modified)
42
+ # @param other_attributes [Array<Hash>] One or more attribute hashes to merge
43
+ # @return [Hash] A new hash with merged attributes
44
+ def merge_attributes(attributes, *other_attributes)
45
+ merge_attributes!(attributes.deep_dup, *other_attributes)
46
+ end
47
+
48
+ # Merges multiple controller strings into a single deduplicated string.
49
+ #
50
+ # Controller names are split on spaces, deduplicated, and rejoined.
51
+ # Blank or nil values are ignored.
52
+ #
53
+ # @param controllers [Array<String, nil>] One or more controller strings
54
+ # @return [String, nil] Space-separated controller names, or nil if all inputs were blank
55
+ # @example
56
+ # merge_controllers("dropdown modal", "dropdown tooltip")
57
+ # # => "dropdown modal tooltip"
58
+ def merge_controllers(*controllers)
59
+ normalize = controllers.flatten.each_with_object({}) do |controller, result|
60
+ next if controller.blank?
61
+
62
+ controller.split.each { |identifier| result[identifier] = true }
63
+ end
64
+
65
+ normalize.keys.join(" ").presence
66
+ end
67
+
68
+ # Merges multiple action strings into a single deduplicated string.
69
+ #
70
+ # Action strings are flattened, filtered for presence, deduplicated, and joined.
71
+ # Unlike controllers, actions maintain their insertion order.
72
+ #
73
+ # @param actions [Array<String, nil>] One or more action strings
74
+ # @return [String, nil] Space-separated action strings, or nil if all inputs were blank
75
+ # @example
76
+ # merge_actions("click->modal#open", "click->modal#open", "keyup->form#validate")
77
+ # # => "click->modal#open keyup->form#validate"
78
+ def merge_actions(*actions)
79
+ actions.flatten.compact_blank.uniq.join(" ").presence
80
+ end
81
+
82
+ # Merges multiple stimulus data hashes with special handling for controllers and actions.
83
+ #
84
+ # This is the main merging method that intelligently combines stimulus data hashes.
85
+ # Controller and action values are deduplicated using their respective merge methods,
86
+ # while other data attributes are merged normally.
87
+ #
88
+ # @param hashes [Array<Hash>] One or more stimulus data hashes
89
+ # @param block [Proc] Optional block passed to Hash#merge for custom merge logic
90
+ # @yield [key, existing, incoming] Hash#merge's conflict resolver for
91
+ # every key other than controller and action
92
+ # @yieldreturn [Object] the value to keep
93
+ # @return [Hash] A new hash with merged stimulus data
94
+ # @example
95
+ # merge(
96
+ # { controller: "dropdown", action: "click->dropdown#toggle" },
97
+ # { controller: "dropdown tooltip", target_value: "main" }
98
+ # )
99
+ # # => { controller: "dropdown tooltip", action: "click->dropdown#toggle", target_value: "main" }
100
+ def merge(*hashes, &block)
101
+ hashes.flatten.compact.each_with_object({}.with_indifferent_access) do |hash, result|
102
+ hash = hash.deep_dup.with_indifferent_access
103
+ result[:controller] = merge_controllers(result[:controller], hash.delete(:controller))
104
+ result[:action] = merge_actions(result[:action], hash.delete(:action))
105
+ result.merge!(hash, &block)
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ # Merges stimulus-specific attributes from one hash into another.
112
+ #
113
+ # This private helper method handles the actual merging of stimulus data attributes,
114
+ # extracting and merging controllers and actions separately before performing a deep merge
115
+ # of the remaining attributes.
116
+ #
117
+ # @param source [Hash] The source attributes hash (will be modified)
118
+ # @param attributes [Hash] The attributes to merge into source
119
+ # @return [void]
120
+ def merge_stimulus_attributes(source, attributes)
121
+ attributes[:data] ||= {}
122
+ controllers = merge_controllers source[:data].delete(:controller), attributes[:data].delete(:controller)
123
+ actions = merge_actions source[:data].delete(:action), attributes[:data].delete(:action)
124
+ source[:data][:controller] = controllers
125
+ source[:data][:action] = actions
126
+ source.deep_merge! attributes
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end