poetry-core 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/DESIGN.md +145 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +30 -0
  6. data/THIRD_PARTY_NOTICES.md +507 -0
  7. data/app/components/poetry/core/box/component.rb +82 -0
  8. data/app/components/poetry/core/component.rb +390 -0
  9. data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
  10. data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
  11. data/app/components/poetry/core/concerns/introspection.rb +533 -0
  12. data/app/components/poetry/core/concerns/options.rb +262 -0
  13. data/app/components/poetry/core/concerns/parts.rb +148 -0
  14. data/app/components/poetry/core/concerns/stimulus.rb +363 -0
  15. data/app/components/poetry/core/concerns/styles.rb +323 -0
  16. data/app/components/poetry/core/style.rb +150 -0
  17. data/app/components/poetry/core/wrapper/component.rb +90 -0
  18. data/app/javascript/poetry/core/accordion_controller.js +165 -0
  19. data/app/javascript/poetry/core/action_bar_controller.js +109 -0
  20. data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
  21. data/app/javascript/poetry/core/calendar_controller.js +406 -0
  22. data/app/javascript/poetry/core/carousel_controller.js +167 -0
  23. data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
  24. data/app/javascript/poetry/core/checked_controller.js +208 -0
  25. data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
  26. data/app/javascript/poetry/core/combobox_controller.js +1095 -0
  27. data/app/javascript/poetry/core/command_controller.js +510 -0
  28. data/app/javascript/poetry/core/context_menu_controller.js +204 -0
  29. data/app/javascript/poetry/core/date_field_controller.js +584 -0
  30. data/app/javascript/poetry/core/date_picker_controller.js +111 -0
  31. data/app/javascript/poetry/core/deferred_controller.js +111 -0
  32. data/app/javascript/poetry/core/dialog_controller.js +206 -0
  33. data/app/javascript/poetry/core/dismissable_controller.js +182 -0
  34. data/app/javascript/poetry/core/drawer_controller.js +365 -0
  35. data/app/javascript/poetry/core/file_input_controller.js +191 -0
  36. data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
  37. data/app/javascript/poetry/core/helpers/announce.js +228 -0
  38. data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
  39. data/app/javascript/poetry/core/helpers/collection.js +18 -0
  40. data/app/javascript/poetry/core/helpers/direction.js +15 -0
  41. data/app/javascript/poetry/core/helpers/escape.js +38 -0
  42. data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
  43. data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
  44. data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
  45. data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
  46. data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
  47. data/app/javascript/poetry/core/helpers/mask.js +283 -0
  48. data/app/javascript/poetry/core/helpers/portal.js +217 -0
  49. data/app/javascript/poetry/core/helpers/presence.js +210 -0
  50. data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
  51. data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
  52. data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
  53. data/app/javascript/poetry/core/helpers/state.js +80 -0
  54. data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
  55. data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
  56. data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
  57. data/app/javascript/poetry/core/hotkey_controller.js +55 -0
  58. data/app/javascript/poetry/core/hover_card_controller.js +431 -0
  59. data/app/javascript/poetry/core/index.js +218 -0
  60. data/app/javascript/poetry/core/mask_controller.js +500 -0
  61. data/app/javascript/poetry/core/menu_controller.js +1050 -0
  62. data/app/javascript/poetry/core/menubar_controller.js +329 -0
  63. data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
  64. data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
  65. data/app/javascript/poetry/core/number_field_controller.js +404 -0
  66. data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
  67. data/app/javascript/poetry/core/otp_controller.js +219 -0
  68. data/app/javascript/poetry/core/popover_controller.js +300 -0
  69. data/app/javascript/poetry/core/popper_controller.js +439 -0
  70. data/app/javascript/poetry/core/pressed_controller.js +74 -0
  71. data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
  72. data/app/javascript/poetry/core/radio_group_controller.js +197 -0
  73. data/app/javascript/poetry/core/resizable_controller.js +202 -0
  74. data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
  75. data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
  76. data/app/javascript/poetry/core/search_field_controller.js +95 -0
  77. data/app/javascript/poetry/core/select_controller.js +924 -0
  78. data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
  79. data/app/javascript/poetry/core/sheet_controller.js +24 -0
  80. data/app/javascript/poetry/core/sidebar_controller.js +272 -0
  81. data/app/javascript/poetry/core/slider_controller.js +419 -0
  82. data/app/javascript/poetry/core/state_controller.js +82 -0
  83. data/app/javascript/poetry/core/table_selection_controller.js +153 -0
  84. data/app/javascript/poetry/core/tabs_controller.js +151 -0
  85. data/app/javascript/poetry/core/tag_group_controller.js +134 -0
  86. data/app/javascript/poetry/core/toast_controller.js +228 -0
  87. data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
  88. data/app/javascript/poetry/core/toaster_controller.js +197 -0
  89. data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
  90. data/app/javascript/poetry/core/tooltip_controller.js +541 -0
  91. data/app/javascript/poetry/core/tree_controller.js +265 -0
  92. data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
  93. data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
  94. data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
  95. data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
  96. data/app/views/poetry/core/preview.html.erb +13 -0
  97. data/config/component_registry.yml +20 -0
  98. data/config/controllers_manifest.json +1528 -0
  99. data/config/importmap.rb +11 -0
  100. data/config/state_vocabulary.json +31 -0
  101. data/lib/active_model/type/list.rb +33 -0
  102. data/lib/active_model/type/symbol.rb +36 -0
  103. data/lib/poetry/core/check/stable_identity.rb +104 -0
  104. data/lib/poetry/core/check.rb +1445 -0
  105. data/lib/poetry/core/config.rb +286 -0
  106. data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
  107. data/lib/poetry/core/css/bem_merger.rb +45 -0
  108. data/lib/poetry/core/css/bem_reference.rb +87 -0
  109. data/lib/poetry/core/css/override_scan.rb +110 -0
  110. data/lib/poetry/core/css/resolver.rb +161 -0
  111. data/lib/poetry/core/css/safelist.rb +41 -0
  112. data/lib/poetry/core/css/tailwind_merger.rb +102 -0
  113. data/lib/poetry/core/css/template_classes.rb +102 -0
  114. data/lib/poetry/core/css/theme_coverage.rb +72 -0
  115. data/lib/poetry/core/css/var_coverage.rb +75 -0
  116. data/lib/poetry/core/css/verifier.rb +106 -0
  117. data/lib/poetry/core/design_lint.rb +720 -0
  118. data/lib/poetry/core/design_md/import.rb +250 -0
  119. data/lib/poetry/core/design_md.rb +461 -0
  120. data/lib/poetry/core/engine.rb +71 -0
  121. data/lib/poetry/core/errors.rb +53 -0
  122. data/lib/poetry/core/html/attributes.rb +667 -0
  123. data/lib/poetry/core/icons.rb +157 -0
  124. data/lib/poetry/core/llms_text.rb +354 -0
  125. data/lib/poetry/core/page_architectures.rb +304 -0
  126. data/lib/poetry/core/part_contract.rb +285 -0
  127. data/lib/poetry/core/preview/abstract.rb +121 -0
  128. data/lib/poetry/core/preview/base.rb +227 -0
  129. data/lib/poetry/core/preview/sidecarable.rb +136 -0
  130. data/lib/poetry/core/preview/template.rb +186 -0
  131. data/lib/poetry/core/recipe_items.rb +82 -0
  132. data/lib/poetry/core/registry.rb +363 -0
  133. data/lib/poetry/core/registry_address.rb +115 -0
  134. data/lib/poetry/core/registry_client.rb +217 -0
  135. data/lib/poetry/core/registry_installer.rb +234 -0
  136. data/lib/poetry/core/registry_items.rb +184 -0
  137. data/lib/poetry/core/skill_text.rb +288 -0
  138. data/lib/poetry/core/stable_id.rb +110 -0
  139. data/lib/poetry/core/stimulus/builder.rb +395 -0
  140. data/lib/poetry/core/stimulus/declarations.rb +444 -0
  141. data/lib/poetry/core/stimulus/manifest.rb +68 -0
  142. data/lib/poetry/core/stimulus/merger.rb +131 -0
  143. data/lib/poetry/core/stimulus_contract.rb +263 -0
  144. data/lib/poetry/core/tag_helper.rb +10 -0
  145. data/lib/poetry/core/template_compile.rb +74 -0
  146. data/lib/poetry/core/token_import.rb +317 -0
  147. data/lib/poetry/core/tokens/color.rb +215 -0
  148. data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
  149. data/lib/poetry/core/tokens/generator.rb +211 -0
  150. data/lib/poetry/core/tokens.rb +117 -0
  151. data/lib/poetry/core/version.rb +9 -0
  152. data/lib/poetry/core.rb +52 -0
  153. data/lib/poetry-core.rb +5 -0
  154. data/tokens/aliases.css +145 -0
  155. data/tokens/tailwind-theme.css +47 -0
  156. data/tokens/tokens.css +78 -0
  157. data/tokens/tokens.dtcg.json +779 -0
  158. data/vendor/floating-ui/LICENSE +20 -0
  159. data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
  160. data/vendor/shadcn-tailwind/LICENSE +21 -0
  161. data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
  162. data/vendor/shadcn-tailwind/tailwind.css +629 -0
  163. data/vendor/tw-animate-css/LICENSE +21 -0
  164. data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
  165. data/vendor/tw-animate-css/tw-animate.css +1 -0
  166. metadata +267 -0
@@ -0,0 +1,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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ # The view-helper seam: included into ActionView by the engine
6
+ # (initializer "poetry_core.tag_helper"). Carries no helpers in core.
7
+ module TagHelper
8
+ end
9
+ end
10
+ 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