fluxbit_view_components 0.5.3 → 0.5.4
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/app/components/fluxbit/bottom_navigation_component.rb +5 -5
- data/app/components/fluxbit/carousel_component.rb +1 -2
- data/app/components/fluxbit/form/telephone_component.rb +4 -4
- data/app/components/fluxbit/form/upload_image_component.rb +2 -1
- data/app/components/fluxbit/gravatar_component.rb +19 -77
- data/app/components/fluxbit/pagination_component.rb +1 -1
- data/app/components/fluxbit/stepper_component.rb +3 -3
- data/app/components/fluxbit/theme_button_component.rb +2 -2
- data/app/helpers/fluxbit/view_helper.rb +4 -0
- data/lib/fluxbit/gravatar.rb +110 -0
- data/lib/fluxbit/view_components/version.rb +1 -1
- data/lib/fluxbit/view_components.rb +2 -0
- data/lib/install/install.rb +1 -1
- data/lib/tasks/lookbook_static.rake +542 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: db4a121022c04abb99806e36df03f68c0a1dc4ced2ff73c31da5ab2ea3c5b84a
|
|
4
|
+
data.tar.gz: 6b5622d2bcdff4c893aa1a0efe36164e1b48fcfd04b60659afefa4a79120f33d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d5c59a4eb9832c2889b512670a22b3206bc0339a2407a17f511d1b7b26fb7060cadce92e19d9f58bd926efe6fa72fa4d698f9b8220cbb354b1f381a90c721916
|
|
7
|
+
data.tar.gz: 2a97218e6bfdc7b07ddac8c2236ad1d9a96e6f3605dfdced076aa24066e59b45de8f1d1e1c1808e22585ffac20cc9170e8ba21ad63d2ce505c22f987fc0c1e53
|
|
@@ -79,11 +79,11 @@ class Fluxbit::BottomNavigationComponent < Fluxbit::Component
|
|
|
79
79
|
if cta?
|
|
80
80
|
# Insert CTA in the middle for both variants
|
|
81
81
|
half = (items.size / 2.0).floor
|
|
82
|
-
safe_join(items[0...half] + [tag.div(cta, class: styles[:cta_wrapper])] + items[half..])
|
|
82
|
+
safe_join(items[0...half] + [ tag.div(cta, class: styles[:cta_wrapper]) ] + items[half..])
|
|
83
83
|
elsif pagination?
|
|
84
84
|
# Insert pagination in the middle
|
|
85
85
|
half = (items.size / 2.0).floor
|
|
86
|
-
safe_join(items[0...half] + [pagination] + items[half..])
|
|
86
|
+
safe_join(items[0...half] + [ pagination ] + items[half..])
|
|
87
87
|
else
|
|
88
88
|
safe_join(items)
|
|
89
89
|
end
|
|
@@ -120,7 +120,7 @@ class Fluxbit::BottomNavigationComponent < Fluxbit::Component
|
|
|
120
120
|
total += 2 if pagination? # Pagination spans 2 grid cells (col-span-2)
|
|
121
121
|
|
|
122
122
|
# Ensure columns is within valid range (2-6)
|
|
123
|
-
[[total, 2].max, 6].min
|
|
123
|
+
[ [ total, 2 ].max, 6 ].min
|
|
124
124
|
end
|
|
125
125
|
|
|
126
126
|
##
|
|
@@ -170,7 +170,7 @@ class Fluxbit::BottomNavigationComponent < Fluxbit::Component
|
|
|
170
170
|
end
|
|
171
171
|
|
|
172
172
|
if @tooltip_text
|
|
173
|
-
safe_join([button_content, render_tooltip])
|
|
173
|
+
safe_join([ button_content, render_tooltip ])
|
|
174
174
|
else
|
|
175
175
|
button_content
|
|
176
176
|
end
|
|
@@ -250,7 +250,7 @@ class Fluxbit::BottomNavigationComponent < Fluxbit::Component
|
|
|
250
250
|
end
|
|
251
251
|
|
|
252
252
|
if @tooltip_text
|
|
253
|
-
safe_join([button_content, render_tooltip])
|
|
253
|
+
safe_join([ button_content, render_tooltip ])
|
|
254
254
|
else
|
|
255
255
|
button_content
|
|
256
256
|
end
|
|
@@ -128,7 +128,7 @@ class Fluxbit::CarouselComponent < Fluxbit::Component
|
|
|
128
128
|
|
|
129
129
|
button_props = {
|
|
130
130
|
type: "button",
|
|
131
|
-
class: [styles[:controls][:button], is_previous ? styles[:controls][:previous] : styles[:controls][:next]].join(" "),
|
|
131
|
+
class: [ styles[:controls][:button], is_previous ? styles[:controls][:previous] : styles[:controls][:next] ].join(" "),
|
|
132
132
|
data: {}
|
|
133
133
|
}
|
|
134
134
|
|
|
@@ -150,5 +150,4 @@ class Fluxbit::CarouselComponent < Fluxbit::Component
|
|
|
150
150
|
end
|
|
151
151
|
end
|
|
152
152
|
end
|
|
153
|
-
|
|
154
153
|
end
|
|
@@ -53,8 +53,8 @@ class Fluxbit::Form::TelephoneComponent < Fluxbit::Form::TextFieldComponent
|
|
|
53
53
|
current_classes = @props[:class].to_s
|
|
54
54
|
|
|
55
55
|
# Get size class from config
|
|
56
|
-
size_index = [@sizing, 0].max
|
|
57
|
-
size_index = [size_index, @@telephone_styles[:input][:sizes].length - 1].min
|
|
56
|
+
size_index = [ @sizing, 0 ].max
|
|
57
|
+
size_index = [ size_index, @@telephone_styles[:input][:sizes].length - 1 ].min
|
|
58
58
|
custom_size_class = @@telephone_styles[:input][:sizes][size_index]
|
|
59
59
|
|
|
60
60
|
# Remove the old size class and add our custom one
|
|
@@ -144,8 +144,8 @@ class Fluxbit::Form::TelephoneComponent < Fluxbit::Form::TextFieldComponent
|
|
|
144
144
|
|
|
145
145
|
def country_select_classes
|
|
146
146
|
# Get size from config
|
|
147
|
-
size_index = [@sizing, 0].max
|
|
148
|
-
size_index = [size_index, @@telephone_styles[:country_select][:sizes].length - 1].min
|
|
147
|
+
size_index = [ @sizing, 0 ].max
|
|
148
|
+
size_index = [ size_index, @@telephone_styles[:country_select][:sizes].length - 1 ].min
|
|
149
149
|
size_config = @@telephone_styles[:country_select][:sizes][size_index]
|
|
150
150
|
|
|
151
151
|
# Get color from config
|
|
@@ -34,7 +34,8 @@ class Fluxbit::Form::UploadImageComponent < Fluxbit::Form::FieldComponent
|
|
|
34
34
|
@initials = @props.delete(:initials)
|
|
35
35
|
@image_path = @props.delete(:image_path) ||
|
|
36
36
|
(if @object&.send(@attribute).respond_to?(:attached?) && @object&.send(@attribute)&.send("attached?")
|
|
37
|
-
@object
|
|
37
|
+
attachment = @object.send(@attribute)
|
|
38
|
+
attachment.variable? ? attachment.variant(resize_to_fit: [ 160, 160 ]) : attachment
|
|
38
39
|
end) || @props.delete(:image_placeholder) || ""
|
|
39
40
|
|
|
40
41
|
@props["class"] = "absolute inset-0 h-full w-full cursor-pointer rounded-md border-gray-300 opacity-0"
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# From:
|
|
4
|
-
# https://github.com/chrislloyd/gravtastic/blob/master/lib/gravtastic.rb
|
|
5
|
-
# https://chrislloyd.github.io/gravtastic/
|
|
6
|
-
|
|
7
|
-
require "digest/md5"
|
|
8
|
-
|
|
9
3
|
# The `Fluxbit::GravatarComponent` is a component for rendering Gravatar avatars.
|
|
10
4
|
# It extends `Fluxbit::AvatarComponent` and provides options for configuring the
|
|
11
5
|
# Gravatar's appearance and behavior. You can control the Gravatar's rating, size,
|
|
12
|
-
# filetype, and other attributes.
|
|
13
|
-
#
|
|
6
|
+
# filetype, and other attributes.
|
|
7
|
+
#
|
|
8
|
+
# The URL generation logic lives in `Fluxbit::Gravatar` and can be used standalone:
|
|
9
|
+
#
|
|
10
|
+
# Fluxbit::Gravatar.url(email: "user@example.com")
|
|
11
|
+
#
|
|
14
12
|
class Fluxbit::GravatarComponent < Fluxbit::AvatarComponent
|
|
15
13
|
include Fluxbit::Config::AvatarComponent
|
|
16
14
|
include Fluxbit::Config::GravatarComponent
|
|
@@ -31,82 +29,26 @@ class Fluxbit::GravatarComponent < Fluxbit::AvatarComponent
|
|
|
31
29
|
# @option props [Hash] **props Remaining options declared as HTML attributes, applied to the Gravatar container.
|
|
32
30
|
def initialize(**props)
|
|
33
31
|
@props = props
|
|
34
|
-
@
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
32
|
+
@email = @props.delete(:email)
|
|
33
|
+
@url_only = @props.delete(:url_only)
|
|
34
|
+
|
|
35
|
+
@gravatar_url_options = {
|
|
36
|
+
rating: @props.delete(:rating),
|
|
37
|
+
secure: @props.delete(:secure),
|
|
38
|
+
filetype: @props.delete(:filetype),
|
|
39
|
+
default: @props.delete(:default),
|
|
40
|
+
size: @props[:size],
|
|
40
41
|
name: @props.delete(:name),
|
|
41
42
|
initials: @props.delete(:initials)
|
|
42
|
-
}
|
|
43
|
+
}.compact
|
|
44
|
+
|
|
43
45
|
add class: gravatar_styles[:base], to: @props
|
|
44
|
-
|
|
45
|
-
@url_only = @props.delete(:url_only)
|
|
46
|
-
src = gravatar_url
|
|
46
|
+
src = Fluxbit::Gravatar.url(email: @email, **@gravatar_url_options)
|
|
47
47
|
super(src: src, **@props)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def call
|
|
51
|
-
return
|
|
51
|
+
return Fluxbit::Gravatar.url(email: @email, **@gravatar_url_options).html_safe if @url_only
|
|
52
52
|
super
|
|
53
53
|
end
|
|
54
|
-
|
|
55
|
-
# The raw MD5 hash of the users' email. Gravatar is particularly tricky as
|
|
56
|
-
# it downcases all emails. This is really the guts of the module,
|
|
57
|
-
# everything else is just convenience.
|
|
58
|
-
def gravatar_id
|
|
59
|
-
Digest::MD5.hexdigest(@email.to_s.downcase)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Constructs the full Gravatar url.
|
|
63
|
-
def gravatar_url
|
|
64
|
-
gravatar_hostname(@gravatar_options.delete(:secure)) +
|
|
65
|
-
gravatar_filename(@gravatar_options.delete(:filetype)) +
|
|
66
|
-
"?#{url_params_from_hash(process_options(@gravatar_options))}"
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Creates a params hash like "?foo=bar" from a hash like {'foo' => 'bar'}.
|
|
70
|
-
# The values are sorted so it produces deterministic output (and can
|
|
71
|
-
# therefore be tested easily).
|
|
72
|
-
def url_params_from_hash(hash)
|
|
73
|
-
hash.map do |key, val|
|
|
74
|
-
[ gravatar_abbreviations[key.to_sym] || key.to_s, val.to_s ].join("=")
|
|
75
|
-
end.sort.join("&")
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# Returns either Gravatar's secure hostname or not.
|
|
79
|
-
def gravatar_hostname(secure)
|
|
80
|
-
"http#{secure ? 's://secure.' : '://'}gravatar.com/avatar/"
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# Munges the ID and the filetype into one. Like "abc123.png"
|
|
84
|
-
def gravatar_filename(filetype)
|
|
85
|
-
"#{gravatar_id}.#{filetype}"
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Some options need to be processed before becoming URL params
|
|
89
|
-
def process_options(options_to)
|
|
90
|
-
processed_options = {}
|
|
91
|
-
options_to.each do |key, val|
|
|
92
|
-
case key
|
|
93
|
-
when :forcedefault
|
|
94
|
-
processed_options[key] = "y" if val
|
|
95
|
-
when :name, :initials
|
|
96
|
-
processed_options[key] = val if val.present?
|
|
97
|
-
else
|
|
98
|
-
processed_options[key] = val
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
processed_options
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def gravatar_abbreviations
|
|
105
|
-
{
|
|
106
|
-
size: "s",
|
|
107
|
-
default: "d",
|
|
108
|
-
rating: "r",
|
|
109
|
-
forcedefault: "f"
|
|
110
|
-
}
|
|
111
|
-
end
|
|
112
54
|
end
|
|
@@ -195,7 +195,7 @@ class Fluxbit::PaginationComponent < Fluxbit::Component
|
|
|
195
195
|
params = (respond_to?(:request) ? request.GET : controller.request.GET).dup
|
|
196
196
|
params.merge!(vars[:params].transform_keys(&:to_s)) if vars[:params].is_a?(Hash)
|
|
197
197
|
# Set page and possibly limit
|
|
198
|
-
page_param = vars[:page_param]&.to_s.presence ||
|
|
198
|
+
page_param = vars[:page_param]&.to_s.presence || "page"
|
|
199
199
|
params[page_param] = page
|
|
200
200
|
params[vars[:limit_param].to_s] = vars[:limit] if vars[:limit_extra] && vars[:limit_param]
|
|
201
201
|
# Apply params proc if given
|
|
@@ -120,11 +120,11 @@ class Fluxbit::StepperComponent < Fluxbit::Component
|
|
|
120
120
|
|
|
121
121
|
connector_classes = if step.state == :completed
|
|
122
122
|
styles[:connector][:completed][@variant][@orientation]
|
|
123
|
-
|
|
123
|
+
elsif step.state == :active
|
|
124
124
|
styles[:connector][:active][@color][@variant][@orientation]
|
|
125
|
-
|
|
125
|
+
else
|
|
126
126
|
styles[:connector][@variant][@orientation]
|
|
127
|
-
|
|
127
|
+
end
|
|
128
128
|
|
|
129
129
|
tag.div(class: connector_classes)
|
|
130
130
|
end
|
|
@@ -24,8 +24,8 @@ class Fluxbit::ThemeButtonComponent < Fluxbit::ButtonComponent
|
|
|
24
24
|
props[:remove_dropdown_arrow] = true
|
|
25
25
|
|
|
26
26
|
# Add Stimulus controller
|
|
27
|
-
props["data-controller"] = [props["data-controller"], "fx-theme-button"].compact.join(" ")
|
|
28
|
-
props["data-action"] = [props["data-action"], "click->fx-theme-button#toggle"].compact.join(" ")
|
|
27
|
+
props["data-controller"] = [ props["data-controller"], "fx-theme-button" ].compact.join(" ")
|
|
28
|
+
props["data-action"] = [ props["data-action"], "click->fx-theme-button#toggle" ].compact.join(" ")
|
|
29
29
|
|
|
30
30
|
super(**props)
|
|
31
31
|
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/md5"
|
|
4
|
+
|
|
5
|
+
module Fluxbit
|
|
6
|
+
# Standalone Gravatar URL generator.
|
|
7
|
+
#
|
|
8
|
+
# Can be used independently of the component:
|
|
9
|
+
#
|
|
10
|
+
# Fluxbit::Gravatar.url(email: "user@example.com")
|
|
11
|
+
# Fluxbit::Gravatar.url(email: "user@example.com", size: :lg, rating: :g)
|
|
12
|
+
#
|
|
13
|
+
# Or via the helper in views:
|
|
14
|
+
#
|
|
15
|
+
# fx_gravatar_url(email: "user@example.com")
|
|
16
|
+
#
|
|
17
|
+
# From:
|
|
18
|
+
# https://github.com/chrislloyd/gravtastic/blob/master/lib/gravtastic.rb
|
|
19
|
+
# https://chrislloyd.github.io/gravtastic/
|
|
20
|
+
module Gravatar
|
|
21
|
+
ABBREVIATIONS = {
|
|
22
|
+
size: "s",
|
|
23
|
+
default: "d",
|
|
24
|
+
rating: "r",
|
|
25
|
+
forcedefault: "f"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Generates a Gravatar URL for the given email.
|
|
30
|
+
#
|
|
31
|
+
# @param email [String] The email address.
|
|
32
|
+
# @param rating [Symbol] Gravatar rating (:g, :pg, :r, :x).
|
|
33
|
+
# @param secure [Boolean] Use HTTPS (default: true).
|
|
34
|
+
# @param filetype [Symbol] Image format (:png, :jpg, :gif, etc.).
|
|
35
|
+
# @param default [Symbol] Default image style (:identicon, :robohash, :mp, etc.).
|
|
36
|
+
# @param size [Symbol, Integer] Size as a symbol (:xs, :sm, :md, :lg, :xl) or pixel integer.
|
|
37
|
+
# @param name [String] Optional name param appended to URL.
|
|
38
|
+
# @param initials [String] Optional initials param appended to URL.
|
|
39
|
+
# @return [String] The full Gravatar URL.
|
|
40
|
+
def url(email:, rating: nil, secure: true, filetype: nil, default: nil, size: nil, name: nil, initials: nil)
|
|
41
|
+
styles = config.gravatar_styles
|
|
42
|
+
|
|
43
|
+
rating = resolve_option(rating, collection: styles[:rating], fallback: config.rating)
|
|
44
|
+
filetype = resolve_option(filetype, collection: styles[:filetype], fallback: config.filetype)
|
|
45
|
+
default = resolve_option(default, collection: styles[:default], fallback: config.default)
|
|
46
|
+
size_px = resolve_size(size, styles[:size])
|
|
47
|
+
|
|
48
|
+
params = { rating: rating, default: default, size: size_px }
|
|
49
|
+
params[:name] = name if name.present?
|
|
50
|
+
params[:initials] = initials if initials.present?
|
|
51
|
+
|
|
52
|
+
hostname(secure) + filename(email, filetype) + "?" + to_query(process_options(params))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Returns the MD5 hash of the email, which Gravatar uses as identifier.
|
|
56
|
+
#
|
|
57
|
+
# @param email [String]
|
|
58
|
+
# @return [String]
|
|
59
|
+
def gravatar_id(email)
|
|
60
|
+
Digest::MD5.hexdigest(email.to_s.downcase)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def config
|
|
66
|
+
Fluxbit::Config::GravatarComponent
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resolve_option(value, collection:, fallback:)
|
|
70
|
+
return fallback if value.nil?
|
|
71
|
+
|
|
72
|
+
value = value.to_sym if value.respond_to?(:to_sym)
|
|
73
|
+
value.in?(collection) ? value : fallback
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def resolve_size(value, sizes)
|
|
77
|
+
return sizes[:md] if value.nil?
|
|
78
|
+
return value if value.is_a?(Integer)
|
|
79
|
+
|
|
80
|
+
key = value.to_sym
|
|
81
|
+
sizes[key] || sizes[:md]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def hostname(secure)
|
|
85
|
+
"http#{secure ? 's://secure.' : '://'}gravatar.com/avatar/"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def filename(email, filetype)
|
|
89
|
+
"#{gravatar_id(email)}.#{filetype}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def process_options(options)
|
|
93
|
+
options.each_with_object({}) do |(key, val), result|
|
|
94
|
+
case key
|
|
95
|
+
when :forcedefault
|
|
96
|
+
result[key] = "y" if val
|
|
97
|
+
when :name, :initials
|
|
98
|
+
result[key] = val if val.present?
|
|
99
|
+
else
|
|
100
|
+
result[key] = val
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def to_query(hash)
|
|
106
|
+
hash.map { |key, val| "#{ABBREVIATIONS[key.to_sym] || key}=#{val}" }.sort.join("&")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/lib/install/install.rb
CHANGED
|
@@ -98,7 +98,7 @@ else
|
|
|
98
98
|
say.call "⚠️ Couldn't find controllers/index.js, skipping Stimulus controller setup", :red
|
|
99
99
|
say.call " Add these lines to your controllers/index.js:", :red
|
|
100
100
|
say.call ' import { registerFluxbitControllers } from "fluxbit-view-components"', :red
|
|
101
|
-
say.call
|
|
101
|
+
say.call " registerFluxbitControllers(Stimulus)", :red
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
if layout_path.exist?
|
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LookbookStaticGenerator
|
|
4
|
+
class Generator
|
|
5
|
+
attr_reader :output_dir, :previews_dir, :docs_dir, :assets_dir, :combination_mode, :port
|
|
6
|
+
|
|
7
|
+
def initialize(combination_mode: :cartesian)
|
|
8
|
+
@combination_mode = combination_mode
|
|
9
|
+
# Output to project root 'dist' folder (assuming running from demo app)
|
|
10
|
+
@output_dir = Rails.root.join("../dist").to_s
|
|
11
|
+
@previews_dir = File.join(@output_dir, "inspect")
|
|
12
|
+
@docs_dir = File.join(@output_dir, "pages")
|
|
13
|
+
@assets_dir = File.join(@output_dir, "assets")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run!
|
|
17
|
+
puts ""
|
|
18
|
+
puts "🚀 Generating static Lookbook..."
|
|
19
|
+
puts " Mode: #{combination_mode} (parameter combinations)"
|
|
20
|
+
puts " Output: #{output_dir}"
|
|
21
|
+
puts ""
|
|
22
|
+
|
|
23
|
+
prepare_directories
|
|
24
|
+
start_server
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
collect_assets
|
|
28
|
+
pages_count = generate_docs
|
|
29
|
+
previews_count, param_previews_count = generate_previews
|
|
30
|
+
generate_index(pages_count, previews_count, param_previews_count)
|
|
31
|
+
print_summary(pages_count, previews_count, param_previews_count)
|
|
32
|
+
ensure
|
|
33
|
+
stop_server
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# ─── Directory Setup ───────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
def prepare_directories
|
|
42
|
+
FileUtils.rm_rf(output_dir)
|
|
43
|
+
FileUtils.mkdir_p([previews_dir, docs_dir, assets_dir])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# ─── Server Management ─────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def start_server
|
|
49
|
+
@port = find_available_port
|
|
50
|
+
puts "📡 Starting server on port #{@port}..."
|
|
51
|
+
|
|
52
|
+
@server_pid = spawn(
|
|
53
|
+
{ "RAILS_ENV" => "development", "PORT" => @port.to_s },
|
|
54
|
+
"bundle", "exec", "rails", "server", "-p", @port.to_s, "-b", "127.0.0.1",
|
|
55
|
+
chdir: Rails.root.to_s,
|
|
56
|
+
out: File::NULL,
|
|
57
|
+
err: File::NULL
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
wait_for_server
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def wait_for_server
|
|
64
|
+
print " Waiting for server"
|
|
65
|
+
60.times do
|
|
66
|
+
begin
|
|
67
|
+
TCPSocket.new("127.0.0.1", @port).close
|
|
68
|
+
puts " ✅"
|
|
69
|
+
sleep 2 # Give Lookbook time to initialize
|
|
70
|
+
return
|
|
71
|
+
rescue Errno::ECONNREFUSED
|
|
72
|
+
print "."
|
|
73
|
+
sleep 1
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
abort "❌ Server failed to start within 60 seconds"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def stop_server
|
|
80
|
+
return unless @server_pid
|
|
81
|
+
puts "🛑 Stopping server..."
|
|
82
|
+
Process.kill("TERM", @server_pid)
|
|
83
|
+
Process.wait(@server_pid)
|
|
84
|
+
puts " ✅ Server stopped"
|
|
85
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
86
|
+
# Process already gone
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def find_available_port
|
|
90
|
+
server = TCPServer.new("127.0.0.1", 0)
|
|
91
|
+
port = server.addr[1]
|
|
92
|
+
server.close
|
|
93
|
+
port
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# ─── HTTP Helpers ──────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def fetch_page(path, retries: 3)
|
|
99
|
+
uri = URI("http://127.0.0.1:#{@port}#{path}")
|
|
100
|
+
attempt = 0
|
|
101
|
+
begin
|
|
102
|
+
attempt += 1
|
|
103
|
+
response = Net::HTTP.get_response(uri)
|
|
104
|
+
case response
|
|
105
|
+
when Net::HTTPSuccess
|
|
106
|
+
response.body
|
|
107
|
+
when Net::HTTPRedirection
|
|
108
|
+
redirect_location = response["location"]
|
|
109
|
+
redirect_uri = URI(redirect_location)
|
|
110
|
+
unless redirect_uri.host
|
|
111
|
+
redirect_uri = URI("http://127.0.0.1:#{@port}#{redirect_location}")
|
|
112
|
+
end
|
|
113
|
+
Net::HTTP.get(redirect_uri)
|
|
114
|
+
else
|
|
115
|
+
puts " ⚠️ HTTP #{response.code} for #{path}"
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Net::OpenTimeout => e
|
|
119
|
+
if attempt <= retries
|
|
120
|
+
sleep 1
|
|
121
|
+
retry
|
|
122
|
+
end
|
|
123
|
+
puts " ⚠️ Failed to fetch #{path}: #{e.message}"
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ─── File Helpers ──────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def sanitize_filename(name)
|
|
131
|
+
name.to_s.gsub(/[^a-zA-Z0-9_\-.]/, "_").gsub(/_+/, "_").gsub(/^_|_$/, "")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def save_html(dir, filename, content)
|
|
135
|
+
return false unless content && !content.strip.empty?
|
|
136
|
+
FileUtils.mkdir_p(dir)
|
|
137
|
+
File.write(File.join(dir, "#{filename}.html"), content.force_encoding("UTF-8"))
|
|
138
|
+
true
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# ─── Assets Collection ─────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
def collect_assets
|
|
144
|
+
puts ""
|
|
145
|
+
puts "📦 Collecting assets..."
|
|
146
|
+
|
|
147
|
+
assets_to_fetch = []
|
|
148
|
+
|
|
149
|
+
# 1. Fetch Lookbook UI assets (for docs/index)
|
|
150
|
+
lookbook_html = fetch_page("/lookbook/")
|
|
151
|
+
if lookbook_html
|
|
152
|
+
assets_to_fetch += lookbook_html.scan(/href="([^"]*\.css[^"]*)"/).flatten
|
|
153
|
+
assets_to_fetch += lookbook_html.scan(/src="([^"]*\.js[^"]*)"/).flatten
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# 2. Fetch Application assets from a sample preview (for component previews)
|
|
157
|
+
first_preview = Lookbook.previews.first
|
|
158
|
+
if first_preview
|
|
159
|
+
scenario = first_preview.scenarios.first
|
|
160
|
+
preview_class_path = first_preview.preview_class.name.underscore.sub(/_preview$/, "")
|
|
161
|
+
preview_url = "/rails/view_components/#{preview_class_path}/#{scenario.name}"
|
|
162
|
+
|
|
163
|
+
preview_html = fetch_page(preview_url)
|
|
164
|
+
if preview_html
|
|
165
|
+
assets_to_fetch += preview_html.scan(/href="([^"]*\.css[^"]*)"/).flatten
|
|
166
|
+
assets_to_fetch += preview_html.scan(/src="([^"]*\.js[^"]*)"/).flatten
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
assets_to_fetch.uniq.each do |asset_path|
|
|
171
|
+
# Handle relative paths if necessary (though usually they are absolute /assets/...)
|
|
172
|
+
asset_content = fetch_page(asset_path)
|
|
173
|
+
next unless asset_content
|
|
174
|
+
|
|
175
|
+
# Remove query strings for filename
|
|
176
|
+
clean_path = asset_path.split("?").first
|
|
177
|
+
asset_filename = File.basename(clean_path)
|
|
178
|
+
ext = File.extname(asset_filename)
|
|
179
|
+
|
|
180
|
+
# Determine destination
|
|
181
|
+
if ext == ".css"
|
|
182
|
+
dest_dir = File.join(assets_dir, "css")
|
|
183
|
+
elsif ext == ".js"
|
|
184
|
+
dest_dir = File.join(assets_dir, "js")
|
|
185
|
+
else
|
|
186
|
+
# Fallback or other assets (images etc? user didn't ask explicitly but good to have)
|
|
187
|
+
# Put in assets root or verify extension?
|
|
188
|
+
# For now, stick to css/js as requested.
|
|
189
|
+
next unless %w[.css .js].include?(ext)
|
|
190
|
+
dest_dir = assets_dir
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
FileUtils.mkdir_p(dest_dir)
|
|
194
|
+
File.binwrite(File.join(dest_dir, asset_filename), asset_content)
|
|
195
|
+
|
|
196
|
+
# Also copy map files if they exist? (Optional, skipping for now)
|
|
197
|
+
end
|
|
198
|
+
puts " ✅ Assets collected"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# ─── Docs Generation ──────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
def generate_docs
|
|
204
|
+
puts ""
|
|
205
|
+
puts "📚 Generating docs..."
|
|
206
|
+
|
|
207
|
+
pages = Lookbook.pages
|
|
208
|
+
count = 0
|
|
209
|
+
|
|
210
|
+
pages.each do |page|
|
|
211
|
+
page_path = page.lookup_path
|
|
212
|
+
page_url = "/lookbook/pages/#{page_path}"
|
|
213
|
+
|
|
214
|
+
parts = page_path.to_s.split("/")
|
|
215
|
+
dir = File.join(docs_dir, *parts[0...-1])
|
|
216
|
+
filename = parts.last
|
|
217
|
+
|
|
218
|
+
html = fetch_page(page_url)
|
|
219
|
+
if save_html(dir, filename, html)
|
|
220
|
+
count += 1
|
|
221
|
+
puts " ✅ #{page_path}"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
puts " 📄 #{count} doc pages generated"
|
|
226
|
+
count
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# ─── Previews Generation ──────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
def generate_previews
|
|
232
|
+
puts ""
|
|
233
|
+
puts "🎨 Generating previews..."
|
|
234
|
+
|
|
235
|
+
previews = Lookbook.previews
|
|
236
|
+
previews_count = 0
|
|
237
|
+
param_previews_count = 0
|
|
238
|
+
|
|
239
|
+
previews.each do |preview|
|
|
240
|
+
preview_path = preview.lookup_path
|
|
241
|
+
path_parts = preview_path.to_s.split("/")
|
|
242
|
+
|
|
243
|
+
# Extract category and component name
|
|
244
|
+
# e.g., fluxbit/components/accordion => components/accordion
|
|
245
|
+
if path_parts.first == "fluxbit"
|
|
246
|
+
category = path_parts[1] || "other"
|
|
247
|
+
component_name = path_parts[2..].join("/")
|
|
248
|
+
else
|
|
249
|
+
category = path_parts[0] || "other"
|
|
250
|
+
component_name = path_parts[1..].join("/")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
component_dir = File.join(previews_dir, category, component_name)
|
|
254
|
+
preview_file = preview.file_path.to_s
|
|
255
|
+
|
|
256
|
+
puts " 📦 #{preview.label} (#{preview_path})"
|
|
257
|
+
|
|
258
|
+
preview.scenarios.each do |scenario|
|
|
259
|
+
scenario_name = scenario.name.to_s
|
|
260
|
+
|
|
261
|
+
# Construct ViewComponent preview path: fluxbit/components/accordion_component/default
|
|
262
|
+
# Removes _preview suffix from class name if present
|
|
263
|
+
preview_class_path = preview.preview_class.name.underscore.sub(/_preview$/, "")
|
|
264
|
+
base_preview_path = "/rails/view_components/#{preview_class_path}/#{scenario_name}"
|
|
265
|
+
|
|
266
|
+
display_params = "_display%5Btheme%5D=light"
|
|
267
|
+
|
|
268
|
+
# Fetch base preview with defaults
|
|
269
|
+
html = fetch_page("#{base_preview_path}?#{display_params}")
|
|
270
|
+
if save_html(component_dir, sanitize_filename(scenario_name), html)
|
|
271
|
+
previews_count += 1
|
|
272
|
+
puts " ✅ #{scenario_name}"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Parse parameters and generate variations
|
|
276
|
+
params = parse_params_from_source(preview_file, scenario_name)
|
|
277
|
+
resolve_select_methods(preview.preview_class, params)
|
|
278
|
+
|
|
279
|
+
combinations = generate_param_combinations(params)
|
|
280
|
+
|
|
281
|
+
if combinations.any?
|
|
282
|
+
puts " 🔀 Generating #{combinations.length} parameter variation(s)..."
|
|
283
|
+
combinations.each do |combo|
|
|
284
|
+
query = combo_to_query_params(combo) + "&" + display_params
|
|
285
|
+
variant_url = "#{base_preview_path}?#{query}"
|
|
286
|
+
variant_filename = combo_to_filename(scenario_name, combo)
|
|
287
|
+
|
|
288
|
+
html = fetch_page(variant_url)
|
|
289
|
+
if save_html(component_dir, variant_filename, html)
|
|
290
|
+
param_previews_count += 1
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
puts " ✅ #{combinations.length} variations generated"
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
puts " 🎨 #{previews_count} base previews, #{param_previews_count} parameter variations"
|
|
300
|
+
[previews_count, param_previews_count]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# ─── Parameter Parsing ─────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
def parse_params_from_source(file_path, method_name)
|
|
306
|
+
return [] unless File.exist?(file_path)
|
|
307
|
+
|
|
308
|
+
source = File.read(file_path)
|
|
309
|
+
params = []
|
|
310
|
+
|
|
311
|
+
# Find the comment block + method definition
|
|
312
|
+
method_pattern = /^(\s*(?:#[^\n]*\n)+)\s*def\s+#{Regexp.escape(method_name)}\b([^)]*\))?/m
|
|
313
|
+
match = source.match(method_pattern)
|
|
314
|
+
return [] unless match
|
|
315
|
+
|
|
316
|
+
comment_block = match[1]
|
|
317
|
+
method_signature = match[0]
|
|
318
|
+
|
|
319
|
+
# Extract default values from method signature
|
|
320
|
+
defaults = {}
|
|
321
|
+
if method_signature =~ /def\s+#{Regexp.escape(method_name)}\s*\(([^)]*)\)/m
|
|
322
|
+
sig_params = $1
|
|
323
|
+
sig_params.scan(/(\w+):\s*([^,)]+)/).each do |name, default_val|
|
|
324
|
+
defaults[name.strip] = default_val.strip.delete("'\":")
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Parse each @param annotation
|
|
329
|
+
comment_block.scan(/#\s*@param\s+(\w+)\s+(.+)/).each do |param_name, param_rest|
|
|
330
|
+
info = { name: param_name, type: nil, values: nil, default: defaults[param_name] }
|
|
331
|
+
|
|
332
|
+
case param_rest.strip
|
|
333
|
+
when /\[Boolean\]/i, /toggle/i
|
|
334
|
+
info[:type] = :boolean
|
|
335
|
+
info[:values] = %w[true false]
|
|
336
|
+
when /select\s*\{\s*choices:\s*\[([^\]]+)\]\s*\}/
|
|
337
|
+
info[:type] = :select
|
|
338
|
+
info[:values] = $1.split(",").map { |v| v.strip.delete("'\"") }
|
|
339
|
+
when /select\s+"[^"]*"\s+:(\w+)/
|
|
340
|
+
info[:type] = :select_method
|
|
341
|
+
info[:method_name] = $1
|
|
342
|
+
when /range\s*\{\s*min:\s*(-?\d+),\s*max:\s*(-?\d+),\s*step:\s*(\d+)\s*\}/
|
|
343
|
+
info[:type] = :range
|
|
344
|
+
min, max, step = $1.to_i, $2.to_i, $3.to_i
|
|
345
|
+
info[:values] = (min..max).step(step).map(&:to_s)
|
|
346
|
+
when /number\s*\{\s*min:\s*(-?\d+),\s*max:\s*(-?\d+)/
|
|
347
|
+
info[:type] = :number
|
|
348
|
+
min, max = $1.to_i, $2.to_i
|
|
349
|
+
step = (param_rest.match(/step:\s*(\d+)/) ? $1.to_i : 1)
|
|
350
|
+
info[:values] = (min..max).step(step).map(&:to_s)
|
|
351
|
+
else
|
|
352
|
+
# text, String, Integer without choices - skip (infinite possibilities)
|
|
353
|
+
next
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
params << info if info[:values] && info[:values].any?
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
params
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def resolve_select_methods(preview_class, params)
|
|
363
|
+
params.each do |param|
|
|
364
|
+
next unless param[:type] == :select_method && param[:method_name]
|
|
365
|
+
|
|
366
|
+
begin
|
|
367
|
+
instance = preview_class.new
|
|
368
|
+
if instance.respond_to?(param[:method_name], true)
|
|
369
|
+
values = instance.send(param[:method_name])
|
|
370
|
+
param[:values] = Array(values).map(&:to_s)
|
|
371
|
+
param[:type] = :select
|
|
372
|
+
else
|
|
373
|
+
param[:values] = []
|
|
374
|
+
end
|
|
375
|
+
rescue => e
|
|
376
|
+
puts " ⚠️ Could not resolve :#{param[:method_name]}: #{e.message}"
|
|
377
|
+
param[:values] = []
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def generate_param_combinations(params)
|
|
383
|
+
enumerable = params.select { |p| p[:values] && p[:values].any? }
|
|
384
|
+
return [] if enumerable.empty?
|
|
385
|
+
|
|
386
|
+
if combination_mode == :cartesian
|
|
387
|
+
# Full cartesian product
|
|
388
|
+
value_arrays = enumerable.map { |p| p[:values].map { |v| [p[:name], v] } }
|
|
389
|
+
if value_arrays.length == 1
|
|
390
|
+
combinations = value_arrays.first.map { |pair| Hash[*pair] }
|
|
391
|
+
else
|
|
392
|
+
combinations = value_arrays.first.product(*value_arrays[1..]).map(&:to_h)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Remove the combination that matches all defaults
|
|
396
|
+
default_combo = enumerable.each_with_object({}) do |p, h|
|
|
397
|
+
h[p[:name]] = p[:default].to_s
|
|
398
|
+
end
|
|
399
|
+
combinations.reject! { |c| c == default_combo }
|
|
400
|
+
|
|
401
|
+
combinations
|
|
402
|
+
else
|
|
403
|
+
# Individual: one file per param value
|
|
404
|
+
combinations = []
|
|
405
|
+
enumerable.each do |param|
|
|
406
|
+
param[:values].each do |value|
|
|
407
|
+
next if value == param[:default].to_s
|
|
408
|
+
combinations << { param[:name] => value }
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
combinations
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def combo_to_filename(scenario_name, combo)
|
|
416
|
+
parts = combo.map { |k, v| "#{sanitize_filename(k)}_#{sanitize_filename(v)}" }
|
|
417
|
+
"#{sanitize_filename(scenario_name)}--#{parts.join("--")}"
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def combo_to_query_params(combo)
|
|
421
|
+
combo.map { |k, v| "#{k}=#{URI.encode_www_form_component(v)}" }.join("&")
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# ─── Index Page Generation ─────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
def generate_index(pages_count, previews_count, param_previews_count)
|
|
427
|
+
puts ""
|
|
428
|
+
puts "📋 Generating index..."
|
|
429
|
+
|
|
430
|
+
pages = Lookbook.pages
|
|
431
|
+
previews = Lookbook.previews
|
|
432
|
+
|
|
433
|
+
html = build_index_html(pages, previews, pages_count, previews_count, param_previews_count)
|
|
434
|
+
File.write(File.join(output_dir, "index.html"), html)
|
|
435
|
+
puts " ✅ index.html generated"
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def build_index_html(pages, previews, pages_count, previews_count, param_previews_count)
|
|
439
|
+
previews_by_category = previews.group_by do |p|
|
|
440
|
+
parts = p.lookup_path.to_s.split("/")
|
|
441
|
+
parts.first == "fluxbit" ? (parts[1] || "other") : (parts[0] || "other")
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
<<~HTML
|
|
445
|
+
<!DOCTYPE html>
|
|
446
|
+
<html lang="en">
|
|
447
|
+
<head>
|
|
448
|
+
<meta charset="UTF-8">
|
|
449
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
450
|
+
<title>Fluxbit ViewComponents - Static Lookbook</title>
|
|
451
|
+
<style>
|
|
452
|
+
:root { --bg: #0f172a; --surface: #1e293b; --border: #334155; --text: #e2e8f0; --text-muted: #94a3b8; --primary: #3b82f6; --primary-hover: #60a5fa; }
|
|
453
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
454
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; padding: 2rem; }
|
|
455
|
+
.container { max-width: 960px; margin: 0 auto; }
|
|
456
|
+
h1 { font-size: 2rem; margin-bottom: 0.5rem; background: linear-gradient(135deg, var(--primary), #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
|
457
|
+
.subtitle { color: var(--text-muted); margin-bottom: 2rem; }
|
|
458
|
+
h2 { font-size: 1.25rem; margin: 1.5rem 0 0.75rem; color: var(--primary); border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
|
|
459
|
+
h3 { font-size: 1rem; margin: 1rem 0 0.5rem; color: var(--text-muted); }
|
|
460
|
+
ul { list-style: none; padding: 0; }
|
|
461
|
+
li { margin: 0.25rem 0; }
|
|
462
|
+
a { color: var(--primary); text-decoration: none; transition: color 0.2s; }
|
|
463
|
+
a:hover { color: var(--primary-hover); }
|
|
464
|
+
.section { background: var(--surface); border: 1px solid var(--border); border-radius: 0.75rem; padding: 1.5rem; margin-bottom: 1.5rem; }
|
|
465
|
+
.badge { display: inline-block; font-size: 0.75rem; padding: 0.125rem 0.5rem; background: var(--border); border-radius: 9999px; color: var(--text-muted); margin-left: 0.5rem; }
|
|
466
|
+
.stats { display: flex; gap: 1rem; margin-bottom: 2rem; }
|
|
467
|
+
.stat { background: var(--surface); border: 1px solid var(--border); border-radius: 0.75rem; padding: 1rem 1.5rem; flex: 1; text-align: center; }
|
|
468
|
+
.stat-value { font-size: 1.5rem; font-weight: 700; color: var(--primary); }
|
|
469
|
+
.stat-label { font-size: 0.875rem; color: var(--text-muted); }
|
|
470
|
+
</style>
|
|
471
|
+
</head>
|
|
472
|
+
<body>
|
|
473
|
+
<div class="container">
|
|
474
|
+
<h1>Fluxbit ViewComponents</h1>
|
|
475
|
+
<p class="subtitle">Static Lookbook — Generated #{Time.now.strftime("%Y-%m-%d %H:%M")}</p>
|
|
476
|
+
|
|
477
|
+
<div class="stats">
|
|
478
|
+
<div class="stat"><div class="stat-value">#{pages_count}</div><div class="stat-label">Doc Pages</div></div>
|
|
479
|
+
<div class="stat"><div class="stat-value">#{previews_count}</div><div class="stat-label">Previews</div></div>
|
|
480
|
+
<div class="stat"><div class="stat-value">#{param_previews_count}</div><div class="stat-label">Param Variations</div></div>
|
|
481
|
+
</div>
|
|
482
|
+
|
|
483
|
+
<div class="section">
|
|
484
|
+
<h2>📚 Documentation</h2>
|
|
485
|
+
<ul>
|
|
486
|
+
#{pages.map { |pg| "<li><a href=\"pages/#{pg.lookup_path}.html\">#{pg.label}</a></li>" }.join("\n ")}
|
|
487
|
+
</ul>
|
|
488
|
+
</div>
|
|
489
|
+
|
|
490
|
+
#{previews_by_category.map { |category, cat_previews|
|
|
491
|
+
items = cat_previews.map { |preview|
|
|
492
|
+
path_parts = preview.lookup_path.to_s.split("/")
|
|
493
|
+
component_name = path_parts.first == "fluxbit" ? path_parts[2..].join("/") : path_parts[1..].join("/")
|
|
494
|
+
scenarios_html = preview.scenarios.map { |s|
|
|
495
|
+
"<li><a href=\"inspect/#{category}/#{component_name}/#{sanitize_filename(s.name)}.html\">#{s.label}</a></li>"
|
|
496
|
+
}.join("\n ")
|
|
497
|
+
"<h3>#{preview.label}<span class=\"badge\">#{preview.scenarios.count} scenarios</span></h3>\n <ul>#{scenarios_html}</ul>"
|
|
498
|
+
}.join("\n ")
|
|
499
|
+
"<div class=\"section\"><h2>🎨 #{category.capitalize}</h2>\n #{items}\n </div>"
|
|
500
|
+
}.join("\n\n ")}
|
|
501
|
+
</div>
|
|
502
|
+
</body>
|
|
503
|
+
</html>
|
|
504
|
+
HTML
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# ─── Summary ───────────────────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
def print_summary(pages_count, previews_count, param_previews_count)
|
|
510
|
+
total = pages_count + previews_count + param_previews_count
|
|
511
|
+
puts ""
|
|
512
|
+
puts "═══════════════════════════════════════════════════"
|
|
513
|
+
puts " ✅ Static Lookbook generated successfully!"
|
|
514
|
+
puts " 📄 #{pages_count} doc pages"
|
|
515
|
+
puts " 🎨 #{previews_count} base previews"
|
|
516
|
+
puts " 🔀 #{param_previews_count} parameter variations"
|
|
517
|
+
puts " 📊 #{total} total files"
|
|
518
|
+
puts " 📁 #{output_dir}"
|
|
519
|
+
puts "═══════════════════════════════════════════════════"
|
|
520
|
+
puts ""
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
namespace :lookbook do
|
|
526
|
+
desc "Generate a static version of Lookbook in public/static-lookbook. " \
|
|
527
|
+
"Usage: rake lookbook:generate_static[cartesian] (default) or rake lookbook:generate_static[individual]"
|
|
528
|
+
task :generate_static, [:combination_mode] => :environment do |_t, args|
|
|
529
|
+
require "net/http"
|
|
530
|
+
require "fileutils"
|
|
531
|
+
require "uri"
|
|
532
|
+
require "socket"
|
|
533
|
+
|
|
534
|
+
mode = (args[:combination_mode] || "cartesian").to_sym
|
|
535
|
+
unless %i[cartesian individual].include?(mode)
|
|
536
|
+
abort "❌ Invalid combination mode: #{mode}. Use 'cartesian' or 'individual'."
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
generator = LookbookStaticGenerator::Generator.new(combination_mode: mode)
|
|
540
|
+
generator.run!
|
|
541
|
+
end
|
|
542
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fluxbit_view_components
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Arthur Molina
|
|
@@ -251,6 +251,7 @@ files:
|
|
|
251
251
|
- lib/fluxbit/config/theme_button_component.rb
|
|
252
252
|
- lib/fluxbit/config/timeline_component.rb
|
|
253
253
|
- lib/fluxbit/config/tooltip_component.rb
|
|
254
|
+
- lib/fluxbit/gravatar.rb
|
|
254
255
|
- lib/fluxbit/templates/darkmode.js.template
|
|
255
256
|
- lib/fluxbit/templates/tailwind.config.js.template
|
|
256
257
|
- lib/fluxbit/view_components.rb
|
|
@@ -302,6 +303,7 @@ files:
|
|
|
302
303
|
- lib/generators/fluxbit/templates/update_all.turbo_stream.erb.tt
|
|
303
304
|
- lib/install/install.rb
|
|
304
305
|
- lib/tasks/fluxbit_view_components_tasks.rake
|
|
306
|
+
- lib/tasks/lookbook_static.rake
|
|
305
307
|
homepage: https://github.com/arthurmolina/fluxbit_view_components
|
|
306
308
|
licenses:
|
|
307
309
|
- MIT
|