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,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "daisyui"
5
+
6
+ exit DaisyUI::Tailwind::Command.run(ARGV)
data/lib/daisy_ui/base.rb CHANGED
@@ -35,6 +35,8 @@ module DaisyUI
35
35
  # This renders: <div class="[generated classes]" id="..." data-...="..." ...>
36
36
  #
37
37
  class Base < Phlex::HTML
38
+ include Variants::Rendering
39
+
38
40
  BOOLS = [true, false].freeze
39
41
  # Shared color modifiers used across multiple components
40
42
  # Maps color names to their DaisyUI background/text class combinations
@@ -145,6 +147,16 @@ module DaisyUI
145
147
  @component_class = Shareable.share(component_class)
146
148
  end
147
149
 
150
+ # What a `variants` block declared beyond modifiers: base, defaults,
151
+ # compounds, states, `on` rules and slots (see DaisyUI::Variants).
152
+ def variant_definitions
153
+ @variant_definitions || Variants::EMPTY
154
+ end
155
+
156
+ def variant_definitions=(definitions)
157
+ @variant_definitions = Shareable.share(definitions)
158
+ end
159
+
148
160
  # Positional symbols a component handles itself (e.g. Dropdown's
149
161
  # :popover): accepted as modifiers, but they emit no class.
150
162
  def flags
@@ -183,6 +195,7 @@ module DaisyUI
183
195
  subclass.modifiers = (modifiers || {}).dup
184
196
  subclass.modifier_groups = modifier_groups.transform_values(&:dup)
185
197
  subclass.flags = flags.dup
198
+ subclass.variant_definitions = variant_definitions
186
199
  # Inherit component_class if it was explicitly set
187
200
  return unless instance_variable_defined?(:@component_class)
188
201
 
@@ -216,11 +229,15 @@ module DaisyUI
216
229
  ClassMerge.reset!
217
230
  end
218
231
 
232
+ # Declares variants, compounds, defaults, states and slots; returns the
233
+ # declarations, so `Widget.variants.schema` describes them.
234
+ def variants(&) = Variants.build(self, &)
235
+
219
236
  # @api private Called by DaisyUI.finalize! for every component: derives
220
237
  # component_class on the main Ractor and makes sure every registry is
221
238
  # frozen for non-main Ractors.
222
239
  def finalize!
223
- [component_class, modifiers, modifier_groups, flags].each { |registry| Shareable.share(registry) }
240
+ [component_class, modifiers, modifier_groups, flags, variant_definitions].each { |registry| Shareable.share(registry) }
224
241
  self
225
242
  end
226
243
  end
@@ -246,17 +263,20 @@ module DaisyUI
246
263
  # Main extension points - override these in your project!
247
264
 
248
265
  # Builds the final CSS class string from:
249
- # 1. Component base class (e.g., "dropdown")
266
+ # 1. Component base class (e.g., "dropdown"), then a `variants` base
250
267
  # 2. Modifier classes (e.g., "dropdown-top")
251
- # 3. Responsive classes (e.g., "sm:dropdown-hover")
252
- # 4. User-provided classes via `class:` option
268
+ # 3. What a `variants` block derives: defaults, compounds, `on` states
269
+ # 4. Responsive classes (e.g., "sm:dropdown-hover")
270
+ # 5. User-provided classes via `class:` option
253
271
  #
254
272
  # Note: This method DESTRUCTIVELY removes :class from options
255
273
  # so it won't appear again in attributes
256
274
  def classes
257
275
  merge_classes(
258
276
  base_class,
277
+ variant_base_classes,
259
278
  *modifier_classes,
279
+ *variant_classes,
260
280
  *responsive_classes,
261
281
  options.delete(:class)
262
282
  )
@@ -11,6 +11,10 @@ module DaisyUI
11
11
  # * appends the JS dir to the asset load path so Propshaft/Sprockets serve
12
12
  # the .min.js and its sourcemap (Sprockets also needs them precompiled).
13
13
  #
14
+ # Rails also loads lib/tasks/daisyui.rake from here: `bin/rails
15
+ # daisyui:tailwind[OUTPUT]` writes the Tailwind sources of the app's own
16
+ # components (DaisyUI::Tailwind::AppSources).
17
+ #
14
18
  # The controllers are opt-in at the markup level (`Dropdown(:popover, stimulus:
15
19
  # true)`, `Tooltip(:popover)`) and not preloaded, so a page that never uses
16
20
  # one never fetches it.
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ class McpServer
5
+ # The text of the `variants_guide` tool: the `variants` DSL on one page, with
6
+ # the three ways it surprises. Keep it in step with the README's "Variants,
7
+ # states and slots" and the docs site page.
8
+ module VariantsGuide
9
+ TEXT = <<~MARKDOWN
10
+ # Variants, states and slots
11
+
12
+ A component that owns its design declares it in a `variants` block on a
13
+ `DaisyUI::Base` subclass. `get_component` shows the result as a schema.
14
+
15
+ ```ruby
16
+ class Widget < DaisyUI::Button
17
+ variants do
18
+ base "font-medium"
19
+ variant :size, sm: "btn-sm", md: "btn-md"
20
+ default :size, :md
21
+ compound style: :outline, color: nil, class: "[--btn-bg:var(--color-base-100)]"
22
+ state :hover, "hover"
23
+ on :hover, style: %i[ghost link], class: "[--btn-bg:transparent]"
24
+ end
25
+ end
26
+ ```
27
+
28
+ ## Which declaration
29
+
30
+ | Need | Use |
31
+ |---|---|
32
+ | Alternatives, only one applies (sizes, colors) | `variant :group, member: "classes"` |
33
+ | An on/off modifier | `flag :name, "classes"`; `flag :name` alone has no class and exists to be matched |
34
+ | Classes every instance gets | `base "classes"` (appended to the inherited base) |
35
+ | A member when the caller names none of the group | `default :group, :member` |
36
+ | Classes when modifiers hold together or one is absent | `compound(**match, class:, set: {})` |
37
+ | The same classes under a browser state | `state :name, "<tailwind variant>"` once, then `on(states, **match, class:)` |
38
+ | A part of the component (trigger, menu) | `slot :name, "classes"`, then `slot_classes(name, options)` / `slot_options(name, options)` in the part's method |
39
+
40
+ ## Match values (`compound`, `on`)
41
+
42
+ | Value | Holds when |
43
+ |---|---|
44
+ | `:member` | that member is selected: given, defaulted or set by an earlier compound |
45
+ | `[:a, :b]` | any of them is; a `nil` in the list means "or none": `shape: [nil, :row]` |
46
+ | `nil` / `false` | no member of the group is |
47
+ | `true` | any member of the group is: `color: true` |
48
+ | `true` / `false` | for a flag: on / off |
49
+
50
+ `set: { group: :member }` on a compound selects a member for the caller's empty
51
+ groups; later compounds see it. `class` and `set` are reserved keys: a group called that could never be matched.
52
+ A `class:` or a modifier value can be a Hash keyed by slot (`root` is the component).
53
+
54
+ ## Render order
55
+
56
+ component class, `base`, the caller's modifiers, defaulted and set members,
57
+ compounds, `on` rules, `responsive:`, then the caller's `class:`. ClassMerge
58
+ resolves conflicts, the last one wins.
59
+
60
+ ## Pitfalls
61
+
62
+ 1. A `default` renders after the caller's modifiers. A default member's class
63
+ beats a caller modifier that sets the same property (a default size's
64
+ `--fontsize` beats an `:inline` modifier's). Put a value both can set in
65
+ `base`, not in a default.
66
+ 2. A compound or `on` rule also renders after the caller's modifiers, so it
67
+ can override a modifier's arbitrary property. Leave the conflicting member
68
+ out of the match (`layout: [nil, :inline, :row]`).
69
+ 3. `on` writes `"<state>:<class>"`: the state prefix is outermost. A rule that
70
+ needs a child selector first (`[&_li]:hover:bg-...`) cannot be an `on :hover`
71
+ rule; keep it literal in `base`.
72
+
73
+ Also: do not name a group after an inherited one with a different meaning
74
+ (`Button` already has `shape`); a state selector with a child combinator puts `>`
75
+ in the class attribute, so parse rendered HTML with Nokogiri, not a regexp.
76
+
77
+ ## Tailwind sources
78
+
79
+ Tailwind v4 compiles only classes written out literally, and an `on` class is
80
+ written nowhere. Generate sources for your own components, commit the file,
81
+ import it from the Tailwind entry and fail a spec when it is stale:
82
+
83
+ ```sh
84
+ bin/rails "daisyui:tailwind[app/assets/stylesheets/generated/daisyui_app.css]"
85
+ bundle exec daisyui-tailwind --app --require ./config/components.rb -o app/assets/stylesheets/generated/daisyui_app.css
86
+ ```
87
+
88
+ ```ruby
89
+ expect(DaisyUI::Tailwind.stale?("app/assets/stylesheets/generated/daisyui_app.css")).to be(false)
90
+ ```
91
+
92
+ ## Introspection
93
+
94
+ `Widget.variants.schema` returns `base`, `variants`, `flags`, `defaults`,
95
+ `compounds`, `on`, `states` and `slots` as frozen data.
96
+ MARKDOWN
97
+ end
98
+ end
99
+ end
@@ -18,7 +18,7 @@ module DaisyUI
18
18
  },
19
19
  {
20
20
  name: "get_component",
21
- description: "Get detailed information about a specific DaisyUI component including all modifiers and usage examples",
21
+ description: "Get detailed information about a specific DaisyUI component including all modifiers, its variants schema (groups, flags, defaults, compounds, on rules, states, slots) and usage examples",
22
22
  inputSchema: {
23
23
  type: "object",
24
24
  properties: {
@@ -37,6 +37,11 @@ module DaisyUI
37
37
  },
38
38
  required: ["query"]
39
39
  }
40
+ },
41
+ {
42
+ name: "variants_guide",
43
+ description: "How to declare a component's design with the variants DSL (variant, flag, default, compound, state/on, slot): when to use which, the match values, render order and the pitfalls",
44
+ inputSchema: { type: "object", properties: {}, required: [] }
40
45
  }
41
46
  ].freeze
42
47
  Shareable.make(TOOLS) # the Hashes inside too
@@ -113,6 +118,8 @@ module DaisyUI
113
118
  get_component(arguments["component"])
114
119
  when "search_components"
115
120
  search_components(arguments["query"])
121
+ when "variants_guide"
122
+ VariantsGuide::TEXT
116
123
  else
117
124
  return { content: [{ type: "text", text: "Unknown tool: #{tool_name}" }], isError: true }
118
125
  end
@@ -160,6 +167,14 @@ module DaisyUI
160
167
  ## Available Modifiers
161
168
  #{modifier_list.empty? ? 'No modifiers' : modifier_list}
162
169
 
170
+ ## Variants schema
171
+
172
+ Groups, flags, defaults, compounds, `on` rules, states and slots. `variants_guide` explains them.
173
+
174
+ ```json
175
+ #{JSON.pretty_generate(klass.variants.schema)}
176
+ ```
177
+
163
178
  ## Usage Examples
164
179
 
165
180
  ```ruby
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ module Tailwind
5
+ # The Tailwind sources for an app's own components (DaisyUI::Base
6
+ # subclasses outside the gem), read from their declarations rather than
7
+ # the gem's manifest: the component class, every modifier, and what a
8
+ # `variants` block declares, `on` rules written out once per state. Those
9
+ # state-prefixed classes appear nowhere literally in the app's source, so
10
+ # Tailwind only compiles them from here.
11
+ #
12
+ # One `@source inline(…)` per component, sorted by name, under a header
13
+ # with no version, so the file changes only when a declaration does: it
14
+ # is meant to be committed, and DaisyUI::Tailwind.stale? checks it.
15
+ class AppSources
16
+ HEADER = "/* Generated by daisyui-tailwind --app from the app's DaisyUI components: do not edit. */\n"
17
+
18
+ def initialize(components, responsive: [], prefix: nil)
19
+ anonymous = components.reject(&:name)
20
+ raise ArgumentError, "an anonymous class has no name to label its block: #{anonymous.inspect}" unless anonymous.empty?
21
+
22
+ @components = components.sort_by(&:name)
23
+ @breakpoints = responsive.map(&:to_s)
24
+ @prefix = prefix.to_s
25
+ Sources.validate_options!(@breakpoints, @prefix)
26
+ end
27
+
28
+ def render(format)
29
+ case format.to_s
30
+ when "css" then to_css
31
+ when "json" then to_json
32
+ else raise ArgumentError, "unknown format #{format.inspect}: css or json"
33
+ end
34
+ end
35
+
36
+ def to_css
37
+ return "#{HEADER}/* No app components. */\n" if @components.empty?
38
+
39
+ lines = @components.flat_map { |component| ["/* #{component.name} */", %(@source inline("#{classes(component).join(' ')}");)] }
40
+ "#{HEADER}#{lines.join("\n")}\n"
41
+ end
42
+
43
+ def to_json(*)
44
+ require "json"
45
+
46
+ components = @components.to_h do |component|
47
+ [component.name, { "name" => component.component_class&.to_s, "classes" => classes(component) }]
48
+ end
49
+ json = { "components" => components, "classes" => components.values.flat_map { |entry| entry["classes"] }.uniq.sort }
50
+ "#{JSON.pretty_generate(json)}\n"
51
+ end
52
+
53
+ private
54
+
55
+ def classes(component)
56
+ classes = component.variants.literal_classes(prefix: @prefix) + responsive_classes(component)
57
+ invalid = classes.reject { |token| Candidate.valid?(token) }
58
+ return classes.uniq.sort if invalid.empty?
59
+
60
+ raise ArgumentError, "#{component.name} declares #{invalid.map(&:inspect).join(', ')}, " \
61
+ "which Tailwind cannot read from @source inline(…)"
62
+ end
63
+
64
+ # What `responsive:` renders: the component class and the modifiers.
65
+ def responsive_classes(component)
66
+ return [] if @breakpoints.empty?
67
+
68
+ tokens = [component.component_class&.to_s, *(component.modifiers || {}).values].compact.flat_map(&:split)
69
+ @breakpoints.flat_map { |breakpoint| tokens.map { |token| "#{breakpoint}:#{@prefix}#{token}" } }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ module Tailwind
5
+ # Which words can go into Tailwind's `@source inline("…")`. inline() is a
6
+ # quoted string with brace expansion, and one unbalanced bracket in it
7
+ # makes Tailwind drop every class after it, so a candidate starts and ends
8
+ # like a utility or variant, has balanced () and [], and holds no quote,
9
+ # brace, semicolon, backslash or backtick. A `/` only introduces a
10
+ # fraction or an opacity (`w-1/2`, `bg-base-100/50`), never a path.
11
+ #
12
+ # @api private
13
+ module Candidate
14
+ SHAPE = /\A[a-z@!\[*-][^\s"'`;{}\\]*[a-z0-9\])%!]\z/
15
+ PATH = %r{/(?![0-9\[])}
16
+ CLOSING = { ")" => "(", "]" => "[" }.freeze
17
+
18
+ class << self
19
+ def valid?(token)
20
+ SHAPE.match?(token) && token.match?(/[a-z]/) && !PATH.match?(token) && balanced?(token)
21
+ end
22
+
23
+ # The candidates among the whitespace-separated words of `classes`.
24
+ def tokens(classes)
25
+ classes.split.select { |token| valid?(token) }
26
+ end
27
+
28
+ private
29
+
30
+ def balanced?(token)
31
+ open = []
32
+ token.each_char do |char|
33
+ if CLOSING.value?(char)
34
+ open << char
35
+ elsif CLOSING.key?(char)
36
+ return false unless open.pop == CLOSING[char]
37
+ end
38
+ end
39
+ open.empty?
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ module Tailwind
5
+ # `daisyui-tailwind`: the Tailwind sources for the components an app uses,
6
+ # for its CSS build to call (`bundle exec daisyui-tailwind app -o …`).
7
+ # Returns the exit status; usage errors print to `err` and return 1.
8
+ class Command
9
+ BANNER = <<~TEXT
10
+ Usage: daisyui-tailwind [options] [PATH...]
11
+
12
+ Prints Tailwind CSS v4 sources (@source inline) for the DaisyUI components
13
+ that the Ruby and templates under each PATH use, plus the components those
14
+ render. @import the output from your Tailwind entry.
15
+
16
+ With --app, prints the sources of the app's own components (DaisyUI::Base
17
+ subclasses, after requiring each --require FILE) instead: their variants,
18
+ compounds and state-prefixed classes, for a file you commit.
19
+
20
+ TEXT
21
+
22
+ def self.run(argv, out: $stdout, err: $stderr) = new(out:, err:).run(argv)
23
+
24
+ def initialize(out:, err:)
25
+ @out = out
26
+ @err = err
27
+ end
28
+
29
+ def run(argv)
30
+ require "optparse"
31
+
32
+ options = { components: [], format: "css", responsive: [], require: [] }
33
+ parser = parser(options)
34
+ paths = parser.parse(argv)
35
+ return help(parser) if options[:help]
36
+ return usage(parser) if paths.empty? && options[:components].empty? && !options[:app]
37
+
38
+ result = Tailwind.sources(scan: paths, **sources_options(options))
39
+ options[:output] ? write(options[:output], result) : @out.write(result)
40
+ 0
41
+ rescue OptionParser::ParseError, ArgumentError, SystemCallError, LoadError => e
42
+ @err.puts "daisyui-tailwind: #{e.message}"
43
+ 1
44
+ end
45
+
46
+ private
47
+
48
+ def parser(options)
49
+ OptionParser.new do |opts|
50
+ opts.banner = BANNER
51
+ opts.on("-c", "--component NAMES", Array, "Components to include, e.g. Button,Navbar (repeatable)") do |names|
52
+ options[:components].concat(names)
53
+ end
54
+ opts.on("-f", "--format FORMAT", %w[css json], "css (default) or json") { |format| options[:format] = format }
55
+ opts.on("-o", "--output FILE", "Write to FILE, only when its content changes") { |file| options[:output] = file }
56
+ opts.on("--responsive BREAKPOINTS", Array, "Add breakpoint variants, e.g. sm,md,lg") do |breakpoints|
57
+ options[:responsive].concat(breakpoints)
58
+ end
59
+ opts.on("--prefix PREFIX", "The DaisyUI.configuration.prefix your app sets") { |prefix| options[:prefix] = prefix }
60
+ opts.on("--app", "The app's own components instead of the gem's") { options[:app] = true }
61
+ opts.on("-r", "--require FILE", "With --app: load FILE first, e.g. config/environment.rb (repeatable)") do |file|
62
+ options[:require] << file
63
+ end
64
+ opts.on("-h", "--help", "Show this help") { options[:help] = true }
65
+ end
66
+ end
67
+
68
+ # With --app: load the app's files, then freeze DaisyUI as boot does.
69
+ def sources_options(options)
70
+ if options[:app]
71
+ options[:require].each { |file| require File.expand_path(file) }
72
+ DaisyUI.finalize!
73
+ end
74
+ options.slice(:components, :format, :responsive, :prefix, :app)
75
+ end
76
+
77
+ def help(parser)
78
+ @out.puts parser
79
+ 0
80
+ end
81
+
82
+ def usage(parser)
83
+ @err.puts parser
84
+ 1
85
+ end
86
+
87
+ # Only on change: a CSS watcher that sees its own input rewritten on
88
+ # every build rebuilds forever.
89
+ def write(path, content)
90
+ if File.exist?(path) && File.read(path) == content
91
+ @err.puts "daisyui-tailwind: #{path} is up to date"
92
+ else
93
+ require "fileutils"
94
+
95
+ FileUtils.mkdir_p(File.dirname(path))
96
+ File.write(path, content)
97
+ @err.puts "daisyui-tailwind: wrote #{path}"
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ module Tailwind
5
+ # Reads a component's Ruby the way the manifest needs it: the words of its
6
+ # string literals (where its sub-part classes live, e.g. "card-body") and
7
+ # the constants its code references (the components it renders). Ripper
8
+ # keeps comments out of both.
9
+ #
10
+ # @api private
11
+ module Lexer
12
+ class << self
13
+ def strings(source)
14
+ tokens(source, :on_tstring_content).flat_map(&:split)
15
+ end
16
+
17
+ def constants(source)
18
+ tokens(source, :on_const).uniq
19
+ end
20
+
21
+ private
22
+
23
+ def tokens(source, type)
24
+ require "ripper"
25
+
26
+ ::Ripper.lex(source).filter_map { |(_position, kind, token)| token if kind == type }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ module Tailwind
5
+ # The committed tailwind_manifest.json: per component (by short name, "Button"),
6
+ # its class, daisyUI name, files, the components it renders and its
7
+ # classes; `shared` is base.rb, which every component draws on. The JSON
8
+ # is the contract for build scripts in other languages, so its keys are
9
+ # documented in the README.
10
+ class Manifest
11
+ def self.load(path = MANIFEST_PATH, root: ROOT)
12
+ require "json"
13
+
14
+ new(JSON.parse(File.read(path)), root:)
15
+ end
16
+
17
+ attr_reader :components, :shared, :root
18
+
19
+ def initialize(data, root: ROOT)
20
+ @components = data.fetch("components")
21
+ @shared = data.fetch("shared")
22
+ @root = root
23
+ end
24
+
25
+ def component_names = components.keys
26
+
27
+ # `names` ("Button" or "DaisyUI::Button") plus every component they
28
+ # render, transitively. Sorted.
29
+ def resolve(names)
30
+ resolved = []
31
+ pending = names.map { |name| canonical(name) }
32
+ until pending.empty?
33
+ name = pending.pop
34
+ next if resolved.include?(name)
35
+
36
+ resolved << name
37
+ pending.concat(components.fetch(name)["depends_on"])
38
+ end
39
+ resolved.sort
40
+ end
41
+
42
+ # Absolute paths of the components' files, for a literal `@source`.
43
+ def files(names) = names.flat_map { |name| absolute(components.fetch(name)["files"]) }
44
+
45
+ def shared_files = absolute(shared["files"])
46
+
47
+ private
48
+
49
+ def canonical(name)
50
+ short = name.to_s.delete_prefix("DaisyUI::")
51
+ return short if components.key?(short)
52
+
53
+ raise UnknownComponentError.new(name, component_names)
54
+ end
55
+
56
+ def absolute(paths) = paths.map { |path| File.join(root, path) }
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DaisyUI
4
+ module Tailwind
5
+ # Builds data/daisyui/tailwind_manifest.json from the components
6
+ # themselves (`bundle exec rake tailwind:manifest`); the manifest spec
7
+ # fails while the committed file differs. Per component:
8
+ #
9
+ # - `classes`: its component class (a Symbol Tailwind cannot see), its
10
+ # registered modifiers and the class words in its Ruby's strings
11
+ # - `modifiers`: the registered modifier classes, which `responsive:`
12
+ # can prefix with a breakpoint
13
+ # - `depends_on`: the other components its code references
14
+ #
15
+ # @api private
16
+ module ManifestBuilder
17
+ SCHEMA = 1
18
+ SHARED_FILE = "lib/daisy_ui/base.rb"
19
+
20
+ class << self
21
+ def build(root: ROOT)
22
+ components = gem_components(File.join(root, "lib", "daisy_ui"))
23
+ names = components.map(&:first)
24
+ {
25
+ "schema" => SCHEMA,
26
+ "shared" => shared(root),
27
+ "components" => components.to_h { |name, klass, file| [name, entry(klass, file:, names:, root:)] }
28
+ }
29
+ end
30
+
31
+ def generate(root: ROOT)
32
+ require "json"
33
+
34
+ "#{JSON.pretty_generate(build(root:))}\n"
35
+ end
36
+
37
+ def write(path = MANIFEST_PATH, root: ROOT)
38
+ json = generate(root:)
39
+ File.write(path, json) unless File.exist?(path) && File.read(path) == json
40
+ path
41
+ end
42
+
43
+ def entry(klass, file:, names:, root:)
44
+ source = File.read(file)
45
+ name = klass.component_class&.to_s
46
+ modifiers = modifier_classes(klass)
47
+ {
48
+ "class" => klass.name,
49
+ "name" => name,
50
+ "files" => [file.delete_prefix("#{root}/")],
51
+ "depends_on" => ((Lexer.constants(source) & names) - [short_name(klass)]).sort,
52
+ "modifiers" => modifiers,
53
+ "classes" => ([name, *modifiers].compact | Candidate.tokens(Lexer.strings(source).join(" "))).sort
54
+ }
55
+ end
56
+
57
+ private
58
+
59
+ # The gem's own components, by short name: a DaisyUI:: class defined
60
+ # in a file under lib/daisy_ui (not one a spec or an app defined).
61
+ def gem_components(lib)
62
+ ClassMerge::DaisyGroups.gem_components.filter_map do |klass|
63
+ file = Object.const_source_location(klass.name)&.first
64
+ [short_name(klass), klass, file] if file&.start_with?("#{lib}/")
65
+ end.sort_by(&:first)
66
+ end
67
+
68
+ def shared(root)
69
+ source = File.read(File.join(root, SHARED_FILE))
70
+ { "files" => [SHARED_FILE], "classes" => Candidate.tokens(Lexer.strings(source).join(" ")).uniq.sort }
71
+ end
72
+
73
+ def modifier_classes(klass)
74
+ classes = (klass.modifiers || {}).values.flat_map { |value| value.to_s.split }.uniq.sort
75
+ invalid = classes.reject { |token| Candidate.valid?(token) }
76
+ return classes if invalid.empty?
77
+
78
+ raise ArgumentError, "#{klass.name} registers #{invalid.map(&:inspect).join(', ')}, " \
79
+ "which Tailwind cannot read from @source inline(…)"
80
+ end
81
+
82
+ def short_name(klass) = klass.name.split("::").last
83
+ end
84
+ end
85
+ end
86
+ end