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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/DESIGN.md +145 -0
- data/LICENSE.txt +21 -0
- data/README.md +30 -0
- data/THIRD_PARTY_NOTICES.md +507 -0
- data/app/components/poetry/core/box/component.rb +82 -0
- data/app/components/poetry/core/component.rb +390 -0
- data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
- data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
- data/app/components/poetry/core/concerns/introspection.rb +533 -0
- data/app/components/poetry/core/concerns/options.rb +262 -0
- data/app/components/poetry/core/concerns/parts.rb +148 -0
- data/app/components/poetry/core/concerns/stimulus.rb +363 -0
- data/app/components/poetry/core/concerns/styles.rb +323 -0
- data/app/components/poetry/core/style.rb +150 -0
- data/app/components/poetry/core/wrapper/component.rb +90 -0
- data/app/javascript/poetry/core/accordion_controller.js +165 -0
- data/app/javascript/poetry/core/action_bar_controller.js +109 -0
- data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
- data/app/javascript/poetry/core/calendar_controller.js +406 -0
- data/app/javascript/poetry/core/carousel_controller.js +167 -0
- data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
- data/app/javascript/poetry/core/checked_controller.js +208 -0
- data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
- data/app/javascript/poetry/core/combobox_controller.js +1095 -0
- data/app/javascript/poetry/core/command_controller.js +510 -0
- data/app/javascript/poetry/core/context_menu_controller.js +204 -0
- data/app/javascript/poetry/core/date_field_controller.js +584 -0
- data/app/javascript/poetry/core/date_picker_controller.js +111 -0
- data/app/javascript/poetry/core/deferred_controller.js +111 -0
- data/app/javascript/poetry/core/dialog_controller.js +206 -0
- data/app/javascript/poetry/core/dismissable_controller.js +182 -0
- data/app/javascript/poetry/core/drawer_controller.js +365 -0
- data/app/javascript/poetry/core/file_input_controller.js +191 -0
- data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
- data/app/javascript/poetry/core/helpers/announce.js +228 -0
- data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
- data/app/javascript/poetry/core/helpers/collection.js +18 -0
- data/app/javascript/poetry/core/helpers/direction.js +15 -0
- data/app/javascript/poetry/core/helpers/escape.js +38 -0
- data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
- data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
- data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
- data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
- data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
- data/app/javascript/poetry/core/helpers/mask.js +283 -0
- data/app/javascript/poetry/core/helpers/portal.js +217 -0
- data/app/javascript/poetry/core/helpers/presence.js +210 -0
- data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
- data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
- data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
- data/app/javascript/poetry/core/helpers/state.js +80 -0
- data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
- data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
- data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
- data/app/javascript/poetry/core/hotkey_controller.js +55 -0
- data/app/javascript/poetry/core/hover_card_controller.js +431 -0
- data/app/javascript/poetry/core/index.js +218 -0
- data/app/javascript/poetry/core/mask_controller.js +500 -0
- data/app/javascript/poetry/core/menu_controller.js +1050 -0
- data/app/javascript/poetry/core/menubar_controller.js +329 -0
- data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
- data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
- data/app/javascript/poetry/core/number_field_controller.js +404 -0
- data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
- data/app/javascript/poetry/core/otp_controller.js +219 -0
- data/app/javascript/poetry/core/popover_controller.js +300 -0
- data/app/javascript/poetry/core/popper_controller.js +439 -0
- data/app/javascript/poetry/core/pressed_controller.js +74 -0
- data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
- data/app/javascript/poetry/core/radio_group_controller.js +197 -0
- data/app/javascript/poetry/core/resizable_controller.js +202 -0
- data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
- data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
- data/app/javascript/poetry/core/search_field_controller.js +95 -0
- data/app/javascript/poetry/core/select_controller.js +924 -0
- data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
- data/app/javascript/poetry/core/sheet_controller.js +24 -0
- data/app/javascript/poetry/core/sidebar_controller.js +272 -0
- data/app/javascript/poetry/core/slider_controller.js +419 -0
- data/app/javascript/poetry/core/state_controller.js +82 -0
- data/app/javascript/poetry/core/table_selection_controller.js +153 -0
- data/app/javascript/poetry/core/tabs_controller.js +151 -0
- data/app/javascript/poetry/core/tag_group_controller.js +134 -0
- data/app/javascript/poetry/core/toast_controller.js +228 -0
- data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
- data/app/javascript/poetry/core/toaster_controller.js +197 -0
- data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
- data/app/javascript/poetry/core/tooltip_controller.js +541 -0
- data/app/javascript/poetry/core/tree_controller.js +265 -0
- data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
- data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
- data/app/views/poetry/core/preview.html.erb +13 -0
- data/config/component_registry.yml +20 -0
- data/config/controllers_manifest.json +1528 -0
- data/config/importmap.rb +11 -0
- data/config/state_vocabulary.json +31 -0
- data/lib/active_model/type/list.rb +33 -0
- data/lib/active_model/type/symbol.rb +36 -0
- data/lib/poetry/core/check/stable_identity.rb +104 -0
- data/lib/poetry/core/check.rb +1445 -0
- data/lib/poetry/core/config.rb +286 -0
- data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
- data/lib/poetry/core/css/bem_merger.rb +45 -0
- data/lib/poetry/core/css/bem_reference.rb +87 -0
- data/lib/poetry/core/css/override_scan.rb +110 -0
- data/lib/poetry/core/css/resolver.rb +161 -0
- data/lib/poetry/core/css/safelist.rb +41 -0
- data/lib/poetry/core/css/tailwind_merger.rb +102 -0
- data/lib/poetry/core/css/template_classes.rb +102 -0
- data/lib/poetry/core/css/theme_coverage.rb +72 -0
- data/lib/poetry/core/css/var_coverage.rb +75 -0
- data/lib/poetry/core/css/verifier.rb +106 -0
- data/lib/poetry/core/design_lint.rb +720 -0
- data/lib/poetry/core/design_md/import.rb +250 -0
- data/lib/poetry/core/design_md.rb +461 -0
- data/lib/poetry/core/engine.rb +71 -0
- data/lib/poetry/core/errors.rb +53 -0
- data/lib/poetry/core/html/attributes.rb +667 -0
- data/lib/poetry/core/icons.rb +157 -0
- data/lib/poetry/core/llms_text.rb +354 -0
- data/lib/poetry/core/page_architectures.rb +304 -0
- data/lib/poetry/core/part_contract.rb +285 -0
- data/lib/poetry/core/preview/abstract.rb +121 -0
- data/lib/poetry/core/preview/base.rb +227 -0
- data/lib/poetry/core/preview/sidecarable.rb +136 -0
- data/lib/poetry/core/preview/template.rb +186 -0
- data/lib/poetry/core/recipe_items.rb +82 -0
- data/lib/poetry/core/registry.rb +363 -0
- data/lib/poetry/core/registry_address.rb +115 -0
- data/lib/poetry/core/registry_client.rb +217 -0
- data/lib/poetry/core/registry_installer.rb +234 -0
- data/lib/poetry/core/registry_items.rb +184 -0
- data/lib/poetry/core/skill_text.rb +288 -0
- data/lib/poetry/core/stable_id.rb +110 -0
- data/lib/poetry/core/stimulus/builder.rb +395 -0
- data/lib/poetry/core/stimulus/declarations.rb +444 -0
- data/lib/poetry/core/stimulus/manifest.rb +68 -0
- data/lib/poetry/core/stimulus/merger.rb +131 -0
- data/lib/poetry/core/stimulus_contract.rb +263 -0
- data/lib/poetry/core/tag_helper.rb +10 -0
- data/lib/poetry/core/template_compile.rb +74 -0
- data/lib/poetry/core/token_import.rb +317 -0
- data/lib/poetry/core/tokens/color.rb +215 -0
- data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
- data/lib/poetry/core/tokens/generator.rb +211 -0
- data/lib/poetry/core/tokens.rb +117 -0
- data/lib/poetry/core/version.rb +9 -0
- data/lib/poetry/core.rb +52 -0
- data/lib/poetry-core.rb +5 -0
- data/tokens/aliases.css +145 -0
- data/tokens/tailwind-theme.css +47 -0
- data/tokens/tokens.css +78 -0
- data/tokens/tokens.dtcg.json +779 -0
- data/vendor/floating-ui/LICENSE +20 -0
- data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
- data/vendor/shadcn-tailwind/LICENSE +21 -0
- data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
- data/vendor/shadcn-tailwind/tailwind.css +629 -0
- data/vendor/tw-animate-css/LICENSE +21 -0
- data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
- data/vendor/tw-animate-css/tw-animate.css +1 -0
- 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
|