style_capsule 1.0.2
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 +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE.md +22 -0
- data/README.md +398 -0
- data/SECURITY.md +55 -0
- data/lib/style_capsule/component.rb +485 -0
- data/lib/style_capsule/component_styles_support.rb +73 -0
- data/lib/style_capsule/css_file_writer.rb +244 -0
- data/lib/style_capsule/css_processor.rb +182 -0
- data/lib/style_capsule/helper.rb +163 -0
- data/lib/style_capsule/phlex_helper.rb +66 -0
- data/lib/style_capsule/railtie.rb +68 -0
- data/lib/style_capsule/stylesheet_registry.rb +494 -0
- data/lib/style_capsule/version.rb +5 -0
- data/lib/style_capsule/view_component.rb +479 -0
- data/lib/style_capsule/view_component_helper.rb +53 -0
- data/lib/style_capsule.rb +93 -0
- data/lib/tasks/style_capsule.rake +89 -0
- data/sig/style_capsule.rbs +110 -0
- metadata +305 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StyleCapsule
|
|
4
|
+
# Railtie to automatically include StyleCapsule helpers in Rails
|
|
5
|
+
if defined?(Rails::Railtie)
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
# Automatically include ERB helper in ActionView::Base (standard Rails pattern)
|
|
8
|
+
# This makes helpers available in all ERB templates automatically
|
|
9
|
+
ActiveSupport.on_load(:action_view) do
|
|
10
|
+
include StyleCapsule::Helper
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Configure CSS file writer for file-based caching
|
|
14
|
+
config.after_initialize do
|
|
15
|
+
# Configure default output directory
|
|
16
|
+
StyleCapsule::CssFileWriter.configure(
|
|
17
|
+
output_dir: Rails.root.join(StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR),
|
|
18
|
+
enabled: true
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Add output directory to asset paths if it exists
|
|
22
|
+
if Rails.application.config.respond_to?(:assets)
|
|
23
|
+
capsules_dir = Rails.root.join(StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR)
|
|
24
|
+
if Dir.exist?(capsules_dir)
|
|
25
|
+
Rails.application.config.assets.paths << capsules_dir
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Clear CSS caches and stylesheet manifest when classes are unloaded in development
|
|
31
|
+
# This prevents memory leaks from stale cache entries during code reloading
|
|
32
|
+
if Rails.env.development?
|
|
33
|
+
config.to_prepare do
|
|
34
|
+
# Clear CSS caches for all classes that include StyleCapsule modules
|
|
35
|
+
# Skip singleton classes and handle errors gracefully
|
|
36
|
+
ObjectSpace.each_object(Class) do |klass|
|
|
37
|
+
# Skip singleton classes and classes without names (they can cause errors)
|
|
38
|
+
# Singleton classes don't have proper names and can't be safely checked
|
|
39
|
+
next if klass.name.blank?
|
|
40
|
+
next if klass.singleton_class?
|
|
41
|
+
|
|
42
|
+
# Use method_defined? instead of respond_to? to avoid triggering delegation
|
|
43
|
+
# that might cause errors with singleton classes or other edge cases
|
|
44
|
+
begin
|
|
45
|
+
if klass.method_defined?(:clear_css_cache, false) || klass.private_method_defined?(:clear_css_cache)
|
|
46
|
+
klass.clear_css_cache
|
|
47
|
+
end
|
|
48
|
+
rescue
|
|
49
|
+
# Skip classes that cause errors (singleton classes, delegation issues, etc.)
|
|
50
|
+
next
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Clear stylesheet manifest to allow re-registration during code reload
|
|
55
|
+
StyleCapsule::StylesheetRegistry.clear_manifest
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Load rake tasks for CSS file building
|
|
60
|
+
rake_tasks do
|
|
61
|
+
load File.expand_path("../tasks/style_capsule.rake", __dir__)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Note: PhlexHelper should be included explicitly in ApplicationComponent
|
|
65
|
+
# or your base Phlex component class, not automatically via Railtie
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/current_attributes"
|
|
4
|
+
|
|
5
|
+
module StyleCapsule
|
|
6
|
+
# Hybrid registry for stylesheet files that need to be injected into <head>
|
|
7
|
+
#
|
|
8
|
+
# Uses a process-wide manifest for static file paths (like Propshaft) and request-scoped
|
|
9
|
+
# storage for inline CSS. This approach:
|
|
10
|
+
# - Collects static file paths once per process (no rebuilding on each request)
|
|
11
|
+
# - Stores inline CSS per-request (since it's component-specific)
|
|
12
|
+
# - Works correctly with both threaded and forked web servers (Puma, Unicorn, etc.)
|
|
13
|
+
#
|
|
14
|
+
# Supports namespaces for separation of stylesheets (e.g., "admin", "user", "public").
|
|
15
|
+
#
|
|
16
|
+
# @example Usage in a component with head rendering
|
|
17
|
+
# class MyComponent < ApplicationComponent
|
|
18
|
+
# include StyleCapsule::Component
|
|
19
|
+
# stylesheet_registry # Enable head rendering
|
|
20
|
+
#
|
|
21
|
+
# def component_styles
|
|
22
|
+
# <<~CSS
|
|
23
|
+
# .section { color: red; }
|
|
24
|
+
# CSS
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @example Register a stylesheet file manually (default namespace)
|
|
29
|
+
# StyleCapsule::StylesheetRegistry.register('stylesheets/my_component')
|
|
30
|
+
#
|
|
31
|
+
# @example Register a stylesheet with a namespace
|
|
32
|
+
# StyleCapsule::StylesheetRegistry.register('stylesheets/admin/dashboard', namespace: :admin)
|
|
33
|
+
#
|
|
34
|
+
# @example Usage in layout (ERB) - render all namespaces
|
|
35
|
+
# # In app/views/layouts/application.html.erb
|
|
36
|
+
# <head>
|
|
37
|
+
# <%= StyleCapsule::StylesheetRegistry.render_head_stylesheets(self) %>
|
|
38
|
+
# </head>
|
|
39
|
+
#
|
|
40
|
+
# @example Usage in layout (ERB) - render specific namespace
|
|
41
|
+
# <head>
|
|
42
|
+
# <%= StyleCapsule::StylesheetRegistry.render_head_stylesheets(self, namespace: :admin) %>
|
|
43
|
+
# </head>
|
|
44
|
+
#
|
|
45
|
+
# @example Usage in layout (Phlex)
|
|
46
|
+
# # In app/views/layouts/application_layout.rb
|
|
47
|
+
# def view_template(&block)
|
|
48
|
+
# html do
|
|
49
|
+
# head do
|
|
50
|
+
# raw StyleCapsule::StylesheetRegistry.render_head_stylesheets(view_context)
|
|
51
|
+
# end
|
|
52
|
+
# body(&block)
|
|
53
|
+
# end
|
|
54
|
+
# end
|
|
55
|
+
class StylesheetRegistry < ActiveSupport::CurrentAttributes
|
|
56
|
+
# Default namespace for backward compatibility
|
|
57
|
+
DEFAULT_NAMESPACE = :default
|
|
58
|
+
|
|
59
|
+
# Process-wide manifest for static file paths (like Propshaft)
|
|
60
|
+
# Organized by namespace: { namespace => Set of {file_path, options} hashes }
|
|
61
|
+
@manifest = {} # rubocop:disable Style/ClassVars
|
|
62
|
+
|
|
63
|
+
# Process-wide cache for inline CSS (with expiration support)
|
|
64
|
+
# Structure: { cache_key => { css_content: String, cached_at: Time, expires_at: Time } }
|
|
65
|
+
@inline_cache = {} # rubocop:disable Style/ClassVars
|
|
66
|
+
|
|
67
|
+
# Track last cleanup time for lazy cleanup (prevents excessive cleanup calls)
|
|
68
|
+
@last_cleanup_time = nil # rubocop:disable Style/ClassVars
|
|
69
|
+
|
|
70
|
+
# Request-scoped storage for inline CSS only
|
|
71
|
+
attribute :inline_stylesheets
|
|
72
|
+
|
|
73
|
+
class << self
|
|
74
|
+
attr_reader :manifest, :inline_cache
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Normalize namespace (nil/blank becomes DEFAULT_NAMESPACE)
|
|
78
|
+
#
|
|
79
|
+
# @param namespace [Symbol, String, nil] Namespace identifier
|
|
80
|
+
# @return [Symbol] Normalized namespace
|
|
81
|
+
def self.normalize_namespace(namespace)
|
|
82
|
+
return DEFAULT_NAMESPACE if namespace.nil? || namespace.to_s.strip.empty?
|
|
83
|
+
namespace.to_sym
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Register a stylesheet file path for head rendering
|
|
87
|
+
#
|
|
88
|
+
# Static file paths are stored in process-wide manifest (collected once per process).
|
|
89
|
+
# This is similar to Propshaft's manifest approach - files are static, so we can
|
|
90
|
+
# collect them process-wide without rebuilding on each request.
|
|
91
|
+
#
|
|
92
|
+
# Files registered here are served through Rails asset pipeline (via stylesheet_link_tag).
|
|
93
|
+
# This includes both:
|
|
94
|
+
# - Pre-built files from assets:precompile (already in asset pipeline)
|
|
95
|
+
# - Dynamically written files (written during runtime, also served through asset pipeline)
|
|
96
|
+
#
|
|
97
|
+
# The Set automatically deduplicates entries, so registering the same file multiple times
|
|
98
|
+
# (e.g., when the same component renders multiple times) is safe and efficient.
|
|
99
|
+
#
|
|
100
|
+
# @param file_path [String] Path to stylesheet (relative to app/assets/stylesheets)
|
|
101
|
+
# @param namespace [Symbol, String, nil] Optional namespace for separation (nil/blank uses default)
|
|
102
|
+
# @param options [Hash] Options for stylesheet_link_tag
|
|
103
|
+
# @return [void]
|
|
104
|
+
def self.register(file_path, namespace: nil, **options)
|
|
105
|
+
ns = normalize_namespace(namespace)
|
|
106
|
+
@manifest[ns] ||= Set.new
|
|
107
|
+
# Use a hash with file_path and options as the key to avoid duplicates
|
|
108
|
+
# Set will automatically deduplicate based on hash equality
|
|
109
|
+
entry = {file_path: file_path, options: options}
|
|
110
|
+
@manifest[ns] << entry
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Register inline CSS for head rendering
|
|
114
|
+
#
|
|
115
|
+
# Inline CSS can be cached based on cache configuration. Supports:
|
|
116
|
+
# - No caching (default): stored per-request
|
|
117
|
+
# - Time-based caching: cache expires after TTL
|
|
118
|
+
# - Custom proc caching: use proc to determine cache key and validity
|
|
119
|
+
# - File-based caching: writes CSS to files for HTTP caching
|
|
120
|
+
#
|
|
121
|
+
# @param css_content [String] CSS content (should already be scoped)
|
|
122
|
+
# @param namespace [Symbol, String, nil] Optional namespace for separation (nil/blank uses default)
|
|
123
|
+
# @param capsule_id [String, nil] Optional capsule ID for reference
|
|
124
|
+
# @param cache_key [String, nil] Optional cache key (for cache lookup)
|
|
125
|
+
# @param cache_strategy [Symbol, nil] Cache strategy: :none, :time, :proc, :file (default: :none)
|
|
126
|
+
# @param cache_ttl [Integer, ActiveSupport::Duration, nil] Time-to-live in seconds (for :time strategy). Supports ActiveSupport::Duration (e.g., 1.hour, 30.minutes)
|
|
127
|
+
# @param cache_proc [Proc, nil] Custom cache proc (for :proc strategy)
|
|
128
|
+
# Proc receives: (css_content, capsule_id, namespace) and should return [cache_key, should_cache, expires_at]
|
|
129
|
+
# @param component_class [Class, nil] Component class (for :file strategy)
|
|
130
|
+
# @param stylesheet_link_options [Hash, nil] Options for stylesheet_link_tag (for :file strategy)
|
|
131
|
+
# @return [void]
|
|
132
|
+
def self.register_inline(css_content, namespace: nil, capsule_id: nil, cache_key: nil, cache_strategy: :none, cache_ttl: nil, cache_proc: nil, component_class: nil, stylesheet_link_options: nil)
|
|
133
|
+
ns = normalize_namespace(namespace)
|
|
134
|
+
|
|
135
|
+
# Handle file-based caching (writes to file and registers as file path)
|
|
136
|
+
if cache_strategy == :file && component_class && capsule_id
|
|
137
|
+
# Check if file already exists (from precompilation via assets:precompile or previous write)
|
|
138
|
+
# Pre-built files are already available through Rails asset pipeline and just need to be registered
|
|
139
|
+
existing_path = CssFileWriter.file_path_for(
|
|
140
|
+
component_class: component_class,
|
|
141
|
+
capsule_id: capsule_id
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if existing_path
|
|
145
|
+
# File exists (pre-built or previously written), register it as a file path
|
|
146
|
+
# The Set in @manifest will deduplicate if the same file is registered multiple times
|
|
147
|
+
# This file will be served through Rails asset pipeline (stylesheet_link_tag)
|
|
148
|
+
link_options = stylesheet_link_options || {}
|
|
149
|
+
register(existing_path, namespace: namespace, **link_options)
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# File doesn't exist, write it dynamically (development mode or first render)
|
|
154
|
+
# After writing, register it as a file path so it's served as an asset
|
|
155
|
+
file_path = CssFileWriter.write_css(
|
|
156
|
+
css_content: css_content,
|
|
157
|
+
component_class: component_class,
|
|
158
|
+
capsule_id: capsule_id
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if file_path
|
|
162
|
+
# Register as file path instead of inline CSS
|
|
163
|
+
# This ensures the file is served through Rails asset pipeline
|
|
164
|
+
link_options = stylesheet_link_options || {}
|
|
165
|
+
register(file_path, namespace: namespace, **link_options)
|
|
166
|
+
return
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Check cache if strategy is enabled
|
|
171
|
+
cached_css = nil
|
|
172
|
+
if cache_strategy != :none && cache_key && cache_strategy != :file
|
|
173
|
+
cached_css = cached_inline(cache_key, cache_strategy: cache_strategy, cache_ttl: cache_ttl, cache_proc: cache_proc, css_content: css_content, capsule_id: capsule_id, namespace: ns)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Use cached CSS if available, otherwise use provided CSS
|
|
177
|
+
final_css = cached_css || css_content
|
|
178
|
+
|
|
179
|
+
# Store in request-scoped registry
|
|
180
|
+
registry = instance.inline_stylesheets || {}
|
|
181
|
+
registry[ns] ||= []
|
|
182
|
+
registry[ns] << {
|
|
183
|
+
type: :inline,
|
|
184
|
+
css_content: final_css,
|
|
185
|
+
capsule_id: capsule_id
|
|
186
|
+
}
|
|
187
|
+
instance.inline_stylesheets = registry
|
|
188
|
+
|
|
189
|
+
# Cache the CSS if strategy is enabled and not already cached
|
|
190
|
+
if cache_strategy != :none && cache_key && !cached_css && cache_strategy != :file
|
|
191
|
+
cache_inline_css(cache_key, css_content, cache_strategy: cache_strategy, cache_ttl: cache_ttl, cache_proc: cache_proc, capsule_id: capsule_id, namespace: ns)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Get cached inline CSS if available and not expired
|
|
196
|
+
#
|
|
197
|
+
# Automatically performs lazy cleanup of expired entries if it's been more than
|
|
198
|
+
# 5 minutes since the last cleanup to prevent memory leaks in long-running processes.
|
|
199
|
+
#
|
|
200
|
+
# @param cache_key [String] Cache key to look up
|
|
201
|
+
# @param cache_strategy [Symbol] Cache strategy
|
|
202
|
+
# @param cache_ttl [Integer, ActiveSupport::Duration, nil] Time-to-live in seconds. Supports ActiveSupport::Duration (e.g., 1.hour, 30.minutes)
|
|
203
|
+
# @param cache_proc [Proc, nil] Custom cache proc
|
|
204
|
+
# @param css_content [String] Original CSS content (for proc strategy)
|
|
205
|
+
# @param capsule_id [String, nil] Capsule ID (for proc strategy)
|
|
206
|
+
# @param namespace [Symbol] Namespace (for proc strategy)
|
|
207
|
+
# @return [String, nil] Cached CSS content or nil if not cached/expired
|
|
208
|
+
def self.cached_inline(cache_key, cache_strategy:, cache_ttl: nil, cache_proc: nil, css_content: nil, capsule_id: nil, namespace: nil)
|
|
209
|
+
# Lazy cleanup: remove expired entries if it's been a while (every 5 minutes)
|
|
210
|
+
# This prevents memory leaks in long-running processes without impacting performance
|
|
211
|
+
cleanup_expired_cache_if_needed
|
|
212
|
+
|
|
213
|
+
cached_entry = @inline_cache[cache_key]
|
|
214
|
+
return nil unless cached_entry
|
|
215
|
+
|
|
216
|
+
# Check expiration based on strategy
|
|
217
|
+
case cache_strategy
|
|
218
|
+
when :time
|
|
219
|
+
return nil if cache_ttl && cached_entry[:expires_at] && Time.current > cached_entry[:expires_at]
|
|
220
|
+
when :proc
|
|
221
|
+
return nil unless cache_proc
|
|
222
|
+
# Proc should validate cache entry
|
|
223
|
+
_key, should_use, _expires = cache_proc.call(css_content, capsule_id, namespace)
|
|
224
|
+
return nil unless should_use
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
cached_entry[:css_content]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Cache inline CSS with expiration
|
|
231
|
+
#
|
|
232
|
+
# @param cache_key [String] Cache key
|
|
233
|
+
# @param css_content [String] CSS content to cache
|
|
234
|
+
# @param cache_strategy [Symbol] Cache strategy
|
|
235
|
+
# @param cache_ttl [Integer, ActiveSupport::Duration, nil] Time-to-live in seconds. Supports ActiveSupport::Duration (e.g., 1.hour, 30.minutes)
|
|
236
|
+
# @param cache_proc [Proc, nil] Custom cache proc
|
|
237
|
+
# @param capsule_id [String, nil] Capsule ID
|
|
238
|
+
# @param namespace [Symbol] Namespace
|
|
239
|
+
# @return [void]
|
|
240
|
+
def self.cache_inline_css(cache_key, css_content, cache_strategy:, cache_ttl: nil, cache_proc: nil, capsule_id: nil, namespace: nil)
|
|
241
|
+
expires_at = nil
|
|
242
|
+
|
|
243
|
+
case cache_strategy
|
|
244
|
+
when :time
|
|
245
|
+
expires_at = cache_ttl ? Time.current + cache_ttl : nil
|
|
246
|
+
when :proc
|
|
247
|
+
if cache_proc
|
|
248
|
+
_key, _should_cache, proc_expires = cache_proc.call(css_content, capsule_id, namespace)
|
|
249
|
+
expires_at = proc_expires
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
@inline_cache[cache_key] = {
|
|
254
|
+
css_content: css_content,
|
|
255
|
+
cached_at: Time.current,
|
|
256
|
+
expires_at: expires_at
|
|
257
|
+
}
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Clear inline CSS cache
|
|
261
|
+
#
|
|
262
|
+
# @param cache_key [String, nil] Specific cache key to clear (nil clears all)
|
|
263
|
+
# @return [void]
|
|
264
|
+
def self.clear_inline_cache(cache_key = nil)
|
|
265
|
+
if cache_key
|
|
266
|
+
@inline_cache.delete(cache_key)
|
|
267
|
+
else
|
|
268
|
+
@inline_cache.clear
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Clean up expired entries from the inline CSS cache
|
|
273
|
+
#
|
|
274
|
+
# Removes all cache entries that have expired (where expires_at is set and
|
|
275
|
+
# Time.current > expires_at). This prevents memory leaks in long-running processes.
|
|
276
|
+
#
|
|
277
|
+
# This method is called automatically by cached_inline (lazy cleanup every 5 minutes),
|
|
278
|
+
# but can also be called manually for explicit cleanup (e.g., from a background job
|
|
279
|
+
# or scheduled task).
|
|
280
|
+
#
|
|
281
|
+
# @return [Integer] Number of expired entries removed
|
|
282
|
+
# @example Manual cleanup
|
|
283
|
+
# StyleCapsule::StylesheetRegistry.cleanup_expired_cache
|
|
284
|
+
# @example Scheduled cleanup (e.g., in a background job)
|
|
285
|
+
# # In a scheduled job or initializer
|
|
286
|
+
# StyleCapsule::StylesheetRegistry.cleanup_expired_cache
|
|
287
|
+
def self.cleanup_expired_cache
|
|
288
|
+
return 0 if @inline_cache.empty?
|
|
289
|
+
|
|
290
|
+
current_time = Time.current
|
|
291
|
+
expired_keys = []
|
|
292
|
+
|
|
293
|
+
@inline_cache.each do |cache_key, entry|
|
|
294
|
+
# Remove entries that have an expires_at time and it's in the past
|
|
295
|
+
if entry[:expires_at] && current_time > entry[:expires_at]
|
|
296
|
+
expired_keys << cache_key
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
expired_keys.each { |key| @inline_cache.delete(key) }
|
|
301
|
+
@last_cleanup_time = current_time
|
|
302
|
+
|
|
303
|
+
expired_keys.size
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Check if cleanup is needed and perform it if so
|
|
307
|
+
#
|
|
308
|
+
# Only performs cleanup if it's been more than 5 minutes since the last cleanup.
|
|
309
|
+
# This prevents excessive cleanup calls while still preventing memory leaks.
|
|
310
|
+
#
|
|
311
|
+
# @return [void]
|
|
312
|
+
# @api private
|
|
313
|
+
def self.cleanup_expired_cache_if_needed
|
|
314
|
+
# Cleanup every 5 minutes (300 seconds) to balance memory usage and performance
|
|
315
|
+
cleanup_interval = 300
|
|
316
|
+
|
|
317
|
+
if @last_cleanup_time.nil? || (Time.current - @last_cleanup_time) > cleanup_interval
|
|
318
|
+
cleanup_expired_cache
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
private_class_method :cleanup_expired_cache_if_needed
|
|
323
|
+
|
|
324
|
+
# Get all registered file paths from process-wide manifest (organized by namespace)
|
|
325
|
+
#
|
|
326
|
+
# @return [Hash<Symbol, Set<Hash>>] Hash of namespace => set of file registrations
|
|
327
|
+
def self.manifest_files
|
|
328
|
+
@manifest.dup
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Get all registered inline stylesheets for current request (organized by namespace)
|
|
332
|
+
#
|
|
333
|
+
# @return [Hash<Symbol, Array<Hash>>] Hash of namespace => array of inline stylesheet registrations
|
|
334
|
+
def self.request_inline_stylesheets
|
|
335
|
+
instance.inline_stylesheets || {}
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Get all stylesheets (files + inline) for a specific namespace
|
|
339
|
+
#
|
|
340
|
+
# @param namespace [Symbol, String, nil] Namespace identifier (nil/blank uses default)
|
|
341
|
+
# @return [Array<Hash>] Array of stylesheet registrations for the namespace
|
|
342
|
+
def self.stylesheets_for(namespace: nil)
|
|
343
|
+
ns = normalize_namespace(namespace)
|
|
344
|
+
result = []
|
|
345
|
+
|
|
346
|
+
# Add process-wide file paths
|
|
347
|
+
if @manifest[ns]
|
|
348
|
+
result.concat(@manifest[ns].to_a)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Add request-scoped inline CSS
|
|
352
|
+
inline = request_inline_stylesheets[ns] || []
|
|
353
|
+
result.concat(inline)
|
|
354
|
+
|
|
355
|
+
result
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Clear request-scoped inline stylesheets (does not clear process-wide manifest)
|
|
359
|
+
#
|
|
360
|
+
# @param namespace [Symbol, String, nil] Optional namespace to clear (nil clears all)
|
|
361
|
+
# @return [void]
|
|
362
|
+
def self.clear(namespace: nil)
|
|
363
|
+
if namespace.nil?
|
|
364
|
+
instance.inline_stylesheets = {}
|
|
365
|
+
else
|
|
366
|
+
ns = normalize_namespace(namespace)
|
|
367
|
+
registry = instance.inline_stylesheets || {}
|
|
368
|
+
registry.delete(ns)
|
|
369
|
+
instance.inline_stylesheets = registry
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Clear process-wide manifest (useful for testing or development reloading)
|
|
374
|
+
#
|
|
375
|
+
# @param namespace [Symbol, String, nil] Optional namespace to clear (nil clears all)
|
|
376
|
+
# @return [void]
|
|
377
|
+
def self.clear_manifest(namespace: nil)
|
|
378
|
+
if namespace.nil?
|
|
379
|
+
@manifest = {}
|
|
380
|
+
else
|
|
381
|
+
ns = normalize_namespace(namespace)
|
|
382
|
+
@manifest.delete(ns)
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Render registered stylesheets as HTML
|
|
387
|
+
#
|
|
388
|
+
# This should be called in the layout's <head> section.
|
|
389
|
+
# Combines process-wide file manifest with request-scoped inline CSS.
|
|
390
|
+
# Automatically clears request-scoped inline CSS after rendering (manifest persists).
|
|
391
|
+
#
|
|
392
|
+
# @param view_context [ActionView::Base, nil] The view context (for helpers like content_tag, stylesheet_link_tag)
|
|
393
|
+
# In ERB: pass `self` (the view context)
|
|
394
|
+
# In Phlex: pass `view_context` method
|
|
395
|
+
# If nil, falls back to basic HTML generation
|
|
396
|
+
# @param namespace [Symbol, String, nil] Optional namespace to render (nil/blank renders all namespaces)
|
|
397
|
+
# @return [String] HTML-safe string with stylesheet tags
|
|
398
|
+
def self.render_head_stylesheets(view_context = nil, namespace: nil)
|
|
399
|
+
if namespace.nil? || namespace.to_s.strip.empty?
|
|
400
|
+
# Render all namespaces
|
|
401
|
+
all_stylesheets = []
|
|
402
|
+
|
|
403
|
+
# Collect from process-wide manifest (all namespaces)
|
|
404
|
+
@manifest.each do |ns, files|
|
|
405
|
+
all_stylesheets.concat(files.to_a)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Collect from request-scoped inline CSS (all namespaces)
|
|
409
|
+
request_inline_stylesheets.each do |_ns, inline|
|
|
410
|
+
all_stylesheets.concat(inline)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
clear # Clear request-scoped inline CSS only
|
|
414
|
+
return "".html_safe if all_stylesheets.empty?
|
|
415
|
+
|
|
416
|
+
all_stylesheets.map do |stylesheet|
|
|
417
|
+
if stylesheet[:type] == :inline
|
|
418
|
+
render_inline_stylesheet(stylesheet, view_context)
|
|
419
|
+
else
|
|
420
|
+
render_file_stylesheet(stylesheet, view_context)
|
|
421
|
+
end
|
|
422
|
+
end.join("\n").html_safe
|
|
423
|
+
|
|
424
|
+
else
|
|
425
|
+
# Render specific namespace
|
|
426
|
+
ns = normalize_namespace(namespace)
|
|
427
|
+
stylesheets = stylesheets_for(namespace: ns).dup
|
|
428
|
+
clear(namespace: ns) # Clear request-scoped inline CSS only
|
|
429
|
+
|
|
430
|
+
return "".html_safe if stylesheets.empty?
|
|
431
|
+
|
|
432
|
+
stylesheets.map do |stylesheet|
|
|
433
|
+
if stylesheet[:type] == :inline
|
|
434
|
+
render_inline_stylesheet(stylesheet, view_context)
|
|
435
|
+
else
|
|
436
|
+
render_file_stylesheet(stylesheet, view_context)
|
|
437
|
+
end
|
|
438
|
+
end.join("\n").html_safe
|
|
439
|
+
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Check if there are any registered stylesheets
|
|
444
|
+
#
|
|
445
|
+
# @param namespace [Symbol, String, nil] Optional namespace to check (nil checks all)
|
|
446
|
+
# @return [Boolean]
|
|
447
|
+
def self.any?(namespace: nil)
|
|
448
|
+
if namespace.nil?
|
|
449
|
+
# Check process-wide manifest
|
|
450
|
+
has_files = !@manifest.empty? && @manifest.values.any? { |files| !files.empty? }
|
|
451
|
+
# Check request-scoped inline CSS
|
|
452
|
+
inline = request_inline_stylesheets
|
|
453
|
+
has_inline = !inline.empty? && inline.values.any? { |stylesheets| !stylesheets.empty? }
|
|
454
|
+
has_files || has_inline
|
|
455
|
+
else
|
|
456
|
+
ns = normalize_namespace(namespace)
|
|
457
|
+
# Check process-wide manifest
|
|
458
|
+
has_files = @manifest[ns] && !@manifest[ns].empty?
|
|
459
|
+
# Check request-scoped inline CSS
|
|
460
|
+
has_inline = request_inline_stylesheets[ns] && !request_inline_stylesheets[ns].empty?
|
|
461
|
+
!!(has_files || has_inline)
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Render a file-based stylesheet
|
|
466
|
+
def self.render_file_stylesheet(stylesheet, view_context)
|
|
467
|
+
file_path = stylesheet[:file_path]
|
|
468
|
+
options = stylesheet[:options] || {}
|
|
469
|
+
|
|
470
|
+
if view_context&.respond_to?(:stylesheet_link_tag)
|
|
471
|
+
view_context.stylesheet_link_tag(file_path, **options)
|
|
472
|
+
else
|
|
473
|
+
# Fallback if no view context
|
|
474
|
+
href = "/assets/#{file_path}.css"
|
|
475
|
+
tag_options = options.map { |k, v| %(#{k}="#{v}") }.join(" ")
|
|
476
|
+
%(<link rel="stylesheet" href="#{href}"#{" #{tag_options}" unless tag_options.empty?}>).html_safe
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Render an inline stylesheet
|
|
481
|
+
def self.render_inline_stylesheet(stylesheet, view_context)
|
|
482
|
+
# CSS content is already scoped when registered from components
|
|
483
|
+
# capsule_id is stored for reference but CSS is pre-processed
|
|
484
|
+
css_content = stylesheet[:css_content]
|
|
485
|
+
|
|
486
|
+
# Construct HTML manually to avoid any HTML escaping issues
|
|
487
|
+
# CSS content should not be HTML-escaped as it's inside a <style> tag
|
|
488
|
+
# Using string interpolation with html_safe ensures CSS is not escaped
|
|
489
|
+
%(<style type="text/css">#{css_content}</style>).html_safe
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
private_class_method :render_file_stylesheet, :render_inline_stylesheet
|
|
493
|
+
end
|
|
494
|
+
end
|