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,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Poetry
6
+ module Core
7
+ module CSS
8
+ # poetry's in-tree server-side class-variants engine - no external
9
+ # runtime dependency.
10
+ #
11
+ # The resolver is a dictionary from a component's style surface to CSS
12
+ # utility classes:
13
+ #
14
+ # base "inline-flex items-center" # the block root
15
+ # element :icon, "size-4 shrink-0" # BEM elements (block__icon)
16
+ # variant :color, red: "text-red-600", ... # BEM modifiers (block--color-red)
17
+ # compound ({ color: :red, mode: :dark }, "...") # multi-key combinations
18
+ #
19
+ # The resolver deliberately stores **no defaults**: default values
20
+ # live in exactly one place - the component's `style :attr, default:`
21
+ # declaration (ActiveModel fills them before render) - which
22
+ # structurally rules out the duplicated-defaults class of bug. Render
23
+ # criteria always arrive resolved.
24
+ #
25
+ # Merging goes through the shared, FIFO-cached Config merger
26
+ # (configured once - never a fresh TailwindMerge instance per render).
27
+ #
28
+ # @example
29
+ # resolver = Poetry::Core::CSS::Resolver.new
30
+ # resolver.base("inline-flex items-center")
31
+ # .variant(:color, red: "text-red-600", blue: "text-blue-600")
32
+ # resolver.render(color: :red) # => "inline-flex items-center text-red-600"
33
+ #
34
+ # @api private
35
+ class Resolver
36
+ Compound = Struct.new(:criteria, :classes)
37
+
38
+ attr_reader :bases, :elements, :variants, :compounds
39
+
40
+ def initialize
41
+ @bases = []
42
+ @elements = {}
43
+ @variants = {}
44
+ @compounds = []
45
+ end
46
+
47
+ # Subclass inheritance: a child Style extends a copy of its parent's
48
+ # dictionary (dup-on-inherit).
49
+ def dup
50
+ self.class.new.tap do |copy|
51
+ copy.instance_variable_set(:@bases, @bases.dup)
52
+ copy.instance_variable_set(:@elements, @elements.transform_values(&:dup))
53
+ copy.instance_variable_set(:@variants, @variants.transform_values(&:dup))
54
+ copy.instance_variable_set(:@compounds, @compounds.dup)
55
+ end
56
+ end
57
+
58
+ # -- The dictionary DSL ------------------------------------------------
59
+
60
+ def base(classes)
61
+ @bases << classes.to_s
62
+ self
63
+ end
64
+
65
+ def element(name, classes)
66
+ (@elements[name.to_sym] ||= []) << classes.to_s
67
+ self
68
+ end
69
+
70
+ def variant(attr, mapping)
71
+ bucket = (@variants[attr.to_sym] ||= {})
72
+ mapping.each { |value, classes| bucket[value] = classes.to_s }
73
+ self
74
+ end
75
+
76
+ def compound(criteria, classes)
77
+ raise ArgumentError, "compound criteria must name at least two variant keys" if criteria.size < 2
78
+
79
+ @compounds << Compound.new(criteria.transform_keys(&:to_sym), classes.to_s)
80
+ self
81
+ end
82
+
83
+ # -- Rendering ---------------------------------------------------------
84
+
85
+ # Resolves utility classes for the block root (element: nil) or a
86
+ # named element. `criteria` are the component's resolved style values;
87
+ # `extra` is a caller-supplied class string appended last (wins on
88
+ # Tailwind conflicts via the merger).
89
+ #
90
+ # @param element [Symbol, nil] a declared element name, or nil for the root
91
+ # @param extra [String, nil] caller classes appended after the dictionary's
92
+ # @param criteria [Hash{Symbol => Object}] resolved style values keyed by variant attr
93
+ # @return [String, nil] the merged class string, or nil when nothing resolves
94
+ def render(element = nil, extra: nil, **criteria)
95
+ classes = element ? @elements.fetch(element.to_sym, []).dup : root_classes(criteria)
96
+ classes << extra if extra
97
+ merger.merge(*classes)
98
+ end
99
+
100
+ # The introspection surface (previews, docs, the registry):
101
+ # { attr => [values] } for the declared variant space.
102
+ def variant_options
103
+ @variants.transform_values(&:keys)
104
+ end
105
+
106
+ # The capsule digest: a deterministic content hash of the whole
107
+ # dictionary. Embedded in the generated :bem reference stylesheet,
108
+ # so CSS written against an older dictionary is detectable instead
109
+ # of silently drifting.
110
+ #
111
+ # @return [String] a 12-character hex digest
112
+ def digest
113
+ Digest::SHA256.hexdigest(canonical_dictionary.inspect)[0, 12]
114
+ end
115
+
116
+ # Every utility class string in the dictionary (bases, elements,
117
+ # variants, compounds) - the Verifier's and the safelist's input.
118
+ def all_classes
119
+ [
120
+ @bases,
121
+ @elements.values.flatten,
122
+ @variants.values.flat_map(&:values),
123
+ @compounds.map(&:classes)
124
+ ].flatten.flat_map(&:split).uniq
125
+ end
126
+
127
+ private
128
+
129
+ # A stable, order-insensitive serialization of the dictionary for the
130
+ # capsule digest.
131
+ def canonical_dictionary
132
+ {
133
+ bases: @bases,
134
+ elements: @elements.sort_by { |name, _| name.to_s }.to_h { |name, v| [name, v] },
135
+ variants: @variants.sort_by { |attr, _| attr.to_s }
136
+ .map { |attr, mapping| [attr, mapping.sort_by { |value, _| value.to_s }] },
137
+ compounds: @compounds.map { |rule| [rule.criteria.sort_by { |k, _| k.to_s }, rule.classes] }
138
+ }
139
+ end
140
+
141
+ def root_classes(criteria)
142
+ classes = @bases.dup
143
+ @variants.each do |attr, mapping|
144
+ next unless criteria.key?(attr)
145
+
146
+ hit = mapping[criteria[attr]]
147
+ classes << hit if hit
148
+ end
149
+ @compounds.each do |rule|
150
+ classes << rule.classes if rule.criteria.all? { |attr, value| criteria[attr] == value }
151
+ end
152
+ classes
153
+ end
154
+
155
+ def merger
156
+ Poetry::Core::Config.current.classname_merger
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ module CSS
6
+ # Generates the Tailwind safelist: every utility class poetry's Style
7
+ # dictionaries emit, one per line. This solves the consumer-purge
8
+ # problem: a host app's Tailwind build can't see classes resolved at
9
+ # render time in Ruby, so the generated safelist file is added to the
10
+ # host's `@source` paths and every dictionary class survives the build.
11
+ #
12
+ # Template-static classes (from .html.erb sidecars) are extracted via
13
+ # the Herb AST and included too - see {TemplateClasses}.
14
+ #
15
+ # @example
16
+ # Poetry::Core::CSS::Safelist.new(style_classes: [MyApp::Button::Style]).text
17
+ # # => "# poetry Tailwind safelist ...\nbg-primary\ninline-flex\n..."
18
+ #
19
+ # @api private
20
+ class Safelist
21
+ HEADER = "# poetry Tailwind safelist - generated by `bin/rake css:safelist`; add to @source. DO NOT EDIT."
22
+
23
+ # @param style_classes [Enumerable<Class>] Style subclasses to collect from
24
+ # @param template_classes [Enumerable<String>] extra (template-static) classes
25
+ def initialize(style_classes:, template_classes: [])
26
+ @style_classes = style_classes
27
+ @template_classes = template_classes
28
+ end
29
+
30
+ def classes
31
+ dictionary = @style_classes.flat_map { |style| style.resolver.all_classes }
32
+ (dictionary + @template_classes.to_a).uniq.sort
33
+ end
34
+
35
+ def text
36
+ "#{([HEADER] + classes).join("\n")}\n"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ # The CSS tooling namespace: class-name merging and the compiled-build gates.
6
+ module CSS
7
+ # Intelligently merges Tailwind CSS class names by resolving conflicts and removing duplicates.
8
+ #
9
+ # This class provides a convenient wrapper around TailwindMerge::Merger to handle the common
10
+ # pattern of merging multiple Tailwind CSS class lists while automatically resolving style
11
+ # conflicts. When multiple classes target the same CSS property, the last one wins.
12
+ #
13
+ # @example Basic usage
14
+ # merger = Poetry::Core::CSS::Merger.new
15
+ # merger.merge('text-red-500', 'text-blue-500')
16
+ # # => 'text-blue-500' (conflict resolved - last one wins)
17
+ #
18
+ # @example Merging arrays of classes
19
+ # merger.merge(['bg-white', 'p-4'], ['bg-gray-100', 'rounded'])
20
+ # # => 'p-4 bg-gray-100 rounded' (bg-white replaced by bg-gray-100)
21
+ #
22
+ # @example Handling nil and blank values
23
+ # merger.merge('p-4', nil, '', 'rounded')
24
+ # # => 'p-4 rounded' (nil and blank values are filtered out)
25
+ #
26
+ # @example Working with symbols
27
+ # merger.merge(:rounded, :'text-center')
28
+ # # => 'rounded text-center' (symbols converted to strings)
29
+ #
30
+ # @see Poetry::Core::CSS::BemMerger (the css_mode = :bem counterpart)
31
+ # @api private
32
+ class TailwindMerger
33
+ # Bounded FIFO cache over merge results: components render the same
34
+ # class combinations over and over, so tailwind_merge runs once per
35
+ # DISTINCT combo instead of once per render.
36
+ CACHE_LIMIT = 512
37
+
38
+ # Initializes a new CSS merger instance.
39
+ #
40
+ # Creates an underlying TailwindMerge::Merger instance that handles the
41
+ # core logic of identifying and resolving Tailwind CSS class conflicts.
42
+ def initialize
43
+ @base_merger = TailwindMerge::Merger.new
44
+ @cache = {}
45
+ @mutex = Mutex.new
46
+ end
47
+
48
+ # Merges multiple Tailwind CSS class names, resolving any styling conflicts.
49
+ #
50
+ # This method intelligently combines CSS classes by:
51
+ # 1. Flattening nested arrays into a single list
52
+ # 2. Removing nil and blank values
53
+ # 3. Converting all values to strings (handles symbols, etc.)
54
+ # 4. Resolving Tailwind class conflicts (last value wins)
55
+ # 5. Removing exact duplicates
56
+ #
57
+ # When multiple classes affect the same CSS property (e.g., text-red-500 and
58
+ # text-blue-500), only the last one in the sequence is kept. Non-conflicting
59
+ # classes are preserved.
60
+ #
61
+ # @param classes [Array<String, Symbol, Array, nil>] One or more class names,
62
+ # arrays of class names, or nil values to merge
63
+ # @return [String, nil] A space-separated string of merged class names, or nil
64
+ # if the input is empty after normalization
65
+ #
66
+ # @example Simple merge
67
+ # merge('p-4', 'rounded')
68
+ # # => 'p-4 rounded'
69
+ #
70
+ # @example Conflict resolution
71
+ # merge('text-sm', 'text-lg')
72
+ # # => 'text-lg'
73
+ #
74
+ # @example Array handling
75
+ # merge(['p-4', 'rounded'], ['bg-white', 'shadow'])
76
+ # # => 'p-4 rounded bg-white shadow'
77
+ #
78
+ # @example Blank value filtering
79
+ # merge('p-4', nil, '', 'rounded')
80
+ # # => 'p-4 rounded'
81
+ #
82
+ # @example Returns nil for empty input
83
+ # merge(nil, '', [])
84
+ # # => nil
85
+ def merge(*classes)
86
+ normalized = classes.flatten.compact_blank.map(&:to_s)
87
+ return nil if normalized.empty?
88
+
89
+ key = normalized.join(" ")
90
+ @mutex.synchronize do
91
+ if @cache.key?(key)
92
+ @cache[key]
93
+ else
94
+ @cache.shift if @cache.size >= CACHE_LIMIT # Hash keeps insertion order: shift = FIFO eviction
95
+ @cache[key] = @base_merger.merge(key)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ module CSS
6
+ # Herb-AST extraction of the STATIC class attributes in ERB templates:
7
+ # only the literal chunks of a class attribute are collected;
8
+ # ERB-dynamic chunks are skipped entirely, so dynamic class logic can
9
+ # never false-flag. Feeds the Tailwind safelist (template classes
10
+ # survive the host's purge) and doubles as the herb parse gate -
11
+ # parse errors are surfaced, not swallowed.
12
+ #
13
+ # Herb is loaded lazily: it is a build/CI-time tool, not a runtime
14
+ # dependency of the gem.
15
+ #
16
+ # @example
17
+ # result = Poetry::Core::CSS::TemplateClasses.extract('<div class="p-4 <%= extra %>">')
18
+ # result.classes # => ["p-4"]
19
+ #
20
+ # @api private
21
+ class TemplateClasses
22
+ ParseError = Struct.new(:path, :message) do
23
+ def to_s
24
+ "#{path}: #{message}"
25
+ end
26
+ end
27
+
28
+ Result = Struct.new(:classes, :errors)
29
+
30
+ class << self
31
+ # Whether the herb gem is loadable (extraction is optional at
32
+ # install time in a host app; required in poetry's own CI).
33
+ def available?
34
+ herb!
35
+ true
36
+ rescue Poetry::Core::Error
37
+ false
38
+ end
39
+
40
+ # Extracts static classes from one ERB source string.
41
+ #
42
+ # @return [Result] classes (Array<String>) + errors (Array<String>)
43
+ def extract(source)
44
+ herb!
45
+ parsed = Herb.parse(source)
46
+ classes = []
47
+ walk(parsed.value) do |node|
48
+ next unless node.is_a?(Herb::AST::HTMLAttributeNode) && class_attribute?(node)
49
+
50
+ classes.concat(static_classes(node))
51
+ end
52
+ Result.new(classes.uniq, parsed.errors.map(&:message))
53
+ end
54
+
55
+ # Scans every template under root matching the glob.
56
+ #
57
+ # @return [Result] unique sorted classes + per-file ParseErrors
58
+ def scan(root:, glob: "app/components/**/*.html.erb")
59
+ classes = []
60
+ errors = []
61
+ Dir.glob(glob, base: root.to_s).sort.each do |relative|
62
+ result = extract(File.read(File.join(root, relative)))
63
+ classes.concat(result.classes)
64
+ errors.concat(result.errors.map { |message| ParseError.new(relative, message) })
65
+ end
66
+ Result.new(classes.uniq.sort, errors)
67
+ end
68
+
69
+ private
70
+
71
+ def herb!
72
+ require "herb"
73
+ rescue LoadError
74
+ raise Poetry::Core::Error,
75
+ "the herb gem is required for template class extraction - add `gem \"herb\"` to your Gemfile"
76
+ end
77
+
78
+ def walk(node, &block)
79
+ yield node
80
+ node.child_nodes.compact.each { |child| walk(child, &block) } if node.respond_to?(:child_nodes)
81
+ end
82
+
83
+ def class_attribute?(attribute)
84
+ name = attribute.name.child_nodes.compact.first
85
+ name.respond_to?(:content) && name.content == "class"
86
+ end
87
+
88
+ # Only the LiteralNode chunks of the attribute value - ERB chunks
89
+ # are dynamic and deliberately ignored.
90
+ def static_classes(attribute)
91
+ value = attribute.value
92
+ return [] unless value
93
+
94
+ value.child_nodes.compact.filter_map do |chunk|
95
+ chunk.content if chunk.is_a?(Herb::AST::LiteralNode)
96
+ end.flat_map(&:split)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ module CSS
6
+ # The theme-layer coverage gate: bidirectional agreement between
7
+ # the cn-* names Style dictionaries emit and the rules a theme
8
+ # fragment defines.
9
+ #
10
+ # missing - dictionary cn names with no theme rule (the component
11
+ # would render undesigned; css:verify_compiled reddens
12
+ # too - this names the culprit precisely)
13
+ # orphans - theme cn tokens no dictionary emits (a dead rule, or a
14
+ # rename the theme didn't follow). Consumer-facing utility
15
+ # classes a theme defines on purpose (cn-font-heading)
16
+ # ride the allowlist.
17
+ #
18
+ # The parser is deliberately narrow: cn tokens are read from top-level
19
+ # selectors only (lines that open a rule), never from @apply bodies -
20
+ # theme fragments are flat by convention (see themes/default.css).
21
+ #
22
+ # @example
23
+ # coverage = Poetry::Core::CSS::ThemeCoverage.new(
24
+ # theme_css: File.read("themes/default.css"),
25
+ # style_classes: [MyApp::Button::Style]
26
+ # )
27
+ # coverage.ok? || [coverage.missing, coverage.orphans]
28
+ #
29
+ # @api private
30
+ class ThemeCoverage
31
+ SELECTOR_LINE = /^[^@\s{}][^{]*\{/
32
+ CN_TOKEN = /\.(cn-[a-z0-9-]+)/
33
+
34
+ def initialize(theme_css:, style_classes:, allowlist: [])
35
+ @theme_css = theme_css
36
+ @style_classes = style_classes
37
+ @allowlist = allowlist.map(&:to_s)
38
+ end
39
+
40
+ # Every cn-* class any dictionary emits (bases, elements, variants,
41
+ # compounds), deduplicated across dictionaries.
42
+ def dictionary_names
43
+ @dictionary_names ||= @style_classes
44
+ .flat_map { |style| style.resolver.all_classes }
45
+ .select { |cls| cls.start_with?("cn-") }
46
+ .uniq.sort
47
+ end
48
+
49
+ # Every cn-* token appearing in a theme selector (a combined
50
+ # compound selector like `.cn-x-a.cn-x-b` contributes both).
51
+ def theme_names
52
+ @theme_names ||= @theme_css.each_line
53
+ .grep(SELECTOR_LINE)
54
+ .flat_map { |line| line[0...line.index("{")].scan(CN_TOKEN) }
55
+ .flatten.uniq.sort
56
+ end
57
+
58
+ def missing
59
+ dictionary_names - theme_names
60
+ end
61
+
62
+ def orphans
63
+ theme_names - dictionary_names - @allowlist
64
+ end
65
+
66
+ def ok?
67
+ missing.empty? && orphans.empty?
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ module CSS
6
+ # The custom-property contract: every `var(--x)` READ in a
7
+ # compiled build must resolve to a DEFINITION - a declaration or a
8
+ # CSS `@property` registration in the compiled CSS, or a runtime assignment
9
+ # (JS setProperty, inline style attributes) that the caller passes in,
10
+ # since runtime assignments never appear in the build output.
11
+ #
12
+ # Derivation over maintenance: the definition set is read from the
13
+ # artifacts that already exist (the compiled build + the actual JS and
14
+ # template sources), never a hand-maintained list - so a token rename
15
+ # or deletion reddens every stale read the moment it lands. A read
16
+ # WITH a fallback still counts: `var(--x, 4px)` where --x is defined
17
+ # nowhere is a dead read whose intent lives only in the fallback -
18
+ # drift by construction, not a feature.
19
+ #
20
+ # Dynamic runtime names (`--drawer-swipe-movement-${axis}`) enter as
21
+ # PREFIXES: a read resolves if it starts with a registered prefix.
22
+ #
23
+ # @example
24
+ # coverage = Poetry::Core::CSS::VarCoverage.new(
25
+ # compiled_css: File.read("app/assets/builds/tailwind.css"),
26
+ # extra_definitions: ["--anchor-width"] # assigned from JS at runtime
27
+ # )
28
+ # coverage.ok? || coverage.dead_reads # => ["--never-defined"]
29
+ #
30
+ # @api private
31
+ class VarCoverage
32
+ # `--x:` declarations; the lookbehind keeps Stimulus event tokens
33
+ # (`poetry--core--calendar:change`) and BEM-ish substrings out.
34
+ DECLARATION = /(?<![\w-])(--[A-Za-z][\w-]*)\s*:/
35
+ PROPERTY_RULE = /@property\s+(--[A-Za-z][\w-]*)/
36
+ READ = /var\(\s*(--[A-Za-z][\w-]*)/
37
+
38
+ def initialize(compiled_css:, extra_definitions: [], definition_prefixes: [], extra_reads: [])
39
+ @compiled_css = strip_comments(compiled_css)
40
+ @extra_definitions = extra_definitions.map(&:to_s)
41
+ @definition_prefixes = definition_prefixes.map(&:to_s)
42
+ @extra_reads = extra_reads.map(&:to_s)
43
+ end
44
+
45
+ def definitions
46
+ @definitions ||= (@compiled_css.scan(DECLARATION).flatten +
47
+ @compiled_css.scan(PROPERTY_RULE).flatten +
48
+ @extra_definitions).to_set
49
+ end
50
+
51
+ def reads
52
+ @reads ||= (@compiled_css.scan(READ).flatten + @extra_reads).uniq.sort
53
+ end
54
+
55
+ # Reads that resolve to nothing: not declared in the build, not
56
+ # registered via CSS `@property`, not runtime-assigned. Each one is a rule
57
+ # silently falling back (or to nothing at all) - a class of bug
58
+ # once dug out by hand as a dozen dead reads of renamed vars.
59
+ def dead_reads
60
+ reads.reject do |name|
61
+ definitions.include?(name) || @definition_prefixes.any? { |prefix| name.start_with?(prefix) }
62
+ end
63
+ end
64
+
65
+ def ok? = dead_reads.empty?
66
+
67
+ private
68
+
69
+ def strip_comments(css)
70
+ css.gsub(%r{/\*.*?\*/}m, "")
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "did_you_mean/levenshtein"
4
+
5
+ module Poetry
6
+ module Core
7
+ module CSS
8
+ # The class Verifier: validates that every class a Style dictionary
9
+ # emits actually exists in the COMPILED Tailwind stylesheet -
10
+ # catching typos and LLM-hallucinated classes before they ship as
11
+ # silently-unstyled markup.
12
+ #
13
+ # A pure function over (classes, compiled CSS): no Tailwind toolchain
14
+ # required at verify time, just the build output. Built as a reusable
15
+ # library on purpose - CI, the agent checks, the editor LSP, and the
16
+ # host-app `poetry:verify` task all consume this one implementation.
17
+ #
18
+ # @example
19
+ # verifier = Poetry::Core::CSS::Verifier.new(compiled_css: File.read("builds/tailwind.css"))
20
+ # verifier.unknown(["inline-flex", "text-red-510"]).map(&:to_s)
21
+ # # => ["text-red-510 (did you mean \"text-red-500\"?)"]
22
+ #
23
+ # @api private
24
+ class Verifier
25
+ Unknown = Struct.new(:class_name, :suggestion) do
26
+ def to_s
27
+ suggestion ? "#{class_name} (did you mean #{suggestion.inspect}?)" : class_name
28
+ end
29
+ end
30
+
31
+ # Maximum Levenshtein distance for a did-you-mean suggestion.
32
+ SUGGESTION_DISTANCE = 2
33
+
34
+ # Named group/peer MARKER classes (`group/menu-item`, `peer/menu-button`).
35
+ # Tailwind emits no CSS for the bare marker by design - it only ever
36
+ # appears inside the selectors of utilities that CONSUME it
37
+ # (`group-focus/menu-item:*`), so its presence in compiled CSS depends
38
+ # on which theme is active. A marker is valid markup regardless - a
39
+ # theme fragment may be the only place its consumers live - so
40
+ # verification skips them instead of rewarding the coincidence of a
41
+ # same-theme consumer.
42
+ MARKER_CLASS = %r{\A(?:group|peer)/[a-z0-9-]+\z}
43
+
44
+ # Extracts the set of class names defined by a compiled CSS text.
45
+ # Selectors are the text runs preceding `{` (which also covers rules
46
+ # nested in @media etc.); Tailwind's escaping (`\:` `\/` `\[` ...) is
47
+ # unescaped so names compare in their authored form. Tokens starting
48
+ # with a digit are rejected (artifacts of dimensions like `.5rem` in
49
+ # at-rule params - real leading-digit classes are escaped by Tailwind
50
+ # and survive unescaping).
51
+ def self.known_classes(compiled_css)
52
+ selectors = compiled_css.gsub(%r{/\*.*?\*/}m, "").scan(/[^{}]+(?=\{)/)
53
+ selectors.each_with_object(Set.new) do |selector, known|
54
+ selector.scan(/\.((?:\\.|[-\w])+)/) do |(token)|
55
+ name = token.gsub(/\\(.)/, '\1')
56
+ known << name unless name.match?(/\A\d/)
57
+ end
58
+ end
59
+ end
60
+
61
+ attr_reader :known
62
+
63
+ def initialize(compiled_css:)
64
+ @known = self.class.known_classes(compiled_css)
65
+ end
66
+
67
+ # The classes (of those given) that do NOT exist in the compiled CSS,
68
+ # each with a nearest-known-class suggestion when one is close enough.
69
+ #
70
+ # @param classes [Enumerable<String>]
71
+ # @return [Array<Unknown>]
72
+ def unknown(classes)
73
+ classes.uniq.reject { |name| @known.include?(name) || name.match?(MARKER_CLASS) }.map do |name|
74
+ Unknown.new(name, suggestion_for(name))
75
+ end
76
+ end
77
+
78
+ # Verifies every class in a Style dictionary (bases, elements,
79
+ # variants, compounds).
80
+ #
81
+ # @param style_class [Class] a Poetry::Core::Style subclass
82
+ # @return [Array<Unknown>]
83
+ def verify_style(style_class)
84
+ unknown(style_class.resolver.all_classes)
85
+ end
86
+
87
+ private
88
+
89
+ def suggestion_for(name)
90
+ best = nil
91
+ best_distance = SUGGESTION_DISTANCE + 1
92
+ @known.each do |candidate|
93
+ next if (candidate.length - name.length).abs > SUGGESTION_DISTANCE
94
+
95
+ distance = DidYouMean::Levenshtein.distance(name, candidate)
96
+ if distance < best_distance
97
+ best = candidate
98
+ best_distance = distance
99
+ end
100
+ end
101
+ best
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end