daisyui 2.1.0 → 2.2.0

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.
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ module Tailwind
5
+ # Renders the Tailwind sources for a set of (resolved) components:
6
+ #
7
+ # - `to_css`: a partial for the app's Tailwind entry to @import, one
8
+ # `@source inline("…")` per component after base.rb's shared classes. It
9
+ # names no path, so it is the same on every machine.
10
+ # - `to_json`: the same for a build script, plus each component's absolute
11
+ # files for anyone who wants a literal `@source` instead.
12
+ #
13
+ # `responsive:` adds `<breakpoint>:` variants of the classes `responsive:`
14
+ # renders (a component's own class and its modifiers). `prefix:` mirrors
15
+ # DaisyUI.configuration.prefix: the component class and modifiers always
16
+ # render prefixed, while a class string in a component's Ruby may render
17
+ # either way (`component_classes("card-body")` is prefixed, Tooltip's
18
+ # "after:hidden" is not), so those are given both ways.
19
+ class Sources
20
+ BREAKPOINT = /\A@?[a-z0-9][a-z0-9-]*\z/
21
+ PREFIX = /\A[^\s"'`;{}\\]+\z/
22
+
23
+ attr_reader :components
24
+
25
+ # @api private Shared with AppSources.
26
+ def self.validate_options!(breakpoints, prefix)
27
+ breakpoints.each do |breakpoint|
28
+ raise ArgumentError, "invalid breakpoint #{breakpoint.inspect}: give the variant name, e.g. sm or @md" unless BREAKPOINT.match?(breakpoint)
29
+ end
30
+ return if prefix.empty? || PREFIX.match?(prefix)
31
+
32
+ raise ArgumentError, "invalid prefix #{prefix.inspect}"
33
+ end
34
+
35
+ def initialize(manifest, components, responsive: [], prefix: nil)
36
+ @manifest = manifest
37
+ @components = components
38
+ @breakpoints = responsive.map(&:to_s)
39
+ @prefix = prefix.to_s
40
+ validate!
41
+ end
42
+
43
+ def render(format)
44
+ case format.to_s
45
+ when "css" then to_css
46
+ when "json" then to_json
47
+ else raise ArgumentError, "unknown format #{format.inspect}: css or json"
48
+ end
49
+ end
50
+
51
+ def to_css
52
+ header = "/* Generated by daisyui-tailwind (daisyui #{VERSION}): do not edit. */\n"
53
+ return "#{header}/* No DaisyUI components referenced. */\n" if components.empty?
54
+
55
+ lines = [
56
+ "/* daisyUI components: #{components.join(', ')} */",
57
+ "/* Shared by every component (lib/daisy_ui/base.rb) */",
58
+ inline(shared_classes)
59
+ ]
60
+ components.each { |name| lines.push("/* #{@manifest.components.fetch(name)['class']} */", inline(classes(name))) }
61
+ "#{header}#{lines.join("\n")}\n"
62
+ end
63
+
64
+ def to_json(*)
65
+ require "json"
66
+
67
+ "#{JSON.pretty_generate(json)}\n"
68
+ end
69
+
70
+ private
71
+
72
+ def json
73
+ files = @manifest.shared_files + @manifest.files(components)
74
+ {
75
+ "daisyui" => VERSION,
76
+ "components" => components.to_h { |name| [name, component_json(name)] },
77
+ "shared" => { "files" => @manifest.shared_files, "classes" => shared_classes },
78
+ "classes" => (shared_classes + components.flat_map { |name| classes(name) }).uniq.sort,
79
+ "files" => files.uniq.sort
80
+ }
81
+ end
82
+
83
+ def component_json(name)
84
+ entry = @manifest.components.fetch(name)
85
+ entry.slice("class", "name", "depends_on").merge("files" => @manifest.files([name]), "classes" => classes(name))
86
+ end
87
+
88
+ def classes(name)
89
+ entry = @manifest.components.fetch(name)
90
+ expand(entry["classes"], [entry["name"], *entry["modifiers"]].compact)
91
+ end
92
+
93
+ def shared_classes = expand(@manifest.shared["classes"], @manifest.shared["classes"])
94
+
95
+ def expand(classes, responsive)
96
+ prefixed = classes.map { |token| "#{@prefix}#{token}" }
97
+ prefixed += classes - responsive unless @prefix.empty?
98
+ prefixed + @breakpoints.flat_map { |breakpoint| responsive.map { |token| "#{breakpoint}:#{@prefix}#{token}" } }
99
+ end
100
+
101
+ def inline(classes) = %(@source inline("#{classes.join(' ')}");)
102
+
103
+ def validate! = self.class.validate_options!(@breakpoints, @prefix)
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "did_you_mean"
4
+
5
+ module DaisyUI
6
+ module Tailwind
7
+ # A component name (`--component Buton`) the manifest does not have.
8
+ class UnknownComponentError < ArgumentError
9
+ def initialize(name, known)
10
+ suggestions = DidYouMean::SpellChecker.new(dictionary: known).correct(name.to_s)
11
+ message = "DaisyUI has no component #{name}"
12
+ message = "#{message}. Did you mean? #{suggestions.join(', ')}" unless suggestions.empty?
13
+ super(message)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ module Tailwind
5
+ # Finds the components an app's Ruby and templates reference:
6
+ #
7
+ # - the constant: `DaisyUI::Card`, `class AppNavbar < DaisyUI::Navbar`
8
+ # - an instance: `Card.new(…)`
9
+ # - a kit call, with parentheses (`Button(:primary)`) or without
10
+ # (`Badge :primary do`, `Hero do`, `Modal { … }`, `Toast class: "…"`,
11
+ # `Button variant do`)
12
+ #
13
+ # Comments are skipped (each file's own kind: `#` lines in Ruby, `<%# %>`
14
+ # in ERB, `-#` in HAML, `/` in Slim), so "# Loading state…" is not a use
15
+ # of Loading, while a HAML `#sidebar= …` line still counts. A name is matched whole: `MyButton(` and `UI::Card(` are not
16
+ # uses (an app wrapper `UI::Card < DaisyUI::Card` is found by its own
17
+ # file). Matching on text over-reports rather than misses: a false hit
18
+ # costs some CSS, a miss leaves a component unstyled.
19
+ class Usage
20
+ COMMENTS = {
21
+ "rb" => /^[ \t]*#.*$/,
22
+ "erb" => /<%#.*?%>/m,
23
+ "haml" => /^[ \t]*-#.*$/,
24
+ "slim" => %r{^[ \t]*/.*$}
25
+ }.freeze
26
+ EXTENSIONS = COMMENTS.keys.freeze
27
+ # What follows a name in a call: `(`, `.new`, a block, or arguments
28
+ # without parentheses: `:primary`, `class: …`, `"…"`, a splat
29
+ # (`*modifiers`, `**options`, `&block`), or a variable that ends the line
30
+ # or is followed by `,`, `do` or `{` (`Button variant do`), which keeps
31
+ # "Join thousands of…" in a string from counting.
32
+ CALL = /
33
+ \( | \.new\b | [ \t]*\{ |
34
+ [ \t]+ (?: do\b | :[a-z_] | ["'] | [a-z_]\w*:[ \t] | (?: \*\*? | & ) [a-z_@(] |
35
+ [a-z_@][\w@]* (?: \.[a-z_]\w* )* [ \t]* (?: , | do\b | \{ | $ ) )
36
+ /x
37
+
38
+ def initialize(names)
39
+ names = names.sort_by { |name| -name.length }.map { |name| Regexp.escape(name) }.join("|")
40
+ @pattern = /DaisyUI::(#{names})\b|(?<![A-Za-z0-9_:])(#{names})(?=#{CALL})/
41
+ end
42
+
43
+ # Sorted names referenced in the files under `paths` (directories or
44
+ # files). A path that does not exist raises: a typo must not quietly
45
+ # leave every component out.
46
+ def scan(paths)
47
+ files(paths).flat_map { |file| references(File.read(file).scrub, kind: File.extname(file).delete_prefix(".")) }
48
+ .uniq.sort
49
+ end
50
+
51
+ # Sorted names referenced in `source`, a file of `kind` (its extension).
52
+ def references(source, kind: "rb")
53
+ source.gsub(COMMENTS.fetch(kind, COMMENTS["rb"]), "").scan(@pattern).flat_map(&:compact).uniq.sort
54
+ end
55
+
56
+ private
57
+
58
+ def files(paths)
59
+ paths.flat_map do |path|
60
+ path = path.to_s
61
+ if File.directory?(path)
62
+ Dir.glob(File.join(path, "**", "*.{#{EXTENSIONS.join(',')}}"))
63
+ elsif File.file?(path)
64
+ [path]
65
+ else
66
+ raise ArgumentError, "no such file or directory: #{path}"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ # Tailwind CSS sources for only the daisyUI components an app renders.
5
+ #
6
+ # Pointing Tailwind's `@source` at the whole gem hands it every class of
7
+ # every component (and ClassMerge's catalogue of Tailwind utilities), yet
8
+ # still misses what Tailwind v4 cannot see in Ruby: a Symbol component class
9
+ # (`self.component_class = :btn`) and anything in a comment. The gem's
10
+ # manifest (data/daisyui/tailwind_manifest.json, built from the components by
11
+ # `rake tailwind:manifest`) lists each component's classes and the
12
+ # components it renders, so an app's CSS build gets `@source inline(…)` for
13
+ # just the components it uses:
14
+ #
15
+ # DaisyUI::Tailwind.sources(scan: ["app/views", "app/components"])
16
+ # DaisyUI::Tailwind.sources(components: %w[Button Navbar], format: :json)
17
+ #
18
+ # `bundle exec daisyui-tailwind` (DaisyUI::Tailwind::Command) does the same
19
+ # from a build script.
20
+ module Tailwind
21
+ ROOT = File.expand_path("../..", __dir__).freeze
22
+ # In the gem's data/ dir, not lib/: an app that still points Tailwind at
23
+ # the gem's lib/ must not start scanning it.
24
+ MANIFEST_PATH = File.join(ROOT, "data", "daisyui", "tailwind_manifest.json").freeze
25
+
26
+ class << self
27
+ def manifest = Manifest.load
28
+
29
+ # The components the Ruby and templates under `scan` reference, plus the
30
+ # `components` named, plus every component those render. Sorted names.
31
+ def used_components(scan: [], components: [], manifest: self.manifest)
32
+ found = scan.empty? ? [] : Usage.new(manifest.component_names).scan(scan)
33
+ manifest.resolve(found + components)
34
+ end
35
+
36
+ # CSS (`format: :css`, for Tailwind to @import) or JSON for those
37
+ # components. `responsive:` adds breakpoint variants (`%w[sm md]`) of the
38
+ # classes `responsive:` renders; `prefix:` defaults to the
39
+ # DaisyUI.configuration.prefix the components render with.
40
+ #
41
+ # `app: true` gives the app's own components instead (app_components),
42
+ # or `app: [Widget, …]` the ones named: see AppSources. Their file is
43
+ # separate from the gem components' and meant to be committed.
44
+ def sources(scan: [], components: [], app: false, format: :css, responsive: [], prefix: DaisyUI.configuration.prefix)
45
+ return app_sources(app, scan:, components:).new(app == true ? app_components : app, responsive:, prefix:).render(format) if app
46
+
47
+ manifest = self.manifest
48
+ used = used_components(scan:, components:, manifest:)
49
+ Sources.new(manifest, used, responsive:, prefix:).render(format)
50
+ end
51
+
52
+ # The app's DaisyUI::Base subclasses, sorted by name: every named one
53
+ # whose constant still names it (a class a code reload replaced is
54
+ # skipped), defined outside the gem's lib/ or a gem component the app
55
+ # reopened to declare variants (the gem's own never do). A Rails app is
56
+ # eager-loaded first.
57
+ def app_components
58
+ ::Rails.application.eager_load! if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
59
+ ClassMerge::DaisyGroups.components.select { |component| current?(component) && app_owned?(component) }.sort_by(&:name)
60
+ end
61
+
62
+ # Whether the committed app sources at `path` differ from what the
63
+ # declarations give now (or the file is missing): a spec can fail on it,
64
+ # the way the gem's own manifest spec does.
65
+ def stale?(path, app: true, **)
66
+ !File.file?(path) || File.read(path) != sources(app:, **)
67
+ end
68
+
69
+ private
70
+
71
+ def app_sources(app, scan:, components:)
72
+ return AppSources if scan.empty? && components.empty?
73
+
74
+ raise ArgumentError, "app: gives the app's own components, a file of its own; " \
75
+ "scan: and components: give the gem's (#{app.inspect} with #{(scan + components).inspect})"
76
+ end
77
+
78
+ def app_owned?(component)
79
+ return true unless Object.const_source_location(component.name)&.first.to_s.start_with?(File.join(ROOT, "lib", ""))
80
+
81
+ !component.variant_definitions.equal?(Variants::EMPTY)
82
+ end
83
+
84
+ def current?(component)
85
+ Object.const_get(component.name).equal?(component)
86
+ rescue NameError
87
+ false
88
+ end
89
+ end
90
+ end
91
+ end
@@ -23,9 +23,16 @@ module DaisyUI
23
23
  name = "#{name}##{part}" if part
24
24
  message = "#{name} has no modifier #{modifier.inspect}"
25
25
  suggestions = DidYouMean::SpellChecker.new(dictionary: known.map(&:to_s)).correct(modifier.to_s)
26
- return message if suggestions.empty?
26
+ message = "#{message}. Did you mean? #{suggestions.map { |s| ":#{s}" }.join(', ')}" unless suggestions.empty?
27
+ groups.empty? ? message : "#{message}. Variants: #{groups}"
28
+ end
29
+
30
+ # The component's groups and their members, "size: sm, lg; color: …";
31
+ # a sub-component part has modifiers of its own, so none for it.
32
+ def groups
33
+ return "" if part || !component.respond_to?(:modifier_groups)
27
34
 
28
- "#{message}. Did you mean? #{suggestions.map { |s| ":#{s}" }.join(', ')}"
35
+ component.modifier_groups.map { |group, members| "#{group}: #{members.join(', ')}" }.join("; ")
29
36
  end
30
37
  end
31
38
  end
@@ -3,5 +3,5 @@
3
3
  module DaisyUI
4
4
  # This timestamp is automatically updated when releasing a new version
5
5
  # Format: YYYY-MM-DD HH:MM:SS UTC
6
- UPDATED_AT = "2026-10-15 20:45:37 UTC"
6
+ UPDATED_AT = "2026-10-26 18:45:50 UTC"
7
7
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ class Variants
5
+ # What a `compound` or `on` match means, and the checks that make a typo
6
+ # fail where it is declared (at boot) rather than never match:
7
+ #
8
+ # | value | matches when |
9
+ # |------------------|-----------------------------------------------------|
10
+ # | `:member` | that member is selected (given, defaulted or set) |
11
+ # | `[:a, :b]` | any of them is; a `nil` in the list means "or none" |
12
+ # | `nil` / `false` | no member of that group is |
13
+ # | `true` | any member of that group is |
14
+ # | `true` / `false` | a flag is on / off |
15
+ #
16
+ # @api private
17
+ class Match
18
+ def initialize(klass)
19
+ @klass = klass
20
+ end
21
+
22
+ # `selection` is { group => member or nil }, `on` the flags given.
23
+ def self.holds?(match, selection, on)
24
+ match.all? do |key, value|
25
+ if selection.key?(key)
26
+ member = selection[key]
27
+ case value
28
+ when true then !member.nil?
29
+ when false, nil then member.nil?
30
+ when Array then value.include?(member)
31
+ else member == value
32
+ end
33
+ else
34
+ on.include?(key) == value
35
+ end
36
+ end
37
+ end
38
+
39
+ def match!(match)
40
+ match.each do |key, value|
41
+ if groups.key?(key)
42
+ value_members(value).each { |member| member!(key, member) }
43
+ elsif flags.include?(key)
44
+ raise ArgumentError, "#{owner}: flag #{key.inspect} matches true or false, got #{value.inspect}" unless BOOLS.include?(value)
45
+ else
46
+ raise ArgumentError, "#{owner} has no variant or flag #{key.inspect}"
47
+ end
48
+ end
49
+ end
50
+
51
+ def member!(group, member)
52
+ raise ArgumentError, "#{owner} has no variant #{group.inspect}" unless groups.key?(group)
53
+ return if groups[group].include?(member)
54
+
55
+ raise ArgumentError, "#{owner}: #{group.inspect} has no member #{member.inspect}; #{group}: #{groups[group].join(', ')}"
56
+ end
57
+
58
+ def state!(name)
59
+ return if @klass.variant_definitions[:states].key?(name)
60
+
61
+ raise ArgumentError, "#{owner} has no state #{name.inspect}: declare it with `state #{name.inspect}, \"<variant>\"` first"
62
+ end
63
+
64
+ def slot!(name)
65
+ return if name == :root || @klass.variant_definitions[:slots].key?(name)
66
+
67
+ raise ArgumentError, "#{owner} has no slot #{name.inspect}: declare it with `slot #{name.inspect}` first"
68
+ end
69
+
70
+ private
71
+
72
+ BOOLS = [true, false].freeze
73
+ private_constant :BOOLS
74
+
75
+ def value_members(value)
76
+ return [] if value.nil? || value == true || value == false
77
+
78
+ Array(value).compact
79
+ end
80
+
81
+ def groups = @klass.modifier_groups
82
+
83
+ # Ungrouped modifiers and registered flags.
84
+ def flags = Schema.flags(@klass)
85
+
86
+ def owner = @klass.name || @klass.inspect
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ class Variants
5
+ # The instance side of the Variants API, included in DaisyUI::Base. A
6
+ # component without declarations (every gem component) skips it all.
7
+ module Rendering
8
+ private
9
+
10
+ # The `base` classes a `variants` block declared.
11
+ def variant_base_classes
12
+ apply_prefix(self.class.variant_definitions[:base])
13
+ end
14
+
15
+ # For `slot` (:root is the component itself): the classes of the
16
+ # members a default or compound selected (the root's given members are
17
+ # modifier_classes already), of every compound that holds, and of every
18
+ # `on` rule that holds, prefixed with its states.
19
+ def variant_classes(slot = :root)
20
+ definitions = self.class.variant_definitions
21
+ return [] if definitions.equal?(EMPTY)
22
+
23
+ resolution = variant_resolution
24
+ [
25
+ *variant_member_classes(resolution, slot, definitions),
26
+ *resolution.compounds.map { |compound| apply_prefix(compound[:class][slot]) },
27
+ *resolution.rules.flat_map { |rule| state_classes(rule, slot, definitions[:states]) }
28
+ ].compact
29
+ end
30
+
31
+ # Classes for one of the component's parts: the slot's own, those its
32
+ # variants, compounds and `on` rules give it, then `options[:class]`
33
+ # (removed from `options`, as component_classes does).
34
+ def slot_classes(name, options = self.options)
35
+ slots = self.class.variant_definitions[:slots]
36
+ raise ArgumentError, "#{self.class.name || self.class.inspect} has no slot #{name.inspect}" unless slots.key?(name)
37
+
38
+ merge_classes(apply_prefix(slots[name]), *variant_classes(name), options.delete(:class))
39
+ end
40
+
41
+ # `options` with the slot's classes, to splat into the part.
42
+ def slot_options(name, options = self.options)
43
+ classes = slot_classes(name, options)
44
+ classes ? options.merge(class: classes) : options
45
+ end
46
+
47
+ def variant_resolution
48
+ @variant_resolution ||= Resolution.new(self.class, modifiers)
49
+ end
50
+
51
+ def variant_member_classes(resolution, slot, definitions)
52
+ if slot == :root
53
+ resolution.implied_members.map { |member| apply_prefix(modifier_map[member]) }
54
+ else
55
+ resolution.active.map { |member| apply_prefix(definitions[:slot_classes].dig(member, slot)) }
56
+ end
57
+ end
58
+
59
+ def state_classes(rule, slot, states)
60
+ return [] unless (classes = rule[:class][slot])
61
+
62
+ rule[:states].flat_map do |state|
63
+ classes.split.map { |token| "#{states.fetch(state)}:#{apply_prefix(token)}" }
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ class Variants
5
+ # Which members, compounds and `on` rules hold for one instance, worked
6
+ # out explicitly rather than left to ClassMerge, so a default or a set
7
+ # member never renders beside the member the caller gave, with class
8
+ # merging on or off:
9
+ #
10
+ # 1. the caller's members (the last one per group, as ClassMerge keeps)
11
+ # 2. defaults, for groups the caller left empty
12
+ # 3. compounds in declaration order; each one's `set` fills or replaces a
13
+ # member the caller did not name, and later compounds see it
14
+ # 4. `on` rules, against the final selection
15
+ #
16
+ # @api private
17
+ class Resolution
18
+ attr_reader :compounds, :rules
19
+
20
+ def initialize(klass, given)
21
+ definitions = klass.variant_definitions
22
+ @given = given
23
+ @selection = klass.modifier_groups.to_h { |group, members| [group, given.reverse.find { |m| members.include?(m) }] }
24
+ @named = @selection.compact.keys
25
+ definitions[:defaults].each { |group, member| @selection[group] ||= member }
26
+ @compounds = matching_compounds(definitions[:compounds])
27
+ @rules = definitions[:rules].select { |rule| holds?(rule[:match]) }
28
+ end
29
+
30
+ # Members a default or a compound selected, not the caller.
31
+ def implied_members
32
+ @selection.filter_map { |group, member| member unless @named.include?(group) }
33
+ end
34
+
35
+ # Every member and flag in effect, for the slots' per-member classes.
36
+ def active = (@given | implied_members)
37
+
38
+ private
39
+
40
+ # One at a time: a compound's `set` is seen by the compounds after it.
41
+ def matching_compounds(compounds)
42
+ compounds.each_with_object([]) do |compound, matched|
43
+ next unless holds?(compound[:match])
44
+
45
+ matched << compound
46
+ compound[:set].each { |group, member| @selection[group] = member unless @named.include?(group) }
47
+ end
48
+ end
49
+
50
+ def holds?(match) = Match.holds?(match, @selection, @given)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ class Variants
5
+ # `Component.variants.schema`: a component's declarations as plain,
6
+ # frozen data. A component that only calls `register_modifiers` gets its
7
+ # variants and flags, with the rest empty.
8
+ #
9
+ # @api private
10
+ class Schema
11
+ # Ungrouped modifiers and the flags registered without a class.
12
+ def self.flags(klass)
13
+ grouped = klass.modifier_groups.values.flatten
14
+ ((klass.modifiers || {}).keys - grouped) | klass.flags
15
+ end
16
+
17
+ def initialize(klass)
18
+ @klass = klass
19
+ @definitions = klass.variant_definitions
20
+ end
21
+
22
+ def to_h
23
+ Shareable.share(
24
+ base:,
25
+ variants: @klass.modifier_groups.transform_values(&:dup),
26
+ flags: self.class.flags(@klass),
27
+ defaults: @definitions[:defaults].dup,
28
+ compounds: @definitions[:compounds].map { |entry| { match: entry[:match], class: shown(entry[:class]), set: entry[:set] } },
29
+ on: @definitions[:rules].map { |entry| { states: entry[:states], match: entry[:match], class: shown(entry[:class]) } },
30
+ states: @definitions[:states].dup,
31
+ slots: { root: base, **@definitions[:slots] }
32
+ )
33
+ end
34
+
35
+ # Every class token the declarations can render, `prefix` included the
36
+ # way Base#apply_prefix adds it (after any state), sorted.
37
+ def literal_classes(prefix)
38
+ plain = [base, *modifier_classes, *@definitions[:slots].values, *slot_member_classes, *compound_classes]
39
+ plain = plain.compact.flat_map(&:split).map { |token| "#{prefix}#{token}" }
40
+ (plain + state_classes(prefix)).uniq.sort
41
+ end
42
+
43
+ private
44
+
45
+ def base
46
+ classes = [@klass.component_class&.to_s, @definitions[:base]].compact.join(" ")
47
+ classes.empty? ? nil : classes
48
+ end
49
+
50
+ def modifier_classes = (@klass.modifiers || {}).values
51
+
52
+ def slot_member_classes = @definitions[:slot_classes].values.flat_map(&:values)
53
+
54
+ def compound_classes = @definitions[:compounds].flat_map { |entry| entry[:class].values }
55
+
56
+ def state_classes(prefix)
57
+ @definitions[:rules].flat_map do |rule|
58
+ tokens = rule[:class].values.flat_map(&:split)
59
+ rule[:states].flat_map do |state|
60
+ tokens.map { |token| "#{@definitions[:states].fetch(state)}:#{prefix}#{token}" }
61
+ end
62
+ end
63
+ end
64
+
65
+ # A root-only class shows as its String, per-slot classes as the Hash.
66
+ def shown(classes)
67
+ return nil if classes.empty?
68
+
69
+ classes.keys == [:root] ? classes[:root] : classes
70
+ end
71
+ end
72
+ end
73
+ end