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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +168 -23
- data/README.md +266 -20
- data/data/daisyui/tailwind_manifest.json +2278 -0
- data/exe/daisyui-tailwind +6 -0
- data/lib/daisy_ui/base.rb +24 -4
- data/lib/daisy_ui/engine.rb +4 -0
- data/lib/daisy_ui/mcp_server/variants_guide.rb +99 -0
- data/lib/daisy_ui/mcp_server.rb +16 -1
- data/lib/daisy_ui/tailwind/app_sources.rb +73 -0
- data/lib/daisy_ui/tailwind/candidate.rb +44 -0
- data/lib/daisy_ui/tailwind/command.rb +102 -0
- data/lib/daisy_ui/tailwind/lexer.rb +31 -0
- data/lib/daisy_ui/tailwind/manifest.rb +59 -0
- data/lib/daisy_ui/tailwind/manifest_builder.rb +86 -0
- data/lib/daisy_ui/tailwind/sources.rb +106 -0
- data/lib/daisy_ui/tailwind/unknown_component_error.rb +17 -0
- data/lib/daisy_ui/tailwind/usage.rb +72 -0
- data/lib/daisy_ui/tailwind.rb +91 -0
- data/lib/daisy_ui/unknown_modifier_error.rb +9 -2
- data/lib/daisy_ui/updated_at.rb +1 -1
- data/lib/daisy_ui/variants/match.rb +89 -0
- data/lib/daisy_ui/variants/rendering.rb +68 -0
- data/lib/daisy_ui/variants/resolution.rb +53 -0
- data/lib/daisy_ui/variants/schema.rb +73 -0
- data/lib/daisy_ui/variants.rb +160 -0
- data/lib/daisy_ui/version.rb +1 -1
- data/lib/daisy_ui.rb +2 -0
- data/lib/tasks/daisyui.rake +12 -0
- metadata +21 -1
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.
|
|
252
|
-
# 4.
|
|
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
|
)
|
data/lib/daisy_ui/engine.rb
CHANGED
|
@@ -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
|
data/lib/daisy_ui/mcp_server.rb
CHANGED
|
@@ -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
|