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