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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ # The recipes projection: multi-file, non-UI payloads
6
+ # - skill bundles, scaffold template sets, screen slices - served
7
+ # through the same registry-item schema as components and
8
+ # blocks. Like RegistryItems, this is a LIVE projection: a recipe
9
+ # declares its files as callables over gem-shipped sources, so the
10
+ # served item can never drift from what the generators install.
11
+ #
12
+ # @api private
13
+ class RecipeItems
14
+ # The published registry-item JSON schema recipes are served under -
15
+ # the same schema component items validate against.
16
+ ITEM_SCHEMA = RegistryItems::ITEM_SCHEMA
17
+
18
+ # @param recipes [Array<Hash>] declarations: "name" (flat kebab),
19
+ # "title", "description", optional "registry_dependencies"
20
+ # (kebab item names), and "files" - a callable (or array)
21
+ # yielding { "path" =>, "target" =>, "content" => } hashes.
22
+ # Targets must be relative and traversal-free; enforced here so a
23
+ # bad declaration fails in the gem's suite, not in a host.
24
+ # @param gem_name [String] recorded in every item's meta
25
+ # @param gem_version [String] recorded in every item's meta
26
+ def initialize(recipes:, gem_name:, gem_version:)
27
+ @list = recipes
28
+ @gem_name = gem_name
29
+ @gem_version = gem_version
30
+ end
31
+
32
+ def names
33
+ @names ||= begin
34
+ all = @list.map { |recipe| recipe.fetch("name") }
35
+ duplicates = all.tally.select { |_name, count| count > 1 }.keys
36
+ raise Error, "recipe name collision: #{duplicates.join(", ")}" if duplicates.any?
37
+
38
+ all.sort
39
+ end
40
+ end
41
+
42
+ def item(name)
43
+ recipe = @list.find { |candidate| candidate.fetch("name") == name }
44
+ return nil unless recipe
45
+
46
+ {
47
+ "$schema" => ITEM_SCHEMA,
48
+ "name" => name,
49
+ "type" => "registry:block",
50
+ "title" => recipe.fetch("title"),
51
+ "description" => recipe.fetch("description"),
52
+ "files" => recipe_files(recipe),
53
+ "registryDependencies" => recipe["registry_dependencies"] || [],
54
+ "meta" => { "gem" => @gem_name, "gem_version" => @gem_version,
55
+ "provided" => "copy-in", "kind" => "recipe" }
56
+ }
57
+ end
58
+
59
+ def summaries
60
+ names.map do |name|
61
+ full = item(name)
62
+ full.merge("files" => full["files"].map { |file| file.except("content") })
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def recipe_files(recipe)
69
+ source = recipe.fetch("files")
70
+ (source.respond_to?(:call) ? source.call : source).map do |file|
71
+ target = file.fetch("target")
72
+ unless Pathname.new(target).relative? && target.split("/").none?("..")
73
+ raise Error, "recipe #{recipe.fetch("name")} has an unsafe target: #{target}"
74
+ end
75
+
76
+ { "path" => file.fetch("path"), "content" => file.fetch("content"),
77
+ "type" => "registry:file", "target" => target }
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,363 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Poetry
6
+ module Core
7
+ # The generated component registry: the machine-readable index of
8
+ # every component's public surface, built entirely FROM SOURCE - the
9
+ # prop_definitions introspection shim + the Style dictionaries -
10
+ # and CI-verified against a fresh build (a committed registry that
11
+ # can never drift from the code).
12
+ #
13
+ # Consumers: the docs site, the MCP server, the agent skill, and
14
+ # the A2UI / WebMCP projections - one contract, every surface.
15
+ #
16
+ # @example Generate the registry for a gem root
17
+ # Poetry::Core::Registry.new(source_root: gem_root).generate!
18
+ class Registry
19
+ # The banner written at the top of the generated YAML.
20
+ HEADER = <<~HEADER
21
+ # poetry component registry - generated by `bin/rake registry:generate` FROM SOURCE.
22
+ # DO NOT EDIT: CI verifies this file matches a fresh build (rake registry:verify).
23
+ HEADER
24
+
25
+ # Where the committed registry lives, relative to a gem root.
26
+ RELATIVE_PATH = "config/component_registry.yml"
27
+
28
+ # Stimulus lifecycle callbacks are not consumer-callable actions.
29
+ LIFECYCLE_METHODS = %w[connect disconnect initialize].freeze
30
+
31
+ # The id-minting funnel, as source text: a component family whose
32
+ # sources never match renders no poetry-minted random id (the
33
+ # "identity" derivation - see the entry's identity key).
34
+ MINT_PATTERN = /\bpoetry_instance_id\b|\binstance_id\b|\bSecureRandom\b/
35
+
36
+ # The committed-registry view: the four generated sections
37
+ # plus the root they resolve against, read straight from the YAML a
38
+ # `registry:generate` run committed - no component classes, no Rails.
39
+ # It satisfies every LlmsText/SkillText read (entries / blocks /
40
+ # source_root), so boot-free consumers (the MCP server, runtime skill
41
+ # delivery) share one loader instead of each parsing the payload.
42
+ class Committed
43
+ attr_reader :entries, :blocks, :helpers, :helper_args, :form_builder, :source_root
44
+
45
+ # Holds the sections of one committed registry file;
46
+ # {Registry.committed} is the loader.
47
+ #
48
+ # @param entries [Array<Hash>] the "components" section
49
+ # @param blocks [Array<Hash>, nil] the "blocks" section
50
+ # @param helpers [Hash, nil] the "helpers" section
51
+ # @param helper_args [Hash, nil] the "helper_args" section
52
+ # @param source_root [Pathname] the gem root the paths resolve against
53
+ # @param form_builder [Hash, nil] the "form_builder" section
54
+ def initialize(entries:, blocks:, helpers:, helper_args:, source_root:, form_builder: nil)
55
+ @form_builder = form_builder
56
+ @entries = entries
57
+ @blocks = blocks
58
+ @helpers = helpers
59
+ @helper_args = helper_args
60
+ @source_root = source_root
61
+ end
62
+ end
63
+
64
+ # Loads the committed registry YAML boot-free.
65
+ #
66
+ # @param root [String, Pathname] the gem root holding the registry file
67
+ # @return [Committed]
68
+ def self.committed(root)
69
+ source_root = Pathname.new(root)
70
+ payload = YAML.load_file(source_root.join(RELATIVE_PATH), aliases: true)
71
+ Committed.new(entries: payload.fetch("components"), blocks: payload["blocks"],
72
+ helpers: payload["helpers"], helper_args: payload["helper_args"],
73
+ form_builder: payload["form_builder"], source_root: source_root)
74
+ end
75
+
76
+ # @param components [Enumerable<Class>, nil] component classes; defaults
77
+ # to every named Poetry::Core::Component descendant (eager-loaded)
78
+ # whose source lives under source_root.
79
+ # @param source_root [Pathname, String] the gem root to discover in and
80
+ # write the registry to - poetry-ui passes its own root.
81
+ # @param helpers [Hash, nil] wrapper helpers that are NOT components
82
+ # (poetry_input_group_addon et al): helper name => contract hash
83
+ # ({"options" => [...]}, or {} for a plain wrapper). Emitted as the
84
+ # registry's "helpers" section so boot-free consumers (poetry check,
85
+ # the MCP server) know the full valid helper set AND the value
86
+ # contracts runtime-enforced inside those helpers.
87
+ # @param blocks [Hash, nil] the gem's block catalog: block
88
+ # name => {"title", "description", "components", "template"} - title/
89
+ # description parsed from each template's poetry:block header, the
90
+ # composed components derived from its poetry_* calls, template the
91
+ # gem-relative source path boot-free consumers (the MCP server) read.
92
+ # Emitted as the registry's "blocks" section.
93
+ # @param helper_args [Hash, nil] max POSITIONAL arity per poetry_*
94
+ # helper, introspected from the gem's helper-module signatures
95
+ # (e.g. `poetry_link "text", href:` on a kwargs-only
96
+ # helper raises at render). Rest-signatures are omitted (unknowable); the linter
97
+ # enforces arity only where a key exists, so registries without the
98
+ # map stay lint-identical.
99
+ # @param descriptions [Hash, nil] one-line human descriptions per
100
+ # component_path (the gem's editorial component_descriptions.yml),
101
+ # merged into each entry as "description" - the summary llms.txt, the
102
+ # MCP describe_component, and the docs page all read from one source.
103
+ # @param form_builder [Hash, nil] the gem's FormBuilder surface
104
+ # ({"rules" => [...], "methods" => {name => one-liner}, "input_types"
105
+ # => [...]}) - emitted as the registry's "form_builder" section so
106
+ # llms.txt, the MCP server, and skills can teach the model-bound
107
+ # form story without booting the gem. Optional like every other
108
+ # section: absent -> the registry stays byte-identical.
109
+ def initialize(components: nil, source_root: Poetry::Core.root, # rubocop:disable Metrics/ParameterLists
110
+ helpers: nil, blocks: nil, helper_args: nil, descriptions: nil, form_builder: nil)
111
+ @source_root = Pathname.new(source_root)
112
+ @components = (components || discover).sort_by(&:name)
113
+ @helpers = helpers
114
+ @blocks = blocks
115
+ @helper_args = helper_args
116
+ @descriptions = descriptions
117
+ @form_builder = form_builder
118
+ end
119
+
120
+ # The discovered component classes (the registry's working set).
121
+ attr_reader :components
122
+
123
+ # The block catalog and the root template paths resolve against -
124
+ # LlmsText reads both to inline block source into llms-full.txt.
125
+ attr_reader :blocks, :source_root
126
+
127
+ # The FormBuilder surface (optional section) - LlmsText renders it as
128
+ # the Forms section.
129
+ attr_reader :form_builder
130
+
131
+ # The full registry payload's "components" section: one contract hash
132
+ # per discovered component, keyed by component path.
133
+ #
134
+ # @return [Hash{String => Hash}]
135
+ def entries
136
+ @components.to_h { |component| [component.component_path, entry_for(component)] }
137
+ end
138
+
139
+ # The complete registry serialized as plain-data YAML (components plus
140
+ # the optional helpers/blocks/helper_args/form_builder sections),
141
+ # headed by the do-not-edit banner.
142
+ #
143
+ # @return [String]
144
+ def to_yaml
145
+ payload = { "components" => entries }
146
+ payload["helpers"] = plain(@helpers.sort.to_h) if @helpers&.any?
147
+ payload["blocks"] = plain(@blocks.sort.to_h) if @blocks&.any?
148
+ payload["helper_args"] = plain(@helper_args.sort.to_h) if @helper_args&.any?
149
+ payload["form_builder"] = plain(@form_builder) if @form_builder&.any?
150
+ HEADER + YAML.dump(payload)
151
+ end
152
+
153
+ # Writes the registry YAML to its committed location under root.
154
+ #
155
+ # @param root [Pathname, String] the gem root to write under
156
+ # @return [Pathname] the written file path
157
+ def generate!(root: @source_root)
158
+ path = Pathname.new(root).join(RELATIVE_PATH)
159
+ path.dirname.mkpath
160
+ path.write(to_yaml)
161
+ path
162
+ end
163
+
164
+ # False when the committed registry does not match a fresh build.
165
+ #
166
+ # @param root [Pathname, String] the gem root holding the committed file
167
+ # @return [Boolean]
168
+ def verified?(root: @source_root)
169
+ path = Pathname.new(root).join(RELATIVE_PATH)
170
+ path.exist? && path.read == to_yaml
171
+ end
172
+
173
+ private
174
+
175
+ # Named Component descendants whose source lives inside source_root's
176
+ # app/ tree - excludes host-app AND test-defined components, so
177
+ # discovery is identical under a fresh boot and inside the test suite.
178
+ # The components dir is eager-loaded first: `descendants` only sees
179
+ # loaded classes, and lazy autoloading (tests) would otherwise make
180
+ # discovery order-dependent.
181
+ def discover
182
+ app_root = @source_root.join("app")
183
+ components_dir = app_root.join("components")
184
+ if defined?(Rails) && Rails.respond_to?(:autoloaders) && components_dir.directory?
185
+ Rails.autoloaders.main.eager_load_dir(components_dir)
186
+ end
187
+ Poetry::Core::Component.descendants.select(&:name).reject(&:internal_component).select do |component|
188
+ path = Object.const_source_location(component.name)&.first
189
+ path&.start_with?(app_root.to_s)
190
+ end
191
+ end
192
+
193
+ # Whether the component's rendered output can carry a poetry-minted
194
+ # id. An IDENTITY constant declared on the component's own ancestry
195
+ # wins (composition: a component whose DOM carries ids minted by
196
+ # components it RENDERS derives false and declares true). Otherwise
197
+ # the ancestor chain below Poetry::Core::Component - the class
198
+ # itself, family modules, intermediate bases; everywhere minting
199
+ # calls live - is source-scanned for the poetry_instance_id funnel
200
+ # (or a direct SecureRandom mint). The walk stops at the base class
201
+ # so the funnel's own definition never reads as a call, and the
202
+ # scan errs toward true: a false positive keeps a warning, a false
203
+ # negative would silence a real one.
204
+ def mints_identity?(component)
205
+ family = component.ancestors.take_while { |mod| mod != Poetry::Core::Component }
206
+ override = family.find { |mod| mod.const_defined?(:IDENTITY, false) }
207
+ return override.const_get(:IDENTITY, false) if override
208
+
209
+ family.any? { |mod| mint_source?(mod) }
210
+ end
211
+
212
+ # Whether one ancestor's source file mentions the minting funnel.
213
+ def mint_source?(mod)
214
+ path = mod.name && Object.const_source_location(mod.name)&.first
215
+ return false unless path && File.exist?(path)
216
+
217
+ mint_sources = (@mint_sources ||= {})
218
+ mint_sources.fetch(path) { mint_sources[path] = File.read(path).match?(MINT_PATTERN) }
219
+ end
220
+
221
+ def entry_for(component)
222
+ props = component.prop_definitions
223
+ entry = {
224
+ "class_name" => component.name,
225
+ "identifier" => component.component_identifier,
226
+ "bem_block" => component.component_path.tr("/", "-"),
227
+ "styles" => plain(props[:styles]),
228
+ "options" => plain(props[:options]),
229
+ "slots" => plain(props[:slots])
230
+ }
231
+ # The one-line human description (editorial, merged from the gem's
232
+ # component_descriptions.yml) - the summary llms.txt / describe_component
233
+ # / the docs page read from this single source.
234
+ if @descriptions && (description = @descriptions[component.component_path])
235
+ entry["description"] = description
236
+ end
237
+ # Hand-rolled with_* conveniences beyond the registered slots
238
+ # (NavigationMenu#with_link) - consumers call them, so check and the
239
+ # agent surfaces must know them.
240
+ entry["slot_extras"] = plain(props[:slot_extras]) if props[:slot_extras]&.any?
241
+ if (style = component.style_class)
242
+ entry["elements"] = style.resolver.elements.keys.map(&:to_s)
243
+ entry["capsule"] = style.capsule
244
+ end
245
+ # The JS wiring surface: the poetry--core--* controller
246
+ # identifiers the component wires, each joined to its manifest
247
+ # (targets / values / actions) so the registry is the ONE contract
248
+ # poetry check / llms.txt / the MCP server read. Derived from the
249
+ # component's controller-identifier constants (%i[poetry core x]);
250
+ # the rendered-truth drift test guarantees this equals what the
251
+ # previews actually emit as data-controller.
252
+ # plain() rebuilds fresh objects - the manifest arrays are shared
253
+ # across components that wire the same controller (popper is in 8),
254
+ # which would otherwise make Psych emit YAML anchors/aliases.
255
+ controllers = wired_controllers(component)
256
+ entry["controllers"] = plain(controllers) unless controllers.empty?
257
+ # Component-specific constraints agents must honor (the contract's
258
+ # agent_rules section) - declared as an AGENT_RULES constant.
259
+ entry["agent_rules"] = component::AGENT_RULES.dup if component.const_defined?(:AGENT_RULES)
260
+ # The requires_content declaration:
261
+ # the same fact that raises at render lets poetry check flag a
262
+ # blockless call statically.
263
+ if component.respond_to?(:required_content) && (hint = component.required_content)
264
+ entry["requires_content"] = hint
265
+ end
266
+ # The REQUIRED_SLOTS declaration: the
267
+ # same fact that raises in before_render lets poetry check flag a
268
+ # call that never sets the slot, statically.
269
+ entry["required_slots"] = plain(props[:required_slots]) if props[:required_slots]&.any?
270
+ # The REQUIRES_ANY declaration: the conditional any-of
271
+ # contracts - the before_render disjunction, stated statically.
272
+ entry["requires_any"] = plain(props[:requires_any]) if props[:requires_any]&.any?
273
+ # The identity fact: false when the family renders no
274
+ # poetry-minted id - key:/id: would have nothing to stabilize, so
275
+ # check's stable-identity rules skip the helper. Absent = the
276
+ # component mints (legacy registries keep every warning).
277
+ entry["identity"] = false unless mints_identity?(component)
278
+ # The part contract: the
279
+ # styling surface - data-slot parts, state attributes per part,
280
+ # CSS var seams - hand-authored prose that PartContract.verify
281
+ # holds to the rendered DOM of every preview.
282
+ parts = component.part_definitions
283
+ entry["parts"] = plain(parts) if parts.any?
284
+
285
+ # The element-level wiring projection (use_stimulus declarations,
286
+ # resolved): which parts carry which controllers, values, actions,
287
+ # and targets - the usage view beside the controllers section's
288
+ # capability view.
289
+ if component.respond_to?(:stimulus_definitions)
290
+ wiring = component.stimulus_definitions
291
+ entry["stimulus"] = plain(wiring) if wiring.any?
292
+ end
293
+ # The agent-tool projection (tool declarations, MCP Tool-shaped):
294
+ # the component's operate surface - what an in-page agent may
295
+ # invoke on a rendered instance, and which Stimulus action each
296
+ # tool dispatches.
297
+ if component.respond_to?(:tool_definitions)
298
+ tools = component.tool_definitions
299
+ entry["tools"] = plain(tools) if tools.any?
300
+ end
301
+ entry
302
+ end
303
+
304
+ # The controllers a component wires, joined to the manifest. Two
305
+ # sources: use_stimulus declarations (exact - the resolved
306
+ # identifiers of every declared element) and, for components not yet
307
+ # migrated to declarations, the legacy constant scan - an array of
308
+ # symbols beginning [:poetry, :core, ...] is exactly the Builder
309
+ # identifier shape.
310
+ def wired_controllers(component)
311
+ identifiers = []
312
+ if component.respond_to?(:stimulus_identifiers)
313
+ # Manifest-known only: host-app controllers declared by a
314
+ # component are not poetry's to document.
315
+ identifiers += component.stimulus_identifiers
316
+ .select { |id| Poetry::Core::Stimulus::Manifest.catalog.key?(id) }
317
+ end
318
+
319
+ # Constants live inconsistently: some components define them on the
320
+ # Component class (Checkbox::CHECKED), others on the enclosing module
321
+ # (Select::SELECT/POPPER), and subclasses inherit them (Sheet reuses
322
+ # Dialog's). Scan the class (+ ancestors) AND the component's module,
323
+ # then let the rendered-truth drift test arbitrate.
324
+ scopes = [component]
325
+ scopes << component.module_parent if component.respond_to?(:module_parent)
326
+
327
+ identifiers += scopes.flat_map do |scope|
328
+ own = scope.equal?(component) # inherited only matters for the class
329
+ scope.constants(own).filter_map do |name|
330
+ value = scope.const_get(name)
331
+ next unless value.is_a?(Array) && value.all?(Symbol) && value.first(2) == %i[poetry core]
332
+
333
+ Poetry::Core::Stimulus::Builder.format_identifier(value)
334
+ rescue StandardError
335
+ next
336
+ end
337
+ end
338
+
339
+ identifiers.uniq.sort.map do |identifier|
340
+ definition = Poetry::Core::Stimulus::Manifest.catalog[identifier] || {}
341
+ {
342
+ "identifier" => identifier,
343
+ "targets" => definition["targets"] || [],
344
+ "values" => (definition["values"] || {}).keys.sort,
345
+ "actions" => (definition["methods"] || []) - LIFECYCLE_METHODS,
346
+ "events" => definition["events"] || []
347
+ }
348
+ end
349
+ end
350
+
351
+ # Deep-converts symbols to strings so the YAML is plain data (no
352
+ # !ruby/symbol tags) - loadable by any consumer, any language.
353
+ def plain(value)
354
+ case value
355
+ when Hash then value.to_h { |k, v| [k.to_s, plain(v)] }
356
+ when Array then value.map { |v| plain(v) }
357
+ when Symbol then value.to_s
358
+ else value
359
+ end
360
+ end
361
+ end
362
+ end
363
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Poetry
6
+ module Core
7
+ # The uniform registry address scheme:
8
+ # no --from flag, ONE classifier for every
9
+ # generator argument and every registryDependencies entry. An address is
10
+ # exactly one of:
11
+ #
12
+ # https://acme.dev/r/fancy-chart.json :url any endpoint
13
+ # ./registry/fancy-chart.json :file a local item file
14
+ # @acme/fancy-chart :namespace a configured registry
15
+ # button / Button / input_group :bare the installed gems
16
+ #
17
+ # Item names normalize to kebab-case everywhere (InputGroup and
18
+ # input_group are both input-group) - the block catalog's existing
19
+ # naming, and the wider registry ecosystem's.
20
+ #
21
+ # @example
22
+ # Poetry::Core::RegistryAddress.parse("@acme/fancy-chart").kind # => :namespace
23
+ class RegistryAddress
24
+ # The `@namespace/item-name` address shape (`@acme/fancy-chart`): a
25
+ # registry namespace, a slash, an item name.
26
+ NAMESPACED = %r{\A(@[a-z0-9][a-z0-9_-]*)/([A-Za-z0-9_-]+)\z}
27
+ # The bare item-name address shape (the installed gems).
28
+ BARE = /\A[A-Za-z0-9_-]+\z/
29
+
30
+ attr_reader :kind, :raw, :namespace, :name, :location
31
+
32
+ # Parses one address into its kind: `http(s)://` is a :url, `@x/y` a
33
+ # :namespace, a `.json` path or a `./`, `../`, `/`, `~` prefix a
34
+ # :file, and a plain item name a :bare address.
35
+ #
36
+ # @param raw [String, #to_s] the address as typed
37
+ # @return [RegistryAddress]
38
+ # @raise [ArgumentError] for an empty or unrecognized address
39
+ def self.parse(raw)
40
+ raw = raw.to_s.strip
41
+ raise ArgumentError, "empty registry address" if raw.empty?
42
+
43
+ if raw.match?(%r{\Ahttps?://}) then new(kind: :url, raw: raw, location: raw)
44
+ elsif raw.start_with?("@") then parse_namespaced(raw)
45
+ elsif raw.end_with?(".json") || raw.start_with?("./", "../", "/", "~")
46
+ new(kind: :file, raw: raw, location: raw)
47
+ elsif raw.match?(BARE)
48
+ new(kind: :bare, raw: raw, name: normalize(raw))
49
+ else
50
+ raise ArgumentError, "unrecognized registry address #{raw.inspect}"
51
+ end
52
+ end
53
+
54
+ # Parses an `@namespace/item-name` address.
55
+ #
56
+ # @param raw [String] the address, already known to start with `@`
57
+ # @return [RegistryAddress] a :namespace address
58
+ # @raise [ArgumentError] when the shape is not `@registry/item-name`
59
+ def self.parse_namespaced(raw)
60
+ match = NAMESPACED.match(raw)
61
+ raise ArgumentError, "malformed namespaced address #{raw.inspect} (expected @registry/item-name)" unless match
62
+
63
+ new(kind: :namespace, raw: raw, namespace: match[1], name: normalize(match[2]))
64
+ end
65
+
66
+ # CamelCase / snake_case / kebab-case all land on the kebab item name.
67
+ #
68
+ # @param name [String] an item name in any of the three spellings
69
+ # @return [String] the kebab-case item name
70
+ def self.normalize(name)
71
+ name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.tr("_", "-")
72
+ end
73
+
74
+ # Builds a frozen address; {.parse} is the usual entry point.
75
+ #
76
+ # @param kind [Symbol] :bare, :namespace, :url, or :file
77
+ # @param raw [String] the address as typed
78
+ # @param namespace [String, nil] the `@registry` part of a :namespace address
79
+ # @param name [String, nil] the normalized item name (:bare, :namespace)
80
+ # @param location [String, nil] the URL or path (:url, :file)
81
+ def initialize(kind:, raw:, namespace: nil, name: nil, location: nil)
82
+ @kind = kind
83
+ @raw = raw
84
+ @namespace = namespace
85
+ @name = name
86
+ @location = location
87
+ freeze
88
+ end
89
+
90
+ # Whether the item has to be fetched rather than found among the
91
+ # installed gems - every kind but :bare.
92
+ #
93
+ # @return [Boolean]
94
+ def remote?
95
+ kind != :bare
96
+ end
97
+
98
+ # The address of a bare dependency named inside a parent item - the
99
+ # sibling convention: an @acme item's bare deps are @acme items; a
100
+ # url/file item's bare deps sit next to it.
101
+ #
102
+ # @param dep_name [String] the dependency's bare item name
103
+ # @return [RegistryAddress]
104
+ # @raise [ArgumentError] for a :bare address, which has no siblings
105
+ def sibling(dep_name)
106
+ case kind
107
+ when :namespace then self.class.parse("#{namespace}/#{dep_name}")
108
+ when :url then self.class.parse(URI.join(location, "#{dep_name}.json").to_s)
109
+ when :file then self.class.parse(File.join(File.dirname(location), "#{dep_name}.json"))
110
+ else raise ArgumentError, "a #{kind} address has no siblings"
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end