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,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module Poetry
|
|
6
|
+
module Core
|
|
7
|
+
# The DOM verifier for use_stimulus declarations - PartContract's
|
|
8
|
+
# stimulus twin: reconciles a component's declared wiring against its
|
|
9
|
+
# rendered previews, in both directions, so the declarations (and the
|
|
10
|
+
# registry surface they feed) can never state wiring the component
|
|
11
|
+
# doesn't render, nor omit wiring it does. Components without
|
|
12
|
+
# declarations are skipped by callers - the mixed-world rule while the
|
|
13
|
+
# migration sweeps run.
|
|
14
|
+
#
|
|
15
|
+
# Like PartContract.verify, verification is a pure function: the
|
|
16
|
+
# caller renders the previews and supplies the HTML; nothing here
|
|
17
|
+
# boots or renders.
|
|
18
|
+
#
|
|
19
|
+
# Scoping is by IDENTIFIER, not DOM ownership: wiring a component
|
|
20
|
+
# forwards into an embedded Button (toast's action slot) speaks the
|
|
21
|
+
# component's controller and is credited to it wherever it lands;
|
|
22
|
+
# embedded components' own wiring speaks other identifiers and is
|
|
23
|
+
# their own previews' business. The one ownership-scoped rule is
|
|
24
|
+
# foreign-wiring: an OWNED node (nearest data-component is this
|
|
25
|
+
# component) speaking a manifest-known identifier the declarations
|
|
26
|
+
# omit - the hand-written-bypass shape.
|
|
27
|
+
#
|
|
28
|
+
# Rules (all errors):
|
|
29
|
+
# - undeclared-controller: a rendered data-controller token of a
|
|
30
|
+
# declared identifier that no register entry produces
|
|
31
|
+
# - undeclared-action: a rendered action descriptor no declaration
|
|
32
|
+
# produces (suggestion carries a paste-ready `action` line)
|
|
33
|
+
# - undeclared-target: a rendered target no declaration produces
|
|
34
|
+
# - undeclared-value: a rendered value KEY no declaration produces
|
|
35
|
+
# (value content is render-time state - keys only)
|
|
36
|
+
# - phantom-controller / phantom-action / phantom-target /
|
|
37
|
+
# phantom-value: declared wiring no preview renders - dead wiring or
|
|
38
|
+
# missing preview coverage (the declared-axis coverage rule)
|
|
39
|
+
# - foreign-wiring: hand-written wiring for an undeclared,
|
|
40
|
+
# manifest-known controller on an owned node
|
|
41
|
+
#
|
|
42
|
+
# @api private
|
|
43
|
+
module StimulusContract
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# @param component [Class] a component class with use_stimulus
|
|
47
|
+
# declarations (callers skip undeclared components)
|
|
48
|
+
# @param docs [Array<String, #css>] every preview render
|
|
49
|
+
# @return [Array<Check::Finding>]
|
|
50
|
+
def verify(component:, docs:)
|
|
51
|
+
elements = component.stimulus_elements.values
|
|
52
|
+
return [] if elements.empty?
|
|
53
|
+
|
|
54
|
+
declared = declared_tokens(elements)
|
|
55
|
+
rendered = rendered_tokens(component.component_title, docs, declared)
|
|
56
|
+
findings = []
|
|
57
|
+
dom_to_declarations(findings, component, declared, rendered)
|
|
58
|
+
declarations_to_dom(findings, component, declared, rendered)
|
|
59
|
+
foreign_findings(findings, component, rendered)
|
|
60
|
+
findings
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The declaration side, condition-blind: every entry drives a real
|
|
64
|
+
# Builder into one Attributes per element, and the emitted hash is
|
|
65
|
+
# parsed by the SAME parser as the rendered DOM - the two sides'
|
|
66
|
+
# token formats cannot drift.
|
|
67
|
+
def declared_tokens(elements)
|
|
68
|
+
identifiers = elements.flat_map { |element| element.wirings.map(&:identifier) }.uniq
|
|
69
|
+
tokens = empty_tokens(identifiers)
|
|
70
|
+
elements.each do |element|
|
|
71
|
+
attrs = HTML::Attributes.new
|
|
72
|
+
element.wirings.each do |wiring|
|
|
73
|
+
builder = Stimulus::Builder.new(wiring.identifier, attrs)
|
|
74
|
+
wiring.entries.each { |entry| apply_entry(builder, entry) }
|
|
75
|
+
end
|
|
76
|
+
collect_attributes(tokens, attrs.to_attributes)
|
|
77
|
+
end
|
|
78
|
+
tokens
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def apply_entry(builder, entry)
|
|
82
|
+
case entry.kind
|
|
83
|
+
when :register then builder.register_controller
|
|
84
|
+
when :value then builder.with_value(entry.name, "declared")
|
|
85
|
+
when :action then builder.with_action(entry.name, on: entry.on, at: entry.at)
|
|
86
|
+
when :target then builder.with_target(entry.name)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def rendered_tokens(title, docs, declared)
|
|
91
|
+
tokens = empty_tokens(declared[:identifiers])
|
|
92
|
+
docs.each do |doc|
|
|
93
|
+
doc = Nokogiri::HTML5.fragment(doc.to_s) unless doc.respond_to?(:css)
|
|
94
|
+
doc.css("*").each do |node|
|
|
95
|
+
attributes = node.attribute_nodes.to_h { |attribute| [attribute.name, attribute.value.to_s] }
|
|
96
|
+
collect_attributes(tokens, attributes)
|
|
97
|
+
collect_foreign(tokens, attributes) if owned?(node, title)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
tokens
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def empty_tokens(identifiers)
|
|
104
|
+
{ identifiers: identifiers, controllers: Set.new, actions: Set.new,
|
|
105
|
+
targets: Set.new, values: Set.new, foreign: Set.new }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# One parser for both sides. Only tokens speaking a declared
|
|
109
|
+
# identifier count; scoped keys resolve against the LONGEST known
|
|
110
|
+
# identifier (manifest + declared), so poetry--core--toaster keys
|
|
111
|
+
# never misparse as poetry--core--toast values.
|
|
112
|
+
def collect_attributes(tokens, attributes)
|
|
113
|
+
attributes.each do |name, value|
|
|
114
|
+
case name
|
|
115
|
+
when "data-controller"
|
|
116
|
+
value.split.each { |token| tokens[:controllers] << token if tokens[:identifiers].include?(token) }
|
|
117
|
+
when "data-action"
|
|
118
|
+
value.split.each do |token|
|
|
119
|
+
tokens[:actions] << token if tokens[:identifiers].include?(action_identifier(token))
|
|
120
|
+
end
|
|
121
|
+
else
|
|
122
|
+
identifier, kind, rest = parse_scoped_key(name, tokens[:identifiers])
|
|
123
|
+
next unless identifier && tokens[:identifiers].include?(identifier)
|
|
124
|
+
|
|
125
|
+
case kind
|
|
126
|
+
when :target then value.split.each { |target| tokens[:targets] << [identifier, target] }
|
|
127
|
+
when :value then tokens[:values] << [identifier, rest]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Hand-written wiring for a manifest-known identifier the
|
|
134
|
+
# declarations omit, on an owned node.
|
|
135
|
+
def collect_foreign(tokens, attributes)
|
|
136
|
+
attributes.each do |name, value|
|
|
137
|
+
case name
|
|
138
|
+
when "data-controller"
|
|
139
|
+
value.split.each do |token|
|
|
140
|
+
tokens[:foreign] << "data-controller=#{token}" if foreign?(token, tokens[:identifiers])
|
|
141
|
+
end
|
|
142
|
+
when "data-action"
|
|
143
|
+
value.split.each do |token|
|
|
144
|
+
tokens[:foreign] << "data-action=#{token}" if foreign?(action_identifier(token), tokens[:identifiers])
|
|
145
|
+
end
|
|
146
|
+
else
|
|
147
|
+
identifier, = parse_scoped_key(name, tokens[:identifiers])
|
|
148
|
+
tokens[:foreign] << name if identifier && foreign?(identifier, tokens[:identifiers])
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def foreign?(identifier, declared)
|
|
154
|
+
!declared.include?(identifier) && Stimulus::Manifest.catalog.key?(identifier)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# data-<identifier>-target / data-<identifier>-<name>-value, longest
|
|
158
|
+
# known identifier first.
|
|
159
|
+
def parse_scoped_key(name, declared_identifiers)
|
|
160
|
+
return nil unless name.start_with?("data-") && name.length > 5
|
|
161
|
+
|
|
162
|
+
body = name.delete_prefix("data-")
|
|
163
|
+
known = (Stimulus::Manifest.catalog.keys + declared_identifiers).uniq.sort_by(&:length).reverse
|
|
164
|
+
known.each do |identifier|
|
|
165
|
+
next unless body.start_with?(identifier)
|
|
166
|
+
|
|
167
|
+
rest = body.delete_prefix(identifier)
|
|
168
|
+
return [identifier, :target, nil] if rest == "-target"
|
|
169
|
+
return [identifier, :value, rest.delete_prefix("-").delete_suffix("-value")] if
|
|
170
|
+
rest.start_with?("-") && rest.end_with?("-value")
|
|
171
|
+
end
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def action_identifier(token)
|
|
176
|
+
token.split("->").last.to_s.split("#").first
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# PartContract's ownership walk: the nearest ancestor-or-self
|
|
180
|
+
# carrying data-component decides.
|
|
181
|
+
def owned?(node, title)
|
|
182
|
+
current = node
|
|
183
|
+
while current.respond_to?(:key?)
|
|
184
|
+
return current["data-component"] == title if current.key?("data-component")
|
|
185
|
+
|
|
186
|
+
current = current.parent
|
|
187
|
+
end
|
|
188
|
+
false
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def dom_to_declarations(findings, component, declared, rendered)
|
|
192
|
+
title = component.component_title
|
|
193
|
+
(rendered[:controllers] - declared[:controllers]).sort.each do |token|
|
|
194
|
+
findings << finding("undeclared-controller",
|
|
195
|
+
"#{title} renders data-controller token #{token.inspect} that no " \
|
|
196
|
+
"register declaration produces")
|
|
197
|
+
end
|
|
198
|
+
(rendered[:actions] - declared[:actions]).sort.each do |token|
|
|
199
|
+
findings << finding("undeclared-action",
|
|
200
|
+
"#{title} renders action #{token.inspect} that no declaration produces",
|
|
201
|
+
suggestion: action_scaffold(token))
|
|
202
|
+
end
|
|
203
|
+
(rendered[:targets] - declared[:targets]).sort.each do |(identifier, target)|
|
|
204
|
+
findings << finding("undeclared-target",
|
|
205
|
+
"#{title} renders #{identifier} target #{target.inspect} that no " \
|
|
206
|
+
"declaration produces",
|
|
207
|
+
suggestion: "target :#{target.underscore}")
|
|
208
|
+
end
|
|
209
|
+
(rendered[:values] - declared[:values]).sort.each do |(identifier, key)|
|
|
210
|
+
findings << finding("undeclared-value",
|
|
211
|
+
"#{title} renders #{identifier} value key #{key.inspect} that no " \
|
|
212
|
+
"declaration produces",
|
|
213
|
+
suggestion: "value :#{key.underscore}")
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def declarations_to_dom(findings, component, declared, rendered)
|
|
218
|
+
title = component.component_title
|
|
219
|
+
phantom(findings, title, "controller", declared[:controllers] - rendered[:controllers],
|
|
220
|
+
&:inspect)
|
|
221
|
+
phantom(findings, title, "action", declared[:actions] - rendered[:actions], &:inspect)
|
|
222
|
+
phantom(findings, title, "target", declared[:targets] - rendered[:targets]) do |(id, target)|
|
|
223
|
+
"#{target.inspect} (#{id})"
|
|
224
|
+
end
|
|
225
|
+
phantom(findings, title, "value", declared[:values] - rendered[:values]) do |(id, key)|
|
|
226
|
+
"#{key.inspect} (#{id})"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def phantom(findings, title, kind, missing, &)
|
|
231
|
+
missing.sort.each do |token|
|
|
232
|
+
findings << finding("phantom-#{kind}",
|
|
233
|
+
"#{title} declares #{kind} #{yield(token)} but no preview " \
|
|
234
|
+
"renders it - dead wiring or missing preview coverage")
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def foreign_findings(findings, component, rendered)
|
|
239
|
+
rendered[:foreign].sort.each do |token|
|
|
240
|
+
findings << finding("foreign-wiring",
|
|
241
|
+
"#{component.component_title} hand-writes #{token} for a controller " \
|
|
242
|
+
"its declarations omit - declare the wiring (or route it through " \
|
|
243
|
+
"stimulus_attributes) so it is validated and published")
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def action_scaffold(token)
|
|
248
|
+
event, descriptor = token.include?("->") ? token.split("->", 2) : [nil, token]
|
|
249
|
+
method = descriptor.split("#").last.to_s.underscore
|
|
250
|
+
return "action :#{method}, on: :EVENT" if event.nil?
|
|
251
|
+
|
|
252
|
+
event, at = event.split("@", 2)
|
|
253
|
+
line = "action :#{method}, on: #{event.include?(":") ? event.inspect : ":#{event}"}"
|
|
254
|
+
line << ", at: :#{at}" if at
|
|
255
|
+
line
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def finding(rule, message, suggestion: nil)
|
|
259
|
+
Check::Finding.new(rule: rule, severity: :error, message: message, suggestion: suggestion)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Core
|
|
5
|
+
# The Herb COMPILE gate: every ERB template is fed through
|
|
6
|
+
# `Herb::Engine` - the same compiler Rails routes templates through when
|
|
7
|
+
# a host opts into the Herb ERB implementation - so a template that
|
|
8
|
+
# parses but refuses to compile (ERB output in an attribute name, bare
|
|
9
|
+
# output in attribute position, an element nested where the validators
|
|
10
|
+
# forbid it) fails poetry's CI instead of the host's first request.
|
|
11
|
+
#
|
|
12
|
+
# Parsing clean (see CSS::TemplateClasses, the parse gate) and
|
|
13
|
+
# compiling clean are different contracts: the engine runs validators
|
|
14
|
+
# the parser does not.
|
|
15
|
+
#
|
|
16
|
+
# Herb is loaded lazily: it is a build/CI-time tool, not a runtime
|
|
17
|
+
# dependency of the gem.
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# result = Poetry::Core::TemplateCompile.check(root: Poetry::Core.root)
|
|
21
|
+
# result.errors # => [] when every template compiles
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
class TemplateCompile
|
|
25
|
+
# The templates a gem ships: its components + the generator templates
|
|
26
|
+
# copied verbatim into hosts. Rails renders both through the engine.
|
|
27
|
+
DEFAULT_GLOBS = ["app/**/*.erb", "lib/generators/**/templates/**/*.erb"].freeze
|
|
28
|
+
|
|
29
|
+
CompileError = Struct.new(:path, :message) do
|
|
30
|
+
def to_s
|
|
31
|
+
"#{path}: #{message}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Result = Struct.new(:compiled, :errors)
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Compiles one ERB source string, returning the engine's error
|
|
39
|
+
# message or nil when it compiles.
|
|
40
|
+
#
|
|
41
|
+
# @return [String, nil]
|
|
42
|
+
def compile(source, filename: "template.html.erb")
|
|
43
|
+
herb!
|
|
44
|
+
Herb::Engine.new(source, filename: filename)
|
|
45
|
+
nil
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
e.message
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Compiles every template under root matching the globs.
|
|
51
|
+
#
|
|
52
|
+
# @return [Result] compiled (Integer, templates that compiled) + errors (Array<CompileError>)
|
|
53
|
+
def check(root:, globs: DEFAULT_GLOBS)
|
|
54
|
+
errors = []
|
|
55
|
+
paths = globs.flat_map { |glob| Dir.glob(glob, base: root.to_s) }.uniq.sort
|
|
56
|
+
paths.each do |relative|
|
|
57
|
+
message = compile(File.read(File.join(root, relative)), filename: relative)
|
|
58
|
+
errors << CompileError.new(relative, message) if message
|
|
59
|
+
end
|
|
60
|
+
Result.new(paths.size - errors.size, errors)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def herb!
|
|
66
|
+
require "herb"
|
|
67
|
+
rescue LoadError
|
|
68
|
+
raise Poetry::Core::Error,
|
|
69
|
+
"the herb gem is required for the template compile gate - add `gem \"herb\"` to your Gemfile"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Poetry
|
|
6
|
+
module Core
|
|
7
|
+
# Design-tool token ingestion (Figma variables, Paper "Copy theme", or any
|
|
8
|
+
# DTCG / CSS-custom-property source) into the SAME token-override plan the
|
|
9
|
+
# DESIGN.md importer already runs.
|
|
10
|
+
#
|
|
11
|
+
# TokenImport does exactly one job: turn a foreign token export into the
|
|
12
|
+
# `doc` hash `DesignMd::Import#plan` consumes. Everything downstream is
|
|
13
|
+
# unchanged, so every imported swatch inherits poetry's contrast law for
|
|
14
|
+
# free -- the WCAG 2.2 AA gate, the DROP-not-fabricate rule, the
|
|
15
|
+
# deterministic nearest-AA suggestion, the dark-mode pins. There is no new
|
|
16
|
+
# verification code here by design: a Figma palette that fails AA is
|
|
17
|
+
# dropped at the door exactly like a hand-authored DESIGN.md's would be.
|
|
18
|
+
#
|
|
19
|
+
# Three input formats, one pipeline (dispatched by extension; the host
|
|
20
|
+
# tasks poetry:design:import / poetry:figma:import / poetry:paper:import all
|
|
21
|
+
# funnel through `load`):
|
|
22
|
+
#
|
|
23
|
+
# .json -> figma : a DTCG / Figma-variables export (plugin shapes vary)
|
|
24
|
+
# .css -> css_vars : a Paper "Copy theme" or drop-in CSS custom-property theme block
|
|
25
|
+
# .md -> DesignMd.parse : the existing DESIGN.md path
|
|
26
|
+
#
|
|
27
|
+
# Tolerant like DesignMd.parse: whatever cannot be resolved to a poetry role
|
|
28
|
+
# or parsed as a color lands in doc["unknown"]["colors"], and the planner
|
|
29
|
+
# REPORTS it rather than guessing.
|
|
30
|
+
#
|
|
31
|
+
# @example
|
|
32
|
+
# doc = Poetry::Core::TokenImport.load("figma-variables.json")
|
|
33
|
+
# Poetry::Core::DesignMd::Import.new.plan(doc) # the same gated pipeline
|
|
34
|
+
#
|
|
35
|
+
# @api private
|
|
36
|
+
module TokenImport
|
|
37
|
+
# Path segments (from Figma collections/modes or CSS selectors) that name
|
|
38
|
+
# a color mode. Anything else defaults to light; poetry then PINS dark
|
|
39
|
+
# from its shipped defaults for light-only imports (DesignMd::Import).
|
|
40
|
+
# ("default" is deliberately NOT here -- it collides with the
|
|
41
|
+
# `primary/DEFAULT` group-leaf convention common in token exports, far
|
|
42
|
+
# likelier than a Figma mode literally named "Default"; light is the
|
|
43
|
+
# fallback anyway.)
|
|
44
|
+
MODES = { "light" => "light", "day" => "light",
|
|
45
|
+
"dark" => "dark", "night" => "dark" }.freeze
|
|
46
|
+
|
|
47
|
+
# Leaf segments that are a wrapper, not the role itself: `primary/DEFAULT`
|
|
48
|
+
# is the `primary` role, `primary/foreground` is `primary-foreground`.
|
|
49
|
+
BASE_LEAVES = %w[default base value main].freeze
|
|
50
|
+
FOREGROUND_LEAVES = %w[foreground fg on text contrast].freeze
|
|
51
|
+
|
|
52
|
+
# Grouping segments that carry no semantic role (the DTCG top group, a
|
|
53
|
+
# collection named "Tokens", etc.). Dropped before role resolution so a
|
|
54
|
+
# standalone `color/foreground` token is the `foreground` role, not
|
|
55
|
+
# `color-foreground`, while `primary/foreground` still joins.
|
|
56
|
+
WRAPPERS = %w[color colors colour colours tokens token theme palette semantic].freeze
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# Dispatch by file extension. Returns a DesignMd `doc` for any source;
|
|
60
|
+
# markdown falls through to DesignMd.parse so the three host tasks share
|
|
61
|
+
# one code path.
|
|
62
|
+
def load(path)
|
|
63
|
+
content = File.read(path)
|
|
64
|
+
name = File.basename(path)
|
|
65
|
+
case File.extname(path).downcase
|
|
66
|
+
when ".json" then figma(JSON.parse(content), name: name)
|
|
67
|
+
when ".css" then css_vars(content, name: name)
|
|
68
|
+
else DesignMd.parse(content)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A DTCG / Figma-variables export -> doc. The export
|
|
73
|
+
# shape varies by plugin (nested groups, collection/mode dimensions,
|
|
74
|
+
# `{alias}` references, hex strings vs DTCG color objects), so the
|
|
75
|
+
# walker is deliberately tolerant: it finds every color-ish leaf, keys
|
|
76
|
+
# it by dotted path AND last segment for alias resolution, then maps
|
|
77
|
+
# each to a poetry role and mode.
|
|
78
|
+
def figma(data, name: "figma")
|
|
79
|
+
leaves = []
|
|
80
|
+
collect_leaves(data, [], leaves)
|
|
81
|
+
by_path = leaves.to_h { |leaf| [leaf[:path].join(".").downcase, leaf[:value]] }
|
|
82
|
+
by_leaf = leaves.to_h { |leaf| [leaf[:path].last.to_s.downcase, leaf[:value]] }
|
|
83
|
+
|
|
84
|
+
light = {}
|
|
85
|
+
dark = {}
|
|
86
|
+
unknown = {}
|
|
87
|
+
radius = nil
|
|
88
|
+
|
|
89
|
+
leaves.each do |leaf|
|
|
90
|
+
role = role_name(leaf[:path])
|
|
91
|
+
if role == "radius"
|
|
92
|
+
radius ||= dimension_css(leaf[:value])
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
next unless color_leaf?(leaf)
|
|
96
|
+
|
|
97
|
+
color = resolve_color(leaf[:value], by_path, by_leaf, 0)
|
|
98
|
+
bucket = detect_mode(leaf[:path]) == "dark" ? dark : light
|
|
99
|
+
color ? bucket[role] = color : unknown[role] ||= raw_value(leaf[:value])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
build_doc(name: name, light: light, dark: dark, unknown: unknown, radius: radius)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A CSS custom-property theme (Paper "Copy theme", a drop-in theme block, or a
|
|
106
|
+
# Tailwind v4 `@theme` block) -> doc. `:root`/`@theme`/`html`/`:host`
|
|
107
|
+
# blocks are light; a `.dark` (or dark data-attr) block is dark. `var()`
|
|
108
|
+
# references are resolved within their own mode; concrete hex/oklch/rgb
|
|
109
|
+
# values parse directly.
|
|
110
|
+
def css_vars(css, name: "theme")
|
|
111
|
+
modes = { "light" => {}, "dark" => {} }
|
|
112
|
+
each_block(css) do |mode, declarations|
|
|
113
|
+
declarations.each { |var, value| modes[mode][var] = value }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
light = {}
|
|
117
|
+
dark = {}
|
|
118
|
+
unknown = {}
|
|
119
|
+
radius = nil
|
|
120
|
+
|
|
121
|
+
modes.each do |mode, vars|
|
|
122
|
+
vars.each do |var, raw|
|
|
123
|
+
role = css_role(var)
|
|
124
|
+
if role == "radius"
|
|
125
|
+
radius ||= raw.strip if mode == "light"
|
|
126
|
+
next
|
|
127
|
+
end
|
|
128
|
+
color = resolve_css_value(raw, vars)
|
|
129
|
+
bucket = mode == "dark" ? dark : light
|
|
130
|
+
color ? bucket[role] = color : unknown[role] ||= raw.strip
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
build_doc(name: name, light: light, dark: dark, unknown: unknown, radius: radius)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# --- shared -------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def build_doc(name:, light:, dark:, unknown:, radius:)
|
|
142
|
+
{
|
|
143
|
+
"name" => name,
|
|
144
|
+
"theme" => nil,
|
|
145
|
+
"description" => nil,
|
|
146
|
+
"colors" => { "light" => light, "dark" => dark },
|
|
147
|
+
"typography" => { "pairing" => nil, "family" => nil },
|
|
148
|
+
"radius" => radius,
|
|
149
|
+
"radius_scale" => nil,
|
|
150
|
+
"contrast" => nil,
|
|
151
|
+
"treatment" => nil,
|
|
152
|
+
"components" => nil,
|
|
153
|
+
"unknown" => { "colors" => unknown, "sections" => [] }
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Role name from a token path: drop mode segments, treat a DEFAULT/base
|
|
158
|
+
# leaf as its parent, a foreground/on/text leaf as `<parent>-foreground`,
|
|
159
|
+
# otherwise the leaf itself. Normalized to poetry's kebab role spelling;
|
|
160
|
+
# DesignMd::Import's ALIASES + role table decide the final mapping (and
|
|
161
|
+
# DROP + report anything with no poetry home).
|
|
162
|
+
def role_name(path)
|
|
163
|
+
segments = path.map(&:to_s).reject { |s| MODES.key?(s.downcase) || WRAPPERS.include?(s.downcase) }
|
|
164
|
+
leaf = segments.last.to_s
|
|
165
|
+
parent = segments[-2]
|
|
166
|
+
role =
|
|
167
|
+
if BASE_LEAVES.include?(leaf.downcase) && parent
|
|
168
|
+
parent
|
|
169
|
+
elsif FOREGROUND_LEAVES.include?(leaf.downcase) && parent
|
|
170
|
+
"#{parent}-foreground"
|
|
171
|
+
else
|
|
172
|
+
leaf
|
|
173
|
+
end
|
|
174
|
+
normalize(role)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def detect_mode(path)
|
|
178
|
+
hit = path.filter_map { |s| MODES[s.to_s.downcase] }.last
|
|
179
|
+
hit || "light"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def normalize(name)
|
|
183
|
+
name.to_s.strip.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# --- DTCG / Figma walking ----------------------------------------
|
|
187
|
+
|
|
188
|
+
def collect_leaves(node, path, out)
|
|
189
|
+
return unless node.is_a?(Hash)
|
|
190
|
+
|
|
191
|
+
if token?(node)
|
|
192
|
+
out << { path: path, value: token_value(node), type: token_type(node) }
|
|
193
|
+
return
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
node.each do |key, child|
|
|
197
|
+
next if key.to_s.start_with?("$")
|
|
198
|
+
|
|
199
|
+
collect_leaves(child, path + [key.to_s], out)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def token?(node)
|
|
204
|
+
node.is_a?(Hash) && (node.key?("$value") || (node.key?("value") && node.key?("type")))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def token_value(node)
|
|
208
|
+
node.key?("$value") ? node["$value"] : node["value"]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def token_type(node)
|
|
212
|
+
node["$type"] || node["type"]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# A color leaf is one typed "color", or (type-less) one whose value
|
|
216
|
+
# resolves to a color. Radius is handled separately, before this.
|
|
217
|
+
def color_leaf?(leaf)
|
|
218
|
+
type = leaf[:type].to_s.downcase
|
|
219
|
+
return true if type == "color"
|
|
220
|
+
return false unless type.empty?
|
|
221
|
+
|
|
222
|
+
!color_shape(leaf[:value]).nil?
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Resolve a DTCG value (string, alias, or color object) to a Color, or
|
|
226
|
+
# nil. Aliases (`{group.token}` / `{token}`) chase by dotted path then by
|
|
227
|
+
# last segment, with a small recursion cap.
|
|
228
|
+
def resolve_color(value, by_path, by_leaf, depth)
|
|
229
|
+
return nil if depth > 12
|
|
230
|
+
|
|
231
|
+
if value.is_a?(String) && (ref = value[/\A\{(.+)\}\z/, 1])
|
|
232
|
+
target = by_path[ref.downcase] || by_leaf[ref.split(".").last.to_s.downcase]
|
|
233
|
+
return target ? resolve_color(target, by_path, by_leaf, depth + 1) : nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
color_shape(value)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# A concrete DTCG color value -> Color (never an alias here).
|
|
240
|
+
def color_shape(value)
|
|
241
|
+
case value
|
|
242
|
+
when String then Tokens::Color.parse(value)
|
|
243
|
+
when Hash then color_object(value)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def color_object(obj)
|
|
248
|
+
space = obj["colorSpace"] || obj["$colorSpace"]
|
|
249
|
+
if space == "oklch"
|
|
250
|
+
Tokens::Color.from_dtcg(obj)
|
|
251
|
+
elsif %w[srgb srgb-linear rgb].include?(space) && obj["components"].is_a?(Array)
|
|
252
|
+
Tokens::Color.from_srgb(obj["components"].first(3).map(&:to_f), alpha: (obj["alpha"] || 1.0).to_f)
|
|
253
|
+
elsif (hex = obj["hex"] || obj["value"]).is_a?(String)
|
|
254
|
+
Tokens::Color.parse(hex)
|
|
255
|
+
end
|
|
256
|
+
rescue ArgumentError
|
|
257
|
+
nil
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def dimension_css(value)
|
|
261
|
+
case value
|
|
262
|
+
when Hash
|
|
263
|
+
v = value["value"] || value["$value"]
|
|
264
|
+
v && "#{format("%g", v.to_f)}#{value["unit"] || "px"}"
|
|
265
|
+
when Numeric then "#{format("%g", value)}px"
|
|
266
|
+
when String then value.strip
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def raw_value(value)
|
|
271
|
+
value.is_a?(String) ? value.strip : value.inspect
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# --- CSS custom-property parsing ---------------------------------
|
|
275
|
+
|
|
276
|
+
# Yield [mode, {var => value}] for each recognised selector block. Naive
|
|
277
|
+
# brace matching is fine: token themes are flat declaration blocks.
|
|
278
|
+
def each_block(css)
|
|
279
|
+
css.to_s.gsub(%r{/\*.*?\*/}m, "").scan(/([^{}]+)\{([^{}]*)\}/m).each do |selector, body|
|
|
280
|
+
mode = block_mode(selector.strip)
|
|
281
|
+
next unless mode
|
|
282
|
+
|
|
283
|
+
declarations = body.scan(/--([\w-]+)\s*:\s*([^;]+);/).to_h { |var, value| [var, value.strip] }
|
|
284
|
+
yield(mode, declarations) unless declarations.empty?
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def block_mode(selector)
|
|
289
|
+
lower = selector.downcase
|
|
290
|
+
return "dark" if lower.include?(".dark") || lower.include?("data-theme=\"dark\"") ||
|
|
291
|
+
lower.include?("data-theme=dark") || lower.include?("[data-mode=dark")
|
|
292
|
+
return "light" if lower.include?(":root") || lower.start_with?("@theme") ||
|
|
293
|
+
lower.include?("html") || lower.include?(":host")
|
|
294
|
+
|
|
295
|
+
nil
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Drop a leading Tailwind `color-` namespace so `--color-primary` and
|
|
299
|
+
# `--primary` both land on the `primary` role.
|
|
300
|
+
def css_role(var)
|
|
301
|
+
normalize(var.delete_prefix("color-"))
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def resolve_css_value(raw, vars, depth = 0)
|
|
305
|
+
return nil if depth > 12
|
|
306
|
+
|
|
307
|
+
if (ref = raw.strip[/\Avar\(\s*--([\w-]+)\s*(?:,[^)]*)?\)\z/, 1])
|
|
308
|
+
target = vars[ref]
|
|
309
|
+
return target ? resolve_css_value(target, vars, depth + 1) : nil
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
Tokens::Color.parse(raw.strip)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|