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