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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 06e4ed1b421bf498659d15d049ad70dc8bb4fe5ec1bc732a6259649e3cfe4a8e
4
- data.tar.gz: d82fbbdcd495f2035ec683c65696def950c51e9ca76e4d687bc83e58f375edca
3
+ metadata.gz: db4a121022c04abb99806e36df03f68c0a1dc4ced2ff73c31da5ab2ea3c5b84a
4
+ data.tar.gz: 6b5622d2bcdff4c893aa1a0efe36164e1b48fcfd04b60659afefa4a79120f33d
5
5
  SHA512:
6
- metadata.gz: 39191b6eb817d7ce9fb3cc8ebcef6ee6bcf8d06ee0adca6c956ec4f40a019755aa9401d8aff4b48dd2c122c4e8e4968c55849d34d5ca3bf5687b19ec33ba5290
7
- data.tar.gz: 7f5fd219cf6bab11f202d574464d0e71897284488e8ec116a8df6036f752ba73eea071f3d03661b59d69b90cf1b6c49a002e08c1b8d9dd7dd3071fdaaea7949d
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&.send(@attribute)&.variant(resize_to_fit: [ 160, 160 ])
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. The Gravatar URL is constructed based on the
13
- # provided email address and options.
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
- @gravatar_options = {
35
- rating: options((@props.delete(:rating)|| "").to_sym, collection: gravatar_styles[:rating], default: @@rating),
36
- secure: options(@props.delete(:secure), default: true),
37
- filetype: options((@props.delete(:filetype)|| "").to_sym, collection: gravatar_styles[:filetype], default: @@filetype),
38
- default: options((@props.delete(:default)|| "").to_sym, collection: gravatar_styles[:default], default: @@default),
39
- size: gravatar_styles[:size][options(@props[:size], collection: gravatar_styles[:size], default: @@size)],
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
- @email = @props.delete(:email)
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 gravatar_url.html_safe if @url_only
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 || 'page'
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
- elsif step.state == :active
123
+ elsif step.state == :active
124
124
  styles[:connector][:active][@color][@variant][@orientation]
125
- else
125
+ else
126
126
  styles[:connector][@variant][@orientation]
127
- end
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
@@ -2,6 +2,10 @@
2
2
 
3
3
  module Fluxbit
4
4
  module ViewHelper
5
+ def fx_gravatar_url(...)
6
+ Fluxbit::Gravatar.url(...)
7
+ end
8
+
5
9
  def fx_body_class
6
10
  "h-full bg-slate-100 dark:bg-slate-900 dark:text-white"
7
11
  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
@@ -1,5 +1,5 @@
1
1
  module Fluxbit
2
2
  module ViewComponents
3
- VERSION = "0.5.3"
3
+ VERSION = "0.5.4"
4
4
  end
5
5
  end
@@ -54,4 +54,6 @@ module Fluxbit
54
54
  require "fluxbit/config/theme_button_component"
55
55
  require "fluxbit/config/tooltip_component"
56
56
  end
57
+
58
+ require "fluxbit/gravatar"
57
59
  end
@@ -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 ' registerFluxbitControllers(Stimulus)', :red
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.3
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