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,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
|