daisyui 2.1.1 → 2.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75952f05719457c52d7085643cfeebc3b0dd2f72e4ddfc768d1ba7468431c161
4
- data.tar.gz: 9029feca27ce0d586ad608c23170afcb7bafc1f17998af22793a2b39ce8c1ce3
3
+ metadata.gz: 6b5e9f54e5ef6670c715b8b23b7f25e2143b5dff828a17df64cd1c937cd8ea5a
4
+ data.tar.gz: 0ddd4bbef303e8fbbed5248317e107755cd96205ed8acb1e6b5e50b35505ad5c
5
5
  SHA512:
6
- metadata.gz: 206f1d5f22c5ac7d41df14f1984fedfa70056b5128aa97ff3affb1bb5607cb6cfe1e7c92899c469b70e763976f5fa1c300fa9dcd18f3c10fd9dd10e41734af98
7
- data.tar.gz: b65a346205a5fe198a858b3de5e191f20ee909fbe86c565e3d8aee87680ac9a1c2bf5ee681d6fdf698700063e271534ad766ac1afecfdf63ceb43557d03922b5
6
+ metadata.gz: f0f500a23192a07122b72795bb10c391ac5541d35ab6c5e168d73a8cac84070b46fbfd68de149c4fe4d2e01263c449c903c0172732a4b0245b0083f39f0f234f
7
+ data.tar.gz: d9f72b8aa71f1289efbfc83c7763fe3be6ce9e71b1a9b07ecf8a63c157228772031128eb665e2cf6eefe28c3f30119d5e03d19961f57c00ed88a76e1270b201a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,78 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Fixed
4
+
5
+ - The Tailwind sources generator accepts named groups and peers
6
+ (`group/item`, `group-has-[…]/item:block`, `peer-checked/field:block`) and a
7
+ plugin utility's named modifier (`motion-ease-in-out/opacity`) instead of
8
+ raising on them as paths. A bare word before the `/` (`app/components`) and a
9
+ second `/` (`lib/daisy_ui/base.rb`) are still rejected (#71).
10
+
11
+ ## [2.2.0] - 2026-10-26
12
+
13
+ ### Documentation for the variants API
14
+
15
+ - Docs site: "Variants, states and slots" (when to use `variant`, `flag`,
16
+ `compound`, `state`/`on` and `slot`, the match values, render order and its
17
+ three pitfalls, introspection) and "Tailwind sources for your components"
18
+ (`--app`, the rake task, committing the file, the `stale?` spec, stylelint).
19
+ - MCP server: `get_component` includes the component's `variants.schema`, and a
20
+ new `variants_guide` tool returns a short guide to the DSL with the pitfalls.
21
+ - README: the variants section links the guide and lists the pitfalls.
22
+
23
+ ### Variants, states and slots
24
+
25
+ A `variants` block declares what a component that owns its design needs
26
+ beyond `register_modifiers`, as data the gem owns (#67). See the README's
27
+ "Variants, states and slots".
28
+
29
+ ```ruby
30
+ class Widget < DaisyUI::Button
31
+ variants do
32
+ base "font-medium"
33
+ default :size, :md
34
+ compound style: :outline, color: nil, class: "[--btn-bg:var(--color-base-100)]"
35
+ state :hover, "hover"
36
+ state :open, "[.dropdown:is(:focus-within,[open],:has(:popover-open))>&]"
37
+ on %i[hover open], style: %i[ghost link], class: "[--btn-bg:transparent]"
38
+ end
39
+ end
40
+ ```
41
+
42
+ - `variant` and `flag` are `register_modifiers` (groups, ClassMerge,
43
+ inheritance unchanged); `base` appends to the inherited base; `default`
44
+ applies when the caller names no member of a group.
45
+ - `compound(**match, class:, set:)` adds classes when modifiers hold together
46
+ or one holds and another is absent (`color: nil`, `shape: [nil, :row]`,
47
+ `color: true`, flags `true`/`false`); `set:` selects members for the
48
+ caller's empty groups, in declaration order.
49
+ - `state :name, "variant"` names a Tailwind variant once; `on(states, …)`
50
+ writes a declaration once and renders it under each state.
51
+ - `slot :name, "classes"`, per-slot Hash values, and `slot_classes` /
52
+ `slot_options` for components with parts.
53
+ - Defaults and `set:` members are resolved, not left to ClassMerge: a default
54
+ never renders beside the member the caller gave, with class merging off too.
55
+ - A typo (a group, member, flag, state or slot the component does not have)
56
+ raises `ArgumentError` where it is declared.
57
+ - `Component.variants.schema` returns plain, frozen data: variants, flags,
58
+ defaults, compounds, `on` rules, states and slots.
59
+ - An unknown modifier's error now lists the component's groups:
60
+ `… has no modifier :huge. Variants: size: xs, sm, …`.
61
+ - Registries are frozen by `DaisyUI.finalize!` like the others.
62
+
63
+ ### Tailwind sources for an app's own components
64
+
65
+ State-prefixed classes appear nowhere literally in an app's source, so
66
+ `bin/rails "daisyui:tailwind[app/assets/stylesheets/generated/daisyui_app.css]"`
67
+ (or `daisyui-tailwind --app --require FILE`) writes `@source inline(…)` for
68
+ every `DaisyUI::Base` subclass the app defines: its classes, compounds, slots
69
+ and each `on` class per state. Sorted, versionless output meant to be
70
+ committed; `DaisyUI::Tailwind.stale?(path)` fails a spec when a declaration
71
+ changed and the file was not regenerated. From Ruby:
72
+ `DaisyUI::Tailwind.sources(app: true)`.
73
+
74
+ ## [2.1.1] - 2026-10-17
75
+
3
76
  ### Tailwind sources for only the components an app uses
4
77
 
5
78
  `bundle exec daisyui-tailwind app -o app/assets/stylesheets/generated/daisyui.css`
data/README.md CHANGED
@@ -216,6 +216,42 @@ language can read it directly from `$(bundle info daisyui --path)`:
216
216
  - `classes`: everything it can render: `name`, the modifiers and the class words in its Ruby. Words that are not classes cost nothing.
217
217
  - `shared`: base.rb's classes, which every component can render (`:skeleton`, the colour modifiers). Include them with any component.
218
218
 
219
+ ### Your own components (`--app`)
220
+
221
+ A component you declare with [`variants`](#variants-states-and-slots) renders
222
+ classes that appear nowhere literally in your source: an `on %i[hover open]`
223
+ rule writes `hover:…` and `[.dropdown…>&]:…` at render time. Tailwind only
224
+ compiles what it finds written out, so hand it your components' classes too:
225
+
226
+ ```sh
227
+ bin/rails "daisyui:tailwind[app/assets/stylesheets/generated/daisyui_app.css]"
228
+ # without Rails: load your components, then walk them
229
+ bundle exec daisyui-tailwind --app --require ./config/components.rb -o app/assets/stylesheets/generated/daisyui_app.css
230
+ ```
231
+
232
+ ```css
233
+ @import "./generated/daisyui.css"; /* the gem's components you use */
234
+ @import "./generated/daisyui_app.css"; /* your own: variants, compounds, states, slots */
235
+ ```
236
+
237
+ With the app loaded (the rake task eager-loads Rails), it walks every
238
+ `DaisyUI::Base` subclass defined outside the gem and writes one
239
+ `@source inline(…)` per component, sorted by name: its component class,
240
+ modifiers, `base`, compound and slot classes, and each `on` class once per
241
+ state. `--responsive` and `--prefix` work as above; `--format json` too. From
242
+ Ruby: `DaisyUI::Tailwind.sources(app: true)`, or `app: [Widget, Picker]` for
243
+ some. The header carries no version and nothing depends on the machine, so the
244
+ file only changes when a declaration does: **commit it**, and let a spec fail
245
+ when it is stale, the way this gem checks its own manifest:
246
+
247
+ ```ruby
248
+ # spec/daisyui_tailwind_spec.rb
249
+ it("has fresh Tailwind sources") { expect(DaisyUI::Tailwind.stale?("app/assets/stylesheets/generated/daisyui_app.css")).to be(false) }
250
+ ```
251
+
252
+ `stale?` eager-loads a Rails app before it compares. The CSS build then never
253
+ boots the app; it only imports the file.
254
+
219
255
  ## Class merging
220
256
 
221
257
  Components merge their classes so the last conflicting class wins, for Tailwind
@@ -306,6 +342,116 @@ end
306
342
  Keyword arguments are not validated: `Toggle(primray: true)` renders a
307
343
  `primray` attribute, because unknown keywords are HTML attributes.
308
344
 
345
+ ## Variants, states and slots
346
+
347
+ The full guide, with when to use which declaration, is on the docs site:
348
+ [Variants, states and slots](https://daisyui.phlex.fun/docs/variants). Four things
349
+ to know before you write one:
350
+
351
+ - A `default` renders after the caller's modifiers, so it beats one that sets the same property. Put such a value in `base`.
352
+ - A `compound` or `on` rule also renders after the caller's modifiers. Leave the member it would override out of the match (`layout: [nil, :row]`).
353
+ - `on` writes `"<state>:<class>"`, the state outermost. A rule that needs a child selector first (`[&_li]:hover:...`) stays literal in `base`.
354
+ - `on` classes appear nowhere literally: generate [Tailwind sources](#your-own-components---app) for your components or Tailwind never compiles them.
355
+
356
+ `register_modifiers` maps a symbol to classes. A component that owns its design
357
+ also needs classes for two modifiers together, the same declarations under a
358
+ browser state, and classes for its parts. Declare them in a `variants` block:
359
+
360
+ ```ruby
361
+ class Widget < DaisyUI::Button
362
+ OPEN = "[.dropdown:is(:focus-within,[open],:has(:popover-open))>&]"
363
+
364
+ variants do
365
+ base "font-medium" # after the inherited base (btn)
366
+ variant :size, sm: "btn-sm [--fontsize:var(--text-sm)]" # overrides one inherited member
367
+ default :size, :md # when the caller names no size
368
+
369
+ compound style: :outline, color: nil, class: "[--btn-bg:var(--color-base-100)]"
370
+ compound style: %i[ghost link], class: "[--btn-border:transparent]"
371
+
372
+ state :hover, "hover"
373
+ state :open, OPEN
374
+ on %i[hover open], style: %i[ghost link], class: "[--btn-bg:color-mix(in_oklab,var(--color-base-content)_8%,transparent)]"
375
+ end
376
+ end
377
+
378
+ Widget() # btn font-medium btn-md
379
+ Widget(:ghost, :sm) # btn font-medium btn-ghost btn-sm [--fontsize:…] [--btn-border:transparent]
380
+ # hover:[--btn-bg:…] [.dropdown…>&]:[--btn-bg:…]
381
+ ```
382
+
383
+ | Declaration | |
384
+ |---|---|
385
+ | `variant :group, member: "classes", …` | Alternatives: the grouped `register_modifiers(group: { … })`. Members merge as one ClassMerge group. |
386
+ | `flag :name, "classes"` | An on/off modifier: an ungrouped `register_modifiers` entry. `flag :name` alone is a `register_flags` flag, no class, for compounds to match. |
387
+ | `base "classes"` | Classes every instance gets, appended to the inherited base. |
388
+ | `default :group, :member` | The member used when the caller names none of the group. Never rendered beside a member the caller gives. |
389
+ | `compound(**match, class:, set: {})` | `class:` when every key of `match` holds. `set: { group: :member }` selects members for the caller's empty groups; later compounds see them. Applied in order, after defaults. |
390
+ | `state :name, "variant"` | Names a Tailwind variant once: `"hover"`, `"focus-visible"`, `"[.dropdown[open]>&]"`. |
391
+ | `on(states, **match, class:)` | Like `compound`, with each class written once per state: `on %i[hover open], class: "a"` renders `hover:a [.dropdown…>&]:a`. |
392
+ | `slot :name, "classes"` | A part of the component (see below). |
393
+
394
+ A `match` value says when a group or flag holds:
395
+
396
+ | Value | Holds when |
397
+ |---|---|
398
+ | `:member` | that member is selected: given, defaulted or set by an earlier compound |
399
+ | `[:a, :b]` | any of them is; a `nil` in the list means "or none": `shape: [nil, :row]` |
400
+ | `nil` / `false` | no member of the group is |
401
+ | `true` | any member of the group is: `color: true` |
402
+ | `true` / `false` | for a flag: it is on / off |
403
+
404
+ Classes render in this order, merged by ClassMerge: the component class, `base`,
405
+ the given modifiers, the defaulted and set members, compounds, `on` rules,
406
+ `responsive:`, then `class:`. Every declared class goes through
407
+ `DaisyUI.configuration.prefix` like a modifier (`hover:d-…` with `prefix = "d-"`).
408
+ A typo fails where it is declared, at boot: a `compound`, `on`, `default` or
409
+ `set:` naming a group, member, flag, state or slot the component does not have
410
+ raises `ArgumentError`. `class` and `set` are reserved keys: a group called that could never be matched, and nothing stops you naming one.
411
+
412
+ **Slots.** A component with parts declares each one; a variant, compound or `on`
413
+ value can be a Hash keyed by slot (`root` is the component itself):
414
+
415
+ ```ruby
416
+ class Picker < DaisyUI::Dropdown
417
+ variants do
418
+ slot :trigger
419
+ slot :menu, "mt-2"
420
+ variant :reach, wide: { root: "picker-wide", menu: "w-96" }
421
+ state :open, "[.dropdown:is(:focus-within,[open],:has(:popover-open))>&]"
422
+ on :open, class: { menu: "shadow-lg" }
423
+ end
424
+
425
+ def trigger(*mods, **options, &) = render DaisyUI::Button.new(*mods, **slot_options(:trigger, options), &)
426
+ def list(*mods, **options, &) = render DaisyUI::Menu.new(*mods, class: slot_classes(:menu, options), **options, &)
427
+ end
428
+ ```
429
+
430
+ `slot_classes(:menu, options)` returns the slot's classes, those of every
431
+ selected member, compound and `on` rule for it, then `options.delete(:class)`,
432
+ merged. `slot_options(:trigger, options)` returns `options` with that `class:`.
433
+
434
+ **Introspection.** `Widget.variants.schema` is plain, frozen data for a docs
435
+ page, a component gallery or a design-tool parity check:
436
+
437
+ ```ruby
438
+ {
439
+ base: "btn font-medium",
440
+ variants: { style: %i[ghost link outline dash soft], size: %i[lg md xs xl sm], … }, # a re-declared member moves last
441
+ flags: %i[active disabled wide block],
442
+ defaults: { size: :md },
443
+ compounds: [{ match: { style: :outline, color: nil }, class: "[--btn-bg:…]", set: {} }, …],
444
+ on: [{ states: %i[hover open], match: { style: %i[ghost link] }, class: "[--btn-bg:…]" }],
445
+ states: { hover: "hover", open: "[.dropdown…>&]" },
446
+ slots: { root: "btn font-medium" }
447
+ }
448
+ ```
449
+
450
+ A component that only calls `register_modifiers` has a schema too (variants and
451
+ flags). An unknown modifier's error names the groups:
452
+ `Widget has no modifier :huge. Variants: style: ghost, link, outline, dash, soft; size: lg, md, xs, xl, sm; …`.
453
+ The state-prefixed classes need [Tailwind sources of their own](#your-own-components---app).
454
+
309
455
  ## Threads, fibers and Ractors
310
456
 
311
457
  Components and `DaisyUI::ClassMerge` are safe to share between threads (Puma)
@@ -554,6 +700,9 @@ Add to your Claude Code MCP settings (`~/.claude.json` or project `.claude.json`
554
700
  - **list_components** - List all available DaisyUI components
555
701
  - **get_component** - Get detailed info about a specific component (modifiers, usage examples)
556
702
  - **search_components** - Search components by name or modifier
703
+ - **variants_guide** - How to declare a component with `variants`: which declaration to use, the match values, render order and the pitfalls
704
+
705
+ `get_component` also returns the component's variants schema (groups, flags, defaults, compounds, `on` rules, states and slots).
557
706
 
558
707
  # Usage
559
708
 
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
@@ -6,18 +6,24 @@ module DaisyUI
6
6
  # quoted string with brace expansion, and one unbalanced bracket in it
7
7
  # makes Tailwind drop every class after it, so a candidate starts and ends
8
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.
9
+ # brace, semicolon, backslash or backtick.
10
+ #
11
+ # A `/` before a digit or `[` is a fraction or an opacity (`w-1/2`,
12
+ # `bg-base-100/50`). A `/` before anything else is a path, except one
13
+ # trailing `/<name>` on a variant or utility: a named group or peer
14
+ # (`group/item`, `group-hover/item:underline`, `peer-checked/field:block`)
15
+ # or a plugin utility's named modifier (`motion-ease-in-out/opacity`).
11
16
  #
12
17
  # @api private
13
18
  module Candidate
14
19
  SHAPE = /\A[a-z@!\[*-][^\s"'`;{}\\]*[a-z0-9\])%!]\z/
15
20
  PATH = %r{/(?![0-9\[])}
21
+ NAMED = %r{\A(?<head>.+)/[A-Za-z0-9_-]+!?\z}
16
22
  CLOSING = { ")" => "(", "]" => "[" }.freeze
17
23
 
18
24
  class << self
19
25
  def valid?(token)
20
- SHAPE.match?(token) && token.match?(/[a-z]/) && !PATH.match?(token) && balanced?(token)
26
+ SHAPE.match?(token) && token.match?(/[a-z]/) && balanced?(token) && !paths?(token)
21
27
  end
22
28
 
23
29
  # The candidates among the whitespace-separated words of `classes`.
@@ -38,6 +44,35 @@ module DaisyUI
38
44
  end
39
45
  open.empty?
40
46
  end
47
+
48
+ def paths?(token)
49
+ parts(token).any? { |part| path?(part) }
50
+ end
51
+
52
+ def path?(part)
53
+ return false unless PATH.match?(part)
54
+
55
+ head = part[NAMED, :head]
56
+ head.nil? || PATH.match?(head) || !named?(head)
57
+ end
58
+
59
+ # What may carry a `/<name>`: a group or a peer, or a utility with a
60
+ # `-` in it. A bare word (`app`, `vendor`, `hover`) is a directory.
61
+ def named?(head)
62
+ %w[group peer].include?(head) || (head.include?("-") && SHAPE.match?(head))
63
+ end
64
+
65
+ # The token split on its top-level `:`, as Tailwind splits variants
66
+ # from the utility; a `:` inside [] or () belongs to its part.
67
+ def parts(token)
68
+ depth = 0
69
+ slices = token.each_char.slice_when do |char, _|
70
+ depth += 1 if "[(".include?(char)
71
+ depth -= 1 if "])".include?(char)
72
+ char == ":" && depth.zero?
73
+ end
74
+ slices.map { |chars| chars.join.delete_suffix(":") }
75
+ end
41
76
  end
42
77
  end
43
78
  end