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,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Poetry
8
+ module Core
9
+ # Fetches and validates registry items for the add generator.
10
+ # Security posture - the registry ecosystem's standard defenses plus
11
+ # poetry's tightenings: https only (plain http allowed for localhost work), a
12
+ # response size cap, a redirect limit, env-only auth (${VAR}
13
+ # placeholders expanded from ENV at fetch time, so secrets never live in
14
+ # config files), schema validation before anything downstream touches an
15
+ # item, and NEVER any script execution. Namespaces resolve from the
16
+ # registries: section of config/poetry_components.yml first, then from
17
+ # the public registries.json directory when one is configured.
18
+ #
19
+ # @api private
20
+ class RegistryClient
21
+ class Error < Poetry::Core::Error; end
22
+
23
+ MAX_BYTES = 1_048_576
24
+ MAX_REDIRECTS = 3
25
+ LOCAL_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
26
+ NAME_FORMAT = /\A[a-z0-9][a-z0-9._-]*\z/
27
+
28
+ # @param registries [Hash] "@ns" => a url template String containing
29
+ # "{name}", or {"url" => template, "headers" => {header => value}}
30
+ # @param directory [String, nil] a registries.json URL for resolving
31
+ # namespaces that are not configured locally
32
+ # @param base_dir [String] :file addresses resolve relative to this
33
+ # @param fetcher [#call, nil] injectable transport (url, headers) ->
34
+ # body String; defaults to the hardened Net::HTTP transport
35
+ def initialize(registries: {}, directory: nil, base_dir: Dir.pwd, fetcher: nil)
36
+ @registries = registries || {}
37
+ @directory = directory
38
+ @base_dir = base_dir
39
+ @fetcher = fetcher || method(:default_fetch)
40
+ end
41
+
42
+ # Resolve a remote address (:url / :file / :namespace) to a validated
43
+ # item hash.
44
+ def resolve(address)
45
+ payload =
46
+ case address.kind
47
+ when :url then fetch_json(address.location)
48
+ when :file then read_json(address.location)
49
+ when :namespace then fetch_json(*namespace_request(address))
50
+ else raise Error, "#{address.raw.inspect} is not a remote address"
51
+ end
52
+ validate_item!(payload, source: address.raw)
53
+ end
54
+
55
+ private
56
+
57
+ def namespace_request(address)
58
+ entry = @registries[address.namespace] || directory_entry(address.namespace)
59
+ unless entry
60
+ raise Error, "unknown registry namespace #{address.namespace} - add it to the " \
61
+ "registries: section of config/poetry_components.yml " \
62
+ "(or configure directory: with a registries.json URL)"
63
+ end
64
+
65
+ template, headers = normalize_registry_entry(address.namespace, entry)
66
+ [template.sub("{name}", address.name), expand_env(headers)]
67
+ end
68
+
69
+ def normalize_registry_entry(namespace, entry)
70
+ template, headers =
71
+ case entry
72
+ when String then [entry, {}]
73
+ when Hash then [entry["url"], entry["headers"] || {}]
74
+ end
75
+ unless template.is_a?(String) && template.include?("{name}")
76
+ raise Error, "registry #{namespace} needs a url template containing {name}, got #{entry.inspect}"
77
+ end
78
+
79
+ [template, headers]
80
+ end
81
+
82
+ # The public directory (registries.json): {"registries" => {"@ns" =>
83
+ # template-or-hash}} - fetched once, memoized for the run.
84
+ def directory_entry(namespace)
85
+ return nil unless @directory
86
+
87
+ @directory_registries ||= fetch_json(@directory).fetch("registries") do
88
+ raise Error, "#{@directory} is not a registries.json directory (no registries key)"
89
+ end
90
+ @directory_registries[namespace]
91
+ end
92
+
93
+ # Secrets are env-only: config carries ${VAR} placeholders, never
94
+ # values. A missing variable fails loudly before any request is made.
95
+ def expand_env(headers)
96
+ headers.to_h do |key, value|
97
+ expanded = value.to_s.gsub(/\$\{([A-Z0-9_]+)\}/) do
98
+ ENV.fetch(Regexp.last_match(1)) do
99
+ raise Error, "header #{key} references ${#{Regexp.last_match(1)}} but it is not set in ENV"
100
+ end
101
+ end
102
+ [key, expanded]
103
+ end
104
+ end
105
+
106
+ def fetch_json(url, headers = {})
107
+ validate_scheme!(url)
108
+ parse_json(@fetcher.call(url, headers), source: url)
109
+ end
110
+
111
+ def read_json(location)
112
+ path = File.expand_path(location, @base_dir)
113
+ raise Error, "no registry item file at #{path}" unless File.file?(path)
114
+ raise Error, "#{path} exceeds the #{MAX_BYTES}-byte item cap" if File.size(path) > MAX_BYTES
115
+
116
+ parse_json(File.read(path), source: location)
117
+ end
118
+
119
+ def parse_json(body, source:)
120
+ raise Error, "#{source} exceeds the #{MAX_BYTES}-byte item cap" if body.bytesize > MAX_BYTES
121
+
122
+ JSON.parse(body)
123
+ rescue JSON::ParserError => e
124
+ raise Error, "#{source} is not valid JSON: #{e.message}"
125
+ end
126
+
127
+ def validate_scheme!(url)
128
+ uri = URI.parse(url)
129
+ return if uri.scheme == "https"
130
+ return if uri.scheme == "http" && LOCAL_HOSTS.include?(uri.host)
131
+
132
+ raise Error, "refusing #{url.inspect}: registries must be https (plain http is allowed for localhost only)"
133
+ end
134
+
135
+ def default_fetch(url, headers, hops = 0)
136
+ response = perform_get(URI.parse(url), headers)
137
+ case response
138
+ when Net::HTTPRedirection then follow_redirect(url, headers, response, hops)
139
+ when Net::HTTPSuccess
140
+ body = response.body.to_s
141
+ raise Error, "#{url} exceeds the #{MAX_BYTES}-byte item cap" if body.bytesize > MAX_BYTES
142
+
143
+ body
144
+ else
145
+ raise Error, "#{url} responded #{response.code}"
146
+ end
147
+ rescue SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, SocketError => e
148
+ raise Error, "could not fetch #{url}: #{e.message}"
149
+ end
150
+
151
+ def perform_get(uri, headers)
152
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
153
+ open_timeout: 5, read_timeout: 10) do |http|
154
+ request = Net::HTTP::Get.new(uri)
155
+ headers.each { |key, value| request[key] = value }
156
+ http.request(request)
157
+ end
158
+ end
159
+
160
+ def follow_redirect(url, headers, response, hops)
161
+ raise Error, "too many redirects fetching #{url}" if hops >= MAX_REDIRECTS
162
+
163
+ target = URI.join(url, response["location"].to_s).to_s
164
+ validate_scheme!(target)
165
+ default_fetch(target, headers, hops + 1)
166
+ end
167
+
168
+ def validate_item!(payload, source:)
169
+ raise Error, "#{source} is not a registry item (expected a JSON object)" unless payload.is_a?(Hash)
170
+
171
+ validate_identity!(payload, source: source)
172
+ validate_files!(payload, source: source)
173
+ validate_extras!(payload, source: source)
174
+ payload
175
+ end
176
+
177
+ def validate_identity!(payload, source:)
178
+ name = payload["name"]
179
+ unless name.is_a?(String) && name.match?(NAME_FORMAT)
180
+ raise Error,
181
+ "#{source}: item name #{name.inspect} is invalid"
182
+ end
183
+
184
+ type = payload["type"]
185
+ return if type.is_a?(String) && type.start_with?("registry:")
186
+
187
+ raise Error, "#{source}: item type #{type.inspect} is invalid (expected registry:*)"
188
+ end
189
+
190
+ def validate_files!(payload, source:)
191
+ files = payload.fetch("files", [])
192
+ raise Error, "#{source}: files must be an array" unless files.is_a?(Array)
193
+
194
+ files.each do |file|
195
+ valid = file.is_a?(Hash) &&
196
+ file["path"].is_a?(String) && !file["path"].empty? &&
197
+ file["content"].is_a?(String) &&
198
+ (file["target"].nil? || file["target"].is_a?(String))
199
+ raise Error, "#{source}: malformed files entry #{file.inspect[0, 120]}" unless valid
200
+ end
201
+ end
202
+
203
+ def validate_extras!(payload, source:)
204
+ %w[registryDependencies dependencies].each do |key|
205
+ value = payload[key]
206
+ next if value.nil? || (value.is_a?(Array) && value.all?(String))
207
+
208
+ raise Error, "#{source}: #{key} must be an array of strings"
209
+ end
210
+ raise Error, "#{source}: css must be a string" unless payload["css"].nil? || payload["css"].is_a?(String)
211
+ return if payload["cssVars"].nil? || payload["cssVars"].is_a?(Hash)
212
+
213
+ raise Error, "#{source}: cssVars must be an object"
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Poetry
7
+ module Core
8
+ # Turns remote registry addresses into a deterministic install PLAN:
9
+ # resolve the dependency tree (visited-set walk,
10
+ # then Kahn topo-sort so dependencies write before dependents), classify
11
+ # every dependency (poetry gem components satisfy at RUNTIME - no copy;
12
+ # gem blocks copy in through their own generator; everything else
13
+ # fetches recursively via the sibling convention), validate every write
14
+ # target against a traversal-safe allowlist, and collect the report-only
15
+ # side effects (Gemfile additions are PRINTED, never executed - no
16
+ # lifecycle scripts, ever: a fetched item can never run code on install).
17
+ # The add generator executes the plan with its idempotent primitives;
18
+ # this class never touches the filesystem it plans for.
19
+ #
20
+ # @api private
21
+ class RegistryInstaller
22
+ class Error < Poetry::Core::Error; end
23
+
24
+ ALLOWED_TARGET_ROOTS = %w[app/components app/views app/javascript app/helpers app/assets].freeze
25
+ COMMUNITY_CSS_DIR = "app/assets/tailwind/poetry/community"
26
+
27
+ Plan = Struct.new(:writes, :css_writes, :entry_lines, :gem_satisfied, :block_installs,
28
+ :gem_deps, :docs, :manifest, keyword_init: true)
29
+ Write = Struct.new(:target, :content, :item, keyword_init: true)
30
+
31
+ # @param client [RegistryClient] resolves remote addresses to items
32
+ # @param local_components [Enumerable<String>] kebab names the
33
+ # installed poetry gems provide at runtime (no copy needed)
34
+ # @param local_blocks [Enumerable<String>] kebab names of the gem's
35
+ # copy-in blocks (installed via their own generator)
36
+ # @param destination_root [String] the host app root targets must stay
37
+ # inside
38
+ # @param loaded_gems [Enumerable<String>] present gems, for the
39
+ # report-only dependency check
40
+ def initialize(client:, destination_root:, local_components: [], local_blocks: [],
41
+ loaded_gems: Gem.loaded_specs.keys)
42
+ @client = client
43
+ @local_components = local_components.to_set { |name| name.tr("_", "-") }
44
+ @local_blocks = local_blocks.to_set { |name| name.tr("_", "-") }
45
+ @destination_root = destination_root
46
+ @loaded_gems = loaded_gems.to_set
47
+ end
48
+
49
+ # @param addresses [Array<RegistryAddress>] remote addresses only
50
+ # @return [Plan]
51
+ def plan(addresses)
52
+ state = { items: {}, edges: Hash.new { |hash, key| hash[key] = [] },
53
+ resolved: {}, sources: {}, gem_satisfied: [], block_installs: [] }
54
+ addresses.each { |address| resolve_into(state, address) }
55
+ build_plan(state)
56
+ end
57
+
58
+ private
59
+
60
+ def resolve_into(state, address)
61
+ return state[:resolved][address.raw] if state[:resolved].key?(address.raw)
62
+
63
+ item = @client.resolve(address)
64
+ name = item["name"]
65
+ state[:resolved][address.raw] = name
66
+ register_item(state, name, item, address)
67
+ (item["registryDependencies"] || []).each do |dep|
68
+ dep_name = resolve_dependency(state, address, dep)
69
+ state[:edges][name] << dep_name if dep_name
70
+ end
71
+ name
72
+ end
73
+
74
+ # Two addresses may legitimately land on the same item (diamond deps);
75
+ # the same NAME with different content is a collision, not a merge.
76
+ def register_item(state, name, item, address)
77
+ if (existing = state[:items][name])
78
+ return if item_digest(existing) == item_digest(item)
79
+
80
+ raise Error, "#{address.raw} and #{state[:sources][name]} both provide #{name.inspect} " \
81
+ "with different content - refusing to pick one"
82
+ end
83
+ state[:items][name] = item
84
+ state[:sources][name] = address.raw
85
+ end
86
+
87
+ def item_digest(item)
88
+ Digest::SHA256.hexdigest(JSON.generate(item))
89
+ end
90
+
91
+ # @return [String, nil] the remote item name this dependency resolves
92
+ # to, or nil when the installed gems satisfy it (recorded on state).
93
+ def resolve_dependency(state, parent, dep)
94
+ dep_address = RegistryAddress.parse(dep)
95
+ return resolve_into(state, dep_address) unless local_candidate?(dep_address)
96
+
97
+ kebab = dep_address.name
98
+ if @local_components.include?(kebab)
99
+ state[:gem_satisfied] |= [kebab]
100
+ nil
101
+ elsif @local_blocks.include?(kebab)
102
+ state[:block_installs] |= [kebab]
103
+ nil
104
+ elsif dep_address.kind == :bare
105
+ resolve_into(state, parent.sibling(kebab))
106
+ else
107
+ raise Error, "#{dep} is not provided by the installed poetry gems"
108
+ end
109
+ end
110
+
111
+ # Bare names and @poetry/* check the installed gems first - poetry's
112
+ # twist on the dependency walk: core components are runtime-provided, so a
113
+ # community item that composes Button needs no Button copy.
114
+ def local_candidate?(address)
115
+ address.kind == :bare || (address.kind == :namespace && address.namespace == "@poetry")
116
+ end
117
+
118
+ def build_plan(state)
119
+ order = topo_order(state)
120
+ items = order.map { |name| state[:items][name] }
121
+ Plan.new(
122
+ writes: collect_writes(state, order),
123
+ css_writes: items.filter_map { |item| css_write(item) },
124
+ entry_lines: items.filter_map { |item| entry_line(item) },
125
+ gem_satisfied: state[:gem_satisfied].sort,
126
+ block_installs: state[:block_installs].sort,
127
+ gem_deps: missing_gem_deps(items),
128
+ docs: items.filter_map { |item| item["docs"] },
129
+ manifest: order.to_h { |name| [name, { "source" => state[:sources][name] }] }
130
+ )
131
+ end
132
+
133
+ # Kahn: an item writes only after every remote item it depends on has
134
+ # written. Deterministic (sorted ready set); leftovers = a cycle.
135
+ def topo_order(state)
136
+ remaining = state[:items].keys.sort
137
+ deps = remaining.to_h { |name| [name, state[:edges][name] & remaining] }
138
+ order = []
139
+ until remaining.empty?
140
+ ready = remaining.select { |name| (deps[name] - order).empty? }.sort
141
+ raise Error, "registry dependency cycle among: #{remaining.join(", ")}" if ready.empty?
142
+
143
+ order.concat(ready)
144
+ remaining -= ready
145
+ end
146
+ order
147
+ end
148
+
149
+ def collect_writes(state, order)
150
+ seen = {}
151
+ order.flat_map do |name|
152
+ state[:items][name].fetch("files", []).filter_map do |file|
153
+ target = file["target"] || file["path"]
154
+ validate_target!(target, item: name)
155
+ deduplicate_write(seen, target, file["content"], name)
156
+ end
157
+ end
158
+ end
159
+
160
+ def deduplicate_write(seen, target, content, name)
161
+ digest = Digest::SHA256.hexdigest(content)
162
+ if (previous = seen[target])
163
+ return nil if previous[:digest] == digest
164
+
165
+ raise Error, "#{name} and #{previous[:name]} both write #{target} with different content"
166
+ end
167
+ seen[target] = { digest: digest, name: name }
168
+ Write.new(target: target, content: content, item: name)
169
+ end
170
+
171
+ # Traversal defense: relative, inside the allowlisted app roots, and -
172
+ # belt and braces - expanding inside destination_root.
173
+ def validate_target!(target, item:)
174
+ offense =
175
+ if target.start_with?("/", "~") || target.match?(/\A[A-Za-z]:/) then "an absolute path"
176
+ elsif target.include?("..") then "path traversal"
177
+ elsif target.include?("\0") || target.include?("\\") then "an illegal character"
178
+ elsif ALLOWED_TARGET_ROOTS.none? { |root| target.start_with?("#{root}/") }
179
+ "outside the allowed roots (#{ALLOWED_TARGET_ROOTS.join(", ")})"
180
+ end
181
+ raise Error, "#{item}: refusing target #{target.inspect} - #{offense}" if offense
182
+
183
+ expanded = File.expand_path(target, @destination_root)
184
+ return if expanded.start_with?(File.expand_path(@destination_root) + File::SEPARATOR)
185
+
186
+ raise Error, "#{item}: refusing target #{target.inspect} - escapes the app root"
187
+ end
188
+
189
+ def css_write(item)
190
+ content = css_content(item)
191
+ return nil unless content
192
+
193
+ { path: "#{COMMUNITY_CSS_DIR}/#{item["name"]}.css", content: content }
194
+ end
195
+
196
+ def entry_line(item)
197
+ return nil unless css_content(item)
198
+
199
+ %(@import "./poetry/community/#{item["name"]}.css";)
200
+ end
201
+
202
+ # cssVars carry the item schema's three sections (theme/light/dark); css
203
+ # passes through verbatim. One file per item, imported from the host's
204
+ # Tailwind entry like every other poetry fragment.
205
+ def css_content(item)
206
+ vars = item["cssVars"] || {}
207
+ sections = [
208
+ css_block("@theme", vars["theme"]),
209
+ css_block(":root", vars["light"]),
210
+ css_block(".dark", vars["dark"]),
211
+ item["css"].is_a?(String) && !item["css"].empty? ? item["css"] : nil
212
+ ].compact
213
+ return nil if sections.empty?
214
+
215
+ "/* #{item["name"]} - installed by bin/rails g poetry:add */\n#{sections.join("\n\n")}\n"
216
+ end
217
+
218
+ def css_block(selector, vars)
219
+ return nil unless vars.is_a?(Hash) && vars.any?
220
+
221
+ lines = vars.map { |key, value| " --#{key.to_s.delete_prefix("--")}: #{value};" }
222
+ "#{selector} {\n#{lines.join("\n")}\n}"
223
+ end
224
+
225
+ # Report-only, never executed: which declared gem dependencies the
226
+ # bundle is missing. "name" or "name@constraint" forms accepted.
227
+ def missing_gem_deps(items)
228
+ items.flat_map { |item| item["dependencies"] || [] }.uniq.reject do |dep|
229
+ @loaded_gems.include?(dep.split("@").first)
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Poetry
6
+ module Core
7
+ # The interop projection of a gem's registry:
8
+ # every component and block as a registry-item hash matching the
9
+ # published registry-item.json schema ({ITEM_SCHEMA}) - name / type /
10
+ # title / files[]
11
+ # ({path, content, type, target}) / registryDependencies / meta - plus
12
+ # index summaries for registry.json. Built BOOT-FREE from the committed
13
+ # component_registry.yml + the source tree, so the docs site serves
14
+ # GET /r/:name.json live and any tool speaking that schema can consume a
15
+ # poetry item. Names are kebab-case with the gem prefix stripped
16
+ # (poetry/ui/command/dialog -> command-dialog), matching the block
17
+ # catalog's existing naming.
18
+ #
19
+ # @api private
20
+ class RegistryItems
21
+ # The published registry-item JSON schema every emitted item validates
22
+ # against.
23
+ ITEM_SCHEMA = "https://ui.shadcn.com/schema/registry-item.json"
24
+
25
+ # The same header strip the block generator applies: the poetry:block
26
+ # comment is registry metadata, not file content.
27
+ BLOCK_HEADER = /\A<%#\s*poetry:block[^%]*%>\n?/
28
+
29
+ # @param registry [Hash] a parsed component_registry.yml payload
30
+ # @param root [Pathname, String] the gem root files resolve against
31
+ # @param gem_name [String] recorded in every item's meta
32
+ # @param gem_version [String] recorded in every item's meta
33
+ # @param dependencies [Hash] curated composition edges (underscore
34
+ # component name => [underscore names]) - the add generator's map,
35
+ # single-sourced by the owning gem
36
+ def initialize(registry:, root:, gem_name:, gem_version:, dependencies: {})
37
+ @registry = registry
38
+ @root = Pathname.new(root)
39
+ @gem_name = gem_name
40
+ @gem_version = gem_version
41
+ @dependencies = dependencies
42
+ end
43
+
44
+ # Every item name (components + blocks), collision-checked: one flat
45
+ # kebab namespace per gem, the registry-schema convention.
46
+ def names
47
+ @names ||= begin
48
+ all = component_paths.keys + blocks.keys
49
+ duplicates = all.tally.select { |_name, count| count > 1 }.keys
50
+ raise Error, "registry item name collision: #{duplicates.join(", ")}" if duplicates.any?
51
+
52
+ all.sort
53
+ end
54
+ end
55
+
56
+ def item(name)
57
+ if (path = component_paths[name])
58
+ component_item(name, path)
59
+ elsif (entry = blocks[name])
60
+ block_item(name, entry)
61
+ end
62
+ end
63
+
64
+ # Index entries: the full item minus file content (the
65
+ # registry.json index shape - paths and targets stay, bytes don't).
66
+ def summaries
67
+ names.map do |name|
68
+ full = item(name)
69
+ full.merge("files" => full["files"].map { |file| file.except("content") })
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def components
76
+ @registry["components"] || {}
77
+ end
78
+
79
+ def blocks
80
+ @registry["blocks"] || {}
81
+ end
82
+
83
+ def component_paths
84
+ @component_paths ||= components.keys.to_h do |path|
85
+ [path.delete_prefix(path_prefix).tr("/", "_").tr("_", "-"), path]
86
+ end
87
+ end
88
+
89
+ # The gem prefix to strip is the component paths' common DIRECTORY
90
+ # prefix (poetry/ui/button + poetry/ui/command/dialog -> poetry/ui/):
91
+ # derived, so every gem's items flatten the same way with nothing to
92
+ # configure.
93
+ def path_prefix
94
+ @path_prefix ||= begin
95
+ dirs = components.keys.map { |path| path.split("/")[0..-2] }
96
+ common = dirs.reduce { |left, right| left.zip(right).take_while { |a, b| a == b }.map(&:first) }
97
+ common.nil? || common.empty? ? "" : "#{common.join("/")}/"
98
+ end
99
+ end
100
+
101
+ def component_item(name, path)
102
+ entry = components.fetch(path)
103
+ {
104
+ "$schema" => ITEM_SCHEMA,
105
+ "name" => name,
106
+ "type" => "registry:component",
107
+ "title" => name.tr("-", " ").capitalize,
108
+ "files" => component_files(path),
109
+ "registryDependencies" => (@dependencies[name.tr("-", "_")] || []).map { |dep| dep.tr("_", "-") },
110
+ "meta" => component_meta(entry)
111
+ }
112
+ end
113
+
114
+ # The part contract rides meta so /r consumers see the
115
+ # DOM-verified styling surface without fetching the source.
116
+ def component_meta(entry)
117
+ meta = { "gem" => @gem_name, "gem_version" => @gem_version, "provided" => "runtime-gem",
118
+ "class_name" => entry["class_name"], "identifier" => entry["identifier"] }
119
+ meta["parts"] = entry["parts"] if entry["parts"]
120
+ meta
121
+ end
122
+
123
+ # The component's whole source dir (component.rb, style.rb, template,
124
+ # preview) - the same set the copy-in generator installs, target
125
+ # mirroring path. Files belonging to a NESTED registered component
126
+ # stay with their own item, in both nesting shapes: a subdirectory,
127
+ # or the sibling-file convention (command/dialog keeps
128
+ # dialog_component.rb beside its parent).
129
+ def component_files(path)
130
+ dir = @root.join("app/components", path)
131
+ return sibling_files(path) unless dir.directory?
132
+
133
+ dir.glob("**/*").select(&:file?).sort.filter_map do |file|
134
+ rel = "app/components/#{path}/#{file.relative_path_from(dir)}"
135
+ next if nested_dir_prefixes(path).any? { |prefix| rel.start_with?("app/components/#{prefix}") }
136
+ next if nested_leaves(path).any? { |leaf| file.basename.to_s.start_with?("#{leaf}_") }
137
+
138
+ { "path" => rel, "content" => file.read, "type" => "registry:file", "target" => rel }
139
+ end
140
+ end
141
+
142
+ def nested_dir_prefixes(path)
143
+ components.keys.filter_map { |other| "#{other}/" if other.start_with?("#{path}/") }
144
+ end
145
+
146
+ def nested_leaves(path)
147
+ components.keys.filter_map do |other|
148
+ next unless other.start_with?("#{path}/")
149
+
150
+ File.basename(other) unless @root.join("app/components", other).directory?
151
+ end
152
+ end
153
+
154
+ # A nested component with no directory of its own keeps its files
155
+ # beside its parent, prefixed with its leaf name (dialog_component.rb,
156
+ # dialog_component.html.erb, dialog_preview.rb).
157
+ def sibling_files(path)
158
+ parent = File.dirname(path)
159
+ leaf = File.basename(path)
160
+ @root.join("app/components", parent).glob("#{leaf}_*").select(&:file?).sort.map do |file|
161
+ rel = "app/components/#{parent}/#{file.basename}"
162
+ { "path" => rel, "content" => file.read, "type" => "registry:file", "target" => rel }
163
+ end
164
+ end
165
+
166
+ def block_item(name, entry)
167
+ source = @root.join(entry.fetch("template")).read
168
+ target = "app/views/blocks/_#{name.tr("-", "_")}.html.erb"
169
+ {
170
+ "$schema" => ITEM_SCHEMA,
171
+ "name" => name,
172
+ "type" => "registry:block",
173
+ "title" => entry["title"],
174
+ "description" => entry["description"],
175
+ "files" => [{ "path" => entry.fetch("template"), "content" => source.sub(BLOCK_HEADER, ""),
176
+ "type" => "registry:file", "target" => target }],
177
+ "registryDependencies" => (entry["components"] || []).map { |dep| dep.tr("_", "-") },
178
+ "meta" => { "gem" => @gem_name, "gem_version" => @gem_version, "provided" => "copy-in",
179
+ "keywords" => entry["keywords"] }.compact
180
+ }
181
+ end
182
+ end
183
+ end
184
+ end