style_capsule 1.4.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +46 -79
- data/docs/non_rails_support.md +85 -0
- data/lib/style_capsule/asset_path.rb +39 -0
- data/lib/style_capsule/class_registry.rb +3 -2
- data/lib/style_capsule/component.rb +13 -316
- data/lib/style_capsule/component_builder.rb +4 -1
- data/lib/style_capsule/component_class_methods.rb +295 -0
- data/lib/style_capsule/css_file_writer.rb +62 -25
- data/lib/style_capsule/css_processor.rb +4 -6
- data/lib/style_capsule/head_injection_middleware.rb +72 -0
- data/lib/style_capsule/helper.rb +17 -23
- data/lib/style_capsule/helper_scope_cache.rb +72 -0
- data/lib/style_capsule/instrumentation.rb +15 -15
- data/lib/style_capsule/phlex_helper.rb +4 -9
- data/lib/style_capsule/railtie.rb +11 -0
- data/lib/style_capsule/standalone_helper.rb +12 -19
- data/lib/style_capsule/stylesheet_registry.rb +244 -64
- data/lib/style_capsule/version.rb +1 -1
- data/lib/style_capsule/view_component.rb +13 -318
- data/lib/style_capsule/view_component_helper.rb +2 -2
- data/lib/style_capsule.rb +9 -4
- data/lib/tasks/style_capsule.rake +15 -2
- data/sig/style_capsule.rbs +43 -39
- metadata +84 -9
|
@@ -75,6 +75,8 @@ module StyleCapsule
|
|
|
75
75
|
DEFAULT_OUTPUT_DIR = "app/assets/builds/capsules"
|
|
76
76
|
# Fallback directory for when default location is read-only (absolute path)
|
|
77
77
|
FALLBACK_OUTPUT_DIR = "/tmp/style_capsule"
|
|
78
|
+
# Positive cache for resolved asset-relative paths (avoids repeated File.exist? on hot path)
|
|
79
|
+
FILE_PATH_CACHE_MAX = 512
|
|
78
80
|
|
|
79
81
|
class << self
|
|
80
82
|
attr_accessor :output_dir, :filename_pattern, :enabled, :fallback_dir
|
|
@@ -99,23 +101,24 @@ module StyleCapsule
|
|
|
99
101
|
# filename_pattern: ->(klass, capsule) { "#{klass.name.underscore}-#{capsule}.css" }
|
|
100
102
|
# )
|
|
101
103
|
def configure(output_dir: nil, filename_pattern: nil, enabled: true, fallback_dir: nil)
|
|
104
|
+
clear_file_path_hit_cache!
|
|
102
105
|
@enabled = enabled
|
|
106
|
+
@output_dir = resolve_output_dir(output_dir)
|
|
107
|
+
@fallback_dir = resolve_path(fallback_dir, FALLBACK_OUTPUT_DIR)
|
|
108
|
+
@filename_pattern = filename_pattern || default_filename_pattern
|
|
109
|
+
end
|
|
103
110
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
Rails.root.join(DEFAULT_OUTPUT_DIR)
|
|
108
|
-
else
|
|
109
|
-
Pathname.new(DEFAULT_OUTPUT_DIR)
|
|
110
|
-
end
|
|
111
|
+
def resolve_output_dir(output_dir)
|
|
112
|
+
return resolve_path(output_dir, DEFAULT_OUTPUT_DIR) if output_dir
|
|
113
|
+
return Rails.root.join(DEFAULT_OUTPUT_DIR) if rails_available?
|
|
111
114
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
else
|
|
115
|
-
Pathname.new(FALLBACK_OUTPUT_DIR)
|
|
116
|
-
end
|
|
115
|
+
Pathname.new(DEFAULT_OUTPUT_DIR)
|
|
116
|
+
end
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
def resolve_path(dir, default_path)
|
|
119
|
+
return Pathname.new(default_path) unless dir
|
|
120
|
+
|
|
121
|
+
dir.is_a?(Pathname) ? dir : Pathname.new(dir.to_s)
|
|
119
122
|
end
|
|
120
123
|
|
|
121
124
|
# Write CSS content to file
|
|
@@ -124,6 +127,7 @@ module StyleCapsule
|
|
|
124
127
|
# @param component_class [Class] Component class that generated the CSS
|
|
125
128
|
# @param capsule_id [String] Capsule ID for the component
|
|
126
129
|
# @return [String, nil] Relative file path (for stylesheet_link_tag) or nil if disabled/failed
|
|
130
|
+
# rubocop:disable Metrics/AbcSize -- writes asset file with fallback and instrumentation paths
|
|
127
131
|
def write_css(css_content:, component_class:, capsule_id:)
|
|
128
132
|
return nil unless enabled?
|
|
129
133
|
|
|
@@ -194,13 +198,11 @@ module StyleCapsule
|
|
|
194
198
|
# Return relative path for stylesheet_link_tag
|
|
195
199
|
# Path should be relative to app/assets
|
|
196
200
|
# Handle case where output directory is not under rails_assets_root (e.g., in tests)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
# If paths don't share a common prefix (e.g., in tests), return just the filename
|
|
201
|
-
filename.gsub(/\.css$/, "")
|
|
202
|
-
end
|
|
201
|
+
rel = asset_logical_path(file_path, filename)
|
|
202
|
+
remember_file_path_hit("#{component_class.name}:#{capsule_id}", rel)
|
|
203
|
+
rel
|
|
203
204
|
end
|
|
205
|
+
# rubocop:enable Metrics/AbcSize
|
|
204
206
|
|
|
205
207
|
# Check if file exists for given component and capsule
|
|
206
208
|
#
|
|
@@ -223,6 +225,10 @@ module StyleCapsule
|
|
|
223
225
|
def file_path_for(component_class:, capsule_id:)
|
|
224
226
|
return nil unless enabled?
|
|
225
227
|
|
|
228
|
+
cache_key = "#{component_class.name}:#{capsule_id}"
|
|
229
|
+
hit = file_path_hit_cache_mutex.synchronize { file_path_hit_cache[cache_key] }
|
|
230
|
+
return hit if hit
|
|
231
|
+
|
|
226
232
|
filename = generate_filename(component_class, capsule_id)
|
|
227
233
|
file_path = output_directory.join(filename)
|
|
228
234
|
|
|
@@ -230,12 +236,9 @@ module StyleCapsule
|
|
|
230
236
|
|
|
231
237
|
# Return relative path for stylesheet_link_tag
|
|
232
238
|
# Handle case where output directory is not under rails_assets_root (e.g., in tests)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
# If paths don't share a common prefix (e.g., in tests), return just the filename
|
|
237
|
-
filename.gsub(/\.css$/, "")
|
|
238
|
-
end
|
|
239
|
+
rel = asset_logical_path(file_path, filename)
|
|
240
|
+
remember_file_path_hit(cache_key, rel)
|
|
241
|
+
rel
|
|
239
242
|
end
|
|
240
243
|
|
|
241
244
|
# Ensure output directory exists
|
|
@@ -264,6 +267,7 @@ module StyleCapsule
|
|
|
264
267
|
def clear_files
|
|
265
268
|
return unless enabled?
|
|
266
269
|
|
|
270
|
+
clear_file_path_hit_cache!
|
|
267
271
|
dir = output_directory
|
|
268
272
|
return unless Dir.exist?(dir)
|
|
269
273
|
|
|
@@ -285,6 +289,39 @@ module StyleCapsule
|
|
|
285
289
|
|
|
286
290
|
private
|
|
287
291
|
|
|
292
|
+
def file_path_hit_cache
|
|
293
|
+
@file_path_hit_cache ||= {}
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def file_path_hit_cache_mutex
|
|
297
|
+
@file_path_hit_cache_mutex ||= Mutex.new
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def remember_file_path_hit(cache_key, relative_path)
|
|
301
|
+
file_path_hit_cache_mutex.synchronize do
|
|
302
|
+
file_path_hit_cache[cache_key] = relative_path
|
|
303
|
+
while file_path_hit_cache.size > FILE_PATH_CACHE_MAX
|
|
304
|
+
file_path_hit_cache.shift
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def clear_file_path_hit_cache!
|
|
310
|
+
file_path_hit_cache_mutex.synchronize do
|
|
311
|
+
file_path_hit_cache.clear
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Logical path for stylesheet_link_tag (no parent segments; safe for AssetPath validation)
|
|
316
|
+
def asset_logical_path(file_path, filename)
|
|
317
|
+
candidate = file_path.relative_path_from(rails_assets_root).to_s.gsub(/\.css$/, "")
|
|
318
|
+
return candidate unless candidate.split("/").any? { |segment| segment == ".." }
|
|
319
|
+
|
|
320
|
+
filename.gsub(/\.css$/, "")
|
|
321
|
+
rescue ArgumentError
|
|
322
|
+
filename.gsub(/\.css$/, "")
|
|
323
|
+
end
|
|
324
|
+
|
|
288
325
|
# Get output directory (with default)
|
|
289
326
|
def output_directory
|
|
290
327
|
@output_dir ||= if rails_available?
|
|
@@ -40,6 +40,7 @@ module StyleCapsule
|
|
|
40
40
|
# @param component_class [Class, String, nil] Optional component class for instrumentation
|
|
41
41
|
# @return [String] CSS with scoped selectors
|
|
42
42
|
# @raise [ArgumentError] If CSS content exceeds maximum size or capsule_id is invalid
|
|
43
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- selector patching walks CSS rules
|
|
43
44
|
def self.scope_selectors(css_string, capsule_id, component_class: nil)
|
|
44
45
|
return css_string if css_string.nil? || css_string.strip.empty?
|
|
45
46
|
|
|
@@ -61,8 +62,8 @@ module StyleCapsule
|
|
|
61
62
|
css = css_string.dup
|
|
62
63
|
capsule_attr = %([data-capsule="#{capsule_id}"])
|
|
63
64
|
|
|
64
|
-
# Strip CSS comments
|
|
65
|
-
#
|
|
65
|
+
# Strip CSS comments so they do not interfere with selector matching.
|
|
66
|
+
# Comments are removed from the output (typical for production CSS).
|
|
66
67
|
css_without_comments = strip_comments(css)
|
|
67
68
|
|
|
68
69
|
# Process CSS rule by rule
|
|
@@ -108,13 +109,10 @@ module StyleCapsule
|
|
|
108
109
|
"#{prefix}#{whitespace}#{scoped_selectors}#{opening_brace}"
|
|
109
110
|
end
|
|
110
111
|
|
|
111
|
-
# Restore comments in their original positions
|
|
112
|
-
# Since we stripped comments, we need to put them back
|
|
113
|
-
# For simplicity, we'll just return the processed CSS without comments
|
|
114
|
-
# (comments are typically removed in production CSS anyway)
|
|
115
112
|
css_without_comments
|
|
116
113
|
end
|
|
117
114
|
end
|
|
115
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
118
116
|
|
|
119
117
|
# Scope CSS using CSS nesting (wraps entire CSS in [data-capsule] { ... })
|
|
120
118
|
#
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StyleCapsule
|
|
4
|
+
# Injects stylesheets registered during body rendering into +<head>+ on the same request.
|
|
5
|
+
#
|
|
6
|
+
# Layouts call +stylesheet_registry_tags+ before the body, so +register_stylesheet+ in
|
|
7
|
+
# components runs too late for that call. This middleware appends pending link/style tags
|
|
8
|
+
# immediately before +</head>+ once the full HTML response is available.
|
|
9
|
+
class HeadInjectionMiddleware
|
|
10
|
+
HTML_CONTENT_TYPE = %r{\Atext/html}i
|
|
11
|
+
SUCCESS_STATUS = (200..299)
|
|
12
|
+
|
|
13
|
+
def initialize(app)
|
|
14
|
+
@app = app
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(env)
|
|
18
|
+
status, headers, response = @app.call(env)
|
|
19
|
+
return [status, headers, response] unless inject_response?(status, headers, response)
|
|
20
|
+
return [status, headers, response] unless StylesheetRegistry.pending_head_stylesheets?
|
|
21
|
+
|
|
22
|
+
maybe_inject_head_styles(status, headers, response, env)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def maybe_inject_head_styles(status, headers, response, env)
|
|
28
|
+
body = read_response_body(response)
|
|
29
|
+
return [status, headers, response] if body.empty?
|
|
30
|
+
|
|
31
|
+
view_context = view_context_for(env)
|
|
32
|
+
injected_body = StylesheetRegistry.inject_pending_head_stylesheets(body, view_context)
|
|
33
|
+
return [status, headers, response] if injected_body.equal?(body)
|
|
34
|
+
|
|
35
|
+
headers = headers.dup
|
|
36
|
+
headers["Content-Length"] = injected_body.bytesize.to_s if headers.key?("Content-Length")
|
|
37
|
+
|
|
38
|
+
[status, headers, [injected_body]]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def inject_response?(status, headers, response)
|
|
42
|
+
return false unless SUCCESS_STATUS.cover?(status)
|
|
43
|
+
return false unless html_content_type?(headers["Content-Type"])
|
|
44
|
+
return false if chunked_response?(headers)
|
|
45
|
+
return false if response.respond_to?(:to_ary) && response.to_ary == [""]
|
|
46
|
+
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def chunked_response?(headers)
|
|
51
|
+
transfer_encoding = headers["Transfer-Encoding"]
|
|
52
|
+
transfer_encoding&.match?(/chunked/i)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def html_content_type?(content_type)
|
|
56
|
+
return false if content_type.blank?
|
|
57
|
+
|
|
58
|
+
content_type.split(";", 2).first.to_s.strip.match?(HTML_CONTENT_TYPE)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def read_response_body(response)
|
|
62
|
+
parts = []
|
|
63
|
+
response.each { |part| parts << part.to_s }
|
|
64
|
+
parts.join
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def view_context_for(env)
|
|
68
|
+
controller = env["action_controller.instance"]
|
|
69
|
+
controller&.view_context
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/style_capsule/helper.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "digest/sha1"
|
|
4
|
+
require_relative "helper_scope_cache"
|
|
4
5
|
# ActiveSupport string extensions are conditionally required in lib/style_capsule.rb
|
|
5
6
|
|
|
6
7
|
module StyleCapsule
|
|
@@ -24,6 +25,8 @@ module StyleCapsule
|
|
|
24
25
|
# The CSS will be automatically scoped and content wrapped in a scoped div.
|
|
25
26
|
# The <style> tag will be extracted and processed separately.
|
|
26
27
|
module Helper
|
|
28
|
+
include HelperScopeCache
|
|
29
|
+
|
|
27
30
|
# Maximum HTML content size (10MB) to prevent DoS attacks
|
|
28
31
|
MAX_HTML_SIZE = 10_000_000
|
|
29
32
|
# Generate capsule ID based on caller location for uniqueness
|
|
@@ -36,16 +39,7 @@ module StyleCapsule
|
|
|
36
39
|
|
|
37
40
|
# Scope CSS content and return scoped CSS
|
|
38
41
|
def scope_css(css_content, capsule_id)
|
|
39
|
-
|
|
40
|
-
cache_key = "style_capsule_#{capsule_id}"
|
|
41
|
-
|
|
42
|
-
if Thread.current[cache_key]
|
|
43
|
-
return Thread.current[cache_key]
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
scoped_css = CssProcessor.scope_selectors(css_content, capsule_id)
|
|
47
|
-
Thread.current[cache_key] = scoped_css
|
|
48
|
-
scoped_css
|
|
42
|
+
scope_css_with_bounded_cache(css_content, capsule_id)
|
|
49
43
|
end
|
|
50
44
|
|
|
51
45
|
# ERB helper: automatically wraps content in scoped div and processes CSS
|
|
@@ -83,7 +77,14 @@ module StyleCapsule
|
|
|
83
77
|
# <%= style_capsule(css_content, capsule_id: "test-123") do %>
|
|
84
78
|
# <div class="section">Content</div>
|
|
85
79
|
# <% end %>
|
|
86
|
-
|
|
80
|
+
#
|
|
81
|
+
# Or with tag option:
|
|
82
|
+
# <%= style_capsule(tag: :section) do %>
|
|
83
|
+
# <style>.section { color: red; }</style>
|
|
84
|
+
# <div class="section">Content</div>
|
|
85
|
+
# <% end %>
|
|
86
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- ERB helper extracts styles and wraps scoped markup
|
|
87
|
+
def style_capsule(css_content = nil, capsule_id: nil, tag: :div, &content_block)
|
|
87
88
|
html_content = nil
|
|
88
89
|
|
|
89
90
|
# If CSS content is provided as argument, use it
|
|
@@ -96,17 +97,9 @@ module StyleCapsule
|
|
|
96
97
|
raise ArgumentError, "HTML content exceeds maximum size of #{MAX_HTML_SIZE} bytes (got #{full_content.bytesize} bytes)"
|
|
97
98
|
end
|
|
98
99
|
|
|
99
|
-
# Extract <style> tags from
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
style_match = full_content.match(/<style[^>]*>(.*?)<\/style>/m)
|
|
103
|
-
if style_match
|
|
104
|
-
css_content = style_match[1]
|
|
105
|
-
# Use sub instead of gsub to only remove first occurrence (reduces backtracking)
|
|
106
|
-
html_content = full_content.sub(/<style[^>]*>.*?<\/style>/m, "").strip
|
|
107
|
-
else
|
|
108
|
-
# No style tag found, treat entire content as HTML
|
|
109
|
-
css_content = nil
|
|
100
|
+
# Extract every <style> block; combine CSS, strip tags from markup
|
|
101
|
+
html_content, css_content = extract_styles_from_markup(full_content)
|
|
102
|
+
if css_content.nil?
|
|
110
103
|
html_content = full_content
|
|
111
104
|
end
|
|
112
105
|
elsif css_content && block_given?
|
|
@@ -129,10 +122,11 @@ module StyleCapsule
|
|
|
129
122
|
|
|
130
123
|
# Render style tag and wrapped content
|
|
131
124
|
style_tag = content_tag(:style, raw(scoped_css), type: "text/css")
|
|
132
|
-
wrapped_content = content_tag(
|
|
125
|
+
wrapped_content = content_tag(tag, raw(html_content), data: {capsule: capsule_id})
|
|
133
126
|
|
|
134
127
|
(style_tag + wrapped_content).html_safe
|
|
135
128
|
end
|
|
129
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
136
130
|
|
|
137
131
|
# Register a stylesheet file for head rendering
|
|
138
132
|
#
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
5
|
+
module StyleCapsule
|
|
6
|
+
# Bounded per-thread cache for scoped CSS from ERB / standalone helpers (avoids unbounded Thread.current growth).
|
|
7
|
+
module HelperScopeCache
|
|
8
|
+
MAX_SCOPE_CACHE_ENTRIES = 256
|
|
9
|
+
STYLE_OPEN = %r{<style[^>]*>}im
|
|
10
|
+
STYLE_CLOSE = %r{</style>}im
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# @param full_html [String]
|
|
15
|
+
# @return [Array(String, String, nil)] html_without_styles, combined_css (nil if none)
|
|
16
|
+
def extract_styles_from_markup(full_html)
|
|
17
|
+
scanner = StringScanner.new(full_html)
|
|
18
|
+
html_out = +""
|
|
19
|
+
segments = []
|
|
20
|
+
|
|
21
|
+
until scanner.eos?
|
|
22
|
+
break unless collect_style_segment!(scanner, html_out, segments)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
combined = segments.empty? ? nil : segments.join("\n\n")
|
|
26
|
+
[html_out.strip, combined]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def collect_style_segment!(scanner, html_out, segments)
|
|
30
|
+
text = scanner.scan_until(STYLE_OPEN)
|
|
31
|
+
if text.nil?
|
|
32
|
+
html_out << scanner.rest
|
|
33
|
+
return false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
html_out << text.sub(STYLE_OPEN, "")
|
|
37
|
+
|
|
38
|
+
css = scanner.scan_until(STYLE_CLOSE)
|
|
39
|
+
if css.nil?
|
|
40
|
+
html_out << scanner.rest
|
|
41
|
+
return false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
segments << css.sub(STYLE_CLOSE, "")
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def scope_css_with_bounded_cache(css_content, capsule_id)
|
|
49
|
+
fingerprint = Digest::SHA1.hexdigest(css_content.to_s)
|
|
50
|
+
cache_key = "style_capsule_#{capsule_id}_#{fingerprint}"
|
|
51
|
+
|
|
52
|
+
bucket = Thread.current[:style_capsule_scope_cache] ||= {order: [], hash: {}}
|
|
53
|
+
hash = bucket[:hash]
|
|
54
|
+
order = bucket[:order]
|
|
55
|
+
|
|
56
|
+
return hash[cache_key] if hash.key?(cache_key)
|
|
57
|
+
|
|
58
|
+
scoped_css = CssProcessor.scope_selectors(css_content, capsule_id)
|
|
59
|
+
evict_scope_cache_if_full!(hash, order)
|
|
60
|
+
hash[cache_key] = scoped_css
|
|
61
|
+
order << cache_key
|
|
62
|
+
scoped_css
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def evict_scope_cache_if_full!(hash, order)
|
|
66
|
+
while order.size >= MAX_SCOPE_CACHE_ENTRIES
|
|
67
|
+
old = order.shift
|
|
68
|
+
hash.delete(old)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -12,6 +12,7 @@ module StyleCapsule
|
|
|
12
12
|
# @example Subscribing to CSS processing events
|
|
13
13
|
# ActiveSupport::Notifications.subscribe("style_capsule.css_processor.scope") do |name, start, finish, id, payload|
|
|
14
14
|
# duration_ms = (finish - start) * 1000
|
|
15
|
+
# # input_size is derived from :css_content when subscribers exist
|
|
15
16
|
# input_size = payload[:input_size]
|
|
16
17
|
# Rails.logger.info "CSS scoped in #{duration_ms}ms, input: #{input_size} bytes"
|
|
17
18
|
# end
|
|
@@ -31,7 +32,7 @@ module StyleCapsule
|
|
|
31
32
|
#
|
|
32
33
|
# @return [Boolean]
|
|
33
34
|
def self.available?
|
|
34
|
-
defined?(ActiveSupport::Notifications)
|
|
35
|
+
!!defined?(ActiveSupport::Notifications)
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
# Instrument an operation with automatic timing and size metrics
|
|
@@ -52,10 +53,11 @@ module StyleCapsule
|
|
|
52
53
|
# component_class: component_class.name,
|
|
53
54
|
# capsule_id: capsule_id,
|
|
54
55
|
# strategy: :selector_patching,
|
|
55
|
-
#
|
|
56
|
+
# css_content: css_content
|
|
56
57
|
# ) do
|
|
57
58
|
# CssProcessor.scope_selectors(css_content, capsule_id)
|
|
58
59
|
# end
|
|
60
|
+
# # When subscribers exist, :input_size is set from :css_content before the block runs.
|
|
59
61
|
def self.instrument(event_name, payload = {}, &block)
|
|
60
62
|
return yield unless available?
|
|
61
63
|
|
|
@@ -65,14 +67,7 @@ module StyleCapsule
|
|
|
65
67
|
return yield
|
|
66
68
|
end
|
|
67
69
|
|
|
68
|
-
|
|
69
|
-
unless payload[:input_size]
|
|
70
|
-
if payload[:css_content]
|
|
71
|
-
payload[:input_size] = payload[:css_content].bytesize
|
|
72
|
-
elsif payload[:input]
|
|
73
|
-
payload[:input_size] = payload[:input].bytesize
|
|
74
|
-
end
|
|
75
|
-
end
|
|
70
|
+
apply_input_size!(payload)
|
|
76
71
|
|
|
77
72
|
# Use ActiveSupport::Notifications.instrument which automatically:
|
|
78
73
|
# - Measures duration using monotonic time (available in event.duration)
|
|
@@ -87,6 +82,14 @@ module StyleCapsule
|
|
|
87
82
|
result
|
|
88
83
|
end
|
|
89
84
|
|
|
85
|
+
def self.apply_input_size!(payload)
|
|
86
|
+
return if payload[:input_size]
|
|
87
|
+
|
|
88
|
+
source = payload[:css_content] || payload[:input]
|
|
89
|
+
payload[:input_size] = source.bytesize if source
|
|
90
|
+
end
|
|
91
|
+
private_class_method :apply_input_size!
|
|
92
|
+
|
|
90
93
|
# Instrument an event without a block (for events that don't have duration)
|
|
91
94
|
#
|
|
92
95
|
# @param event_name [String] Event name
|
|
@@ -115,23 +118,20 @@ module StyleCapsule
|
|
|
115
118
|
# @param strategy [Symbol] Scoping strategy (:selector_patching or :nesting)
|
|
116
119
|
# @param component_class [Class, String] Component class
|
|
117
120
|
# @param capsule_id [String] Capsule ID
|
|
118
|
-
# @param css_content [String] CSS content (
|
|
121
|
+
# @param css_content [String] CSS content (`:input_size` derived lazily when subscribed)
|
|
119
122
|
# @yield The CSS processing operation
|
|
120
123
|
# @return [String] Processed CSS
|
|
121
124
|
def self.instrument_css_processing(strategy:, component_class:, capsule_id:, css_content:, &block)
|
|
122
125
|
component_name = component_class.is_a?(Class) ? component_class.name : component_class.to_s
|
|
123
|
-
input_size = css_content.bytesize
|
|
124
126
|
|
|
125
127
|
instrument(
|
|
126
128
|
"style_capsule.css_processor.scope",
|
|
127
129
|
strategy: strategy,
|
|
128
130
|
component_class: component_name,
|
|
129
131
|
capsule_id: capsule_id,
|
|
130
|
-
|
|
132
|
+
css_content: css_content
|
|
131
133
|
) do
|
|
132
134
|
result = yield
|
|
133
|
-
# Output size will be calculated by subscribers if needed
|
|
134
|
-
# They can access it via: result.bytesize
|
|
135
135
|
result
|
|
136
136
|
end
|
|
137
137
|
end
|
|
@@ -16,7 +16,7 @@ module StyleCapsule
|
|
|
16
16
|
# Usage in Phlex components:
|
|
17
17
|
# class MyComponent < ApplicationComponent
|
|
18
18
|
# include StyleCapsule::PhlexHelper
|
|
19
|
-
#
|
|
19
|
+
# style_capsule namespace: :user # Set default namespace for register_stylesheet
|
|
20
20
|
#
|
|
21
21
|
# def view_template
|
|
22
22
|
# register_stylesheet("stylesheets/user/my_component") # Uses :user namespace automatically
|
|
@@ -33,7 +33,7 @@ module StyleCapsule
|
|
|
33
33
|
# div { "Content" }
|
|
34
34
|
# end
|
|
35
35
|
#
|
|
36
|
-
# If the component has a default namespace set via
|
|
36
|
+
# If the component has a default namespace set via style_capsule or stylesheet_registry,
|
|
37
37
|
# it will be used automatically when namespace is not explicitly provided.
|
|
38
38
|
#
|
|
39
39
|
# @param file_path [String] Path to stylesheet (relative to app/assets/stylesheets)
|
|
@@ -60,18 +60,13 @@ module StyleCapsule
|
|
|
60
60
|
# @return [void] Renders stylesheet tags via raw
|
|
61
61
|
def stylesheet_registry_tags(namespace: nil)
|
|
62
62
|
output = StyleCapsule::StylesheetRegistry.render_head_stylesheets(view_context, namespace: namespace)
|
|
63
|
-
# Phlex's raw() requires the object to be marked as safe
|
|
64
|
-
# Use Phlex's safe() if available, otherwise fall back to html_safe for test doubles
|
|
65
|
-
# The output from render_head_stylesheets is already html_safe (SafeBuffer)
|
|
66
63
|
output_string = output.to_s
|
|
67
64
|
|
|
68
65
|
if respond_to?(:safe)
|
|
69
|
-
|
|
70
|
-
safe_content = safe(output_string)
|
|
71
|
-
raw(safe_content)
|
|
66
|
+
raw(safe(output_string))
|
|
72
67
|
end
|
|
73
68
|
|
|
74
|
-
#
|
|
69
|
+
# Phlex `raw` may return nil when writing to the buffer; callers/tests expect the HTML string.
|
|
75
70
|
output_string
|
|
76
71
|
end
|
|
77
72
|
end
|
|
@@ -4,6 +4,10 @@ module StyleCapsule
|
|
|
4
4
|
# Railtie to automatically include StyleCapsule helpers in Rails
|
|
5
5
|
if defined?(Rails::Railtie)
|
|
6
6
|
class Railtie < Rails::Railtie
|
|
7
|
+
config.style_capsule = ActiveSupport::OrderedOptions.new
|
|
8
|
+
config.style_capsule.run_on_precompile = true
|
|
9
|
+
config.style_capsule.head_injection_middleware = true
|
|
10
|
+
|
|
7
11
|
# Automatically include ERB helper in ActionView::Base (standard Rails pattern)
|
|
8
12
|
# This makes helpers available in all ERB templates automatically
|
|
9
13
|
ActiveSupport.on_load(:action_view) do
|
|
@@ -53,6 +57,13 @@ module StyleCapsule
|
|
|
53
57
|
load File.expand_path("../tasks/style_capsule.rake", __dir__)
|
|
54
58
|
end
|
|
55
59
|
|
|
60
|
+
initializer "style_capsule.head_injection_middleware", after: :load_config_initializers do |app|
|
|
61
|
+
next unless app.config.style_capsule.head_injection_middleware
|
|
62
|
+
|
|
63
|
+
require_relative "head_injection_middleware"
|
|
64
|
+
app.config.middleware.use StyleCapsule::HeadInjectionMiddleware
|
|
65
|
+
end
|
|
66
|
+
|
|
56
67
|
# Note: PhlexHelper should be included explicitly in ApplicationComponent
|
|
57
68
|
# or your base Phlex component class, not automatically via Railtie
|
|
58
69
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest/sha1"
|
|
4
4
|
require "cgi"
|
|
5
|
+
require_relative "helper_scope_cache"
|
|
5
6
|
|
|
6
7
|
module StyleCapsule
|
|
7
8
|
# Standalone helper module for use without Rails
|
|
@@ -27,6 +28,8 @@ module StyleCapsule
|
|
|
27
28
|
# "<style>.section { color: red; }</style><div class='section'>Content</div>"
|
|
28
29
|
# end
|
|
29
30
|
module StandaloneHelper
|
|
31
|
+
include HelperScopeCache
|
|
32
|
+
|
|
30
33
|
# Maximum HTML content size (10MB) to prevent DoS attacks
|
|
31
34
|
MAX_HTML_SIZE = 10_000_000
|
|
32
35
|
|
|
@@ -40,16 +43,7 @@ module StyleCapsule
|
|
|
40
43
|
|
|
41
44
|
# Scope CSS content and return scoped CSS
|
|
42
45
|
def scope_css(css_content, capsule_id)
|
|
43
|
-
|
|
44
|
-
cache_key = "style_capsule_#{capsule_id}"
|
|
45
|
-
|
|
46
|
-
if Thread.current[cache_key]
|
|
47
|
-
return Thread.current[cache_key]
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
scoped_css = CssProcessor.scope_selectors(css_content, capsule_id)
|
|
51
|
-
Thread.current[cache_key] = scoped_css
|
|
52
|
-
scoped_css
|
|
46
|
+
scope_css_with_bounded_cache(css_content, capsule_id)
|
|
53
47
|
end
|
|
54
48
|
|
|
55
49
|
# Generate HTML tag without Rails helpers
|
|
@@ -59,6 +53,7 @@ module StyleCapsule
|
|
|
59
53
|
# @param options [Hash] HTML attributes
|
|
60
54
|
# @param block [Proc] Block for tag content
|
|
61
55
|
# @return [String] HTML string
|
|
56
|
+
# rubocop:disable Metrics/AbcSize -- serializes flat and nested HTML attributes
|
|
62
57
|
def content_tag(tag, content = nil, **options, &block)
|
|
63
58
|
tag_name = tag.to_s
|
|
64
59
|
content = capture(&block) if block_given? && content.nil?
|
|
@@ -76,6 +71,7 @@ module StyleCapsule
|
|
|
76
71
|
attrs = " #{attrs}" unless attrs.empty?
|
|
77
72
|
"<#{tag_name}#{attrs}>#{content}</#{tag_name}>"
|
|
78
73
|
end
|
|
74
|
+
# rubocop:enable Metrics/AbcSize
|
|
79
75
|
|
|
80
76
|
# Capture block content (simplified version without Rails)
|
|
81
77
|
#
|
|
@@ -114,7 +110,8 @@ module StyleCapsule
|
|
|
114
110
|
# @param capsule_id [String, nil] Optional capsule ID
|
|
115
111
|
# @param content_block [Proc] Block containing HTML content
|
|
116
112
|
# @return [String] HTML with scoped CSS and wrapped content
|
|
117
|
-
|
|
113
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- standalone helper mirrors Rails helper flow
|
|
114
|
+
def style_capsule(css_content = nil, capsule_id: nil, tag: :div, &content_block)
|
|
118
115
|
html_content = nil
|
|
119
116
|
|
|
120
117
|
# If CSS content is provided as argument, use it
|
|
@@ -127,13 +124,8 @@ module StyleCapsule
|
|
|
127
124
|
raise ArgumentError, "HTML content exceeds maximum size of #{MAX_HTML_SIZE} bytes (got #{full_content.bytesize} bytes)"
|
|
128
125
|
end
|
|
129
126
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if style_match
|
|
133
|
-
css_content = style_match[1]
|
|
134
|
-
html_content = full_content.sub(/<style[^>]*>.*?<\/style>/m, "").strip
|
|
135
|
-
else
|
|
136
|
-
css_content = nil
|
|
127
|
+
html_content, css_content = extract_styles_from_markup(full_content)
|
|
128
|
+
if css_content.nil?
|
|
137
129
|
html_content = full_content
|
|
138
130
|
end
|
|
139
131
|
elsif css_content && block_given?
|
|
@@ -156,10 +148,11 @@ module StyleCapsule
|
|
|
156
148
|
|
|
157
149
|
# Render style tag and wrapped content
|
|
158
150
|
style_tag = content_tag(:style, raw(scoped_css), type: "text/css")
|
|
159
|
-
wrapped_content = content_tag(
|
|
151
|
+
wrapped_content = content_tag(tag, raw(html_content), data: {capsule: capsule_id})
|
|
160
152
|
|
|
161
153
|
html_safe(style_tag + wrapped_content)
|
|
162
154
|
end
|
|
155
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
163
156
|
|
|
164
157
|
# Register a stylesheet file for head rendering
|
|
165
158
|
#
|